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,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
This commit is contained in:
link2026
2026-06-15 09:24:59 +08:00
parent 6c6a950140
commit 9d856fcfc4
37 changed files with 2605 additions and 430 deletions

View File

@@ -54,6 +54,27 @@ struct TimelineEntryDetailView: View {
@State private var showDeleteConfirm = false
@State private var evidenceTarget: Indicator?
@State private var reminderPrefill: ReminderPrefill?
///
private struct ReminderPrefill: Identifiable {
let id = UUID()
let title: String
let note: String
}
///
@State private var reportPhotoStart: ReportPhotoPage?
private struct ReportPhotoPage: Identifiable {
let id = UUID()
let index: Int
}
/// ,
private var reportEntry: Report? {
if case .report(let r) = detail { return r }
return nil
}
var body: some View {
VStack(spacing: 0) {
@@ -84,6 +105,15 @@ struct TimelineEntryDetailView: View {
EvidenceImagePreview(report: report, indicator: indicator)
}
}
.sheet(item: $reminderPrefill) { prefill in
// (/// + ;)
CustomReminderEditSheet(prefillTitle: prefill.title, prefillNote: prefill.note)
}
.sheet(item: $reportPhotoStart) { start in
if let r = reportEntry {
ReportImagesViewer(assets: r.assets, startIndex: start.index)
}
}
}
// MARK: - (:SwiftData + Vault unlink, CLAUDE.md §6)
@@ -120,6 +150,10 @@ struct TimelineEntryDetailView: View {
for p in paths { try? FileVault.shared.remove(relativePath: p) }
ctx.delete(r)
case .diary(let d):
// ;cascade Asset ,Vault JPEG unlink
for p in Set(d.assets.map(\.relativePath)) {
try? FileVault.shared.remove(relativePath: p)
}
ctx.delete(d)
case .symptom(let s):
ctx.delete(s)
@@ -167,7 +201,7 @@ struct TimelineEntryDetailView: View {
case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情")
case .report: return String(appLoc: "报告详情")
case .diary: return String(appLoc: "日记详情")
case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
case .symptom: return String(appLoc: "症状详情")
}
}
@@ -186,28 +220,31 @@ struct TimelineEntryDetailView: View {
// MARK: -
private func indicatorBody(_ i: Indicator) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(i.status)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(i.value)
.font(.tjScaled( 30, weight: .bold, design: .rounded))
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
if !i.unit.isEmpty {
Text(i.unit).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
VStack(alignment: .leading, spacing: 16) {
card {
HStack(alignment: .firstTextBaseline) {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(i.status)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(i.value)
.font(.tjScaled( 30, weight: .bold, design: .rounded))
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
if !i.unit.isEmpty {
Text(i.unit).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
}
}
divider
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
if let report = i.report {
evidenceButton(for: i, assets: report.assets)
}
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
}
divider
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
if let report = i.report {
evidenceButton(for: i, assets: report.assets)
}
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
}
}
@@ -217,21 +254,28 @@ struct TimelineEntryDetailView: View {
let combined: IndicatorStatus = sys.status != .normal
? sys.status
: (dia?.status ?? .normal)
return card {
HStack(alignment: .firstTextBaseline) {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(combined)
return VStack(alignment: .leading, spacing: 16) {
card {
HStack(alignment: .firstTextBaseline) {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(combined)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(sys.value)/\(dia?.value ?? "")")
.font(.tjScaled( 30, weight: .bold, design: .rounded))
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(sys.value)/\(dia?.value ?? "")")
.font(.tjScaled( 30, weight: .bold, design: .rounded))
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
// :seriesKey bp.systolic MonitorMetric.bloodPressure
RecordAnotherButton(name: String(appLoc: "血压"),
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
name: String(appLoc: "血压"),
unit: "mmHg", range: sys.range))
}
}
@@ -248,16 +292,16 @@ struct TimelineEntryDetailView: View {
TjBadge(text: r.type.label, style: .neutral)
Text(Self.dateText(r.reportDate))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
if !r.assets.isEmpty {
Text(String(appLoc: "原图\(r.assets.count)"))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
}
}
if let inst = r.institution, !inst.isEmpty {
field(String(appLoc: "机构"), inst)
}
}
if !r.assets.isEmpty {
reportPhotosCard(r.assets)
}
ReportSummaryCard(report: r)
if !r.indicators.isEmpty {
@@ -286,26 +330,146 @@ struct TimelineEntryDetailView: View {
}
}
// MARK: -
private func diaryBody(_ d: DiaryEntry) -> some View {
VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
Text(d.content)
.font(.tjScaled( 15))
.foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if !d.tags.isEmpty {
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
/// : ,,
private func reportPhotosCard(_ assets: [Asset]) -> some View {
card {
HStack {
Text(String(appLoc: "原图\(assets.count)"))
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Spacer()
Text(String(appLoc: "点图放大")).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
Button {
reportPhotoStart = ReportPhotoPage(index: idx)
} label: {
reportThumb(asset)
}
.buttonStyle(.plain)
}
}
}
}
}
private func reportThumb(_ asset: Asset) -> some View {
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
Image(uiImage: img).resizable().scaledToFill()
} placeholder: { isLoading in
if isLoading {
Tj.Palette.paper
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
}
}
.frame(width: 96, height: 120)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
// MARK: -
@ViewBuilder
private func diaryBody(_ d: DiaryEntry) -> some View {
if d.isMedicationLog {
medicationBody(d)
} else {
VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
Text(d.content)
.font(.tjScaled( 15))
.foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if !d.tags.isEmpty {
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
}
}
}
}
}
// MARK: - 使(// + )
/// 使(tag ): [] · + ,
/// ,/(CLAUDE.md §1§10)
private func medicationBody(_ d: DiaryEntry) -> some View {
let lines = Self.medicationLines(d.content)
return VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
if lines.isEmpty {
Text(d.content)
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
} else {
ForEach(Array(lines.enumerated()), id: \.offset) { idx, line in
if idx > 0 { divider }
Text(line)
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
}
medicationActionRow(d)
Text("「设置提醒」只到点提示,不提供任何用药或剂量建议。")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
/// :(, + ),
private func medicationActionRow(_ d: DiaryEntry) -> some View {
HStack(spacing: 10) {
medAction(title: String(appLoc: "设置提醒"), icon: "bell.badge") {
let lines = Self.medicationLines(d.content)
if lines.count <= 1 {
let f = Self.medicationReminderFields(forLine: lines.first ?? d.content)
reminderPrefill = ReminderPrefill(title: f.title, note: f.note)
} else {
// :,,/
reminderPrefill = ReminderPrefill(title: String(appLoc: "服药提醒"),
note: lines.joined(separator: "\n"))
}
}
}
}
private func medAction(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 6) {
Image(systemName: icon).font(.tjScaled( 18, weight: .medium))
Text(title).font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.ink)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.amber.opacity(0.14))
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK: -
private func symptomBody(_ s: Symptom) -> some View {
@@ -412,6 +576,76 @@ struct TimelineEntryDetailView: View {
private nonisolated static func dateText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day())
}
// MARK: - (,便)
/// content ,
nonisolated static func medicationLines(_ content: String) -> [String] {
content.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
/// ( 80mg · ):
/// =:<+>, = (" · " ,/)
nonisolated static func medicationReminderFields(forLine line: String) -> (title: String, note: String) {
let parts = line.components(separatedBy: " · ")
let head = (parts.first ?? line).trimmingCharacters(in: .whitespaces)
let usage = parts.count > 1
? parts.dropFirst().joined(separator: " · ").trimmingCharacters(in: .whitespaces)
: ""
let name = head.isEmpty ? line.trimmingCharacters(in: .whitespaces) : head
return (title: String(appLoc: "吃药:") + name, note: usage)
}
}
/// (,)
private struct ReportImagesViewer: View {
@Environment(\.dismiss) private var dismiss
let assets: [Asset]
@State private var selection: Int
init(assets: [Asset], startIndex: Int) {
self.assets = assets
_selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0)))
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
Text("原图 · 第 \(selection + 1)/\(assets.count)")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
TabView(selection: $selection) {
ForEach(Array(assets.enumerated()), id: \.offset) { index, asset in
EvidenceImagePage(asset: asset, highlight: nil)
.tag(index)
.padding(16)
}
}
.tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never))
}
.background(Tj.Palette.sand.ignoresSafeArea())
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
}
}
/// ( + ),
@@ -479,19 +713,16 @@ private struct EvidenceImagePage: View {
let asset: Asset
let highlight: CGRect?
private var image: UIImage? {
try? FileVault.shared.loadImage(relativePath: asset.relativePath)
}
var body: some View {
GeometryReader { geo in
if let image {
VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { image in
ZStack {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: geo.size.width, height: geo.size.height)
if let highlight {
// ,imageSize letterbox ,
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
}
}
@@ -502,9 +733,14 @@ private struct EvidenceImagePage: View {
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
.frame(width: geo.size.width, height: geo.size.height)
} placeholder: { isLoading in
if isLoading {
ProgressView()
.frame(width: geo.size.width, height: geo.size.height)
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
}