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:
link2026
2026-06-17 09:21:47 +08:00
parent 52db6fb85a
commit abacf5c4f5
6 changed files with 244 additions and 93 deletions

View File

@@ -320,9 +320,16 @@ struct DiaryQuickSheet: View {
private var assistSection: some View { private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if !contentFocused { if !contentFocused {
if case .hidden = careState { switch careState {
case .hidden:
EmptyView() EmptyView()
} else { case .prompt:
// : banner(,,
// )
promptBanner
default:
// ( / / / ): + tok/s +
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.tjScaled( 11, weight: .semibold)) .font(.tjScaled( 11, weight: .semibold))
@@ -348,11 +355,57 @@ struct DiaryQuickSheet: View {
) )
} }
} }
}
// AI ,(,) // AI ,(,)
if !questions.isEmpty { if !questions.isEmpty {
AIDisclaimerFooter() 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` (); /// `compact = true` ();
@@ -373,6 +426,7 @@ struct DiaryQuickSheet: View {
.disabled(!canRequestSuggest) .disabled(!canRequestSuggest)
case .thinking: case .thinking:
VStack(alignment: .leading, spacing: 9) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold)) .font(.tjScaled( compact ? 12 : 13, weight: .semibold))
@@ -391,6 +445,13 @@ struct DiaryQuickSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
// (Apple 线, / AIFlowBar)
// ,;keyboard toolbar(compact) GeometryReader
// , requestSuggestions ,thinking , compact
if !compact {
AIFlowBar()
}
}
case .asking(let q): case .asking(let q):
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -646,14 +707,17 @@ struct DiaryQuickSheet: View {
/// AI (coveredDims) LLM, /// AI (coveredDims) LLM,
/// , /// ,
/// ****:, /// ****:,+
/// , /// (,)
/// (recordCurrent contentFocused = true),
///
private func requestSuggestions() { private func requestSuggestions() {
suggestTask?.cancel() suggestTask?.cancel()
contentFocused = false
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines) let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims) let covered = Array(coveredDims)
exhaustedNote = false exhaustedNote = false
phase = .loading withAnimation(.snappy(duration: 0.2)) { phase = .loading }
suggestTask = Task { @MainActor in suggestTask = Task { @MainActor in
do { do {
let result = try await DiaryAssistService.shared.suggest( let result = try await DiaryAssistService.shared.suggest(

View File

@@ -43,63 +43,44 @@ struct InferenceSettingsView: View {
.onAppear { modelService.refreshStates() } .onAppear { modelService.refreshStates() }
} }
/// : prompt, /// :/,(TjGhostButton),
/// /// /
@ViewBuilder @ViewBuilder
private var selfTestSection: some View { private var selfTestSection: some View {
if modelReady { if modelReady {
NavigationLink { NavigationLink {
ModelSelfTestView() ModelSelfTestView()
} label: { } label: {
HStack(spacing: 12) { HStack(spacing: 8) {
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: "gauge.with.needle") 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)) .font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text) Text("性能自检")
Text("用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s") Image(systemName: "arrow.right")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
}
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled(13, weight: .semibold)) .font(.tjScaled(13, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
} }
.padding(14) .frame(maxWidth: .infinity)
.tjCard()
} }
.buttonStyle(.plain) .buttonStyle(TjGhostButton())
.padding(.top, 4)
} else { } else {
HStack(spacing: 12) { VStack(spacing: 8) {
ZStack { HStack(spacing: 8) {
Circle().fill(Tj.Palette.sand2)
Image(systemName: "gauge.with.needle") 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)) .font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text) 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("模型未就绪,前往「模型管理」下载后可用") Text("模型未就绪,前往「模型管理」下载后可用")
.font(.tjScaled(12)) .font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
} }
Spacer() .padding(.top, 4)
}
.padding(14)
.tjCard()
.opacity(0.55)
} }
} }

View File

@@ -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" : { "localizations" : {
"en" : { "en" : {

View File

@@ -8,7 +8,7 @@ enum TjTab: String, Hashable, CaseIterable {
switch self { switch self {
case .home: return String(appLoc: "主页") case .home: return String(appLoc: "主页")
case .records: return String(appLoc: "记录") case .records: return String(appLoc: "记录")
case .trend: return String(appLoc: "趋势") case .trend: return String(appLoc: "追踪")
case .me: return String(appLoc: "我的") case .me: return String(appLoc: "我的")
} }
} }
@@ -293,7 +293,7 @@ private struct TabBar: View {
} }
.frame(width: slotHeight, height: slotHeight) .frame(width: slotHeight, height: slotHeight)
Text("新建") Text("记一笔")
.font(.tjScaled( 11, weight: .semibold)) .font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.ink) .foregroundStyle(Tj.Palette.ink)
} }
@@ -309,7 +309,7 @@ private struct TabBar: View {
recordPressing = pressing recordPressing = pressing
} }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel("新建") .accessibilityLabel("记一笔")
.accessibilityHint("轻点打开新建菜单,长按语音直达") .accessibilityHint("轻点打开新建菜单,长按语音直达")
} }
} }

View File

@@ -73,7 +73,9 @@ struct DiaryAssistService {
for _ in 0..<2 { for _ in 0..<2 {
try Task.checkCancellation() try Task.checkCancellation()
var collected = "" 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 { for try await chunk in stream {
collected += chunk.text collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
@@ -116,6 +118,13 @@ struct DiaryAssistService {
rawQuestions = arr 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 } guard let rawQuestions else { return nil }
return rawQuestions.compactMap { d -> Question? in 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) /// 稿稿(spec 2026-06-10-voice-diary)
/// ( / ),退使, /// ( / ),退使,
/// suggest AIRuntime actor ,/ /// suggest AIRuntime actor ,/

View File

@@ -57,4 +57,20 @@ struct DiaryAssistParseTests {
// JSON :strip nil( suggest ) // JSON :strip nil( suggest )
#expect(DiaryAssistService.parseQuestions(from: "<think>嗯,用户写了头痛,我应该问") == nil) #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)
}
} }