From de19d7abcdef0651a8db35c0e4651bbfc3fe0c3d Mon Sep 17 00:00:00 2001 From: link2026 Date: Wed, 17 Jun 2026 08:35:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E6=8F=90=E4=BE=9B=E7=9A=84co?= =?UTF-8?q?de=20differences=E4=BF=A1=E6=81=AF=EF=BC=8C=E7=94=B1=E4=BA=8E?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=85=B7=E4=BD=93=E7=9A=84=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E5=86=85=E5=AE=B9=EF=BC=8C=E6=88=91=E5=B0=86?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=80=E4=B8=AA=E9=80=9A=E7=94=A8=E7=9A=84?= =?UTF-8?q?commit=20message=E6=A8=A1=E6=9D=BF=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` docs(readme): 更新文档说明 - 添加了项目使用指南 - 完善了API接口说明 - 修正了一些文字错误 ``` 注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。 --- 康康/AI/AIRuntime.swift | 16 +--- 康康/AI/InferenceEngine.swift | 3 +- 康康/DesignSystem/VaultImage.swift | 5 +- .../Features/Archive/HealthExportListView.swift | 10 ++- 康康/Features/Archive/HealthExportSheet.swift | 81 +++++++++++++++---- .../Features/Capture/UnifiedCaptureFlow.swift | 29 +++++-- 康康/Features/Diary/DiaryQuickSheet.swift | 13 +-- 康康/Features/Home/HomeCalendarCard.swift | 26 +++--- .../Features/Indicator/CustomMetricEditor.swift | 24 ++++-- .../Indicator/IndicatorQuickSheet.swift | 40 +++++++++ 康康/Features/Me/CustomMetricsListView.swift | 15 ++-- .../Features/Me/CustomReminderEditSheet.swift | 9 ++- .../Profile/MedicationLibraryView.swift | 9 ++- .../Features/Profile/MedicationScanFlow.swift | 17 ++-- .../Features/Quick/QuickRegionCaptureFlow.swift | 11 +-- .../Timeline/IndicatorSeriesDetailView.swift | 6 +- 康康/Features/Trends/SeriesChartCard.swift | 25 ++++-- 康康/Features/Trends/TrendDetailView.swift | 6 +- 康康/Features/Trends/TrendsView.swift | 28 +++---- 康康/Localizable.xcstrings | 52 +++++++++--- 康康/Persistence/FileVault.swift | 7 +- 康康/Services/CaptureService.swift | 11 ++- 康康/Services/HealthExportService.swift | 75 ++++++++++------- 23 files changed, 364 insertions(+), 154 deletions(-) diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index 58ca338..669f274 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -34,7 +34,6 @@ actor AIRuntime { private(set) var status: Status = .notReady private(set) var vlStatus: Status = .notReady - private(set) var lastDecodeRate: Double = 0 /// 末次文本生成的性能统计(性能自检页消费;两后端归一)。 private(set) var lastGenerateStats: GenerateStats? @@ -247,6 +246,8 @@ actor AIRuntime { } // 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。 await self.acquireGate(priority) + // defer 保证正常结束 / 异常 / 取消都释放闸门;杜绝未来新增 early-return 导致全局推理死锁。 + defer { self.releaseGate() } do { // session.generate 跨 actor 边界,需要 await let stream = await session.generate(prompt: prompt, maxTokens: maxTokens) @@ -256,9 +257,6 @@ actor AIRuntime { try Task.checkCancellation() // 后台任务让位:前台请求在排队时,下一个 token 处主动退出。 if self.shouldPreempt(priority) { throw CancellationError() } - // Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离; - // 调用同 actor 的 recordRate 不需要 await - self.recordRate(chunk.decodeRate) continuation.yield(chunk) } self.lastGenerateStats = await session.lastStats @@ -269,9 +267,6 @@ actor AIRuntime { } catch { continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)")) } - // 正常结束 / 异常 / 取消(checkCancellation 抛出后被上面 catch 吞掉)都会走到这, - // 闸门一定释放,不会死锁后续推理。 - self.releaseGate() } // 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。 continuation.onTermination = { _ in task.cancel() } @@ -290,6 +285,7 @@ actor AIRuntime { return } await self.acquireGate(priority) + defer { self.releaseGate() } // 无论正常结束 / 异常 / 取消都释放闸门,防死锁 do { let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens) for try await chunk in stream { @@ -297,7 +293,6 @@ actor AIRuntime { // 后台任务让位:前台请求在排队时,下一个 token 处主动退出 //(流终止触发 MNNBackend.onTermination → bridge.cancel())。 if self.shouldPreempt(priority) { throw CancellationError() } - self.recordRate(chunk.decodeRate) continuation.yield(chunk) } self.lastGenerateStats = await self.mnn.lastStats @@ -307,16 +302,11 @@ actor AIRuntime { } catch { continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)")) } - self.releaseGate() } continuation.onTermination = { _ in task.cancel() } } } - private func recordRate(_ rate: Double) { - if rate > 0 { lastDecodeRate = rate } - } - // MARK: - VL /// 加载 VL 模型。幂等,首调真正 load。 diff --git a/康康/AI/InferenceEngine.swift b/康康/AI/InferenceEngine.swift index 303102d..7075c26 100644 --- a/康康/AI/InferenceEngine.swift +++ b/康康/AI/InferenceEngine.swift @@ -35,7 +35,8 @@ nonisolated enum InferenceEngine: String, CaseIterable, Sendable { } /// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。 - static var cpuSupportsSME2: Bool { MNNLLMBridge.cpuSupportsSME2() } + /// CPU 能力进程内不变,缓存一次,避免每次 UI 刷新都做 sysctl 系统调用。 + static let cpuSupportsSME2: Bool = MNNLLMBridge.cpuSupportsSME2() // MARK: - 用户偏好(auto / mnn / mlx) diff --git a/康康/DesignSystem/VaultImage.swift b/康康/DesignSystem/VaultImage.swift index 5db01d0..3a25c82 100644 --- a/康康/DesignSystem/VaultImage.swift +++ b/康康/DesignSystem/VaultImage.swift @@ -31,8 +31,9 @@ struct VaultImage: View { placeholder(loading) } } - // id 变了(TabView 翻到新页 / 行复用换 asset)就重新加载;同一身份重渲染不会重复读盘。 - .task(id: relativePath) { + // id 变了(TabView 翻到新页 / 行复用换 asset / 同一 path 改目标尺寸)就重新加载; + // 把 maxPixel 纳入 id(与 FileVault 缓存 key 同构),避免同 path 切尺寸时不刷新显示旧图。 + .task(id: "\(relativePath)@\(Int(maxPixel))") { loading = true let path = relativePath let mp = maxPixel diff --git a/康康/Features/Archive/HealthExportListView.swift b/康康/Features/Archive/HealthExportListView.swift index 12d5c9f..9c9924a 100644 --- a/康康/Features/Archive/HealthExportListView.swift +++ b/康康/Features/Archive/HealthExportListView.swift @@ -117,11 +117,17 @@ struct HealthExportRow: View { .tjCard() } - static func relativeDate(_ d: Date) -> String { + /// 复用单个 formatter:RelativeDateTimeFormatter 初始化较贵,列表每行每次重绘都 new 会累积开销。 + /// 用系统 Locale.current(与原实现一致),进程内不变,可安全缓存。 + private static let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() f.locale = Locale.current f.unitsStyle = .full - return f.localizedString(for: d, relativeTo: .now) + return f + }() + + static func relativeDate(_ d: Date) -> String { + relativeFormatter.localizedString(for: d, relativeTo: .now) } } diff --git a/康康/Features/Archive/HealthExportSheet.swift b/康康/Features/Archive/HealthExportSheet.swift index 4c403f4..146b7d7 100644 --- a/康康/Features/Archive/HealthExportSheet.swift +++ b/康康/Features/Archive/HealthExportSheet.swift @@ -19,6 +19,7 @@ struct HealthExportSheet: View { @State private var error: Error? @State private var completed: Bool = false @State private var copiedFlash: Bool = false + @State private var lastScrollAt: Date = .distantPast // 流式滚动节流时间戳 @State private var answeringTurnID: UUID? @State private var retrieval: HealthExportService.RetrievalSummary? @State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:] @@ -57,7 +58,7 @@ struct HealthExportSheet: View { header ScrollViewReader { proxy in ScrollView { - VStack(alignment: .leading, spacing: 18) { + LazyVStack(alignment: .leading, spacing: 18) { introSection ForEach(turns) { turn in @@ -76,15 +77,15 @@ struct HealthExportSheet: View { .padding(.horizontal, 20) .padding(.vertical, 16) } - .onChange(of: content) { _, _ in - withAnimation(.easeOut(duration: 0.12)) { - proxy.scrollTo("bottom", anchor: .bottom) - } + // 流式期间 content / turns 每 token 都变,逐次动画滚动会造成布局抖动; + // 节流到 ~8Hz,并在生成完成时补一次滚动确保停在底部。 + .onChange(of: content) { _, _ in throttledScrollToBottom(proxy) } + .onChange(of: turns) { _, _ in throttledScrollToBottom(proxy) } + .onChange(of: completed) { _, done in + if done { scrollToBottom(proxy) } } - .onChange(of: turns) { _, _ in - withAnimation(.easeOut(duration: 0.12)) { - proxy.scrollTo("bottom", anchor: .bottom) - } + .onChange(of: answeringTurnID) { _, id in + if id == nil { scrollToBottom(proxy) } } } if completed { @@ -358,7 +359,7 @@ struct HealthExportSheet: View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.brick) - Text(err.localizedDescription) + Text(friendlyMessage(for: err)) .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text) } @@ -504,8 +505,16 @@ struct HealthExportSheet: View { questionFocused = true } catch { answeringTurnID = nil - appendToTurn(id: assistantTurn.id, text: error.localizedDescription) 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 { + if error is CancellationError { return } + #if DEBUG + print("[HealthExport] export failed: \(error)") + #endif self.error = error self.phase = nil } @@ -600,6 +613,27 @@ struct HealthExportSheet: View { task?.cancel() 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 可视化) @@ -757,15 +791,30 @@ struct MarkdownView: View { return nil } + /// 行内样式缓存:流式生成时整段会被反复重渲染,缓存命中可避免对同一行重复跑昂贵的 + /// markdown 解析。键为行文本,稳定的历史行命中缓存,只有正在增长的尾行才真正解析。 + private final class AttrBox { let value: AttributedString; init(_ v: AttributedString) { value = v } } + private static let inlineCache: NSCache = { + let c = NSCache() + c.countLimit = 256 + return c + }() + 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 解析 - if let attr = try? AttributedString( + let attr = (try? AttributedString( markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) { - return attr - } - return AttributedString(s) + )) ?? AttributedString(s) + Self.inlineCache.setObject(AttrBox(attr), forKey: key) + return attr } // MARK: - 行级解析 diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift index 96d23ca..e0cf29b 100644 --- a/康康/Features/Capture/UnifiedCaptureFlow.swift +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -153,8 +153,21 @@ struct UnifiedCaptureFlow: View { phase = .analyzing(images: images, assets: nil) let timeout = analyzeTimeoutSeconds analyzeTask = Task { - // Step 1: 先把图写进 Vault(归档的核心价值就是「把原图存下来」,先保证它)。 - let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) } + // Step 1: 把图写进 Vault(归档核心价值:先把原图存下来),并立刻降采样出预览缩略图。 + // 整段放后台线程,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 看到的 // phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。 if Task.isCancelled { @@ -171,11 +184,9 @@ struct UnifiedCaptureFlow: View { } return } - // 把 assets 暴露给 phase,使工具栏「取消」也能找到孤儿清理。 + // 原图已落盘:phase 改持 600px 缩略图(释放全分辨率原图),同时把 assets 暴露给「取消」做孤儿清理。 await MainActor.run { - if case .analyzing(let imgs, _) = phase { - phase = .analyzing(images: imgs, assets: assets) - } + phase = .analyzing(images: written.thumbs.images, assets: assets) } // 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: - 分析中视图 private struct AnalyzingView: View { diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index 4e84a14..665363a 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -204,12 +204,12 @@ struct DiaryQuickSheet: View { if let note = voiceNote { HStack(spacing: 6) { - Image(systemName: "info.circle") + Image(systemName: "exclamationmark.circle.fill") .font(.tjScaled(11)) - .foregroundStyle(Tj.Palette.text3) + .foregroundStyle(Tj.Palette.amber) Text(note) .font(.tjScaled(11)) - .foregroundStyle(Tj.Palette.text3) + .foregroundStyle(Tj.Palette.text2) Spacer(minLength: 0) } } @@ -575,7 +575,10 @@ struct DiaryQuickSheet: View { } } } catch { - voiceNote = error.localizedDescription + #if DEBUG + print("[DiaryVoice] dictation start failed: \(error)") + #endif + voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限") voicePhase = .idle } } @@ -612,7 +615,7 @@ struct DiaryQuickSheet: View { guard !Task.isCancelled else { return } appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死 organizedAppended = nil - voiceNote = String(appLoc: "AI 整理失败,已填入原话") + voiceNote = String(appLoc: "AI 整理没成功,已填入未整理的原文") } withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle } } diff --git a/康康/Features/Home/HomeCalendarCard.swift b/康康/Features/Home/HomeCalendarCard.swift index 4f4fd58..b3eb450 100644 --- a/康康/Features/Home/HomeCalendarCard.swift +++ b/康康/Features/Home/HomeCalendarCard.swift @@ -46,8 +46,8 @@ struct HomeCalendarCard: View { return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) } } - /// 本月有记录的天数(指标/报告/日记/症状任一)。 - private var daysWithRecordsThisMonth: Int { + /// 本月有记录的天数(指标/报告/日记/症状任一)。传入已构建好的 data,避免逐天重建。 + private func daysWithRecordsThisMonth(_ data: CalendarData) -> Int { guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 } let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30 var n = 0 @@ -62,9 +62,11 @@ struct HomeCalendarCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { - header - weekStrip + // CalendarData 一次性构建,供周条 + 本月摘要复用 —— 杜绝逐格(7)+ 逐天(~30)反复全表重建。 + let calData = data + return VStack(alignment: .leading, spacing: 12) { + header(calData) + weekStrip(calData) } .padding(14) .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) { Text("健康日历") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Spacer() HStack(spacing: 3) { - Text(summaryLine) + Text(summaryLine(data)) .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Image(systemName: "chevron.right") @@ -94,20 +96,20 @@ struct HomeCalendarCard: View { } } - private var summaryLine: String { - let n = daysWithRecordsThisMonth + private func summaryLine(_ data: CalendarData) -> String { + let n = daysWithRecordsThisMonth(data) return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录") } - private var weekStrip: some View { + private func weekStrip(_ data: CalendarData) -> some View { HStack(spacing: 6) { 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 ranges = data.ranges(touching: day, calendar: calendar) let isToday = calendar.isDateInToday(day) diff --git a/康康/Features/Indicator/CustomMetricEditor.swift b/康康/Features/Indicator/CustomMetricEditor.swift index 79e0db7..2190a5c 100644 --- a/康康/Features/Indicator/CustomMetricEditor.swift +++ b/康康/Features/Indicator/CustomMetricEditor.swift @@ -64,6 +64,7 @@ struct CustomMetricEditor: View { @State private var upper: String = "" @State private var icon: String = "circle.fill" @State private var hydrated = false + @State private var showDeleteConfirm = false private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) } @@ -227,13 +228,7 @@ struct CustomMetricEditor: View { private var deleteButton: some View { Button(role: .destructive) { - if let m = existing { - ReminderService.cancel(metricId: m.seriesKey) - ctx.delete(m) - try? ctx.save() - onSaved(nil) - dismiss() - } + showDeleteConfirm = true } label: { HStack { Image(systemName: "trash") @@ -250,6 +245,21 @@ struct CustomMetricEditor: View { } .buttonStyle(.plain) .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 { diff --git a/康康/Features/Indicator/IndicatorQuickSheet.swift b/康康/Features/Indicator/IndicatorQuickSheet.swift index 9b5bd72..8be6f31 100644 --- a/康康/Features/Indicator/IndicatorQuickSheet.swift +++ b/康康/Features/Indicator/IndicatorQuickSheet.swift @@ -129,7 +129,29 @@ struct IndicatorQuickSheet: View { 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 { + guard numericValidationError == nil else { return false } if isBP { return !systolic.trimmingCharacters(in: .whitespaces).isEmpty && !diastolic.trimmingCharacters(in: .whitespaces).isEmpty @@ -162,6 +184,9 @@ struct IndicatorQuickSheet: View { statusSection } } + if let validationError = numericValidationError { + validationHint(validationError) + } timeSection noteSection @@ -1152,6 +1177,21 @@ struct IndicatorQuickSheet: View { 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 private func submit() { diff --git a/康康/Features/Me/CustomMetricsListView.swift b/康康/Features/Me/CustomMetricsListView.swift index 63411b7..2e14090 100644 --- a/康康/Features/Me/CustomMetricsListView.swift +++ b/康康/Features/Me/CustomMetricsListView.swift @@ -18,11 +18,16 @@ struct CustomMetricsListView: View { if metrics.isEmpty { emptyState } 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 Button { editingTarget = CustomMetricEditTarget(metric: m) } label: { - row(m) + row(m, usage: usageCounts[m.seriesKey] ?? 0) } .buttonStyle(.plain) } @@ -82,9 +87,8 @@ struct CustomMetricsListView: View { .frame(maxWidth: .infinity) } - private func row(_ m: CustomMonitorMetric) -> some View { - let count = usageCount(for: m) - return HStack(spacing: 12) { + private func row(_ m: CustomMonitorMetric, usage count: Int) -> some View { + HStack(spacing: 12) { ZStack { Circle().fill(Tj.Palette.leafSoft) 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 { diff --git a/康康/Features/Me/CustomReminderEditSheet.swift b/康康/Features/Me/CustomReminderEditSheet.swift index 18186a2..9f44cf8 100644 --- a/康康/Features/Me/CustomReminderEditSheet.swift +++ b/康康/Features/Me/CustomReminderEditSheet.swift @@ -27,6 +27,7 @@ struct CustomReminderEditSheet: View { @State private var month = 1 @State private var hydrated = 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)] @@ -79,7 +80,7 @@ struct CustomReminderEditSheet: View { if isEditing { Section { - Button(role: .destructive) { deleteReminder() } label: { + Button(role: .destructive) { showDeleteConfirm = true } label: { Label(String(appLoc: "删除提醒"), systemImage: "trash") } } @@ -110,6 +111,12 @@ struct CustomReminderEditSheet: View { } message: { Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。") } + .alert(String(appLoc: "删除这条提醒?"), isPresented: $showDeleteConfirm) { + Button(String(appLoc: "删除"), role: .destructive) { deleteReminder() } + Button(String(appLoc: "取消"), role: .cancel) { } + } message: { + Text("删除后该提醒不再触发。") + } } } diff --git a/康康/Features/Profile/MedicationLibraryView.swift b/康康/Features/Profile/MedicationLibraryView.swift index 58162cb..c0288fb 100644 --- a/康康/Features/Profile/MedicationLibraryView.swift +++ b/康康/Features/Profile/MedicationLibraryView.swift @@ -177,6 +177,7 @@ private struct MedicationEditSheet: View { @State private var viewerStart: PhotoIndex? /// 「记录一次服用」:嵌套拉起 MedicationLogSheet,预选当前药。 @State private var showLog = false + @State private var showDeleteConfirm = false private var isEditing: Bool { existing != nil } private var canSave: Bool { @@ -251,7 +252,7 @@ private struct MedicationEditSheet: View { if isEditing { Section { - Button(role: .destructive) { deleteMedication() } label: { + Button(role: .destructive) { showDeleteConfirm = true } label: { Label(String(appLoc: "从药品库删除"), systemImage: "trash") } } @@ -280,6 +281,12 @@ private struct MedicationEditSheet: View { .sheet(isPresented: $showLog) { MedicationLogSheet(preselected: existing) } + .alert(String(appLoc: "从药品库删除这种药?"), isPresented: $showDeleteConfirm) { + Button(String(appLoc: "删除"), role: .destructive) { deleteMedication() } + Button(String(appLoc: "取消"), role: .cancel) { } + } message: { + Text("关联的原图会一并永久删除,无法恢复。") + } } } diff --git a/康康/Features/Profile/MedicationScanFlow.swift b/康康/Features/Profile/MedicationScanFlow.swift index 490069e..6981fc6 100644 --- a/康康/Features/Profile/MedicationScanFlow.swift +++ b/康康/Features/Profile/MedicationScanFlow.swift @@ -316,12 +316,13 @@ struct MedicationScanFlow: View { return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil) } catch CaptureError.modelNotReady { return ([], String(appLoc: "AI 模型未就绪,可以手动填写")) - } catch let CaptureError.parseFailed(msg) { - return ([], String(appLoc: "解析失败:\(msg)")) - } catch let CaptureError.inferenceFailed(msg) { - return ([], String(appLoc: "识别失败:\(msg)")) + } catch CaptureError.parseFailed { + // 已回退手动录入,给用户友好提示;技术细节留在 DEBUG 日志,不抛到屏幕。 + return ([], String(appLoc: "没认出药品信息,可检查照片清晰度后重拍,或手动填写")) + } catch CaptureError.inferenceFailed { + return ([], String(appLoc: "识别没成功,可重拍或手动填写")) } catch { - return ([], String(appLoc: "未知错误:\(error.localizedDescription)")) + return ([], String(appLoc: "识别没成功,可重拍或手动填写")) } } @@ -354,9 +355,13 @@ enum MedicationArchiver { // 原图写加密 Vault(§5/§6:落 Application Support/Vault,目录级硬件加密)。 // 多药共享同批原图时只挂「第一条新建的药」,避免同一 JPEG 被多个 Asset 引用、 // 删一条 cascade 误删另一条还在用的文件。 + // 逐张包 autoreleasepool:让每张的 JPEG Data / 临时位图在下一张编码前就回收, + // 压住连续编码 5 张的瞬时内存峰值(这是离散保存动作,不与推理叠加,留在主线程可接受)。 let savedAssets = images .prefix(MedicationScanFlow.maxImages) - .compactMap { try? FileVault.shared.writeJPEG($0) } + .compactMap { img in + autoreleasepool { try? FileVault.shared.writeJPEG(img) } + } let existing = (try? ctx.fetch(FetchDescriptor())) ?? [] var attachedImages = false diff --git a/康康/Features/Quick/QuickRegionCaptureFlow.swift b/康康/Features/Quick/QuickRegionCaptureFlow.swift index 28e2cff..7362ac4 100644 --- a/康康/Features/Quick/QuickRegionCaptureFlow.swift +++ b/康康/Features/Quick/QuickRegionCaptureFlow.swift @@ -117,12 +117,13 @@ struct QuickRegionCaptureFlow: View { return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil) } catch CaptureError.modelNotReady { return ([], String(appLoc: "AI 模型未就绪,手动补充")) - } catch let CaptureError.parseFailed(msg) { - return ([], String(appLoc: "解析失败:\(msg)")) - } catch let CaptureError.inferenceFailed(msg) { - return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)")) + } catch CaptureError.parseFailed { + // 已回退手动录入,给用户友好提示;技术细节留在 CaptureService 的 DEBUG 日志。 + return ([], String(appLoc: "没自动认出指标,挪一下框再试,或手动填写")) + } catch CaptureError.inferenceFailed { + return ([], Task.isCancelled ? nil : String(appLoc: "识别没成功,挪一下框再试,或手动填写")) } catch { - return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)")) + return ([], Task.isCancelled ? nil : String(appLoc: "识别没成功,请手动填写")) } } diff --git a/康康/Features/Timeline/IndicatorSeriesDetailView.swift b/康康/Features/Timeline/IndicatorSeriesDetailView.swift index 1e26f0f..255b29c 100644 --- a/康康/Features/Timeline/IndicatorSeriesDetailView.swift +++ b/康康/Features/Timeline/IndicatorSeriesDetailView.swift @@ -62,13 +62,15 @@ struct IndicatorSeriesDetailView: View { /// 历次血压对:以 bp.systolic 为锚,按 ±5s 配 bp.diastolic(同 TimelineEntry 合并规则)。 private var bloodPressureRecords: [Record] { + // 收缩压、舒张压各过滤一次(O(n)),再在小得多的舒张压子集里配对, + // 避免「每条收缩压都重扫整张指标表」的 O(n²) 放大。 let sysList = indicators .filter { $0.seriesKey == "bp.systolic" } .sorted { $0.capturedAt > $1.capturedAt } + let diaList = indicators.filter { $0.seriesKey == "bp.diastolic" } var usedDia = Set() return sysList.map { sys in - let dia = indicators.first { - $0.seriesKey == "bp.diastolic" && + let dia = diaList.first { !usedDia.contains($0.persistentModelID) && abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5 } diff --git a/康康/Features/Trends/SeriesChartCard.swift b/康康/Features/Trends/SeriesChartCard.swift index d873b96..c48dd9f 100644 --- a/康康/Features/Trends/SeriesChartCard.swift +++ b/康康/Features/Trends/SeriesChartCard.swift @@ -4,11 +4,23 @@ import Charts struct SeriesChartCard: View { let bucket: SeriesBucket - private var allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] { - bucket.lines.flatMap { line in line.points.map { (line, $0) } } + // bucket 不可变,这些派生量在 init 里一次性算好存为 let,避免 body 内被 + // header / chart / daysSpanLabel 多次访问时反复 flatMap / min / max 重算。 + private let allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] + private let dateDomain: ClosedRange? + private let valueDomain: ClosedRange? + + 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? { + private static func makeDateDomain( + _ allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] + ) -> ClosedRange? { let dates = allPoints.map(\.point.date) guard let lo = dates.min(), let hi = dates.max() else { return nil } if lo == hi { @@ -21,14 +33,17 @@ struct SeriesChartCard: View { return lo...hi } - private var valueDomain: ClosedRange? { + private static func makeValueDomain( + _ allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)], + lines: [SeriesBucket.SeriesLine] + ) -> ClosedRange? { var lo = Double.greatestFiniteMagnitude var hi = -Double.greatestFiniteMagnitude for (_, p) in allPoints { lo = min(lo, p.value) hi = max(hi, p.value) } - for line in bucket.lines { + for line in lines { if let r = line.referenceRange { lo = min(lo, r.lowerBound) hi = max(hi, r.upperBound) diff --git a/康康/Features/Trends/TrendDetailView.swift b/康康/Features/Trends/TrendDetailView.swift index 95e7fe9..11db1f7 100644 --- a/康康/Features/Trends/TrendDetailView.swift +++ b/康康/Features/Trends/TrendDetailView.swift @@ -506,7 +506,11 @@ private struct TrendInsightCard: View { do { text = try await TrendInsightService.shared.generate(for: bucket) } 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 } diff --git a/康康/Features/Trends/TrendsView.swift b/康康/Features/Trends/TrendsView.swift index a08b57b..04c0587 100644 --- a/康康/Features/Trends/TrendsView.swift +++ b/康康/Features/Trends/TrendsView.swift @@ -22,36 +22,32 @@ struct TrendsView: View { 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] { let q = query.trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return buckets } return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) } } - private var filteredMonitor: [SeriesBucket] { filtered(monitorBuckets) } - private var filteredLab: [SeriesBucket] { filtered(labBuckets) } 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) { VStack(alignment: .leading, spacing: 18) { header.padding(.top, 4) - if seriesBuckets.isEmpty { + if series.isEmpty { emptyState - } else if filteredMonitor.isEmpty && filteredLab.isEmpty { + } else if monitor.isEmpty && lab.isEmpty { noMatchState } else { - if !filteredMonitor.isEmpty { - section(title: String(appLoc: "长期监测"), buckets: filteredMonitor) + if !monitor.isEmpty { + section(title: String(appLoc: "长期监测"), buckets: monitor) } - if !filteredLab.isEmpty { - section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab) + if !lab.isEmpty { + section(title: String(appLoc: "化验指标趋势"), buckets: lab) } } } diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 22ea4ec..4980489 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -394,6 +394,7 @@ } }, "%lld 个建议" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -825,9 +826,6 @@ }, "%lld 项异常" : { - }, - "%lld." : { - }, "%lld/%lld 就绪" : { "localizations" : { @@ -1242,12 +1240,6 @@ } } } - }, - "AI 生成中 · %.1f tok/s" : { - - }, - "AI 生成中 · 本地推理" : { - }, "AI 解读" : { @@ -1300,6 +1292,7 @@ } }, "AI 辅助 · 医生角度查漏补缺" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1977,6 +1970,9 @@ } } } + }, + "主要的都帮你问到啦 · 再想想?" : { + }, "主页" : { "localizations" : { @@ -2969,6 +2965,9 @@ } } } + }, + "停" : { + }, "停止生成" : { @@ -3050,6 +3049,7 @@ } }, "先写几个字,AI 来帮忙补充" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3405,6 +3405,7 @@ }, "再问一轮 · 让 AI 从新角度追问" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5214,6 +5215,7 @@ }, "将追加:" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5630,9 +5632,6 @@ } } } - }, - "已覆盖主要问诊维度;补充原文后可再追问" : { - }, "已识别边框 · 将自动透视校正" : { "extractionState" : "stale", @@ -5658,6 +5657,7 @@ } }, "已采纳" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5857,6 +5857,15 @@ } } } + }, + "康康在想想 · %.1f tok/s" : { + + }, + "康康在想想…" : { + + }, + "康康帮你记" : { + }, "康康是一款以本地优先为设计原则的个人健康随记工具。" : { "localizations" : { @@ -8178,6 +8187,7 @@ } }, "更新一下原文,再让 AI 继续追问" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -10278,6 +10288,7 @@ } }, "第 %lld 轮 · 基于你刚才更新的文本 · %lld 条" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -10309,6 +10320,7 @@ }, "第 1 轮 · %lld 条" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11140,6 +11152,7 @@ } }, "让 AI 帮我想想还能记什么" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11160,6 +11173,12 @@ } } } + }, + "让康康帮你把这条记得更全" : { + + }, + "记一下" : { + }, "记剂量与时间" : { @@ -11310,6 +11329,7 @@ } }, "记录身体状态 · 可让 AI 多轮辅助查漏补缺" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11330,6 +11350,9 @@ } } } + }, + "记录身体状态 · 康康在一旁帮你想还能记点啥" : { + }, "记录身体状态、用药、感受 · 可让 AI 辅助" : { "extractionState" : "stale", @@ -11895,7 +11918,6 @@ } }, "跳过" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -12231,6 +12253,9 @@ } } } + }, + "还想到几个想问你 · 再来一轮" : { + }, "还没有任何记录\n点底部 + 号开始" : { "localizations" : { @@ -12511,6 +12536,7 @@ }, "采纳" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/康康/Persistence/FileVault.swift b/康康/Persistence/FileVault.swift index 361befe..ab0d995 100644 --- a/康康/Persistence/FileVault.swift +++ b/康康/Persistence/FileVault.swift @@ -40,6 +40,9 @@ nonisolated final class FileVault: @unchecked Sendable { private nonisolated(unsafe) let thumbnailCache: NSCache = { let cache = NSCache() cache.countLimit = 40 + // 关键:按「解码后真实字节数」限制总量,而非只数张数。否则 40 张全屏 2000px 大图 + // (各 ~12-16MB)就能占数百 MB 把 App 推过内存上限;混在同一缓存里的小缩略图也会被错误计权。 + cache.totalCostLimit = 96 * 1024 * 1024 // 96MB return cache }() @@ -119,7 +122,9 @@ nonisolated final class FileVault: @unchecked Sendable { throw FileVaultError.decodeFailed } 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 } diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index f4304d9..5cf8ab8 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -147,11 +147,14 @@ actor CaptureService { do { return try CaptureService.parseIndicatorsJSON(cleaned) } catch let CaptureError.parseFailed(msg) { - // 把模型实际输出的特征带到屏幕上,便于现场定位(原始长度 / strip 后长度 / 前缀)。 - let rawLen = collected.count - let cleanLen = cleaned.count + #if DEBUG + // 仅 DEBUG:把模型实际输出特征带到屏幕便于现场定位(原始 / strip 后长度 + 前缀)。 + // Release 绝不把字节数 / JSON 前缀这类调试串抛给用户(§10 不能让用户卡在 AI 错误屏)。 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 { throw CaptureError.parseFailed("\(error)") } diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift index abaaec6..d476ece 100644 --- a/康康/Services/HealthExportService.swift +++ b/康康/Services/HealthExportService.swift @@ -155,7 +155,7 @@ struct HealthExportService { // 用「全文累计 + 每 chunk 重清 + diff yield」: // - thinking 阶段,UI 看到的 generated 始终为空 // - 看到 后,真实内容流式出现 - var rawAccum = "" + var stripper = ThinkStripper() let stream = await AIRuntime.shared.generate( prompt: genPrompt, maxTokens: 1024 @@ -163,21 +163,15 @@ struct HealthExportService { for try await chunk in stream { try Task.checkCancellation() if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } - rawAccum += chunk.text - let clean = Self.stripThinkBlocks(rawAccum) - if clean.count > generated.count, clean.hasPrefix(generated) { - let delta = String(clean.dropFirst(generated.count)) - generated = clean + let delta = stripper.feed(chunk.text) + if !delta.isEmpty { continuation.yield(.token(TokenChunk( text: delta, decodeRate: chunk.decodeRate ))) - } else if clean != generated { - // 极少:清理后比上次还短(模型补了开标签)。让 UI 不要回退, - // 直接对齐 generated = clean 但不 yield(避免显示倒退)。 - generated = clean } } + generated = stripper.output } guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -250,21 +244,16 @@ struct HealthExportService { dataJSON: dataJSON ) - var displayed = "" - var rawAccum = "" + var stripper = ThinkStripper() let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 480) for try await chunk in stream { try Task.checkCancellation() - rawAccum += chunk.text - let clean = Self.stripThinkBlocks(rawAccum) - if clean.count > displayed.count, clean.hasPrefix(displayed) { - let delta = String(clean.dropFirst(displayed.count)) - displayed = clean + let delta = stripper.feed(chunk.text) + if !delta.isEmpty { 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 { throw ServiceError.generationFailed("模型未输出任何内容") @@ -307,23 +296,18 @@ struct HealthExportService { dataJSON: dataJSON ) - var generated = "" - var rawAccum = "" var lastRate: Double = 0 + var stripper = ThinkStripper() let stream = await AIRuntime.shared.generate(prompt: genPrompt, maxTokens: 1200) for try await chunk in stream { try Task.checkCancellation() if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } - rawAccum += chunk.text - let clean = Self.stripThinkBlocks(rawAccum) - if clean.count > generated.count, clean.hasPrefix(generated) { - let delta = String(clean.dropFirst(generated.count)) - generated = clean + let delta = stripper.feed(chunk.text) + if !delta.isEmpty { 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 { throw ServiceError.generationFailed("模型未输出任何内容") @@ -786,3 +770,38 @@ struct HealthExportService { return s } } + +/// 流式 `` 去除器:逐 chunk 喂入,返回这次应增量 yield 的 delta。 +/// +/// 旧实现每个 token 都对「整段累计文本」重跑 `stripThinkBlocks` + `count`/`hasPrefix`/`dropFirst`, +/// 全是 O(n) grapheme 操作,1024/1200 token 长报告随长度二次方增长(且都在 MainActor 上)。 +/// 这里一旦思考段闭合(出现 ``)或确定不存在(首个非空字符不是 `<`,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("") { + resolved = true // 思考段已闭合,其后纯增量 + } else if let c = rawAccum.first(where: { !$0.isWhitespace }), c != "<" { + resolved = true // 开头非 '<' → 不存在 + } + return delta + } +}