根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:

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

- 添加了项目使用指南
- 完善了API接口说明
- 修正了一些文字错误
```

注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。
This commit is contained in:
link2026
2026-06-17 08:35:59 +08:00
parent b3777d508d
commit de19d7abcd
23 changed files with 364 additions and 154 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
if done { scrollToBottom(proxy) }
} }
.onChange(of: turns) { _, _ in .onChange(of: answeringTurnID) { _, id in
withAnimation(.easeOut(duration: 0.12)) { if id == nil { scrollToBottom(proxy) }
proxy.scrollTo("bottom", anchor: .bottom)
}
} }
} }
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,15 +791,30 @@ 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)
return attr Self.inlineCache.setObject(AttrBox(attr), forKey: key)
} return attr
return AttributedString(s)
} }
// MARK: - // MARK: -

View File

@@ -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 dismisscancelAll // :,View dismisscancelAll
// 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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("删除后该提醒不再触发。")
}
} }
} }

View File

@@ -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("关联的原图会一并永久删除,无法恢复。")
}
} }
} }

View File

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

View File

@@ -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: "识别没成功,请手动填写"))
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" : {

View File

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

View File

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

View File

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