import SwiftUI import SwiftData /// 「健康记录」录入 sheet。 /// 主体仍是 DiaryEntry @Model;UI/文案改为面向健康记录,并加 AI 辅助区: /// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。 /// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。 struct DiaryQuickSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @State private var content: String = "" @State private var createdAt: Date = .now /// 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;nil = 没有展开的填空面板。 @State private var fillingId: UUID? /// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。 @State private var fillValues: [String] = [] /// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。 @State private var exhaustedNote = false /// sheet detent。默认 large,确保建议面板有足够展示空间。 /// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。 @State private var detent: PresentationDetent = .large @FocusState private var contentFocused: Bool 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 } private var canSubmit: Bool { hasContent } 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("记录身体状态 · 可让 AI 多轮辅助查漏补缺") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) } Spacer() Text("本机保存") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) .padding(.bottom, 14) ScrollViewReader { proxy in ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "内容")) 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) ) } assistSection VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "时间")) DatePicker("", selection: $createdAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() } // 底部锚点,新一轮 question 进来后自动滚到这里 Color.clear.frame(height: 1).id("assist-bottom") } .padding(.horizontal, 20) .padding(.bottom, 6) } .scrollDismissesKeyboard(.interactively) .onChange(of: questions.count) { old, new in guard new > old else { return } // 滚到新一轮的 round divider(让用户先看到「第 N 轮」的标签, // 再依次看到这一轮的 questions) let roundId = "round-\(questions[old].round)" DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { withAnimation(.easeOut(duration: 0.25)) { proxy.scrollTo(roundId, anchor: .top) } } } } 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) .onDisappear { suggestTask?.cancel() } } // MARK: - AI 辅助区 @ViewBuilder private var assistSection: some View { VStack(alignment: .leading, spacing: 10) { // section header HStack(spacing: 6) { Image(systemName: "sparkles") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺")) Spacer() if hasQuestions { Text("\(questions.count) 个建议") .font(.system(size: 10, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } if lastRate > 0 { Text(String(format: "%.1f tok/s", lastRate)) .font(.system(size: 10, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) } } // 累积的 questions 列表(多轮,带轮次分隔) if hasQuestions { VStack(spacing: 8) { ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in if idx == 0 || questions[idx - 1].round != q.round { roundDivider(round: q.round, count: questions.filter { $0.round == q.round }.count) .id("round-\(q.round)") } questionRow(index: roundLocalIndex(at: idx), question: q) } } } if exhaustedNote { HStack(spacing: 6) { Image(systemName: "checkmark.seal.fill") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.leaf) Text("已覆盖主要问诊维度;补充原文后可再追问") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) Spacer(minLength: 0) } .padding(.vertical, 2) } // 底部主操作按钮(状态机驱动) phaseFooter } } @ViewBuilder private var phaseFooter: some View { switch phase { case .idle: assistPrimaryButton( icon: "sparkles", label: canRequestSuggest ? String(appLoc: "让 AI 帮我想想还能记什么") : String(appLoc: "先写几个字,AI 来帮忙补充"), enabled: canRequestSuggest, action: requestSuggestions ) case .loading: HStack(spacing: 10) { ProgressView().controlSize(.small) Text("AI 思考中… 本地推理,通常 5-10 秒") .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text2) Spacer() Button("取消") { cancelSuggestions() } .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } .padding(.vertical, 11) .padding(.horizontal, 12) .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) ) case .ready: assistPrimaryButton( icon: "arrow.clockwise", label: canRequestSuggest ? String(appLoc: "再问一轮 · 让 AI 从新角度追问") : String(appLoc: "更新一下原文,再让 AI 继续追问"), enabled: canRequestSuggest, action: requestSuggestions ) case .failed(let err): VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.brick) Text(err.localizedDescription) .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text) Spacer() } Button { requestSuggestions() } label: { Text("重试") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } .buttonStyle(.plain) } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.brickSoft.opacity(0.5)) ) } } private func assistPrimaryButton(icon: String, label: String, enabled: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 8) { Image(systemName: icon) Text(label) } .font(.system(size: 13, weight: .semibold)) .foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3) .frame(maxWidth: .infinity) .padding(.vertical, 11) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder( enabled ? Tj.Palette.ink : Tj.Palette.line, style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3]) ) ) } .buttonStyle(.plain) .disabled(!enabled) } /// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。 private func roundLocalIndex(at idx: Int) -> Int { let target = questions[idx].round var count = 0 for i in 0...idx where questions[i].round == target { count += 1 } return count } /// 第 N 轮的分隔条 —— 让用户清楚下一轮 LLM 看到的是更新过的最新文本。 private func roundDivider(round: Int, count: Int) -> some View { HStack(spacing: 8) { HStack(spacing: 6) { Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) Text(round == 1 ? String(appLoc: "第 1 轮 · \(count) 条") : String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条")) .font(.system(size: 11, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } Rectangle() .fill(Tj.Palette.line) .frame(height: 1) .mask( HStack(spacing: 3) { ForEach(0..<60, id: \.self) { _ in Rectangle().frame(width: 3, height: 1) } } ) } .padding(.top, round == 1 ? 0 : 6) } private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View { let adopted = question.adopted let filling = fillingId == question.id return VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 8) { Text("\(index).") .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick) Text(question.q) .font(.system(size: 13, weight: .medium)) .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text) .strikethrough(adopted, color: Tj.Palette.text3) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) if adopted { HStack(spacing: 4) { Image(systemName: "checkmark") .font(.system(size: 10, weight: .bold)) Text("已采纳") .font(.system(size: 11, weight: .semibold)) } .foregroundStyle(Tj.Palette.leaf) .padding(.horizontal, 8) .padding(.vertical, 5) .background(Capsule().fill(Tj.Palette.leafSoft)) } else if !filling { Button { adopt(question) } label: { HStack(spacing: 4) { Image(systemName: "plus.circle.fill") .font(.system(size: 12)) Text("采纳") .font(.system(size: 12, weight: .semibold)) } .foregroundStyle(Tj.Palette.paper) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Capsule().fill(Tj.Palette.ink)) } .buttonStyle(.plain) } } if filling { QuestionFillPanel( template: question.fill, values: $fillValues, onCommit: { assembled in commitAdoption(question, text: assembled) }, onCancel: { closeFill() } ) } else if !question.fill.isEmpty && !adopted { HStack(alignment: .top, spacing: 4) { Text("将追加:") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) Text(question.fill) .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) } .padding(.leading, 22) } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) } // MARK: - Actions private func sectionLabel(_ text: String) -> some View { Text(text) .font(.system(size: 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } /// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM, /// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。 private func requestSuggestions() { suggestTask?.cancel() let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines) let covered = Array(coveredDims) // 1. 主动收起键盘 —— 否则建议面板被键盘吃掉一半 contentFocused = false // 2. 确保 sheet 在 large(用户可能下拉到 medium 又触发 AI) if detent != .large { withAnimation(.snappy(duration: 0.25)) { detent = .large } } 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 } /// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。 /// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。 private func adopt(_ question: DiaryAssistService.Question) { guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else { // 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。 commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill) return } withAnimation(.snappy(duration: 0.18)) { fillingId = question.id fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill)) } } /// 关闭填空面板(取消)。 private func closeFill() { withAnimation(.snappy(duration: 0.18)) { fillingId = nil fillValues = [] } } /// 提交采纳:把(填好的)整句追加到正文,标记 adopted,收起面板。 private func commitAdoption(_ question: DiaryAssistService.Question, text: String) { if let idx = questions.firstIndex(where: { $0.id == question.id }) { withAnimation(.snappy(duration: 0.18)) { questions[idx].adopted = true } } appendToContent(text) fillingId = nil fillValues = [] } /// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。 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() }