根据提供的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 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

View File

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

View File

@@ -31,8 +31,9 @@ struct VaultImage<Content: View, Placeholder: View>: 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

View File

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

View File

@@ -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<NSString, AttrBox> = {
let c = NSCache<NSString, AttrBox>()
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: -

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Medication>())) ?? []
var attachedImages = false

View File

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

View File

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

View File

@@ -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<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)
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<Double>? {
private static func makeValueDomain(
_ allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)],
lines: [SeriesBucket.SeriesLine]
) -> ClosedRange<Double>? {
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)

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,9 @@ nonisolated final class FileVault: @unchecked Sendable {
private nonisolated(unsafe) let thumbnailCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
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
}

View File

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

View File

@@ -155,7 +155,7 @@ struct HealthExportService {
// + chunk + diff yield:
// - thinking ,UI generated
// - </think> ,
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
}
}
/// `<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
}
}