import SwiftUI import SwiftData /// 「和康康聊聊」——全局健康记录问答(RAG)。 /// /// 你问,康康基于你本机的健康记录(指标 / 报告 / 症状 / 日记)检索后作答,100% 端侧。 /// 复用 `HealthExportService.answer`(检索快照 → dialogueAnswer prompt → 流式作答 + 检索可视化), /// 卡通形象 `CompanionAvatarView` 放在顶栏当「对话对象」,情绪随生成状态切换。 /// /// 长期记忆:每轮问答落 `ChatTurn`,进场读回最近 10 轮,跨会话续聊(§12#3 本地 RAG 长期记忆)。 /// 边界:UI 不直接调 AIRuntime(红线 #3),全部经 `HealthExportService`;失败不卡屏(红线 #5)。 struct HealthChatView: View { let onClose: () -> Void @Environment(\.modelContext) private var ctx /// 本场对话(含进场读回的历史 + 本次新问答)。assistant 空文本 = 正在生成的占位轮。 @State private var turns: [HealthExportDialogueTurn] = [] /// 每条 assistant 回答对应的「查阅到的记录」摘要,展示在气泡下方做 RAG 可视化。 @State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:] @State private var draft = "" /// 正在流式作答的 assistant 轮 id;非 nil = 正在回答(锁输入、形象进入思考/说话态)。 @State private var answeringTurnID: UUID? @State private var rate: Double = 0 @State private var task: Task? @FocusState private var inputFocused: Bool // MARK: 语音提问(复用 SpeechDictationService,与写日记同一套本机转写) @State private var isRecording = false /// 必须 @State:struct View 重建时普通 let 会换新实例,导致 stop() 落在没在录音的新服务上。 @State private var dictation = SpeechDictationService() @State private var micDeniedAlert = false private var isAnswering: Bool { answeringTurnID != nil } /// 端侧模型就绪才能问(answer 走本地推理);未就绪时空态给下载指引。 private var modelReady: Bool { ModelStore.shared.isComplete(for: .llm) } /// 形象情绪:录音→listening;检索/等首 token→thinking;流式吐字→speaking;其余→idle。 private var mood: CompanionAvatarView.Mood { if isRecording { return .listening } guard let id = answeringTurnID else { return .idle } let streaming = turns.first { $0.id == id }?.text.isEmpty == false return streaming ? .speaking : .thinking } /// 空态引导:点一下即作为问题发出,降低「不知道能问啥」的启动成本。 private let starters = [ "帮我总结一下最近的身体状况", "我最近的血压怎么样?", "上一次体检有哪些异常?", "最近的血糖有变化吗?" ] var body: some View { VStack(spacing: 0) { header transcript } .background(Tj.Palette.sand.ignoresSafeArea()) .safeAreaInset(edge: .bottom) { inputBar } .onAppear(perform: loadHistory) .onDisappear { task?.cancel() dictation.abort() } .alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $micDeniedAlert) { Button(String(appLoc: "前往设置")) { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } Button(String(appLoc: "取消"), role: .cancel) {} } message: { Text("语音提问全程在本机完成,声音和文字都不会上传。") } } // MARK: - 顶栏(卡通形象 + 状态) private var header: some View { HStack(spacing: 12) { CompanionAvatarView(mood: mood, size: 44) VStack(alignment: .leading, spacing: 1) { Text("康康") .font(.tjScaled(15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text(headerSubtitle) .font(.tjScaled(11)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } Spacer(minLength: 8) Button(action: onClose) { Image(systemName: "xmark") .font(.tjScaled(14, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) } .buttonStyle(.plain) } .padding(.horizontal, 20) .padding(.vertical, 12) .background(Tj.Palette.paper) .overlay(alignment: .bottom) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } } private var headerSubtitle: String { if isRecording { return String(appLoc: "正在听你说…") } if isAnswering { return rate > 0 ? String(appLoc: "正在翻你的记录 · \(String(format: "%.1f", rate)) tok/s") : String(appLoc: "正在翻你的记录…") } return String(appLoc: "问问你的健康记录 · 全程在本机") } // MARK: - 对话区 private var transcript: some View { ScrollViewReader { proxy in ScrollView { if turns.isEmpty { emptyState .padding(.top, 28) .padding(.horizontal, 20) } else { LazyVStack(alignment: .leading, spacing: 14) { ForEach(turns) { turn in bubble(turn) } Color.clear.frame(height: 1).id(bottomAnchor) } .padding(.horizontal, 20) .padding(.vertical, 16) } } // 每吐一个 token / 新增一轮都滚到底,保证最新内容始终可见。 .onChange(of: turns.last?.text) { _, _ in scrollToBottom(proxy) } .onChange(of: turns.count) { _, _ in scrollToBottom(proxy) } } } private let bottomAnchor = "chat.bottom" private func scrollToBottom(_ proxy: ScrollViewProxy) { withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(bottomAnchor, anchor: .bottom) } } @ViewBuilder private func bubble(_ turn: HealthExportDialogueTurn) -> some View { if turn.role == .user { HStack { Spacer(minLength: 40) Text(turn.text) .font(.tjScaled(14)) .foregroundStyle(Tj.Palette.paper) .padding(.horizontal, 14) .padding(.vertical, 10) .background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.ink)) } } else { VStack(alignment: .leading, spacing: 8) { // 空文本 + 正在回答 = 首 token 未到,显示「思考」占位,不给用户空白焦虑。 if turn.text.isEmpty && answeringTurnID == turn.id { thinkingBubble } else { // 康康的回答按 Markdown 渲染(加粗 / 要点列表 / 异常值高亮), // 复用报告页那套流式优化过的 MarkdownView(带内联缓存,逐 token 重渲不卡)。 MarkdownView(text: turn.text) .padding(.horizontal, 14) .padding(.vertical, 10) .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.line, lineWidth: 1)) } if let summary = turnRetrievals[turn.id], summary.totalCount > 0 { retrievalChips(summary) } } .padding(.trailing, 32) } } private var thinkingBubble: some View { HStack(spacing: 5) { Text("正在查阅你的记录") .font(.tjScaled(13)) .foregroundStyle(Tj.Palette.text3) ProgressView().scaleEffect(0.7) } .padding(.horizontal, 14) .padding(.vertical, 10) .background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.paper)) .overlay(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1)) } /// RAG 可视化:这条回答查阅了哪些本机记录(§12#3 端侧不可替代性的现场证据)。 private func retrievalChips(_ summary: HealthExportService.RetrievalSummary) -> some View { VStack(alignment: .leading, spacing: 6) { Text("查阅了本机 \(summary.totalCount) 条记录") .font(.tjScaled(10, weight: .medium)) .foregroundStyle(Tj.Palette.text3) FlowChips(items: summary.chips) } .padding(.leading, 2) } // MARK: - 空态(引导起手问题) private var emptyState: some View { VStack(spacing: 16) { CompanionAvatarView(mood: .idle, size: 110) VStack(spacing: 6) { Text("我是康康") .font(.tjScaled(20, weight: .semibold, design: .serif)) .foregroundStyle(Tj.Palette.text) Text(modelReady ? String(appLoc: "问我关于你健康记录的事,我只看你本机的数据来回答") : String(appLoc: "先到「我的 · 模型管理」下载模型,就能和我聊了")) .font(.tjScaled(13)) .foregroundStyle(Tj.Palette.text3) .multilineTextAlignment(.center) } .padding(.horizontal, 12) if modelReady { VStack(spacing: 10) { ForEach(starters, id: \.self) { q in Button { send(q) } label: { HStack { Text(q) .font(.tjScaled(13, weight: .medium)) .foregroundStyle(Tj.Palette.text) Spacer() Image(systemName: "arrow.up.right") .font(.tjScaled(11, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 14) .padding(.vertical, 12) .tjCard(bordered: true) .contentShape(Rectangle()) } .buttonStyle(.plain) } } .padding(.top, 4) } } .frame(maxWidth: .infinity) } // MARK: - 输入栏 private var inputBar: some View { HStack(spacing: 10) { if SpeechDictationService.isAvailable { Button(action: toggleMic) { Image(systemName: isRecording ? "stop.fill" : "mic.fill") .font(.tjScaled(15, weight: .semibold)) .foregroundStyle(isRecording ? Tj.Palette.paper : Tj.Palette.text3) .frame(width: 40, height: 40) .background(Circle().fill(isRecording ? Tj.Palette.brick : Tj.Palette.sand2)) } .buttonStyle(.plain) .disabled(isAnswering) .opacity(isAnswering ? 0.4 : 1) } TextField(String(appLoc: "问问你的健康记录…"), text: $draft, axis: .vertical) .font(.tjScaled(14)) .foregroundStyle(Tj.Palette.text) .lineLimit(1...4) .focused($inputFocused) .padding(.horizontal, 14) .padding(.vertical, 10) .background(Capsule().fill(Tj.Palette.sand2)) .disabled(isRecording) Button { send(draft) } label: { Image(systemName: "arrow.up") .font(.tjScaled(16, weight: .bold)) .foregroundStyle(Tj.Palette.paper) .frame(width: 40, height: 40) .background(Circle().fill(canSend ? Tj.Palette.ink : Tj.Palette.line)) } .buttonStyle(.plain) .disabled(!canSend) } .padding(.horizontal, 16) .padding(.vertical, 10) .background(Tj.Palette.paper) .overlay(alignment: .top) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } } private var canSend: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isAnswering && !isRecording && modelReady } // MARK: - 发送 / 流式作答(镜像 HealthExportSheet.sendQuestion) private func send(_ raw: String) { let question = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !question.isEmpty, !isAnswering, modelReady else { return } draft = "" inputFocused = false let userTurn = HealthExportDialogueTurn.user(question) let assistantTurn = HealthExportDialogueTurn.assistant("") turns.append(userTurn) turns.append(assistantTurn) answeringTurnID = assistantTurn.id rate = 0 // 只把最近若干轮喂给模型:端侧小模型 context 有限,历史全塞会溢出/变慢。 let conversationForPrompt = Array( turns.filter { $0.id != assistantTurn.id }.suffix(8) ) 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 persist(question: question, answerID: assistantTurn.id) } catch { answeringTurnID = nil if error is CancellationError { return } // 已有部分回答就保留;否则给一句友好兜底,绝不把技术异常当 AI 回答展示(红线 #5)。 if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }), turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { turns[idx].text = String(appLoc: "这次没能回答上来,请换个说法再试一次。") } } } } private func appendToTurn(id: UUID, text: String) { guard let idx = turns.firstIndex(where: { $0.id == id }) else { return } turns[idx].text += text } /// 一轮问答落库成 ChatTurn(跨会话记忆)。空回答 / 兜底话术不入库,避免污染历史。 private func persist(question: String, answerID: UUID) { guard let answer = turns.first(where: { $0.id == answerID })?.text .trimmingCharacters(in: .whitespacesAndNewlines), !answer.isEmpty else { return } let turn = ChatTurn(question: question, answer: answer, decodeRate: rate) ctx.insert(turn) try? ctx.save() } // MARK: - 历史读回 /// 进场读回最近 10 轮问答,续上「长期记忆」。只在 onAppear 读一次,新问答只追加,不会重复。 private func loadHistory() { var desc = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) desc.fetchLimit = 10 let recent = (try? ctx.fetch(desc)) ?? [] guard !recent.isEmpty else { return } var loaded: [HealthExportDialogueTurn] = [] for ct in recent.reversed() { loaded.append(.user(ct.question)) loaded.append(.assistant(ct.answer)) } turns = loaded } // MARK: - 语音提问 private func toggleMic() { if isRecording { Task { @MainActor in let final = await dictation.stop() isRecording = false let merged = final.trimmingCharacters(in: .whitespacesAndNewlines) if !merged.isEmpty { draft = merged } } } else { Task { @MainActor in guard await dictation.requestAuthorization() else { micDeniedAlert = true return } do { inputFocused = false draft = "" try dictation.start { partial in draft = partial } isRecording = true } catch { isRecording = false } } } } } /// 检索 chip 的自适应换行排布(简单流式布局,依赖 iOS 16+ Layout)。 private struct FlowChips: View { let items: [String] var body: some View { FlowLayout(spacing: 6) { ForEach(items, id: \.self) { chip in Text(chip) .font(.tjScaled(10, weight: .medium)) .foregroundStyle(Tj.Palette.text2) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Capsule().fill(Tj.Palette.sand2)) } } } } /// 极简流式布局:逐行摆放,超出可用宽度就换行。仅用于检索 chip,不追求通用。 private struct FlowLayout: Layout { var spacing: CGFloat = 6 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { let maxWidth = proposal.width ?? .infinity var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 for sub in subviews { let size = sub.sizeThatFits(.unspecified) if x + size.width > maxWidth, x > 0 { x = 0 y += rowHeight + spacing rowHeight = 0 } x += size.width + spacing rowHeight = max(rowHeight, size.height) } return CGSize(width: maxWidth == .infinity ? x : maxWidth, height: y + rowHeight) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { let maxWidth = bounds.width var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 for sub in subviews { let size = sub.sizeThatFits(.unspecified) if x + size.width > maxWidth, x > 0 { x = 0 y += rowHeight + spacing rowHeight = 0 } sub.place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y), anchor: .topLeading, proposal: ProposedViewSize(size)) x += size.width + spacing rowHeight = max(rowHeight, size.height) } } } #Preview { HealthChatView(onClose: {}) }