import SwiftUI import SwiftData /// 「导出身体档案」全屏 sheet。 /// 状态机:多轮问答 → running(retrieving → generating)→ completed / failed struct HealthExportSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss /// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。 let initialPrompt: String @State private var turns: [HealthExportDialogueTurn] = [] @State private var draftQuestion: String = "" @State private var phase: HealthExportService.Phase? @State private var content: String = "" @State private var rate: Double = 0 @State private var task: Task? @State private var error: Error? @State private var completed: Bool = false @State private var copiedFlash: Bool = false @State private var answeringTurnID: UUID? @State private var retrieval: HealthExportService.RetrievalSummary? @State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:] @FocusState private var questionFocused: Bool // 快捷问答 @State private var promptStore = QuickPromptStore.shared @State private var showAddPrompt = false @State private var newPromptText = "" init(initialPrompt: String = "") { self.initialPrompt = initialPrompt } private var isGeneratingReport: Bool { phase != nil && !completed && error == nil } private var isAnswering: Bool { answeringTurnID != nil } private var canAsk: Bool { !isAnswering && !isGeneratingReport && !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } /// 已有有效用户对话内容。 private var hasUserContent: Bool { turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) } /// 可生成报告:有对话内容,或输入框里有文字(允许跳过多轮对话直接生成)。 private var canGenerateReport: Bool { !isAnswering && !isGeneratingReport && (hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } var body: some View { VStack(spacing: 0) { header ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 18) { introSection ForEach(turns) { turn in dialogueBubble(turn) } if isGeneratingReport { phaseIndicator } if !content.isEmpty { reportCard } if let err = error { errorRow(err) } Color.clear.frame(height: 1).id("bottom") } .padding(.horizontal, 20) .padding(.vertical, 16) } .onChange(of: content) { _, _ in withAnimation(.easeOut(duration: 0.12)) { proxy.scrollTo("bottom", anchor: .bottom) } } .onChange(of: turns) { _, _ in withAnimation(.easeOut(duration: 0.12)) { proxy.scrollTo("bottom", anchor: .bottom) } } } if completed { actionRow } else { composer } } .background(Tj.Palette.sand.ignoresSafeArea()) .onAppear { if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty { draftQuestion = initialPrompt } questionFocused = true } .onDisappear { task?.cancel() } .alert("添加快捷问答", isPresented: $showAddPrompt) { TextField("输入一句常用问题…", text: $newPromptText) Button("取消", role: .cancel) { newPromptText = "" } Button("添加") { promptStore.add(prompt: newPromptText) newPromptText = "" } } message: { Text("保存后点一下,就能把这句话填进输入框") } } // MARK: - 快捷问答 /// 内置 + 自定义快捷问答 chip 行;点 chip 填入输入框,末尾「+ 自定义」追加,长按自定义删除。 private var quickPromptRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(promptStore.all) { p in quickPromptChip(p) } addQuickPromptChip } .padding(.vertical, 1) // 给 chip 描边留出像素,避免被 ScrollView 裁切 } } private func quickPromptChip(_ p: QuickPrompt) -> some View { Button { draftQuestion = p.prompt questionFocused = true } label: { Text(p.title) .font(.tjScaled( 12, weight: .medium)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) .padding(.horizontal, 12) .padding(.vertical, 7) .background(Capsule().fill(Tj.Palette.sand2)) .overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)) } .buttonStyle(.plain) .contextMenu { if !p.isBuiltin { Button(role: .destructive) { promptStore.delete(p) } label: { Label("删除", systemImage: "trash") } } } } private var addQuickPromptChip: some View { Button { showAddPrompt = true } label: { Label("自定义", systemImage: "plus") .font(.tjScaled( 12, weight: .medium)) .foregroundStyle(Tj.Palette.text2) .padding(.horizontal, 12) .padding(.vertical, 7) .background(Capsule().fill(Tj.Palette.paper)) .overlay( Capsule().strokeBorder( Tj.Palette.line, style: StrokeStyle(lineWidth: 1, dash: [3]) ) ) } .buttonStyle(.plain) } // MARK: - Header private var header: some View { HStack(alignment: .center, spacing: 12) { Button { close() } label: { Image(systemName: "xmark") .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) } VStack(alignment: .leading, spacing: 2) { Text("身体档案") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("先问清楚,再整理给医生") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer() TjLockChip() } .padding(.horizontal, 20) .padding(.vertical, 14) .background(Tj.Palette.sand) .overlay(alignment: .bottom) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } } // MARK: - Dialogue private var introSection: some View { VStack(alignment: .leading, spacing: 14) { Text("围绕你的指标和健康日记提问") .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) quickPromptRow Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) } private func dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View { let isUser = turn.role == .user return HStack(alignment: .top, spacing: 8) { if isUser { Spacer(minLength: 44) } VStack(alignment: .leading, spacing: 6) { Text(turn.role.transcriptLabel) .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3) if !isUser, let summary = turnRetrievals[turn.id] { RetrievalChipsView(summary: summary) } if turn.id == answeringTurnID && turn.text.isEmpty { VStack(alignment: .leading, spacing: 8) { Text(turnRetrievals[turn.id] == nil ? "正在查看本地记录…" : "正在根据这些记录回答…") .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) AIFlowBar() } } else { Text(turn.text) .font(.tjScaled( 14)) .lineSpacing(3) .foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) } } .padding(12) .frame(maxWidth: 300, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(isUser ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1) ) if !isUser { Spacer(minLength: 44) } } } private var reportCard: some View { VStack(alignment: .leading, spacing: 10) { Text("整理好的报告") .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) MarkdownView(text: content) if completed { Divider().background(Tj.Palette.lineSoft) AIDisclaimerFooter() } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) } // MARK: - Phase indicator private var phaseIndicator: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { phasePill(.extractingIntent) arrow phasePill(.retrieving) arrow phasePill(.generating) } if let retrieval { RetrievalChipsView(summary: retrieval) } if phase == .generating && rate > 0 { Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate)) .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) } else { Text(phase?.label ?? "") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } // AI 计算中:多彩流光线(与日记 AI 辅助同一组件) AIFlowBar().padding(.top, 2) } } private func phasePill(_ p: HealthExportService.Phase) -> some View { let active = (p == phase) let done = phaseOrder(p) < phaseOrder(phase ?? .extractingIntent) let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2) let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3 return Text(p.label) .font(.tjScaled( 11, weight: active ? .semibold : .regular)) .foregroundStyle(fg) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Capsule().fill(fill)) } private var arrow: some View { Image(systemName: "chevron.right") .font(.tjScaled( 10, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } private func phaseOrder(_ p: HealthExportService.Phase) -> Int { switch p { case .extractingIntent: return 0 case .retrieving: return 1 case .generating: return 2 case .completed: return 3 } } // MARK: - Error private func errorRow(_ err: Error) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.brick) Text(err.localizedDescription) .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text) } Button { reset() } label: { Text("返回修改") } .buttonStyle(TjGhostButton(height: 40, fontSize: 13)) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.brickSoft.opacity(0.6)) ) } // MARK: - Action row (completed) private var actionRow: some View { HStack(spacing: 10) { Button { copy() } label: { Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc") } .buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14)) ShareLink(item: AIDisclaimer.appended(to: content)) { Label("分享", systemImage: "square.and.arrow.up") .font(.tjScaled( 13, weight: .semibold)) .tracking(1) .foregroundStyle(Tj.Palette.ink) .padding(.horizontal, 14) .frame(height: 44) .background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1)) .contentShape(Capsule()) // 纯描边胶囊:内边距区也可点 } Spacer() Button { regenerate() } label: { Label("重新整理", systemImage: "arrow.clockwise") } .buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16)) } .padding(.horizontal, 20) .padding(.vertical, 12) .background(Tj.Palette.paper) .overlay(alignment: .top) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } } private var composer: some View { VStack(spacing: 10) { HStack(spacing: 8) { TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical) .font(.tjScaled( 14)) .lineLimit(1...4) .padding(.horizontal, 12) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.sand2) ) .focused($questionFocused) .disabled(isAnswering || isGeneratingReport) Button { sendQuestion() } label: { Image(systemName: "arrow.up") .font(.tjScaled( 15, weight: .bold)) .foregroundStyle(Tj.Palette.paper) .frame(width: 40, height: 40) .background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line)) } .disabled(!canAsk) .accessibilityLabel("发送问题") } if isGeneratingReport { Button { stopGeneration() } label: { Label("停止生成", systemImage: "stop.fill") .font(.tjScaled( 14, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) .frame(maxWidth: .infinity) .frame(height: 44) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.brick, lineWidth: 1) ) .contentShape(Rectangle()) } .buttonStyle(.plain) } else { Button { startReportGeneration() } label: { Label("生成整理报告", systemImage: "doc.text.below.ecg") } .buttonStyle(TjPrimaryButton(height: 44, fontSize: 14)) .disabled(!canGenerateReport) .opacity(canGenerateReport ? 1 : 0.45) } } .padding(.horizontal, 20) .padding(.vertical, 12) .background(Tj.Palette.paper) .overlay(alignment: .top) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } } // MARK: - Actions private func sendQuestion() { let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines) guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return } draftQuestion = "" questionFocused = false let userTurn = HealthExportDialogueTurn.user(question) let assistantTurn = HealthExportDialogueTurn.assistant("") turns.append(userTurn) turns.append(assistantTurn) answeringTurnID = assistantTurn.id let conversationForPrompt = turns.filter { $0.id != assistantTurn.id } let stream = HealthExportService.shared.answer( question: question, conversation: conversationForPrompt, in: ctx ) task?.cancel() task = Task { @MainActor in do { for try await event in stream { switch event { case .retrieved(let summary): withAnimation(.snappy(duration: 0.25)) { turnRetrievals[assistantTurn.id] = summary } case .token(let chunk): appendToTurn(id: assistantTurn.id, text: chunk.text) if chunk.decodeRate > 0 { rate = chunk.decodeRate } case .phaseChanged, .completed: break } } answeringTurnID = nil questionFocused = true } catch { answeringTurnID = nil appendToTurn(id: assistantTurn.id, text: error.localizedDescription) questionFocused = true } } } private func appendToTurn(id: UUID, text: String) { guard let idx = turns.firstIndex(where: { $0.id == id }) else { return } turns[idx].text += text } private func startReportGeneration() { guard canGenerateReport else { return } questionFocused = false // 直接生成:输入框里有文字(快捷问答/手输)就把它作为一条诉求追加进对话, // 不必先走多轮问答 —— 用户点一下「生成报告」即可。 let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines) if !draft.isEmpty { turns.append(.user(draft)) draftQuestion = "" } content = "" rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示 error = nil completed = false retrieval = nil phase = .retrieving let stream = HealthExportService.shared.export(conversation: turns, in: ctx) task?.cancel() task = Task { @MainActor in do { for try await event in stream { switch event { case .phaseChanged(let ph): phase = ph case .retrieved(let summary): withAnimation(.snappy(duration: 0.25)) { retrieval = summary } case .token(let chunk): content += chunk.text if chunk.decodeRate > 0 { rate = chunk.decodeRate } case .completed: completed = true } } } catch { self.error = error self.phase = nil } } } private func regenerate() { completed = false startReportGeneration() } /// 停止正在进行的报告生成:取消推理任务,回到可重新生成的干净态(已写的诉求保留在对话里)。 private func stopGeneration() { task?.cancel() task = nil phase = nil rate = 0 completed = false content = "" retrieval = nil } private func reset() { task?.cancel() task = nil phase = nil content = "" rate = 0 error = nil completed = false answeringTurnID = nil retrieval = nil turnRetrievals = [:] questionFocused = true } private func copy() { UIPasteboard.general.string = AIDisclaimer.appended(to: content) copiedFlash = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { copiedFlash = false } } private func close() { task?.cancel() dismiss() } } // MARK: - 检索结果 chips(本地 RAG 可视化) /// 生成开始前先把「本地 RAG 找到了什么」演出来:N 条记录 + 记录名 chips。 /// 结构化检索(不用 embedding)的天然优势 —— 每条命中都可解释、可展示(§12 卖点 3)。 private struct RetrievalChipsView: View { let summary: HealthExportService.RetrievalSummary var body: some View { VStack(alignment: .leading, spacing: 6) { if summary.totalCount == 0 { Text("本地档案中暂无相关记录,将仅按你的描述整理") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } else { Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录")) .font(.tjScaled( 11, weight: .medium)) .foregroundStyle(Tj.Palette.leaf) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in Text(chip) .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text2) .lineLimit(1) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Capsule().fill(Tj.Palette.sand2)) .overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)) } } .padding(.vertical, 1) } } } .transition(.opacity.combined(with: .move(edge: .top))) } } // MARK: - 简易 Markdown 渲染(行级) /// 极简 Markdown 渲染器,够给医生看的报告就行。 /// 支持: `# 一级`、`## 二级`、`-` 列表、`**粗体**`(走 AttributedString 的 inline 解析)。 /// 不支持表格、代码块、链接 —— 报告生成 prompt 也不会让 LLM 输出这些。 struct MarkdownView: View { let text: String var body: some View { let blocks = Self.parse(text) VStack(alignment: .leading, spacing: 10) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in renderBlock(block) } } } @ViewBuilder private func renderBlock(_ block: Block) -> some View { switch block { case .h1(let s): VStack(alignment: .leading, spacing: 8) { Text(inline(s)) .font(.tjScaled( 22, weight: .bold)) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) Rectangle() .fill(Tj.Palette.ink) .frame(height: 1) .frame(maxWidth: .infinity) } .padding(.top, 2) .padding(.bottom, 4) case .h2(let s): HStack(alignment: .center, spacing: 8) { RoundedRectangle(cornerRadius: 1.5, style: .continuous) .fill(Tj.Palette.brick) .frame(width: 3, height: 16) Text(inline(s)) .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) } .padding(.top, 10) .padding(.bottom, 2) case .bullet(let s): if let abnormalText = Self.extractAbnormal(s) { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.brick) Text(inline(abnormalText)) .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .padding(.horizontal, 10) .padding(.vertical, 7) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Tj.Palette.brickSoft.opacity(0.55)) ) .overlay(alignment: .leading) { RoundedRectangle(cornerRadius: 1.5, style: .continuous) .fill(Tj.Palette.brick) .frame(width: 3) } } else { HStack(alignment: .firstTextBaseline, spacing: 10) { Circle() .fill(Tj.Palette.text3) .frame(width: 4, height: 4) .padding(.top, 6) Text(inline(s)) .font(.tjScaled( 14)) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) } .padding(.leading, 2) } case .body(let s): Text(inline(s)) .font(.tjScaled( 14)) .lineSpacing(3) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) case .gap: Spacer().frame(height: 4) } } /// 如果 bullet 文本以 ⚠️ 或常见异常关键词开头,返回 strip 掉前缀后的纯文本。 /// 否则返回 nil(表示不是异常项)。 private static func extractAbnormal(_ s: String) -> String? { let trimmed = s.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("⚠️") { return trimmed.replacingOccurrences(of: "⚠️", with: "") .trimmingCharacters(in: .whitespaces) } // 关键词兜底高亮,但排除否定语境(「无异常」「未见偏高」「没有偏低」等), // 否则正常结论会被误标红。判断:信号词前最近 4 字内出现否定词即视为否定。 let negations = ["无", "未", "没"] let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"] for sig in abnormalSignals { guard let r = trimmed.range(of: sig) else { continue } let window = String(trimmed[.. AttributedString { // **bold** / *italic* / [text](url) 走 AttributedString markdown 解析 if let attr = try? AttributedString( markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) ) { return attr } return AttributedString(s) } // MARK: - 行级解析 enum Block { case h1(String) case h2(String) case bullet(String) case body(String) case gap } static func parse(_ raw: String) -> [Block] { var out: [Block] = [] let lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") for line in lines { let t = line.trimmingCharacters(in: .whitespaces) if t.isEmpty { // 连续空行折叠成一个 gap if case .gap = out.last { continue } out.append(.gap) continue } if t.hasPrefix("# ") { out.append(.h1(String(t.dropFirst(2)))) } else if t.hasPrefix("## ") { out.append(.h2(String(t.dropFirst(3)))) } else if t.hasPrefix("### ") { out.append(.h2(String(t.dropFirst(4)))) } else if t.hasPrefix("- ") || t.hasPrefix("* ") { out.append(.bullet(String(t.dropFirst(2)))) } else { out.append(.body(t)) } } return out } } #Preview("HealthExportSheet · 空状态") { HealthExportSheet() .modelContainer(for: [ Indicator.self, Report.self, DiaryEntry.self, Asset.self, ChatTurn.self, Symptom.self, UserProfile.self, MetricReminder.self, CustomMonitorMetric.self, HealthExport.self ], inMemory: true) } #Preview("MarkdownView · 演示") { ScrollView { MarkdownView(text: """ # 就诊摘要 — 感冒就诊 ## 主诉 本人男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。 ## 本人背景 - 高血压 2 年 - 在服药:**缬沙坦 80mg qd** - 过敏:青霉素 ## 近期症状 - 2026-05-24 感冒(进行中,severity 2):鼻塞、低烧 - 2026-05-20 头痛(已结束) ## 关键指标 - ⚠️ 收缩压 142 mmHg (参考 <140) — 2026-05-26 - 体温 37.2 ℃ (参考 36-37) — 2026-05-25 """) .padding() } .background(Tj.Palette.sand) }