import SwiftUI import SwiftData /// 「健康记录」录入 sheet。 /// 主体仍是 DiaryEntry @Model;UI/文案改为面向健康记录,并加 AI 辅助区: /// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。 /// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。 struct DiaryQuickSheet: View { /// 语音「写日记」直达:隐藏顶部 2×2 入口选择,进入即聚焦正文直接写。 /// 默认 false —— 从「新建」菜单进入时仍保留四选入口。 var directWrite: Bool = false @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @State private var content: String = "" @State private var createdAt: Date = .now /// 「拍药盒」分支:全屏扫描流程,识别后入药品库。 @State private var showMedicationScan = false /// 「用药」分支:记一次服用(选药 + 剂量 + 时间),存为带「用药」tag 的日记。 @State private var showMedicationLog = false /// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。 @State private var showSymptomStart = false /// AI 辅助状态 enum AssistPhase { case idle // 从未生成 case loading // 正在 LLM 调用 case ready // 有结果显示,等待下一轮 / 采纳 / 重试 case failed(Error) // 最近一次失败 } @State private var phase: AssistPhase = .idle @State private var questions: [DiaryAssistService.Question] = [] @State private var lastRate: Double = 0 @State private var currentRound: Int = 0 /// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。 @State private var coveredDims: Set = [] @State private var suggestTask: Task? /// 关心条里被用户「跳过」的 question id。跳过的不从 questions 里删(其维度已计入 /// coveredDims,下一轮不会再问),只是不再排进关心条队列。 @State private var skippedQuestionIDs: Set = [] /// 上一轮「再想想」没问出任何新维度(全被去重)时为 true,提示用户主要维度已问全。 @State private var exhaustedNote = false /// sheet detent。默认 large,确保建议面板有足够展示空间。 /// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。 @State private var detent: PresentationDetent = .large @FocusState private var contentFocused: Bool // MARK: 语音输入状态(spec 2026-06-10-voice-diary) enum VoicePhase: Equatable { case idle, recording, organizing } @State private var voicePhase: VoicePhase = .idle @State private var liveTranscript = "" @State private var recordingSeconds = 0 /// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。 @State private var rawTranscript: String? /// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。 /// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。 @State private var organizedAppended: String? /// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。 @State private var voiceNote: String? @State private var voiceDeniedAlert = false @State private var voiceFlowTask: Task? @State private var recordingWatchdog: Task? /// 必须 @State:struct View 重建(键盘收起/detent 变化都会触发)时普通 let 会换成 /// 全新实例,导致 stop() 落在没在录音的新服务上返回空串(「没听清」假错误), /// 且真正在录音的老实例关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一。 @State private var dictation = SpeechDictationService() private var hasContent: Bool { !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var hasQuestions: Bool { !questions.isEmpty } private var isLoading: Bool { if case .loading = phase { return true } return false } private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle } private var canSubmit: Bool { hasContent } // MARK: - 关心条(care bar)状态 /// 收敛后的「关心」状态:把 phase + 队列 + 语音占用,归并成一个枚举。 /// 键盘正上方的关心条与正文里的回落卡共用同一份状态,保证两处显示一致。 private enum CareState { case hidden // 没内容 / 语音占用,不打扰 case prompt // 有内容、还没生成,邀请用户让康康想想 case thinking // 正在本地推理 case asking(DiaryAssistService.Question) // 当前轮到要问的一句 case caughtUp(exhausted: Bool) // 队列已清空;exhausted=这轮没问出新东西 case failed(String) } /// 还没被采纳 / 跳过的问题队列。关心条一次只露出队首一句(其余按维度排队等着)。 private var pendingQuestions: [DiaryAssistService.Question] { questions.filter { !$0.adopted && !skippedQuestionIDs.contains($0.id) } } private var currentCareQuestion: DiaryAssistService.Question? { pendingQuestions.first } private var careState: CareState { if voicePhase != .idle { return .hidden } switch phase { case .loading: return .thinking case .failed(let err): return .failed(err.localizedDescription) case .idle: return hasContent ? .prompt : .hidden case .ready: if let q = currentCareQuestion { return .asking(q) } return hasContent ? .caughtUp(exhausted: exhaustedNote) : .hidden } } var body: some View { VStack(spacing: 0) { Capsule() .fill(Tj.Palette.line) .frame(width: 40, height: 4) .padding(.top, 10) .padding(.bottom, 14) HStack { VStack(alignment: .leading, spacing: 2) { Text("健康记录") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("记录身体状态 · 康康在一旁帮你想还能记点啥") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer() Text("本机保存") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) .padding(.bottom, 10) // 模式入口(写日记 / 用药 / 拍药盒 / 记症状)只在「还没动笔」时露出: // 一旦聚焦正文或开始打字就收起,把写日记界面让出来,保持清爽 //(对齐用药/症状那种打开即专一表单的观感)。 modeSelector .animation(.snappy(duration: 0.22), value: showModeSelector) ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { HStack { sectionLabel(String(appLoc: "内容")) Spacer() if SpeechDictationService.isAvailable, voicePhase == .idle { Button(action: startVoice) { HStack(spacing: 4) { Image(systemName: "mic.fill") .font(.tjScaled(11, weight: .semibold)) Text("说一段") .font(.tjScaled(12, weight: .semibold)) } .foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Capsule().strokeBorder( isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5), lineWidth: 1)) .contentShape(Capsule()) } .buttonStyle(.plain) .disabled(isLoading) // AI 追问生成中不抢 AIRuntime 队列 } } TextField("今天身体怎么样?吃了什么药、有什么感觉?", text: $content, axis: .vertical) .lineLimit(3...8) .focused($contentFocused) .onChange(of: content) { _, _ in exhaustedNote = false } .padding(.horizontal, 14) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) // ①「关心条」主舞台:贴键盘正上方,随写随冒、一次只问一句。 .toolbar { ToolbarItemGroup(placement: .keyboard) { careBarRow(compact: true) } } if voicePhase != .idle { DiaryVoicePanel( mode: voicePhase == .organizing ? .organizing : .recording(elapsedSeconds: recordingSeconds), transcript: liveTranscript, onStop: stopVoiceAndOrganize, onCancelOrganize: cancelOrganize ) } if let note = voiceNote { HStack(spacing: 6) { Image(systemName: "info.circle") .font(.tjScaled(11)) .foregroundStyle(Tj.Palette.text3) Text(note) .font(.tjScaled(11)) .foregroundStyle(Tj.Palette.text3) Spacer(minLength: 0) } } if let organized = organizedAppended, rawTranscript != nil, content.range(of: organized) != nil { Button(action: revertToRawTranscript) { HStack(spacing: 4) { Image(systemName: "arrow.uturn.backward") .font(.tjScaled(10, weight: .semibold)) Text("改用原话") .font(.tjScaled(11, weight: .semibold)) } .foregroundStyle(Tj.Palette.ink) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) .contentShape(Capsule()) } .buttonStyle(.plain) } } assistSection VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "时间")) DatePicker("", selection: $createdAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() } } .padding(.horizontal, 20) .padding(.bottom, 6) } .scrollDismissesKeyboard(.interactively) HStack(spacing: 12) { Button("取消") { dismiss() } .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18)) Button("保存") { submit() } .buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18)) .disabled(!canSubmit) .opacity(canSubmit ? 1 : 0.4) } .padding(.horizontal, 20) .padding(.vertical, 14) } .background( Tj.Palette.sand .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) .ignoresSafeArea(edges: .bottom) ) .presentationDetents([.medium, .large], selection: $detent) .presentationDragIndicator(.hidden) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) .fullScreenCover(isPresented: $showMedicationScan) { MedicationScanFlow( onSave: { meds, images in // 识别后入药品库(含原图),不再写日记。服用流水走「写日记 · 用药」模式。 MedicationArchiver.archive(medications: meds, images: images, in: ctx) dismiss() }, onClose: { showMedicationScan = false } ) } .sheet(isPresented: $showSymptomStart) { // 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。 SymptomStartSheet() } .sheet(isPresented: $showMedicationLog) { // 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。 MedicationLogSheet() } .onAppear { // 语音「写日记」直达:进入即聚焦正文,光标直接落在输入框,免去再点一下。 // 轻微延迟等 sheet 呈现动画落定,否则首帧聚焦偶发不弹键盘。 guard directWrite else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { contentFocused = true } } .onDisappear { suggestTask?.cancel() voiceFlowTask?.cancel() recordingWatchdog?.cancel() dictation.abort() } .alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) { Button(String(appLoc: "前往设置")) { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } Button(String(appLoc: "取消"), role: .cancel) {} } message: { Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。") } } // MARK: - 关心条(care bar)视图 /// 正文里的「回落卡」:键盘收起时(关心条随键盘一起消失)在这里接住同一份 careState, /// 并承载 AI 免责声明。键盘弹起时整块让位给键盘正上方的关心条,避免两处重复。 @ViewBuilder private var assistSection: some View { VStack(alignment: .leading, spacing: 10) { if !contentFocused { if case .hidden = careState { 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) } } 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 后,免责声明常驻(键盘弹起时关心条在上方,这条留在正文里兜合规)。 if !questions.isEmpty { AIDisclaimerFooter() } } } /// 关心条的统一渲染。`compact = true` 给键盘正上方那条(单行紧凑); /// `compact = false` 给正文回落卡(问题可换两行、留白更松)。两处共用同一 careState 与动作。 @ViewBuilder private func careBarRow(compact: Bool) -> some View { switch careState { case .hidden: EmptyView() case .prompt: Button(action: requestSuggestions) { careCapsule(icon: "sparkles", text: String(appLoc: "让康康帮你把这条记得更全"), tint: Tj.Palette.brick, style: .soft, compact: compact) } .buttonStyle(.plain) .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) } .buttonStyle(.plain) } 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) } case .caughtUp(let exhausted): Button(action: requestSuggestions) { careCapsule( icon: exhausted ? "checkmark.seal.fill" : "sparkles", text: exhausted ? String(appLoc: "主要的都帮你问到啦 · 再想想?") : String(appLoc: "还想到几个想问你 · 再来一轮"), tint: exhausted ? Tj.Palette.leaf : Tj.Palette.brick, style: .soft, compact: compact) } .buttonStyle(.plain) .disabled(!canRequestSuggest) case .failed(let msg): HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.brick) Text(msg) .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) Spacer(minLength: 0) Button(action: requestSuggestions) { Text("重试") .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } .buttonStyle(.plain) } } } private enum CareCapsuleStyle { case filled, soft } /// 关心条里的胶囊。filled = 实心(主动作「记一下」);soft = 浅色底(邀请类)。 private func careCapsule(icon: String, text: String, tint: Color, style: CareCapsuleStyle, compact: Bool) -> some View { HStack(spacing: 5) { Image(systemName: icon) .font(.tjScaled( compact ? 11 : 12, weight: .semibold)) Text(text) .font(.tjScaled( compact ? 12 : 13, weight: .semibold)) .lineLimit(1) } .foregroundStyle(style == .filled ? Tj.Palette.paper : tint) .padding(.horizontal, 12) .padding(.vertical, 7) .background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12))) .contentShape(Capsule()) } // MARK: - Actions private func sectionLabel(_ text: String) -> some View { Text(text) .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } /// 顶部模式入口仅在「未直达写日记 + 正文未聚焦 + 未输入任何内容」时展示。 /// 用户一旦点进正文 / 开始写,就收起入口,留出清爽的写作面。 private var showModeSelector: Bool { !directWrite && !contentFocused && !hasContent } /// 模式入口(2×2):写日记(本页)/ 用药(记剂量+时间)/ 拍药盒(识别入药品库)/ 记症状。 @ViewBuilder private var modeSelector: some View { if showModeSelector { LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10) { modeCard(icon: "pencil", title: String(appLoc: "写日记"), subtitle: String(appLoc: "文字或语音"), active: true) { contentFocused = true } modeCard(icon: "pills.fill", title: String(appLoc: "用药"), subtitle: String(appLoc: "记剂量与时间"), active: false) { showMedicationLog = true } modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"), subtitle: String(appLoc: "识别入药品库"), active: false) { showMedicationScan = true } modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"), subtitle: String(appLoc: "持续追踪"), active: false) { showSymptomStart = true } } .padding(.horizontal, 20) .padding(.bottom, 14) .transition(.opacity.combined(with: .move(edge: .top))) } } /// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。 /// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。 private func modeCard(icon: String, title: String, subtitle: String, active: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: 5) { Image(systemName: icon) .font(.tjScaled( 15, weight: .medium)) .foregroundStyle(active ? Tj.Palette.paper : Tj.Palette.ink) .frame(width: 28, height: 28) .background(Circle().fill(active ? Tj.Palette.ink : Tj.Palette.sand2)) Text(title) .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text(subtitle) .font(.tjScaled( 10)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(active ? Tj.Palette.ink : Tj.Palette.line, lineWidth: active ? 1.5 : 1) ) .contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) } .buttonStyle(.plain) } // MARK: 语音输入流程 private func startVoice() { contentFocused = false voiceNote = nil voiceFlowTask = Task { @MainActor in guard await dictation.requestAuthorization() else { voiceDeniedAlert = true return } do { liveTranscript = "" recordingSeconds = 0 try dictation.start { partial in liveTranscript = partial } withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording } // 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致) recordingWatchdog = Task { @MainActor in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) guard !Task.isCancelled, voicePhase == .recording else { return } recordingSeconds += 1 if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds { stopVoiceAndOrganize() return } } } } catch { voiceNote = error.localizedDescription voicePhase = .idle } } } private func stopVoiceAndOrganize() { guard voicePhase == .recording else { return } recordingWatchdog?.cancel() voiceFlowTask = Task { @MainActor in // 防御兜底:服务返回空(极端情况下实例丢失/最终结果丢失)时, // 用 @State 里的实时字幕——那就是用户亲眼看到的已识别文字。 var transcript = (await dictation.stop()) .trimmingCharacters(in: .whitespacesAndNewlines) if transcript.isEmpty { transcript = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines) } liveTranscript = transcript guard !transcript.isEmpty else { withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle } voiceNote = String(appLoc: "没听清,再试一次") return } rawTranscript = transcript withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing } do { let result = try await DiaryAssistService.shared.organize(transcript: transcript) guard !Task.isCancelled else { return } appendToContent(result.text) organizedAppended = result.text lastRate = result.decodeRate } catch is CancellationError { // cancelOrganize 已处理回退,这里只收尾 } catch { guard !Task.isCancelled else { return } appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死 organizedAppended = nil voiceNote = String(appLoc: "AI 整理失败,已填入原话") } withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle } } } /// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。 private func cancelOrganize() { guard voicePhase == .organizing else { return } voiceFlowTask?.cancel() if let raw = rawTranscript { appendToContent(raw) organizedAppended = nil voiceNote = String(appLoc: "已取消整理,填入原话") } withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle } } /// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。 private func revertToRawTranscript() { guard let raw = rawTranscript, let organized = organizedAppended, let range = content.range(of: organized, options: .backwards) else { return } withAnimation(.snappy(duration: 0.18)) { content = content.replacingCharacters(in: range, with: raw) organizedAppended = nil } } /// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM, /// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。 /// 关心条形态下**不收键盘**:让生成中的「康康在想想」就停在键盘正上方, /// 出结果后直接在原位换成第一句追问,书写节奏不被打断。 private func requestSuggestions() { suggestTask?.cancel() let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines) let covered = Array(coveredDims) exhaustedNote = false phase = .loading suggestTask = Task { @MainActor in do { let result = try await DiaryAssistService.shared.suggest( content: snapshotContent, coveredDimensions: covered ) if Task.isCancelled { return } // 客户端硬去重(不依赖 1.7B 听话): // ① 维度已在往轮覆盖 → 丢;② 本轮内维度重复 → 丢;③ 文本与已有近似 → 丢。 let coveredSnapshot = coveredDims var acceptedNorms = questions.map { Self.normalize($0.q) } var batchDims = Set() let nextRound = currentRound + 1 let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines) let norm = Self.normalize(q.q) if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil } if !dim.isEmpty, batchDims.contains(dim) { return nil } if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil } if !dim.isEmpty { batchDims.insert(dim) } acceptedNorms.append(norm) var stamped = q stamped.round = nextRound return stamped } withAnimation(.snappy(duration: 0.2)) { if fresh.isEmpty { exhaustedNote = true // 这轮没问出任何新维度 } else { questions.append(contentsOf: fresh) for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) } currentRound = nextRound exhaustedNote = false } lastRate = result.decodeRate phase = .ready } } catch is CancellationError { if !Task.isCancelled { phase = hasQuestions ? .ready : .idle } } catch { if !Task.isCancelled { phase = .failed(error) } } } } /// 简单归一化:去空白 + 折叠成统一形式,用于客户端去重比对。 private static func normalize(_ s: String) -> String { s.trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: " ", with: "") .replacingOccurrences(of: "?", with: "?") } /// 近似判重:归一化后相等,或字符集 Jaccard ≥ 0.8(抓「会/下」这类换一两字的重复)。 private static func isSimilar(_ a: String, _ b: String) -> Bool { if a == b { return true } let sa = Set(a), sb = Set(b) guard !sa.isEmpty, !sb.isEmpty else { return false } let inter = sa.intersection(sb).count let union = sa.union(sb).count return union > 0 && Double(inter) / Double(union) >= 0.8 } private func cancelSuggestions() { suggestTask?.cancel() 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) if let idx = questions.firstIndex(where: { $0.id == question.id }) { questions[idx].adopted = true } // 落字后把键盘留住:用户顺势接着写,关心条已切到下一句。 contentFocused = true } /// 「跳过」:这句先不记,关心条滑到下一句。该维度已在生成时计入 coveredDims, /// 下一轮 prompt 不会再问它,所以跳过的不必从 questions 里删。 private func skipCurrent(_ question: DiaryAssistService.Question) { skippedQuestionIDs.insert(question.id) } /// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。 private func appendToContent(_ text: String) { let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !toAppend.isEmpty else { return } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { content = toAppend } else if content.hasSuffix("\n") { content += toAppend } else { content += "\n" + toAppend } } private func submit() { guard canSubmit else { return } let entry = DiaryEntry( content: content.trimmingCharacters(in: .whitespacesAndNewlines), createdAt: createdAt ) ctx.insert(entry) try? ctx.save() dismiss() } } #Preview { DiaryQuickSheet() }