```
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(
|
||||
|
||||
Reference in New Issue
Block a user