import SwiftUI import SwiftData /// 「聊着记」——全屏动画形象「康康」与用户的语音多轮对话页。 /// /// 是什么:进场先拿一份健康数据快照(makeContextJSON)喂给 LLM,康康基于最近记录开场追问; /// 用户按麦克风说一句 → 端侧转写 → 康康共情 + 再追问;聊够(maxRounds 轮)或用户主动收尾时, /// 把整段对话蒸馏成一条日记草稿回传给调用方(DiaryQuickSheet 负责回填正文并关闭 cover)。 /// /// 状态机(Phase)如何转: /// preparing ──首 token──► speaking ──流式完──► awaitingUser /// awaitingUser ──点麦克风──► recording ──点停/看门狗──► thinking(reply)──► speaking ──► awaitingUser /// awaitingUser ──「聊完了」──► distilling ──► onDraft(结束) /// thinking/speaking ──reply 全空失败──► failed ──重试──► thinking /// /// 失败链(红线 #5「不让用户卡在 AI 错误屏」): /// · 开场 opening 失败(全空)→ 落 DiaryChatPrompts.staticOpening + 一次性 toast,继续能聊; /// · 追问 reply 失败(全空且非取消)→ Phase.failed,可「重试」或「不聊了,把原话记下来」; /// · 蒸馏 distill 失败 / 为空 → onDraft(fallbackDraft, usedFallback: true),填用户原话; /// · 流已产出部分文本后再出错(含取消)→ 视同说完,把部分文本并入对话,不吓用户。 /// /// 边界:UI 不直接调 AIRuntime,全部经 DiaryChatService(红线 #3);推理串行由 AIRuntime actor 保证。 struct DiaryCompanionView: View { /// 蒸馏(或兜底拼接)出的草稿回传;usedFallback=true 表示没走成 AI、填的是原话。 /// 调用方负责回填正文与关闭 cover;本视图 onDraft 后不要再调 onClose。 let onDraft: (_ draft: String, _ usedFallback: Bool) -> Void let onClose: () -> Void /// 单轮回答的录音上限。单轮比整段口述短,「说一段」先例是 180s,这里收到 120s。 static let maxRecordingSeconds = 120 // MARK: - 状态机 enum Phase: Equatable { case preparing // 进场:makeContextJSON + openingLine 流式中(首 token 前) case speaking // 康康气泡打字机流式中(opening 或 reply) case awaitingUser // 等用户:按 mic / 点「聊完了」 case recording // 录音中,实时字幕 case thinking // reply 已发出、首 token 未到 case distilling // 蒸馏中 case failed(String) // 可重试错误(仅 reply 全空时进入) } @Environment(\.modelContext) private var ctx @State private var phase: Phase = .preparing @State private var turns: [HealthExportDialogueTurn] = [] /// 流式中的康康气泡文本;完成后 trim 并入 turns,随即清空。 @State private var bubbleText = "" @State private var liveTranscript = "" /// 整场对话复用的健康数据快照;进场 makeContextJSON 生成一次。 @State private var dataJSON = "{}" @State private var recordingSeconds = 0 @State private var lastRate: Double = 0 /// 一次性提示条文案(开场兜底 / 没听清等),显示 2.5s 后自动清。 @State private var toast: String? @State private var showAbandonConfirm = false @State private var micDeniedAlert = false /// 流式 / 蒸馏任务句柄。onDisappear 与「停」按钮据此取消,不空耗底层解码。 @State private var chatTask: Task? /// 录音计时看门狗(每秒 +1,到点自动停)。 @State private var recordingWatchdog: Task? /// 最近一次用户原话,reply 失败后「重试」复用。 @State private var pendingLatest: String? /// .task 只跑一次的闸(startOpening 防重入)。 @State private var started = false /// 必须 @State:struct View 重建(任何父状态变化都可能触发)时普通 let 会换成全新实例, /// 导致 stop() 落在没在录音的新服务上返回空串(「没听清」假错误),且真正在录音的老实例 /// 关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一(照搬 DiaryQuickSheet 的约定)。 @State private var dictation = SpeechDictationService() // MARK: - 派生 /// 形象情绪:思考类三态 → thinking,说话 → speaking,录音 → listening,其余 → idle。 private var avatarMood: CompanionAvatarView.Mood { switch phase { case .preparing, .thinking, .distilling: return .thinking case .speaking: return .speaking case .recording: return .listening case .awaitingUser, .failed: return .idle } } private var hasUserTurns: Bool { turns.contains { $0.role == .user } } private var wrappedUp: Bool { DiaryChatService.isWrappedUp(turns: turns) } // MARK: - Body var body: some View { VStack(spacing: 0) { topBar CompanionAvatarView(mood: avatarMood, size: 150) .padding(.vertical, 14) .animation(.snappy(duration: 0.25), value: avatarMood) dialogueArea .frame(maxWidth: .infinity, maxHeight: .infinity) controlArea .padding(.horizontal, 20) .padding(.top, 8) .animation(.snappy(duration: 0.22), value: phase) AIDisclaimerFooter() .padding(.horizontal, 20) .padding(.top, 8) .padding(.bottom, 10) } .background(Tj.Palette.sand.ignoresSafeArea()) .task { startOpening() } .onChange(of: toast) { _, newValue in // 一次性提示:出现 2.5s 后自动清(被新 toast 顶掉则由新 onChange 接管)。 guard newValue != nil else { return } Task { @MainActor in try? await Task.sleep(nanoseconds: 2_500_000_000) if toast == newValue { toast = nil } } } .onDisappear { chatTask?.cancel() recordingWatchdog?.cancel() dictation.abort() } .confirmationDialog(String(appLoc: "聊到一半,要放弃吗?"), isPresented: $showAbandonConfirm, titleVisibility: .visible) { Button("把说过的记下来") { onDraft(DiaryChatService.fallbackDraft(turns: turns), true) } Button("直接关闭", role: .destructive) { onClose() } Button("取消", role: .cancel) {} } .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 topBar: some View { HStack(alignment: .center, spacing: 12) { Button(action: attemptClose) { Image(systemName: "xmark") .font(.tjScaled(15, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) .frame(width: 44, height: 44) .background(Circle().fill(Tj.Palette.paper)) .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) .contentShape(Circle()) } .buttonStyle(.plain) Spacer(minLength: 8) VStack(spacing: 2) { Text("和康康聊聊") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("聊完帮你整理成一条记录") .font(.tjScaled(11)) .foregroundStyle(Tj.Palette.text3) } Spacer(minLength: 8) roundDots .frame(width: 44, alignment: .trailing) } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 4) } /// 右上轮次指示:maxRounds 个小圆点,已用满 i 轮的点亮成 brick。 private var roundDots: some View { HStack(spacing: 4) { ForEach(0.. i ? Tj.Palette.brick : Tj.Palette.line) .frame(width: 6, height: 6) } } } // MARK: - 对话区 private var dialogueArea: some View { ScrollViewReader { proxy in ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 10) { ForEach(turns) { turn in bubbleRow(text: turn.text, isUser: turn.role == .user) } // 流式中的康康气泡:带右下 tok/s(lastRate>0 才显示)。 if !bubbleText.isEmpty { bubbleRow(text: bubbleText, isUser: false, rate: lastRate) } // 录音中的实时字幕:走用户样式,brick 半透描边区分「还没定稿」。 if phase == .recording, !liveTranscript.isEmpty { bubbleRow(text: liveTranscript, isUser: true, strokeColor: Tj.Palette.brick.opacity(0.5)) } if let toast { toastBar(toast) } // 滚动锚点(照搬 DiaryVoicePanel 的 tail 锚点写法)。 Color.clear.frame(height: 1).id("tail") } .padding(.horizontal, 20) .padding(.vertical, 8) } .onChange(of: turns.count) { _, _ in scrollToTail(proxy) } .onChange(of: bubbleText) { _, _ in scrollToTail(proxy) } } } private func scrollToTail(_ proxy: ScrollViewProxy) { // 不加显式动画:流式期 bubbleText 每 token 都变,逐次动画滚动会抖(照搬 DiaryVoicePanel:94-96)。 proxy.scrollTo("tail", anchor: .bottom) } /// 一条对话气泡。康康(assistant)靠左 paper 底 + lineSoft 描边;用户靠右 sand2 底。 /// - rate: 非空且 >0 时在右下角挂一行 tok/s(仅流式气泡用)。 /// - strokeColor: 覆盖默认描边(录音实时字幕用 brick 半透)。 @ViewBuilder private func bubbleRow(text: String, isUser: Bool, rate: Double? = nil, strokeColor: Color? = nil) -> some View { let stroke = strokeColor ?? (isUser ? Color.clear : Tj.Palette.lineSoft) HStack(alignment: .top, spacing: 0) { if isUser { Spacer(minLength: 40) } VStack(alignment: .leading, spacing: 4) { Text(text) .font(.tjScaled(15)) .lineSpacing(3) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) if let rate, rate > 0 { Text(String(format: "%.1f tok/s", rate)) .font(.tjScaled(10, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) .frame(maxWidth: .infinity, alignment: .trailing) } } .padding(12) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(isUser ? Tj.Palette.sand2 : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(stroke, lineWidth: 1) ) if !isUser { Spacer(minLength: 40) } } } /// 居中一次性提示条(amber 图标 + text2 文字)。text 为已本地化字符串,直接展示。 private func toastBar(_ text: String) -> some View { HStack(spacing: 6) { Image(systemName: "exclamationmark.circle.fill") .font(.tjScaled(11)) .foregroundStyle(Tj.Palette.amber) Text(text) .font(.tjScaled(11)) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 4) } // MARK: - 控制区 @ViewBuilder private var controlArea: some View { Group { switch phase { case .preparing: statusBlock(String(appLoc: "康康正在翻你的记录…")) case .thinking: VStack(spacing: 10) { Text("康康在想…") .font(.tjScaled(13, weight: .medium)) .foregroundStyle(Tj.Palette.text2) AIFlowBar().frame(maxWidth: 200) Button("停") { stopThinking() } .buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18)) } case .distilling: // 蒸馏最多几秒,且 onDisappear 兜底取消,不给「停」按钮。 statusBlock(String(appLoc: "康康在帮你整理…")) case .speaking: // 流式吐字中不放按钮,等说完自然进 awaitingUser。 Color.clear.frame(height: 1) case .awaitingUser: awaitingControls case .recording: recordingControls case .failed(let msg): failedControls(msg) } } .frame(maxWidth: .infinity) .frame(minHeight: 150) } /// 「正在忙」通用块:一句状态 + 流光条。preparing / distilling 共用。 private func statusBlock(_ text: String) -> some View { VStack(spacing: 12) { Text(text) .font(.tjScaled(13, weight: .medium)) .foregroundStyle(Tj.Palette.text2) AIFlowBar().frame(maxWidth: 200) } } @ViewBuilder private var awaitingControls: some View { if wrappedUp { // 聊够了:麦克风隐藏,只留「聊完了」主按钮收尾。 Button("聊完了,帮我记下来") { finishChat() } .buttonStyle(TjPrimaryButton()) } else { VStack(spacing: 12) { Button(action: startRecording) { ZStack { Circle().fill(Tj.Palette.ink) .frame(width: 72, height: 72) Image(systemName: "mic.fill") .font(.tjScaled(26, weight: .medium)) .foregroundStyle(Tj.Palette.paper) } .contentShape(Circle()) } .buttonStyle(.plain) Text("点一下,说给康康听") .font(.tjScaled(12)) .foregroundStyle(Tj.Palette.text3) if hasUserTurns { Button("聊完了,帮我记下来") { finishChat() } .buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18)) } } } } private var recordingControls: some View { VStack(spacing: 12) { HStack(spacing: 14) { Button(action: stopAndReply) { ZStack { Circle().fill(Tj.Palette.brick) .frame(width: 72, height: 72) Image(systemName: "stop.fill") .font(.tjScaled(24, weight: .medium)) .foregroundStyle(Tj.Palette.paper) } .contentShape(Circle()) } .buttonStyle(.plain) Text(timeString(recordingSeconds)) .font(.tjMono(12)) .foregroundStyle(recordingSeconds >= Self.maxRecordingSeconds - 20 ? Tj.Palette.brick : Tj.Palette.text3) } Text("说完了") .font(.tjScaled(12)) .foregroundStyle(Tj.Palette.text3) } } private func failedControls(_ msg: String) -> some View { VStack(spacing: 12) { Text(msg) // 运行期错误描述(变量),原样展示,不再查表。 .font(.tjScaled(12)) .foregroundStyle(Tj.Palette.text2) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 10) { Button("重试") { retryReply() } .buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18)) if hasUserTurns { Button("不聊了,把原话记下来") { onDraft(DiaryChatService.fallbackDraft(turns: turns), true) } .buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18)) } } } .padding(.horizontal, 20) } private func timeString(_ seconds: Int) -> String { String(format: "%d:%02d", seconds / 60, seconds % 60) } // MARK: - 动作:开场与流式消费 /// 进场只跑一次:生成健康数据快照,再流式拉开场白。 private func startOpening() { guard !started else { return } started = true dataJSON = DiaryChatService.shared.makeContextJSON(in: ctx) consumeBubbleStream(DiaryChatService.shared.openingLine(dataJSON: dataJSON), isOpening: true) } /// 消费一条流式回应(opening 或 reply):逐 token 灌进 bubbleText,完成后并入 turns。 /// 失败语义见文件头「失败链」;含取消在内,只要已有部分文本一律视同说完并入,不吓用户。 private func consumeBubbleStream(_ stream: AsyncThrowingStream, isOpening: Bool) { chatTask = Task { @MainActor in do { for try await chunk in stream { if bubbleText.isEmpty, !chunk.text.isEmpty { withAnimation(.snappy(duration: 0.2)) { phase = .speaking } } bubbleText += chunk.text if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } } // —— 正常完成 —— let done = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines) bubbleText = "" if !done.isEmpty { turns.append(.assistant(done)) } else if isOpening { // 极端:正常完成却全空(streamWithFallback 通常抛 .empty,这里兜一手)。 turns.append(.assistant(DiaryChatPrompts.staticOpening)) toast = String(appLoc: "康康没连上,先用老一套开场啦") } withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } } catch { // —— 流出错(含 CancellationError) —— let partial = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines) bubbleText = "" if !partial.isEmpty { // 已产出内容:视同说完并入,不算失败。 turns.append(.assistant(partial)) withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } } else if isOpening { // 开场全空:落静态开场白 + 一次性提示,继续能聊。 turns.append(.assistant(DiaryChatPrompts.staticOpening)) toast = String(appLoc: "康康没连上,先用老一套开场啦") withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } } else if error is CancellationError { // 追问被主动取消:回等待,不算失败。 withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } } else { // 追问全空失败:可重试。 withAnimation(.snappy(duration: 0.2)) { phase = .failed(error.localizedDescription) } } } } } /// 「停」:取消流式追问,回等待态(consumeBubbleStream 的取消分支也会兜到 awaitingUser)。 private func stopThinking() { chatTask?.cancel() withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } } // MARK: - 动作:录音与追问 /// 请求权限 → 开录音 → 起看门狗。权限拒 → alert;start 抛错 → toast 提示。 private func startRecording() { toast = nil chatTask = Task { @MainActor in guard await dictation.requestAuthorization() else { micDeniedAlert = true return } do { liveTranscript = "" recordingSeconds = 0 try dictation.start { partial in liveTranscript = partial } withAnimation(.snappy(duration: 0.2)) { phase = .recording } // 计时 + 看门狗(照搬 DiaryQuickSheet:701 结构,到点自动 stopAndReply)。 recordingWatchdog = Task { @MainActor in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) guard !Task.isCancelled, phase == .recording else { return } recordingSeconds += 1 if recordingSeconds >= Self.maxRecordingSeconds { stopAndReply() return } } } } catch { toast = String(appLoc: "没听清,再试一次") withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } } } } /// 停止录音取最终稿(空则用实时字幕兜底,照搬 DiaryQuickSheet:691-695),追加为用户轮并发起追问。 private func stopAndReply() { guard phase == .recording else { return } recordingWatchdog?.cancel() chatTask = Task { @MainActor in var text = (await dictation.stop()) .trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { text = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines) } guard !text.isEmpty else { toast = String(appLoc: "没听清,再试一次") withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser } return } pendingLatest = text turns.append(.user(text)) liveTranscript = "" withAnimation(.snappy(duration: 0.2)) { phase = .thinking } sendReply(latest: text) } } /// 发起一轮追问。transcript 用 dropLast() 去掉刚 append 的最新用户轮(它单独走 latest 参数, /// 避免重复);roundsUsed 只数 assistant 轮,不受这条用户轮影响,直接从当前 turns 算即正确。 private func sendReply(latest: String) { let prior = HealthExportDialogueTurn.transcript(from: Array(turns.dropLast())) let stream = DiaryChatService.shared.reply( transcript: prior, latest: latest, dataJSON: dataJSON, roundsUsed: DiaryChatService.roundsUsed(in: turns) ) consumeBubbleStream(stream, isOpening: false) } /// 重试上一轮追问:turns 里失败那轮的用户话仍在,复用 pendingLatest。 private func retryReply() { guard let latest = pendingLatest else { return } withAnimation(.snappy(duration: 0.2)) { phase = .thinking } sendReply(latest: latest) } // MARK: - 动作:收尾与关闭 /// 「聊完了」:蒸馏整段对话成日记草稿回传。失败 / 为空 → 落用户原话(usedFallback=true)。 private func finishChat() { withAnimation(.snappy(duration: 0.2)) { phase = .distilling } chatTask = Task { @MainActor in do { let r = try await DiaryChatService.shared.distill(turns: turns) onDraft(r.text.isEmpty ? DiaryChatService.fallbackDraft(turns: turns) : r.text, r.text.isEmpty) } catch { onDraft(DiaryChatService.fallbackDraft(turns: turns), true) } } } /// 关闭:聊过内容才二次确认(避免误关丢失),否则直接关。 private func attemptClose() { if hasUserTurns { showAbandonConfirm = true } else { onClose() } } } #Preview { // 无真实数据时,开场 openingLine 落 staticOpening 兜底,正好看整页布局。 DiaryCompanionView(onDraft: { _, _ in }, onClose: {}) .modelContainer(for: [ Indicator.self, Report.self, DiaryEntry.self, Asset.self, ChatTurn.self, Symptom.self, UserProfile.self, MetricReminder.self, CustomMonitorMetric.self, HealthExport.self ], inMemory: true) }