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

@@ -23,9 +23,12 @@ struct ArchiveListView: View {
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var metricReminders: [MetricReminder]
@Query(sort: \Medication.updatedAt, order: .reverse)
private var medications: [Medication]
/// push `navigationDestination(item:)`
/// `navigationDestination(isPresented:)` SwiftUI ()
private enum Route: Hashable { case exports, reminders }
private enum Route: Hashable { case exports, reminders, medicationLibrary }
@State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom?
@@ -33,57 +36,73 @@ struct ArchiveListView: View {
@State private var selectedGroup: IndicatorGroup?
@State private var route: Route?
/// :,(///), chip
@State private var searching = false
@State private var query = ""
@MainActor
private var allEntries: [TimelineEntry] {
let mapped =
TimelineEntry.from(indicators: indicators) +
TimelineEntry.aggregatedIndicators(indicators) +
reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:))
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
return filtered.sorted { $0.date > $1.date }
let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
let q = query.trimmingCharacters(in: .whitespaces)
let byQuery = q.isEmpty ? byKind : byKind.filter { $0.title.localizedCaseInsensitiveContains(q) }
return byQuery.sorted { $0.date > $1.date }
}
private var grouped: [(section: DateSection, items: [TimelineEntry])] {
TimelineGrouping.group(allEntries)
}
private var totalCount: Int { allEntries.count }
var body: some View {
NavigationStack {
content
.navigationDestination(item: $route) { route in
switch route {
case .exports: HealthExportListView()
case .reminders: RemindersListView()
case .exports: HealthExportListView()
case .reminders: RemindersListView()
case .medicationLibrary: MedicationLibraryView()
}
}
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 0) {
header
// ( O(m²))+ / body .isEmpty
// allEntries,;,
let entries = allEntries
let groups = TimelineGrouping.group(entries)
return VStack(alignment: .leading, spacing: 0) {
header(total: entries.count)
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 14)
if reminderTotal > 0 {
reminderBoard
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
// :/,
medicationBoard
.padding(.horizontal, 20)
.padding(.bottom, 14)
filterChips
.padding(.bottom, searching ? 10 : 14)
if searching {
searchField
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
filterChips
.padding(.bottom, 14)
if allEntries.isEmpty {
if entries.isEmpty {
emptyState
} else {
ScrollView(showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
ForEach(grouped, id: \.section) { group in
ForEach(groups, id: \.section) { group in
Section {
VStack(spacing: 10) {
ForEach(group.items) { entry in
@@ -149,12 +168,12 @@ struct ArchiveListView: View {
diaries: diaries, symptoms: symptoms)
}
private var header: some View {
private func header(total: Int) -> some View {
HStack(alignment: .lastTextBaseline) {
Text("记录")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount)"))
Text(total == 0 ? "" : String(appLoc: "\(total)"))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
@@ -173,9 +192,57 @@ struct ArchiveListView: View {
}
.buttonStyle(.plain)
}
searchToggle
}
}
private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searching.toggle()
if !searching { query = "" }
}
} label: {
Image(systemName: searching ? "xmark" : "magnifyingglass")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索记录"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标 / 报告 / 症状名"), text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !query.isEmpty {
Button { query = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
// MARK: -
/// ( + ),
@@ -241,6 +308,58 @@ struct ArchiveListView: View {
.buttonStyle(.plain)
}
// MARK: -
/// :, · N
private var medicationCountLabel: String {
medications.isEmpty
? String(appLoc: "药品库")
: String(appLoc: "药品库 · \(medications.count) 种常用药")
}
/// :; 3 (,)
private var medicationPreviewLine: String {
if medications.isEmpty { return String(appLoc: "拍药盒或手动添加常用药") }
let names = medications.prefix(3).map(\.name).joined(separator: " · ")
return medications.count > 3 ? names + "" : names
}
/// (MedicationLibraryView,push );
private var medicationBoard: some View {
Button { route = .medicationLibrary } label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(medications.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
Image(systemName: "pills.fill")
.font(.tjScaled( 16))
.foregroundStyle(medications.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(medicationCountLabel)
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text(medicationPreviewLine)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.contentShape(Rectangle())
.tjCard()
}
.buttonStyle(.plain)
}
private var filterChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
@@ -291,13 +410,19 @@ struct ArchiveListView: View {
}
private var emptyState: some View {
VStack(spacing: 14) {
let q = query.trimmingCharacters(in: .whitespaces)
let isSearchMiss = !q.isEmpty
return VStack(spacing: 14) {
Spacer()
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
TjPlaceholder(label: isSearchMiss
? String(appLoc: "没有匹配「\(q)」的记录")
: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
.frame(width: 240, height: 140)
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
if !isSearchMiss {
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
}
.frame(maxWidth: .infinity)