592 lines
26 KiB
Swift
592 lines
26 KiB
Swift
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<Void, Never>?
|
|
/// 录音计时看门狗(每秒 +1,到点自动停)。
|
|
@State private var recordingWatchdog: Task<Void, Never>?
|
|
/// 最近一次用户原话,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..<DiaryChatPrompts.maxRounds, id: \.self) { i in
|
|
Circle()
|
|
.fill(DiaryChatService.roundsUsed(in: turns) > 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<TokenChunk, Error>,
|
|
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)
|
|
}
|