feat(语音日记): DiaryQuickSheet 接入语音输入(录音→整理→回退原话)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,24 @@ struct DiaryQuickSheet: View {
|
|||||||
@State private var detent: PresentationDetent = .large
|
@State private var detent: PresentationDetent = .large
|
||||||
@FocusState private var contentFocused: Bool
|
@FocusState private var contentFocused: Bool
|
||||||
|
|
||||||
|
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
||||||
|
|
||||||
|
enum VoicePhase: Equatable { case idle, recording, organizing }
|
||||||
|
@State private var voicePhase: VoicePhase = .idle
|
||||||
|
@State private var liveTranscript = ""
|
||||||
|
@State private var recordingSeconds = 0
|
||||||
|
/// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。
|
||||||
|
@State private var rawTranscript: String?
|
||||||
|
/// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。
|
||||||
|
/// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。
|
||||||
|
@State private var organizedAppended: String?
|
||||||
|
/// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。
|
||||||
|
@State private var voiceNote: String?
|
||||||
|
@State private var voiceDeniedAlert = false
|
||||||
|
@State private var voiceFlowTask: Task<Void, Never>?
|
||||||
|
@State private var recordingWatchdog: Task<Void, Never>?
|
||||||
|
private let dictation = SpeechDictationService()
|
||||||
|
|
||||||
private var hasContent: Bool {
|
private var hasContent: Bool {
|
||||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
}
|
||||||
@@ -45,7 +63,7 @@ struct DiaryQuickSheet: View {
|
|||||||
if case .loading = phase { return true }
|
if case .loading = phase { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
private var canRequestSuggest: Bool { hasContent && !isLoading }
|
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||||||
private var canSubmit: Bool { hasContent }
|
private var canSubmit: Bool { hasContent }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -77,7 +95,29 @@ struct DiaryQuickSheet: View {
|
|||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
sectionLabel(String(appLoc: "内容"))
|
sectionLabel(String(appLoc: "内容"))
|
||||||
|
Spacer()
|
||||||
|
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||||||
|
Button(action: startVoice) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.tjScaled(11, weight: .semibold))
|
||||||
|
Text("说一段")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().strokeBorder(
|
||||||
|
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
|
||||||
|
lineWidth: 1))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isLoading) // AI 追问生成中不抢 AIRuntime 队列
|
||||||
|
}
|
||||||
|
}
|
||||||
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
|
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
|
||||||
text: $content, axis: .vertical)
|
text: $content, axis: .vertical)
|
||||||
.lineLimit(3...8)
|
.lineLimit(3...8)
|
||||||
@@ -93,6 +133,48 @@ struct DiaryQuickSheet: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if voicePhase != .idle {
|
||||||
|
DiaryVoicePanel(
|
||||||
|
mode: voicePhase == .organizing
|
||||||
|
? .organizing
|
||||||
|
: .recording(elapsedSeconds: recordingSeconds),
|
||||||
|
transcript: liveTranscript,
|
||||||
|
onStop: stopVoiceAndOrganize,
|
||||||
|
onCancelOrganize: cancelOrganize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let note = voiceNote {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(note)
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let organized = organizedAppended,
|
||||||
|
rawTranscript != nil,
|
||||||
|
content.range(of: organized) != nil {
|
||||||
|
Button(action: revertToRawTranscript) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "arrow.uturn.backward")
|
||||||
|
.font(.tjScaled(10, weight: .semibold))
|
||||||
|
Text("改用原话")
|
||||||
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assistSection
|
assistSection
|
||||||
@@ -143,7 +225,22 @@ struct DiaryQuickSheet: View {
|
|||||||
.presentationDragIndicator(.hidden)
|
.presentationDragIndicator(.hidden)
|
||||||
.presentationBackground(Tj.Palette.sand)
|
.presentationBackground(Tj.Palette.sand)
|
||||||
.presentationCornerRadius(Tj.Radius.xl)
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
.onDisappear { suggestTask?.cancel() }
|
.onDisappear {
|
||||||
|
suggestTask?.cancel()
|
||||||
|
voiceFlowTask?.cancel()
|
||||||
|
recordingWatchdog?.cancel()
|
||||||
|
dictation.abort()
|
||||||
|
}
|
||||||
|
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
|
||||||
|
Button(String(appLoc: "前往设置")) {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AI 辅助区
|
// MARK: - AI 辅助区
|
||||||
@@ -455,6 +552,95 @@ struct DiaryQuickSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: 语音输入流程
|
||||||
|
|
||||||
|
private func startVoice() {
|
||||||
|
contentFocused = false
|
||||||
|
voiceNote = nil
|
||||||
|
voiceFlowTask = Task { @MainActor in
|
||||||
|
guard await dictation.requestAuthorization() else {
|
||||||
|
voiceDeniedAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
liveTranscript = ""
|
||||||
|
recordingSeconds = 0
|
||||||
|
try dictation.start { partial in liveTranscript = partial }
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
|
||||||
|
// 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致)
|
||||||
|
recordingWatchdog = Task { @MainActor in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard !Task.isCancelled, voicePhase == .recording else { return }
|
||||||
|
recordingSeconds += 1
|
||||||
|
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
|
||||||
|
stopVoiceAndOrganize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
voiceNote = error.localizedDescription
|
||||||
|
voicePhase = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopVoiceAndOrganize() {
|
||||||
|
guard voicePhase == .recording else { return }
|
||||||
|
recordingWatchdog?.cancel()
|
||||||
|
voiceFlowTask = Task { @MainActor in
|
||||||
|
let transcript = (await dictation.stop())
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
liveTranscript = transcript
|
||||||
|
guard !transcript.isEmpty else {
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
voiceNote = String(appLoc: "没听清,再试一次")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawTranscript = transcript
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
|
||||||
|
do {
|
||||||
|
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
appendToContent(result.text)
|
||||||
|
organizedAppended = result.text
|
||||||
|
lastRate = result.decodeRate
|
||||||
|
} catch is CancellationError {
|
||||||
|
// cancelOrganize 已处理回退,这里只收尾
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||||||
|
organizedAppended = nil
|
||||||
|
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。
|
||||||
|
private func cancelOrganize() {
|
||||||
|
guard voicePhase == .organizing else { return }
|
||||||
|
voiceFlowTask?.cancel()
|
||||||
|
if let raw = rawTranscript {
|
||||||
|
appendToContent(raw)
|
||||||
|
organizedAppended = nil
|
||||||
|
voiceNote = String(appLoc: "已取消整理,填入原话")
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。
|
||||||
|
private func revertToRawTranscript() {
|
||||||
|
guard let raw = rawTranscript,
|
||||||
|
let organized = organizedAppended,
|
||||||
|
let range = content.range(of: organized, options: .backwards) else { return }
|
||||||
|
withAnimation(.snappy(duration: 0.18)) {
|
||||||
|
content = content.replacingCharacters(in: range, with: raw)
|
||||||
|
organizedAppended = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
||||||
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
||||||
private func requestSuggestions() {
|
private func requestSuggestions() {
|
||||||
|
|||||||
Reference in New Issue
Block a user