658 lines
27 KiB
Swift
658 lines
27 KiB
Swift
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<Void, Never>?
|
|
@State private var organizeTask: Task<Void, Never>?
|
|
@State private var watchdog: Task<Void, Never>?
|
|
/// 必须 @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)
|
|
}
|