Files
kangkang/康康/Features/Capture/UnifiedCaptureFlow.swift
link2026 9d856fcfc4 ```
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,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
2026-06-15 09:24:59 +08:00

400 lines
15 KiB
Swift

import SwiftUI
import SwiftData
import UIKit
import Combine
/// VL ( + )
/// , A1-A3 / B1-B5 mockup
///
/// :
/// ```
/// idle captured(images) analyzing editing(parsed, assets)
///
/// editing(empty, assets)
/// editing saved dismiss
/// ```
struct UnifiedCaptureFlow: View {
@Environment(\.modelContext) private var ctx
let onClose: () -> Void
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
@State private var phase: Phase = .idle
@State private var analyzeTask: Task<Void, Never>? = nil
@State private var showTip: Bool = false
/// VL (); cancel ,UI 退
private let analyzeTimeoutSeconds: Int = 30
enum Phase {
case idle
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
case editing(parsed: ParsedReport,
assets: [FileVault.SavedAsset],
warning: String?)
}
var body: some View {
NavigationStack {
content
.background(Tj.Palette.sand.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { cancelAll() }
.foregroundStyle(Tj.Palette.text)
}
}
.navigationTitle(phaseTitle)
.navigationBarTitleDisplayMode(.inline)
}
.onAppear {
if !hasSeenCaptureTip { showTip = true }
}
.sheet(isPresented: $showTip) {
CaptureTipSheet(onDismiss: {
hasSeenCaptureTip = true
showTip = false
})
.presentationDetents([.medium])
}
}
private var phaseTitle: String {
switch phase {
case .idle: return String(appLoc: "拍摄报告")
case .analyzing: return String(appLoc: "本地识别中…")
case .editing: return String(appLoc: "核对报告信息")
}
}
@ViewBuilder
private var content: some View {
switch phase {
case .idle:
captureEntry
case .analyzing(let images, _):
AnalyzingView(
images: images,
timeoutSeconds: analyzeTimeoutSeconds,
onCancel: {
analyzeTask?.cancel()
analyzeTask = nil
phase = .idle
}
)
case .editing(let parsed, let assets, let warning):
CaptureReviewForm(
parsed: parsed,
assets: assets,
warning: warning,
metaOnly: true, // + meta,(§ CaptureService.extractReportMeta)
onSave: { final in saveAll(parsed: final, assets: assets) },
onCancel: cancelAll,
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
)
}
}
// MARK: -
/// + SwiftData Vault , sheet
/// (),
/// (§6), Vault
/// .analyzing/.editing assets;.idle ,
private func cancelAll() {
analyzeTask?.cancel()
analyzeTask = nil
switch phase {
case .idle:
break
case .analyzing(_, let maybeAssets):
if let assets = maybeAssets { removeOrphans(assets) }
case .editing(_, let assets, _):
removeOrphans(assets)
}
onClose()
}
private func removeOrphans(_ assets: [FileVault.SavedAsset]) {
for a in assets {
try? FileVault.shared.remove(relativePath: a.relativePath)
}
}
// MARK: - : /
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
#else
if DocumentScannerView.isSupported {
DocumentScannerView(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
.ignoresSafeArea()
} else {
PhotoPickerSheet(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
}
#endif
}
// MARK: -
private func startAnalyze(images: [UIImage]) {
guard !images.isEmpty else { onClose(); return }
analyzeTask?.cancel()
phase = .analyzing(images: images, assets: nil)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
// Step 1: Vault(,)
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
// :,View dismisscancelAll
// phase .analyzing(_, nil),
if Task.isCancelled {
for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) }
return
}
guard !assets.isEmpty else {
await MainActor.run {
phase = .editing(
parsed: .empty(),
assets: [],
warning: String(appLoc: "图片保存失败,请重试")
)
}
return
}
// assets phase,使
await MainActor.run {
if case .analyzing(let imgs, _) = phase {
phase = .analyzing(images: imgs, assets: assets)
}
}
// Step 2: meta (OCR + LLM,///)
// 2B OOM watchdog cancel
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
if Task.isCancelled {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
}
return
}
await MainActor.run {
phase = .editing(
parsed: meta,
assets: assets,
warning: recognized ? nil
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
)
}
}
}
/// : assets,, meta
private func reanalyze(assets: [FileVault.SavedAsset]) {
analyzeTask?.cancel()
// UIImage,AnalyzingView , 600px ,
// ( MB)
let thumbnails: [UIImage] = assets.compactMap {
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
}
phase = .analyzing(images: thumbnails, assets: assets)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
if Task.isCancelled {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保留原图"))
}
return
}
await MainActor.run {
phase = .editing(parsed: meta, assets: assets,
warning: recognized ? nil
: String(appLoc: "未能自动识别报告信息,可手动填写"))
}
}
}
// MARK: -
private func saveAll(parsed final: ParsedReport,
assets: [FileVault.SavedAsset]) {
let report = Report(
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
type: ReportType(rawValue: final.typeRaw) ?? .other,
reportDate: final.reportDate,
institution: final.institution.isEmpty ? nil : final.institution,
summary: final.summary.isEmpty ? nil : final.summary,
pageCount: final.pageCount
)
ctx.insert(report)
// Asset
for a in assets {
let asset = Asset(relativePath: a.relativePath, bytes: a.bytes)
ctx.insert(asset)
report.assets.append(asset)
}
// Indicator
for ind in final.indicators {
let i = Indicator(
name: ind.name,
value: ind.value,
unit: ind.unit,
range: ind.range,
status: ind.status,
capturedAt: final.reportDate,
report: report,
source: .report,
sourcePageIndex: ind.sourcePageIndex,
sourceBoxX: ind.sourceBoxX,
sourceBoxY: ind.sourceBoxY,
sourceBoxWidth: ind.sourceBoxWidth,
sourceBoxHeight: ind.sourceBoxHeight
)
ctx.insert(i)
}
try? ctx.save()
// :,
// AI (/) token
Task { await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx) }
onClose()
}
}
// MARK: -
private struct AnalyzingView: View {
let images: [UIImage]
let timeoutSeconds: Int
let onCancel: () -> Void
@State private var elapsed: Int = 0
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 20) {
Spacer()
if let first = images.first {
Image(uiImage: first)
.resizable()
.scaledToFit()
.frame(maxHeight: 240)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
ProgressView().tint(Tj.Palette.ink).scaleEffect(1.4)
)
)
}
VStack(spacing: 6) {
Text("本地识别中")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
if elapsed >= timeoutSeconds - 5 {
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
}
}
Button(action: onCancel) {
Text("取消识别 · 改为手动录入")
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 20)
.frame(minHeight: 44) // HIG
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.top, 4)
Spacer()
}
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onReceive(tick) { _ in elapsed += 1 }
}
}
// MARK: - 使
private struct CaptureTipSheet: View {
let onDismiss: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
Image(systemName: "doc.viewfinder")
.font(.tjScaled( 28))
.foregroundStyle(Tj.Palette.ink)
Text("拍报告的小贴士")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
}
VStack(alignment: .leading, spacing: 12) {
tip(String(appLoc: "纸张铺平,避免反光、阴影"))
tip(String(appLoc: "整页入框,避免裁切到指标"))
tip(String(appLoc: "多页报告可连拍,系统自动透视校正"))
tip(String(appLoc: "识别全程在本地,图片不会上传"))
}
Spacer()
Button {
onDismiss()
} label: {
Text("我知道了,开始拍")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
}
private func tip(_ text: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Tj.Palette.leaf)
.padding(.top, 2)
Text(text)
.font(.tjSerifBody())
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
}
}