From 8c8599e77dd272b7502966b07ef72279252d6832 Mon Sep 17 00:00:00 2001 From: link2026 Date: Wed, 10 Jun 2026 06:24:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AF=AD=E9=9F=B3=E6=97=A5=E8=AE=B0):=20D?= =?UTF-8?q?iaryQuickSheet=20=E6=8E=A5=E5=85=A5=E8=AF=AD=E9=9F=B3=E8=BE=93?= =?UTF-8?q?=E5=85=A5(=E5=BD=95=E9=9F=B3=E2=86=92=E6=95=B4=E7=90=86?= =?UTF-8?q?=E2=86=92=E5=9B=9E=E9=80=80=E5=8E=9F=E8=AF=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- 康康/Features/Diary/DiaryQuickSheet.swift | 192 +++++++++++++++++++++- 1 file changed, 189 insertions(+), 3 deletions(-) diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index 5141c22..462807a 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -37,6 +37,24 @@ struct DiaryQuickSheet: View { @State private var detent: PresentationDetent = .large @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? + @State private var recordingWatchdog: Task? + private let dictation = SpeechDictationService() + private var hasContent: Bool { !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -45,7 +63,7 @@ struct DiaryQuickSheet: View { if case .loading = phase { return true } return false } - private var canRequestSuggest: Bool { hasContent && !isLoading } + private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle } private var canSubmit: Bool { hasContent } var body: some View { @@ -77,7 +95,29 @@ struct DiaryQuickSheet: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { - sectionLabel(String(appLoc: "内容")) + HStack { + 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("今天身体怎么样?吃了什么药、有什么感觉?", text: $content, axis: .vertical) .lineLimit(3...8) @@ -93,6 +133,48 @@ struct DiaryQuickSheet: View { RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .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 @@ -143,7 +225,22 @@ struct DiaryQuickSheet: View { .presentationDragIndicator(.hidden) .presentationBackground(Tj.Palette.sand) .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 辅助区 @@ -455,6 +552,95 @@ struct DiaryQuickSheet: View { .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, /// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。 private func requestSuggestions() {