feat(DiaryQuickSheet): 添加AI追问问答功能和底部协作入口

- 新增currentAnswer状态管理追问输入,添加answerFocused状态独立处理键盘避让
- 移除键盘工具条的关心条,将AI协作入口固定到底部按钮
- 添加完整的问答式追问卡片组件,支持自由回答输入和加入日记功能
- 修改prompt阶段行为,不再在正文区显示邀请横幅
- 更新recordCurrent为answerCurrent,实现问题+答案一同加入日记的逻辑
- 调整底部操作栏布局,间距和内边距优化

refactor(InferenceSettingsView): 性能自检改为内联展开模式

- 将性能自检视图从导航链接改为当前页就地展开
- 添加showSelfTest状态控制展开收起动画
- 支持ModelSelfTestView内联嵌入模式,去除外层导航和背景

chore(Localizable): 同步更新本地化字符串资源

- 添加新的UI文本:加入日记、在这儿写下你的回答、康康帮你一起填等
- 修复部分字符串位置调整和翻译映射问题
- 同步更新多语言版本的翻译内容

style(RootView): 优化记一笔标签页视觉设计

- 为记一笔标签添加语音识别角标标识
- 使用麦克风图标配合加号突出长按语音直达功能
```
This commit is contained in:
link2026
2026-06-17 10:05:32 +08:00
parent abacf5c4f5
commit 30f75dc2cd
5 changed files with 278 additions and 190 deletions

View File

@@ -31,6 +31,8 @@ struct DiaryQuickSheet: View {
}
@State private var phase: AssistPhase = .idle
@State private var questions: [DiaryAssistService.Question] = []
/// ()
@State private var currentAnswer: String = ""
@State private var lastRate: Double = 0
@State private var currentRound: Int = 0
/// (question.dim), prompt
@@ -45,6 +47,9 @@ struct DiaryQuickSheet: View {
/// medium,()
@State private var detent: PresentationDetent = .large
@FocusState private var contentFocused: Bool
/// contentFocused :,
/// ,
@FocusState private var answerFocused: Bool
// MARK: (spec 2026-06-10-voice-diary)
@@ -184,12 +189,6 @@ struct DiaryQuickSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
// :,
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
careBarRow(compact: true)
}
}
if voicePhase != .idle {
DiaryVoicePanel(
@@ -248,11 +247,39 @@ struct DiaryQuickSheet: View {
}
.scrollDismissesKeyboard(.interactively)
HStack(spacing: 12) {
HStack(spacing: 8) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
// :AI ,(spec 2026-06-17)
// flexible ;
Button(action: requestSuggestions) {
HStack(spacing: 5) {
Image(systemName: "sparkles")
.font(.tjScaled( 13, weight: .semibold))
.symbolEffect(.pulse, options: .repeating, isActive: isLoading)
Text(isLoading ? String(appLoc: "想想中…") : String(appLoc: "康康帮你一起填"))
.font(.tjScaled( 14, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.foregroundStyle(canRequestSuggest ? Tj.Palette.brick : Tj.Palette.text3)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick.opacity(canRequestSuggest ? 0.12 : 0.05))
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.brick.opacity(canRequestSuggest ? 0.4 : 0.15),
lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
Button("保存") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
.disabled(!canSubmit)
.opacity(canSubmit ? 1 : 0.4)
}
@@ -321,12 +348,10 @@ struct DiaryQuickSheet: View {
VStack(alignment: .leading, spacing: 10) {
if !contentFocused {
switch careState {
case .hidden:
case .hidden, .prompt:
// prompt():AI
//,
EmptyView()
case .prompt:
// : banner(,,
// )
promptBanner
default:
// ( / / / ): + tok/s +
VStack(alignment: .leading, spacing: 10) {
@@ -365,49 +390,6 @@ struct DiaryQuickSheet: View {
.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 = false` () careState
@ViewBuilder
@@ -454,28 +436,8 @@ struct DiaryQuickSheet: View {
}
case .asking(let q):
HStack(spacing: 10) {
Image(systemName: "text.bubble.fill")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(q.q)
.font(.tjScaled( compact ? 13 : 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(compact ? 1 : 2)
.fixedSize(horizontal: false, vertical: !compact)
Spacer(minLength: 6)
Button { skipCurrent(q) } label: {
Text("跳过")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
Button { recordCurrent(q) } label: {
careCapsule(icon: "plus", text: String(appLoc: "记一下"),
tint: Tj.Palette.ink, style: .filled, compact: compact)
}
.buttonStyle(.plain)
}
// ,asking
askingCard(q)
case .caughtUp(let exhausted):
Button(action: requestSuggestions) {
@@ -512,7 +474,7 @@ struct DiaryQuickSheet: View {
private enum CareCapsuleStyle { case filled, soft }
/// filled = ();soft = ()
/// filled = ;soft = (,prompt / caughtUp )
private func careCapsule(icon: String, text: String, tint: Color,
style: CareCapsuleStyle, compact: Bool) -> some View {
HStack(spacing: 5) {
@@ -529,6 +491,80 @@ struct DiaryQuickSheet: View {
.contentShape(Capsule())
}
/// ( / )
private var answerTrimmed: String {
currentAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func answerPlaceholder(for q: DiaryAssistService.Question) -> String {
q.dim.isEmpty
? String(appLoc: "在这儿写下你的回答…")
: String(appLoc: "说说「\(q.dim)」的情况…")
}
/// ():
/// · :(spec 2026-06-17)
@ViewBuilder
private func askingCard(_ q: DiaryAssistService.Question) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "text.bubble.fill")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(q.q)
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 6)
if pendingQuestions.count > 1 {
Text(String(format: String(appLoc: "还有 %d 个"), pendingQuestions.count - 1))
.font(.tjScaled( 10, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
}
TextField(answerPlaceholder(for: q), text: $currentAnswer, axis: .vertical)
.font(.tjScaled( 13))
.lineLimit(1...4)
.focused($answerFocused)
.submitLabel(.done)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(answerFocused ? Tj.Palette.brick.opacity(0.5) : Tj.Palette.line,
lineWidth: 1)
)
HStack(spacing: 10) {
Button { skipCurrent(q) } label: {
Text("跳过这条")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
Spacer(minLength: 0)
Button { answerCurrent(q) } label: {
HStack(spacing: 5) {
Image(systemName: "text.append")
.font(.tjScaled( 12, weight: .semibold))
Text("加入日记")
.font(.tjScaled( 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Capsule().fill(answerTrimmed.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(answerTrimmed.isEmpty)
}
}
}
// MARK: - Actions
private func sectionLabel(_ text: String) -> some View {
@@ -709,11 +745,12 @@ struct DiaryQuickSheet: View {
/// ,
/// ****:,+
/// (,)
/// (recordCurrent contentFocused = true),
///
/// :
///· :(answerCurrent),
private func requestSuggestions() {
suggestTask?.cancel()
contentFocused = false
answerFocused = false
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims)
exhaustedNote = false
@@ -789,25 +826,30 @@ struct DiaryQuickSheet: View {
phase = hasQuestions ? .ready : .idle
}
/// :,,
/// `assemble(values: [])` 退
/// 便, `[]`
private func recordCurrent(_ question: DiaryAssistService.Question) {
let stub = question.fill.isEmpty
? question.q
: DiaryFillTemplate.assemble(question.fill, values: [])
appendToContent(stub)
/// :· :,
/// ,(,)
/// question.dim(//); dim
/// 退· +(spec 2026-06-17)
private func answerCurrent(_ question: DiaryAssistService.Question) {
let answer = currentAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !answer.isEmpty else { return }
let dim = question.dim.trimmingCharacters(in: .whitespacesAndNewlines)
appendToContent(dim.isEmpty ? "· \(answer)" : "· \(dim):\(answer)")
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
questions[idx].adopted = true
}
// :,
contentFocused = true
currentAnswer = ""
// ;,
if pendingQuestions.isEmpty { answerFocused = false }
}
/// :, coveredDims,
/// prompt , questions
/// :, coveredDims,
/// prompt , questions ;
/// ()
private func skipCurrent(_ question: DiaryAssistService.Question) {
skippedQuestionIDs.insert(question.id)
currentAnswer = ""
if pendingQuestions.isEmpty { answerFocused = false }
}
/// (,)

View File

@@ -5,6 +5,8 @@ import SwiftUI
struct InferenceSettingsView: View {
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
@State private var modelService = ModelDownloadService.shared
/// , push
@State private var showSelfTest = false
private var selected: EnginePreference {
EnginePreference(rawValue: engineRaw) ?? .auto
@@ -48,19 +50,27 @@ struct InferenceSettingsView: View {
@ViewBuilder
private var selfTestSection: some View {
if modelReady {
NavigationLink {
ModelSelfTestView()
} label: {
HStack(spacing: 8) {
Image(systemName: "gauge.with.needle")
.font(.tjScaled(15, weight: .semibold))
Text("性能自检")
Image(systemName: "arrow.right")
.font(.tjScaled(13, weight: .semibold))
VStack(spacing: 12) {
Button {
withAnimation(.easeInOut(duration: 0.22)) { showSelfTest.toggle() }
} label: {
HStack(spacing: 8) {
Image(systemName: "gauge.with.needle")
.font(.tjScaled(15, weight: .semibold))
Text("性能自检")
Image(systemName: "chevron.down")
.font(.tjScaled(13, weight: .semibold))
.rotationEffect(.degrees(showSelfTest ? 180 : 0))
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
if showSelfTest {
ModelSelfTestView(embedded: true)
.transition(.opacity.combined(with: .move(edge: .top)))
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
.padding(.top, 4)
} else {
VStack(spacing: 8) {

View File

@@ -33,45 +33,55 @@ struct ModelSelfTestView: View {
}
}
/// :, ScrollView / /
var embedded = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
promptCard
HStack {
Text(phase.label)
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(statusColor)
.lineLimit(1)
Spacer()
if rate > 0 {
Text(String(format: "%.1f tok/s", rate))
.font(.tjScaled( 12, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
Button {
Task { await run() }
} label: {
Text(isBusy ? "运行中…" : "运行性能自检").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(isBusy)
if isBusy { AIFlowBar() }
if let r = lastResult { statsCard(r) }
outputCard
if !history.isEmpty { historyCard }
if embedded {
content
} else {
ScrollView {
content.padding(16)
}
.padding(16)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("性能自检")
.navigationBarTitleDisplayMode(.inline)
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 16) {
promptCard
HStack {
Text(phase.label)
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(statusColor)
.lineLimit(1)
Spacer()
if rate > 0 {
Text(String(format: "%.1f tok/s", rate))
.font(.tjScaled( 12, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
Button {
Task { await run() }
} label: {
Text(isBusy ? "运行中…" : "运行性能自检").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(isBusy)
if isBusy { AIFlowBar() }
if let r = lastResult { statsCard(r) }
outputCard
if !history.isEmpty { historyCard }
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("性能自检")
.navigationBarTitleDisplayMode(.inline)
.onAppear { history = BenchmarkService.load() }
}

View File

@@ -3806,6 +3806,9 @@
}
}
}
},
"加入日记" : {
},
"加入记录" : {
@@ -4530,6 +4533,9 @@
}
}
}
},
"在这儿写下你的回答…" : {
},
"在这里输入主诉……" : {
"extractionState" : "stale",
@@ -5887,6 +5893,9 @@
},
"康康在想想…" : {
},
"康康帮你一起填" : {
},
"康康帮你记" : {
@@ -6466,6 +6475,9 @@
}
}
}
},
"想想中…" : {
},
"慢性肾病" : {
"localizations" : {
@@ -10034,9 +10046,6 @@
}
}
}
},
"用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s" : {
},
"用于自动判定 正常/偏高/偏低" : {
"localizations" : {
@@ -11211,8 +11220,27 @@
"让康康帮你把这条记得更全" : {
},
"记一" : {
"记一" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "追加"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "추가"
}
}
}
},
"记剂量与时间" : {
@@ -11720,6 +11748,9 @@
},
"说完了,整理成日记" : {
},
"说说「%@」的情况…" : {
},
"说说你想给医生看什么" : {
"extractionState" : "stale",
@@ -11938,50 +11969,6 @@
}
}
},
"追踪" : {
"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" : {
@@ -12005,6 +11992,7 @@
}
},
"跳过" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -12028,6 +12016,9 @@
},
"跳过 · 手动录入" : {
},
"跳过这条" : {
},
"身体档案" : {
"localizations" : {
@@ -12343,6 +12334,9 @@
},
"还想到几个想问你 · 再来一轮" : {
},
"还有 %d 个" : {
},
"还没有任何记录\n点底部 + 号开始" : {
"localizations" : {
@@ -12575,6 +12569,28 @@
}
}
},
"追踪" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tracking"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "推移"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "추적"
}
}
}
},
"通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明" : {
"localizations" : {
"en" : {

View File

@@ -292,6 +292,16 @@ private struct TabBar: View {
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: slotHeight, height: slotHeight)
// :, +
.overlay(alignment: .bottomTrailing) {
Image(systemName: "mic.fill")
.font(.tjScaled( 8, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 15, height: 15)
.background(Circle().fill(Tj.Palette.brick))
.overlay(Circle().strokeBorder(Tj.Palette.paper, lineWidth: 1.5))
.offset(x: 3, y: 2)
}
Text("记一笔")
.font(.tjScaled( 11, weight: .semibold))