From abacf5c4f51c671d836126149f179b61ca3fe02b Mon Sep 17 00:00:00 2001 From: link2026 Date: Wed, 17 Jun 2026 09:21:47 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(diary):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=97=A5=E8=AE=B0AI=E5=8D=8F=E5=8A=A9=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加promptBanner作为未开始协作时的醒目邀请横幅,包含圆形图标、标题和说明文字 - 重构assistSection使用switch语句处理careState不同状态,区分隐藏、prompt和其他状态 - 增加动画过渡效果消除聚焦/失焦切换时的布局跳动 - 优化thinking状态下的UI展示,添加AIFlowBar彩色呼吸条显示推理进度 - 修改requestSuggestions逻辑,进入推理时收起键盘以完整显示协作卡片 refactor(inference): 优化性能自检界面样式 - 将性能自检入口改为描边动作按钮(TjGhostButton),与引擎选择在视觉上区分开 - 调整未就绪状态下的禁用样式和提示文案 feat(localization): 添加新的本地化字符串 - 新增"追踪"和"记一笔"的多语言翻译,包括英语、日语和韩语 fix(diary): 增强AI问答解析稳定性 - 将最大token数从400提升至512,避免中文问题JSON被截断导致解析失败 - 实现salvageQuestionObjects方法作为终极兜底机制,逐个解析平衡的{...}对象 - 当外层wrapper解析失败时,仍可救回内部已闭合的问题对象,确保用户不被AI错误卡住 test(diary): 补充AI问答解析测试用例 - 添加截断对象恢复测试,验证maxTokens截断时前序完整问题的救回能力 - 添加wrapper key错误情况的恢复测试,确保模型输出格式异常时的容错性 ``` --- 康康/Features/Diary/DiaryQuickSheet.swift | 148 +++++++++++++------ 康康/Features/Me/InferenceSettingsView.swift | 75 ++++------ 康康/Localizable.xcstrings | 44 ++++++ 康康/RootView.swift | 6 +- 康康/Services/DiaryAssistService.swift | 48 +++++- 康康Tests/DiaryAssistParseTests.swift | 16 ++ 6 files changed, 244 insertions(+), 93 deletions(-) diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index 665363a..f609e46 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -320,32 +320,40 @@ struct DiaryQuickSheet: View { private var assistSection: some View { VStack(alignment: .leading, spacing: 10) { if !contentFocused { - if case .hidden = careState { + switch careState { + case .hidden: EmptyView() - } else { - HStack(spacing: 6) { - Image(systemName: "sparkles") - .font(.tjScaled( 11, weight: .semibold)) - .foregroundStyle(Tj.Palette.brick) - sectionLabel(String(appLoc: "康康帮你记")) - Spacer(minLength: 0) - if lastRate > 0 { - Text(String(format: "%.1f tok/s", lastRate)) - .font(.tjScaled( 10, design: .monospaced)) - .foregroundStyle(Tj.Palette.leaf) + case .prompt: + // 还没开始协作:醒目的整行邀请 banner(自带标题,不再挂「康康帮你记」抬头, + // 免得两行都在说「帮你记」)。这是「智能协作不明显」的主补强点。 + promptBanner + default: + // 已在协作(想想 / 追问 / 问完 / 失败):挂抬头 + tok/s + 关心条卡片。 + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.tjScaled( 11, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + sectionLabel(String(appLoc: "康康帮你记")) + Spacer(minLength: 0) + if lastRate > 0 { + Text(String(format: "%.1f tok/s", lastRate)) + .font(.tjScaled( 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.leaf) + } } + careBarRow(compact: false) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) } - careBarRow(compact: false) - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(Tj.Palette.paper) - ) - .overlay( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) - ) } } // 用过一次 AI 后,免责声明常驻(键盘弹起时关心条在上方,这条留在正文里兜合规)。 @@ -353,6 +361,51 @@ struct DiaryQuickSheet: View { AIDisclaimerFooter() } } + // 聚焦/失焦切换(关心条在键盘上 ↔ 在正文卡片)平滑过渡,消除布局跳动。 + .animation(.snappy(duration: 0.22), value: contentFocused) + } + + /// 「还没让康康帮忙」时的醒目邀请 banner(正文里的主入口)。 + /// 比键盘上那条小胶囊更有存在感:圆形图标 + 标题 + 一句副说明 + 箭头, + /// 让「这里有个本地 AI 协作」一眼可见(对齐目标:智能协作要明显)。 + private var promptBanner: some View { + Button(action: requestSuggestions) { + HStack(spacing: 11) { + Image(systemName: "sparkles") + .font(.tjScaled( 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.paper) + .frame(width: 32, height: 32) + .background(Circle().fill(Tj.Palette.brick)) + VStack(alignment: .leading, spacing: 2) { + Text("让康康帮你把这条记得更全") + .font(.tjScaled( 14, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("从医生问诊角度提几个值得补充的细节 · 本机推理") + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + } + Spacer(minLength: 0) + Image(systemName: "arrow.right") + .font(.tjScaled( 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.brick.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.brick.opacity(0.45), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(!canRequestSuggest) + .opacity(canRequestSuggest ? 1 : 0.5) } /// 关心条的统一渲染。`compact = true` 给键盘正上方那条(单行紧凑); @@ -373,23 +426,31 @@ struct DiaryQuickSheet: View { .disabled(!canRequestSuggest) case .thinking: - HStack(spacing: 8) { - Image(systemName: "sparkles") - .font(.tjScaled( compact ? 12 : 13, weight: .semibold)) - .foregroundStyle(Tj.Palette.brick) - .symbolEffect(.pulse, options: .repeating) - Text(lastRate > 0 - ? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate) - : String(appLoc: "康康在想想…")) - .font(.tjScaled( 13, weight: .medium)) - .foregroundStyle(Tj.Palette.text2) - Spacer(minLength: 0) - Button(action: cancelSuggestions) { - Text("停") - .font(.tjScaled( 12, weight: .semibold)) - .foregroundStyle(Tj.Palette.text3) + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.tjScaled( compact ? 12 : 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + .symbolEffect(.pulse, options: .repeating) + Text(lastRate > 0 + ? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate) + : String(appLoc: "康康在想想…")) + .font(.tjScaled( 13, weight: .medium)) + .foregroundStyle(Tj.Palette.text2) + Spacer(minLength: 0) + Button(action: cancelSuggestions) { + Text("停") + .font(.tjScaled( 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + } + // 「智能推理中」的彩色呼吸条(Apple 风格流光线,与语音整理 / 报告生成共用 AIFlowBar)。 + // 正文协作卡片宽度确定,放这里;keyboard toolbar(compact)里 GeometryReader 取不到 + // 稳定宽度,且 requestSuggestions 进推理已收键盘,thinking 必落在本卡片,无需 compact 版。 + if !compact { + AIFlowBar() } - .buttonStyle(.plain) } case .asking(let q): @@ -646,14 +707,17 @@ struct DiaryQuickSheet: View { /// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM, /// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。 - /// 关心条形态下**不收键盘**:让生成中的「康康在想想」就停在键盘正上方, - /// 出结果后直接在原位换成第一句追问,书写节奏不被打断。 + /// 进入推理即**收起键盘**:把舞台让给正文里的协作卡片,让「康康在想想」+ 彩色呼吸条 + /// 的本地推理过程完整可见(键盘挡住卡片时呼吸条就白跑了,正是「推理时没有呼吸条」的成因)。 + /// 出结果后点「记一下」会自动重新聚焦续写(recordCurrent 里 contentFocused = true), + /// 书写节奏照样接得上。 private func requestSuggestions() { suggestTask?.cancel() + contentFocused = false let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines) let covered = Array(coveredDims) exhaustedNote = false - phase = .loading + withAnimation(.snappy(duration: 0.2)) { phase = .loading } suggestTask = Task { @MainActor in do { let result = try await DiaryAssistService.shared.suggest( diff --git a/康康/Features/Me/InferenceSettingsView.swift b/康康/Features/Me/InferenceSettingsView.swift index 1c2adb8..ae3d8b1 100644 --- a/康康/Features/Me/InferenceSettingsView.swift +++ b/康康/Features/Me/InferenceSettingsView.swift @@ -43,63 +43,44 @@ struct InferenceSettingsView: View { .onAppear { modelService.refreshStates() } } - /// 性能自检入口:用当前选中的引擎跑固定 prompt,实测并按后端归档对比。 - /// 模型未就绪时显示「前往下载」提示而非死链。 + /// 性能自检入口:它是「动作/工具」而非引擎选择,所以做成描边动作按钮(TjGhostButton), + /// 从视觉类别上和上方引擎/状态卡区分开。模型未就绪时禁用并给出下载提示而非死链。 @ViewBuilder private var selfTestSection: some View { if modelReady { NavigationLink { ModelSelfTestView() } label: { - HStack(spacing: 12) { - ZStack { - Circle().fill(Tj.Palette.sand2) - Image(systemName: "gauge.with.needle") - .font(.tjScaled(18)) - .foregroundStyle(Tj.Palette.ink) - } - .frame(width: 44, height: 44) - VStack(alignment: .leading, spacing: 2) { - Text("性能自检") - .font(.tjScaled(15, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - Text("用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s") - .font(.tjScaled(12)) - .foregroundStyle(Tj.Palette.text3) - .lineLimit(2) - } - Spacer() - Image(systemName: "chevron.right") - .font(.tjScaled(13, weight: .semibold)) - .foregroundStyle(Tj.Palette.text3) - } - .padding(14) - .tjCard() - } - .buttonStyle(.plain) - } else { - HStack(spacing: 12) { - ZStack { - Circle().fill(Tj.Palette.sand2) + HStack(spacing: 8) { Image(systemName: "gauge.with.needle") - .font(.tjScaled(18)) - .foregroundStyle(Tj.Palette.text2) - } - .frame(width: 44, height: 44) - VStack(alignment: .leading, spacing: 2) { - Text("性能自检") .font(.tjScaled(15, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - Text("模型未就绪,前往「模型管理」下载后可用") - .font(.tjScaled(12)) - .foregroundStyle(Tj.Palette.text3) - .lineLimit(2) + Text("性能自检") + Image(systemName: "arrow.right") + .font(.tjScaled(13, weight: .semibold)) } - Spacer() + .frame(maxWidth: .infinity) } - .padding(14) - .tjCard() - .opacity(0.55) + .buttonStyle(TjGhostButton()) + .padding(.top, 4) + } else { + VStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "gauge.with.needle") + .font(.tjScaled(15, weight: .semibold)) + Text("性能自检") + } + .font(.tjScaled(15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) + .opacity(0.6) + + Text("模型未就绪,前往「模型管理」下载后可用") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(.top, 4) } } diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 37e6637..9e231c1 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -11938,6 +11938,50 @@ } } }, + "追踪" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracking" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推移" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추적" + } + } + } + }, + "记一笔" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가" + } + } + } + }, "跟随系统" : { "localizations" : { "en" : { diff --git a/康康/RootView.swift b/康康/RootView.swift index 1c97e51..639e287 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -8,7 +8,7 @@ enum TjTab: String, Hashable, CaseIterable { switch self { case .home: return String(appLoc: "主页") case .records: return String(appLoc: "记录") - case .trend: return String(appLoc: "趋势") + case .trend: return String(appLoc: "追踪") case .me: return String(appLoc: "我的") } } @@ -293,7 +293,7 @@ private struct TabBar: View { } .frame(width: slotHeight, height: slotHeight) - Text("新建") + Text("记一笔") .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } @@ -309,7 +309,7 @@ private struct TabBar: View { recordPressing = pressing } .accessibilityElement(children: .combine) - .accessibilityLabel("新建") + .accessibilityLabel("记一笔") .accessibilityHint("轻点打开新建菜单,长按语音直达") } } diff --git a/康康/Services/DiaryAssistService.swift b/康康/Services/DiaryAssistService.swift index c1f1797..4c71b2e 100644 --- a/康康/Services/DiaryAssistService.swift +++ b/康康/Services/DiaryAssistService.swift @@ -73,7 +73,9 @@ struct DiaryAssistService { for _ in 0..<2 { try Task.checkCancellation() var collected = "" - let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400) + // 512(原 400):3-4 条含 dim/q/fill 的中文问题 JSON 约 250-320 token,400 在 + // 模型偶尔加前导/换行时易把尾部那条问题截断 → 整体解析失败。留足余量更稳。 + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512) for try await chunk in stream { collected += chunk.text if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } @@ -116,6 +118,13 @@ struct DiaryAssistService { rawQuestions = arr } } + // ③ 终极兜底:外层包裹畸形 / 尾部被 maxTokens 截断时,①② 的整体解析都会失败。 + // 逐个抠出文本里所有平衡的 {…},凡含 "q" 字段的就当一条问题救回 —— 即便最后一条 + // 被截断,前面已闭合的几条仍能用。守 §3.2 / 红线 #5「失败回退,不让用户卡在 AI 错误屏」。 + if rawQuestions == nil { + let salvaged = salvageQuestionObjects(from: stripped) + if !salvaged.isEmpty { rawQuestions = salvaged } + } guard let rawQuestions else { return nil } return rawQuestions.compactMap { d -> Question? in @@ -131,6 +140,43 @@ struct DiaryAssistService { } } + /// 逐对象救回:扫描原始文本里【所有】平衡的 `{…}` 子串(含嵌套内层),逐个尝试解析, + /// 凡是含 `"q"` 字段的对象就当作一条问题收集。用于 ①② 整体解析失败的兜底: + /// - 外层 `{"questions":[…]}` 的 `}` 被截断 → 但内部前几条 question 对象已闭合,照样救回; + /// - 模型把对象拆在解释文字里 → 也能逐个抠出。 + /// 外层 wrapper 自身不含 `"q"`(只有 `"questions"`),天然被过滤掉,不会误收。 + /// 按 q 文本去重(保序),避免嵌套时同一对象被父子两层重复收集。 + private static func salvageQuestionObjects(from raw: String) -> [[String: Any]] { + var openStack: [String.Index] = [] + var collected: [[String: Any]] = [] + var seenQ = Set() + var inString = false + var escape = false + var idx = raw.startIndex + while idx < raw.endIndex { + let ch = raw[idx] + if escape { escape = false } + else if ch == "\\" { escape = true } + else if ch == "\"" { inString.toggle() } + else if !inString { + if ch == "{" { + openStack.append(idx) + } else if ch == "}", let open = openStack.popLast() { + let sub = CaptureService.repairJSON(String(raw[open...idx])) + if let data = sub.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let q = dict["q"] as? String, + !q.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + seenQ.insert(q).inserted { + collected.append(dict) + } + } + } + idx = raw.index(after: idx) + } + return collected + } + /// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。 /// 失败(模型未就绪 / 输出为空)抛错,调用方回退为直接使用原话,不卡死。 /// 与 suggest 同样走 AIRuntime actor 队列,自然与追问/拍照串行。 diff --git a/康康Tests/DiaryAssistParseTests.swift b/康康Tests/DiaryAssistParseTests.swift index c12947f..4c52906 100644 --- a/康康Tests/DiaryAssistParseTests.swift +++ b/康康Tests/DiaryAssistParseTests.swift @@ -57,4 +57,20 @@ struct DiaryAssistParseTests { // 整段都在思考、没吐 JSON 就被截断:strip 后为空 → nil(交给 suggest 重试) #expect(DiaryAssistService.parseQuestions(from: "嗯,用户写了头痛,我应该问") == nil) } + + @Test func salvagesTruncatedTailObject() { + // maxTokens 截断:外层 } 和最后一条 question 都没闭合,但前两条已完整。 + // ①② 整体解析必失败,③ 逐对象救回前两条 —— 用户仍拿到可用追问,不卡错误屏。 + let raw = #"{"questions":[{"dim":"起病诱因","q":"何时开始?","fill":"从[时间]"},{"dim":"症状性质","q":"哪种不适?","fill":"性质[]"},{"dim":"伴随症状","q":"还有别的不"# + let qs = DiaryAssistService.parseQuestions(from: raw) + #expect(qs?.count == 2) + #expect(qs?.first?.q == "何时开始?") + #expect(qs?.last?.dim == "症状性质") + } + + @Test func salvagesWhenWrapperKeyWrong() { + // 模型把外层 key 写错(items 而非 questions)且没吐裸数组:①② 都不命中 → ③ 抠内层对象救回 + let raw = #"{"items":[{"dim":"持续频率","q":"持续多久了?","fill":"已持续[时长]"}]}"# + #expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1) + } }