```
feat(DiaryQuickSheet): 添加AI追问问答功能和底部协作入口 - 新增currentAnswer状态管理追问输入,添加answerFocused状态独立处理键盘避让 - 移除键盘工具条的关心条,将AI协作入口固定到底部按钮 - 添加完整的问答式追问卡片组件,支持自由回答输入和加入日记功能 - 修改prompt阶段行为,不再在正文区显示邀请横幅 - 更新recordCurrent为answerCurrent,实现问题+答案一同加入日记的逻辑 - 调整底部操作栏布局,间距和内边距优化 refactor(InferenceSettingsView): 性能自检改为内联展开模式 - 将性能自检视图从导航链接改为当前页就地展开 - 添加showSelfTest状态控制展开收起动画 - 支持ModelSelfTestView内联嵌入模式,去除外层导航和背景 chore(Localizable): 同步更新本地化字符串资源 - 添加新的UI文本:加入日记、在这儿写下你的回答、康康帮你一起填等 - 修复部分字符串位置调整和翻译映射问题 - 同步更新多语言版本的翻译内容 style(RootView): 优化记一笔标签页视觉设计 - 为记一笔标签添加语音识别角标标识 - 使用麦克风图标配合加号突出长按语音直达功能 ```
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
||||
|
||||
Reference in New Issue
Block a user