import SwiftUI import SwiftData /// 「记录问诊」录入 sheet(2026-06-28)。 /// /// 流程:开始录音 → 端侧实时转写(`ConsultationRecorder`)→ 停止 → AI 整理成问诊小结 /// (`DiaryAssistService.organizeConsultation`)→ 审阅可编辑 → 保存为带「问诊」tag 的 DiaryEntry /// (录音作为 audio/m4a Asset 落加密 Vault)。 /// /// 守红线:UI 不直接调 AIRuntime(经 DiaryAssistService);识别 + 录音全程本机; /// AI 整理失败回退录音原文(#5);录音落盘尽力而为,失败也照常存文字。 /// 本机不支持端侧识别(模拟器/老机型)→ 退化为手动文字录入,功能仍可用。 struct ConsultationSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss enum Phase: Equatable { case idle, recording, transcribing, organizing, review } @State private var phase: Phase = .idle @State private var liveTranscript = "" @State private var recordingSeconds = 0 /// 审阅区可编辑的问诊小结(整理稿;整理失败时是录音原文)。最终落库的就是它。 @State private var note = "" /// 最终转写原话:「改用原话」回退用,也作整理失败兜底。 @State private var rawTranscript = "" /// 录音临时文件(tmp 里的 m4a)。保存时 importFile 搬进 Vault;未保存则 onDisappear 删掉。 @State private var audioTempURL: URL? @State private var createdAt: Date = .now @State private var decodeRate: Double = 0 @State private var voiceNote: String? @State private var deniedAlert = false @State private var saved = false @State private var recordTask: Task? @State private var organizeTask: Task? @State private var watchdog: Task? /// 必须 @State:struct View 重建时普通 let 会换新实例,导致 stop() 落在没在录音的新实例上、 /// 而真正录音的老实例关不掉麦克风悬挂(同 DiaryQuickSheet.dictation 的注释)。 @State private var recorder = ConsultationRecorder() @FocusState private var noteFocused: Bool /// 录音上限 10 分钟(超时由看门狗触发停止并整理)。 private let maxSeconds = 600 /// 本机不支持端侧识别 → 手动录入模式(进入即可打字,无录音)。 private var manualMode: Bool { !ConsultationRecorder.isAvailable } private var canSave: Bool { !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { VStack(spacing: 0) { grabber header ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { if let note = voiceNote { noteBanner(note) } phaseContent } .padding(.horizontal, 20) .padding(.top, 4) .padding(.bottom, 8) } .scrollDismissesKeyboard(.interactively) bottomBar } .background( Tj.Palette.sand .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) .ignoresSafeArea(edges: .bottom) ) .presentationDetents([.large]) .presentationDragIndicator(.hidden) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) .onDisappear(perform: cleanup) .alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $deniedAlert) { Button(String(appLoc: "前往设置")) { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } Button(String(appLoc: "取消"), role: .cancel) {} } message: { Text("问诊录音全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。") } } // MARK: - Header private var grabber: some View { Capsule() .fill(Tj.Palette.line) .frame(width: 40, height: 4) .padding(.top, 10) .padding(.bottom, 14) } private var header: some View { HStack(alignment: .center, spacing: 12) { Button { dismiss() } label: { Image(systemName: "xmark") .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) } .buttonStyle(.plain) VStack(alignment: .leading, spacing: 2) { Text("记录问诊") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("录音 → 本机转写 → AI 整理成问诊小结") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer() TjLockChip() } .padding(.horizontal, 20) .padding(.bottom, 12) } // MARK: - Phase content @ViewBuilder private var phaseContent: some View { switch phase { case .idle: if manualMode { manualEntry } else { idleStart } case .recording: recordingCard case .transcribing: transcribingCard case .organizing: organizingCard case .review: reviewArea } } /// 待开始:大录音按钮 + 三条说明(本机识别 / 可回放 / 不上传)。 private var idleStart: some View { VStack(spacing: 18) { Button(action: startRecording) { VStack(spacing: 10) { ZStack { Circle().fill(Tj.Palette.ink2) .frame(width: 76, height: 76) .shadow(color: Tj.Palette.ink2.opacity(0.3), radius: 12, y: 4) Image(systemName: "mic.fill") .font(.tjScaled( 30, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) } Text("开始录音") .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.ink2) } .frame(maxWidth: .infinity) .padding(.vertical, 22) .contentShape(Rectangle()) } .buttonStyle(.plain) VStack(alignment: .leading, spacing: 10) { bullet("stethoscope", String(appLoc: "看医生时录下来,事后不怕记不全")) bullet("waveform", String(appLoc: "录音结束后在本机转写,原声存进加密档案")) bullet("sparkles", String(appLoc: "康康自动整理成主诉/医生建议/复查小节")) } .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) ) Text("仅供个人记录,不构成诊断或用药建议。") .font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) } .padding(.top, 6) } private func bullet(_ icon: String, _ text: String) -> some View { HStack(alignment: .top, spacing: 10) { Image(systemName: icon) .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.ink2) .frame(width: 20) Text(text) .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } } /// 录音中:计时 + 实时字幕 + 结束按钮。 private var recordingCard: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: "waveform") .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) .symbolEffect(.variableColor.iterative, options: .repeating) Text("正在录音 · 声音不上传") .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.text2) Spacer(minLength: 0) Text(Self.timeText(recordingSeconds)) .font(.tjScaled( 13, design: .monospaced)) .foregroundStyle(recordingSeconds >= maxSeconds - 30 ? Tj.Palette.brick : Tj.Palette.text3) } // 离线转写(SenseVoice)无实时字幕:录音中只给声纹动效 + 说明,结束后整段转写。 VStack(spacing: 10) { Image(systemName: "waveform") .font(.tjScaled( 44, weight: .regular)) .foregroundStyle(Tj.Palette.ink2) .symbolEffect(.variableColor.iterative, options: .repeating) Text("正在录音,结束后会在本机把整段录音转成文字") .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, minHeight: 150) .padding(.vertical, 8) Button(action: stopAndOrganize) { HStack(spacing: 8) { Image(systemName: "stop.circle.fill") Text("结束并整理") } .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) .frame(maxWidth: .infinity) .padding(.vertical, 13) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.brick) ) .contentShape(Rectangle()) } .buttonStyle(.plain) } .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) ) } /// 录音转写中:整段录音离线转文字(优先本地 SenseVoice,不可用回退本机识别)。无实时字幕,统一转写。 private var transcribingCard: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: "waveform") .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) .symbolEffect(.variableColor.iterative, options: .repeating) Text(SenseVoiceASRService.isAvailable ? String(appLoc: "正在转写录音 · 本地 SenseVoice") : String(appLoc: "正在转写录音 · 本机识别")) .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.text2) Spacer(minLength: 0) } Text("录音已结束,正在本机把整段录音转成文字,请稍候…") .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) AIFlowBar() } .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) ) } /// AI 整理中:呼吸流光 + 置灰原话预览 + 取消(取消即用原话)。 private var organizingCard: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: "sparkles") .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) .symbolEffect(.pulse, options: .repeating) Text(decodeRate > 0 ? String(format: String(appLoc: "正在整理问诊小结 · %.1f tok/s"), decodeRate) : String(appLoc: "正在整理问诊小结 · 本地推理")) .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.text2) Spacer(minLength: 0) Button(String(appLoc: "用原话")) { cancelOrganize() } .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) .buttonStyle(.plain) } Text(rawTranscript) .font(.tjScaled( 14)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) .lineLimit(6) AIFlowBar() } .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 var reviewArea: some View { VStack(alignment: .leading, spacing: 14) { if audioTempURL != nil { HStack(spacing: 6) { Image(systemName: "waveform.circle.fill") .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.ink2) Text("录音已保存,可在记录详情里回放") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text2) Spacer(minLength: 0) } } VStack(alignment: .leading, spacing: 8) { HStack { Text(String(appLoc: "问诊小结")) .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) Spacer() if decodeRate > 0 { Text(String(format: "%.1f tok/s", decodeRate)) .font(.tjScaled( 10, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) } } TextField(String(appLoc: "整理后的问诊小结,可在这里修改…"), text: $note, axis: .vertical) .font(.tjScaled( 15)) .lineLimit(6...16) .focused($noteFocused) .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) ) HStack(spacing: 10) { if !rawTranscript.isEmpty && note != rawTranscript { reviewChip("arrow.uturn.backward", String(appLoc: "改用原话")) { withAnimation(.snappy(duration: 0.18)) { note = rawTranscript } } } if !rawTranscript.isEmpty { reviewChip("arrow.clockwise", String(appLoc: "重新整理")) { reorganize() } } Spacer(minLength: 0) } } VStack(alignment: .leading, spacing: 8) { Text(String(appLoc: "时间")) .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) DatePicker("", selection: $createdAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() } AIDisclaimerFooter() } } private func reviewChip(_ icon: String, _ label: String, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 4) { Image(systemName: icon).font(.tjScaled( 10, weight: .semibold)) Text(label).font(.tjScaled( 11, weight: .semibold)) } .foregroundStyle(Tj.Palette.ink) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) .contentShape(Capsule()) } .buttonStyle(.plain) } /// 手动模式(本机不支持识别):直接打字录入问诊小结。 private var manualEntry: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { Image(systemName: "info.circle") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Text("本机暂不支持录音转写,可手动记录这次问诊") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer(minLength: 0) } TextField(String(appLoc: "记录这次问诊:主诉、医生说了什么、用药、复查…"), text: $note, axis: .vertical) .font(.tjScaled( 15)) .lineLimit(6...16) .focused($noteFocused) .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) ) DatePicker("", selection: $createdAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() } .padding(.top, 6) } // MARK: - Bottom bar @ViewBuilder private var bottomBar: some View { switch phase { case .idle where manualMode: HStack(spacing: 8) { Button(String(appLoc: "取消")) { dismiss() } .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14)) Button(String(appLoc: "保存")) { save() } .buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14)) .disabled(!canSave) .opacity(canSave ? 1 : 0.4) } .padding(.horizontal, 20) .padding(.vertical, 14) case .idle: // 非手动模式的待开始页:开始录音是内容区大按钮,关闭走 header 的 ✕,底部不再占一行。 EmptyView() case .review: HStack(spacing: 8) { Button(String(appLoc: "重新录音")) { restartRecording() } .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14)) Button(String(appLoc: "保存")) { save() } .buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14)) .disabled(!canSave) .opacity(canSave ? 1 : 0.4) } .padding(.horizontal, 20) .padding(.vertical, 14) case .recording, .transcribing, .organizing: EmptyView() } } private func noteBanner(_ text: String) -> some View { HStack(spacing: 6) { Image(systemName: "exclamationmark.circle.fill") .font(.tjScaled( 11)).foregroundStyle(Tj.Palette.amber) Text(text).font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text2) Spacer(minLength: 0) } } // MARK: - Actions private func startRecording() { voiceNote = nil noteFocused = false recordTask = Task { @MainActor in guard await recorder.requestAuthorization() else { deniedAlert = true return } do { liveTranscript = "" recordingSeconds = 0 try recorder.start { partial in liveTranscript = partial } withAnimation(.snappy(duration: 0.2)) { phase = .recording } watchdog = 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 >= maxSeconds { stopAndOrganize(); return } } } } catch { #if DEBUG print("[Consultation] recorder start failed: \(error)") #endif voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限") phase = .idle } } } private func stopAndOrganize() { guard phase == .recording else { return } watchdog?.cancel() organizeTask = Task { @MainActor in let result = await recorder.stop() audioTempURL = result.audioTempURL // 系统端侧识别(SFSpeech,录音时顺带跑)作为 SenseVoice 不可用 / 失败时的自动回退稿。 var sfFallback = result.transcript.trimmingCharacters(in: .whitespacesAndNewlines) if sfFallback.isEmpty { sfFallback = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines) } // 离线转写整段录音:优先本地 SenseVoice(MNN),不可用 / 失败 / 空 → 回退 SFSpeech(守红线 #5)。 withAnimation(.snappy(duration: 0.2)) { phase = .transcribing } var transcript = "" if SenseVoiceASRService.isAvailable, let url = result.audioTempURL { transcript = (try? await SenseVoiceASRService.shared.transcribe(audioFileURL: url)) ?? "" } if Task.isCancelled { return } if transcript.isEmpty { transcript = sfFallback } rawTranscript = transcript guard !transcript.isEmpty else { // 没听清:进审阅让用户手动补;录音(若有)仍可保存。 voiceNote = String(appLoc: "没听清,可手动补充或重新录音") note = "" withAnimation(.snappy(duration: 0.2)) { phase = .review } return } withAnimation(.snappy(duration: 0.2)) { phase = .organizing } await runOrganize(transcript: transcript) } } /// 调用 LLM 整理;成功填小结,取消/失败回退原话(红线 #5)。供首次整理与「重新整理」复用。 private func runOrganize(transcript: String) async { do { let organized = try await DiaryAssistService.shared.organizeConsultation(transcript: transcript) guard !Task.isCancelled else { return } note = organized.text decodeRate = organized.decodeRate } catch is CancellationError { return // cancelOrganize 已处理回退 } catch { note = transcript voiceNote = String(appLoc: "AI 整理没成功,已填入录音原文") } withAnimation(.snappy(duration: 0.2)) { phase = .review } } private func cancelOrganize() { guard phase == .organizing else { return } organizeTask?.cancel() note = rawTranscript withAnimation(.snappy(duration: 0.2)) { phase = .review } } /// 审阅区「重新整理」:从原话再跑一遍 LLM(首次整理结果不满意时)。 private func reorganize() { guard !rawTranscript.isEmpty else { return } organizeTask?.cancel() decodeRate = 0 withAnimation(.snappy(duration: 0.2)) { phase = .organizing } organizeTask = Task { @MainActor in await runOrganize(transcript: rawTranscript) } } /// 审阅区「重新录音」:丢弃当前录音/小结,回到待开始。 private func restartRecording() { organizeTask?.cancel() if let url = audioTempURL { try? FileManager.default.removeItem(at: url) } audioTempURL = nil note = "" rawTranscript = "" liveTranscript = "" decodeRate = 0 voiceNote = nil withAnimation(.snappy(duration: 0.2)) { phase = .idle } } private func save() { let content = note.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } let entry = DiaryEntry(content: content, createdAt: createdAt, tags: [DiaryEntry.consultationTag]) ctx.insert(entry) // 录音搬进加密 Vault 并挂为 Asset(尽力而为:失败也照常存文字)。 if let temp = audioTempURL, let savedAsset = try? FileVault.shared.importFile(at: temp, preferredExtension: "m4a") { let asset = Asset(relativePath: savedAsset.relativePath, mimeType: "audio/m4a", bytes: savedAsset.bytes) ctx.insert(asset) entry.assets.append(asset) audioTempURL = nil // 已搬走,cleanup 不再删 } try? ctx.save() saved = true dismiss() } /// 关闭时收尾:停录音、撤任务、删未保存的录音临时文件。 private func cleanup() { recordTask?.cancel() organizeTask?.cancel() watchdog?.cancel() recorder.abort() if !saved, let url = audioTempURL { try? FileManager.default.removeItem(at: url) } } private static func timeText(_ seconds: Int) -> String { String(format: "%d:%02d", seconds / 60, seconds % 60) } } #Preview { ConsultationSheet() .modelContainer(for: [DiaryEntry.self, Asset.self], inMemory: true) }