Files
kangkang/康康/Services/DiaryChatService.swift
link2026 198570186e 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
- 修正错误描述
```
2026-07-14 13:07:15 +08:00

231 lines
12 KiB
Swift

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