```
feat(diary): 优化日记AI协助交互体验
- 添加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错误情况的恢复测试,确保模型输出格式异常时的容错性
```
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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("轻点打开新建菜单,长按语音直达")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>()
|
||||
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 队列,自然与追问/拍照串行。
|
||||
|
||||
@@ -57,4 +57,20 @@ struct DiaryAssistParseTests {
|
||||
// 整段都在思考、没吐 JSON 就被截断:strip 后为空 → nil(交给 suggest 重试)
|
||||
#expect(DiaryAssistService.parseQuestions(from: "<think>嗯,用户写了头痛,我应该问") == 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user