Compare commits
37 Commits
main
...
feat/mnn-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d856fcfc4 | ||
|
|
6c6a950140 | ||
|
|
f58d6064ba | ||
|
|
c3f8ec400c | ||
|
|
69de5faf4b | ||
|
|
477a64ecb4 | ||
|
|
6405733358 | ||
|
|
2e27677f80 | ||
|
|
2e90139df7 | ||
|
|
77139f5e32 | ||
|
|
0dd60d6021 | ||
|
|
43cdde9bab | ||
|
|
0a824610cf | ||
|
|
7e8e692695 | ||
|
|
3f9a2af279 | ||
|
|
a65c63947b | ||
|
|
8494e51823 | ||
|
|
070e016f81 | ||
|
|
8c8599e77d | ||
|
|
b7e8ab33ec | ||
|
|
db327afd79 | ||
|
|
5eb724ab86 | ||
|
|
cfeb25247a | ||
|
|
26a7d53b1b | ||
|
|
e603738330 | ||
|
|
7f0a76098a | ||
|
|
b79ae54b7b | ||
|
|
ca5a3fa38b | ||
|
|
836f3d4234 | ||
|
|
b919404412 | ||
|
|
ddfd474bb3 | ||
|
|
cbacd9461a | ||
|
|
39b1521f00 | ||
|
|
9da3fbc87e | ||
|
|
f6c0ba7077 | ||
|
|
afc6a79dd7 | ||
|
|
06484d09ff |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@
|
||||
/Models/
|
||||
/build/
|
||||
.DS_Store
|
||||
|
||||
# MNN 预编译二进制:由 scripts/build-mnn-xcframework.sh 本地生成,不入库防历史膨胀
|
||||
/Frameworks/MNN.xcframework/
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -22,9 +22,9 @@
|
||||
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
||||
| 持久化 | SwiftData | 见 §5 数据模型 |
|
||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
||||
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
|
||||
| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
|
||||
| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 |
|
||||
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
|
||||
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
||||
| 模型 | **Qwen3.5-2B**(一个多模态模型,文本+视觉一肩挑) | 真机主用:`taobao-mnn/Qwen3.5-2B-MNN`(~1.2GB);MLX 兜底:`mlx-community/Qwen3.5-2B-4bit`(~1.7GB)。**已废弃**:Qwen3-1.7B / Qwen2.5-VL-3B / Qwen3-VL-4B(4B 实测过慢退回 2B) |
|
||||
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||
| Face ID | LocalAuthentication | |
|
||||
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
||||
@@ -38,13 +38,14 @@
|
||||
### 3.1 模块边界(强制)
|
||||
|
||||
```
|
||||
UI → CaptureService / AskService / TrendService → AIRuntime → MLX
|
||||
UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX
|
||||
↓
|
||||
Persistence
|
||||
```
|
||||
|
||||
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
|
||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务(模型共享内存/Metal 显存,并发会 OOM 被 jetsam 杀)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。**真正落地**是 actor 内信号量闸门 `acquireGate()/releaseGate()`,所有占显存的重活(解码 + 模型加载)进入前先 await,且加载 VL 前先卸 LLM。
|
||||
- **引擎选择**:`InferenceEngine.current` 由偏好(`.auto`/`.mnn`/`.mlx`)+ 设备可用性解析,真机默认 `.mnn`(SME2/NEON),模拟器回退 `.mlx`。
|
||||
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
||||
|
||||
### 3.2 VL pipeline(拍一张 = 一条流程)
|
||||
@@ -66,7 +67,7 @@ VL prompt 必须:
|
||||
### 3.3 RAG(结构化检索,不做 embedding)
|
||||
|
||||
**两段式调用**:
|
||||
1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||
1. 用 Qwen3.5-2B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
||||
|
||||
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
||||
@@ -84,7 +85,9 @@ VL prompt 必须:
|
||||
## 4. 模型分发
|
||||
|
||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
|
||||
- **用户面只有一个模型**:Qwen3.5-2B-MNN(~1.2GB,`ModelKind.userFacing = [.mnnLLM]`)。多模态,文本+视觉全包,下载全部 / 就绪计数只算它
|
||||
- MLX 兜底版 Qwen3.5-2B-4bit(~1.7GB)仅模拟器与兜底用,不展示、不计入「下载全部」,但旁路导入仍可单独导
|
||||
- WiFi 提示必须有
|
||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||
|
||||
@@ -259,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
|
||||
| 周次 | 必交付 |
|
||||
|---|---|
|
||||
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
|
||||
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
|
||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
||||
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
||||
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
||||
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -22,9 +22,12 @@
|
||||
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
||||
| 持久化 | SwiftData | 见 §5 数据模型 |
|
||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
||||
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
|
||||
| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
|
||||
| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 |
|
||||
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
|
||||
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
||||
| **统一模型(文本+视觉)** | **Qwen3.5-2B 多模态,一个模型全包** | 同一个 Qwen3.5-2B 同时做文本生成 / 关键词抽取 / 趋势解读 **和** 拍照→结构化指标。两种格式两种引擎,按设备选(见下两行)。代码:`ModelKind` |
|
||||
| ├ MNN 主(iPhone17+/SME2) | `taobao-mnn/Qwen3.5-2B-MNN`(~1.1GiB,含 `visual.mnn`) | 挑战赛考核路径,真机默认。文本 + 图→文都走它。`ModelKind.mnnLLM`,唯一对用户暴露(`userFacing`) |
|
||||
| └ MLX 兜底 / 模拟器 | `mlx-community/Qwen3.5-2B-4bit`(~1.7GB,多模态) | Metal GPU。走 `qwen3_5`,文本与 VL 复用同一模型。`ModelKind.llm`。4B 实测过慢已退回 2B |
|
||||
| ~~VL(独立)~~ | ~~`mlx-community/Qwen3-VL-4B-Instruct-4bit`~~ **已废弃** | MLX VL 已改复用统一 Qwen3.5-2B 多模态;`ModelKind.vl` 仅保留枚举避免动穷举 switch,不再下载/展示 |
|
||||
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||
| Face ID | LocalAuthentication | |
|
||||
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
||||
@@ -38,13 +41,13 @@
|
||||
### 3.1 模块边界(强制)
|
||||
|
||||
```
|
||||
UI → CaptureService / AskService / TrendService → AIRuntime → MLX
|
||||
UI → CaptureService / AskService / TrendService → AIRuntime → MNN(主) / MLX(兜底)
|
||||
↓
|
||||
Persistence
|
||||
```
|
||||
|
||||
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
|
||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务(`InferenceEngine` 选 MNN/SME2 主或 MLX/GPU 兜底,共享内存/显存,并发会 OOM)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
|
||||
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
||||
|
||||
### 3.2 VL pipeline(拍一张 = 一条流程)
|
||||
@@ -66,7 +69,7 @@ VL prompt 必须:
|
||||
### 3.3 RAG(结构化检索,不做 embedding)
|
||||
|
||||
**两段式调用**:
|
||||
1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||
1. 用统一 Qwen3.5-2B(MNN 主 / MLX 兜底)抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
||||
|
||||
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
||||
@@ -84,7 +87,7 @@ VL prompt 必须:
|
||||
## 4. 模型分发
|
||||
|
||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
|
||||
- **用户侧只下载统一模型 Qwen3.5-2B(MNN,~1.1GiB,含视觉)**——不再是 ~4GB 两模型。`ModelKind.userFacing = [.mnnLLM]`,「下载全部」/ 就绪计数只算它。MLX 兜底模型 `Qwen3.5-2B-4bit`(~1.7GB)仅模拟器 / 旁路导入用,不计入用户下载;`Qwen3-VL-4B` 已废弃,不再分发。WiFi 提示仍保留
|
||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||
|
||||
@@ -249,7 +252,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
||||
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
||||
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
|
||||
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)
|
||||
6. **新功能必须问"清单里有吗"**——清单外的功能(多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)、**用药提醒**(记录 · 用药记录点药 → 复用自由提醒 `CustomReminder` / `CustomReminderEditSheet`,只到点提示,**仍不给剂量/频次建议**,守 §1 "不做剂量推荐")
|
||||
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
||||
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
||||
|
||||
@@ -259,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
|
||||
| 周次 | 必交付 |
|
||||
|---|---|
|
||||
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
|
||||
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
|
||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
||||
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
||||
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
||||
@@ -281,7 +284,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做)
|
||||
|
||||
1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意
|
||||
2. 100% 本地 + SME2 加速 — 技术亮点
|
||||
2. 100% 本地 + **MNN + Arm SME2 端侧 CPU 加速**(挑战赛考核点,MLX/GPU 兜底) — 技术亮点
|
||||
3. 本地 RAG 长期记忆 — 端侧不可替代性
|
||||
4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立
|
||||
5. AI 趋势解读 — 长期价值
|
||||
|
||||
11
KangkangWidget-src/KangkangWidgetBundle.swift
Normal file
11
KangkangWidget-src/KangkangWidgetBundle.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
/// KangkangWidget extension 入口。
|
||||
/// W5 做 Live Activity 时,把 ActivityConfiguration 也注册进这个 Bundle。
|
||||
@main
|
||||
struct KangkangWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
PinnedIndicatorsWidget()
|
||||
}
|
||||
}
|
||||
249
KangkangWidget-src/PinnedIndicatorsWidget.swift
Normal file
249
KangkangWidget-src/PinnedIndicatorsWidget.swift
Normal file
@@ -0,0 +1,249 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - 快照模型(主 App 的独立拷贝)
|
||||
//
|
||||
// ⚠️ 同步契约:与主 App `康康/Persistence/WidgetSnapshot.swift` 字段必须一致。
|
||||
// extension 不引主 App 代码(免去 target membership 配置),改字段时两边一起改。
|
||||
|
||||
private struct WidgetSnapshot: Codable, Equatable {
|
||||
struct Item: Codable, Equatable {
|
||||
var name: String
|
||||
var value: String
|
||||
var unit: String
|
||||
var statusRaw: String // high|low|normal
|
||||
var capturedAt: Date
|
||||
}
|
||||
|
||||
var updatedAt: Date
|
||||
var items: [Item]
|
||||
|
||||
static let appGroupID = "group.com.xuhuayong.kangkang"
|
||||
static let storeKey = "kk.widget.snapshot.v1"
|
||||
|
||||
static func load() -> WidgetSnapshot? {
|
||||
guard let defaults = UserDefaults(suiteName: appGroupID),
|
||||
let data = defaults.data(forKey: storeKey) else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 调色(镜像主 App Tj.Palette,extension 不引 DesignSystem)
|
||||
|
||||
private enum KkColor {
|
||||
static let sand = Color(red: 0.976, green: 0.969, blue: 0.949)
|
||||
static let ink = Color(red: 0.165, green: 0.153, blue: 0.137)
|
||||
static let text = Color(red: 0.149, green: 0.137, blue: 0.118)
|
||||
static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384)
|
||||
static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580)
|
||||
static let brick = Color(red: 0.886, green: 0.388, blue: 0.314) // high
|
||||
static let amber = Color(red: 0.871, green: 0.627, blue: 0.314) // low
|
||||
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) // normal
|
||||
}
|
||||
|
||||
private func statusColor(_ raw: String) -> Color {
|
||||
switch raw {
|
||||
case "high": return KkColor.brick
|
||||
case "low": return KkColor.amber
|
||||
default: return KkColor.leaf
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
private struct PinnedEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let items: [WidgetSnapshot.Item]
|
||||
let updatedAt: Date?
|
||||
}
|
||||
|
||||
private struct PinnedProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> PinnedEntry {
|
||||
PinnedEntry(date: .now, items: Self.sampleItems, updatedAt: .now)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (PinnedEntry) -> Void) {
|
||||
if context.isPreview {
|
||||
completion(placeholder(in: context))
|
||||
} else {
|
||||
completion(currentEntry())
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<PinnedEntry>) -> Void) {
|
||||
// 数据由主 App 写快照后 reloadAllTimelines 主动推;这里 30 分钟兜底刷一次
|
||||
// (只为让"x 天前"的相对时间不至于太陈旧)。
|
||||
let entry = currentEntry()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
|
||||
private func currentEntry() -> PinnedEntry {
|
||||
let snap = WidgetSnapshot.load()
|
||||
return PinnedEntry(date: .now, items: snap?.items ?? [], updatedAt: snap?.updatedAt)
|
||||
}
|
||||
|
||||
static let sampleItems: [WidgetSnapshot.Item] = [
|
||||
.init(name: "收缩压", value: "128", unit: "mmHg", statusRaw: "normal",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 5)),
|
||||
.init(name: "空腹血糖", value: "6.4", unit: "mmol/L", statusRaw: "high",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 30)),
|
||||
.init(name: "体重", value: "68.5", unit: "kg", statusRaw: "normal",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 50)),
|
||||
.init(name: "尿酸", value: "486", unit: "μmol/L", statusRaw: "high",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 80)),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private struct PinnedIndicatorsView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: PinnedEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.items.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
switch family {
|
||||
case .systemMedium: mediumView
|
||||
default: smallView
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) { KkColor.sand }
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
Text("在康康里关注指标后\n这里会显示最新值")
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
|
||||
/// 小尺寸:首条放大 + 其余最多 2 条小行。
|
||||
private var smallView: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
header
|
||||
if let first = entry.items.first {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(first.name)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||
Text(first.value)
|
||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(statusColor(first.statusRaw))
|
||||
Text(first.unit)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(entry.items.dropFirst().prefix(2), id: \.name) { item in
|
||||
compactRow(item)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
/// 中尺寸:两列网格,最多 6 条。
|
||||
private var mediumView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
|
||||
alignment: .leading, spacing: 8) {
|
||||
ForEach(entry.items.prefix(6), id: \.name) { item in
|
||||
gridCell(item)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text("康康 · 长期监测")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
Spacer()
|
||||
if let updatedAt = entry.updatedAt {
|
||||
Text(updatedAt, style: .relative)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func compactRow(_ item: WidgetSnapshot.Item) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor(item.statusRaw))
|
||||
.frame(width: 5, height: 5)
|
||||
Text(item.name)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 2)
|
||||
Text(item.value)
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(KkColor.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func gridCell(_ item: WidgetSnapshot.Item) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor(item.statusRaw))
|
||||
.frame(width: 5, height: 5)
|
||||
Text(item.name)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||
Text(item.value)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(KkColor.text)
|
||||
Text(item.unit)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct PinnedIndicatorsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "PinnedIndicatorsWidget", provider: PinnedProvider()) { entry in
|
||||
PinnedIndicatorsView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("长期监测")
|
||||
.description("展示你关注的健康指标最新值。数据 100% 在本机。")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("small", as: .systemSmall) {
|
||||
PinnedIndicatorsWidget()
|
||||
} timeline: {
|
||||
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
|
||||
}
|
||||
|
||||
#Preview("medium", as: .systemMedium) {
|
||||
PinnedIndicatorsWidget()
|
||||
} timeline: {
|
||||
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
|
||||
}
|
||||
52
docs/Widget接入步骤.md
Normal file
52
docs/Widget接入步骤.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 桌面 Widget 接入步骤(约 3 分钟,Xcode 操作)
|
||||
|
||||
代码已全部写好。主 App 侧(快照写入 + RootView hook)已自动编译生效;
|
||||
Widget extension 需要你在 Xcode 里建一次 target,再放入两个源文件。
|
||||
|
||||
## 1. 创建 Widget Extension target
|
||||
|
||||
1. Xcode 打开 `康康.xcodeproj` → 菜单 **File → New → Target…**
|
||||
2. 选 **iOS → Widget Extension**,点 Next
|
||||
3. Product Name 填 **`KangkangWidget`**
|
||||
- ❌ 不勾 "Include Live Activity"(W5 做 Live Activity 时再往这个 target 里加,Bundle 入口已留好注释)
|
||||
- ❌ 不勾 "Include Configuration App Intent"(我们用 StaticConfiguration)
|
||||
4. 点 Finish;弹出 "Activate scheme?" 选 **Activate**
|
||||
|
||||
## 2. 替换模板代码
|
||||
|
||||
Xcode 会在工程根目录生成 `KangkangWidget/` 文件夹(含模板 swift 文件)。
|
||||
|
||||
1. 删除模板生成的所有 `.swift` 文件(`KangkangWidget.swift`、`KangkangWidgetBundle.swift`、`AppIntent.swift` 等,**保留 `Info.plist` 和 Assets**),选 "Move to Trash"
|
||||
2. 把 `KangkangWidget-src/` 里的两个文件拖进 Xcode 的 `KangkangWidget` 文件夹(勾选 target:KangkangWidget):
|
||||
- `KangkangWidgetBundle.swift`
|
||||
- `PinnedIndicatorsWidget.swift`
|
||||
3. 拖完后可删掉暂存目录 `KangkangWidget-src/`
|
||||
|
||||
## 3. 配置 App Group(两个 target 都要)
|
||||
|
||||
数据通过 App Group UserDefaults 传递,ID 固定为 **`group.com.xuhuayong.kangkang`**。
|
||||
|
||||
1. 选中工程 → target **康康** → Signing & Capabilities → **+ Capability → App Groups** → + 添加 `group.com.xuhuayong.kangkang`
|
||||
2. target **KangkangWidget** → 同样添加 App Groups → 勾选同一个 `group.com.xuhuayong.kangkang`
|
||||
3. KangkangWidget 的 **iOS Deployment Target 改成 17.0**(模板默认可能更高)
|
||||
|
||||
> 个人开发者账号下 App Group 会自动注册;如签名报错,在两个 target 的 Signing 里确认 Team 一致。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
1. scheme 切回 **康康**,跑真机/模拟器
|
||||
2. 进 App(首页出现即写入快照),回到桌面 → 长按 → 添加小组件 → 找 **康康 · 长期监测**
|
||||
3. 小/中两个尺寸都支持。没有任何 pinned 指标时显示引导文案;
|
||||
在趋势页关注指标(或 C2「关联到趋势」)后,回桌面即可看到最新值
|
||||
|
||||
## 故障排查
|
||||
|
||||
- **小组件空白/不出现**:先确认两个 target 的 App Group 勾的是同一个 ID;再确认主 App 至少前台打开过一次(快照由主 App 写)
|
||||
- **数据不更新**:快照在 App 进后台时刷新;强杀 App 不触发 `scenePhase == .background`,正常 Home 手势退出即可
|
||||
- **编译报 `containerBackground` 不存在**:KangkangWidget 的 Deployment Target 没改成 17.0
|
||||
|
||||
## 架构备忘(给后续会话)
|
||||
|
||||
- 主 App 写快照:`康康/Persistence/WidgetSnapshot.swift`(数据契约)+ `WidgetSnapshotRefresher.swift`(pinned 指标 → App Group,RootView 在启动和进后台时调用)
|
||||
- Widget 读快照:`KangkangWidget/PinnedIndicatorsWidget.swift` 内有 `WidgetSnapshot` 的**独立拷贝**(extension 不引主 App 代码)。⚠️ 改字段两边同步
|
||||
- Widget 不读 SwiftData:store 有文件保护且在主 App 沙盒,extension 锁屏时读不到;快照 = 最后一次看到的值,锁屏也能显示
|
||||
137
docs/release/小红书文案.md
Normal file
137
docs/release/小红书文案.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 康康 · 小红书发布文案(比赛评审用)
|
||||
|
||||
> 使用说明:
|
||||
> - `◻︎` 处填真机实测数字(打开 我的 → 模型管理 → 性能自检,截图同时把数字抄进来)
|
||||
> - `#比赛官方话题#` 和 `@官方账号` 替换成组委会指定的话题和账号(评审通常按官方话题检索作品,**漏带话题可能查不到你的帖子**)
|
||||
> - 主推版做主帖;技术版可隔 2~3 天发第二篇,小红书对"同一项目多角度连发"权重友好
|
||||
> - 发布时间建议:工作日 12:00–13:30 或 20:00–22:30
|
||||
|
||||
---
|
||||
|
||||
## 版本 A · 主推版(大众 + 评委兼顾)
|
||||
|
||||
### 标题(三选一,均 ≤ 20 字)
|
||||
|
||||
1. 体检报告拍一下,AI 解读不联网📱
|
||||
2. 我做了个不上传的健康 AI,飞行模式都能用
|
||||
3. 爸妈的体检报告,终于有 AI 肯"离线"看了
|
||||
|
||||
### 正文
|
||||
|
||||
体检报告上一堆↑↓箭头,看得懂的没几个;
|
||||
想让 AI 帮忙解读,又得把化验单拍给云端——
|
||||
等于把自己最隐私的数据交出去了。
|
||||
|
||||
所以我做了「康康」:一个 **100% 本地推理** 的健康档案 App🍃
|
||||
所有 AI 都跑在 iPhone 自己的芯片上,**开飞行模式照样用**,数据一个字节都不出手机。
|
||||
|
||||
✅ 它能做什么👇
|
||||
|
||||
📷 **拍一张,报告变档案**
|
||||
化验单/体检报告对着拍,OCR + 端侧大模型自动抽出每项指标、参考范围、偏高偏低,归档成可检索的电子档案。
|
||||
|
||||
📈 **趋势看得见**
|
||||
血压、血糖、体重……长期指标自动画折线,AI 用大白话告诉你"这半年在变好还是变差"。
|
||||
|
||||
💬 **问它,它真的记得你**
|
||||
"我去年尿酸多少?""最近三次血脂对比一下"——它从你自己的历史记录里检索回答,每句话都带引用,点一下能跳回原始报告。
|
||||
|
||||
🗣️ **嘴说就能记**
|
||||
"昨晚头疼,睡得不好"——说一句,自动整理成日记;药盒扫一下,自动录入正在吃的药。
|
||||
|
||||
🏥 **看病前 30 秒**
|
||||
一键生成给医生看的就诊摘要:近期症状 + 关键指标 + 用药过敏史,门诊不再大脑空白。
|
||||
|
||||
🔐 **隐私三件套**
|
||||
系统级硬件加密 + Face ID 锁 + 永久删除。没有账号、没有云、没有"用户协议第 38 条"。
|
||||
|
||||
⚙️ 技术控看这里:
|
||||
端侧跑的是 Qwen3.5 大模型,推理框架是阿里开源的 MNN,在 iPhone 17 上吃满了 Arm 最新的 SME2 矩阵指令——纯 CPU 解码 ◻︎ tok/s,锁屏界面实时显示生成速度,推理快到不像没联网😎
|
||||
|
||||
这是我参加 #比赛官方话题# 的参赛作品,从设计到代码一个人肝了六周。
|
||||
如果你也觉得"健康数据就该留在自己手机里",求个赞和收藏🙏
|
||||
有想要的功能评论区告诉我,下个版本安排!
|
||||
|
||||
⚠️ 康康只做记录和科普式解读,不做诊断不替代医生,身体不舒服请及时就医。
|
||||
|
||||
### 话题标签
|
||||
|
||||
\#比赛官方话题# #端侧AI #本地大模型 #健康管理 #体检报告解读 #隐私保护 #iOS开发 #独立开发者 #AI应用 #数字健康
|
||||
|
||||
### 配图脚本(9 宫格)
|
||||
|
||||
| # | 内容 | 备注 |
|
||||
|---|------|------|
|
||||
| 1 | 封面:手机展示首页 + 大字标题"体检报告 AI 解读,不联网" | 封面字要大,缩略图能读清 |
|
||||
| 2 | 拍照识别报告全流程(拍摄→指标确认页) | 可两张拼一张 |
|
||||
| 3 | 报告详情 C2:原图/解读/指标 三 Tab | 露出"对比上次"区块 |
|
||||
| 4 | 趋势页折线图 + AI 一句话解读 | |
|
||||
| 5 | AI 问答:带 [1][2] 引用 Pill 的回答 | 体现"检索自己的记录" |
|
||||
| 6 | **控制中心飞行模式开启 + App 正常生成回答** 同屏 | 全帖最有说服力的一张 |
|
||||
| 7 | 性能自检卡:SME2 标识 + prefill/decode tok/s | 评委重点看这张 |
|
||||
| 8 | 锁屏 Live Activity 实时 tok/s | |
|
||||
| 9 | 隐私设置页:Face ID + 永久删除 | |
|
||||
|
||||
---
|
||||
|
||||
## 版本 B · 技术圈层版(隔 2~3 天发)
|
||||
|
||||
### 标题(二选一)
|
||||
|
||||
1. 在 iPhone 的 CPU 上,我把大模型跑到 ◻︎ tok/s
|
||||
2. 不用 GPU,iPhone 17 纯 CPU 跑通 Qwen3.5🔥
|
||||
|
||||
### 正文
|
||||
|
||||
最近所有人都在卷云端大模型,我反着来:
|
||||
把整套健康 AI——视觉识别、RAG 问答、趋势解读——全部塞进 iPhone 本地,**纯 CPU 推理**。
|
||||
|
||||
为什么是 CPU 不是 GPU?
|
||||
因为 Arm 在新一代芯片里加了 SME2(可伸缩矩阵扩展):专为矩阵乘法设计的指令集,大模型推理的核心运算正好是它的主场。
|
||||
|
||||
我的技术栈👇
|
||||
🔹 模型:Qwen3.5-2B(多模态,一个模型同时干文本 + 看图识报告)
|
||||
🔹 推理框架:MNN(阿里开源),iPhone 17/A19 走 SME2,老机型自动回退 NEON
|
||||
🔹 兜底:MLX(Apple 官方,Metal GPU),双后端运行时无感切换
|
||||
🔹 应用层:SwiftUI + SwiftData,RAG 用结构化检索(意图抽取→按关键词查库→拼 prompt),不引入 embedding 模型,首响更快
|
||||
|
||||
实测数据(iPhone 17,可在 App 内"性能自检"复现):
|
||||
⚡ prefill ◻︎ tok/s / decode ◻︎ tok/s
|
||||
⚡ 拍一张化验单到出结构化指标:约 ◻︎ 秒
|
||||
⚡ 模型常驻互斥 + actor 串行闸门,长时间使用不 OOM
|
||||
|
||||
几个有意思的坑:
|
||||
1️⃣ MNN 默认 enable_thinking=true,模型疯狂输出 <think> 吃光 token 预算,要在 bridge 层 set_config 关掉
|
||||
2️⃣ 长文本逐行复读死循环——采样器默认不带 repetition penalty,MNN 要显式写进 mixed_samplers
|
||||
3️⃣ LLM 和 VL 同时驻留必 jetsam,做了常驻互斥 + 推理优先级闸门(交互任务可插队后台预生成)
|
||||
|
||||
做这个项目的初衷很简单:健康数据是最不该上云的数据。
|
||||
端侧推理已经到了"真能用"的拐点,这是我给 #比赛官方话题# 交的答卷。
|
||||
|
||||
代码细节/性能调优有兴趣的评论区聊👇
|
||||
|
||||
⚠️ App 仅做记录与科普式解读,不提供诊断建议。
|
||||
|
||||
### 话题标签
|
||||
|
||||
\#比赛官方话题# #端侧AI #MNN #Qwen #ArmSME2 #大模型推理 #iOS开发 #SwiftUI #独立开发者 #本地大模型
|
||||
|
||||
### 配图脚本
|
||||
|
||||
1. 封面:性能自检卡大图,tok/s 数字放大做封面字
|
||||
2. 架构图:UI → Service → AIRuntime → MNN(SME2)/MLX 双后端
|
||||
3. 飞行模式 + 流式生成同屏
|
||||
4. 锁屏 Live Activity tok/s
|
||||
5. 拍照识别报告前后对比(原图 → 结构化指标)
|
||||
6. Xcode/代码截图:MNNLLMBridge 或 actor 闸门片段(打码无关信息)
|
||||
7. 老机型 NEON vs iPhone 17 SME2 速度对比(如有数据)
|
||||
|
||||
---
|
||||
|
||||
## 发布贴士
|
||||
|
||||
1. **官方话题必带且放第一位**,正文里也 @官方账号 一次
|
||||
2. 封面图决定 80% 点击:大字 + 高对比,别用纯截图
|
||||
3. 发布后 1 小时内回评论(尤其问"怎么下载"的,回复"比赛 demo 阶段,关注我等上架"),互动率影响推荐量
|
||||
4. 不要写"治疗""诊断""疗效"等词,健康类内容平台审得严,现有文案已规避
|
||||
5. 主帖发出后把链接填进比赛报名系统/问卷(如果章程要求回填链接)
|
||||
41
docs/research/mnn-kv-cache-prefix.md
Normal file
41
docs/research/mnn-kv-cache-prefix.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# MNN 前缀 KV Cache 调研(2026-06-10)
|
||||
|
||||
## 结论
|
||||
|
||||
当前打包的 MNN.xcframework 已暴露 prefix cache 能力,技术上可以把每个场景**固定的
|
||||
system prompt + few-shot 模板**的 prefill 结果缓存到磁盘,二次调用跳过这部分 prefill。
|
||||
**建议 W6 polish 阶段、用性能自检卡量化 prefill 占比之后再决定是否接入**;当前瓶颈在
|
||||
decode 而非二次 prefill,优先级低于 C1/C2/Live Activity。
|
||||
|
||||
## 依据(`Frameworks/MNN.xcframework/ios-arm64/MNN.framework/Headers/llm/llm.hpp`)
|
||||
|
||||
| API | 行号 | 含义 |
|
||||
|---|---|---|
|
||||
| `bool setPrefixCacheFile(const std::string& filename, int flag = 0)` | :161 | 指定前缀缓存文件;配套私有成员 `mPrefixCacheMode` / `mPrefixLength` / `mIsPrefixFileExist` / `completePrefixWrite()`(:250-255)印证:命中时 prefill 只算增量部分 |
|
||||
| `bool reuse_kv()` | :171 | 读 config 开关 `reuse_kv`,多轮对话内复用 KV(同一会话增量 prefill) |
|
||||
| `void syncPromptCache(const ChatMessages&)` | :176 | decode 结束后同步缓存文本——注释明确说明 cache 在 generate() 后自更新,此接口供做过后处理(如 deleteThinkPart)的调用方提供更准确版本 |
|
||||
| `void setKVCacheInfo(size_t add, size_t remove, ...)` / `eraseHistory(begin, end)` | :158-160 | 更底层的 KV 区间管理,可做部分历史擦除 |
|
||||
|
||||
## 对本项目的适用性
|
||||
|
||||
- 我们所有调用都是「固定模板前缀 + 可变数据后缀」的单轮 `response()`,与 prefix cache
|
||||
的模型吻合。
|
||||
- 模板体量(估):报告识别 ~900 tok、导出报告 ~700 tok、意图抽取 ~300 tok。
|
||||
按性能自检卡实测的 prefill 速率推算,每次调用预计省 **1~3s**。
|
||||
- 多场景共用一个 cache 文件是否支持多前缀未知;最坏情况只对单一场景(建议选「报告识别」,
|
||||
模板最长、调用最频繁)生效。
|
||||
|
||||
## 风险
|
||||
|
||||
1. `flag` 参数语义在头文件无注释,需读 MNN 源码或实验确认。
|
||||
2. OMNI(多模态)分支下行为未验证——我们的 MNN 模型是 Omni 构建。
|
||||
3. cache 文件与模型权重版本绑定:模型更新/重下载后必须失效,否则可能输出乱码。
|
||||
4. `<img>` 标签在 prompt 前部(`analyzeImages` 把图片标签拼在最前),意味着报告识别场景的
|
||||
"固定前缀" 实际不固定 —— **文本场景(导出/意图抽取)才是干净的 prefix cache 候选**。
|
||||
|
||||
## 建议的接入步骤(W6,如性能自检显示 prefill 占比 >30%)
|
||||
|
||||
1. `MNNLLMBridge` init 后调 `setPrefixCacheFile(<AppSupport>/mnn-prefix.cache)`(仅文本场景)。
|
||||
2. 真机 A/B:同一导出报告各跑 3 次,对比 `LlmContext.prefill_us`。
|
||||
3. 异常处理:加载失败或输出劣化时删除 cache 文件并禁用,回退现状。
|
||||
4. `ModelDownloadService.importModel` / 重下载路径上顺手删除旧 cache 文件。
|
||||
1626
docs/superpowers/plans/2026-06-10-competition-optimizations.md
Normal file
1626
docs/superpowers/plans/2026-06-10-competition-optimizations.md
Normal file
File diff suppressed because it is too large
Load Diff
930
docs/superpowers/plans/2026-06-10-voice-diary.md
Normal file
930
docs/superpowers/plans/2026-06-10-voice-diary.md
Normal file
@@ -0,0 +1,930 @@
|
||||
# 语音健康日记 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在「健康记录」(`DiaryQuickSheet`)加语音输入:iOS 端侧流式语音识别实时转写,停止后由本地 LLM(Qwen3.5-2B,经 AIRuntime)整理成健康日记草稿,追加进输入框,可一键回退原话。
|
||||
|
||||
**Architecture:** `DiaryQuickSheet`(mic 按钮 + 状态机)→ `SpeechDictationService`(新,AVAudioEngine + SFSpeechRecognizer 端侧流式转写,不落盘音频)→ `DiaryAssistService.organize(transcript:)`(新方法,经 AIRuntime actor 队列)。Spec:`docs/superpowers/specs/2026-06-10-voice-diary-design.md`。
|
||||
|
||||
**Tech Stack:** SwiftUI、Speech framework(`requiresOnDeviceRecognition = true`)、AVFoundation、Swift Testing(`康康Tests`)。
|
||||
|
||||
**工程约定(执行前必读):**
|
||||
- 工程是 Xcode 16 同步组(`PBXFileSystemSynchronizedRootGroup`):`康康/`、`康康Tests/` 下新建文件**自动入 target,不要改 pbxproj 的文件列表**(权限键除外,见 Task 1)。
|
||||
- CLI 编译/测试必须:`export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`,且加 `-derivedDataPath ./build/cli-dd`(避免和 Xcode 抢 build.db 锁)。
|
||||
- 工程 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`:类型默认 MainActor;系统回调闭包(audio tap、recognitionTask handler)是 nonisolated,**闭包内只碰局部捕获变量,回主线程用 `Task { @MainActor in }`**。
|
||||
- 用户可见文案用 `String(appLoc: "...")`;字号用 `Font.tjScaled(...)`,禁止裸 `.system(size:)`;颜色只用 `Tj.Palette.*`。**不要手改 `Localizable.xcstrings`**(键缺失时回退键名本身,中文键名即兜底文案)。
|
||||
- `git status` 里已有 `康康/Localizable.xcstrings` 的无关改动——**任何 commit 都不要带上它**(逐文件 `git add`)。
|
||||
- spec 偏差说明(已确认的两处小调整):① CLAUDE.md 提到的 `DebugAIRunner` 已不在工程中,prompt 自检改为 `康康Tests` 单元测试 + 真机手测清单;② mic 按钮放「内容」section 标签行右侧(而非输入框内右下角 overlay),避免与文字重叠,仍属"输入框旁"。
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 建独立分支
|
||||
|
||||
**Files:** 无(纯 git)
|
||||
|
||||
- [x] **Step 1: 从当前分支建 `feat/voice-diary`**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
git checkout -b feat/voice-diary
|
||||
```
|
||||
|
||||
Expected: `Switched to a new branch 'feat/voice-diary'`(`Localizable.xcstrings` 的本地改动会跟着工作区走,不影响)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 新增麦克风 + 语音识别权限描述(pbxproj)
|
||||
|
||||
**Files:**
|
||||
- Modify: `康康.xcodeproj/project.pbxproj:430`、`康康.xcodeproj/project.pbxproj:486`(Debug + Release 两个构建配置)
|
||||
|
||||
pbxproj 的 `INFOPLIST_KEY_*` 按字母序排列:Microphone 插在 `NSHealthUpdateUsageDescription` 之后,SpeechRecognition 插在 `NSPhotoLibraryUsageDescription` 之后。每个锚点行在文件中出现 **2 次**(Debug/Release),用 replace_all 一次改两处。
|
||||
|
||||
- [x] **Step 1: 插入 NSMicrophoneUsageDescription(replace_all)**
|
||||
|
||||
用 Edit 工具,`replace_all: true`:
|
||||
|
||||
old_string(注意行首是 4 个 tab):
|
||||
```
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||
```
|
||||
|
||||
new_string:
|
||||
```
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||
```
|
||||
|
||||
- [x] **Step 2: 插入 NSSpeechRecognitionUsageDescription(replace_all)**
|
||||
|
||||
old_string:
|
||||
```
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||
```
|
||||
|
||||
new_string:
|
||||
```
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||
```
|
||||
|
||||
- [x] **Step 3: 验证两个键各出现 2 次**
|
||||
|
||||
```bash
|
||||
grep -c "NSMicrophoneUsageDescription\|NSSpeechRecognitionUsageDescription" 康康.xcodeproj/project.pbxproj
|
||||
```
|
||||
|
||||
Expected: `4`
|
||||
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康.xcodeproj/project.pbxproj
|
||||
git commit -m "feat(语音日记): 新增麦克风与语音识别权限描述(端侧识别文案)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: organize prompt(TDD)
|
||||
|
||||
**Files:**
|
||||
- Test: `康康Tests/DiaryOrganizePromptTests.swift`(新建)
|
||||
- Modify: `康康/AI/Prompts/DiaryAssistPrompts.swift`(文件末尾 `}` 前加方法)
|
||||
|
||||
- [x] **Step 1: 写失败测试**
|
||||
|
||||
新建 `康康Tests/DiaryOrganizePromptTests.swift`:
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct DiaryOrganizePromptTests {
|
||||
@Test func organizePromptContainsTranscriptAndHardRules() {
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: "今天早上头晕量了血压140 90")
|
||||
|
||||
#expect(prompt.contains("今天早上头晕量了血压140 90"))
|
||||
// 健康数据红线:数值/单位/药名/时间不许改,必须写进 prompt
|
||||
#expect(prompt.contains("数值"))
|
||||
#expect(prompt.contains("药名"))
|
||||
// 自适应样式两条规则都在
|
||||
#expect(prompt.contains("一段通顺的话"))
|
||||
#expect(prompt.contains("分行"))
|
||||
// 项目 prompt 规范:禁思考标签
|
||||
#expect(prompt.contains("/no_think"))
|
||||
}
|
||||
|
||||
@Test func organizePromptTruncatesLongTranscript() {
|
||||
let long = String(repeating: "头晕", count: 2000) // 4000 字符,超过上限
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: long)
|
||||
|
||||
// 整条 prompt 里口述部分被截断到 organizeTranscriptLimit
|
||||
let expectedTail = String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit))
|
||||
#expect(prompt.contains(expectedTail))
|
||||
#expect(!prompt.contains(String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit + 2))))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 跑测试确认编译失败(方法还不存在)**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:'康康Tests/DiaryOrganizePromptTests' \
|
||||
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: 编译错误 `type 'DiaryAssistPrompts' has no member 'organize'`(TEST FAILED)。
|
||||
|
||||
- [x] **Step 3: 实现 organize prompt**
|
||||
|
||||
在 `康康/AI/Prompts/DiaryAssistPrompts.swift` 的 enum 末尾(`suggest` 方法后、收尾 `}` 前)加:
|
||||
|
||||
```swift
|
||||
// MARK: - 语音口述 → 日记整理
|
||||
|
||||
/// 口述转写稿截断上限(字符)。2B 模型 context 保护:超长口述只取前面部分。
|
||||
static let organizeTranscriptLimit = 1200
|
||||
|
||||
/// 把语音转写稿整理成健康日记草稿。自适应样式:内容少 → 一段通顺的话;
|
||||
/// 多方面 → 按「方面:内容」分行。
|
||||
/// 红线(spec §2):只重组语言,严禁增删改任何数值、单位、药名、时间——
|
||||
/// 2B 模型把 140/90 改成 130/90 即健康数据事故,所以规则放第一条并配 few-shot 强化。
|
||||
static func organize(transcript: String) -> String {
|
||||
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
|
||||
return """
|
||||
你是健康记录助手。下面是用户口述身体状态的语音转写原话,可能口语化、有重复、缺标点。
|
||||
请把它整理成一条清晰的健康日记。
|
||||
|
||||
硬性规则:
|
||||
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
||||
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
|
||||
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
||||
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
||||
- 不诊断、不给用药建议、不写「建议就医」。
|
||||
- 只输出整理后的日记正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
|
||||
|
||||
示例 1(口述:那个今天早上起来有点头晕然后我量了下血压140 90比平时高一点没吃早饭就出门了):
|
||||
今天早上起来有点头晕,量了血压 140/90,比平时高一点。没吃早饭就出门了。
|
||||
|
||||
示例 2(口述:今天头晕了一上午下午好点了血压早上量的140 90嗯缬沙坦吃了降脂药忘了吃早饭没吃中午吃的清淡晚上散步了半小时):
|
||||
症状:头晕了一上午,下午好转。
|
||||
血压:早上 140/90。
|
||||
用药:缬沙坦已服,降脂药忘服。
|
||||
饮食:早饭未吃,午餐清淡。
|
||||
运动:晚上散步半小时。
|
||||
|
||||
【口述原话】:
|
||||
\(trimmed)
|
||||
|
||||
Output: /no_think
|
||||
"""
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: 跑测试确认通过**
|
||||
|
||||
同 Step 2 命令。Expected: `** TEST SUCCEEDED **`,2 个用例通过。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康Tests/DiaryOrganizePromptTests.swift 康康/AI/Prompts/DiaryAssistPrompts.swift
|
||||
git commit -m "feat(语音日记): organize prompt(自适应样式 + 数值不可改红线)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: DiaryAssistService.organize
|
||||
|
||||
**Files:**
|
||||
- Modify: `康康/Services/DiaryAssistService.swift:99` 之后(`suggest` 方法后、struct 收尾 `}` 前)
|
||||
|
||||
无新单测(纯转发 AIRuntime,LLM 行为靠真机手测;解析逻辑只有 strip + trim,复用已测过的 `stripThinkBlocks`)。
|
||||
|
||||
- [x] **Step 1: 加 organize 方法**
|
||||
|
||||
在 `suggest` 方法的收尾 `}` 之后、struct 收尾 `}` 之前加:
|
||||
|
||||
```swift
|
||||
/// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。
|
||||
/// 失败(模型未就绪 / 输出为空)抛错,调用方回退为直接使用原话,不卡死。
|
||||
/// 与 suggest 同样走 AIRuntime actor 队列,自然与追问/拍照串行。
|
||||
func organize(transcript: String) async throws -> (text: String, decodeRate: Double) {
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
} catch {
|
||||
throw AssistError.modelNotReady
|
||||
}
|
||||
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: transcript)
|
||||
var collected = ""
|
||||
var lastRate: Double = 0
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||
}
|
||||
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw AssistError.empty }
|
||||
return (text, lastRate)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编译验证**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康/Services/DiaryAssistService.swift
|
||||
git commit -m "feat(语音日记): DiaryAssistService.organize 转写稿整理"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: SpeechDictationService(端侧流式转写)
|
||||
|
||||
**Files:**
|
||||
- Create: `康康/Services/SpeechDictationService.swift`
|
||||
|
||||
硬件绑定,无单测;模拟器路径(`isAvailable == false`)与真机路径在 Task 7 手测。
|
||||
|
||||
- [x] **Step 1: 新建 SpeechDictationService.swift**
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import Speech
|
||||
import AVFoundation
|
||||
|
||||
/// 端侧流式语音转写(spec 2026-06-10-voice-diary)。
|
||||
/// AVAudioEngine 麦克风 buffer → SFSpeechAudioBufferRecognitionRequest,
|
||||
/// `requiresOnDeviceRecognition = true` 硬性端侧,识别内容不出设备;**不落盘任何音频**。
|
||||
///
|
||||
/// 生命周期:start(onPartial:) 开始录音并实时回调 partial;stop() 结束并返回最终稿。
|
||||
/// 调用方:DiaryQuickSheet。工程默认 MainActor 隔离,本类型即 MainActor;
|
||||
/// audio tap 与识别回调在系统线程,闭包内只碰局部捕获对象,回主线程统一走 Task { @MainActor }。
|
||||
final class SpeechDictationService {
|
||||
|
||||
enum DictationError: Error, LocalizedError {
|
||||
case unavailable
|
||||
case audioEngineStartFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
return String(appLoc: "本机不支持端侧语音识别")
|
||||
case .audioEngineStartFailed(let m):
|
||||
return String(appLoc: "录音启动失败:\(m)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先系统语言;系统语言不支持端侧时兜底中文(demo 机即使系统是英文也能用)。
|
||||
private static func makeRecognizer() -> SFSpeechRecognizer? {
|
||||
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {
|
||||
return r
|
||||
}
|
||||
if let r = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")),
|
||||
r.supportsOnDeviceRecognition {
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 本机是否支持端侧识别。false(模拟器/老机型)时 UI 隐藏 mic 入口,静默降级。
|
||||
static var isAvailable: Bool { makeRecognizer() != nil }
|
||||
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
/// 识别回调持续刷新;isFinal 或出错时置 didFinish。stop() 用「final 优先、partial 兜底」。
|
||||
private var latestText = ""
|
||||
private var didFinish = false
|
||||
|
||||
private(set) var isRecording = false
|
||||
|
||||
/// 麦克风 + 语音识别两个权限一起申请。任一被拒返回 false。
|
||||
func requestAuthorization() async -> Bool {
|
||||
let speech = await withCheckedContinuation { (c: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { c.resume(returning: $0) }
|
||||
}
|
||||
guard speech == .authorized else { return false }
|
||||
return await AVAudioApplication.requestRecordPermission()
|
||||
}
|
||||
|
||||
/// 开始录音 + 流式识别。partial 结果在主线程回调(录音面板实时字幕)。
|
||||
func start(onPartial: @escaping (String) -> Void) throws {
|
||||
guard !isRecording else { return }
|
||||
guard let recognizer = Self.makeRecognizer(), recognizer.isAvailable else {
|
||||
throw DictationError.unavailable
|
||||
}
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
throw DictationError.audioEngineStartFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.requiresOnDeviceRecognition = true // 红线:识别不出设备
|
||||
request.shouldReportPartialResults = true
|
||||
request.addsPunctuation = true
|
||||
self.request = request
|
||||
latestText = ""
|
||||
didFinish = false
|
||||
|
||||
let input = audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
// tap 在音频线程跑:只碰局部捕获的 request,不碰 self
|
||||
input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
|
||||
request.append(buffer)
|
||||
}
|
||||
audioEngine.prepare()
|
||||
do {
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
input.removeTap(onBus: 0)
|
||||
deactivateSession()
|
||||
throw DictationError.audioEngineStartFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
// 系统线程 → 主线程
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if let result {
|
||||
self.latestText = result.bestTranscription.formattedString
|
||||
onPartial(self.latestText)
|
||||
if result.isFinal { self.didFinish = true }
|
||||
}
|
||||
if error != nil { self.didFinish = true }
|
||||
}
|
||||
}
|
||||
isRecording = true
|
||||
}
|
||||
|
||||
/// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回最终稿。
|
||||
/// 中途识别出错时已拿到的 partial 一样返回(spec 错误表:照常进整理流程)。
|
||||
func stop() async -> String {
|
||||
guard isRecording else { return "" }
|
||||
isRecording = false
|
||||
|
||||
audioEngine.stop()
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
request?.endAudio()
|
||||
|
||||
let deadline = Date().addingTimeInterval(1.5)
|
||||
while !didFinish && Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
task?.cancel()
|
||||
task = nil
|
||||
request = nil
|
||||
deactivateSession()
|
||||
return latestText
|
||||
}
|
||||
|
||||
/// 用户直接关 sheet 时的清理:不关心结果,立即停。
|
||||
func abort() {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
audioEngine.stop()
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
request?.endAudio()
|
||||
task?.cancel()
|
||||
task = nil
|
||||
request = nil
|
||||
deactivateSession()
|
||||
}
|
||||
|
||||
private func deactivateSession() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编译验证**
|
||||
|
||||
同 Task 3 Step 2 命令。Expected: `BUILD SUCCEEDED`。若出现 actor 隔离 warning(标注 error in Swift 6 language mode 的不阻塞),按提示把回调内对 self 的访问收进 `Task { @MainActor in }`,不许用 `nonisolated(unsafe)` 糊。
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康/Services/SpeechDictationService.swift
|
||||
git commit -m "feat(语音日记): SpeechDictationService 端侧流式转写(不落盘音频)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: DiaryVoicePanel(录音/整理面板视图)
|
||||
|
||||
**Files:**
|
||||
- Create: `康康/Features/Diary/DiaryVoicePanel.swift`
|
||||
|
||||
纯展示组件,状态全部外部传入,DiaryQuickSheet(已 600+ 行)不再膨胀。
|
||||
|
||||
- [x] **Step 1: 新建 DiaryVoicePanel.swift**
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
/// 「健康记录」语音输入面板(spec 2026-06-10-voice-diary)。
|
||||
/// 两种模式:recording(实时字幕 + 计时 + 停止)/ organizing(AI 整理中,可取消)。
|
||||
/// 纯展示:状态由 DiaryQuickSheet 持有并传入。
|
||||
struct DiaryVoicePanel: View {
|
||||
enum Mode: Equatable {
|
||||
case recording(elapsedSeconds: Int)
|
||||
case organizing
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
/// recording 时为实时字幕;organizing 时为已定稿的转写稿(置灰展示)。
|
||||
let transcript: String
|
||||
let onStop: () -> Void
|
||||
let onCancelOrganize: () -> Void
|
||||
|
||||
/// 录音上限 3 分钟(超时由 DiaryQuickSheet 的看门狗触发 onStop)。
|
||||
static let maxRecordingSeconds = 180
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header
|
||||
transcriptArea
|
||||
if case .recording = mode {
|
||||
stopButton
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
if mode == .organizing {
|
||||
AIFlowBar().padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
switch mode {
|
||||
case .recording(let elapsed):
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
Text("正在听 · 识别在本机完成")
|
||||
.font(.tjScaled(13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Text(Self.format(elapsed))
|
||||
.font(.tjScaled(12, design: .monospaced))
|
||||
.foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30
|
||||
? Tj.Palette.brick : Tj.Palette.text3)
|
||||
}
|
||||
case .organizing:
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
Text("AI 整理中 · 本地推理")
|
||||
.font(.tjScaled(13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Button("取消") { onCancelOrganize() }
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var transcriptArea: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(showsIndicators: false) {
|
||||
Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript)
|
||||
.font(.tjScaled(14))
|
||||
.foregroundStyle(transcriptColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Color.clear.frame(height: 1).id("tail")
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.onChange(of: transcript) { _, _ in
|
||||
proxy.scrollTo("tail", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var transcriptColor: Color {
|
||||
if transcript.isEmpty { return Tj.Palette.text3 }
|
||||
return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text
|
||||
}
|
||||
|
||||
private var stopButton: some View {
|
||||
Button(action: onStop) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "stop.circle.fill")
|
||||
Text("说完了,整理成日记")
|
||||
}
|
||||
.font(.tjScaled(14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.brick)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private static func format(_ seconds: Int) -> String {
|
||||
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("录音中") {
|
||||
DiaryVoicePanel(mode: .recording(elapsedSeconds: 23),
|
||||
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||
onStop: {}, onCancelOrganize: {})
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("整理中") {
|
||||
DiaryVoicePanel(mode: .organizing,
|
||||
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||
onStop: {}, onCancelOrganize: {})
|
||||
.padding()
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编译验证**
|
||||
|
||||
同 Task 3 Step 2 命令。Expected: `BUILD SUCCEEDED`。
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康/Features/Diary/DiaryVoicePanel.swift
|
||||
git commit -m "feat(语音日记): DiaryVoicePanel 录音/整理面板"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DiaryQuickSheet 接入(mic 按钮 + 状态机 + 回退 pill)
|
||||
|
||||
**Files:**
|
||||
- Modify: `康康/Features/Diary/DiaryQuickSheet.swift`
|
||||
|
||||
改 5 处:① 状态 + 录音流程函数;② 「内容」标签行加 mic 按钮;③ 输入框下方挂面板 / 提示条 / 回退 pill;④ `canRequestSuggest` 把 organizing 排除;⑤ onDisappear 清理。
|
||||
|
||||
- [x] **Step 1: 加语音状态(`@FocusState` 行之后、`hasContent` 之前)**
|
||||
|
||||
在 `DiaryQuickSheet.swift:38`(`@FocusState private var contentFocused: Bool`)之后插入:
|
||||
|
||||
```swift
|
||||
|
||||
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
||||
|
||||
enum VoicePhase: Equatable { case idle, recording, organizing }
|
||||
@State private var voicePhase: VoicePhase = .idle
|
||||
@State private var liveTranscript = ""
|
||||
@State private var recordingSeconds = 0
|
||||
/// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。
|
||||
@State private var rawTranscript: String?
|
||||
/// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。
|
||||
/// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。
|
||||
@State private var organizedAppended: String?
|
||||
/// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。
|
||||
@State private var voiceNote: String?
|
||||
@State private var voiceDeniedAlert = false
|
||||
@State private var voiceFlowTask: Task<Void, Never>?
|
||||
@State private var recordingWatchdog: Task<Void, Never>?
|
||||
private let dictation = SpeechDictationService()
|
||||
```
|
||||
|
||||
- [x] **Step 2: 「内容」标签行加 mic 按钮**
|
||||
|
||||
把(`DiaryQuickSheet.swift:79-80` 附近):
|
||||
|
||||
```swift
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel(String(appLoc: "内容"))
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```swift
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
sectionLabel(String(appLoc: "内容"))
|
||||
Spacer()
|
||||
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||||
Button(action: startVoice) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.tjScaled(11, weight: .semibold))
|
||||
Text("说一段")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().strokeBorder(
|
||||
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
|
||||
lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading) // AI 追问生成中不抢 AIRuntime 队列
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`TextField` 那段不动,仍在该 VStack 内。)
|
||||
|
||||
- [x] **Step 3: 输入框下方挂面板 / 提示条 / 回退 pill**
|
||||
|
||||
在 TextField 的 `.overlay(...)` 闭合后、该 VStack 的收尾 `}` 之前(即原 `DiaryQuickSheet.swift:95` `)` 与 `:96` `}` 之间)插入:
|
||||
|
||||
```swift
|
||||
|
||||
if voicePhase != .idle {
|
||||
DiaryVoicePanel(
|
||||
mode: voicePhase == .organizing
|
||||
? .organizing
|
||||
: .recording(elapsedSeconds: recordingSeconds),
|
||||
transcript: liveTranscript,
|
||||
onStop: stopVoiceAndOrganize,
|
||||
onCancelOrganize: cancelOrganize
|
||||
)
|
||||
}
|
||||
|
||||
if let note = voiceNote {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(note)
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
if let organized = organizedAppended,
|
||||
rawTranscript != nil,
|
||||
content.range(of: organized) != nil {
|
||||
Button(action: revertToRawTranscript) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.uturn.backward")
|
||||
.font(.tjScaled(10, weight: .semibold))
|
||||
Text("改用原话")
|
||||
.font(.tjScaled(11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: organizing 期间禁用「AI 追问」+ 关 sheet 清理 + 权限 alert**
|
||||
|
||||
把 `DiaryQuickSheet.swift:48`:
|
||||
|
||||
```swift
|
||||
private var canRequestSuggest: Bool { hasContent && !isLoading }
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```swift
|
||||
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||||
```
|
||||
|
||||
把 `DiaryQuickSheet.swift:146`:
|
||||
|
||||
```swift
|
||||
.onDisappear { suggestTask?.cancel() }
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```swift
|
||||
.onDisappear {
|
||||
suggestTask?.cancel()
|
||||
voiceFlowTask?.cancel()
|
||||
recordingWatchdog?.cancel()
|
||||
dictation.abort()
|
||||
}
|
||||
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
|
||||
Button(String(appLoc: "前往设置")) {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 5: 加流程函数(`// MARK: - Actions` 区,`requestSuggestions` 之前)**
|
||||
|
||||
在 `DiaryQuickSheet.swift` 的 `sectionLabel` 函数后插入:
|
||||
|
||||
```swift
|
||||
|
||||
// MARK: 语音输入流程
|
||||
|
||||
private func startVoice() {
|
||||
contentFocused = false
|
||||
voiceNote = nil
|
||||
voiceFlowTask = Task { @MainActor in
|
||||
guard await dictation.requestAuthorization() else {
|
||||
voiceDeniedAlert = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
liveTranscript = ""
|
||||
recordingSeconds = 0
|
||||
try dictation.start { partial in liveTranscript = partial }
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
|
||||
// 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致)
|
||||
recordingWatchdog = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled, voicePhase == .recording else { return }
|
||||
recordingSeconds += 1
|
||||
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
|
||||
stopVoiceAndOrganize()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
voiceNote = error.localizedDescription
|
||||
voicePhase = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopVoiceAndOrganize() {
|
||||
guard voicePhase == .recording else { return }
|
||||
recordingWatchdog?.cancel()
|
||||
voiceFlowTask = Task { @MainActor in
|
||||
let transcript = (await dictation.stop())
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
liveTranscript = transcript
|
||||
guard !transcript.isEmpty else {
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
voiceNote = String(appLoc: "没听清,再试一次")
|
||||
return
|
||||
}
|
||||
rawTranscript = transcript
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
|
||||
do {
|
||||
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
|
||||
guard !Task.isCancelled else { return }
|
||||
appendToContent(result.text)
|
||||
organizedAppended = result.text
|
||||
lastRate = result.decodeRate
|
||||
} catch is CancellationError {
|
||||
// cancelOrganize 已处理回退,这里只收尾
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||||
organizedAppended = nil
|
||||
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。
|
||||
private func cancelOrganize() {
|
||||
guard voicePhase == .organizing else { return }
|
||||
voiceFlowTask?.cancel()
|
||||
if let raw = rawTranscript {
|
||||
appendToContent(raw)
|
||||
organizedAppended = nil
|
||||
voiceNote = String(appLoc: "已取消整理,填入原话")
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
}
|
||||
|
||||
/// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。
|
||||
private func revertToRawTranscript() {
|
||||
guard let raw = rawTranscript,
|
||||
let organized = organizedAppended,
|
||||
let range = content.range(of: organized, options: .backwards) else { return }
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
content = content.replacingCharacters(in: range, with: raw)
|
||||
organizedAppended = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 6: 编译验证(touch 强制重编拿全量警告)**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
touch 康康/Features/Diary/DiaryQuickSheet.swift
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||
|
||||
- [x] **Step 7: 跑全量单测(确认没碰坏别的)**
|
||||
|
||||
```bash
|
||||
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: `** TEST SUCCEEDED **`。
|
||||
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康/Features/Diary/DiaryQuickSheet.swift
|
||||
git commit -m "feat(语音日记): DiaryQuickSheet 接入语音输入(录音→整理→回退原话)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 验证与手测清单
|
||||
|
||||
**Files:** 无新增代码
|
||||
|
||||
- [x] **Step 1: 模拟器降级路径验证**
|
||||
|
||||
模拟器跑 App(或 Xcode Preview `DiaryQuickSheet`),打开「+ 新建 → 写日记」:
|
||||
- `SpeechDictationService.isAvailable` 在模拟器多半为 false → 「说一段」按钮应**整体不显示**,其余功能照旧。
|
||||
- 若模拟器恰好支持端侧识别(部分 macOS/Xcode 组合会),按钮出现也算通过——继续验证录音面板出现、无崩溃即可。
|
||||
|
||||
- [ ] **Step 2: 真机手测清单(连 iPhone 跑,逐项确认)**
|
||||
|
||||
1. 首次点「说一段」→ 依次弹语音识别 + 麦克风两个系统权限框,文案是 Task 1 写的端侧说明
|
||||
2. 拒绝权限 → 再点按钮弹「前往设置」alert,能跳系统设置
|
||||
3. 录音中:实时字幕逐字上屏、计时走动、说话时 waveform 动画
|
||||
4. 点「说完了,整理成日记」→ 面板转「AI 整理中」(AIFlowBar 流动)→ 整理稿**追加**进输入框(已有手打内容不被覆盖)
|
||||
5. 口述含数值(如"血压一百四九十")→ 整理稿数值未被改动(说 3 条不同口述各验一次)
|
||||
6. 「改用原话」pill 出现;点击 → 整理稿被替换为原始转写稿;再手动编辑正文该段 → pill 消失
|
||||
7. 飞行模式(模型已下载)→ 全流程照常,验证 100% 本地
|
||||
8. 一个字不说就点停止 → 「没听清,再试一次」,回 idle 不卡死
|
||||
9. 模型未下载(或长按删除模型后)→ 整理失败 → 原话直接入框 + 提示
|
||||
10. 录音中直接下滑关 sheet → 无崩溃,再次打开正常
|
||||
11. 「AI 整理中」点取消 → 原话入框 + 「已取消整理,填入原话」
|
||||
|
||||
- [ ] **Step 3: 把手测结果记进 commit(若有 fix,随 fix 一起提)**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "test(语音日记): 真机手测清单通过(见 plan Task 7)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:权限(T1)、organize prompt + 自适应 + 数值红线(T2)、Service(T3)、端侧转写不落盘 + 3 分钟上限 + zh 兜底(T4)、面板 + 实时字幕(T5)、mic 入口 + 状态机 + 追加不覆盖 + 改用原话 + 全部错误回退 + organizing 禁用追问(T6)、手测含飞行模式/空转写/取消(T7)。spec 各节均有对应任务。
|
||||
- **占位符**:无 TBD/TODO;所有代码步骤给了完整代码。
|
||||
- **类型一致性**:`SpeechDictationService.isAvailable/requestAuthorization/start(onPartial:)/stop()/abort()` 在 T4 定义、T6 使用一致;`DiaryVoicePanel.Mode`/`maxRecordingSeconds` T5 定义、T6 使用一致;`organize(transcript:) -> (text:, decodeRate:)` T3 定义、T6 解构一致;`AssistError` 复用现有定义。
|
||||
296
docs/superpowers/plans/2026-06-10-voice-export-composer.md
Normal file
296
docs/superpowers/plans/2026-06-10-voice-export-composer.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 「身体档案」输入框语音听写 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在「身体档案」(`HealthExportSheet`)底部聊天输入框加端侧语音听写:点 mic 开始、识别文字实时流进输入框、再点停止,不调 LLM、不自动发送。
|
||||
|
||||
**Architecture:** 复用 `SpeechDictationService`(@State 持有);新增 static 纯函数 `merge(prefix:partial:)` 处理"已有文字 + 听写文字"拼接(唯一可单测逻辑);`HealthExportSheet` 加 6 个 @State + mic 按钮 + 3 个流程函数。Spec:`docs/superpowers/specs/2026-06-10-voice-export-composer-design.md`。
|
||||
|
||||
**Tech Stack:** SwiftUI、Speech(经 SpeechDictationService)、Swift Testing。
|
||||
|
||||
**工程约定:** 同 `2026-06-10-voice-diary.md` 的「执行前必读」(同步组免改 pbxproj、CLI 用 `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` + `-derivedDataPath ./build/cli-dd`、全量并行测试不可信要 `-only-testing` 定点跑、commit 逐文件 add 不带 `Localizable.xcstrings`)。**当前环境注意**:xcode-select 已指向完整 Xcode 且许可证未接受——`git` 用 `DEVELOPER_DIR=/Library/Developer/CommandLineTools` 前缀绕过;`xcodebuild` 必须先让用户跑 `sudo xcodebuild -license accept`。直接在 `feat/mnn-sme2-runtime` 分支上做(上一功能合并后该分支即集成分支,不再另开分支避免并发会话分支错位)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `merge(prefix:partial:)`(TDD)
|
||||
|
||||
**Files:**
|
||||
- Test: `康康Tests/SpeechDictationMergeTests.swift`(新建)
|
||||
- Modify: `康康/Services/SpeechDictationService.swift`(`isAvailable` 之后加 static 方法)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
新建 `康康Tests/SpeechDictationMergeTests.swift`:
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct SpeechDictationMergeTests {
|
||||
@Test func emptyPrefixReturnsPartial() {
|
||||
#expect(SpeechDictationService.merge(prefix: "", partial: "今天头晕") == "今天头晕")
|
||||
}
|
||||
|
||||
@Test func plainPrefixJoinsWithSpace() {
|
||||
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "新听写")
|
||||
== "已有内容 新听写")
|
||||
}
|
||||
|
||||
@Test func whitespaceTerminatedPrefixConcatsDirectly() {
|
||||
#expect(SpeechDictationService.merge(prefix: "第一行\n", partial: "新听写")
|
||||
== "第一行\n新听写")
|
||||
}
|
||||
|
||||
@Test func emptyPartialKeepsPrefix() {
|
||||
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "") == "已有内容")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认编译失败**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:'康康Tests/SpeechDictationMergeTests' \
|
||||
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|TEST (SUCCEEDED|FAILED)" | head -5
|
||||
```
|
||||
|
||||
Expected: `error: type 'SpeechDictationService' has no member 'merge'`(TEST FAILED)。
|
||||
|
||||
- [ ] **Step 3: 实现 merge**
|
||||
|
||||
在 `康康/Services/SpeechDictationService.swift` 的 `static var isAvailable` 行之后加:
|
||||
|
||||
```swift
|
||||
|
||||
/// 听写文本拼接:听写开始时输入框已有 prefix,partial 持续拼在其后。
|
||||
/// prefix 空 → 直接用 partial;prefix 以空白/换行结尾 → 直接连接;否则补一个空格。
|
||||
static func merge(prefix: String, partial: String) -> String {
|
||||
guard !partial.isEmpty else { return prefix }
|
||||
guard !prefix.isEmpty else { return partial }
|
||||
if let last = prefix.unicodeScalars.last,
|
||||
CharacterSet.whitespacesAndNewlines.contains(last) {
|
||||
return prefix + partial
|
||||
}
|
||||
return prefix + " " + partial
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
同 Step 2 命令。Expected: `** TEST SUCCEEDED **`,4 个用例通过。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
DEVELOPER_DIR=/Library/Developer/CommandLineTools git add 康康Tests/SpeechDictationMergeTests.swift 康康/Services/SpeechDictationService.swift
|
||||
DEVELOPER_DIR=/Library/Developer/CommandLineTools git commit -m "feat(语音听写): SpeechDictationService.merge 前缀拼接(TDD)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: HealthExportSheet 接入
|
||||
|
||||
**Files:**
|
||||
- Modify: `康康/Features/Archive/HealthExportSheet.swift`(状态区 :27-30、canAsk :38、canGenerateReport :49、快捷问答 chip :133、onDisappear :103、alert :104、composer :410)
|
||||
|
||||
- [ ] **Step 1: 加听写状态(「快捷问答」状态块之后、`init` 之前)**
|
||||
|
||||
在 `@State private var newPromptText = ""` 之后插入:
|
||||
|
||||
```swift
|
||||
|
||||
// 语音听写(spec 2026-06-10-voice-export-composer)。
|
||||
// dictation 必须 @State:struct View 重建时普通 let 会换新实例(日记踩过的坑)。
|
||||
@State private var dictation = SpeechDictationService()
|
||||
@State private var isDictating = false
|
||||
/// 听写开始时输入框已有文字,partial 始终拼在它后面。
|
||||
@State private var dictationPrefix = ""
|
||||
@State private var dictationTask: Task<Void, Never>?
|
||||
@State private var dictationWatchdog: Task<Void, Never>?
|
||||
@State private var dictationDeniedAlert = false
|
||||
/// 录音上限,与日记一致(防麦克风悬挂)。
|
||||
private static let dictationMaxSeconds = 180
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 录音中禁发送/生成/chip**
|
||||
|
||||
`canAsk` 加条件:
|
||||
|
||||
```swift
|
||||
private var canAsk: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
!isDictating &&
|
||||
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
```
|
||||
|
||||
`canGenerateReport` 的 `!isGeneratingReport &&` 后加 `!isDictating &&`。
|
||||
|
||||
快捷问答 chip 动作(`draftQuestion = p.prompt` 处)改为:
|
||||
|
||||
```swift
|
||||
guard !isDictating else { return }
|
||||
draftQuestion = p.prompt
|
||||
```
|
||||
|
||||
- [ ] **Step 3: composer 加 mic 按钮 + TextField 录音中禁用**
|
||||
|
||||
TextField 的 `.disabled(isAnswering || isGeneratingReport)` 改为 `.disabled(isAnswering || isGeneratingReport || isDictating)`。
|
||||
|
||||
TextField 与发送 Button 之间插入:
|
||||
|
||||
```swift
|
||||
|
||||
if SpeechDictationService.isAvailable {
|
||||
Button { toggleDictation() } label: {
|
||||
Image(systemName: isDictating ? "stop.fill" : "mic.fill")
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(isDictating ? Tj.Palette.paper : Tj.Palette.brick)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Circle().fill(isDictating ? Tj.Palette.brick : Tj.Palette.brickSoft))
|
||||
.symbolEffect(.pulse, options: .repeating, isActive: isDictating)
|
||||
}
|
||||
.disabled(isAnswering || isGeneratingReport)
|
||||
.accessibilityLabel(isDictating ? String(appLoc: "停止听写") : String(appLoc: "语音输入"))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 生命周期 + 权限 alert**
|
||||
|
||||
`.onDisappear { task?.cancel() }` 改为:
|
||||
|
||||
```swift
|
||||
.onDisappear {
|
||||
task?.cancel()
|
||||
dictationTask?.cancel()
|
||||
dictationWatchdog?.cancel()
|
||||
dictation.abort()
|
||||
}
|
||||
```
|
||||
|
||||
现有「添加快捷问答」alert 的 `}` 闭合之后追加:
|
||||
|
||||
```swift
|
||||
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $dictationDeniedAlert) {
|
||||
Button(String(appLoc: "前往设置")) {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("语音输入全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 流程函数(`// MARK: - Actions` 之后、`sendQuestion` 之前)**
|
||||
|
||||
```swift
|
||||
// MARK: 语音听写
|
||||
|
||||
private func toggleDictation() {
|
||||
if isDictating { stopDictation() } else { startDictation() }
|
||||
}
|
||||
|
||||
private func startDictation() {
|
||||
questionFocused = false
|
||||
dictationTask = Task { @MainActor in
|
||||
guard await dictation.requestAuthorization() else {
|
||||
dictationDeniedAlert = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
dictationPrefix = draftQuestion
|
||||
try dictation.start { partial in
|
||||
draftQuestion = SpeechDictationService.merge(prefix: dictationPrefix,
|
||||
partial: partial)
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { isDictating = true }
|
||||
dictationWatchdog = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.dictationMaxSeconds) * 1_000_000_000)
|
||||
guard !Task.isCancelled, isDictating else { return }
|
||||
stopDictation()
|
||||
}
|
||||
} catch {
|
||||
isDictating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDictation() {
|
||||
guard isDictating else { return }
|
||||
dictationWatchdog?.cancel()
|
||||
dictationTask = Task { @MainActor in
|
||||
let final = (await dictation.stop()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !final.isEmpty {
|
||||
draftQuestion = SpeechDictationService.merge(prefix: dictationPrefix,
|
||||
partial: final)
|
||||
}
|
||||
// final 为空:partial 已实时在输入框,保持现状即天然兜底(spec:不提示「没听清」)
|
||||
withAnimation(.snappy(duration: 0.2)) { isDictating = false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: touch 强制重编验证**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
touch 康康/Features/Archive/HealthExportSheet.swift
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||
|
||||
- [ ] **Step 7: 定点回归(语音相关全部测试)**
|
||||
|
||||
```bash
|
||||
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:'康康Tests/SpeechDictationMergeTests' \
|
||||
-only-testing:'康康Tests/SpeechDictationAvailabilityTests' \
|
||||
-only-testing:'康康Tests/DiaryOrganizePromptTests' \
|
||||
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "Test case.*(passed|failed)|TEST (SUCCEEDED|FAILED)"
|
||||
```
|
||||
|
||||
Expected: `** TEST SUCCEEDED **`,7 用例通过。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
DEVELOPER_DIR=/Library/Developer/CommandLineTools git add 康康/Features/Archive/HealthExportSheet.swift
|
||||
DEVELOPER_DIR=/Library/Developer/CommandLineTools git commit -m "feat(语音听写): 身体档案输入框听写实时上屏"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 真机手测清单
|
||||
|
||||
- [ ] **Step 1: 真机逐项确认**
|
||||
|
||||
1. 「身体档案」composer 出现 mic 按钮(模拟器不支持端侧识别时隐藏)
|
||||
2. 点 mic → 说话 → 字实时出现在输入框;输入框已有文字时保留并以空格衔接
|
||||
3. 录音中:输入框/发送/「生成整理报告」/快捷问答 chip 均不可用;mic 为红色停止态
|
||||
4. 再点 mic → 停止,文字落定,点发送正常走问答
|
||||
5. 权限拒绝 → alert 跳设置
|
||||
6. 录音中直接关 sheet → 无崩溃、麦克风指示灯熄灭
|
||||
7. 3 分钟自动停止
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:merge 纯函数+单测(T1)、@State 持有/实时上屏/停止落定/空结果保持现状(T2 S5)、mic 隐藏与禁用矩阵(T2 S2-S3)、权限 alert + onDisappear abort + 看门狗(T2 S4-S5)、真机清单(T3)。无缺口。
|
||||
- **占位符**:无;所有代码步骤给全。
|
||||
- **类型一致性**:`merge(prefix:partial:)` T1 定义、T2 S5 调用一致;`dictationMaxSeconds`/`isDictating`/`dictationPrefix` 命名前后一致;`SpeechDictationService.isAvailable/requestAuthorization/start/stop/abort` 与现有实现签名一致。
|
||||
121
docs/superpowers/specs/2026-06-10-voice-diary-design.md
Normal file
121
docs/superpowers/specs/2026-06-10-voice-diary-design.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 语音健康日记(语音转文字 + AI 整理)设计
|
||||
|
||||
> 2026-06-10 · 在「健康记录」(`DiaryQuickSheet`)里加语音输入:iOS 端侧语音识别实时转写,停止后由本地 LLM 整理成健康日记草稿,可编辑后保存。
|
||||
|
||||
## 背景
|
||||
|
||||
「健康记录」目前只能手打文字(`DiaryQuickSheet` → `DiaryEntry`),已有「AI 医生角度多轮追问」辅助。口述比打字门槛低得多,尤其适合身体不适时记录。
|
||||
|
||||
现有两个本地模型(Qwen3.5-2B 文本、Qwen3-VL 视觉)都没有音频编码器,无法做 ASR;引入 Whisper 类模型要 +0.5~1.5GB 体积和一条新推理链路,不可接受。`SFSpeechRecognizer` 支持强制端侧识别(`requiresOnDeviceRecognition = true`),中文质量够用、零体积,与「100% 本地」卖点完全一致。
|
||||
|
||||
## 决策(已与用户确认)
|
||||
|
||||
| 维度 | 决定 |
|
||||
|---|---|
|
||||
| 交互形态 | 说完 → 自动调 LLM 整理成日记草稿(非纯听写) |
|
||||
| 整理样式 | 自适应:口述短 → 一段通顺的话;口述长且多方面 → 自动分点 |
|
||||
| 入口 | `DiaryQuickSheet` 输入框旁麦克风按钮(不动 RecordSheet 骨架) |
|
||||
| 转写链路 | 流式实时转写(AVAudioEngine buffer → 实时字幕),不落盘音频 |
|
||||
| ASR 引擎 | `SFSpeechRecognizer` 端侧;不引入 Whisper;不做云端回退 |
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
DiaryQuickSheet(mic 按钮 + 录音面板)
|
||||
├─► SpeechDictationService(新)── AVAudioEngine + SFSpeechRecognizer(端侧)
|
||||
└─► DiaryAssistService.organize(transcript:)(新方法)──► AIRuntime ──► MNN/MLX
|
||||
```
|
||||
|
||||
符合模块边界:UI 不直接碰 AIRuntime;语音采集是系统能力,封装成独立 Service。
|
||||
|
||||
## 组件
|
||||
|
||||
### 1. `SpeechDictationService`(新,`Services/`,`@MainActor`)
|
||||
|
||||
封装 AVAudioEngine 麦克风采集 + `SFSpeechAudioBufferRecognitionRequest` 流式识别。
|
||||
|
||||
接口:
|
||||
|
||||
- `static var isAvailable: Bool` — 本机是否支持**端侧**中文识别(`supportsOnDeviceRecognition` + locale 检查;模拟器/老机型为 false)
|
||||
- `func requestAuthorization() async -> Bool` — 麦克风 + 语音识别两个权限一起申请
|
||||
- `func start(onPartial: @escaping (String) -> Void) throws` — 开始录音,partial 结果实时回调(录音面板字幕)
|
||||
- `func stop() async -> String` — 停止并返回最终转写稿
|
||||
|
||||
实现要点:
|
||||
|
||||
- `requiresOnDeviceRecognition = true`(硬性,识别内容不出设备)
|
||||
- `addsPunctuation = true`(自动标点)
|
||||
- locale 跟随系统,不支持端侧时 `isAvailable = false`
|
||||
- **不写任何音频文件**,buffer 即用即弃
|
||||
- 录音上限 3 分钟,到点自动 stop
|
||||
|
||||
### 2. `DiaryAssistService.organize(transcript:)`(新方法)
|
||||
|
||||
```swift
|
||||
func organize(transcript: String) async throws -> (text: String, decodeRate: Double)
|
||||
```
|
||||
|
||||
- prompt 加在 `AI/Prompts/DiaryAssistPrompts.swift`:`organizePrompt(transcript:)`
|
||||
- few-shot 两例:短口述 → 一段第一人称通顺文本;长口述(症状/用药/饮食多方面)→ 分点
|
||||
- **硬性约束写进 prompt:只重组语言,不得增删改任何数值、单位、药名、时间**(健康数据,2B 模型改数即事故)
|
||||
- 转写稿超长先截断(保护 context),非流式,await 完整结果
|
||||
- 走 AIRuntime actor 队列,与「多轮追问」「拍照识别」自然串行
|
||||
|
||||
### 3. `DiaryQuickSheet` UI 改动
|
||||
|
||||
- 内容输入框 trailing 加 mic 按钮(`isAvailable == false` 时整个隐藏)
|
||||
- 录音态:输入框下方展开录音面板 —— 实时字幕区 + 脉冲动画(sparkles/waveform `symbolEffect`)+「停止」按钮
|
||||
- 整理态:面板转「AI 整理中」(复用 `AIFlowBar` + tok/s),可取消
|
||||
- 完成:整理稿**追加**进输入框(沿用 `appendToContent`,不覆盖已写内容);面板收起
|
||||
- 完成后显示一次性「改用原话」pill:点击把刚追加的整理稿替换为原始转写稿(原始稿在本次 sheet 生命周期内持有;再次录音或手动编辑该段后 pill 消失)
|
||||
- 整理稿入框后,既有「AI 多轮追问」功能照常可用,无需特殊处理
|
||||
|
||||
## 状态机
|
||||
|
||||
```
|
||||
idle ──(点 mic,权限 OK)──► recording ──(停止/3min 到点)──► organizing ──► done(回 idle)
|
||||
```
|
||||
|
||||
- 实时字幕只显示在录音面板,**停止前不进输入框**
|
||||
- `organizing` 期间 mic 按钮与「AI 追问」按钮禁用(AIRuntime 串行,避免排队困惑)
|
||||
|
||||
## 错误处理(红线 #5:全部有回退,不卡死)
|
||||
|
||||
| 故障 | 行为 |
|
||||
|---|---|
|
||||
| 权限被拒 | 弹说明 alert + 「前往设置」跳系统设置 |
|
||||
| 本机不支持端侧识别(含模拟器) | mic 按钮隐藏,静默降级为纯手打 |
|
||||
| 识别中途出错 | 已拿到的 partial 文本照常进 organizing |
|
||||
| 转写结果为空 | 提示「没听清,再试一次」,回 idle |
|
||||
| LLM 未就绪 / 整理失败 | **原始转写稿直接追加进输入框** + 提示「AI 整理失败,已填入原话」 |
|
||||
|
||||
不做云端识别回退(红线 #1:不引入云服务)。
|
||||
|
||||
## 权限(project.pbxproj 新增两条 INFOPLIST_KEY)
|
||||
|
||||
- `NSMicrophoneUsageDescription`:康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。
|
||||
- `NSSpeechRecognitionUsageDescription`:语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。
|
||||
|
||||
## 测试
|
||||
|
||||
- `organize` prompt:`DebugAIRunner` 加自检入口(短/长两条样例口述,肉眼验自适应样式 + 数值不被改动)
|
||||
- 录音链路:真机手测清单(权限首次申请、录音字幕、3 分钟自动停、整理失败回退、「改用原话」)
|
||||
- 模拟器:验证 `isAvailable == false` 时 mic 按钮隐藏
|
||||
|
||||
## 范围边界(不做)
|
||||
|
||||
- 症状 / AI 问答的语音入口
|
||||
- 音频文件保存或回放
|
||||
- Whisper / 任何新模型
|
||||
- Live Activity 集成(前台短流程,无必要)
|
||||
- 多语言听写优化(locale 跟系统,不支持即降级)
|
||||
|
||||
## 卖点映射(§12)
|
||||
|
||||
1. 降低记录门槛 → 卖点 1(影像档案之外的日常记录闭环)
|
||||
2. 「系统端侧 ASR + 本地 LLM 整理」全链路不出设备 → 卖点 2(100% 本地)
|
||||
3. 日记语料变多 → 卖点 3(本地 RAG 长期记忆)
|
||||
|
||||
## 排期
|
||||
|
||||
清单外新功能(红线 #6),本设计即立项讨论结论。工作量约 1~1.5 天,独立小分支插队,不挤占 C1/VL 主线。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 「身体档案」输入框语音输入 设计
|
||||
|
||||
> 2026-06-10 · 在「身体档案」(`HealthExportSheet`)底部聊天输入框加端侧语音听写,复用 `SpeechDictationService`,识别文字实时流进输入框。
|
||||
|
||||
## 背景
|
||||
|
||||
「身体档案」composer 是聊天式输入(提问/诉求 → 发送 → LLM 对话/生成报告)。与日记不同,这里输入的内容马上交给 LLM,**不需要"整理"加工**;口述原话直接进输入框即正确行为(类似系统键盘听写)。
|
||||
|
||||
## 决策(已与用户确认)
|
||||
|
||||
| 维度 | 决定 |
|
||||
|---|---|
|
||||
| 交互 | 听写直接流进输入框:点 mic 开始,实时上屏;再点停止;用户自查后手动发送 |
|
||||
| LLM | 不调用(无整理步骤、不自动发送) |
|
||||
| 复用 | `SpeechDictationService`(**@State 持有**,防视图重建丢实例)、权限 alert 文案、3 分钟看门狗、onDisappear abort |
|
||||
| UI | mic 按钮放 TextField 与发送键之间;`isAvailable == false` 隐藏;录音中变红色停止态(脉冲动画) |
|
||||
|
||||
## 组件
|
||||
|
||||
### 1. `SpeechDictationService.merge(prefix:partial:)`(新,static 纯函数)
|
||||
|
||||
听写文本拼接规则,唯一可单测的逻辑:
|
||||
- `prefix` 为空 → 返回 `partial`
|
||||
- `prefix` 以空白/换行结尾 → `prefix + partial`
|
||||
- 其余 → `prefix + " " + partial`
|
||||
|
||||
### 2. `HealthExportSheet` 改动
|
||||
|
||||
- `@State dictation` + `isDictating` + `dictationPrefix` + 看门狗 Task
|
||||
- 点 mic:申请权限(拒绝 → alert 跳设置,与日记同文案)→ 记录 `dictationPrefix = draftQuestion` → start,每个 partial:`draftQuestion = merge(prefix:partial:)`
|
||||
- 再点:`stop()`,最终稿同 merge 落定;**stop 返回空时保留输入框现状**(partial 已实时在框里,天然兜底,不提示「没听清」)
|
||||
- 3 分钟看门狗自动停(防麦克风悬挂)
|
||||
|
||||
## 冲突防护
|
||||
|
||||
- 录音中:TextField 与发送按钮、「生成整理报告」按钮禁用(防手输与 partial 互相覆盖、防录音中发送)
|
||||
- `isAnswering / isGeneratingReport` 时 mic 禁用
|
||||
- `onDisappear` abort
|
||||
|
||||
## 测试
|
||||
|
||||
- `merge(prefix:partial:)` 3 个单测(空前缀 / 空白结尾前缀 / 普通前缀)
|
||||
- 真机手测:听写上屏、停止落定、已有文字保留、权限拒绝、3 分钟自动停
|
||||
|
||||
## 不做(YAGNI)
|
||||
|
||||
快捷问答弹窗 / 个人资料 Form 等其他输入处的语音;自动发送;录音面板;LLM 整理。
|
||||
210
docs/踩坑与排查记录.md
Normal file
210
docs/踩坑与排查记录.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 康康 · 踩坑与排查记录
|
||||
|
||||
> 本地推理 / SwiftData / 端侧模型这类问题不好复现也不好搜,踩过的坑按统一模板记在这里,方便回查。
|
||||
> 新增条目往最上面加(倒序),模板见文末。
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-09 · 生成身体档案报告时,LLM 逐行复读死循环
|
||||
|
||||
### 现象
|
||||
多轮「身体档案」对话点生成报告后,「## 关键指标」整段陷入死循环:同一行
|
||||
`⚠️ 收缩压 (107 mmHg)` 连续重复几十遍,最后被 maxTokens 截断成半行「⚠️ 收缩」。
|
||||
(本质是小模型 **repetition / degeneration loop**,不是数据真有几十条。)
|
||||
|
||||
### 根因(确认)
|
||||
采样器**完全没有重复惩罚**,叠加低温 → 几乎必然复读。两个后端都有问题:
|
||||
|
||||
| 后端 | 位置 | 原配置 | 问题 |
|
||||
|---|---|---|---|
|
||||
| MNN(主) | `MNNLLMBridge.mm` `initWithConfigPath` 的 `set_config` | `temperature 0.3, topP 0.85` | 无 `penalty` |
|
||||
| MLX(兜底) | `LLMSession.swift` `GenerateParameters` | `temperature 0.3, topP 0.85` | 无 `repetitionPenalty` |
|
||||
|
||||
关键细节(读 MNN 源码 `transformers/llm/engine/src/`):
|
||||
- `llmconfig.hpp`:`mixed_samplers` 默认 `{topK, tfs, typical, topP, min_p, temperature}` —— **不含 `penalty`**;
|
||||
`penalty` / `ngram_factor` 默认 `1.0`(=全关)。
|
||||
- `sampler.cpp` `configMixed`:只会把 `penalty`「**移到链首(如果存在)**」,**不会自动插入**。
|
||||
所以光设 `"penalty":1.1` 没用,必须把 `"penalty"` 显式写进 `mixed_samplers`。
|
||||
- `sampler.cpp` `stepPenalty`:`repetition_penalty` 对 logits 乘法惩罚;**n-gram 命中整段重复时惩罚直接升到 `max_penalty`** —— 这正是掐断「整行复读」最有效的开关。
|
||||
|
||||
**为什么低温反而更糟**:temperature 0.3 接近贪心,一旦吐出 `收缩压 (107 mmHg)\n`,
|
||||
最高概率的后续就是再吐一遍同样的行,无惩罚就永远出不来。
|
||||
|
||||
### 排查过程(可复用思路)
|
||||
1. 看现象先判定是「数据重复」还是「生成复读」—— 被截断成半行 `收缩` 说明是 token 级复读,不是数据。
|
||||
2. `grep -niE "penalty|temperature|top_?p|sampler" 康康/AI/` 一把定位两个后端的采样配置 → 都没 penalty。
|
||||
3. 不猜 MNN 配置键,直接读构建用的源码 `MNN_SRC=/Users/xuhuayong/apps/MNN-src`
|
||||
的 `llmconfig.hpp` / `sampler.cpp`,确认键名、默认值、`mixed_samplers` 不自动插 penalty。
|
||||
4. MLX 侧读 SPM checkout 的 `MLXLMCommon/Evaluate.swift`,确认 `GenerateParameters` 有
|
||||
`repetitionPenalty: Float?` + `repetitionContextSize: Int`。
|
||||
|
||||
### 修复
|
||||
- **MNN** `MNNLLMBridge.mm`:`set_config` 显式开重复惩罚 +
|
||||
把 `penalty` 放进 mixed 链首:
|
||||
```jsonc
|
||||
{
|
||||
"jinja":{"context":{"enable_thinking":false}},
|
||||
"sampler_type":"mixed",
|
||||
"mixed_samplers":["penalty","topK","topP","temperature"],
|
||||
"temperature":0.3,"topP":0.85,"topK":40,
|
||||
"penalty":1.1,"n_gram":8,"ngram_factor":1.05
|
||||
}
|
||||
```
|
||||
(注意:JSON merge-patch 对数组是**整体替换**,所以这里会覆盖掉默认 `mixed_samplers`,符合预期。)
|
||||
- **MLX** `LLMSession.swift`:`GenerateParameters(..., repetitionPenalty: 1.1, repetitionContextSize: 64)`。
|
||||
|
||||
取值都偏保守:`penalty 1.1` / `ngram_factor 1.05` 是业界常用档(MNN 自带 omni 默认 1.05),
|
||||
低温 + 轻惩罚既能掐复读,又不破坏 JSON / 结构化输出的稳定性。
|
||||
|
||||
### 验证
|
||||
- `xcodebuild ... -destination generic/platform=iOS` 编译通过(两个后端均编进)。
|
||||
- ⚠️ **真机/模拟器跑一遍多轮导出生成报告**,确认不再复读 —— 复读属推理期行为,单测覆盖不到,必须实跑。
|
||||
|
||||
### 预防 / 相关注意
|
||||
- 任何新增的「长文本生成」(非 JSON 抽取)都走同一套带惩罚的采样参数,别再裸 temperature。
|
||||
- **相关隐患(未修,留观)**:`HealthExportService.retrieveDialogueSnapshot` 取指标时
|
||||
**没有 `prefix` 截断**(窗口检索版 `retrieve` 截了 `prefix(20)`)。指标极多时 prompt 会膨胀、
|
||||
也更易诱发复读。若复发,优先给 dialogue snapshot 也加上限。
|
||||
|
||||
---
|
||||
|
||||
> 以下几条据 W1–W2(2026-05~06)记忆补记,细节以代码/提交为准。
|
||||
|
||||
## 2026-06-09 · MNN 路径 Qwen3.5 强制思考,只吐 `<think>` / JSON 解析失败
|
||||
|
||||
### 现象
|
||||
MNN 真机路径上模型自检只显示 `<think>` 思考过程,AI 辅助拿不到 JSON(解析失败);
|
||||
同样的 prompt 走 MLX 兜底却正常。
|
||||
|
||||
### 根因
|
||||
模型自带 `config.json`(taobao-mnn 预转换件)写死 `"jinja":{"context":{"enable_thinking":true}}`,
|
||||
Qwen3.5 聊天模板据此每个 assistant 回合硬塞 `<think>\n` 开思考,吞掉 token 预算。
|
||||
**prompt 里的 `/no_think` 对 MNN 无效** —— 模板只读 `enable_thinking`,不看文本软开关。
|
||||
只在真机爆是因为 MLX 经 swift-transformers 套模板时不传 `enable_thinking` → 走 else 空 think 块,天然不思考。
|
||||
(这点从仓库代码看不出来,config.json 是下载/旁路导入的模型产物,不在 git 里。)
|
||||
|
||||
### 修复
|
||||
`MNNLLMBridge.mm` 在 `createLLM` 后、`load()` 前 merge-patch 关闭:
|
||||
`set_config("{\"jinja\":{\"context\":{\"enable_thinking\":false}}}")`。不改模型文件、不动字节校验。`stripThink` 保留兜底。
|
||||
|
||||
### 预防
|
||||
再遇 MNN 只出思考 / JSON 解析失败,先查 `config.json` 的 `enable_thinking`,别去调 `/no_think` 或加大预算。
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-07 · 「记录指标·拍照识别」VL 直读化验单不稳 → 改 Vision OCR + LLM
|
||||
|
||||
### 现象
|
||||
Qwen-VL 直读密集小字化验单经常返回 `{"indicators":[]}`(读不出指标)。
|
||||
|
||||
### 根因 / 决策
|
||||
小模型 VL 对密集中文小字不稳。改链路:`DocumentScanner 整页扫描 → Apple Vision OCR(zh-Hans/Hant/en)
|
||||
→ Qwen3 LLM 解析(VLPrompts.indicatorsFromText)→ stripThink → parseIndicatorsJSON → 确认页人工校对 → 存`。
|
||||
Vision OCR 是系统框架、100% 本地,不违反隐私红线。
|
||||
|
||||
### 预防
|
||||
这条路**不要改回 VL 直读**。VL 仍只用于「体检报告归档」整份解读,两者分开。OCR 行分组偶有错位,靠确认页人工校正兜底。
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-01 · git 全量 push 撞 HTTP 413(历史里有 165MB 构建产物)
|
||||
|
||||
### 现象
|
||||
`git push` 到 myv0(Gitea 反代有上传体积限制)报 **HTTP 413**。
|
||||
|
||||
### 根因
|
||||
旧 commit 误把 `build/` 构建产物提交进库(最大单文件 xcarchive DWARF **165MB**),后来虽 `git rm --cached` + `.gitignore`,
|
||||
但对象仍留在历史 → `.git` 87MB,全量 push 超反代上限。
|
||||
|
||||
### 修复
|
||||
对主仓库 `git filter-repo --path build/ --invert-paths --force` 从全历史剥离 → `.git` 87M→2.9M,不再 413。
|
||||
注意:① 重写了所有 commit hash(内容不变),旧克隆需重新 clone;② filter-repo 会移除所有 remote,事后须重新 `git remote add origin`;③ 凭证不写入 `.git/config`。
|
||||
|
||||
### 预防
|
||||
`build/` 必须在 `.gitignore`;别把构建产物 / 大二进制提交进库。
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-31 · 快拍 VL 识别时 App 自动退出(jetsam OOM,非崩溃)
|
||||
|
||||
### 现象
|
||||
iPhone 15 Pro Max 上 VL 识别时 App 直接退出。
|
||||
|
||||
### 根因
|
||||
不是代码崩溃(catch 只切 warning 屏,Swift 报错不会杀进程),是 **OS 内存超限 jetsam kill**。三因叠加:
|
||||
① 无 entitlement(8GB 设备默认单 App 上限 ~3GB,VL ~3GB 常驻冲过);② 从不卸载模型(LLM ~1GB + VL ~3GB 同驻 → 4GB+);③ 没设 MLX cache 上限。
|
||||
|
||||
### 修复
|
||||
① 新建 `康康.entitlements` 加 `com.apple.developer.kernel.increased-memory-limit=true`;
|
||||
② `AIRuntime` 加 `unloadLLM/unloadVL` 做**常驻互斥**(两大模型永不同时驻留)+ actor 内**串行推理闸门**(GPU 同一时刻只一个解码/加载);
|
||||
③ `GPU.set(cacheLimit: 256MB)`,启动调一次。
|
||||
|
||||
### 验证
|
||||
编译 + 单测通过。⚠️ **真机 OOM 是否真消失仍需 iPhone 15 Pro Max 实测**(本机无法跑真机)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-30 · 每次重打包 SwiftData 数据被清空
|
||||
|
||||
### 现象
|
||||
W2 期每次重新打包安装,本地数据全没了。
|
||||
|
||||
### 根因
|
||||
`KangkangApp.swift` 里 `ModelContainer` 创建失败的 catch 块原本**直接删 store 文件**。
|
||||
SwiftData 只对纯增量改动自动轻量迁移;一旦 schema 改动超纲(最常见:**给已存在 `@Model` 新增「非可选且无内联默认值」属性**)→ 迁移抛错 → 进 catch → 删库。
|
||||
|
||||
### 修复
|
||||
catch 改为把旧 store(含 `-wal`/`-shm`)挪到 `Application Support/StoreBackups/<时间戳>/` 再重建,不删除。
|
||||
|
||||
### 预防
|
||||
给已存在 `@Model` 加属性**一律给可选或内联默认值**(如 `var x: String = "daily"`),才走轻量迁移。正式发布前升级为 `VersionedSchema` + `SchemaMigrationPlan`。
|
||||
|
||||
---
|
||||
|
||||
## (无明确日期)· 编辑 Localizable.xcstrings 炸出上万行噪声 diff
|
||||
|
||||
### 现象
|
||||
改 `Localizable.xcstrings` 新增 3 个 key,却产生 ~15000 行 diff。
|
||||
|
||||
### 根因
|
||||
仓库里该文件是 **Xcode 规范格式**(`"key" : {` 冒号两侧带空格、2 空格缩进、key 按 Xcode 排序、结尾无换行);
|
||||
用 `python json.dump(indent=2)` 重写会把分隔符变成 `": "` 且顺序不同 → 几乎每行都 diff。
|
||||
|
||||
### 修复 / 正确做法
|
||||
基于 HEAD 原始文本做**文本插入**:把新 key 块按 Xcode 格式(` "<key>" : ` + `separators=(',', ' : ')` 的 value)拼到 strings 段末尾,保持结尾无换行。**不要整文件 json.dump 回写**。
|
||||
|
||||
---
|
||||
|
||||
## 附:命令行编译方式(排查时拿真实错误/警告)
|
||||
|
||||
- 系统默认是 Command Line Tools,裸 `xcodebuild` 不可用,需显式指向完整 Xcode:
|
||||
`export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`
|
||||
- **必须用独立 derivedDataPath**(如 `-derivedDataPath /tmp/kk-derived-xxx`),否则和 Xcode 抢同一把 `build.db` 锁报 `database is locked`(不是代码错)。
|
||||
- 增量编译会吞警告:要看某文件警告先 `touch` 它强制重编,再 grep `error:|warning:|BUILD (SUCCEEDED|FAILED)`。
|
||||
- 工程是 Swift 5 + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`;跨到 `nonisolated` 调 MainActor 成员的隔离警告(标 "error in Swift 6 mode")在 Swift 5 下不阻塞构建。
|
||||
|
||||
---
|
||||
|
||||
## 模板(复制下面这段新增条目)
|
||||
|
||||
```markdown
|
||||
## YYYY-MM-DD · 一句话标题
|
||||
|
||||
### 现象
|
||||
(用户看到什么 / 怎么触发)
|
||||
|
||||
### 根因(确认)
|
||||
(定位到的真正原因,不是猜测;贴关键文件:行)
|
||||
|
||||
### 排查过程
|
||||
(怎么一步步定位的,方便下次复用思路)
|
||||
|
||||
### 修复
|
||||
(改了什么,贴 diff 要点或配置)
|
||||
|
||||
### 验证
|
||||
(怎么确认修好了;不能单测的要写明需实跑)
|
||||
|
||||
### 预防 / 相关注意
|
||||
(怎么避免再犯;顺带发现的隐患)
|
||||
```
|
||||
51
scripts/build-mnn-xcframework.sh
Normal file
51
scripts/build-mnn-xcframework.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
# 构建 MNN.xcframework(device arm64 + simulator arm64),含 LLM 引擎 + SME2。
|
||||
# 产物输出到 康康/../Frameworks/MNN.xcframework(被 .gitignore,不入库,防历史膨胀)。
|
||||
#
|
||||
# 用法:
|
||||
# MNN_SRC=/path/to/MNN sh scripts/build-mnn-xcframework.sh
|
||||
# 需求:CMake 3.14+、Xcode、约 10-40 分钟。
|
||||
#
|
||||
# 关键 flag:
|
||||
# MNN_BUILD_LLM=ON —— 编入 llm 引擎(并导出 llm/llm.hpp),自动开 MNN_LOW_MEMORY
|
||||
# MNN_BUILD_LLM_OMNI=ON —— VL(图→文)所需:多模态 Omni + OpenCV 图像解码。
|
||||
# 统一模型(Qwen3.5-2B-MNN 一肩挑文本+视觉)必须开。
|
||||
# MNN_SME2=ON —— CMake 默认 ON,A19/iPhone17 运行时经 KleidiAI 自动启用,A17 回退 NEON
|
||||
# MNN_METAL=OFF —— 考核走 CPU+SME2,关 Metal 保持精简
|
||||
set -e
|
||||
|
||||
MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}"
|
||||
OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/Frameworks"
|
||||
TOOLCHAIN_NEW="${MNN_SRC}/cmake/ios.toolchain.new.cmake"
|
||||
EXTRA="-DMNN_BUILD_LLM=ON -DMNN_BUILD_LLM_OMNI=ON -DMNN_METAL=OFF -DMNN_ARM82=true -DMNN_SME2=ON"
|
||||
COMMON="-DCMAKE_BUILD_TYPE=Release -DENABLE_BITCODE=0 -DMNN_AAPL_FMWK=1 -DMNN_SEP_BUILD=0 -DMNN_BUILD_SHARED_LIBS=false -DMNN_USE_THREAD_POOL=OFF"
|
||||
|
||||
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
|
||||
cd "$MNN_SRC"
|
||||
|
||||
# 新版 ios-cmake toolchain(支持 SIMULATORARM64;MNN 自带的旧版只支持 x86_64 模拟器)
|
||||
if [ ! -f "$TOOLCHAIN_NEW" ]; then
|
||||
curl -sL "https://raw.githubusercontent.com/leetal/ios-cmake/master/ios.toolchain.cmake" -o "$TOOLCHAIN_NEW"
|
||||
fi
|
||||
|
||||
# device arm64
|
||||
rm -rf build-dev-arm64 && mkdir build-dev-arm64 && cd build-dev-arm64
|
||||
cmake .. $COMMON $EXTRA -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_NEW" -DPLATFORM=OS64 -DDEPLOYMENT_TARGET=17.0
|
||||
make MNN -j16
|
||||
cd ..
|
||||
|
||||
# simulator arm64
|
||||
rm -rf build-sim-arm64 && mkdir build-sim-arm64 && cd build-sim-arm64
|
||||
cmake .. $COMMON $EXTRA -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_NEW" -DPLATFORM=SIMULATORARM64 -DDEPLOYMENT_TARGET=17.0
|
||||
make MNN -j16
|
||||
cd ..
|
||||
|
||||
# 合成 xcframework
|
||||
rm -rf "$OUT_DIR/MNN.xcframework"
|
||||
mkdir -p "$OUT_DIR"
|
||||
xcrun xcodebuild -create-xcframework \
|
||||
-framework build-dev-arm64/MNN.framework \
|
||||
-framework build-sim-arm64/MNN.framework \
|
||||
-output "$OUT_DIR/MNN.xcframework"
|
||||
|
||||
echo "✅ 输出: $OUT_DIR/MNN.xcframework"
|
||||
@@ -10,6 +10,7 @@
|
||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
|
||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
|
||||
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; };
|
||||
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -33,6 +34,7 @@
|
||||
5E463CF92FC403BB0089145B /* 康康.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "康康.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5E463D082FC403BC0089145B /* 康康Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5E463D122FC403BC0089145B /* 康康UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FEEDFACE000000000000F001 /* MNN.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MNN.xcframework; path = Frameworks/MNN.xcframework; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -61,6 +63,7 @@
|
||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
|
||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
|
||||
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
|
||||
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -88,6 +91,7 @@
|
||||
5E463D0B2FC403BC0089145B /* 康康Tests */,
|
||||
5E463D152FC403BC0089145B /* 康康UITests */,
|
||||
5E463CFA2FC403BB0089145B /* Products */,
|
||||
FEEDFACE000000000000F001 /* MNN.xcframework */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -183,7 +187,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
LastUpgradeCheck = 2650;
|
||||
TargetAttributes = {
|
||||
5E463CF82FC403BB0089145B = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
@@ -211,7 +215,7 @@
|
||||
mainGroup = 5E463CF02FC403BB0089145B;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */,
|
||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
|
||||
@@ -292,6 +296,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -321,6 +326,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -344,6 +350,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -354,6 +361,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -383,6 +391,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
@@ -399,6 +408,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
@@ -411,11 +421,13 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "康康";
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
@@ -423,8 +435,10 @@
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -450,6 +464,7 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -465,11 +480,13 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "康康";
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
@@ -477,8 +494,10 @@
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -504,6 +523,7 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -517,6 +537,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -544,6 +565,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -570,6 +592,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -596,6 +619,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -659,12 +683,12 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */ = {
|
||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/ml-explore/mlx-swift-examples";
|
||||
repositoryURL = "https://github.com/ml-explore/mlx-swift-lm";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.29.1;
|
||||
kind = exactVersion;
|
||||
version = 2.31.3;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@@ -672,17 +696,17 @@
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
FEED000000000000DEAD0003 /* MLXLLM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||
productName = MLXLLM;
|
||||
};
|
||||
FEED000000000000DEAD0004 /* MLXLMCommon */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||
productName = MLXLMCommon;
|
||||
};
|
||||
FEED000000000000DEAD0006 /* MLXVLM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||
productName = MLXVLM;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"originHash" : "6b8265ebd61c6fdfca835dd1f90f17439ca9abc5c11a8b7b5db8790be0349e4d",
|
||||
"originHash" : "facc0ac7c70363ea20f6cd1235de91dea6b06f0d00190946045a6c8ae753abc2",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "gzipswift",
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/1024jp/GzipSwift",
|
||||
"location" : "https://github.com/mattt/EventSource.git",
|
||||
"state" : {
|
||||
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05",
|
||||
"version" : "6.0.1"
|
||||
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
|
||||
"version" : "1.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -15,17 +15,35 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ml-explore/mlx-swift",
|
||||
"state" : {
|
||||
"revision" : "072b684acaae80b6a463abab3a103732f33774bf",
|
||||
"version" : "0.29.1"
|
||||
"revision" : "dc43e62d7055353c7f99fa071a4e71d29dfddc44",
|
||||
"version" : "0.31.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mlx-swift-examples",
|
||||
"identity" : "mlx-swift-lm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ml-explore/mlx-swift-examples",
|
||||
"location" : "https://github.com/ml-explore/mlx-swift-lm",
|
||||
"state" : {
|
||||
"revision" : "9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631",
|
||||
"version" : "2.29.1"
|
||||
"revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff",
|
||||
"version" : "2.31.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
|
||||
"version" : "1.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -37,6 +55,24 @@
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
|
||||
"version" : "4.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-huggingface",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-huggingface.git",
|
||||
"state" : {
|
||||
"revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-jinja",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -46,6 +82,15 @@
|
||||
"version" : "2.3.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95",
|
||||
"version" : "2.100.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -55,13 +100,31 @@
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "669763cfd5806a67e21972d7e5e2d6b80b1ea985",
|
||||
"version" : "1.6.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-transformers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-transformers",
|
||||
"state" : {
|
||||
"revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2",
|
||||
"version" : "1.0.0"
|
||||
"revision" : "58c4bc11963a140358d791f678a60a2745a23146",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "yyjson",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ibireme/yyjson.git",
|
||||
"state" : {
|
||||
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
|
||||
"version" : "0.12.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -15,6 +15,13 @@ enum AIRuntimeError: Error, LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
/// 推理优先级。interactive = 用户正在屏幕前等(识别/问答/自检);
|
||||
/// background = 预生成(报告摘要等),排队让行、解码中可被协作式抢占。
|
||||
nonisolated enum InferencePriority: Sendable, Equatable {
|
||||
case interactive
|
||||
case background
|
||||
}
|
||||
|
||||
actor AIRuntime {
|
||||
static let shared = AIRuntime()
|
||||
|
||||
@@ -29,9 +36,34 @@ actor AIRuntime {
|
||||
private(set) var vlStatus: Status = .notReady
|
||||
private(set) var lastDecodeRate: Double = 0
|
||||
|
||||
/// 末次文本生成的性能统计(性能自检页消费;两后端归一)。
|
||||
private(set) var lastGenerateStats: GenerateStats?
|
||||
|
||||
/// 当前实际生效的后端标签(性能自检 / PPT 截图用)。
|
||||
var activeBackendLabel: String {
|
||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||
return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · NEON"
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
return "MLX · CPU(模拟器)"
|
||||
#else
|
||||
return "MLX · GPU"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var llmSession: LLMSession?
|
||||
private var vlSession: VLSession?
|
||||
|
||||
// MARK: - MNN 后端(CPU/SME2,挑战赛考核路径)
|
||||
// .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型全包(已实测)。
|
||||
// 模拟器无 MNN,VL 回退 MLX 的 Qwen3-VL-4B。
|
||||
private let mnn = MNNBackend()
|
||||
private(set) var mnnStatus: Status = .notReady
|
||||
/// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-2B-MNN)。
|
||||
nonisolated static var mnnModelFolder: URL {
|
||||
ModelStore.shared.localURL(for: .mnnLLM)
|
||||
}
|
||||
|
||||
// MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地)
|
||||
//
|
||||
// actor 只串行化「方法入口」,但 generate() 同步返回流、真正解码在内部 Task;
|
||||
@@ -42,30 +74,56 @@ actor AIRuntime {
|
||||
// 这里用 actor 内信号量(count = 1):所有「会占显存的重活」(解码 + 模型加载)
|
||||
// 进入前先 await acquireGate(),结束后 releaseGate()。actor 串行执行保证
|
||||
// gateBusy / gateWaiters 的读写天然无并发。
|
||||
private struct GateWaiter {
|
||||
let priority: InferencePriority
|
||||
let cont: CheckedContinuation<Void, Never>
|
||||
}
|
||||
private var gateBusy = false
|
||||
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var gateHolderPriority: InferencePriority = .interactive
|
||||
private var preemptRequested = false
|
||||
private var gateWaiters: [GateWaiter] = []
|
||||
|
||||
private func acquireGate() async {
|
||||
/// interactive 排到所有 background 等待者之前;同优先级保持 FIFO。纯函数,单测覆盖。
|
||||
nonisolated static func gateInsertionIndex(of priority: InferencePriority,
|
||||
in waiting: [InferencePriority]) -> Int {
|
||||
guard priority == .interactive else { return waiting.count }
|
||||
return waiting.firstIndex(of: .background) ?? waiting.count
|
||||
}
|
||||
|
||||
private func acquireGate(_ priority: InferencePriority = .interactive) async {
|
||||
if !gateBusy {
|
||||
gateBusy = true
|
||||
gateHolderPriority = priority
|
||||
return
|
||||
}
|
||||
// 前台请求撞上后台持有者:请其让位 —— 后台解码循环在下一个 token 抛 CancellationError。
|
||||
if priority == .interactive, gateHolderPriority == .background {
|
||||
preemptRequested = true
|
||||
}
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||
gateWaiters.append(cont)
|
||||
let idx = Self.gateInsertionIndex(of: priority, in: gateWaiters.map(\.priority))
|
||||
gateWaiters.insert(GateWaiter(priority: priority, cont: cont), at: idx)
|
||||
}
|
||||
// 被 releaseGate 唤醒时即已持有闸门(gateBusy 保持 true)。
|
||||
}
|
||||
|
||||
private func releaseGate() {
|
||||
preemptRequested = false
|
||||
if gateWaiters.isEmpty {
|
||||
gateBusy = false
|
||||
} else {
|
||||
// 把闸门直接交给队首等待者,gateBusy 维持 true,不留空窗。
|
||||
let next = gateWaiters.removeFirst()
|
||||
next.resume()
|
||||
gateHolderPriority = next.priority
|
||||
next.cont.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台持有者每收到一个 token 查一次:前台在排队就让位。
|
||||
private func shouldPreempt(_ priority: InferencePriority) -> Bool {
|
||||
priority == .background && preemptRequested
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
/// App 启动时调用一次:给 MLX 的 GPU 缓冲池设上限,避免 reuse cache 在大模型常驻之上
|
||||
@@ -74,12 +132,22 @@ actor AIRuntime {
|
||||
nonisolated static func configureMLXMemory() {
|
||||
#if !targetEnvironment(simulator)
|
||||
// 256MB cache 上限:够复用、不至于在 3GB 模型之上再囤几百 MB 空闲缓冲。
|
||||
MLX.GPU.set(cacheLimit: 256 * 1024 * 1024)
|
||||
MLX.Memory.cacheLimit = 256 * 1024 * 1024
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 加载模型。首次调用会真正加载,后续幂等。
|
||||
/// 加载文本模型。首次调用会真正加载,后续幂等。
|
||||
/// 按当前引擎路由:.mnn → MNN(CPU/SME2);.mlx → 现有 MLX(GPU)。
|
||||
func prepare() async throws {
|
||||
// 选了 MNN 且模型已就绪才走 MNN;否则(选 MLX,或 MNN 模型尚未下载)回退 MLX,
|
||||
// 保证过渡期 App 始终可用。引擎指示器(Phase 5)展示实际生效后端。
|
||||
let mnnReady = ModelStore.shared.isComplete(for: .mnnLLM)
|
||||
if InferenceEngine.current == .mnn, mnnReady {
|
||||
try await prepareMNN()
|
||||
return
|
||||
}
|
||||
// 走 MLX:先卸 MNN 释放内存(单模型常驻策略)。
|
||||
await unloadMNN()
|
||||
// 已有其他调用方在加载时,轮询等其结束再判定结果。
|
||||
// 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的
|
||||
// `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。
|
||||
@@ -119,9 +187,54 @@ actor AIRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载 MNN 文本模型。幂等。单模型常驻:载入前卸掉 MLX 的 LLM/VL 释放内存。
|
||||
private func prepareMNN() async throws {
|
||||
while mnnStatus == .loading {
|
||||
try await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
if mnnStatus == .ready { return }
|
||||
|
||||
let folder = Self.mnnModelFolder
|
||||
guard ModelStore.shared.isComplete(for: .mnnLLM) else {
|
||||
mnnStatus = .error("MNN 模型未就绪")
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
if mnnStatus == .ready { return }
|
||||
|
||||
// 单模型常驻:卸 MLX LLM/VL,避免与 MNN 模型叠加占内存。
|
||||
unloadLLM()
|
||||
unloadVL()
|
||||
|
||||
mnnStatus = .loading
|
||||
do {
|
||||
try await mnn.load(folderURL: folder)
|
||||
mnnStatus = .ready
|
||||
} catch {
|
||||
mnnStatus = .error("\(error)")
|
||||
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 卸载 MNN,释放桥与权重。幂等。
|
||||
private func unloadMNN() async {
|
||||
guard mnnStatus != .notReady else { return }
|
||||
await mnn.unload()
|
||||
mnnStatus = .notReady
|
||||
MLX.Memory.clearCache()
|
||||
}
|
||||
|
||||
/// 流式生成。调用前应先 await prepare()。
|
||||
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
||||
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
/// priority = .background 时排队让行、解码中可被前台请求按 token 抢占(CancellationError 透传)。
|
||||
func generate(prompt: String,
|
||||
maxTokens: Int = 256,
|
||||
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||
return mnnGenerate(prompt: prompt, maxTokens: maxTokens, priority: priority)
|
||||
}
|
||||
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
||||
let snapshotStatus = status
|
||||
let snapshotSession = llmSession
|
||||
@@ -133,7 +246,7 @@ actor AIRuntime {
|
||||
return
|
||||
}
|
||||
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
||||
await self.acquireGate()
|
||||
await self.acquireGate(priority)
|
||||
do {
|
||||
// session.generate 跨 actor 边界,需要 await
|
||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
@@ -141,12 +254,18 @@ actor AIRuntime {
|
||||
// 消费者(UI)提前关闭/取消时,下面的 checkCancellation 让本 Task 尽快退出,
|
||||
// 连带丢弃 session 流并触发其 onTermination,停止底层 MLX 解码,不空耗 GPU。
|
||||
try Task.checkCancellation()
|
||||
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出。
|
||||
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
||||
// 调用同 actor 的 recordRate 不需要 await
|
||||
self.recordRate(chunk.decodeRate)
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
self.lastGenerateStats = await session.lastStats
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
// 取消/抢占以 CancellationError 透传,调用方据此区分「让位」与「真失败」。
|
||||
continuation.finish(throwing: CancellationError())
|
||||
} catch {
|
||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||
}
|
||||
@@ -159,6 +278,41 @@ actor AIRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// MNN(CPU/SME2)文本流式生成。结构与 MLX 分支一致:进闸门、串行解码、记录速率。
|
||||
private func mnnGenerate(prompt: String,
|
||||
maxTokens: Int,
|
||||
priority: InferencePriority) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
let ready = (mnnStatus == .ready)
|
||||
return AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
guard ready else {
|
||||
continuation.finish(throwing: AIRuntimeError.notReady)
|
||||
return
|
||||
}
|
||||
await self.acquireGate(priority)
|
||||
do {
|
||||
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
for try await chunk in stream {
|
||||
try Task.checkCancellation()
|
||||
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出
|
||||
//(流终止触发 MNNBackend.onTermination → bridge.cancel())。
|
||||
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||
self.recordRate(chunk.decodeRate)
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
self.lastGenerateStats = await self.mnn.lastStats
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
continuation.finish(throwing: CancellationError())
|
||||
} catch {
|
||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||
}
|
||||
self.releaseGate()
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private func recordRate(_ rate: Double) {
|
||||
if rate > 0 { lastDecodeRate = rate }
|
||||
}
|
||||
@@ -167,30 +321,37 @@ actor AIRuntime {
|
||||
|
||||
/// 加载 VL 模型。幂等,首调真正 load。
|
||||
func prepareVL() async throws {
|
||||
// 选了 MNN 且多模态模型就绪:VL 复用同一个 MNN 模型(文本+视觉一肩挑),走 prepareMNN。
|
||||
if InferenceEngine.current == .mnn, ModelStore.shared.isComplete(for: .mnnLLM) {
|
||||
try await prepareMNN()
|
||||
return
|
||||
}
|
||||
while vlStatus == .loading {
|
||||
try await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
if vlStatus == .ready { return }
|
||||
|
||||
// 同 prepare():用 isComplete 排除半下载(避免在残缺权重上崩溃),与下载服务判据一致。
|
||||
guard ModelStore.shared.isComplete(for: .vl) else {
|
||||
// MLX VL 改用 .llm 的 Qwen3.5-2B 多模态(VLMModelFactory 走 qwen3_5 视觉路径),
|
||||
// 不再单独需要 Qwen3-VL-4B。用 isComplete 排除半下载,与下载服务判据一致。
|
||||
guard ModelStore.shared.isComplete(for: .llm) else {
|
||||
vlStatus = .error("VL 模型未就绪")
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
// 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。
|
||||
// —— 这正是「异常项快拍识别时 App 自动退出」的主因防护。
|
||||
// —— 这正是「指标速记识别时 App 自动退出」的主因防护。
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
if vlStatus == .ready { return }
|
||||
|
||||
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。
|
||||
unloadLLM()
|
||||
await unloadMNN()
|
||||
|
||||
vlStatus = .loading
|
||||
do {
|
||||
let session = try await VLSession.load(
|
||||
folderURL: ModelStore.shared.localURL(for: .vl)
|
||||
folderURL: ModelStore.shared.localURL(for: .llm)
|
||||
)
|
||||
self.vlSession = session
|
||||
vlStatus = .ready
|
||||
@@ -208,7 +369,7 @@ actor AIRuntime {
|
||||
guard llmSession != nil else { return }
|
||||
llmSession = nil
|
||||
status = .notReady
|
||||
MLX.GPU.clearCache()
|
||||
MLX.Memory.clearCache()
|
||||
}
|
||||
|
||||
/// 卸载 VL,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。
|
||||
@@ -216,7 +377,7 @@ actor AIRuntime {
|
||||
guard vlSession != nil else { return }
|
||||
vlSession = nil
|
||||
vlStatus = .notReady
|
||||
MLX.GPU.clearCache()
|
||||
MLX.Memory.clearCache()
|
||||
}
|
||||
|
||||
/// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。
|
||||
@@ -225,6 +386,16 @@ actor AIRuntime {
|
||||
func analyzeReport(imageURLs: [URL],
|
||||
prompt: String,
|
||||
maxTokens: Int = 512) async throws -> String {
|
||||
// 选了 MNN 且就绪:图→文走同一个 MNN 多模态模型。
|
||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
do {
|
||||
return try await mnn.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
|
||||
} catch {
|
||||
throw AIRuntimeError.inferenceFailed("\(error)")
|
||||
}
|
||||
}
|
||||
guard vlStatus == .ready, let session = vlSession else {
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
19
康康/AI/GenerateStats.swift
Normal file
19
康康/AI/GenerateStats.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// 单次生成的性能统计,两后端(MNN / MLX)归一。
|
||||
/// MNN 取自 LlmContext(prefill_us / decode_us);MLX 取自 GenerateCompletionInfo。
|
||||
struct GenerateStats: Sendable, Equatable {
|
||||
var promptTokens: Int
|
||||
var genTokens: Int
|
||||
/// prefill(读入 prompt)耗时,秒。
|
||||
var prefillSeconds: Double
|
||||
/// decode(逐 token 生成)耗时,秒。
|
||||
var decodeSeconds: Double
|
||||
|
||||
var prefillTokensPerSecond: Double {
|
||||
prefillSeconds > 0 ? Double(promptTokens) / prefillSeconds : 0
|
||||
}
|
||||
var decodeTokensPerSecond: Double {
|
||||
decodeSeconds > 0 ? Double(genTokens) / decodeSeconds : 0
|
||||
}
|
||||
}
|
||||
77
康康/AI/InferenceEngine.swift
Normal file
77
康康/AI/InferenceEngine.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
/// 端侧推理引擎选择。
|
||||
/// - mnn:Qwen + MNN + SME2(CPU),挑战赛考核路径,真机默认。
|
||||
/// - mlx:Qwen + MLX(Metal GPU),兜底 / 对照。模拟器只有它可用。
|
||||
nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
|
||||
case mnn
|
||||
case mlx
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mnn: return "MNN · CPU/SME2"
|
||||
case .mlx: return "MLX · GPU"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本构建/设备是否可用。MNN 仅 device 切片有真实内核,模拟器回退 MLX。
|
||||
var isAvailable: Bool {
|
||||
switch self {
|
||||
case .mlx: return true
|
||||
case .mnn: return MNNLLMBridge.isAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 持久化(UserDefaults,跨 actor 安全)
|
||||
|
||||
private static let key = "kk.inferenceEngine"
|
||||
|
||||
/// 由偏好(可能是 .auto)解析出的、本次调用实际使用的具体引擎。
|
||||
/// AIRuntime / MeView 等消费方只看这个,永远拿到 .mnn 或 .mlx。
|
||||
/// 解析后仍做一次可用性兜底,保证总有可用引擎。
|
||||
static var current: InferenceEngine {
|
||||
let resolved = preference.resolved
|
||||
return resolved.isAvailable ? resolved : .mlx
|
||||
}
|
||||
|
||||
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
||||
static var cpuSupportsSME2: Bool { MNNLLMBridge.cpuSupportsSME2() }
|
||||
|
||||
// MARK: - 用户偏好(auto / mnn / mlx)
|
||||
|
||||
/// 用户在设置页的选择。默认 .auto:按本机配置自动择优。
|
||||
/// 与具体引擎共用同一 UserDefaults key——历史写入的 "mnn"/"mlx" 仍兼容。
|
||||
static var preference: EnginePreference {
|
||||
get {
|
||||
let raw = UserDefaults.standard.string(forKey: key)
|
||||
return raw.flatMap(EnginePreference.init(rawValue:)) ?? .auto
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 推理引擎的「用户偏好」,比具体引擎多一个 .auto。
|
||||
/// - auto:按本机配置自动选——真机优先 MNN(考核路径,含 SME2/NEON),
|
||||
/// MNN 不可用(模拟器)时回退 MLX。
|
||||
nonisolated enum EnginePreference: String, CaseIterable, Sendable {
|
||||
case auto
|
||||
case mnn
|
||||
case mlx
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .auto: return "自动"
|
||||
case .mnn: return InferenceEngine.mnn.displayName
|
||||
case .mlx: return InferenceEngine.mlx.displayName
|
||||
}
|
||||
}
|
||||
|
||||
/// 把偏好解析成具体引擎(不做可用性兜底,那一步留给 `InferenceEngine.current`)。
|
||||
var resolved: InferenceEngine {
|
||||
switch self {
|
||||
case .mnn: return .mnn
|
||||
case .mlx: return .mlx
|
||||
case .auto: return InferenceEngine.mnn.isAvailable ? .mnn : .mlx
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import MLXLMCommon
|
||||
actor LLMSession {
|
||||
let container: ModelContainer
|
||||
|
||||
/// 末次生成统计(取自流末尾的 .info 完成事件,性能自检用)。
|
||||
private(set) var lastStats: GenerateStats?
|
||||
|
||||
private func record(_ s: GenerateStats) { lastStats = s }
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.container = container
|
||||
}
|
||||
@@ -45,10 +50,16 @@ actor LLMSession {
|
||||
let task = Task {
|
||||
do {
|
||||
try await Self.withDeviceOverride {
|
||||
// 低温:本 App 文本任务多为"直答/JSON 抽取",高温随机性会经常吐成非 JSON。
|
||||
// 0.3 + topP 0.85 让输出更确定、JSON 更稳(与 MNN set_config 降温对齐)。
|
||||
// repetitionPenalty:低温 + 无惩罚时,长文本(如「关键指标」列表)会逐行复读
|
||||
// 进入死循环;1.1 的重复惩罚 + 64 token 上下文窗口掐断复读(与 MNN penalty 对齐)。
|
||||
let parameters = GenerateParameters(
|
||||
maxTokens: maxTokens,
|
||||
temperature: Float(0.6),
|
||||
topP: Float(0.9)
|
||||
temperature: Float(0.3),
|
||||
topP: Float(0.85),
|
||||
repetitionPenalty: Float(1.1),
|
||||
repetitionContextSize: 64
|
||||
)
|
||||
|
||||
try await container.perform { (context: ModelContext) in
|
||||
@@ -72,9 +83,14 @@ actor LLMSession {
|
||||
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||
|
||||
case .info:
|
||||
case .info(let info):
|
||||
// 生成完成统计,是流的最后一个事件
|
||||
break
|
||||
await self.record(GenerateStats(
|
||||
promptTokens: info.promptTokenCount,
|
||||
genTokens: info.generationTokenCount,
|
||||
prefillSeconds: info.promptTime,
|
||||
decodeSeconds: info.generateTime
|
||||
))
|
||||
|
||||
case .toolCall:
|
||||
// 纯文本生成不会触发,switch 穷举
|
||||
|
||||
55
康康/AI/MNN/MNNLLMBridge.h
Normal file
55
康康/AI/MNN/MNNLLMBridge.h
Normal file
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// MNNLLMBridge.h
|
||||
// 康康
|
||||
//
|
||||
// Objective-C 接口,封装 MNN-LLM(Qwen)的加载与流式推理。
|
||||
// 真实实现在 .mm 中以 ObjC++ 调用 <MNN/llm/llm.hpp>;模拟器下编为可用性返回 NO 的桩
|
||||
// (MNN.framework 仅 device arm64 切片有真实 CPU/SME2 内核,模拟器走 MLX 兜底)。
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 末次生成的性能统计(取自 MNN LlmContext)。
|
||||
@interface MNNGenerateStats : NSObject
|
||||
@property (nonatomic, readonly) int promptTokens;
|
||||
@property (nonatomic, readonly) int genTokens;
|
||||
@property (nonatomic, readonly) double prefillMs;
|
||||
@property (nonatomic, readonly) double decodeMs;
|
||||
/// 解码速率 tok/s = genTokens / (decodeMs/1000)。demo 卖点 #6 / Live Activity 用。
|
||||
@property (nonatomic, readonly) double decodeTokensPerSecond;
|
||||
@end
|
||||
|
||||
@interface MNNLLMBridge : NSObject
|
||||
|
||||
/// 本构建是否含真实 MNN 运行时(device=YES,simulator 桩=NO)。
|
||||
+ (BOOL)isAvailable;
|
||||
/// CPU 是否支持 SME2(运行时探测);A19/iPhone17 YES,A17/iPhone15Pro NO。仅用于 UI 展示加速状态。
|
||||
+ (BOOL)cpuSupportsSME2;
|
||||
|
||||
/// 用 MNN llm 的 config.json 路径加载模型(目录含 llm.mnn / 权重 / tokenizer)。失败返回 nil。
|
||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isLoaded;
|
||||
|
||||
/// 纯文本流式生成。onToken 每解码出一段文本回调一次(在调用线程,同步阻塞直到生成结束)。
|
||||
/// 返回末次统计。
|
||||
- (MNNGenerateStats *)generateText:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *piece))onToken;
|
||||
|
||||
/// 图→文(VL,需 MNN_BUILD_LLM_OMNI 构建)。imagePaths 为本地文件路径。
|
||||
/// 当前文本构建未含 OMNI 时返回 nil 并置 error。
|
||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
|
||||
prompt:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *piece))onToken
|
||||
error:(NSError *_Nullable *_Nullable)error;
|
||||
|
||||
/// 请求取消当前生成(best-effort:置标志,后续 token 不再回调)。
|
||||
- (void)cancel;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
210
康康/AI/MNN/MNNLLMBridge.mm
Normal file
210
康康/AI/MNN/MNNLLMBridge.mm
Normal file
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// MNNLLMBridge.mm
|
||||
// 康康
|
||||
//
|
||||
// ObjC++ 实现。device 真机用 <MNN/llm/llm.hpp>;模拟器编为桩(返回不可用,上层回退 MLX)。
|
||||
//
|
||||
|
||||
#import "MNNLLMBridge.h"
|
||||
#include <sys/sysctl.h>
|
||||
|
||||
// MARK: - 性能统计(私有 readwrite 重声明)
|
||||
@interface MNNGenerateStats ()
|
||||
@property (nonatomic, readwrite) int promptTokens;
|
||||
@property (nonatomic, readwrite) int genTokens;
|
||||
@property (nonatomic, readwrite) double prefillMs;
|
||||
@property (nonatomic, readwrite) double decodeMs;
|
||||
@end
|
||||
|
||||
@implementation MNNGenerateStats
|
||||
- (double)decodeTokensPerSecond {
|
||||
return self.decodeMs > 0 ? (self.genTokens / (self.decodeMs / 1000.0)) : 0;
|
||||
}
|
||||
@end
|
||||
|
||||
// MARK: - SME2 / 可用性探测(device + simulator 都可编)
|
||||
|
||||
static BOOL kk_sysctlFlag(const char *name) {
|
||||
int64_t v = 0; size_t sz = sizeof(v);
|
||||
if (sysctlbyname(name, &v, &sz, NULL, 0) != 0) return NO;
|
||||
return v != 0;
|
||||
}
|
||||
|
||||
#if TARGET_OS_SIMULATOR
|
||||
|
||||
// ============ 模拟器桩:无真实 MNN ============
|
||||
@implementation MNNLLMBridge
|
||||
+ (BOOL)isAvailable { return NO; }
|
||||
+ (BOOL)cpuSupportsSME2 { return NO; }
|
||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath { return nil; }
|
||||
- (BOOL)isLoaded { return NO; }
|
||||
- (MNNGenerateStats *)generateText:(NSString *)prompt maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *))onToken { return [MNNGenerateStats new]; }
|
||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths prompt:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens onToken:(void (^)(NSString *))onToken
|
||||
error:(NSError **)error {
|
||||
if (error) *error = [NSError errorWithDomain:@"MNN" code:-1
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"MNN 在模拟器不可用"}];
|
||||
return nil;
|
||||
}
|
||||
- (void)cancel {}
|
||||
@end
|
||||
|
||||
#else
|
||||
|
||||
// ============ 真机:真实 MNN-LLM ============
|
||||
// MNN 第三方头文件的文档注释不规范,会触发一堆 -Wdocumentation 警告(Executor/
|
||||
// Tensor/Interpreter/ImageProcess.hpp)。只在解析 MNN 头时关掉该警告,不影响本项目。
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdocumentation"
|
||||
#include <MNN/llm/llm.hpp>
|
||||
#pragma clang diagnostic pop
|
||||
#include <string>
|
||||
#include <ostream>
|
||||
#include <streambuf>
|
||||
#include <atomic>
|
||||
|
||||
using MNN::Transformer::Llm;
|
||||
|
||||
namespace {
|
||||
/// 把 MNN 写入 ostream 的解码文本转成 NSString 回调;按 UTF-8 完整边界聚合,避免截断多字节。
|
||||
class TokenStreamBuf : public std::streambuf {
|
||||
public:
|
||||
TokenStreamBuf(void (^onToken)(NSString *), std::atomic<bool> *cancel)
|
||||
: _onToken(onToken), _cancel(cancel) {}
|
||||
void flush() {
|
||||
if (_pending.empty()) return;
|
||||
emitPending(); // 末尾尽力 emit(即便非完整 UTF-8 也交出去)
|
||||
_pending.clear();
|
||||
}
|
||||
protected:
|
||||
std::streamsize xsputn(const char *s, std::streamsize n) override {
|
||||
append(s, (size_t)n);
|
||||
return n;
|
||||
}
|
||||
int overflow(int c) override {
|
||||
if (c != EOF) { char ch = (char)c; append(&ch, 1); }
|
||||
return c;
|
||||
}
|
||||
private:
|
||||
void append(const char *s, size_t n) {
|
||||
if (_cancel && _cancel->load()) return; // 已取消,吞掉不回调
|
||||
_pending.append(s, n);
|
||||
// 仅当整个 pending 是合法 UTF-8 才 emit(token 通常是完整字/词,边界自然对齐)
|
||||
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
|
||||
length:_pending.size()
|
||||
encoding:NSUTF8StringEncoding];
|
||||
if (str) { if (_onToken) _onToken(str); _pending.clear(); }
|
||||
}
|
||||
void emitPending() {
|
||||
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
|
||||
length:_pending.size()
|
||||
encoding:NSUTF8StringEncoding];
|
||||
if (str && _onToken) _onToken(str);
|
||||
}
|
||||
void (^_onToken)(NSString *);
|
||||
std::atomic<bool> *_cancel;
|
||||
std::string _pending;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
@implementation MNNLLMBridge {
|
||||
Llm *_llm;
|
||||
std::atomic<bool> _cancel;
|
||||
BOOL _loaded;
|
||||
}
|
||||
|
||||
+ (BOOL)isAvailable { return YES; }
|
||||
|
||||
+ (BOOL)cpuSupportsSME2 {
|
||||
// Apple 通过 sysctl 暴露 ARM 特性位:FEAT_SME2(A19/iPhone17+)。
|
||||
return kk_sysctlFlag("hw.optional.arm.FEAT_SME2");
|
||||
}
|
||||
|
||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath {
|
||||
self = [super init];
|
||||
if (!self) return nil;
|
||||
_cancel = false;
|
||||
_llm = Llm::createLLM(std::string(configPath.UTF8String));
|
||||
if (_llm == nullptr) return nil;
|
||||
// load 前以 merge-patch 调三件事(只翻这几个叶子,保留 chat_template 等其余配置):
|
||||
// ① enable_thinking=false:config.json 默认 true,模板会给每个 assistant 回合硬塞
|
||||
// <think>\n 开启思考,吞掉 token 预算并污染 JSON(prompt 里的 /no_think 对此模板无效)。
|
||||
// ② 降温:config.json 默认 temperature=1.0 对结构化 JSON 太高,随机性大→经常吐成非 JSON。
|
||||
// 本 App 所有任务都是"直答/JSON",压到 0.3 + topP 0.85 让输出更确定、JSON 更稳。
|
||||
// ③ 重复惩罚:MNN 默认 mixed_samplers 不含 "penalty"、penalty/ngram_factor=1.0(全关),
|
||||
// 叠加低温 → 长文本(如「关键指标」列表)会陷入逐行复读死循环(收缩压 107 mmHg ×N)。
|
||||
// 显式把 "penalty" 放进 mixed 链首,开 repetition penalty(1.1)+ n-gram 惩罚(ngram_factor 1.05):
|
||||
// n-gram 命中整段重复时惩罚升到 max_penalty,直接掐断逐行复读。
|
||||
_llm->set_config("{"
|
||||
"\"jinja\":{\"context\":{\"enable_thinking\":false}},"
|
||||
"\"sampler_type\":\"mixed\","
|
||||
"\"mixed_samplers\":[\"penalty\",\"topK\",\"topP\",\"temperature\"],"
|
||||
"\"temperature\":0.3,\"topP\":0.85,\"topK\":40,"
|
||||
"\"penalty\":1.1,\"n_gram\":8,\"ngram_factor\":1.05"
|
||||
"}");
|
||||
_loaded = _llm->load();
|
||||
if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; }
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_llm) { Llm::destroy(_llm); _llm = nullptr; }
|
||||
}
|
||||
|
||||
- (BOOL)isLoaded { return _loaded; }
|
||||
|
||||
- (void)cancel { _cancel = true; }
|
||||
|
||||
// 统一生成:full 已是最终 prompt(文本,或含 <img>路径</img> 标签)。
|
||||
// 多模态模型 createLLM 返回 Omni,response 解析 <img> 标签并对路径 CV::imread(OMNI 框架内)。
|
||||
- (MNNGenerateStats *)runResponse:(NSString *)full
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *))onToken {
|
||||
_cancel = false;
|
||||
TokenStreamBuf buf(onToken, &_cancel);
|
||||
std::ostream os(&buf);
|
||||
if (_llm) {
|
||||
_llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens);
|
||||
}
|
||||
buf.flush();
|
||||
return [self statsFromContext];
|
||||
}
|
||||
|
||||
- (MNNGenerateStats *)generateText:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *))onToken {
|
||||
return [self runResponse:prompt maxTokens:maxTokens onToken:onToken];
|
||||
}
|
||||
|
||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
|
||||
prompt:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *))onToken
|
||||
error:(NSError **)error {
|
||||
// 在 prompt 前拼 <img>本地路径</img>;Omni 解析标签并对路径 imread(需 OMNI 框架)。
|
||||
NSMutableString *full = [NSMutableString string];
|
||||
for (NSString *p in imagePaths) {
|
||||
[full appendFormat:@"<img>%@</img>", p];
|
||||
}
|
||||
[full appendString:prompt];
|
||||
return [self runResponse:full maxTokens:maxTokens onToken:onToken];
|
||||
}
|
||||
|
||||
- (MNNGenerateStats *)statsFromContext {
|
||||
MNNGenerateStats *s = [MNNGenerateStats new];
|
||||
if (_llm) {
|
||||
const MNN::Transformer::LlmContext *ctx = _llm->getContext();
|
||||
if (ctx) {
|
||||
s.promptTokens = ctx->prompt_len;
|
||||
s.genTokens = ctx->gen_seq_len;
|
||||
s.prefillMs = ctx->prefill_us / 1000.0;
|
||||
s.decodeMs = ctx->decode_us / 1000.0;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
113
康康/AI/MNNBackend.swift
Normal file
113
康康/AI/MNNBackend.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
|
||||
/// MNN(CPU / SME2)推理后端,封装 `MNNLLMBridge` 的文本流式生成。
|
||||
/// 与 `LLMSession`/`VLSession` 同款 actor 隔离;跨调用的串行化由上游 `AIRuntime` 闸门保证。
|
||||
///
|
||||
/// 文本与视觉(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型承担:`generate` 走文本,
|
||||
/// `analyze` 把图片拼成 <img> 标签交给 Omni 内核 imread 解码(需 OMNI 构建,xcframework 已含)。
|
||||
/// 已实测可用,真机走此单模型全包路径;模拟器无 MNN,VL 仍回退 MLX(见 `AIRuntime`)。
|
||||
actor MNNBackend {
|
||||
private var bridge: MNNLLMBridge?
|
||||
|
||||
/// 末次生成统计(供 AIRuntime 在流结束后取走,性能自检用)。
|
||||
private(set) var lastStats: GenerateStats?
|
||||
|
||||
private func record(_ s: GenerateStats) { lastStats = s }
|
||||
|
||||
var isLoaded: Bool { bridge?.isLoaded ?? false }
|
||||
|
||||
/// 从 MNN 模型目录加载(目录含 MNN llm 的 config.json + llm.mnn + 权重 + tokenizer)。
|
||||
func load(folderURL: URL) throws {
|
||||
let configPath = folderURL.appendingPathComponent("config.json").path
|
||||
guard FileManager.default.fileExists(atPath: configPath) else {
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
guard let b = MNNLLMBridge(configPath: configPath) else {
|
||||
throw AIRuntimeError.modelLoadFailed("MNN createLLM/load 失败")
|
||||
}
|
||||
bridge = b
|
||||
}
|
||||
|
||||
func unload() { bridge = nil }
|
||||
|
||||
/// 文本流式生成。`bridge.generateText` 同步阻塞、逐段回调,放在 detached 线程跑,
|
||||
/// 把每段文本 yield 成 `TokenChunk`(含即时 tok/s)。流被取消时调用 `bridge.cancel()`。
|
||||
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
guard let bridge else {
|
||||
return AsyncThrowingStream { $0.finish(throwing: AIRuntimeError.notReady) }
|
||||
}
|
||||
let box = MNNUncheckedBox(bridge)
|
||||
return AsyncThrowingStream { continuation in
|
||||
let meter = MNNRateMeter()
|
||||
let task = Task.detached(priority: .userInitiated) {
|
||||
let stats = box.value.generateText(prompt, maxTokens: Int32(maxTokens)) { piece in
|
||||
let rate = meter.tick()
|
||||
continuation.yield(TokenChunk(text: piece, decodeRate: rate))
|
||||
}
|
||||
// ObjC 统计对象先抽成 Sendable 的 GenerateStats 再跨 actor 记录。
|
||||
await self.record(GenerateStats(
|
||||
promptTokens: Int(stats.promptTokens),
|
||||
genTokens: Int(stats.genTokens),
|
||||
prefillSeconds: stats.prefillMs / 1000.0,
|
||||
decodeSeconds: stats.decodeMs / 1000.0
|
||||
))
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
box.value.cancel()
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 图→文(VL)。一次性收集(JSON 抽取不需流式)。桥接里把图片路径拼成 <img> 标签,
|
||||
/// MNN Omni 内部 imread 加载(需 OMNI 框架);blocking 调用放 detached 线程。
|
||||
func analyze(imageURLs: [URL], prompt: String, maxTokens: Int) async throws -> String {
|
||||
guard let bridge else { throw AIRuntimeError.notReady }
|
||||
let paths = imageURLs.map(\.path)
|
||||
let box = MNNUncheckedBox(bridge)
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let sink = MNNTextSink()
|
||||
do {
|
||||
let stats = try box.value.analyzeImages(paths, prompt: prompt, maxTokens: Int32(maxTokens)) { piece in
|
||||
sink.append(piece)
|
||||
}
|
||||
await self.record(GenerateStats(
|
||||
promptTokens: Int(stats.promptTokens),
|
||||
genTokens: Int(stats.genTokens),
|
||||
prefillSeconds: stats.prefillMs / 1000.0,
|
||||
decodeSeconds: stats.decodeMs / 1000.0
|
||||
))
|
||||
cont.resume(returning: sink.text)
|
||||
} catch {
|
||||
cont.resume(throwing: AIRuntimeError.inferenceFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单线程串行回调聚合文本,无竞争。
|
||||
private nonisolated final class MNNTextSink: @unchecked Sendable {
|
||||
private(set) var text = ""
|
||||
func append(_ s: String) { text += s }
|
||||
}
|
||||
|
||||
/// 把非 Sendable 的 ObjC 桥对象安全带过 detached 边界。
|
||||
/// 安全性来自 `AIRuntime` 闸门:同一时刻只有一个生成在跑,桥不会被并发访问。
|
||||
private nonisolated struct MNNUncheckedBox<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
init(_ value: T) { self.value = value }
|
||||
}
|
||||
|
||||
/// 即时解码速率计:回调在单线程串行调用,内部计数无竞争。
|
||||
private nonisolated final class MNNRateMeter: @unchecked Sendable {
|
||||
private let start = Date()
|
||||
private var produced = 0
|
||||
func tick() -> Double {
|
||||
produced += 1
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
return elapsed > 0 ? Double(produced) / elapsed : 0
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,23 @@ nonisolated enum ModelManifest {
|
||||
static func files(for kind: ModelKind) -> [ModelFile] {
|
||||
switch kind {
|
||||
case .llm:
|
||||
// Qwen3.5-2B-4bit:多模态仓库,但走 LLMModelFactory 的 qwen3_5 文本路径加载。
|
||||
// 字节数取自 mlx-community/Qwen3.5-2B-4bit 仓库实际 blob 大小(HF API,2026-06 核对)。
|
||||
// 该仓库 tokenizer 体系为 vocab.json + tokenizer.json(无 merges.txt /
|
||||
// special_tokens_map.json / added_tokens.json),chat_template 改为 .jinja。
|
||||
// 一并镜像视觉预处理配置(preprocessor / processor / video_preprocessor),
|
||||
// 文本加载用不到但体积可忽略,保持与仓库一致避免漏文件。
|
||||
return [
|
||||
ModelFile(path: "config.json", bytes: 937),
|
||||
ModelFile(path: "model.safetensors", bytes: 968_080_210),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 49_731),
|
||||
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 9_706),
|
||||
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
||||
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
||||
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
||||
ModelFile(path: "added_tokens.json", bytes: 707),
|
||||
ModelFile(path: "config.json", bytes: 3_113),
|
||||
ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
|
||||
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
|
||||
ModelFile(path: "vocab.json", bytes: 6_722_759),
|
||||
ModelFile(path: "chat_template.jinja", bytes: 7_755),
|
||||
ModelFile(path: "preprocessor_config.json", bytes: 390),
|
||||
ModelFile(path: "processor_config.json", bytes: 1_300),
|
||||
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
|
||||
]
|
||||
case .vl:
|
||||
// Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小
|
||||
@@ -52,6 +59,19 @@ nonisolated enum ModelManifest {
|
||||
ModelFile(path: "preprocessor_config.json", bytes: 782),
|
||||
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
|
||||
]
|
||||
case .mnnLLM:
|
||||
// taobao-mnn/Qwen3.5-2B-MNN 预转换 MNN 格式(HF API 实测,2026-06)。
|
||||
// 运行时必需:config.json(MNN llm 配置)+ llm_config.json(超参)+ llm.mnn(图)
|
||||
// + llm.mnn.weight(量化权重 ~1.1GB)+ tokenizer.txt + visual.mnn(多模态,文本路径不用但配置含 mllm)。
|
||||
// 排除 README/.gitattributes 与可读 dump(llm.mnn.json / export_args.json)。
|
||||
return [
|
||||
ModelFile(path: "config.json", bytes: 652),
|
||||
ModelFile(path: "llm_config.json", bytes: 8_692),
|
||||
ModelFile(path: "llm.mnn", bytes: 2_148_136),
|
||||
ModelFile(path: "llm.mnn.weight", bytes: 1_176_647_702),
|
||||
ModelFile(path: "tokenizer.txt", bytes: 6_465_727),
|
||||
ModelFile(path: "visual.mnn", bytes: 488_096),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
nonisolated enum ModelKind: String, CaseIterable {
|
||||
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
||||
case llm = "Qwen3-1.7B-4bit"
|
||||
/// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。
|
||||
/// 同一个 Qwen3.5-2B,两种格式两种引擎:
|
||||
/// - mnnLLM:MNN(CPU/SME2,考核路径)文本+视觉一肩挑,taobao-mnn 预转换。iPhone17+(A19/SME2)主用,只露它。
|
||||
/// - llm:MLX(GPU)兜底,Qwen3.5-2B-4bit 多模态(同时兜底文本与视觉,走 qwen3_5)。
|
||||
/// - vl:已废弃(MLX VL 改走 .llm 多模态),保留枚举避免动一圈穷举 switch,不再下载/展示。
|
||||
case llm = "Qwen3.5-2B-4bit"
|
||||
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
||||
case mnnLLM = "Qwen3.5-2B-MNN"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .llm: return "Qwen3-1.7B"
|
||||
case .llm: return "Qwen3.5-2B (MLX)"
|
||||
case .vl: return "Qwen3-VL-4B"
|
||||
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +23,12 @@ nonisolated enum ModelKind: String, CaseIterable {
|
||||
|
||||
/// 用于判定该模型是否已就绪的最小标志文件
|
||||
var sentinelFilename: String { "config.json" }
|
||||
|
||||
/// 面向用户的模型集合:模型管理页 / 下载全部 / 就绪计数对外只暴露统一的
|
||||
/// Qwen3.5-2B(MNN,文本+视觉全包,iPhone17+ 走它)。
|
||||
/// MLX 的 .llm/.vl 仅作模拟器与兜底路径,保留枚举与下载能力(旁路导入仍可单独导),
|
||||
/// 但不在「我的 · 模型管理」展示,也不计入「下载全部」与就绪计数。
|
||||
static let userFacing: [ModelKind] = [.mnnLLM]
|
||||
}
|
||||
|
||||
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
||||
|
||||
@@ -16,7 +16,7 @@ enum DiaryAssistPrompts {
|
||||
"持续频率", "既往家族史", "用药过敏", "生活方式",
|
||||
]
|
||||
|
||||
/// - content: 患者当前全文。
|
||||
/// - content: 用户当前全文。
|
||||
/// - coveredDimensions: 之前各轮已经问过(或记录里已写明)的维度名,本轮必须避开。
|
||||
/// 第一轮传空数组。
|
||||
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
|
||||
@@ -30,8 +30,8 @@ enum DiaryAssistPrompts {
|
||||
: "\n- 已问过的维度【不要再问】:\(covered.joined(separator: "、"))。本轮只能从这些还没问的维度里挑:\(allowedLine)。"
|
||||
|
||||
return """
|
||||
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
|
||||
请从医生问诊角度提出 3-4 个最值得追问的问题,帮患者把这条记录补全。
|
||||
你是社区医生的小助手。用户写了一段身体状态的健康记录,信息可能不够完整。
|
||||
请从医生问诊角度提出 3-4 个最值得追问的问题,帮用户把这条记录补全。
|
||||
|
||||
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
|
||||
1. 起病诱因 —— 何时开始、有无诱因
|
||||
@@ -76,4 +76,44 @@ enum DiaryAssistPrompts {
|
||||
Output: /no_think
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - 语音口述 → 日记整理
|
||||
|
||||
/// 口述转写稿截断上限(字符)。2B 模型 context 保护:超长口述只取前面部分。
|
||||
static let organizeTranscriptLimit = 1200
|
||||
|
||||
/// 把语音转写稿整理成健康日记草稿。自适应样式:内容少 → 一段通顺的话;
|
||||
/// 多方面 → 按「方面:内容」分行。
|
||||
/// 红线(spec 2026-06-10-voice-diary §2):只重组语言,严禁增删改任何数值、单位、药名、时间——
|
||||
/// 2B 模型把 140/90 改成 130/90 即健康数据事故,所以规则放第一条并配 few-shot 强化。
|
||||
static func organize(transcript: String) -> String {
|
||||
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
|
||||
return """
|
||||
你是健康记录助手。下面是用户口述身体状态的语音转写原话,可能口语化、有重复、缺标点。
|
||||
请把它整理成一条清晰的健康日记。
|
||||
|
||||
硬性规则:
|
||||
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
||||
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
|
||||
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
||||
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
||||
- 不诊断、不给用药建议、不写「建议就医」。
|
||||
- 只输出整理后的日记正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
|
||||
|
||||
示例 1(口述:那个今天早上起来有点头晕然后我量了下血压140 90比平时高一点没吃早饭就出门了):
|
||||
今天早上起来有点头晕,量了血压 140/90,比平时高一点。没吃早饭就出门了。
|
||||
|
||||
示例 2(口述:今天头晕了一上午下午好点了血压早上量的140 90嗯缬沙坦吃了降脂药忘了吃早饭没吃中午吃的清淡晚上散步了半小时):
|
||||
症状:头晕了一上午,下午好转。
|
||||
血压:早上 140/90。
|
||||
用药:缬沙坦已服,降脂药忘服。
|
||||
饮食:早饭未吃,午餐清淡。
|
||||
运动:晚上散步半小时。
|
||||
|
||||
【口述原话】:
|
||||
\(trimmed)
|
||||
|
||||
Output: /no_think
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ enum HealthExportPrompts {
|
||||
- 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。
|
||||
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
|
||||
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
|
||||
- 「主诉」「患者疑问」可参考【患者原话】,但不得加入原话与数据里都没有的症状。
|
||||
- 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
|
||||
|
||||
输出格式:
|
||||
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
|
||||
@@ -78,11 +78,11 @@ enum HealthExportPrompts {
|
||||
- 严格按以下 6 段(顺序与标题固定):
|
||||
\(labelLine)
|
||||
## 主诉
|
||||
## 患者背景
|
||||
## 本人背景
|
||||
## 近期症状(按时间倒序)
|
||||
## 关键指标(异常项优先)
|
||||
## 在服药与过敏
|
||||
## 患者疑问
|
||||
## 本人疑问
|
||||
|
||||
—— 格式示例(只示范「无记录」与数值写法,内容请勿照抄)——
|
||||
真实数据:{"profile":{},"symptoms":[],"indicators":[{"name":"体温","value":"38.5","unit":"℃","range":"36-37.2","status":"high","date":"2026-05-01"}],"reports":[],"diaries":[],"time_window":{"from":"2026-04-02","to":"2026-05-02"}}
|
||||
@@ -90,7 +90,7 @@ enum HealthExportPrompts {
|
||||
# 就诊摘要 — 近期健康摘要
|
||||
## 主诉
|
||||
无记录
|
||||
## 患者背景
|
||||
## 本人背景
|
||||
无记录
|
||||
## 近期症状(按时间倒序)
|
||||
无记录
|
||||
@@ -98,7 +98,7 @@ enum HealthExportPrompts {
|
||||
⚠️ 体温 38.5 ℃(参考 36-37.2,2026-05-01)
|
||||
## 在服药与过敏
|
||||
无记录
|
||||
## 患者疑问
|
||||
## 本人疑问
|
||||
无记录
|
||||
—— 示例结束(以上咳嗽/体温等仅示范格式,切勿出现在你的输出里)——
|
||||
|
||||
@@ -107,7 +107,7 @@ enum HealthExportPrompts {
|
||||
【真实数据】:
|
||||
\(dataJSON)
|
||||
|
||||
【患者原话】:\(userPrompt)
|
||||
【本人原话】:\(userPrompt)
|
||||
|
||||
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
|
||||
直接输出 Markdown,不要思考过程,不要 <think> 标签:
|
||||
@@ -169,7 +169,7 @@ enum HealthExportPrompts {
|
||||
## 相关健康日记
|
||||
## 相关指标
|
||||
## 已知背景
|
||||
## 患者关心的问题
|
||||
## 本人关心的问题
|
||||
## 可带给医生确认的要点
|
||||
|
||||
【本地健康记录】:
|
||||
|
||||
44
康康/AI/Prompts/InsightPrompts.swift
Normal file
44
康康/AI/Prompts/InsightPrompts.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// 本地解读类 prompt:报告大白话摘要 + 趋势一句话解读。
|
||||
/// 红线:不诊断、不荐药;称呼「你」,不出现「患者」(产品定位:自我健康记录)。
|
||||
nonisolated enum InsightPrompts {
|
||||
|
||||
/// 报告整体大白话摘要(归档后台预生成,写回 Report.summary)。
|
||||
static func reportPlainSummary(title: String, typeLabel: String, indicatorLines: String) -> String {
|
||||
"""
|
||||
你是健康档案助手。下面是一份报告的指标列表,请用大白话给本人(称「你」)写 2~3 句整体解读:
|
||||
- 第 1 句:总体情况(共几项、几项异常)。
|
||||
- 之后:点名最值得留意的异常项,用生活化语言说明偏高/偏低意味着什么方向。
|
||||
- 不诊断疾病、不推荐药物或剂量;异常较多时建议「带上报告咨询医生」。
|
||||
- 只输出正文文字,不要标题、列表、JSON、markdown。
|
||||
|
||||
示例:
|
||||
输入:血常规(化验单),指标:白细胞 5.2 (3.5-9.5) normal;血红蛋白 118 (130-175) low;血小板 210 (125-350) normal
|
||||
输出:这份血常规共 3 项,2 项正常,血红蛋白略低于参考范围。血红蛋白偏低通常与贫血方向有关,平时可以多补充含铁食物;如果还伴随乏力头晕,建议带上报告咨询医生。
|
||||
|
||||
现在的报告:\(title)(\(typeLabel))
|
||||
指标:
|
||||
\(indicatorLines)
|
||||
只输出 2~3 句正文。/no_think
|
||||
"""
|
||||
}
|
||||
|
||||
/// 趋势一句话解读(TrendDetailView,按数据指纹缓存)。
|
||||
static func trendInsight(title: String, unit: String, rangeText: String, dataLines: String) -> String {
|
||||
"""
|
||||
你是健康档案助手。下面是「\(title)」的历史记录(单位 \(unit)\(rangeText)),请用大白话给本人(称「你」)写 1~2 句趋势解读:
|
||||
- 说清整体走向(上升/下降/平稳/波动)和当前值与参考范围的关系。
|
||||
- 不诊断疾病、不推荐药物;持续异常时温和建议「复查或咨询医生」。
|
||||
- 只输出正文文字,不要标题、列表、JSON。
|
||||
|
||||
示例:
|
||||
输入:体重,单位 kg,记录:2026-04-01 72.5 / 2026-04-15 71.8 / 2026-05-01 71.2
|
||||
输出:近一个月你的体重稳步下降了约 1.3kg,节奏平缓,继续保持现在的习惯就好。
|
||||
|
||||
现在的记录:
|
||||
\(dataLines)
|
||||
只输出 1~2 句正文。/no_think
|
||||
"""
|
||||
}
|
||||
}
|
||||
43
康康/AI/Prompts/IntentPrompts.swift
Normal file
43
康康/AI/Prompts/IntentPrompts.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// 「长按 + 语音直达」prompt:端侧语音转写文本 → LLM(MNN/SME2 主链路)分类到新建入口。
|
||||
/// 输出契约:严格 JSON `{"intent":"…"}`;解析失败/超时 → VoiceIntentService 回退关键词匹配(§3.2)。
|
||||
nonisolated enum IntentPrompts {
|
||||
|
||||
static func classify(_ utterance: String) -> String {
|
||||
classifyTemplate.replacingOccurrences(of: "{{TEXT}}", with: String(utterance.prefix(120)))
|
||||
}
|
||||
|
||||
private static let classifyTemplate: String = #"""
|
||||
你是健康 App 的语音意图分类器。用户长按「新建」按钮说了一句话,判断 ta 想打开哪个功能。
|
||||
请只输出一段合法 JSON,格式 {"intent":"<分类>"},不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||
|
||||
分类(只能选下面其中一个):
|
||||
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
|
||||
- "medication" 记录用药、拍药盒、吃了什么药
|
||||
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…)
|
||||
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
|
||||
- "archive" 归档整份体检报告/化验单(拍报告存档)
|
||||
- "export" 生成给医生看的身体档案/健康总结
|
||||
- "reminder" 设置周期提醒
|
||||
- "unknown" 无法判断
|
||||
|
||||
规则:
|
||||
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
|
||||
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"。
|
||||
- 既像日记又提到具体数值时,以数值为准 → "indicator"。
|
||||
|
||||
示例:
|
||||
"帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"}
|
||||
"我今天有点头疼,想记录一下" → {"intent":"symptom"}
|
||||
"刚买了一盒降压药,拍一下存进去" → {"intent":"medication"}
|
||||
"今天睡得不错,写个日记" → {"intent":"diary"}
|
||||
"把这份体检报告存档" → {"intent":"archive"}
|
||||
"每天早上八点提醒我量血压" → {"intent":"reminder"}
|
||||
"整理一份给医生看的健康总结" → {"intent":"export"}
|
||||
|
||||
现在判断下面这句话,只输出 JSON。/no_think
|
||||
|
||||
用户的话:{{TEXT}}
|
||||
"""#
|
||||
}
|
||||
64
康康/AI/Prompts/MedicationPrompts.swift
Normal file
64
康康/AI/Prompts/MedicationPrompts.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
/// 「拍药盒入档」prompt:Vision OCR 出药盒/说明书/处方文字后,
|
||||
/// 交 LLM(Qwen,MNN/SME2 主链路)结构化抽药品名 + 规格 + 用法。
|
||||
/// 输出契约:严格 JSON;解析失败 → UI 回退手动录入(§3.2 失败回退红线)。
|
||||
/// 注意:只做"识别入档",不做剂量推荐/用药提醒(§1 明确不做)。
|
||||
nonisolated enum MedicationPrompts {
|
||||
|
||||
static func medicationsFromText(_ ocrText: String) -> String {
|
||||
// ≤5 张合并 OCR,放宽到 2400(单张 1200 偏小,易把背面用法截掉)。
|
||||
medicationsFromTextTemplate
|
||||
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 2400))
|
||||
}
|
||||
|
||||
private static let medicationsFromTextTemplate: String = #"""
|
||||
你是药品包装识别助手。下面是对一种药品的多张照片(药盒正面/背面/说明书/处方单)做 OCR 得到的纯文本,各张之间用「---」分隔,可能有错字、换行混乱或无关噪声。
|
||||
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||
|
||||
JSON schema(严格):
|
||||
{
|
||||
"medications": [
|
||||
{
|
||||
"name": string, // 药品名,见下方「name 怎么填」
|
||||
"strength": string, // 规格,如 "80mg"、"0.5g×24片";识别不出填 ""
|
||||
"usage": string // 用法用量,如 "每日一次,一次一粒";包装上没有就填 ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
name 怎么填(关键,别搞混):
|
||||
- 药品名 = 通用名(化学/药典名),这是要填进 name 的主体。中文药名照中文写,英文药名(如 "Metformin"、"Amoxicillin")就照英文原样抄,不要翻译、不要丢。
|
||||
- 若包装上同时印有商品名/商标名(厂商起的牌子名,如 "代文""泰诺""Tylenol"),把它放在通用名后的括号里,例如 "缬沙坦胶囊(代文)"。只读到商品名、读不到通用名时,就直接用商品名当 name。
|
||||
- 生产厂家/公司名/品牌 LOGO 文字(如 "XX药业有限公司""诺华""拜耳")不是药名,一律不要当 name,也不要塞进括号。
|
||||
|
||||
通用规则:
|
||||
- 只提取药品本身;"国药准字"批准文号、生产厂家、批号、有效期、条形码、贮藏、二维码一律忽略。
|
||||
- 多张照片通常是同一种药的不同面,合并成一条,不要因为来自不同照片就重复输出;处方单可能有多种药,才分多条。
|
||||
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
|
||||
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
|
||||
- 同一药品只输出一次。
|
||||
|
||||
示例 1(药盒,含商品名 + 厂商):
|
||||
输入 OCR 文本: 代文 缬沙坦胶囊 80mg×7粒 国药准字H20103521 北京诺华制药有限公司
|
||||
输出:
|
||||
{"medications":[{"name":"缬沙坦胶囊(代文)","strength":"80mg×7粒","usage":""}]}
|
||||
|
||||
示例 2(说明书含用法):
|
||||
输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用
|
||||
输出:
|
||||
{"medications":[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次,随餐服用"}]}
|
||||
|
||||
示例 3(英文药名,正反两张合并):
|
||||
输入 OCR 文本: Amoxicillin Capsules 500mg GSK
|
||||
---
|
||||
Dosage: Take one capsule three times daily
|
||||
输出:
|
||||
{"medications":[{"name":"Amoxicillin","strength":"500mg","usage":"Take one capsule three times daily"}]}
|
||||
|
||||
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
|
||||
|
||||
OCR 文本:
|
||||
{{OCR_TEXT}}
|
||||
"""#
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
/// VL 模型(Qwen3-VL)用于体检 / 化验单识别的 prompt 模板。
|
||||
/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。
|
||||
/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。
|
||||
enum VLPrompts {
|
||||
nonisolated enum VLPrompts {
|
||||
|
||||
/// 输出 JSON 的字段定义(写进 prompt 里教模型):
|
||||
/// ```
|
||||
@@ -31,12 +31,38 @@ enum VLPrompts {
|
||||
|
||||
/// VL 模型不知"今天"是哪天,且 few-shot 示例里写死了日期,
|
||||
/// 必须把当天日期显式注入 prompt,模型在无报告日期时才会用对正确的回退值。
|
||||
static func reportExtraction(today: Date = .now) -> String {
|
||||
/// ocrText 非空时把 Vision OCR 的结果作为参考文本注入 —— Vision 抄数字比
|
||||
/// 2B 多模态读密集小字稳;版面与表格结构仍以图片为准。
|
||||
static func reportExtraction(today: Date = .now, ocrText: String = "") -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
let todayStr = f.string(from: today)
|
||||
return reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||
let ocrSection: String
|
||||
if ocrText.isEmpty {
|
||||
ocrSection = ""
|
||||
} else {
|
||||
ocrSection = """
|
||||
|
||||
|
||||
OCR 参考文本(系统对同一报告做文字识别的结果,可能有错字、串行或漏行;版面与表格结构以图片为准,但数值、小数点以 OCR 文字更可靠):
|
||||
\(clipOCR(ocrText))
|
||||
|
||||
"""
|
||||
}
|
||||
return reportExtractionTemplate
|
||||
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||
.replacingOccurrences(of: "{{OCR_SECTION}}", with: ocrSection)
|
||||
}
|
||||
|
||||
/// OCR 文本截断:限制进入 prompt 的体量(2B 模型上下文有限)。截到最后一个完整行。
|
||||
static func clipOCR(_ text: String, limit: Int = 1800) -> String {
|
||||
guard text.count > limit else { return text }
|
||||
let clipped = String(text.prefix(limit))
|
||||
if let lastNewline = clipped.lastIndex(of: "\n") {
|
||||
return String(clipped[..<lastNewline]) + "\n(后续内容过长已截断)"
|
||||
}
|
||||
return clipped + "\n(后续内容过长已截断)"
|
||||
}
|
||||
|
||||
private static let reportExtractionTemplate: String = #"""
|
||||
@@ -84,13 +110,60 @@ JSON schema(严格):
|
||||
输入: 一份春季体检,3 项可读
|
||||
输出:
|
||||
{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]}
|
||||
|
||||
{{OCR_SECTION}}
|
||||
现在请识别图片并输出 JSON:
|
||||
"""#
|
||||
|
||||
// MARK: - 局部小框识别(异常项快拍)
|
||||
// MARK: - 报告归档 · 轻量 meta(只抽日期/机构/类型/标题,不识别指标)
|
||||
|
||||
/// 异常项快拍专用:输入是报告/化验单的**局部照片**(常常只有一两行指标)。
|
||||
/// 报告归档新链路:只保存原图,**不逐项识别指标**(逐项多模态识别在 2B 上易 OOM / 卡死)。
|
||||
/// 用 Vision OCR 出纯文本后,交文本 LLM 只抽报告级 meta —— 输出极小(~50 token),快且稳。
|
||||
/// 识别不到就留空,UI 用占位(今天 / 空机构),用户可手填。
|
||||
static func reportMetaFromText(_ ocrText: String, today: Date = .now) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
let todayStr = f.string(from: today)
|
||||
return reportMetaTemplate
|
||||
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||
.replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 1500))
|
||||
}
|
||||
|
||||
private static let reportMetaTemplate: String = #"""
|
||||
你是体检/化验报告归档助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声。
|
||||
请只提取这份报告的「元信息」,**不要提取任何具体指标/数值**。只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||
|
||||
今天的日期是 {{TODAY}}。
|
||||
|
||||
JSON schema(严格):
|
||||
{
|
||||
"title": string, // 报告抬头,如 "春季年度体检";读不出就填 ""
|
||||
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
|
||||
"report_date": "YYYY-MM-DD", // 报告/采样/体检日期;实在读不出就填 ""
|
||||
"institution": string // 医院/体检机构名;读不出就填 ""
|
||||
}
|
||||
|
||||
规则:
|
||||
- 只输出上面 4 个字段,绝不输出 indicators / 数值 / 参考范围。
|
||||
- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"。
|
||||
- 日期挑「报告日期 / 检查日期 / 采样日期」其一,统一成 YYYY-MM-DD;只有年月就补 -01;读不出填 ""。
|
||||
- institution 取医院/体检中心全称,去掉「检验科/报告单」等栏目词;读不出填 ""。
|
||||
- 不要编造;读不出的字段填 ""。
|
||||
|
||||
示例 OCR 文本:
|
||||
协和医院体检中心 健康体检报告 姓名:张三 体检日期:2026-04-12 低密度脂蛋白 3.84 ↑ ...
|
||||
输出:
|
||||
{"title":"健康体检报告","type":"checkup","report_date":"2026-04-12","institution":"协和医院体检中心"}
|
||||
|
||||
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
|
||||
|
||||
OCR 文本:
|
||||
{{OCR_TEXT}}
|
||||
"""#
|
||||
|
||||
// MARK: - 局部小框识别(指标速记)
|
||||
|
||||
/// 指标速记专用:输入是报告/化验单的**局部照片**(常常只有一两行指标)。
|
||||
/// 只要 indicators 数组,不要报告标题/机构/日期等元信息 —— 这条路径只存数值,不建 Report。
|
||||
static func regionExtraction(today: Date = .now) -> String {
|
||||
let f = DateFormatter()
|
||||
|
||||
@@ -24,6 +24,7 @@ struct KangkangApp: App {
|
||||
CustomMonitorMetric.self,
|
||||
HealthExport.self,
|
||||
CustomReminder.self,
|
||||
Medication.self,
|
||||
])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
// 建库后给 store 文件补 .completeUnlessOpen 保护(§6),两条创建路径共用。
|
||||
@@ -102,6 +103,9 @@ struct KangkangApp: App {
|
||||
// 语言 / 字体档位切换 → 整树重建,即时生效(固定字号经 tjScaled 读新倍率)。
|
||||
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
|
||||
}
|
||||
// 设计系统是纯浅色(背景恒为 sand)。锁定 light:否则系统深色模式下,
|
||||
// 未显式设色的 Text/TextField 走 .primary 变白,在浅背景上看不见(如日记输入框)。
|
||||
.preferredColorScheme(.light)
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
|
||||
34
康康/DesignSystem/AIDisclaimer.swift
Normal file
34
康康/DesignSystem/AIDisclaimer.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 全 App 统一的 AI 免责声明(上架合规:医疗类内容必须声明不做诊断)。
|
||||
/// 任何展示 AI 生成的归纳/解读/建议文本的地方,都应在结果末尾附上 `AIDisclaimerFooter`;
|
||||
/// 离开 App 的文本(复制/分享)用 `AIDisclaimer.appended(to:)` 把声明带上。
|
||||
enum AIDisclaimer {
|
||||
/// 面向用户展示的完整声明文本。
|
||||
static let text =
|
||||
"本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考," +
|
||||
"不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。"
|
||||
|
||||
/// 复制/分享时把声明追加到正文末尾(分隔线 + 声明),让文本离开 App 也带着免责。
|
||||
static func appended(to body: String) -> String {
|
||||
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return "\(trimmed)\n\n———\n\(text)"
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 结果下方的免责声明脚注:小字、弱色、信息图标。放在任何 AI 生成文本块末尾。
|
||||
struct AIDisclaimerFooter: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(AIDisclaimer.text)
|
||||
.font(.tjScaled( 10))
|
||||
.lineSpacing(2)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
45
康康/DesignSystem/AIFlowBar.swift
Normal file
45
康康/DesignSystem/AIFlowBar.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Apple Intelligence 式多彩流光线:蓝→紫→粉→橙→青,横向无缝循环流动。
|
||||
/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待)。
|
||||
///
|
||||
/// 注意:这条线的颜色是刻意走出 `Tj.Palette` 单色系统的 AI 高光点缀(应产品要求的
|
||||
/// Apple 风格),仅此组件如此;其余 UI 仍严格守 §9 单色 token。
|
||||
struct AIFlowBar: View {
|
||||
var height: CGFloat = 3
|
||||
/// 流动一整圈的秒数,越小越快。
|
||||
var cycle: Double = 1.0
|
||||
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
/// 颜色重复一遍:offset 走完一个整段时首尾同色,循环无缝。
|
||||
private static let flow: [Color] = {
|
||||
let base: [Color] = [
|
||||
Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝
|
||||
Color(red: 0.62, green: 0.36, blue: 0.92), // 紫
|
||||
Color(red: 0.96, green: 0.40, blue: 0.62), // 粉
|
||||
Color(red: 1.00, green: 0.55, blue: 0.30), // 橙
|
||||
Color(red: 0.30, green: 0.80, blue: 0.92), // 青
|
||||
]
|
||||
return base + base
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: Self.flow,
|
||||
startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: w * 2)
|
||||
.offset(x: phase)
|
||||
.onAppear {
|
||||
phase = 0
|
||||
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
|
||||
phase = -w
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
47
康康/DesignSystem/VaultImage.swift
Normal file
47
康康/DesignSystem/VaultImage.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 从加密 Vault 异步加载并降采样显示原图的通用组件。
|
||||
///
|
||||
/// 替代「在 body 里 `try? FileVault.shared.loadImage(...)` 同步读盘 + 全量解码」的旧写法,
|
||||
/// 解决两个真实问题:
|
||||
/// 1. **OOM**:全分辨率位图(4000×3000 ≈ 48MB)进内存,翻几页就触发 jetsam。这里按 `maxPixel`
|
||||
/// 降采样,缩略图几百 KB,全屏图几 MB。
|
||||
/// 2. **主线程卡顿**:读盘 + JPEG 解码在主线程会掉帧。这里放到后台线程,主线程只拿结果绘制。
|
||||
///
|
||||
/// 区分「加载中」与「读取失败」两态:加载中显示中性占位,只有真正失败才显示「原图无法读取」,
|
||||
/// 不会一打开就闪一下吓人的错误文案。`content` 拿到 `UIImage`(而非 `Image`),
|
||||
/// 方便需要 `image.size` 的调用方(如证据高亮 overlay)按真实宽高比定位。
|
||||
struct VaultImage<Content: View, Placeholder: View>: View {
|
||||
let relativePath: String
|
||||
/// 降采样目标最大边(像素)。缩略图给 ~400,全屏查看器给 ~2000。
|
||||
var maxPixel: CGFloat = 1024
|
||||
|
||||
@ViewBuilder var content: (UIImage) -> Content
|
||||
/// 占位回调,`isLoading == true` 表示仍在加载,`false` 表示加载完成但失败。
|
||||
@ViewBuilder var placeholder: (_ isLoading: Bool) -> Placeholder
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var loading = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
content(image)
|
||||
} else {
|
||||
placeholder(loading)
|
||||
}
|
||||
}
|
||||
// id 变了(TabView 翻到新页 / 行复用换 asset)就重新加载;同一身份重渲染不会重复读盘。
|
||||
.task(id: relativePath) {
|
||||
loading = true
|
||||
let path = relativePath
|
||||
let mp = maxPixel
|
||||
let loaded = await Task.detached(priority: .userInitiated) {
|
||||
try? FileVault.shared.loadDownsampledImage(relativePath: path, maxPixelSize: mp)
|
||||
}.value
|
||||
guard !Task.isCancelled else { return }
|
||||
image = loaded
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,32 +23,36 @@ 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?
|
||||
@State private var selectedEntry: TimelineEntry?
|
||||
@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
|
||||
@@ -56,33 +60,49 @@ struct ArchiveListView: View {
|
||||
switch route {
|
||||
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
|
||||
@@ -109,6 +129,9 @@ struct ArchiveListView: View {
|
||||
TimelineEntryDetailView(detail: d)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedGroup) { group in
|
||||
IndicatorSeriesDetailView(group: group)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -123,9 +146,14 @@ struct ArchiveListView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
// 其余条目(报告/指标/日记/已结束症状):点 → 只读详情
|
||||
// 其余条目:指标 → 同类聚合详情(横向翻页 + 趋势);报告/日记/已结束症状 → 只读详情
|
||||
Button {
|
||||
if detail(for: entry) != nil { selectedEntry = entry }
|
||||
guard let d = detail(for: entry) else { return }
|
||||
switch d {
|
||||
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
|
||||
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
|
||||
default: selectedEntry = entry
|
||||
}
|
||||
} label: {
|
||||
TimelineRow(entry: entry)
|
||||
}
|
||||
@@ -140,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()
|
||||
@@ -164,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: - 提醒任务汇总卡
|
||||
|
||||
/// 两类提醒(自由 + 指标记录)合计,含已关闭。
|
||||
@@ -232,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) {
|
||||
@@ -282,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)
|
||||
if !isSearchMiss {
|
||||
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -29,6 +29,8 @@ struct HealthExportDetailView: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
@@ -117,7 +119,7 @@ struct HealthExportDetailView: View {
|
||||
}
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||
|
||||
ShareLink(item: export.content) {
|
||||
ShareLink(item: AIDisclaimer.appended(to: export.content)) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
@@ -149,7 +151,7 @@ struct HealthExportDetailView: View {
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
UIPasteboard.general.string = export.content
|
||||
UIPasteboard.general.string = AIDisclaimer.appended(to: export.content)
|
||||
copiedFlash = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||
copiedFlash = false
|
||||
@@ -175,9 +177,9 @@ struct HealthExportDetailView: View {
|
||||
# 就诊摘要 — 感冒就诊
|
||||
|
||||
## 主诉
|
||||
患者男,38 岁,感冒 3 天未愈。
|
||||
本人男,38 岁,感冒 3 天未愈。
|
||||
|
||||
## 患者背景
|
||||
## 本人背景
|
||||
- 高血压 2 年
|
||||
- 在服药:缬沙坦 80mg qd
|
||||
""",
|
||||
|
||||
@@ -20,8 +20,15 @@ struct HealthExportSheet: View {
|
||||
@State private var completed: Bool = false
|
||||
@State private var copiedFlash: Bool = false
|
||||
@State private var answeringTurnID: UUID?
|
||||
@State private var retrieval: HealthExportService.RetrievalSummary?
|
||||
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
||||
@FocusState private var questionFocused: Bool
|
||||
|
||||
// 快捷问答
|
||||
@State private var promptStore = QuickPromptStore.shared
|
||||
@State private var showAddPrompt = false
|
||||
@State private var newPromptText = ""
|
||||
|
||||
init(initialPrompt: String = "") {
|
||||
self.initialPrompt = initialPrompt
|
||||
}
|
||||
@@ -33,10 +40,16 @@ struct HealthExportSheet: View {
|
||||
!isGeneratingReport &&
|
||||
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
/// 已有有效用户对话内容。
|
||||
private var hasUserContent: Bool {
|
||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
}
|
||||
|
||||
/// 可生成报告:有对话内容,或输入框里有文字(允许跳过多轮对话直接生成)。
|
||||
private var canGenerateReport: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
(hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -88,6 +101,75 @@ struct HealthExportSheet: View {
|
||||
questionFocused = true
|
||||
}
|
||||
.onDisappear { task?.cancel() }
|
||||
.alert("添加快捷问答", isPresented: $showAddPrompt) {
|
||||
TextField("输入一句常用问题…", text: $newPromptText)
|
||||
Button("取消", role: .cancel) { newPromptText = "" }
|
||||
Button("添加") {
|
||||
promptStore.add(prompt: newPromptText)
|
||||
newPromptText = ""
|
||||
}
|
||||
} message: {
|
||||
Text("保存后点一下,就能把这句话填进输入框")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 快捷问答
|
||||
|
||||
/// 内置 + 自定义快捷问答 chip 行;点 chip 填入输入框,末尾「+ 自定义」追加,长按自定义删除。
|
||||
private var quickPromptRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(promptStore.all) { p in
|
||||
quickPromptChip(p)
|
||||
}
|
||||
addQuickPromptChip
|
||||
}
|
||||
.padding(.vertical, 1) // 给 chip 描边留出像素,避免被 ScrollView 裁切
|
||||
}
|
||||
}
|
||||
|
||||
private func quickPromptChip(_ p: QuickPrompt) -> some View {
|
||||
Button {
|
||||
draftQuestion = p.prompt
|
||||
questionFocused = true
|
||||
} label: {
|
||||
Text(p.title)
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
if !p.isBuiltin {
|
||||
Button(role: .destructive) {
|
||||
promptStore.delete(p)
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var addQuickPromptChip: some View {
|
||||
Button { showAddPrompt = true } label: {
|
||||
Label("自定义", systemImage: "plus")
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(
|
||||
Capsule().strokeBorder(
|
||||
Tj.Palette.line,
|
||||
style: StrokeStyle(lineWidth: 1, dash: [3])
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -128,14 +210,7 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("例:最近血压波动大吗?")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
quickPromptRow
|
||||
|
||||
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
||||
.font(.tjScaled( 11))
|
||||
@@ -161,12 +236,17 @@ struct HealthExportSheet: View {
|
||||
Text(turn.role.transcriptLabel)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
||||
if !isUser, let summary = turnRetrievals[turn.id] {
|
||||
RetrievalChipsView(summary: summary)
|
||||
}
|
||||
if turn.id == answeringTurnID && turn.text.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("正在查看本地记录…")
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(turnRetrievals[turn.id] == nil
|
||||
? "正在查看本地记录…"
|
||||
: "正在根据这些记录回答…")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
AIFlowBar()
|
||||
}
|
||||
} else {
|
||||
Text(turn.text)
|
||||
@@ -196,6 +276,11 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
MarkdownView(text: content)
|
||||
|
||||
if completed {
|
||||
Divider().background(Tj.Palette.lineSoft)
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -220,6 +305,9 @@ struct HealthExportSheet: View {
|
||||
arrow
|
||||
phasePill(.generating)
|
||||
}
|
||||
if let retrieval {
|
||||
RetrievalChipsView(summary: retrieval)
|
||||
}
|
||||
if phase == .generating && rate > 0 {
|
||||
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
@@ -229,6 +317,9 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
// AI 计算中:多彩流光线(与日记 AI 辅助同一组件)
|
||||
AIFlowBar().padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +382,7 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||
|
||||
ShareLink(item: content) {
|
||||
ShareLink(item: AIDisclaimer.appended(to: content)) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
@@ -319,7 +410,7 @@ struct HealthExportSheet: View {
|
||||
private var composer: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical)
|
||||
TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
|
||||
.font(.tjScaled( 14))
|
||||
.lineLimit(1...4)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -342,6 +433,21 @@ struct HealthExportSheet: View {
|
||||
.accessibilityLabel("发送问题")
|
||||
}
|
||||
|
||||
if isGeneratingReport {
|
||||
Button { stopGeneration() } label: {
|
||||
Label("停止生成", systemImage: "stop.fill")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.brick, lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button { startReportGeneration() } label: {
|
||||
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||
}
|
||||
@@ -349,6 +455,7 @@ struct HealthExportSheet: View {
|
||||
.disabled(!canGenerateReport)
|
||||
.opacity(canGenerateReport ? 1 : 0.45)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Tj.Palette.paper)
|
||||
@@ -380,9 +487,18 @@ struct HealthExportSheet: View {
|
||||
task?.cancel()
|
||||
task = Task { @MainActor in
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
for try await event in stream {
|
||||
switch event {
|
||||
case .retrieved(let summary):
|
||||
withAnimation(.snappy(duration: 0.25)) {
|
||||
turnRetrievals[assistantTurn.id] = summary
|
||||
}
|
||||
case .token(let chunk):
|
||||
appendToTurn(id: assistantTurn.id, text: chunk.text)
|
||||
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||
case .phaseChanged, .completed:
|
||||
break
|
||||
}
|
||||
}
|
||||
answeringTurnID = nil
|
||||
questionFocused = true
|
||||
@@ -402,10 +518,20 @@ struct HealthExportSheet: View {
|
||||
private func startReportGeneration() {
|
||||
guard canGenerateReport else { return }
|
||||
questionFocused = false
|
||||
|
||||
// 直接生成:输入框里有文字(快捷问答/手输)就把它作为一条诉求追加进对话,
|
||||
// 不必先走多轮问答 —— 用户点一下「生成报告」即可。
|
||||
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !draft.isEmpty {
|
||||
turns.append(.user(draft))
|
||||
draftQuestion = ""
|
||||
}
|
||||
|
||||
content = ""
|
||||
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||
error = nil
|
||||
completed = false
|
||||
retrieval = nil
|
||||
phase = .retrieving
|
||||
|
||||
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
|
||||
@@ -416,6 +542,8 @@ struct HealthExportSheet: View {
|
||||
switch event {
|
||||
case .phaseChanged(let ph):
|
||||
phase = ph
|
||||
case .retrieved(let summary):
|
||||
withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
|
||||
case .token(let chunk):
|
||||
content += chunk.text
|
||||
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||
@@ -435,6 +563,17 @@ struct HealthExportSheet: View {
|
||||
startReportGeneration()
|
||||
}
|
||||
|
||||
/// 停止正在进行的报告生成:取消推理任务,回到可重新生成的干净态(已写的诉求保留在对话里)。
|
||||
private func stopGeneration() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
phase = nil
|
||||
rate = 0
|
||||
completed = false
|
||||
content = ""
|
||||
retrieval = nil
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
@@ -444,11 +583,13 @@ struct HealthExportSheet: View {
|
||||
error = nil
|
||||
completed = false
|
||||
answeringTurnID = nil
|
||||
retrieval = nil
|
||||
turnRetrievals = [:]
|
||||
questionFocused = true
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
UIPasteboard.general.string = content
|
||||
UIPasteboard.general.string = AIDisclaimer.appended(to: content)
|
||||
copiedFlash = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||
copiedFlash = false
|
||||
@@ -461,6 +602,44 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 检索结果 chips(本地 RAG 可视化)
|
||||
|
||||
/// 生成开始前先把「本地 RAG 找到了什么」演出来:N 条记录 + 记录名 chips。
|
||||
/// 结构化检索(不用 embedding)的天然优势 —— 每条命中都可解释、可展示(§12 卖点 3)。
|
||||
private struct RetrievalChipsView: View {
|
||||
let summary: HealthExportService.RetrievalSummary
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if summary.totalCount == 0 {
|
||||
Text("本地档案中暂无相关记录,将仅按你的描述整理")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
} else {
|
||||
Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录"))
|
||||
.font(.tjScaled( 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in
|
||||
Text(chip)
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 简易 Markdown 渲染(行级)
|
||||
|
||||
/// 极简 Markdown 渲染器,够给医生看的报告就行。
|
||||
@@ -641,9 +820,9 @@ struct MarkdownView: View {
|
||||
# 就诊摘要 — 感冒就诊
|
||||
|
||||
## 主诉
|
||||
患者男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
|
||||
本人男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
|
||||
|
||||
## 患者背景
|
||||
## 本人背景
|
||||
- 高血压 2 年
|
||||
- 在服药:**缬沙坦 80mg qd**
|
||||
- 过敏:青霉素
|
||||
|
||||
92
康康/Features/Archive/QuickPrompt.swift
Normal file
92
康康/Features/Archive/QuickPrompt.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 「身体档案」里的快捷问答:点一下把一句常用问题填进输入框。
|
||||
/// 内置 3 条(不可删),用户可自定义追加(可删)。
|
||||
struct QuickPrompt: Identifiable, Codable, Equatable {
|
||||
let id: UUID
|
||||
var title: String // chip 上显示的短标签
|
||||
var prompt: String // 点击后填入输入框的完整问题
|
||||
var isBuiltin: Bool
|
||||
|
||||
init(id: UUID = UUID(), title: String, prompt: String, isBuiltin: Bool) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.prompt = prompt
|
||||
self.isBuiltin = isBuiltin
|
||||
}
|
||||
}
|
||||
|
||||
/// 快捷问答存储:内置常量 + 自定义条目(UserDefaults JSON,无 SwiftData schema 迁移风险)。
|
||||
/// 自定义条目只是 UI 便利项、不是健康记录,故不进 SwiftData。
|
||||
@Observable
|
||||
final class QuickPromptStore {
|
||||
static let shared = QuickPromptStore()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "kk.quickPrompts.custom.v1"
|
||||
|
||||
private(set) var custom: [QuickPrompt]
|
||||
|
||||
private init() {
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([QuickPrompt].self, from: data) {
|
||||
custom = decoded
|
||||
} else {
|
||||
custom = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 内置在前、自定义在后,供 chip 行展示。
|
||||
var all: [QuickPrompt] { Self.builtins + custom }
|
||||
|
||||
/// 追加一条自定义问答。空白忽略;标签自动取问题前几个字。
|
||||
func add(prompt rawPrompt: String) {
|
||||
let trimmed = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
custom.append(QuickPrompt(title: Self.deriveTitle(trimmed),
|
||||
prompt: trimmed,
|
||||
isBuiltin: false))
|
||||
persist()
|
||||
}
|
||||
|
||||
/// 删除一条自定义问答(内置不可删)。
|
||||
func delete(_ p: QuickPrompt) {
|
||||
guard !p.isBuiltin else { return }
|
||||
custom.removeAll { $0.id == p.id }
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(custom) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义条目的短标签:压成单行,取前 8 个字,超出补省略号。
|
||||
static func deriveTitle(_ prompt: String) -> String {
|
||||
let oneLine = prompt.replacingOccurrences(of: "\n", with: " ")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let head = oneLine.prefix(8)
|
||||
return oneLine.count > 8 ? "\(head)…" : String(head)
|
||||
}
|
||||
|
||||
/// 内置 3 条(首屏):覆盖「就诊 / 解读 / 速查」三类,数据依赖稳、不碰诊断红线。
|
||||
static let builtins: [QuickPrompt] = [
|
||||
QuickPrompt(
|
||||
title: "就诊摘要",
|
||||
prompt: "根据我最近的身体症状,结合历史指标,整理一份让门诊医生快速了解我情况的就诊摘要。",
|
||||
isBuiltin: true
|
||||
),
|
||||
QuickPrompt(
|
||||
title: "趋势解读",
|
||||
prompt: "把我血压最近半年的变化讲清楚:是变好还是变差、要注意什么。",
|
||||
isBuiltin: true
|
||||
),
|
||||
QuickPrompt(
|
||||
title: "速答清单",
|
||||
prompt: "把我的过敏史、正在吃的药、慢性病整理成一句话清单,方便就诊时快速回答医生。",
|
||||
isBuiltin: true
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -8,9 +8,12 @@ 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 上不显示该按钮。
|
||||
/// 「重新识别信息」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
||||
var onReanalyze: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -23,7 +26,9 @@ struct CaptureReviewForm: View {
|
||||
pageThumbnails
|
||||
}
|
||||
metaSection
|
||||
if !metaOnly {
|
||||
indicatorSection
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
actions
|
||||
}
|
||||
@@ -68,13 +73,20 @@ struct CaptureReviewForm: View {
|
||||
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
|
||||
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
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(
|
||||
@@ -86,7 +98,6 @@ struct CaptureReviewForm: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - meta(title / type / date / institution / summary)
|
||||
|
||||
@@ -117,11 +128,13 @@ struct CaptureReviewForm: View {
|
||||
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)
|
||||
|
||||
@@ -32,8 +32,14 @@ struct PhotoPickerSheet: View {
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Button("取消", action: onCancel)
|
||||
Button(action: onCancel) {
|
||||
Text("取消")
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.horizontal, 24)
|
||||
.frame(minHeight: 44) // HIG 最小命中区
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if loading {
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
|
||||
@@ -62,7 +62,7 @@ struct UnifiedCaptureFlow: View {
|
||||
switch phase {
|
||||
case .idle: return String(appLoc: "拍摄报告")
|
||||
case .analyzing: return String(appLoc: "本地识别中…")
|
||||
case .editing: return String(appLoc: "核对识别结果")
|
||||
case .editing: return String(appLoc: "核对报告信息")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ struct UnifiedCaptureFlow: View {
|
||||
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) }
|
||||
@@ -152,9 +153,7 @@ struct UnifiedCaptureFlow: View {
|
||||
phase = .analyzing(images: images, assets: nil)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
analyzeTask = Task {
|
||||
// Step 1: 先把图写进 Vault。
|
||||
// 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时,
|
||||
// assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。
|
||||
// Step 1: 先把图写进 Vault(归档的核心价值就是「把原图存下来」,先保证它)。
|
||||
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
|
||||
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
||||
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
||||
@@ -167,7 +166,7 @@ struct UnifiedCaptureFlow: View {
|
||||
phase = .editing(
|
||||
parsed: .empty(),
|
||||
assets: [],
|
||||
warning: String(appLoc: "图片保存失败,手动录入并保留文本")
|
||||
warning: String(appLoc: "图片保存失败,请重试")
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -179,49 +178,40 @@ struct UnifiedCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: VL 推理(timeout 哨兵到点 cancel 父任务,VLSession 在下一个 token break)。
|
||||
// Step 2: 轻量 meta 提取(OCR + 文本 LLM,只抽日期/机构/类型/标题)。
|
||||
// 不再跑多模态逐项识别 —— 那在 2B 上又慢又会 OOM 卡死。watchdog 到点 cancel。
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
analyzeTask?.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
|
||||
if Task.isCancelled {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
|
||||
await MainActor.run {
|
||||
phase = .editing(parsed: .empty(), assets: assets,
|
||||
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
|
||||
}
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: parsed,
|
||||
parsed: meta,
|
||||
assets: assets,
|
||||
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
|
||||
warning: recognized ? nil
|
||||
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
|
||||
)
|
||||
}
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
await editingFallback(assets: assets,
|
||||
msg: Task.isCancelled
|
||||
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
|
||||
: String(appLoc: "推理失败:\(msg)"))
|
||||
} catch CaptureError.modelNotReady {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
|
||||
} catch {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。
|
||||
/// 「重新识别信息」:复用已存 assets,不再写图,只重跑一次轻量 meta 提取。
|
||||
private func reanalyze(assets: [FileVault.SavedAsset]) {
|
||||
analyzeTask?.cancel()
|
||||
// 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可
|
||||
// 这里没有原始 UIImage,AnalyzingView 只把首张缩略图模糊后当背景,降采样到 600px 足够,
|
||||
// 避免为「重新识别」把整页全分辨率原图(数十 MB)载进内存。
|
||||
let thumbnails: [UIImage] = assets.compactMap {
|
||||
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
|
||||
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
|
||||
}
|
||||
phase = .analyzing(images: thumbnails, assets: assets)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
@@ -232,40 +222,19 @@ struct UnifiedCaptureFlow: View {
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
|
||||
if Task.isCancelled {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
|
||||
await MainActor.run {
|
||||
phase = .editing(parsed: .empty(), assets: assets,
|
||||
warning: String(appLoc: "识别超时,已保留原图"))
|
||||
}
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: parsed,
|
||||
assets: assets,
|
||||
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
|
||||
)
|
||||
phase = .editing(parsed: meta, assets: assets,
|
||||
warning: recognized ? nil
|
||||
: String(appLoc: "未能自动识别报告信息,可手动填写"))
|
||||
}
|
||||
} catch CaptureError.modelNotReady {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
await editingFallback(assets: assets,
|
||||
msg: Task.isCancelled
|
||||
? String(appLoc: "识别超时(>\(timeout)s)")
|
||||
: String(appLoc: "推理失败:\(msg)"))
|
||||
} catch {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// reanalyze 失败时回到 editing,保留 assets 但清空 parsed。
|
||||
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
|
||||
await MainActor.run {
|
||||
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +280,9 @@ struct UnifiedCaptureFlow: View {
|
||||
}
|
||||
|
||||
try? ctx.save()
|
||||
// 后台预生成大白话摘要:用户继续操作,详情页打开时秒开。
|
||||
// 低优先级 —— 任何前台 AI 任务(再次拍照/问答)都会让它在下一个 token 让位。
|
||||
Task { await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx) }
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
@@ -359,9 +331,15 @@ private struct AnalyzingView: View {
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ struct DiaryQuickSheet: View {
|
||||
|
||||
@State private var content: String = ""
|
||||
@State private var createdAt: Date = .now
|
||||
/// 「拍药盒」分支:全屏扫描流程,识别后入药品库。
|
||||
@State private var showMedicationScan = false
|
||||
/// 「用药」分支:记一次服用(选药 + 剂量 + 时间),存为带「用药」tag 的日记。
|
||||
@State private var showMedicationLog = false
|
||||
/// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。
|
||||
@State private var showSymptomStart = false
|
||||
|
||||
/// AI 辅助状态
|
||||
enum AssistPhase {
|
||||
@@ -37,6 +43,27 @@ struct DiaryQuickSheet: View {
|
||||
@State private var detent: PresentationDetent = .large
|
||||
@FocusState private var contentFocused: Bool
|
||||
|
||||
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
||||
|
||||
enum VoicePhase: Equatable { case idle, recording, organizing }
|
||||
@State private var voicePhase: VoicePhase = .idle
|
||||
@State private var liveTranscript = ""
|
||||
@State private var recordingSeconds = 0
|
||||
/// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。
|
||||
@State private var rawTranscript: String?
|
||||
/// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。
|
||||
/// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。
|
||||
@State private var organizedAppended: String?
|
||||
/// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。
|
||||
@State private var voiceNote: String?
|
||||
@State private var voiceDeniedAlert = false
|
||||
@State private var voiceFlowTask: Task<Void, Never>?
|
||||
@State private var recordingWatchdog: Task<Void, Never>?
|
||||
/// 必须 @State:struct View 重建(键盘收起/detent 变化都会触发)时普通 let 会换成
|
||||
/// 全新实例,导致 stop() 落在没在录音的新服务上返回空串(「没听清」假错误),
|
||||
/// 且真正在录音的老实例关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一。
|
||||
@State private var dictation = SpeechDictationService()
|
||||
|
||||
private var hasContent: Bool {
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
@@ -45,7 +72,7 @@ struct DiaryQuickSheet: View {
|
||||
if case .loading = phase { return true }
|
||||
return false
|
||||
}
|
||||
private var canRequestSuggest: Bool { hasContent && !isLoading }
|
||||
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||||
private var canSubmit: Bool { hasContent }
|
||||
|
||||
var body: some View {
|
||||
@@ -71,13 +98,59 @@ struct DiaryQuickSheet: View {
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
// 入口四选(2×2):写日记(本页)/ 用药(MedicationLogSheet,记剂量+时间)/
|
||||
// 拍药盒(识别入药品库)/ 记症状(SymptomStartSheet)。
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
|
||||
GridItem(.flexible(), spacing: 10)], spacing: 10) {
|
||||
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
|
||||
subtitle: String(appLoc: "文字或语音"), active: true) {
|
||||
contentFocused = true
|
||||
}
|
||||
modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
|
||||
subtitle: String(appLoc: "记剂量与时间"), active: false) {
|
||||
showMedicationLog = true
|
||||
}
|
||||
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
|
||||
subtitle: String(appLoc: "识别入药品库"), active: false) {
|
||||
showMedicationScan = true
|
||||
}
|
||||
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
|
||||
subtitle: String(appLoc: "持续追踪"), active: false) {
|
||||
showSymptomStart = true
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
sectionLabel(String(appLoc: "内容"))
|
||||
Spacer()
|
||||
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||||
Button(action: startVoice) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.tjScaled(11, weight: .semibold))
|
||||
Text("说一段")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().strokeBorder(
|
||||
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
|
||||
lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading) // AI 追问生成中不抢 AIRuntime 队列
|
||||
}
|
||||
}
|
||||
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
|
||||
text: $content, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
@@ -93,6 +166,48 @@ struct DiaryQuickSheet: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
|
||||
if voicePhase != .idle {
|
||||
DiaryVoicePanel(
|
||||
mode: voicePhase == .organizing
|
||||
? .organizing
|
||||
: .recording(elapsedSeconds: recordingSeconds),
|
||||
transcript: liveTranscript,
|
||||
onStop: stopVoiceAndOrganize,
|
||||
onCancelOrganize: cancelOrganize
|
||||
)
|
||||
}
|
||||
|
||||
if let note = voiceNote {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(note)
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
if let organized = organizedAppended,
|
||||
rawTranscript != nil,
|
||||
content.range(of: organized) != nil {
|
||||
Button(action: revertToRawTranscript) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.uturn.backward")
|
||||
.font(.tjScaled(10, weight: .semibold))
|
||||
Text("改用原话")
|
||||
.font(.tjScaled(11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
assistSection
|
||||
@@ -143,7 +258,40 @@ struct DiaryQuickSheet: View {
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.onDisappear { suggestTask?.cancel() }
|
||||
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||
MedicationScanFlow(
|
||||
onSave: { meds, images in
|
||||
// 识别后入药品库(含原图),不再写日记。服用流水走「写日记 · 用药」模式。
|
||||
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
|
||||
dismiss()
|
||||
},
|
||||
onClose: { showMedicationScan = false }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showSymptomStart) {
|
||||
// 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。
|
||||
SymptomStartSheet()
|
||||
}
|
||||
.sheet(isPresented: $showMedicationLog) {
|
||||
// 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。
|
||||
MedicationLogSheet()
|
||||
}
|
||||
.onDisappear {
|
||||
suggestTask?.cancel()
|
||||
voiceFlowTask?.cancel()
|
||||
recordingWatchdog?.cancel()
|
||||
dictation.abort()
|
||||
}
|
||||
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
|
||||
Button(String(appLoc: "前往设置")) {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AI 辅助区
|
||||
@@ -182,6 +330,7 @@ struct DiaryQuickSheet: View {
|
||||
questionRow(index: roundLocalIndex(at: idx), question: q)
|
||||
}
|
||||
}
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
|
||||
if exhaustedNote {
|
||||
@@ -212,30 +361,12 @@ struct DiaryQuickSheet: View {
|
||||
? String(appLoc: "让 AI 帮我想想还能记什么")
|
||||
: String(appLoc: "先写几个字,AI 来帮忙补充"),
|
||||
enabled: canRequestSuggest,
|
||||
prominent: true,
|
||||
action: requestSuggestions
|
||||
)
|
||||
|
||||
case .loading:
|
||||
HStack(spacing: 10) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("AI 思考中… 本地推理,通常 5-10 秒")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Button("取消") { cancelSuggestions() }
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
assistLoadingIndicator
|
||||
|
||||
case .ready:
|
||||
assistPrimaryButton(
|
||||
@@ -273,26 +404,25 @@ struct DiaryQuickSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 辅助主按钮。`prominent` 为真走实心强调样式(填充 brick + 白字 + 轻投影,一眼可点),
|
||||
/// 否则走低调描边样式(用于 .ready 的「再问一轮」)。
|
||||
private func assistPrimaryButton(icon: String,
|
||||
label: String,
|
||||
enabled: Bool,
|
||||
prominent: Bool = false,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
Text(label)
|
||||
}
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
|
||||
.foregroundStyle(prominent
|
||||
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
|
||||
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 11)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(
|
||||
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
||||
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||||
)
|
||||
)
|
||||
.padding(.vertical, prominent ? 14 : 11)
|
||||
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
|
||||
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@@ -300,6 +430,58 @@ struct DiaryQuickSheet: View {
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
if prominent {
|
||||
shape
|
||||
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
|
||||
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
|
||||
radius: 8, x: 0, y: 3)
|
||||
} else {
|
||||
shape
|
||||
.strokeBorder(
|
||||
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
||||
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// .loading 等待态:安静的 paper 卡片,底部一条细窄的不确定进度条来回滑动(Linear/Vercel 式极简)。
|
||||
/// 不用高亮扫光、不填强调色,避免刺眼;只靠细线 + sparkles 轻脉冲传达「在算」。
|
||||
private var assistLoadingIndicator: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
Text(lastRate > 0
|
||||
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
|
||||
: String(appLoc: "AI 生成中 · 本地推理"))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Button("取消") { cancelSuggestions() }
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
AIFlowBar().padding(.horizontal, 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
/// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
|
||||
private func roundLocalIndex(at idx: Int) -> Int {
|
||||
let target = questions[idx].round
|
||||
@@ -421,6 +603,135 @@ struct DiaryQuickSheet: View {
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
|
||||
/// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。
|
||||
/// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。
|
||||
private func modeCard(icon: String, title: String, subtitle: String,
|
||||
active: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(active ? Tj.Palette.paper : Tj.Palette.ink)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Circle().fill(active ? Tj.Palette.ink : Tj.Palette.sand2))
|
||||
Text(title)
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(subtitle)
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(active ? Tj.Palette.ink : Tj.Palette.line,
|
||||
lineWidth: active ? 1.5 : 1)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: 语音输入流程
|
||||
|
||||
private func startVoice() {
|
||||
contentFocused = false
|
||||
voiceNote = nil
|
||||
voiceFlowTask = Task { @MainActor in
|
||||
guard await dictation.requestAuthorization() else {
|
||||
voiceDeniedAlert = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
liveTranscript = ""
|
||||
recordingSeconds = 0
|
||||
try dictation.start { partial in liveTranscript = partial }
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
|
||||
// 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致)
|
||||
recordingWatchdog = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled, voicePhase == .recording else { return }
|
||||
recordingSeconds += 1
|
||||
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
|
||||
stopVoiceAndOrganize()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
voiceNote = error.localizedDescription
|
||||
voicePhase = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopVoiceAndOrganize() {
|
||||
guard voicePhase == .recording else { return }
|
||||
recordingWatchdog?.cancel()
|
||||
voiceFlowTask = Task { @MainActor in
|
||||
// 防御兜底:服务返回空(极端情况下实例丢失/最终结果丢失)时,
|
||||
// 用 @State 里的实时字幕——那就是用户亲眼看到的已识别文字。
|
||||
var transcript = (await dictation.stop())
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if transcript.isEmpty {
|
||||
transcript = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
liveTranscript = transcript
|
||||
guard !transcript.isEmpty else {
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
voiceNote = String(appLoc: "没听清,再试一次")
|
||||
return
|
||||
}
|
||||
rawTranscript = transcript
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
|
||||
do {
|
||||
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
|
||||
guard !Task.isCancelled else { return }
|
||||
appendToContent(result.text)
|
||||
organizedAppended = result.text
|
||||
lastRate = result.decodeRate
|
||||
} catch is CancellationError {
|
||||
// cancelOrganize 已处理回退,这里只收尾
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||||
organizedAppended = nil
|
||||
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。
|
||||
private func cancelOrganize() {
|
||||
guard voicePhase == .organizing else { return }
|
||||
voiceFlowTask?.cancel()
|
||||
if let raw = rawTranscript {
|
||||
appendToContent(raw)
|
||||
organizedAppended = nil
|
||||
voiceNote = String(appLoc: "已取消整理,填入原话")
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
}
|
||||
|
||||
/// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。
|
||||
private func revertToRawTranscript() {
|
||||
guard let raw = rawTranscript,
|
||||
let organized = organizedAppended,
|
||||
let range = content.range(of: organized, options: .backwards) else { return }
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
content = content.replacingCharacters(in: range, with: raw)
|
||||
organizedAppended = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
||||
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
||||
private func requestSuggestions() {
|
||||
|
||||
141
康康/Features/Diary/DiaryVoicePanel.swift
Normal file
141
康康/Features/Diary/DiaryVoicePanel.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 「健康记录」语音输入面板(spec 2026-06-10-voice-diary)。
|
||||
/// 两种模式:recording(实时字幕 + 计时 + 停止)/ organizing(AI 整理中,可取消)。
|
||||
/// 纯展示:状态由 DiaryQuickSheet 持有并传入。
|
||||
struct DiaryVoicePanel: View {
|
||||
enum Mode: Equatable {
|
||||
case recording(elapsedSeconds: Int)
|
||||
case organizing
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
/// recording 时为实时字幕;organizing 时为已定稿的转写稿(置灰展示)。
|
||||
let transcript: String
|
||||
let onStop: () -> Void
|
||||
let onCancelOrganize: () -> Void
|
||||
|
||||
/// 录音上限 3 分钟(超时由 DiaryQuickSheet 的看门狗触发 onStop)。
|
||||
static let maxRecordingSeconds = 180
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header
|
||||
transcriptArea
|
||||
if case .recording = mode {
|
||||
stopButton
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
if mode == .organizing {
|
||||
AIFlowBar().padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
switch mode {
|
||||
case .recording(let elapsed):
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
Text("正在听 · 识别在本机完成")
|
||||
.font(.tjScaled(13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Text(Self.format(elapsed))
|
||||
.font(.tjScaled(12, design: .monospaced))
|
||||
.foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30
|
||||
? Tj.Palette.brick : Tj.Palette.text3)
|
||||
}
|
||||
case .organizing:
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
Text("AI 整理中 · 本地推理")
|
||||
.font(.tjScaled(13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Button("取消") { onCancelOrganize() }
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var transcriptArea: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(showsIndicators: false) {
|
||||
Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript)
|
||||
.font(.tjScaled(14))
|
||||
.foregroundStyle(transcriptColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Color.clear.frame(height: 1).id("tail")
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.onChange(of: transcript) { _, _ in
|
||||
proxy.scrollTo("tail", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var transcriptColor: Color {
|
||||
if transcript.isEmpty { return Tj.Palette.text3 }
|
||||
return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text
|
||||
}
|
||||
|
||||
private var stopButton: some View {
|
||||
Button(action: onStop) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "stop.circle.fill")
|
||||
Text("说完了,整理成日记")
|
||||
}
|
||||
.font(.tjScaled(14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.brick)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private static func format(_ seconds: Int) -> String {
|
||||
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("录音中") {
|
||||
DiaryVoicePanel(mode: .recording(elapsedSeconds: 23),
|
||||
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||
onStop: {}, onCancelOrganize: {})
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("整理中") {
|
||||
DiaryVoicePanel(mode: .organizing,
|
||||
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||
onStop: {}, onCancelOrganize: {})
|
||||
.padding()
|
||||
}
|
||||
133
康康/Features/Diary/MedicationLogSheet.swift
Normal file
133
康康/Features/Diary/MedicationLogSheet.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 「写日记 · 用药」:记一次服用流水 —— 选药(药品库或手输)+ 剂量 + 时间
|
||||
/// → 带 `DiaryEntry.medicationTag` 的日记,进「记录」时间线的「用药」分类。
|
||||
///
|
||||
/// 与药品库(`Medication`,master 清单)分层:这里是「某次吃了多少、什么时候吃的」。
|
||||
/// 嵌套 sheet,自带保存 / 取消(同 `SymptomStartSheet`),关闭后回到写日记页。
|
||||
struct MedicationLogSheet: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Query(sort: \Medication.updatedAt, order: .reverse)
|
||||
private var library: [Medication]
|
||||
|
||||
/// 选中的药品库药;手输模式为 nil。
|
||||
@State private var selectedMed: Medication?
|
||||
/// 手输药名(药品库为空,或想记不在库里的药)。与 selectedMed 互斥。
|
||||
@State private var manualName = ""
|
||||
@State private var dosage = ""
|
||||
@State private var takenAt: Date = .now
|
||||
|
||||
private var resolvedName: String {
|
||||
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
private var canSave: Bool { !resolvedName.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
if library.isEmpty {
|
||||
TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $manualName)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
} else {
|
||||
ForEach(library) { m in
|
||||
Button { select(m) } label: { medRow(m) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "pencil")
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
TextField(String(appLoc: "或手动输入药名"), text: $manualName)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.onChange(of: manualName) { _, v in
|
||||
if !v.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
selectedMed = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("吃了哪个药")
|
||||
} footer: {
|
||||
if library.isEmpty {
|
||||
Text("药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(appLoc: "剂量,如:1 片 / 80mg"), text: $dosage)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
} header: {
|
||||
Text("剂量")
|
||||
}
|
||||
|
||||
Section {
|
||||
DatePicker(String(appLoc: "时间"), selection: $takenAt, in: ...Date.now)
|
||||
} header: {
|
||||
Text("时间")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle("记录用药")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(String(appLoc: "取消")) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(String(appLoc: "保存")) { save() }
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func medRow(_ m: Medication) -> some View {
|
||||
let on = selectedMed === m
|
||||
return HStack(spacing: 10) {
|
||||
Image(systemName: on ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(on ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(m.name)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
if !m.detailLine.isEmpty {
|
||||
Text(m.detailLine)
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func select(_ m: Medication) {
|
||||
selectedMed = m
|
||||
manualName = ""
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard canSave else { return }
|
||||
// content 单行:「药名 [规格] · 剂量」。剂量进正文,时间用 createdAt 承载。
|
||||
// 与 TimelineEntry.firstLine / TimelineEntryDetailView.medicationLines 单行解析兼容。
|
||||
var line = resolvedName
|
||||
if let s = selectedMed?.strength, !s.isEmpty { line += " \(s)" }
|
||||
let dose = dosage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !dose.isEmpty { line += " · \(dose)" }
|
||||
|
||||
let entry = DiaryEntry(content: line, createdAt: takenAt, tags: [DiaryEntry.medicationTag])
|
||||
ctx.insert(entry)
|
||||
try? ctx.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MedicationLogSheet()
|
||||
.modelContainer(for: [Medication.self, DiaryEntry.self, Asset.self], inMemory: true)
|
||||
}
|
||||
@@ -18,21 +18,19 @@ struct HomeView: View {
|
||||
|
||||
/// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。
|
||||
@State private var selectedEntry: TimelineEntry?
|
||||
/// 点指标行 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。
|
||||
@State private var selectedGroup: IndicatorGroup?
|
||||
|
||||
@MainActor
|
||||
private var recentEntries: [TimelineEntry] {
|
||||
let all =
|
||||
TimelineEntry.from(indicators: indicators) +
|
||||
TimelineEntry.aggregatedIndicators(indicators) +
|
||||
reports.map(TimelineEntry.from(report:)) +
|
||||
diaries.map(TimelineEntry.from(diary:)) +
|
||||
symptoms.map(TimelineEntry.from(symptom:))
|
||||
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
|
||||
}
|
||||
|
||||
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] {
|
||||
TimelineGrouping.group(recentEntries)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -65,6 +63,9 @@ struct HomeView: View {
|
||||
TimelineEntryDetailView(detail: d)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedGroup) { group in
|
||||
IndicatorSeriesDetailView(group: group)
|
||||
}
|
||||
}
|
||||
|
||||
private var greeting: some View {
|
||||
@@ -100,7 +101,10 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var recentSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
// 聚合(含血压配对 O(m²))在一次 body 内只算一次,再派生分组,避免 .isEmpty 与分组各算一遍。
|
||||
let entries = recentEntries
|
||||
let groups = TimelineGrouping.group(entries)
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
@@ -112,11 +116,11 @@ struct HomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if recentEntries.isEmpty {
|
||||
if entries.isEmpty {
|
||||
emptyRecent
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(recentGrouped, id: \.section) { group in
|
||||
ForEach(groups, id: \.section) { group in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(group.section.label)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
@@ -125,12 +129,16 @@ struct HomeView: View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(group.items) { entry in
|
||||
Button {
|
||||
if TimelineDetail.resolve(
|
||||
// 指标 → 同类聚合详情(历次 + 趋势);其余 → 只读详情。与档案库 C1 一致。
|
||||
guard let d = TimelineDetail.resolve(
|
||||
for: entry,
|
||||
indicators: indicators, reports: reports,
|
||||
diaries: diaries, symptoms: symptoms
|
||||
) != nil {
|
||||
selectedEntry = entry
|
||||
) else { return }
|
||||
switch d {
|
||||
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
|
||||
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
|
||||
default: selectedEntry = entry
|
||||
}
|
||||
} label: {
|
||||
TimelineRow(entry: entry)
|
||||
|
||||
@@ -31,6 +31,18 @@ struct IndicatorQuickSheet: View {
|
||||
/// nil 时(如 Preview)不显示拍照按钮。
|
||||
var onRequestCamera: (() -> Void)? = nil
|
||||
|
||||
/// 从已有指标「再记一条」时的预选目标。nil = 正常空白新建。
|
||||
/// seriesKey 命中 MonitorMetric / CustomMonitorMetric → 预选对应预设(保留进趋势 + 自动判异常);
|
||||
/// 否则按 name/unit/range 走自由输入预填。数值一律留空,由用户填新读数。
|
||||
var prefill: Prefill? = nil
|
||||
|
||||
struct Prefill: Equatable {
|
||||
var seriesKey: String?
|
||||
var name: String = ""
|
||||
var unit: String = ""
|
||||
var range: String = ""
|
||||
}
|
||||
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var profiles: [UserProfile]
|
||||
@@ -69,6 +81,32 @@ struct IndicatorQuickSheet: View {
|
||||
// 隐藏管理 sheet 触发态
|
||||
@State private var showHiddenSheet: Bool = false
|
||||
|
||||
// 「再记一条」预选只应用一次
|
||||
@State private var didApplyPrefill = false
|
||||
|
||||
// 顶部搜索:点放大镜展开搜索框,按名实时过滤长期监测预设 / 自定义指标 / 化验项快捷。
|
||||
@State private var searchingMetrics = false
|
||||
@State private var metricQuery = ""
|
||||
|
||||
private var isSearchingMetrics: Bool {
|
||||
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
private var filteredMonitorMetrics: [MonitorMetric] {
|
||||
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty else { return visibleMonitorMetrics }
|
||||
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
private var filteredCustomMetrics: [CustomMonitorMetric] {
|
||||
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty else { return customMetrics }
|
||||
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
private var filteredLabPresets: [IndicatorPreset] {
|
||||
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty else { return labPresets }
|
||||
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
|
||||
private static var defaultReminderTime: Date {
|
||||
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
||||
}
|
||||
@@ -137,12 +175,14 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
footer
|
||||
}
|
||||
.onAppear { applyPrefillIfNeeded() }
|
||||
.task(id: longTermKey) { hydrateReminder() }
|
||||
.background(
|
||||
Tj.Palette.sand
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
.preferredColorScheme(.light)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
@@ -160,7 +200,8 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Text("记录指标")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
@@ -168,12 +209,56 @@ struct IndicatorQuickSheet: View {
|
||||
Text("本地处理 · 永不上传")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
searchToggle
|
||||
}
|
||||
if searchingMetrics { searchField }
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
/// 顶部「拍照识别」入口:并入原「异常项快拍」。点后由 RootView 切到相机 VL 流程。
|
||||
private var searchToggle: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
searchingMetrics.toggle()
|
||||
if !searchingMetrics { metricQuery = "" }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: searchingMetrics ? "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(searchingMetrics ? 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: $metricQuery)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.tint(Tj.Palette.ink)
|
||||
if !metricQuery.isEmpty {
|
||||
Button { metricQuery = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(fieldBg)
|
||||
.overlay(fieldBorder)
|
||||
}
|
||||
|
||||
/// 顶部「拍照识别」入口:并入原「指标速记」。点后由 RootView 切到相机 VL 流程。
|
||||
@ViewBuilder
|
||||
private var cameraEntrySection: some View {
|
||||
if let onRequestCamera {
|
||||
@@ -240,13 +325,19 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
||||
LazyVGrid(columns: columns, spacing: 8) {
|
||||
ForEach(visibleMonitorMetrics) { m in
|
||||
ForEach(filteredMonitorMetrics) { m in
|
||||
monitorTile(m)
|
||||
}
|
||||
ForEach(customMetrics) { cm in
|
||||
ForEach(filteredCustomMetrics) { cm in
|
||||
customTile(cm)
|
||||
}
|
||||
addCustomTile
|
||||
// 搜索态下不显示「自定义(新建)」格,聚焦过滤结果。
|
||||
if !isSearchingMetrics { addCustomTile }
|
||||
}
|
||||
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
|
||||
Text("没有匹配的长期监测指标")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showHiddenSheet) {
|
||||
@@ -385,12 +476,15 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var labPresetSection: some View {
|
||||
// 搜索且化验项无匹配:整段隐藏(避免只剩一个空标题)。
|
||||
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(labPresets) { p in
|
||||
ForEach(filteredLabPresets) { p in
|
||||
chip(p.name, selected: selectedLabPreset == p) {
|
||||
applyLab(p)
|
||||
}
|
||||
@@ -399,6 +493,7 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bpFieldSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -423,6 +518,8 @@ struct IndicatorQuickSheet: View {
|
||||
TextField(placeholder, text: value)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.tjScaled( 20, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.tint(Tj.Palette.ink)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.vertical, 10)
|
||||
.frame(width: 90)
|
||||
@@ -468,6 +565,8 @@ struct IndicatorQuickSheet: View {
|
||||
sectionLabel(String(appLoc: "指标名"))
|
||||
TextField("例如:血红蛋白", text: $name)
|
||||
.textInputAutocapitalization(.never)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.tint(Tj.Palette.ink)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(fieldBg)
|
||||
@@ -489,6 +588,8 @@ struct IndicatorQuickSheet: View {
|
||||
TextField(monitorFieldPlaceholder, text: $value)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.tjScaled( 18, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.tint(Tj.Palette.ink)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(fieldBg)
|
||||
@@ -499,6 +600,8 @@ struct IndicatorQuickSheet: View {
|
||||
TextField("mmol/L", text: $unit)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.tint(Tj.Palette.ink)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(fieldBg)
|
||||
@@ -522,6 +625,8 @@ struct IndicatorQuickSheet: View {
|
||||
TextField("例如:< 3.40 或 3.9 - 6.1", text: $range)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.tint(Tj.Palette.ink)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(fieldBg)
|
||||
@@ -581,6 +686,8 @@ struct IndicatorQuickSheet: View {
|
||||
sectionLabel(String(appLoc: "备注(可选)"))
|
||||
TextField("例如:空腹采血", text: $note, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.tint(Tj.Palette.ink)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(fieldBg)
|
||||
@@ -928,6 +1035,29 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
// MARK: - apply preset
|
||||
|
||||
/// 「再记一条」预选:seriesKey 命中长期监测预设 / 自定义指标则选中对应卡片(进趋势 + 自动判异常),
|
||||
/// 否则按 name/unit/range 走自由输入。数值不预填——让用户填新读数。只应用一次。
|
||||
private func applyPrefillIfNeeded() {
|
||||
guard !didApplyPrefill, let p = prefill else { return }
|
||||
didApplyPrefill = true
|
||||
if let key = p.seriesKey {
|
||||
if let m = MonitorMetric.allCases.first(where: { metric in
|
||||
metric.fields.contains { $0.seriesKey == key }
|
||||
}) {
|
||||
applyMonitor(m)
|
||||
return
|
||||
}
|
||||
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
|
||||
applyCustom(cm)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 无 seriesKey 或未匹配预设(化验项 / 报告 / 自由指标):自由输入预填,不带 seriesKey,不进趋势。
|
||||
name = p.name
|
||||
unit = p.unit
|
||||
range = p.range
|
||||
}
|
||||
|
||||
private func applyMonitor(_ m: MonitorMetric) {
|
||||
if selectedMonitor == m {
|
||||
// 取消选择
|
||||
|
||||
39
康康/Features/Indicator/RecordAnotherButton.swift
Normal file
39
康康/Features/Indicator/RecordAnotherButton.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
extension IndicatorQuickSheet.Prefill {
|
||||
/// 从一条已有指标推断「再记一条」的预选目标:
|
||||
/// seriesKey 命中长期监测 / 自定义指标则预选对应预设(进趋势 + 自动判异常),否则按 name/unit/range 自由预填。
|
||||
init(indicator i: Indicator) {
|
||||
self.init(seriesKey: i.seriesKey, name: i.name, unit: i.unit, range: i.range)
|
||||
}
|
||||
}
|
||||
|
||||
/// 指标详情 / 同类聚合详情底部「再记一条」按钮:打开预选同款指标的录入表单(数值留空,由用户填新读数)。
|
||||
/// 自带弹窗状态,`TimelineEntryDetailView` 与 `IndicatorSeriesDetailView` 共用同一组件。
|
||||
struct RecordAnotherButton: View {
|
||||
/// 按钮文案里显示的指标名(如「空腹血糖」「血压」)。
|
||||
let name: String
|
||||
/// 打开录入表单时的预选目标。
|
||||
let prefill: IndicatorQuickSheet.Prefill
|
||||
|
||||
@State private var showSheet = false
|
||||
|
||||
var body: some View {
|
||||
Button { showSheet = true } label: {
|
||||
Label(String(appLoc: "再记一条「\(name)」"), systemImage: "plus.circle.fill")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.leaf.opacity(0.16))
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.sheet(isPresented: $showSheet) {
|
||||
IndicatorQuickSheet(prefill: prefill)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,20 @@ struct CustomReminderEditSheet: View {
|
||||
|
||||
/// nil = 新建模式。
|
||||
let reminder: CustomReminder?
|
||||
/// 新建模式下的预填(如从「用药记录」点药进来,预填「吃药:药名」+ 用法备注)。
|
||||
/// 编辑模式(reminder != nil)忽略。
|
||||
private let prefillTitle: String
|
||||
private let prefillNote: String
|
||||
|
||||
@State private var title = ""
|
||||
@State private var note = ""
|
||||
@State private var pickedTime: Date = .now
|
||||
@State private var frequency: CustomReminder.Frequency = .daily
|
||||
/// 多选频率(每日/每周/每月/每年 可同时勾选)。
|
||||
@State private var frequencies: Set<CustomReminder.Frequency> = [.daily]
|
||||
@State private var weekdays: Set<Int> = Set(1...7)
|
||||
@State private var dayOfMonth = 1
|
||||
/// 每月多选日期(1...31)。
|
||||
@State private var monthDays: Set<Int> = [1]
|
||||
@State private var dayOfMonth = 1 // 仅「每年」用
|
||||
@State private var month = 1
|
||||
@State private var hydrated = false
|
||||
@State private var showAuthDeniedAlert = false
|
||||
@@ -24,8 +31,10 @@ struct CustomReminderEditSheet: View {
|
||||
/// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。
|
||||
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
|
||||
|
||||
init(reminder: CustomReminder? = nil) {
|
||||
init(reminder: CustomReminder? = nil, prefillTitle: String = "", prefillNote: String = "") {
|
||||
self.reminder = reminder
|
||||
self.prefillTitle = prefillTitle
|
||||
self.prefillNote = prefillNote
|
||||
}
|
||||
|
||||
private var isEditing: Bool { reminder != nil }
|
||||
@@ -33,8 +42,9 @@ struct CustomReminderEditSheet: View {
|
||||
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
private var canSave: Bool {
|
||||
guard !trimmedTitle.isEmpty else { return false }
|
||||
if frequency == .weekly { return !weekdays.isEmpty }
|
||||
guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false }
|
||||
if frequencies.contains(.weekly) && weekdays.isEmpty { return false }
|
||||
if frequencies.contains(.monthly) && monthDays.isEmpty { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -51,18 +61,12 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker(String(appLoc: "重复"), selection: $frequency) {
|
||||
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
|
||||
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
|
||||
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
|
||||
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
frequencyChips
|
||||
frequencyDetail
|
||||
} header: {
|
||||
Text("重复")
|
||||
} footer: {
|
||||
Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。")
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -109,23 +113,60 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 频率子控件
|
||||
// MARK: - 频率多选 chip
|
||||
|
||||
private static let freqOrder: [CustomReminder.Frequency] = [.daily, .weekly, .monthly, .yearly]
|
||||
|
||||
private func freqLabel(_ f: CustomReminder.Frequency) -> String {
|
||||
switch f {
|
||||
case .daily: return String(appLoc: "每日")
|
||||
case .weekly: return String(appLoc: "每周")
|
||||
case .monthly: return String(appLoc: "每月")
|
||||
case .yearly: return String(appLoc: "每年")
|
||||
}
|
||||
}
|
||||
|
||||
private var frequencyChips: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Self.freqOrder, id: \.self) { f in
|
||||
let on = frequencies.contains(f)
|
||||
Button {
|
||||
if on { frequencies.remove(f) } else { frequencies.insert(f) }
|
||||
} label: {
|
||||
Text(freqLabel(f))
|
||||
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
|
||||
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 32)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// MARK: - 频率子控件(随勾选项展开,可同时出现多组)
|
||||
|
||||
@ViewBuilder
|
||||
private var frequencyDetail: some View {
|
||||
switch frequency {
|
||||
case .daily:
|
||||
EmptyView()
|
||||
case .weekly:
|
||||
if frequencies.contains(.weekly) {
|
||||
subCaption(String(appLoc: "每周 · 选星期几"))
|
||||
weekdayRow
|
||||
case .monthly:
|
||||
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
|
||||
ForEach(1...31, id: \.self) { d in
|
||||
Text(String(appLoc: "\(d)日")).tag(d)
|
||||
}
|
||||
if frequencies.contains(.monthly) {
|
||||
subCaption(String(appLoc: "每月 · 选日期(可多选)"))
|
||||
monthDayGrid
|
||||
if monthDays.contains(where: { $0 >= 29 }) { skipHint }
|
||||
}
|
||||
if dayOfMonth >= 29 { skipHint }
|
||||
case .yearly:
|
||||
if frequencies.contains(.yearly) {
|
||||
subCaption(String(appLoc: "每年 · 选月/日"))
|
||||
Picker(String(appLoc: "月份"), selection: $month) {
|
||||
ForEach(1...12, id: \.self) { mo in
|
||||
Text(String(appLoc: "\(mo)月")).tag(mo)
|
||||
@@ -140,6 +181,41 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func subCaption(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
/// 每月日期多选网格(1...31,7 列)。
|
||||
private var monthDayGrid: some View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
|
||||
ForEach(1...31, id: \.self) { d in
|
||||
let on = monthDays.contains(d)
|
||||
Button {
|
||||
if on { monthDays.remove(d) } else { monthDays.insert(d) }
|
||||
} label: {
|
||||
Text("\(d)")
|
||||
.font(.tjScaled( 12, weight: on ? .semibold : .regular))
|
||||
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 30)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private var skipHint: some View {
|
||||
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
||||
.font(.tjScaled( 11))
|
||||
@@ -229,13 +305,18 @@ struct CustomReminderEditSheet: View {
|
||||
if let r = reminder {
|
||||
title = r.title
|
||||
note = r.note
|
||||
frequency = r.frequency
|
||||
frequencies = r.frequencies
|
||||
weekdays = Set(r.weekdays)
|
||||
monthDays = Set(r.monthlyDays)
|
||||
dayOfMonth = r.dayOfMonth
|
||||
month = r.month
|
||||
pickedTime = Calendar.current.date(
|
||||
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
||||
) ?? .now
|
||||
} else {
|
||||
// 新建模式:从调用方带进来的预填(药名 / 用法)。
|
||||
title = prefillTitle
|
||||
note = prefillNote
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +326,7 @@ struct CustomReminderEditSheet: View {
|
||||
let hour = cal.component(.hour, from: pickedTime)
|
||||
let minute = cal.component(.minute, from: pickedTime)
|
||||
let sortedDays = weekdays.sorted()
|
||||
let sortedMonthDays = monthDays.sorted()
|
||||
|
||||
let target: CustomReminder
|
||||
if let r = reminder {
|
||||
@@ -253,8 +335,9 @@ struct CustomReminderEditSheet: View {
|
||||
r.hour = hour
|
||||
r.minute = minute
|
||||
r.weekdays = sortedDays
|
||||
r.frequency = frequency
|
||||
r.dayOfMonth = dayOfMonth
|
||||
r.frequencies = frequencies // 写 frequenciesRaw(+ 代表 frequencyRaw)
|
||||
r.monthlyDays = sortedMonthDays // 写 monthDays
|
||||
r.dayOfMonth = dayOfMonth // 仅每年用
|
||||
r.month = month
|
||||
r.updatedAt = .now
|
||||
target = r
|
||||
@@ -265,10 +348,11 @@ struct CustomReminderEditSheet: View {
|
||||
hour: hour,
|
||||
minute: minute,
|
||||
weekdays: sortedDays,
|
||||
frequency: frequency,
|
||||
dayOfMonth: dayOfMonth,
|
||||
month: month
|
||||
)
|
||||
new.frequencies = frequencies
|
||||
new.monthlyDays = sortedMonthDays
|
||||
ctx.insert(new)
|
||||
target = new
|
||||
}
|
||||
|
||||
153
康康/Features/Me/InferenceSettingsView.swift
Normal file
153
康康/Features/Me/InferenceSettingsView.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 推理引擎设置:在 MNN(CPU/SME2,考核路径)与 MLX(GPU,兜底)间切换,并展示 SME2 探测状态。
|
||||
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
||||
struct InferenceSettingsView: View {
|
||||
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
|
||||
|
||||
private var selected: EnginePreference {
|
||||
EnginePreference(rawValue: engineRaw) ?? .auto
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("推理引擎")
|
||||
.font(.tjTitle())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
ForEach(EnginePreference.allCases, id: \.self) { engine in
|
||||
engineRow(engine)
|
||||
}
|
||||
|
||||
sme2Card
|
||||
noteCard
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func engineRow(_ engine: EnginePreference) -> some View {
|
||||
let available = isAvailable(engine)
|
||||
let isOn = (selected == engine)
|
||||
return Button {
|
||||
guard available else { return }
|
||||
engineRaw = engine.rawValue
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(isOn ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||
Image(systemName: iconName(engine))
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(isOn ? Tj.Palette.ink : Tj.Palette.text2)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(engine.displayName)
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(subtitle(engine, available: available))
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
if isOn {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.tjCard()
|
||||
.opacity(available ? 1 : 0.45)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!available)
|
||||
}
|
||||
|
||||
/// .auto 永远可用;具体引擎看自身可用性。
|
||||
private func isAvailable(_ engine: EnginePreference) -> Bool {
|
||||
switch engine {
|
||||
case .auto: return true
|
||||
case .mnn: return InferenceEngine.mnn.isAvailable
|
||||
case .mlx: return InferenceEngine.mlx.isAvailable
|
||||
}
|
||||
}
|
||||
|
||||
private func iconName(_ engine: EnginePreference) -> String {
|
||||
switch engine {
|
||||
case .auto: return "wand.and.stars"
|
||||
case .mnn: return "cpu.fill"
|
||||
case .mlx: return "bolt.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func subtitle(_ engine: EnginePreference, available: Bool) -> String {
|
||||
switch engine {
|
||||
case .auto:
|
||||
// 显示自动解析后实际命中的引擎,让用户看清「这台机选了什么」。
|
||||
let resolved = engine.resolved
|
||||
if resolved == .mnn {
|
||||
return InferenceEngine.cpuSupportsSME2
|
||||
? String(appLoc: "按本机配置选择 · 当前 MNN + SME2")
|
||||
: String(appLoc: "按本机配置选择 · 当前 MNN(NEON)")
|
||||
} else {
|
||||
return String(appLoc: "按本机配置选择 · 当前 MLX(MNN 不可用)")
|
||||
}
|
||||
case .mnn:
|
||||
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
|
||||
return InferenceEngine.cpuSupportsSME2
|
||||
? String(appLoc: "端侧 CPU + SME2 加速 · 挑战赛考核路径")
|
||||
: String(appLoc: "端侧 CPU(本机无 SME2,NEON 回退)")
|
||||
case .mlx:
|
||||
return String(appLoc: "Metal GPU · 兜底 / 对照")
|
||||
}
|
||||
}
|
||||
|
||||
private var sme2Card: some View {
|
||||
let sme2 = InferenceEngine.cpuSupportsSME2
|
||||
return HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(sme2 ? Tj.Palette.leafSoft : Tj.Palette.sand2)
|
||||
Image(systemName: sme2 ? "checkmark.seal.fill" : "minus.circle")
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(sme2 ? Tj.Palette.ink : Tj.Palette.text2)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Arm SME2")
|
||||
.font(.tjScaled(15, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(sme2 ? String(appLoc: "本设备支持,MNN 已启用 SME2 加速")
|
||||
: String(appLoc: "本设备不支持(需 A19/iPhone 17+)"))
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
private var noteCard: some View {
|
||||
Text("MNN 在端侧 CPU 上以 Arm SME2 指令集加速 Qwen 推理(本地、不上云)。切换后下一次 AI 调用生效。")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.tjCard()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
InferenceSettingsView()
|
||||
}
|
||||
@@ -37,6 +37,7 @@ struct MeView: View {
|
||||
profileCard
|
||||
customMetricsCard
|
||||
modelManagementCard
|
||||
inferenceEngineCard
|
||||
languageCard
|
||||
fontScaleCard
|
||||
faceIDCard
|
||||
@@ -151,10 +152,26 @@ struct MeView: View {
|
||||
|
||||
private var modelDetail: String {
|
||||
let states = downloadService.states
|
||||
if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return String(appLoc: "已就绪") }
|
||||
if ModelKind.userFacing.allSatisfy({ states[$0]?.phase == .ready }) { return String(appLoc: "已就绪") }
|
||||
if downloadService.isAnyDownloading { return String(appLoc: "下载中…") }
|
||||
let readyCount = ModelKind.allCases.filter { states[$0]?.phase == .ready }.count
|
||||
return readyCount == 0 ? String(appLoc: "未下载") : String(appLoc: "\(readyCount)/\(ModelKind.allCases.count) 就绪")
|
||||
let readyCount = ModelKind.userFacing.filter { states[$0]?.phase == .ready }.count
|
||||
return readyCount == 0 ? String(appLoc: "未下载") : String(appLoc: "\(readyCount)/\(ModelKind.userFacing.count) 就绪")
|
||||
}
|
||||
|
||||
private var inferenceEngineCard: some View {
|
||||
NavigationLink {
|
||||
InferenceSettingsView()
|
||||
} label: {
|
||||
settingsCard(title: String(appLoc: "推理引擎"), detail: engineDetail, icon: "cpu.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var engineDetail: String {
|
||||
switch InferenceEngine.current {
|
||||
case .mnn: return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · CPU"
|
||||
case .mlx: return "MLX · GPU"
|
||||
}
|
||||
}
|
||||
|
||||
private var languageCard: some View {
|
||||
@@ -265,6 +282,6 @@ struct MeView: View {
|
||||
.modelContainer(for: [
|
||||
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
|
||||
Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self,
|
||||
CustomMonitorMetric.self,
|
||||
CustomMonitorMetric.self, Medication.self,
|
||||
], inMemory: true)
|
||||
}
|
||||
|
||||
@@ -15,26 +15,26 @@ struct ModelManagementView: View {
|
||||
private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
|
||||
|
||||
private var allReady: Bool {
|
||||
ModelKind.allCases.allSatisfy { service.states[$0]?.phase == .ready }
|
||||
ModelKind.userFacing.allSatisfy { service.states[$0]?.phase == .ready }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(ModelKind.allCases, id: \.self) { kind in
|
||||
ForEach(ModelKind.userFacing, id: \.self) { kind in
|
||||
modelCard(kind)
|
||||
}
|
||||
|
||||
actionButtons
|
||||
.padding(.top, 4)
|
||||
|
||||
if service.states[.llm]?.phase == .ready {
|
||||
if service.states[.mnnLLM]?.phase == .ready || service.states[.llm]?.phase == .ready {
|
||||
NavigationLink {
|
||||
ModelSelfTestView()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "play.circle")
|
||||
Text("运行推理自检")
|
||||
Image(systemName: "gauge.with.needle")
|
||||
Text("性能自检")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ struct ModelManagementView: View {
|
||||
private var actionButtons: some View {
|
||||
if service.isAnyDownloading {
|
||||
Button {
|
||||
for kind in ModelKind.allCases { service.cancel(kind) }
|
||||
for kind in ModelKind.userFacing { service.cancel(kind) }
|
||||
} label: {
|
||||
Text("暂停下载").frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ struct ModelManagementView: View {
|
||||
} else if allReady {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
Text("两个模型都已就绪")
|
||||
Text("Qwen3.5-2B 已就绪")
|
||||
}
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
@@ -198,8 +198,8 @@ struct ModelManagementView: View {
|
||||
defer { if scoped { folder.stopAccessingSecurityScopedResource() } }
|
||||
|
||||
let name = folder.lastPathComponent
|
||||
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else {
|
||||
let names = ModelKind.allCases.map(\.rawValue).joined(separator: " 或 ")
|
||||
guard let kind = ModelKind.userFacing.first(where: { $0.rawValue == name }) else {
|
||||
let names = ModelKind.userFacing.map(\.rawValue).joined(separator: " 或 ")
|
||||
importError = String(appLoc: "请选择名为 \(names) 的文件夹")
|
||||
return
|
||||
}
|
||||
@@ -213,13 +213,14 @@ struct ModelManagementView: View {
|
||||
// MARK: - 辅助
|
||||
|
||||
private var totalAllBytes: Int {
|
||||
ModelKind.allCases.reduce(0) { $0 + ModelManifest.totalBytes(for: $1) }
|
||||
ModelKind.userFacing.reduce(0) { $0 + ModelManifest.totalBytes(for: $1) }
|
||||
}
|
||||
|
||||
private func subtitle(_ kind: ModelKind) -> String {
|
||||
switch kind {
|
||||
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答")
|
||||
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答(MLX 兜底)")
|
||||
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
|
||||
case .mnnLLM: return String(appLoc: "文本解读 + 拍照识别 · MNN + SME2 端侧加速")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 模型推理自检:加载 LLM 跑一段固定 prompt,流式显示输出 + tok/s。
|
||||
/// 模型就绪后从「我的 · 模型管理」进入,用于现场快速验证本地推理是否正常。
|
||||
/// 性能自检:跑固定 prompt,展示当前后端(MNN·SME2 / MNN·NEON / MLX·GPU)的
|
||||
/// prefill / decode 实测速度,并按后端存档对比 —— 挑战赛考核点的可见证据(§12 卖点 2/6)。
|
||||
struct ModelSelfTestView: View {
|
||||
@State private var output = ""
|
||||
@State private var phase: Phase = .idle
|
||||
@State private var rate: Double = 0
|
||||
@State private var lastResult: BenchmarkResult?
|
||||
@State private var history: [String: BenchmarkResult] = [:]
|
||||
|
||||
private enum Phase: Equatable {
|
||||
case idle, loading, running, done, failed(String)
|
||||
@@ -21,8 +23,6 @@ struct ModelSelfTestView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。"
|
||||
|
||||
private var isBusy: Bool { phase == .loading || phase == .running }
|
||||
|
||||
private var statusColor: Color {
|
||||
@@ -34,19 +34,9 @@ struct ModelSelfTestView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("测试 PROMPT")
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(prompt)
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard()
|
||||
promptCard
|
||||
|
||||
HStack {
|
||||
Text(phase.label)
|
||||
@@ -64,11 +54,79 @@ struct ModelSelfTestView: View {
|
||||
Button {
|
||||
Task { await run() }
|
||||
} label: {
|
||||
Text(isBusy ? "运行中…" : "运行推理自检").frame(maxWidth: .infinity)
|
||||
Text(isBusy ? "运行中…" : "运行性能自检").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
.disabled(isBusy)
|
||||
|
||||
if isBusy { AIFlowBar() }
|
||||
|
||||
if let r = lastResult { statsCard(r) }
|
||||
|
||||
outputCard
|
||||
|
||||
if !history.isEmpty { historyCard }
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle("性能自检")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { history = BenchmarkService.load() }
|
||||
}
|
||||
|
||||
private var promptCard: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("测试 PROMPT")
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(BenchmarkService.fixedPrompt)
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
private func statsCard(_ r: BenchmarkResult) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("本次结果")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
TjBadge(text: r.backendLabel, style: .leaf)
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
metric(String(appLoc: "读入"), r.prefillTokensPerSecond > 0
|
||||
? String(format: "%.0f tok/s", r.prefillTokensPerSecond) : "—")
|
||||
metric(String(appLoc: "生成"), String(format: "%.1f tok/s", r.decodeTokensPerSecond))
|
||||
metric(String(appLoc: "总耗时"), String(format: "%.1fs", r.totalSeconds))
|
||||
}
|
||||
Text(String(appLoc: "prompt \(r.promptTokens) tok · 生成 \(r.genTokens) tok · 100% 本地"))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
private func metric(_ label: String, _ value: String) -> some View {
|
||||
VStack(spacing: 3) {
|
||||
Text(value)
|
||||
.font(.tjScaled( 15, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(label)
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var outputCard: some View {
|
||||
ScrollView {
|
||||
Text(output.isEmpty ? "(暂无输出)" : output)
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
@@ -77,7 +135,7 @@ struct ModelSelfTestView: View {
|
||||
.textSelection(.enabled)
|
||||
.padding(12)
|
||||
}
|
||||
.frame(maxHeight: 280)
|
||||
.frame(maxHeight: 220)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
@@ -86,27 +144,52 @@ struct ModelSelfTestView: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle("推理自检")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
private var historyCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("各引擎实测对比")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
ForEach(history.keys.sorted(), id: \.self) { key in
|
||||
if let r = history[key] {
|
||||
HStack {
|
||||
Text(key)
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text(String(format: String(appLoc: "生成 %.1f tok/s"), r.decodeTokensPerSecond))
|
||||
.font(.tjScaled( 12, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
Text(r.date.formatted(.dateTime.month().day()))
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("在「我的 · 推理引擎」切换引擎后再跑一次,即可对比 SME2 与 GPU。")
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func run() async {
|
||||
output = ""
|
||||
rate = 0
|
||||
lastResult = nil
|
||||
phase = .loading
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
phase = .running
|
||||
for try await chunk in await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200) {
|
||||
output += chunk.text
|
||||
rate = chunk.decodeRate
|
||||
let result = try await BenchmarkService.shared.run { piece, r in
|
||||
output += piece
|
||||
if r > 0 { rate = r }
|
||||
if phase == .loading { phase = .running }
|
||||
}
|
||||
lastResult = result
|
||||
history = BenchmarkService.load()
|
||||
phase = .done
|
||||
} catch {
|
||||
phase = .failed(error.localizedDescription)
|
||||
|
||||
383
康康/Features/Profile/MedicationLibraryView.swift
Normal file
383
康康/Features/Profile/MedicationLibraryView.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 「记录 · 药品库」管理页:我有哪些药的 master 清单(药名 / 规格 / 用法 / 原图)。
|
||||
/// 拍药盒识别或手动录入入库;某次服用流水另走「写日记 · 用药」(带 `DiaryEntry.medicationTag` 的日记,含剂量 + 时间)。
|
||||
/// 列表 / 增删改范式照搬 `CustomMetricsListView`;编辑表单照搬 `CustomReminderEditSheet`。
|
||||
struct MedicationLibraryView: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Query(sort: \Medication.updatedAt, order: .reverse)
|
||||
private var medications: [Medication]
|
||||
|
||||
/// sheet 形态(从「记录」拉起)补「完成」按钮;push 形态不补,靠返回键。
|
||||
var presentedAsSheet: Bool = false
|
||||
|
||||
@State private var editingTarget: MedicationEditTarget?
|
||||
@State private var showScan = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
hintBanner
|
||||
if medications.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(medications) { m in
|
||||
Button {
|
||||
editingTarget = MedicationEditTarget(medication: m)
|
||||
} label: {
|
||||
row(m)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle("药品库")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if presentedAsSheet {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(String(appLoc: "完成")) { dismiss() }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
Button { showScan = true } label: {
|
||||
Image(systemName: "camera")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
}
|
||||
.accessibilityLabel(String(appLoc: "拍药盒添加"))
|
||||
Button { editingTarget = MedicationEditTarget(medication: nil) } label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
}
|
||||
.accessibilityLabel(String(appLoc: "手动添加"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingTarget) { target in
|
||||
MedicationEditSheet(existing: target.medication)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showScan) {
|
||||
// 拍药盒 → 本地 OCR + LLM 识别 → 核对 → 入药品库(含原图)。
|
||||
MedicationScanFlow(
|
||||
onSave: { meds, images in
|
||||
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
|
||||
},
|
||||
onClose: { showScan = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - subviews
|
||||
|
||||
private var hintBanner: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("药品库是你的常用药清单。记录某次服用请到「写日记 · 用药」,可填剂量和时间。")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2.opacity(0.5))
|
||||
)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 14) {
|
||||
Spacer(minLength: 40)
|
||||
TjPlaceholder(label: String(appLoc: "药品库还是空的"))
|
||||
.frame(width: 220, height: 130)
|
||||
Text("右上角拍药盒或 + 手动添加")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func row(_ m: Medication) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.leafSoft)
|
||||
Image(systemName: "pills.fill")
|
||||
.font(.tjScaled( 17, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(m.name)
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
if !m.detailLine.isEmpty {
|
||||
Text(m.detailLine)
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if !m.assets.isEmpty {
|
||||
Text("📷 \(m.assets.count)")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.tjScaled( 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `medication == nil` → 新建;否则编辑。`id` 用 UUID 让同一对象重开 sheet 也能刷新。
|
||||
private struct MedicationEditTarget: Identifiable {
|
||||
let id = UUID()
|
||||
let medication: Medication?
|
||||
}
|
||||
|
||||
/// 药品库的新建 / 编辑表单(范式同 `CustomReminderEditSheet`:本地 @State 暂存,保存才写库)。
|
||||
private struct MedicationEditSheet: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// nil = 新建模式。
|
||||
let existing: Medication?
|
||||
|
||||
@State private var name = ""
|
||||
@State private var strength = ""
|
||||
@State private var usage = ""
|
||||
@State private var note = ""
|
||||
@State private var hydrated = false
|
||||
/// 点缩略图全屏查看的起始页;nil = 未打开查看器。
|
||||
@State private var viewerStart: PhotoIndex?
|
||||
|
||||
private var isEditing: Bool { existing != nil }
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if let m = existing, !m.assets.isEmpty {
|
||||
Section {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(Array(m.assets.enumerated()), id: \.offset) { idx, asset in
|
||||
Button {
|
||||
viewerStart = PhotoIndex(index: idx)
|
||||
} label: {
|
||||
MedicationAssetThumb(asset: asset)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
|
||||
} header: {
|
||||
Text(String(appLoc: "原图\(m.assets.count)张"))
|
||||
} footer: {
|
||||
Text("点图片可放大查看。原图均存在本机加密目录,不上传。")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $name)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $strength)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $usage)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
} footer: {
|
||||
Text("仅作清单记录,不提供任何用药或剂量建议。")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
|
||||
if isEditing {
|
||||
Section {
|
||||
Button(role: .destructive) { deleteMedication() } label: {
|
||||
Label(String(appLoc: "从药品库删除"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle(isEditing ? String(appLoc: "编辑药品") : String(appLoc: "添加药品"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(String(appLoc: "取消")) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(String(appLoc: "保存")) { save() }
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: hydrate)
|
||||
.fullScreenCover(item: $viewerStart) { start in
|
||||
if let m = existing {
|
||||
MedicationPhotoViewer(assets: m.assets, startIndex: start.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hydrate() {
|
||||
guard !hydrated else { return }
|
||||
hydrated = true
|
||||
if let m = existing {
|
||||
name = m.name
|
||||
strength = m.strength
|
||||
usage = m.usage
|
||||
note = m.note ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard canSave else { return }
|
||||
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let s = strength.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let u = usage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nt = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let m = existing {
|
||||
m.name = n
|
||||
m.strength = s
|
||||
m.usage = u
|
||||
m.note = nt.isEmpty ? nil : nt
|
||||
m.updatedAt = .now
|
||||
} else {
|
||||
let med = Medication(name: n, strength: s, usage: u, note: nt.isEmpty ? nil : nt)
|
||||
ctx.insert(med)
|
||||
}
|
||||
try? ctx.save()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func deleteMedication() {
|
||||
guard let m = existing else { return }
|
||||
// 先删 Vault 里的 JPEG(cascade 只删 Asset 记录,文件要手动 unlink,§6 永久删除)。
|
||||
for a in m.assets {
|
||||
try? FileVault.shared.remove(relativePath: a.relativePath)
|
||||
}
|
||||
ctx.delete(m)
|
||||
try? ctx.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 原图查看
|
||||
|
||||
/// 全屏查看器的起始页载体(`.fullScreenCover(item:)` 需 Identifiable)。
|
||||
private struct PhotoIndex: Identifiable {
|
||||
let id = UUID()
|
||||
let index: Int
|
||||
}
|
||||
|
||||
/// 药品库行内 / 编辑表单里的方形缩略图。原图从加密 Vault 同步读取(数量少,与 EvidenceImagePage 同款)。
|
||||
private struct MedicationAssetThumb: View {
|
||||
let asset: Asset
|
||||
|
||||
var body: some View {
|
||||
VaultImage(relativePath: asset.relativePath, maxPixel: 500) { img in
|
||||
Image(uiImage: img).resizable().scaledToFill()
|
||||
} placeholder: { isLoading in
|
||||
if isLoading {
|
||||
Tj.Palette.paper
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
}
|
||||
}
|
||||
.frame(width: 150, height: 150)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 全屏翻页查看药品原图(看清药盒小字)。
|
||||
private struct MedicationPhotoViewer: 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 {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
TabView(selection: $selection) {
|
||||
ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
|
||||
VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { img in
|
||||
Image(uiImage: img).resizable().scaledToFit()
|
||||
} placeholder: { isLoading in
|
||||
if isLoading {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
}
|
||||
}
|
||||
.tag(idx)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never))
|
||||
.ignoresSafeArea()
|
||||
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(.black.opacity(0.4)))
|
||||
}
|
||||
.padding(.trailing, 18)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
MedicationLibraryView(presentedAsSheet: true)
|
||||
}
|
||||
.modelContainer(for: [Medication.self, Asset.self], inMemory: true)
|
||||
}
|
||||
452
康康/Features/Profile/MedicationScanFlow.swift
Normal file
452
康康/Features/Profile/MedicationScanFlow.swift
Normal file
@@ -0,0 +1,452 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
/// 「拍药盒入库」流程:拍药盒/说明书(最多 5 张,选一张识别)→ Vision OCR → LLM 结构化 → 核对 → 存入药品库(连同原图)。
|
||||
/// 入口:「记录 · 药品库」与「记录 · 健康日记 · 拍药盒」。
|
||||
/// 两个入口确认后都走 `MedicationArchiver`:每条药建一个 `Medication`(挂原图),不写日记、不写当前用药。
|
||||
/// 服用流水改由「写日记 · 用药」生成带 `medicationTag` 的 DiaryEntry。只识别入库,不做用药提醒/剂量建议(§1)。
|
||||
///
|
||||
/// 状态机:
|
||||
/// ```
|
||||
/// idle(相机/相册) ─拍到第1张→ collecting(复看:删/继续拍≤5/选一张/开始识别)
|
||||
/// │ 开始识别
|
||||
/// ▼
|
||||
/// recognizing(选中单张 OCR + LLM) ─→ confirm(核对一种药) ─onSave→ 关闭
|
||||
/// │ 失败/没读出 ───────────────► confirm(空行 + 警示)
|
||||
/// ```
|
||||
struct MedicationScanFlow: View {
|
||||
/// 用户确认后回传(结构化药品, 原图)。入库由调用方做(走 MedicationArchiver.archive(medications:))。
|
||||
let onSave: ([ParsedMedication], [UIImage]) -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
/// 一种药最多关联 5 张原图(正面/背面/说明书…)。
|
||||
static let maxImages = 5
|
||||
|
||||
@State private var phase: Phase = .idle
|
||||
/// 已拍/已选的原图,跨 collecting → recognizing → confirm 一直留着,确认时全部作为该药原图落库。
|
||||
@State private var images: [UIImage] = []
|
||||
/// 识别用的照片索引(在多张里单选一张)。一次只记一种药 → 只 OCR 这一张;删图时校正。
|
||||
@State private var recognizeIndex = 0
|
||||
/// 在 collecting 复看页「继续拍/继续选」时弹相机或相册。
|
||||
@State private var showMoreCapture = false
|
||||
/// 识别任务句柄:识别中点「取消」要能立刻中断,不留后台推理。
|
||||
@State private var recognitionTask: Task<Void, Never>?
|
||||
|
||||
enum Phase {
|
||||
case idle
|
||||
case collecting
|
||||
case recognizing
|
||||
case confirm(items: [EditableMedication], warning: String?)
|
||||
}
|
||||
|
||||
struct EditableMedication: Identifiable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var strength: String
|
||||
var usage: String
|
||||
var include: Bool = true
|
||||
}
|
||||
|
||||
private var remainingSlots: Int { max(0, Self.maxImages - images.count) }
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
// 不整体 ignoresSafeArea:相机内部已全屏黑底,忽略安全区会让「取消」顶进灵动岛。
|
||||
initialCaptureEntry
|
||||
|
||||
case .collecting:
|
||||
collectingView
|
||||
.fullScreenCover(isPresented: $showMoreCapture) { moreCaptureSheet }
|
||||
|
||||
case .recognizing:
|
||||
recognizingView
|
||||
|
||||
case .confirm(let items, let warning):
|
||||
NavigationStack {
|
||||
MedicationConfirmView(
|
||||
items: items,
|
||||
warning: warning,
|
||||
onSave: { saveItems($0) },
|
||||
onRetake: { images = []; phase = .idle }
|
||||
)
|
||||
.navigationTitle("核对药品")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("取消") { onClose() }
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 入口:拍照(真机)/ 相册(模拟器)
|
||||
|
||||
/// 首张:进入即拍/选。拿到第一张就转 collecting 复看。
|
||||
@ViewBuilder
|
||||
private var initialCaptureEntry: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
onFinish: { picked in
|
||||
appendImages(picked)
|
||||
if images.isEmpty { onClose() } else { phase = .collecting }
|
||||
},
|
||||
onCancel: onClose
|
||||
)
|
||||
#else
|
||||
SingleShotCameraView(
|
||||
onCapture: { appendImages([$0]); phase = .collecting },
|
||||
onCancel: onClose
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// collecting 复看页里「继续拍/继续选」弹出的二次采集。
|
||||
@ViewBuilder
|
||||
private var moreCaptureSheet: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
onFinish: { picked in appendImages(picked); showMoreCapture = false },
|
||||
onCancel: { showMoreCapture = false }
|
||||
)
|
||||
#else
|
||||
SingleShotCameraView(
|
||||
onCapture: { appendImages([$0]); showMoreCapture = false },
|
||||
onCancel: { showMoreCapture = false }
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func appendImages(_ new: [UIImage]) {
|
||||
guard remainingSlots > 0 else { return }
|
||||
images.append(contentsOf: new.prefix(remainingSlots))
|
||||
}
|
||||
|
||||
// MARK: - 复看(已拍 N 张:删 / 继续拍 / 开始识别)
|
||||
|
||||
private var collectingView: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 12)], spacing: 12) {
|
||||
ForEach(Array(images.enumerated()), id: \.offset) { idx, img in
|
||||
let isPick = idx == recognizeIndex
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 96, height: 96)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(isPick ? Tj.Palette.ink : Color.clear, lineWidth: 3)
|
||||
)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if isPick {
|
||||
Text("识别此张")
|
||||
.font(.tjScaled(10, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Tj.Palette.ink))
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
// 点图把它选为「识别用」那张(单选)。
|
||||
.onTapGesture { recognizeIndex = idx }
|
||||
Button {
|
||||
images.remove(at: idx)
|
||||
// 校正识别索引:删选中前面的图要左移;删到越界则收回末尾。
|
||||
if images.isEmpty {
|
||||
recognizeIndex = 0
|
||||
phase = .idle
|
||||
} else if idx < recognizeIndex {
|
||||
recognizeIndex -= 1
|
||||
} else if recognizeIndex >= images.count {
|
||||
recognizeIndex = images.count - 1
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.tjScaled(20))
|
||||
.foregroundStyle(.white, .black.opacity(0.5))
|
||||
.padding(4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
if remainingSlots > 0 {
|
||||
Button { showMoreCapture = true } label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "plus")
|
||||
.font(.tjScaled(22, weight: .medium))
|
||||
Text("继续拍")
|
||||
.font(.tjScaled(12))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.frame(width: 96, height: 96)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1, dash: [4]))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("已拍 \(images.count)/\(Self.maxImages) 张 · 可拍正面、背面、说明书")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if images.count > 1 {
|
||||
Text("点照片选「识别此张」· 一次记一种药")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
Text("照片与文字均不离开设备")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
|
||||
Button {
|
||||
startRecognition()
|
||||
} label: {
|
||||
Text("开始识别")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
.disabled(images.isEmpty)
|
||||
.opacity(images.isEmpty ? 0.4 : 1)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
flowCancelButton { onClose() }
|
||||
}
|
||||
}
|
||||
|
||||
private var recognizingView: some View {
|
||||
VStack(spacing: 18) {
|
||||
if images.indices.contains(recognizeIndex) {
|
||||
Image(uiImage: images[recognizeIndex])
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 320)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
Text("正在本地识别药品…")
|
||||
.font(.tjScaled(14))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Text("照片与文字均不离开设备")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// 识别中也要能退出,不能让用户干等(§3.2 不卡死)
|
||||
.overlay(alignment: .topLeading) {
|
||||
flowCancelButton {
|
||||
recognitionTask?.cancel()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flowCancelButton(_ action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text("取消")
|
||||
.font(.tjScaled(16, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.horizontal, 18)
|
||||
.frame(minHeight: 44)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - 识别(选中单张 OCR → LLM 结构化)
|
||||
|
||||
private func startRecognition() {
|
||||
guard images.indices.contains(recognizeIndex) else { return }
|
||||
phase = .recognizing
|
||||
let target = images[recognizeIndex]
|
||||
recognitionTask = Task {
|
||||
let (items, warning) = await recognize(target)
|
||||
guard !Task.isCancelled else { return } // 识别中点了取消:不再回写 phase
|
||||
await MainActor.run {
|
||||
// 全失败也不卡死:给一条空行让用户手填(§3.2 失败回退红线)。
|
||||
if items.isEmpty {
|
||||
phase = .confirm(items: [EditableMedication(name: "", strength: "", usage: "")],
|
||||
warning: warning ?? String(appLoc: "没读出药品,可以手动填写"))
|
||||
} else {
|
||||
phase = .confirm(items: items, warning: warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) {
|
||||
do {
|
||||
// 一次只识别选中的这一张 → 一种药。
|
||||
let text = (try? await OCRService.recognizeText(in: image))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if text.isEmpty {
|
||||
return ([], String(appLoc: "没识别到文字,拍清楚一点再试"))
|
||||
}
|
||||
let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: text)
|
||||
// 一次一种药:即使识别出多条,也只取第一条。
|
||||
let items = parsed.prefix(1).map {
|
||||
EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage)
|
||||
}
|
||||
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
|
||||
} catch CaptureError.modelNotReady {
|
||||
return ([], String(appLoc: "AI 模型未就绪,可以手动填写"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
return ([], String(appLoc: "解析失败:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
return ([], String(appLoc: "识别失败:\(msg)"))
|
||||
} catch {
|
||||
return ([], String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 保存
|
||||
|
||||
private func saveItems(_ items: [EditableMedication]) {
|
||||
let meds = items
|
||||
.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
.map {
|
||||
ParsedMedication(name: $0.name.trimmingCharacters(in: .whitespaces),
|
||||
strength: $0.strength.trimmingCharacters(in: .whitespaces),
|
||||
usage: $0.usage.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
// 确认后入药品库(连同原图)。空条目则不入库,由调用方据数组是否为空决定。
|
||||
onSave(meds, images)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 入药品库(MainActor,SwiftData 写主上下文必须由 View 侧持有的 ctx 来做,§3.1)
|
||||
|
||||
/// 拍药盒确认后入药品库,两个入口(药品库页、写日记 · 拍药盒)共用:
|
||||
/// 每条药建一个 `Medication`(挂原图),按 name+strength 软去重;**不写日记、不写 currentMedications**。
|
||||
/// 服用流水改由「写日记 · 用药」生成带 `DiaryEntry.medicationTag` 的日记。
|
||||
@MainActor
|
||||
enum MedicationArchiver {
|
||||
static func archive(medications: [ParsedMedication], images: [UIImage] = [], in ctx: ModelContext) {
|
||||
guard !medications.isEmpty else { return }
|
||||
|
||||
// 原图写加密 Vault(§5/§6:落 Application Support/Vault,目录级硬件加密)。
|
||||
// 多药共享同批原图时只挂「第一条新建的药」,避免同一 JPEG 被多个 Asset 引用、
|
||||
// 删一条 cascade 误删另一条还在用的文件。
|
||||
let savedAssets = images
|
||||
.prefix(MedicationScanFlow.maxImages)
|
||||
.compactMap { try? FileVault.shared.writeJPEG($0) }
|
||||
|
||||
let existing = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
|
||||
var attachedImages = false
|
||||
for m in medications {
|
||||
// 软去重:同 name+strength 已在库则只补用法 / 刷新时间,不重复建。
|
||||
if let dup = existing.first(where: { $0.name == m.name && $0.strength == m.strength }) {
|
||||
if dup.usage.isEmpty, !m.usage.isEmpty { dup.usage = m.usage }
|
||||
dup.updatedAt = .now
|
||||
continue
|
||||
}
|
||||
let med = Medication(name: m.name, strength: m.strength, usage: m.usage)
|
||||
if !attachedImages {
|
||||
for s in savedAssets {
|
||||
let asset = Asset(relativePath: s.relativePath, bytes: s.bytes)
|
||||
ctx.insert(asset)
|
||||
med.assets.append(asset)
|
||||
}
|
||||
attachedImages = true
|
||||
}
|
||||
ctx.insert(med)
|
||||
}
|
||||
try? ctx.save()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 核对页
|
||||
|
||||
private struct MedicationConfirmView: View {
|
||||
@State var items: [MedicationScanFlow.EditableMedication]
|
||||
let warning: String?
|
||||
let onSave: ([MedicationScanFlow.EditableMedication]) -> Void
|
||||
let onRetake: () -> Void
|
||||
|
||||
private var canSave: Bool {
|
||||
items.contains {
|
||||
$0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
if let warning {
|
||||
Section {
|
||||
Label(warning, systemImage: "exclamationmark.triangle")
|
||||
.font(.tjScaled(13))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach($items) { $item in
|
||||
Section {
|
||||
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
onRetake()
|
||||
} label: {
|
||||
Label("重拍", systemImage: "camera")
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
} footer: {
|
||||
Text("一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
|
||||
Button {
|
||||
onSave(items)
|
||||
} label: {
|
||||
Text("存入药品库")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
.disabled(!canSave)
|
||||
.opacity(canSave ? 1 : 0.4)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MedicationScanFlow(onSave: { _, _ in }, onClose: {})
|
||||
}
|
||||
@@ -87,8 +87,6 @@ private struct ProfileEditForm: View {
|
||||
items: $profile.allergies)
|
||||
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
|
||||
items: $profile.familyHistory)
|
||||
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
|
||||
items: $profile.currentMedications)
|
||||
}
|
||||
.navigationTitle("个人资料")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -370,17 +368,14 @@ private struct ChronicSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("自定义慢病", text: $newCustomCondition)
|
||||
Button("加") {
|
||||
EntryInputField(placeholder: String(appLoc: "自定义慢病"), text: $newCustomCondition) {
|
||||
let trimmed = newCustomCondition.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty,
|
||||
!profile.chronicConditions.contains(trimmed) else { return }
|
||||
profile.chronicConditions.append(trimmed)
|
||||
newCustomCondition = ""
|
||||
}
|
||||
.disabled(newCustomCondition.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
} header: {
|
||||
Text("慢病(影响参考范围与 AI 解读)")
|
||||
}
|
||||
@@ -408,6 +403,51 @@ private struct ChronicSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 聊天框风格的条目输入(圆角容器 + 多行增长 + 圆形发送按钮)
|
||||
|
||||
/// 替代原先单行 `TextField + “加”` 的搜索框观感:文字随内容换行增长(1~4 行),
|
||||
/// 右侧圆形发送按钮(内容为空时禁用变灰)。过敏 / 家族史 / 用药 / 自定义慢病共用。
|
||||
private struct EntryInputField: View {
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
var onSubmit: () -> Void
|
||||
|
||||
private var canSubmit: Bool {
|
||||
!text.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(placeholder, text: $text, axis: .vertical)
|
||||
.lineLimit(1...5)
|
||||
.foregroundStyle(Tj.Palette.text) // 固定深色:避免深色模式下继承系统 .primary 变白看不清
|
||||
.tint(Tj.Palette.ink)
|
||||
.frame(minHeight: 40, alignment: .top) // 初始就有聊天框体量,内容多了随 axis 增长
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
|
||||
Button {
|
||||
if canSubmit { onSubmit() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.tjScaled(28))
|
||||
.foregroundStyle(canSubmit ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 过敏 / 家族史 / 用药(每节自带 @State,敲字只重算本节)
|
||||
|
||||
private struct StringListSection: View {
|
||||
@@ -431,16 +471,13 @@ private struct StringListSection: View {
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
TextField(placeholder, text: $newInput)
|
||||
Button("加") {
|
||||
EntryInputField(placeholder: placeholder, text: $newInput) {
|
||||
let trimmed = newInput.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty, !items.contains(trimmed) else { return }
|
||||
items.append(trimmed)
|
||||
newInput = ""
|
||||
}
|
||||
.disabled(newInput.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
/// 异常项快拍 · 统一流程。
|
||||
/// 指标速记 · 统一流程。
|
||||
/// 整幅单拍(真机)/ 相册(模拟器)→ 静态图手动框选 → 框内 OCR+LLM 抽指标 → 核对 → 存独立 Indicator。
|
||||
///
|
||||
/// 状态机:
|
||||
@@ -32,8 +32,9 @@ struct QuickRegionCaptureFlow: View {
|
||||
private var content: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
// 不再整体 ignoresSafeArea:相机/框选内部已各自做全屏黑底,
|
||||
// 这里再忽略安全区会把「取消」顶进灵动岛,几乎点不到。
|
||||
captureEntry
|
||||
.ignoresSafeArea()
|
||||
|
||||
case .adjust(let image):
|
||||
RegionAdjustView(
|
||||
@@ -45,7 +46,6 @@ struct QuickRegionCaptureFlow: View {
|
||||
onRetake: { phase = .idle },
|
||||
onCancel: { onClose() }
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
case .confirm(let image, let items, let warning):
|
||||
NavigationStack {
|
||||
@@ -57,7 +57,7 @@ struct QuickRegionCaptureFlow: View {
|
||||
onCancel: { onClose() },
|
||||
onRetake: { phase = .idle }
|
||||
)
|
||||
.navigationTitle(String(appLoc: "核对异常项"))
|
||||
.navigationTitle(String(appLoc: "核对指标"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
@@ -95,17 +95,18 @@ struct QuickRegionCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别(框内子图 → OCR → LLM)
|
||||
// MARK: - 识别(框内子图 → Vision OCR → Qwen3 整理)
|
||||
|
||||
/// 对已裁好的框内子图跑识别。失败/超时返回提示文案,绝不抛出(由 RegionAdjustView 展示)。
|
||||
/// 链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B 结构化抽指标(对齐 indicator-capture-ocr-llm)。
|
||||
/// 固定链路:Vision 端侧 OCR 出文字 → Qwen3 跑一次结构化整理抽指标。
|
||||
/// (旧的「大模型直读」VL 路径已移除:端侧看图慢且易卡,OCR→整理又快又准。)
|
||||
private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
||||
do {
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
if Task.isCancelled { return ([], nil) } // 超时:文案由调用方给
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#if DEBUG
|
||||
print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---")
|
||||
NSLog("KKDBG-OCR region text:\n%@\n--- end OCR ---", trimmed)
|
||||
#endif
|
||||
if trimmed.isEmpty {
|
||||
return ([], String(appLoc: "没识别到文字,挪一下框再试"))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 异常项快拍 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。
|
||||
/// 指标速记 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。
|
||||
/// 与「记录指标」自由输入落库一致 —— 每个勾选项 = 一条独立 Indicator。
|
||||
struct QuickRegionConfirmView: View {
|
||||
let image: UIImage?
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// 异常项快拍 · 静态图框选识别。
|
||||
/// 指标速记 · 静态图框选识别。
|
||||
/// 拍/选一张后,在静态照片上手动拖动 + 缩放一个方框,点「识别」只对框内做 OCR+LLM。
|
||||
/// 可反复挪框重识别,满意后进入核对页;0 项也能进核对手动补(失败回退红线)。
|
||||
struct RegionAdjustView: View {
|
||||
@@ -50,7 +50,11 @@ struct RegionAdjustView: View {
|
||||
Text("取消")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 60, minHeight: 44) // HIG 最小命中区,命中整块而非文字
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
Text("框住异常指标")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
@@ -63,10 +67,14 @@ struct RegionAdjustView: View {
|
||||
Text("重拍")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 60, minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import AVFoundation
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// 异常项快拍 · 整幅单拍相机。
|
||||
/// 指标速记 · 整幅单拍相机。
|
||||
/// 全屏实时预览 + 一个快门 → 返回**整幅** upright UIImage(不裁剪)。
|
||||
/// 拍完后由 `RegionAdjustView` 在静态图上手动框选识别区域。
|
||||
/// 只在真机可用(模拟器无相机,`QuickRegionCaptureFlow` 退化到 PhotoPicker)。
|
||||
@@ -49,18 +49,20 @@ struct SingleShotCameraView: View {
|
||||
Text("取消")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 18)
|
||||
.frame(minHeight: 44) // 苹果 HIG 最小命中区
|
||||
.background(Capsule().fill(.black.opacity(0.35)))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("拍一张含异常指标的照片 · 拍完再框选")
|
||||
Text("拍一张含目标指标的照片 · 拍完再框选")
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -97,7 +99,7 @@ struct SingleShotCameraView: View {
|
||||
Text("相机权限未开启")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(.white)
|
||||
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||
Text("指标速记需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case quick, indicator, healthExport, archive, diary, symptom, reminder
|
||||
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary
|
||||
var id: String { rawValue }
|
||||
|
||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||
/// 注:`.quick`(异常项快拍)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
|
||||
/// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」;
|
||||
/// `.symptom`(记录症状)与拍药盒一起并入 `.diary`(健康日记)顶部三选一,不再单列;
|
||||
/// `.medicationLibrary`(药品库)是浏览/管理目的地,主入口在「记录」Tab 顶部板卡,
|
||||
/// 这里垫底保留一个快捷方式(创建动作在前,药品库作为「去管理」入口排最后)。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
|
||||
|
||||
/// 健康日记行的功能提示 pill(代替 subtitle,让"症状/药盒在日记里"一眼可见)。
|
||||
/// 计算属性:每次按当前语言解析,语言切换即时更新(同 ProfileEditView 的 presets 约定)。
|
||||
static var diaryFeaturePills: [String] {
|
||||
[String(appLoc: "写日记"), String(appLoc: "拍药盒"), String(appLoc: "记症状")]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .quick: return String(appLoc: "异常项快拍")
|
||||
case .quick: return String(appLoc: "指标速记")
|
||||
case .indicator: return String(appLoc: "记录指标")
|
||||
case .healthExport: return String(appLoc: "身体档案")
|
||||
case .archive: return String(appLoc: "体检报告归档")
|
||||
case .diary: return String(appLoc: "健康日记")
|
||||
case .symptom: return String(appLoc: "记录症状")
|
||||
case .reminder: return String(appLoc: "开启一个提醒")
|
||||
case .medicationLibrary: return String(appLoc: "药品库")
|
||||
}
|
||||
}
|
||||
var subtitle: String {
|
||||
@@ -25,9 +35,10 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .indicator: return String(appLoc: "手动填写,或拍照自动识别")
|
||||
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
|
||||
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
|
||||
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
|
||||
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
|
||||
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
|
||||
case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加")
|
||||
}
|
||||
}
|
||||
var icon: String {
|
||||
@@ -39,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .diary: return "heart.text.square"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
case .reminder: return "bell.badge"
|
||||
case .medicationLibrary: return "pills.fill"
|
||||
}
|
||||
}
|
||||
var accent: Color {
|
||||
@@ -50,6 +62,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .symptom: return Tj.Palette.amber
|
||||
case .reminder: return Tj.Palette.leaf
|
||||
case .medicationLibrary: return Tj.Palette.ink
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +89,7 @@ struct RecordSheet: View {
|
||||
}
|
||||
.padding(.bottom, 14)
|
||||
|
||||
// ScrollView 包裹:6 个入口在小屏固定 detent 下可能溢出,滚动确保都能触达。
|
||||
// ScrollView 包裹:入口在小屏固定 detent 下可能溢出,滚动确保都能触达。
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(RecordKind.displayOrder) { kind in
|
||||
@@ -93,14 +106,28 @@ struct RecordSheet: View {
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(kind.title)
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
if kind == .diary {
|
||||
// 醒目提示:症状/药盒已并入日记,用 pill 直接点名
|
||||
HStack(spacing: 5) {
|
||||
ForEach(RecordKind.diaryFeaturePills, id: \.self) { pill in
|
||||
Text(pill)
|
||||
.font(.tjScaled( 10, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(kind.subtitle)
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
@@ -111,6 +138,17 @@ struct RecordSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// 语音直达提示:长按 + 即可说话,不用翻菜单
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.tjScaled( 10))
|
||||
Text("下次试试长按 + ,直接说出想记的内容")
|
||||
.font(.tjScaled( 11))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(.bottom, 22)
|
||||
}
|
||||
|
||||
276
康康/Features/Record/VoiceCommandSheet.swift
Normal file
276
康康/Features/Record/VoiceCommandSheet.swift
Normal file
@@ -0,0 +1,276 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 「长按 + 语音直达」面板:开口说想记什么 → 端侧转写(SpeechDictationService)
|
||||
/// → LLM 意图分类(VoiceIntentService)→ 回调 RootView 打开对应新建入口。
|
||||
///
|
||||
/// 状态机:
|
||||
/// ```
|
||||
/// requesting(权限) → recording(实时字幕) → classifying → onResolve(intent) 关闭
|
||||
/// │ 拒绝 → denied │ 没听到/没听懂 → failed(再说一次 / 打开菜单)
|
||||
/// ```
|
||||
/// 全程本机:转写 requiresOnDeviceRecognition,分类走端侧 LLM。
|
||||
struct VoiceCommandSheet: View {
|
||||
/// 识别成功:RootView 负责关闭本 sheet 并路由。
|
||||
let onResolve: (VoiceIntent) -> Void
|
||||
/// 兜底:打开普通新建菜单(RecordSheet)。
|
||||
let onOpenMenu: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
enum Phase: Equatable {
|
||||
case requesting
|
||||
case denied
|
||||
case recording
|
||||
case classifying
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
@State private var phase: Phase = .requesting
|
||||
@State private var transcript = ""
|
||||
@State private var seconds = 0
|
||||
/// @State 保证视图身份期内实例唯一(同 DiaryQuickSheet 的注释,防止重建后麦克风悬挂)。
|
||||
@State private var dictation = SpeechDictationService()
|
||||
@State private var ticker: Task<Void, Never>?
|
||||
|
||||
/// 录音超过 20s 自动结束:语音直达说的都是短句,长录是忘了点完成。
|
||||
private let maxSeconds = 20
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Capsule()
|
||||
.fill(Tj.Palette.line)
|
||||
.frame(width: 40, height: 4)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("说出想记的内容")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("比如:记一下血压 / 我头疼 / 拍个药盒")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Text("全程本机")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
content
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
buttons
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(
|
||||
Tj.Palette.sand
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
.presentationDetents([.fraction(0.5)])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.task { await begin() }
|
||||
.onDisappear {
|
||||
ticker?.cancel()
|
||||
dictation.abort()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分阶段内容
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch phase {
|
||||
case .requesting:
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 30)
|
||||
|
||||
case .denied:
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "mic.slash")
|
||||
.font(.tjScaled( 30))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("需要麦克风与语音识别权限")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("语音和文字都只在本机处理,不会上传。")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Button("前往设置") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 16)
|
||||
|
||||
case .recording:
|
||||
VStack(spacing: 14) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Tj.Palette.brick)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("正在听 · \(seconds)s")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
}
|
||||
transcriptBox(placeholder: String(appLoc: "请开口说话…"))
|
||||
}
|
||||
|
||||
case .classifying:
|
||||
VStack(spacing: 14) {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
Text("正在理解…")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
transcriptBox(placeholder: "")
|
||||
}
|
||||
|
||||
case .failed(let message):
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "questionmark.bubble")
|
||||
.font(.tjScaled( 28))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(message)
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.multilineTextAlignment(.center)
|
||||
if !transcript.isEmpty {
|
||||
Text("“\(transcript)”")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private func transcriptBox(placeholder: String) -> some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
Text(transcript.isEmpty ? placeholder : transcript)
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(transcript.isEmpty ? Tj.Palette.text3 : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(minHeight: 64, maxHeight: 110)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.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: - 底部按钮
|
||||
|
||||
@ViewBuilder
|
||||
private var buttons: some View {
|
||||
switch phase {
|
||||
case .recording:
|
||||
HStack(spacing: 12) {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||
Button("说完了") { finishRecording() }
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||
}
|
||||
case .failed:
|
||||
HStack(spacing: 12) {
|
||||
Button("打开新建菜单") { onOpenMenu() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 14, horizontalPadding: 14))
|
||||
Button("再说一次") { Task { await begin() } }
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14, horizontalPadding: 18))
|
||||
}
|
||||
case .denied:
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||
case .requesting, .classifying:
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 流程
|
||||
|
||||
private func begin() async {
|
||||
ticker?.cancel()
|
||||
transcript = ""
|
||||
seconds = 0
|
||||
guard SpeechDictationService.isAvailable else {
|
||||
phase = .failed(message: String(appLoc: "本机不支持端侧语音识别,试试下面的新建菜单"))
|
||||
return
|
||||
}
|
||||
phase = .requesting
|
||||
guard await dictation.requestAuthorization() else {
|
||||
phase = .denied
|
||||
return
|
||||
}
|
||||
do {
|
||||
try dictation.start { transcript = $0 }
|
||||
phase = .recording
|
||||
startTicker()
|
||||
} catch {
|
||||
phase = .failed(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func startTicker() {
|
||||
ticker = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard phase == .recording else { return }
|
||||
seconds += 1
|
||||
if seconds >= maxSeconds {
|
||||
finishRecording()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishRecording() {
|
||||
guard phase == .recording else { return }
|
||||
ticker?.cancel()
|
||||
phase = .classifying
|
||||
Task {
|
||||
let text = await dictation.stop()
|
||||
transcript = text
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
phase = .failed(message: String(appLoc: "没听到内容,再试一次?"))
|
||||
return
|
||||
}
|
||||
if let intent = await VoiceIntentService.classify(trimmed) {
|
||||
onResolve(intent)
|
||||
} else {
|
||||
phase = .failed(message: String(appLoc: "没听懂想记什么,再说一次,或直接选菜单"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Text("bg")
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
VoiceCommandSheet(onResolve: { print($0) }, onOpenMenu: {})
|
||||
}
|
||||
}
|
||||
482
康康/Features/Timeline/IndicatorSeriesDetailView.swift
Normal file
482
康康/Features/Timeline/IndicatorSeriesDetailView.swift
Normal file
@@ -0,0 +1,482 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 一个指标的「同类组」标识。决定聚合详情里收哪些历次记录、跳哪个趋势 bucket。
|
||||
/// - `.series`:有 seriesKey 的长期监测指标(血糖/体重/...)。
|
||||
/// - `.bloodPressure`:血压(bp.systolic + bp.diastolic 合并成一对)。
|
||||
/// - `.lab`:无 seriesKey 的化验/手动指标,按 name+unit 归一化 key 聚合。
|
||||
enum IndicatorGroup: Identifiable, Hashable {
|
||||
case series(key: String)
|
||||
case bloodPressure
|
||||
case lab(key: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .series(let k): return "series:\(k)"
|
||||
case .bloodPressure: return "bp"
|
||||
case .lab(let k): return "lab:\(k)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 从单条指标推断其所属同类组(与趋势页 SeriesBucket 的分组语义一致)。
|
||||
static func of(_ i: Indicator) -> IndicatorGroup {
|
||||
if let key = i.seriesKey, !key.isEmpty {
|
||||
return key.hasPrefix("bp.") ? .bloodPressure : .series(key: key)
|
||||
}
|
||||
return .lab(key: SeriesBucket.normalizedKey(name: i.name, unit: i.unit))
|
||||
}
|
||||
}
|
||||
|
||||
/// 同类指标聚合详情:横向翻页看该指标的历次记录,底部可跳趋势图。
|
||||
/// 从「记录」页点指标条目进入。数据全部 @Query 实时,删除后翻页列表自动更新。
|
||||
struct IndicatorSeriesDetailView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var ctx
|
||||
|
||||
let group: IndicatorGroup
|
||||
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
@Query private var profiles: [UserProfile]
|
||||
@Query private var customMetrics: [CustomMonitorMetric]
|
||||
|
||||
@State private var selection: String?
|
||||
@State private var showTrend = false
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var evidenceTarget: Indicator?
|
||||
|
||||
// MARK: - 数据
|
||||
|
||||
/// 聚合详情里的一页:单值指标一条;血压一对。
|
||||
private enum Record: Identifiable {
|
||||
case single(Indicator)
|
||||
case bp(sys: Indicator, dia: Indicator?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .single(let i): return "\(i.persistentModelID)"
|
||||
case .bp(let s, _): return "bp-\(s.persistentModelID)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历次血压对:以 bp.systolic 为锚,按 ±5s 配 bp.diastolic(同 TimelineEntry 合并规则)。
|
||||
private var bloodPressureRecords: [Record] {
|
||||
let sysList = indicators
|
||||
.filter { $0.seriesKey == "bp.systolic" }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
var usedDia = Set<PersistentIdentifier>()
|
||||
return sysList.map { sys in
|
||||
let dia = indicators.first {
|
||||
$0.seriesKey == "bp.diastolic" &&
|
||||
!usedDia.contains($0.persistentModelID) &&
|
||||
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
||||
}
|
||||
if let dia { usedDia.insert(dia.persistentModelID) }
|
||||
return .bp(sys: sys, dia: dia)
|
||||
}
|
||||
}
|
||||
|
||||
private var records: [Record] {
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return bloodPressureRecords
|
||||
case .series(let key):
|
||||
return indicators
|
||||
.filter { $0.seriesKey == key }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
.map(Record.single)
|
||||
case .lab(let nk):
|
||||
return indicators
|
||||
.filter {
|
||||
($0.seriesKey ?? "").isEmpty &&
|
||||
SeriesBucket.normalizedKey(name: $0.name, unit: $0.unit) == nk
|
||||
}
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
.map(Record.single)
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return String(appLoc: "血压")
|
||||
case .series, .lab:
|
||||
if case let .single(i)? = records.first { return i.name }
|
||||
return String(appLoc: "指标详情")
|
||||
}
|
||||
}
|
||||
|
||||
/// 对应的趋势 bucket(需 ≥2 个可解析数值点才存在);nil 时隐藏「查看趋势图」。
|
||||
private var bucket: SeriesBucket? {
|
||||
let all = SeriesBucket.build(from: indicators,
|
||||
profile: profiles.first,
|
||||
customMetrics: customMetrics)
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return all.first { $0.id == "bp" }
|
||||
case .series(let key):
|
||||
return all.first { b in b.lines.contains { $0.seriesKey == key } }
|
||||
case .lab(let nk):
|
||||
return all.first { $0.kind == .lab && $0.id == "lab:\(nk)" }
|
||||
}
|
||||
}
|
||||
|
||||
private var currentIndex: Int {
|
||||
records.firstIndex { $0.id == selection } ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
if records.isEmpty {
|
||||
Spacer()
|
||||
TjPlaceholder(label: String(appLoc: "记录已不存在"))
|
||||
.frame(width: 200, height: 120)
|
||||
Spacer()
|
||||
} else {
|
||||
pages
|
||||
pager
|
||||
recordAnotherRow
|
||||
if bucket != nil { trendButton }
|
||||
}
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationDestination(isPresented: $showTrend) {
|
||||
if let bucket { TrendDetailView(bucket: bucket) }
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.onAppear { if selection == nil { selection = records.first?.id } }
|
||||
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
|
||||
Button(String(appLoc: "删除"), role: .destructive) { deleteCurrent() }
|
||||
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||
} message: {
|
||||
Text("删除后无法恢复。")
|
||||
}
|
||||
.sheet(item: $evidenceTarget) { indicator in
|
||||
if let report = indicator.report {
|
||||
EvidenceImagePreview(report: report, indicator: indicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
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(title)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
if records.count > 1 {
|
||||
Text("\(records.count) 条")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(Tj.Palette.sand)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻页内容
|
||||
|
||||
private var pages: some View {
|
||||
TabView(selection: $selection) {
|
||||
ForEach(records) { rec in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
recordCard(rec)
|
||||
deleteButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.tag(Optional(rec.id))
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func recordCard(_ rec: Record) -> some View {
|
||||
switch rec {
|
||||
case .single(let i): singleCard(i)
|
||||
case .bp(let sys, let dia): bpCard(sys: sys, dia: dia)
|
||||
}
|
||||
}
|
||||
|
||||
private func singleCard(_ 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)
|
||||
}
|
||||
}
|
||||
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 i.report != nil { evidenceButton(for: i) }
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
|
||||
private func bpCard(sys: Indicator, dia: Indicator?) -> some 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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻页器
|
||||
|
||||
private var pager: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 20) {
|
||||
pagerArrow("chevron.left", enabled: currentIndex > 0) {
|
||||
if currentIndex > 0 { selection = records[currentIndex - 1].id }
|
||||
}
|
||||
if records.count <= 7 {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(records.enumerated()), id: \.offset) { idx, _ in
|
||||
Circle()
|
||||
.fill(idx == currentIndex ? Tj.Palette.ink : Tj.Palette.line)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
pagerArrow("chevron.right", enabled: currentIndex < records.count - 1) {
|
||||
if currentIndex < records.count - 1 { selection = records[currentIndex + 1].id }
|
||||
}
|
||||
}
|
||||
Text("第 \(currentIndex + 1) / 共 \(records.count) 条")
|
||||
.font(.tjScaled(11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func pagerArrow(_ system: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: system)
|
||||
.font(.tjScaled(13, weight: .semibold))
|
||||
.foregroundStyle(enabled ? Tj.Palette.text : Tj.Palette.text3.opacity(0.4))
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
// MARK: - 再记一条(与指标详情共用 RecordAnotherButton 组件)
|
||||
|
||||
/// 按当前翻到的那一页指标预选「再记一条」:血压走双字段,其余按 name/unit/range/seriesKey。
|
||||
@ViewBuilder
|
||||
private var recordAnotherRow: some View {
|
||||
if records.indices.contains(currentIndex) {
|
||||
switch records[currentIndex] {
|
||||
case .single(let i):
|
||||
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, bucket == nil ? 20 : 10)
|
||||
case .bp(let sys, _):
|
||||
RecordAnotherButton(
|
||||
name: String(appLoc: "血压"),
|
||||
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
|
||||
name: String(appLoc: "血压"),
|
||||
unit: "mmHg", range: sys.range)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, bucket == nil ? 20 : 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 趋势 / 删除
|
||||
|
||||
private var trendButton: some View {
|
||||
Button { showTrend = true } label: {
|
||||
Label(String(appLoc: "查看趋势图"), systemImage: "chart.xyaxis.line")
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.ink)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
|
||||
private var deleteButton: some View {
|
||||
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||
Label(String(appLoc: "永久删除"), systemImage: "trash")
|
||||
.font(.tjScaled(12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
/// 删当前页记录(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)。
|
||||
/// 删后把 selection 落到相邻一条;删空则关闭。
|
||||
private func deleteCurrent() {
|
||||
guard records.indices.contains(currentIndex) else { return }
|
||||
let removingIndex = currentIndex
|
||||
switch records[removingIndex] {
|
||||
case .single(let i):
|
||||
deleteIndicator(i)
|
||||
case .bp(let sys, let dia):
|
||||
deleteIndicator(sys)
|
||||
if let dia { deleteIndicator(dia) }
|
||||
}
|
||||
try? ctx.save()
|
||||
|
||||
let remaining = records
|
||||
if remaining.isEmpty {
|
||||
dismiss()
|
||||
} else {
|
||||
let next = min(removingIndex, remaining.count - 1)
|
||||
selection = remaining[next].id
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteIndicator(_ i: Indicator) {
|
||||
if let asset = i.asset {
|
||||
try? FileVault.shared.remove(relativePath: asset.relativePath)
|
||||
ctx.delete(asset)
|
||||
}
|
||||
ctx.delete(i)
|
||||
}
|
||||
|
||||
// MARK: - 复用件
|
||||
|
||||
@ViewBuilder
|
||||
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) { content() }
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func field(_ label: String, _ value: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(label).font(.tjScaled(13)).foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.tjScaled(14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func evidenceButton(for indicator: Indicator) -> some View {
|
||||
if indicator.hasEvidenceBox,
|
||||
let page = indicator.sourcePageIndex,
|
||||
let assets = indicator.report?.assets,
|
||||
assets.indices.contains(page) {
|
||||
Button {
|
||||
evidenceTarget = indicator
|
||||
} label: {
|
||||
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
|
||||
private func statusChip(_ s: IndicatorStatus) -> some View {
|
||||
let text: String
|
||||
let color: Color
|
||||
let arrow: String
|
||||
switch s {
|
||||
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = "↑"
|
||||
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = "↓"
|
||||
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
|
||||
}
|
||||
return HStack(spacing: 3) {
|
||||
if !arrow.isEmpty { Text(arrow).font(.tjScaled(11, weight: .bold)) }
|
||||
Text(text).font(.tjScaled(12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(color.opacity(0.14)))
|
||||
}
|
||||
|
||||
private nonisolated static func dateTimeText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day().hour().minute())
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import SwiftData
|
||||
import Foundation
|
||||
|
||||
enum TimelineKind: String, CaseIterable, Identifiable {
|
||||
case indicator, report, symptom, diary
|
||||
case diary, symptom, indicator, medication, report
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
@@ -12,6 +12,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
|
||||
case .report: return String(appLoc: "报告")
|
||||
case .symptom: return String(appLoc: "症状")
|
||||
case .diary: return String(appLoc: "日记")
|
||||
case .medication: return String(appLoc: "用药")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
|
||||
case .report: return "doc.fill"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
case .diary: return "pencil"
|
||||
case .medication: return "pills.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +32,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
|
||||
case .report: return Tj.Palette.ink2
|
||||
case .symptom: return Tj.Palette.amber
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .medication: return Tj.Palette.ink
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,10 +42,12 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
let kind: TimelineKind
|
||||
let date: Date
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var subtitle: String
|
||||
let trailing: String?
|
||||
let trailingIsAlert: Bool
|
||||
let isOngoing: Bool
|
||||
/// 同名指标聚合后的累计次数(>1 时副标题附「共 N 次」)。非聚合条目恒为 1。
|
||||
var aggregateCount: Int = 1
|
||||
|
||||
static func from(indicator i: Indicator) -> TimelineEntry {
|
||||
TimelineEntry(
|
||||
@@ -84,6 +89,34 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
return entries
|
||||
}
|
||||
|
||||
/// 「记录」列表 / 首页最近记录用:把同名(同类组)指标聚合成一条,代表取最新一次,
|
||||
/// 附带该组累计次数(`aggregateCount`,>1 时副标题缀「共 N 次」)。
|
||||
/// 点代表条目跳 `IndicatorSeriesDetailView` 看历次。分组口径与聚合详情 / 趋势一致
|
||||
/// (`IndicatorGroup`):血压(bp.*)并一组、有 seriesKey 的按 key、无 seriesKey 的按 name+unit 归一。
|
||||
static func aggregatedIndicators(_ indicators: [Indicator]) -> [TimelineEntry] {
|
||||
var order: [String] = []
|
||||
var groups: [String: [Indicator]] = [:]
|
||||
for i in indicators {
|
||||
let key = IndicatorGroup.of(i).id
|
||||
if groups[key] == nil { order.append(key) }
|
||||
groups[key, default: []].append(i)
|
||||
}
|
||||
return order.compactMap { key -> TimelineEntry? in
|
||||
guard let members = groups[key] else { return nil }
|
||||
// 该组逐条条目(血压已合并 sys/dia),取最新一条作代表。
|
||||
guard var rep = from(indicators: members).max(by: { $0.date < $1.date }) else { return nil }
|
||||
// 次数:血压按测量次数(bp.systolic 条数),其余按成员条数。
|
||||
let count = key == IndicatorGroup.bloodPressure.id
|
||||
? members.filter { $0.seriesKey == "bp.systolic" }.count
|
||||
: members.count
|
||||
rep.aggregateCount = count
|
||||
if count > 1 {
|
||||
rep.subtitle += " · " + String(appLoc: "共 \(count) 次")
|
||||
}
|
||||
return rep
|
||||
}
|
||||
}
|
||||
|
||||
private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry {
|
||||
let abnormal = sys.status != .normal || dia.status != .normal
|
||||
// 方向箭头按实际 status 给:两值同向才标 ↑/↓;一高一低只标红不给方向
|
||||
@@ -132,13 +165,16 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 带「用药」tag 的日记(拍药盒入档)归到 .medication 分类,其余是普通文字日记。
|
||||
/// id 统一用 "diary-" 前缀:TimelineDetail.resolve 两个分类都反查 diaries。
|
||||
static func from(diary d: DiaryEntry) -> TimelineEntry {
|
||||
TimelineEntry(
|
||||
let isMed = d.isMedicationLog
|
||||
return TimelineEntry(
|
||||
id: "diary-\(d.persistentModelID)",
|
||||
kind: .diary,
|
||||
kind: isMed ? .medication : .diary,
|
||||
date: d.createdAt,
|
||||
title: d.content.firstLine(),
|
||||
subtitle: String(appLoc: "文字日记"),
|
||||
subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"),
|
||||
trailing: nil,
|
||||
trailingIsAlert: false,
|
||||
isOngoing: false
|
||||
|
||||
@@ -22,7 +22,8 @@ enum TimelineDetail {
|
||||
case .report:
|
||||
return reports.first { "report-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.report)
|
||||
case .diary:
|
||||
case .diary, .medication:
|
||||
// 用药记录本质是带「用药」tag 的 DiaryEntry,详情同日记。
|
||||
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.diary)
|
||||
case .symptom:
|
||||
@@ -53,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) {
|
||||
@@ -83,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)
|
||||
@@ -119,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)
|
||||
@@ -166,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: "症状详情")
|
||||
}
|
||||
}
|
||||
@@ -185,6 +220,7 @@ struct TimelineEntryDetailView: View {
|
||||
// MARK: - 指标
|
||||
|
||||
private func indicatorBody(_ i: Indicator) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
@@ -208,6 +244,8 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 血压(合并条目)
|
||||
@@ -216,7 +254,8 @@ struct TimelineEntryDetailView: View {
|
||||
let combined: IndicatorStatus = sys.status != .normal
|
||||
? sys.status
|
||||
: (dia?.status ?? .normal)
|
||||
return card {
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
@@ -232,6 +271,12 @@ struct TimelineEntryDetailView: View {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 报告
|
||||
@@ -247,25 +292,18 @@ 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 let sum = r.summary, !sum.isEmpty {
|
||||
card {
|
||||
Text(String(appLoc: "摘要"))
|
||||
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if !r.assets.isEmpty {
|
||||
reportPhotosCard(r.assets)
|
||||
}
|
||||
|
||||
ReportSummaryCard(report: r)
|
||||
|
||||
if !r.indicators.isEmpty {
|
||||
card {
|
||||
Text(String(appLoc: "指标"))
|
||||
@@ -292,9 +330,56 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 报告原图卡:可点缩略图 → 全屏翻页查看。归档只存图时,这是看原图的唯一入口,必须独立于指标存在。
|
||||
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))
|
||||
@@ -311,6 +396,79 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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: - 症状
|
||||
|
||||
@@ -418,9 +576,80 @@ 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 EvidenceImagePreview: View {
|
||||
/// 报告原图浏览(纯翻页看图,无指标高亮)。归档只存图的报告也能随时调取查看。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 原图证据预览(翻页 + 高亮框)。指标详情与同类聚合详情共用,故为模块内可见。
|
||||
struct EvidenceImagePreview: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let report: Report
|
||||
let indicator: Indicator
|
||||
@@ -484,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)
|
||||
}
|
||||
}
|
||||
@@ -507,12 +733,17 @@ private struct EvidenceImagePage: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EvidenceHighlightOverlay: View {
|
||||
@@ -558,3 +789,52 @@ private struct EvidenceHighlightOverlay: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 报告摘要卡(无摘要时后台预生成兜底)
|
||||
|
||||
/// 有摘要直接显示;无摘要且有指标时触发后台预生成(归档时若被抢占,这里兜底),
|
||||
/// 生成期间显示流光线,完成后 SwiftData 观察自动刷新出文本。
|
||||
private struct ReportSummaryCard: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
let report: Report
|
||||
@State private var generating = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let sum = report.summary, !sum.isEmpty {
|
||||
container {
|
||||
Text(String(appLoc: "摘要"))
|
||||
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else if generating {
|
||||
container {
|
||||
Text("本地 AI 正在解读这份报告…")
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
AIFlowBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard (report.summary ?? "").isEmpty, !report.indicators.isEmpty else { return }
|
||||
generating = true
|
||||
await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx)
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
|
||||
private func container<C: View>(@ViewBuilder _ body: () -> C) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) { body() }
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ struct TrendDetailView: View {
|
||||
}
|
||||
chartCard
|
||||
statsCard
|
||||
aiPlaceholder
|
||||
TrendInsightCard(bucket: bucket)
|
||||
pointsList
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -318,27 +318,6 @@ struct TrendDetailView: View {
|
||||
return ("\(arrow) \(fmt(abs(d)))\(pctStr)", color)
|
||||
}
|
||||
|
||||
// MARK: AI 解读占位
|
||||
|
||||
private var aiPlaceholder: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("AI 趋势解读即将上线")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2.opacity(0.6))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: 数据点列表
|
||||
|
||||
/// 跨线按天合并:每天一行,展示该天各线的值。倒序。
|
||||
@@ -423,6 +402,80 @@ struct TrendDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AI 趋势解读卡
|
||||
|
||||
/// 进入页面先查指纹缓存:命中秒显;未命中本地现算(经 TrendInsightService,§3.1)。
|
||||
private struct TrendInsightCard: View {
|
||||
let bucket: SeriesBucket
|
||||
@State private var text: String?
|
||||
@State private var running = false
|
||||
@State private var failedMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
Text("AI 解读")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
}
|
||||
if let text {
|
||||
Text(text)
|
||||
.font(.tjScaled( 13))
|
||||
.lineSpacing(3)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
AIDisclaimerFooter()
|
||||
} else if running {
|
||||
Text("本地 AI 解读中…")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
AIFlowBar()
|
||||
} else if let failedMessage {
|
||||
HStack {
|
||||
Text(failedMessage)
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Button("重试") { Task { await load(force: true) } }
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
.task(id: bucket.id) { await load(force: false) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func load(force: Bool) async {
|
||||
if !force, let cached = TrendInsightService.shared.cachedText(for: bucket) {
|
||||
text = cached
|
||||
return
|
||||
}
|
||||
running = true
|
||||
failedMessage = nil
|
||||
do {
|
||||
text = try await TrendInsightService.shared.generate(for: bucket)
|
||||
} catch {
|
||||
failedMessage = String(appLoc: "AI 解读暂不可用(模型未就绪或繁忙)")
|
||||
}
|
||||
running = false
|
||||
}
|
||||
}
|
||||
|
||||
enum TrendRange: String, CaseIterable, Identifiable {
|
||||
case all, year, sixMonths, threeMonths
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -12,6 +12,10 @@ struct TrendsView: View {
|
||||
|
||||
private var profile: UserProfile? { profiles.first }
|
||||
|
||||
/// 顶部搜索:点放大镜展开搜索框,按指标名(bucket.title)实时过滤两段列表。
|
||||
@State private var searching = false
|
||||
@State private var query = ""
|
||||
|
||||
private var seriesBuckets: [SeriesBucket] {
|
||||
SeriesBucket.build(from: indicators,
|
||||
profile: profile,
|
||||
@@ -25,6 +29,14 @@ struct TrendsView: View {
|
||||
seriesBuckets.filter { $0.kind == .lab }
|
||||
}
|
||||
|
||||
private func filtered(_ buckets: [SeriesBucket]) -> [SeriesBucket] {
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty else { return buckets }
|
||||
return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
private var filteredMonitor: [SeriesBucket] { filtered(monitorBuckets) }
|
||||
private var filteredLab: [SeriesBucket] { filtered(labBuckets) }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -32,12 +44,14 @@ struct TrendsView: View {
|
||||
header.padding(.top, 4)
|
||||
if seriesBuckets.isEmpty {
|
||||
emptyState
|
||||
} else if filteredMonitor.isEmpty && filteredLab.isEmpty {
|
||||
noMatchState
|
||||
} else {
|
||||
if !monitorBuckets.isEmpty {
|
||||
section(title: String(appLoc: "长期监测"), buckets: monitorBuckets)
|
||||
if !filteredMonitor.isEmpty {
|
||||
section(title: String(appLoc: "长期监测"), buckets: filteredMonitor)
|
||||
}
|
||||
if !labBuckets.isEmpty {
|
||||
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets)
|
||||
if !filteredLab.isEmpty {
|
||||
section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +65,73 @@ struct TrendsView: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("趋势")
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
searchToggle
|
||||
}
|
||||
if searching { searchField }
|
||||
}
|
||||
}
|
||||
|
||||
private var searchToggle: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
searching.toggle()
|
||||
if !searching { query = "" }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: searching ? "xmark" : "magnifyingglass")
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
private var noMatchState: some View {
|
||||
VStack(spacing: 12) {
|
||||
TjPlaceholder(label: String(appLoc: "没有匹配「\(query)」的指标"))
|
||||
.frame(height: 120)
|
||||
.frame(maxWidth: 260)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
private func section(title: String, buckets: [SeriesBucket]) -> some View {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ final class HealthExport {
|
||||
var inferredLabelCN: String?
|
||||
|
||||
// demo 卖点凭证
|
||||
/// 模型 tag,如 "Qwen3-1.7B-4bit"。截图能证明本地推理。
|
||||
/// 模型 tag,如 "Qwen3.5-2B-MNN"(iPhone17+ 主路径)或 "Qwen3.5-2B-4bit"(MLX 兜底)。截图能证明本地推理。
|
||||
var modelTag: String
|
||||
/// 末次 tok/s,对应 demo 卖点 #6 Live Activity 数据。
|
||||
var decodeRate: Double
|
||||
@@ -44,7 +44,7 @@ final class HealthExport {
|
||||
inferredTimeToDate: Date? = nil,
|
||||
inferredIntent: String? = nil,
|
||||
inferredLabelCN: String? = nil,
|
||||
modelTag: String = "Qwen3-1.7B-4bit",
|
||||
modelTag: String = "Qwen3.5-2B-MNN",
|
||||
decodeRate: Double = 0) {
|
||||
self.prompt = prompt
|
||||
self.content = content
|
||||
|
||||
@@ -6,7 +6,7 @@ enum IndicatorStatus: String, Codable, CaseIterable {
|
||||
case high, low, normal
|
||||
}
|
||||
|
||||
/// 指标录入来源。manual = 「记录指标」手动录入;quickCapture = 异常项快拍(VL);report = 报告归档携带。
|
||||
/// 指标录入来源。manual = 「记录指标」手动录入;quickCapture = 指标速记(VL);report = 报告归档携带。
|
||||
/// 旧数据无此字段 → 默认 manual(轻量迁移)。
|
||||
enum IndicatorSource: String, Codable, CaseIterable {
|
||||
case manual, quickCapture, report
|
||||
@@ -14,7 +14,7 @@ enum IndicatorSource: String, Codable, CaseIterable {
|
||||
var label: String {
|
||||
switch self {
|
||||
case .manual: return String(appLoc: "手动记录")
|
||||
case .quickCapture: return String(appLoc: "异常项快拍")
|
||||
case .quickCapture: return String(appLoc: "指标速记")
|
||||
case .report: return String(appLoc: "报告归档")
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,12 @@ final class DiaryEntry {
|
||||
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
|
||||
@@ -178,6 +184,14 @@ final class DiaryEntry {
|
||||
}
|
||||
}
|
||||
|
||||
extension DiaryEntry {
|
||||
/// 「拍药盒入档」落库时打的 tag。是数据标识不是 UI 文案,**不要**走 appLoc 本地化
|
||||
/// (语言切换后旧数据要还能被识别)。时间线据此把该日记归到「用药」分类。
|
||||
static let medicationTag = "用药"
|
||||
|
||||
var isMedicationLog: Bool { tags.contains(Self.medicationTag) }
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Asset {
|
||||
var relativePath: String
|
||||
@@ -196,6 +210,45 @@ final class Asset {
|
||||
}
|
||||
}
|
||||
|
||||
/// 药品库:用户「我有哪些药」的 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
|
||||
@@ -345,9 +398,13 @@ final class CustomReminder {
|
||||
var hour: Int // 0...23
|
||||
var minute: Int // 0...59
|
||||
var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天
|
||||
var frequencyRaw: String = "daily" // CustomReminder.Frequency 原始值
|
||||
var dayOfMonth: Int = 1 // monthly / yearly 用,1...31
|
||||
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
|
||||
@@ -384,10 +441,41 @@ final class CustomReminder {
|
||||
set { frequencyRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// 列表行副标题:按频率展示「每天 / 每周 一三五 / 每月15日 / 每年3月15日」。
|
||||
/// 生效的频率集合(多选)。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: "已关闭") }
|
||||
switch frequency {
|
||||
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:
|
||||
@@ -396,7 +484,9 @@ final class CustomReminder {
|
||||
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:
|
||||
return String(appLoc: "每月\(dayOfMonth)日")
|
||||
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)日")
|
||||
}
|
||||
@@ -412,13 +502,18 @@ final class CustomReminder {
|
||||
func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
|
||||
guard enabled else { return false }
|
||||
let c = calendar.dateComponents([.weekday, .day, .month], from: date)
|
||||
switch frequency {
|
||||
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: return weekdays.contains(c.weekday ?? -1)
|
||||
case .monthly: return dayOfMonth == (c.day ?? -1)
|
||||
case .yearly: return month == (c.month ?? -1) && dayOfMonth == (c.day ?? -1)
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import ImageIO
|
||||
|
||||
enum FileVaultError: Error {
|
||||
case readFailed
|
||||
@@ -10,7 +11,10 @@ enum FileVaultError: Error {
|
||||
|
||||
/// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全),
|
||||
/// 可被任意 actor / Task 跨边界访问。实例方法显式 `nonisolated`,见 ModelStore 同款注释。
|
||||
final class FileVault: @unchecked Sendable {
|
||||
/// 类级 `nonisolated`:工程开了 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,默认会把本类
|
||||
/// 连同 `thumbnailCache`(非 Sendable 的 NSCache)推成 MainActor,令 nonisolated I/O 方法 / 单例
|
||||
/// 初始化访问不了;本类是纯文件 I/O + 缓存工具,必须脱离 MainActor 供任意 actor 调用,故整类标 nonisolated。
|
||||
nonisolated final class FileVault: @unchecked Sendable {
|
||||
nonisolated static let shared: FileVault = {
|
||||
do {
|
||||
let appSupport = try FileManager.default.url(
|
||||
@@ -28,6 +32,17 @@ final class FileVault: @unchecked Sendable {
|
||||
|
||||
let rootURL: URL
|
||||
|
||||
/// 已降采样图片的内存缓存。NSCache 线程安全、内存吃紧时系统自动回收;
|
||||
/// key = "相对路径@目标像素",避免 TabView 翻页 / 列表滚动反复读盘解码同一张图。
|
||||
/// 只缓存降采样后的小图(几百 KB),不缓存全分辨率原图。
|
||||
/// `nonisolated(unsafe)`:本工程默认 MainActor 隔离,非 Sendable 的 NSCache 存储属性即便整类标
|
||||
/// nonisolated 仍被推成 MainActor,令各 nonisolated I/O 方法访问不到;NSCache 本身线程安全,故 unsafe 豁免。
|
||||
private nonisolated(unsafe) let thumbnailCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 40
|
||||
return cache
|
||||
}()
|
||||
|
||||
init(rootURL: URL) throws {
|
||||
self.rootURL = rootURL
|
||||
try FileManager.default.createDirectory(
|
||||
@@ -81,6 +96,33 @@ final class FileVault: @unchecked Sendable {
|
||||
return image
|
||||
}
|
||||
|
||||
/// 按目标最大边降采样加载。用 ImageIO 直接解出缩略图,**绝不**把全分辨率位图载进内存:
|
||||
/// 一张 4000×3000 体检照全量解码是 ~48MB RGBA,翻几页就 jetsam;降到 ≤2000px 后仅几 MB。
|
||||
/// 自动尊重 EXIF 方向。结果按尺寸缓存,翻页/滚动回看命中缓存不再读盘。
|
||||
/// 失败(锁屏读不到 / 损坏)抛 readFailed,与 loadImage 一致,UI 显示占位即可。
|
||||
nonisolated func loadDownsampledImage(relativePath: String, maxPixelSize: CGFloat) throws -> UIImage {
|
||||
let cacheKey = "\(relativePath)@\(Int(maxPixelSize))" as NSString
|
||||
if let cached = thumbnailCache.object(forKey: cacheKey) { return cached }
|
||||
|
||||
let url = try resolveSafePath(relativePath)
|
||||
let srcOptions: [CFString: Any] = [kCGImageSourceShouldCache: false]
|
||||
guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions as CFDictionary) else {
|
||||
throw FileVaultError.readFailed
|
||||
}
|
||||
let thumbOptions: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true, // 应用 EXIF 旋转,免得横竖颠倒
|
||||
kCGImageSourceShouldCacheImmediately: true, // 在后台线程就解码,不拖到主线程绘制时
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
|
||||
]
|
||||
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else {
|
||||
throw FileVaultError.decodeFailed
|
||||
}
|
||||
let image = UIImage(cgImage: cg)
|
||||
thumbnailCache.setObject(image, forKey: cacheKey)
|
||||
return image
|
||||
}
|
||||
|
||||
nonisolated func remove(relativePath: String) throws {
|
||||
let url = try resolveSafePath(relativePath)
|
||||
do {
|
||||
@@ -88,6 +130,8 @@ final class FileVault: @unchecked Sendable {
|
||||
} catch {
|
||||
throw FileVaultError.removeFailed
|
||||
}
|
||||
// 删文件后清掉降采样缓存,避免详情页仍显示已删原图(删除次数极少,整清无虞)。
|
||||
thumbnailCache.removeAllObjects()
|
||||
}
|
||||
|
||||
/// 清空 Vault 全部文件。单个文件删除失败(被占用/权限)不中断,继续删其余;
|
||||
@@ -99,6 +143,7 @@ final class FileVault: @unchecked Sendable {
|
||||
try? fm.removeItem(at: url)
|
||||
}
|
||||
let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? []
|
||||
thumbnailCache.removeAllObjects()
|
||||
if !remaining.isEmpty {
|
||||
throw FileVaultError.removeFailed
|
||||
}
|
||||
|
||||
44
康康/Persistence/WidgetSnapshot.swift
Normal file
44
康康/Persistence/WidgetSnapshot.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// 主 App → 桌面 Widget 的数据快照(经 App Group UserDefaults 传递)。
|
||||
///
|
||||
/// 为什么不让 Widget 直接读 SwiftData:store 在 App 沙盒且开了文件保护,
|
||||
/// extension 进程独立、锁屏时不可读;快照是「最后一次看到的值」,锁屏也能显示。
|
||||
///
|
||||
/// ⚠️ 同步契约:`KangkangWidget` extension 里有本结构的独立拷贝
|
||||
/// (extension 不引主 App 代码,避免 Xcode target membership 配置成本)。
|
||||
/// 改字段时两边一起改:KangkangWidget/PinnedIndicatorsWidget.swift。
|
||||
struct WidgetSnapshot: Codable, Equatable {
|
||||
struct Item: Codable, Equatable {
|
||||
var name: String // "收缩压"
|
||||
var value: String // "128"
|
||||
var unit: String // "mmHg"
|
||||
var statusRaw: String // IndicatorStatus.rawValue: high|low|normal
|
||||
var capturedAt: Date
|
||||
}
|
||||
|
||||
var updatedAt: Date
|
||||
var items: [Item]
|
||||
|
||||
// MARK: - App Group 存取
|
||||
|
||||
/// App Group ID。两个 target 的 App Groups capability 都要勾这一个。
|
||||
static let appGroupID = "group.com.xuhuayong.kangkang"
|
||||
static let storeKey = "kk.widget.snapshot.v1"
|
||||
|
||||
/// App Group 未配置(capability 没加)时返回 nil → 调用方静默跳过,App 照常运行。
|
||||
static var sharedDefaults: UserDefaults? {
|
||||
UserDefaults(suiteName: appGroupID)
|
||||
}
|
||||
|
||||
func save(to defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) {
|
||||
guard let defaults, let data = try? JSONEncoder().encode(self) else { return }
|
||||
defaults.set(data, forKey: Self.storeKey)
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) -> WidgetSnapshot? {
|
||||
guard let defaults,
|
||||
let data = defaults.data(forKey: storeKey) else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||
}
|
||||
}
|
||||
42
康康/Persistence/WidgetSnapshotRefresher.swift
Normal file
42
康康/Persistence/WidgetSnapshotRefresher.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import WidgetKit
|
||||
|
||||
/// 把 pinned 指标的最新值写进 App Group 快照,并请求 WidgetKit 刷新。
|
||||
/// 调用时机:App 进后台 / 启动完成(RootView)。读库很轻(只取 pinned),无 AI、无网络。
|
||||
/// App Group capability 未配置时整体静默 no-op,不影响主 App。
|
||||
enum WidgetSnapshotRefresher {
|
||||
|
||||
/// 每个系列(seriesKey,无则按 name)只取最新一条,最多 6 条。
|
||||
@MainActor
|
||||
static func refresh(in ctx: ModelContext) {
|
||||
let pinnedPredicate = #Predicate<Indicator> { $0.pinned == true }
|
||||
var descriptor = FetchDescriptor<Indicator>(
|
||||
predicate: pinnedPredicate,
|
||||
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = 200 // pinned 总量不大,设上限只是兜底
|
||||
guard let pinned = try? ctx.fetch(descriptor) else { return }
|
||||
|
||||
var seenSeries = Set<String>()
|
||||
var items: [WidgetSnapshot.Item] = []
|
||||
for ind in pinned { // 已按 capturedAt 降序,首见即该系列最新
|
||||
let key = ind.seriesKey ?? ind.name
|
||||
guard seenSeries.insert(key).inserted else { continue }
|
||||
items.append(.init(
|
||||
name: ind.name,
|
||||
value: ind.value,
|
||||
unit: ind.unit,
|
||||
statusRaw: ind.statusRaw,
|
||||
capturedAt: ind.capturedAt
|
||||
))
|
||||
if items.count >= 6 { break }
|
||||
}
|
||||
|
||||
let snapshot = WidgetSnapshot(updatedAt: .now, items: items)
|
||||
// 内容没变就不写、不刷新,省 WidgetKit 的刷新预算。
|
||||
if let old = WidgetSnapshot.load(), old.items == snapshot.items { return }
|
||||
snapshot.save()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
enum TjTab: String, Hashable, CaseIterable {
|
||||
case home, records, trend, me
|
||||
@@ -35,6 +37,8 @@ enum ActiveFlow: Identifiable {
|
||||
}
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var tab: TjTab = .home
|
||||
/// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。
|
||||
@State private var pushEdge: Edge = .trailing
|
||||
@@ -45,6 +49,25 @@ struct RootView: View {
|
||||
@State private var showIndicator = false
|
||||
@State private var showReminders = false
|
||||
@State private var showHealthExport = false
|
||||
/// 长按 + :语音直达(说一句话 → LLM 意图分类 → 打开对应入口)。
|
||||
@State private var showVoiceCommand = false
|
||||
/// 语音直达「拍药盒」:RootView 层直接弹 MedicationScanFlow,不绕日记 sheet。
|
||||
@State private var showMedicationScan = false
|
||||
/// 「记录 · 药品库」:sheet + NavigationStack 形态的药品清单管理页。
|
||||
@State private var showMedicationLibrary = false
|
||||
|
||||
/// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。
|
||||
private func route(_ intent: VoiceIntent) {
|
||||
switch intent {
|
||||
case .diary: showDiary = true
|
||||
case .medication: showMedicationScan = true
|
||||
case .symptom: showSymptomStart = true
|
||||
case .indicator: showIndicator = true
|
||||
case .archive: activeFlow = .archive
|
||||
case .export: showHealthExport = true
|
||||
case .reminder: showReminders = true
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。
|
||||
/// 所有改 tab 的地方都走这里,保证过渡方向正确。
|
||||
@@ -70,9 +93,15 @@ struct RootView: View {
|
||||
|
||||
TabBar(active: tab,
|
||||
onTap: { select($0) },
|
||||
onTapRecord: { showRecordSheet = true })
|
||||
onTapRecord: { showRecordSheet = true },
|
||||
onLongPressRecord: { showVoiceCommand = true })
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
// 桌面 Widget 快照:启动后写一次,进后台时再写一次(轻量读库,App Group 未配置则 no-op)。
|
||||
.task { WidgetSnapshotRefresher.refresh(in: ctx) }
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .background { WidgetSnapshotRefresher.refresh(in: ctx) }
|
||||
}
|
||||
.sheet(isPresented: $showRecordSheet) {
|
||||
RecordSheet { kind in
|
||||
showRecordSheet = false
|
||||
@@ -85,6 +114,7 @@ struct RootView: View {
|
||||
case .indicator: showIndicator = true
|
||||
case .reminder: showReminders = true
|
||||
case .healthExport: showHealthExport = true
|
||||
case .medicationLibrary: showMedicationLibrary = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +126,7 @@ struct RootView: View {
|
||||
DiaryQuickSheet()
|
||||
}
|
||||
.sheet(isPresented: $showIndicator) {
|
||||
// 「拍照识别」入口:关闭手输表单 → 打开异常项快拍 VL 流程(并入「记录指标」)。
|
||||
// 「拍照识别」入口:关闭手输表单 → 打开指标速记 VL 流程(并入「记录指标」)。
|
||||
IndicatorQuickSheet(onRequestCamera: {
|
||||
showIndicator = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
@@ -108,9 +138,36 @@ struct RootView: View {
|
||||
// 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。
|
||||
NavigationStack { RemindersListView(presentedAsSheet: true) }
|
||||
}
|
||||
.sheet(isPresented: $showMedicationLibrary) {
|
||||
NavigationStack { MedicationLibraryView(presentedAsSheet: true) }
|
||||
}
|
||||
.fullScreenCover(isPresented: $showHealthExport) {
|
||||
HealthExportSheet()
|
||||
}
|
||||
.sheet(isPresented: $showVoiceCommand) {
|
||||
VoiceCommandSheet(
|
||||
onResolve: { intent in
|
||||
showVoiceCommand = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
route(intent)
|
||||
}
|
||||
},
|
||||
onOpenMenu: {
|
||||
showVoiceCommand = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
showRecordSheet = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||
MedicationScanFlow(
|
||||
onSave: { meds, images in
|
||||
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
|
||||
},
|
||||
onClose: { showMedicationScan = false }
|
||||
)
|
||||
}
|
||||
#if os(iOS)
|
||||
.fullScreenCover(item: $activeFlow) { flow in
|
||||
switch flow {
|
||||
@@ -137,8 +194,11 @@ private struct TabBar: View {
|
||||
let active: TjTab
|
||||
let onTap: (TjTab) -> Void
|
||||
let onTapRecord: () -> Void
|
||||
let onLongPressRecord: () -> Void
|
||||
|
||||
@Namespace private var indicatorNS
|
||||
/// + 号按压态(长按手势驱动的缩放视觉,代替 ButtonStyle)。
|
||||
@State private var recordPressing = false
|
||||
|
||||
private let cornerRadius: CGFloat = 22
|
||||
private let slotHeight: CGFloat = 34
|
||||
@@ -201,8 +261,10 @@ private struct TabBar: View {
|
||||
.buttonStyle(TabPressStyle())
|
||||
}
|
||||
|
||||
/// + 号:点按 → 新建菜单;长按 → 语音直达。
|
||||
/// 不用 Button + simultaneousGesture(长按成功后松手仍可能触发 tap 二次弹菜单),
|
||||
/// 改为 tap / longPress 双手势 + onPressingChanged 驱动按压缩放。
|
||||
private var recordSlot: some View {
|
||||
Button(action: onTapRecord) {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle()
|
||||
@@ -226,8 +288,18 @@ private struct TabBar: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.scaleEffect(recordPressing ? 0.92 : 1.0)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: recordPressing)
|
||||
.onTapGesture { onTapRecord() }
|
||||
.onLongPressGesture(minimumDuration: 0.45) {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
onLongPressRecord()
|
||||
} onPressingChanged: { pressing in
|
||||
recordPressing = pressing
|
||||
}
|
||||
.buttonStyle(TabPressStyle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("新建")
|
||||
.accessibilityHint("轻点打开新建菜单,长按语音直达")
|
||||
}
|
||||
}
|
||||
// 你好
|
||||
|
||||
67
康康/Services/BenchmarkService.swift
Normal file
67
康康/Services/BenchmarkService.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
/// 单次性能自检结果。按后端标签归档,供「MNN·SME2 vs MLX·GPU」对比展示(§12 卖点 2/6)。
|
||||
struct BenchmarkResult: Codable, Equatable {
|
||||
var backendLabel: String
|
||||
var promptTokens: Int
|
||||
var genTokens: Int
|
||||
var prefillTokensPerSecond: Double
|
||||
var decodeTokensPerSecond: Double
|
||||
var totalSeconds: Double
|
||||
var date: Date
|
||||
}
|
||||
|
||||
/// 性能自检服务:跑固定 prompt,取 AIRuntime 的归一统计,按后端标签存 UserDefaults。
|
||||
/// UI(ModelSelfTestView)只经本服务调 AIRuntime(§3.1)。
|
||||
@MainActor
|
||||
struct BenchmarkService {
|
||||
static let shared = BenchmarkService()
|
||||
private init() {}
|
||||
|
||||
nonisolated static let storeKey = "kk.benchmark.results"
|
||||
|
||||
/// 固定测试 prompt:跨设备/引擎可比的前提。
|
||||
static let fixedPrompt = "用中文一句话介绍肝功能里 ALT 这个指标。"
|
||||
|
||||
/// 跑一次自检。onToken 把流式输出交给 UI 展示。
|
||||
func run(onToken: @escaping @MainActor (String, Double) -> Void) async throws -> BenchmarkResult {
|
||||
try await AIRuntime.shared.prepare()
|
||||
let start = Date()
|
||||
let stream = await AIRuntime.shared.generate(prompt: Self.fixedPrompt, maxTokens: 128)
|
||||
for try await chunk in stream {
|
||||
onToken(chunk.text, chunk.decodeRate)
|
||||
}
|
||||
let total = Date().timeIntervalSince(start)
|
||||
let label = await AIRuntime.shared.activeBackendLabel
|
||||
let stats = await AIRuntime.shared.lastGenerateStats
|
||||
let result = BenchmarkResult(
|
||||
backendLabel: label,
|
||||
promptTokens: stats?.promptTokens ?? 0,
|
||||
genTokens: stats?.genTokens ?? 0,
|
||||
prefillTokensPerSecond: stats?.prefillTokensPerSecond ?? 0,
|
||||
decodeTokensPerSecond: stats?.decodeTokensPerSecond ?? 0,
|
||||
totalSeconds: total,
|
||||
date: .now
|
||||
)
|
||||
Self.save(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - 存档(静态纯函数,单测覆盖;nonisolated:纯 UserDefaults 操作,无需主线程)
|
||||
|
||||
nonisolated static func save(_ result: BenchmarkResult, defaults: UserDefaults = .standard) {
|
||||
var all = load(defaults: defaults)
|
||||
all[result.backendLabel] = result
|
||||
if let data = try? JSONEncoder().encode(all) {
|
||||
defaults.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func load(defaults: UserDefaults = .standard) -> [String: BenchmarkResult] {
|
||||
guard let data = defaults.data(forKey: storeKey),
|
||||
let all = try? JSONDecoder().decode([String: BenchmarkResult].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return all
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import ImageIO
|
||||
import SwiftData
|
||||
|
||||
/// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。
|
||||
@@ -32,7 +33,9 @@ struct ParsedReport: Sendable {
|
||||
var isEmpty: Bool { indicators.isEmpty }
|
||||
|
||||
/// 占位空结果,失败回退时给 UI。
|
||||
static func empty(date: Date = .now) -> ParsedReport {
|
||||
/// nonisolated:本工程默认 MainActor 隔离,而 CaptureService(actor)里的 extractReportMeta
|
||||
/// 需要在 actor 上下文构造空结果 —— 纯值工厂,标 nonisolated 才能跨隔离调用(Swift 6)。
|
||||
nonisolated static func empty(date: Date = .now) -> ParsedReport {
|
||||
ParsedReport(
|
||||
title: "",
|
||||
typeRaw: ReportType.other.rawValue,
|
||||
@@ -77,51 +80,38 @@ actor CaptureService {
|
||||
try await runVL(on: assets)
|
||||
}
|
||||
|
||||
/// 异常项快拍:对一张**局部照片**(JPEG data)跑 VL,只抽 indicators,不建 Report、不留图。
|
||||
/// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtectionUnlessOpen`),推理后 `defer` 删除 ——
|
||||
/// 符合「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。
|
||||
/// - 失败抛 `CaptureError`,UI 回退手动录入(§3.2 失败回退红线)。
|
||||
/// 调用方(MainActor)负责把识别结果落成独立 Indicator。
|
||||
func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] {
|
||||
do {
|
||||
try await AIRuntime.shared.prepareVL()
|
||||
} catch {
|
||||
throw CaptureError.modelNotReady
|
||||
/// 报告归档「轻量 meta 提取」:**只保存原图,不逐项识别指标**。
|
||||
/// 逐项多模态识别在 2B 上又慢又易 OOM(jetsam 杀进程 = 用户说的「死机」),
|
||||
/// 故归档链路改为:Vision OCR(本地,<1s/页)→ 文本 LLM 只抽 {title,type,date,institution}(~50 token)。
|
||||
/// 全程容错:OCR 空 / 模型未就绪 / 解析失败都返回 (空 meta, recognized:false),绝不抛、绝不阻断保存原图(§3.2)。
|
||||
/// 返回的 indicators 恒为空 —— 归档不建指标。
|
||||
func extractReportMeta(assets: [FileVault.SavedAsset]) async -> (meta: ParsedReport, recognized: Bool) {
|
||||
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
|
||||
let ocr = await Self.ocrReference(for: urls)
|
||||
guard !ocr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return (.empty(), false)
|
||||
}
|
||||
|
||||
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("region-\(UUID().uuidString).jpg")
|
||||
do {
|
||||
// 用 .completeFileProtectionUnlessOpen 而非 .complete:VL 推理可能持续数秒,
|
||||
// 期间设备若锁屏,.complete 会让读/写抛 EPERM 使快拍在锁屏下必失败;
|
||||
// unlessOpen 允许已打开句柄继续访问,与 Vault(completeUnlessOpen)一致。
|
||||
try imageData.write(to: tmpURL, options: [.completeFileProtectionUnlessOpen, .atomic])
|
||||
try await AIRuntime.shared.prepare() // 文本 LLM(轻);OOM 闸门已处理 VL 卸载
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)")
|
||||
return (.empty(), false)
|
||||
}
|
||||
defer { try? FileManager.default.removeItem(at: tmpURL) }
|
||||
|
||||
let raw: String
|
||||
var collected = ""
|
||||
do {
|
||||
raw = try await AIRuntime.shared.analyzeReport(
|
||||
imageURLs: [tmpURL],
|
||||
prompt: VLPrompts.regionExtraction(),
|
||||
// 整张化验单可能含十余项,512 token 会截断 → 解析失败。给足额度。
|
||||
maxTokens: 2048
|
||||
)
|
||||
// meta 输出极小,256 token 足够,远小于逐项识别的 2048 —— 这是不卡死的关键。
|
||||
let stream = await AIRuntime.shared.generate(prompt: VLPrompts.reportMetaFromText(ocr),
|
||||
maxTokens: 256)
|
||||
for try await chunk in stream { collected += chunk.text }
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
return (.empty(), false)
|
||||
}
|
||||
#if DEBUG
|
||||
print("🔎 [recognizeRegion] image bytes=\(imageData.count), VL raw output:\n\(raw)\n--- end VL raw ---")
|
||||
#endif
|
||||
do {
|
||||
return try CaptureService.parseIndicatorsJSON(raw)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
throw CaptureError.parseFailed(msg)
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("\(error)")
|
||||
let cleaned = CaptureService.stripThink(collected)
|
||||
guard var parsed = try? CaptureService.parseReportJSON(cleaned, pageCount: assets.count) else {
|
||||
return (.empty(), false)
|
||||
}
|
||||
// 归档只存 meta + 原图,丢弃模型可能附带的任何指标。
|
||||
parsed.indicators = []
|
||||
return (parsed, true)
|
||||
}
|
||||
|
||||
/// 「拍照识别」OCR 链路:把 Vision OCR 出的纯文本交给 LLM(Qwen3-1.7B)结构化抽指标。
|
||||
@@ -149,12 +139,19 @@ actor CaptureService {
|
||||
// Qwen3 可能吐 <think>…</think>,先剥掉再抠 JSON。
|
||||
let cleaned = CaptureService.stripThink(collected)
|
||||
#if DEBUG
|
||||
print("🧠 [recognizeIndicators] LLM cleaned output:\n\(cleaned)\n--- end LLM ---")
|
||||
// 取证:原始输出(含可能未闭合的 <think>)+ strip 后,定位「空/非法 JSON」根因。
|
||||
// 用 NSLog(走统一日志)而非 print(stdout 被 Xcode lldb 接管,idevicesyslog 抓不到)。
|
||||
NSLog("KKDBG-VL RAW LLM output (%d chars):\n%@\n--- end RAW ---", collected.count, collected)
|
||||
NSLog("KKDBG-VL cleaned (%d chars):\n%@\n--- end cleaned ---", cleaned.count, cleaned)
|
||||
#endif
|
||||
do {
|
||||
return try CaptureService.parseIndicatorsJSON(cleaned)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
throw CaptureError.parseFailed(msg)
|
||||
// 把模型实际输出的特征带到屏幕上,便于现场定位(原始长度 / strip 后长度 / 前缀)。
|
||||
let rawLen = collected.count
|
||||
let cleanLen = cleaned.count
|
||||
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
|
||||
throw CaptureError.parseFailed("\(msg)〔raw \(rawLen)字/clean \(cleanLen)字·前缀:\(preview)〕")
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("\(error)")
|
||||
}
|
||||
@@ -182,11 +179,14 @@ actor CaptureService {
|
||||
throw CaptureError.modelNotReady
|
||||
}
|
||||
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
|
||||
// OCR 参考(Vision 本地,<1s/页):给 2B 多模态当数字「抄写员」,降低小字误读。
|
||||
// 任何失败都静默回退为空串,绝不阻断识别主流程(§3.2)。
|
||||
let ocr = await Self.ocrReference(for: urls)
|
||||
let raw: String
|
||||
do {
|
||||
raw = try await AIRuntime.shared.analyzeReport(
|
||||
imageURLs: urls,
|
||||
prompt: VLPrompts.reportExtraction()
|
||||
prompt: VLPrompts.reportExtraction(ocrText: ocr)
|
||||
)
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
@@ -200,6 +200,29 @@ actor CaptureService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 对 Vault 报告图逐页 OCR 拼参考文本。最多 4 页;失败/空文本返回 ""。
|
||||
/// 用 ImageIO 直取 CGImage(不经 UIImage,避免跨 actor 传非 Sendable 引用)。
|
||||
private static func ocrReference(for urls: [URL]) async -> String {
|
||||
var pages: [String] = []
|
||||
for (idx, url) in urls.prefix(4).enumerated() {
|
||||
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { continue }
|
||||
// OCR 不需要全分辨率:一张 4000px 体检照全量解码 ≈48MB,正赶在 VL 推理前叠加,
|
||||
// 易触发 jetsam。降到 ≤3000px 既省内存又加速 Vision,医检报告字号此分辨率仍清晰;
|
||||
// 且原图仍完整交给 VL 自行读取,OCR 仅当数字「抄写员」辅助,降采样不影响最终可用信息。
|
||||
let thumbOptions: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: 3000
|
||||
]
|
||||
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else { continue }
|
||||
guard let text = try? await OCRService.recognizeText(in: cg),
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue }
|
||||
pages.append(urls.count > 1 ? "【第 \(idx + 1) 页】\n\(text)" : text)
|
||||
}
|
||||
return pages.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - JSON parse(static + 纯函数 → 方便单测)
|
||||
|
||||
/// 从 VL 输出里抠出第一段合法 JSON 对象并解析。
|
||||
@@ -213,7 +236,7 @@ actor CaptureService {
|
||||
// 用 extractBalancedJSON(而非只认 {} 的 extractJSONObject):VL 多项时偶尔直接吐
|
||||
// 裸数组 [{...},{...}],只认对象会从第一个 { 配对,只截出第一个 indicator、静默丢掉
|
||||
// 其余 —— 这是影像档案核心卖点上的数据丢失。顶层是数组时整体视作 indicators。
|
||||
let jsonString = extractBalancedJSON(from: raw)
|
||||
let jsonString = repairJSON(extractBalancedJSON(from: raw))
|
||||
guard let data = jsonString.data(using: .utf8) else {
|
||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||
}
|
||||
@@ -259,7 +282,7 @@ actor CaptureService {
|
||||
/// 复用 `extractJSONObject` + `parseIndicator`。解析不到任何 indicator 返回空数组(不抛),
|
||||
/// UI 据此走「没读出指标,手动补充」分支。JSON 本身不合法才抛 `parseFailed`。
|
||||
static func parseIndicatorsJSON(_ raw: String) throws -> [ParsedReport.ParsedIndicator] {
|
||||
let jsonString = extractBalancedJSON(from: raw)
|
||||
let jsonString = repairJSON(extractBalancedJSON(from: raw))
|
||||
guard let data = jsonString.data(using: .utf8) else {
|
||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||
}
|
||||
@@ -324,6 +347,21 @@ actor CaptureService {
|
||||
return String(s[start...])
|
||||
}
|
||||
|
||||
/// 弱模型(2B)常见 JSON 畸形的安全修复,仅在 JSONSerialization 前兜底:
|
||||
/// - 中文弯引号 “ ” → 直引号 "(模型偶尔给 key/value 套全角引号)
|
||||
/// - 去对象/数组尾逗号(`,}` / `,]` → `}` / `]`)
|
||||
/// 修不好仍按原逻辑报错;只做结构性修正,不改字符串语义。
|
||||
static func repairJSON(_ s: String) -> String {
|
||||
var t = s
|
||||
t = t.replacingOccurrences(of: "\u{201C}", with: "\"") // “
|
||||
t = t.replacingOccurrences(of: "\u{201D}", with: "\"") // ”
|
||||
if let re = try? NSRegularExpression(pattern: ",\\s*([}\\]])") {
|
||||
t = re.stringByReplacingMatches(
|
||||
in: t, range: NSRange(t.startIndex..., in: t), withTemplate: "$1")
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/// 抠出第一段平衡的 JSON 值,`{...}` 或 `[...]` 以先出现者为准。
|
||||
/// 用于局部识别(模型可能输出 `{"indicators":[...]}` 或裸 `[...]`)。
|
||||
/// 失败返回去围栏后的原串(后续 JSONSerialization 报错)。
|
||||
|
||||
@@ -74,8 +74,8 @@ struct DiaryAssistService {
|
||||
|
||||
// 1. 去 <think>...</think>(复用 HealthExportService 的兜底)
|
||||
let stripped = HealthExportService.stripThinkBlocks(collected)
|
||||
// 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject)
|
||||
let jsonStr = CaptureService.extractJSONObject(from: stripped)
|
||||
// 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject)+ 弱模型畸形修复
|
||||
let jsonStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
|
||||
guard let data = jsonStr.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
|
||||
let dict = obj as? [String: Any] else {
|
||||
@@ -98,4 +98,29 @@ struct DiaryAssistService {
|
||||
guard !questions.isEmpty else { throw AssistError.empty }
|
||||
return (Array(questions.prefix(4)), lastRate)
|
||||
}
|
||||
|
||||
/// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。
|
||||
/// 失败(模型未就绪 / 输出为空)抛错,调用方回退为直接使用原话,不卡死。
|
||||
/// 与 suggest 同样走 AIRuntime actor 队列,自然与追问/拍照串行。
|
||||
func organize(transcript: String) async throws -> (text: String, decodeRate: Double) {
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
} catch {
|
||||
throw AssistError.modelNotReady
|
||||
}
|
||||
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: transcript)
|
||||
var collected = ""
|
||||
var lastRate: Double = 0
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||
}
|
||||
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw AssistError.empty }
|
||||
return (text, lastRate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ struct HealthExportDialogueTurn: Identifiable, Hashable, Sendable {
|
||||
|
||||
var transcriptLabel: String {
|
||||
switch self {
|
||||
case .user: return String(appLoc: "患者")
|
||||
case .user: return String(appLoc: "我")
|
||||
case .assistant: return String(appLoc: "康康")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,56 @@ struct HealthExportService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检索结果摘要 —— 把「本地 RAG 找到了什么」拿给 UI 演出来(§12 卖点 3)。
|
||||
struct RetrievalSummary: Sendable, Equatable {
|
||||
var chips: [String]
|
||||
var indicatorCount: Int
|
||||
var reportCount: Int
|
||||
var symptomCount: Int
|
||||
var diaryCount: Int
|
||||
|
||||
var totalCount: Int { indicatorCount + reportCount + symptomCount + diaryCount }
|
||||
|
||||
/// 同名指标合并计数(保持检索的新→旧顺序),超出 cap 折叠成 "+N"。纯函数,单测覆盖。
|
||||
static func groupedChips(_ names: [String], cap: Int = 8) -> [String] {
|
||||
var order: [String] = []
|
||||
var counts: [String: Int] = [:]
|
||||
for n in names {
|
||||
if counts[n] == nil { order.append(n) }
|
||||
counts[n, default: 0] += 1
|
||||
}
|
||||
var chips = order.map { name -> String in
|
||||
let c = counts[name] ?? 1
|
||||
return c > 1 ? "\(name) ×\(c)" : name
|
||||
}
|
||||
if chips.count > cap {
|
||||
let overflow = chips.count - cap
|
||||
chips = Array(chips.prefix(cap)) + ["+\(overflow)"]
|
||||
}
|
||||
return chips
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func from(snapshot: Snapshot) -> RetrievalSummary {
|
||||
var chips = groupedChips(snapshot.indicators.map(\.name), cap: 8)
|
||||
chips += snapshot.reports.prefix(3).map(\.title)
|
||||
chips += snapshot.symptoms.prefix(3).map(\.name)
|
||||
if !snapshot.diaries.isEmpty {
|
||||
chips.append(String(appLoc: "日记 ×\(snapshot.diaries.count)"))
|
||||
}
|
||||
return RetrievalSummary(
|
||||
chips: chips,
|
||||
indicatorCount: snapshot.indicators.count,
|
||||
reportCount: snapshot.reports.count,
|
||||
symptomCount: snapshot.symptoms.count,
|
||||
diaryCount: snapshot.diaries.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum Event {
|
||||
case phaseChanged(Phase)
|
||||
case retrieved(RetrievalSummary)
|
||||
case token(TokenChunk)
|
||||
case completed(persistentID: PersistentIdentifier)
|
||||
// .failed 走 stream throw,不在 Event 里
|
||||
@@ -80,6 +128,7 @@ struct HealthExportService {
|
||||
// —— Phase 2: 检索 ——
|
||||
continuation.yield(.phaseChanged(.retrieving))
|
||||
let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
|
||||
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||||
try Task.checkCancellation()
|
||||
|
||||
// —— Phase 3: 生成 ——
|
||||
@@ -178,9 +227,10 @@ struct HealthExportService {
|
||||
}
|
||||
|
||||
/// 多轮导出页的单轮问答。只回答,不入库。
|
||||
/// 事件流:先 .retrieved(检索可视化),后 .token 流式正文;不发 .phaseChanged / .completed。
|
||||
func answer(question: String,
|
||||
conversation: [HealthExportDialogueTurn],
|
||||
in modelContext: ModelContext) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task { @MainActor in
|
||||
do {
|
||||
@@ -191,6 +241,7 @@ struct HealthExportService {
|
||||
}
|
||||
|
||||
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
|
||||
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||||
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
|
||||
let prompt = HealthExportPrompts.dialogueAnswer(
|
||||
@@ -209,7 +260,7 @@ struct HealthExportService {
|
||||
if clean.count > displayed.count, clean.hasPrefix(displayed) {
|
||||
let delta = String(clean.dropFirst(displayed.count))
|
||||
displayed = clean
|
||||
continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate))
|
||||
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
|
||||
} else if clean != displayed {
|
||||
displayed = clean
|
||||
}
|
||||
@@ -245,6 +296,7 @@ struct HealthExportService {
|
||||
|
||||
continuation.yield(.phaseChanged(.retrieving))
|
||||
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
|
||||
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||||
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
|
||||
try Task.checkCancellation()
|
||||
@@ -398,6 +450,8 @@ struct HealthExportService {
|
||||
var reports: [Report]
|
||||
var diaries: [DiaryEntry]
|
||||
var profile: UserProfile
|
||||
/// 药品库(用户「我有哪些药」清单)→ AI 背景 current_meds。空 → 不写该字段。
|
||||
var medications: [Medication] = []
|
||||
/// 相关指标的趋势行(确定性计算,不进 LLM)。空 → 不渲染「## 指标趋势」段。
|
||||
var trends: [ExportTrend] = []
|
||||
}
|
||||
@@ -478,6 +532,9 @@ struct HealthExportService {
|
||||
// —— Profile(单例) ——
|
||||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||||
|
||||
// —— 药品库(全量,作为 AI 背景 current_meds) ——
|
||||
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
|
||||
|
||||
// —— 趋势(确定性,不进 LLM) ——
|
||||
// 用全量 in-window 还原完整序列;裁剪后的 indicators 决定哪些 series 相关。
|
||||
let trends = ExportTrendBuilder.build(
|
||||
@@ -494,6 +551,7 @@ struct HealthExportService {
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
profile: profile,
|
||||
medications: medications,
|
||||
trends: trends
|
||||
)
|
||||
}
|
||||
@@ -509,6 +567,7 @@ struct HealthExportService {
|
||||
let indicators = (try? ctx.fetch(indicatorDesc)) ?? []
|
||||
let diaries = (try? ctx.fetch(diaryDesc)) ?? []
|
||||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||||
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
|
||||
|
||||
let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt)
|
||||
let fromDate = dates.min() ?? Date()
|
||||
@@ -529,6 +588,7 @@ struct HealthExportService {
|
||||
reports: [],
|
||||
diaries: diaries,
|
||||
profile: profile,
|
||||
medications: medications,
|
||||
trends: trends
|
||||
)
|
||||
}
|
||||
@@ -559,7 +619,11 @@ struct HealthExportService {
|
||||
if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
|
||||
if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
|
||||
if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory }
|
||||
if !profile.currentMedications.isEmpty { profDict["current_meds"] = profile.currentMedications }
|
||||
// current_meds 改读药品库(Medication);旧 profile.currentMedications 已停用。
|
||||
let medNames = snapshot.medications.map { m in
|
||||
m.detailLine.isEmpty ? m.name : "\(m.name) \(m.detailLine)"
|
||||
}
|
||||
if !medNames.isEmpty { profDict["current_meds"] = medNames }
|
||||
root["profile"] = profDict
|
||||
|
||||
// symptoms
|
||||
@@ -629,7 +693,8 @@ struct HealthExportService {
|
||||
/// 检索结果是否「实质为空」:无症状/指标/报告/日记,且 profile 也没有任何可写字段。
|
||||
/// 为真时跳过 LLM,改用确定性「无记录」摘要,避免小模型凭先验编造病例。
|
||||
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
|
||||
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, s.diaries.isEmpty else {
|
||||
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty,
|
||||
s.diaries.isEmpty, s.medications.isEmpty else {
|
||||
return false
|
||||
}
|
||||
let p = s.profile
|
||||
@@ -641,10 +706,9 @@ struct HealthExportService {
|
||||
&& p.allergies.isEmpty
|
||||
&& p.chronicConditions.isEmpty
|
||||
&& p.familyHistory.isEmpty
|
||||
&& p.currentMedications.isEmpty
|
||||
}
|
||||
|
||||
/// 无真实记录时的确定性摘要:6 段全「无记录」,主诉仅照搬患者原话,不做任何推断。
|
||||
/// 无真实记录时的确定性摘要:6 段全「无记录」,主诉仅照搬本人原话,不做任何推断。
|
||||
static func fallbackReport(label: String, userPrompt: String) -> String {
|
||||
let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)"
|
||||
let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -652,12 +716,12 @@ struct HealthExportService {
|
||||
return """
|
||||
\(title)
|
||||
|
||||
> 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据患者原话,未做任何推断。
|
||||
> 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据本人原话,未做任何推断。
|
||||
|
||||
## 主诉
|
||||
\(complaintLine)
|
||||
|
||||
## 患者背景
|
||||
## 本人背景
|
||||
无记录
|
||||
|
||||
## 近期症状(按时间倒序)
|
||||
@@ -669,7 +733,7 @@ struct HealthExportService {
|
||||
## 在服药与过敏
|
||||
无记录
|
||||
|
||||
## 患者疑问
|
||||
## 本人疑问
|
||||
无记录
|
||||
"""
|
||||
}
|
||||
|
||||
114
康康/Services/MedicationScanService.swift
Normal file
114
康康/Services/MedicationScanService.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
|
||||
/// 药盒识别结果(结构化,与 UserProfile.currentMedications 的字符串条目解耦)。
|
||||
struct ParsedMedication: Sendable, Identifiable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var strength: String // 规格,如 "80mg×7粒"
|
||||
var usage: String // 用法,如 "口服,一次1片,一日2次"
|
||||
|
||||
/// 写入 UserProfile.currentMedications 的单行文本,
|
||||
/// 与手动录入习惯一致(placeholder "如:缬沙坦 80mg qd")。
|
||||
var entryText: String {
|
||||
var s = name.trimmingCharacters(in: .whitespaces)
|
||||
let st = strength.trimmingCharacters(in: .whitespaces)
|
||||
let u = usage.trimmingCharacters(in: .whitespaces)
|
||||
if !st.isEmpty { s += " \(st)" }
|
||||
if !u.isEmpty { s += " · \(u)" }
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
/// 「拍药盒入档」服务:OCR 文本 → LLM(MNN/SME2 主链路)结构化抽药品。
|
||||
/// 与 CaptureService.recognizeIndicators 同构:UI 不直接碰 AIRuntime(§3.1),
|
||||
/// 失败抛 CaptureError,UI 回退手动录入(§3.2)。
|
||||
/// actor 原因同 CaptureService:方法要等 AIRuntime(actor),自身无可变状态。
|
||||
actor MedicationScanService {
|
||||
static let shared = MedicationScanService()
|
||||
private init() {}
|
||||
|
||||
/// 药盒/说明书/处方的 OCR 文本 → [ParsedMedication]。
|
||||
/// 调用方(MainActor)先做 OCR 再传文本进来,避免 UIImage 跨 actor。
|
||||
func recognizeMedications(fromOCRText text: String) async throws -> [ParsedMedication] {
|
||||
do {
|
||||
try await AIRuntime.shared.prepare() // 载 LLM(与 VL 互斥卸载由 AIRuntime 闸门处理)
|
||||
} catch {
|
||||
throw CaptureError.modelNotReady
|
||||
}
|
||||
|
||||
let prompt = MedicationPrompts.medicationsFromText(text)
|
||||
var collected = ""
|
||||
do {
|
||||
// 药盒一般 1-2 种药,512 token 足够;与其他推理由 AIRuntime 闸门串行。
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
}
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
}
|
||||
|
||||
let cleaned = CaptureService.stripThink(collected)
|
||||
do {
|
||||
return try Self.parseMedicationsJSON(cleaned)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
|
||||
throw CaptureError.parseFailed("\(msg)〔前缀:\(preview)〕")
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON parse(static 纯函数 → 方便单测)
|
||||
|
||||
/// 兼容 `{"medications":[...]}` 与裸数组 `[...]`。
|
||||
/// 解析不到任何药品返回空数组(不抛),UI 据此走「手动补充」分支;JSON 不合法才抛。
|
||||
static func parseMedicationsJSON(_ raw: String) throws -> [ParsedMedication] {
|
||||
let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: raw))
|
||||
guard let data = jsonString.data(using: .utf8) else {
|
||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||
}
|
||||
let obj: Any
|
||||
do {
|
||||
obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
|
||||
}
|
||||
let rawList: [[String: Any]]
|
||||
if let dict = obj as? [String: Any] {
|
||||
rawList = arrayValue(dict, keys: ["medications", "meds", "drugs", "药品", "用药", "items"])
|
||||
} else if let arr = obj as? [[String: Any]] {
|
||||
rawList = arr
|
||||
} else {
|
||||
throw CaptureError.parseFailed("根节点既不是对象也不是数组")
|
||||
}
|
||||
var seen = Set<String>()
|
||||
return rawList.compactMap { parseMedication($0) }.filter { seen.insert($0.name).inserted }
|
||||
}
|
||||
|
||||
private static func parseMedication(_ d: [String: Any]) -> ParsedMedication? {
|
||||
guard let name = stringValue(d, keys: ["name", "drug", "medication", "药名", "药品", "名称"])?
|
||||
.trimmingCharacters(in: .whitespaces),
|
||||
!name.isEmpty else { return nil }
|
||||
let strength = stringValue(d, keys: ["strength", "spec", "specification", "规格", "剂量"]) ?? ""
|
||||
let usage = stringValue(d, keys: ["usage", "dosage", "用法", "用量", "用法用量"]) ?? ""
|
||||
return ParsedMedication(name: name,
|
||||
strength: strength.trimmingCharacters(in: .whitespaces),
|
||||
usage: usage.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
|
||||
private static func stringValue(_ d: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let s = d[key] as? String { return s }
|
||||
if let n = d[key] as? NSNumber { return n.stringValue }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] {
|
||||
for key in keys {
|
||||
if let arr = d[key] as? [[String: Any]] { return arr }
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ final class ModelDownloadService {
|
||||
}
|
||||
|
||||
func downloadAll() {
|
||||
for kind in ModelKind.allCases { download(kind) }
|
||||
for kind in ModelKind.userFacing { download(kind) }
|
||||
}
|
||||
|
||||
/// 暂停下载。已下载的 .part 保留,下次从断点续传。
|
||||
|
||||
@@ -23,7 +23,7 @@ enum OCRService {
|
||||
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
|
||||
do {
|
||||
try handler.perform([request])
|
||||
let obs = (request.results as? [VNRecognizedTextObservation]) ?? []
|
||||
let obs = request.results ?? []
|
||||
cont.resume(returning: assemble(obs))
|
||||
} catch {
|
||||
cont.resume(throwing: error)
|
||||
|
||||
@@ -80,20 +80,24 @@ enum ReminderService {
|
||||
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let h = reminder.hour, m = reminder.minute
|
||||
let slots: [Slot]
|
||||
switch reminder.frequency {
|
||||
// 多选频率:把每个选中频率展开成槽,合并调度(suffix 各不冲突,可单独取消)。
|
||||
var slots: [Slot] = []
|
||||
for f in reminder.frequencies {
|
||||
switch f {
|
||||
case .daily:
|
||||
slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))]
|
||||
slots.append(Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m)))
|
||||
case .weekly:
|
||||
slots = reminder.weekdays.map { wd in
|
||||
slots += reminder.weekdays.map { wd in
|
||||
Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd))
|
||||
}
|
||||
case .monthly:
|
||||
slots = [Slot(suffix: "monthly",
|
||||
dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))]
|
||||
slots += reminder.monthlyDays.map { d in
|
||||
Slot(suffix: "m\(d)", dc: DateComponents(day: d, hour: h, minute: m))
|
||||
}
|
||||
case .yearly:
|
||||
slots = [Slot(suffix: "yearly",
|
||||
dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))]
|
||||
slots.append(Slot(suffix: "yearly",
|
||||
dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m)))
|
||||
}
|
||||
}
|
||||
await schedule(
|
||||
idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
|
||||
@@ -146,11 +150,13 @@ enum ReminderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消某个 idBase 下所有可能后缀的 pending 通知(daily/monthly/yearly + 7 个 weekday,不漏)。
|
||||
/// 取消某个 idBase 下所有可能后缀的 pending 通知,不漏:
|
||||
/// daily / yearly / 旧版 monthly + 7 个 weekday(w1...w7)+ 31 个月内日(m1...m31)。
|
||||
private static func cancelBase(_ idBase: String) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"]
|
||||
ids += (1...7).map { "\(idBase).w\($0)" }
|
||||
ids += (1...31).map { "\(idBase).m\($0)" }
|
||||
center.removePendingNotificationRequests(withIdentifiers: ids)
|
||||
}
|
||||
}
|
||||
|
||||
61
康康/Services/ReportInsightService.swift
Normal file
61
康康/Services/ReportInsightService.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// 报告大白话摘要预生成(§3.1:流程经本服务碰 AIRuntime,UI 不直接调)。
|
||||
/// 时机:归档保存后立即后台跑(用户继续操作时完成);详情页打开时兜底重试。
|
||||
/// 写回策略:只在 summary 为空时生成 —— 绝不覆盖 VL 已给出或用户编辑过的摘要。
|
||||
@MainActor
|
||||
final class ReportInsightService {
|
||||
static let shared = ReportInsightService()
|
||||
private init() {}
|
||||
|
||||
/// 进行中的报告 ID,防止「保存后台任务」与「详情页兜底」重复触发。
|
||||
private var inFlight: Set<String> = []
|
||||
|
||||
func pregenerateIfNeeded(report: Report, in ctx: ModelContext) async {
|
||||
guard (report.summary ?? "").isEmpty, !report.indicators.isEmpty else { return }
|
||||
let key = String(describing: report.persistentModelID)
|
||||
guard !inFlight.contains(key) else { return }
|
||||
inFlight.insert(key)
|
||||
defer { inFlight.remove(key) }
|
||||
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
} catch {
|
||||
return // 模型未就绪:静默放弃,详情页下次打开再试
|
||||
}
|
||||
|
||||
let prompt = InsightPrompts.reportPlainSummary(
|
||||
title: report.title,
|
||||
typeLabel: report.type.label,
|
||||
indicatorLines: Self.indicatorLines(for: report.indicators)
|
||||
)
|
||||
var collected = ""
|
||||
do {
|
||||
let stream = await AIRuntime.shared.generate(
|
||||
prompt: prompt, maxTokens: 200, priority: .background)
|
||||
for try await chunk in stream { collected += chunk.text }
|
||||
} catch {
|
||||
return // 被前台任务抢占(CancellationError)或推理失败:放弃,兜底路径再试
|
||||
}
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, (report.summary ?? "").isEmpty else { return }
|
||||
report.summary = text
|
||||
try? ctx.save()
|
||||
}
|
||||
|
||||
/// 「名 值 单位(参考 range)status」每指标一行;异常项排前,上限 15 行控 prompt 体积。
|
||||
static func indicatorLines(for indicators: [Indicator]) -> String {
|
||||
let sorted = indicators.sorted {
|
||||
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
|
||||
}
|
||||
return sorted.prefix(15).map { i in
|
||||
var line = "\(i.name) \(i.value)"
|
||||
if !i.unit.isEmpty { line += " \(i.unit)" }
|
||||
if !i.range.isEmpty { line += "(参考 \(i.range))" }
|
||||
line += " \(i.status.rawValue)"
|
||||
return line
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
161
康康/Services/SpeechDictationService.swift
Normal file
161
康康/Services/SpeechDictationService.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import Speech
|
||||
import AVFoundation
|
||||
|
||||
/// 端侧流式语音转写(spec 2026-06-10-voice-diary)。
|
||||
/// AVAudioEngine 麦克风 buffer → SFSpeechAudioBufferRecognitionRequest,
|
||||
/// `requiresOnDeviceRecognition = true` 硬性端侧,识别内容不出设备;**不落盘任何音频**。
|
||||
///
|
||||
/// 生命周期:start(onPartial:) 开始录音并实时回调 partial;stop() 结束并返回最终稿。
|
||||
/// 调用方:DiaryQuickSheet。工程默认 MainActor 隔离,本类型即 MainActor;
|
||||
/// audio tap 与识别回调在系统线程,闭包内只碰局部捕获对象,回主线程统一走 Task { @MainActor }。
|
||||
final class SpeechDictationService {
|
||||
|
||||
enum DictationError: Error, LocalizedError {
|
||||
case unavailable
|
||||
case audioEngineStartFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
return String(appLoc: "本机不支持端侧语音识别")
|
||||
case .audioEngineStartFailed(let m):
|
||||
return String(appLoc: "录音启动失败:\(m)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把已有文字 `prefix` 与新听写片段 `partial` 合并成一段。纯函数,方便单测、与录音生命周期解耦。
|
||||
/// 规则:空片段保留原文;空前缀直接用片段;前缀已以空白(空格/换行)结尾则直接拼,
|
||||
/// 否则中间补一个空格——避免「已有内容新听写」黏成一坨,也不会在换行后多塞空格。
|
||||
static func merge(prefix: String, partial: String) -> String {
|
||||
if partial.isEmpty { return prefix }
|
||||
if prefix.isEmpty { return partial }
|
||||
if prefix.last?.isWhitespace == true { return prefix + partial }
|
||||
return prefix + " " + partial
|
||||
}
|
||||
|
||||
/// 优先系统语言;系统语言不支持端侧时兜底中文(demo 机即使系统是英文也能用)。
|
||||
private static func makeRecognizer() -> SFSpeechRecognizer? {
|
||||
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {
|
||||
return r
|
||||
}
|
||||
if let r = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")),
|
||||
r.supportsOnDeviceRecognition {
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 本机是否支持端侧识别。false(模拟器/老机型)时 UI 隐藏 mic 入口,静默降级。
|
||||
static var isAvailable: Bool { makeRecognizer() != nil }
|
||||
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
/// 识别回调持续刷新;isFinal 或出错时置 didFinish。stop() 用「final 优先、partial 兜底」。
|
||||
private var latestText = ""
|
||||
private var didFinish = false
|
||||
|
||||
private(set) var isRecording = false
|
||||
|
||||
/// 麦克风 + 语音识别两个权限一起申请。任一被拒返回 false。
|
||||
func requestAuthorization() async -> Bool {
|
||||
let speech = await withCheckedContinuation { (c: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { c.resume(returning: $0) }
|
||||
}
|
||||
guard speech == .authorized else { return false }
|
||||
return await AVAudioApplication.requestRecordPermission()
|
||||
}
|
||||
|
||||
/// 开始录音 + 流式识别。partial 结果在主线程回调(录音面板实时字幕)。
|
||||
func start(onPartial: @escaping (String) -> Void) throws {
|
||||
guard !isRecording else { return }
|
||||
guard let recognizer = Self.makeRecognizer(), recognizer.isAvailable else {
|
||||
throw DictationError.unavailable
|
||||
}
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
throw DictationError.audioEngineStartFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.requiresOnDeviceRecognition = true // 红线:识别不出设备
|
||||
request.shouldReportPartialResults = true
|
||||
request.addsPunctuation = true
|
||||
self.request = request
|
||||
latestText = ""
|
||||
didFinish = false
|
||||
|
||||
let input = audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
// tap 在音频线程跑:只碰局部捕获的 request,不碰 self
|
||||
input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
|
||||
request.append(buffer)
|
||||
}
|
||||
audioEngine.prepare()
|
||||
do {
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
input.removeTap(onBus: 0)
|
||||
deactivateSession()
|
||||
throw DictationError.audioEngineStartFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
// 系统线程 → 主线程
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if let result {
|
||||
self.latestText = result.bestTranscription.formattedString
|
||||
onPartial(self.latestText)
|
||||
if result.isFinal { self.didFinish = true }
|
||||
}
|
||||
if error != nil { self.didFinish = true }
|
||||
}
|
||||
}
|
||||
isRecording = true
|
||||
}
|
||||
|
||||
/// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回最终稿。
|
||||
/// 中途识别出错时已拿到的 partial 一样返回(spec 错误表:照常进整理流程)。
|
||||
func stop() async -> String {
|
||||
guard isRecording else { return "" }
|
||||
isRecording = false
|
||||
|
||||
audioEngine.stop()
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
request?.endAudio()
|
||||
|
||||
let deadline = Date().addingTimeInterval(1.5)
|
||||
while !didFinish && Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
task?.cancel()
|
||||
task = nil
|
||||
request = nil
|
||||
deactivateSession()
|
||||
return latestText
|
||||
}
|
||||
|
||||
/// 用户直接关 sheet 时的清理:不关心结果,立即停。
|
||||
func abort() {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
audioEngine.stop()
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
request?.endAudio()
|
||||
task?.cancel()
|
||||
task = nil
|
||||
request = nil
|
||||
deactivateSession()
|
||||
}
|
||||
|
||||
private func deactivateSession() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
}
|
||||
94
康康/Services/TrendInsightService.swift
Normal file
94
康康/Services/TrendInsightService.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
/// 趋势 AI 一句话解读:小预算(≤140 token)+ 按数据指纹缓存(UserDefaults)。
|
||||
/// 数据没变不重算 —— 进趋势详情页秒开;新增/修改记录改变指纹 → 自动重新生成。
|
||||
@MainActor
|
||||
final class TrendInsightService {
|
||||
static let shared = TrendInsightService()
|
||||
private init() {}
|
||||
|
||||
struct Cached: Codable, Equatable {
|
||||
var fingerprint: String
|
||||
var text: String
|
||||
var generatedAt: Date
|
||||
}
|
||||
|
||||
nonisolated static let storePrefix = "kk.trendInsight."
|
||||
|
||||
/// 数据指纹:每条线的 key + 点数 + 首末时间 + 末值/极值。体量小,直接当指纹字符串。
|
||||
nonisolated static func fingerprint(for bucket: SeriesBucket) -> String {
|
||||
var parts: [String] = [bucket.id]
|
||||
for line in bucket.lines {
|
||||
let pts = line.points
|
||||
let first = pts.first.map { Int($0.date.timeIntervalSince1970) } ?? 0
|
||||
let last = pts.last.map { Int($0.date.timeIntervalSince1970) } ?? 0
|
||||
let lastV = pts.last?.value ?? 0
|
||||
let minV = pts.map(\.value).min() ?? 0
|
||||
let maxV = pts.map(\.value).max() ?? 0
|
||||
parts.append("\(line.seriesKey)#\(pts.count)#\(first)#\(last)#\(lastV)#\(minV)#\(maxV)")
|
||||
}
|
||||
return parts.joined(separator: "|")
|
||||
}
|
||||
|
||||
/// 命中缓存(指纹一致)返回文本,否则 nil。
|
||||
func cachedText(for bucket: SeriesBucket) -> String? {
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.storePrefix + bucket.id),
|
||||
let c = try? JSONDecoder().decode(Cached.self, from: data),
|
||||
c.fingerprint == Self.fingerprint(for: bucket) else {
|
||||
return nil
|
||||
}
|
||||
return c.text
|
||||
}
|
||||
|
||||
/// 现算一条解读并写缓存。模型未就绪/输出为空时抛错,UI 显示「暂不可用 + 重试」。
|
||||
func generate(for bucket: SeriesBucket) async throws -> String {
|
||||
try await AIRuntime.shared.prepare()
|
||||
let prompt = InsightPrompts.trendInsight(
|
||||
title: bucket.title,
|
||||
unit: bucket.unit,
|
||||
rangeText: Self.rangeText(for: bucket),
|
||||
dataLines: Self.dataLines(for: bucket)
|
||||
)
|
||||
var collected = ""
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 140)
|
||||
for try await chunk in stream { collected += chunk.text }
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw AIRuntimeError.inferenceFailed("空输出") }
|
||||
let cached = Cached(fingerprint: Self.fingerprint(for: bucket), text: text, generatedAt: .now)
|
||||
if let data = try? JSONEncoder().encode(cached) {
|
||||
UserDefaults.standard.set(data, forKey: Self.storePrefix + bucket.id)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/// 每条线最近 24 个点拼成 "yyyy-MM-dd 值";多线(血压)各占一行带 label 前缀。
|
||||
/// 用 UTC 时区:展示日期仅供模型理解走向,差几小时无影响,但测试与设备时区无关。
|
||||
nonisolated static func dataLines(for bucket: SeriesBucket) -> String {
|
||||
let df = DateFormatter()
|
||||
df.locale = Locale(identifier: "en_US_POSIX")
|
||||
df.timeZone = TimeZone(identifier: "UTC")
|
||||
df.dateFormat = "yyyy-MM-dd"
|
||||
var lines: [String] = []
|
||||
for line in bucket.lines {
|
||||
let pts = line.points.suffix(24)
|
||||
let prefix = bucket.lines.count > 1 ? "\(line.label ?? line.seriesKey):" : ""
|
||||
let series = pts.map { "\(df.string(from: $0.date)) \(fmt($0.value))" }
|
||||
.joined(separator: " / ")
|
||||
lines.append(prefix + series)
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// ",参考 lo-hi" 或空串(无参考范围时整段省略)。
|
||||
nonisolated static func rangeText(for bucket: SeriesBucket) -> String {
|
||||
guard let r = bucket.lines.first?.referenceRange else { return "" }
|
||||
return ",参考 \(fmt(r.lowerBound))-\(fmt(r.upperBound))"
|
||||
}
|
||||
|
||||
private nonisolated static func fmt(_ v: Double) -> String {
|
||||
v.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(format: "%.0f", v)
|
||||
: String(format: "%.1f", v)
|
||||
}
|
||||
}
|
||||
96
康康/Services/VoiceIntentService.swift
Normal file
96
康康/Services/VoiceIntentService.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
/// 「长按 + 语音直达」可路由到的新建入口。rawValue 与 IntentPrompts 的分类 token 一致。
|
||||
enum VoiceIntent: String, CaseIterable, Sendable {
|
||||
case diary, medication, symptom, indicator, archive, export, reminder
|
||||
}
|
||||
|
||||
/// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。
|
||||
/// 两路都不中返回 nil,UI 走「没听懂 → 再说一次 / 打开新建菜单」。
|
||||
/// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。
|
||||
/// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。
|
||||
nonisolated enum VoiceIntentService {
|
||||
|
||||
static func classify(_ utterance: String) async -> VoiceIntent? {
|
||||
let text = utterance.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
// 模型冷启动可能要载入十几秒,语音直达等不起:6s 拿不到就走关键词。
|
||||
if let intent = try? await withTimeout(seconds: 6, operation: {
|
||||
try await classifyWithLLM(text)
|
||||
}) {
|
||||
return intent
|
||||
}
|
||||
return keywordMatch(text)
|
||||
}
|
||||
|
||||
// MARK: - LLM 分类
|
||||
|
||||
private static func classifyWithLLM(_ text: String) async throws -> VoiceIntent {
|
||||
try await AIRuntime.shared.prepare()
|
||||
let stream = await AIRuntime.shared.generate(prompt: IntentPrompts.classify(text),
|
||||
maxTokens: 48)
|
||||
var collected = ""
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
}
|
||||
guard let intent = parseIntent(from: collected) else {
|
||||
throw CaptureError.parseFailed("intent")
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
/// 从模型输出抠 `{"intent":"…"}`。容错:think 块、围栏、裸词。"unknown"/未知值返回 nil。
|
||||
static func parseIntent(from raw: String) -> VoiceIntent? {
|
||||
let cleaned = CaptureService.stripThink(raw)
|
||||
let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: cleaned))
|
||||
if let data = jsonString.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = obj["intent"] as? String {
|
||||
return VoiceIntent(rawValue: token.trimmingCharacters(in: .whitespaces).lowercased())
|
||||
}
|
||||
// 兜底:模型偶尔只吐裸词(diary / symptom …)
|
||||
let bare = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`。."))
|
||||
.lowercased()
|
||||
return VoiceIntent(rawValue: bare)
|
||||
}
|
||||
|
||||
// MARK: - 关键词回退(纯函数,单测覆盖)
|
||||
|
||||
/// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。
|
||||
static func keywordMatch(_ text: String) -> VoiceIntent? {
|
||||
let t = text.lowercased()
|
||||
let rules: [(VoiceIntent, [String])] = [
|
||||
(.reminder, ["提醒", "别忘", "闹钟"]),
|
||||
(.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
|
||||
(.archive, ["报告", "化验单", "体检", "归档"]),
|
||||
(.export, ["身体档案", "给医生", "健康总结", "导出"]),
|
||||
(.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
|
||||
"高压", "低压"]),
|
||||
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "疼", "痛",
|
||||
"咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]),
|
||||
(.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]),
|
||||
]
|
||||
for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) {
|
||||
return intent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单超时竞速:operation 与 sleep 赛跑,超时抛 CancellationError 并取消未完成方。
|
||||
nonisolated private func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw CancellationError()
|
||||
}
|
||||
guard let result = try await group.next() else { throw CancellationError() }
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
6
康康/康康-Bridging-Header.h
Normal file
6
康康/康康-Bridging-Header.h
Normal file
@@ -0,0 +1,6 @@
|
||||
//
|
||||
// 康康-Bridging-Header.h
|
||||
// 把 Objective-C 接口暴露给 Swift。
|
||||
//
|
||||
|
||||
#import "AI/MNN/MNNLLMBridge.h"
|
||||
45
康康Tests/BenchmarkStoreTests.swift
Normal file
45
康康Tests/BenchmarkStoreTests.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import 康康
|
||||
|
||||
struct BenchmarkStoreTests {
|
||||
|
||||
/// 每个用例独立 suite,避免 Swift Testing 并行执行时互相清空数据。
|
||||
private func freshDefaults(_ name: String) -> UserDefaults {
|
||||
let suite = "test.kk.benchmark.\(name)"
|
||||
let d = UserDefaults(suiteName: suite)!
|
||||
d.removePersistentDomain(forName: suite)
|
||||
return d
|
||||
}
|
||||
|
||||
@Test func savesAndLoadsPerBackend() {
|
||||
let d = freshDefaults("savesAndLoads")
|
||||
let mnn = BenchmarkResult(backendLabel: "MNN · SME2", promptTokens: 30, genTokens: 80,
|
||||
prefillTokensPerSecond: 120, decodeTokensPerSecond: 25,
|
||||
totalSeconds: 4.2, date: .now)
|
||||
let mlx = BenchmarkResult(backendLabel: "MLX · GPU", promptTokens: 30, genTokens: 80,
|
||||
prefillTokensPerSecond: 300, decodeTokensPerSecond: 40,
|
||||
totalSeconds: 2.5, date: .now)
|
||||
BenchmarkService.save(mnn, defaults: d)
|
||||
BenchmarkService.save(mlx, defaults: d)
|
||||
let all = BenchmarkService.load(defaults: d)
|
||||
#expect(all.count == 2)
|
||||
#expect(all["MNN · SME2"]?.decodeTokensPerSecond == 25)
|
||||
}
|
||||
|
||||
@Test func overwritesSameBackend() {
|
||||
let d = freshDefaults("overwrites")
|
||||
let old = BenchmarkResult(backendLabel: "MLX · GPU", promptTokens: 1, genTokens: 1,
|
||||
prefillTokensPerSecond: 1, decodeTokensPerSecond: 1,
|
||||
totalSeconds: 1, date: .now)
|
||||
var new = old
|
||||
new.decodeTokensPerSecond = 99
|
||||
BenchmarkService.save(old, defaults: d)
|
||||
BenchmarkService.save(new, defaults: d)
|
||||
#expect(BenchmarkService.load(defaults: d)["MLX · GPU"]?.decodeTokensPerSecond == 99)
|
||||
}
|
||||
|
||||
@Test func loadOnEmptyReturnsEmpty() {
|
||||
#expect(BenchmarkService.load(defaults: freshDefaults("loadEmpty")).isEmpty)
|
||||
}
|
||||
}
|
||||
28
康康Tests/DiaryOrganizePromptTests.swift
Normal file
28
康康Tests/DiaryOrganizePromptTests.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct DiaryOrganizePromptTests {
|
||||
@Test func organizePromptContainsTranscriptAndHardRules() {
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: "今天早上头晕量了血压140 90")
|
||||
|
||||
#expect(prompt.contains("今天早上头晕量了血压140 90"))
|
||||
// 健康数据红线:数值/单位/药名/时间不许改,必须写进 prompt
|
||||
#expect(prompt.contains("数值"))
|
||||
#expect(prompt.contains("药名"))
|
||||
// 自适应样式两条规则都在
|
||||
#expect(prompt.contains("一段通顺的话"))
|
||||
#expect(prompt.contains("分行"))
|
||||
// 项目 prompt 规范:禁思考标签
|
||||
#expect(prompt.contains("/no_think"))
|
||||
}
|
||||
|
||||
@Test func organizePromptTruncatesLongTranscript() {
|
||||
let long = String(repeating: "头晕", count: 2000) // 4000 字符,超过上限
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: long)
|
||||
|
||||
// 整条 prompt 里口述部分被截断到 organizeTranscriptLimit
|
||||
let expectedTail = String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit))
|
||||
#expect(prompt.contains(expectedTail))
|
||||
#expect(!prompt.contains(String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit + 2))))
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct HealthExportDialogueTests {
|
||||
@Test func dialogueTranscriptKeepsTurnOrderAndRoles() {
|
||||
@Test func dialogueTranscriptKeepsTurnOrderAndRoles() throws {
|
||||
let turns: [HealthExportDialogueTurn] = [
|
||||
.user("我最近头晕,帮我看看"),
|
||||
.assistant("我会结合你的指标和日记整理。"),
|
||||
@@ -12,10 +12,13 @@ struct HealthExportDialogueTests {
|
||||
|
||||
let transcript = HealthExportDialogueTurn.transcript(from: turns)
|
||||
|
||||
#expect(transcript.contains("患者: 我最近头晕,帮我看看"))
|
||||
// 用户角色标签是「我」(产品红线:不出现「患者」,见 ca5a3fa)。
|
||||
#expect(transcript.contains("我: 我最近头晕,帮我看看"))
|
||||
#expect(transcript.contains("康康: 我会结合你的指标和日记整理。"))
|
||||
#expect(transcript.contains("患者: 重点看血压"))
|
||||
#expect(transcript.range(of: "患者: 我最近头晕")!.lowerBound < transcript.range(of: "患者: 重点看血压")!.lowerBound)
|
||||
#expect(transcript.contains("我: 重点看血压"))
|
||||
let first = try #require(transcript.range(of: "我: 我最近头晕"))
|
||||
let second = try #require(transcript.range(of: "我: 重点看血压"))
|
||||
#expect(first.lowerBound < second.lowerBound)
|
||||
}
|
||||
|
||||
@Test func dialogueTranscriptDropsEmptyTurns() {
|
||||
@@ -27,8 +30,8 @@ struct HealthExportDialogueTests {
|
||||
|
||||
let transcript = HealthExportDialogueTurn.transcript(from: turns)
|
||||
|
||||
#expect(!transcript.contains("患者: "))
|
||||
#expect(!transcript.contains("我: "))
|
||||
#expect(transcript.contains("康康: 请补充想看的问题"))
|
||||
#expect(transcript.contains("患者: 最近三个月"))
|
||||
#expect(transcript.contains("我: 最近三个月"))
|
||||
}
|
||||
}
|
||||
|
||||
28
康康Tests/InferencePriorityTests.swift
Normal file
28
康康Tests/InferencePriorityTests.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct InferencePriorityTests {
|
||||
|
||||
@Test func interactiveJumpsAheadOfBackground() {
|
||||
let idx = AIRuntime.gateInsertionIndex(of: .interactive,
|
||||
in: [.interactive, .background, .background])
|
||||
#expect(idx == 1)
|
||||
}
|
||||
|
||||
@Test func interactiveKeepsFIFOAmongInteractive() {
|
||||
let idx = AIRuntime.gateInsertionIndex(of: .interactive,
|
||||
in: [.interactive, .interactive])
|
||||
#expect(idx == 2)
|
||||
}
|
||||
|
||||
@Test func backgroundAlwaysAppends() {
|
||||
let idx = AIRuntime.gateInsertionIndex(of: .background,
|
||||
in: [.interactive, .background])
|
||||
#expect(idx == 2)
|
||||
}
|
||||
|
||||
@Test func emptyQueueInsertsAtZero() {
|
||||
#expect(AIRuntime.gateInsertionIndex(of: .interactive, in: []) == 0)
|
||||
#expect(AIRuntime.gateInsertionIndex(of: .background, in: []) == 0)
|
||||
}
|
||||
}
|
||||
26
康康Tests/InsightPromptsTests.swift
Normal file
26
康康Tests/InsightPromptsTests.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct InsightPromptsTests {
|
||||
|
||||
@Test func reportSummaryPromptCarriesDataAndGuards() {
|
||||
let p = InsightPrompts.reportPlainSummary(
|
||||
title: "春季体检", typeLabel: "体检报告",
|
||||
indicatorLines: "血红蛋白 118 g/L(参考 130-175)low")
|
||||
#expect(p.contains("春季体检"))
|
||||
#expect(p.contains("血红蛋白 118"))
|
||||
#expect(p.contains("/no_think"))
|
||||
#expect(p.contains("不诊断"))
|
||||
#expect(!p.contains("患者"))
|
||||
}
|
||||
|
||||
@Test func trendPromptCarriesDataAndGuards() {
|
||||
let p = InsightPrompts.trendInsight(
|
||||
title: "空腹血糖", unit: "mmol/L", rangeText: ",参考 3.9-6.1",
|
||||
dataLines: "2026-05-01 5.2 / 2026-06-01 5.8")
|
||||
#expect(p.contains("空腹血糖"))
|
||||
#expect(p.contains("2026-06-01 5.8"))
|
||||
#expect(p.contains("/no_think"))
|
||||
#expect(!p.contains("患者"))
|
||||
}
|
||||
}
|
||||
45
康康Tests/MedicationReminderParsingTests.swift
Normal file
45
康康Tests/MedicationReminderParsingTests.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import 康康
|
||||
|
||||
/// 「用药记录」点药设吃药提醒:行拆分与提醒预填的纯函数测试。
|
||||
/// 入口逻辑见 `TimelineEntryDetailView.medicationBody`。
|
||||
struct MedicationReminderParsingTests {
|
||||
|
||||
// MARK: - 行拆分
|
||||
|
||||
@Test func splitsMultipleLinesAndDropsBlanks() {
|
||||
let content = "缬沙坦胶囊 80mg · 一日一次\n\n二甲双胍 0.5g · 一日三次\n "
|
||||
let lines = TimelineEntryDetailView.medicationLines(content)
|
||||
#expect(lines == ["缬沙坦胶囊 80mg · 一日一次", "二甲双胍 0.5g · 一日三次"])
|
||||
}
|
||||
|
||||
@Test func singleLineNoNewline() {
|
||||
#expect(TimelineEntryDetailView.medicationLines("阿司匹林肠溶片 100mg") == ["阿司匹林肠溶片 100mg"])
|
||||
}
|
||||
|
||||
@Test func emptyContentYieldsNoLines() {
|
||||
#expect(TimelineEntryDetailView.medicationLines("\n \n").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 提醒预填
|
||||
|
||||
@Test func splitsNameAndUsageOnMiddot() {
|
||||
let f = TimelineEntryDetailView.medicationReminderFields(forLine: "缬沙坦胶囊 80mg · 一日一次")
|
||||
#expect(f.title.contains("缬沙坦胶囊 80mg")) // 标题带药名+规格(可能含「吃药:」前缀)
|
||||
#expect(!f.title.contains("一日一次")) // 用法不进标题
|
||||
#expect(f.note == "一日一次") // 用法进备注
|
||||
}
|
||||
|
||||
@Test func noUsageGivesEmptyNote() {
|
||||
let f = TimelineEntryDetailView.medicationReminderFields(forLine: "阿司匹林 100mg")
|
||||
#expect(f.title.contains("阿司匹林 100mg"))
|
||||
#expect(f.note.isEmpty)
|
||||
}
|
||||
|
||||
@Test func multipleMiddotsKeepEverythingAfterFirstAsUsage() {
|
||||
let f = TimelineEntryDetailView.medicationReminderFields(forLine: "甲药 · 餐后 · 一日两次")
|
||||
#expect(f.title.contains("甲药"))
|
||||
#expect(f.note == "餐后 · 一日两次")
|
||||
}
|
||||
}
|
||||
103
康康Tests/MedicationScanServiceTests.swift
Normal file
103
康康Tests/MedicationScanServiceTests.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import SwiftData
|
||||
@testable import 康康
|
||||
|
||||
/// MedicationScanService.parseMedicationsJSON 纯函数单测(JSON 容错与去重)。
|
||||
struct MedicationScanServiceTests {
|
||||
|
||||
@Test func parsesStandardObject() throws {
|
||||
let raw = """
|
||||
{"medications":[{"name":"缬沙坦胶囊","strength":"80mg×7粒","usage":""}]}
|
||||
"""
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
#expect(meds[0].name == "缬沙坦胶囊")
|
||||
#expect(meds[0].strength == "80mg×7粒")
|
||||
#expect(meds[0].entryText == "缬沙坦胶囊 80mg×7粒")
|
||||
}
|
||||
|
||||
@Test func parsesBareArrayWithFence() throws {
|
||||
let raw = """
|
||||
```json
|
||||
[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次"}]
|
||||
```
|
||||
"""
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
#expect(meds[0].entryText == "二甲双胍缓释片 0.5g×30片 · 口服,一次1片,一日2次")
|
||||
}
|
||||
|
||||
@Test func parsesChineseKeysAndDedupes() throws {
|
||||
let raw = """
|
||||
{"medications":[
|
||||
{"药名":"阿司匹林肠溶片","规格":"100mg","用法":""},
|
||||
{"name":"阿司匹林肠溶片","strength":"100mg","usage":""}
|
||||
]}
|
||||
"""
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
}
|
||||
|
||||
@Test func emptyNameRowsAreDropped() throws {
|
||||
let raw = #"{"medications":[{"name":"","strength":"10mg","usage":""}]}"#
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.isEmpty)
|
||||
}
|
||||
|
||||
@Test func trailingCommaIsRepaired() throws {
|
||||
let raw = #"{"medications":[{"name":"氯雷他定片","strength":"10mg×6片","usage":"",},]}"#
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
#expect(meds[0].name == "氯雷他定片")
|
||||
}
|
||||
|
||||
@Test func invalidJSONThrows() {
|
||||
#expect(throws: (any Error).self) {
|
||||
try MedicationScanService.parseMedicationsJSON("识别不出来,抱歉")
|
||||
}
|
||||
}
|
||||
|
||||
/// 英文药名照原样保留(prompt 已要求英文不翻译);解析层只透传,不应被丢弃。
|
||||
@Test func parsesEnglishDrugName() throws {
|
||||
let raw = #"{"medications":[{"name":"Amoxicillin","strength":"500mg","usage":"Take one capsule three times daily"}]}"#
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
#expect(meds[0].name == "Amoxicillin")
|
||||
#expect(meds[0].entryText == "Amoxicillin 500mg · Take one capsule three times daily")
|
||||
}
|
||||
|
||||
/// 通用名 + 商品名合并写法(prompt 约定 "通用名(商品名)")原样透传。
|
||||
@Test func parsesGenericWithBrandName() throws {
|
||||
let raw = #"{"medications":[{"name":"缬沙坦胶囊(代文)","strength":"80mg×7粒","usage":""}]}"#
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
#expect(meds[0].name == "缬沙坦胶囊(代文)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 「用药」日记 → 时间线分类映射(拍药盒入档落库后在「记录」tab 的归类)。
|
||||
@MainActor
|
||||
struct MedicationTimelineTests {
|
||||
|
||||
private func makeContext() throws -> ModelContext {
|
||||
// DiaryEntry 现关联 Asset(拍药盒原图),schema 必须带上 Asset.self,否则建容器报错。
|
||||
let schema = Schema([DiaryEntry.self, Asset.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return ModelContext(try ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
@Test func medicationTaggedDiaryMapsToMedicationKind() throws {
|
||||
let ctx = try makeContext()
|
||||
let med = DiaryEntry(content: "缬沙坦胶囊 80mg×7粒", tags: [DiaryEntry.medicationTag])
|
||||
let plain = DiaryEntry(content: "今天睡得不错")
|
||||
ctx.insert(med); ctx.insert(plain)
|
||||
try ctx.save()
|
||||
|
||||
let medEntry = TimelineEntry.from(diary: med)
|
||||
#expect(medEntry.kind == .medication)
|
||||
#expect(medEntry.title == "缬沙坦胶囊 80mg×7粒")
|
||||
|
||||
#expect(TimelineEntry.from(diary: plain).kind == .diary)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import Foundation
|
||||
|
||||
struct ModelManifestTests {
|
||||
|
||||
@Test func llmHasNineFunctionalFiles() {
|
||||
#expect(ModelManifest.files(for: .llm).count == 9)
|
||||
@Test func llmHasTenFunctionalFiles() {
|
||||
#expect(ModelManifest.files(for: .llm).count == 10)
|
||||
}
|
||||
|
||||
@Test func vlHasFourteenFunctionalFiles() {
|
||||
@@ -13,13 +13,35 @@ struct ModelManifestTests {
|
||||
}
|
||||
|
||||
@Test func llmTotalBytesMatchesManifest() {
|
||||
#expect(ModelManifest.totalBytes(for: .llm) == 984_013_244)
|
||||
#expect(ModelManifest.totalBytes(for: .llm) == 1_749_079_691)
|
||||
}
|
||||
|
||||
@Test func vlTotalBytesMatchesManifest() {
|
||||
#expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929)
|
||||
}
|
||||
|
||||
@Test func mnnHasSixFunctionalFiles() {
|
||||
#expect(ModelManifest.files(for: .mnnLLM).count == 6)
|
||||
}
|
||||
|
||||
@Test func mnnTotalBytesMatchesManifest() {
|
||||
#expect(ModelManifest.totalBytes(for: .mnnLLM) == 1_185_759_005)
|
||||
}
|
||||
|
||||
@Test func mnnHasEssentialRuntimeFiles() {
|
||||
let names = ModelManifest.files(for: .mnnLLM).map(\.path)
|
||||
#expect(names.contains("config.json"))
|
||||
#expect(names.contains("llm.mnn"))
|
||||
#expect(names.contains("llm.mnn.weight"))
|
||||
#expect(names.contains("tokenizer.txt"))
|
||||
}
|
||||
|
||||
@Test func mnnFileURLUsesRepoPath() {
|
||||
let file = ModelFile(path: "config.json", bytes: 652)
|
||||
let url = ModelManifest.fileURL(for: .mnnLLM, file: file)
|
||||
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-2B-MNN/config.json")
|
||||
}
|
||||
|
||||
@Test func excludesReadmeAndGitattributes() {
|
||||
for kind in [ModelKind.llm, .vl] {
|
||||
let names = ModelManifest.files(for: kind).map(\.path)
|
||||
@@ -40,8 +62,8 @@ struct ModelManifestTests {
|
||||
}
|
||||
|
||||
@Test func fileURLIsBaseSlashRepoSlashPath() {
|
||||
let file = ModelFile(path: "config.json", bytes: 937)
|
||||
let file = ModelFile(path: "config.json", bytes: 3_113)
|
||||
let url = ModelManifest.fileURL(for: .llm, file: file)
|
||||
#expect(url.absoluteString == "https://file.myv0.com/Qwen3-1.7B-4bit/config.json")
|
||||
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-2B-4bit/config.json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ struct ModelsSchemaTests {
|
||||
UserProfile.self,
|
||||
MetricReminder.self,
|
||||
CustomMonitorMetric.self,
|
||||
Medication.self,
|
||||
])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
@@ -190,4 +191,42 @@ struct ModelsSchemaTests {
|
||||
#expect(fetched.bloodTypeRaw == "A")
|
||||
#expect(fetched.chronicConditions == ["高血压"])
|
||||
}
|
||||
|
||||
@Test func medicationRoundtripAndDetailLine() throws {
|
||||
let container = try makeContainer()
|
||||
let ctx = ModelContext(container)
|
||||
|
||||
let med = Medication(name: "缬沙坦胶囊", strength: "80mg×7粒", usage: "一日一次,一次一粒")
|
||||
ctx.insert(med)
|
||||
try ctx.save()
|
||||
|
||||
let fetched = try #require(try ctx.fetch(FetchDescriptor<Medication>()).first)
|
||||
#expect(fetched.name == "缬沙坦胶囊")
|
||||
#expect(fetched.detailLine == "80mg×7粒 · 一日一次,一次一粒")
|
||||
#expect(fetched.updatedAt == fetched.createdAt)
|
||||
}
|
||||
|
||||
@Test func medicationDetailLineOmitsEmptyParts() {
|
||||
#expect(Medication(name: "维生素C").detailLine == "")
|
||||
#expect(Medication(name: "钙片", strength: "600mg").detailLine == "600mg")
|
||||
}
|
||||
|
||||
@Test func cascadeDeleteMedicationRemovesAssets() throws {
|
||||
let container = try makeContainer()
|
||||
let ctx = ModelContext(container)
|
||||
|
||||
let med = Medication(name: "二甲双胍缓释片", strength: "0.5g×30片")
|
||||
let asset = Asset(relativePath: "med-1.jpg", bytes: 2048)
|
||||
ctx.insert(asset)
|
||||
med.assets.append(asset)
|
||||
ctx.insert(med)
|
||||
try ctx.save()
|
||||
#expect(med.assets.count == 1)
|
||||
|
||||
ctx.delete(med)
|
||||
try ctx.save()
|
||||
|
||||
#expect(try ctx.fetch(FetchDescriptor<Medication>()).isEmpty)
|
||||
#expect(try ctx.fetch(FetchDescriptor<Asset>()).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import CoreGraphics
|
||||
import AVFoundation
|
||||
@testable import 康康
|
||||
|
||||
/// 异常项快拍的局部裁剪几何。
|
||||
/// 指标速记的局部裁剪几何。
|
||||
/// 回归用例:屏上「宽而矮」的小框,必须裁出「宽 > 高」的照片 rect。
|
||||
/// 旧实现用 `metadataOutputRectConverted`(传感器横向坐标)套到竖屏照片 → x/y 轴对调,
|
||||
/// 把宽框裁成竖窄条(2026-05-31 真机 bug)。本组用例钉住正确的纯几何映射。
|
||||
@@ -112,4 +112,5 @@ final class RegionImageCropperTests: XCTestCase {
|
||||
imageFrame: CGRect(x: 0, y: 0, width: 100, height: 100)),
|
||||
.zero)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user