根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:

```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
- 修正错误描述
```
This commit is contained in:
link2026
2026-07-14 13:07:15 +08:00
parent 32180d7c0e
commit 198570186e
15 changed files with 2180 additions and 308 deletions

View File

@@ -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(

View File

@@ -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?

View File

@@ -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.581G3.551G,config/tokenizer_config/
// processor_config/index ;model_type gemma44bit,)
// 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

View 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
"""
}
}

View 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)
}

View 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)
}

View File

@@ -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

View 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")
}
}
}

View File

@@ -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

View File

@@ -1,5 +1,7 @@
import Foundation
import AVFoundation
// @preconcurrency:AVFAudio AVAudioConverterInputBlock @Sendable
// AVAudioPCMBuffer Sendable,decodeToMonoFloat buffer ,
@preconcurrency import AVFoundation
/// (SenseVoice, sherpa-mnn MNN )
///

View 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) // index2500
+ 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))
}
}

View 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"))
}
}

View File

@@ -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))
}
}

View File

@@ -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() {