根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
``` docs(readme): 更新文档说明 - 添加项目使用说明 - 完善配置指南 - 修正错误描述 ```
This commit is contained in:
@@ -28,6 +28,7 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
|
||||
private let lock = NSLock()
|
||||
private var handle: FileHandle?
|
||||
private var written: Int = 0
|
||||
private var expectedTotal: Int = 0
|
||||
private var onProgress: ((Int) -> Void)?
|
||||
private var responseError: Error?
|
||||
private var continuation: CheckedContinuation<Void, Error>?
|
||||
@@ -77,24 +78,33 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
|
||||
lock.withLock {
|
||||
self.handle = fileHandle
|
||||
self.written = offset
|
||||
self.expectedTotal = expectedBytes
|
||||
self.onProgress = onProgress
|
||||
self.responseError = nil
|
||||
}
|
||||
|
||||
// offset 0 也发 `bytes=0-`:诱导服务器回 206 + Content-Range 报告资源总大小,
|
||||
// didReceive 里与清单比对不符立即失败 —— 上游仓库更新导致大小漂移时(2026-07-14 实发),
|
||||
// 秒级报 sizeMismatch,而不是把 3.5GB 下完才在收尾校验发现。
|
||||
var request = URLRequest(url: url)
|
||||
if offset > 0 {
|
||||
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
||||
}
|
||||
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
||||
|
||||
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
// 句柄在 didCompleteWithError 内关闭(同一 delegate 队列,串行于 didReceive)。
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
lock.lock()
|
||||
self.continuation = cont
|
||||
lock.unlock()
|
||||
session.dataTask(with: request).resume()
|
||||
do {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
lock.lock()
|
||||
self.continuation = cont
|
||||
lock.unlock()
|
||||
session.dataTask(with: request).resume()
|
||||
}
|
||||
} catch let error as DownloadError {
|
||||
// 响应头快速失败的大小不符:删 .part,防止换源续传时把两个版本的字节拼进同一文件。
|
||||
// badStatus(如临时 503)保留 .part,便于重试续传。
|
||||
if case .sizeMismatch = error { try? fm.removeItem(at: part) }
|
||||
throw error
|
||||
}
|
||||
|
||||
let finalSize = Self.fileSize(at: part)
|
||||
@@ -119,9 +129,27 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
|
||||
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
||||
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
|
||||
completionHandler(.cancel)
|
||||
} else {
|
||||
completionHandler(.allow)
|
||||
return
|
||||
}
|
||||
// 快速失败:206 的 Content-Range(`bytes a-b/total`)报告了资源真实总大小,与清单不符
|
||||
// 说明服务器上的文件版本变了,立即取消(收尾校验必失败,没必要下完)。
|
||||
// 只信 206 —— 200 的 Content-Length 可能是压缩后长度(gzip),比对会误伤,仍走收尾校验。
|
||||
if let http = response as? HTTPURLResponse, http.statusCode == 206,
|
||||
let contentRange = http.value(forHTTPHeaderField: "Content-Range"),
|
||||
let totalPart = contentRange.split(separator: "/").last,
|
||||
let serverTotal = Int(totalPart) {
|
||||
lock.lock()
|
||||
let expected = expectedTotal
|
||||
lock.unlock()
|
||||
if serverTotal != expected {
|
||||
lock.lock()
|
||||
responseError = DownloadError.sizeMismatch(expected: expected, got: serverTotal)
|
||||
lock.unlock()
|
||||
completionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
nonisolated func urlSession(
|
||||
|
||||
@@ -198,15 +198,17 @@ actor GeminiBackend {
|
||||
}
|
||||
|
||||
/// Gemini `GenerateContentResponse` 的最小可解码子集。
|
||||
private struct GeminiResponse: Decodable {
|
||||
struct Candidate: Decodable { let content: Content? }
|
||||
struct Content: Decodable { let parts: [Part]? }
|
||||
struct Part: Decodable { let text: String? }
|
||||
struct Usage: Decodable {
|
||||
/// nonisolated:工程默认 MainActor 隔离,不标会把 Decodable 合成实现推成 MainActor,
|
||||
/// 在 GeminiBackend 的后台解码上下文用不了(Swift 6 报错)。
|
||||
private nonisolated struct GeminiResponse: Decodable {
|
||||
nonisolated struct Candidate: Decodable { let content: Content? }
|
||||
nonisolated struct Content: Decodable { let parts: [Part]? }
|
||||
nonisolated struct Part: Decodable { let text: String? }
|
||||
nonisolated struct Usage: Decodable {
|
||||
let promptTokenCount: Int?
|
||||
let candidatesTokenCount: Int?
|
||||
}
|
||||
struct APIError: Decodable { let message: String? }
|
||||
nonisolated struct APIError: Decodable { let message: String? }
|
||||
let candidates: [Candidate]?
|
||||
let usageMetadata: Usage?
|
||||
let error: APIError?
|
||||
|
||||
@@ -42,19 +42,23 @@ nonisolated enum ModelManifest {
|
||||
// 经 LLMModelFactory 的 "gemma4" 路径加载(mlx-swift-lm ≥3.31.4 已注册;Gemma4Model 只取
|
||||
// language_model 权重,跳过视觉/音频)。e2b 检查点无视觉权重(视觉在 gemma4_unified 12B)。
|
||||
// 字节数取自 ModelScope mlx-community/gemma-4-e2b-it-4bit 仓库实际 blob 大小
|
||||
//(repo/files API,2026-07 核对)。排除 README.md / .gitattributes / configuration.json
|
||||
//(后者为 ModelScope 元数据,MLX 加载用不到)。Gemma 4 布局与 3n 不同:无 tokenizer.model /
|
||||
// special_tokens_map.json,tokenizer_config.json 精简(chat template 独立在 chat_template.jinja),
|
||||
// 新增 processor_config.json。全部运行文件按上游 mlx-swift-lm 预置仓库快照镜像,避免漏文件。
|
||||
//(Range 请求 Content-Range 总长,2026-07-14 与魔搭 / hf-mirror 双源核对一致)。
|
||||
// 上游 2026-07 中旬更新过一次 checkpoint(权重 3.581G→3.551G,config/tokenizer_config/
|
||||
// processor_config/index 同步变化;model_type 仍 gemma4、4bit,加载路径不受影响)——
|
||||
// 清单值过期会让 FileDownloader 校验 sizeMismatch,表现为「下载失败」,更新须双源重新核对。
|
||||
// 排除 README.md / .gitattributes / configuration.json(后者为 ModelScope 元数据,MLX 加载
|
||||
// 用不到)。Gemma 4 布局与 3n 不同:无 tokenizer.model / special_tokens_map.json,
|
||||
// tokenizer_config.json 精简(chat template 独立在 chat_template.jinja),新增
|
||||
// processor_config.json。全部运行文件按上游 mlx-swift-lm 预置仓库快照镜像,避免漏文件。
|
||||
return [
|
||||
ModelFile(path: "config.json", bytes: 5_996),
|
||||
ModelFile(path: "config.json", bytes: 6_395),
|
||||
ModelFile(path: "generation_config.json", bytes: 208),
|
||||
ModelFile(path: "model.safetensors", bytes: 3_581_101_896),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 230_329),
|
||||
ModelFile(path: "model.safetensors", bytes: 3_550_670_554),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 218_323),
|
||||
ModelFile(path: "tokenizer.json", bytes: 32_169_626),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 2_095),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 2_740),
|
||||
ModelFile(path: "chat_template.jinja", bytes: 17_336),
|
||||
ModelFile(path: "processor_config.json", bytes: 902),
|
||||
ModelFile(path: "processor_config.json", bytes: 1_316),
|
||||
]
|
||||
case .vl:
|
||||
// Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小
|
||||
|
||||
143
康康/AI/Prompts/DiaryChatPrompts.swift
Normal file
143
康康/AI/Prompts/DiaryChatPrompts.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
|
||||
/// 「聊着记」用到的三个 LLM prompt:全屏动画小形象「康康」和用户语音聊天,
|
||||
/// 基于已有健康记录做有记忆的追问,聊完蒸馏成一条健康日记。
|
||||
///
|
||||
/// 人设铁律(贯穿三个 prompt):康康是「陪伴记录的小伙伴」不是医生——
|
||||
/// 不诊断、不荐药、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
|
||||
///
|
||||
/// 数值红线沿用 `DiaryAssistPrompts.organize`:蒸馏时严禁增删改任何数值/单位/药名/时间;
|
||||
/// 语言规则对齐 §「不用「患者」称呼」与 aiOutputLanguageDirective(App/Localization.swift)。
|
||||
///
|
||||
/// nonisolated:纯字符串拼装,无共享状态;`DiaryChatService` 的 nonisolated 纯函数
|
||||
/// (roundsUsed/isWrappedUp)要引用 `maxRounds`,不标会被默认 MainActor 隔离挡住。
|
||||
nonisolated enum DiaryChatPrompts {
|
||||
|
||||
/// 收尾前允许的追问总轮数(开场那句提问不计入)。
|
||||
static let maxRounds = 4
|
||||
|
||||
/// 对话稿截断上限(字符),2B/E2B 端侧模型 context 保护。
|
||||
static let distillTranscriptLimit = 2000
|
||||
|
||||
/// AI 全挂时的确定性开场白(View 兜底用,不经 LLM)。
|
||||
static var staticOpening: String { String(appLoc: "今天身体感觉怎么样?想到什么就说什么,我帮你记着。") }
|
||||
|
||||
// MARK: - 开场白
|
||||
|
||||
/// 用户刚进「聊着记」,康康说的第一句话:自然打招呼 + 基于最近一条记录关心地追问 1 个问题。
|
||||
static func opening(dataJSON: String) -> String {
|
||||
"""
|
||||
你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。
|
||||
用户刚打开「聊着记」,你要说第一句话:先自然地打个招呼,再基于下面【健康记录】里最近一条记录,像老朋友一样关心地问 1 个问题。
|
||||
|
||||
铁律:
|
||||
- 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造。
|
||||
- 数值、日期、药名必须与记录完全一致,一个字都不能改。
|
||||
- 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
|
||||
- 最多 2 句话,只问 1 个问题;不用列表、不用 Markdown、不用表情符号。
|
||||
- 记录里 diaries / indicators 全为空时,不提任何具体记录,只简单问今天感觉怎么样。
|
||||
|
||||
示例 1(记录里有「2026-07-11 日记:头疼了一下午」):
|
||||
前天你记过头疼了一下午,现在好些了吗?
|
||||
|
||||
示例 2(记录里有血压 145/92 偏高):
|
||||
昨天量的血压 145/92 有点偏高,今天有没有再量一次?
|
||||
|
||||
示例 3(记录为空):
|
||||
嗨,今天身体感觉怎么样?想到什么就跟我说说,我帮你记下来。
|
||||
|
||||
【健康记录】:
|
||||
\(dataJSON)
|
||||
|
||||
直接输出这句话,不要引号、不要解释、不要 <think> 标签。
|
||||
\(aiOutputLanguageDirective())
|
||||
/no_think
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - 追问回复
|
||||
|
||||
/// 用户每说一句,康康的回复:先接住,再按剩余轮次决定是否追问。
|
||||
/// - roundsLeft: 收尾前还剩几轮追问机会;> 0 可再追问,== 0 必须温和收尾。
|
||||
static func reply(transcript: String, latest: String, dataJSON: String, roundsLeft: Int) -> String {
|
||||
// 轮次分支在 Swift 里拼,不让模型自己算:两段互斥,收尾分支绝不出现「追问」字样。
|
||||
let roundRule: String = roundsLeft > 0
|
||||
? "本轮还可以追问,最多问 1 个新问题。追问只围绕「把这条记录补完整」:时间、部位、程度、吃没吃药、睡眠饮食等;可以结合【健康记录】做有记忆的追问(如「上次你记过 XX,这次也一样吗」)。"
|
||||
: "【不要再提任何问题】。用 1-2 句话温和收尾:先接住用户刚说的话,再告诉 TA 今天先聊到这儿,点下面的「聊完了」,我帮你整理成一条记录。"
|
||||
return """
|
||||
你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。
|
||||
你正在「聊着记」里陪用户聊天。任务:先用一小句接住用户刚说的话(共情或确认),再按【本轮规则】决定要不要追问。
|
||||
|
||||
铁律:
|
||||
- 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造;数值、日期、药名必须与记录完全一致。
|
||||
- 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
|
||||
- 总共最多 2 句话;不用列表、不用 Markdown、不用表情符号。
|
||||
|
||||
【本轮规则】:
|
||||
\(roundRule)
|
||||
|
||||
示例(还能追问,用户说「下午头就不疼了,不过晚上只睡了五个小时」):
|
||||
不疼了就好。昨晚只睡五个小时,是入睡难还是半夜醒了?
|
||||
|
||||
示例(收尾):
|
||||
好,记下啦。今天先聊到这儿,点下面的「聊完了」,我帮你把这些整理成一条记录。
|
||||
|
||||
【健康记录】:
|
||||
\(dataJSON)
|
||||
|
||||
【对话】:
|
||||
\(transcript.isEmpty ? "无" : transcript)
|
||||
|
||||
【用户刚说的】:
|
||||
\(latest)
|
||||
|
||||
直接输出你要说的话,不要引号、不要解释、不要 <think> 标签。\(aiOutputLanguageDirective())
|
||||
/no_think
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - 蒸馏成日记
|
||||
|
||||
/// 聊完后把整段对话蒸馏成一条健康日记草稿。结构与红线对齐 `DiaryAssistPrompts.organize`:
|
||||
/// 只搬运不改写、严禁增删改数值,且**不拼** aiOutputLanguageDirective——语言跟「我:」原话走。
|
||||
static func distill(transcript: String) -> String {
|
||||
let trimmed = String(transcript.prefix(distillTranscriptLimit))
|
||||
return """
|
||||
你是「康康」,用户健康 App 里陪伴记录的小伙伴。下面是你和用户在「聊着记」里的对话稿。
|
||||
请把用户说的话蒸馏成一条健康日记草稿。
|
||||
|
||||
硬性规则:
|
||||
- 【只取「我:」说的内容作为事实】——「康康:」的话只是提问和陪伴,绝不能把康康提到的记录、猜测或问题当成用户今天发生的事写进日记。
|
||||
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
||||
- 输出语言必须与「我:」的原话完全一致:原话是中文就用中文,是英文就用英文,是日文/韩文就用对应语言,不要翻译。
|
||||
- 用第一人称;去掉口头语和重复;不加入对话里没有的事实。
|
||||
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
||||
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
||||
- 不诊断、不给用药建议、不写「建议就医」。
|
||||
- 只输出日记正文,不要开头语、不要解释、不要 markdown 围栏、不要 <think> 标签。
|
||||
|
||||
示例 1(单方面 → 成段):
|
||||
我: 今天头有点晕早上量了血压140 90
|
||||
康康: 记下啦。上次你记过没吃早饭,今天吃了吗?
|
||||
我: 吃了吃了 喝了粥
|
||||
输出:
|
||||
今天头有点晕,早上量了血压 140/90。早饭吃了,喝了粥。
|
||||
|
||||
示例 2(多方面 → 分行;注意康康的话不进日记):
|
||||
我: 今天头晕了一上午下午好点了血压早上量的140 90缬沙坦吃了
|
||||
康康: 记下啦。上次你血压也偏高,是不是又没睡好?昨晚睡了几个小时?
|
||||
我: 就睡了五个小时中午吃的清淡
|
||||
输出:
|
||||
症状:头晕了一上午,下午好转。
|
||||
血压:早上 140/90。
|
||||
用药:缬沙坦已服。
|
||||
睡眠:只睡了五个小时。
|
||||
饮食:午餐清淡。
|
||||
|
||||
【对话记录】:
|
||||
\(trimmed)
|
||||
|
||||
Output: /no_think
|
||||
"""
|
||||
}
|
||||
}
|
||||
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() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
230
康康/Services/DiaryChatService.swift
Normal file
230
康康/Services/DiaryChatService.swift
Normal file
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// 「聊着记」功能的唯一 AI 门面(红线 #3:UI 不直接调 AIRuntime,一律经 Service)。
|
||||
///
|
||||
/// 场景:全屏动画形象与用户语音多轮对话——康康基于已有健康记录追问,聊完把整段对话蒸馏成日记草稿。
|
||||
/// 对话上下文(健康数据快照)进场调一次 `makeContextJSON` 生成,整场复用;每轮回应流式吐字。
|
||||
///
|
||||
/// 云优先策略(出处:CaptureService.runVL 的「云优先回退端侧」模式):
|
||||
/// - 云端可用(Gemini)时先走 `generateCloud`(不占显存、不进 OOM 闸门);
|
||||
/// - 云端离线 / 超时 / 额度耗尽 / 输出为空,一律静默回退端侧 Gemma(MLX/GPU)。
|
||||
///
|
||||
/// 失败回退语义(§3.2 / 红线 #5「不让用户卡在 AI 错误屏」):
|
||||
/// - `openingLine` / `reply`(流式):
|
||||
/// · 云端【首个有效 token 前】失败 → 静默吞掉,落端侧;
|
||||
/// · 云端【已产出内容后】失败 → `finish(throwing:)`,View 对「已有部分文本」的流错误按说完处理;
|
||||
/// · 端侧 `prepare()` 失败 → `ChatError.modelNotReady`;
|
||||
/// · 两路都走完但全空 → `ChatError.empty`;
|
||||
/// · 取消(`CancellationError`)一律透传,不吞、不回退。
|
||||
/// - `distill`(一次性 await):云端失败 / 清理后为空 → 落端侧;端侧 `prepare()` 失败 → `.modelNotReady`;
|
||||
/// 最终为空 → `.empty`。
|
||||
/// - 纯函数 `roundsUsed` / `isWrappedUp` / `fallbackDraft`:不碰 AI,永不失败。
|
||||
@MainActor
|
||||
struct DiaryChatService {
|
||||
static let shared = DiaryChatService()
|
||||
private init() {}
|
||||
|
||||
enum ChatError: Error, LocalizedError {
|
||||
case modelNotReady // 端侧模型未就绪且云端不可用 / 失败
|
||||
case empty // 两路都生成了但内容为空
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好")
|
||||
case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 上下文快照
|
||||
|
||||
/// 生成整场对话复用的健康数据 JSON(profile + 指标 + 日记 + 用药 …)。
|
||||
/// 只读 `ctx` 做快照,不写 SwiftData;同步、永不失败。
|
||||
/// 调用时机:进场调用一次,把结果缓存在 View 层,后续每轮 `reply` 传同一份,不重复检索。
|
||||
func makeContextJSON(in ctx: ModelContext) -> String {
|
||||
HealthExportService.serializeData(
|
||||
snapshot: HealthExportService.retrieveDialogueSnapshot(ctx: ctx)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 流式:开场白 / 每轮回应
|
||||
|
||||
/// 开场白:一句暖场引导 + 首个追问。160 token——开场较短,一句话足够。
|
||||
func openingLine(dataJSON: String) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
streamWithFallback(prompt: DiaryChatPrompts.opening(dataJSON: dataJSON), maxTokens: 160)
|
||||
}
|
||||
|
||||
/// 每轮回应:共情用户最新一句 + 再追问一步(或收尾)。220 token——含共情 + 追问,比开场略长。
|
||||
/// - roundsUsed: 已发生的追问轮数(取自 `roundsUsed(in:)`)。
|
||||
/// `roundsLeft = 本轮生成后剩余可问轮数`,为 0 时 prompt 内部自动切收尾指令(不再追问)。
|
||||
func reply(transcript: String,
|
||||
latest: String,
|
||||
dataJSON: String,
|
||||
roundsUsed: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
let roundsLeft = max(0, DiaryChatPrompts.maxRounds - roundsUsed - 1)
|
||||
let prompt = DiaryChatPrompts.reply(
|
||||
transcript: transcript,
|
||||
latest: latest,
|
||||
dataJSON: dataJSON,
|
||||
roundsLeft: roundsLeft
|
||||
)
|
||||
return streamWithFallback(prompt: prompt, maxTokens: 220)
|
||||
}
|
||||
|
||||
// MARK: - 蒸馏:聊完 → 日记草稿
|
||||
|
||||
/// 把整段对话蒸馏成一篇健康日记草稿。一次性 await(非流式)。
|
||||
/// 云优先:`cloudAvailable` 时先试云端并收集全文,任何错误 / 清理后为空都静默回退端侧;
|
||||
/// 端侧走到时若 `prepare()` 失败,才抛 `.modelNotReady`(即云端也失败 / 不可用)。
|
||||
/// 400 token:对齐 `DiaryAssistService.organize`(:193)的产物量级——一篇日记草稿。
|
||||
func distill(turns: [HealthExportDialogueTurn]) async throws -> (text: String, decodeRate: Double) {
|
||||
let transcript = HealthExportDialogueTurn.transcript(from: turns)
|
||||
let prompt = DiaryChatPrompts.distill(transcript: transcript)
|
||||
|
||||
// —— 云端优先(CaptureService.runVL 模式:失败静默回退,绝不卡死) ——
|
||||
if AIRuntime.shared.cloudAvailable {
|
||||
do {
|
||||
var collected = ""
|
||||
var rate: Double = 0
|
||||
let stream = await AIRuntime.shared.generateCloud(prompt: prompt, maxTokens: 400)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||
}
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty { return (text, rate) }
|
||||
// 云端走完但清理后为空 → 落端侧(不抛)。
|
||||
} catch {
|
||||
// 云端任何失败(离线 / 超时 / 额度)静默吞掉,落端侧(§3.2 失败回退红线)。
|
||||
}
|
||||
}
|
||||
|
||||
// —— 端侧回退 ——
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
} catch {
|
||||
throw ChatError.modelNotReady
|
||||
}
|
||||
var collected = ""
|
||||
var lastRate: Double = 0
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||
}
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw ChatError.empty }
|
||||
return (text, lastRate)
|
||||
}
|
||||
|
||||
// MARK: - 核心:云优先流式,失败前段回退端侧
|
||||
|
||||
/// 流式生成 + 云→端回退。返回同步创建的流,内部起 Task 逐 token 吐(去 `<think>` 后的 delta)。
|
||||
///
|
||||
/// 「首个有效 token」的界定:经 `ThinkStripper.feed` 清理后【首个非空 delta 被 yield】的那一刻。
|
||||
/// 在此之前云端出任何错 → 静默回退端侧;在此之后出错 → `finish(throwing:)`(View 已有部分文本,按说完处理)。
|
||||
/// 风格对齐 HealthExportService.answer 的既有流式写法。
|
||||
private func streamWithFallback(prompt: String,
|
||||
maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task { @MainActor in
|
||||
// —— 云端优先(CaptureService.runVL 模式) ——
|
||||
var cloudYielded = false // 是否已 yield 过非空内容 = 是否已产出「首个有效 token」
|
||||
if AIRuntime.shared.cloudAvailable {
|
||||
do {
|
||||
var stripper = ThinkStripper()
|
||||
let stream = await AIRuntime.shared.generateCloud(prompt: prompt, maxTokens: maxTokens)
|
||||
for try await chunk in stream {
|
||||
try Task.checkCancellation()
|
||||
let delta = stripper.feed(chunk.text)
|
||||
if !delta.isEmpty {
|
||||
cloudYielded = true
|
||||
continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate))
|
||||
}
|
||||
}
|
||||
if cloudYielded {
|
||||
continuation.finish() // 云端有内容,正常收尾,不再走端侧。
|
||||
return
|
||||
}
|
||||
// 云端走完但全空 → 落端侧(不 return)。
|
||||
} catch is CancellationError {
|
||||
continuation.finish(throwing: CancellationError()) // 取消透传,不吞、不回退。
|
||||
return
|
||||
} catch {
|
||||
if cloudYielded {
|
||||
// 已产出内容后失败:交给 View(有部分文本即按说完处理)。
|
||||
continuation.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
// 首个有效 token 前失败:静默吞掉,落端侧(CaptureService.runVL 模式)。
|
||||
}
|
||||
}
|
||||
|
||||
// —— 端侧回退(MLX/GPU Gemma) ——
|
||||
do {
|
||||
try await AIRuntime.shared.prepare() // OOM 闸门内卸载互斥模型
|
||||
} catch {
|
||||
continuation.finish(throwing: ChatError.modelNotReady)
|
||||
return
|
||||
}
|
||||
do {
|
||||
var localYielded = false
|
||||
var stripper = ThinkStripper()
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
for try await chunk in stream {
|
||||
try Task.checkCancellation()
|
||||
let delta = stripper.feed(chunk.text)
|
||||
if !delta.isEmpty {
|
||||
localYielded = true
|
||||
continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate))
|
||||
}
|
||||
}
|
||||
if localYielded {
|
||||
continuation.finish()
|
||||
} else {
|
||||
continuation.finish(throwing: ChatError.empty) // 端侧走完全空。
|
||||
}
|
||||
} catch is CancellationError {
|
||||
continuation.finish(throwing: CancellationError()) // 取消透传。
|
||||
} catch {
|
||||
// 端侧解码中途失败:透传错误(View 已有部分文本时按说完处理)。
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
// 消费者(UI)关闭 / 取消流时取消内部 Task,停止底层解码,不空耗算力。
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 纯函数(无 AI,无共享状态;nonisolated 便于非隔离单测精确断言)
|
||||
|
||||
/// 已发生的「追问轮数」= assistant 轮数 - 1(开场白不算一轮追问)。无 assistant 轮时为 0。
|
||||
nonisolated static func roundsUsed(in turns: [HealthExportDialogueTurn]) -> Int {
|
||||
let assistantCount = turns.filter { $0.role == .assistant }.count
|
||||
return max(0, assistantCount - 1)
|
||||
}
|
||||
|
||||
/// 是否已聊够(达到 `DiaryChatPrompts.maxRounds` 轮追问,该收尾了)。
|
||||
nonisolated static func isWrappedUp(turns: [HealthExportDialogueTurn]) -> Bool {
|
||||
roundsUsed(in: turns) >= DiaryChatPrompts.maxRounds
|
||||
}
|
||||
|
||||
/// 蒸馏失败时的兜底草稿:只取用户原话拼接,康康(assistant)的话绝不出现,数值原样不改写。
|
||||
/// - 0 条有效用户话 → `""`;
|
||||
/// - 恰 1 条 → 该原话本身(无前缀);
|
||||
/// - ≥2 条 → 每条前缀 `"· "`,按 `"\n"` join。
|
||||
nonisolated static func fallbackDraft(turns: [HealthExportDialogueTurn]) -> String {
|
||||
let userLines = turns
|
||||
.filter { $0.role == .user }
|
||||
.map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
switch userLines.count {
|
||||
case 0: return ""
|
||||
case 1: return userLines[0]
|
||||
default: return userLines.map { "· " + $0 }.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,7 +785,8 @@ struct HealthExportService {
|
||||
/// 全是 O(n) grapheme 操作,1024/1200 token 长报告随长度二次方增长(且都在 MainActor 上)。
|
||||
/// 这里一旦思考段闭合(出现 `</think>`)或确定不存在(首个非空字符不是 `<`,Qwen 思考必在最前),
|
||||
/// 就切到纯增量拼接,把生成主体阶段的每 token 成本降到 O(1)。最坏情况退化为旧行为,无正确性风险。
|
||||
private struct ThinkStripper {
|
||||
/// internal:HealthExportService 与 DiaryChatService 的流式输出共用。
|
||||
struct ThinkStripper {
|
||||
private var rawAccum = ""
|
||||
private(set) var output = ""
|
||||
private var resolved = false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
// @preconcurrency:AVFAudio 的 AVAudioConverterInputBlock 标了 @Sendable 但
|
||||
// AVAudioPCMBuffer 未标 Sendable,decodeToMonoFloat 同步喂 buffer 是安全的,压掉误报。
|
||||
@preconcurrency import AVFoundation
|
||||
|
||||
/// 端侧问诊语音转写服务(SenseVoice,经 sherpa-mnn 跑在 MNN 后端)。
|
||||
///
|
||||
|
||||
103
康康Tests/DiaryChatPromptTests.swift
Normal file
103
康康Tests/DiaryChatPromptTests.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
/// 日记追问对话的 prompt 拼装契约:收尾/继续两种口径互斥、红线短语、
|
||||
/// dataJSON 原文嵌入、蒸馏截断与 /no_think 结尾。
|
||||
/// 对齐 DiaryOrganizePromptTests 风格:纯字符串断言,不涉隔离。
|
||||
struct DiaryChatPromptTests {
|
||||
|
||||
/// 唯一、易断言的注入片段。
|
||||
private let dataJSON = #"{"indicators":[{"name":"收缩压","value":"145"}]}"#
|
||||
|
||||
// MARK: 收尾口径(roundsLeft == 0)
|
||||
|
||||
@Test func replyAtZeroRoundsForbidsFurtherQuestions() {
|
||||
let prompt = DiaryChatPrompts.reply(
|
||||
transcript: "我: 我今天头疼",
|
||||
latest: "我头疼",
|
||||
dataJSON: dataJSON,
|
||||
roundsLeft: 0
|
||||
)
|
||||
#expect(prompt.contains("不要再提任何问题"))
|
||||
#expect(!prompt.contains("最多问 1 个新问题"))
|
||||
}
|
||||
|
||||
// MARK: 继续口径(roundsLeft > 0)
|
||||
|
||||
@Test func replyWithRoundsLeftAllowsOneMoreQuestion() {
|
||||
let prompt = DiaryChatPrompts.reply(
|
||||
transcript: "我: 我今天头疼",
|
||||
latest: "我头疼",
|
||||
dataJSON: dataJSON,
|
||||
roundsLeft: 2
|
||||
)
|
||||
#expect(prompt.contains("最多问 1 个新问题"))
|
||||
#expect(!prompt.contains("不要再提任何问题"))
|
||||
}
|
||||
|
||||
// MARK: 红线短语 + dataJSON 原文嵌入(opening 与 reply 都要有)
|
||||
|
||||
@Test func openingCarriesNoReferralRuleAndEmbedsDataJSON() {
|
||||
let prompt = DiaryChatPrompts.opening(dataJSON: dataJSON)
|
||||
#expect(prompt.contains("不写「建议就医」"))
|
||||
#expect(prompt.contains(dataJSON))
|
||||
}
|
||||
|
||||
@Test func replyCarriesNoReferralRuleAndEmbedsDataJSON() {
|
||||
let prompt = DiaryChatPrompts.reply(
|
||||
transcript: "我: 我今天头疼",
|
||||
latest: "我头疼",
|
||||
dataJSON: dataJSON,
|
||||
roundsLeft: 1
|
||||
)
|
||||
#expect(prompt.contains("不写「建议就医」"))
|
||||
#expect(prompt.contains(dataJSON))
|
||||
}
|
||||
|
||||
// MARK: 蒸馏 prompt 的取材约束短语
|
||||
|
||||
@Test func distillContainsUserOnlyAndForbiddenRule() {
|
||||
let prompt = DiaryChatPrompts.distill(transcript: "我: 我今天头疼\n康康: 收到")
|
||||
#expect(prompt.contains("只取「我:」"))
|
||||
#expect(prompt.contains("绝对不许"))
|
||||
}
|
||||
|
||||
// MARK: 三个 prompt trim 后都以 /no_think 结尾
|
||||
|
||||
@Test func openingEndsWithNoThink() {
|
||||
let prompt = DiaryChatPrompts.opening(dataJSON: dataJSON)
|
||||
#expect(prompt.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("/no_think"))
|
||||
}
|
||||
|
||||
@Test func replyEndsWithNoThink() {
|
||||
let prompt = DiaryChatPrompts.reply(
|
||||
transcript: "我: 我今天头疼",
|
||||
latest: "我头疼",
|
||||
dataJSON: dataJSON,
|
||||
roundsLeft: 1
|
||||
)
|
||||
#expect(prompt.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("/no_think"))
|
||||
}
|
||||
|
||||
@Test func distillEndsWithNoThink() {
|
||||
let prompt = DiaryChatPrompts.distill(transcript: "我: 我今天头疼")
|
||||
#expect(prompt.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("/no_think"))
|
||||
}
|
||||
|
||||
// MARK: 蒸馏对超长稿件截断到 distillTranscriptLimit(2000)
|
||||
|
||||
@Test func distillTruncatesTranscriptBeyondLimit() {
|
||||
// 起始标记落在前 2000 内应保留;越限标记落在第 2500 字符附近应被截掉。
|
||||
let earlyMark = "起始标记AAA"
|
||||
let lateMark = "越限标记ZZZ"
|
||||
let transcript = earlyMark
|
||||
+ String(repeating: "文", count: 2500 - earlyMark.count) // 把越限标记推到 index≈2500
|
||||
+ lateMark
|
||||
+ String(repeating: "字", count: 500) // 凑到 3000+ 字符
|
||||
#expect(transcript.count > DiaryChatPrompts.distillTranscriptLimit)
|
||||
|
||||
let prompt = DiaryChatPrompts.distill(transcript: transcript)
|
||||
#expect(prompt.contains(earlyMark))
|
||||
#expect(!prompt.contains(lateMark))
|
||||
}
|
||||
}
|
||||
130
康康Tests/DiaryChatServiceTests.swift
Normal file
130
康康Tests/DiaryChatServiceTests.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
/// 日记追问对话的纯逻辑:轮数计数、收尾判定、离线兜底草稿。
|
||||
/// DiaryChatService 是 @MainActor struct,测试整体标 @MainActor 规避隔离。
|
||||
@MainActor
|
||||
struct DiaryChatServiceTests {
|
||||
|
||||
// MARK: roundsUsed —— 开场白不算追问(assistant 轮数 − 1),user 轮不计
|
||||
|
||||
@Test func roundsUsedIsZeroForEmptyTurns() {
|
||||
#expect(DiaryChatService.roundsUsed(in: []) == 0)
|
||||
}
|
||||
|
||||
@Test func roundsUsedIsZeroForOpeningOnly() {
|
||||
// 仅 1 条 assistant(开场白)→ max(0, 1-1) = 0
|
||||
let turns: [HealthExportDialogueTurn] = [.assistant("说说你今天的情况")]
|
||||
#expect(DiaryChatService.roundsUsed(in: turns) == 0)
|
||||
}
|
||||
|
||||
@Test func roundsUsedCountsAssistantRepliesAfterOpening() {
|
||||
// 开场 + 2 条追问 = 3 条 assistant → max(0, 3-1) = 2
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.assistant("开场"),
|
||||
.user("我头疼"),
|
||||
.assistant("追问一"),
|
||||
.user("还发烧"),
|
||||
.assistant("追问二")
|
||||
]
|
||||
#expect(DiaryChatService.roundsUsed(in: turns) == 2)
|
||||
}
|
||||
|
||||
@Test func roundsUsedIgnoresUserTurns() {
|
||||
// 夹杂多条 user 不影响计数:仍是 3 条 assistant → 2
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.assistant("开场"),
|
||||
.user("u1"), .user("u2"),
|
||||
.assistant("追问一"),
|
||||
.user("u3"), .user("u4"), .user("u5"),
|
||||
.assistant("追问二"),
|
||||
.user("u6")
|
||||
]
|
||||
#expect(DiaryChatService.roundsUsed(in: turns) == 2)
|
||||
}
|
||||
|
||||
// MARK: isWrappedUp —— roundsUsed >= maxRounds(4)
|
||||
|
||||
@Test func isNotWrappedUpAtThreeRounds() {
|
||||
// 4 条 assistant(开场 + 3 追问)→ roundsUsed = 3 < 4 → 未收尾
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.assistant("开场"),
|
||||
.user("a"), .assistant("q1"),
|
||||
.user("b"), .assistant("q2"),
|
||||
.user("c"), .assistant("q3")
|
||||
]
|
||||
#expect(DiaryChatService.roundsUsed(in: turns) == 3)
|
||||
#expect(DiaryChatService.isWrappedUp(turns: turns) == false)
|
||||
}
|
||||
|
||||
@Test func isWrappedUpAtFourRounds() {
|
||||
// 5 条 assistant(开场 + 4 追问)→ roundsUsed = 4 = maxRounds → 收尾
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.assistant("开场"),
|
||||
.user("a"), .assistant("q1"),
|
||||
.user("b"), .assistant("q2"),
|
||||
.user("c"), .assistant("q3"),
|
||||
.user("d"), .assistant("q4")
|
||||
]
|
||||
#expect(DiaryChatService.roundsUsed(in: turns) == DiaryChatPrompts.maxRounds)
|
||||
#expect(DiaryChatService.isWrappedUp(turns: turns) == true)
|
||||
}
|
||||
|
||||
// MARK: fallbackDraft —— 只拼 user 原话,assistant 绝不进草稿
|
||||
|
||||
@Test func fallbackDraftIsEmptyWhenNoUserTurns() {
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.assistant("开场"),
|
||||
.assistant("再问一句")
|
||||
]
|
||||
#expect(DiaryChatService.fallbackDraft(turns: turns) == "")
|
||||
}
|
||||
|
||||
@Test func fallbackDraftReturnsSingleUserVerbatimWithoutBullet() {
|
||||
// 恰 1 条 user → 原话本身,无「· 」前缀;数值原样
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.assistant("说说你今天的情况"),
|
||||
.user("今天头疼,量了血压 140/90")
|
||||
]
|
||||
let draft = DiaryChatService.fallbackDraft(turns: turns)
|
||||
#expect(draft == "今天头疼,量了血压 140/90")
|
||||
#expect(!draft.contains("· "))
|
||||
}
|
||||
|
||||
@Test func fallbackDraftBulletsMultipleUserTurns() {
|
||||
// ≥2 条 user → 每条前缀「· 」按换行拼接
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.assistant("开场"),
|
||||
.user("今天头疼"),
|
||||
.assistant("量血压了吗?"),
|
||||
.user("量了血压 140/90")
|
||||
]
|
||||
let draft = DiaryChatService.fallbackDraft(turns: turns)
|
||||
#expect(draft == "· 今天头疼\n· 量了血压 140/90")
|
||||
}
|
||||
|
||||
@Test func fallbackDraftDropsBlankUserTurns() {
|
||||
// 纯空格 / 换行空白的 user 轮被丢弃,剩两条有效仍带「· 」
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.user("第一条有效"),
|
||||
.user(" "),
|
||||
.user("\n \n"),
|
||||
.user("第二条有效")
|
||||
]
|
||||
let draft = DiaryChatService.fallbackDraft(turns: turns)
|
||||
#expect(draft == "· 第一条有效\n· 第二条有效")
|
||||
}
|
||||
|
||||
@Test func fallbackDraftNeverIncludesAssistantTextAndKeepsNumbers() {
|
||||
// assistant 文本绝不出现;140/90 原样保留
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.assistant("上次你记过头疼"),
|
||||
.user("今天又头疼了,血压 140/90"),
|
||||
.assistant("需要提醒复诊吗?")
|
||||
]
|
||||
let draft = DiaryChatService.fallbackDraft(turns: turns)
|
||||
#expect(!draft.contains("上次你记过头疼"))
|
||||
#expect(!draft.contains("需要提醒复诊吗?"))
|
||||
#expect(draft.contains("140/90"))
|
||||
}
|
||||
}
|
||||
@@ -133,4 +133,24 @@ struct FileDownloaderTests {
|
||||
}
|
||||
#expect(!FileManager.default.fileExists(atPath: dst.path))
|
||||
}
|
||||
|
||||
@Test func failsFastOnServerTotalMismatchAndRemovesPart() async throws {
|
||||
// 上游仓库更新后文件变大的场景:206 Content-Range 报告的总大小(100)≠ 清单预期(5),
|
||||
// 应在响应头阶段就抛 sizeMismatch(got=服务器总大小),并删掉 .part 防跨版本拼接。
|
||||
let url = uniqueURL()
|
||||
MockURLProtocol.register(url, body: Data(repeating: 0xAB, count: 100))
|
||||
let dst = tempFile()
|
||||
defer { try? FileManager.default.removeItem(at: dst.deletingLastPathComponent()) }
|
||||
|
||||
let dl = FileDownloader(configuration: mockConfiguration())
|
||||
do {
|
||||
try await dl.download(from: url, to: dst, expectedBytes: 5)
|
||||
Issue.record("预期抛 sizeMismatch,实际成功")
|
||||
} catch let DownloadError.sizeMismatch(expected, got) {
|
||||
#expect(expected == 5)
|
||||
#expect(got == 100) // 快速失败拿到的是响应头里的服务器总大小,不是下完的字节数
|
||||
}
|
||||
#expect(!FileManager.default.fileExists(atPath: dst.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: dst.appendingPathExtension("part").path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ struct ModelManifestTests {
|
||||
}
|
||||
|
||||
@Test func llmTotalBytesMatchesManifest() {
|
||||
// gemma-4-e2b-it-4bit 全部运行文件字节之和(ModelScope repo/files 实测,2026-07)。
|
||||
#expect(ModelManifest.totalBytes(for: .llm) == 3_613_528_388)
|
||||
// gemma-4-e2b-it-4bit 全部运行文件字节之和(魔搭/hf-mirror 双源实测,2026-07-14,
|
||||
// 上游 7 月中旬更新过 checkpoint 后的新值)。
|
||||
#expect(ModelManifest.totalBytes(for: .llm) == 3_583_086_498)
|
||||
}
|
||||
|
||||
@Test func vlTotalBytesMatchesManifest() {
|
||||
|
||||
Reference in New Issue
Block a user