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,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
278 lines
10 KiB
Swift
278 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
/// VL 解析后的可编辑表单。
|
|
/// 用户可改 title / type / reportDate / institution / summary / 各 indicator;
|
|
/// 也可删除识别错的 indicator,或手加一行。
|
|
/// 「保存」回调写 SwiftData + 关联已写入 Vault 的 assets。
|
|
struct CaptureReviewForm: View {
|
|
@State var parsed: ParsedReport
|
|
let assets: [FileVault.SavedAsset]
|
|
let warning: String?
|
|
/// 归档模式:只存原图 + 基本信息(标题/类型/日期/机构),隐藏指标区与摘要。
|
|
/// 报告归档不再逐项识别(逐项多模态在 2B 上易 OOM 卡死),见 CaptureService.extractReportMeta。
|
|
var metaOnly: Bool = false
|
|
let onSave: (ParsedReport) -> Void
|
|
let onCancel: () -> Void
|
|
/// 「重新识别信息」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
|
var onReanalyze: (() -> Void)? = nil
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
if let warning {
|
|
warningBanner(warning)
|
|
}
|
|
if !assets.isEmpty {
|
|
pageThumbnails
|
|
}
|
|
metaSection
|
|
if !metaOnly {
|
|
indicatorSection
|
|
}
|
|
Spacer(minLength: 8)
|
|
actions
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.bottom, 24)
|
|
}
|
|
}
|
|
|
|
// MARK: - 顶部 warning
|
|
|
|
private func warningBanner(_ text: String) -> some View {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(Tj.Palette.amber)
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(text)
|
|
.font(.tjScaled( 12))
|
|
.foregroundStyle(Tj.Palette.text2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
if let onReanalyze {
|
|
Button {
|
|
onReanalyze()
|
|
} label: {
|
|
Label("重新识别", systemImage: "arrow.clockwise")
|
|
.font(.tjScaled( 12, weight: .semibold))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Tj.Palette.ink)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.brickSoft.opacity(0.5))
|
|
)
|
|
}
|
|
|
|
// MARK: - 缩略图
|
|
|
|
private var pageThumbnails: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
|
|
if metaOnly {
|
|
Text("原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。")
|
|
.font(.tjScaled( 11))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
|
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
|
|
Image(uiImage: img).resizable().scaledToFill()
|
|
} placeholder: { _ in
|
|
Tj.Palette.paper
|
|
}
|
|
.frame(width: 84, height: 110)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - meta(title / type / date / institution / summary)
|
|
|
|
private var metaSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
sectionLabel(String(appLoc: "基本信息"))
|
|
VStack(spacing: 10) {
|
|
labeledField(String(appLoc: "标题")) {
|
|
TextField("如:春季年度体检", text: $parsed.title)
|
|
.textFieldStyle(.plain)
|
|
}
|
|
labeledField(String(appLoc: "类型")) {
|
|
Picker("", selection: $parsed.typeRaw) {
|
|
ForEach(ReportType.allCases, id: \.rawValue) { t in
|
|
Text(t.label).tag(t.rawValue)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
labeledField(String(appLoc: "报告日期")) {
|
|
DatePicker("", selection: $parsed.reportDate,
|
|
in: ...Date.now,
|
|
displayedComponents: .date)
|
|
.datePickerStyle(.compact)
|
|
.labelsHidden()
|
|
.environment(\.locale, Locale.current)
|
|
}
|
|
labeledField(String(appLoc: "机构(可选)")) {
|
|
TextField("如:协和医院", text: $parsed.institution)
|
|
}
|
|
if !metaOnly {
|
|
labeledField(String(appLoc: "摘要(可选)")) {
|
|
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
|
.lineLimit(1...3)
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(fieldBg)
|
|
.overlay(fieldBorder)
|
|
}
|
|
}
|
|
|
|
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label)
|
|
.font(.tjScaled( 11, weight: .medium))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
content()
|
|
}
|
|
}
|
|
|
|
// MARK: - indicators
|
|
|
|
private var indicatorSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
|
|
Spacer()
|
|
Button {
|
|
parsed.indicators.append(
|
|
.init(name: "", value: "", unit: "", range: "", status: .normal)
|
|
)
|
|
} label: {
|
|
Label("加一项", systemImage: "plus.circle")
|
|
.font(.tjScaled( 12, weight: .medium))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Tj.Palette.ink)
|
|
}
|
|
if parsed.indicators.isEmpty {
|
|
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
|
|
.font(.tjScaled( 12))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
.padding(.vertical, 8)
|
|
} else {
|
|
VStack(spacing: 10) {
|
|
ForEach($parsed.indicators) { $indicator in
|
|
indicatorRow($indicator)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func indicatorRow(_ binding: Binding<ParsedReport.ParsedIndicator>) -> some View {
|
|
let id = binding.wrappedValue.id
|
|
return VStack(spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
TextField("指标名", text: binding.name)
|
|
.font(.tjScaled( 14, weight: .medium))
|
|
Button(role: .destructive) {
|
|
parsed.indicators.removeAll { $0.id == id }
|
|
} label: {
|
|
Image(systemName: "minus.circle.fill")
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
HStack(spacing: 8) {
|
|
TextField("数值", text: binding.value)
|
|
.keyboardType(.decimalPad)
|
|
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
|
.frame(maxWidth: 90)
|
|
TextField("单位", text: binding.unit)
|
|
.frame(maxWidth: 80)
|
|
.autocorrectionDisabled()
|
|
TextField("参考", text: binding.range)
|
|
.autocorrectionDisabled()
|
|
}
|
|
Picker("", selection: binding.status) {
|
|
Text("正常").tag(IndicatorStatus.normal)
|
|
Text("偏高 ↑").tag(IndicatorStatus.high)
|
|
Text("偏低 ↓").tag(IndicatorStatus.low)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.paper)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.strokeBorder(statusColor(binding.status.wrappedValue).opacity(0.4),
|
|
lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func statusColor(_ s: IndicatorStatus) -> Color {
|
|
switch s {
|
|
case .normal: return Tj.Palette.leaf
|
|
case .high: return Tj.Palette.brick
|
|
case .low: return Tj.Palette.amber
|
|
}
|
|
}
|
|
|
|
// MARK: - actions
|
|
|
|
private var actions: some View {
|
|
VStack(spacing: 10) {
|
|
Button {
|
|
onSave(parsed)
|
|
} label: {
|
|
Text("保存到记录")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(TjPrimaryButton())
|
|
|
|
Button(action: onCancel) {
|
|
Text("取消(图片不保留)")
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - helpers
|
|
|
|
private func sectionLabel(_ t: String) -> some View {
|
|
Text(t)
|
|
.font(.tjScaled( 12, weight: .semibold))
|
|
.tracking(0.3)
|
|
.foregroundStyle(Tj.Palette.text2)
|
|
}
|
|
|
|
private var fieldBg: some View {
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.paper)
|
|
}
|
|
|
|
private var fieldBorder: some View {
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
|
}
|
|
}
|