feat(AI): 优化AIRuntime任务取消机制并增强安全保护 - 在AI推理流中添加Task.checkCancellation()检查,使消费者取消时能快速退出 - 为异步流添加onTermination回调以取消内部Task,与LLMSession一致 - 实现SwiftData store的completeUnlessOpen文件保护,提升数据安全性 - 在store备份过程中同样应用加密保护 feat(home): 优化主页交互体验并统一详情查看功能 - 在主页"最近记录"中点击任意条目可打开只读详情sheet - 将时间线详情解析逻辑统一收敛到TimelineDetail.resolve方法 - 修复血压条目的精确反查逻辑,避免时间窗匹配错误 feat(archive): 新增提醒任务汇总卡并完善档案库功能 - 在档案库页面新增提醒任务汇总卡,显示总数和启用状态 - 添加按更新时间倒序合并的提醒标题预览功能 - 实现RemindersListView导航路由,统一管理提醒任务 - 优化导出列表显示,优先使用中文标签展示 feat(me): 优化个人中心界面并改进语言设置体验 - 将个人中心标题改为内容文字渲染,解决导航栏背景问题 - 为语言选择器添加个性化图标,使用本族语代表字区分 - 修复语言设置视图的图标显示逻辑 feat(timeline): 新增记录详情页删除功能并优化图表显示 - 在时间线详情页添加永久删除按钮和确认弹窗 - 实现完整的删除逻辑,包括SwiftData硬删和Vault原图unlink - 修复系列图表的数值范围计算,处理同值数据的对称留白 - 优化血压图表合并逻辑,只保留有数据点的线条 refactor(calendar): 修复DST切换导致的月份天数计算错误 - 使用calendar.range(of:.day,in:.month)替代日期间隔计算 - 避免在夏令时切换月份出现天数偏差问题 fix(ui): 修复多个UI组件的交互响应区域问题 - 为纯描边按钮和胶囊添加contentShape以扩大点击区域 - 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐 ```
156 lines
5.4 KiB
Swift
156 lines
5.4 KiB
Swift
import Foundation
|
|
|
|
enum AIRuntimeError: Error, LocalizedError {
|
|
case notReady
|
|
case modelLoadFailed(String)
|
|
case inferenceFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notReady: return String(appLoc: "AI 模型尚未准备好")
|
|
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
|
|
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
|
|
}
|
|
}
|
|
}
|
|
|
|
actor AIRuntime {
|
|
static let shared = AIRuntime()
|
|
|
|
enum Status: Sendable, Equatable {
|
|
case notReady
|
|
case loading
|
|
case ready
|
|
case error(String)
|
|
}
|
|
|
|
private(set) var status: Status = .notReady
|
|
private(set) var vlStatus: Status = .notReady
|
|
private(set) var lastDecodeRate: Double = 0
|
|
|
|
private var llmSession: LLMSession?
|
|
private var vlSession: VLSession?
|
|
|
|
private init() {}
|
|
|
|
/// 加载模型。首次调用会真正加载,后续幂等。
|
|
func prepare() async throws {
|
|
switch status {
|
|
case .ready:
|
|
return
|
|
case .loading:
|
|
// 已有其他调用方在加载;本次 prepare 直接返回,
|
|
// 调用方需稍后 await prepare() 再判 status,或自行轮询 / 显示加载 UI。
|
|
// W3 引入 prepare 队列时优化。
|
|
return
|
|
case .error, .notReady:
|
|
break
|
|
}
|
|
|
|
guard ModelStore.shared.isReady(.llm) else {
|
|
status = .error("LLM 模型未就绪")
|
|
throw AIRuntimeError.notReady
|
|
}
|
|
|
|
status = .loading
|
|
do {
|
|
let session = try await LLMSession.load(
|
|
folderURL: ModelStore.shared.localURL(for: .llm)
|
|
)
|
|
self.llmSession = session
|
|
status = .ready
|
|
} catch {
|
|
status = .error("\(error)")
|
|
throw AIRuntimeError.modelLoadFailed("\(error)")
|
|
}
|
|
}
|
|
|
|
/// 流式生成。调用前应先 await prepare()。
|
|
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
|
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
|
|
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
|
let snapshotStatus = status
|
|
let snapshotSession = llmSession
|
|
|
|
return AsyncThrowingStream { continuation in
|
|
let task = Task {
|
|
guard snapshotStatus == .ready, let session = snapshotSession else {
|
|
continuation.finish(throwing: AIRuntimeError.notReady)
|
|
return
|
|
}
|
|
do {
|
|
// session.generate 跨 actor 边界,需要 await
|
|
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
|
for try await chunk in stream {
|
|
// 消费者(UI)提前关闭/取消时,下面的 checkCancellation 让本 Task 尽快退出,
|
|
// 连带丢弃 session 流并触发其 onTermination,停止底层 MLX 解码,不空耗 GPU。
|
|
try Task.checkCancellation()
|
|
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
|
// 调用同 actor 的 recordRate 不需要 await
|
|
self.recordRate(chunk.decodeRate)
|
|
continuation.yield(chunk)
|
|
}
|
|
continuation.finish()
|
|
} catch {
|
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
|
}
|
|
}
|
|
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
|
continuation.onTermination = { _ in task.cancel() }
|
|
}
|
|
}
|
|
|
|
private func recordRate(_ rate: Double) {
|
|
if rate > 0 { lastDecodeRate = rate }
|
|
}
|
|
|
|
// MARK: - VL
|
|
|
|
/// 加载 VL 模型。幂等,首调真正 load。
|
|
func prepareVL() async throws {
|
|
switch vlStatus {
|
|
case .ready, .loading:
|
|
return
|
|
case .error, .notReady:
|
|
break
|
|
}
|
|
|
|
guard ModelStore.shared.isReady(.vl) else {
|
|
vlStatus = .error("VL 模型未就绪")
|
|
throw AIRuntimeError.notReady
|
|
}
|
|
|
|
vlStatus = .loading
|
|
do {
|
|
let session = try await VLSession.load(
|
|
folderURL: ModelStore.shared.localURL(for: .vl)
|
|
)
|
|
self.vlSession = session
|
|
vlStatus = .ready
|
|
} catch {
|
|
vlStatus = .error("\(error)")
|
|
throw AIRuntimeError.modelLoadFailed("\(error)")
|
|
}
|
|
}
|
|
|
|
/// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。
|
|
/// 调用方负责解析 + 失败回退(§3.2)。
|
|
/// AIRuntime 是 actor,本调用与 LLM.generate() 自然串行,不会 OOM。
|
|
func analyzeReport(imageURLs: [URL],
|
|
prompt: String,
|
|
maxTokens: Int = 512) async throws -> String {
|
|
guard vlStatus == .ready, let session = vlSession else {
|
|
throw AIRuntimeError.notReady
|
|
}
|
|
do {
|
|
return try await session.analyze(
|
|
imageURLs: imageURLs,
|
|
prompt: prompt,
|
|
maxTokens: maxTokens
|
|
)
|
|
} catch {
|
|
throw AIRuntimeError.inferenceFailed("\(error)")
|
|
}
|
|
}
|
|
}
|