From 198570186e60851864a405eb53a0d6b4e941156e Mon Sep 17 00:00:00 2001 From: link2026 Date: Tue, 14 Jul 2026 13:07:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E6=8F=90=E4=BE=9B=E7=9A=84co?= =?UTF-8?q?de=20differences=E4=BF=A1=E6=81=AF=EF=BC=8C=E6=88=91=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E6=B2=A1=E6=9C=89=E5=85=B7=E4=BD=93=E7=9A=84=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=8F=98=E6=9B=B4=E5=86=85=E5=AE=B9=E3=80=82=E5=9B=A0?= =?UTF-8?q?=E6=AD=A4=E7=94=9F=E6=88=90=E4=B8=80=E4=B8=AA=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E7=9A=84commit=20message=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` docs(readme): 更新文档说明 - 添加项目使用说明 - 完善配置指南 - 修正错误描述 ``` --- 康康/AI/FileDownloader.swift | 48 +- 康康/AI/GeminiBackend.swift | 14 +- 康康/AI/ModelManifest.swift | 22 +- 康康/AI/Prompts/DiaryChatPrompts.swift | 143 +++ 康康/Features/Diary/CompanionAvatarView.swift | 188 ++++ 康康/Features/Diary/DiaryCompanionView.swift | 591 +++++++++++ 康康/Features/Diary/DiaryQuickSheet.swift | 50 + 康康/Localizable.xcstrings | 937 ++++++++++++------ 康康/Services/DiaryChatService.swift | 230 +++++ 康康/Services/HealthExportService.swift | 3 +- 康康/Services/SenseVoiceASRService.swift | 4 +- 康康Tests/DiaryChatPromptTests.swift | 103 ++ 康康Tests/DiaryChatServiceTests.swift | 130 +++ 康康Tests/ModelDownloadCoreTests.swift | 20 + 康康Tests/ModelManifestTests.swift | 5 +- 15 files changed, 2180 insertions(+), 308 deletions(-) create mode 100644 康康/AI/Prompts/DiaryChatPrompts.swift create mode 100644 康康/Features/Diary/CompanionAvatarView.swift create mode 100644 康康/Features/Diary/DiaryCompanionView.swift create mode 100644 康康/Services/DiaryChatService.swift create mode 100644 康康Tests/DiaryChatPromptTests.swift create mode 100644 康康Tests/DiaryChatServiceTests.swift diff --git a/康康/AI/FileDownloader.swift b/康康/AI/FileDownloader.swift index 5c4a514..5b7ccd0 100644 --- a/康康/AI/FileDownloader.swift +++ b/康康/AI/FileDownloader.swift @@ -28,6 +28,7 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl private let lock = NSLock() private var handle: FileHandle? private var written: Int = 0 + private var expectedTotal: Int = 0 private var onProgress: ((Int) -> Void)? private var responseError: Error? private var continuation: CheckedContinuation? @@ -77,24 +78,33 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl lock.withLock { self.handle = fileHandle self.written = offset + self.expectedTotal = expectedBytes self.onProgress = onProgress self.responseError = nil } + // offset 0 也发 `bytes=0-`:诱导服务器回 206 + Content-Range 报告资源总大小, + // didReceive 里与清单比对不符立即失败 —— 上游仓库更新导致大小漂移时(2026-07-14 实发), + // 秒级报 sizeMismatch,而不是把 3.5GB 下完才在收尾校验发现。 var request = URLRequest(url: url) - if offset > 0 { - request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") - } + request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) defer { session.finishTasksAndInvalidate() } // 句柄在 didCompleteWithError 内关闭(同一 delegate 队列,串行于 didReceive)。 - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - lock.lock() - self.continuation = cont - lock.unlock() - session.dataTask(with: request).resume() + do { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + lock.lock() + self.continuation = cont + lock.unlock() + session.dataTask(with: request).resume() + } + } catch let error as DownloadError { + // 响应头快速失败的大小不符:删 .part,防止换源续传时把两个版本的字节拼进同一文件。 + // badStatus(如临时 503)保留 .part,便于重试续传。 + if case .sizeMismatch = error { try? fm.removeItem(at: part) } + throw error } let finalSize = Self.fileSize(at: part) @@ -119,9 +129,27 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl if let http = response as? HTTPURLResponse, http.statusCode >= 400 { lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock() completionHandler(.cancel) - } else { - completionHandler(.allow) + return } + // 快速失败:206 的 Content-Range(`bytes a-b/total`)报告了资源真实总大小,与清单不符 + // 说明服务器上的文件版本变了,立即取消(收尾校验必失败,没必要下完)。 + // 只信 206 —— 200 的 Content-Length 可能是压缩后长度(gzip),比对会误伤,仍走收尾校验。 + if let http = response as? HTTPURLResponse, http.statusCode == 206, + let contentRange = http.value(forHTTPHeaderField: "Content-Range"), + let totalPart = contentRange.split(separator: "/").last, + let serverTotal = Int(totalPart) { + lock.lock() + let expected = expectedTotal + lock.unlock() + if serverTotal != expected { + lock.lock() + responseError = DownloadError.sizeMismatch(expected: expected, got: serverTotal) + lock.unlock() + completionHandler(.cancel) + return + } + } + completionHandler(.allow) } nonisolated func urlSession( diff --git a/康康/AI/GeminiBackend.swift b/康康/AI/GeminiBackend.swift index 2c13e97..3d0418a 100644 --- a/康康/AI/GeminiBackend.swift +++ b/康康/AI/GeminiBackend.swift @@ -198,15 +198,17 @@ actor GeminiBackend { } /// Gemini `GenerateContentResponse` 的最小可解码子集。 -private struct GeminiResponse: Decodable { - struct Candidate: Decodable { let content: Content? } - struct Content: Decodable { let parts: [Part]? } - struct Part: Decodable { let text: String? } - struct Usage: Decodable { +/// nonisolated:工程默认 MainActor 隔离,不标会把 Decodable 合成实现推成 MainActor, +/// 在 GeminiBackend 的后台解码上下文用不了(Swift 6 报错)。 +private nonisolated struct GeminiResponse: Decodable { + nonisolated struct Candidate: Decodable { let content: Content? } + nonisolated struct Content: Decodable { let parts: [Part]? } + nonisolated struct Part: Decodable { let text: String? } + nonisolated struct Usage: Decodable { let promptTokenCount: Int? let candidatesTokenCount: Int? } - struct APIError: Decodable { let message: String? } + nonisolated struct APIError: Decodable { let message: String? } let candidates: [Candidate]? let usageMetadata: Usage? let error: APIError? diff --git a/康康/AI/ModelManifest.swift b/康康/AI/ModelManifest.swift index 2b57779..246a8af 100644 --- a/康康/AI/ModelManifest.swift +++ b/康康/AI/ModelManifest.swift @@ -42,19 +42,23 @@ nonisolated enum ModelManifest { // 经 LLMModelFactory 的 "gemma4" 路径加载(mlx-swift-lm ≥3.31.4 已注册;Gemma4Model 只取 // language_model 权重,跳过视觉/音频)。e2b 检查点无视觉权重(视觉在 gemma4_unified 12B)。 // 字节数取自 ModelScope mlx-community/gemma-4-e2b-it-4bit 仓库实际 blob 大小 - //(repo/files API,2026-07 核对)。排除 README.md / .gitattributes / configuration.json - //(后者为 ModelScope 元数据,MLX 加载用不到)。Gemma 4 布局与 3n 不同:无 tokenizer.model / - // special_tokens_map.json,tokenizer_config.json 精简(chat template 独立在 chat_template.jinja), - // 新增 processor_config.json。全部运行文件按上游 mlx-swift-lm 预置仓库快照镜像,避免漏文件。 + //(Range 请求 Content-Range 总长,2026-07-14 与魔搭 / hf-mirror 双源核对一致)。 + // 上游 2026-07 中旬更新过一次 checkpoint(权重 3.581G→3.551G,config/tokenizer_config/ + // processor_config/index 同步变化;model_type 仍 gemma4、4bit,加载路径不受影响)—— + // 清单值过期会让 FileDownloader 校验 sizeMismatch,表现为「下载失败」,更新须双源重新核对。 + // 排除 README.md / .gitattributes / configuration.json(后者为 ModelScope 元数据,MLX 加载 + // 用不到)。Gemma 4 布局与 3n 不同:无 tokenizer.model / special_tokens_map.json, + // tokenizer_config.json 精简(chat template 独立在 chat_template.jinja),新增 + // processor_config.json。全部运行文件按上游 mlx-swift-lm 预置仓库快照镜像,避免漏文件。 return [ - ModelFile(path: "config.json", bytes: 5_996), + ModelFile(path: "config.json", bytes: 6_395), ModelFile(path: "generation_config.json", bytes: 208), - ModelFile(path: "model.safetensors", bytes: 3_581_101_896), - ModelFile(path: "model.safetensors.index.json", bytes: 230_329), + ModelFile(path: "model.safetensors", bytes: 3_550_670_554), + ModelFile(path: "model.safetensors.index.json", bytes: 218_323), ModelFile(path: "tokenizer.json", bytes: 32_169_626), - ModelFile(path: "tokenizer_config.json", bytes: 2_095), + ModelFile(path: "tokenizer_config.json", bytes: 2_740), ModelFile(path: "chat_template.jinja", bytes: 17_336), - ModelFile(path: "processor_config.json", bytes: 902), + ModelFile(path: "processor_config.json", bytes: 1_316), ] case .vl: // Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小 diff --git a/康康/AI/Prompts/DiaryChatPrompts.swift b/康康/AI/Prompts/DiaryChatPrompts.swift new file mode 100644 index 0000000..d52ffd5 --- /dev/null +++ b/康康/AI/Prompts/DiaryChatPrompts.swift @@ -0,0 +1,143 @@ +import Foundation + +/// 「聊着记」用到的三个 LLM prompt:全屏动画小形象「康康」和用户语音聊天, +/// 基于已有健康记录做有记忆的追问,聊完蒸馏成一条健康日记。 +/// +/// 人设铁律(贯穿三个 prompt):康康是「陪伴记录的小伙伴」不是医生—— +/// 不诊断、不荐药、不写「建议就医」、不评价病情轻重、不用「患者」称呼。 +/// +/// 数值红线沿用 `DiaryAssistPrompts.organize`:蒸馏时严禁增删改任何数值/单位/药名/时间; +/// 语言规则对齐 §「不用「患者」称呼」与 aiOutputLanguageDirective(App/Localization.swift)。 +/// +/// nonisolated:纯字符串拼装,无共享状态;`DiaryChatService` 的 nonisolated 纯函数 +/// (roundsUsed/isWrappedUp)要引用 `maxRounds`,不标会被默认 MainActor 隔离挡住。 +nonisolated enum DiaryChatPrompts { + + /// 收尾前允许的追问总轮数(开场那句提问不计入)。 + static let maxRounds = 4 + + /// 对话稿截断上限(字符),2B/E2B 端侧模型 context 保护。 + static let distillTranscriptLimit = 2000 + + /// AI 全挂时的确定性开场白(View 兜底用,不经 LLM)。 + static var staticOpening: String { String(appLoc: "今天身体感觉怎么样?想到什么就说什么,我帮你记着。") } + + // MARK: - 开场白 + + /// 用户刚进「聊着记」,康康说的第一句话:自然打招呼 + 基于最近一条记录关心地追问 1 个问题。 + static func opening(dataJSON: String) -> String { + """ + 你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。 + 用户刚打开「聊着记」,你要说第一句话:先自然地打个招呼,再基于下面【健康记录】里最近一条记录,像老朋友一样关心地问 1 个问题。 + + 铁律: + - 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造。 + - 数值、日期、药名必须与记录完全一致,一个字都不能改。 + - 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。 + - 最多 2 句话,只问 1 个问题;不用列表、不用 Markdown、不用表情符号。 + - 记录里 diaries / indicators 全为空时,不提任何具体记录,只简单问今天感觉怎么样。 + + 示例 1(记录里有「2026-07-11 日记:头疼了一下午」): + 前天你记过头疼了一下午,现在好些了吗? + + 示例 2(记录里有血压 145/92 偏高): + 昨天量的血压 145/92 有点偏高,今天有没有再量一次? + + 示例 3(记录为空): + 嗨,今天身体感觉怎么样?想到什么就跟我说说,我帮你记下来。 + + 【健康记录】: + \(dataJSON) + + 直接输出这句话,不要引号、不要解释、不要 标签。 + \(aiOutputLanguageDirective()) + /no_think + """ + } + + // MARK: - 追问回复 + + /// 用户每说一句,康康的回复:先接住,再按剩余轮次决定是否追问。 + /// - roundsLeft: 收尾前还剩几轮追问机会;> 0 可再追问,== 0 必须温和收尾。 + static func reply(transcript: String, latest: String, dataJSON: String, roundsLeft: Int) -> String { + // 轮次分支在 Swift 里拼,不让模型自己算:两段互斥,收尾分支绝不出现「追问」字样。 + let roundRule: String = roundsLeft > 0 + ? "本轮还可以追问,最多问 1 个新问题。追问只围绕「把这条记录补完整」:时间、部位、程度、吃没吃药、睡眠饮食等;可以结合【健康记录】做有记忆的追问(如「上次你记过 XX,这次也一样吗」)。" + : "【不要再提任何问题】。用 1-2 句话温和收尾:先接住用户刚说的话,再告诉 TA 今天先聊到这儿,点下面的「聊完了」,我帮你整理成一条记录。" + return """ + 你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。 + 你正在「聊着记」里陪用户聊天。任务:先用一小句接住用户刚说的话(共情或确认),再按【本轮规则】决定要不要追问。 + + 铁律: + - 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造;数值、日期、药名必须与记录完全一致。 + - 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。 + - 总共最多 2 句话;不用列表、不用 Markdown、不用表情符号。 + + 【本轮规则】: + \(roundRule) + + 示例(还能追问,用户说「下午头就不疼了,不过晚上只睡了五个小时」): + 不疼了就好。昨晚只睡五个小时,是入睡难还是半夜醒了? + + 示例(收尾): + 好,记下啦。今天先聊到这儿,点下面的「聊完了」,我帮你把这些整理成一条记录。 + + 【健康记录】: + \(dataJSON) + + 【对话】: + \(transcript.isEmpty ? "无" : transcript) + + 【用户刚说的】: + \(latest) + + 直接输出你要说的话,不要引号、不要解释、不要 标签。\(aiOutputLanguageDirective()) + /no_think + """ + } + + // MARK: - 蒸馏成日记 + + /// 聊完后把整段对话蒸馏成一条健康日记草稿。结构与红线对齐 `DiaryAssistPrompts.organize`: + /// 只搬运不改写、严禁增删改数值,且**不拼** aiOutputLanguageDirective——语言跟「我:」原话走。 + static func distill(transcript: String) -> String { + let trimmed = String(transcript.prefix(distillTranscriptLimit)) + return """ + 你是「康康」,用户健康 App 里陪伴记录的小伙伴。下面是你和用户在「聊着记」里的对话稿。 + 请把用户说的话蒸馏成一条健康日记草稿。 + + 硬性规则: + - 【只取「我:」说的内容作为事实】——「康康:」的话只是提问和陪伴,绝不能把康康提到的记录、猜测或问题当成用户今天发生的事写进日记。 + - 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。 + - 输出语言必须与「我:」的原话完全一致:原话是中文就用中文,是英文就用英文,是日文/韩文就用对应语言,不要翻译。 + - 用第一人称;去掉口头语和重复;不加入对话里没有的事实。 + - 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。 + - 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。 + - 不诊断、不给用药建议、不写「建议就医」。 + - 只输出日记正文,不要开头语、不要解释、不要 markdown 围栏、不要 标签。 + + 示例 1(单方面 → 成段): + 我: 今天头有点晕早上量了血压140 90 + 康康: 记下啦。上次你记过没吃早饭,今天吃了吗? + 我: 吃了吃了 喝了粥 + 输出: + 今天头有点晕,早上量了血压 140/90。早饭吃了,喝了粥。 + + 示例 2(多方面 → 分行;注意康康的话不进日记): + 我: 今天头晕了一上午下午好点了血压早上量的140 90缬沙坦吃了 + 康康: 记下啦。上次你血压也偏高,是不是又没睡好?昨晚睡了几个小时? + 我: 就睡了五个小时中午吃的清淡 + 输出: + 症状:头晕了一上午,下午好转。 + 血压:早上 140/90。 + 用药:缬沙坦已服。 + 睡眠:只睡了五个小时。 + 饮食:午餐清淡。 + + 【对话记录】: + \(trimmed) + + Output: /no_think + """ + } +} diff --git a/康康/Features/Diary/CompanionAvatarView.swift b/康康/Features/Diary/CompanionAvatarView.swift new file mode 100644 index 0000000..3e5a83e --- /dev/null +++ b/康康/Features/Diary/CompanionAvatarView.swift @@ -0,0 +1,188 @@ +import SwiftUI +import Foundation + +/// 「聊着记」里的动画小形象「康康」——纯 SwiftUI Shape 拼出的圆脸小生物,零图片资源、零第三方库。 +/// 全屏语音记录界面用它给「正在听 / 正在想 / 正在说」这几个抽象状态一个有生命感、可爱的落点。 +/// +/// 为什么所有连续动画都由「时间 t」纯函数驱动(而非 @State + withAnimation.repeatForever): +/// 参照本目录外 `DesignSystem/AIFlowBar.swift` 的先例——这个形象所处的场景(语音流式吐字、每 0.5s +/// 刷 tok/s)父视图在高频重绘,隐式 repeatForever 动画会被反复打断/重置 → 看起来「几乎不动」。 +/// 改用 `TimelineView(.animation)` 按屏幕刷新率直接从时间算出每一帧的形变,与父视图重绘彻底解耦, +/// 任何场景下都匀速呼吸/眨眼/说话。 +/// +/// 呼吸缩放、眨眼、嘴形、思考点这四种连续形变全部抽成 `nonisolated static` 纯函数(只依赖 t 与 mood, +/// 无任何共享状态),既方便单测逐帧断言,也让 body 只负责把算好的数值贴到 Shape 上。mood 之间的离散 +/// 差异(腮红透明度、眼睛上移、声波环有无)则用 `.animation(.snappy, value: mood)` 平滑过渡。 +struct CompanionAvatarView: View { + /// nonisolated:Equatable 合成实现要在 nonisolated static 纯函数里比较 mood, + /// 不标会被默认 MainActor 隔离推成主 actor 隔离(Swift 6 报错)。 + nonisolated enum Mood: Equatable { case idle, listening, thinking, speaking } + + var mood: Mood + var size: CGFloat = 156 + + var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + ZStack { + // 1 背景光环:最底层的一圈暖沙色,给小家伙一个「站台」 + Circle() + .fill(Tj.Palette.sand2) + .frame(width: size, height: size) + + // 2 倾听声波环 ×2:仅 .listening 出现,由内向外扩散淡出,第二环相位 +0.8s 错开 + if mood == .listening { + Group { + soundRing(t: t, phase: 0, color: Tj.Palette.teal) + soundRing(t: t, phase: 0.8, color: Tj.Palette.tealSoft) + } + .transition(.opacity) + } + + // 3-6 脸部整体(身体 + 腮红 + 眼 + 嘴):作为一个整体一起呼吸、一起歪头 + face(t: t) + .frame(width: size, height: size) + .scaleEffect(Self.breathScale(t: t, mood: mood), anchor: .bottom) + .rotationEffect(.degrees(headTilt(t: t)), anchor: .bottom) + + // 7 思考点 ×3:仅 .thinking 出现,头顶右上斜排,循环点亮 + if mood == .thinking { + thinkingDots(t: t) + .transition(.opacity) + } + } + .frame(width: size, height: size) + .animation(.snappy(duration: 0.25), value: mood) + } + } + + // MARK: - 子视图(实例方法,默认 MainActor;只把纯函数算出的数值贴到 Shape 上) + + /// 脸:身体 Ellipse 打底,再叠腮红 / 眼睛 / 嘴。整组由外层统一做呼吸缩放与歪头旋转。 + private func face(t: Double) -> some View { + let blushOpacity: Double = (mood == .listening || mood == .speaking) ? 1.0 : 0.7 + let eyeLift: CGFloat = (mood == .thinking) ? -0.02 * size : 0 // 思考时眼睛上移「往上看」 + let mouthWidth: CGFloat = (mood == .thinking) ? 0.05 * size : 0.16 * size + + return ZStack { + // 身体:柔和纸色椭圆 + 细描边 + 极浅暖影 + Ellipse() + .fill(Tj.Palette.paper) + .frame(width: 0.78 * size, height: 0.72 * size) + .overlay(Ellipse().strokeBorder(Tj.Palette.line, lineWidth: 1.5)) + .shadow(color: Tj.Palette.shadow.opacity(0.08), + radius: 0.05 * size, x: 0, y: 0.018 * size) + + // 腮红 ×2 + ForEach([-1.0, 1.0], id: \.self) { side in + Circle() + .fill(Tj.Palette.brickSoft) + .frame(width: 0.09 * size, height: 0.09 * size) + .opacity(blushOpacity) + .offset(x: CGFloat(side) * 0.24 * size, y: 0.075 * size) + } + + // 眼睛 ×2:竖长胶囊,眨眼 = 竖向压扁;.thinking 时整体上移 + ForEach([-1.0, 1.0], id: \.self) { side in + Capsule() + .fill(Tj.Palette.ink) + .frame(width: 0.055 * size, height: 0.11 * size) + .scaleEffect(x: 1, y: Self.blinkScaleY(t: t, mood: mood)) + .offset(x: CGFloat(side) * 0.155 * size, y: -0.03 * size + eyeLift) + } + + // 嘴:横胶囊,高度随状态开合;.thinking 时收成 0.05·size 小圆点 + Capsule() + .fill(Tj.Palette.ink) + .frame(width: mouthWidth, + height: Self.mouthHeight(t: t, mood: mood, size: size)) + .offset(y: 0.135 * size) + } + } + + /// 单个声波环:base 直径 0.82·size,随相位小数部分从 ×1 放大到 ×1.35 并线性淡出。 + private func soundRing(t: Double, phase: Double, color: Color) -> some View { + let f = Self.frac((t + phase) / 1.6) + return Circle() + .strokeBorder(color, lineWidth: 2) + .frame(width: 0.82 * size, height: 0.82 * size) + .scaleEffect(1 + 0.35 * CGFloat(f)) + .opacity(1 - f) + } + + /// 思考点 ×3:直径 0.05·size,沿右上 45° 斜线排开,逐个点亮成「思考中」气泡。 + private func thinkingDots(t: Double) -> some View { + ZStack { + ForEach(0..<3, id: \.self) { i in + Circle() + .fill(Tj.Palette.amber) + .frame(width: 0.05 * size, height: 0.05 * size) + .opacity(Self.thinkingDotOpacity(t: t, index: i)) + .offset(x: (0.29 + 0.085 * CGFloat(i)) * size, + y: -(0.29 + 0.085 * CGFloat(i)) * size) + } + } + } + + /// 歪头角度(度):仅 .thinking 时以 2.6s 为周期在 ±2° 间轻摆,其余恒 0。 + private func headTilt(t: Double) -> Double { + mood == .thinking ? 2 * sin(2 * .pi * t / 2.6) : 0 + } + + // MARK: - 纯函数(nonisolated,供单测逐帧断言;只依赖 t 与 mood,无共享状态) + + /// 呼吸缩放:各状态不同周期/幅度的正弦。idle 最慢最柔,speaking 最快最促。 + nonisolated static func breathScale(t: Double, mood: Mood) -> CGFloat { + switch mood { + case .idle: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 3.2)) + case .listening: return CGFloat(1 + 0.03 * sin(2 * .pi * t / 1.6)) + case .thinking: return CGFloat(1 + 0.015 * sin(2 * .pi * t / 2.6)) + case .speaking: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 0.6)) + } + } + + /// 眨眼竖向缩放:约每 3.4s 眨一次(压到 0.15 持续 0.12s),.thinking 恒 0.85 凝神不眨。 + nonisolated static func blinkScaleY(t: Double, mood: Mood) -> CGFloat { + if mood == .thinking { return 0.85 } + var phase = t.truncatingRemainder(dividingBy: 3.4) + if phase < 0 { phase += 3.4 } // 兜住负 t,让单测传任意时刻都成立 + return phase <= 0.12 ? 0.15 : 1 + } + + /// 嘴高度:idle 微笑一条线,listening/thinking 微张,speaking 随 0.28s 周期开合。 + nonisolated static func mouthHeight(t: Double, mood: Mood, size: CGFloat) -> CGFloat { + switch mood { + case .idle: return 0.035 * size + case .listening: return 0.05 * size + case .thinking: return 0.05 * size + case .speaking: return 0.03 * size + 0.05 * size * CGFloat(abs(sin(2 * .pi * t / 0.28))) + } + } + + /// 思考点透明度:三点各错相位 120° 的正弦,负半周压到 0,形成「跑马灯」逐个点亮。 + nonisolated static func thinkingDotOpacity(t: Double, index: Int) -> Double { + 0.25 + 0.75 * max(0.0, sin(2 * .pi * t / 0.9 - Double(index) * 2 * .pi / 3)) + } + + /// 取小数部分(声波环相位用)。 + private static func frac(_ x: Double) -> Double { x - floor(x) } +} + +#Preview("四种情绪") { + HStack(spacing: 18) { + ForEach(0..<4, id: \.self) { i in + VStack(spacing: 12) { + CompanionAvatarView( + mood: [.idle, .listening, .thinking, .speaking][i], + size: 116 + ) + Text(["idle", "listening", "thinking", "speaking"][i]) + .font(.tjScaled(12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text2) + } + } + } + .padding(36) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Tj.Palette.sand) +} diff --git a/康康/Features/Diary/DiaryCompanionView.swift b/康康/Features/Diary/DiaryCompanionView.swift new file mode 100644 index 0000000..70efb27 --- /dev/null +++ b/康康/Features/Diary/DiaryCompanionView.swift @@ -0,0 +1,591 @@ +import SwiftUI +import SwiftData + +/// 「聊着记」——全屏动画形象「康康」与用户的语音多轮对话页。 +/// +/// 是什么:进场先拿一份健康数据快照(makeContextJSON)喂给 LLM,康康基于最近记录开场追问; +/// 用户按麦克风说一句 → 端侧转写 → 康康共情 + 再追问;聊够(maxRounds 轮)或用户主动收尾时, +/// 把整段对话蒸馏成一条日记草稿回传给调用方(DiaryQuickSheet 负责回填正文并关闭 cover)。 +/// +/// 状态机(Phase)如何转: +/// preparing ──首 token──► speaking ──流式完──► awaitingUser +/// awaitingUser ──点麦克风──► recording ──点停/看门狗──► thinking(reply)──► speaking ──► awaitingUser +/// awaitingUser ──「聊完了」──► distilling ──► onDraft(结束) +/// thinking/speaking ──reply 全空失败──► failed ──重试──► thinking +/// +/// 失败链(红线 #5「不让用户卡在 AI 错误屏」): +/// · 开场 opening 失败(全空)→ 落 DiaryChatPrompts.staticOpening + 一次性 toast,继续能聊; +/// · 追问 reply 失败(全空且非取消)→ Phase.failed,可「重试」或「不聊了,把原话记下来」; +/// · 蒸馏 distill 失败 / 为空 → onDraft(fallbackDraft, usedFallback: true),填用户原话; +/// · 流已产出部分文本后再出错(含取消)→ 视同说完,把部分文本并入对话,不吓用户。 +/// +/// 边界:UI 不直接调 AIRuntime,全部经 DiaryChatService(红线 #3);推理串行由 AIRuntime actor 保证。 +struct DiaryCompanionView: View { + /// 蒸馏(或兜底拼接)出的草稿回传;usedFallback=true 表示没走成 AI、填的是原话。 + /// 调用方负责回填正文与关闭 cover;本视图 onDraft 后不要再调 onClose。 + let onDraft: (_ draft: String, _ usedFallback: Bool) -> Void + let onClose: () -> Void + + /// 单轮回答的录音上限。单轮比整段口述短,「说一段」先例是 180s,这里收到 120s。 + static let maxRecordingSeconds = 120 + + // MARK: - 状态机 + + enum Phase: Equatable { + case preparing // 进场:makeContextJSON + openingLine 流式中(首 token 前) + case speaking // 康康气泡打字机流式中(opening 或 reply) + case awaitingUser // 等用户:按 mic / 点「聊完了」 + case recording // 录音中,实时字幕 + case thinking // reply 已发出、首 token 未到 + case distilling // 蒸馏中 + case failed(String) // 可重试错误(仅 reply 全空时进入) + } + + @Environment(\.modelContext) private var ctx + + @State private var phase: Phase = .preparing + @State private var turns: [HealthExportDialogueTurn] = [] + /// 流式中的康康气泡文本;完成后 trim 并入 turns,随即清空。 + @State private var bubbleText = "" + @State private var liveTranscript = "" + /// 整场对话复用的健康数据快照;进场 makeContextJSON 生成一次。 + @State private var dataJSON = "{}" + @State private var recordingSeconds = 0 + @State private var lastRate: Double = 0 + /// 一次性提示条文案(开场兜底 / 没听清等),显示 2.5s 后自动清。 + @State private var toast: String? + @State private var showAbandonConfirm = false + @State private var micDeniedAlert = false + /// 流式 / 蒸馏任务句柄。onDisappear 与「停」按钮据此取消,不空耗底层解码。 + @State private var chatTask: Task? + /// 录音计时看门狗(每秒 +1,到点自动停)。 + @State private var recordingWatchdog: Task? + /// 最近一次用户原话,reply 失败后「重试」复用。 + @State private var pendingLatest: String? + /// .task 只跑一次的闸(startOpening 防重入)。 + @State private var started = false + /// 必须 @State:struct View 重建(任何父状态变化都可能触发)时普通 let 会换成全新实例, + /// 导致 stop() 落在没在录音的新服务上返回空串(「没听清」假错误),且真正在录音的老实例 + /// 关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一(照搬 DiaryQuickSheet 的约定)。 + @State private var dictation = SpeechDictationService() + + // MARK: - 派生 + + /// 形象情绪:思考类三态 → thinking,说话 → speaking,录音 → listening,其余 → idle。 + private var avatarMood: CompanionAvatarView.Mood { + switch phase { + case .preparing, .thinking, .distilling: return .thinking + case .speaking: return .speaking + case .recording: return .listening + case .awaitingUser, .failed: return .idle + } + } + + private var hasUserTurns: Bool { turns.contains { $0.role == .user } } + private var wrappedUp: Bool { DiaryChatService.isWrappedUp(turns: turns) } + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + topBar + CompanionAvatarView(mood: avatarMood, size: 150) + .padding(.vertical, 14) + .animation(.snappy(duration: 0.25), value: avatarMood) + dialogueArea + .frame(maxWidth: .infinity, maxHeight: .infinity) + controlArea + .padding(.horizontal, 20) + .padding(.top, 8) + .animation(.snappy(duration: 0.22), value: phase) + AIDisclaimerFooter() + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 10) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .task { startOpening() } + .onChange(of: toast) { _, newValue in + // 一次性提示:出现 2.5s 后自动清(被新 toast 顶掉则由新 onChange 接管)。 + guard newValue != nil else { return } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_500_000_000) + if toast == newValue { toast = nil } + } + } + .onDisappear { + chatTask?.cancel() + recordingWatchdog?.cancel() + dictation.abort() + } + .confirmationDialog(String(appLoc: "聊到一半,要放弃吗?"), + isPresented: $showAbandonConfirm, + titleVisibility: .visible) { + Button("把说过的记下来") { + onDraft(DiaryChatService.fallbackDraft(turns: turns), true) + } + Button("直接关闭", role: .destructive) { onClose() } + Button("取消", role: .cancel) {} + } + .alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $micDeniedAlert) { + Button(String(appLoc: "前往设置")) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button(String(appLoc: "取消"), role: .cancel) {} + } message: { + Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。") + } + } + + // MARK: - 顶栏 + + private var topBar: some View { + HStack(alignment: .center, spacing: 12) { + Button(action: attemptClose) { + Image(systemName: "xmark") + .font(.tjScaled(15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text2) + .frame(width: 44, height: 44) + .background(Circle().fill(Tj.Palette.paper)) + .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + + Spacer(minLength: 8) + + VStack(spacing: 2) { + Text("和康康聊聊") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("聊完帮你整理成一条记录") + .font(.tjScaled(11)) + .foregroundStyle(Tj.Palette.text3) + } + + Spacer(minLength: 8) + + roundDots + .frame(width: 44, alignment: .trailing) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 4) + } + + /// 右上轮次指示:maxRounds 个小圆点,已用满 i 轮的点亮成 brick。 + private var roundDots: some View { + HStack(spacing: 4) { + ForEach(0.. i + ? Tj.Palette.brick : Tj.Palette.line) + .frame(width: 6, height: 6) + } + } + } + + // MARK: - 对话区 + + private var dialogueArea: some View { + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 10) { + ForEach(turns) { turn in + bubbleRow(text: turn.text, isUser: turn.role == .user) + } + // 流式中的康康气泡:带右下 tok/s(lastRate>0 才显示)。 + if !bubbleText.isEmpty { + bubbleRow(text: bubbleText, isUser: false, rate: lastRate) + } + // 录音中的实时字幕:走用户样式,brick 半透描边区分「还没定稿」。 + if phase == .recording, !liveTranscript.isEmpty { + bubbleRow(text: liveTranscript, isUser: true, + strokeColor: Tj.Palette.brick.opacity(0.5)) + } + if let toast { + toastBar(toast) + } + // 滚动锚点(照搬 DiaryVoicePanel 的 tail 锚点写法)。 + Color.clear.frame(height: 1).id("tail") + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + } + .onChange(of: turns.count) { _, _ in scrollToTail(proxy) } + .onChange(of: bubbleText) { _, _ in scrollToTail(proxy) } + } + } + + private func scrollToTail(_ proxy: ScrollViewProxy) { + // 不加显式动画:流式期 bubbleText 每 token 都变,逐次动画滚动会抖(照搬 DiaryVoicePanel:94-96)。 + proxy.scrollTo("tail", anchor: .bottom) + } + + /// 一条对话气泡。康康(assistant)靠左 paper 底 + lineSoft 描边;用户靠右 sand2 底。 + /// - rate: 非空且 >0 时在右下角挂一行 tok/s(仅流式气泡用)。 + /// - strokeColor: 覆盖默认描边(录音实时字幕用 brick 半透)。 + @ViewBuilder + private func bubbleRow(text: String, + isUser: Bool, + rate: Double? = nil, + strokeColor: Color? = nil) -> some View { + let stroke = strokeColor ?? (isUser ? Color.clear : Tj.Palette.lineSoft) + HStack(alignment: .top, spacing: 0) { + if isUser { Spacer(minLength: 40) } + VStack(alignment: .leading, spacing: 4) { + Text(text) + .font(.tjScaled(15)) + .lineSpacing(3) + .foregroundStyle(Tj.Palette.text) + .fixedSize(horizontal: false, vertical: true) + if let rate, rate > 0 { + Text(String(format: "%.1f tok/s", rate)) + .font(.tjScaled(10, design: .monospaced)) + .foregroundStyle(Tj.Palette.leaf) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(isUser ? Tj.Palette.sand2 : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(stroke, lineWidth: 1) + ) + if !isUser { Spacer(minLength: 40) } + } + } + + /// 居中一次性提示条(amber 图标 + text2 文字)。text 为已本地化字符串,直接展示。 + private func toastBar(_ text: String) -> some View { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .font(.tjScaled(11)) + .foregroundStyle(Tj.Palette.amber) + Text(text) + .font(.tjScaled(11)) + .foregroundStyle(Tj.Palette.text2) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 4) + } + + // MARK: - 控制区 + + @ViewBuilder + private var controlArea: some View { + Group { + switch phase { + case .preparing: + statusBlock(String(appLoc: "康康正在翻你的记录…")) + + case .thinking: + VStack(spacing: 10) { + Text("康康在想…") + .font(.tjScaled(13, weight: .medium)) + .foregroundStyle(Tj.Palette.text2) + AIFlowBar().frame(maxWidth: 200) + Button("停") { stopThinking() } + .buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18)) + } + + case .distilling: + // 蒸馏最多几秒,且 onDisappear 兜底取消,不给「停」按钮。 + statusBlock(String(appLoc: "康康在帮你整理…")) + + case .speaking: + // 流式吐字中不放按钮,等说完自然进 awaitingUser。 + Color.clear.frame(height: 1) + + case .awaitingUser: + awaitingControls + + case .recording: + recordingControls + + case .failed(let msg): + failedControls(msg) + } + } + .frame(maxWidth: .infinity) + .frame(minHeight: 150) + } + + /// 「正在忙」通用块:一句状态 + 流光条。preparing / distilling 共用。 + private func statusBlock(_ text: String) -> some View { + VStack(spacing: 12) { + Text(text) + .font(.tjScaled(13, weight: .medium)) + .foregroundStyle(Tj.Palette.text2) + AIFlowBar().frame(maxWidth: 200) + } + } + + @ViewBuilder + private var awaitingControls: some View { + if wrappedUp { + // 聊够了:麦克风隐藏,只留「聊完了」主按钮收尾。 + Button("聊完了,帮我记下来") { finishChat() } + .buttonStyle(TjPrimaryButton()) + } else { + VStack(spacing: 12) { + Button(action: startRecording) { + ZStack { + Circle().fill(Tj.Palette.ink) + .frame(width: 72, height: 72) + Image(systemName: "mic.fill") + .font(.tjScaled(26, weight: .medium)) + .foregroundStyle(Tj.Palette.paper) + } + .contentShape(Circle()) + } + .buttonStyle(.plain) + + Text("点一下,说给康康听") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + + if hasUserTurns { + Button("聊完了,帮我记下来") { finishChat() } + .buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18)) + } + } + } + } + + private var recordingControls: some View { + VStack(spacing: 12) { + HStack(spacing: 14) { + Button(action: stopAndReply) { + ZStack { + Circle().fill(Tj.Palette.brick) + .frame(width: 72, height: 72) + Image(systemName: "stop.fill") + .font(.tjScaled(24, weight: .medium)) + .foregroundStyle(Tj.Palette.paper) + } + .contentShape(Circle()) + } + .buttonStyle(.plain) + + Text(timeString(recordingSeconds)) + .font(.tjMono(12)) + .foregroundStyle(recordingSeconds >= Self.maxRecordingSeconds - 20 + ? Tj.Palette.brick : Tj.Palette.text3) + } + Text("说完了") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + } + } + + private func failedControls(_ msg: String) -> some View { + VStack(spacing: 12) { + Text(msg) // 运行期错误描述(变量),原样展示,不再查表。 + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text2) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + Button("重试") { retryReply() } + .buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18)) + if hasUserTurns { + Button("不聊了,把原话记下来") { + onDraft(DiaryChatService.fallbackDraft(turns: turns), true) + } + .buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18)) + } + } + } + .padding(.horizontal, 20) + } + + private func timeString(_ seconds: Int) -> String { + String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + // MARK: - 动作:开场与流式消费 + + /// 进场只跑一次:生成健康数据快照,再流式拉开场白。 + private func startOpening() { + guard !started else { return } + started = true + dataJSON = DiaryChatService.shared.makeContextJSON(in: ctx) + consumeBubbleStream(DiaryChatService.shared.openingLine(dataJSON: dataJSON), + isOpening: true) + } + + /// 消费一条流式回应(opening 或 reply):逐 token 灌进 bubbleText,完成后并入 turns。 + /// 失败语义见文件头「失败链」;含取消在内,只要已有部分文本一律视同说完并入,不吓用户。 + private func consumeBubbleStream(_ stream: AsyncThrowingStream, + isOpening: Bool) { + chatTask = Task { @MainActor in + do { + for try await chunk in stream { + if bubbleText.isEmpty, !chunk.text.isEmpty { + withAnimation(.snappy(duration: 0.2)) { phase = .speaking } + } + bubbleText += chunk.text + if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } + } + // —— 正常完成 —— + let done = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines) + bubbleText = "" + if !done.isEmpty { + turns.append(.assistant(done)) + } else if isOpening { + // 极端:正常完成却全空(streamWithFallback 通常抛 .empty,这里兜一手)。 + turns.append(.assistant(DiaryChatPrompts.staticOpening)) + toast = String(appLoc: "康康没连上,先用老一套开场啦") + } + withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } + } catch { + // —— 流出错(含 CancellationError) —— + let partial = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines) + bubbleText = "" + if !partial.isEmpty { + // 已产出内容:视同说完并入,不算失败。 + turns.append(.assistant(partial)) + withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } + } else if isOpening { + // 开场全空:落静态开场白 + 一次性提示,继续能聊。 + turns.append(.assistant(DiaryChatPrompts.staticOpening)) + toast = String(appLoc: "康康没连上,先用老一套开场啦") + withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } + } else if error is CancellationError { + // 追问被主动取消:回等待,不算失败。 + withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } + } else { + // 追问全空失败:可重试。 + withAnimation(.snappy(duration: 0.2)) { + phase = .failed(error.localizedDescription) + } + } + } + } + } + + /// 「停」:取消流式追问,回等待态(consumeBubbleStream 的取消分支也会兜到 awaitingUser)。 + private func stopThinking() { + chatTask?.cancel() + withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } + } + + // MARK: - 动作:录音与追问 + + /// 请求权限 → 开录音 → 起看门狗。权限拒 → alert;start 抛错 → toast 提示。 + private func startRecording() { + toast = nil + chatTask = Task { @MainActor in + guard await dictation.requestAuthorization() else { + micDeniedAlert = true + return + } + do { + liveTranscript = "" + recordingSeconds = 0 + try dictation.start { partial in liveTranscript = partial } + withAnimation(.snappy(duration: 0.2)) { phase = .recording } + // 计时 + 看门狗(照搬 DiaryQuickSheet:701 结构,到点自动 stopAndReply)。 + recordingWatchdog = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled, phase == .recording else { return } + recordingSeconds += 1 + if recordingSeconds >= Self.maxRecordingSeconds { + stopAndReply() + return + } + } + } + } catch { + toast = String(appLoc: "没听清,再试一次") + withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } + } + } + } + + /// 停止录音取最终稿(空则用实时字幕兜底,照搬 DiaryQuickSheet:691-695),追加为用户轮并发起追问。 + private func stopAndReply() { + guard phase == .recording else { return } + recordingWatchdog?.cancel() + chatTask = Task { @MainActor in + var text = (await dictation.stop()) + .trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + text = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard !text.isEmpty else { + toast = String(appLoc: "没听清,再试一次") + withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } + return + } + pendingLatest = text + turns.append(.user(text)) + liveTranscript = "" + withAnimation(.snappy(duration: 0.2)) { phase = .thinking } + sendReply(latest: text) + } + } + + /// 发起一轮追问。transcript 用 dropLast() 去掉刚 append 的最新用户轮(它单独走 latest 参数, + /// 避免重复);roundsUsed 只数 assistant 轮,不受这条用户轮影响,直接从当前 turns 算即正确。 + private func sendReply(latest: String) { + let prior = HealthExportDialogueTurn.transcript(from: Array(turns.dropLast())) + let stream = DiaryChatService.shared.reply( + transcript: prior, + latest: latest, + dataJSON: dataJSON, + roundsUsed: DiaryChatService.roundsUsed(in: turns) + ) + consumeBubbleStream(stream, isOpening: false) + } + + /// 重试上一轮追问:turns 里失败那轮的用户话仍在,复用 pendingLatest。 + private func retryReply() { + guard let latest = pendingLatest else { return } + withAnimation(.snappy(duration: 0.2)) { phase = .thinking } + sendReply(latest: latest) + } + + // MARK: - 动作:收尾与关闭 + + /// 「聊完了」:蒸馏整段对话成日记草稿回传。失败 / 为空 → 落用户原话(usedFallback=true)。 + private func finishChat() { + withAnimation(.snappy(duration: 0.2)) { phase = .distilling } + chatTask = Task { @MainActor in + do { + let r = try await DiaryChatService.shared.distill(turns: turns) + onDraft(r.text.isEmpty ? DiaryChatService.fallbackDraft(turns: turns) : r.text, + r.text.isEmpty) + } catch { + onDraft(DiaryChatService.fallbackDraft(turns: turns), true) + } + } + } + + /// 关闭:聊过内容才二次确认(避免误关丢失),否则直接关。 + private func attemptClose() { + if hasUserTurns { + showAbandonConfirm = true + } else { + onClose() + } + } +} + +#Preview { + // 无真实数据时,开场 openingLine 落 staticOpening 兜底,正好看整页布局。 + DiaryCompanionView(onDraft: { _, _ in }, onClose: {}) + .modelContainer(for: [ + Indicator.self, Report.self, DiaryEntry.self, Asset.self, + ChatTurn.self, Symptom.self, UserProfile.self, + MetricReminder.self, CustomMonitorMetric.self, HealthExport.self + ], inMemory: true) +} diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index 78cef48..46feb67 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -72,6 +72,11 @@ struct DiaryQuickSheet: View { /// 且真正在录音的老实例关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一。 @State private var dictation = SpeechDictationService() + // MARK: 「聊着记」形象对话模式 + + /// 全屏形象对话(DiaryCompanionView),聊完蒸馏成日记草稿回填正文。 + @State private var showCompanionChat = false + private var hasContent: Bool { !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -155,6 +160,25 @@ struct DiaryQuickSheet: View { sectionLabel(String(appLoc: "内容")) Spacer() if SpeechDictationService.isAvailable, voicePhase == .idle { + // 「聊着记」:全屏形象对话模式,和「说一段」同显隐条件。 + Button(action: startCompanionChat) { + HStack(spacing: 4) { + Image(systemName: "bubble.left.and.bubble.right.fill") + .font(.tjScaled(11, weight: .semibold)) + Text("聊着记") + .font(.tjScaled(12, weight: .semibold)) + } + .foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().strokeBorder( + isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5), + lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(isLoading) + Button(action: startVoice) { HStack(spacing: 4) { Image(systemName: "mic.fill") @@ -305,6 +329,19 @@ struct DiaryQuickSheet: View { onClose: { showMedicationScan = false } ) } + .fullScreenCover(isPresented: $showCompanionChat) { + // 「聊着记」:和康康形象聊天,聊完蒸馏成日记草稿回填正文,由用户确认后走现有保存。 + DiaryCompanionView( + onDraft: { draft, usedFallback in + appendToContent(draft) + voiceNote = usedFallback + ? String(appLoc: "AI 整理没成功,已填入你聊天说的原话") + : String(appLoc: "康康把聊的内容整理好了,看一眼没问题就点保存") + showCompanionChat = false + }, + onClose: { showCompanionChat = false } + ) + } .sheet(isPresented: $showSymptomStart) { // 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。 SymptomStartSheet() @@ -645,6 +682,19 @@ struct DiaryQuickSheet: View { .buttonStyle(.plain) } + // MARK: 「聊着记」形象对话 + + /// 入口闸:端侧模型就绪或云增强可用才放行(§4 模型未就绪仍可启动,AI 入口提示前往下载)。 + private func startCompanionChat() { + guard ModelStore.shared.isComplete(for: .llm) || AIRuntime.shared.cloudAvailable else { + voiceNote = String(appLoc: "聊着记需要 AI:请先在「我的 · 模型管理」下载模型") + return + } + contentFocused = false + voiceNote = nil + showCompanionChat = true + } + // MARK: 语音输入流程 private func startVoice() { diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index c362d22..e9f0a32 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -1324,6 +1324,28 @@ } } }, + "AI 整理没成功,已填入你聊天说的原话" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI couldn't tidy it up — added what you said as-is" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI の整理に失敗したため,話した原文をそのまま追加しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 정리에 실패해 말한 원문을 그대로 채웠어요" + } + } + } + }, "AI 整理没成功,已填入录音原文" : { "localizations" : { "en" : { @@ -1717,12 +1739,6 @@ }, "Gemini 请求失败(%lld):%@" : { "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "new", - "value" : "Gemini 请求失败(%1$lld):%2$@" - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -1740,6 +1756,12 @@ "state" : "translated", "value" : "Gemini 요청 실패 (%lld): %@" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "Gemini 请求失败(%1$lld):%2$@" + } } } }, @@ -1839,6 +1861,9 @@ } } } + }, + "MLX · GPU" : { + }, "mmHg" : { @@ -1870,12 +1895,6 @@ }, "prompt %lld tok · 生成 %lld tok · 100%% 本地" : { "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "new", - "value" : "prompt %1$lld tok · 生成 %2$lld tok · 100%% 本地" - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -1893,6 +1912,12 @@ "state" : "translated", "value" : "프롬프트 %lld tok · 생성 %lld tok · 100%% 로컬" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "prompt %1$lld tok · 生成 %2$lld tok · 100%% 本地" + } } } }, @@ -2399,6 +2424,28 @@ } } }, + "不聊了,把原话记下来" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop chatting, keep my words" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "会話をやめて原文を記録" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그만 얘기하고 원문 기록" + } + } + } + }, "两个模型都已就绪" : { "extractionState" : "stale", "localizations" : { @@ -2775,6 +2822,28 @@ } } }, + "今天身体感觉怎么样?想到什么就说什么,我帮你记着。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How are you feeling today? Just say whatever comes to mind — I'll remember it for you." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日の体調はどうですか?思いついたことを話してください。私が覚えておきます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 몸 상태는 어때요? 떠오르는 대로 말해 주세요. 제가 기억해 둘게요." + } + } + } + }, "今日提醒" : { "localizations" : { "en" : { @@ -2929,6 +2998,28 @@ } } }, + "伴随症状" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other symptoms" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "併発症状" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동반 증상" + } + } + } + }, "但本地 AI 相关功能可能无法运行。" : { "localizations" : { "en" : { @@ -5058,6 +5149,28 @@ } } }, + "加重缓解" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What helps or worsens it" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "悪化・緩和の要因" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "악화·완화 요인" + } + } + } + }, "化验单" : { "localizations" : { "en" : { @@ -5216,12 +5329,6 @@ }, "原图 · 第 %lld/%lld 页" : { "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "new", - "value" : "原图 · 第 %1$lld/%2$lld 页" - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -5239,6 +5346,12 @@ "state" : "translated", "value" : "원본 · %lld/%lld 페이지" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "原图 · 第 %1$lld/%2$lld 页" + } } } }, @@ -5836,6 +5949,28 @@ } } }, + "和康康聊聊" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chat with Kangkang" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang とおしゃべり" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang와 수다" + } + } + } + }, "咳嗽" : { "localizations" : { "en" : { @@ -7232,6 +7367,7 @@ } }, "已停用:Gemma 4 无 MNN 转换模型,统一走 MLX" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -7476,12 +7612,6 @@ }, "已拍 %lld/%lld 张 · 可拍正面、背面、说明书" : { "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "new", - "value" : "已拍 %1$lld/%2$lld 张 · 可拍正面、背面、说明书" - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -7499,6 +7629,12 @@ "state" : "translated", "value" : "%lld/%lld장 촬영 · 앞면, 뒷면, 설명서를 찍을 수 있어요" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "已拍 %1$lld/%2$lld 张 · 可拍正面、背面、说明书" + } } } }, @@ -7859,6 +7995,50 @@ } } }, + "康康在帮你整理…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang is writing it up…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang がまとめています…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang가 정리하는 중…" + } + } + } + }, + "康康在想…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang is thinking…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang が考えています…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang가 생각 중…" + } + } + } + }, "康康在想想 · %.1f tok/s" : { "localizations" : { "en" : { @@ -7947,6 +8127,28 @@ } } }, + "康康把聊的内容整理好了,看一眼没问题就点保存" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang wrote up your chat — review it and tap Save" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang が会話をまとめました。確認して保存を押してください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang가 대화를 정리했어요. 확인하고 저장을 누르세요" + } + } + } + }, "康康是一款以本地优先为设计原则的个人健康随记工具。" : { "localizations" : { "en" : { @@ -7991,6 +8193,50 @@ } } }, + "康康正在翻你的记录…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang is looking through your records…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang が記録を見返しています…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang가 기록을 살펴보는 중…" + } + } + } + }, + "康康没连上,先用老一套开场啦" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang couldn't connect — starting the usual way" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang につながらないので,いつもの挨拶で始めます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang가 연결되지 않아 평소 인사로 시작할게요" + } + } + } + }, "康康自动整理成主诉/医生建议/复查小节" : { "localizations" : { "en" : { @@ -9256,6 +9502,28 @@ } } }, + "把说过的记下来" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep what I said" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "話した内容を記録する" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "말한 내용 기록하기" + } + } + } + }, "抑郁/焦虑" : { "localizations" : { "en" : { @@ -9785,6 +10053,28 @@ } } }, + "持续频率" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duration & frequency" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "持続・頻度" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지속·빈도" + } + } + } + }, "指标" : { "localizations" : { "en" : { @@ -10052,6 +10342,7 @@ } }, "按本机配置选择 · 当前 MLX · GPU" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11099,6 +11390,28 @@ } } }, + "既往家族史" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Past & family history" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "既往歴・家族歴" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "과거력·가족력" + } + } + } + }, "日" : { "localizations" : { "en" : { @@ -11695,6 +12008,28 @@ } } }, + "本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考,不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This content is automatically compiled by on-device AI from the health records you entered. It is for personal health management and communication with your doctor only, and does not constitute a medical diagnosis, treatment advice, or professional medical opinion. For specific health concerns, please consult a licensed physician." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本内容は、入力された健康記録をもとに端末内のAIが自動でまとめたものです。個人の健康管理や受診時の情報共有の参考であり、医学的な診断・治療の助言や専門的な医療意見ではありません。具体的な健康上の問題は医師にご相談ください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 내용은 입력하신 건강 기록을 바탕으로 기기 내 AI가 자동으로 정리한 것입니다. 개인 건강 관리와 진료 시 소통을 위한 참고용이며, 의학적 진단·치료 조언이나 전문 의료 소견이 아닙니다. 구체적인 건강 문제는 의사와 상담하세요." + } + } + } + }, "本周" : { "localizations" : { "en" : { @@ -13498,12 +13833,6 @@ }, "每年%lld月%lld日" : { "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "new", - "value" : "每年%1$lld月%2$lld日" - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -13521,6 +13850,12 @@ "state" : "translated", "value" : "매년 %lld월 %lld일" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "每年%1$lld月%2$lld日" + } } } }, @@ -14162,6 +14497,28 @@ } } }, + "点一下,说给康康听" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap and talk to Kangkang" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タップして Kangkang に話す" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "탭하고 Kangkang에게 말하기" + } + } + } + }, "点右上「解读」生成本地趋势解读" : { "localizations" : { "en" : { @@ -14652,6 +15009,28 @@ } } }, + "生活方式" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lifestyle" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "生活習慣" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생활 습관" + } + } + } + }, "用 %lld 次" : { "localizations" : { "en" : { @@ -14806,6 +15185,28 @@ } } }, + "用药过敏" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medications & allergies" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "服薬・アレルギー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "복용약·알레르기" + } + } + } + }, "甲状腺疾病" : { "localizations" : { "en" : { @@ -14982,6 +15383,28 @@ } } }, + "症状性质" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symptom details" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "症状の性質" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "증상 양상" + } + } + } + }, "症状持续中" : { "localizations" : { "en" : { @@ -15026,6 +15449,28 @@ } } }, + "直接关闭" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard and close" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "そのまま閉じる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그냥 닫기" + } + } + } + }, "相机权限未开启" : { "localizations" : { "en" : { @@ -15183,12 +15628,6 @@ }, "第 %lld / 共 %lld 条" : { "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "new", - "value" : "第 %1$lld / 共 %2$lld 条" - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -15206,6 +15645,12 @@ "state" : "translated", "value" : "%lld / %lld개" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "第 %1$lld / 共 %2$lld 条" + } } } }, @@ -15883,6 +16328,116 @@ } } }, + "聊到一半,要放弃吗?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leave this chat?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "会話の途中ですが,やめますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대화 중인데 그만둘까요?" + } + } + } + }, + "聊完了,帮我记下来" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done chatting, write it down" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "話し終わった,まとめて" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다 얘기했어요, 기록해 줘" + } + } + } + }, + "聊完帮你整理成一条记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When you're done, it becomes one entry" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "話し終えたら 1 件の記録にまとめます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다 얘기하면 기록 하나로 정리해 드려요" + } + } + } + }, + "聊着记" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chat to log" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "おしゃべり記録" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수다로 기록" + } + } + } + }, + "聊着记需要 AI:请先在「我的 · 模型管理」下载模型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chat to log needs AI — download the model in Me · Model Management first" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "おしゃべり記録には AI が必要です。「マイページ · モデル管理」でモデルをダウンロードしてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수다 기록에는 AI가 필요해요. 먼저 '나 · 모델 관리'에서 모델을 다운로드하세요" + } + } + } + }, "腹痛" : { "localizations" : { "en" : { @@ -17795,6 +18350,28 @@ } } }, + "起病诱因" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onset & triggers" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "発症のきっかけ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "발병 유인" + } + } + } + }, "超大" : { "localizations" : { "en" : { @@ -18730,6 +19307,50 @@ } } }, + "这次没能回答上来,请换个说法再试一次。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't answer this time. Try rephrasing and asking again." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今回はうまく回答できませんでした。言い方を変えてもう一度お試しください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번에는 답변하지 못했어요. 다르게 표현해 다시 시도해 주세요." + } + } + } + }, + "这次没能生成成功,请稍后重试。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't generate this time. Please try again later." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今回は生成できませんでした。しばらくして再度お試しください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번에는 생성하지 못했어요. 잠시 후 다시 시도해 주세요." + } + } + } + }, "进入核对(%lld)" : { "localizations" : { "en" : { @@ -19659,249 +20280,7 @@ } } } - }, - "本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考,不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This content is automatically compiled by on-device AI from the health records you entered. It is for personal health management and communication with your doctor only, and does not constitute a medical diagnosis, treatment advice, or professional medical opinion. For specific health concerns, please consult a licensed physician." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "本内容は、入力された健康記録をもとに端末内のAIが自動でまとめたものです。個人の健康管理や受診時の情報共有の参考であり、医学的な診断・治療の助言や専門的な医療意見ではありません。具体的な健康上の問題は医師にご相談ください。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 내용은 입력하신 건강 기록을 바탕으로 기기 내 AI가 자동으로 정리한 것입니다. 개인 건강 관리와 진료 시 소통을 위한 참고용이며, 의학적 진단·치료 조언이나 전문 의료 소견이 아닙니다. 구체적인 건강 문제는 의사와 상담하세요." - } - } - } - }, - "起病诱因" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onset & triggers" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "発症のきっかけ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "발병 유인" - } - } - } - }, - "症状性质" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symptom details" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "症状の性質" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "증상 양상" - } - } - } - }, - "伴随症状" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Other symptoms" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "併発症状" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "동반 증상" - } - } - } - }, - "加重缓解" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "What helps or worsens it" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "悪化・緩和の要因" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "악화·완화 요인" - } - } - } - }, - "持续频率" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Duration & frequency" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "持続・頻度" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "지속·빈도" - } - } - } - }, - "既往家族史" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Past & family history" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "既往歴・家族歴" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "과거력·가족력" - } - } - } - }, - "用药过敏" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medications & allergies" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "服薬・アレルギー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "복용약·알레르기" - } - } - } - }, - "生活方式" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lifestyle" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "生活習慣" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "생활 습관" - } - } - } - }, - "这次没能回答上来,请换个说法再试一次。" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Couldn't answer this time. Try rephrasing and asking again." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今回はうまく回答できませんでした。言い方を変えてもう一度お試しください。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이번에는 답변하지 못했어요. 다르게 표현해 다시 시도해 주세요." - } - } - } - }, - "这次没能生成成功,请稍后重试。" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Couldn't generate this time. Please try again later." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今回は生成できませんでした。しばらくして再度お試しください。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이번에는 생성하지 못했어요. 잠시 후 다시 시도해 주세요." - } - } - } } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/康康/Services/DiaryChatService.swift b/康康/Services/DiaryChatService.swift new file mode 100644 index 0000000..098e00c --- /dev/null +++ b/康康/Services/DiaryChatService.swift @@ -0,0 +1,230 @@ +import Foundation +import SwiftData + +/// 「聊着记」功能的唯一 AI 门面(红线 #3:UI 不直接调 AIRuntime,一律经 Service)。 +/// +/// 场景:全屏动画形象与用户语音多轮对话——康康基于已有健康记录追问,聊完把整段对话蒸馏成日记草稿。 +/// 对话上下文(健康数据快照)进场调一次 `makeContextJSON` 生成,整场复用;每轮回应流式吐字。 +/// +/// 云优先策略(出处:CaptureService.runVL 的「云优先回退端侧」模式): +/// - 云端可用(Gemini)时先走 `generateCloud`(不占显存、不进 OOM 闸门); +/// - 云端离线 / 超时 / 额度耗尽 / 输出为空,一律静默回退端侧 Gemma(MLX/GPU)。 +/// +/// 失败回退语义(§3.2 / 红线 #5「不让用户卡在 AI 错误屏」): +/// - `openingLine` / `reply`(流式): +/// · 云端【首个有效 token 前】失败 → 静默吞掉,落端侧; +/// · 云端【已产出内容后】失败 → `finish(throwing:)`,View 对「已有部分文本」的流错误按说完处理; +/// · 端侧 `prepare()` 失败 → `ChatError.modelNotReady`; +/// · 两路都走完但全空 → `ChatError.empty`; +/// · 取消(`CancellationError`)一律透传,不吞、不回退。 +/// - `distill`(一次性 await):云端失败 / 清理后为空 → 落端侧;端侧 `prepare()` 失败 → `.modelNotReady`; +/// 最终为空 → `.empty`。 +/// - 纯函数 `roundsUsed` / `isWrappedUp` / `fallbackDraft`:不碰 AI,永不失败。 +@MainActor +struct DiaryChatService { + static let shared = DiaryChatService() + private init() {} + + enum ChatError: Error, LocalizedError { + case modelNotReady // 端侧模型未就绪且云端不可用 / 失败 + case empty // 两路都生成了但内容为空 + + var errorDescription: String? { + switch self { + case .modelNotReady: return String(appLoc: "AI 模型尚未准备好") + case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试") + } + } + } + + // MARK: - 上下文快照 + + /// 生成整场对话复用的健康数据 JSON(profile + 指标 + 日记 + 用药 …)。 + /// 只读 `ctx` 做快照,不写 SwiftData;同步、永不失败。 + /// 调用时机:进场调用一次,把结果缓存在 View 层,后续每轮 `reply` 传同一份,不重复检索。 + func makeContextJSON(in ctx: ModelContext) -> String { + HealthExportService.serializeData( + snapshot: HealthExportService.retrieveDialogueSnapshot(ctx: ctx) + ) + } + + // MARK: - 流式:开场白 / 每轮回应 + + /// 开场白:一句暖场引导 + 首个追问。160 token——开场较短,一句话足够。 + func openingLine(dataJSON: String) -> AsyncThrowingStream { + streamWithFallback(prompt: DiaryChatPrompts.opening(dataJSON: dataJSON), maxTokens: 160) + } + + /// 每轮回应:共情用户最新一句 + 再追问一步(或收尾)。220 token——含共情 + 追问,比开场略长。 + /// - roundsUsed: 已发生的追问轮数(取自 `roundsUsed(in:)`)。 + /// `roundsLeft = 本轮生成后剩余可问轮数`,为 0 时 prompt 内部自动切收尾指令(不再追问)。 + func reply(transcript: String, + latest: String, + dataJSON: String, + roundsUsed: Int) -> AsyncThrowingStream { + let roundsLeft = max(0, DiaryChatPrompts.maxRounds - roundsUsed - 1) + let prompt = DiaryChatPrompts.reply( + transcript: transcript, + latest: latest, + dataJSON: dataJSON, + roundsLeft: roundsLeft + ) + return streamWithFallback(prompt: prompt, maxTokens: 220) + } + + // MARK: - 蒸馏:聊完 → 日记草稿 + + /// 把整段对话蒸馏成一篇健康日记草稿。一次性 await(非流式)。 + /// 云优先:`cloudAvailable` 时先试云端并收集全文,任何错误 / 清理后为空都静默回退端侧; + /// 端侧走到时若 `prepare()` 失败,才抛 `.modelNotReady`(即云端也失败 / 不可用)。 + /// 400 token:对齐 `DiaryAssistService.organize`(:193)的产物量级——一篇日记草稿。 + func distill(turns: [HealthExportDialogueTurn]) async throws -> (text: String, decodeRate: Double) { + let transcript = HealthExportDialogueTurn.transcript(from: turns) + let prompt = DiaryChatPrompts.distill(transcript: transcript) + + // —— 云端优先(CaptureService.runVL 模式:失败静默回退,绝不卡死) —— + if AIRuntime.shared.cloudAvailable { + do { + var collected = "" + var rate: Double = 0 + let stream = await AIRuntime.shared.generateCloud(prompt: prompt, maxTokens: 400) + for try await chunk in stream { + collected += chunk.text + if chunk.decodeRate > 0 { rate = chunk.decodeRate } + } + let text = HealthExportService.stripThinkBlocks(collected) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { return (text, rate) } + // 云端走完但清理后为空 → 落端侧(不抛)。 + } catch { + // 云端任何失败(离线 / 超时 / 额度)静默吞掉,落端侧(§3.2 失败回退红线)。 + } + } + + // —— 端侧回退 —— + do { + try await AIRuntime.shared.prepare() + } catch { + throw ChatError.modelNotReady + } + var collected = "" + var lastRate: Double = 0 + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400) + for try await chunk in stream { + collected += chunk.text + if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } + } + let text = HealthExportService.stripThinkBlocks(collected) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { throw ChatError.empty } + return (text, lastRate) + } + + // MARK: - 核心:云优先流式,失败前段回退端侧 + + /// 流式生成 + 云→端回退。返回同步创建的流,内部起 Task 逐 token 吐(去 `` 后的 delta)。 + /// + /// 「首个有效 token」的界定:经 `ThinkStripper.feed` 清理后【首个非空 delta 被 yield】的那一刻。 + /// 在此之前云端出任何错 → 静默回退端侧;在此之后出错 → `finish(throwing:)`(View 已有部分文本,按说完处理)。 + /// 风格对齐 HealthExportService.answer 的既有流式写法。 + private func streamWithFallback(prompt: String, + maxTokens: Int) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { @MainActor in + // —— 云端优先(CaptureService.runVL 模式) —— + var cloudYielded = false // 是否已 yield 过非空内容 = 是否已产出「首个有效 token」 + if AIRuntime.shared.cloudAvailable { + do { + var stripper = ThinkStripper() + let stream = await AIRuntime.shared.generateCloud(prompt: prompt, maxTokens: maxTokens) + for try await chunk in stream { + try Task.checkCancellation() + let delta = stripper.feed(chunk.text) + if !delta.isEmpty { + cloudYielded = true + continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate)) + } + } + if cloudYielded { + continuation.finish() // 云端有内容,正常收尾,不再走端侧。 + return + } + // 云端走完但全空 → 落端侧(不 return)。 + } catch is CancellationError { + continuation.finish(throwing: CancellationError()) // 取消透传,不吞、不回退。 + return + } catch { + if cloudYielded { + // 已产出内容后失败:交给 View(有部分文本即按说完处理)。 + continuation.finish(throwing: error) + return + } + // 首个有效 token 前失败:静默吞掉,落端侧(CaptureService.runVL 模式)。 + } + } + + // —— 端侧回退(MLX/GPU Gemma) —— + do { + try await AIRuntime.shared.prepare() // OOM 闸门内卸载互斥模型 + } catch { + continuation.finish(throwing: ChatError.modelNotReady) + return + } + do { + var localYielded = false + var stripper = ThinkStripper() + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: maxTokens) + for try await chunk in stream { + try Task.checkCancellation() + let delta = stripper.feed(chunk.text) + if !delta.isEmpty { + localYielded = true + continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate)) + } + } + if localYielded { + continuation.finish() + } else { + continuation.finish(throwing: ChatError.empty) // 端侧走完全空。 + } + } catch is CancellationError { + continuation.finish(throwing: CancellationError()) // 取消透传。 + } catch { + // 端侧解码中途失败:透传错误(View 已有部分文本时按说完处理)。 + continuation.finish(throwing: error) + } + } + // 消费者(UI)关闭 / 取消流时取消内部 Task,停止底层解码,不空耗算力。 + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - 纯函数(无 AI,无共享状态;nonisolated 便于非隔离单测精确断言) + + /// 已发生的「追问轮数」= assistant 轮数 - 1(开场白不算一轮追问)。无 assistant 轮时为 0。 + nonisolated static func roundsUsed(in turns: [HealthExportDialogueTurn]) -> Int { + let assistantCount = turns.filter { $0.role == .assistant }.count + return max(0, assistantCount - 1) + } + + /// 是否已聊够(达到 `DiaryChatPrompts.maxRounds` 轮追问,该收尾了)。 + nonisolated static func isWrappedUp(turns: [HealthExportDialogueTurn]) -> Bool { + roundsUsed(in: turns) >= DiaryChatPrompts.maxRounds + } + + /// 蒸馏失败时的兜底草稿:只取用户原话拼接,康康(assistant)的话绝不出现,数值原样不改写。 + /// - 0 条有效用户话 → `""`; + /// - 恰 1 条 → 该原话本身(无前缀); + /// - ≥2 条 → 每条前缀 `"· "`,按 `"\n"` join。 + nonisolated static func fallbackDraft(turns: [HealthExportDialogueTurn]) -> String { + let userLines = turns + .filter { $0.role == .user } + .map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + switch userLines.count { + case 0: return "" + case 1: return userLines[0] + default: return userLines.map { "· " + $0 }.joined(separator: "\n") + } + } +} diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift index 295f487..2037b89 100644 --- a/康康/Services/HealthExportService.swift +++ b/康康/Services/HealthExportService.swift @@ -785,7 +785,8 @@ struct HealthExportService { /// 全是 O(n) grapheme 操作,1024/1200 token 长报告随长度二次方增长(且都在 MainActor 上)。 /// 这里一旦思考段闭合(出现 ``)或确定不存在(首个非空字符不是 `<`,Qwen 思考必在最前), /// 就切到纯增量拼接,把生成主体阶段的每 token 成本降到 O(1)。最坏情况退化为旧行为,无正确性风险。 -private struct ThinkStripper { +/// internal:HealthExportService 与 DiaryChatService 的流式输出共用。 +struct ThinkStripper { private var rawAccum = "" private(set) var output = "" private var resolved = false diff --git a/康康/Services/SenseVoiceASRService.swift b/康康/Services/SenseVoiceASRService.swift index 5800f94..60e2927 100644 --- a/康康/Services/SenseVoiceASRService.swift +++ b/康康/Services/SenseVoiceASRService.swift @@ -1,5 +1,7 @@ import Foundation -import AVFoundation +// @preconcurrency:AVFAudio 的 AVAudioConverterInputBlock 标了 @Sendable 但 +// AVAudioPCMBuffer 未标 Sendable,decodeToMonoFloat 同步喂 buffer 是安全的,压掉误报。 +@preconcurrency import AVFoundation /// 端侧问诊语音转写服务(SenseVoice,经 sherpa-mnn 跑在 MNN 后端)。 /// diff --git a/康康Tests/DiaryChatPromptTests.swift b/康康Tests/DiaryChatPromptTests.swift new file mode 100644 index 0000000..6b17ee9 --- /dev/null +++ b/康康Tests/DiaryChatPromptTests.swift @@ -0,0 +1,103 @@ +import Testing +@testable import 康康 + +/// 日记追问对话的 prompt 拼装契约:收尾/继续两种口径互斥、红线短语、 +/// dataJSON 原文嵌入、蒸馏截断与 /no_think 结尾。 +/// 对齐 DiaryOrganizePromptTests 风格:纯字符串断言,不涉隔离。 +struct DiaryChatPromptTests { + + /// 唯一、易断言的注入片段。 + private let dataJSON = #"{"indicators":[{"name":"收缩压","value":"145"}]}"# + + // MARK: 收尾口径(roundsLeft == 0) + + @Test func replyAtZeroRoundsForbidsFurtherQuestions() { + let prompt = DiaryChatPrompts.reply( + transcript: "我: 我今天头疼", + latest: "我头疼", + dataJSON: dataJSON, + roundsLeft: 0 + ) + #expect(prompt.contains("不要再提任何问题")) + #expect(!prompt.contains("最多问 1 个新问题")) + } + + // MARK: 继续口径(roundsLeft > 0) + + @Test func replyWithRoundsLeftAllowsOneMoreQuestion() { + let prompt = DiaryChatPrompts.reply( + transcript: "我: 我今天头疼", + latest: "我头疼", + dataJSON: dataJSON, + roundsLeft: 2 + ) + #expect(prompt.contains("最多问 1 个新问题")) + #expect(!prompt.contains("不要再提任何问题")) + } + + // MARK: 红线短语 + dataJSON 原文嵌入(opening 与 reply 都要有) + + @Test func openingCarriesNoReferralRuleAndEmbedsDataJSON() { + let prompt = DiaryChatPrompts.opening(dataJSON: dataJSON) + #expect(prompt.contains("不写「建议就医」")) + #expect(prompt.contains(dataJSON)) + } + + @Test func replyCarriesNoReferralRuleAndEmbedsDataJSON() { + let prompt = DiaryChatPrompts.reply( + transcript: "我: 我今天头疼", + latest: "我头疼", + dataJSON: dataJSON, + roundsLeft: 1 + ) + #expect(prompt.contains("不写「建议就医」")) + #expect(prompt.contains(dataJSON)) + } + + // MARK: 蒸馏 prompt 的取材约束短语 + + @Test func distillContainsUserOnlyAndForbiddenRule() { + let prompt = DiaryChatPrompts.distill(transcript: "我: 我今天头疼\n康康: 收到") + #expect(prompt.contains("只取「我:」")) + #expect(prompt.contains("绝对不许")) + } + + // MARK: 三个 prompt trim 后都以 /no_think 结尾 + + @Test func openingEndsWithNoThink() { + let prompt = DiaryChatPrompts.opening(dataJSON: dataJSON) + #expect(prompt.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("/no_think")) + } + + @Test func replyEndsWithNoThink() { + let prompt = DiaryChatPrompts.reply( + transcript: "我: 我今天头疼", + latest: "我头疼", + dataJSON: dataJSON, + roundsLeft: 1 + ) + #expect(prompt.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("/no_think")) + } + + @Test func distillEndsWithNoThink() { + let prompt = DiaryChatPrompts.distill(transcript: "我: 我今天头疼") + #expect(prompt.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("/no_think")) + } + + // MARK: 蒸馏对超长稿件截断到 distillTranscriptLimit(2000) + + @Test func distillTruncatesTranscriptBeyondLimit() { + // 起始标记落在前 2000 内应保留;越限标记落在第 2500 字符附近应被截掉。 + let earlyMark = "起始标记AAA" + let lateMark = "越限标记ZZZ" + let transcript = earlyMark + + String(repeating: "文", count: 2500 - earlyMark.count) // 把越限标记推到 index≈2500 + + lateMark + + String(repeating: "字", count: 500) // 凑到 3000+ 字符 + #expect(transcript.count > DiaryChatPrompts.distillTranscriptLimit) + + let prompt = DiaryChatPrompts.distill(transcript: transcript) + #expect(prompt.contains(earlyMark)) + #expect(!prompt.contains(lateMark)) + } +} diff --git a/康康Tests/DiaryChatServiceTests.swift b/康康Tests/DiaryChatServiceTests.swift new file mode 100644 index 0000000..35038cc --- /dev/null +++ b/康康Tests/DiaryChatServiceTests.swift @@ -0,0 +1,130 @@ +import Testing +@testable import 康康 + +/// 日记追问对话的纯逻辑:轮数计数、收尾判定、离线兜底草稿。 +/// DiaryChatService 是 @MainActor struct,测试整体标 @MainActor 规避隔离。 +@MainActor +struct DiaryChatServiceTests { + + // MARK: roundsUsed —— 开场白不算追问(assistant 轮数 − 1),user 轮不计 + + @Test func roundsUsedIsZeroForEmptyTurns() { + #expect(DiaryChatService.roundsUsed(in: []) == 0) + } + + @Test func roundsUsedIsZeroForOpeningOnly() { + // 仅 1 条 assistant(开场白)→ max(0, 1-1) = 0 + let turns: [HealthExportDialogueTurn] = [.assistant("说说你今天的情况")] + #expect(DiaryChatService.roundsUsed(in: turns) == 0) + } + + @Test func roundsUsedCountsAssistantRepliesAfterOpening() { + // 开场 + 2 条追问 = 3 条 assistant → max(0, 3-1) = 2 + let turns: [HealthExportDialogueTurn] = [ + .assistant("开场"), + .user("我头疼"), + .assistant("追问一"), + .user("还发烧"), + .assistant("追问二") + ] + #expect(DiaryChatService.roundsUsed(in: turns) == 2) + } + + @Test func roundsUsedIgnoresUserTurns() { + // 夹杂多条 user 不影响计数:仍是 3 条 assistant → 2 + let turns: [HealthExportDialogueTurn] = [ + .assistant("开场"), + .user("u1"), .user("u2"), + .assistant("追问一"), + .user("u3"), .user("u4"), .user("u5"), + .assistant("追问二"), + .user("u6") + ] + #expect(DiaryChatService.roundsUsed(in: turns) == 2) + } + + // MARK: isWrappedUp —— roundsUsed >= maxRounds(4) + + @Test func isNotWrappedUpAtThreeRounds() { + // 4 条 assistant(开场 + 3 追问)→ roundsUsed = 3 < 4 → 未收尾 + let turns: [HealthExportDialogueTurn] = [ + .assistant("开场"), + .user("a"), .assistant("q1"), + .user("b"), .assistant("q2"), + .user("c"), .assistant("q3") + ] + #expect(DiaryChatService.roundsUsed(in: turns) == 3) + #expect(DiaryChatService.isWrappedUp(turns: turns) == false) + } + + @Test func isWrappedUpAtFourRounds() { + // 5 条 assistant(开场 + 4 追问)→ roundsUsed = 4 = maxRounds → 收尾 + let turns: [HealthExportDialogueTurn] = [ + .assistant("开场"), + .user("a"), .assistant("q1"), + .user("b"), .assistant("q2"), + .user("c"), .assistant("q3"), + .user("d"), .assistant("q4") + ] + #expect(DiaryChatService.roundsUsed(in: turns) == DiaryChatPrompts.maxRounds) + #expect(DiaryChatService.isWrappedUp(turns: turns) == true) + } + + // MARK: fallbackDraft —— 只拼 user 原话,assistant 绝不进草稿 + + @Test func fallbackDraftIsEmptyWhenNoUserTurns() { + let turns: [HealthExportDialogueTurn] = [ + .assistant("开场"), + .assistant("再问一句") + ] + #expect(DiaryChatService.fallbackDraft(turns: turns) == "") + } + + @Test func fallbackDraftReturnsSingleUserVerbatimWithoutBullet() { + // 恰 1 条 user → 原话本身,无「· 」前缀;数值原样 + let turns: [HealthExportDialogueTurn] = [ + .assistant("说说你今天的情况"), + .user("今天头疼,量了血压 140/90") + ] + let draft = DiaryChatService.fallbackDraft(turns: turns) + #expect(draft == "今天头疼,量了血压 140/90") + #expect(!draft.contains("· ")) + } + + @Test func fallbackDraftBulletsMultipleUserTurns() { + // ≥2 条 user → 每条前缀「· 」按换行拼接 + let turns: [HealthExportDialogueTurn] = [ + .assistant("开场"), + .user("今天头疼"), + .assistant("量血压了吗?"), + .user("量了血压 140/90") + ] + let draft = DiaryChatService.fallbackDraft(turns: turns) + #expect(draft == "· 今天头疼\n· 量了血压 140/90") + } + + @Test func fallbackDraftDropsBlankUserTurns() { + // 纯空格 / 换行空白的 user 轮被丢弃,剩两条有效仍带「· 」 + let turns: [HealthExportDialogueTurn] = [ + .user("第一条有效"), + .user(" "), + .user("\n \n"), + .user("第二条有效") + ] + let draft = DiaryChatService.fallbackDraft(turns: turns) + #expect(draft == "· 第一条有效\n· 第二条有效") + } + + @Test func fallbackDraftNeverIncludesAssistantTextAndKeepsNumbers() { + // assistant 文本绝不出现;140/90 原样保留 + let turns: [HealthExportDialogueTurn] = [ + .assistant("上次你记过头疼"), + .user("今天又头疼了,血压 140/90"), + .assistant("需要提醒复诊吗?") + ] + let draft = DiaryChatService.fallbackDraft(turns: turns) + #expect(!draft.contains("上次你记过头疼")) + #expect(!draft.contains("需要提醒复诊吗?")) + #expect(draft.contains("140/90")) + } +} diff --git a/康康Tests/ModelDownloadCoreTests.swift b/康康Tests/ModelDownloadCoreTests.swift index d0f1da5..f595e19 100644 --- a/康康Tests/ModelDownloadCoreTests.swift +++ b/康康Tests/ModelDownloadCoreTests.swift @@ -133,4 +133,24 @@ struct FileDownloaderTests { } #expect(!FileManager.default.fileExists(atPath: dst.path)) } + + @Test func failsFastOnServerTotalMismatchAndRemovesPart() async throws { + // 上游仓库更新后文件变大的场景:206 Content-Range 报告的总大小(100)≠ 清单预期(5), + // 应在响应头阶段就抛 sizeMismatch(got=服务器总大小),并删掉 .part 防跨版本拼接。 + let url = uniqueURL() + MockURLProtocol.register(url, body: Data(repeating: 0xAB, count: 100)) + let dst = tempFile() + defer { try? FileManager.default.removeItem(at: dst.deletingLastPathComponent()) } + + let dl = FileDownloader(configuration: mockConfiguration()) + do { + try await dl.download(from: url, to: dst, expectedBytes: 5) + Issue.record("预期抛 sizeMismatch,实际成功") + } catch let DownloadError.sizeMismatch(expected, got) { + #expect(expected == 5) + #expect(got == 100) // 快速失败拿到的是响应头里的服务器总大小,不是下完的字节数 + } + #expect(!FileManager.default.fileExists(atPath: dst.path)) + #expect(!FileManager.default.fileExists(atPath: dst.appendingPathExtension("part").path)) + } } diff --git a/康康Tests/ModelManifestTests.swift b/康康Tests/ModelManifestTests.swift index 403a162..69535ef 100644 --- a/康康Tests/ModelManifestTests.swift +++ b/康康Tests/ModelManifestTests.swift @@ -15,8 +15,9 @@ struct ModelManifestTests { } @Test func llmTotalBytesMatchesManifest() { - // gemma-4-e2b-it-4bit 全部运行文件字节之和(ModelScope repo/files 实测,2026-07)。 - #expect(ModelManifest.totalBytes(for: .llm) == 3_613_528_388) + // gemma-4-e2b-it-4bit 全部运行文件字节之和(魔搭/hf-mirror 双源实测,2026-07-14, + // 上游 7 月中旬更新过 checkpoint 后的新值)。 + #expect(ModelManifest.totalBytes(for: .llm) == 3_583_086_498) } @Test func vlTotalBytesMatchesManifest() {