根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
``` docs(readme): 更新文档说明 - 添加项目使用说明 - 完善配置指南 - 修正错误描述 ```
This commit is contained in:
188
康康/Features/Diary/CompanionAvatarView.swift
Normal file
188
康康/Features/Diary/CompanionAvatarView.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
/// 「聊着记」里的动画小形象「康康」——纯 SwiftUI Shape 拼出的圆脸小生物,零图片资源、零第三方库。
|
||||
/// 全屏语音记录界面用它给「正在听 / 正在想 / 正在说」这几个抽象状态一个有生命感、可爱的落点。
|
||||
///
|
||||
/// 为什么所有连续动画都由「时间 t」纯函数驱动(而非 @State + withAnimation.repeatForever):
|
||||
/// 参照本目录外 `DesignSystem/AIFlowBar.swift` 的先例——这个形象所处的场景(语音流式吐字、每 0.5s
|
||||
/// 刷 tok/s)父视图在高频重绘,隐式 repeatForever 动画会被反复打断/重置 → 看起来「几乎不动」。
|
||||
/// 改用 `TimelineView(.animation)` 按屏幕刷新率直接从时间算出每一帧的形变,与父视图重绘彻底解耦,
|
||||
/// 任何场景下都匀速呼吸/眨眼/说话。
|
||||
///
|
||||
/// 呼吸缩放、眨眼、嘴形、思考点这四种连续形变全部抽成 `nonisolated static` 纯函数(只依赖 t 与 mood,
|
||||
/// 无任何共享状态),既方便单测逐帧断言,也让 body 只负责把算好的数值贴到 Shape 上。mood 之间的离散
|
||||
/// 差异(腮红透明度、眼睛上移、声波环有无)则用 `.animation(.snappy, value: mood)` 平滑过渡。
|
||||
struct CompanionAvatarView: View {
|
||||
/// nonisolated:Equatable 合成实现要在 nonisolated static 纯函数里比较 mood,
|
||||
/// 不标会被默认 MainActor 隔离推成主 actor 隔离(Swift 6 报错)。
|
||||
nonisolated enum Mood: Equatable { case idle, listening, thinking, speaking }
|
||||
|
||||
var mood: Mood
|
||||
var size: CGFloat = 156
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
|
||||
let t = timeline.date.timeIntervalSinceReferenceDate
|
||||
ZStack {
|
||||
// 1 背景光环:最底层的一圈暖沙色,给小家伙一个「站台」
|
||||
Circle()
|
||||
.fill(Tj.Palette.sand2)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
// 2 倾听声波环 ×2:仅 .listening 出现,由内向外扩散淡出,第二环相位 +0.8s 错开
|
||||
if mood == .listening {
|
||||
Group {
|
||||
soundRing(t: t, phase: 0, color: Tj.Palette.teal)
|
||||
soundRing(t: t, phase: 0.8, color: Tj.Palette.tealSoft)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
// 3-6 脸部整体(身体 + 腮红 + 眼 + 嘴):作为一个整体一起呼吸、一起歪头
|
||||
face(t: t)
|
||||
.frame(width: size, height: size)
|
||||
.scaleEffect(Self.breathScale(t: t, mood: mood), anchor: .bottom)
|
||||
.rotationEffect(.degrees(headTilt(t: t)), anchor: .bottom)
|
||||
|
||||
// 7 思考点 ×3:仅 .thinking 出现,头顶右上斜排,循环点亮
|
||||
if mood == .thinking {
|
||||
thinkingDots(t: t)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.animation(.snappy(duration: 0.25), value: mood)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 子视图(实例方法,默认 MainActor;只把纯函数算出的数值贴到 Shape 上)
|
||||
|
||||
/// 脸:身体 Ellipse 打底,再叠腮红 / 眼睛 / 嘴。整组由外层统一做呼吸缩放与歪头旋转。
|
||||
private func face(t: Double) -> some View {
|
||||
let blushOpacity: Double = (mood == .listening || mood == .speaking) ? 1.0 : 0.7
|
||||
let eyeLift: CGFloat = (mood == .thinking) ? -0.02 * size : 0 // 思考时眼睛上移「往上看」
|
||||
let mouthWidth: CGFloat = (mood == .thinking) ? 0.05 * size : 0.16 * size
|
||||
|
||||
return ZStack {
|
||||
// 身体:柔和纸色椭圆 + 细描边 + 极浅暖影
|
||||
Ellipse()
|
||||
.fill(Tj.Palette.paper)
|
||||
.frame(width: 0.78 * size, height: 0.72 * size)
|
||||
.overlay(Ellipse().strokeBorder(Tj.Palette.line, lineWidth: 1.5))
|
||||
.shadow(color: Tj.Palette.shadow.opacity(0.08),
|
||||
radius: 0.05 * size, x: 0, y: 0.018 * size)
|
||||
|
||||
// 腮红 ×2
|
||||
ForEach([-1.0, 1.0], id: \.self) { side in
|
||||
Circle()
|
||||
.fill(Tj.Palette.brickSoft)
|
||||
.frame(width: 0.09 * size, height: 0.09 * size)
|
||||
.opacity(blushOpacity)
|
||||
.offset(x: CGFloat(side) * 0.24 * size, y: 0.075 * size)
|
||||
}
|
||||
|
||||
// 眼睛 ×2:竖长胶囊,眨眼 = 竖向压扁;.thinking 时整体上移
|
||||
ForEach([-1.0, 1.0], id: \.self) { side in
|
||||
Capsule()
|
||||
.fill(Tj.Palette.ink)
|
||||
.frame(width: 0.055 * size, height: 0.11 * size)
|
||||
.scaleEffect(x: 1, y: Self.blinkScaleY(t: t, mood: mood))
|
||||
.offset(x: CGFloat(side) * 0.155 * size, y: -0.03 * size + eyeLift)
|
||||
}
|
||||
|
||||
// 嘴:横胶囊,高度随状态开合;.thinking 时收成 0.05·size 小圆点
|
||||
Capsule()
|
||||
.fill(Tj.Palette.ink)
|
||||
.frame(width: mouthWidth,
|
||||
height: Self.mouthHeight(t: t, mood: mood, size: size))
|
||||
.offset(y: 0.135 * size)
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个声波环:base 直径 0.82·size,随相位小数部分从 ×1 放大到 ×1.35 并线性淡出。
|
||||
private func soundRing(t: Double, phase: Double, color: Color) -> some View {
|
||||
let f = Self.frac((t + phase) / 1.6)
|
||||
return Circle()
|
||||
.strokeBorder(color, lineWidth: 2)
|
||||
.frame(width: 0.82 * size, height: 0.82 * size)
|
||||
.scaleEffect(1 + 0.35 * CGFloat(f))
|
||||
.opacity(1 - f)
|
||||
}
|
||||
|
||||
/// 思考点 ×3:直径 0.05·size,沿右上 45° 斜线排开,逐个点亮成「思考中」气泡。
|
||||
private func thinkingDots(t: Double) -> some View {
|
||||
ZStack {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(Tj.Palette.amber)
|
||||
.frame(width: 0.05 * size, height: 0.05 * size)
|
||||
.opacity(Self.thinkingDotOpacity(t: t, index: i))
|
||||
.offset(x: (0.29 + 0.085 * CGFloat(i)) * size,
|
||||
y: -(0.29 + 0.085 * CGFloat(i)) * size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 歪头角度(度):仅 .thinking 时以 2.6s 为周期在 ±2° 间轻摆,其余恒 0。
|
||||
private func headTilt(t: Double) -> Double {
|
||||
mood == .thinking ? 2 * sin(2 * .pi * t / 2.6) : 0
|
||||
}
|
||||
|
||||
// MARK: - 纯函数(nonisolated,供单测逐帧断言;只依赖 t 与 mood,无共享状态)
|
||||
|
||||
/// 呼吸缩放:各状态不同周期/幅度的正弦。idle 最慢最柔,speaking 最快最促。
|
||||
nonisolated static func breathScale(t: Double, mood: Mood) -> CGFloat {
|
||||
switch mood {
|
||||
case .idle: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 3.2))
|
||||
case .listening: return CGFloat(1 + 0.03 * sin(2 * .pi * t / 1.6))
|
||||
case .thinking: return CGFloat(1 + 0.015 * sin(2 * .pi * t / 2.6))
|
||||
case .speaking: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
/// 眨眼竖向缩放:约每 3.4s 眨一次(压到 0.15 持续 0.12s),.thinking 恒 0.85 凝神不眨。
|
||||
nonisolated static func blinkScaleY(t: Double, mood: Mood) -> CGFloat {
|
||||
if mood == .thinking { return 0.85 }
|
||||
var phase = t.truncatingRemainder(dividingBy: 3.4)
|
||||
if phase < 0 { phase += 3.4 } // 兜住负 t,让单测传任意时刻都成立
|
||||
return phase <= 0.12 ? 0.15 : 1
|
||||
}
|
||||
|
||||
/// 嘴高度:idle 微笑一条线,listening/thinking 微张,speaking 随 0.28s 周期开合。
|
||||
nonisolated static func mouthHeight(t: Double, mood: Mood, size: CGFloat) -> CGFloat {
|
||||
switch mood {
|
||||
case .idle: return 0.035 * size
|
||||
case .listening: return 0.05 * size
|
||||
case .thinking: return 0.05 * size
|
||||
case .speaking: return 0.03 * size + 0.05 * size * CGFloat(abs(sin(2 * .pi * t / 0.28)))
|
||||
}
|
||||
}
|
||||
|
||||
/// 思考点透明度:三点各错相位 120° 的正弦,负半周压到 0,形成「跑马灯」逐个点亮。
|
||||
nonisolated static func thinkingDotOpacity(t: Double, index: Int) -> Double {
|
||||
0.25 + 0.75 * max(0.0, sin(2 * .pi * t / 0.9 - Double(index) * 2 * .pi / 3))
|
||||
}
|
||||
|
||||
/// 取小数部分(声波环相位用)。
|
||||
private static func frac(_ x: Double) -> Double { x - floor(x) }
|
||||
}
|
||||
|
||||
#Preview("四种情绪") {
|
||||
HStack(spacing: 18) {
|
||||
ForEach(0..<4, id: \.self) { i in
|
||||
VStack(spacing: 12) {
|
||||
CompanionAvatarView(
|
||||
mood: [.idle, .listening, .thinking, .speaking][i],
|
||||
size: 116
|
||||
)
|
||||
Text(["idle", "listening", "thinking", "speaking"][i])
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(36)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Tj.Palette.sand)
|
||||
}
|
||||
591
康康/Features/Diary/DiaryCompanionView.swift
Normal file
591
康康/Features/Diary/DiaryCompanionView.swift
Normal file
@@ -0,0 +1,591 @@
|
||||
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)
|
||||
}
|
||||
@@ -72,6 +72,11 @@ struct DiaryQuickSheet: View {
|
||||
/// 且真正在录音的老实例关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一。
|
||||
@State private var dictation = SpeechDictationService()
|
||||
|
||||
// MARK: 「聊着记」形象对话模式
|
||||
|
||||
/// 全屏形象对话(DiaryCompanionView),聊完蒸馏成日记草稿回填正文。
|
||||
@State private var showCompanionChat = false
|
||||
|
||||
private var hasContent: Bool {
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
@@ -155,6 +160,25 @@ struct DiaryQuickSheet: View {
|
||||
sectionLabel(String(appLoc: "内容"))
|
||||
Spacer()
|
||||
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||||
// 「聊着记」:全屏形象对话模式,和「说一段」同显隐条件。
|
||||
Button(action: startCompanionChat) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bubble.left.and.bubble.right.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)
|
||||
|
||||
Button(action: startVoice) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "mic.fill")
|
||||
@@ -305,6 +329,19 @@ struct DiaryQuickSheet: View {
|
||||
onClose: { showMedicationScan = false }
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showCompanionChat) {
|
||||
// 「聊着记」:和康康形象聊天,聊完蒸馏成日记草稿回填正文,由用户确认后走现有保存。
|
||||
DiaryCompanionView(
|
||||
onDraft: { draft, usedFallback in
|
||||
appendToContent(draft)
|
||||
voiceNote = usedFallback
|
||||
? String(appLoc: "AI 整理没成功,已填入你聊天说的原话")
|
||||
: String(appLoc: "康康把聊的内容整理好了,看一眼没问题就点保存")
|
||||
showCompanionChat = false
|
||||
},
|
||||
onClose: { showCompanionChat = false }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showSymptomStart) {
|
||||
// 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。
|
||||
SymptomStartSheet()
|
||||
@@ -645,6 +682,19 @@ struct DiaryQuickSheet: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: 「聊着记」形象对话
|
||||
|
||||
/// 入口闸:端侧模型就绪或云增强可用才放行(§4 模型未就绪仍可启动,AI 入口提示前往下载)。
|
||||
private func startCompanionChat() {
|
||||
guard ModelStore.shared.isComplete(for: .llm) || AIRuntime.shared.cloudAvailable else {
|
||||
voiceNote = String(appLoc: "聊着记需要 AI:请先在「我的 · 模型管理」下载模型")
|
||||
return
|
||||
}
|
||||
contentFocused = false
|
||||
voiceNote = nil
|
||||
showCompanionChat = true
|
||||
}
|
||||
|
||||
// MARK: 语音输入流程
|
||||
|
||||
private func startVoice() {
|
||||
|
||||
Reference in New Issue
Block a user