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? /// 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) .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) } } } // 底部主操作按钮(状态机驱动) 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 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 { 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 !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 } } phase = .loading suggestTask = Task { @MainActor in do { let result = try await DiaryAssistService.shared.suggest( content: snapshotContent, coveredDimensions: covered ) if Task.isCancelled { return } // 客户端字面兜底(防 LLM 不听话);跨轮去重主要靠 prompt 的维度排除。 let existing = Set(questions.map { Self.normalize($0.q) }) let nextRound = currentRound + 1 let fresh = result.questions .filter { !existing.contains(Self.normalize($0.q)) } .map { q -> DiaryAssistService.Question in var stamped = q stamped.round = nextRound return stamped } withAnimation(.snappy(duration: 0.2)) { questions.append(contentsOf: fresh) for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) } lastRate = result.decodeRate currentRound = nextRound 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: "?") } private func cancelSuggestions() { suggestTask?.cancel() phase = hasQuestions ? .ready : .idle } /// 把 question.fill 追加到 textfield 末尾,并把该 question 标记为 adopted。 /// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。 private func adopt(_ question: DiaryAssistService.Question) { if let idx = questions.firstIndex(where: { $0.id == question.id }) { withAnimation(.snappy(duration: 0.18)) { questions[idx].adopted = true } } let toAppend = question.fill.isEmpty ? question.q : question.fill 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() }