Compare commits

..

11 Commits

Author SHA1 Message Date
link2026
b79ae54b7b ```
feat(iOS): 更新MNN后端模型配置优化性能

将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本
实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX
兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。
```
2026-06-09 22:20:07 +08:00
link2026
ca5a3fa38b ```
fix(AI): 统一将"患者"相关术语替换为"本人"

统一修改代码中的称谓,将所有"患者"相关的文本替换为"本人",
包括提示词、注释和界面显示中的"患者"、"患者背景"、"患者疑问"等表述,
以保持用户称谓的一致性。

BREAKING CHANGE: 修改了API返回内容中的术语表述
```
2026-06-08 23:28:37 +08:00
link2026
836f3d4234 ```
feat(AI): 统一多模态模型架构,整合文本和视觉推理路径

- 将文本生成和VL(图→文)功能合并到单一的Qwen3.5-4B多模态MNN模型
- 移除独立的Qwen3-VL-4B模型依赖,MLX VL改为使用.llm的多模态模型
- 更新ModelKind枚举,新增userFacing集合用于面向用户展示
- MNN后端现在同时支持文本和视觉任务,模拟器回退到MLX

refactor(models): 模型管理和界面调整以适应新的多模态架构

- 更新模型管理界面,只显示统一的Qwen3.5-4B(MNN)模型给用户
- 修改就绪状态检查逻辑,使用ModelKind.userFacing替代allCases
- 更新模型文件清单,从Qwen3.5-2B升级到Qwen3.5-4B-4bit
- 调整模型管理页面UI,突出MNN+SME2端侧加速功能

feat(camera): 添加拍照识别引擎切换功能

- 实现双路径拍照识别:Apple Vision OCR + 文本模型 和 Qwen3-VL直接识别
- 添加预处理逻辑,优化Qwen3-VL对窄长区域图片的识别效果
- 在模型管理页面添加拍照识别引擎选择组件
- 提供用户界面选项,在两种识别方式间切换

style(ui): 优化输入框样式和颜色主题一致性

- 为指标快速表单添加浅色主题偏好
- 统一所有文本输入框的颜色样式(theme)
- 创建EntryInputField组件,替换原有的单行输入+按钮模式
- 实现聊天框风格的条目输入,支持多行自适应和圆形发送按钮

fix(build): 修正Xcode项目配置中的重复框架搜索路径

- 清理project.pbxproj中重复的FRAMEWORK_SEARCH_PATHS配置
- 重新排列Swift桥接头文件配置确保正确引用
- 修复因路径配置重复导致的编译警告问题

test: 增加区域图片预处理和模型清单测试覆盖

- 添加RegionImageCropper.prepareForQwenVL的单元测试
- 验证宽而矮图片的放大和填充逻辑
- 更新ModelManifestTests中的字节数预期值以匹配新模型
- 修正OCRService中VNRecognizedTextObservation类型的处理
```
2026-06-08 23:25:31 +08:00
link2026
b919404412 fix(MNN): 抑制 MNN 第三方头的 -Wdocumentation 警告
MNN 公共头(Executor/Tensor/Interpreter/ImageProcess.hpp)文档注释不规范,
桥接 #include <MNN/llm/llm.hpp> 时触发 13 条 -Wdocumentation 警告。
用 #pragma clang diagnostic 只在解析 MNN 头时关掉,不影响本项目自身文档警告。

device BUILD SUCCEEDED,警告 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:29:09 +08:00
link2026
ddfd474bb3 feat(AI): MNN 4B 多模态一肩挑文本+视觉,合并为单模型(MLX 仍兜底)
利用 Qwen3.5-4B-MNN 本身是多模态(含 visual.mnn),让同一个 MNN 模型
同时做文本生成与拍照识别 → MNN 路径只需下 1 个模型(7.4GB→2.64GB)。
MLX(.llm/.vl)保留作兜底,尤其开发机 iPhone 15 Pro(A17 无 SME2)。

- MNN.xcframework 重建为 OMNI(MNN_BUILD_LLM_OMNI=ON,加 OpenCV 图像解码);
  构建脚本同步加 OMNI flag
- MNNLLMBridge.analyzeImages:把图片路径拼成 <img>路径</img> 标签 + response,
  Omni 内部 CV::imread 加载(无需桥接 include OpenCV);与 generateText 共用 runResponse
- MNNBackend.analyze:detached 线程跑 blocking VL 调用,聚合为字符串
- AIRuntime:engine=.mnn 且就绪时,prepareVL→prepareMNN、analyzeReport→mnn.analyze;
  否则回退 MLX VL

device + 模拟器 BUILD SUCCEEDED,0 error,OMNI 框架链接干净。
VL 实际识别质量需真机用化验单 A/B(demo 核心)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:52:58 +08:00
link2026
cbacd9461a feat(AI): MNN 文本模型升到 Qwen3.5-4B(taobao-mnn 预转换)
现场机 iPhone 17(A19/SME2)内存与加速均可承载 4B,质量优于 2B。

- ModelKind.mnnLLM rawValue → "Qwen3.5-4B-MNN",displayName → Qwen3.5-4B (MNN/SME2)
- ModelManifest:7 个运行时文件(llm.mnn.weight ~2.45GB + 拆分的
  visual.mnn.weight 188MB),总计 2,836,770,850 bytes(~2.64GiB)
- ModelManifestTests:文件数 7 / 总字节 / URL 更新到 Qwen3.5-4B-MNN
- CLAUDE.md §2:MNN 主模型记为 Qwen3.5-4B,MLX 兜底仍 2B

模拟器 ModelManifestTests TEST SUCCEEDED。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:28:14 +08:00
link2026
39b1521f00 feat(AI): MNN 模型纳入下载体系 ModelKind.mnnLLM(Phase 4)
文本 MNN 模型用 taobao-mnn/Qwen3.5-2B-MNN 官方预转换格式(~1.10GiB),
不再从头转换(避开多模态转文本风险,官方转更可靠)。

- ModelStore.ModelKind 新增 .mnnLLM = "Qwen3.5-2B-MNN"
- ModelManifest:.mnnLLM 文件清单(config.json/llm_config.json/llm.mnn/
  llm.mnn.weight 1.1GB/tokenizer.txt/visual.mnn,HF API 实测字节)
- AIRuntime:mnnModelFolder + 就绪判定改走 ModelStore.isComplete(.mnnLLM)
- ModelManagementView:subtitle 加 .mnnLLM 文案(仅此一处,未动其它 WIP)
- ModelManifestTests:+4 条 mnnLLM 断言(文件数/总字节/必需文件/URL)

模拟器 ModelManifestTests TEST SUCCEEDED。下载经现有链路,需上传到
file.myv0.com/Qwen3.5-2B-MNN/(CDN 清单随附)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:38:16 +08:00
link2026
9da3fbc87e feat(Me): 推理引擎切换页 + SME2 状态 + CLAUDE.md 更新(Phase 5 部分)
- InferenceSettingsView:MNN(CPU/SME2)/ MLX(GPU)单选切换,展示当前设备
  SME2 探测状态(A19 启用 / A17 回退);走设计系统卡片,新文件不动 WIP 的
  ModelManagementView
- MeView:「模型管理」下新增「推理引擎」入口,detail 显示 MNN·SME2 / MNN·CPU / MLX·GPU
- CLAUDE.md §2/§12:AI 运行时改为 MNN(主,SME2)+ MLX(兜底)双后端,
  卖点 #2 明确 MNN+Arm SME2 端侧 CPU 加速为挑战赛考核点

模拟器 BUILD SUCCEEDED,0 error。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:23:00 +08:00
link2026
f6c0ba7077 feat(AI): 双后端路由 MNN/MLX,AIRuntime 按引擎分发(Phase 3 核心)
- InferenceEngine:引擎枚举(.mnn 默认 / .mlx 兜底)+ UserDefaults 持久化
  + 可用性/SME2 运行时探测(经 MNNLLMBridge)
- MNNBackend:actor 封装 MNNLLMBridge 文本流式生成,detached 线程跑同步
  response、按 UTF-8 边界 yield TokenChunk,串行化交给 AIRuntime 闸门
- AIRuntime:prepare/generate 按引擎分发;.mnn 且模型就绪→MNN,否则回退 MLX
  (过渡期 App 始终可用);prepareVL/单模型常驻时互卸 MNN↔MLX 释放内存
  公有 API 不变,各 Service 零改动

模拟器 BUILD SUCCEEDED,0 error。引擎切换 UI + SME2 指示留待 Phase 5。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:58:27 +08:00
link2026
afc6a79dd7 feat(AI/MNN): 集成 MNN.xcframework + ObjC++ 桥(LLM+SME2,Phase 1-2)
挑战赛考核点要求 Qwen + MNN + SME2 + CPU 端侧推理,MLX(GPU)不满足。
本提交打通原生 MNN 集成的工程层:

- scripts/build-mnn-xcframework.sh:从 alibaba/MNN 源码构建 device+sim arm64
  双切片 xcframework,MNN_BUILD_LLM=ON 导出 llm/llm.hpp,MNN_SME2=ON
  (KleidiAI 运行时自动路由:A19/iPhone17 走 SME2,A17 回退 NEON)
- MNNLLMBridge.{h,mm}:ObjC++ 封装 MNN Llm 的加载/流式生成,streambuf 按
  UTF-8 边界聚合回调,getContext() 取 prefill/decode 算 tok/s;模拟器编为桩
  (走 MLX 兜底),SME2 经 sysctl hw.optional.arm.FEAT_SME2 探测
- pbxproj:链接 MNN.xcframework + bridging header
- 二进制 gitignore,由脚本本地生成防历史膨胀

模拟器 BUILD SUCCEEDED(0 error),xcframework 处理 + 桥编译 + 链接通过。
下一步 Phase 3:MNNBackend + AIRuntime 双后端路由。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:31:02 +08:00
link2026
06484d09ff feat(AI): LLM 迁移到 mlx-swift-lm 2.31.3 + Qwen3.5-2B
将 SPM 依赖从 mlx-swift-examples 2.29.1 迁到改名延续仓库 mlx-swift-lm
2.31.3(含 qwen3_5 架构、旧 loadContainer API 兼容),文本 LLM 由
Qwen3-1.7B 换为 Qwen3.5-2B-4bit(走 qwen3_5→Qwen35Model 文本路径)。
连带 mlx-swift 0.29.1→0.31.4,顺修弃用 API:
- MLX.GPU.clearCache() → MLX.Memory.clearCache()
- MLX.GPU.set(cacheLimit:) → MLX.Memory.cacheLimit

更新 ModelManifest(.llm 文件清单+精确字节数,~1.63GiB)、ModelManifestTests、
HealthExport.modelTag 默认值。App BUILD SUCCEEDED + ModelManifestTests 通过。

保留作 MNN 改造的 GPU 兜底基线。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:00:28 +08:00
50 changed files with 2287 additions and 272 deletions

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@
/Models/ /Models/
/build/ /build/
.DS_Store .DS_Store
# MNN 预编译二进制:由 scripts/build-mnn-xcframework.sh 本地生成,不入库防历史膨胀
/Frameworks/MNN.xcframework/

View File

@@ -22,9 +22,10 @@
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` | | UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
| 持久化 | SwiftData | 见 §5 数据模型 | | 持久化 | SwiftData | 见 §5 数据模型 |
| 图表 | Swift Charts | iOS 16+ 原生 | | 图表 | Swift Charts | iOS 16+ 原生 |
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama | | **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` |
| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 | | **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 | | LLM | MNN 主(iPhone17+/SME2):Qwen3.5-2B(`taobao-mnn/Qwen3.5-2B-MNN`,~1.1GiB);MLX 兜底:Qwen3.5-2B-4bit | 文本生成、关键词抽取、趋势解读。4B 实测过慢已退回 2B |
| VL | Qwen3-VL-4B-Instruct 4bit (MLX `mlx-community/Qwen3-VL-4B-Instruct-4bit`) | 拍照→结构化指标。MNN VL 需 OMNI 构建,暂走 MLX |
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
| Face ID | LocalAuthentication | | | Face ID | LocalAuthentication | |
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
@@ -281,7 +282,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做) ## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做)
1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意 1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意
2. 100% 本地 + SME2 加速 — 技术亮点 2. 100% 本地 + **MNN + Arm SME2 端侧 CPU 加速**(挑战赛考核点,MLX/GPU 兜底) — 技术亮点
3. 本地 RAG 长期记忆 — 端侧不可替代性 3. 本地 RAG 长期记忆 — 端侧不可替代性
4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立 4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立
5. AI 趋势解读 — 长期价值 5. AI 趋势解读 — 长期价值

View 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 也加上限。
---
> 以下几条据 W1W2(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 要点或配置)
### 验证
(怎么确认修好了;不能单测的要写明需实跑)
### 预防 / 相关注意
(怎么避免再犯;顺带发现的隐患)
```

View 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"

View File

@@ -10,6 +10,7 @@
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; }; FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; }; FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; }; FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; };
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -33,6 +34,7 @@
5E463CF92FC403BB0089145B /* 康康.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "康康.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -61,6 +63,7 @@
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */, FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */, FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */, FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -88,6 +91,7 @@
5E463D0B2FC403BC0089145B /* 康康Tests */, 5E463D0B2FC403BC0089145B /* 康康Tests */,
5E463D152FC403BC0089145B /* 康康UITests */, 5E463D152FC403BC0089145B /* 康康UITests */,
5E463CFA2FC403BB0089145B /* Products */, 5E463CFA2FC403BB0089145B /* Products */,
FEEDFACE000000000000F001 /* MNN.xcframework */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -211,7 +215,7 @@
mainGroup = 5E463CF02FC403BB0089145B; mainGroup = 5E463CF02FC403BB0089145B;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */, 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */; productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
@@ -416,6 +420,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "康康"; INFOPLIST_KEY_CFBundleDisplayName = "康康";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -450,6 +455,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -470,6 +476,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "康康"; INFOPLIST_KEY_CFBundleDisplayName = "康康";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -504,6 +511,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -659,12 +667,12 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */ = { 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ml-explore/mlx-swift-examples"; repositoryURL = "https://github.com/ml-explore/mlx-swift-lm";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = exactVersion;
minimumVersion = 2.29.1; version = 2.31.3;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
@@ -672,17 +680,17 @@
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
FEED000000000000DEAD0003 /* MLXLLM */ = { FEED000000000000DEAD0003 /* MLXLLM */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */; package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
productName = MLXLLM; productName = MLXLLM;
}; };
FEED000000000000DEAD0004 /* MLXLMCommon */ = { FEED000000000000DEAD0004 /* MLXLMCommon */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */; package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
productName = MLXLMCommon; productName = MLXLMCommon;
}; };
FEED000000000000DEAD0006 /* MLXVLM */ = { FEED000000000000DEAD0006 /* MLXVLM */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */; package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
productName = MLXVLM; productName = MLXVLM;
}; };
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */

View File

@@ -1,13 +1,13 @@
{ {
"originHash" : "6b8265ebd61c6fdfca835dd1f90f17439ca9abc5c11a8b7b5db8790be0349e4d", "originHash" : "facc0ac7c70363ea20f6cd1235de91dea6b06f0d00190946045a6c8ae753abc2",
"pins" : [ "pins" : [
{ {
"identity" : "gzipswift", "identity" : "eventsource",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/1024jp/GzipSwift", "location" : "https://github.com/mattt/EventSource.git",
"state" : { "state" : {
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05", "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
"version" : "6.0.1" "version" : "1.4.1"
} }
}, },
{ {
@@ -15,17 +15,35 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift", "location" : "https://github.com/ml-explore/mlx-swift",
"state" : { "state" : {
"revision" : "072b684acaae80b6a463abab3a103732f33774bf", "revision" : "dc43e62d7055353c7f99fa071a4e71d29dfddc44",
"version" : "0.29.1" "version" : "0.31.4"
} }
}, },
{ {
"identity" : "mlx-swift-examples", "identity" : "mlx-swift-lm",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift-examples", "location" : "https://github.com/ml-explore/mlx-swift-lm",
"state" : { "state" : {
"revision" : "9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631", "revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff",
"version" : "2.29.1" "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" "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", "identity" : "swift-jinja",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -46,6 +82,15 @@
"version" : "2.3.6" "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", "identity" : "swift-numerics",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -55,13 +100,31 @@
"version" : "1.1.1" "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", "identity" : "swift-transformers",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-transformers", "location" : "https://github.com/huggingface/swift-transformers",
"state" : { "state" : {
"revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2", "revision" : "58c4bc11963a140358d791f678a60a2745a23146",
"version" : "1.0.0" "version" : "1.2.1"
}
},
{
"identity" : "yyjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ibireme/yyjson.git",
"state" : {
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
"version" : "0.12.0"
} }
} }
], ],

View File

@@ -32,6 +32,16 @@ actor AIRuntime {
private var llmSession: LLMSession? private var llmSession: LLMSession?
private var vlSession: VLSession? 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 ) // MARK: - (§3.1 OOM )
// //
// actor , generate() Task; // actor , generate() Task;
@@ -74,12 +84,22 @@ actor AIRuntime {
nonisolated static func configureMLXMemory() { nonisolated static func configureMLXMemory() {
#if !targetEnvironment(simulator) #if !targetEnvironment(simulator)
// 256MB cache : 3GB MB // 256MB cache : 3GB MB
MLX.GPU.set(cacheLimit: 256 * 1024 * 1024) MLX.Memory.cacheLimit = 256 * 1024 * 1024
#endif #endif
} }
/// , /// ,
/// :.mnn MNN(CPU/SME2);.mlx MLX(GPU)
func prepare() async throws { 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 // return: ready, generate
// `guard status == .ready` () // `guard status == .ready` ()
@@ -119,9 +139,51 @@ 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() /// await prepare()
/// :, actor LLMSession await /// :, actor LLMSession await
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> { func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
if InferenceEngine.current == .mnn, mnnStatus == .ready {
return mnnGenerate(prompt: prompt, maxTokens: maxTokens)
}
// actor ,Task 访 self.status / self.llmSession // actor ,Task 访 self.status / self.llmSession
let snapshotStatus = status let snapshotStatus = status
let snapshotSession = llmSession let snapshotSession = llmSession
@@ -159,6 +221,33 @@ actor AIRuntime {
} }
} }
/// MNN(CPU/SME2) MLX :
private func mnnGenerate(prompt: String, maxTokens: Int) -> 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()
do {
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
for try await chunk in stream {
try Task.checkCancellation()
self.recordRate(chunk.decodeRate)
continuation.yield(chunk)
}
continuation.finish()
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
self.releaseGate()
}
continuation.onTermination = { _ in task.cancel() }
}
}
private func recordRate(_ rate: Double) { private func recordRate(_ rate: Double) {
if rate > 0 { lastDecodeRate = rate } if rate > 0 { lastDecodeRate = rate }
} }
@@ -167,30 +256,37 @@ actor AIRuntime {
/// VL , load /// VL , load
func prepareVL() async throws { func prepareVL() async throws {
// MNN :VL MNN (+), prepareMNN
if InferenceEngine.current == .mnn, ModelStore.shared.isComplete(for: .mnnLLM) {
try await prepareMNN()
return
}
while vlStatus == .loading { while vlStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000) try await Task.sleep(nanoseconds: 80_000_000)
} }
if vlStatus == .ready { return } if vlStatus == .ready { return }
// prepare(): isComplete (), // MLX VL .llm Qwen3.5-2B (VLMModelFactory qwen3_5 ),
guard ModelStore.shared.isComplete(for: .vl) else { // Qwen3-VL-4B isComplete ,
guard ModelStore.shared.isComplete(for: .llm) else {
vlStatus = .error("VL 模型未就绪") vlStatus = .error("VL 模型未就绪")
throw AIRuntimeError.notReady throw AIRuntimeError.notReady
} }
// :( LLM ), LLM + VL // :( LLM ), LLM + VL
// App 退 // App 退
await acquireGate() await acquireGate()
defer { releaseGate() } defer { releaseGate() }
if vlStatus == .ready { return } if vlStatus == .ready { return }
// OOM (§3.1): VL(~3GB) LLM(~1GB), jetsam // OOM (§3.1): VL(~3GB) LLM(~1GB), jetsam
unloadLLM() unloadLLM()
await unloadMNN()
vlStatus = .loading vlStatus = .loading
do { do {
let session = try await VLSession.load( let session = try await VLSession.load(
folderURL: ModelStore.shared.localURL(for: .vl) folderURL: ModelStore.shared.localURL(for: .llm)
) )
self.vlSession = session self.vlSession = session
vlStatus = .ready vlStatus = .ready
@@ -208,7 +304,7 @@ actor AIRuntime {
guard llmSession != nil else { return } guard llmSession != nil else { return }
llmSession = nil llmSession = nil
status = .notReady status = .notReady
MLX.GPU.clearCache() MLX.Memory.clearCache()
} }
/// VL, ModelContainer MLX /// VL, ModelContainer MLX
@@ -216,7 +312,7 @@ actor AIRuntime {
guard vlSession != nil else { return } guard vlSession != nil else { return }
vlSession = nil vlSession = nil
vlStatus = .notReady vlStatus = .notReady
MLX.GPU.clearCache() MLX.Memory.clearCache()
} }
/// JSON ( VLPrompts.reportExtraction ) /// JSON ( VLPrompts.reportExtraction )
@@ -225,6 +321,16 @@ actor AIRuntime {
func analyzeReport(imageURLs: [URL], func analyzeReport(imageURLs: [URL],
prompt: String, prompt: String,
maxTokens: Int = 512) async throws -> 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 { guard vlStatus == .ready, let session = vlSession else {
throw AIRuntimeError.notReady throw AIRuntimeError.notReady
} }

View 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
}
}
}

View File

@@ -45,10 +45,16 @@ actor LLMSession {
let task = Task { let task = Task {
do { do {
try await Self.withDeviceOverride { 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( let parameters = GenerateParameters(
maxTokens: maxTokens, maxTokens: maxTokens,
temperature: Float(0.6), temperature: Float(0.3),
topP: Float(0.9) topP: Float(0.85),
repetitionPenalty: Float(1.1),
repetitionContextSize: 64
) )
try await container.perform { (context: ModelContext) in try await container.perform { (context: ModelContext) in

View 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

View 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

View File

@@ -0,0 +1,95 @@
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?
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) {
_ = box.value.generateText(prompt, maxTokens: Int32(maxTokens)) { piece in
let rate = meter.tick()
continuation.yield(TokenChunk(text: piece, decodeRate: rate))
}
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 {
_ = try box.value.analyzeImages(paths, prompt: prompt, maxTokens: Int32(maxTokens)) { piece in
sink.append(piece)
}
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
}
}

View File

@@ -18,16 +18,23 @@ nonisolated enum ModelManifest {
static func files(for kind: ModelKind) -> [ModelFile] { static func files(for kind: ModelKind) -> [ModelFile] {
switch kind { switch kind {
case .llm: 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 [ return [
ModelFile(path: "config.json", bytes: 937), ModelFile(path: "config.json", bytes: 3_113),
ModelFile(path: "model.safetensors", bytes: 968_080_210), ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
ModelFile(path: "model.safetensors.index.json", bytes: 49_731), ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
ModelFile(path: "tokenizer.json", bytes: 11_422_654), ModelFile(path: "tokenizer.json", bytes: 19_989_343),
ModelFile(path: "tokenizer_config.json", bytes: 9_706), ModelFile(path: "tokenizer_config.json", bytes: 1_139),
ModelFile(path: "vocab.json", bytes: 2_776_833), ModelFile(path: "vocab.json", bytes: 6_722_759),
ModelFile(path: "merges.txt", bytes: 1_671_853), ModelFile(path: "chat_template.jinja", bytes: 7_755),
ModelFile(path: "special_tokens_map.json", bytes: 613), ModelFile(path: "preprocessor_config.json", bytes: 390),
ModelFile(path: "added_tokens.json", bytes: 707), ModelFile(path: "processor_config.json", bytes: 1_300),
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
] ]
case .vl: case .vl:
// Qwen3-VL-4B-Instruct-4bit: mlx-community blob // Qwen3-VL-4B-Instruct-4bit: mlx-community blob
@@ -52,6 +59,19 @@ nonisolated enum ModelManifest {
ModelFile(path: "preprocessor_config.json", bytes: 782), ModelFile(path: "preprocessor_config.json", bytes: 782),
ModelFile(path: "video_preprocessor_config.json", bytes: 817), 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),
]
} }
} }

View File

@@ -1,14 +1,20 @@
import Foundation import Foundation
nonisolated enum ModelKind: String, CaseIterable { nonisolated enum ModelKind: String, CaseIterable {
/// HuggingFace mlx-community , Models/ /// Models/ / CDN
case llm = "Qwen3-1.7B-4bit" /// 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 vl = "Qwen3-VL-4B-Instruct-4bit"
case mnnLLM = "Qwen3.5-2B-MNN"
var displayName: String { var displayName: String {
switch self { switch self {
case .llm: return "Qwen3-1.7B" case .llm: return "Qwen3.5-2B (MLX)"
case .vl: return "Qwen3-VL-4B" 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" } var sentinelFilename: String { "config.json" }
/// : / /
/// Qwen3.5-2B(MNN,+,iPhone17+ )
/// MLX .llm/.vl ,(),
/// · ,
static let userFacing: [ModelKind] = [.mnnLLM]
} }
/// `@unchecked Sendable`:rootURL let, filesystem(线), /// `@unchecked Sendable`:rootURL let, filesystem(线),

View File

@@ -16,7 +16,7 @@ enum DiaryAssistPrompts {
"持续频率", "既往家族史", "用药过敏", "生活方式", "持续频率", "既往家族史", "用药过敏", "生活方式",
] ]
/// - content: /// - content:
/// - coveredDimensions: (), /// - coveredDimensions: (),
/// ///
static func suggest(content: String, coveredDimensions: [String] = []) -> String { static func suggest(content: String, coveredDimensions: [String] = []) -> String {
@@ -30,8 +30,8 @@ enum DiaryAssistPrompts {
: "\n- 已问过的维度【不要再问】:\(covered.joined(separator: ""))。本轮只能从这些还没问的维度里挑:\(allowedLine)" : "\n- 已问过的维度【不要再问】:\(covered.joined(separator: ""))。本轮只能从这些还没问的维度里挑:\(allowedLine)"
return """ return """
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。 你是社区医生的小助手。用户写了一段身体状态的健康记录,信息可能不够完整。
请从医生问诊角度提出 3-4 个最值得追问的问题,帮患者把这条记录补全。 请从医生问诊角度提出 3-4 个最值得追问的问题,帮用户把这条记录补全。
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注: 【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
1. 起病诱因 —— 何时开始、有无诱因 1. 起病诱因 —— 何时开始、有无诱因

View File

@@ -70,7 +70,7 @@ enum HealthExportPrompts {
- 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。 - 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。 - JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。 - 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
- 「主诉」「患者疑问」可参考【患者原话】,但不得加入原话与数据里都没有的症状。 - 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
输出格式: 输出格式:
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。 - 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
@@ -78,11 +78,11 @@ enum HealthExportPrompts {
- 严格按以下 6 段(顺序与标题固定): - 严格按以下 6 段(顺序与标题固定):
\(labelLine) \(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"}} 真实数据:{"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) ⚠️ 体温 38.5 ℃(参考 36-37.2,2026-05-01)
## 在服药与过敏 ## 在服药与过敏
无记录 无记录
## 患者疑问 ## 本人疑问
无记录 无记录
—— 示例结束(以上咳嗽/体温等仅示范格式,切勿出现在你的输出里)—— —— 示例结束(以上咳嗽/体温等仅示范格式,切勿出现在你的输出里)——
@@ -107,7 +107,7 @@ enum HealthExportPrompts {
【真实数据】: 【真实数据】:
\(dataJSON) \(dataJSON)
患者原话】:\(userPrompt) 本人原话】:\(userPrompt)
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。 再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
直接输出 Markdown,不要思考过程,不要 <think> 标签: 直接输出 Markdown,不要思考过程,不要 <think> 标签:
@@ -169,7 +169,7 @@ enum HealthExportPrompts {
## 相关健康日记 ## 相关健康日记
## 相关指标 ## 相关指标
## 已知背景 ## 已知背景
## 患者关心的问题 ## 本人关心的问题
## 可带给医生确认的要点 ## 可带给医生确认的要点
【本地健康记录】: 【本地健康记录】:

View File

@@ -3,7 +3,7 @@ import Foundation
/// VL (Qwen3-VL) / prompt /// VL (Qwen3-VL) / prompt
/// : JSON,markdown /// : JSON,markdown
/// CaptureService 退(§3.2 退线) /// CaptureService 退(§3.2 退线)
enum VLPrompts { nonisolated enum VLPrompts {
/// JSON ( prompt ): /// JSON ( prompt ):
/// ``` /// ```
@@ -88,9 +88,9 @@ JSON schema(严格):
现在请识别图片并输出 JSON: 现在请识别图片并输出 JSON:
"""# """#
// MARK: - () // MARK: - ()
/// :/****() /// :/****()
/// indicators ,// , Report /// indicators ,// , Report
static func regionExtraction(today: Date = .now) -> String { static func regionExtraction(today: Date = .now) -> String {
let f = DateFormatter() let f = DateFormatter()

View File

@@ -102,6 +102,9 @@ struct KangkangApp: App {
// / ,( tjScaled ) // / ,( tjScaled )
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)") .id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
} }
// ( sand) light:,
// Text/TextField .primary ,()
.preferredColorScheme(.light)
} }
.modelContainer(sharedModelContainer) .modelContainer(sharedModelContainer)
} }

View 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)
}
}

View File

@@ -0,0 +1,45 @@
import SwiftUI
/// Apple Intelligence 线:,
/// AppAI ( 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())
}
}

View File

@@ -30,6 +30,7 @@ struct ArchiveListView: View {
@State private var filter: TimelineKind? = nil @State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom? @State private var endingSymptom: Symptom?
@State private var selectedEntry: TimelineEntry? @State private var selectedEntry: TimelineEntry?
@State private var selectedGroup: IndicatorGroup?
@State private var route: Route? @State private var route: Route?
@MainActor @MainActor
@@ -109,6 +110,9 @@ struct ArchiveListView: View {
TimelineEntryDetailView(detail: d) TimelineEntryDetailView(detail: d)
} }
} }
.sheet(item: $selectedGroup) { group in
IndicatorSeriesDetailView(group: group)
}
} }
@ViewBuilder @ViewBuilder
@@ -123,9 +127,14 @@ struct ArchiveListView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
// (///): // : ( + );//
Button { 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: { } label: {
TimelineRow(entry: entry) TimelineRow(entry: entry)
} }

View File

@@ -29,6 +29,8 @@ struct HealthExportDetailView: View {
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
) )
AIDisclaimerFooter()
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 16) .padding(.vertical, 16)
@@ -117,7 +119,7 @@ struct HealthExportDetailView: View {
} }
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14)) .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") Label("分享", systemImage: "square.and.arrow.up")
.font(.tjScaled( 13, weight: .semibold)) .font(.tjScaled( 13, weight: .semibold))
.tracking(1) .tracking(1)
@@ -149,7 +151,7 @@ struct HealthExportDetailView: View {
} }
private func copy() { private func copy() {
UIPasteboard.general.string = export.content UIPasteboard.general.string = AIDisclaimer.appended(to: export.content)
copiedFlash = true copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false copiedFlash = false
@@ -175,9 +177,9 @@ struct HealthExportDetailView: View {
# 就诊摘要 — 感冒就诊 # 就诊摘要 — 感冒就诊
## 主诉 ## 主诉
患者男,38 岁,感冒 3 天未愈。 本人男,38 岁,感冒 3 天未愈。
## 患者背景 ## 本人背景
- 高血压 2 年 - 高血压 2 年
- 在服药:缬沙坦 80mg qd - 在服药:缬沙坦 80mg qd
""", """,

View File

@@ -22,6 +22,11 @@ struct HealthExportSheet: View {
@State private var answeringTurnID: UUID? @State private var answeringTurnID: UUID?
@FocusState private var questionFocused: Bool @FocusState private var questionFocused: Bool
//
@State private var promptStore = QuickPromptStore.shared
@State private var showAddPrompt = false
@State private var newPromptText = ""
init(initialPrompt: String = "") { init(initialPrompt: String = "") {
self.initialPrompt = initialPrompt self.initialPrompt = initialPrompt
} }
@@ -33,10 +38,16 @@ struct HealthExportSheet: View {
!isGeneratingReport && !isGeneratingReport &&
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !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 { private var canGenerateReport: Bool {
!isAnswering && !isAnswering &&
!isGeneratingReport && !isGeneratingReport &&
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) (hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
var body: some View { var body: some View {
@@ -88,6 +99,75 @@ struct HealthExportSheet: View {
questionFocused = true questionFocused = true
} }
.onDisappear { task?.cancel() } .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 // MARK: - Header
@@ -128,14 +208,7 @@ struct HealthExportSheet: View {
.font(.tjScaled( 13, weight: .semibold)) .font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2) .foregroundStyle(Tj.Palette.text2)
VStack(alignment: .leading, spacing: 6) { quickPromptRow
Text("例:最近血压波动大吗?")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据") Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
.font(.tjScaled( 11)) .font(.tjScaled( 11))
@@ -162,11 +235,11 @@ struct HealthExportSheet: View {
.font(.tjScaled( 11, weight: .semibold)) .font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3) .foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
if turn.id == answeringTurnID && turn.text.isEmpty { if turn.id == answeringTurnID && turn.text.isEmpty {
HStack(spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ProgressView()
Text("正在查看本地记录…") Text("正在查看本地记录…")
.font(.tjScaled( 13)) .font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
AIFlowBar()
} }
} else { } else {
Text(turn.text) Text(turn.text)
@@ -196,6 +269,11 @@ struct HealthExportSheet: View {
.font(.tjScaled( 13, weight: .semibold)) .font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2) .foregroundStyle(Tj.Palette.text2)
MarkdownView(text: content) MarkdownView(text: content)
if completed {
Divider().background(Tj.Palette.lineSoft)
AIDisclaimerFooter()
}
} }
.padding(16) .padding(16)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -229,6 +307,9 @@ struct HealthExportSheet: View {
.font(.tjScaled( 11)) .font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
} }
// AI :线( AI )
AIFlowBar().padding(.top, 2)
} }
} }
@@ -291,7 +372,7 @@ struct HealthExportSheet: View {
} }
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14)) .buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: content) { ShareLink(item: AIDisclaimer.appended(to: content)) {
Label("分享", systemImage: "square.and.arrow.up") Label("分享", systemImage: "square.and.arrow.up")
.font(.tjScaled( 13, weight: .semibold)) .font(.tjScaled( 13, weight: .semibold))
.tracking(1) .tracking(1)
@@ -319,7 +400,7 @@ struct HealthExportSheet: View {
private var composer: some View { private var composer: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
HStack(spacing: 8) { HStack(spacing: 8) {
TextField("继续提问补充情况…", text: $draftQuestion, axis: .vertical) TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
.font(.tjScaled( 14)) .font(.tjScaled( 14))
.lineLimit(1...4) .lineLimit(1...4)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -342,6 +423,21 @@ struct HealthExportSheet: View {
.accessibilityLabel("发送问题") .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: { Button { startReportGeneration() } label: {
Label("生成整理报告", systemImage: "doc.text.below.ecg") Label("生成整理报告", systemImage: "doc.text.below.ecg")
} }
@@ -349,6 +445,7 @@ struct HealthExportSheet: View {
.disabled(!canGenerateReport) .disabled(!canGenerateReport)
.opacity(canGenerateReport ? 1 : 0.45) .opacity(canGenerateReport ? 1 : 0.45)
} }
}
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(Tj.Palette.paper) .background(Tj.Palette.paper)
@@ -402,6 +499,15 @@ struct HealthExportSheet: View {
private func startReportGeneration() { private func startReportGeneration() {
guard canGenerateReport else { return } guard canGenerateReport else { return }
questionFocused = false questionFocused = false
// :(/),
//
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
if !draft.isEmpty {
turns.append(.user(draft))
draftQuestion = ""
}
content = "" content = ""
rate = 0 // , tok/s rate = 0 // , tok/s
error = nil error = nil
@@ -435,6 +541,16 @@ struct HealthExportSheet: View {
startReportGeneration() startReportGeneration()
} }
/// :,()
private func stopGeneration() {
task?.cancel()
task = nil
phase = nil
rate = 0
completed = false
content = ""
}
private func reset() { private func reset() {
task?.cancel() task?.cancel()
task = nil task = nil
@@ -448,7 +564,7 @@ struct HealthExportSheet: View {
} }
private func copy() { private func copy() {
UIPasteboard.general.string = content UIPasteboard.general.string = AIDisclaimer.appended(to: content)
copiedFlash = true copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false copiedFlash = false
@@ -641,9 +757,9 @@ struct MarkdownView: View {
# 就诊摘要 — 感冒就诊 # 就诊摘要 — 感冒就诊
## 主诉 ## 主诉
患者男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。 本人男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
## 患者背景 ## 本人背景
- 高血压 2 年 - 高血压 2 年
- 在服药:**缬沙坦 80mg qd** - 在服药:**缬沙坦 80mg qd**
- 过敏:青霉素 - 过敏:青霉素

View 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
),
]
}

View File

@@ -182,6 +182,7 @@ struct DiaryQuickSheet: View {
questionRow(index: roundLocalIndex(at: idx), question: q) questionRow(index: roundLocalIndex(at: idx), question: q)
} }
} }
AIDisclaimerFooter()
} }
if exhaustedNote { if exhaustedNote {
@@ -212,30 +213,12 @@ struct DiaryQuickSheet: View {
? String(appLoc: "让 AI 帮我想想还能记什么") ? String(appLoc: "让 AI 帮我想想还能记什么")
: String(appLoc: "先写几个字,AI 来帮忙补充"), : String(appLoc: "先写几个字,AI 来帮忙补充"),
enabled: canRequestSuggest, enabled: canRequestSuggest,
prominent: true,
action: requestSuggestions action: requestSuggestions
) )
case .loading: case .loading:
HStack(spacing: 10) { assistLoadingIndicator
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)
)
case .ready: case .ready:
assistPrimaryButton( assistPrimaryButton(
@@ -273,26 +256,25 @@ struct DiaryQuickSheet: View {
} }
} }
/// `prominent` ( brick + + ,),
/// ( .ready )
private func assistPrimaryButton(icon: String, private func assistPrimaryButton(icon: String,
label: String, label: String,
enabled: Bool, enabled: Bool,
prominent: Bool = false,
action: @escaping () -> Void) -> some View { action: @escaping () -> Void) -> some View {
Button(action: action) { Button(action: action) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: icon) Image(systemName: icon)
Text(label) Text(label)
} }
.font(.tjScaled( 13, weight: .semibold)) .font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3) .foregroundStyle(prominent
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 11) .padding(.vertical, prominent ? 14 : 11)
.background( .background(assistButtonBackground(enabled: enabled, prominent: prominent))
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(
enabled ? Tj.Palette.ink : Tj.Palette.line,
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
)
)
// : contentShape (+) // : contentShape (+)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -300,6 +282,58 @@ struct DiaryQuickSheet: View {
.disabled(!enabled) .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) /// questions list idx question, round (1-based)
private func roundLocalIndex(at idx: Int) -> Int { private func roundLocalIndex(at idx: Int) -> Int {
let target = questions[idx].round let target = questions[idx].round

View File

@@ -143,6 +143,7 @@ struct IndicatorQuickSheet: View {
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom) .ignoresSafeArea(edges: .bottom)
) )
.preferredColorScheme(.light)
.presentationDetents([.large]) .presentationDetents([.large])
.presentationDragIndicator(.hidden) .presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand) .presentationBackground(Tj.Palette.sand)
@@ -173,7 +174,7 @@ struct IndicatorQuickSheet: View {
.padding(.bottom, 16) .padding(.bottom, 16)
} }
/// : RootView VL /// : RootView VL
@ViewBuilder @ViewBuilder
private var cameraEntrySection: some View { private var cameraEntrySection: some View {
if let onRequestCamera { if let onRequestCamera {
@@ -423,6 +424,8 @@ struct IndicatorQuickSheet: View {
TextField(placeholder, text: value) TextField(placeholder, text: value)
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
.font(.tjScaled( 20, weight: .semibold, design: .monospaced)) .font(.tjScaled( 20, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.vertical, 10) .padding(.vertical, 10)
.frame(width: 90) .frame(width: 90)
@@ -468,6 +471,8 @@ struct IndicatorQuickSheet: View {
sectionLabel(String(appLoc: "指标名")) sectionLabel(String(appLoc: "指标名"))
TextField("例如:血红蛋白", text: $name) TextField("例如:血红蛋白", text: $name)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(fieldBg) .background(fieldBg)
@@ -489,6 +494,8 @@ struct IndicatorQuickSheet: View {
TextField(monitorFieldPlaceholder, text: $value) TextField(monitorFieldPlaceholder, text: $value)
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
.font(.tjScaled( 18, weight: .semibold, design: .monospaced)) .font(.tjScaled( 18, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(fieldBg) .background(fieldBg)
@@ -499,6 +506,8 @@ struct IndicatorQuickSheet: View {
TextField("mmol/L", text: $unit) TextField("mmol/L", text: $unit)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(fieldBg) .background(fieldBg)
@@ -522,6 +531,8 @@ struct IndicatorQuickSheet: View {
TextField("例如:< 3.40 或 3.9 - 6.1", text: $range) TextField("例如:< 3.40 或 3.9 - 6.1", text: $range)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(fieldBg) .background(fieldBg)
@@ -581,6 +592,8 @@ struct IndicatorQuickSheet: View {
sectionLabel(String(appLoc: "备注(可选)")) sectionLabel(String(appLoc: "备注(可选)"))
TextField("例如:空腹采血", text: $note, axis: .vertical) TextField("例如:空腹采血", text: $note, axis: .vertical)
.lineLimit(1...3) .lineLimit(1...3)
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(fieldBg) .background(fieldBg)

View 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()
}

View File

@@ -37,6 +37,7 @@ struct MeView: View {
profileCard profileCard
customMetricsCard customMetricsCard
modelManagementCard modelManagementCard
inferenceEngineCard
languageCard languageCard
fontScaleCard fontScaleCard
faceIDCard faceIDCard
@@ -151,10 +152,26 @@ struct MeView: View {
private var modelDetail: String { private var modelDetail: String {
let states = downloadService.states 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: "下载中…") } if downloadService.isAnyDownloading { return String(appLoc: "下载中…") }
let readyCount = ModelKind.allCases.filter { states[$0]?.phase == .ready }.count let readyCount = ModelKind.userFacing.filter { states[$0]?.phase == .ready }.count
return readyCount == 0 ? String(appLoc: "未下载") : String(appLoc: "\(readyCount)/\(ModelKind.allCases.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 { private var languageCard: some View {

View File

@@ -15,20 +15,20 @@ struct ModelManagementView: View {
private let monitorQueue = DispatchQueue(label: "kk.netmonitor") private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
private var allReady: Bool { private var allReady: Bool {
ModelKind.allCases.allSatisfy { service.states[$0]?.phase == .ready } ModelKind.userFacing.allSatisfy { service.states[$0]?.phase == .ready }
} }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 14) { VStack(spacing: 14) {
ForEach(ModelKind.allCases, id: \.self) { kind in ForEach(ModelKind.userFacing, id: \.self) { kind in
modelCard(kind) modelCard(kind)
} }
actionButtons actionButtons
.padding(.top, 4) .padding(.top, 4)
if service.states[.llm]?.phase == .ready { if service.states[.mnnLLM]?.phase == .ready {
NavigationLink { NavigationLink {
ModelSelfTestView() ModelSelfTestView()
} label: { } label: {
@@ -146,7 +146,7 @@ struct ModelManagementView: View {
private var actionButtons: some View { private var actionButtons: some View {
if service.isAnyDownloading { if service.isAnyDownloading {
Button { Button {
for kind in ModelKind.allCases { service.cancel(kind) } for kind in ModelKind.userFacing { service.cancel(kind) }
} label: { } label: {
Text("暂停下载").frame(maxWidth: .infinity) Text("暂停下载").frame(maxWidth: .infinity)
} }
@@ -154,7 +154,7 @@ struct ModelManagementView: View {
} else if allReady { } else if allReady {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
Text("两个模型都已就绪") Text("Qwen3.5-2B 已就绪")
} }
.font(.tjScaled( 13, weight: .semibold)) .font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.leaf) .foregroundStyle(Tj.Palette.leaf)
@@ -198,8 +198,8 @@ struct ModelManagementView: View {
defer { if scoped { folder.stopAccessingSecurityScopedResource() } } defer { if scoped { folder.stopAccessingSecurityScopedResource() } }
let name = folder.lastPathComponent let name = folder.lastPathComponent
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else { guard let kind = ModelKind.userFacing.first(where: { $0.rawValue == name }) else {
let names = ModelKind.allCases.map(\.rawValue).joined(separator: "") let names = ModelKind.userFacing.map(\.rawValue).joined(separator: "")
importError = String(appLoc: "请选择名为 \(names) 的文件夹") importError = String(appLoc: "请选择名为 \(names) 的文件夹")
return return
} }
@@ -213,13 +213,14 @@ struct ModelManagementView: View {
// MARK: - // MARK: -
private var totalAllBytes: Int { 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 { private func subtitle(_ kind: ModelKind) -> String {
switch kind { switch kind {
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答") case .llm: return String(appLoc: "文本解读 · 趋势 / 问答(MLX 兜底)")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标") case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
case .mnnLLM: return String(appLoc: "文本解读 + 拍照识别 · MNN + SME2 端侧加速")
} }
} }

View File

@@ -370,17 +370,14 @@ private struct ChronicSection: View {
} }
} }
HStack { EntryInputField(placeholder: String(appLoc: "自定义慢病"), text: $newCustomCondition) {
TextField("自定义慢病", text: $newCustomCondition)
Button("") {
let trimmed = newCustomCondition.trimmingCharacters(in: .whitespaces) let trimmed = newCustomCondition.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, guard !trimmed.isEmpty,
!profile.chronicConditions.contains(trimmed) else { return } !profile.chronicConditions.contains(trimmed) else { return }
profile.chronicConditions.append(trimmed) profile.chronicConditions.append(trimmed)
newCustomCondition = "" newCustomCondition = ""
} }
.disabled(newCustomCondition.trimmingCharacters(in: .whitespaces).isEmpty) .listRowBackground(Color.clear)
}
} header: { } header: {
Text("慢病(影响参考范围与 AI 解读)") Text("慢病(影响参考范围与 AI 解读)")
} }
@@ -408,6 +405,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,) // MARK: - / / ( @State,)
private struct StringListSection: View { private struct StringListSection: View {
@@ -431,16 +473,13 @@ private struct StringListSection: View {
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
} }
HStack { EntryInputField(placeholder: placeholder, text: $newInput) {
TextField(placeholder, text: $newInput)
Button("") {
let trimmed = newInput.trimmingCharacters(in: .whitespaces) let trimmed = newInput.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, !items.contains(trimmed) else { return } guard !trimmed.isEmpty, !items.contains(trimmed) else { return }
items.append(trimmed) items.append(trimmed)
newInput = "" newInput = ""
} }
.disabled(newInput.trimmingCharacters(in: .whitespaces).isEmpty) .listRowBackground(Color.clear)
}
} }
} }
} }

View File

@@ -2,7 +2,7 @@ import SwiftUI
import SwiftData import SwiftData
import UIKit import UIKit
/// · /// ·
/// ()/ () OCR+LLM Indicator /// ()/ () OCR+LLM Indicator
/// ///
/// : /// :
@@ -57,7 +57,7 @@ struct QuickRegionCaptureFlow: View {
onCancel: { onClose() }, onCancel: { onClose() },
onRetake: { phase = .idle } onRetake: { phase = .idle }
) )
.navigationTitle(String(appLoc: "核对异常项")) .navigationTitle(String(appLoc: "核对指标"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
@@ -95,17 +95,18 @@ struct QuickRegionCaptureFlow: View {
} }
} }
// MARK: - ( OCR LLM) // MARK: - ( Vision OCR Qwen3 )
/// /,( RegionAdjustView ) /// /,( 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?) { private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
do { do {
let text = try await OCRService.recognizeText(in: image) let text = try await OCRService.recognizeText(in: image)
if Task.isCancelled { return ([], nil) } // : if Task.isCancelled { return ([], nil) } // :
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
#if DEBUG #if DEBUG
print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---") NSLog("KKDBG-OCR region text:\n%@\n--- end OCR ---", trimmed)
#endif #endif
if trimmed.isEmpty { if trimmed.isEmpty {
return ([], String(appLoc: "没识别到文字,挪一下框再试")) return ([], String(appLoc: "没识别到文字,挪一下框再试"))

View File

@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
/// · VL + ,() /// · VL + ,()
/// = Indicator /// = Indicator
struct QuickRegionConfirmView: View { struct QuickRegionConfirmView: View {
let image: UIImage? let image: UIImage?

View File

@@ -2,7 +2,7 @@ import SwiftUI
import AVFoundation import AVFoundation
import UIKit import UIKit
/// · /// ·
/// /, + , OCR+LLM /// /, + , OCR+LLM
/// ,;0 (退线) /// ,;0 (退线)
struct RegionAdjustView: View { struct RegionAdjustView: View {

View File

@@ -3,7 +3,7 @@ import AVFoundation
import UIKit import UIKit
import Combine import Combine
/// · /// ·
/// + **** upright UIImage() /// + **** upright UIImage()
/// `RegionAdjustView` /// `RegionAdjustView`
/// (,`QuickRegionCaptureFlow` 退 PhotoPicker) /// (,`QuickRegionCaptureFlow` 退 PhotoPicker)
@@ -60,7 +60,7 @@ struct SingleShotCameraView: View {
Spacer() Spacer()
Text("拍一张含异常指标的照片 · 拍完再框选") Text("拍一张含目标指标的照片 · 拍完再框选")
.font(.tjScaled( 13, weight: .medium)) .font(.tjScaled( 13, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -97,7 +97,7 @@ struct SingleShotCameraView: View {
Text("相机权限未开启") Text("相机权限未开启")
.font(.tjH2()) .font(.tjH2())
.foregroundStyle(.white) .foregroundStyle(.white)
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。") Text("指标速记需要相机。去「设置 → 康康 → 相机」打开后再回来。")
.font(.tjScaled( 13)) .font(.tjScaled( 13))
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)

View File

@@ -5,12 +5,12 @@ enum RecordKind: String, Identifiable, CaseIterable {
var id: String { rawValue } var id: String { rawValue }
/// RecordSheet () enum , /// RecordSheet () enum ,
/// :`.quick`() `.indicator`(), /// :`.quick`() `.indicator`(),
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive] static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
var title: String { var title: String {
switch self { switch self {
case .quick: return String(appLoc: "异常项快拍") case .quick: return String(appLoc: "指标速记")
case .indicator: return String(appLoc: "记录指标") case .indicator: return String(appLoc: "记录指标")
case .healthExport: return String(appLoc: "身体档案") case .healthExport: return String(appLoc: "身体档案")
case .archive: return String(appLoc: "体检报告归档") case .archive: return String(appLoc: "体检报告归档")

View File

@@ -0,0 +1,457 @@
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
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: - /
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())
}
}

View File

@@ -420,7 +420,8 @@ struct TimelineEntryDetailView: View {
} }
} }
private struct EvidenceImagePreview: View { /// ( + ),
struct EvidenceImagePreview: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let report: Report let report: Report
let indicator: Indicator let indicator: Indicator

View File

@@ -1112,6 +1112,7 @@
} }
}, },
"AI 思考中… 本地推理,通常 5-10 秒" : { "AI 思考中… 本地推理,通常 5-10 秒" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1223,6 +1224,12 @@
} }
} }
} }
},
"AI 生成中 · %.1f tok/s" : {
},
"AI 生成中 · 本地推理" : {
}, },
"AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。" : { "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。" : {
"localizations" : { "localizations" : {
@@ -1317,6 +1324,9 @@
}, },
"Apple 健康里没有可导入的生日、性别、身高或血型。" : { "Apple 健康里没有可导入的生日、性别、身高或血型。" : {
},
"Arm SME2" : {
}, },
"B 型" : { "B 型" : {
"localizations" : { "localizations" : {
@@ -1386,12 +1396,18 @@
}, },
"lo" : { "lo" : {
},
"Metal GPU · 兜底 / 对照" : {
}, },
"mmHg" : { "mmHg" : {
}, },
"mmol/L" : { "mmol/L" : {
},
"MNN 在端侧 CPU 上以 Arm SME2 指令集加速 Qwen 推理(本地、不上云)。切换后下一次 AI 调用生效。" : {
}, },
"O 型" : { "O 型" : {
"localizations" : { "localizations" : {
@@ -1414,6 +1430,9 @@
} }
} }
} }
},
"Qwen3.5-2B 已就绪" : {
}, },
"s" : { "s" : {
@@ -1853,6 +1872,7 @@
} }
}, },
"两个模型都已就绪" : { "两个模型都已就绪" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2523,9 +2543,6 @@
} }
} }
} }
},
"例:最近血压波动大吗?" : {
}, },
"例:最近血糖好像不稳,把过去三个月的化验单整理一下" : { "例:最近血糖好像不稳,把过去三个月的化验单整理一下" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -2572,9 +2589,6 @@
} }
} }
} }
},
"例:把我最近头晕、睡眠和指标变化整理给医生" : {
}, },
"例如:< 3.40 或 3.9 - 6.1" : { "例如:< 3.40 或 3.9 - 6.1" : {
"localizations" : { "localizations" : {
@@ -2773,6 +2787,9 @@
} }
} }
} }
},
"保存后点一下,就能把这句话填进输入框" : {
}, },
"保存归档" : { "保存归档" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -3635,6 +3652,7 @@
} }
}, },
"加" : { "加" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5914,31 +5932,6 @@
} }
} }
} }
},
"异常项快拍" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abnormal item quick capture"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "異常項目クイック撮影"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이상 항목 빠른 촬영"
}
}
}
},
"异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。" : {
}, },
"强度" : { "强度" : {
"localizations" : { "localizations" : {
@@ -6277,9 +6270,6 @@
} }
} }
} }
},
"患者" : {
}, },
"慢性肾病" : { "慢性肾病" : {
"localizations" : { "localizations" : {
@@ -6324,6 +6314,9 @@
} }
} }
} }
},
"我" : {
}, },
"我的" : { "我的" : {
"localizations" : { "localizations" : {
@@ -6671,7 +6664,7 @@
} }
} }
}, },
"拍一张含异常指标的照片 · 拍完再框选" : { "拍一张含目标指标的照片 · 拍完再框选" : {
}, },
"拍到的局部" : { "拍到的局部" : {
@@ -7045,6 +7038,31 @@
}, },
"指标详情" : { "指标详情" : {
},
"指标速记" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Quick metric log"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "指標クイック記録"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "지표 빠른 기록"
}
}
}
},
"指标速记需要相机。去「设置 → 康康 → 相机」打开后再回来。" : {
}, },
"按%lld岁调整" : { "按%lld岁调整" : {
"localizations" : { "localizations" : {
@@ -7067,6 +7085,15 @@
} }
} }
} }
},
"按本机配置选择 · 当前 MLX(MNN 不可用)" : {
},
"按本机配置选择 · 当前 MNN + SME2" : {
},
"按本机配置选择 · 当前 MNN(NEON)" : {
}, },
"推理中…" : { "推理中…" : {
"localizations" : { "localizations" : {
@@ -7111,6 +7138,9 @@
} }
} }
} }
},
"推理引擎" : {
}, },
"推理自检" : { "推理自检" : {
"localizations" : { "localizations" : {
@@ -7618,6 +7648,7 @@
} }
}, },
"文本解读 · 趋势 / 问答" : { "文本解读 · 趋势 / 问答" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7638,6 +7669,12 @@
} }
} }
} }
},
"文本解读 · 趋势 / 问答(MLX 兜底)" : {
},
"文本解读 + 拍照识别 · MNN + SME2 端侧加速" : {
}, },
"新建" : { "新建" : {
"localizations" : { "localizations" : {
@@ -8617,6 +8654,15 @@
} }
} }
} }
},
"本设备/模拟器不可用,自动回退 MLX" : {
},
"本设备不支持(需 A19/iPhone 17+)" : {
},
"本设备支持,MNN 已启用 SME2 加速" : {
}, },
"本设备未设置 Face ID 或密码" : { "本设备未设置 Face ID 或密码" : {
"localizations" : { "localizations" : {
@@ -8693,6 +8739,9 @@
}, },
"查看原图位置" : { "查看原图位置" : {
},
"查看趋势图" : {
}, },
"标准" : { "标准" : {
@@ -8767,7 +8816,7 @@
} }
} }
}, },
"核对异常项" : { "核对指标" : {
}, },
"核对识别结果" : { "核对识别结果" : {
@@ -9275,6 +9324,9 @@
} }
} }
} }
},
"添加" : {
}, },
"添加你自己的长期监测项" : { "添加你自己的长期监测项" : {
"localizations" : { "localizations" : {
@@ -9297,6 +9349,9 @@
} }
} }
} }
},
"添加快捷问答" : {
}, },
"点底部 + 号可以补一条" : { "点底部 + 号可以补一条" : {
"localizations" : { "localizations" : {
@@ -9873,6 +9928,22 @@
} }
} }
}, },
"端侧 CPU + SME2 加速 · 挑战赛考核路径" : {
},
"端侧 CPU(本机无 SME2,NEON 回退)" : {
},
"第 %lld / 共 %lld 条" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "new",
"value" : "第 %1$lld / 共 %2$lld 条"
}
}
}
},
"第 %lld 轮 · 基于你刚才更新的文本 · %lld 条" : { "第 %lld 轮 · 基于你刚才更新的文本 · %lld 条" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -10789,6 +10860,9 @@
} }
} }
} }
},
"记录已不存在" : {
}, },
"记录指标" : { "记录指标" : {
"localizations" : { "localizations" : {
@@ -11597,6 +11671,9 @@
} }
} }
} }
},
"输入一句常用问题…" : {
}, },
"输入密码" : { "输入密码" : {
"localizations" : { "localizations" : {

View File

@@ -28,7 +28,7 @@ final class HealthExport {
var inferredLabelCN: String? var inferredLabelCN: String?
// demo // demo
/// tag, "Qwen3-1.7B-4bit" /// tag, "Qwen3.5-2B-MNN"(iPhone17+ ) "Qwen3.5-2B-4bit"(MLX )
var modelTag: String var modelTag: String
/// tok/s, demo #6 Live Activity /// tok/s, demo #6 Live Activity
var decodeRate: Double var decodeRate: Double
@@ -44,7 +44,7 @@ final class HealthExport {
inferredTimeToDate: Date? = nil, inferredTimeToDate: Date? = nil,
inferredIntent: String? = nil, inferredIntent: String? = nil,
inferredLabelCN: String? = nil, inferredLabelCN: String? = nil,
modelTag: String = "Qwen3-1.7B-4bit", modelTag: String = "Qwen3.5-2B-MNN",
decodeRate: Double = 0) { decodeRate: Double = 0) {
self.prompt = prompt self.prompt = prompt
self.content = content self.content = content

View File

@@ -6,7 +6,7 @@ enum IndicatorStatus: String, Codable, CaseIterable {
case high, low, normal case high, low, normal
} }
/// manual = ;quickCapture = (VL);report = /// manual = ;quickCapture = (VL);report =
/// manual() /// manual()
enum IndicatorSource: String, Codable, CaseIterable { enum IndicatorSource: String, Codable, CaseIterable {
case manual, quickCapture, report case manual, quickCapture, report
@@ -14,7 +14,7 @@ enum IndicatorSource: String, Codable, CaseIterable {
var label: String { var label: String {
switch self { switch self {
case .manual: return String(appLoc: "手动记录") case .manual: return String(appLoc: "手动记录")
case .quickCapture: return String(appLoc: "异常项快拍") case .quickCapture: return String(appLoc: "指标速记")
case .report: return String(appLoc: "报告归档") case .report: return String(appLoc: "报告归档")
} }
} }

View File

@@ -96,7 +96,7 @@ struct RootView: View {
DiaryQuickSheet() DiaryQuickSheet()
} }
.sheet(isPresented: $showIndicator) { .sheet(isPresented: $showIndicator) {
// : VL () // : VL ()
IndicatorQuickSheet(onRequestCamera: { IndicatorQuickSheet(onRequestCamera: {
showIndicator = false showIndicator = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {

View File

@@ -77,53 +77,6 @@ actor CaptureService {
try await runVL(on: assets) 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
}
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])
} catch {
throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)")
}
defer { try? FileManager.default.removeItem(at: tmpURL) }
let raw: String
do {
raw = try await AIRuntime.shared.analyzeReport(
imageURLs: [tmpURL],
prompt: VLPrompts.regionExtraction(),
// ,512 token
maxTokens: 2048
)
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
#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)")
}
}
/// OCR : Vision OCR LLM(Qwen3-1.7B) /// OCR : Vision OCR LLM(Qwen3-1.7B)
/// Report; `CaptureError`,UI 退(§3.2) /// Report; `CaptureError`,UI 退(§3.2)
/// (MainActor) OCR,OCR actor, UIImage actor /// (MainActor) OCR,OCR actor, UIImage actor
@@ -149,12 +102,19 @@ actor CaptureService {
// Qwen3 <think></think>, JSON // Qwen3 <think></think>, JSON
let cleaned = CaptureService.stripThink(collected) let cleaned = CaptureService.stripThink(collected)
#if DEBUG #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 #endif
do { do {
return try CaptureService.parseIndicatorsJSON(cleaned) return try CaptureService.parseIndicatorsJSON(cleaned)
} catch let CaptureError.parseFailed(msg) { } 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 { } catch {
throw CaptureError.parseFailed("\(error)") throw CaptureError.parseFailed("\(error)")
} }
@@ -213,7 +173,7 @@ actor CaptureService {
// extractBalancedJSON( {} extractJSONObject):VL // extractBalancedJSON( {} extractJSONObject):VL
// [{...},{...}], { , indicator // [{...},{...}], { , indicator
// indicators // indicators
let jsonString = extractBalancedJSON(from: raw) let jsonString = repairJSON(extractBalancedJSON(from: raw))
guard let data = jsonString.data(using: .utf8) else { guard let data = jsonString.data(using: .utf8) else {
throw CaptureError.parseFailed("非 UTF-8 输出") throw CaptureError.parseFailed("非 UTF-8 输出")
} }
@@ -259,7 +219,7 @@ actor CaptureService {
/// `extractJSONObject` + `parseIndicator` indicator (), /// `extractJSONObject` + `parseIndicator` indicator (),
/// UI ,JSON `parseFailed` /// UI ,JSON `parseFailed`
static func parseIndicatorsJSON(_ raw: String) throws -> [ParsedReport.ParsedIndicator] { 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 { guard let data = jsonString.data(using: .utf8) else {
throw CaptureError.parseFailed("非 UTF-8 输出") throw CaptureError.parseFailed("非 UTF-8 输出")
} }
@@ -324,6 +284,21 @@ actor CaptureService {
return String(s[start...]) 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 ,`{...}` `[...]` /// JSON ,`{...}` `[...]`
/// ( `{"indicators":[...]}` `[...]`) /// ( `{"indicators":[...]}` `[...]`)
/// ( JSONSerialization ) /// ( JSONSerialization )

View File

@@ -74,8 +74,8 @@ struct DiaryAssistService {
// 1. <think>...</think>( HealthExportService ) // 1. <think>...</think>( HealthExportService )
let stripped = HealthExportService.stripThinkBlocks(collected) let stripped = HealthExportService.stripThinkBlocks(collected)
// 2. JSON( CaptureService.extractJSONObject) // 2. JSON( CaptureService.extractJSONObject)+
let jsonStr = CaptureService.extractJSONObject(from: stripped) let jsonStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
guard let data = jsonStr.data(using: .utf8), guard let data = jsonStr.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let dict = obj as? [String: Any] else { let dict = obj as? [String: Any] else {

View File

@@ -7,7 +7,7 @@ struct HealthExportDialogueTurn: Identifiable, Hashable, Sendable {
var transcriptLabel: String { var transcriptLabel: String {
switch self { switch self {
case .user: return String(appLoc: "患者") case .user: return String(appLoc: "")
case .assistant: return String(appLoc: "康康") case .assistant: return String(appLoc: "康康")
} }
} }

View File

@@ -644,7 +644,7 @@ struct HealthExportService {
&& p.currentMedications.isEmpty && p.currentMedications.isEmpty
} }
/// :6 ,, /// :6 ,,
static func fallbackReport(label: String, userPrompt: String) -> String { static func fallbackReport(label: String, userPrompt: String) -> String {
let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)" let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)"
let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines) let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -652,12 +652,12 @@ struct HealthExportService {
return """ return """
\(title) \(title)
> 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据患者原话,未做任何推断。 > 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据本人原话,未做任何推断。
## 主诉 ## 主诉
\(complaintLine) \(complaintLine)
## 患者背景 ## 本人背景
无记录 无记录
## 近期症状(按时间倒序) ## 近期症状(按时间倒序)
@@ -669,7 +669,7 @@ struct HealthExportService {
## 在服药与过敏 ## 在服药与过敏
无记录 无记录
## 患者疑问 ## 本人疑问
无记录 无记录
""" """
} }

View File

@@ -58,7 +58,7 @@ final class ModelDownloadService {
} }
func downloadAll() { func downloadAll() {
for kind in ModelKind.allCases { download(kind) } for kind in ModelKind.userFacing { download(kind) }
} }
/// .part , /// .part ,

View File

@@ -23,7 +23,7 @@ enum OCRService {
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:]) let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
do { do {
try handler.perform([request]) try handler.perform([request])
let obs = (request.results as? [VNRecognizedTextObservation]) ?? [] let obs = request.results ?? []
cont.resume(returning: assemble(obs)) cont.resume(returning: assemble(obs))
} catch { } catch {
cont.resume(throwing: error) cont.resume(throwing: error)

View File

@@ -0,0 +1,6 @@
//
// 康康-Bridging-Header.h
// 把 Objective-C 接口暴露给 Swift。
//
#import "AI/MNN/MNNLLMBridge.h"

View File

@@ -4,8 +4,8 @@ import Foundation
struct ModelManifestTests { struct ModelManifestTests {
@Test func llmHasNineFunctionalFiles() { @Test func llmHasTenFunctionalFiles() {
#expect(ModelManifest.files(for: .llm).count == 9) #expect(ModelManifest.files(for: .llm).count == 10)
} }
@Test func vlHasFourteenFunctionalFiles() { @Test func vlHasFourteenFunctionalFiles() {
@@ -13,13 +13,35 @@ struct ModelManifestTests {
} }
@Test func llmTotalBytesMatchesManifest() { @Test func llmTotalBytesMatchesManifest() {
#expect(ModelManifest.totalBytes(for: .llm) == 984_013_244) #expect(ModelManifest.totalBytes(for: .llm) == 1_749_079_691)
} }
@Test func vlTotalBytesMatchesManifest() { @Test func vlTotalBytesMatchesManifest() {
#expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929) #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() { @Test func excludesReadmeAndGitattributes() {
for kind in [ModelKind.llm, .vl] { for kind in [ModelKind.llm, .vl] {
let names = ModelManifest.files(for: kind).map(\.path) let names = ModelManifest.files(for: kind).map(\.path)
@@ -40,8 +62,8 @@ struct ModelManifestTests {
} }
@Test func fileURLIsBaseSlashRepoSlashPath() { @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) 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")
} }
} }

View File

@@ -3,7 +3,7 @@ import CoreGraphics
import AVFoundation import AVFoundation
@testable import @testable import
/// ///
/// :, > rect /// :, > rect
/// `metadataOutputRectConverted`() x/y , /// `metadataOutputRectConverted`() x/y ,
/// (2026-05-31 bug) /// (2026-05-31 bug)
@@ -112,4 +112,5 @@ final class RegionImageCropperTests: XCTestCase {
imageFrame: CGRect(x: 0, y: 0, width: 100, height: 100)), imageFrame: CGRect(x: 0, y: 0, width: 100, height: 100)),
.zero) .zero)
} }
} }