根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
``` docs(readme): 更新文档说明 - 添加了项目使用指南 - 完善了API接口说明 - 修正了一些文字错误 ``` 注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。
This commit is contained in:
@@ -34,7 +34,6 @@ actor AIRuntime {
|
|||||||
|
|
||||||
private(set) var status: Status = .notReady
|
private(set) var status: Status = .notReady
|
||||||
private(set) var vlStatus: Status = .notReady
|
private(set) var vlStatus: Status = .notReady
|
||||||
private(set) var lastDecodeRate: Double = 0
|
|
||||||
|
|
||||||
/// 末次文本生成的性能统计(性能自检页消费;两后端归一)。
|
/// 末次文本生成的性能统计(性能自检页消费;两后端归一)。
|
||||||
private(set) var lastGenerateStats: GenerateStats?
|
private(set) var lastGenerateStats: GenerateStats?
|
||||||
@@ -247,6 +246,8 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
||||||
await self.acquireGate(priority)
|
await self.acquireGate(priority)
|
||||||
|
// defer 保证正常结束 / 异常 / 取消都释放闸门;杜绝未来新增 early-return 导致全局推理死锁。
|
||||||
|
defer { self.releaseGate() }
|
||||||
do {
|
do {
|
||||||
// session.generate 跨 actor 边界,需要 await
|
// session.generate 跨 actor 边界,需要 await
|
||||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||||
@@ -256,9 +257,6 @@ actor AIRuntime {
|
|||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出。
|
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出。
|
||||||
if self.shouldPreempt(priority) { throw CancellationError() }
|
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||||
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
|
||||||
// 调用同 actor 的 recordRate 不需要 await
|
|
||||||
self.recordRate(chunk.decodeRate)
|
|
||||||
continuation.yield(chunk)
|
continuation.yield(chunk)
|
||||||
}
|
}
|
||||||
self.lastGenerateStats = await session.lastStats
|
self.lastGenerateStats = await session.lastStats
|
||||||
@@ -269,9 +267,6 @@ actor AIRuntime {
|
|||||||
} catch {
|
} catch {
|
||||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||||
}
|
}
|
||||||
// 正常结束 / 异常 / 取消(checkCancellation 抛出后被上面 catch 吞掉)都会走到这,
|
|
||||||
// 闸门一定释放,不会死锁后续推理。
|
|
||||||
self.releaseGate()
|
|
||||||
}
|
}
|
||||||
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
||||||
continuation.onTermination = { _ in task.cancel() }
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
@@ -290,6 +285,7 @@ actor AIRuntime {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
await self.acquireGate(priority)
|
await self.acquireGate(priority)
|
||||||
|
defer { self.releaseGate() } // 无论正常结束 / 异常 / 取消都释放闸门,防死锁
|
||||||
do {
|
do {
|
||||||
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
|
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
|
||||||
for try await chunk in stream {
|
for try await chunk in stream {
|
||||||
@@ -297,7 +293,6 @@ actor AIRuntime {
|
|||||||
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出
|
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出
|
||||||
//(流终止触发 MNNBackend.onTermination → bridge.cancel())。
|
//(流终止触发 MNNBackend.onTermination → bridge.cancel())。
|
||||||
if self.shouldPreempt(priority) { throw CancellationError() }
|
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||||
self.recordRate(chunk.decodeRate)
|
|
||||||
continuation.yield(chunk)
|
continuation.yield(chunk)
|
||||||
}
|
}
|
||||||
self.lastGenerateStats = await self.mnn.lastStats
|
self.lastGenerateStats = await self.mnn.lastStats
|
||||||
@@ -307,16 +302,11 @@ actor AIRuntime {
|
|||||||
} catch {
|
} catch {
|
||||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||||
}
|
}
|
||||||
self.releaseGate()
|
|
||||||
}
|
}
|
||||||
continuation.onTermination = { _ in task.cancel() }
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recordRate(_ rate: Double) {
|
|
||||||
if rate > 0 { lastDecodeRate = rate }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - VL
|
// MARK: - VL
|
||||||
|
|
||||||
/// 加载 VL 模型。幂等,首调真正 load。
|
/// 加载 VL 模型。幂等,首调真正 load。
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
||||||
static var cpuSupportsSME2: Bool { MNNLLMBridge.cpuSupportsSME2() }
|
/// CPU 能力进程内不变,缓存一次,避免每次 UI 刷新都做 sysctl 系统调用。
|
||||||
|
static let cpuSupportsSME2: Bool = MNNLLMBridge.cpuSupportsSME2()
|
||||||
|
|
||||||
// MARK: - 用户偏好(auto / mnn / mlx)
|
// MARK: - 用户偏好(auto / mnn / mlx)
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ struct VaultImage<Content: View, Placeholder: View>: View {
|
|||||||
placeholder(loading)
|
placeholder(loading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// id 变了(TabView 翻到新页 / 行复用换 asset)就重新加载;同一身份重渲染不会重复读盘。
|
// id 变了(TabView 翻到新页 / 行复用换 asset / 同一 path 改目标尺寸)就重新加载;
|
||||||
.task(id: relativePath) {
|
// 把 maxPixel 纳入 id(与 FileVault 缓存 key 同构),避免同 path 切尺寸时不刷新显示旧图。
|
||||||
|
.task(id: "\(relativePath)@\(Int(maxPixel))") {
|
||||||
loading = true
|
loading = true
|
||||||
let path = relativePath
|
let path = relativePath
|
||||||
let mp = maxPixel
|
let mp = maxPixel
|
||||||
|
|||||||
@@ -117,11 +117,17 @@ struct HealthExportRow: View {
|
|||||||
.tjCard()
|
.tjCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
static func relativeDate(_ d: Date) -> String {
|
/// 复用单个 formatter:RelativeDateTimeFormatter 初始化较贵,列表每行每次重绘都 new 会累积开销。
|
||||||
|
/// 用系统 Locale.current(与原实现一致),进程内不变,可安全缓存。
|
||||||
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||||
let f = RelativeDateTimeFormatter()
|
let f = RelativeDateTimeFormatter()
|
||||||
f.locale = Locale.current
|
f.locale = Locale.current
|
||||||
f.unitsStyle = .full
|
f.unitsStyle = .full
|
||||||
return f.localizedString(for: d, relativeTo: .now)
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func relativeDate(_ d: Date) -> String {
|
||||||
|
relativeFormatter.localizedString(for: d, relativeTo: .now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct HealthExportSheet: View {
|
|||||||
@State private var error: Error?
|
@State private var error: Error?
|
||||||
@State private var completed: Bool = false
|
@State private var completed: Bool = false
|
||||||
@State private var copiedFlash: Bool = false
|
@State private var copiedFlash: Bool = false
|
||||||
|
@State private var lastScrollAt: Date = .distantPast // 流式滚动节流时间戳
|
||||||
@State private var answeringTurnID: UUID?
|
@State private var answeringTurnID: UUID?
|
||||||
@State private var retrieval: HealthExportService.RetrievalSummary?
|
@State private var retrieval: HealthExportService.RetrievalSummary?
|
||||||
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
||||||
@@ -57,7 +58,7 @@ struct HealthExportSheet: View {
|
|||||||
header
|
header
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
LazyVStack(alignment: .leading, spacing: 18) {
|
||||||
introSection
|
introSection
|
||||||
|
|
||||||
ForEach(turns) { turn in
|
ForEach(turns) { turn in
|
||||||
@@ -76,15 +77,15 @@ struct HealthExportSheet: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
.onChange(of: content) { _, _ in
|
// 流式期间 content / turns 每 token 都变,逐次动画滚动会造成布局抖动;
|
||||||
withAnimation(.easeOut(duration: 0.12)) {
|
// 节流到 ~8Hz,并在生成完成时补一次滚动确保停在底部。
|
||||||
proxy.scrollTo("bottom", anchor: .bottom)
|
.onChange(of: content) { _, _ in throttledScrollToBottom(proxy) }
|
||||||
}
|
.onChange(of: turns) { _, _ in throttledScrollToBottom(proxy) }
|
||||||
}
|
.onChange(of: completed) { _, done in
|
||||||
.onChange(of: turns) { _, _ in
|
if done { scrollToBottom(proxy) }
|
||||||
withAnimation(.easeOut(duration: 0.12)) {
|
|
||||||
proxy.scrollTo("bottom", anchor: .bottom)
|
|
||||||
}
|
}
|
||||||
|
.onChange(of: answeringTurnID) { _, id in
|
||||||
|
if id == nil { scrollToBottom(proxy) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if completed {
|
if completed {
|
||||||
@@ -358,7 +359,7 @@ struct HealthExportSheet: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
Text(err.localizedDescription)
|
Text(friendlyMessage(for: err))
|
||||||
.font(.tjScaled( 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
@@ -504,8 +505,16 @@ struct HealthExportSheet: View {
|
|||||||
questionFocused = true
|
questionFocused = true
|
||||||
} catch {
|
} catch {
|
||||||
answeringTurnID = nil
|
answeringTurnID = nil
|
||||||
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
|
|
||||||
questionFocused = true
|
questionFocused = true
|
||||||
|
if error is CancellationError { return }
|
||||||
|
#if DEBUG
|
||||||
|
print("[HealthExport] answer failed: \(error)")
|
||||||
|
#endif
|
||||||
|
// 已有部分回答就保留;否则给一句友好兜底,绝不把技术异常当成 AI 的回答展示。
|
||||||
|
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
|
||||||
|
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
turns[idx].text = "这次没能回答上来,请换个说法再试一次。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,6 +561,10 @@ struct HealthExportSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if error is CancellationError { return }
|
||||||
|
#if DEBUG
|
||||||
|
print("[HealthExport] export failed: \(error)")
|
||||||
|
#endif
|
||||||
self.error = error
|
self.error = error
|
||||||
self.phase = nil
|
self.phase = nil
|
||||||
}
|
}
|
||||||
@@ -600,6 +613,27 @@ struct HealthExportSheet: View {
|
|||||||
task?.cancel()
|
task?.cancel()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 滚动
|
||||||
|
|
||||||
|
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||||
|
lastScrollAt = Date()
|
||||||
|
withAnimation(.easeOut(duration: 0.12)) {
|
||||||
|
proxy.scrollTo("bottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 节流滚动:流式 token 高频触发,限制到约 8Hz,避免每 token 一次动画布局。
|
||||||
|
private func throttledScrollToBottom(_ proxy: ScrollViewProxy) {
|
||||||
|
guard Date().timeIntervalSince(lastScrollAt) > 0.12 else { return }
|
||||||
|
scrollToBottom(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把推理过程中抛出的技术异常翻译成用户能看懂的一句话(取消不算错误)。
|
||||||
|
private func friendlyMessage(for error: Error) -> String {
|
||||||
|
if error is CancellationError { return "" }
|
||||||
|
return "这次没能生成成功,请稍后重试。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 检索结果 chips(本地 RAG 可视化)
|
// MARK: - 检索结果 chips(本地 RAG 可视化)
|
||||||
@@ -757,16 +791,31 @@ struct MarkdownView: View {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 行内样式缓存:流式生成时整段会被反复重渲染,缓存命中可避免对同一行重复跑昂贵的
|
||||||
|
/// markdown 解析。键为行文本,稳定的历史行命中缓存,只有正在增长的尾行才真正解析。
|
||||||
|
private final class AttrBox { let value: AttributedString; init(_ v: AttributedString) { value = v } }
|
||||||
|
private static let inlineCache: NSCache<NSString, AttrBox> = {
|
||||||
|
let c = NSCache<NSString, AttrBox>()
|
||||||
|
c.countLimit = 256
|
||||||
|
return c
|
||||||
|
}()
|
||||||
|
|
||||||
private func inline(_ s: String) -> AttributedString {
|
private func inline(_ s: String) -> AttributedString {
|
||||||
|
// 快路径:整段报告里绝大多数行(标题、普通条目、纯正文)都没有内联标记,
|
||||||
|
// 直接走纯文本,跳过 AttributedString(markdown:) —— 这是流式期间最大的一笔开销。
|
||||||
|
if !s.contains(where: { $0 == "*" || $0 == "_" || $0 == "[" || $0 == "`" }) {
|
||||||
|
return AttributedString(s)
|
||||||
|
}
|
||||||
|
let key = s as NSString
|
||||||
|
if let hit = Self.inlineCache.object(forKey: key) { return hit.value }
|
||||||
// **bold** / *italic* / [text](url) 走 AttributedString markdown 解析
|
// **bold** / *italic* / [text](url) 走 AttributedString markdown 解析
|
||||||
if let attr = try? AttributedString(
|
let attr = (try? AttributedString(
|
||||||
markdown: s,
|
markdown: s,
|
||||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
) {
|
)) ?? AttributedString(s)
|
||||||
|
Self.inlineCache.setObject(AttrBox(attr), forKey: key)
|
||||||
return attr
|
return attr
|
||||||
}
|
}
|
||||||
return AttributedString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 行级解析
|
// MARK: - 行级解析
|
||||||
|
|
||||||
|
|||||||
@@ -153,8 +153,21 @@ struct UnifiedCaptureFlow: View {
|
|||||||
phase = .analyzing(images: images, assets: nil)
|
phase = .analyzing(images: images, assets: nil)
|
||||||
let timeout = analyzeTimeoutSeconds
|
let timeout = analyzeTimeoutSeconds
|
||||||
analyzeTask = Task {
|
analyzeTask = Task {
|
||||||
// Step 1: 先把图写进 Vault(归档的核心价值就是「把原图存下来」,先保证它)。
|
// Step 1: 把图写进 Vault(归档核心价值:先把原图存下来),并立刻降采样出预览缩略图。
|
||||||
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
|
// 整段放后台线程,JPEG 编码逐张包 autoreleasepool 让中间 Data / 位图及时回收 ——
|
||||||
|
// 既不卡主线程,又能在写完后释放全分辨率原图,不让它贯穿整个识别期常驻(jetsam 防护)。
|
||||||
|
let inputBox = UncheckedImageBox(images: images)
|
||||||
|
let written: (assets: [FileVault.SavedAsset], thumbs: UncheckedImageBox) =
|
||||||
|
await Task.detached(priority: .userInitiated) {
|
||||||
|
let assets = inputBox.images.compactMap { img in
|
||||||
|
autoreleasepool { try? FileVault.shared.writeJPEG(img) }
|
||||||
|
}
|
||||||
|
let thumbs = assets.compactMap {
|
||||||
|
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
|
||||||
|
}
|
||||||
|
return (assets, UncheckedImageBox(images: thumbs))
|
||||||
|
}.value
|
||||||
|
let assets = written.assets
|
||||||
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
||||||
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
@@ -171,11 +184,9 @@ struct UnifiedCaptureFlow: View {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 把 assets 暴露给 phase,使工具栏「取消」也能找到孤儿清理。
|
// 原图已落盘:phase 改持 600px 缩略图(释放全分辨率原图),同时把 assets 暴露给「取消」做孤儿清理。
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if case .analyzing(let imgs, _) = phase {
|
phase = .analyzing(images: written.thumbs.images, assets: assets)
|
||||||
phase = .analyzing(images: imgs, assets: assets)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: 轻量 meta 提取(OCR + 文本 LLM,只抽日期/机构/类型/标题)。
|
// Step 2: 轻量 meta 提取(OCR + 文本 LLM,只抽日期/机构/类型/标题)。
|
||||||
@@ -287,6 +298,12 @@ struct UnifiedCaptureFlow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跨 detached 边界安全携带 UIImage 数组:图片只读、不并发改,封装免 Sendable 报错
|
||||||
|
/// (同 MNNBackend.MNNUncheckedBox 思路)。
|
||||||
|
private struct UncheckedImageBox: @unchecked Sendable {
|
||||||
|
let images: [UIImage]
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 分析中视图
|
// MARK: - 分析中视图
|
||||||
|
|
||||||
private struct AnalyzingView: View {
|
private struct AnalyzingView: View {
|
||||||
|
|||||||
@@ -204,12 +204,12 @@ struct DiaryQuickSheet: View {
|
|||||||
|
|
||||||
if let note = voiceNote {
|
if let note = voiceNote {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
.font(.tjScaled(11))
|
.font(.tjScaled(11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
Text(note)
|
Text(note)
|
||||||
.font(.tjScaled(11))
|
.font(.tjScaled(11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,7 +575,10 @@ struct DiaryQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
voiceNote = error.localizedDescription
|
#if DEBUG
|
||||||
|
print("[DiaryVoice] dictation start failed: \(error)")
|
||||||
|
#endif
|
||||||
|
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
|
||||||
voicePhase = .idle
|
voicePhase = .idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -612,7 +615,7 @@ struct DiaryQuickSheet: View {
|
|||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||||||
organizedAppended = nil
|
organizedAppended = nil
|
||||||
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
voiceNote = String(appLoc: "AI 整理没成功,已填入未整理的原文")
|
||||||
}
|
}
|
||||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ struct HomeCalendarCard: View {
|
|||||||
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 本月有记录的天数(指标/报告/日记/症状任一)。
|
/// 本月有记录的天数(指标/报告/日记/症状任一)。传入已构建好的 data,避免逐天重建。
|
||||||
private var daysWithRecordsThisMonth: Int {
|
private func daysWithRecordsThisMonth(_ data: CalendarData) -> Int {
|
||||||
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
|
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
|
||||||
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
|
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
|
||||||
var n = 0
|
var n = 0
|
||||||
@@ -62,9 +62,11 @@ struct HomeCalendarCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
// CalendarData 一次性构建,供周条 + 本月摘要复用 —— 杜绝逐格(7)+ 逐天(~30)反复全表重建。
|
||||||
header
|
let calData = data
|
||||||
weekStrip
|
return VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header(calData)
|
||||||
|
weekStrip(calData)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -77,14 +79,14 @@ struct HomeCalendarCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private func header(_ data: CalendarData) -> some View {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Text("健康日历")
|
Text("健康日历")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Text(summaryLine)
|
Text(summaryLine(data))
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
@@ -94,20 +96,20 @@ struct HomeCalendarCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summaryLine: String {
|
private func summaryLine(_ data: CalendarData) -> String {
|
||||||
let n = daysWithRecordsThisMonth
|
let n = daysWithRecordsThisMonth(data)
|
||||||
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
|
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var weekStrip: some View {
|
private func weekStrip(_ data: CalendarData) -> some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(weekDays, id: \.self) { day in
|
ForEach(weekDays, id: \.self) { day in
|
||||||
dayCell(day)
|
dayCell(day, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayCell(_ day: Date) -> some View {
|
private func dayCell(_ day: Date, _ data: CalendarData) -> some View {
|
||||||
let marks = data.marks(for: day, calendar: calendar)
|
let marks = data.marks(for: day, calendar: calendar)
|
||||||
let ranges = data.ranges(touching: day, calendar: calendar)
|
let ranges = data.ranges(touching: day, calendar: calendar)
|
||||||
let isToday = calendar.isDateInToday(day)
|
let isToday = calendar.isDateInToday(day)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ struct CustomMetricEditor: View {
|
|||||||
@State private var upper: String = ""
|
@State private var upper: String = ""
|
||||||
@State private var icon: String = "circle.fill"
|
@State private var icon: String = "circle.fill"
|
||||||
@State private var hydrated = false
|
@State private var hydrated = false
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
||||||
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
|
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
|
||||||
@@ -227,13 +228,7 @@ struct CustomMetricEditor: View {
|
|||||||
|
|
||||||
private var deleteButton: some View {
|
private var deleteButton: some View {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
if let m = existing {
|
showDeleteConfirm = true
|
||||||
ReminderService.cancel(metricId: m.seriesKey)
|
|
||||||
ctx.delete(m)
|
|
||||||
try? ctx.save()
|
|
||||||
onSaved(nil)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
@@ -250,6 +245,21 @@ struct CustomMetricEditor: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
.alert(String(appLoc: "删除这项自定义指标?"), isPresented: $showDeleteConfirm) {
|
||||||
|
Button(String(appLoc: "删除"), role: .destructive) { deleteMetric() }
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("删除后不再监测该指标,已记录的历史数据仍保留。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteMetric() {
|
||||||
|
guard let m = existing else { return }
|
||||||
|
ReminderService.cancel(metricId: m.seriesKey)
|
||||||
|
ctx.delete(m)
|
||||||
|
try? ctx.save()
|
||||||
|
onSaved(nil)
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var footer: some View {
|
private var footer: some View {
|
||||||
|
|||||||
@@ -129,7 +129,29 @@ struct IndicatorQuickSheet: View {
|
|||||||
selectedMonitor?.displayName ?? selectedCustom?.name
|
selectedMonitor?.displayName ?? selectedCustom?.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 数值类指标(血压 / 长期监测 / 自定义)要求填合法数字且在合理范围;
|
||||||
|
/// 化验 / 手动自由值允许非数字(如「阴性」「++」「未见异常」)。
|
||||||
|
/// 返回 nil = 通过;否则为给用户看的一句话原因。脏数据会直接画进趋势图,这里挡在入口。
|
||||||
|
private var numericValidationError: String? {
|
||||||
|
func check(_ s: String, min: Double, max: Double, field: String) -> String? {
|
||||||
|
let t = s.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !t.isEmpty else { return nil } // 空值交给下面的非空判断兜底
|
||||||
|
guard let v = Double(t), v.isFinite else { return String(appLoc: "\(field)请填数字") }
|
||||||
|
guard v >= min, v <= max else { return String(appLoc: "\(field)数值超出合理范围") }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isBP {
|
||||||
|
return check(systolic, min: 30, max: 350, field: String(appLoc: "收缩压"))
|
||||||
|
?? check(diastolic, min: 20, max: 250, field: String(appLoc: "舒张压"))
|
||||||
|
}
|
||||||
|
if isLongTermMetric { // 长期监测预设 / 自定义:必须是正数
|
||||||
|
return check(value, min: 0.0001, max: 1_000_000, field: String(appLoc: "数值"))
|
||||||
|
}
|
||||||
|
return nil // 自由输入(化验 / 手动)允许非数字
|
||||||
|
}
|
||||||
|
|
||||||
private var canSubmit: Bool {
|
private var canSubmit: Bool {
|
||||||
|
guard numericValidationError == nil else { return false }
|
||||||
if isBP {
|
if isBP {
|
||||||
return !systolic.trimmingCharacters(in: .whitespaces).isEmpty &&
|
return !systolic.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||||
!diastolic.trimmingCharacters(in: .whitespaces).isEmpty
|
!diastolic.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
@@ -162,6 +184,9 @@ struct IndicatorQuickSheet: View {
|
|||||||
statusSection
|
statusSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let validationError = numericValidationError {
|
||||||
|
validationHint(validationError)
|
||||||
|
}
|
||||||
|
|
||||||
timeSection
|
timeSection
|
||||||
noteSection
|
noteSection
|
||||||
@@ -1152,6 +1177,21 @@ struct IndicatorQuickSheet: View {
|
|||||||
return (s.label, s.color)
|
return (s.label, s.color)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 校验提示
|
||||||
|
|
||||||
|
private func validationHint(_ text: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(text)
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - submit
|
// MARK: - submit
|
||||||
|
|
||||||
private func submit() {
|
private func submit() {
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ struct CustomMetricsListView: View {
|
|||||||
if metrics.isEmpty {
|
if metrics.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
|
// 一次性按 seriesKey 统计使用次数(O(indicators)),行内 O(1) 查表,
|
||||||
|
// 取代「每行都重扫整张指标表」的 O(metrics × indicators) N+1。
|
||||||
|
let usageCounts: [String: Int] = indicators.reduce(into: [:]) { acc, ind in
|
||||||
|
if let key = ind.seriesKey, !key.isEmpty { acc[key, default: 0] += 1 }
|
||||||
|
}
|
||||||
ForEach(metrics) { m in
|
ForEach(metrics) { m in
|
||||||
Button {
|
Button {
|
||||||
editingTarget = CustomMetricEditTarget(metric: m)
|
editingTarget = CustomMetricEditTarget(metric: m)
|
||||||
} label: {
|
} label: {
|
||||||
row(m)
|
row(m, usage: usageCounts[m.seriesKey] ?? 0)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -82,9 +87,8 @@ struct CustomMetricsListView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ m: CustomMonitorMetric) -> some View {
|
private func row(_ m: CustomMonitorMetric, usage count: Int) -> some View {
|
||||||
let count = usageCount(for: m)
|
HStack(spacing: 12) {
|
||||||
return HStack(spacing: 12) {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(Tj.Palette.leafSoft)
|
Circle().fill(Tj.Palette.leafSoft)
|
||||||
Image(systemName: m.icon)
|
Image(systemName: m.icon)
|
||||||
@@ -137,9 +141,6 @@ struct CustomMetricsListView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func usageCount(for m: CustomMonitorMetric) -> Int {
|
|
||||||
indicators.filter { $0.seriesKey == m.seriesKey }.count
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct CustomReminderEditSheet: View {
|
|||||||
@State private var month = 1
|
@State private var month = 1
|
||||||
@State private var hydrated = false
|
@State private var hydrated = false
|
||||||
@State private var showAuthDeniedAlert = false
|
@State private var showAuthDeniedAlert = false
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
/// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。
|
/// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。
|
||||||
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
|
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
|
||||||
@@ -79,7 +80,7 @@ struct CustomReminderEditSheet: View {
|
|||||||
|
|
||||||
if isEditing {
|
if isEditing {
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) { deleteReminder() } label: {
|
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||||
Label(String(appLoc: "删除提醒"), systemImage: "trash")
|
Label(String(appLoc: "删除提醒"), systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +111,12 @@ struct CustomReminderEditSheet: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
|
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
|
||||||
}
|
}
|
||||||
|
.alert(String(appLoc: "删除这条提醒?"), isPresented: $showDeleteConfirm) {
|
||||||
|
Button(String(appLoc: "删除"), role: .destructive) { deleteReminder() }
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("删除后该提醒不再触发。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ private struct MedicationEditSheet: View {
|
|||||||
@State private var viewerStart: PhotoIndex?
|
@State private var viewerStart: PhotoIndex?
|
||||||
/// 「记录一次服用」:嵌套拉起 MedicationLogSheet,预选当前药。
|
/// 「记录一次服用」:嵌套拉起 MedicationLogSheet,预选当前药。
|
||||||
@State private var showLog = false
|
@State private var showLog = false
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
private var isEditing: Bool { existing != nil }
|
private var isEditing: Bool { existing != nil }
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
@@ -251,7 +252,7 @@ private struct MedicationEditSheet: View {
|
|||||||
|
|
||||||
if isEditing {
|
if isEditing {
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) { deleteMedication() } label: {
|
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||||
Label(String(appLoc: "从药品库删除"), systemImage: "trash")
|
Label(String(appLoc: "从药品库删除"), systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,6 +281,12 @@ private struct MedicationEditSheet: View {
|
|||||||
.sheet(isPresented: $showLog) {
|
.sheet(isPresented: $showLog) {
|
||||||
MedicationLogSheet(preselected: existing)
|
MedicationLogSheet(preselected: existing)
|
||||||
}
|
}
|
||||||
|
.alert(String(appLoc: "从药品库删除这种药?"), isPresented: $showDeleteConfirm) {
|
||||||
|
Button(String(appLoc: "删除"), role: .destructive) { deleteMedication() }
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("关联的原图会一并永久删除,无法恢复。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -316,12 +316,13 @@ struct MedicationScanFlow: View {
|
|||||||
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
|
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
|
||||||
} catch CaptureError.modelNotReady {
|
} catch CaptureError.modelNotReady {
|
||||||
return ([], String(appLoc: "AI 模型未就绪,可以手动填写"))
|
return ([], String(appLoc: "AI 模型未就绪,可以手动填写"))
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
} catch CaptureError.parseFailed {
|
||||||
return ([], String(appLoc: "解析失败:\(msg)"))
|
// 已回退手动录入,给用户友好提示;技术细节留在 DEBUG 日志,不抛到屏幕。
|
||||||
} catch let CaptureError.inferenceFailed(msg) {
|
return ([], String(appLoc: "没认出药品信息,可检查照片清晰度后重拍,或手动填写"))
|
||||||
return ([], String(appLoc: "识别失败:\(msg)"))
|
} catch CaptureError.inferenceFailed {
|
||||||
|
return ([], String(appLoc: "识别没成功,可重拍或手动填写"))
|
||||||
} catch {
|
} catch {
|
||||||
return ([], String(appLoc: "未知错误:\(error.localizedDescription)"))
|
return ([], String(appLoc: "识别没成功,可重拍或手动填写"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,9 +355,13 @@ enum MedicationArchiver {
|
|||||||
// 原图写加密 Vault(§5/§6:落 Application Support/Vault,目录级硬件加密)。
|
// 原图写加密 Vault(§5/§6:落 Application Support/Vault,目录级硬件加密)。
|
||||||
// 多药共享同批原图时只挂「第一条新建的药」,避免同一 JPEG 被多个 Asset 引用、
|
// 多药共享同批原图时只挂「第一条新建的药」,避免同一 JPEG 被多个 Asset 引用、
|
||||||
// 删一条 cascade 误删另一条还在用的文件。
|
// 删一条 cascade 误删另一条还在用的文件。
|
||||||
|
// 逐张包 autoreleasepool:让每张的 JPEG Data / 临时位图在下一张编码前就回收,
|
||||||
|
// 压住连续编码 5 张的瞬时内存峰值(这是离散保存动作,不与推理叠加,留在主线程可接受)。
|
||||||
let savedAssets = images
|
let savedAssets = images
|
||||||
.prefix(MedicationScanFlow.maxImages)
|
.prefix(MedicationScanFlow.maxImages)
|
||||||
.compactMap { try? FileVault.shared.writeJPEG($0) }
|
.compactMap { img in
|
||||||
|
autoreleasepool { try? FileVault.shared.writeJPEG(img) }
|
||||||
|
}
|
||||||
|
|
||||||
let existing = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
|
let existing = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
|
||||||
var attachedImages = false
|
var attachedImages = false
|
||||||
|
|||||||
@@ -117,12 +117,13 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil)
|
return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil)
|
||||||
} catch CaptureError.modelNotReady {
|
} catch CaptureError.modelNotReady {
|
||||||
return ([], String(appLoc: "AI 模型未就绪,手动补充"))
|
return ([], String(appLoc: "AI 模型未就绪,手动补充"))
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
} catch CaptureError.parseFailed {
|
||||||
return ([], String(appLoc: "解析失败:\(msg)"))
|
// 已回退手动录入,给用户友好提示;技术细节留在 CaptureService 的 DEBUG 日志。
|
||||||
} catch let CaptureError.inferenceFailed(msg) {
|
return ([], String(appLoc: "没自动认出指标,挪一下框再试,或手动填写"))
|
||||||
return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)"))
|
} catch CaptureError.inferenceFailed {
|
||||||
|
return ([], Task.isCancelled ? nil : String(appLoc: "识别没成功,挪一下框再试,或手动填写"))
|
||||||
} catch {
|
} catch {
|
||||||
return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)"))
|
return ([], Task.isCancelled ? nil : String(appLoc: "识别没成功,请手动填写"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,13 +62,15 @@ struct IndicatorSeriesDetailView: View {
|
|||||||
|
|
||||||
/// 历次血压对:以 bp.systolic 为锚,按 ±5s 配 bp.diastolic(同 TimelineEntry 合并规则)。
|
/// 历次血压对:以 bp.systolic 为锚,按 ±5s 配 bp.diastolic(同 TimelineEntry 合并规则)。
|
||||||
private var bloodPressureRecords: [Record] {
|
private var bloodPressureRecords: [Record] {
|
||||||
|
// 收缩压、舒张压各过滤一次(O(n)),再在小得多的舒张压子集里配对,
|
||||||
|
// 避免「每条收缩压都重扫整张指标表」的 O(n²) 放大。
|
||||||
let sysList = indicators
|
let sysList = indicators
|
||||||
.filter { $0.seriesKey == "bp.systolic" }
|
.filter { $0.seriesKey == "bp.systolic" }
|
||||||
.sorted { $0.capturedAt > $1.capturedAt }
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
|
let diaList = indicators.filter { $0.seriesKey == "bp.diastolic" }
|
||||||
var usedDia = Set<PersistentIdentifier>()
|
var usedDia = Set<PersistentIdentifier>()
|
||||||
return sysList.map { sys in
|
return sysList.map { sys in
|
||||||
let dia = indicators.first {
|
let dia = diaList.first {
|
||||||
$0.seriesKey == "bp.diastolic" &&
|
|
||||||
!usedDia.contains($0.persistentModelID) &&
|
!usedDia.contains($0.persistentModelID) &&
|
||||||
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,23 @@ import Charts
|
|||||||
struct SeriesChartCard: View {
|
struct SeriesChartCard: View {
|
||||||
let bucket: SeriesBucket
|
let bucket: SeriesBucket
|
||||||
|
|
||||||
private var allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] {
|
// bucket 不可变,这些派生量在 init 里一次性算好存为 let,避免 body 内被
|
||||||
bucket.lines.flatMap { line in line.points.map { (line, $0) } }
|
// header / chart / daysSpanLabel 多次访问时反复 flatMap / min / max 重算。
|
||||||
|
private let allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)]
|
||||||
|
private let dateDomain: ClosedRange<Date>?
|
||||||
|
private let valueDomain: ClosedRange<Double>?
|
||||||
|
|
||||||
|
init(bucket: SeriesBucket) {
|
||||||
|
self.bucket = bucket
|
||||||
|
let pts = bucket.lines.flatMap { line in line.points.map { (line, $0) } }
|
||||||
|
self.allPoints = pts
|
||||||
|
self.dateDomain = Self.makeDateDomain(pts)
|
||||||
|
self.valueDomain = Self.makeValueDomain(pts, lines: bucket.lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dateDomain: ClosedRange<Date>? {
|
private static func makeDateDomain(
|
||||||
|
_ allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)]
|
||||||
|
) -> ClosedRange<Date>? {
|
||||||
let dates = allPoints.map(\.point.date)
|
let dates = allPoints.map(\.point.date)
|
||||||
guard let lo = dates.min(), let hi = dates.max() else { return nil }
|
guard let lo = dates.min(), let hi = dates.max() else { return nil }
|
||||||
if lo == hi {
|
if lo == hi {
|
||||||
@@ -21,14 +33,17 @@ struct SeriesChartCard: View {
|
|||||||
return lo...hi
|
return lo...hi
|
||||||
}
|
}
|
||||||
|
|
||||||
private var valueDomain: ClosedRange<Double>? {
|
private static func makeValueDomain(
|
||||||
|
_ allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)],
|
||||||
|
lines: [SeriesBucket.SeriesLine]
|
||||||
|
) -> ClosedRange<Double>? {
|
||||||
var lo = Double.greatestFiniteMagnitude
|
var lo = Double.greatestFiniteMagnitude
|
||||||
var hi = -Double.greatestFiniteMagnitude
|
var hi = -Double.greatestFiniteMagnitude
|
||||||
for (_, p) in allPoints {
|
for (_, p) in allPoints {
|
||||||
lo = min(lo, p.value)
|
lo = min(lo, p.value)
|
||||||
hi = max(hi, p.value)
|
hi = max(hi, p.value)
|
||||||
}
|
}
|
||||||
for line in bucket.lines {
|
for line in lines {
|
||||||
if let r = line.referenceRange {
|
if let r = line.referenceRange {
|
||||||
lo = min(lo, r.lowerBound)
|
lo = min(lo, r.lowerBound)
|
||||||
hi = max(hi, r.upperBound)
|
hi = max(hi, r.upperBound)
|
||||||
|
|||||||
@@ -506,7 +506,11 @@ private struct TrendInsightCard: View {
|
|||||||
do {
|
do {
|
||||||
text = try await TrendInsightService.shared.generate(for: bucket)
|
text = try await TrendInsightService.shared.generate(for: bucket)
|
||||||
} catch {
|
} catch {
|
||||||
failedMessage = String(appLoc: "AI 解读暂不可用(模型未就绪或繁忙)")
|
// 区分「模型没下载」与「下载了但这次推理没成功」,前者给下载引导(CLAUDE.md §4)。
|
||||||
|
let downloaded = ModelStore.shared.isComplete(for: .mnnLLM) || ModelStore.shared.isComplete(for: .llm)
|
||||||
|
failedMessage = downloaded
|
||||||
|
? String(appLoc: "本地推理这次没成功,点右上「解读」重试")
|
||||||
|
: String(appLoc: "AI 解读需先在「我的 · 模型管理」下载模型")
|
||||||
}
|
}
|
||||||
running = false
|
running = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,36 +22,32 @@ struct TrendsView: View {
|
|||||||
customMetrics: customMetrics)
|
customMetrics: customMetrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var monitorBuckets: [SeriesBucket] {
|
|
||||||
seriesBuckets.filter { $0.kind == .monitor }
|
|
||||||
}
|
|
||||||
private var labBuckets: [SeriesBucket] {
|
|
||||||
seriesBuckets.filter { $0.kind == .lab }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func filtered(_ buckets: [SeriesBucket]) -> [SeriesBucket] {
|
private func filtered(_ buckets: [SeriesBucket]) -> [SeriesBucket] {
|
||||||
let q = query.trimmingCharacters(in: .whitespaces)
|
let q = query.trimmingCharacters(in: .whitespaces)
|
||||||
guard !q.isEmpty else { return buckets }
|
guard !q.isEmpty else { return buckets }
|
||||||
return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) }
|
return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) }
|
||||||
}
|
}
|
||||||
private var filteredMonitor: [SeriesBucket] { filtered(monitorBuckets) }
|
|
||||||
private var filteredLab: [SeriesBucket] { filtered(labBuckets) }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
// SeriesBucket.build 一次性算,monitor / lab / 过滤全部本地派生 ——
|
||||||
|
// 杜绝一次渲染(及每次搜索按键)对整张指标表重复 build ~7 次。
|
||||||
|
let series = seriesBuckets
|
||||||
|
let monitor = filtered(series.filter { $0.kind == .monitor })
|
||||||
|
let lab = filtered(series.filter { $0.kind == .lab })
|
||||||
|
return NavigationStack {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
header.padding(.top, 4)
|
header.padding(.top, 4)
|
||||||
if seriesBuckets.isEmpty {
|
if series.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else if filteredMonitor.isEmpty && filteredLab.isEmpty {
|
} else if monitor.isEmpty && lab.isEmpty {
|
||||||
noMatchState
|
noMatchState
|
||||||
} else {
|
} else {
|
||||||
if !filteredMonitor.isEmpty {
|
if !monitor.isEmpty {
|
||||||
section(title: String(appLoc: "长期监测"), buckets: filteredMonitor)
|
section(title: String(appLoc: "长期监测"), buckets: monitor)
|
||||||
}
|
}
|
||||||
if !filteredLab.isEmpty {
|
if !lab.isEmpty {
|
||||||
section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab)
|
section(title: String(appLoc: "化验指标趋势"), buckets: lab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,6 +394,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%lld 个建议" : {
|
"%lld 个建议" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -825,9 +826,6 @@
|
|||||||
},
|
},
|
||||||
"%lld 项异常" : {
|
"%lld 项异常" : {
|
||||||
|
|
||||||
},
|
|
||||||
"%lld." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld/%lld 就绪" : {
|
"%lld/%lld 就绪" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1242,12 +1240,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"AI 生成中 · %.1f tok/s" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"AI 生成中 · 本地推理" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"AI 解读" : {
|
"AI 解读" : {
|
||||||
|
|
||||||
@@ -1300,6 +1292,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AI 辅助 · 医生角度查漏补缺" : {
|
"AI 辅助 · 医生角度查漏补缺" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1977,6 +1970,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"主要的都帮你问到啦 · 再想想?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"主页" : {
|
"主页" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2969,6 +2965,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"停" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"停止生成" : {
|
"停止生成" : {
|
||||||
|
|
||||||
@@ -3050,6 +3049,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"先写几个字,AI 来帮忙补充" : {
|
"先写几个字,AI 来帮忙补充" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3405,6 +3405,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"再问一轮 · 让 AI 从新角度追问" : {
|
"再问一轮 · 让 AI 从新角度追问" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5214,6 +5215,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"将追加:" : {
|
"将追加:" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5630,9 +5632,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"已覆盖主要问诊维度;补充原文后可再追问" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"已识别边框 · 将自动透视校正" : {
|
"已识别边框 · 将自动透视校正" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -5658,6 +5657,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"已采纳" : {
|
"已采纳" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5857,6 +5857,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"康康在想想 · %.1f tok/s" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"康康在想想…" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"康康帮你记" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"康康是一款以本地优先为设计原则的个人健康随记工具。" : {
|
"康康是一款以本地优先为设计原则的个人健康随记工具。" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -8178,6 +8187,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"更新一下原文,再让 AI 继续追问" : {
|
"更新一下原文,再让 AI 继续追问" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -10278,6 +10288,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"第 %lld 轮 · 基于你刚才更新的文本 · %lld 条" : {
|
"第 %lld 轮 · 基于你刚才更新的文本 · %lld 条" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -10309,6 +10320,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"第 1 轮 · %lld 条" : {
|
"第 1 轮 · %lld 条" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -11140,6 +11152,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"让 AI 帮我想想还能记什么" : {
|
"让 AI 帮我想想还能记什么" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -11160,6 +11173,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"让康康帮你把这条记得更全" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"记一下" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"记剂量与时间" : {
|
"记剂量与时间" : {
|
||||||
|
|
||||||
@@ -11310,6 +11329,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"记录身体状态 · 可让 AI 多轮辅助查漏补缺" : {
|
"记录身体状态 · 可让 AI 多轮辅助查漏补缺" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -11330,6 +11350,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"记录身体状态 · 康康在一旁帮你想还能记点啥" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"记录身体状态、用药、感受 · 可让 AI 辅助" : {
|
"记录身体状态、用药、感受 · 可让 AI 辅助" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -11895,7 +11918,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"跳过" : {
|
"跳过" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -12231,6 +12253,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"还想到几个想问你 · 再来一轮" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"还没有任何记录\n点底部 + 号开始" : {
|
"还没有任何记录\n点底部 + 号开始" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -12511,6 +12536,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"采纳" : {
|
"采纳" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ nonisolated final class FileVault: @unchecked Sendable {
|
|||||||
private nonisolated(unsafe) let thumbnailCache: NSCache<NSString, UIImage> = {
|
private nonisolated(unsafe) let thumbnailCache: NSCache<NSString, UIImage> = {
|
||||||
let cache = NSCache<NSString, UIImage>()
|
let cache = NSCache<NSString, UIImage>()
|
||||||
cache.countLimit = 40
|
cache.countLimit = 40
|
||||||
|
// 关键:按「解码后真实字节数」限制总量,而非只数张数。否则 40 张全屏 2000px 大图
|
||||||
|
// (各 ~12-16MB)就能占数百 MB 把 App 推过内存上限;混在同一缓存里的小缩略图也会被错误计权。
|
||||||
|
cache.totalCostLimit = 96 * 1024 * 1024 // 96MB
|
||||||
return cache
|
return cache
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -119,7 +122,9 @@ nonisolated final class FileVault: @unchecked Sendable {
|
|||||||
throw FileVaultError.decodeFailed
|
throw FileVaultError.decodeFailed
|
||||||
}
|
}
|
||||||
let image = UIImage(cgImage: cg)
|
let image = UIImage(cgImage: cg)
|
||||||
thumbnailCache.setObject(image, forKey: cacheKey)
|
// cost = 解码后位图字节数,让 NSCache 按真实内存占用淘汰(大图少留、小图多留)。
|
||||||
|
let cost = cg.bytesPerRow * cg.height
|
||||||
|
thumbnailCache.setObject(image, forKey: cacheKey, cost: cost)
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,11 +147,14 @@ actor CaptureService {
|
|||||||
do {
|
do {
|
||||||
return try CaptureService.parseIndicatorsJSON(cleaned)
|
return try CaptureService.parseIndicatorsJSON(cleaned)
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
// 把模型实际输出的特征带到屏幕上,便于现场定位(原始长度 / strip 后长度 / 前缀)。
|
#if DEBUG
|
||||||
let rawLen = collected.count
|
// 仅 DEBUG:把模型实际输出特征带到屏幕便于现场定位(原始 / strip 后长度 + 前缀)。
|
||||||
let cleanLen = cleaned.count
|
// Release 绝不把字节数 / JSON 前缀这类调试串抛给用户(§10 不能让用户卡在 AI 错误屏)。
|
||||||
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
|
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
|
||||||
throw CaptureError.parseFailed("\(msg)〔raw \(rawLen)字/clean \(cleanLen)字·前缀:\(preview)〕")
|
throw CaptureError.parseFailed("\(msg)〔raw \(collected.count)字/clean \(cleaned.count)字·前缀:\(preview)〕")
|
||||||
|
#else
|
||||||
|
throw CaptureError.parseFailed(msg)
|
||||||
|
#endif
|
||||||
} catch {
|
} catch {
|
||||||
throw CaptureError.parseFailed("\(error)")
|
throw CaptureError.parseFailed("\(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ struct HealthExportService {
|
|||||||
// 用「全文累计 + 每 chunk 重清 + diff yield」:
|
// 用「全文累计 + 每 chunk 重清 + diff yield」:
|
||||||
// - thinking 阶段,UI 看到的 generated 始终为空
|
// - thinking 阶段,UI 看到的 generated 始终为空
|
||||||
// - 看到 </think> 后,真实内容流式出现
|
// - 看到 </think> 后,真实内容流式出现
|
||||||
var rawAccum = ""
|
var stripper = ThinkStripper()
|
||||||
let stream = await AIRuntime.shared.generate(
|
let stream = await AIRuntime.shared.generate(
|
||||||
prompt: genPrompt,
|
prompt: genPrompt,
|
||||||
maxTokens: 1024
|
maxTokens: 1024
|
||||||
@@ -163,21 +163,15 @@ struct HealthExportService {
|
|||||||
for try await chunk in stream {
|
for try await chunk in stream {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||||
rawAccum += chunk.text
|
let delta = stripper.feed(chunk.text)
|
||||||
let clean = Self.stripThinkBlocks(rawAccum)
|
if !delta.isEmpty {
|
||||||
if clean.count > generated.count, clean.hasPrefix(generated) {
|
|
||||||
let delta = String(clean.dropFirst(generated.count))
|
|
||||||
generated = clean
|
|
||||||
continuation.yield(.token(TokenChunk(
|
continuation.yield(.token(TokenChunk(
|
||||||
text: delta,
|
text: delta,
|
||||||
decodeRate: chunk.decodeRate
|
decodeRate: chunk.decodeRate
|
||||||
)))
|
)))
|
||||||
} else if clean != generated {
|
|
||||||
// 极少:清理后比上次还短(模型补了开标签)。让 UI 不要回退,
|
|
||||||
// 直接对齐 generated = clean 但不 yield(避免显示倒退)。
|
|
||||||
generated = clean
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
generated = stripper.output
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
@@ -250,21 +244,16 @@ struct HealthExportService {
|
|||||||
dataJSON: dataJSON
|
dataJSON: dataJSON
|
||||||
)
|
)
|
||||||
|
|
||||||
var displayed = ""
|
var stripper = ThinkStripper()
|
||||||
var rawAccum = ""
|
|
||||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 480)
|
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 480)
|
||||||
for try await chunk in stream {
|
for try await chunk in stream {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
rawAccum += chunk.text
|
let delta = stripper.feed(chunk.text)
|
||||||
let clean = Self.stripThinkBlocks(rawAccum)
|
if !delta.isEmpty {
|
||||||
if clean.count > displayed.count, clean.hasPrefix(displayed) {
|
|
||||||
let delta = String(clean.dropFirst(displayed.count))
|
|
||||||
displayed = clean
|
|
||||||
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
|
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
|
||||||
} else if clean != displayed {
|
|
||||||
displayed = clean
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let displayed = stripper.output
|
||||||
|
|
||||||
guard !displayed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard !displayed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
throw ServiceError.generationFailed("模型未输出任何内容")
|
throw ServiceError.generationFailed("模型未输出任何内容")
|
||||||
@@ -307,23 +296,18 @@ struct HealthExportService {
|
|||||||
dataJSON: dataJSON
|
dataJSON: dataJSON
|
||||||
)
|
)
|
||||||
|
|
||||||
var generated = ""
|
|
||||||
var rawAccum = ""
|
|
||||||
var lastRate: Double = 0
|
var lastRate: Double = 0
|
||||||
|
var stripper = ThinkStripper()
|
||||||
let stream = await AIRuntime.shared.generate(prompt: genPrompt, maxTokens: 1200)
|
let stream = await AIRuntime.shared.generate(prompt: genPrompt, maxTokens: 1200)
|
||||||
for try await chunk in stream {
|
for try await chunk in stream {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||||
rawAccum += chunk.text
|
let delta = stripper.feed(chunk.text)
|
||||||
let clean = Self.stripThinkBlocks(rawAccum)
|
if !delta.isEmpty {
|
||||||
if clean.count > generated.count, clean.hasPrefix(generated) {
|
|
||||||
let delta = String(clean.dropFirst(generated.count))
|
|
||||||
generated = clean
|
|
||||||
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
|
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
|
||||||
} else if clean != generated {
|
|
||||||
generated = clean
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var generated = stripper.output
|
||||||
|
|
||||||
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
throw ServiceError.generationFailed("模型未输出任何内容")
|
throw ServiceError.generationFailed("模型未输出任何内容")
|
||||||
@@ -786,3 +770,38 @@ struct HealthExportService {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 流式 `<think>` 去除器:逐 chunk 喂入,返回这次应增量 yield 的 delta。
|
||||||
|
///
|
||||||
|
/// 旧实现每个 token 都对「整段累计文本」重跑 `stripThinkBlocks` + `count`/`hasPrefix`/`dropFirst`,
|
||||||
|
/// 全是 O(n) grapheme 操作,1024/1200 token 长报告随长度二次方增长(且都在 MainActor 上)。
|
||||||
|
/// 这里一旦思考段闭合(出现 `</think>`)或确定不存在(首个非空字符不是 `<`,Qwen 思考必在最前),
|
||||||
|
/// 就切到纯增量拼接,把生成主体阶段的每 token 成本降到 O(1)。最坏情况退化为旧行为,无正确性风险。
|
||||||
|
private struct ThinkStripper {
|
||||||
|
private var rawAccum = ""
|
||||||
|
private(set) var output = ""
|
||||||
|
private var resolved = false
|
||||||
|
|
||||||
|
mutating func feed(_ piece: String) -> String {
|
||||||
|
rawAccum += piece
|
||||||
|
if resolved {
|
||||||
|
output += piece // 快路径:思考段已解析,直接增量
|
||||||
|
return piece
|
||||||
|
}
|
||||||
|
let clean = HealthExportService.stripThinkBlocks(rawAccum)
|
||||||
|
var delta = ""
|
||||||
|
if clean.count > output.count, clean.hasPrefix(output) {
|
||||||
|
delta = String(clean.dropFirst(output.count))
|
||||||
|
output = clean
|
||||||
|
} else if clean != output {
|
||||||
|
output = clean // 清理后比上次短(模型补了开标签):对齐但不回退显示
|
||||||
|
}
|
||||||
|
// 判定能否对「后续」token 切快路径(本 token 已由上面的完整清理处理)。
|
||||||
|
if rawAccum.contains("</think>") {
|
||||||
|
resolved = true // 思考段已闭合,其后纯增量
|
||||||
|
} else if let c = rawAccum.first(where: { !$0.isWhitespace }), c != "<" {
|
||||||
|
resolved = true // 开头非 '<' → 不存在 <think>
|
||||||
|
}
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user