feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时 - 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的 SME2和A17的NEON加速 - 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制 - 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构 - 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX - 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理 - 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
542 lines
19 KiB
Swift
542 lines
19 KiB
Swift
import Foundation
|
||
import CoreGraphics
|
||
import SwiftData
|
||
|
||
enum IndicatorStatus: String, Codable, CaseIterable {
|
||
case high, low, normal
|
||
}
|
||
|
||
/// 指标录入来源。manual = 「记录指标」手动录入;quickCapture = 指标速记(VL);report = 报告归档携带。
|
||
/// 旧数据无此字段 → 默认 manual(轻量迁移)。
|
||
enum IndicatorSource: String, Codable, CaseIterable {
|
||
case manual, quickCapture, report
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .manual: return String(appLoc: "手动记录")
|
||
case .quickCapture: return String(appLoc: "指标速记")
|
||
case .report: return String(appLoc: "报告归档")
|
||
}
|
||
}
|
||
}
|
||
|
||
enum ReportType: String, Codable, CaseIterable {
|
||
case checkup, lab, imaging, prescription, other
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .checkup: return String(appLoc: "体检报告")
|
||
case .lab: return String(appLoc: "化验单")
|
||
case .imaging: return String(appLoc: "影像报告")
|
||
case .prescription: return String(appLoc: "处方")
|
||
case .other: return String(appLoc: "其他")
|
||
}
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class Indicator {
|
||
var name: String
|
||
var value: String
|
||
var unit: String
|
||
var range: String
|
||
var statusRaw: String
|
||
var note: String?
|
||
var capturedAt: Date
|
||
|
||
var report: Report?
|
||
var asset: Asset?
|
||
var pinned: Bool = false
|
||
|
||
/// 长期指标系列 key,如 "bp.systolic" / "glucose.fasting" / "weight"。
|
||
/// 来源:IndicatorRecordSheet 选预设时填;VL/Report/自由输入留 nil。
|
||
/// 用途:Trends 按 seriesKey 分组;Timeline 配对(如 bp.systolic + bp.diastolic 合并)。
|
||
var seriesKey: String?
|
||
|
||
/// 录入来源(IndicatorSource.rawValue)。带默认值 → SwiftData 轻量迁移,旧记录视为手动。
|
||
var sourceRaw: String = IndicatorSource.manual.rawValue
|
||
|
||
/// VL 从报告原图中定位到的指标证据。页码为 0-based;box 为原图归一化坐标(0...1)。
|
||
/// 全部可选以兼容旧数据、手动录入和无定位的模型输出。
|
||
var sourcePageIndex: Int?
|
||
var sourceBoxX: Double?
|
||
var sourceBoxY: Double?
|
||
var sourceBoxWidth: Double?
|
||
var sourceBoxHeight: Double?
|
||
|
||
init(name: String,
|
||
value: String,
|
||
unit: String,
|
||
range: String,
|
||
status: IndicatorStatus,
|
||
note: String? = nil,
|
||
capturedAt: Date = .now,
|
||
report: Report? = nil,
|
||
asset: Asset? = nil,
|
||
pinned: Bool = false,
|
||
seriesKey: String? = nil,
|
||
source: IndicatorSource = .manual,
|
||
sourcePageIndex: Int? = nil,
|
||
sourceBoxX: Double? = nil,
|
||
sourceBoxY: Double? = nil,
|
||
sourceBoxWidth: Double? = nil,
|
||
sourceBoxHeight: Double? = nil) {
|
||
self.name = name
|
||
self.value = value
|
||
self.unit = unit
|
||
self.range = range
|
||
self.statusRaw = status.rawValue
|
||
self.note = note
|
||
self.capturedAt = capturedAt
|
||
self.report = report
|
||
self.asset = asset
|
||
self.pinned = pinned
|
||
self.seriesKey = seriesKey
|
||
self.sourceRaw = source.rawValue
|
||
self.sourcePageIndex = sourcePageIndex
|
||
self.sourceBoxX = sourceBoxX
|
||
self.sourceBoxY = sourceBoxY
|
||
self.sourceBoxWidth = sourceBoxWidth
|
||
self.sourceBoxHeight = sourceBoxHeight
|
||
}
|
||
|
||
var status: IndicatorStatus {
|
||
IndicatorStatus(rawValue: statusRaw) ?? .normal
|
||
}
|
||
|
||
var source: IndicatorSource {
|
||
IndicatorSource(rawValue: sourceRaw) ?? .manual
|
||
}
|
||
|
||
var hasEvidenceBox: Bool {
|
||
evidenceRect != nil && sourcePageIndex != nil
|
||
}
|
||
|
||
var evidenceRect: CGRect? {
|
||
guard let x = sourceBoxX,
|
||
let y = sourceBoxY,
|
||
let width = sourceBoxWidth,
|
||
let height = sourceBoxHeight,
|
||
x >= 0, y >= 0, width > 0, height > 0,
|
||
x + width <= 1, y + height <= 1 else {
|
||
return nil
|
||
}
|
||
return CGRect(x: x, y: y, width: width, height: height)
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class Report {
|
||
var title: String
|
||
var typeRaw: String
|
||
var reportDate: Date
|
||
var institution: String?
|
||
var note: String?
|
||
var summary: String?
|
||
var pageCount: Int
|
||
var createdAt: Date
|
||
|
||
@Relationship(deleteRule: .cascade, inverse: \Indicator.report)
|
||
var indicators: [Indicator] = []
|
||
|
||
@Relationship(deleteRule: .cascade)
|
||
var assets: [Asset] = []
|
||
|
||
init(title: String,
|
||
type: ReportType,
|
||
reportDate: Date,
|
||
institution: String? = nil,
|
||
note: String? = nil,
|
||
summary: String? = nil,
|
||
pageCount: Int = 1,
|
||
createdAt: Date = .now) {
|
||
self.title = title
|
||
self.typeRaw = type.rawValue
|
||
self.reportDate = reportDate
|
||
self.institution = institution
|
||
self.note = note
|
||
self.summary = summary
|
||
self.pageCount = pageCount
|
||
self.createdAt = createdAt
|
||
}
|
||
|
||
var type: ReportType {
|
||
ReportType(rawValue: typeRaw) ?? .other
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class DiaryEntry {
|
||
var content: String
|
||
var createdAt: Date
|
||
var tags: [String]
|
||
|
||
/// 拍药盒入档时关联的原图(最多 5 张:正面/背面/说明书…)。
|
||
/// 默认空数组 → 旧数据轻量迁移安全(见 swiftdata-rebuild-data-loss)。
|
||
/// cascade:删日记同删 Asset 记录;Vault 里的 JPEG 仍需在删除入口手动 unlink。
|
||
@Relationship(deleteRule: .cascade)
|
||
var assets: [Asset] = []
|
||
|
||
init(content: String, createdAt: Date = .now, tags: [String] = []) {
|
||
self.content = content
|
||
self.createdAt = createdAt
|
||
self.tags = tags
|
||
}
|
||
}
|
||
|
||
extension DiaryEntry {
|
||
/// 「拍药盒入档」落库时打的 tag。是数据标识不是 UI 文案,**不要**走 appLoc 本地化
|
||
/// (语言切换后旧数据要还能被识别)。时间线据此把该日记归到「用药」分类。
|
||
static let medicationTag = "用药"
|
||
|
||
var isMedicationLog: Bool { tags.contains(Self.medicationTag) }
|
||
}
|
||
|
||
@Model
|
||
final class Asset {
|
||
var relativePath: String
|
||
var mimeType: String
|
||
var bytes: Int
|
||
var createdAt: Date
|
||
|
||
init(relativePath: String,
|
||
mimeType: String = "image/jpeg",
|
||
bytes: Int = 0,
|
||
createdAt: Date = .now) {
|
||
self.relativePath = relativePath
|
||
self.mimeType = mimeType
|
||
self.bytes = bytes
|
||
self.createdAt = createdAt
|
||
}
|
||
}
|
||
|
||
/// 药品库:用户「我有哪些药」的 master 档案(拍药盒识别或手输入库)。
|
||
/// 与「用药使用记录」(带 `DiaryEntry.medicationTag` 的日记,记某次服用的剂量 + 时间)分层:
|
||
/// 这里只放清单 / 规格 / 用法 / 原图,不带服用时间。
|
||
/// 新增 @Model 表 → SwiftData 轻量迁移安全(见 KangkangApp 兜底注释)。
|
||
@Model
|
||
final class Medication {
|
||
var name: String // 药名(通用名,可含商品名),与 ParsedMedication.name 同义
|
||
var strength: String // 规格,如 "80mg×7粒";无则 ""
|
||
var usage: String // 用法,如 "一日一次,一次一粒";无则 ""
|
||
var note: String? // 备注(可选)
|
||
var createdAt: Date
|
||
var updatedAt: Date
|
||
|
||
/// 入库时关联的原图(药盒正面 / 背面 / 说明书,最多 5 张)。默认空数组 → 旧数据轻量迁移安全。
|
||
/// cascade:删药品同删 Asset 记录;Vault 里的 JPEG 仍需在删除入口手动 unlink(同 DiaryEntry.assets 约定)。
|
||
@Relationship(deleteRule: .cascade)
|
||
var assets: [Asset] = []
|
||
|
||
init(name: String,
|
||
strength: String = "",
|
||
usage: String = "",
|
||
note: String? = nil,
|
||
createdAt: Date = .now) {
|
||
self.name = name
|
||
self.strength = strength
|
||
self.usage = usage
|
||
self.note = note
|
||
self.createdAt = createdAt
|
||
self.updatedAt = createdAt
|
||
}
|
||
|
||
/// 列表副标题 / 写日记选药时的展示行:"80mg×7粒 · 一日一次"(缺项自动省略)。
|
||
var detailLine: String {
|
||
[strength, usage]
|
||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||
.joined(separator: " · ")
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class Symptom {
|
||
var name: String
|
||
var startedAt: Date
|
||
var endedAt: Date?
|
||
var note: String?
|
||
var severity: Int
|
||
var tags: [String]
|
||
var createdAt: Date
|
||
|
||
init(name: String,
|
||
startedAt: Date = .now,
|
||
endedAt: Date? = nil,
|
||
note: String? = nil,
|
||
severity: Int = 3,
|
||
tags: [String] = [],
|
||
createdAt: Date = .now) {
|
||
self.name = name
|
||
self.startedAt = startedAt
|
||
self.endedAt = endedAt
|
||
self.note = note
|
||
self.severity = max(1, min(5, severity))
|
||
self.tags = tags
|
||
self.createdAt = createdAt
|
||
}
|
||
|
||
var isOngoing: Bool { endedAt == nil }
|
||
|
||
var duration: TimeInterval {
|
||
(endedAt ?? .now).timeIntervalSince(startedAt)
|
||
}
|
||
}
|
||
|
||
/// 用户自定义的长期监测指标。
|
||
/// 与 hardcoded `MonitorMetric` 并列出现在 IndicatorQuickSheet 的 grid 里;
|
||
/// `seriesKey` 自动生成成 `"custom.<uuid>"`,以此和 Indicator 双向关联。
|
||
@Model
|
||
final class CustomMonitorMetric {
|
||
@Attribute(.unique) var seriesKey: String
|
||
var name: String
|
||
var unit: String
|
||
var lowerBound: Double?
|
||
var upperBound: Double?
|
||
var icon: String
|
||
var createdAt: Date
|
||
|
||
init(name: String,
|
||
unit: String,
|
||
lowerBound: Double? = nil,
|
||
upperBound: Double? = nil,
|
||
icon: String = "circle.fill",
|
||
createdAt: Date = .now) {
|
||
self.seriesKey = "custom.\(UUID().uuidString)"
|
||
self.name = name
|
||
self.unit = unit
|
||
self.lowerBound = lowerBound
|
||
self.upperBound = upperBound
|
||
self.icon = icon
|
||
self.createdAt = createdAt
|
||
}
|
||
|
||
var referenceRange: ClosedRange<Double>? {
|
||
guard let lo = lowerBound, let hi = upperBound, lo <= hi else { return nil }
|
||
return lo...hi
|
||
}
|
||
|
||
var rangeText: String {
|
||
guard let r = referenceRange else { return "" }
|
||
return "\(Self.format(r.lowerBound)) - \(Self.format(r.upperBound))"
|
||
}
|
||
|
||
private static func format(_ v: Double) -> String {
|
||
v.truncatingRemainder(dividingBy: 1) == 0
|
||
? String(format: "%.0f", v)
|
||
: String(format: "%.1f", v)
|
||
}
|
||
}
|
||
|
||
/// 长期监测指标的周期性记录提醒。
|
||
/// 一个 metric 一条(`metricId` = `MonitorMetric.rawValue`)。
|
||
/// 关闭通过 `enabled=false`(保留时间设置),删除走 `ctx.delete`。
|
||
@Model
|
||
final class MetricReminder {
|
||
@Attribute(.unique) var metricId: String
|
||
var displayName: String
|
||
var enabled: Bool
|
||
var hour: Int // 0...23
|
||
var minute: Int // 0...59
|
||
var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天
|
||
var createdAt: Date
|
||
var updatedAt: Date
|
||
|
||
init(metricId: String,
|
||
displayName: String,
|
||
hour: Int = 8,
|
||
minute: Int = 0,
|
||
weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7],
|
||
enabled: Bool = true,
|
||
createdAt: Date = .now) {
|
||
self.metricId = metricId
|
||
self.displayName = displayName
|
||
self.enabled = enabled
|
||
self.hour = max(0, min(23, hour))
|
||
self.minute = max(0, min(59, minute))
|
||
self.weekdays = weekdays
|
||
self.createdAt = createdAt
|
||
self.updatedAt = createdAt
|
||
}
|
||
|
||
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
|
||
|
||
var frequencyLabel: String {
|
||
if !enabled { return String(appLoc: "已关闭") }
|
||
if isEveryDay { return String(appLoc: "每天") }
|
||
if weekdays.isEmpty { return String(appLoc: "未选日") }
|
||
let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")]
|
||
let sorted = weekdays.sorted()
|
||
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
|
||
}
|
||
|
||
var timeLabel: String {
|
||
String(format: "%02d:%02d", hour, minute)
|
||
}
|
||
|
||
/// 这条指标提醒在给定日期「这天」是否会触发(weekday 制,全 7 = 每天);关闭则恒为 false。
|
||
/// 供主页「今日提醒」筛选。
|
||
func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
|
||
guard enabled else { return false }
|
||
return weekdays.contains(calendar.component(.weekday, from: date))
|
||
}
|
||
}
|
||
|
||
/// 自由文案的周期性提醒(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」)。
|
||
/// 与 `MetricReminder`(去记录某指标)语义独立:这里是用户自定义的动作提醒,
|
||
/// 量词(5 公里 / 2 片)直接写在 `title` 自由文本里。
|
||
/// 周期粒度沿用 weekday 约定(全 7 = 每天);本地通知调度见 `ReminderService`。
|
||
@Model
|
||
final class CustomReminder {
|
||
/// 周期粒度。每日只看时间;每周看 weekdays;每月看 dayOfMonth;每年看 month + dayOfMonth。
|
||
enum Frequency: String, CaseIterable, Sendable {
|
||
case daily, weekly, monthly, yearly
|
||
}
|
||
|
||
@Attribute(.unique) var id: UUID
|
||
var title: String // 用户文案,如 "跑步5公里"
|
||
var note: String // 可选备注 → 通知正文
|
||
var hour: Int // 0...23
|
||
var minute: Int // 0...59
|
||
var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天
|
||
var frequencyRaw: String = "daily" // 旧:单选频率代表值;多选见 frequenciesRaw
|
||
var dayOfMonth: Int = 1 // yearly 用 + 旧 monthly 单选兜底,1...31
|
||
var month: Int = 1 // yearly 用,1...12
|
||
/// 多选频率原始值(["daily","weekly",...])。空 = 旧数据,回退到单选 frequency。
|
||
var frequenciesRaw: [String] = []
|
||
/// 每月多选日期(1...31)。空 = 旧数据,回退到单选 dayOfMonth。
|
||
var monthDays: [Int] = []
|
||
var enabled: Bool
|
||
var createdAt: Date
|
||
var updatedAt: Date
|
||
|
||
init(id: UUID = UUID(),
|
||
title: String,
|
||
note: String = "",
|
||
hour: Int = 8,
|
||
minute: Int = 0,
|
||
weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7],
|
||
frequency: Frequency = .daily,
|
||
dayOfMonth: Int = 1,
|
||
month: Int = 1,
|
||
enabled: Bool = true,
|
||
createdAt: Date = .now) {
|
||
self.id = id
|
||
self.title = title
|
||
self.note = note
|
||
self.hour = max(0, min(23, hour))
|
||
self.minute = max(0, min(59, minute))
|
||
self.weekdays = weekdays
|
||
self.frequencyRaw = frequency.rawValue
|
||
self.dayOfMonth = max(1, min(31, dayOfMonth))
|
||
self.month = max(1, min(12, month))
|
||
self.enabled = enabled
|
||
self.createdAt = createdAt
|
||
self.updatedAt = createdAt
|
||
}
|
||
|
||
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
|
||
|
||
var frequency: Frequency {
|
||
get { Frequency(rawValue: frequencyRaw) ?? .daily }
|
||
set { frequencyRaw = newValue.rawValue }
|
||
}
|
||
|
||
/// 生效的频率集合(多选)。frequenciesRaw 为空时回退到单选 frequency(兼容旧数据 / 旧 init)。
|
||
var frequencies: Set<Frequency> {
|
||
get {
|
||
let parsed = Set(frequenciesRaw.compactMap { Frequency(rawValue: $0) })
|
||
return parsed.isEmpty ? [frequency] : parsed
|
||
}
|
||
set {
|
||
frequenciesRaw = newValue.map(\.rawValue).sorted()
|
||
// 同步单选代表值,旧读者读 frequency 仍合理。
|
||
if let rep = newValue.map(\.rawValue).sorted().first { frequencyRaw = rep }
|
||
}
|
||
}
|
||
|
||
/// 每月生效日期(多选,1...31)。monthDays 为空时回退到单选 dayOfMonth(兼容旧数据)。
|
||
/// 注意:不回写 dayOfMonth —— 后者仍归 yearly 独占,避免「同时选每月+每年」时互相覆盖。
|
||
var monthlyDays: [Int] {
|
||
get { monthDays.isEmpty ? [dayOfMonth] : monthDays.sorted() }
|
||
set { monthDays = Set(newValue.map { max(1, min(31, $0)) }).sorted() }
|
||
}
|
||
|
||
/// 列表行副标题:多选频率用「 · 」拼接,如「每周一三五 · 每月1·15日」。
|
||
/// 含「每日」时直接显示「每天」(已覆盖其余)。
|
||
var frequencyLabel: String {
|
||
if !enabled { return String(appLoc: "已关闭") }
|
||
let active = frequencies
|
||
if active.contains(.daily) { return String(appLoc: "每天") }
|
||
// weekly 选满 7 天等价每天。
|
||
if active == [.weekly] && isEveryDay { return String(appLoc: "每天") }
|
||
let order: [Frequency] = [.weekly, .monthly, .yearly]
|
||
let parts = order.filter { active.contains($0) }.map { freqPartLabel($0) }
|
||
return parts.isEmpty ? String(appLoc: "未选日") : parts.joined(separator: " · ")
|
||
}
|
||
|
||
private func freqPartLabel(_ f: Frequency) -> String {
|
||
switch f {
|
||
case .daily:
|
||
return String(appLoc: "每天")
|
||
case .weekly:
|
||
if isEveryDay { return String(appLoc: "每天") }
|
||
if weekdays.isEmpty { return String(appLoc: "未选日") }
|
||
let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")]
|
||
return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined()
|
||
case .monthly:
|
||
let days = monthlyDays
|
||
if days.isEmpty { return String(appLoc: "未选日") }
|
||
return String(appLoc: "每月") + days.map { String($0) }.joined(separator: "·") + String(appLoc: "日")
|
||
case .yearly:
|
||
return String(appLoc: "每年\(month)月\(dayOfMonth)日")
|
||
}
|
||
}
|
||
|
||
var timeLabel: String {
|
||
String(format: "%02d:%02d", hour, minute)
|
||
}
|
||
|
||
/// 这条提醒在给定日期「这天」是否会触发(只看哪天,不看时分);关闭则恒为 false。
|
||
/// 供主页「今日提醒」筛选。monthly/yearly 选了无此日的月份(如 31 日)自然返回 false,
|
||
/// 与 iOS「该月跳过、不顺延」的行为一致。
|
||
func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
|
||
guard enabled else { return false }
|
||
let c = calendar.dateComponents([.weekday, .day, .month], from: date)
|
||
let wd = c.weekday ?? -1, day = c.day ?? -1, mo = c.month ?? -1
|
||
// 多选频率:任一命中即触发。
|
||
for f in frequencies {
|
||
switch f {
|
||
case .daily: return true
|
||
case .weekly: if weekdays.contains(wd) { return true }
|
||
case .monthly: if monthlyDays.contains(day) { return true }
|
||
case .yearly: if month == mo && dayOfMonth == day { return true }
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class ChatTurn {
|
||
var question: String
|
||
var answer: String
|
||
var referencedIndicatorIDs: [String]
|
||
var referencedReportIDs: [String]
|
||
var createdAt: Date
|
||
var decodeRate: Double
|
||
|
||
init(question: String,
|
||
answer: String,
|
||
referencedIndicatorIDs: [String] = [],
|
||
referencedReportIDs: [String] = [],
|
||
createdAt: Date = .now,
|
||
decodeRate: Double = 0) {
|
||
self.question = question
|
||
self.answer = answer
|
||
self.referencedIndicatorIDs = referencedIndicatorIDs
|
||
self.referencedReportIDs = referencedReportIDs
|
||
self.createdAt = createdAt
|
||
self.decodeRate = decodeRate
|
||
}
|
||
}
|