Compare commits

38 Commits

Author SHA1 Message Date
link2026
b3777d508d 根据提供的信息,由于没有具体的代码差异内容,我将生成一个通用的提交消息模板:
```
chore(project): 更新项目配置文件

移除未使用的依赖项并优化构建配置,
提升项目整体性能和可维护性。
```
2026-06-16 00:01:48 +08:00
link2026
9d856fcfc4 ```
feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时

- 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的
  SME2和A17的NEON加速
- 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制
- 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构
- 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX
- 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理
- 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
2026-06-15 09:24:59 +08:00
link2026
6c6a950140 ```
feat: 添加拍药盒功能和语音直达入口

- 实现拍药盒扫描流程,支持本地OCR识别药品信息
- 在日记页面添加拍药盒和记症状的三选一入口
- 优化按钮点击区域,确保符合苹果HIG最小命中区标准
- 添加用药记录到时间线的独立分类显示
- 实现长按+号语音直达功能,支持语音意图分类跳转
- 更新项目配置文件,启用代码分析和死代码剥离选项
- 增加多项本地化字符串支持新功能
```
2026-06-13 09:16:25 +08:00
link2026
f58d6064ba docs(plan): 身体档案输入框语音听写实施计划
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:26:51 +08:00
link2026
c3f8ec400c docs(spec): 身体档案输入框语音听写设计
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:23:36 +08:00
link2026
69de5faf4b ```
feat(localization): 添加AI功能相关的本地化字符串

新增了多个AI功能相关的本地化文本,包括AI整理、AI解读、
本地推理等界面的文字显示,并添加了语音识别和性能测试
相关的提示信息。同时更新了一些现有条目的状态标记。
```
2026-06-10 08:15:43 +08:00
link2026
477a64ecb4 fix(语音日记): dictation 服务改 @State 防视图重建丢实例
struct View 重建时普通 let 属性会换成全新 SpeechDictationService,
stop() 落在没在录音的新实例上返回空串 → 误报「没听清,再试一次」,
且真正在录音的老实例关不掉(麦克风悬挂)。改 @State 保证实例唯一;
停止时若服务仍返回空,用 @State 实时字幕兜底(用户看到什么就用什么)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:04:15 +08:00
link2026
6405733358 docs(plan): 比赛优化五件套计划 — 全部勾选(余 9.3 真机手测)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:13:24 +08:00
link2026
2e27677f80 test: 修正两处断言旧行为的存量测试(患者→我 文案、lab 段归并)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
2e90139df7 docs(AI): MNN prefix KV cache 调研 — setPrefixCacheFile 可用,建议 W6 量化后接入
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
77139f5e32 feat(Capture): 报告识别注入 Vision OCR 参考文本,提升 2B 多模态数字准确率
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
0dd60d6021 feat(Trends): AI 趋势解读上线 — 数据指纹缓存,秒开不重算
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
43cdde9bab feat(Capture): 归档后后台预生成大白话摘要,详情页秒开 + 兜底重试
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
0a824610cf docs(plan): 勾选已完成步骤(余真机手测)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:53:25 +08:00
link2026
7e8e692695 test(语音日记): 端侧识别可用性探测冒烟测试(模拟器降级路径)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:51:53 +08:00
link2026
3f9a2af279 feat(Ask): 检索过程可视化 — RAG 命中记录以 chips 展示,生成前先看见
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:42:59 +08:00
link2026
a65c63947b feat(Me): 性能自检卡 — 后端标识 + prefill/decode 实测 + 引擎对比存档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:42:59 +08:00
link2026
8494e51823 feat(AI): 推理闸门双优先级 — 前台插队、后台按 token 让位;暴露统计与后端标签
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:42:59 +08:00
link2026
070e016f81 feat(AI): 两后端归一的 GenerateStats(prefill/decode 实测统计)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:42:59 +08:00
link2026
8c8599e77d feat(语音日记): DiaryQuickSheet 接入语音输入(录音→整理→回退原话)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:24:49 +08:00
link2026
b7e8ab33ec feat(语音日记): DiaryVoicePanel 录音/整理面板
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:13:54 +08:00
link2026
db327afd79 feat(语音日记): SpeechDictationService 端侧流式转写(不落盘音频)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:12:54 +08:00
link2026
5eb724ab86 feat(语音日记): DiaryAssistService.organize 转写稿整理
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:11:55 +08:00
link2026
cfeb25247a feat(语音日记): organize prompt(自适应样式 + 数值不可改红线)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:11:15 +08:00
link2026
26a7d53b1b feat(语音日记): 新增麦克风与语音识别权限描述(端侧识别文案)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:07:38 +08:00
link2026
e603738330 docs(plan): 语音健康日记实施计划
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:05:59 +08:00
link2026
7f0a76098a docs(spec): 语音健康日记(端侧 ASR + LLM 整理)设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 05:41:29 +08:00
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
117 changed files with 12243 additions and 1139 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,9 @@
| 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,负责拍照→结构化指标 | | 模型 | **Qwen3.5-2B**(一个多模态模型,文本+视觉一肩挑) | 真机主用:`taobao-mnn/Qwen3.5-2B-MNN`(~1.2GB);MLX 兜底:`mlx-community/Qwen3.5-2B-4bit`(~1.7GB)。**已废弃**:Qwen3-1.7B / Qwen2.5-VL-3B / Qwen3-VL-4B(4B 实测过慢退回 2B) |
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
| Face ID | LocalAuthentication | | | Face ID | LocalAuthentication | |
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
@@ -38,13 +38,14 @@
### 3.1 模块边界(强制) ### 3.1 模块边界(强制)
``` ```
UI → CaptureService / AskService / TrendService → AIRuntime → MLX UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX
Persistence Persistence
``` ```
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。 - **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
- **`AIRuntime``actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。 - **`AIRuntime``actor` 单例,串行化**。同一时刻只允许一个推理任务(模型共享内存/Metal 显存,并发会 OOM 被 jetsam 杀)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。**真正落地**是 actor 内信号量闸门 `acquireGate()/releaseGate()`,所有占显存的重活(解码 + 模型加载)进入前先 await,且加载 VL 前先卸 LLM。
- **引擎选择**:`InferenceEngine.current` 由偏好(`.auto`/`.mnn`/`.mlx`)+ 设备可用性解析,真机默认 `.mnn`(SME2/NEON),模拟器回退 `.mlx`
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。 - **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
### 3.2 VL pipeline(拍一张 = 一条流程) ### 3.2 VL pipeline(拍一张 = 一条流程)
@@ -66,7 +67,7 @@ VL prompt 必须:
### 3.3 RAG(结构化检索,不做 embedding) ### 3.3 RAG(结构化检索,不做 embedding)
**两段式调用**: **两段式调用**:
1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s 1. 用 Qwen3.5-2B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答 2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。 **第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
@@ -84,7 +85,9 @@ VL prompt 必须:
## 4. 模型分发 ## 4. 模型分发
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 - 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有 - **用户面只有一个模型**:Qwen3.5-2B-MNN(~1.2GB,`ModelKind.userFacing = [.mnnLLM]`)。多模态,文本+视觉全包,下载全部 / 就绪计数只算它
- MLX 兜底版 Qwen3.5-2B-4bit(~1.7GB)仅模拟器与兜底用,不展示、不计入「下载全部」,但旁路导入仍可单独导
- WiFi 提示必须有
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载" - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) - `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
@@ -259,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
| 周次 | 必交付 | | 周次 | 必交付 |
|---|---| |---|---|
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 | | W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) | | W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | | W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | | W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |

View File

@@ -22,9 +22,12 @@
| 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,负责拍照→结构化指标 | | **统一模型(文本+视觉)** | **Qwen3.5-2B 多模态,一个模型全包** | 同一个 Qwen3.5-2B 同时做文本生成 / 关键词抽取 / 趋势解读 **和** 拍照→结构化指标。两种格式两种引擎,按设备选(见下两行)。代码:`ModelKind` |
| ├ MNN 主(iPhone17+/SME2) | `taobao-mnn/Qwen3.5-2B-MNN`(~1.1GiB,含 `visual.mnn`) | 挑战赛考核路径,真机默认。文本 + 图→文都走它。`ModelKind.mnnLLM`,唯一对用户暴露(`userFacing`) |
| └ MLX 兜底 / 模拟器 | `mlx-community/Qwen3.5-2B-4bit`(~1.7GB,多模态) | Metal GPU。走 `qwen3_5`,文本与 VL 复用同一模型。`ModelKind.llm`。4B 实测过慢已退回 2B |
| ~~VL(独立)~~ | ~~`mlx-community/Qwen3-VL-4B-Instruct-4bit`~~ **已废弃** | MLX VL 已改复用统一 Qwen3.5-2B 多模态;`ModelKind.vl` 仅保留枚举避免动穷举 switch,不再下载/展示 |
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
| Face ID | LocalAuthentication | | | Face ID | LocalAuthentication | |
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
@@ -38,13 +41,13 @@
### 3.1 模块边界(强制) ### 3.1 模块边界(强制)
``` ```
UI → CaptureService / AskService / TrendService → AIRuntime → MLX UI → CaptureService / AskService / TrendService → AIRuntime → MNN(主) / MLX(兜底)
Persistence Persistence
``` ```
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。 - **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
- **`AIRuntime``actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。 - **`AIRuntime``actor` 单例,串行化**。同一时刻只允许一个推理任务(`InferenceEngine` 选 MNN/SME2 主或 MLX/GPU 兜底,共享内存/显存,并发会 OOM)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。 - **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
### 3.2 VL pipeline(拍一张 = 一条流程) ### 3.2 VL pipeline(拍一张 = 一条流程)
@@ -66,7 +69,7 @@ VL prompt 必须:
### 3.3 RAG(结构化检索,不做 embedding) ### 3.3 RAG(结构化检索,不做 embedding)
**两段式调用**: **两段式调用**:
1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s 1.统一 Qwen3.5-2B(MNN 主 / MLX 兜底)抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答 2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。 **第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
@@ -84,7 +87,7 @@ VL prompt 必须:
## 4. 模型分发 ## 4. 模型分发
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 - 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有 - **用户侧只下载统一模型 Qwen3.5-2B(MNN,~1.1GiB,含视觉)**——不再是 ~4GB 两模型。`ModelKind.userFacing = [.mnnLLM]`,「下载全部」/ 就绪计数只算它。MLX 兜底模型 `Qwen3.5-2B-4bit`(~1.7GB)仅模拟器 / 旁路导入用,不计入用户下载;`Qwen3-VL-4B` 已废弃,不再分发。WiFi 提示仍保留
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载" - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) - `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
@@ -249,7 +252,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
3. **UI 不直接调 AIRuntime**——必须经过 Service 3. **UI 不直接调 AIRuntime**——必须经过 Service
4. **AIRuntime 必须 actor 化**——禁止 class + lock 4. **AIRuntime 必须 actor 化**——禁止 class + lock
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏 5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2) 6. **新功能必须问"清单里有吗"**——清单外的功能(多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)、**用药提醒**(记录 · 用药记录点药 → 复用自由提醒 `CustomReminder` / `CustomReminderEditSheet`,只到点提示,**仍不给剂量/频次建议**,守 §1 "不做剂量推荐")
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来 7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架 8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
@@ -259,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
| 周次 | 必交付 | | 周次 | 必交付 |
|---|---| |---|---|
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 | | W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) | | W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | | W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | | W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
@@ -281,7 +284,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,11 @@
import WidgetKit
import SwiftUI
/// KangkangWidget extension
/// W5 Live Activity , ActivityConfiguration Bundle
@main
struct KangkangWidgetBundle: WidgetBundle {
var body: some Widget {
PinnedIndicatorsWidget()
}
}

View File

@@ -0,0 +1,249 @@
import WidgetKit
import SwiftUI
// MARK: - ( App )
//
// : App `/Persistence/WidgetSnapshot.swift`
// extension App ( target membership ),
private struct WidgetSnapshot: Codable, Equatable {
struct Item: Codable, Equatable {
var name: String
var value: String
var unit: String
var statusRaw: String // high|low|normal
var capturedAt: Date
}
var updatedAt: Date
var items: [Item]
static let appGroupID = "group.com.xuhuayong.kangkang"
static let storeKey = "kk.widget.snapshot.v1"
static func load() -> WidgetSnapshot? {
guard let defaults = UserDefaults(suiteName: appGroupID),
let data = defaults.data(forKey: storeKey) else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
}
}
// MARK: - ( App Tj.Palette,extension DesignSystem)
private enum KkColor {
static let sand = Color(red: 0.976, green: 0.969, blue: 0.949)
static let ink = Color(red: 0.165, green: 0.153, blue: 0.137)
static let text = Color(red: 0.149, green: 0.137, blue: 0.118)
static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384)
static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580)
static let brick = Color(red: 0.886, green: 0.388, blue: 0.314) // high
static let amber = Color(red: 0.871, green: 0.627, blue: 0.314) // low
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) // normal
}
private func statusColor(_ raw: String) -> Color {
switch raw {
case "high": return KkColor.brick
case "low": return KkColor.amber
default: return KkColor.leaf
}
}
// MARK: - Timeline
private struct PinnedEntry: TimelineEntry {
let date: Date
let items: [WidgetSnapshot.Item]
let updatedAt: Date?
}
private struct PinnedProvider: TimelineProvider {
func placeholder(in context: Context) -> PinnedEntry {
PinnedEntry(date: .now, items: Self.sampleItems, updatedAt: .now)
}
func getSnapshot(in context: Context, completion: @escaping (PinnedEntry) -> Void) {
if context.isPreview {
completion(placeholder(in: context))
} else {
completion(currentEntry())
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<PinnedEntry>) -> Void) {
// App reloadAllTimelines ; 30
// ("x ")
let entry = currentEntry()
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now
completion(Timeline(entries: [entry], policy: .after(next)))
}
private func currentEntry() -> PinnedEntry {
let snap = WidgetSnapshot.load()
return PinnedEntry(date: .now, items: snap?.items ?? [], updatedAt: snap?.updatedAt)
}
static let sampleItems: [WidgetSnapshot.Item] = [
.init(name: "收缩压", value: "128", unit: "mmHg", statusRaw: "normal",
capturedAt: .now.addingTimeInterval(-3600 * 5)),
.init(name: "空腹血糖", value: "6.4", unit: "mmol/L", statusRaw: "high",
capturedAt: .now.addingTimeInterval(-3600 * 30)),
.init(name: "体重", value: "68.5", unit: "kg", statusRaw: "normal",
capturedAt: .now.addingTimeInterval(-3600 * 50)),
.init(name: "尿酸", value: "486", unit: "μmol/L", statusRaw: "high",
capturedAt: .now.addingTimeInterval(-3600 * 80)),
]
}
// MARK: - Views
private struct PinnedIndicatorsView: View {
@Environment(\.widgetFamily) private var family
let entry: PinnedEntry
var body: some View {
Group {
if entry.items.isEmpty {
emptyView
} else {
switch family {
case .systemMedium: mediumView
default: smallView
}
}
}
.containerBackground(for: .widget) { KkColor.sand }
}
private var emptyView: some View {
VStack(spacing: 6) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 22))
.foregroundStyle(KkColor.text3)
Text("在康康里关注指标后\n这里会显示最新值")
.font(.system(size: 11))
.multilineTextAlignment(.center)
.foregroundStyle(KkColor.text3)
}
}
/// : + 2
private var smallView: some View {
VStack(alignment: .leading, spacing: 6) {
header
if let first = entry.items.first {
VStack(alignment: .leading, spacing: 1) {
Text(first.name)
.font(.system(size: 11))
.foregroundStyle(KkColor.text2)
HStack(alignment: .firstTextBaseline, spacing: 3) {
Text(first.value)
.font(.system(size: 24, weight: .semibold, design: .rounded))
.foregroundStyle(statusColor(first.statusRaw))
Text(first.unit)
.font(.system(size: 10))
.foregroundStyle(KkColor.text3)
}
}
}
ForEach(entry.items.dropFirst().prefix(2), id: \.name) { item in
compactRow(item)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
/// :, 6
private var mediumView: some View {
VStack(alignment: .leading, spacing: 8) {
header
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
alignment: .leading, spacing: 8) {
ForEach(entry.items.prefix(6), id: \.name) { item in
gridCell(item)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var header: some View {
HStack(spacing: 4) {
Text("康康 · 长期监测")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(KkColor.text3)
Spacer()
if let updatedAt = entry.updatedAt {
Text(updatedAt, style: .relative)
.font(.system(size: 9))
.foregroundStyle(KkColor.text3)
}
}
}
private func compactRow(_ item: WidgetSnapshot.Item) -> some View {
HStack(spacing: 4) {
Circle()
.fill(statusColor(item.statusRaw))
.frame(width: 5, height: 5)
Text(item.name)
.font(.system(size: 10))
.foregroundStyle(KkColor.text2)
.lineLimit(1)
Spacer(minLength: 2)
Text(item.value)
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(KkColor.text)
}
}
private func gridCell(_ item: WidgetSnapshot.Item) -> some View {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Circle()
.fill(statusColor(item.statusRaw))
.frame(width: 5, height: 5)
Text(item.name)
.font(.system(size: 10))
.foregroundStyle(KkColor.text2)
.lineLimit(1)
}
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(item.value)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundStyle(KkColor.text)
Text(item.unit)
.font(.system(size: 8))
.foregroundStyle(KkColor.text3)
.lineLimit(1)
}
}
}
}
// MARK: - Widget
struct PinnedIndicatorsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "PinnedIndicatorsWidget", provider: PinnedProvider()) { entry in
PinnedIndicatorsView(entry: entry)
}
.configurationDisplayName("长期监测")
.description("展示你关注的健康指标最新值。数据 100% 在本机。")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
#Preview("small", as: .systemSmall) {
PinnedIndicatorsWidget()
} timeline: {
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
}
#Preview("medium", as: .systemMedium) {
PinnedIndicatorsWidget()
} timeline: {
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
}

View File

@@ -0,0 +1,52 @@
# 桌面 Widget 接入步骤(约 3 分钟Xcode 操作)
代码已全部写好。主 App 侧(快照写入 + RootView hook已自动编译生效
Widget extension 需要你在 Xcode 里建一次 target再放入两个源文件。
## 1. 创建 Widget Extension target
1. Xcode 打开 `康康.xcodeproj` → 菜单 **File → New → Target…**
2.**iOS → Widget Extension**,点 Next
3. Product Name 填 **`KangkangWidget`**
- ❌ 不勾 "Include Live Activity"W5 做 Live Activity 时再往这个 target 里加Bundle 入口已留好注释)
- ❌ 不勾 "Include Configuration App Intent"(我们用 StaticConfiguration
4. 点 Finish弹出 "Activate scheme?" 选 **Activate**
## 2. 替换模板代码
Xcode 会在工程根目录生成 `KangkangWidget/` 文件夹(含模板 swift 文件)。
1. 删除模板生成的所有 `.swift` 文件(`KangkangWidget.swift``KangkangWidgetBundle.swift``AppIntent.swift` 等,**保留 `Info.plist` 和 Assets**),选 "Move to Trash"
2.`KangkangWidget-src/` 里的两个文件拖进 Xcode 的 `KangkangWidget` 文件夹(勾选 targetKangkangWidget
- `KangkangWidgetBundle.swift`
- `PinnedIndicatorsWidget.swift`
3. 拖完后可删掉暂存目录 `KangkangWidget-src/`
## 3. 配置 App Group两个 target 都要)
数据通过 App Group UserDefaults 传递ID 固定为 **`group.com.xuhuayong.kangkang`**。
1. 选中工程 → target **康康** → Signing & Capabilities → **+ Capability → App Groups** → 添加 `group.com.xuhuayong.kangkang`
2. target **KangkangWidget** → 同样添加 App Groups → 勾选同一个 `group.com.xuhuayong.kangkang`
3. KangkangWidget 的 **iOS Deployment Target 改成 17.0**(模板默认可能更高)
> 个人开发者账号下 App Group 会自动注册;如签名报错,在两个 target 的 Signing 里确认 Team 一致。
## 4. 验证
1. scheme 切回 **康康**,跑真机/模拟器
2. 进 App首页出现即写入快照回到桌面 → 长按 → 添加小组件 → 找 **康康 · 长期监测**
3. 小/中两个尺寸都支持。没有任何 pinned 指标时显示引导文案;
在趋势页关注指标(或 C2「关联到趋势」回桌面即可看到最新值
## 故障排查
- **小组件空白/不出现**:先确认两个 target 的 App Group 勾的是同一个 ID再确认主 App 至少前台打开过一次(快照由主 App 写)
- **数据不更新**:快照在 App 进后台时刷新;强杀 App 不触发 `scenePhase == .background`,正常 Home 手势退出即可
- **编译报 `containerBackground` 不存在**KangkangWidget 的 Deployment Target 没改成 17.0
## 架构备忘(给后续会话)
- 主 App 写快照:`康康/Persistence/WidgetSnapshot.swift`(数据契约)+ `WidgetSnapshotRefresher.swift`pinned 指标 → App GroupRootView 在启动和进后台时调用)
- Widget 读快照:`KangkangWidget/PinnedIndicatorsWidget.swift` 内有 `WidgetSnapshot` 的**独立拷贝**extension 不引主 App 代码)。⚠️ 改字段两边同步
- Widget 不读 SwiftDatastore 有文件保护且在主 App 沙盒extension 锁屏时读不到;快照 = 最后一次看到的值,锁屏也能显示

View File

@@ -0,0 +1,137 @@
# 康康 · 小红书发布文案(比赛评审用)
> 使用说明:
> - `◻︎` 处填真机实测数字(打开 我的 → 模型管理 → 性能自检,截图同时把数字抄进来)
> - `#比赛官方话题#` 和 `@官方账号` 替换成组委会指定的话题和账号(评审通常按官方话题检索作品,**漏带话题可能查不到你的帖子**)
> - 主推版做主帖;技术版可隔 2~3 天发第二篇,小红书对"同一项目多角度连发"权重友好
> - 发布时间建议:工作日 12:0013:30 或 20:0022:30
---
## 版本 A · 主推版(大众 + 评委兼顾)
### 标题(三选一,均 ≤ 20 字)
1. 体检报告拍一下,AI 解读不联网📱
2. 我做了个不上传的健康 AI,飞行模式都能用
3. 爸妈的体检报告,终于有 AI 肯"离线"看了
### 正文
体检报告上一堆↑↓箭头,看得懂的没几个;
想让 AI 帮忙解读,又得把化验单拍给云端——
等于把自己最隐私的数据交出去了。
所以我做了「康康」:一个 **100% 本地推理** 的健康档案 App🍃
所有 AI 都跑在 iPhone 自己的芯片上,**开飞行模式照样用**,数据一个字节都不出手机。
✅ 它能做什么👇
📷 **拍一张,报告变档案**
化验单/体检报告对着拍,OCR + 端侧大模型自动抽出每项指标、参考范围、偏高偏低,归档成可检索的电子档案。
📈 **趋势看得见**
血压、血糖、体重……长期指标自动画折线,AI 用大白话告诉你"这半年在变好还是变差"。
💬 **问它,它真的记得你**
"我去年尿酸多少?""最近三次血脂对比一下"——它从你自己的历史记录里检索回答,每句话都带引用,点一下能跳回原始报告。
🗣️ **嘴说就能记**
"昨晚头疼,睡得不好"——说一句,自动整理成日记;药盒扫一下,自动录入正在吃的药。
🏥 **看病前 30 秒**
一键生成给医生看的就诊摘要:近期症状 + 关键指标 + 用药过敏史,门诊不再大脑空白。
🔐 **隐私三件套**
系统级硬件加密 + Face ID 锁 + 永久删除。没有账号、没有云、没有"用户协议第 38 条"。
⚙️ 技术控看这里:
端侧跑的是 Qwen3.5 大模型,推理框架是阿里开源的 MNN,在 iPhone 17 上吃满了 Arm 最新的 SME2 矩阵指令——纯 CPU 解码 ◻︎ tok/s,锁屏界面实时显示生成速度,推理快到不像没联网😎
这是我参加 #比赛官方话题# 的参赛作品,从设计到代码一个人肝了六周。
如果你也觉得"健康数据就该留在自己手机里",求个赞和收藏🙏
有想要的功能评论区告诉我,下个版本安排!
⚠️ 康康只做记录和科普式解读,不做诊断不替代医生,身体不舒服请及时就医。
### 话题标签
\#比赛官方话题# #端侧AI #本地大模型 #健康管理 #体检报告解读 #隐私保护 #iOS开发 #独立开发者 #AI应用 #数字健康
### 配图脚本(9 宫格)
| # | 内容 | 备注 |
|---|------|------|
| 1 | 封面:手机展示首页 + 大字标题"体检报告 AI 解读,不联网" | 封面字要大,缩略图能读清 |
| 2 | 拍照识别报告全流程(拍摄→指标确认页) | 可两张拼一张 |
| 3 | 报告详情 C2:原图/解读/指标 三 Tab | 露出"对比上次"区块 |
| 4 | 趋势页折线图 + AI 一句话解读 | |
| 5 | AI 问答:带 [1][2] 引用 Pill 的回答 | 体现"检索自己的记录" |
| 6 | **控制中心飞行模式开启 + App 正常生成回答** 同屏 | 全帖最有说服力的一张 |
| 7 | 性能自检卡:SME2 标识 + prefill/decode tok/s | 评委重点看这张 |
| 8 | 锁屏 Live Activity 实时 tok/s | |
| 9 | 隐私设置页:Face ID + 永久删除 | |
---
## 版本 B · 技术圈层版(隔 2~3 天发)
### 标题(二选一)
1. 在 iPhone 的 CPU 上,我把大模型跑到 ◻︎ tok/s
2. 不用 GPU,iPhone 17 纯 CPU 跑通 Qwen3.5🔥
### 正文
最近所有人都在卷云端大模型,我反着来:
把整套健康 AI——视觉识别、RAG 问答、趋势解读——全部塞进 iPhone 本地,**纯 CPU 推理**。
为什么是 CPU 不是 GPU?
因为 Arm 在新一代芯片里加了 SME2(可伸缩矩阵扩展):专为矩阵乘法设计的指令集,大模型推理的核心运算正好是它的主场。
我的技术栈👇
🔹 模型:Qwen3.5-2B(多模态,一个模型同时干文本 + 看图识报告)
🔹 推理框架:MNN(阿里开源),iPhone 17/A19 走 SME2,老机型自动回退 NEON
🔹 兜底:MLX(Apple 官方,Metal GPU),双后端运行时无感切换
🔹 应用层:SwiftUI + SwiftData,RAG 用结构化检索(意图抽取→按关键词查库→拼 prompt),不引入 embedding 模型,首响更快
实测数据(iPhone 17,可在 App 内"性能自检"复现):
⚡ prefill ◻︎ tok/s / decode ◻︎ tok/s
⚡ 拍一张化验单到出结构化指标:约 ◻︎ 秒
⚡ 模型常驻互斥 + actor 串行闸门,长时间使用不 OOM
几个有意思的坑:
1⃣ MNN 默认 enable_thinking=true,模型疯狂输出 <think> 吃光 token 预算,要在 bridge 层 set_config 关掉
2⃣ 长文本逐行复读死循环——采样器默认不带 repetition penalty,MNN 要显式写进 mixed_samplers
3⃣ LLM 和 VL 同时驻留必 jetsam,做了常驻互斥 + 推理优先级闸门(交互任务可插队后台预生成)
做这个项目的初衷很简单:健康数据是最不该上云的数据。
端侧推理已经到了"真能用"的拐点,这是我给 #比赛官方话题# 交的答卷。
代码细节/性能调优有兴趣的评论区聊👇
⚠️ App 仅做记录与科普式解读,不提供诊断建议。
### 话题标签
\#比赛官方话题# #端侧AI #MNN #Qwen #ArmSME2 #大模型推理 #iOS开发 #SwiftUI #独立开发者 #本地大模型
### 配图脚本
1. 封面:性能自检卡大图,tok/s 数字放大做封面字
2. 架构图:UI → Service → AIRuntime → MNN(SME2)/MLX 双后端
3. 飞行模式 + 流式生成同屏
4. 锁屏 Live Activity tok/s
5. 拍照识别报告前后对比(原图 → 结构化指标)
6. Xcode/代码截图:MNNLLMBridge 或 actor 闸门片段(打码无关信息)
7. 老机型 NEON vs iPhone 17 SME2 速度对比(如有数据)
---
## 发布贴士
1. **官方话题必带且放第一位**,正文里也 @官方账号 一次
2. 封面图决定 80% 点击:大字 + 高对比,别用纯截图
3. 发布后 1 小时内回评论(尤其问"怎么下载"的,回复"比赛 demo 阶段,关注我等上架"),互动率影响推荐量
4. 不要写"治疗""诊断""疗效"等词,健康类内容平台审得严,现有文案已规避
5. 主帖发出后把链接填进比赛报名系统/问卷(如果章程要求回填链接)

View File

@@ -0,0 +1,41 @@
# MNN 前缀 KV Cache 调研(2026-06-10)
## 结论
当前打包的 MNN.xcframework 已暴露 prefix cache 能力,技术上可以把每个场景**固定的
system prompt + few-shot 模板**的 prefill 结果缓存到磁盘,二次调用跳过这部分 prefill。
**建议 W6 polish 阶段、用性能自检卡量化 prefill 占比之后再决定是否接入**;当前瓶颈在
decode 而非二次 prefill,优先级低于 C1/C2/Live Activity。
## 依据(`Frameworks/MNN.xcframework/ios-arm64/MNN.framework/Headers/llm/llm.hpp`)
| API | 行号 | 含义 |
|---|---|---|
| `bool setPrefixCacheFile(const std::string& filename, int flag = 0)` | :161 | 指定前缀缓存文件;配套私有成员 `mPrefixCacheMode` / `mPrefixLength` / `mIsPrefixFileExist` / `completePrefixWrite()`(:250-255)印证:命中时 prefill 只算增量部分 |
| `bool reuse_kv()` | :171 | 读 config 开关 `reuse_kv`,多轮对话内复用 KV(同一会话增量 prefill) |
| `void syncPromptCache(const ChatMessages&)` | :176 | decode 结束后同步缓存文本——注释明确说明 cache 在 generate() 后自更新,此接口供做过后处理(如 deleteThinkPart)的调用方提供更准确版本 |
| `void setKVCacheInfo(size_t add, size_t remove, ...)` / `eraseHistory(begin, end)` | :158-160 | 更底层的 KV 区间管理,可做部分历史擦除 |
## 对本项目的适用性
- 我们所有调用都是「固定模板前缀 + 可变数据后缀」的单轮 `response()`,与 prefix cache
的模型吻合。
- 模板体量(估):报告识别 ~900 tok、导出报告 ~700 tok、意图抽取 ~300 tok。
按性能自检卡实测的 prefill 速率推算,每次调用预计省 **1~3s**
- 多场景共用一个 cache 文件是否支持多前缀未知;最坏情况只对单一场景(建议选「报告识别」,
模板最长、调用最频繁)生效。
## 风险
1. `flag` 参数语义在头文件无注释,需读 MNN 源码或实验确认。
2. OMNI(多模态)分支下行为未验证——我们的 MNN 模型是 Omni 构建。
3. cache 文件与模型权重版本绑定:模型更新/重下载后必须失效,否则可能输出乱码。
4. `<img>` 标签在 prompt 前部(`analyzeImages` 把图片标签拼在最前),意味着报告识别场景的
"固定前缀" 实际不固定 —— **文本场景(导出/意图抽取)才是干净的 prefix cache 候选**
## 建议的接入步骤(W6,如性能自检显示 prefill 占比 >30%)
1. `MNNLLMBridge` init 后调 `setPrefixCacheFile(<AppSupport>/mnn-prefix.cache)`(仅文本场景)。
2. 真机 A/B:同一导出报告各跑 3 次,对比 `LlmContext.prefill_us`
3. 异常处理:加载失败或输出劣化时删除 cache 文件并禁用,回退现状。
4. `ModelDownloadService.importModel` / 重下载路径上顺手删除旧 cache 文件。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,930 @@
# 语音健康日记 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在「健康记录」(`DiaryQuickSheet`)加语音输入:iOS 端侧流式语音识别实时转写,停止后由本地 LLM(Qwen3.5-2B,经 AIRuntime)整理成健康日记草稿,追加进输入框,可一键回退原话。
**Architecture:** `DiaryQuickSheet`(mic 按钮 + 状态机)→ `SpeechDictationService`(新,AVAudioEngine + SFSpeechRecognizer 端侧流式转写,不落盘音频)→ `DiaryAssistService.organize(transcript:)`(新方法,经 AIRuntime actor 队列)。Spec:`docs/superpowers/specs/2026-06-10-voice-diary-design.md`
**Tech Stack:** SwiftUI、Speech framework(`requiresOnDeviceRecognition = true`)、AVFoundation、Swift Testing(`康康Tests`)。
**工程约定(执行前必读):**
- 工程是 Xcode 16 同步组(`PBXFileSystemSynchronizedRootGroup`):`康康/``康康Tests/` 下新建文件**自动入 target,不要改 pbxproj 的文件列表**(权限键除外,见 Task 1)。
- CLI 编译/测试必须:`export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`,且加 `-derivedDataPath ./build/cli-dd`(避免和 Xcode 抢 build.db 锁)。
- 工程 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`:类型默认 MainActor;系统回调闭包(audio tap、recognitionTask handler)是 nonisolated,**闭包内只碰局部捕获变量,回主线程用 `Task { @MainActor in }`**。
- 用户可见文案用 `String(appLoc: "...")`;字号用 `Font.tjScaled(...)`,禁止裸 `.system(size:)`;颜色只用 `Tj.Palette.*`。**不要手改 `Localizable.xcstrings`**(键缺失时回退键名本身,中文键名即兜底文案)。
- `git status` 里已有 `康康/Localizable.xcstrings` 的无关改动——**任何 commit 都不要带上它**(逐文件 `git add`)。
- spec 偏差说明(已确认的两处小调整):① CLAUDE.md 提到的 `DebugAIRunner` 已不在工程中,prompt 自检改为 `康康Tests` 单元测试 + 真机手测清单;② mic 按钮放「内容」section 标签行右侧(而非输入框内右下角 overlay),避免与文字重叠,仍属"输入框旁"。
---
### Task 0: 建独立分支
**Files:** 无(纯 git)
- [x] **Step 1: 从当前分支建 `feat/voice-diary`**
```bash
cd /Users/xuhuayong/apps/康康
git checkout -b feat/voice-diary
```
Expected: `Switched to a new branch 'feat/voice-diary'`(`Localizable.xcstrings` 的本地改动会跟着工作区走,不影响)。
---
### Task 1: 新增麦克风 + 语音识别权限描述(pbxproj)
**Files:**
- Modify: `康康.xcodeproj/project.pbxproj:430``康康.xcodeproj/project.pbxproj:486`(Debug + Release 两个构建配置)
pbxproj 的 `INFOPLIST_KEY_*` 按字母序排列:Microphone 插在 `NSHealthUpdateUsageDescription` 之后,SpeechRecognition 插在 `NSPhotoLibraryUsageDescription` 之后。每个锚点行在文件中出现 **2 次**(Debug/Release),用 replace_all 一次改两处。
- [x] **Step 1: 插入 NSMicrophoneUsageDescription(replace_all)**
用 Edit 工具,`replace_all: true`:
old_string(注意行首是 4 个 tab):
```
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
```
new_string:
```
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
```
- [x] **Step 2: 插入 NSSpeechRecognitionUsageDescription(replace_all)**
old_string:
```
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
```
new_string:
```
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
```
- [x] **Step 3: 验证两个键各出现 2 次**
```bash
grep -c "NSMicrophoneUsageDescription\|NSSpeechRecognitionUsageDescription" 康康.xcodeproj/project.pbxproj
```
Expected: `4`
- [x] **Step 4: Commit**
```bash
git add 康康.xcodeproj/project.pbxproj
git commit -m "feat(语音日记): 新增麦克风与语音识别权限描述(端侧识别文案)"
```
---
### Task 2: organize prompt(TDD)
**Files:**
- Test: `康康Tests/DiaryOrganizePromptTests.swift`(新建)
- Modify: `康康/AI/Prompts/DiaryAssistPrompts.swift`(文件末尾 `}` 前加方法)
- [x] **Step 1: 写失败测试**
新建 `康康Tests/DiaryOrganizePromptTests.swift`:
```swift
import Testing
@testable import
struct DiaryOrganizePromptTests {
@Test func organizePromptContainsTranscriptAndHardRules() {
let prompt = DiaryAssistPrompts.organize(transcript: "今天早上头晕量了血压140 90")
#expect(prompt.contains("今天早上头晕量了血压140 90"))
// 线:///, prompt
#expect(prompt.contains("数值"))
#expect(prompt.contains("药名"))
//
#expect(prompt.contains("一段通顺的话"))
#expect(prompt.contains("分行"))
// prompt :
#expect(prompt.contains("/no_think"))
}
@Test func organizePromptTruncatesLongTranscript() {
let long = String(repeating: "头晕", count: 2000) // 4000 ,
let prompt = DiaryAssistPrompts.organize(transcript: long)
// prompt organizeTranscriptLimit
let expectedTail = String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit))
#expect(prompt.contains(expectedTail))
#expect(!prompt.contains(String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit + 2))))
}
}
```
- [x] **Step 2: 跑测试确认编译失败(方法还不存在)**
```bash
cd /Users/xuhuayong/apps/康康
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:'康康Tests/DiaryOrganizePromptTests' \
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | tail -20
```
Expected: 编译错误 `type 'DiaryAssistPrompts' has no member 'organize'`(TEST FAILED)。
- [x] **Step 3: 实现 organize prompt**
`康康/AI/Prompts/DiaryAssistPrompts.swift` 的 enum 末尾(`suggest` 方法后、收尾 `}` 前)加:
```swift
// MARK: -
/// 稿()2B context :
static let organizeTranscriptLimit = 1200
/// 稿稿: ;
/// :
/// 线(spec §2):,
/// 2B 140/90 130/90 , few-shot
static func organize(transcript: String) -> String {
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
return """
你是健康记录助手。下面是用户口述身体状态的语音转写原话,可能口语化、有重复、缺标点。
请把它整理成一条清晰的健康日记。
硬性规则:
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
- 不诊断、不给用药建议、不写「建议就医」。
- 只输出整理后的日记正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
示例 1(口述:那个今天早上起来有点头晕然后我量了下血压140 90比平时高一点没吃早饭就出门了):
今天早上起来有点头晕,量了血压 140/90,比平时高一点。没吃早饭就出门了。
示例 2(口述:今天头晕了一上午下午好点了血压早上量的140 90嗯缬沙坦吃了降脂药忘了吃早饭没吃中午吃的清淡晚上散步了半小时):
症状:头晕了一上午,下午好转。
血压:早上 140/90。
用药:缬沙坦已服,降脂药忘服。
饮食:早饭未吃,午餐清淡。
运动:晚上散步半小时。
【口述原话】:
\(trimmed)
Output: /no_think
"""
}
```
- [x] **Step 4: 跑测试确认通过**
同 Step 2 命令。Expected: `** TEST SUCCEEDED **`,2 个用例通过。
- [x] **Step 5: Commit**
```bash
git add 康康Tests/DiaryOrganizePromptTests.swift 康康/AI/Prompts/DiaryAssistPrompts.swift
git commit -m "feat(语音日记): organize prompt(自适应样式 + 数值不可改红线)"
```
---
### Task 3: DiaryAssistService.organize
**Files:**
- Modify: `康康/Services/DiaryAssistService.swift:99` 之后(`suggest` 方法后、struct 收尾 `}` 前)
无新单测(纯转发 AIRuntime,LLM 行为靠真机手测;解析逻辑只有 strip + trim,复用已测过的 `stripThinkBlocks`)。
- [x] **Step 1: 加 organize 方法**
`suggest` 方法的收尾 `}` 之后、struct 收尾 `}` 之前加:
```swift
/// 稿稿(spec 2026-06-10-voice-diary)
/// ( / ),退使,
/// suggest AIRuntime actor ,/
func organize(transcript: String) async throws -> (text: String, decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = DiaryAssistPrompts.organize(transcript: transcript)
var collected = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
let text = HealthExportService.stripThinkBlocks(collected)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw AssistError.empty }
return (text, lastRate)
}
```
- [x] **Step 2: 编译验证**
```bash
cd /Users/xuhuayong/apps/康康
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
xcodebuild -project 康康.xcodeproj -scheme 康康 \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-configuration Debug build -derivedDataPath ./build/cli-dd \
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
```
Expected: `BUILD SUCCEEDED`,无新增 warning。
- [x] **Step 3: Commit**
```bash
git add 康康/Services/DiaryAssistService.swift
git commit -m "feat(语音日记): DiaryAssistService.organize 转写稿整理"
```
---
### Task 4: SpeechDictationService(端侧流式转写)
**Files:**
- Create: `康康/Services/SpeechDictationService.swift`
硬件绑定,无单测;模拟器路径(`isAvailable == false`)与真机路径在 Task 7 手测。
- [x] **Step 1: 新建 SpeechDictationService.swift**
```swift
import Foundation
import Speech
import AVFoundation
/// (spec 2026-06-10-voice-diary)
/// AVAudioEngine buffer SFSpeechAudioBufferRecognitionRequest,
/// `requiresOnDeviceRecognition = true` ,;****
///
/// :start(onPartial:) partial;stop() 稿
/// :DiaryQuickSheet MainActor , MainActor;
/// audio tap 线,,线 Task { @MainActor }
final class SpeechDictationService {
enum DictationError: Error, LocalizedError {
case unavailable
case audioEngineStartFailed(String)
var errorDescription: String? {
switch self {
case .unavailable:
return String(appLoc: "本机不支持端侧语音识别")
case .audioEngineStartFailed(let m):
return String(appLoc: "录音启动失败:\(m)")
}
}
}
/// ;(demo 使)
private static func makeRecognizer() -> SFSpeechRecognizer? {
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {
return r
}
if let r = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")),
r.supportsOnDeviceRecognition {
return r
}
return nil
}
/// false(/) UI mic ,
static var isAvailable: Bool { makeRecognizer() != nil }
private let audioEngine = AVAudioEngine()
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
/// ;isFinal didFinishstop() final partial
private var latestText = ""
private var didFinish = false
private(set) var isRecording = false
/// + false
func requestAuthorization() async -> Bool {
let speech = await withCheckedContinuation { (c: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
SFSpeechRecognizer.requestAuthorization { c.resume(returning: $0) }
}
guard speech == .authorized else { return false }
return await AVAudioApplication.requestRecordPermission()
}
/// + partial 线()
func start(onPartial: @escaping (String) -> Void) throws {
guard !isRecording else { return }
guard let recognizer = Self.makeRecognizer(), recognizer.isAvailable else {
throw DictationError.unavailable
}
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
try session.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
throw DictationError.audioEngineStartFailed(error.localizedDescription)
}
let request = SFSpeechAudioBufferRecognitionRequest()
request.requiresOnDeviceRecognition = true // 线:
request.shouldReportPartialResults = true
request.addsPunctuation = true
self.request = request
latestText = ""
didFinish = false
let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
// tap 线: request, self
input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
request.append(buffer)
}
audioEngine.prepare()
do {
try audioEngine.start()
} catch {
input.removeTap(onBus: 0)
deactivateSession()
throw DictationError.audioEngineStartFailed(error.localizedDescription)
}
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
// 线 线
Task { @MainActor in
guard let self else { return }
if let result {
self.latestText = result.bestTranscription.formattedString
onPartial(self.latestText)
if result.isFinal { self.didFinish = true }
}
if error != nil { self.didFinish = true }
}
}
isRecording = true
}
/// ,( 1.5s, partial),稿
/// partial (spec :)
func stop() async -> String {
guard isRecording else { return "" }
isRecording = false
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
request?.endAudio()
let deadline = Date().addingTimeInterval(1.5)
while !didFinish && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
task?.cancel()
task = nil
request = nil
deactivateSession()
return latestText
}
/// sheet :,
func abort() {
guard isRecording else { return }
isRecording = false
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
request?.endAudio()
task?.cancel()
task = nil
request = nil
deactivateSession()
}
private func deactivateSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
}
```
- [x] **Step 2: 编译验证**
同 Task 3 Step 2 命令。Expected: `BUILD SUCCEEDED`。若出现 actor 隔离 warning(标注 error in Swift 6 language mode 的不阻塞),按提示把回调内对 self 的访问收进 `Task { @MainActor in }`,不许用 `nonisolated(unsafe)` 糊。
- [x] **Step 3: Commit**
```bash
git add 康康/Services/SpeechDictationService.swift
git commit -m "feat(语音日记): SpeechDictationService 端侧流式转写(不落盘音频)"
```
---
### Task 5: DiaryVoicePanel(录音/整理面板视图)
**Files:**
- Create: `康康/Features/Diary/DiaryVoicePanel.swift`
纯展示组件,状态全部外部传入,DiaryQuickSheet(已 600+ 行)不再膨胀。
- [x] **Step 1: 新建 DiaryVoicePanel.swift**
```swift
import SwiftUI
/// (spec 2026-06-10-voice-diary)
/// :recording( + + )/ organizing(AI ,)
/// : DiaryQuickSheet
struct DiaryVoicePanel: View {
enum Mode: Equatable {
case recording(elapsedSeconds: Int)
case organizing
}
let mode: Mode
/// recording ;organizing 稿稿()
let transcript: String
let onStop: () -> Void
let onCancelOrganize: () -> Void
/// 3 ( DiaryQuickSheet onStop)
static let maxRecordingSeconds = 180
var body: some View {
VStack(alignment: .leading, spacing: 10) {
header
transcriptArea
if case .recording = mode {
stopButton
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.overlay(alignment: .bottom) {
if mode == .organizing {
AIFlowBar().padding(.horizontal, 1)
}
}
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
@ViewBuilder
private var header: some View {
switch mode {
case .recording(let elapsed):
HStack(spacing: 8) {
Image(systemName: "waveform")
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.variableColor.iterative, options: .repeating)
Text("正在听 · 识别在本机完成")
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Text(Self.format(elapsed))
.font(.tjScaled(12, design: .monospaced))
.foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30
? Tj.Palette.brick : Tj.Palette.text3)
}
case .organizing:
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.pulse, options: .repeating)
Text("AI 整理中 · 本地推理")
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button("取消") { onCancelOrganize() }
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
}
}
@ViewBuilder
private var transcriptArea: some View {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript)
.font(.tjScaled(14))
.foregroundStyle(transcriptColor)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Color.clear.frame(height: 1).id("tail")
}
.frame(maxHeight: 120)
.onChange(of: transcript) { _, _ in
proxy.scrollTo("tail", anchor: .bottom)
}
}
}
private var transcriptColor: Color {
if transcript.isEmpty { return Tj.Palette.text3 }
return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text
}
private var stopButton: some View {
Button(action: onStop) {
HStack(spacing: 8) {
Image(systemName: "stop.circle.fill")
Text("说完了,整理成日记")
}
.font(.tjScaled(14, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private static func format(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
#Preview("录音中") {
DiaryVoicePanel(mode: .recording(elapsedSeconds: 23),
transcript: "今天早上起来有点头晕,量了血压一百四九十",
onStop: {}, onCancelOrganize: {})
.padding()
}
#Preview("整理中") {
DiaryVoicePanel(mode: .organizing,
transcript: "今天早上起来有点头晕,量了血压一百四九十",
onStop: {}, onCancelOrganize: {})
.padding()
}
```
- [x] **Step 2: 编译验证**
同 Task 3 Step 2 命令。Expected: `BUILD SUCCEEDED`
- [x] **Step 3: Commit**
```bash
git add 康康/Features/Diary/DiaryVoicePanel.swift
git commit -m "feat(语音日记): DiaryVoicePanel 录音/整理面板"
```
---
### Task 6: DiaryQuickSheet 接入(mic 按钮 + 状态机 + 回退 pill)
**Files:**
- Modify: `康康/Features/Diary/DiaryQuickSheet.swift`
改 5 处:① 状态 + 录音流程函数;② 「内容」标签行加 mic 按钮;③ 输入框下方挂面板 / 提示条 / 回退 pill;④ `canRequestSuggest` 把 organizing 排除;⑤ onDisappear 清理。
- [x] **Step 1: 加语音状态(`@FocusState` 行之后、`hasContent` 之前)**
`DiaryQuickSheet.swift:38`(`@FocusState private var contentFocused: Bool`)之后插入:
```swift
// MARK: (spec 2026-06-10-voice-diary)
enum VoicePhase: Equatable { case idle, recording, organizing }
@State private var voicePhase: VoicePhase = .idle
@State private var liveTranscript = ""
@State private var recordingSeconds = 0
/// 稿,退;
@State private var rawTranscript: String?
/// 稿,
/// () pill
@State private var organizedAppended: String?
/// ( / ),
@State private var voiceNote: String?
@State private var voiceDeniedAlert = false
@State private var voiceFlowTask: Task<Void, Never>?
@State private var recordingWatchdog: Task<Void, Never>?
private let dictation = SpeechDictationService()
```
- [x] **Step 2: 「内容」标签行加 mic 按钮**
把(`DiaryQuickSheet.swift:79-80` 附近):
```swift
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "内容"))
```
改为:
```swift
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel(String(appLoc: "内容"))
Spacer()
if SpeechDictationService.isAvailable, voicePhase == .idle {
Button(action: startVoice) {
HStack(spacing: 4) {
Image(systemName: "mic.fill")
.font(.tjScaled(11, weight: .semibold))
Text("说一段")
.font(.tjScaled(12, weight: .semibold))
}
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().strokeBorder(
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(isLoading) // AI AIRuntime
}
}
```
(`TextField` 那段不动,仍在该 VStack 内。)
- [x] **Step 3: 输入框下方挂面板 / 提示条 / 回退 pill**
在 TextField 的 `.overlay(...)` 闭合后、该 VStack 的收尾 `}` 之前(即原 `DiaryQuickSheet.swift:95` `)``:96` `}` 之间)插入:
```swift
if voicePhase != .idle {
DiaryVoicePanel(
mode: voicePhase == .organizing
? .organizing
: .recording(elapsedSeconds: recordingSeconds),
transcript: liveTranscript,
onStop: stopVoiceAndOrganize,
onCancelOrganize: cancelOrganize
)
}
if let note = voiceNote {
HStack(spacing: 6) {
Image(systemName: "info.circle")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
Text(note)
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
}
if let organized = organizedAppended,
rawTranscript != nil,
content.range(of: organized) != nil {
Button(action: revertToRawTranscript) {
HStack(spacing: 4) {
Image(systemName: "arrow.uturn.backward")
.font(.tjScaled(10, weight: .semibold))
Text("改用原话")
.font(.tjScaled(11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
```
- [x] **Step 4: organizing 期间禁用「AI 追问」+ 关 sheet 清理 + 权限 alert**
`DiaryQuickSheet.swift:48`:
```swift
private var canRequestSuggest: Bool { hasContent && !isLoading }
```
改为:
```swift
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
```
`DiaryQuickSheet.swift:146`:
```swift
.onDisappear { suggestTask?.cancel() }
```
改为:
```swift
.onDisappear {
suggestTask?.cancel()
voiceFlowTask?.cancel()
recordingWatchdog?.cancel()
dictation.abort()
}
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
Button(String(appLoc: "前往设置")) {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button(String(appLoc: "取消"), role: .cancel) {}
} message: {
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
}
```
- [x] **Step 5: 加流程函数(`// MARK: - Actions` 区,`requestSuggestions` 之前)**
`DiaryQuickSheet.swift``sectionLabel` 函数后插入:
```swift
// MARK:
private func startVoice() {
contentFocused = false
voiceNote = nil
voiceFlowTask = Task { @MainActor in
guard await dictation.requestAuthorization() else {
voiceDeniedAlert = true
return
}
do {
liveTranscript = ""
recordingSeconds = 0
try dictation.start { partial in liveTranscript = partial }
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
// + 3 (,)
recordingWatchdog = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled, voicePhase == .recording else { return }
recordingSeconds += 1
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
stopVoiceAndOrganize()
return
}
}
}
} catch {
voiceNote = error.localizedDescription
voicePhase = .idle
}
}
}
private func stopVoiceAndOrganize() {
guard voicePhase == .recording else { return }
recordingWatchdog?.cancel()
voiceFlowTask = Task { @MainActor in
let transcript = (await dictation.stop())
.trimmingCharacters(in: .whitespacesAndNewlines)
liveTranscript = transcript
guard !transcript.isEmpty else {
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
voiceNote = String(appLoc: "没听清,再试一次")
return
}
rawTranscript = transcript
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
do {
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
guard !Task.isCancelled else { return }
appendToContent(result.text)
organizedAppended = result.text
lastRate = result.decodeRate
} catch is CancellationError {
// cancelOrganize 退,
} catch {
guard !Task.isCancelled else { return }
appendToContent(transcript) // 线 #5:退,
organizedAppended = nil
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
}
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
}
}
/// : LLM,(退)
private func cancelOrganize() {
guard voicePhase == .organizing else { return }
voiceFlowTask?.cancel()
if let raw = rawTranscript {
appendToContent(raw)
organizedAppended = nil
voiceNote = String(appLoc: "已取消整理,填入原话")
}
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
}
/// :稿稿(spec §2:LLM )
private func revertToRawTranscript() {
guard let raw = rawTranscript,
let organized = organizedAppended,
let range = content.range(of: organized, options: .backwards) else { return }
withAnimation(.snappy(duration: 0.18)) {
content = content.replacingCharacters(in: range, with: raw)
organizedAppended = nil
}
}
```
- [x] **Step 6: 编译验证(touch 强制重编拿全量警告)**
```bash
cd /Users/xuhuayong/apps/康康
touch 康康/Features/Diary/DiaryQuickSheet.swift
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
xcodebuild -project 康康.xcodeproj -scheme 康康 \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-configuration Debug build -derivedDataPath ./build/cli-dd \
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
```
Expected: `BUILD SUCCEEDED`,无新增 warning。
- [x] **Step 7: 跑全量单测(确认没碰坏别的)**
```bash
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5
```
Expected: `** TEST SUCCEEDED **`
- [x] **Step 8: Commit**
```bash
git add 康康/Features/Diary/DiaryQuickSheet.swift
git commit -m "feat(语音日记): DiaryQuickSheet 接入语音输入(录音→整理→回退原话)"
```
---
### Task 7: 验证与手测清单
**Files:** 无新增代码
- [x] **Step 1: 模拟器降级路径验证**
模拟器跑 App(或 Xcode Preview `DiaryQuickSheet`),打开「+ 新建 → 写日记」:
- `SpeechDictationService.isAvailable` 在模拟器多半为 false → 「说一段」按钮应**整体不显示**,其余功能照旧。
- 若模拟器恰好支持端侧识别(部分 macOS/Xcode 组合会),按钮出现也算通过——继续验证录音面板出现、无崩溃即可。
- [ ] **Step 2: 真机手测清单(连 iPhone 跑,逐项确认)**
1. 首次点「说一段」→ 依次弹语音识别 + 麦克风两个系统权限框,文案是 Task 1 写的端侧说明
2. 拒绝权限 → 再点按钮弹「前往设置」alert,能跳系统设置
3. 录音中:实时字幕逐字上屏、计时走动、说话时 waveform 动画
4. 点「说完了,整理成日记」→ 面板转「AI 整理中」(AIFlowBar 流动)→ 整理稿**追加**进输入框(已有手打内容不被覆盖)
5. 口述含数值(如"血压一百四九十")→ 整理稿数值未被改动(说 3 条不同口述各验一次)
6. 「改用原话」pill 出现;点击 → 整理稿被替换为原始转写稿;再手动编辑正文该段 → pill 消失
7. 飞行模式(模型已下载)→ 全流程照常,验证 100% 本地
8. 一个字不说就点停止 → 「没听清,再试一次」,回 idle 不卡死
9. 模型未下载(或长按删除模型后)→ 整理失败 → 原话直接入框 + 提示
10. 录音中直接下滑关 sheet → 无崩溃,再次打开正常
11. 「AI 整理中」点取消 → 原话入框 + 「已取消整理,填入原话」
- [ ] **Step 3: 把手测结果记进 commit(若有 fix,随 fix 一起提)**
```bash
git commit --allow-empty -m "test(语音日记): 真机手测清单通过(见 plan Task 7)"
```
---
## Self-Review 记录
- **Spec 覆盖**:权限(T1)、organize prompt + 自适应 + 数值红线(T2)、Service(T3)、端侧转写不落盘 + 3 分钟上限 + zh 兜底(T4)、面板 + 实时字幕(T5)、mic 入口 + 状态机 + 追加不覆盖 + 改用原话 + 全部错误回退 + organizing 禁用追问(T6)、手测含飞行模式/空转写/取消(T7)。spec 各节均有对应任务。
- **占位符**:无 TBD/TODO;所有代码步骤给了完整代码。
- **类型一致性**:`SpeechDictationService.isAvailable/requestAuthorization/start(onPartial:)/stop()/abort()` 在 T4 定义、T6 使用一致;`DiaryVoicePanel.Mode`/`maxRecordingSeconds` T5 定义、T6 使用一致;`organize(transcript:) -> (text:, decodeRate:)` T3 定义、T6 解构一致;`AssistError` 复用现有定义。

View File

@@ -0,0 +1,296 @@
# 「身体档案」输入框语音听写 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在「身体档案」(`HealthExportSheet`)底部聊天输入框加端侧语音听写:点 mic 开始、识别文字实时流进输入框、再点停止,不调 LLM、不自动发送。
**Architecture:** 复用 `SpeechDictationService`(@State 持有);新增 static 纯函数 `merge(prefix:partial:)` 处理"已有文字 + 听写文字"拼接(唯一可单测逻辑);`HealthExportSheet` 加 6 个 @State + mic 按钮 + 3 个流程函数。Spec:`docs/superpowers/specs/2026-06-10-voice-export-composer-design.md`
**Tech Stack:** SwiftUI、Speech(经 SpeechDictationService)、Swift Testing。
**工程约定:**`2026-06-10-voice-diary.md` 的「执行前必读」(同步组免改 pbxproj、CLI 用 `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` + `-derivedDataPath ./build/cli-dd`、全量并行测试不可信要 `-only-testing` 定点跑、commit 逐文件 add 不带 `Localizable.xcstrings`)。**当前环境注意**:xcode-select 已指向完整 Xcode 且许可证未接受——`git``DEVELOPER_DIR=/Library/Developer/CommandLineTools` 前缀绕过;`xcodebuild` 必须先让用户跑 `sudo xcodebuild -license accept`。直接在 `feat/mnn-sme2-runtime` 分支上做(上一功能合并后该分支即集成分支,不再另开分支避免并发会话分支错位)。
---
### Task 1: `merge(prefix:partial:)`(TDD)
**Files:**
- Test: `康康Tests/SpeechDictationMergeTests.swift`(新建)
- Modify: `康康/Services/SpeechDictationService.swift`(`isAvailable` 之后加 static 方法)
- [ ] **Step 1: 写失败测试**
新建 `康康Tests/SpeechDictationMergeTests.swift`:
```swift
import Testing
@testable import
struct SpeechDictationMergeTests {
@Test func emptyPrefixReturnsPartial() {
#expect(SpeechDictationService.merge(prefix: "", partial: "今天头晕") == "今天头晕")
}
@Test func plainPrefixJoinsWithSpace() {
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "新听写")
== "已有内容 新听写")
}
@Test func whitespaceTerminatedPrefixConcatsDirectly() {
#expect(SpeechDictationService.merge(prefix: "第一行\n", partial: "新听写")
== "第一行\n新听写")
}
@Test func emptyPartialKeepsPrefix() {
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "") == "已有内容")
}
}
```
- [ ] **Step 2: 跑测试确认编译失败**
```bash
cd /Users/xuhuayong/apps/康康
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:'康康Tests/SpeechDictationMergeTests' \
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|TEST (SUCCEEDED|FAILED)" | head -5
```
Expected: `error: type 'SpeechDictationService' has no member 'merge'`(TEST FAILED)。
- [ ] **Step 3: 实现 merge**
`康康/Services/SpeechDictationService.swift``static var isAvailable` 行之后加:
```swift
/// : prefix,partial
/// prefix partial;prefix / ;
static func merge(prefix: String, partial: String) -> String {
guard !partial.isEmpty else { return prefix }
guard !prefix.isEmpty else { return partial }
if let last = prefix.unicodeScalars.last,
CharacterSet.whitespacesAndNewlines.contains(last) {
return prefix + partial
}
return prefix + " " + partial
}
```
- [ ] **Step 4: 跑测试确认通过**
同 Step 2 命令。Expected: `** TEST SUCCEEDED **`,4 个用例通过。
- [ ] **Step 5: Commit**
```bash
cd /Users/xuhuayong/apps/康康
DEVELOPER_DIR=/Library/Developer/CommandLineTools git add 康康Tests/SpeechDictationMergeTests.swift 康康/Services/SpeechDictationService.swift
DEVELOPER_DIR=/Library/Developer/CommandLineTools git commit -m "feat(语音听写): SpeechDictationService.merge 前缀拼接(TDD)"
```
---
### Task 2: HealthExportSheet 接入
**Files:**
- Modify: `康康/Features/Archive/HealthExportSheet.swift`(状态区 :27-30、canAsk :38、canGenerateReport :49、快捷问答 chip :133、onDisappear :103、alert :104、composer :410)
- [ ] **Step 1: 加听写状态(「快捷问答」状态块之后、`init` 之前)**
`@State private var newPromptText = ""` 之后插入:
```swift
// (spec 2026-06-10-voice-export-composer)
// dictation @State:struct View let ()
@State private var dictation = SpeechDictationService()
@State private var isDictating = false
/// ,partial
@State private var dictationPrefix = ""
@State private var dictationTask: Task<Void, Never>?
@State private var dictationWatchdog: Task<Void, Never>?
@State private var dictationDeniedAlert = false
/// ,()
private static let dictationMaxSeconds = 180
```
- [ ] **Step 2: 录音中禁发送/生成/chip**
`canAsk` 加条件:
```swift
private var canAsk: Bool {
!isAnswering &&
!isGeneratingReport &&
!isDictating &&
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
```
`canGenerateReport``!isGeneratingReport &&` 后加 `!isDictating &&`
快捷问答 chip 动作(`draftQuestion = p.prompt` 处)改为:
```swift
guard !isDictating else { return }
draftQuestion = p.prompt
```
- [ ] **Step 3: composer 加 mic 按钮 + TextField 录音中禁用**
TextField 的 `.disabled(isAnswering || isGeneratingReport)` 改为 `.disabled(isAnswering || isGeneratingReport || isDictating)`
TextField 与发送 Button 之间插入:
```swift
if SpeechDictationService.isAvailable {
Button { toggleDictation() } label: {
Image(systemName: isDictating ? "stop.fill" : "mic.fill")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(isDictating ? Tj.Palette.paper : Tj.Palette.brick)
.frame(width: 40, height: 40)
.background(Circle().fill(isDictating ? Tj.Palette.brick : Tj.Palette.brickSoft))
.symbolEffect(.pulse, options: .repeating, isActive: isDictating)
}
.disabled(isAnswering || isGeneratingReport)
.accessibilityLabel(isDictating ? String(appLoc: "停止听写") : String(appLoc: "语音输入"))
}
```
- [ ] **Step 4: 生命周期 + 权限 alert**
`.onDisappear { task?.cancel() }` 改为:
```swift
.onDisappear {
task?.cancel()
dictationTask?.cancel()
dictationWatchdog?.cancel()
dictation.abort()
}
```
现有「添加快捷问答」alert 的 `}` 闭合之后追加:
```swift
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $dictationDeniedAlert) {
Button(String(appLoc: "前往设置")) {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button(String(appLoc: "取消"), role: .cancel) {}
} message: {
Text("语音输入全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
}
```
- [ ] **Step 5: 流程函数(`// MARK: - Actions` 之后、`sendQuestion` 之前)**
```swift
// MARK:
private func toggleDictation() {
if isDictating { stopDictation() } else { startDictation() }
}
private func startDictation() {
questionFocused = false
dictationTask = Task { @MainActor in
guard await dictation.requestAuthorization() else {
dictationDeniedAlert = true
return
}
do {
dictationPrefix = draftQuestion
try dictation.start { partial in
draftQuestion = SpeechDictationService.merge(prefix: dictationPrefix,
partial: partial)
}
withAnimation(.snappy(duration: 0.2)) { isDictating = true }
dictationWatchdog = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Self.dictationMaxSeconds) * 1_000_000_000)
guard !Task.isCancelled, isDictating else { return }
stopDictation()
}
} catch {
isDictating = false
}
}
}
private func stopDictation() {
guard isDictating else { return }
dictationWatchdog?.cancel()
dictationTask = Task { @MainActor in
let final = (await dictation.stop()).trimmingCharacters(in: .whitespacesAndNewlines)
if !final.isEmpty {
draftQuestion = SpeechDictationService.merge(prefix: dictationPrefix,
partial: final)
}
// final :partial ,(spec:)
withAnimation(.snappy(duration: 0.2)) { isDictating = false }
}
}
```
- [ ] **Step 6: touch 强制重编验证**
```bash
cd /Users/xuhuayong/apps/康康
touch 康康/Features/Archive/HealthExportSheet.swift
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
xcodebuild -project 康康.xcodeproj -scheme 康康 \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-configuration Debug build -derivedDataPath ./build/cli-dd \
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
```
Expected: `BUILD SUCCEEDED`,无新增 warning。
- [ ] **Step 7: 定点回归(语音相关全部测试)**
```bash
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:'康康Tests/SpeechDictationMergeTests' \
-only-testing:'康康Tests/SpeechDictationAvailabilityTests' \
-only-testing:'康康Tests/DiaryOrganizePromptTests' \
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "Test case.*(passed|failed)|TEST (SUCCEEDED|FAILED)"
```
Expected: `** TEST SUCCEEDED **`,7 用例通过。
- [ ] **Step 8: Commit**
```bash
cd /Users/xuhuayong/apps/康康
DEVELOPER_DIR=/Library/Developer/CommandLineTools git add 康康/Features/Archive/HealthExportSheet.swift
DEVELOPER_DIR=/Library/Developer/CommandLineTools git commit -m "feat(语音听写): 身体档案输入框听写实时上屏"
```
---
### Task 3: 真机手测清单
- [ ] **Step 1: 真机逐项确认**
1. 「身体档案」composer 出现 mic 按钮(模拟器不支持端侧识别时隐藏)
2. 点 mic → 说话 → 字实时出现在输入框;输入框已有文字时保留并以空格衔接
3. 录音中:输入框/发送/「生成整理报告」/快捷问答 chip 均不可用;mic 为红色停止态
4. 再点 mic → 停止,文字落定,点发送正常走问答
5. 权限拒绝 → alert 跳设置
6. 录音中直接关 sheet → 无崩溃、麦克风指示灯熄灭
7. 3 分钟自动停止
---
## Self-Review 记录
- **Spec 覆盖**:merge 纯函数+单测(T1)、@State 持有/实时上屏/停止落定/空结果保持现状(T2 S5)、mic 隐藏与禁用矩阵(T2 S2-S3)、权限 alert + onDisappear abort + 看门狗(T2 S4-S5)、真机清单(T3)。无缺口。
- **占位符**:无;所有代码步骤给全。
- **类型一致性**:`merge(prefix:partial:)` T1 定义、T2 S5 调用一致;`dictationMaxSeconds`/`isDictating`/`dictationPrefix` 命名前后一致;`SpeechDictationService.isAvailable/requestAuthorization/start/stop/abort` 与现有实现签名一致。

View File

@@ -0,0 +1,121 @@
# 语音健康日记(语音转文字 + AI 整理)设计
> 2026-06-10 · 在「健康记录」(`DiaryQuickSheet`)里加语音输入:iOS 端侧语音识别实时转写,停止后由本地 LLM 整理成健康日记草稿,可编辑后保存。
## 背景
「健康记录」目前只能手打文字(`DiaryQuickSheet``DiaryEntry`),已有「AI 医生角度多轮追问」辅助。口述比打字门槛低得多,尤其适合身体不适时记录。
现有两个本地模型(Qwen3.5-2B 文本、Qwen3-VL 视觉)都没有音频编码器,无法做 ASR;引入 Whisper 类模型要 +0.5~1.5GB 体积和一条新推理链路,不可接受。`SFSpeechRecognizer` 支持强制端侧识别(`requiresOnDeviceRecognition = true`),中文质量够用、零体积,与「100% 本地」卖点完全一致。
## 决策(已与用户确认)
| 维度 | 决定 |
|---|---|
| 交互形态 | 说完 → 自动调 LLM 整理成日记草稿(非纯听写) |
| 整理样式 | 自适应:口述短 → 一段通顺的话;口述长且多方面 → 自动分点 |
| 入口 | `DiaryQuickSheet` 输入框旁麦克风按钮(不动 RecordSheet 骨架) |
| 转写链路 | 流式实时转写(AVAudioEngine buffer → 实时字幕),不落盘音频 |
| ASR 引擎 | `SFSpeechRecognizer` 端侧;不引入 Whisper;不做云端回退 |
## 架构
```
DiaryQuickSheet(mic 按钮 + 录音面板)
├─► SpeechDictationService(新)── AVAudioEngine + SFSpeechRecognizer(端侧)
└─► DiaryAssistService.organize(transcript:)(新方法)──► AIRuntime ──► MNN/MLX
```
符合模块边界:UI 不直接碰 AIRuntime;语音采集是系统能力,封装成独立 Service。
## 组件
### 1. `SpeechDictationService`(新,`Services/`,`@MainActor`)
封装 AVAudioEngine 麦克风采集 + `SFSpeechAudioBufferRecognitionRequest` 流式识别。
接口:
- `static var isAvailable: Bool` — 本机是否支持**端侧**中文识别(`supportsOnDeviceRecognition` + locale 检查;模拟器/老机型为 false)
- `func requestAuthorization() async -> Bool` — 麦克风 + 语音识别两个权限一起申请
- `func start(onPartial: @escaping (String) -> Void) throws` — 开始录音,partial 结果实时回调(录音面板字幕)
- `func stop() async -> String` — 停止并返回最终转写稿
实现要点:
- `requiresOnDeviceRecognition = true`(硬性,识别内容不出设备)
- `addsPunctuation = true`(自动标点)
- locale 跟随系统,不支持端侧时 `isAvailable = false`
- **不写任何音频文件**,buffer 即用即弃
- 录音上限 3 分钟,到点自动 stop
### 2. `DiaryAssistService.organize(transcript:)`(新方法)
```swift
func organize(transcript: String) async throws -> (text: String, decodeRate: Double)
```
- prompt 加在 `AI/Prompts/DiaryAssistPrompts.swift`:`organizePrompt(transcript:)`
- few-shot 两例:短口述 → 一段第一人称通顺文本;长口述(症状/用药/饮食多方面)→ 分点
- **硬性约束写进 prompt:只重组语言,不得增删改任何数值、单位、药名、时间**(健康数据,2B 模型改数即事故)
- 转写稿超长先截断(保护 context),非流式,await 完整结果
- 走 AIRuntime actor 队列,与「多轮追问」「拍照识别」自然串行
### 3. `DiaryQuickSheet` UI 改动
- 内容输入框 trailing 加 mic 按钮(`isAvailable == false` 时整个隐藏)
- 录音态:输入框下方展开录音面板 —— 实时字幕区 + 脉冲动画(sparkles/waveform `symbolEffect`)+「停止」按钮
- 整理态:面板转「AI 整理中」(复用 `AIFlowBar` + tok/s),可取消
- 完成:整理稿**追加**进输入框(沿用 `appendToContent`,不覆盖已写内容);面板收起
- 完成后显示一次性「改用原话」pill:点击把刚追加的整理稿替换为原始转写稿(原始稿在本次 sheet 生命周期内持有;再次录音或手动编辑该段后 pill 消失)
- 整理稿入框后,既有「AI 多轮追问」功能照常可用,无需特殊处理
## 状态机
```
idle ──(点 mic,权限 OK)──► recording ──(停止/3min 到点)──► organizing ──► done(回 idle)
```
- 实时字幕只显示在录音面板,**停止前不进输入框**
- `organizing` 期间 mic 按钮与「AI 追问」按钮禁用(AIRuntime 串行,避免排队困惑)
## 错误处理(红线 #5:全部有回退,不卡死)
| 故障 | 行为 |
|---|---|
| 权限被拒 | 弹说明 alert + 「前往设置」跳系统设置 |
| 本机不支持端侧识别(含模拟器) | mic 按钮隐藏,静默降级为纯手打 |
| 识别中途出错 | 已拿到的 partial 文本照常进 organizing |
| 转写结果为空 | 提示「没听清,再试一次」,回 idle |
| LLM 未就绪 / 整理失败 | **原始转写稿直接追加进输入框** + 提示「AI 整理失败,已填入原话」 |
不做云端识别回退(红线 #1:不引入云服务)。
## 权限(project.pbxproj 新增两条 INFOPLIST_KEY)
- `NSMicrophoneUsageDescription`:康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。
- `NSSpeechRecognitionUsageDescription`:语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。
## 测试
- `organize` prompt:`DebugAIRunner` 加自检入口(短/长两条样例口述,肉眼验自适应样式 + 数值不被改动)
- 录音链路:真机手测清单(权限首次申请、录音字幕、3 分钟自动停、整理失败回退、「改用原话」)
- 模拟器:验证 `isAvailable == false` 时 mic 按钮隐藏
## 范围边界(不做)
- 症状 / AI 问答的语音入口
- 音频文件保存或回放
- Whisper / 任何新模型
- Live Activity 集成(前台短流程,无必要)
- 多语言听写优化(locale 跟系统,不支持即降级)
## 卖点映射(§12)
1. 降低记录门槛 → 卖点 1(影像档案之外的日常记录闭环)
2. 「系统端侧 ASR + 本地 LLM 整理」全链路不出设备 → 卖点 2(100% 本地)
3. 日记语料变多 → 卖点 3(本地 RAG 长期记忆)
## 排期
清单外新功能(红线 #6),本设计即立项讨论结论。工作量约 1~1.5 天,独立小分支插队,不挤占 C1/VL 主线。

View File

@@ -0,0 +1,47 @@
# 「身体档案」输入框语音输入 设计
> 2026-06-10 · 在「身体档案」(`HealthExportSheet`)底部聊天输入框加端侧语音听写,复用 `SpeechDictationService`,识别文字实时流进输入框。
## 背景
「身体档案」composer 是聊天式输入(提问/诉求 → 发送 → LLM 对话/生成报告)。与日记不同,这里输入的内容马上交给 LLM,**不需要"整理"加工**;口述原话直接进输入框即正确行为(类似系统键盘听写)。
## 决策(已与用户确认)
| 维度 | 决定 |
|---|---|
| 交互 | 听写直接流进输入框:点 mic 开始,实时上屏;再点停止;用户自查后手动发送 |
| LLM | 不调用(无整理步骤、不自动发送) |
| 复用 | `SpeechDictationService`(**@State 持有**,防视图重建丢实例)、权限 alert 文案、3 分钟看门狗、onDisappear abort |
| UI | mic 按钮放 TextField 与发送键之间;`isAvailable == false` 隐藏;录音中变红色停止态(脉冲动画) |
## 组件
### 1. `SpeechDictationService.merge(prefix:partial:)`(新,static 纯函数)
听写文本拼接规则,唯一可单测的逻辑:
- `prefix` 为空 → 返回 `partial`
- `prefix` 以空白/换行结尾 → `prefix + partial`
- 其余 → `prefix + " " + partial`
### 2. `HealthExportSheet` 改动
- `@State dictation` + `isDictating` + `dictationPrefix` + 看门狗 Task
- 点 mic:申请权限(拒绝 → alert 跳设置,与日记同文案)→ 记录 `dictationPrefix = draftQuestion` → start,每个 partial:`draftQuestion = merge(prefix:partial:)`
- 再点:`stop()`,最终稿同 merge 落定;**stop 返回空时保留输入框现状**(partial 已实时在框里,天然兜底,不提示「没听清」)
- 3 分钟看门狗自动停(防麦克风悬挂)
## 冲突防护
- 录音中:TextField 与发送按钮、「生成整理报告」按钮禁用(防手输与 partial 互相覆盖、防录音中发送)
- `isAnswering / isGeneratingReport` 时 mic 禁用
- `onDisappear` abort
## 测试
- `merge(prefix:partial:)` 3 个单测(空前缀 / 空白结尾前缀 / 普通前缀)
- 真机手测:听写上屏、停止落定、已有文字保留、权限拒绝、3 分钟自动停
## 不做(YAGNI)
快捷问答弹窗 / 个人资料 Form 等其他输入处的语音;自动发送;录音面板;LLM 整理。

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>";
}; };
@@ -183,7 +187,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600; LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600; LastUpgradeCheck = 2650;
TargetAttributes = { TargetAttributes = {
5E463CF82FC403BB0089145B = { 5E463CF82FC403BB0089145B = {
CreatedOnToolsVersion = 26.0.1; CreatedOnToolsVersion = 26.0.1;
@@ -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 */;
@@ -292,6 +296,7 @@
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -321,6 +326,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -344,6 +350,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -354,6 +361,7 @@
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -383,6 +391,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
@@ -399,6 +408,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
}; };
name = Release; name = Release;
@@ -411,11 +421,13 @@
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
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;
@@ -423,8 +435,10 @@
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。"; INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。"; INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。"; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -450,6 +464,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";
@@ -465,11 +480,13 @@
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
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;
@@ -477,8 +494,10 @@
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。"; INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。"; INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。"; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -504,6 +523,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";
@@ -517,6 +537,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -544,6 +565,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -570,6 +592,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -596,6 +619,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -659,12 +683,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 +696,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

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2600" LastUpgradeVersion = "2650"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@@ -15,6 +15,13 @@ enum AIRuntimeError: Error, LocalizedError {
} }
} }
/// interactive = (//);
/// background = (),
nonisolated enum InferencePriority: Sendable, Equatable {
case interactive
case background
}
actor AIRuntime { actor AIRuntime {
static let shared = AIRuntime() static let shared = AIRuntime()
@@ -29,9 +36,34 @@ actor AIRuntime {
private(set) var vlStatus: Status = .notReady private(set) var vlStatus: Status = .notReady
private(set) var lastDecodeRate: Double = 0 private(set) var lastDecodeRate: Double = 0
/// (;)
private(set) var lastGenerateStats: GenerateStats?
/// ( / PPT )
var activeBackendLabel: String {
if InferenceEngine.current == .mnn, mnnStatus == .ready {
return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · NEON"
}
#if targetEnvironment(simulator)
return "MLX · CPU(模拟器)"
#else
return "MLX · GPU"
#endif
}
private var llmSession: LLMSession? private var 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;
@@ -42,30 +74,56 @@ actor AIRuntime {
// actor (count = 1):( + ) // actor (count = 1):( + )
// await acquireGate(), releaseGate()actor // await acquireGate(), releaseGate()actor
// gateBusy / gateWaiters // gateBusy / gateWaiters
private struct GateWaiter {
let priority: InferencePriority
let cont: CheckedContinuation<Void, Never>
}
private var gateBusy = false private var gateBusy = false
private var gateWaiters: [CheckedContinuation<Void, Never>] = [] private var gateHolderPriority: InferencePriority = .interactive
private var preemptRequested = false
private var gateWaiters: [GateWaiter] = []
private func acquireGate() async { /// interactive background ; FIFO,
nonisolated static func gateInsertionIndex(of priority: InferencePriority,
in waiting: [InferencePriority]) -> Int {
guard priority == .interactive else { return waiting.count }
return waiting.firstIndex(of: .background) ?? waiting.count
}
private func acquireGate(_ priority: InferencePriority = .interactive) async {
if !gateBusy { if !gateBusy {
gateBusy = true gateBusy = true
gateHolderPriority = priority
return return
} }
// : token CancellationError
if priority == .interactive, gateHolderPriority == .background {
preemptRequested = true
}
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
gateWaiters.append(cont) let idx = Self.gateInsertionIndex(of: priority, in: gateWaiters.map(\.priority))
gateWaiters.insert(GateWaiter(priority: priority, cont: cont), at: idx)
} }
// releaseGate (gateBusy true) // releaseGate (gateBusy true)
} }
private func releaseGate() { private func releaseGate() {
preemptRequested = false
if gateWaiters.isEmpty { if gateWaiters.isEmpty {
gateBusy = false gateBusy = false
} else { } else {
// ,gateBusy true, // ,gateBusy true,
let next = gateWaiters.removeFirst() let next = gateWaiters.removeFirst()
next.resume() gateHolderPriority = next.priority
next.cont.resume()
} }
} }
/// token :
private func shouldPreempt(_ priority: InferencePriority) -> Bool {
priority == .background && preemptRequested
}
private init() {} private init() {}
/// App : MLX GPU , reuse cache /// App : MLX GPU , reuse cache
@@ -74,12 +132,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 +187,54 @@ actor AIRuntime {
} }
} }
/// MNN : MLX LLM/VL
private func prepareMNN() async throws {
while mnnStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if mnnStatus == .ready { return }
let folder = Self.mnnModelFolder
guard ModelStore.shared.isComplete(for: .mnnLLM) else {
mnnStatus = .error("MNN 模型未就绪")
throw AIRuntimeError.notReady
}
await acquireGate()
defer { releaseGate() }
if mnnStatus == .ready { return }
// : MLX LLM/VL, MNN
unloadLLM()
unloadVL()
mnnStatus = .loading
do {
try await mnn.load(folderURL: folder)
mnnStatus = .ready
} catch {
mnnStatus = .error("\(error)")
throw AIRuntimeError.modelLoadFailed("\(error)")
}
}
/// MNN,
private func unloadMNN() async {
guard mnnStatus != .notReady else { return }
await mnn.unload()
mnnStatus = .notReady
MLX.Memory.clearCache()
}
/// await prepare() /// await prepare()
/// :, actor LLMSession await /// :, actor LLMSession await
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> { /// priority = .background token (CancellationError )
func generate(prompt: String,
maxTokens: Int = 256,
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
if InferenceEngine.current == .mnn, mnnStatus == .ready {
return mnnGenerate(prompt: prompt, maxTokens: maxTokens, priority: priority)
}
// actor ,Task 访 self.status / self.llmSession // actor ,Task 访 self.status / self.llmSession
let snapshotStatus = status let snapshotStatus = status
let snapshotSession = llmSession let snapshotSession = llmSession
@@ -133,7 +246,7 @@ actor AIRuntime {
return return
} }
// : LLM VL / , // : LLM VL / ,
await self.acquireGate() await self.acquireGate(priority)
do { do {
// session.generate actor , await // session.generate actor , await
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens) let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
@@ -141,12 +254,18 @@ actor AIRuntime {
// (UI)/, checkCancellation Task 退, // (UI)/, checkCancellation Task 退,
// session onTermination, MLX , GPU // session onTermination, MLX , GPU
try Task.checkCancellation() try Task.checkCancellation()
// :, token 退
if self.shouldPreempt(priority) { throw CancellationError() }
// Task generate() , AIRuntime actor ; // Task generate() , AIRuntime actor ;
// actor recordRate await // actor recordRate await
self.recordRate(chunk.decodeRate) self.recordRate(chunk.decodeRate)
continuation.yield(chunk) continuation.yield(chunk)
} }
self.lastGenerateStats = await session.lastStats
continuation.finish() continuation.finish()
} catch is CancellationError {
// / CancellationError ,
continuation.finish(throwing: CancellationError())
} catch { } catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)")) continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
} }
@@ -159,6 +278,41 @@ actor AIRuntime {
} }
} }
/// MNN(CPU/SME2) MLX :
private func mnnGenerate(prompt: String,
maxTokens: Int,
priority: InferencePriority) -> AsyncThrowingStream<TokenChunk, Error> {
let ready = (mnnStatus == .ready)
return AsyncThrowingStream { continuation in
let task = Task {
guard ready else {
continuation.finish(throwing: AIRuntimeError.notReady)
return
}
await self.acquireGate(priority)
do {
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
for try await chunk in stream {
try Task.checkCancellation()
// :, token 退
//( MNNBackend.onTermination bridge.cancel())
if self.shouldPreempt(priority) { throw CancellationError() }
self.recordRate(chunk.decodeRate)
continuation.yield(chunk)
}
self.lastGenerateStats = await self.mnn.lastStats
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: CancellationError())
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
self.releaseGate()
}
continuation.onTermination = { _ in task.cancel() }
}
}
private func recordRate(_ rate: Double) { private func recordRate(_ rate: Double) {
if rate > 0 { lastDecodeRate = rate } if rate > 0 { lastDecodeRate = rate }
} }
@@ -167,30 +321,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 +369,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 +377,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 +386,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,19 @@
import Foundation
/// ,(MNN / MLX)
/// MNN LlmContext(prefill_us / decode_us);MLX GenerateCompletionInfo
struct GenerateStats: Sendable, Equatable {
var promptTokens: Int
var genTokens: Int
/// prefill( prompt),
var prefillSeconds: Double
/// decode( token ),
var decodeSeconds: Double
var prefillTokensPerSecond: Double {
prefillSeconds > 0 ? Double(promptTokens) / prefillSeconds : 0
}
var decodeTokensPerSecond: Double {
decodeSeconds > 0 ? Double(genTokens) / decodeSeconds : 0
}
}

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

@@ -8,6 +8,11 @@ import MLXLMCommon
actor LLMSession { actor LLMSession {
let container: ModelContainer let container: ModelContainer
/// ( .info ,)
private(set) var lastStats: GenerateStats?
private func record(_ s: GenerateStats) { lastStats = s }
init(container: ModelContainer) { init(container: ModelContainer) {
self.container = container self.container = container
} }
@@ -45,10 +50,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
@@ -72,9 +83,14 @@ actor LLMSession {
let rate = elapsed > 0 ? Double(produced) / elapsed : 0 let rate = elapsed > 0 ? Double(produced) / elapsed : 0
continuation.yield(TokenChunk(text: text, decodeRate: rate)) continuation.yield(TokenChunk(text: text, decodeRate: rate))
case .info: case .info(let info):
// , // ,
break await self.record(GenerateStats(
promptTokens: info.promptTokenCount,
genTokens: info.generationTokenCount,
prefillSeconds: info.promptTime,
decodeSeconds: info.generateTime
))
case .toolCall: case .toolCall:
// ,switch // ,switch

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,216 @@
//
// 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) {
// 红线:本 App 每次 generate/analyze 都是一次性独立推理(无多轮对话语义)。
// MNN 的 Llm::response 默认把本轮 prompt+输出累积进 history_tokens / KV cache,
// 不 reset 的话第二次导出会把上一次的完整上下文叠加进来 → all_seq_len 暴涨、
// 冲过上下文上限 → 崩溃(用户报「再次导出死机」)。每轮先 reset 清空历史,
// 与 MLX LLMSession 的「每次 generate 无状态」保持一致。
_llm->reset();
_llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens);
}
buf.flush();
return [self statsFromContext];
}
- (MNNGenerateStats *)generateText:(NSString *)prompt
maxTokens:(int)maxTokens
onToken:(void (^)(NSString *))onToken {
return [self runResponse:prompt maxTokens:maxTokens onToken:onToken];
}
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
prompt:(NSString *)prompt
maxTokens:(int)maxTokens
onToken:(void (^)(NSString *))onToken
error:(NSError **)error {
// 在 prompt 前拼 <img>本地路径</img>;Omni 解析标签并对路径 imread(需 OMNI 框架)。
NSMutableString *full = [NSMutableString string];
for (NSString *p in imagePaths) {
[full appendFormat:@"<img>%@</img>", p];
}
[full appendString:prompt];
return [self runResponse:full maxTokens:maxTokens onToken:onToken];
}
- (MNNGenerateStats *)statsFromContext {
MNNGenerateStats *s = [MNNGenerateStats new];
if (_llm) {
const MNN::Transformer::LlmContext *ctx = _llm->getContext();
if (ctx) {
s.promptTokens = ctx->prompt_len;
s.genTokens = ctx->gen_seq_len;
s.prefillMs = ctx->prefill_us / 1000.0;
s.decodeMs = ctx->decode_us / 1000.0;
}
}
return s;
}
@end
#endif

113
康康/AI/MNNBackend.swift Normal file
View File

@@ -0,0 +1,113 @@
import Foundation
/// MNN(CPU / SME2), `MNNLLMBridge`
/// `LLMSession`/`VLSession` actor ; `AIRuntime`
///
/// () Qwen3.5-2B MNN :`generate` ,
/// `analyze` <img> Omni imread ( OMNI ,xcframework )
/// ,; MNN,VL 退 MLX( `AIRuntime`)
actor MNNBackend {
private var bridge: MNNLLMBridge?
/// ( AIRuntime ,)
private(set) var lastStats: GenerateStats?
private func record(_ s: GenerateStats) { lastStats = s }
var isLoaded: Bool { bridge?.isLoaded ?? false }
/// MNN ( MNN llm config.json + llm.mnn + + tokenizer)
func load(folderURL: URL) throws {
let configPath = folderURL.appendingPathComponent("config.json").path
guard FileManager.default.fileExists(atPath: configPath) else {
throw AIRuntimeError.notReady
}
guard let b = MNNLLMBridge(configPath: configPath) else {
throw AIRuntimeError.modelLoadFailed("MNN createLLM/load 失败")
}
bridge = b
}
func unload() { bridge = nil }
/// `bridge.generateText` , detached 线,
/// yield `TokenChunk`( tok/s) `bridge.cancel()`
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
guard let bridge else {
return AsyncThrowingStream { $0.finish(throwing: AIRuntimeError.notReady) }
}
let box = MNNUncheckedBox(bridge)
return AsyncThrowingStream { continuation in
let meter = MNNRateMeter()
let task = Task.detached(priority: .userInitiated) {
let stats = box.value.generateText(prompt, maxTokens: Int32(maxTokens)) { piece in
let rate = meter.tick()
continuation.yield(TokenChunk(text: piece, decodeRate: rate))
}
// ObjC Sendable GenerateStats actor
await self.record(GenerateStats(
promptTokens: Int(stats.promptTokens),
genTokens: Int(stats.genTokens),
prefillSeconds: stats.prefillMs / 1000.0,
decodeSeconds: stats.decodeMs / 1000.0
))
continuation.finish()
}
continuation.onTermination = { _ in
box.value.cancel()
task.cancel()
}
}
}
/// (VL)(JSON ) <img> ,
/// MNN Omni imread ( OMNI );blocking detached 线
func analyze(imageURLs: [URL], prompt: String, maxTokens: Int) async throws -> String {
guard let bridge else { throw AIRuntimeError.notReady }
let paths = imageURLs.map(\.path)
let box = MNNUncheckedBox(bridge)
return try await withCheckedThrowingContinuation { cont in
Task.detached(priority: .userInitiated) {
let sink = MNNTextSink()
do {
let stats = try box.value.analyzeImages(paths, prompt: prompt, maxTokens: Int32(maxTokens)) { piece in
sink.append(piece)
}
await self.record(GenerateStats(
promptTokens: Int(stats.promptTokens),
genTokens: Int(stats.genTokens),
prefillSeconds: stats.prefillMs / 1000.0,
decodeSeconds: stats.decodeMs / 1000.0
))
cont.resume(returning: sink.text)
} catch {
cont.resume(throwing: AIRuntimeError.inferenceFailed(error.localizedDescription))
}
}
}
}
}
/// 线,
private nonisolated final class MNNTextSink: @unchecked Sendable {
private(set) var text = ""
func append(_ s: String) { text += s }
}
/// Sendable ObjC detached
/// `AIRuntime` :,访
private nonisolated struct MNNUncheckedBox<T>: @unchecked Sendable {
let value: T
init(_ value: T) { self.value = value }
}
/// :线,
private nonisolated final class MNNRateMeter: @unchecked Sendable {
private let start = Date()
private var produced = 0
func tick() -> Double {
produced += 1
let elapsed = Date().timeIntervalSince(start)
return elapsed > 0 ? Double(produced) / elapsed : 0
}
}

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. 起病诱因 —— 何时开始、有无诱因
@@ -76,4 +76,44 @@ enum DiaryAssistPrompts {
Output: /no_think Output: /no_think
""" """
} }
// MARK: -
/// 稿()2B context :
static let organizeTranscriptLimit = 1200
/// 稿稿: ;
/// :
/// 线(spec 2026-06-10-voice-diary §2):,
/// 2B 140/90 130/90 , few-shot
static func organize(transcript: String) -> String {
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
return """
你是健康记录助手。下面是用户口述身体状态的语音转写原话,可能口语化、有重复、缺标点。
请把它整理成一条清晰的健康日记。
硬性规则:
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
- 不诊断、不给用药建议、不写「建议就医」。
- 只输出整理后的日记正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
示例 1(口述:那个今天早上起来有点头晕然后我量了下血压140 90比平时高一点没吃早饭就出门了):
今天早上起来有点头晕,量了血压 140/90,比平时高一点。没吃早饭就出门了。
示例 2(口述:今天头晕了一上午下午好点了血压早上量的140 90嗯缬沙坦吃了降脂药忘了吃早饭没吃中午吃的清淡晚上散步了半小时):
症状:头晕了一上午,下午好转。
血压:早上 140/90。
用药:缬沙坦已服,降脂药忘服。
饮食:早饭未吃,午餐清淡。
运动:晚上散步半小时。
【口述原话】:
\(trimmed)
Output: /no_think
"""
}
} }

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> 标签:
@@ -156,6 +156,7 @@ enum HealthExportPrompts {
铁律: 铁律:
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。 - 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
- 禁止编造数字、日期、症状、药物、检查结果、诊断。 - 禁止编造数字、日期、症状、药物、检查结果、诊断。
- 日期一律照搬【本地健康记录】JSON 里的完整 `date` 字段(格式 yyyy-MM-dd,即年-月-日);严禁只写年份或省略月、日。多轮对话里若把日期说得不全,一律以 JSON 的完整日期为准。
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。 - 禁止给诊断意见、用药建议、剂量建议或急诊判断。
- JSON 里没有的信息,对应小节写「无记录」。 - JSON 里没有的信息,对应小节写「无记录」。
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。 - 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
@@ -163,13 +164,14 @@ enum HealthExportPrompts {
输出要求: 输出要求:
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。 - 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
- 中文,简洁,医生 30 秒能扫完。 - 中文,简洁,医生 30 秒能扫完。
- 「相关健康日记」每条单独一行,格式为「2026-05-01:正文摘要」,日期照抄 JSON 的 date 字段,精确到日。
- 严格按以下段落: - 严格按以下段落:
# 就诊摘要 # 就诊摘要
## 本次想解决的问题 ## 本次想解决的问题
## 相关健康日记 ## 相关健康日记
## 相关指标 ## 相关指标
## 已知背景 ## 已知背景
## 患者关心的问题 ## 本人关心的问题
## 可带给医生确认的要点 ## 可带给医生确认的要点
【本地健康记录】: 【本地健康记录】:

View File

@@ -0,0 +1,44 @@
import Foundation
/// prompt: +
/// 线:;,(:)
nonisolated enum InsightPrompts {
/// (, Report.summary)
static func reportPlainSummary(title: String, typeLabel: String, indicatorLines: String) -> String {
"""
你是健康档案助手。下面是一份报告的指标列表,请用大白话给本人(称「你」)写 2~3 句整体解读:
- 第 1 句:总体情况(共几项、几项异常)。
- 之后:点名最值得留意的异常项,用生活化语言说明偏高/偏低意味着什么方向。
- 不诊断疾病、不推荐药物或剂量;异常较多时建议「带上报告咨询医生」。
- 只输出正文文字,不要标题、列表、JSON、markdown。
示例:
输入:血常规(化验单),指标:白细胞 5.2 (3.5-9.5) normal;血红蛋白 118 (130-175) low;血小板 210 (125-350) normal
输出:这份血常规共 3 项,2 项正常,血红蛋白略低于参考范围。血红蛋白偏低通常与贫血方向有关,平时可以多补充含铁食物;如果还伴随乏力头晕,建议带上报告咨询医生。
现在的报告:\(title)(\(typeLabel))
指标:
\(indicatorLines)
只输出 2~3 句正文。/no_think
"""
}
/// (TrendDetailView,)
static func trendInsight(title: String, unit: String, rangeText: String, dataLines: String) -> String {
"""
你是健康档案助手。下面是「\(title)」的历史记录(单位 \(unit)\(rangeText)),请用大白话给本人(称「你」)写 1~2 句趋势解读:
- 说清整体走向(上升/下降/平稳/波动)和当前值与参考范围的关系。
- 不诊断疾病、不推荐药物;持续异常时温和建议「复查或咨询医生」。
- 只输出正文文字,不要标题、列表、JSON。
示例:
输入:体重,单位 kg,记录:2026-04-01 72.5 / 2026-04-15 71.8 / 2026-05-01 71.2
输出:近一个月你的体重稳步下降了约 1.3kg,节奏平缓,继续保持现在的习惯就好。
现在的记录:
\(dataLines)
只输出 1~2 句正文。/no_think
"""
}
}

View File

@@ -0,0 +1,58 @@
import Foundation
/// + prompt: LLM(MNN/SME2 )
/// : JSON `{"intent":""}`;/ VoiceIntentService 退(§3.2)
nonisolated enum IntentPrompts {
static func classify(_ utterance: String) -> String {
classifyTemplate.replacingOccurrences(of: "{{TEXT}}", with: String(utterance.prefix(120)))
}
private static let classifyTemplate: String = #"""
你是健康 App 的语音意图分类器。用户长按「新建」按钮说了一句话,判断 ta 想打开哪个功能。
请只输出一段合法 JSON,格式 {"intent":"<>"},不要解释、不要 markdown 围栏、不要任何前后缀文字。
分类(只能选下面其中一个):
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
- "medication" 记一次用药/服药、吃了什么药、拍药盒(凡涉及「吃药/服药/用药」都归这里)
- "symptom" 记录身体症状,哪里不舒服(头疼、咳嗽、发烧、头晕…),与吃药无关
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
- "archive" 归档整份体检报告/化验单(拍报告存档)
- "export" 生成给医生看的身体档案/健康总结
- "reminder" 设置周期提醒
- "unknown" 无法判断
规则:
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
- 凡是「记录/记一次用药、服药、吃药、吃了药」→ "medication",哪怕没说具体药名。
- 「记录/记一次」+ 动作时,先看这个动作是什么(吃药→medication、量血压→indicator、
哪里疼→symptom),不要因为出现「记录」二字就归类成 symptom。
- 明确说出具体身体症状(头疼、咳嗽、发烧、头晕、拉肚子…)才算 "symptom";
与吃药/用药无关。只是泛泛说今天的状态、心情、饮食、睡眠、累不累、舒不舒服 → "diary"
- 既像日记又提到具体数值时,以数值为准 → "indicator"
- 含否定或「忘了/没顾上」的吃药(「没吃药」「忘了吃药」「不用吃药」)不是记录用药 → "diary"
- 只有明确要「拍下/存档这份报告或化验单」时才算 "archive";只是顺口提到体检或报告
(「下周去体检」「医生说报告没问题」)不要归 archive,按日记或提醒处理。
- 拿不准、又不明确属于其它类别时,默认 "diary"(日记是最常见、最自由的入口)。
尤其 "medication""archive" 会直接打开相机,把握不大时宁可归 "diary",不要误开相机。
示例:
",12885" → {"intent":"indicator"}
"," → {"intent":"symptom"}
"" → {"intent":"medication"}
"" → {"intent":"medication"}
"," → {"intent":"diary"}
"," → {"intent":"medication"}
"," → {"intent":"diary"}
"" → {"intent":"archive"}
"," → {"intent":"diary"}
"" → {"intent":"diary"}
"" → {"intent":"diary"}
"" → {"intent":"reminder"}
"" → {"intent":"export"}
现在判断下面这句话,只输出 JSON。/no_think
用户的话:{{TEXT}}
"""#
}

View File

@@ -0,0 +1,64 @@
import Foundation
/// prompt:Vision OCR //,
/// LLM(Qwen,MNN/SME2 ) + +
/// : JSON; UI 退(§3.2 退线)
/// :"",/(§1 )
nonisolated enum MedicationPrompts {
static func medicationsFromText(_ ocrText: String) -> String {
// 5 OCR, 2400( 1200 ,)
medicationsFromTextTemplate
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 2400))
}
private static let medicationsFromTextTemplate: String = #"""
你是药品包装识别助手。下面是对一种药品的多张照片(药盒正面/背面/说明书/处方单)做 OCR 得到的纯文本,各张之间用「---」分隔,可能有错字、换行混乱或无关噪声。
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
JSON schema(严格):
{
"medications": [
{
"name": string, // 药品名,见下方「name 怎么填」
"strength": string, // 规格,如 "80mg""0.5g×24";识别不出填 ""
"usage": string // 用法用量,如 ",";包装上没有就填 ""
}
]
}
name 怎么填(关键,别搞混):
- 药品名 = 通用名(化学/药典名),这是要填进 name 的主体。中文药名照中文写,英文药名(如 "Metformin""Amoxicillin")就照英文原样抄,不要翻译、不要丢。
- 若包装上同时印有商品名/商标名(厂商起的牌子名,如 """""Tylenol"),把它放在通用名后的括号里,例如 "()"。只读到商品名、读不到通用名时,就直接用商品名当 name。
- 生产厂家/公司名/品牌 LOGO 文字(如 "XX药业有限公司""""")不是药名,一律不要当 name,也不要塞进括号。
通用规则:
- 只提取药品本身;""批准文号、生产厂家、批号、有效期、条形码、贮藏、二维码一律忽略。
- 多张照片通常是同一种药的不同面,合并成一条,不要因为来自不同照片就重复输出;处方单可能有多种药,才分多条。
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
- 同一药品只输出一次。
示例 1(药盒,含商品名 + 厂商):
输入 OCR 文本: 代文 缬沙坦胶囊 80mg×7粒 国药准字H20103521 北京诺华制药有限公司
输出:
{"medications":[{"name":"()","strength":"80mg×7","usage":""}]}
示例 2(说明书含用法):
输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用
输出:
{"medications":[{"name":"","strength":"0.5g×30","usage":",1,2,"}]}
示例 3(英文药名,正反两张合并):
输入 OCR 文本: Amoxicillin Capsules 500mg GSK
---
Dosage: Take one capsule three times daily
输出:
{"medications":[{"name":"Amoxicillin","strength":"500mg","usage":"Take one capsule three times daily"}]}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:
{{OCR_TEXT}}
"""#
}

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 ):
/// ``` /// ```
@@ -31,12 +31,38 @@ enum VLPrompts {
/// VL "", few-shot , /// VL "", few-shot ,
/// prompt,退 /// prompt,退
static func reportExtraction(today: Date = .now) -> String { /// ocrText Vision OCR Vision
/// 2B ;
static func reportExtraction(today: Date = .now, ocrText: String = "") -> String {
let f = DateFormatter() let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX") f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd" f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today) let todayStr = f.string(from: today)
return reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr) let ocrSection: String
if ocrText.isEmpty {
ocrSection = ""
} else {
ocrSection = """
OCR 参考文本(系统对同一报告做文字识别的结果,可能有错字、串行或漏行;版面与表格结构以图片为准,但数值、小数点以 OCR 文字更可靠):
\(clipOCR(ocrText))
"""
}
return reportExtractionTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_SECTION}}", with: ocrSection)
}
/// OCR : prompt (2B )
static func clipOCR(_ text: String, limit: Int = 1800) -> String {
guard text.count > limit else { return text }
let clipped = String(text.prefix(limit))
if let lastNewline = clipped.lastIndex(of: "\n") {
return String(clipped[..<lastNewline]) + "\n(后续内容过长已截断)"
}
return clipped + "\n(后续内容过长已截断)"
} }
private static let reportExtractionTemplate: String = #""" private static let reportExtractionTemplate: String = #"""
@@ -84,13 +110,60 @@ JSON schema(严格):
输入: 一份春季体检,3 项可读 输入: 一份春季体检,3 项可读
输出: 输出:
{"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]} {"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]}
{{OCR_SECTION}}
现在请识别图片并输出 JSON: 现在请识别图片并输出 JSON:
"""# """#
// MARK: - () // MARK: - · meta(///,)
/// :/****() /// :,****( 2B OOM / )
/// Vision OCR , LLM meta (~50 token),
/// ,UI ( / ),
static func reportMetaFromText(_ ocrText: String, today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return reportMetaTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 1500))
}
private static let reportMetaTemplate: String = #"""
你是体检/化验报告归档助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声。
请只提取这份报告的「元信息」,**不要提取任何具体指标/数值**。只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string, // 报告抬头,如 "";读不出就填 ""
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
"report_date": "YYYY-MM-DD", // 报告/采样/体检日期;实在读不出就填 ""
"institution": string // 医院/体检机构名;读不出就填 ""
}
规则:
- 只输出上面 4 个字段,绝不输出 indicators / 数值 / 参考范围。
- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"
- 日期挑「报告日期 / 检查日期 / 采样日期」其一,统一成 YYYY-MM-DD;只有年月就补 -01;读不出填 ""
- institution 取医院/体检中心全称,去掉「检验科/报告单」等栏目词;读不出填 ""
- 不要编造;读不出的字段填 ""
示例 OCR 文本:
协和医院体检中心 健康体检报告 姓名:张三 体检日期:2026-04-12 低密度脂蛋白 3.84 ↑ ...
输出:
{"title":"","type":"checkup","report_date":"2026-04-12","institution":""}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:
{{OCR_TEXT}}
"""#
// MARK: - ()
/// :/****()
/// indicators ,// , Report /// indicators ,// , Report
static func regionExtraction(today: Date = .now) -> String { static func regionExtraction(today: Date = .now) -> String {
let f = DateFormatter() let f = DateFormatter()

View File

@@ -24,6 +24,7 @@ struct KangkangApp: App {
CustomMonitorMetric.self, CustomMonitorMetric.self,
HealthExport.self, HealthExport.self,
CustomReminder.self, CustomReminder.self,
Medication.self,
]) ])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
// store .completeUnlessOpen (§6), // store .completeUnlessOpen (§6),
@@ -102,6 +103,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,52 @@
import SwiftUI
/// Apple Intelligence 线:,
/// AppAI ( AI /)
///
/// :线 `Tj.Palette` AI (
/// Apple ),; UI §9 token
///
/// `TimelineView(.animation)` `.onAppear` + `repeatForever`:线
/// (tok/s 0.5s ), `repeatForever`
/// / TimelineView ,
/// ,
struct AIFlowBar: View {
var height: CGFloat = 3
/// (),
var cycle: Double = 0.6
private static 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), //
]
/// ( 11 stop,):,
/// ,
private static let gradient: Gradient = {
let colors = base + base + [base[0]]
let last = CGFloat(colors.count - 1)
return Gradient(stops: colors.enumerated().map { i, c in
Gradient.Stop(color: c, location: CGFloat(i) / last)
})
}()
var body: some View {
TimelineView(.animation) { timeline in
GeometryReader { geo in
let w = geo.size.width
let t = timeline.date.timeIntervalSinceReferenceDate
let progress = CGFloat(t.truncatingRemainder(dividingBy: cycle) / cycle)
Capsule()
.fill(LinearGradient(gradient: Self.gradient,
startPoint: .leading, endPoint: .trailing))
.frame(width: w * 2)
.offset(x: -w * progress)
}
}
.frame(height: height)
.clipShape(Capsule())
}
}

View File

@@ -20,6 +20,12 @@ enum Tj {
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518)
static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941) static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059) static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059)
// 线: / 绿,
// ink 线; brick ,线 +
static let teal = Color(red: 0.337, green: 0.529, blue: 0.494)
static let tealSoft = Color(red: 0.808, green: 0.878, blue: 0.863)
// :, ink ,
static let shadow = Color(red: 0.376, green: 0.345, blue: 0.298)
} }
enum Radius { enum Radius {
@@ -64,7 +70,7 @@ extension View {
RoundedRectangle(cornerRadius: radius, style: .continuous) RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0) .strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0)
) )
.shadow(color: bordered ? .clear : Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.05), .shadow(color: bordered ? .clear : Tj.Palette.shadow.opacity(0.06),
radius: 2, x: 0, y: 1) radius: 2, x: 0, y: 1)
} }
} }

View File

@@ -0,0 +1,47 @@
import SwiftUI
/// Vault
///
/// body `try? FileVault.shared.loadImage(...)` + ,
/// :
/// 1. **OOM**:(4000×3000 48MB), jetsam `maxPixel`
/// , KB, MB
/// 2. **线**: + JPEG 线线,线
///
/// :,,
/// `content` `UIImage`( `Image`),
/// 便 `image.size` ( overlay)
struct VaultImage<Content: View, Placeholder: View>: View {
let relativePath: String
/// () ~400, ~2000
var maxPixel: CGFloat = 1024
@ViewBuilder var content: (UIImage) -> Content
/// ,`isLoading == true` ,`false`
@ViewBuilder var placeholder: (_ isLoading: Bool) -> Placeholder
@State private var image: UIImage?
@State private var loading = true
var body: some View {
Group {
if let image {
content(image)
} else {
placeholder(loading)
}
}
// id (TabView / asset);
.task(id: relativePath) {
loading = true
let path = relativePath
let mp = maxPixel
let loaded = await Task.detached(priority: .userInitiated) {
try? FileVault.shared.loadDownsampledImage(relativePath: path, maxPixelSize: mp)
}.value
guard !Task.isCancelled else { return }
image = loaded
loading = false
}
}
}

View File

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

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

@@ -20,8 +20,15 @@ struct HealthExportSheet: View {
@State private var completed: Bool = false @State private var completed: Bool = false
@State private var copiedFlash: Bool = false @State private var copiedFlash: Bool = false
@State private var answeringTurnID: UUID? @State private var answeringTurnID: UUID?
@State private var retrieval: HealthExportService.RetrievalSummary?
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
@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 +40,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 +101,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 +210,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))
@@ -161,12 +236,17 @@ struct HealthExportSheet: View {
Text(turn.role.transcriptLabel) Text(turn.role.transcriptLabel)
.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 !isUser, let summary = turnRetrievals[turn.id] {
RetrievalChipsView(summary: summary)
}
if turn.id == answeringTurnID && turn.text.isEmpty { if turn.id == answeringTurnID && turn.text.isEmpty {
HStack(spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ProgressView() Text(turnRetrievals[turn.id] == nil
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 +276,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)
@@ -220,6 +305,9 @@ struct HealthExportSheet: View {
arrow arrow
phasePill(.generating) phasePill(.generating)
} }
if let retrieval {
RetrievalChipsView(summary: retrieval)
}
if phase == .generating && rate > 0 { if phase == .generating && rate > 0 {
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate)) Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
.font(.tjScaled( 11, design: .monospaced)) .font(.tjScaled( 11, design: .monospaced))
@@ -229,6 +317,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 +382,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 +410,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 +433,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 +455,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)
@@ -380,9 +487,18 @@ struct HealthExportSheet: View {
task?.cancel() task?.cancel()
task = Task { @MainActor in task = Task { @MainActor in
do { do {
for try await chunk in stream { for try await event in stream {
switch event {
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) {
turnRetrievals[assistantTurn.id] = summary
}
case .token(let chunk):
appendToTurn(id: assistantTurn.id, text: chunk.text) appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate } if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .phaseChanged, .completed:
break
}
} }
answeringTurnID = nil answeringTurnID = nil
questionFocused = true questionFocused = true
@@ -402,10 +518,20 @@ 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
completed = false completed = false
retrieval = nil
phase = .retrieving phase = .retrieving
let stream = HealthExportService.shared.export(conversation: turns, in: ctx) let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
@@ -416,6 +542,8 @@ struct HealthExportSheet: View {
switch event { switch event {
case .phaseChanged(let ph): case .phaseChanged(let ph):
phase = ph phase = ph
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
case .token(let chunk): case .token(let chunk):
content += chunk.text content += chunk.text
if chunk.decodeRate > 0 { rate = chunk.decodeRate } if chunk.decodeRate > 0 { rate = chunk.decodeRate }
@@ -435,6 +563,17 @@ struct HealthExportSheet: View {
startReportGeneration() startReportGeneration()
} }
/// :,()
private func stopGeneration() {
task?.cancel()
task = nil
phase = nil
rate = 0
completed = false
content = ""
retrieval = nil
}
private func reset() { private func reset() {
task?.cancel() task?.cancel()
task = nil task = nil
@@ -444,11 +583,13 @@ struct HealthExportSheet: View {
error = nil error = nil
completed = false completed = false
answeringTurnID = nil answeringTurnID = nil
retrieval = nil
turnRetrievals = [:]
questionFocused = true questionFocused = true
} }
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
@@ -461,6 +602,44 @@ struct HealthExportSheet: View {
} }
} }
// MARK: - chips( RAG )
/// RAG :N + chips
/// ( embedding) (§12 3)
private struct RetrievalChipsView: View {
let summary: HealthExportService.RetrievalSummary
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if summary.totalCount == 0 {
Text("本地档案中暂无相关记录,将仅按你的描述整理")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
} else {
Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录"))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.leaf)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in
Text(chip)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
}
}
.padding(.vertical, 1)
}
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// MARK: - Markdown () // MARK: - Markdown ()
/// Markdown , /// Markdown ,
@@ -641,9 +820,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

@@ -8,9 +8,12 @@ struct CaptureReviewForm: View {
@State var parsed: ParsedReport @State var parsed: ParsedReport
let assets: [FileVault.SavedAsset] let assets: [FileVault.SavedAsset]
let warning: String? let warning: String?
/// : + (///),
/// ( 2B OOM ), CaptureService.extractReportMeta
var metaOnly: Bool = false
let onSave: (ParsedReport) -> Void let onSave: (ParsedReport) -> Void
let onCancel: () -> Void let onCancel: () -> Void
/// assets () nil,banner /// assets () nil,banner
var onReanalyze: (() -> Void)? = nil var onReanalyze: (() -> Void)? = nil
var body: some View { var body: some View {
@@ -23,7 +26,9 @@ struct CaptureReviewForm: View {
pageThumbnails pageThumbnails
} }
metaSection metaSection
if !metaOnly {
indicatorSection indicatorSection
}
Spacer(minLength: 8) Spacer(minLength: 8)
actions actions
} }
@@ -68,13 +73,20 @@ struct CaptureReviewForm: View {
private var pageThumbnails: some View { private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)")) sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
if metaOnly {
Text("原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
}
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) { HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) { VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
Image(uiImage: img) Image(uiImage: img).resizable().scaledToFill()
.resizable() } placeholder: { _ in
.scaledToFill() Tj.Palette.paper
}
.frame(width: 84, height: 110) .frame(width: 84, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay( .overlay(
@@ -86,7 +98,6 @@ struct CaptureReviewForm: View {
} }
} }
} }
}
// MARK: - meta(title / type / date / institution / summary) // MARK: - meta(title / type / date / institution / summary)
@@ -117,11 +128,13 @@ struct CaptureReviewForm: View {
labeledField(String(appLoc: "机构(可选)")) { labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution) TextField("如:协和医院", text: $parsed.institution)
} }
if !metaOnly {
labeledField(String(appLoc: "摘要(可选)")) { labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical) TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3) .lineLimit(1...3)
} }
} }
}
.padding(12) .padding(12)
.background(fieldBg) .background(fieldBg)
.overlay(fieldBorder) .overlay(fieldBorder)

View File

@@ -32,8 +32,14 @@ struct PhotoPickerSheet: View {
.clipShape(Capsule()) .clipShape(Capsule())
} }
Button("取消", action: onCancel) Button(action: onCancel) {
Text("取消")
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 24)
.frame(minHeight: 44) // HIG
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if loading { if loading {
ProgressView().tint(Tj.Palette.ink) ProgressView().tint(Tj.Palette.ink)

View File

@@ -62,7 +62,7 @@ struct UnifiedCaptureFlow: View {
switch phase { switch phase {
case .idle: return String(appLoc: "拍摄报告") case .idle: return String(appLoc: "拍摄报告")
case .analyzing: return String(appLoc: "本地识别中…") case .analyzing: return String(appLoc: "本地识别中…")
case .editing: return String(appLoc: "核对识别结果") case .editing: return String(appLoc: "核对报告信息")
} }
} }
@@ -86,6 +86,7 @@ struct UnifiedCaptureFlow: View {
parsed: parsed, parsed: parsed,
assets: assets, assets: assets,
warning: warning, warning: warning,
metaOnly: true, // + meta,(§ CaptureService.extractReportMeta)
onSave: { final in saveAll(parsed: final, assets: assets) }, onSave: { final in saveAll(parsed: final, assets: assets) },
onCancel: cancelAll, onCancel: cancelAll,
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) } onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
@@ -152,9 +153,7 @@ struct UnifiedCaptureFlow: View {
phase = .analyzing(images: images, assets: nil) phase = .analyzing(images: images, assets: nil)
let timeout = analyzeTimeoutSeconds let timeout = analyzeTimeoutSeconds
analyzeTask = Task { analyzeTask = Task {
// Step 1: Vault // Step 1: Vault(,)
// UI , CaptureService.analyze /退,
// assets phase ,cancelAll ,editingFallback
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) } let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
// :,View dismisscancelAll // :,View dismisscancelAll
// phase .analyzing(_, nil), // phase .analyzing(_, nil),
@@ -167,7 +166,7 @@ struct UnifiedCaptureFlow: View {
phase = .editing( phase = .editing(
parsed: .empty(), parsed: .empty(),
assets: [], assets: [],
warning: String(appLoc: "图片保存失败,手动录入并保留文本") warning: String(appLoc: "图片保存失败,请重试")
) )
} }
return return
@@ -179,49 +178,40 @@ struct UnifiedCaptureFlow: View {
} }
} }
// Step 2: VL (timeout cancel ,VLSession token break) // Step 2: meta (OCR + LLM,///)
// 2B OOM watchdog cancel
let watchdog = Task { let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout)) try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel() analyzeTask?.cancel()
} }
defer { watchdog.cancel() } defer { watchdog.cancel() }
do { let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
if Task.isCancelled { if Task.isCancelled {
await editingFallback(assets: assets, await MainActor.run {
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入")) phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
}
return return
} }
await MainActor.run { await MainActor.run {
phase = .editing( phase = .editing(
parsed: parsed, parsed: meta,
assets: assets, assets: assets,
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil warning: recognized ? nil
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
) )
} }
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
: String(appLoc: "推理失败:\(msg)"))
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
} }
} }
/// : assets,, VL /// : assets,, meta
private func reanalyze(assets: [FileVault.SavedAsset]) { private func reanalyze(assets: [FileVault.SavedAsset]) {
analyzeTask?.cancel() analyzeTask?.cancel()
// UIImage,AnalyzingView // UIImage,AnalyzingView , 600px ,
// ( MB)
let thumbnails: [UIImage] = assets.compactMap { let thumbnails: [UIImage] = assets.compactMap {
try? FileVault.shared.loadImage(relativePath: $0.relativePath) try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
} }
phase = .analyzing(images: thumbnails, assets: assets) phase = .analyzing(images: thumbnails, assets: assets)
let timeout = analyzeTimeoutSeconds let timeout = analyzeTimeoutSeconds
@@ -232,40 +222,19 @@ struct UnifiedCaptureFlow: View {
} }
defer { watchdog.cancel() } defer { watchdog.cancel() }
do { let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
if Task.isCancelled { if Task.isCancelled {
await editingFallback(assets: assets, await MainActor.run {
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑")) phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保留原图"))
}
return return
} }
await MainActor.run { await MainActor.run {
phase = .editing( phase = .editing(parsed: meta, assets: assets,
parsed: parsed, warning: recognized ? nil
assets: assets, : String(appLoc: "未能自动识别报告信息,可手动填写"))
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
)
} }
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s)")
: String(appLoc: "推理失败:\(msg)"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
/// reanalyze editing, assets parsed
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
} }
} }
@@ -311,6 +280,9 @@ struct UnifiedCaptureFlow: View {
} }
try? ctx.save() try? ctx.save()
// :,
// AI (/) token
Task { await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx) }
onClose() onClose()
} }
} }
@@ -359,9 +331,15 @@ private struct AnalyzingView: View {
.foregroundStyle(Tj.Palette.amber) .foregroundStyle(Tj.Palette.amber)
} }
} }
Button("取消识别 · 改为手动录入", action: onCancel) Button(action: onCancel) {
Text("取消识别 · 改为手动录入")
.font(.tjScaled( 13, weight: .medium)) .font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 20)
.frame(minHeight: 44) // HIG
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.top, 4) .padding(.top, 4)
Spacer() Spacer()
} }

View File

@@ -6,11 +6,21 @@ import SwiftData
/// Qwen3 3-4 , /// Qwen3 3-4 ,
/// q LLM ; row + /// q LLM ; row +
struct DiaryQuickSheet: View { struct DiaryQuickSheet: View {
/// : 2×2 ,
/// false
var directWrite: Bool = false
@Environment(\.modelContext) private var ctx @Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var content: String = "" @State private var content: String = ""
@State private var createdAt: Date = .now @State private var createdAt: Date = .now
/// :,
@State private var showMedicationScan = false
/// :( + + ),tag
@State private var showMedicationLog = false
/// : SymptomStartSheet(/,)
@State private var showSymptomStart = false
/// AI /// AI
enum AssistPhase { enum AssistPhase {
@@ -26,17 +36,37 @@ struct DiaryQuickSheet: View {
/// (question.dim), prompt /// (question.dim), prompt
@State private var coveredDims: Set<String> = [] @State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>? @State private var suggestTask: Task<Void, Never>?
/// question id;nil = /// question id questions (
@State private var fillingId: UUID? /// coveredDims,),
/// , = @State private var skippedQuestionIDs: Set<UUID> = []
@State private var fillValues: [String] = [] /// () true,
/// () true,
@State private var exhaustedNote = false @State private var exhaustedNote = false
/// sheet detent large, /// sheet detent large,
/// medium,() /// medium,()
@State private var detent: PresentationDetent = .large @State private var detent: PresentationDetent = .large
@FocusState private var contentFocused: Bool @FocusState private var contentFocused: Bool
// MARK: (spec 2026-06-10-voice-diary)
enum VoicePhase: Equatable { case idle, recording, organizing }
@State private var voicePhase: VoicePhase = .idle
@State private var liveTranscript = ""
@State private var recordingSeconds = 0
/// 稿,退;
@State private var rawTranscript: String?
/// 稿,
/// () pill
@State private var organizedAppended: String?
/// ( / ),
@State private var voiceNote: String?
@State private var voiceDeniedAlert = false
@State private var voiceFlowTask: Task<Void, Never>?
@State private var recordingWatchdog: Task<Void, Never>?
/// @State:struct View (/detent ) let
/// , stop() (),
/// @State
@State private var dictation = SpeechDictationService()
private var hasContent: Bool { private var hasContent: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
@@ -45,9 +75,43 @@ struct DiaryQuickSheet: View {
if case .loading = phase { return true } if case .loading = phase { return true }
return false return false
} }
private var canRequestSuggest: Bool { hasContent && !isLoading } private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
private var canSubmit: Bool { hasContent } private var canSubmit: Bool { hasContent }
// MARK: - (care bar)
/// : phase + + ,
/// ,
private enum CareState {
case hidden // / ,
case prompt // ,
case thinking //
case asking(DiaryAssistService.Question) //
case caughtUp(exhausted: Bool) // ;exhausted=西
case failed(String)
}
/// / ()
private var pendingQuestions: [DiaryAssistService.Question] {
questions.filter { !$0.adopted && !skippedQuestionIDs.contains($0.id) }
}
private var currentCareQuestion: DiaryAssistService.Question? { pendingQuestions.first }
private var careState: CareState {
if voicePhase != .idle { return .hidden }
switch phase {
case .loading:
return .thinking
case .failed(let err):
return .failed(err.localizedDescription)
case .idle:
return hasContent ? .prompt : .hidden
case .ready:
if let q = currentCareQuestion { return .asking(q) }
return hasContent ? .caughtUp(exhausted: exhaustedNote) : .hidden
}
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Capsule() Capsule()
@@ -61,7 +125,7 @@ struct DiaryQuickSheet: View {
Text("健康记录") Text("健康记录")
.font(.tjH2()) .font(.tjH2())
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺") Text("记录身体状态 · 康康在一旁帮你想还能记点啥")
.font(.tjScaled( 11)) .font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
} }
@@ -71,13 +135,40 @@ struct DiaryQuickSheet: View {
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 14) .padding(.bottom, 10)
// ( / / / ):
// ,,
//(/)
modeSelector
.animation(.snappy(duration: 0.22), value: showModeSelector)
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel(String(appLoc: "内容")) sectionLabel(String(appLoc: "内容"))
Spacer()
if SpeechDictationService.isAvailable, voicePhase == .idle {
Button(action: startVoice) {
HStack(spacing: 4) {
Image(systemName: "mic.fill")
.font(.tjScaled(11, weight: .semibold))
Text("说一段")
.font(.tjScaled(12, weight: .semibold))
}
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().strokeBorder(
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(isLoading) // AI AIRuntime
}
}
TextField("今天身体怎么样?吃了什么药、有什么感觉?", TextField("今天身体怎么样?吃了什么药、有什么感觉?",
text: $content, axis: .vertical) text: $content, axis: .vertical)
.lineLimit(3...8) .lineLimit(3...8)
@@ -93,6 +184,54 @@ struct DiaryQuickSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1) .strokeBorder(Tj.Palette.line, lineWidth: 1)
) )
// :,
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
careBarRow(compact: true)
}
}
if voicePhase != .idle {
DiaryVoicePanel(
mode: voicePhase == .organizing
? .organizing
: .recording(elapsedSeconds: recordingSeconds),
transcript: liveTranscript,
onStop: stopVoiceAndOrganize,
onCancelOrganize: cancelOrganize
)
}
if let note = voiceNote {
HStack(spacing: 6) {
Image(systemName: "info.circle")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
Text(note)
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
}
if let organized = organizedAppended,
rawTranscript != nil,
content.range(of: organized) != nil {
Button(action: revertToRawTranscript) {
HStack(spacing: 4) {
Image(systemName: "arrow.uturn.backward")
.font(.tjScaled(10, weight: .semibold))
Text("改用原话")
.font(.tjScaled(11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
} }
assistSection assistSection
@@ -103,25 +242,11 @@ struct DiaryQuickSheet: View {
.datePickerStyle(.compact) .datePickerStyle(.compact)
.labelsHidden() .labelsHidden()
} }
// , question
Color.clear.frame(height: 1).id("assist-bottom")
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 6) .padding(.bottom, 6)
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onChange(of: questions.count) { old, new in
guard new > old else { return }
// round divider( N ,
// questions)
let roundId = "round-\(questions[old].round)"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.easeOut(duration: 0.25)) {
proxy.scrollTo(roundId, anchor: .top)
}
}
}
}
HStack(spacing: 12) { HStack(spacing: 12) {
Button("取消") { dismiss() } Button("取消") { dismiss() }
@@ -143,91 +268,76 @@ struct DiaryQuickSheet: View {
.presentationDragIndicator(.hidden) .presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand) .presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl) .presentationCornerRadius(Tj.Radius.xl)
.onDisappear { suggestTask?.cancel() } .fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow(
onSave: { meds, images in
// (), ·
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
dismiss()
},
onClose: { showMedicationScan = false }
)
}
.sheet(isPresented: $showSymptomStart) {
// sheet:/;,
SymptomStartSheet()
}
.sheet(isPresented: $showMedicationLog) {
// sheet:/;()
MedicationLogSheet()
}
.onAppear {
// :,,
// sheet ,
guard directWrite else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
contentFocused = true
}
}
.onDisappear {
suggestTask?.cancel()
voiceFlowTask?.cancel()
recordingWatchdog?.cancel()
dictation.abort()
}
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
Button(String(appLoc: "前往设置")) {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button(String(appLoc: "取消"), role: .cancel) {}
} message: {
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
}
} }
// MARK: - AI // MARK: - (care bar)
/// :() careState,
/// AI ,
@ViewBuilder @ViewBuilder
private var assistSection: some View { private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
// section header if !contentFocused {
if case .hidden = careState {
EmptyView()
} else {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.tjScaled( 11, weight: .semibold)) .font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick) .foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺")) sectionLabel(String(appLoc: "康康帮你记"))
Spacer() Spacer(minLength: 0)
if hasQuestions {
Text("\(questions.count) 个建议")
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
if lastRate > 0 { if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate)) Text(String(format: "%.1f tok/s", lastRate))
.font(.tjScaled( 10, design: .monospaced)) .font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf) .foregroundStyle(Tj.Palette.leaf)
} }
} }
careBarRow(compact: false)
// questions (,) .padding(12)
if hasQuestions { .frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 8) {
ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
if idx == 0 || questions[idx - 1].round != q.round {
roundDivider(round: q.round,
count: questions.filter { $0.round == q.round }.count)
.id("round-\(q.round)")
}
questionRow(index: roundLocalIndex(at: idx), question: q)
}
}
}
if exhaustedNote {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.leaf)
Text("已覆盖主要问诊维度;补充原文后可再追问")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
// ()
phaseFooter
}
}
@ViewBuilder
private var phaseFooter: some View {
switch phase {
case .idle:
assistPrimaryButton(
icon: "sparkles",
label: canRequestSuggest
? String(appLoc: "让 AI 帮我想想还能记什么")
: String(appLoc: "先写几个字,AI 来帮忙补充"),
enabled: canRequestSuggest,
action: requestSuggestions
)
case .loading:
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text("AI 思考中… 本地推理,通常 5-10 秒")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Button("取消") { cancelSuggestions() }
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 11)
.padding(.horizontal, 12)
.background( .background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper) .fill(Tj.Palette.paper)
@@ -236,180 +346,126 @@ struct DiaryQuickSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
) )
}
}
// AI ,(,)
if !questions.isEmpty {
AIDisclaimerFooter()
}
}
}
case .ready: /// `compact = true` ();
assistPrimaryButton( /// `compact = false` () careState
icon: "arrow.clockwise", @ViewBuilder
label: canRequestSuggest private func careBarRow(compact: Bool) -> some View {
? String(appLoc: "再问一轮 · 让 AI 从新角度追问") switch careState {
: String(appLoc: "更新一下原文,再让 AI 继续追问"), case .hidden:
enabled: canRequestSuggest, EmptyView()
action: requestSuggestions
)
case .failed(let err): case .prompt:
VStack(alignment: .leading, spacing: 8) { Button(action: requestSuggestions) {
HStack(spacing: 6) { careCapsule(icon: "sparkles",
Image(systemName: "exclamationmark.triangle.fill") text: String(appLoc: "让康康帮你把这条记得更全"),
tint: Tj.Palette.brick, style: .soft, compact: compact)
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
case .thinking:
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick) .foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription) .symbolEffect(.pulse, options: .repeating)
Text(lastRate > 0
? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate)
: String(appLoc: "康康在想想…"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button(action: cancelSuggestions) {
Text("")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
case .asking(let q):
HStack(spacing: 10) {
Image(systemName: "text.bubble.fill")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(q.q)
.font(.tjScaled( compact ? 13 : 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(compact ? 1 : 2)
.fixedSize(horizontal: false, vertical: !compact)
Spacer(minLength: 6)
Button { skipCurrent(q) } label: {
Text("跳过")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
Button { recordCurrent(q) } label: {
careCapsule(icon: "plus", text: String(appLoc: "记一下"),
tint: Tj.Palette.ink, style: .filled, compact: compact)
}
.buttonStyle(.plain)
}
case .caughtUp(let exhausted):
Button(action: requestSuggestions) {
careCapsule(
icon: exhausted ? "checkmark.seal.fill" : "sparkles",
text: exhausted
? String(appLoc: "主要的都帮你问到啦 · 再想想?")
: String(appLoc: "还想到几个想问你 · 再来一轮"),
tint: exhausted ? Tj.Palette.leaf : Tj.Palette.brick,
style: .soft, compact: compact)
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
case .failed(let msg):
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.brick)
Text(msg)
.font(.tjScaled( 12)) .font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
Spacer() .lineLimit(1)
} Spacer(minLength: 0)
Button { requestSuggestions() } label: { Button(action: requestSuggestions) {
Text("重试") Text("重试")
.font(.tjScaled( 12, weight: .semibold)) .font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink) .foregroundStyle(Tj.Palette.ink)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
} }
} }
private func assistPrimaryButton(icon: String, private enum CareCapsuleStyle { case filled, soft }
label: String,
enabled: Bool, /// filled = ();soft = ()
action: @escaping () -> Void) -> some View { private func careCapsule(icon: String, text: String, tint: Color,
Button(action: action) { style: CareCapsuleStyle, compact: Bool) -> some View {
HStack(spacing: 8) { HStack(spacing: 5) {
Image(systemName: icon) Image(systemName: icon)
Text(label) .font(.tjScaled( compact ? 11 : 12, weight: .semibold))
Text(text)
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.lineLimit(1)
} }
.font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(style == .filled ? Tj.Palette.paper : tint)
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3) .padding(.horizontal, 12)
.frame(maxWidth: .infinity) .padding(.vertical, 7)
.padding(.vertical, 11) .background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12)))
.background( .contentShape(Capsule())
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(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
/// questions list idx question, round (1-based)
private func roundLocalIndex(at idx: Int) -> Int {
let target = questions[idx].round
var count = 0
for i in 0...idx where questions[i].round == target {
count += 1
}
return count
}
/// N LLM
private func roundDivider(round: Int, count: Int) -> some View {
HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(round == 1
? String(appLoc: "第 1 轮 · \(count)")
: String(appLoc: "\(round) 轮 · 基于你刚才更新的文本 · \(count)"))
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
Rectangle()
.fill(Tj.Palette.line)
.frame(height: 1)
.mask(
HStack(spacing: 3) {
ForEach(0..<60, id: \.self) { _ in
Rectangle().frame(width: 3, height: 1)
}
}
)
}
.padding(.top, round == 1 ? 0 : 6)
}
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
let adopted = question.adopted
let filling = fillingId == question.id
return VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Text("\(index).")
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
Text(question.q)
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
.strikethrough(adopted, color: Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
if adopted {
HStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.tjScaled( 10, weight: .bold))
Text("已采纳")
.font(.tjScaled( 11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.leaf)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.leafSoft))
} else if !filling {
Button { adopt(question) } label: {
HStack(spacing: 4) {
Image(systemName: "plus.circle.fill")
.font(.tjScaled( 12))
Text("采纳")
.font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.ink))
}
.buttonStyle(.plain)
}
}
if filling {
QuestionFillPanel(
template: question.fill,
values: $fillValues,
onCommit: { assembled in commitAdoption(question, text: assembled) },
onCancel: { closeFill() }
)
} else if !question.fill.isEmpty && !adopted {
HStack(alignment: .top, spacing: 4) {
Text("将追加:")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Text(question.fill)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, 22)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
} }
// MARK: - Actions // MARK: - Actions
@@ -421,20 +477,178 @@ struct DiaryQuickSheet: View {
.foregroundStyle(Tj.Palette.text2) .foregroundStyle(Tj.Palette.text2)
} }
/// + +
/// / ,,
private var showModeSelector: Bool {
!directWrite && !contentFocused && !hasContent
}
/// (2×2):()/ (+)/ ()/
@ViewBuilder
private var modeSelector: some View {
if showModeSelector {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
GridItem(.flexible(), spacing: 10)], spacing: 10) {
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true
}
modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
subtitle: String(appLoc: "记剂量与时间"), active: false) {
showMedicationLog = true
}
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别入药品库"), active: false) {
showMedicationScan = true
}
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
subtitle: String(appLoc: "持续追踪"), active: false) {
showSymptomStart = true
}
}
.padding(.horizontal, 20)
.padding(.bottom, 14)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
/// ( / / )active
/// : iPhone
private func modeCard(icon: String, title: String, subtitle: String,
active: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 5) {
Image(systemName: icon)
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(active ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 28, height: 28)
.background(Circle().fill(active ? Tj.Palette.ink : Tj.Palette.sand2))
Text(title)
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(subtitle)
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(active ? Tj.Palette.ink : Tj.Palette.line,
lineWidth: active ? 1.5 : 1)
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK:
private func startVoice() {
contentFocused = false
voiceNote = nil
voiceFlowTask = Task { @MainActor in
guard await dictation.requestAuthorization() else {
voiceDeniedAlert = true
return
}
do {
liveTranscript = ""
recordingSeconds = 0
try dictation.start { partial in liveTranscript = partial }
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
// + 3 (,)
recordingWatchdog = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled, voicePhase == .recording else { return }
recordingSeconds += 1
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
stopVoiceAndOrganize()
return
}
}
}
} catch {
voiceNote = error.localizedDescription
voicePhase = .idle
}
}
}
private func stopVoiceAndOrganize() {
guard voicePhase == .recording else { return }
recordingWatchdog?.cancel()
voiceFlowTask = Task { @MainActor in
// :(/),
// @State
var transcript = (await dictation.stop())
.trimmingCharacters(in: .whitespacesAndNewlines)
if transcript.isEmpty {
transcript = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
}
liveTranscript = transcript
guard !transcript.isEmpty else {
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
voiceNote = String(appLoc: "没听清,再试一次")
return
}
rawTranscript = transcript
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
do {
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
guard !Task.isCancelled else { return }
appendToContent(result.text)
organizedAppended = result.text
lastRate = result.decodeRate
} catch is CancellationError {
// cancelOrganize 退,
} catch {
guard !Task.isCancelled else { return }
appendToContent(transcript) // 线 #5:退,
organizedAppended = nil
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
}
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
}
}
/// : LLM,(退)
private func cancelOrganize() {
guard voicePhase == .organizing else { return }
voiceFlowTask?.cancel()
if let raw = rawTranscript {
appendToContent(raw)
organizedAppended = nil
voiceNote = String(appLoc: "已取消整理,填入原话")
}
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
}
/// :稿稿(spec §2:LLM )
private func revertToRawTranscript() {
guard let raw = rawTranscript,
let organized = organizedAppended,
let range = content.range(of: organized, options: .backwards) else { return }
withAnimation(.snappy(duration: 0.18)) {
content = content.replacingCharacters(in: range, with: raw)
organizedAppended = nil
}
}
/// AI (coveredDims) LLM, /// AI (coveredDims) LLM,
/// , /// ,
/// ****:,
/// ,
private func requestSuggestions() { private func requestSuggestions() {
suggestTask?.cancel() suggestTask?.cancel()
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines) let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims) let covered = Array(coveredDims)
// 1.
contentFocused = false
// 2. sheet large( medium AI)
if detent != .large {
withAnimation(.snappy(duration: 0.25)) {
detent = .large
}
}
exhaustedNote = false exhaustedNote = false
phase = .loading phase = .loading
suggestTask = Task { @MainActor in suggestTask = Task { @MainActor in
@@ -508,38 +722,25 @@ struct DiaryQuickSheet: View {
phase = hasQuestions ? .ready : .idle phase = hasQuestions ? .ready : .idle
} }
/// : `[]` ;( adopted) /// :,,
/// q ; coveredDims, prompt /// `assemble(values: [])` 退
private func adopt(_ question: DiaryAssistService.Question) { /// 便, `[]`
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else { private func recordCurrent(_ question: DiaryAssistService.Question) {
// :( fill 退) let stub = question.fill.isEmpty
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill) ? question.q
return : DiaryFillTemplate.assemble(question.fill, values: [])
} appendToContent(stub)
withAnimation(.snappy(duration: 0.18)) {
fillingId = question.id
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
}
}
/// ()
private func closeFill() {
withAnimation(.snappy(duration: 0.18)) {
fillingId = nil
fillValues = []
}
}
/// :(), adopted,
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
if let idx = questions.firstIndex(where: { $0.id == question.id }) { if let idx = questions.firstIndex(where: { $0.id == question.id }) {
withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true questions[idx].adopted = true
} }
// :,
contentFocused = true
} }
appendToContent(text)
fillingId = nil /// :, coveredDims,
fillValues = [] /// prompt , questions
private func skipCurrent(_ question: DiaryAssistService.Question) {
skippedQuestionIDs.insert(question.id)
} }
/// (,) /// (,)

View File

@@ -0,0 +1,141 @@
import SwiftUI
/// (spec 2026-06-10-voice-diary)
/// :recording( + + )/ organizing(AI ,)
/// : DiaryQuickSheet
struct DiaryVoicePanel: View {
enum Mode: Equatable {
case recording(elapsedSeconds: Int)
case organizing
}
let mode: Mode
/// recording ;organizing 稿稿()
let transcript: String
let onStop: () -> Void
let onCancelOrganize: () -> Void
/// 3 ( DiaryQuickSheet onStop)
static let maxRecordingSeconds = 180
var body: some View {
VStack(alignment: .leading, spacing: 10) {
header
transcriptArea
if case .recording = mode {
stopButton
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.overlay(alignment: .bottom) {
if mode == .organizing {
AIFlowBar().padding(.horizontal, 1)
}
}
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
@ViewBuilder
private var header: some View {
switch mode {
case .recording(let elapsed):
HStack(spacing: 8) {
Image(systemName: "waveform")
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.variableColor.iterative, options: .repeating)
Text("正在听 · 识别在本机完成")
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Text(Self.format(elapsed))
.font(.tjScaled(12, design: .monospaced))
.foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30
? Tj.Palette.brick : Tj.Palette.text3)
}
case .organizing:
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.pulse, options: .repeating)
Text("AI 整理中 · 本地推理")
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button("取消") { onCancelOrganize() }
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
}
}
@ViewBuilder
private var transcriptArea: some View {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript)
.font(.tjScaled(14))
.foregroundStyle(transcriptColor)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Color.clear.frame(height: 1).id("tail")
}
.frame(maxHeight: 120)
.onChange(of: transcript) { _, _ in
proxy.scrollTo("tail", anchor: .bottom)
}
}
}
private var transcriptColor: Color {
if transcript.isEmpty { return Tj.Palette.text3 }
return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text
}
private var stopButton: some View {
Button(action: onStop) {
HStack(spacing: 8) {
Image(systemName: "stop.circle.fill")
Text("说完了,整理成日记")
}
.font(.tjScaled(14, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private static func format(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
#Preview("录音中") {
DiaryVoicePanel(mode: .recording(elapsedSeconds: 23),
transcript: "今天早上起来有点头晕,量了血压一百四九十",
onStop: {}, onCancelOrganize: {})
.padding()
}
#Preview("整理中") {
DiaryVoicePanel(mode: .organizing,
transcript: "今天早上起来有点头晕,量了血压一百四九十",
onStop: {}, onCancelOrganize: {})
.padding()
}

View File

@@ -0,0 +1,138 @@
import SwiftUI
import SwiftData
/// · : ()+ +
/// `DiaryEntry.medicationTag` ,线
///
/// (`Medication`,master ):
/// sheet, / ( `SymptomStartSheet`),
struct MedicationLogSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query(sort: \Medication.updatedAt, order: .reverse)
private var library: [Medication]
/// ; nil
@State private var selectedMed: Medication?
/// (,) selectedMed
@State private var manualName = ""
@State private var dosage = ""
@State private var takenAt: Date = .now
/// () nil =
init(preselected: Medication? = nil) {
_selectedMed = State(initialValue: preselected)
}
private var resolvedName: String {
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool { !resolvedName.isEmpty }
var body: some View {
NavigationStack {
Form {
Section {
if library.isEmpty {
TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $manualName)
.foregroundStyle(Tj.Palette.text)
} else {
ForEach(library) { m in
Button { select(m) } label: { medRow(m) }
.buttonStyle(.plain)
}
HStack(spacing: 8) {
Image(systemName: "pencil")
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "或手动输入药名"), text: $manualName)
.foregroundStyle(Tj.Palette.text)
.onChange(of: manualName) { _, v in
if !v.trimmingCharacters(in: .whitespaces).isEmpty {
selectedMed = nil
}
}
}
}
} header: {
Text("吃了哪个药")
} footer: {
if library.isEmpty {
Text("药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。")
}
}
Section {
TextField(String(appLoc: "剂量,如:1 片 / 80mg"), text: $dosage)
.foregroundStyle(Tj.Palette.text)
} header: {
Text("剂量")
}
Section {
DatePicker(String(appLoc: "时间"), selection: $takenAt, in: ...Date.now)
} header: {
Text("时间")
}
}
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("记录用药")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String(appLoc: "取消")) { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(String(appLoc: "保存")) { save() }
.fontWeight(.semibold)
.disabled(!canSave)
}
}
}
}
private func medRow(_ m: Medication) -> some View {
let on = selectedMed === m
return HStack(spacing: 10) {
Image(systemName: on ? "checkmark.circle.fill" : "circle")
.foregroundStyle(on ? Tj.Palette.ink : Tj.Palette.text3)
VStack(alignment: .leading, spacing: 2) {
Text(m.name)
.foregroundStyle(Tj.Palette.text)
if !m.detailLine.isEmpty {
Text(m.detailLine)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
private func select(_ m: Medication) {
selectedMed = m
manualName = ""
}
private func save() {
guard canSave else { return }
// content : [] · , createdAt
// TimelineEntry.firstLine / TimelineEntryDetailView.medicationLines
var line = resolvedName
if let s = selectedMed?.strength, !s.isEmpty { line += " \(s)" }
let dose = dosage.trimmingCharacters(in: .whitespacesAndNewlines)
if !dose.isEmpty { line += " · \(dose)" }
let entry = DiaryEntry(content: line, createdAt: takenAt, tags: [DiaryEntry.medicationTag])
ctx.insert(entry)
try? ctx.save()
dismiss()
}
}
#Preview {
MedicationLogSheet()
.modelContainer(for: [Medication.self, DiaryEntry.self, Asset.self], inMemory: true)
}

View File

@@ -2,7 +2,8 @@ import SwiftUI
import SwiftData import SwiftData
struct HomeView: View { struct HomeView: View {
var onTapArchive: () -> Void = {} /// ; filter chip( `.report`,)
var onTapArchive: (TimelineKind?) -> Void = { _ in }
@Query(sort: \Indicator.capturedAt, order: .reverse) @Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator] private var indicators: [Indicator]
@@ -16,22 +17,26 @@ struct HomeView: View {
@Query(sort: \Symptom.startedAt, order: .reverse) @Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom] private var symptoms: [Symptom]
/// sheet( C1 ) @Query private var profiles: [UserProfile]
@State private var selectedEntry: TimelineEntry? @Query private var customMetrics: [CustomMonitorMetric]
/// ( + , C1 )
@State private var selectedGroup: IndicatorGroup?
private var profile: UserProfile? { profiles.first }
/// 3 :,
@MainActor @MainActor
private var recentEntries: [TimelineEntry] { private var featuredBuckets: [SeriesBucket] {
let all = let all = SeriesBucket.build(from: indicators,
TimelineEntry.from(indicators: indicators) + profile: profile,
reports.map(TimelineEntry.from(report:)) + customMetrics: customMetrics)
diaries.map(TimelineEntry.from(diary:)) + let monitor = all.filter { $0.kind == .monitor }
symptoms.map(TimelineEntry.from(symptom:)) let lab = all.filter { $0.kind == .lab }
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 } return Array((monitor + lab).prefix(3))
} }
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] { private var ongoingSymptomCount: Int { symptoms.filter { $0.endedAt == nil }.count }
TimelineGrouping.group(recentEntries)
}
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -41,46 +46,65 @@ struct HomeView: View {
.padding(.bottom, 18) .padding(.bottom, 18)
HomeCalendarCard() HomeCalendarCard()
.padding(.bottom, 18)
overviewSection
.padding(.bottom, 18)
let buckets = featuredBuckets
if !buckets.isEmpty {
trendsSection(buckets)
.padding(.bottom, 18)
}
TodayRemindersCard() TodayRemindersCard()
OngoingSymptomsCard() OngoingSymptomsCard()
.padding(.bottom, 18) .padding(.bottom, 18)
recentSection
.padding(.bottom, 22)
archiveSection archiveSection
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 20) .padding(.bottom, 20)
} }
.background(Tj.Palette.sand.ignoresSafeArea()) .background(Tj.Palette.sand.ignoresSafeArea())
.sheet(item: $selectedEntry) { entry in .sheet(item: $selectedGroup) { group in
if let d = TimelineDetail.resolve( IndicatorSeriesDetailView(group: group)
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) {
TimelineEntryDetailView(detail: d)
}
} }
} }
// MARK: -
private var greeting: some View { private var greeting: some View {
HStack(alignment: .top) { let t = TimeOfDay.current
VStack(alignment: .leading, spacing: 4) { return HStack(alignment: .center, spacing: 14) {
// : + (//),
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: t.icon)
.font(.tjScaled( 22))
.foregroundStyle(Tj.Palette.amber)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 2) {
Text(todayLine) Text(todayLine)
.font(.tjScaled( 12)) .font(.tjScaled( 11))
.tracking(1) .tracking(1)
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
Text(greetingWord) // 线,
.font(.tjTitle()) Text(t.word)
.font(.tjScaled( 28, weight: .semibold, design: .serif))
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
Text(t.subtitle)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
} }
Spacer()
Spacer(minLength: 8)
TjLockChip() TjLockChip()
.padding(.top, 4) .padding(.top, 2)
} }
} }
@@ -91,77 +115,137 @@ struct HomeView: View {
return "\(day) · \(weekday)" return "\(day) · \(weekday)"
} }
private var greetingWord: String { /// :,
private enum TimeOfDay {
case morning, afternoon, evening
static var current: TimeOfDay {
switch Calendar.current.component(.hour, from: Date()) { switch Calendar.current.component(.hour, from: Date()) {
case 5..<12: return String(appLoc: "早安") case 5..<12: return .morning
case 12..<18: return String(appLoc: "下午好") case 12..<18: return .afternoon
default: return String(appLoc: "晚上好") default: return .evening
} }
} }
private var recentSection: some View { var word: String {
switch self {
case .morning: return String(appLoc: "早安")
case .afternoon: return String(appLoc: "下午好")
case .evening: return String(appLoc: "晚上好")
}
}
var subtitle: String {
switch self {
case .morning: return String(appLoc: "新的一天,慢慢来")
case .afternoon: return String(appLoc: "记得起身活动一下")
case .evening: return String(appLoc: "夜深了,记得早点休息")
}
}
var icon: String {
switch self {
case .morning: return "sun.max.fill"
case .afternoon: return "sun.haze.fill"
case .evening: return "moon.stars.fill"
}
}
}
// MARK: - (2×2, + ,)
private var overviewSection: some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)], spacing: 12) {
statTile(icon: "doc.fill", value: reports.count,
label: String(appLoc: "报告"), tint: Tj.Palette.ink) {
onTapArchive(.report)
}
statTile(icon: "drop.fill", value: indicators.count,
label: String(appLoc: "指标"), tint: Tj.Palette.brick) {
onTapArchive(.indicator)
}
statTile(icon: "pencil", value: diaries.count,
label: String(appLoc: "日记"), tint: Tj.Palette.leaf) {
onTapArchive(.diary)
}
statTile(icon: "waveform.path.ecg", value: symptoms.count,
label: ongoingSymptomCount > 0
? String(appLoc: "症状 · \(ongoingSymptomCount) 进行中")
: String(appLoc: "症状"),
tint: Tj.Palette.amber) {
onTapArchive(.symptom)
}
}
}
private func statTile(icon: String, value: Int, label: String,
tint: Color, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 12) {
ZStack {
Circle().fill(tint.opacity(0.15))
Image(systemName: icon)
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(tint)
}
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 1) {
Text("\(value)")
.font(.tjScaled( 22, weight: .bold, design: .rounded))
.foregroundStyle(Tj.Palette.text)
Text(label)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity)
.tjCard()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: - (线, TrendRow)
private func trendsSection(_ buckets: [SeriesBucket]) -> some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .lastTextBaseline) { Text("健康趋势")
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text) .font(.tjH2())
Spacer() .foregroundStyle(Tj.Palette.text)
Button(action: onTapArchive) {
Text("全部 ")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
if recentEntries.isEmpty { VStack(spacing: 12) {
emptyRecent ForEach(buckets) { bucket in
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(recentGrouped, id: \.section) { group in
VStack(alignment: .leading, spacing: 8) {
Text(group.section.label)
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
VStack(spacing: 10) {
ForEach(group.items) { entry in
Button { Button {
if TimelineDetail.resolve( selectedGroup = group(for: bucket)
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) != nil {
selectedEntry = entry
}
} label: { } label: {
TimelineRow(entry: entry) TrendRow(bucket: bucket)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
} }
} }
}
} /// SeriesBucket IndicatorGroup()
} private func group(for bucket: SeriesBucket) -> IndicatorGroup {
if bucket.id == "bp" { return .bloodPressure }
if bucket.id.hasPrefix("lab:") { return .lab(key: String(bucket.id.dropFirst(4))) }
return .series(key: bucket.id)
} }
private var emptyRecent: some View { // MARK: -
HStack {
Text("还没有任何记录,点底部 + 号开始第一条")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.vertical, 14)
.padding(.horizontal, 16)
.tjCard(bordered: true)
}
private var archiveSection: some View { private var archiveSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text) Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Button(action: onTapArchive) { Button { onTapArchive(.report) } label: {
HStack(spacing: 14) { HStack(spacing: 14) {
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)")) TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
.frame(width: 56, height: 56) .frame(width: 56, height: 56)

View File

@@ -95,8 +95,7 @@ struct TodayRemindersCard: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper) .fill(Tj.Palette.paper)
) )
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04), .shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1)
radius: 2, x: 0, y: 1)
} }
} }

View File

@@ -31,6 +31,18 @@ struct IndicatorQuickSheet: View {
/// nil ( Preview) /// nil ( Preview)
var onRequestCamera: (() -> Void)? = nil var onRequestCamera: (() -> Void)? = nil
/// nil =
/// seriesKey MonitorMetric / CustomMonitorMetric ( + );
/// name/unit/range ,
var prefill: Prefill? = nil
struct Prefill: Equatable {
var seriesKey: String?
var name: String = ""
var unit: String = ""
var range: String = ""
}
@Environment(\.modelContext) private var ctx @Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Query private var profiles: [UserProfile] @Query private var profiles: [UserProfile]
@@ -69,6 +81,32 @@ struct IndicatorQuickSheet: View {
// sheet // sheet
@State private var showHiddenSheet: Bool = false @State private var showHiddenSheet: Bool = false
//
@State private var didApplyPrefill = false
// :, / /
@State private var searchingMetrics = false
@State private var metricQuery = ""
private var isSearchingMetrics: Bool {
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
}
private var filteredMonitorMetrics: [MonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return visibleMonitorMetrics }
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
}
private var filteredCustomMetrics: [CustomMonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return customMetrics }
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private var filteredLabPresets: [IndicatorPreset] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return labPresets }
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private static var defaultReminderTime: Date { private static var defaultReminderTime: Date {
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
} }
@@ -137,12 +175,14 @@ struct IndicatorQuickSheet: View {
footer footer
} }
.onAppear { applyPrefillIfNeeded() }
.task(id: longTermKey) { hydrateReminder() } .task(id: longTermKey) { hydrateReminder() }
.background( .background(
Tj.Palette.sand Tj.Palette.sand
.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)
@@ -160,7 +200,8 @@ struct IndicatorQuickSheet: View {
} }
private var header: some View { private var header: some View {
HStack { VStack(spacing: 12) {
HStack(spacing: 10) {
Text("记录指标") Text("记录指标")
.font(.tjH2()) .font(.tjH2())
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
@@ -168,12 +209,56 @@ struct IndicatorQuickSheet: View {
Text("本地处理 · 永不上传") Text("本地处理 · 永不上传")
.font(.tjScaled( 12)) .font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
searchToggle
}
if searchingMetrics { searchField }
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 16) .padding(.bottom, 16)
} }
/// : RootView VL private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searchingMetrics.toggle()
if !searchingMetrics { metricQuery = "" }
}
} label: {
Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searchingMetrics ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标名"), text: $metricQuery)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !metricQuery.isEmpty {
Button { metricQuery = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(fieldBg)
.overlay(fieldBorder)
}
/// : RootView VL
@ViewBuilder @ViewBuilder
private var cameraEntrySection: some View { private var cameraEntrySection: some View {
if let onRequestCamera { if let onRequestCamera {
@@ -240,13 +325,19 @@ struct IndicatorQuickSheet: View {
} }
let columns = [GridItem(.flexible()), GridItem(.flexible())] let columns = [GridItem(.flexible()), GridItem(.flexible())]
LazyVGrid(columns: columns, spacing: 8) { LazyVGrid(columns: columns, spacing: 8) {
ForEach(visibleMonitorMetrics) { m in ForEach(filteredMonitorMetrics) { m in
monitorTile(m) monitorTile(m)
} }
ForEach(customMetrics) { cm in ForEach(filteredCustomMetrics) { cm in
customTile(cm) customTile(cm)
} }
addCustomTile // (),
if !isSearchingMetrics { addCustomTile }
}
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
Text("没有匹配的长期监测指标")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
} }
} }
.sheet(isPresented: $showHiddenSheet) { .sheet(isPresented: $showHiddenSheet) {
@@ -385,12 +476,15 @@ struct IndicatorQuickSheet: View {
} }
} }
@ViewBuilder
private var labPresetSection: some View { private var labPresetSection: some View {
// :()
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "化验项快捷(不进趋势)")) sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(labPresets) { p in ForEach(filteredLabPresets) { p in
chip(p.name, selected: selectedLabPreset == p) { chip(p.name, selected: selectedLabPreset == p) {
applyLab(p) applyLab(p)
} }
@@ -399,6 +493,7 @@ struct IndicatorQuickSheet: View {
} }
} }
} }
}
private var bpFieldSection: some View { private var bpFieldSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -423,6 +518,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 +565,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 +588,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 +600,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 +625,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 +686,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)
@@ -928,6 +1035,29 @@ struct IndicatorQuickSheet: View {
// MARK: - apply preset // MARK: - apply preset
/// :seriesKey / ( + ),
/// name/unit/range
private func applyPrefillIfNeeded() {
guard !didApplyPrefill, let p = prefill else { return }
didApplyPrefill = true
if let key = p.seriesKey {
if let m = MonitorMetric.allCases.first(where: { metric in
metric.fields.contains { $0.seriesKey == key }
}) {
applyMonitor(m)
return
}
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
applyCustom(cm)
return
}
}
// seriesKey ( / / ):, seriesKey,
name = p.name
unit = p.unit
range = p.range
}
private func applyMonitor(_ m: MonitorMetric) { private func applyMonitor(_ m: MonitorMetric) {
if selectedMonitor == m { if selectedMonitor == m {
// //

View File

@@ -0,0 +1,39 @@
import SwiftUI
extension IndicatorQuickSheet.Prefill {
/// :
/// seriesKey / ( + ), name/unit/range
init(indicator i: Indicator) {
self.init(seriesKey: i.seriesKey, name: i.name, unit: i.unit, range: i.range)
}
}
/// / :(,)
/// ,`TimelineEntryDetailView` `IndicatorSeriesDetailView`
struct RecordAnotherButton: View {
/// ()
let name: String
///
let prefill: IndicatorQuickSheet.Prefill
@State private var showSheet = false
var body: some View {
Button { showSheet = true } label: {
Label(String(appLoc: "再记一条「\(name)"), systemImage: "plus.circle.fill")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.leaf.opacity(0.16))
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.sheet(isPresented: $showSheet) {
IndicatorQuickSheet(prefill: prefill)
}
}
}

View File

@@ -10,13 +10,20 @@ struct CustomReminderEditSheet: View {
/// nil = /// nil =
let reminder: CustomReminder? let reminder: CustomReminder?
/// (,:+ )
/// (reminder != nil)
private let prefillTitle: String
private let prefillNote: String
@State private var title = "" @State private var title = ""
@State private var note = "" @State private var note = ""
@State private var pickedTime: Date = .now @State private var pickedTime: Date = .now
@State private var frequency: CustomReminder.Frequency = .daily /// (/// )
@State private var frequencies: Set<CustomReminder.Frequency> = [.daily]
@State private var weekdays: Set<Int> = Set(1...7) @State private var weekdays: Set<Int> = Set(1...7)
@State private var dayOfMonth = 1 /// (1...31)
@State private var monthDays: Set<Int> = [1]
@State private var dayOfMonth = 1 //
@State private var month = 1 @State private var month = 1
@State private var hydrated = false @State private var hydrated = false
@State private var showAuthDeniedAlert = false @State private var showAuthDeniedAlert = false
@@ -24,8 +31,10 @@ struct CustomReminderEditSheet: View {
/// (, ): / / / /// (, ): / / /
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)] private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
init(reminder: CustomReminder? = nil) { init(reminder: CustomReminder? = nil, prefillTitle: String = "", prefillNote: String = "") {
self.reminder = reminder self.reminder = reminder
self.prefillTitle = prefillTitle
self.prefillNote = prefillNote
} }
private var isEditing: Bool { reminder != nil } private var isEditing: Bool { reminder != nil }
@@ -33,8 +42,9 @@ struct CustomReminderEditSheet: View {
title.trimmingCharacters(in: .whitespacesAndNewlines) title.trimmingCharacters(in: .whitespacesAndNewlines)
} }
private var canSave: Bool { private var canSave: Bool {
guard !trimmedTitle.isEmpty else { return false } guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false }
if frequency == .weekly { return !weekdays.isEmpty } if frequencies.contains(.weekly) && weekdays.isEmpty { return false }
if frequencies.contains(.monthly) && monthDays.isEmpty { return false }
return true return true
} }
@@ -51,18 +61,12 @@ struct CustomReminderEditSheet: View {
} }
Section { Section {
Picker(String(appLoc: "重复"), selection: $frequency) { frequencyChips
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
frequencyDetail frequencyDetail
} header: { } header: {
Text("重复") Text("重复")
} footer: {
Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。")
} }
Section { Section {
@@ -109,23 +113,60 @@ struct CustomReminderEditSheet: View {
} }
} }
// MARK: - // MARK: - chip
private static let freqOrder: [CustomReminder.Frequency] = [.daily, .weekly, .monthly, .yearly]
private func freqLabel(_ f: CustomReminder.Frequency) -> String {
switch f {
case .daily: return String(appLoc: "每日")
case .weekly: return String(appLoc: "每周")
case .monthly: return String(appLoc: "每月")
case .yearly: return String(appLoc: "每年")
}
}
private var frequencyChips: some View {
HStack(spacing: 8) {
ForEach(Self.freqOrder, id: \.self) { f in
let on = frequencies.contains(f)
Button {
if on { frequencies.remove(f) } else { frequencies.insert(f) }
} label: {
Text(freqLabel(f))
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 32)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
.listRowBackground(Color.clear)
}
// MARK: - (,)
@ViewBuilder @ViewBuilder
private var frequencyDetail: some View { private var frequencyDetail: some View {
switch frequency { if frequencies.contains(.weekly) {
case .daily: subCaption(String(appLoc: "每周 · 选星期几"))
EmptyView()
case .weekly:
weekdayRow weekdayRow
case .monthly:
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
ForEach(1...31, id: \.self) { d in
Text(String(appLoc: "\(d)")).tag(d)
} }
if frequencies.contains(.monthly) {
subCaption(String(appLoc: "每月 · 选日期(可多选)"))
monthDayGrid
if monthDays.contains(where: { $0 >= 29 }) { skipHint }
} }
if dayOfMonth >= 29 { skipHint } if frequencies.contains(.yearly) {
case .yearly: subCaption(String(appLoc: "每年 · 选月/日"))
Picker(String(appLoc: "月份"), selection: $month) { Picker(String(appLoc: "月份"), selection: $month) {
ForEach(1...12, id: \.self) { mo in ForEach(1...12, id: \.self) { mo in
Text(String(appLoc: "\(mo)")).tag(mo) Text(String(appLoc: "\(mo)")).tag(mo)
@@ -140,6 +181,41 @@ struct CustomReminderEditSheet: View {
} }
} }
private func subCaption(_ text: String) -> some View {
Text(text)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear)
}
/// (1...31,7 )
private var monthDayGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
ForEach(1...31, id: \.self) { d in
let on = monthDays.contains(d)
Button {
if on { monthDays.remove(d) } else { monthDays.insert(d) }
} label: {
Text("\(d)")
.font(.tjScaled( 12, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 30)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
.listRowBackground(Color.clear)
}
private var skipHint: some View { private var skipHint: some View {
Text(String(appLoc: "部分月份无此日,该月将跳过")) Text(String(appLoc: "部分月份无此日,该月将跳过"))
.font(.tjScaled( 11)) .font(.tjScaled( 11))
@@ -229,13 +305,18 @@ struct CustomReminderEditSheet: View {
if let r = reminder { if let r = reminder {
title = r.title title = r.title
note = r.note note = r.note
frequency = r.frequency frequencies = r.frequencies
weekdays = Set(r.weekdays) weekdays = Set(r.weekdays)
monthDays = Set(r.monthlyDays)
dayOfMonth = r.dayOfMonth dayOfMonth = r.dayOfMonth
month = r.month month = r.month
pickedTime = Calendar.current.date( pickedTime = Calendar.current.date(
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
) ?? .now ) ?? .now
} else {
// :( / )
title = prefillTitle
note = prefillNote
} }
} }
@@ -245,6 +326,7 @@ struct CustomReminderEditSheet: View {
let hour = cal.component(.hour, from: pickedTime) let hour = cal.component(.hour, from: pickedTime)
let minute = cal.component(.minute, from: pickedTime) let minute = cal.component(.minute, from: pickedTime)
let sortedDays = weekdays.sorted() let sortedDays = weekdays.sorted()
let sortedMonthDays = monthDays.sorted()
let target: CustomReminder let target: CustomReminder
if let r = reminder { if let r = reminder {
@@ -253,8 +335,9 @@ struct CustomReminderEditSheet: View {
r.hour = hour r.hour = hour
r.minute = minute r.minute = minute
r.weekdays = sortedDays r.weekdays = sortedDays
r.frequency = frequency r.frequencies = frequencies // frequenciesRaw(+ frequencyRaw)
r.dayOfMonth = dayOfMonth r.monthlyDays = sortedMonthDays // monthDays
r.dayOfMonth = dayOfMonth //
r.month = month r.month = month
r.updatedAt = .now r.updatedAt = .now
target = r target = r
@@ -265,10 +348,11 @@ struct CustomReminderEditSheet: View {
hour: hour, hour: hour,
minute: minute, minute: minute,
weekdays: sortedDays, weekdays: sortedDays,
frequency: frequency,
dayOfMonth: dayOfMonth, dayOfMonth: dayOfMonth,
month: month month: month
) )
new.frequencies = frequencies
new.monthlyDays = sortedMonthDays
ctx.insert(new) ctx.insert(new)
target = new target = new
} }

View File

@@ -0,0 +1,222 @@
import SwiftUI
/// : MNN(CPU/SME2,) MLX(GPU,), SME2
/// ; AI (prepare/generate)
struct InferenceSettingsView: View {
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
@State private var modelService = ModelDownloadService.shared
private var selected: EnginePreference {
EnginePreference(rawValue: engineRaw) ?? .auto
}
/// (MNN MLX )
private var modelReady: Bool {
modelService.states[.mnnLLM]?.phase == .ready
|| modelService.states[.llm]?.phase == .ready
}
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
selfTestSection
noteCard
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.onAppear { modelService.refreshStates() }
}
/// : prompt,
///
@ViewBuilder
private var selfTestSection: some View {
if modelReady {
NavigationLink {
ModelSelfTestView()
} label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: "gauge.with.needle")
.font(.tjScaled(18))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("性能自检")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
}
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled(13, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.tjCard()
}
.buttonStyle(.plain)
} else {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: "gauge.with.needle")
.font(.tjScaled(18))
.foregroundStyle(Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("性能自检")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("模型未就绪,前往「模型管理」下载后可用")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
}
Spacer()
}
.padding(14)
.tjCard()
.opacity(0.55)
}
}
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 {
@@ -265,6 +282,6 @@ struct MeView: View {
.modelContainer(for: [ .modelContainer(for: [
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self, UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self, Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self,
CustomMonitorMetric.self, CustomMonitorMetric.self, Medication.self,
], inMemory: true) ], inMemory: true)
} }

View File

@@ -15,32 +15,19 @@ 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 {
NavigationLink {
ModelSelfTestView()
} label: {
HStack(spacing: 6) {
Image(systemName: "play.circle")
Text("运行推理自检")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
}
if let importError { if let importError {
Text(importError) Text(importError)
.font(.tjScaled( 12)) .font(.tjScaled( 12))
@@ -146,7 +133,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 +141,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 +185,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 +200,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

@@ -1,11 +1,13 @@
import SwiftUI import SwiftUI
/// : LLM prompt, + tok/s /// : prompt,(MNN·SME2 / MNN·NEON / MLX·GPU)
/// · , /// prefill / decode , (§12 2/6)
struct ModelSelfTestView: View { struct ModelSelfTestView: View {
@State private var output = "" @State private var output = ""
@State private var phase: Phase = .idle @State private var phase: Phase = .idle
@State private var rate: Double = 0 @State private var rate: Double = 0
@State private var lastResult: BenchmarkResult?
@State private var history: [String: BenchmarkResult] = [:]
private enum Phase: Equatable { private enum Phase: Equatable {
case idle, loading, running, done, failed(String) case idle, loading, running, done, failed(String)
@@ -21,8 +23,6 @@ struct ModelSelfTestView: View {
} }
} }
private let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。"
private var isBusy: Bool { phase == .loading || phase == .running } private var isBusy: Bool { phase == .loading || phase == .running }
private var statusColor: Color { private var statusColor: Color {
@@ -34,19 +34,9 @@ struct ModelSelfTestView: View {
} }
var body: some View { var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) { promptCard
Text("测试 PROMPT")
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(prompt)
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
HStack { HStack {
Text(phase.label) Text(phase.label)
@@ -64,11 +54,79 @@ struct ModelSelfTestView: View {
Button { Button {
Task { await run() } Task { await run() }
} label: { } label: {
Text(isBusy ? "运行中…" : "运行推理自检").frame(maxWidth: .infinity) Text(isBusy ? "运行中…" : "运行性能自检").frame(maxWidth: .infinity)
} }
.buttonStyle(TjPrimaryButton()) .buttonStyle(TjPrimaryButton())
.disabled(isBusy) .disabled(isBusy)
if isBusy { AIFlowBar() }
if let r = lastResult { statsCard(r) }
outputCard
if !history.isEmpty { historyCard }
}
.padding(16)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("性能自检")
.navigationBarTitleDisplayMode(.inline)
.onAppear { history = BenchmarkService.load() }
}
private var promptCard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("测试 PROMPT")
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(BenchmarkService.fixedPrompt)
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
}
private func statsCard(_ r: BenchmarkResult) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("本次结果")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
TjBadge(text: r.backendLabel, style: .leaf)
}
HStack(spacing: 0) {
metric(String(appLoc: "读入"), r.prefillTokensPerSecond > 0
? String(format: "%.0f tok/s", r.prefillTokensPerSecond) : "")
metric(String(appLoc: "生成"), String(format: "%.1f tok/s", r.decodeTokensPerSecond))
metric(String(appLoc: "总耗时"), String(format: "%.1fs", r.totalSeconds))
}
Text(String(appLoc: "prompt \(r.promptTokens) tok · 生成 \(r.genTokens) tok · 100% 本地"))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
}
private func metric(_ label: String, _ value: String) -> some View {
VStack(spacing: 3) {
Text(value)
.font(.tjScaled( 15, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
Text(label)
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity)
}
private var outputCard: some View {
ScrollView { ScrollView {
Text(output.isEmpty ? "(暂无输出)" : output) Text(output.isEmpty ? "(暂无输出)" : output)
.font(.system(.footnote, design: .monospaced)) .font(.system(.footnote, design: .monospaced))
@@ -77,7 +135,7 @@ struct ModelSelfTestView: View {
.textSelection(.enabled) .textSelection(.enabled)
.padding(12) .padding(12)
} }
.frame(maxHeight: 280) .frame(maxHeight: 220)
.background( .background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper) .fill(Tj.Palette.paper)
@@ -86,27 +144,52 @@ struct ModelSelfTestView: 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)
) )
Spacer()
} }
.padding(16)
.background(Tj.Palette.sand.ignoresSafeArea()) private var historyCard: some View {
.navigationTitle("推理自检") VStack(alignment: .leading, spacing: 10) {
.navigationBarTitleDisplayMode(.inline) Text("各引擎实测对比")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
ForEach(history.keys.sorted(), id: \.self) { key in
if let r = history[key] {
HStack {
Text(key)
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Spacer()
Text(String(format: String(appLoc: "生成 %.1f tok/s"), r.decodeTokensPerSecond))
.font(.tjScaled( 12, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
Text(r.date.formatted(.dateTime.month().day()))
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
}
}
}
Text("在「我的 · 推理引擎」切换引擎后再跑一次,即可对比 SME2 与 GPU。")
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
} }
@MainActor @MainActor
private func run() async { private func run() async {
output = "" output = ""
rate = 0 rate = 0
lastResult = nil
phase = .loading phase = .loading
do { do {
try await AIRuntime.shared.prepare() let result = try await BenchmarkService.shared.run { piece, r in
phase = .running output += piece
for try await chunk in await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200) { if r > 0 { rate = r }
output += chunk.text if phase == .loading { phase = .running }
rate = chunk.decodeRate
} }
lastResult = result
history = BenchmarkService.load()
phase = .done phase = .done
} catch { } catch {
phase = .failed(error.localizedDescription) phase = .failed(error.localizedDescription)

View File

@@ -0,0 +1,411 @@
import SwiftUI
import SwiftData
/// · : master ( / / / )
/// ; · ( `DiaryEntry.medicationTag` , + )
/// / `CustomMetricsListView`; `CustomReminderEditSheet`
struct MedicationLibraryView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query(sort: \Medication.updatedAt, order: .reverse)
private var medications: [Medication]
/// sheet ();push ,
var presentedAsSheet: Bool = false
@State private var editingTarget: MedicationEditTarget?
@State private var showScan = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
hintBanner
if medications.isEmpty {
emptyState
} else {
ForEach(medications) { m in
Button {
editingTarget = MedicationEditTarget(medication: m)
} label: {
row(m)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 32)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("药品库")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if presentedAsSheet {
ToolbarItem(placement: .topBarLeading) {
Button(String(appLoc: "完成")) { dismiss() }
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
Button { showScan = true } label: {
Image(systemName: "camera")
.font(.tjScaled( 16, weight: .semibold))
}
.accessibilityLabel(String(appLoc: "拍药盒添加"))
Button { editingTarget = MedicationEditTarget(medication: nil) } label: {
Image(systemName: "plus")
.font(.tjScaled( 16, weight: .semibold))
}
.accessibilityLabel(String(appLoc: "手动添加"))
}
}
}
.sheet(item: $editingTarget) { target in
MedicationEditSheet(existing: target.medication)
}
.fullScreenCover(isPresented: $showScan) {
// OCR + LLM ()
MedicationScanFlow(
onSave: { meds, images in
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
},
onClose: { showScan = false }
)
}
}
// MARK: - subviews
private var hintBanner: some View {
HStack(spacing: 10) {
Image(systemName: "info.circle.fill")
.foregroundStyle(Tj.Palette.text3)
Text("药品库是你的常用药清单。记录某次服用请到「写日记 · 用药」,可填剂量和时间。")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2.opacity(0.5))
)
}
private var emptyState: some View {
VStack(spacing: 14) {
Spacer(minLength: 40)
TjPlaceholder(label: String(appLoc: "药品库还是空的"))
.frame(width: 220, height: 130)
Text("右上角拍药盒或 + 手动添加")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.frame(maxWidth: .infinity)
}
private func row(_ m: Medication) -> some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.leafSoft)
Image(systemName: "pills.fill")
.font(.tjScaled( 17, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 3) {
Text(m.name)
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if !m.detailLine.isEmpty {
Text(m.detailLine)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
}
Spacer(minLength: 8)
if !m.assets.isEmpty {
Text("📷 \(m.assets.count)")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Image(systemName: "chevron.right")
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
}
/// `medication == nil` ;`id` UUID sheet
private struct MedicationEditTarget: Identifiable {
let id = UUID()
let medication: Medication?
}
/// / ( `CustomReminderEditSheet`: @State ,)
private struct MedicationEditSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// nil =
let existing: Medication?
@State private var name = ""
@State private var strength = ""
@State private var usage = ""
@State private var note = ""
@State private var hydrated = false
/// ;nil =
@State private var viewerStart: PhotoIndex?
/// : MedicationLogSheet,
@State private var showLog = false
private var isEditing: Bool { existing != nil }
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
Form {
if isEditing {
Section {
Button { showLog = true } label: {
HStack(spacing: 10) {
Image(systemName: "pills.circle.fill")
.font(.tjScaled( 18))
.foregroundStyle(Tj.Palette.ink)
Text("记录一次服用")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} footer: {
Text("记某次吃药的剂量和时间,会进「记录 · 用药」时间线。不提供剂量建议。")
}
}
if let m = existing, !m.assets.isEmpty {
Section {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(m.assets.enumerated()), id: \.offset) { idx, asset in
Button {
viewerStart = PhotoIndex(index: idx)
} label: {
MedicationAssetThumb(asset: asset)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
} header: {
Text(String(appLoc: "原图\(m.assets.count)"))
} footer: {
Text("点图片可放大查看。原图均存在本机加密目录,不上传。")
}
}
Section {
TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $name)
.foregroundStyle(Tj.Palette.text)
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $strength)
.foregroundStyle(Tj.Palette.text2)
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $usage)
.foregroundStyle(Tj.Palette.text2)
} footer: {
Text("仅作清单记录,不提供任何用药或剂量建议。")
}
Section {
TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical)
.lineLimit(1...3)
.foregroundStyle(Tj.Palette.text2)
}
if isEditing {
Section {
Button(role: .destructive) { deleteMedication() } label: {
Label(String(appLoc: "从药品库删除"), systemImage: "trash")
}
}
}
}
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle(isEditing ? String(appLoc: "编辑药品") : String(appLoc: "添加药品"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String(appLoc: "取消")) { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(String(appLoc: "保存")) { save() }
.fontWeight(.semibold)
.disabled(!canSave)
}
}
.onAppear(perform: hydrate)
.fullScreenCover(item: $viewerStart) { start in
if let m = existing {
MedicationPhotoViewer(assets: m.assets, startIndex: start.index)
}
}
.sheet(isPresented: $showLog) {
MedicationLogSheet(preselected: existing)
}
}
}
private func hydrate() {
guard !hydrated else { return }
hydrated = true
if let m = existing {
name = m.name
strength = m.strength
usage = m.usage
note = m.note ?? ""
}
}
private func save() {
guard canSave else { return }
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
let s = strength.trimmingCharacters(in: .whitespacesAndNewlines)
let u = usage.trimmingCharacters(in: .whitespacesAndNewlines)
let nt = note.trimmingCharacters(in: .whitespacesAndNewlines)
if let m = existing {
m.name = n
m.strength = s
m.usage = u
m.note = nt.isEmpty ? nil : nt
m.updatedAt = .now
} else {
let med = Medication(name: n, strength: s, usage: u, note: nt.isEmpty ? nil : nt)
ctx.insert(med)
}
try? ctx.save()
dismiss()
}
private func deleteMedication() {
guard let m = existing else { return }
// Vault JPEG(cascade Asset , unlink,§6 )
for a in m.assets {
try? FileVault.shared.remove(relativePath: a.relativePath)
}
ctx.delete(m)
try? ctx.save()
dismiss()
}
}
// MARK: -
/// (`.fullScreenCover(item:)` Identifiable)
private struct PhotoIndex: Identifiable {
let id = UUID()
let index: Int
}
/// / Vault (, EvidenceImagePage )
private struct MedicationAssetThumb: View {
let asset: Asset
var body: some View {
VaultImage(relativePath: asset.relativePath, maxPixel: 500) { img in
Image(uiImage: img).resizable().scaledToFill()
} placeholder: { isLoading in
if isLoading {
Tj.Palette.paper
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
}
}
.frame(width: 150, height: 150)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
}
/// ()
private struct MedicationPhotoViewer: View {
@Environment(\.dismiss) private var dismiss
let assets: [Asset]
@State private var selection: Int
init(assets: [Asset], startIndex: Int) {
self.assets = assets
_selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0)))
}
var body: some View {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
TabView(selection: $selection) {
ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { img in
Image(uiImage: img).resizable().scaledToFit()
} placeholder: { isLoading in
if isLoading {
ProgressView().tint(.white)
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
}
}
.tag(idx)
}
}
.tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never))
.ignoresSafeArea()
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(Circle().fill(.black.opacity(0.4)))
}
.padding(.trailing, 18)
.padding(.top, 14)
}
}
}
#Preview {
NavigationStack {
MedicationLibraryView(presentedAsSheet: true)
}
.modelContainer(for: [Medication.self, Asset.self], inMemory: true)
}

View File

@@ -0,0 +1,452 @@
import SwiftUI
import SwiftData
import UIKit
/// :/( 5 ,) Vision OCR LLM ()
/// : · · ·
/// `MedicationArchiver`: `Medication`(),
/// · `medicationTag` DiaryEntry,/(§1)
///
/// :
/// ```
/// idle(/) 1 collecting(:/5//)
///
///
/// recognizing( OCR + LLM) confirm() onSave
/// / confirm( + )
/// ```
struct MedicationScanFlow: View {
/// (, )( MedicationArchiver.archive(medications:))
let onSave: ([ParsedMedication], [UIImage]) -> Void
let onClose: () -> Void
/// 5 (//)
static let maxImages = 5
@State private var phase: Phase = .idle
/// /, collecting recognizing confirm ,
@State private var images: [UIImage] = []
/// () OCR ;
@State private var recognizeIndex = 0
/// collecting /
@State private var showMoreCapture = false
/// :,
@State private var recognitionTask: Task<Void, Never>?
enum Phase {
case idle
case collecting
case recognizing
case confirm(items: [EditableMedication], warning: String?)
}
struct EditableMedication: Identifiable {
let id = UUID()
var name: String
var strength: String
var usage: String
var include: Bool = true
}
private var remainingSlots: Int { max(0, Self.maxImages - images.count) }
var body: some View {
content
.background(Tj.Palette.sand.ignoresSafeArea())
}
@ViewBuilder
private var content: some View {
switch phase {
case .idle:
// ignoresSafeArea:,
initialCaptureEntry
case .collecting:
collectingView
.fullScreenCover(isPresented: $showMoreCapture) { moreCaptureSheet }
case .recognizing:
recognizingView
case .confirm(let items, let warning):
NavigationStack {
MedicationConfirmView(
items: items,
warning: warning,
onSave: { saveItems($0) },
onRetake: { images = []; phase = .idle }
)
.navigationTitle("核对药品")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { onClose() }
.foregroundStyle(Tj.Palette.text)
}
}
}
}
}
// MARK: - :()/ ()
/// :/ collecting
@ViewBuilder
private var initialCaptureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { picked in
appendImages(picked)
if images.isEmpty { onClose() } else { phase = .collecting }
},
onCancel: onClose
)
#else
SingleShotCameraView(
onCapture: { appendImages([$0]); phase = .collecting },
onCancel: onClose
)
#endif
}
/// collecting /
@ViewBuilder
private var moreCaptureSheet: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { picked in appendImages(picked); showMoreCapture = false },
onCancel: { showMoreCapture = false }
)
#else
SingleShotCameraView(
onCapture: { appendImages([$0]); showMoreCapture = false },
onCancel: { showMoreCapture = false }
)
#endif
}
private func appendImages(_ new: [UIImage]) {
guard remainingSlots > 0 else { return }
images.append(contentsOf: new.prefix(remainingSlots))
}
// MARK: - ( N : / / )
private var collectingView: some View {
VStack(spacing: 0) {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 12)], spacing: 12) {
ForEach(Array(images.enumerated()), id: \.offset) { idx, img in
let isPick = idx == recognizeIndex
ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(isPick ? Tj.Palette.ink : Color.clear, lineWidth: 3)
)
.overlay(alignment: .bottomLeading) {
if isPick {
Text("识别此张")
.font(.tjScaled(10, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Capsule().fill(Tj.Palette.ink))
.padding(5)
}
}
// ()
.onTapGesture { recognizeIndex = idx }
Button {
images.remove(at: idx)
// :;
if images.isEmpty {
recognizeIndex = 0
phase = .idle
} else if idx < recognizeIndex {
recognizeIndex -= 1
} else if recognizeIndex >= images.count {
recognizeIndex = images.count - 1
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.tjScaled(20))
.foregroundStyle(.white, .black.opacity(0.5))
.padding(4)
}
.buttonStyle(.plain)
}
}
if remainingSlots > 0 {
Button { showMoreCapture = true } label: {
VStack(spacing: 6) {
Image(systemName: "plus")
.font(.tjScaled(22, weight: .medium))
Text("继续拍")
.font(.tjScaled(12))
}
.foregroundStyle(Tj.Palette.text2)
.frame(width: 96, height: 96)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1, dash: [4]))
)
}
.buttonStyle(.plain)
}
}
.padding(18)
}
VStack(spacing: 8) {
Text("已拍 \(images.count)/\(Self.maxImages) 张 · 可拍正面、背面、说明书")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
if images.count > 1 {
Text("点照片选「识别此张」· 一次记一种药")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.ink)
}
Text("照片与文字均不离开设备")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
Button {
startRecognition()
} label: {
Text("开始识别")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(images.isEmpty)
.opacity(images.isEmpty ? 0.4 : 1)
}
.padding(.horizontal, 18)
.padding(.bottom, 12)
}
.overlay(alignment: .topLeading) {
flowCancelButton { onClose() }
}
}
private var recognizingView: some View {
VStack(spacing: 18) {
if images.indices.contains(recognizeIndex) {
Image(uiImage: images[recognizeIndex])
.resizable()
.scaledToFit()
.frame(maxHeight: 320)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.padding(.horizontal, 24)
}
ProgressView().tint(Tj.Palette.ink)
Text("正在本地识别药品…")
.font(.tjScaled(14))
.foregroundStyle(Tj.Palette.text2)
Text("照片与文字均不离开设备")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 退,(§3.2 )
.overlay(alignment: .topLeading) {
flowCancelButton {
recognitionTask?.cancel()
onClose()
}
}
}
private func flowCancelButton(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
Text("取消")
.font(.tjScaled(16, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.padding(.horizontal, 18)
.frame(minHeight: 44)
.background(Capsule().fill(Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.padding(.leading, 16)
.padding(.top, 8)
}
// MARK: - ( OCR LLM )
private func startRecognition() {
guard images.indices.contains(recognizeIndex) else { return }
phase = .recognizing
let target = images[recognizeIndex]
recognitionTask = Task {
let (items, warning) = await recognize(target)
guard !Task.isCancelled else { return } // : phase
await MainActor.run {
// :(§3.2 退线)
if items.isEmpty {
phase = .confirm(items: [EditableMedication(name: "", strength: "", usage: "")],
warning: warning ?? String(appLoc: "没读出药品,可以手动填写"))
} else {
phase = .confirm(items: items, warning: warning)
}
}
}
}
private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) {
do {
//
let text = (try? await OCRService.recognizeText(in: image))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if text.isEmpty {
return ([], String(appLoc: "没识别到文字,拍清楚一点再试"))
}
let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: text)
// :使,
let items = parsed.prefix(1).map {
EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage)
}
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
} catch CaptureError.modelNotReady {
return ([], String(appLoc: "AI 模型未就绪,可以手动填写"))
} catch let CaptureError.parseFailed(msg) {
return ([], String(appLoc: "解析失败:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
return ([], String(appLoc: "识别失败:\(msg)"))
} catch {
return ([], String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
// MARK: -
private func saveItems(_ items: [EditableMedication]) {
let meds = items
.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty }
.map {
ParsedMedication(name: $0.name.trimmingCharacters(in: .whitespaces),
strength: $0.strength.trimmingCharacters(in: .whitespaces),
usage: $0.usage.trimmingCharacters(in: .whitespaces))
}
// (),
onSave(meds, images)
onClose()
}
}
// MARK: - (MainActor,SwiftData View ctx ,§3.1)
/// ,( · ):
/// `Medication`(), name+strength ;** currentMedications**
/// · `DiaryEntry.medicationTag`
@MainActor
enum MedicationArchiver {
static func archive(medications: [ParsedMedication], images: [UIImage] = [], in ctx: ModelContext) {
guard !medications.isEmpty else { return }
// Vault(§5/§6: Application Support/Vault,)
// , JPEG Asset
// cascade
let savedAssets = images
.prefix(MedicationScanFlow.maxImages)
.compactMap { try? FileVault.shared.writeJPEG($0) }
let existing = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
var attachedImages = false
for m in medications {
// : name+strength / ,
if let dup = existing.first(where: { $0.name == m.name && $0.strength == m.strength }) {
if dup.usage.isEmpty, !m.usage.isEmpty { dup.usage = m.usage }
dup.updatedAt = .now
continue
}
let med = Medication(name: m.name, strength: m.strength, usage: m.usage)
if !attachedImages {
for s in savedAssets {
let asset = Asset(relativePath: s.relativePath, bytes: s.bytes)
ctx.insert(asset)
med.assets.append(asset)
}
attachedImages = true
}
ctx.insert(med)
}
try? ctx.save()
}
}
// MARK: -
private struct MedicationConfirmView: View {
@State var items: [MedicationScanFlow.EditableMedication]
let warning: String?
let onSave: ([MedicationScanFlow.EditableMedication]) -> Void
let onRetake: () -> Void
private var canSave: Bool {
items.contains {
$0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
}
}
var body: some View {
VStack(spacing: 0) {
Form {
if let warning {
Section {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.tjScaled(13))
.foregroundStyle(Tj.Palette.amber)
}
}
ForEach($items) { $item in
Section {
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
.foregroundStyle(Tj.Palette.text)
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength)
.foregroundStyle(Tj.Palette.text2)
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage)
.foregroundStyle(Tj.Palette.text2)
}
}
Section {
Button {
onRetake()
} label: {
Label("重拍", systemImage: "camera")
.foregroundStyle(Tj.Palette.ink)
}
} footer: {
Text("一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。")
}
}
.scrollContentBackground(.hidden)
Button {
onSave(items)
} label: {
Text("存入药品库")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(!canSave)
.opacity(canSave ? 1 : 0.4)
.padding(.horizontal, 18)
.padding(.bottom, 12)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
}
#Preview {
MedicationScanFlow(onSave: { _, _ in }, onClose: {})
}

View File

@@ -87,8 +87,6 @@ private struct ProfileEditForm: View {
items: $profile.allergies) items: $profile.allergies)
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"), StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
items: $profile.familyHistory) items: $profile.familyHistory)
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
items: $profile.currentMedications)
} }
.navigationTitle("个人资料") .navigationTitle("个人资料")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -370,17 +368,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 +403,51 @@ private struct ChronicSection: View {
} }
} }
// MARK: - ( + + )
/// `TextField + ` :(1~4 ),
/// () / / /
private struct EntryInputField: View {
let placeholder: String
@Binding var text: String
var onSubmit: () -> Void
private var canSubmit: Bool {
!text.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
TextField(placeholder, text: $text, axis: .vertical)
.lineLimit(1...5)
.foregroundStyle(Tj.Palette.text) // : .primary
.tint(Tj.Palette.ink)
.frame(minHeight: 40, alignment: .top) // , axis
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
Button {
if canSubmit { onSubmit() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.tjScaled(28))
.foregroundStyle(canSubmit ? Tj.Palette.ink : Tj.Palette.text3)
}
.buttonStyle(.plain)
.disabled(!canSubmit)
}
.padding(.vertical, 2)
}
}
// MARK: - / / ( @State,) // MARK: - / / ( @State,)
private struct StringListSection: View { private struct StringListSection: View {
@@ -431,16 +471,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
/// ///
/// : /// :
@@ -32,8 +32,9 @@ struct QuickRegionCaptureFlow: View {
private var content: some View { private var content: some View {
switch phase { switch phase {
case .idle: case .idle:
// ignoresSafeArea:/,
// ,
captureEntry captureEntry
.ignoresSafeArea()
case .adjust(let image): case .adjust(let image):
RegionAdjustView( RegionAdjustView(
@@ -45,7 +46,6 @@ struct QuickRegionCaptureFlow: View {
onRetake: { phase = .idle }, onRetake: { phase = .idle },
onCancel: { onClose() } onCancel: { onClose() }
) )
.ignoresSafeArea()
case .confirm(let image, let items, let warning): case .confirm(let image, let items, let warning):
NavigationStack { NavigationStack {
@@ -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 {
@@ -50,7 +50,11 @@ struct RegionAdjustView: View {
Text("取消") Text("取消")
.font(.tjScaled( 16, weight: .medium)) .font(.tjScaled( 16, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 12)
.frame(minWidth: 60, minHeight: 44) // HIG ,
.contentShape(Rectangle())
} }
.buttonStyle(.plain)
Spacer() Spacer()
Text("框住异常指标") Text("框住异常指标")
.font(.tjScaled( 16, weight: .semibold)) .font(.tjScaled( 16, weight: .semibold))
@@ -63,10 +67,14 @@ struct RegionAdjustView: View {
Text("重拍") Text("重拍")
.font(.tjScaled( 16, weight: .medium)) .font(.tjScaled( 16, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 12)
.frame(minWidth: 60, minHeight: 44)
.contentShape(Rectangle())
} }
.buttonStyle(.plain)
} }
.padding(.horizontal, 18) .padding(.horizontal, 8)
.padding(.vertical, 12) .padding(.vertical, 4)
.background(Color.black) .background(Color.black)
} }

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)
@@ -49,18 +49,20 @@ struct SingleShotCameraView: View {
Text("取消") Text("取消")
.font(.tjScaled( 16, weight: .medium)) .font(.tjScaled( 16, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 14) .padding(.horizontal, 18)
.padding(.vertical, 8) .frame(minHeight: 44) // HIG
.background(Capsule().fill(.black.opacity(0.35))) .background(Capsule().fill(.black.opacity(0.35)))
.contentShape(Capsule())
} }
.buttonStyle(.plain)
Spacer() Spacer()
} }
.padding(.horizontal, 18) .padding(.horizontal, 16)
.padding(.top, 8) .padding(.top, 8)
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 +99,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)
@@ -185,6 +187,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
private var previewLayer: AVCaptureVideoPreviewLayer? private var previewLayer: AVCaptureVideoPreviewLayer?
private var setupDone = false private var setupDone = false
private var captureCompletion: ((UIImage?) -> Void)? private var captureCompletion: ((UIImage?) -> Void)?
/// , lockForConfiguration
private var device: AVCaptureDevice?
/// ;,
private weak var focusIndicator: UIView?
override func didMoveToWindow() { override func didMoveToWindow() {
super.didMoveToWindow() super.didMoveToWindow()
@@ -203,6 +209,20 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
return return
} }
session.addInput(input) session.addInput(input)
self.device = device
// :/,,
// ;
if (try? device.lockForConfiguration()) != nil {
if device.isFocusModeSupported(.continuousAutoFocus) {
device.focusMode = .continuousAutoFocus
}
if device.isAutoFocusRangeRestrictionSupported {
device.autoFocusRangeRestriction = .near
}
device.unlockForConfiguration()
}
if session.canAddOutput(output) { session.addOutput(output) } if session.canAddOutput(output) { session.addOutput(output) }
session.commitConfiguration() session.commitConfiguration()
@@ -213,6 +233,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
self.previewLayer = preview self.previewLayer = preview
applyPortrait(preview.connection) applyPortrait(preview.connection)
// :,
let tap = UITapGestureRecognizer(target: self, action: #selector(handleFocusTap(_:)))
addGestureRecognizer(tap)
DispatchQueue.global(qos: .userInitiated).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning() self?.session.startRunning()
} }
@@ -231,6 +255,61 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
previewLayer?.frame = bounds previewLayer?.frame = bounds
} }
// MARK: -
@objc private func handleFocusTap(_ gr: UITapGestureRecognizer) {
guard let previewLayer, device != nil else { return }
let point = gr.location(in: self)
// ( videoGravity/)
let devicePoint = previewLayer.captureDevicePointConverted(fromLayerPoint: point)
focus(at: devicePoint)
showFocusIndicator(at: point)
}
/// /,;
private func focus(at devicePoint: CGPoint) {
guard let device, (try? device.lockForConfiguration()) != nil else { return }
if device.isFocusPointOfInterestSupported {
device.focusPointOfInterest = devicePoint
}
if device.isFocusModeSupported(.autoFocus) {
device.focusMode = .autoFocus // ,
}
if device.isExposurePointOfInterestSupported {
device.exposurePointOfInterest = devicePoint
}
if device.isExposureModeSupported(.autoExpose) {
device.exposureMode = .autoExpose
}
device.unlockForConfiguration()
}
///
private func showFocusIndicator(at point: CGPoint) {
focusIndicator?.removeFromSuperview()
let box = UIView(frame: CGRect(x: 0, y: 0, width: 76, height: 76))
box.center = point
box.backgroundColor = .clear
box.layer.borderColor = UIColor.systemYellow.cgColor
box.layer.borderWidth = 1.5
box.layer.cornerRadius = 6
box.isUserInteractionEnabled = false
box.alpha = 0
box.transform = CGAffineTransform(scaleX: 1.35, y: 1.35)
addSubview(box)
focusIndicator = box
UIView.animate(withDuration: 0.2, animations: {
box.alpha = 1
box.transform = .identity
}, completion: { _ in
UIView.animate(withDuration: 0.3, delay: 0.7, options: []) {
box.alpha = 0
} completion: { _ in
box.removeFromSuperview()
}
})
}
func capture(completion: @escaping (UIImage?) -> Void) { func capture(completion: @escaping (UIImage?) -> Void) {
guard session.isRunning else { completion(nil); return } guard session.isRunning else { completion(nil); return }
captureCompletion = completion captureCompletion = completion

View File

@@ -1,22 +1,32 @@
import SwiftUI import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable { enum RecordKind: String, Identifiable, CaseIterable {
case quick, indicator, healthExport, archive, diary, symptom, reminder case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary
var id: String { rawValue } var id: String { rawValue }
/// RecordSheet () enum , /// RecordSheet () enum ,
/// :`.quick`() `.indicator`(), /// :`.quick`() `.indicator`();
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive] /// `.symptom`() `.diary`(),;
/// `.medicationLibrary`()/,Tab ,
/// (,)
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
/// pill( subtitle,"/")
/// :,( ProfileEditView presets )
static var diaryFeaturePills: [String] {
[String(appLoc: "写日记"), String(appLoc: "拍药盒"), String(appLoc: "记症状")]
}
var title: String { 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: "体检报告归档")
case .diary: return String(appLoc: "健康日记") case .diary: return String(appLoc: "健康日记")
case .symptom: return String(appLoc: "记录症状") case .symptom: return String(appLoc: "记录症状")
case .reminder: return String(appLoc: "开启一个提醒") case .reminder: return String(appLoc: "开启一个提醒")
case .medicationLibrary: return String(appLoc: "药品库")
} }
} }
var subtitle: String { var subtitle: String {
@@ -25,9 +35,10 @@ enum RecordKind: String, Identifiable, CaseIterable {
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: "完整保存整份报告(可多页)")
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助") case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束") case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒") case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加")
} }
} }
var icon: String { var icon: String {
@@ -39,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return "heart.text.square" case .diary: return "heart.text.square"
case .symptom: return "waveform.path.ecg" case .symptom: return "waveform.path.ecg"
case .reminder: return "bell.badge" case .reminder: return "bell.badge"
case .medicationLibrary: return "pills.fill"
} }
} }
var accent: Color { var accent: Color {
@@ -50,6 +62,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return Tj.Palette.leaf case .diary: return Tj.Palette.leaf
case .symptom: return Tj.Palette.amber case .symptom: return Tj.Palette.amber
case .reminder: return Tj.Palette.leaf case .reminder: return Tj.Palette.leaf
case .medicationLibrary: return Tj.Palette.ink
} }
} }
} }
@@ -76,7 +89,7 @@ struct RecordSheet: View {
} }
.padding(.bottom, 14) .padding(.bottom, 14)
// ScrollView :6 detent , // ScrollView : detent ,
ScrollView { ScrollView {
VStack(spacing: 10) { VStack(spacing: 10) {
ForEach(RecordKind.displayOrder) { kind in ForEach(RecordKind.displayOrder) { kind in
@@ -93,14 +106,28 @@ struct RecordSheet: View {
} }
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 3) {
Text(kind.title) Text(kind.title)
.font(.tjScaled( 15, weight: .semibold)) .font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
if kind == .diary {
// :/, pill
HStack(spacing: 5) {
ForEach(RecordKind.diaryFeaturePills, id: \.self) { pill in
Text(pill)
.font(.tjScaled( 10, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Capsule().fill(Tj.Palette.sand2))
}
}
} else {
Text(kind.subtitle) Text(kind.subtitle)
.font(.tjScaled( 12)) .font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
} }
}
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.tjScaled( 14, weight: .medium)) .font(.tjScaled( 14, weight: .medium))
@@ -111,6 +138,17 @@ struct RecordSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
// : + ,
HStack(spacing: 5) {
Image(systemName: "mic.fill")
.font(.tjScaled( 10))
Text("下次试试长按 + ,直接说出想记的内容")
.font(.tjScaled( 11))
}
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity)
.padding(.top, 6)
} }
.padding(.bottom, 22) .padding(.bottom, 22)
} }

View File

@@ -0,0 +1,282 @@
import SwiftUI
import UIKit
/// + : (SpeechDictationService)
/// LLM (VoiceIntentService) RootView
///
/// :
/// ```
/// requesting() recording() classifying onResolve(intent)
/// denied / failed( / )
/// ```
/// : requiresOnDeviceRecognition, LLM
struct VoiceCommandSheet: View {
/// :RootView sheet
let onResolve: (VoiceIntent) -> Void
/// :(RecordSheet)
let onOpenMenu: () -> Void
@Environment(\.dismiss) private var dismiss
enum Phase: Equatable {
case requesting
case denied
case recording
case classifying
case failed(message: String)
}
@State private var phase: Phase = .requesting
@State private var transcript = ""
@State private var seconds = 0
/// @State ( DiaryQuickSheet ,)
@State private var dictation = SpeechDictationService()
@State private var ticker: Task<Void, Never>?
/// 20s :,
private let maxSeconds = 20
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 16)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("说出想记的内容")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("比如:记一下血压 / 我头疼 / 拍个药盒")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("全程本机")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
content
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal, 20)
buttons
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.fraction(0.5)])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.task { await begin() }
.onDisappear {
ticker?.cancel()
dictation.abort()
}
}
// MARK: -
@ViewBuilder
private var content: some View {
switch phase {
case .requesting:
ProgressView().tint(Tj.Palette.ink)
.frame(maxWidth: .infinity)
.padding(.top, 30)
case .denied:
VStack(spacing: 10) {
Image(systemName: "mic.slash")
.font(.tjScaled( 30))
.foregroundStyle(Tj.Palette.text3)
Text("需要麦克风与语音识别权限")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("语音和文字都只在本机处理,不会上传。")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Button("前往设置") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
case .recording:
VStack(spacing: 14) {
HStack(spacing: 8) {
Circle()
.fill(Tj.Palette.brick)
.frame(width: 8, height: 8)
Text("正在听 · \(seconds)s")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
}
transcriptBox(placeholder: String(appLoc: "请开口说话…"))
}
case .classifying:
VStack(spacing: 14) {
HStack(spacing: 8) {
ProgressView().tint(Tj.Palette.ink)
Text("正在理解…")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
}
transcriptBox(placeholder: "")
}
case .failed(let message):
VStack(spacing: 10) {
Image(systemName: "questionmark.bubble")
.font(.tjScaled( 28))
.foregroundStyle(Tj.Palette.text3)
Text(message)
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
.multilineTextAlignment(.center)
if !transcript.isEmpty {
Text("\(transcript)")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 12)
}
}
private func transcriptBox(placeholder: String) -> some View {
ScrollView(showsIndicators: false) {
Text(transcript.isEmpty ? placeholder : transcript)
.font(.tjScaled( 15))
.foregroundStyle(transcript.isEmpty ? Tj.Palette.text3 : Tj.Palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minHeight: 64, maxHeight: 110)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
// MARK: -
@ViewBuilder
private var buttons: some View {
switch phase {
case .recording:
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button("说完了") { finishRecording() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
}
case .failed:
HStack(spacing: 12) {
Button("打开新建菜单") { onOpenMenu() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 14, horizontalPadding: 14))
Button("再说一次") { Task { await begin() } }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14, horizontalPadding: 18))
}
case .denied:
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
case .requesting, .classifying:
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
}
}
// MARK: -
private func begin() async {
ticker?.cancel()
transcript = ""
seconds = 0
guard SpeechDictationService.isAvailable else {
phase = .failed(message: String(appLoc: "本机不支持端侧语音识别,试试下面的新建菜单"))
return
}
phase = .requesting
guard await dictation.requestAuthorization() else {
phase = .denied
return
}
do {
try dictation.start { transcript = $0 }
phase = .recording
startTicker()
} catch {
phase = .failed(message: error.localizedDescription)
}
}
private func startTicker() {
ticker = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard phase == .recording else { return }
seconds += 1
if seconds >= maxSeconds {
finishRecording()
return
}
}
}
}
private func finishRecording() {
guard phase == .recording else { return }
ticker?.cancel()
// :stop() /,
// ,退
let live = transcript
phase = .classifying
Task {
let finalText = await dictation.stop()
let text = finalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? live
: finalText
transcript = text
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
phase = .failed(message: String(appLoc: "没听到内容,再试一次?"))
return
}
if let intent = await VoiceIntentService.classify(trimmed) {
onResolve(intent)
} else {
phase = .failed(message: String(appLoc: "没听懂想记什么,再说一次,或直接选菜单"))
}
}
}
}
#Preview {
Text("bg")
.sheet(isPresented: .constant(true)) {
VoiceCommandSheet(onResolve: { print($0) }, onOpenMenu: {})
}
}

View File

@@ -93,8 +93,7 @@ struct OngoingSymptomsCard: View {
) )
} }
) )
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04), .shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1)
radius: 2, x: 0, y: 1)
} }
private func severityDot(_ value: Int) -> some View { private func severityDot(_ value: Int) -> some View {

View File

@@ -0,0 +1,482 @@
import SwiftUI
import SwiftData
/// bucket
/// - `.series`: seriesKey (//...)
/// - `.bloodPressure`:(bp.systolic + bp.diastolic )
/// - `.lab`: seriesKey /, name+unit key
enum IndicatorGroup: Identifiable, Hashable {
case series(key: String)
case bloodPressure
case lab(key: String)
var id: String {
switch self {
case .series(let k): return "series:\(k)"
case .bloodPressure: return "bp"
case .lab(let k): return "lab:\(k)"
}
}
/// ( SeriesBucket )
static func of(_ i: Indicator) -> IndicatorGroup {
if let key = i.seriesKey, !key.isEmpty {
return key.hasPrefix("bp.") ? .bloodPressure : .series(key: key)
}
return .lab(key: SeriesBucket.normalizedKey(name: i.name, unit: i.unit))
}
}
/// :,
/// @Query ,
struct IndicatorSeriesDetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var ctx
let group: IndicatorGroup
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@Query private var profiles: [UserProfile]
@Query private var customMetrics: [CustomMonitorMetric]
@State private var selection: String?
@State private var showTrend = false
@State private var showDeleteConfirm = false
@State private var evidenceTarget: Indicator?
// MARK: -
/// :;
private enum Record: Identifiable {
case single(Indicator)
case bp(sys: Indicator, dia: Indicator?)
var id: String {
switch self {
case .single(let i): return "\(i.persistentModelID)"
case .bp(let s, _): return "bp-\(s.persistentModelID)"
}
}
}
/// : bp.systolic , ±5s bp.diastolic( TimelineEntry )
private var bloodPressureRecords: [Record] {
let sysList = indicators
.filter { $0.seriesKey == "bp.systolic" }
.sorted { $0.capturedAt > $1.capturedAt }
var usedDia = Set<PersistentIdentifier>()
return sysList.map { sys in
let dia = indicators.first {
$0.seriesKey == "bp.diastolic" &&
!usedDia.contains($0.persistentModelID) &&
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
}
if let dia { usedDia.insert(dia.persistentModelID) }
return .bp(sys: sys, dia: dia)
}
}
private var records: [Record] {
switch group {
case .bloodPressure:
return bloodPressureRecords
case .series(let key):
return indicators
.filter { $0.seriesKey == key }
.sorted { $0.capturedAt > $1.capturedAt }
.map(Record.single)
case .lab(let nk):
return indicators
.filter {
($0.seriesKey ?? "").isEmpty &&
SeriesBucket.normalizedKey(name: $0.name, unit: $0.unit) == nk
}
.sorted { $0.capturedAt > $1.capturedAt }
.map(Record.single)
}
}
private var title: String {
switch group {
case .bloodPressure:
return String(appLoc: "血压")
case .series, .lab:
if case let .single(i)? = records.first { return i.name }
return String(appLoc: "指标详情")
}
}
/// bucket( 2 );nil
private var bucket: SeriesBucket? {
let all = SeriesBucket.build(from: indicators,
profile: profiles.first,
customMetrics: customMetrics)
switch group {
case .bloodPressure:
return all.first { $0.id == "bp" }
case .series(let key):
return all.first { b in b.lines.contains { $0.seriesKey == key } }
case .lab(let nk):
return all.first { $0.kind == .lab && $0.id == "lab:\(nk)" }
}
}
private var currentIndex: Int {
records.firstIndex { $0.id == selection } ?? 0
}
// MARK: - Body
var body: some View {
NavigationStack {
VStack(spacing: 0) {
header
if records.isEmpty {
Spacer()
TjPlaceholder(label: String(appLoc: "记录已不存在"))
.frame(width: 200, height: 120)
Spacer()
} else {
pages
pager
recordAnotherRow
if bucket != nil { trendButton }
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationDestination(isPresented: $showTrend) {
if let bucket { TrendDetailView(bucket: bucket) }
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.onAppear { if selection == nil { selection = records.first?.id } }
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { deleteCurrent() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("删除后无法恢复。")
}
.sheet(item: $evidenceTarget) { indicator in
if let report = indicator.report {
EvidenceImagePreview(report: report, indicator: indicator)
}
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled(16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
Text(title)
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if records.count > 1 {
Text("\(records.count)")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: -
private var pages: some View {
TabView(selection: $selection) {
ForEach(records) { rec in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
recordCard(rec)
deleteButton
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.tag(Optional(rec.id))
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
@ViewBuilder
private func recordCard(_ rec: Record) -> some View {
switch rec {
case .single(let i): singleCard(i)
case .bp(let sys, let dia): bpCard(sys: sys, dia: dia)
}
}
private func singleCard(_ i: Indicator) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(i.status)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(i.value)
.font(.tjScaled(30, weight: .bold, design: .rounded))
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
if !i.unit.isEmpty {
Text(i.unit).font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
}
}
divider
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
if i.report != nil { evidenceButton(for: i) }
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
}
}
private func bpCard(sys: Indicator, dia: Indicator?) -> some View {
let combined: IndicatorStatus = sys.status != .normal ? sys.status : (dia?.status ?? .normal)
return card {
HStack(alignment: .firstTextBaseline) {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(combined)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(sys.value)/\(dia?.value ?? "")")
.font(.tjScaled(30, weight: .bold, design: .rounded))
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text("mmHg").font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
}
}
// MARK: -
private var pager: some View {
VStack(spacing: 8) {
HStack(spacing: 20) {
pagerArrow("chevron.left", enabled: currentIndex > 0) {
if currentIndex > 0 { selection = records[currentIndex - 1].id }
}
if records.count <= 7 {
HStack(spacing: 6) {
ForEach(Array(records.enumerated()), id: \.offset) { idx, _ in
Circle()
.fill(idx == currentIndex ? Tj.Palette.ink : Tj.Palette.line)
.frame(width: 6, height: 6)
}
}
}
pagerArrow("chevron.right", enabled: currentIndex < records.count - 1) {
if currentIndex < records.count - 1 { selection = records[currentIndex + 1].id }
}
}
Text("\(currentIndex + 1) / 共 \(records.count)")
.font(.tjScaled(11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.top, 4)
.padding(.bottom, 10)
.frame(maxWidth: .infinity)
}
private func pagerArrow(_ system: String, enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: system)
.font(.tjScaled(13, weight: .semibold))
.foregroundStyle(enabled ? Tj.Palette.text : Tj.Palette.text3.opacity(0.4))
.frame(width: 30, height: 30)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.disabled(!enabled)
}
// MARK: - ( RecordAnotherButton )
/// :, name/unit/range/seriesKey
@ViewBuilder
private var recordAnotherRow: some View {
if records.indices.contains(currentIndex) {
switch records[currentIndex] {
case .single(let i):
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
.padding(.horizontal, 20)
.padding(.bottom, bucket == nil ? 20 : 10)
case .bp(let sys, _):
RecordAnotherButton(
name: String(appLoc: "血压"),
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
name: String(appLoc: "血压"),
unit: "mmHg", range: sys.range)
)
.padding(.horizontal, 20)
.padding(.bottom, bucket == nil ? 20 : 10)
}
}
}
// MARK: - /
private var trendButton: some View {
Button { showTrend = true } label: {
Label(String(appLoc: "查看趋势图"), systemImage: "chart.xyaxis.line")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.ink)
)
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
private var deleteButton: some View {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(String(appLoc: "永久删除"), systemImage: "trash")
.font(.tjScaled(12, weight: .medium))
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.top, 8)
}
/// (:SwiftData + Vault unlink, CLAUDE.md §6)
/// selection ;
private func deleteCurrent() {
guard records.indices.contains(currentIndex) else { return }
let removingIndex = currentIndex
switch records[removingIndex] {
case .single(let i):
deleteIndicator(i)
case .bp(let sys, let dia):
deleteIndicator(sys)
if let dia { deleteIndicator(dia) }
}
try? ctx.save()
let remaining = records
if remaining.isEmpty {
dismiss()
} else {
let next = min(removingIndex, remaining.count - 1)
selection = remaining[next].id
}
}
private func deleteIndicator(_ i: Indicator) {
if let asset = i.asset {
try? FileVault.shared.remove(relativePath: asset.relativePath)
ctx.delete(asset)
}
ctx.delete(i)
}
// MARK: -
@ViewBuilder
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) { content() }
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
private func field(_ label: String, _ value: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Text(label).font(.tjScaled(13)).foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 12)
Text(value)
.font(.tjScaled(14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.multilineTextAlignment(.trailing)
.fixedSize(horizontal: false, vertical: true)
}
}
@ViewBuilder
private func evidenceButton(for indicator: Indicator) -> some View {
if indicator.hasEvidenceBox,
let page = indicator.sourcePageIndex,
let assets = indicator.report?.assets,
assets.indices.contains(page) {
Button {
evidenceTarget = indicator
} label: {
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private var divider: some View {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
private func statusChip(_ s: IndicatorStatus) -> some View {
let text: String
let color: Color
let arrow: String
switch s {
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = ""
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = ""
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
}
return HStack(spacing: 3) {
if !arrow.isEmpty { Text(arrow).font(.tjScaled(11, weight: .bold)) }
Text(text).font(.tjScaled(12, weight: .semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(color.opacity(0.14)))
}
private nonisolated static func dateTimeText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day().hour().minute())
}
}

View File

@@ -3,7 +3,7 @@ import SwiftData
import Foundation import Foundation
enum TimelineKind: String, CaseIterable, Identifiable { enum TimelineKind: String, CaseIterable, Identifiable {
case indicator, report, symptom, diary case diary, symptom, indicator, medication, report
var id: String { rawValue } var id: String { rawValue }
var label: String { var label: String {
@@ -12,6 +12,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
case .report: return String(appLoc: "报告") case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状") case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记") case .diary: return String(appLoc: "日记")
case .medication: return String(appLoc: "用药")
} }
} }
@@ -21,6 +22,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
case .report: return "doc.fill" case .report: return "doc.fill"
case .symptom: return "waveform.path.ecg" case .symptom: return "waveform.path.ecg"
case .diary: return "pencil" case .diary: return "pencil"
case .medication: return "pills.fill"
} }
} }
@@ -30,6 +32,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
case .report: return Tj.Palette.ink2 case .report: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber case .symptom: return Tj.Palette.amber
case .diary: return Tj.Palette.leaf case .diary: return Tj.Palette.leaf
case .medication: return Tj.Palette.ink
} }
} }
} }
@@ -39,10 +42,12 @@ struct TimelineEntry: Identifiable, Hashable {
let kind: TimelineKind let kind: TimelineKind
let date: Date let date: Date
let title: String let title: String
let subtitle: String var subtitle: String
let trailing: String? let trailing: String?
let trailingIsAlert: Bool let trailingIsAlert: Bool
let isOngoing: Bool let isOngoing: Bool
/// (>1 N ) 1
var aggregateCount: Int = 1
static func from(indicator i: Indicator) -> TimelineEntry { static func from(indicator i: Indicator) -> TimelineEntry {
TimelineEntry( TimelineEntry(
@@ -84,6 +89,34 @@ struct TimelineEntry: Identifiable, Hashable {
return entries return entries
} }
/// / :(),,
/// (`aggregateCount`,>1 N )
/// `IndicatorSeriesDetailView` /
/// (`IndicatorGroup`):(bp.*) seriesKey key seriesKey name+unit
static func aggregatedIndicators(_ indicators: [Indicator]) -> [TimelineEntry] {
var order: [String] = []
var groups: [String: [Indicator]] = [:]
for i in indicators {
let key = IndicatorGroup.of(i).id
if groups[key] == nil { order.append(key) }
groups[key, default: []].append(i)
}
return order.compactMap { key -> TimelineEntry? in
guard let members = groups[key] else { return nil }
// ( sys/dia),
guard var rep = from(indicators: members).max(by: { $0.date < $1.date }) else { return nil }
// :(bp.systolic ),
let count = key == IndicatorGroup.bloodPressure.id
? members.filter { $0.seriesKey == "bp.systolic" }.count
: members.count
rep.aggregateCount = count
if count > 1 {
rep.subtitle += " · " + String(appLoc: "\(count)")
}
return rep
}
}
private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry { private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry {
let abnormal = sys.status != .normal || dia.status != .normal let abnormal = sys.status != .normal || dia.status != .normal
// status : /; // status : /;
@@ -132,13 +165,16 @@ struct TimelineEntry: Identifiable, Hashable {
} }
} }
/// tag () .medication ,
/// id "diary-" :TimelineDetail.resolve diaries
static func from(diary d: DiaryEntry) -> TimelineEntry { static func from(diary d: DiaryEntry) -> TimelineEntry {
TimelineEntry( let isMed = d.isMedicationLog
return TimelineEntry(
id: "diary-\(d.persistentModelID)", id: "diary-\(d.persistentModelID)",
kind: .diary, kind: isMed ? .medication : .diary,
date: d.createdAt, date: d.createdAt,
title: d.content.firstLine(), title: d.content.firstLine(),
subtitle: String(appLoc: "文字日记"), subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"),
trailing: nil, trailing: nil,
trailingIsAlert: false, trailingIsAlert: false,
isOngoing: false isOngoing: false

View File

@@ -22,7 +22,8 @@ enum TimelineDetail {
case .report: case .report:
return reports.first { "report-\($0.persistentModelID)" == entry.id } return reports.first { "report-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.report) .map(TimelineDetail.report)
case .diary: case .diary, .medication:
// tag DiaryEntry,
return diaries.first { "diary-\($0.persistentModelID)" == entry.id } return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.diary) .map(TimelineDetail.diary)
case .symptom: case .symptom:
@@ -53,6 +54,27 @@ struct TimelineEntryDetailView: View {
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
@State private var evidenceTarget: Indicator? @State private var evidenceTarget: Indicator?
@State private var reminderPrefill: ReminderPrefill?
///
private struct ReminderPrefill: Identifiable {
let id = UUID()
let title: String
let note: String
}
///
@State private var reportPhotoStart: ReportPhotoPage?
private struct ReportPhotoPage: Identifiable {
let id = UUID()
let index: Int
}
/// ,
private var reportEntry: Report? {
if case .report(let r) = detail { return r }
return nil
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -83,6 +105,15 @@ struct TimelineEntryDetailView: View {
EvidenceImagePreview(report: report, indicator: indicator) EvidenceImagePreview(report: report, indicator: indicator)
} }
} }
.sheet(item: $reminderPrefill) { prefill in
// (/// + ;)
CustomReminderEditSheet(prefillTitle: prefill.title, prefillNote: prefill.note)
}
.sheet(item: $reportPhotoStart) { start in
if let r = reportEntry {
ReportImagesViewer(assets: r.assets, startIndex: start.index)
}
}
} }
// MARK: - (:SwiftData + Vault unlink, CLAUDE.md §6) // MARK: - (:SwiftData + Vault unlink, CLAUDE.md §6)
@@ -119,6 +150,10 @@ struct TimelineEntryDetailView: View {
for p in paths { try? FileVault.shared.remove(relativePath: p) } for p in paths { try? FileVault.shared.remove(relativePath: p) }
ctx.delete(r) ctx.delete(r)
case .diary(let d): case .diary(let d):
// ;cascade Asset ,Vault JPEG unlink
for p in Set(d.assets.map(\.relativePath)) {
try? FileVault.shared.remove(relativePath: p)
}
ctx.delete(d) ctx.delete(d)
case .symptom(let s): case .symptom(let s):
ctx.delete(s) ctx.delete(s)
@@ -166,7 +201,7 @@ struct TimelineEntryDetailView: View {
case .indicator: return String(appLoc: "指标详情") case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情") case .bloodPressure: return String(appLoc: "血压详情")
case .report: return String(appLoc: "报告详情") case .report: return String(appLoc: "报告详情")
case .diary: return String(appLoc: "日记详情") case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
case .symptom: return String(appLoc: "症状详情") case .symptom: return String(appLoc: "症状详情")
} }
} }
@@ -185,6 +220,7 @@ struct TimelineEntryDetailView: View {
// MARK: - // MARK: -
private func indicatorBody(_ i: Indicator) -> some View { private func indicatorBody(_ i: Indicator) -> some View {
VStack(alignment: .leading, spacing: 16) {
card { card {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text) Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
@@ -208,6 +244,8 @@ struct TimelineEntryDetailView: View {
} }
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) } if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
} }
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
}
} }
// MARK: - () // MARK: - ()
@@ -216,7 +254,8 @@ struct TimelineEntryDetailView: View {
let combined: IndicatorStatus = sys.status != .normal let combined: IndicatorStatus = sys.status != .normal
? sys.status ? sys.status
: (dia?.status ?? .normal) : (dia?.status ?? .normal)
return card { return VStack(alignment: .leading, spacing: 16) {
card {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text) Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer() Spacer()
@@ -232,6 +271,12 @@ struct TimelineEntryDetailView: View {
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) } if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt)) field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
} }
// :seriesKey bp.systolic MonitorMetric.bloodPressure
RecordAnotherButton(name: String(appLoc: "血压"),
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
name: String(appLoc: "血压"),
unit: "mmHg", range: sys.range))
}
} }
// MARK: - // MARK: -
@@ -247,25 +292,18 @@ struct TimelineEntryDetailView: View {
TjBadge(text: r.type.label, style: .neutral) TjBadge(text: r.type.label, style: .neutral)
Text(Self.dateText(r.reportDate)) Text(Self.dateText(r.reportDate))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
if !r.assets.isEmpty {
Text(String(appLoc: "原图\(r.assets.count)"))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
}
} }
if let inst = r.institution, !inst.isEmpty { if let inst = r.institution, !inst.isEmpty {
field(String(appLoc: "机构"), inst) field(String(appLoc: "机构"), inst)
} }
} }
if let sum = r.summary, !sum.isEmpty { if !r.assets.isEmpty {
card { reportPhotosCard(r.assets)
Text(String(appLoc: "摘要"))
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
} }
ReportSummaryCard(report: r)
if !r.indicators.isEmpty { if !r.indicators.isEmpty {
card { card {
Text(String(appLoc: "指标")) Text(String(appLoc: "指标"))
@@ -292,9 +330,56 @@ struct TimelineEntryDetailView: View {
} }
} }
/// : ,,
private func reportPhotosCard(_ assets: [Asset]) -> some View {
card {
HStack {
Text(String(appLoc: "原图\(assets.count)"))
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Spacer()
Text(String(appLoc: "点图放大")).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
Button {
reportPhotoStart = ReportPhotoPage(index: idx)
} label: {
reportThumb(asset)
}
.buttonStyle(.plain)
}
}
}
}
}
private func reportThumb(_ asset: Asset) -> some View {
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
Image(uiImage: img).resizable().scaledToFill()
} placeholder: { isLoading in
if isLoading {
Tj.Palette.paper
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
}
}
.frame(width: 96, height: 120)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
// MARK: - // MARK: -
@ViewBuilder
private func diaryBody(_ d: DiaryEntry) -> some View { private func diaryBody(_ d: DiaryEntry) -> some View {
if d.isMedicationLog {
medicationBody(d)
} else {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
card { card {
Text(Self.dateTimeText(d.createdAt)) Text(Self.dateTimeText(d.createdAt))
@@ -311,6 +396,79 @@ struct TimelineEntryDetailView: View {
} }
} }
} }
}
// MARK: - 使(// + )
/// 使(tag ): [] · + ,
/// ,/(CLAUDE.md §1§10)
private func medicationBody(_ d: DiaryEntry) -> some View {
let lines = Self.medicationLines(d.content)
return VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
if lines.isEmpty {
Text(d.content)
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
} else {
ForEach(Array(lines.enumerated()), id: \.offset) { idx, line in
if idx > 0 { divider }
Text(line)
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
}
medicationActionRow(d)
Text("「设置提醒」只到点提示,不提供任何用药或剂量建议。")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
/// :(, + ),
private func medicationActionRow(_ d: DiaryEntry) -> some View {
HStack(spacing: 10) {
medAction(title: String(appLoc: "设置提醒"), icon: "bell.badge") {
let lines = Self.medicationLines(d.content)
if lines.count <= 1 {
let f = Self.medicationReminderFields(forLine: lines.first ?? d.content)
reminderPrefill = ReminderPrefill(title: f.title, note: f.note)
} else {
// :,,/
reminderPrefill = ReminderPrefill(title: String(appLoc: "服药提醒"),
note: lines.joined(separator: "\n"))
}
}
}
}
private func medAction(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 6) {
Image(systemName: icon).font(.tjScaled( 18, weight: .medium))
Text(title).font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.ink)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.amber.opacity(0.14))
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK: - // MARK: -
@@ -418,9 +576,80 @@ struct TimelineEntryDetailView: View {
private nonisolated static func dateText(_ d: Date) -> String { private nonisolated static func dateText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day()) d.formatted(.dateTime.year().month().day())
} }
// MARK: - (,便)
/// content ,
nonisolated static func medicationLines(_ content: String) -> [String] {
content.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
/// ( 80mg · ):
/// =:<+>, = (" · " ,/)
nonisolated static func medicationReminderFields(forLine line: String) -> (title: String, note: String) {
let parts = line.components(separatedBy: " · ")
let head = (parts.first ?? line).trimmingCharacters(in: .whitespaces)
let usage = parts.count > 1
? parts.dropFirst().joined(separator: " · ").trimmingCharacters(in: .whitespaces)
: ""
let name = head.isEmpty ? line.trimmingCharacters(in: .whitespaces) : head
return (title: String(appLoc: "吃药:") + name, note: usage)
}
} }
private struct EvidenceImagePreview: View { /// (,)
private struct ReportImagesViewer: View {
@Environment(\.dismiss) private var dismiss
let assets: [Asset]
@State private var selection: Int
init(assets: [Asset], startIndex: Int) {
self.assets = assets
_selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0)))
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
Text("原图 · 第 \(selection + 1)/\(assets.count)")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
TabView(selection: $selection) {
ForEach(Array(assets.enumerated()), id: \.offset) { index, asset in
EvidenceImagePage(asset: asset, highlight: nil)
.tag(index)
.padding(16)
}
}
.tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never))
}
.background(Tj.Palette.sand.ignoresSafeArea())
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
}
}
/// ( + ),
struct EvidenceImagePreview: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let report: Report let report: Report
let indicator: Indicator let indicator: Indicator
@@ -484,19 +713,16 @@ private struct EvidenceImagePage: View {
let asset: Asset let asset: Asset
let highlight: CGRect? let highlight: CGRect?
private var image: UIImage? {
try? FileVault.shared.loadImage(relativePath: asset.relativePath)
}
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
if let image { VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { image in
ZStack { ZStack {
Image(uiImage: image) Image(uiImage: image)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
if let highlight { if let highlight {
// ,imageSize letterbox ,
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight) EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
} }
} }
@@ -507,12 +733,17 @@ private struct EvidenceImagePage: 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)
) )
} placeholder: { isLoading in
if isLoading {
ProgressView()
.frame(width: geo.size.width, height: geo.size.height)
} else { } else {
TjPlaceholder(label: String(appLoc: "原图无法读取")) TjPlaceholder(label: String(appLoc: "原图无法读取"))
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
} }
} }
} }
}
} }
private struct EvidenceHighlightOverlay: View { private struct EvidenceHighlightOverlay: View {
@@ -558,3 +789,52 @@ private struct EvidenceHighlightOverlay: View {
) )
} }
} }
// MARK: - ()
/// ;(,),
/// 线, SwiftData
private struct ReportSummaryCard: View {
@Environment(\.modelContext) private var ctx
let report: Report
@State private var generating = false
var body: some View {
Group {
if let sum = report.summary, !sum.isEmpty {
container {
Text(String(appLoc: "摘要"))
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
} else if generating {
container {
Text("本地 AI 正在解读这份报告…")
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
AIFlowBar()
}
}
}
.task {
guard (report.summary ?? "").isEmpty, !report.indicators.isEmpty else { return }
generating = true
await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx)
generating = false
}
}
private func container<C: View>(@ViewBuilder _ body: () -> C) -> some View {
VStack(alignment: .leading, spacing: 10) { body() }
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
}

View File

@@ -133,7 +133,7 @@ extension SeriesBucket {
id: "lab:\(latest.name)", id: "lab:\(latest.name)",
seriesKey: "lab:\(latest.name)", seriesKey: "lab:\(latest.name)",
label: nil, label: nil,
color: Tj.Palette.ink, color: Tj.Palette.teal,
points: points, points: points,
referenceRange: parseRange(latest.range) referenceRange: parseRange(latest.range)
) )
@@ -172,7 +172,7 @@ extension SeriesBucket {
id: key, id: key,
seriesKey: key, seriesKey: key,
label: nil, label: nil,
color: Tj.Palette.ink, color: Tj.Palette.teal,
points: sorted.compactMap { point(from: $0) }, points: sorted.compactMap { point(from: $0) },
referenceRange: range referenceRange: range
) )
@@ -200,7 +200,7 @@ extension SeriesBucket {
id: "bp.systolic", id: "bp.systolic",
seriesKey: "bp.systolic", seriesKey: "bp.systolic",
label: String(appLoc: "收缩"), label: String(appLoc: "收缩"),
color: Tj.Palette.brick, color: Tj.Palette.teal,
points: sysItems.compactMap { point(from: $0) }, points: sysItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: sysField, profile: profile) referenceRange: m.effectiveRange(for: sysField, profile: profile)
) )

View File

@@ -111,6 +111,24 @@ struct SeriesChartCard: View {
} }
} }
// 线,线,
//(线,)
if bucket.lines.count == 1, let line = bucket.lines.first {
ForEach(line.points) { p in
// 线: AreaMark 线(0/)
// ,
AreaMark(
x: .value("时间", p.date),
yStart: .value("基线", (valueDomain ?? 0...1).lowerBound),
yEnd: .value(line.label ?? bucket.title, p.value)
)
.foregroundStyle(LinearGradient(
colors: [line.color.opacity(0.16), line.color.opacity(0)],
startPoint: .top, endPoint: .bottom))
.interpolationMethod(.monotone)
}
}
// 线 + // 线 +
ForEach(bucket.lines) { line in ForEach(bucket.lines) { line in
ForEach(line.points) { p in ForEach(line.points) { p in
@@ -119,8 +137,10 @@ struct SeriesChartCard: View {
y: .value(line.label ?? bucket.title, p.value) y: .value(line.label ?? bucket.title, p.value)
) )
.foregroundStyle(line.color) .foregroundStyle(line.color)
.interpolationMethod(.catmullRom) // monotone:, catmullRom
.lineStyle(StrokeStyle(lineWidth: 2)) .interpolationMethod(.monotone)
// + ,线
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
} }
.symbol { .symbol {
Circle() Circle()

View File

@@ -69,7 +69,7 @@ struct TrendDetailView: View {
} }
chartCard chartCard
statsCard statsCard
aiPlaceholder TrendInsightCard(bucket: bucket)
pointsList pointsList
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -179,6 +179,23 @@ struct TrendDetailView: View {
.foregroundStyle(line.color.opacity(0.08)) .foregroundStyle(line.color.opacity(0.08))
} }
} }
// 线,线,
//(线,)
if filteredLines.count == 1, let line = filteredLines.first {
ForEach(line.points) { p in
// 线: AreaMark 线(0/),
// ,
AreaMark(
x: .value("时间", p.date),
yStart: .value("基线", (valueDomain ?? 0...1).lowerBound),
yEnd: .value(line.label ?? bucket.title, p.value)
)
.foregroundStyle(LinearGradient(
colors: [line.color.opacity(0.16), line.color.opacity(0)],
startPoint: .top, endPoint: .bottom))
.interpolationMethod(.monotone)
}
}
ForEach(filteredLines) { line in ForEach(filteredLines) { line in
ForEach(line.points) { p in ForEach(line.points) { p in
LineMark( LineMark(
@@ -187,8 +204,10 @@ struct TrendDetailView: View {
series: .value("series", line.id) series: .value("series", line.id)
) )
.foregroundStyle(line.color) .foregroundStyle(line.color)
.interpolationMethod(.catmullRom) // monotone:,
.lineStyle(StrokeStyle(lineWidth: 2)) .interpolationMethod(.monotone)
// + ,线
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
PointMark( PointMark(
x: .value("时间", p.date), x: .value("时间", p.date),
y: .value(line.label ?? bucket.title, p.value) y: .value(line.label ?? bucket.title, p.value)
@@ -318,27 +337,6 @@ struct TrendDetailView: View {
return ("\(arrow) \(fmt(abs(d)))\(pctStr)", color) return ("\(arrow) \(fmt(abs(d)))\(pctStr)", color)
} }
// MARK: AI
private var aiPlaceholder: some View {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text("AI 趋势解读即将上线")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2.opacity(0.6))
)
}
// MARK: // MARK:
/// 线:,线 /// 线:,线
@@ -423,6 +421,97 @@ struct TrendDetailView: View {
} }
} }
// MARK: - AI
/// :;( TrendInsightService,§3.1)
private struct TrendInsightCard: View {
let bucket: SeriesBucket
@State private var text: String?
@State private var running = false
@State private var failedMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.ink)
Text("AI 解读")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
// :(),
//
if !running {
Button { Task { await load(force: true) } } label: {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
.font(.tjScaled( 11, weight: .semibold))
Text(text == nil ? String(appLoc: "解读") : String(appLoc: "重新解读"))
.font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.sand2))
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
if let text {
Text(text)
.font(.tjScaled( 13))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
AIDisclaimerFooter()
} else if running {
Text("本地 AI 解读中…")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
AIFlowBar()
} else if let failedMessage {
Text(failedMessage)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
} else {
// ():,
Text("点右上「解读」生成本地趋势解读")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.task(id: bucket.id) { await load(force: false) }
}
@MainActor
private func load(force: Bool) async {
if !force, let cached = TrendInsightService.shared.cachedText(for: bucket) {
text = cached
return
}
running = true
failedMessage = nil
do {
text = try await TrendInsightService.shared.generate(for: bucket)
} catch {
failedMessage = String(appLoc: "AI 解读暂不可用(模型未就绪或繁忙)")
}
running = false
}
}
enum TrendRange: String, CaseIterable, Identifiable { enum TrendRange: String, CaseIterable, Identifiable {
case all, year, sixMonths, threeMonths case all, year, sixMonths, threeMonths
var id: String { rawValue } var id: String { rawValue }

View File

@@ -54,6 +54,19 @@ struct TrendRow: View {
private var sparkline: some View { private var sparkline: some View {
Chart { Chart {
// 线,线,
if bucket.lines.count == 1, let line = bucket.lines.first {
ForEach(line.points) { p in
AreaMark(
x: .value("t", p.date),
y: .value(line.label ?? bucket.title, p.value)
)
.foregroundStyle(LinearGradient(
colors: [line.color.opacity(0.18), line.color.opacity(0)],
startPoint: .top, endPoint: .bottom))
.interpolationMethod(.monotone)
}
}
ForEach(bucket.lines) { line in ForEach(bucket.lines) { line in
ForEach(line.points) { p in ForEach(line.points) { p in
LineMark( LineMark(
@@ -62,8 +75,9 @@ struct TrendRow: View {
series: .value("s", line.id) series: .value("s", line.id)
) )
.foregroundStyle(line.color) .foregroundStyle(line.color)
.interpolationMethod(.catmullRom) // monotone + :线,
.lineStyle(StrokeStyle(lineWidth: 1.6)) .interpolationMethod(.monotone)
.lineStyle(StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round))
} }
} }
// //

View File

@@ -12,6 +12,10 @@ struct TrendsView: View {
private var profile: UserProfile? { profiles.first } private var profile: UserProfile? { profiles.first }
/// :,(bucket.title)
@State private var searching = false
@State private var query = ""
private var seriesBuckets: [SeriesBucket] { private var seriesBuckets: [SeriesBucket] {
SeriesBucket.build(from: indicators, SeriesBucket.build(from: indicators,
profile: profile, profile: profile,
@@ -25,6 +29,14 @@ struct TrendsView: View {
seriesBuckets.filter { $0.kind == .lab } seriesBuckets.filter { $0.kind == .lab }
} }
private func filtered(_ buckets: [SeriesBucket]) -> [SeriesBucket] {
let q = query.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return buckets }
return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) }
}
private var filteredMonitor: [SeriesBucket] { filtered(monitorBuckets) }
private var filteredLab: [SeriesBucket] { filtered(labBuckets) }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -32,12 +44,14 @@ struct TrendsView: View {
header.padding(.top, 4) header.padding(.top, 4)
if seriesBuckets.isEmpty { if seriesBuckets.isEmpty {
emptyState emptyState
} else if filteredMonitor.isEmpty && filteredLab.isEmpty {
noMatchState
} else { } else {
if !monitorBuckets.isEmpty { if !filteredMonitor.isEmpty {
section(title: String(appLoc: "长期监测"), buckets: monitorBuckets) section(title: String(appLoc: "长期监测"), buckets: filteredMonitor)
} }
if !labBuckets.isEmpty { if !filteredLab.isEmpty {
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets) section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab)
} }
} }
} }
@@ -51,9 +65,73 @@ struct TrendsView: View {
} }
private var header: some View { private var header: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline) {
Text("趋势") Text("趋势")
.font(.tjTitle(26)) .font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
Spacer()
searchToggle
}
if searching { searchField }
}
}
private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searching.toggle()
if !searching { query = "" }
}
} label: {
Image(systemName: searching ? "xmark" : "magnifyingglass")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标名"), text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !query.isEmpty {
Button { query = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
private var noMatchState: some View {
VStack(spacing: 12) {
TjPlaceholder(label: String(appLoc: "没有匹配「\(query)」的指标"))
.frame(height: 120)
.frame(maxWidth: 260)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
} }
private func section(title: String, buckets: [SeriesBucket]) -> some View { private func section(title: String, buckets: [SeriesBucket]) -> some View {

File diff suppressed because it is too large Load Diff

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: "报告归档")
} }
} }
@@ -171,6 +171,12 @@ final class DiaryEntry {
var createdAt: Date var createdAt: Date
var tags: [String] var tags: [String]
/// ( 5 ://)
/// ( swiftdata-rebuild-data-loss)
/// cascade: Asset ;Vault JPEG unlink
@Relationship(deleteRule: .cascade)
var assets: [Asset] = []
init(content: String, createdAt: Date = .now, tags: [String] = []) { init(content: String, createdAt: Date = .now, tags: [String] = []) {
self.content = content self.content = content
self.createdAt = createdAt self.createdAt = createdAt
@@ -178,6 +184,14 @@ final class DiaryEntry {
} }
} }
extension DiaryEntry {
/// tag UI ,**** appLoc
/// ()线
static let medicationTag = "用药"
var isMedicationLog: Bool { tags.contains(Self.medicationTag) }
}
@Model @Model
final class Asset { final class Asset {
var relativePath: String var relativePath: String
@@ -196,6 +210,45 @@ final class Asset {
} }
} }
/// : master ()
/// 使( `DiaryEntry.medicationTag` , + ):
/// / / / ,
/// @Model SwiftData ( KangkangApp )
@Model
final class Medication {
var name: String // (,), ParsedMedication.name
var strength: String // , "80mg×7"; ""
var usage: String // , ","; ""
var note: String? // ()
var createdAt: Date
var updatedAt: Date
/// ( / / , 5 )
/// cascade: Asset ;Vault JPEG unlink( DiaryEntry.assets )
@Relationship(deleteRule: .cascade)
var assets: [Asset] = []
init(name: String,
strength: String = "",
usage: String = "",
note: String? = nil,
createdAt: Date = .now) {
self.name = name
self.strength = strength
self.usage = usage
self.note = note
self.createdAt = createdAt
self.updatedAt = createdAt
}
/// / :"80mg×7 · "()
var detailLine: String {
[strength, usage]
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
.joined(separator: " · ")
}
}
@Model @Model
final class Symptom { final class Symptom {
var name: String var name: String
@@ -345,9 +398,13 @@ final class CustomReminder {
var hour: Int // 0...23 var hour: Int // 0...23
var minute: Int // 0...59 var minute: Int // 0...59
var weekdays: [Int] // iOS Calendar :1=, 2=, ..., 7= 7 = var weekdays: [Int] // iOS Calendar :1=, 2=, ..., 7= 7 =
var frequencyRaw: String = "daily" // CustomReminder.Frequency var frequencyRaw: String = "daily" // :; frequenciesRaw
var dayOfMonth: Int = 1 // monthly / yearly ,1...31 var dayOfMonth: Int = 1 // yearly + monthly ,1...31
var month: Int = 1 // yearly ,1...12 var month: Int = 1 // yearly ,1...12
/// (["daily","weekly",...]) = ,退 frequency
var frequenciesRaw: [String] = []
/// (1...31) = ,退 dayOfMonth
var monthDays: [Int] = []
var enabled: Bool var enabled: Bool
var createdAt: Date var createdAt: Date
var updatedAt: Date var updatedAt: Date
@@ -384,10 +441,41 @@ final class CustomReminder {
set { frequencyRaw = newValue.rawValue } set { frequencyRaw = newValue.rawValue }
} }
/// : / / 15 / 315 /// ()frequenciesRaw 退 frequency( / init)
var frequencies: Set<Frequency> {
get {
let parsed = Set(frequenciesRaw.compactMap { Frequency(rawValue: $0) })
return parsed.isEmpty ? [frequency] : parsed
}
set {
frequenciesRaw = newValue.map(\.rawValue).sorted()
// , frequency
if let rep = newValue.map(\.rawValue).sorted().first { frequencyRaw = rep }
}
}
/// (,1...31)monthDays 退 dayOfMonth()
/// : dayOfMonth yearly ,+
var monthlyDays: [Int] {
get { monthDays.isEmpty ? [dayOfMonth] : monthDays.sorted() }
set { monthDays = Set(newValue.map { max(1, min(31, $0)) }).sorted() }
}
/// : · , · 1·15
/// ()
var frequencyLabel: String { var frequencyLabel: String {
if !enabled { return String(appLoc: "已关闭") } if !enabled { return String(appLoc: "已关闭") }
switch frequency { let active = frequencies
if active.contains(.daily) { return String(appLoc: "每天") }
// weekly 7
if active == [.weekly] && isEveryDay { return String(appLoc: "每天") }
let order: [Frequency] = [.weekly, .monthly, .yearly]
let parts = order.filter { active.contains($0) }.map { freqPartLabel($0) }
return parts.isEmpty ? String(appLoc: "未选日") : parts.joined(separator: " · ")
}
private func freqPartLabel(_ f: Frequency) -> String {
switch f {
case .daily: case .daily:
return String(appLoc: "每天") return String(appLoc: "每天")
case .weekly: case .weekly:
@@ -396,7 +484,9 @@ final class CustomReminder {
let names = [String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: "")] let names = [String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: "")]
return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined() return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined()
case .monthly: case .monthly:
return String(appLoc: "每月\(dayOfMonth)") let days = monthlyDays
if days.isEmpty { return String(appLoc: "未选日") }
return String(appLoc: "每月") + days.map { String($0) }.joined(separator: "·") + String(appLoc: "")
case .yearly: case .yearly:
return String(appLoc: "每年\(month)\(dayOfMonth)") return String(appLoc: "每年\(month)\(dayOfMonth)")
} }
@@ -412,13 +502,18 @@ final class CustomReminder {
func occurs(on date: Date, calendar: Calendar = .current) -> Bool { func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
guard enabled else { return false } guard enabled else { return false }
let c = calendar.dateComponents([.weekday, .day, .month], from: date) let c = calendar.dateComponents([.weekday, .day, .month], from: date)
switch frequency { let wd = c.weekday ?? -1, day = c.day ?? -1, mo = c.month ?? -1
// :
for f in frequencies {
switch f {
case .daily: return true case .daily: return true
case .weekly: return weekdays.contains(c.weekday ?? -1) case .weekly: if weekdays.contains(wd) { return true }
case .monthly: return dayOfMonth == (c.day ?? -1) case .monthly: if monthlyDays.contains(day) { return true }
case .yearly: return month == (c.month ?? -1) && dayOfMonth == (c.day ?? -1) case .yearly: if month == mo && dayOfMonth == day { return true }
} }
} }
return false
}
} }
@Model @Model

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import UIKit import UIKit
import ImageIO
enum FileVaultError: Error { enum FileVaultError: Error {
case readFailed case readFailed
@@ -10,7 +11,10 @@ enum FileVaultError: Error {
/// `@unchecked Sendable`:rootURL let, I/O (线), /// `@unchecked Sendable`:rootURL let, I/O (线),
/// actor / Task 访 `nonisolated`, ModelStore /// actor / Task 访 `nonisolated`, ModelStore
final class FileVault: @unchecked Sendable { /// `nonisolated`: `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,
/// `thumbnailCache`( Sendable NSCache) MainActor, nonisolated I/O /
/// 访; I/O + , MainActor actor , nonisolated
nonisolated final class FileVault: @unchecked Sendable {
nonisolated static let shared: FileVault = { nonisolated static let shared: FileVault = {
do { do {
let appSupport = try FileManager.default.url( let appSupport = try FileManager.default.url(
@@ -28,6 +32,17 @@ final class FileVault: @unchecked Sendable {
let rootURL: URL let rootURL: URL
/// NSCache 线;
/// key = "@", TabView /
/// ( KB),
/// `nonisolated(unsafe)`: MainActor , Sendable NSCache 便
/// nonisolated MainActor, nonisolated I/O 访;NSCache 线, unsafe
private nonisolated(unsafe) let thumbnailCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 40
return cache
}()
init(rootURL: URL) throws { init(rootURL: URL) throws {
self.rootURL = rootURL self.rootURL = rootURL
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
@@ -81,6 +96,33 @@ final class FileVault: @unchecked Sendable {
return image return image
} }
/// ImageIO ,****:
/// 4000×3000 ~48MB RGBA, jetsam; 2000px MB
/// EXIF ,/
/// ( / ) readFailed, loadImage ,UI
nonisolated func loadDownsampledImage(relativePath: String, maxPixelSize: CGFloat) throws -> UIImage {
let cacheKey = "\(relativePath)@\(Int(maxPixelSize))" as NSString
if let cached = thumbnailCache.object(forKey: cacheKey) { return cached }
let url = try resolveSafePath(relativePath)
let srcOptions: [CFString: Any] = [kCGImageSourceShouldCache: false]
guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions as CFDictionary) else {
throw FileVaultError.readFailed
}
let thumbOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true, // EXIF ,
kCGImageSourceShouldCacheImmediately: true, // 线,线
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
]
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else {
throw FileVaultError.decodeFailed
}
let image = UIImage(cgImage: cg)
thumbnailCache.setObject(image, forKey: cacheKey)
return image
}
nonisolated func remove(relativePath: String) throws { nonisolated func remove(relativePath: String) throws {
let url = try resolveSafePath(relativePath) let url = try resolveSafePath(relativePath)
do { do {
@@ -88,6 +130,8 @@ final class FileVault: @unchecked Sendable {
} catch { } catch {
throw FileVaultError.removeFailed throw FileVaultError.removeFailed
} }
// ,(,)
thumbnailCache.removeAllObjects()
} }
/// Vault (/),; /// Vault (/),;
@@ -99,6 +143,7 @@ final class FileVault: @unchecked Sendable {
try? fm.removeItem(at: url) try? fm.removeItem(at: url)
} }
let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? [] let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? []
thumbnailCache.removeAllObjects()
if !remaining.isEmpty { if !remaining.isEmpty {
throw FileVaultError.removeFailed throw FileVaultError.removeFailed
} }

View File

@@ -0,0 +1,44 @@
import Foundation
/// App Widget ( App Group UserDefaults )
///
/// Widget SwiftData:store App ,
/// extension ;,
///
/// :`KangkangWidget` extension
/// (extension App , Xcode target membership )
/// :KangkangWidget/PinnedIndicatorsWidget.swift
struct WidgetSnapshot: Codable, Equatable {
struct Item: Codable, Equatable {
var name: String // ""
var value: String // "128"
var unit: String // "mmHg"
var statusRaw: String // IndicatorStatus.rawValue: high|low|normal
var capturedAt: Date
}
var updatedAt: Date
var items: [Item]
// MARK: - App Group
/// App Group ID target App Groups capability
static let appGroupID = "group.com.xuhuayong.kangkang"
static let storeKey = "kk.widget.snapshot.v1"
/// App Group (capability ) nil ,App
static var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: appGroupID)
}
func save(to defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) {
guard let defaults, let data = try? JSONEncoder().encode(self) else { return }
defaults.set(data, forKey: Self.storeKey)
}
static func load(from defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) -> WidgetSnapshot? {
guard let defaults,
let data = defaults.data(forKey: storeKey) else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
import SwiftData
import WidgetKit
/// pinned App Group , WidgetKit
/// :App / (RootView)( pinned), AI
/// App Group capability no-op, App
enum WidgetSnapshotRefresher {
/// (seriesKey, name), 6
@MainActor
static func refresh(in ctx: ModelContext) {
let pinnedPredicate = #Predicate<Indicator> { $0.pinned == true }
var descriptor = FetchDescriptor<Indicator>(
predicate: pinnedPredicate,
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
)
descriptor.fetchLimit = 200 // pinned ,
guard let pinned = try? ctx.fetch(descriptor) else { return }
var seenSeries = Set<String>()
var items: [WidgetSnapshot.Item] = []
for ind in pinned { // capturedAt ,
let key = ind.seriesKey ?? ind.name
guard seenSeries.insert(key).inserted else { continue }
items.append(.init(
name: ind.name,
value: ind.value,
unit: ind.unit,
statusRaw: ind.statusRaw,
capturedAt: ind.capturedAt
))
if items.count >= 6 { break }
}
let snapshot = WidgetSnapshot(updatedAt: .now, items: items)
// , WidgetKit
if let old = WidgetSnapshot.load(), old.items == snapshot.items { return }
snapshot.save()
WidgetCenter.shared.reloadAllTimelines()
}
}

View File

@@ -1,4 +1,6 @@
import SwiftUI import SwiftUI
import SwiftData
import UIKit
enum TjTab: String, Hashable, CaseIterable { enum TjTab: String, Hashable, CaseIterable {
case home, records, trend, me case home, records, trend, me
@@ -35,16 +37,41 @@ enum ActiveFlow: Identifiable {
} }
struct RootView: View { struct RootView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.scenePhase) private var scenePhase
@State private var tab: TjTab = .home @State private var tab: TjTab = .home
/// push : tab trailing , leading /// push : tab trailing , leading
@State private var pushEdge: Edge = .trailing @State private var pushEdge: Edge = .trailing
/// chip `.report`, tab
@State private var pendingRecordsFilter: TimelineKind?
@State private var showRecordSheet = false @State private var showRecordSheet = false
@State private var activeFlow: ActiveFlow? @State private var activeFlow: ActiveFlow?
@State private var showSymptomStart = false @State private var showSymptomStart = false
@State private var showDiary = false @State private var showDiary = false
/// : sheet ,
@State private var diaryDirectWrite = false
@State private var showIndicator = false @State private var showIndicator = false
@State private var showReminders = false @State private var showReminders = false
@State private var showHealthExport = false @State private var showHealthExport = false
/// + :( LLM )
@State private var showVoiceCommand = false
/// :RootView MedicationScanFlow, sheet
@State private var showMedicationScan = false
/// · :sheet + NavigationStack
@State private var showMedicationLibrary = false
/// ( RecordSheet onPick )
private func route(_ intent: VoiceIntent) {
switch intent {
case .diary: diaryDirectWrite = true; showDiary = true
case .medication: showMedicationScan = true
case .symptom: showSymptomStart = true
case .indicator: showIndicator = true
case .archive: activeFlow = .archive
case .export: showHealthExport = true
case .reminder: showReminders = true
}
}
/// tab : pushEdge, tab /// tab : pushEdge, tab
/// tab , /// tab ,
@@ -58,8 +85,11 @@ struct RootView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
Group { Group {
switch tab { switch tab {
case .home: HomeView(onTapArchive: { select(.records) }) case .home: HomeView(onTapArchive: { kind in
case .records: ArchiveListView() pendingRecordsFilter = kind
select(.records)
})
case .records: ArchiveListView(initialFilter: pendingRecordsFilter)
case .trend: TrendsView() case .trend: TrendsView()
case .me: MeView() case .me: MeView()
} }
@@ -69,10 +99,20 @@ struct RootView: View {
.transition(.push(from: pushEdge)) .transition(.push(from: pushEdge))
TabBar(active: tab, TabBar(active: tab,
onTap: { select($0) }, onTap: {
onTapRecord: { showRecordSheet = true }) // tab , .report
if $0 == .records { pendingRecordsFilter = nil }
select($0)
},
onTapRecord: { showRecordSheet = true },
onLongPressRecord: { showVoiceCommand = true })
} }
.background(Tj.Palette.sand.ignoresSafeArea()) .background(Tj.Palette.sand.ignoresSafeArea())
// Widget :,(,App Group no-op)
.task { WidgetSnapshotRefresher.refresh(in: ctx) }
.onChange(of: scenePhase) { _, phase in
if phase == .background { WidgetSnapshotRefresher.refresh(in: ctx) }
}
.sheet(isPresented: $showRecordSheet) { .sheet(isPresented: $showRecordSheet) {
RecordSheet { kind in RecordSheet { kind in
showRecordSheet = false showRecordSheet = false
@@ -81,10 +121,11 @@ struct RootView: View {
case .quick: activeFlow = .quick case .quick: activeFlow = .quick
case .archive: activeFlow = .archive case .archive: activeFlow = .archive
case .symptom: showSymptomStart = true case .symptom: showSymptomStart = true
case .diary: showDiary = true case .diary: diaryDirectWrite = false; showDiary = true
case .indicator: showIndicator = true case .indicator: showIndicator = true
case .reminder: showReminders = true case .reminder: showReminders = true
case .healthExport: showHealthExport = true case .healthExport: showHealthExport = true
case .medicationLibrary: showMedicationLibrary = true
} }
} }
} }
@@ -93,10 +134,10 @@ struct RootView: View {
SymptomStartSheet() SymptomStartSheet()
} }
.sheet(isPresented: $showDiary) { .sheet(isPresented: $showDiary) {
DiaryQuickSheet() DiaryQuickSheet(directWrite: diaryDirectWrite)
} }
.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) {
@@ -108,9 +149,36 @@ struct RootView: View {
// NavigationStack ;sheet // NavigationStack ;sheet
NavigationStack { RemindersListView(presentedAsSheet: true) } NavigationStack { RemindersListView(presentedAsSheet: true) }
} }
.sheet(isPresented: $showMedicationLibrary) {
NavigationStack { MedicationLibraryView(presentedAsSheet: true) }
}
.fullScreenCover(isPresented: $showHealthExport) { .fullScreenCover(isPresented: $showHealthExport) {
HealthExportSheet() HealthExportSheet()
} }
.sheet(isPresented: $showVoiceCommand) {
VoiceCommandSheet(
onResolve: { intent in
showVoiceCommand = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
route(intent)
}
},
onOpenMenu: {
showVoiceCommand = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
showRecordSheet = true
}
}
)
}
.fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow(
onSave: { meds, images in
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
},
onClose: { showMedicationScan = false }
)
}
#if os(iOS) #if os(iOS)
.fullScreenCover(item: $activeFlow) { flow in .fullScreenCover(item: $activeFlow) { flow in
switch flow { switch flow {
@@ -137,8 +205,11 @@ private struct TabBar: View {
let active: TjTab let active: TjTab
let onTap: (TjTab) -> Void let onTap: (TjTab) -> Void
let onTapRecord: () -> Void let onTapRecord: () -> Void
let onLongPressRecord: () -> Void
@Namespace private var indicatorNS @Namespace private var indicatorNS
/// + (, ButtonStyle)
@State private var recordPressing = false
private let cornerRadius: CGFloat = 22 private let cornerRadius: CGFloat = 22
private let slotHeight: CGFloat = 34 private let slotHeight: CGFloat = 34
@@ -172,7 +243,7 @@ private struct TabBar: View {
.fill(Tj.Palette.lineSoft) .fill(Tj.Palette.lineSoft)
.frame(height: 1) .frame(height: 1)
} }
.shadow(color: Tj.Palette.ink.opacity(0.05), radius: 10, x: 0, y: -2) .shadow(color: Tj.Palette.shadow.opacity(0.07), radius: 10, x: 0, y: -2)
} }
private func tabItem(_ t: TjTab) -> some View { private func tabItem(_ t: TjTab) -> some View {
@@ -201,8 +272,10 @@ private struct TabBar: View {
.buttonStyle(TabPressStyle()) .buttonStyle(TabPressStyle())
} }
/// + : ;
/// Button + simultaneousGesture( tap ),
/// tap / longPress + onPressingChanged
private var recordSlot: some View { private var recordSlot: some View {
Button(action: onTapRecord) {
VStack(spacing: 4) { VStack(spacing: 4) {
ZStack { ZStack {
Circle() Circle()
@@ -211,8 +284,8 @@ private struct TabBar: View {
Circle() Circle()
.strokeBorder(Tj.Palette.paper, lineWidth: 2) .strokeBorder(Tj.Palette.paper, lineWidth: 2)
) )
.shadow(color: Tj.Palette.ink.opacity(0.18), .shadow(color: Tj.Palette.shadow.opacity(0.20),
radius: 4, x: 0, y: 2) radius: 5, x: 0, y: 2)
Image(systemName: "plus") Image(systemName: "plus")
.font(.tjScaled( 16, weight: .semibold)) .font(.tjScaled( 16, weight: .semibold))
@@ -226,8 +299,18 @@ private struct TabBar: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.contentShape(Rectangle()) .contentShape(Rectangle())
.scaleEffect(recordPressing ? 0.92 : 1.0)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: recordPressing)
.onTapGesture { onTapRecord() }
.onLongPressGesture(minimumDuration: 0.45) {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
onLongPressRecord()
} onPressingChanged: { pressing in
recordPressing = pressing
} }
.buttonStyle(TabPressStyle()) .accessibilityElement(children: .combine)
.accessibilityLabel("新建")
.accessibilityHint("轻点打开新建菜单,长按语音直达")
} }
} }
// //

View File

@@ -29,7 +29,7 @@ struct LockScreenView: View {
.foregroundStyle(Tj.Palette.ink) .foregroundStyle(Tj.Palette.ink)
} }
.frame(width: 92, height: 92) .frame(width: 92, height: 92)
.shadow(color: Tj.Palette.ink.opacity(0.06), radius: 12, y: 4) .shadow(color: Tj.Palette.shadow.opacity(0.08), radius: 12, y: 4)
VStack(spacing: 6) { VStack(spacing: 6) {
Text("康康 已锁定") Text("康康 已锁定")

View File

@@ -0,0 +1,67 @@
import Foundation
/// ,MNN·SME2 vs MLX·GPU(§12 2/6)
struct BenchmarkResult: Codable, Equatable {
var backendLabel: String
var promptTokens: Int
var genTokens: Int
var prefillTokensPerSecond: Double
var decodeTokensPerSecond: Double
var totalSeconds: Double
var date: Date
}
/// : prompt, AIRuntime , UserDefaults
/// UI(ModelSelfTestView) AIRuntime(§3.1)
@MainActor
struct BenchmarkService {
static let shared = BenchmarkService()
private init() {}
nonisolated static let storeKey = "kk.benchmark.results"
/// prompt:/
static let fixedPrompt = "用中文一句话介绍肝功能里 ALT 这个指标。"
/// onToken UI
func run(onToken: @escaping @MainActor (String, Double) -> Void) async throws -> BenchmarkResult {
try await AIRuntime.shared.prepare()
let start = Date()
let stream = await AIRuntime.shared.generate(prompt: Self.fixedPrompt, maxTokens: 128)
for try await chunk in stream {
onToken(chunk.text, chunk.decodeRate)
}
let total = Date().timeIntervalSince(start)
let label = await AIRuntime.shared.activeBackendLabel
let stats = await AIRuntime.shared.lastGenerateStats
let result = BenchmarkResult(
backendLabel: label,
promptTokens: stats?.promptTokens ?? 0,
genTokens: stats?.genTokens ?? 0,
prefillTokensPerSecond: stats?.prefillTokensPerSecond ?? 0,
decodeTokensPerSecond: stats?.decodeTokensPerSecond ?? 0,
totalSeconds: total,
date: .now
)
Self.save(result)
return result
}
// MARK: - (,;nonisolated: UserDefaults ,线)
nonisolated static func save(_ result: BenchmarkResult, defaults: UserDefaults = .standard) {
var all = load(defaults: defaults)
all[result.backendLabel] = result
if let data = try? JSONEncoder().encode(all) {
defaults.set(data, forKey: storeKey)
}
}
nonisolated static func load(defaults: UserDefaults = .standard) -> [String: BenchmarkResult] {
guard let data = defaults.data(forKey: storeKey),
let all = try? JSONDecoder().decode([String: BenchmarkResult].self, from: data) else {
return [:]
}
return all
}
}

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import UIKit import UIKit
import ImageIO
import SwiftData import SwiftData
/// VL (, SwiftData ) /// VL (, SwiftData )
@@ -32,7 +33,9 @@ struct ParsedReport: Sendable {
var isEmpty: Bool { indicators.isEmpty } var isEmpty: Bool { indicators.isEmpty }
/// ,退 UI /// ,退 UI
static func empty(date: Date = .now) -> ParsedReport { /// nonisolated: MainActor , CaptureService(actor) extractReportMeta
/// actor , nonisolated (Swift 6)
nonisolated static func empty(date: Date = .now) -> ParsedReport {
ParsedReport( ParsedReport(
title: "", title: "",
typeRaw: ReportType.other.rawValue, typeRaw: ReportType.other.rawValue,
@@ -77,51 +80,38 @@ actor CaptureService {
try await runVL(on: assets) try await runVL(on: assets)
} }
/// :****(JPEG data) VL, indicators, Report /// meta :**,**
/// - `NSTemporaryDirectory`(`.completeFileProtectionUnlessOpen`), `defer` /// 2B OOM(jetsam = ),
/// (§ )线(§6), Vault Asset /// :Vision OCR(,<1s/) LLM {title,type,date,institution}(~50 token)
/// - `CaptureError`,UI 退(§3.2 退线) /// :OCR / / ( meta, recognized:false),(§3.2)
/// (MainActor) Indicator /// indicators
func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] { func extractReportMeta(assets: [FileVault.SavedAsset]) async -> (meta: ParsedReport, recognized: Bool) {
do { let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
try await AIRuntime.shared.prepareVL() let ocr = await Self.ocrReference(for: urls)
} catch { guard !ocr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CaptureError.modelNotReady return (.empty(), false)
} }
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("region-\(UUID().uuidString).jpg")
do { do {
// .completeFileProtectionUnlessOpen .complete:VL , try await AIRuntime.shared.prepare() // LLM();OOM VL
// ,.complete / EPERM 使;
// unlessOpen 访, Vault(completeUnlessOpen)
try imageData.write(to: tmpURL, options: [.completeFileProtectionUnlessOpen, .atomic])
} catch { } catch {
throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)") return (.empty(), false)
} }
defer { try? FileManager.default.removeItem(at: tmpURL) } var collected = ""
let raw: String
do { do {
raw = try await AIRuntime.shared.analyzeReport( // meta ,256 token , 2048
imageURLs: [tmpURL], let stream = await AIRuntime.shared.generate(prompt: VLPrompts.reportMetaFromText(ocr),
prompt: VLPrompts.regionExtraction(), maxTokens: 256)
// ,512 token for try await chunk in stream { collected += chunk.text }
maxTokens: 2048
)
} catch { } catch {
throw CaptureError.inferenceFailed("\(error)") return (.empty(), false)
} }
#if DEBUG let cleaned = CaptureService.stripThink(collected)
print("🔎 [recognizeRegion] image bytes=\(imageData.count), VL raw output:\n\(raw)\n--- end VL raw ---") guard var parsed = try? CaptureService.parseReportJSON(cleaned, pageCount: assets.count) else {
#endif return (.empty(), false)
do {
return try CaptureService.parseIndicatorsJSON(raw)
} catch let CaptureError.parseFailed(msg) {
throw CaptureError.parseFailed(msg)
} catch {
throw CaptureError.parseFailed("\(error)")
} }
// meta + ,
parsed.indicators = []
return (parsed, true)
} }
/// OCR : Vision OCR LLM(Qwen3-1.7B) /// OCR : Vision OCR LLM(Qwen3-1.7B)
@@ -149,12 +139,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)")
} }
@@ -182,11 +179,14 @@ actor CaptureService {
throw CaptureError.modelNotReady throw CaptureError.modelNotReady
} }
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) } let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
// OCR (Vision ,<1s/): 2B ,
// 退,(§3.2)
let ocr = await Self.ocrReference(for: urls)
let raw: String let raw: String
do { do {
raw = try await AIRuntime.shared.analyzeReport( raw = try await AIRuntime.shared.analyzeReport(
imageURLs: urls, imageURLs: urls,
prompt: VLPrompts.reportExtraction() prompt: VLPrompts.reportExtraction(ocrText: ocr)
) )
} catch { } catch {
throw CaptureError.inferenceFailed("\(error)") throw CaptureError.inferenceFailed("\(error)")
@@ -200,6 +200,29 @@ actor CaptureService {
} }
} }
/// Vault OCR 4 ;/ ""
/// ImageIO CGImage( UIImage, actor Sendable )
private static func ocrReference(for urls: [URL]) async -> String {
var pages: [String] = []
for (idx, url) in urls.prefix(4).enumerated() {
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { continue }
// OCR : 4000px 48MB, VL ,
// jetsam 3000px Vision,;
// VL ,OCR ,
let thumbOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: 3000
]
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else { continue }
guard let text = try? await OCRService.recognizeText(in: cg),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue }
pages.append(urls.count > 1 ? "【第 \(idx + 1) 页】\n\(text)" : text)
}
return pages.joined(separator: "\n")
}
// MARK: - JSON parse(static + 便) // MARK: - JSON parse(static + 便)
/// VL JSON /// VL JSON
@@ -213,7 +236,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 +282,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 +347,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

@@ -64,27 +64,61 @@ struct DiaryAssistService {
} }
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions) let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
var collected = ""
// MNN JSON / {"questions":} ( MNN MLX )
// , §10.5退, AI
var lastRate: Double = 0 var lastRate: Double = 0
var parsedButEmpty = false
var lastRaw = ""
for _ in 0..<2 {
try Task.checkCancellation()
var collected = ""
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400) let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
for try await chunk in stream { for try await chunk in stream {
collected += chunk.text collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
} }
lastRaw = collected
if let questions = Self.parseQuestions(from: collected) {
if !questions.isEmpty {
return (Array(questions.prefix(4)), lastRate)
}
parsedButEmpty = true // JSON :, .empty
}
}
// ,,便
#if DEBUG
print("[DiaryAssistService] 解析失败,原始输出 = \(lastRaw)")
#endif
throw parsedButEmpty ? AssistError.empty : AssistError.parseFailed("非 JSON 输出")
}
// 1. <think>...</think>( HealthExportService ) /// ( §3.2 退):
let stripped = HealthExportService.stripThinkBlocks(collected) /// `<think>` JSON `{"questions":[]}`,
// 2. JSON( CaptureService.extractJSONObject) /// 退 `[{}]`(MNN ) nil(/)
let jsonStr = CaptureService.extractJSONObject(from: stripped) /// `[]`( nil : .empty .parseFailed)
guard let data = jsonStr.data(using: .utf8), static func parseQuestions(from raw: String) -> [Question]? {
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), let stripped = HealthExportService.stripThinkBlocks(raw)
let dict = obj as? [String: Any] else {
throw AssistError.parseFailed("非 JSON 输出") var rawQuestions: [[String: Any]]?
// {"questions":[]}
let objStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
if let data = objStr.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let arr = dict["questions"] as? [[String: Any]] {
rawQuestions = arr
} }
guard let rawQuestions = dict["questions"] as? [[String: Any]] else { // 退:, [{},{}]
throw AssistError.parseFailed("缺少 questions 字段") if rawQuestions == nil {
let arrStr = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: stripped))
if let data = arrStr.data(using: .utf8),
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] {
rawQuestions = arr
} }
let questions = rawQuestions.compactMap { d -> Question? in }
guard let rawQuestions else { return nil }
return rawQuestions.compactMap { d -> Question? in
guard let q = (d["q"] as? String)? guard let q = (d["q"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else { .trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
return nil return nil
@@ -95,7 +129,30 @@ struct DiaryAssistService {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return Question(q: q, fill: fill, dim: dim) return Question(q: q, fill: fill, dim: dim)
} }
guard !questions.isEmpty else { throw AssistError.empty } }
return (Array(questions.prefix(4)), lastRate)
/// 稿稿(spec 2026-06-10-voice-diary)
/// ( / ),退使,
/// suggest AIRuntime actor ,/
func organize(transcript: String) async throws -> (text: String, decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = DiaryAssistPrompts.organize(transcript: transcript)
var collected = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
let text = HealthExportService.stripThinkBlocks(collected)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw AssistError.empty }
return (text, lastRate)
} }
} }

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

@@ -35,8 +35,56 @@ struct HealthExportService {
} }
} }
/// RAG UI (§12 3)
struct RetrievalSummary: Sendable, Equatable {
var chips: [String]
var indicatorCount: Int
var reportCount: Int
var symptomCount: Int
var diaryCount: Int
var totalCount: Int { indicatorCount + reportCount + symptomCount + diaryCount }
/// (), cap "+N",
static func groupedChips(_ names: [String], cap: Int = 8) -> [String] {
var order: [String] = []
var counts: [String: Int] = [:]
for n in names {
if counts[n] == nil { order.append(n) }
counts[n, default: 0] += 1
}
var chips = order.map { name -> String in
let c = counts[name] ?? 1
return c > 1 ? "\(name) ×\(c)" : name
}
if chips.count > cap {
let overflow = chips.count - cap
chips = Array(chips.prefix(cap)) + ["+\(overflow)"]
}
return chips
}
@MainActor
static func from(snapshot: Snapshot) -> RetrievalSummary {
var chips = groupedChips(snapshot.indicators.map(\.name), cap: 8)
chips += snapshot.reports.prefix(3).map(\.title)
chips += snapshot.symptoms.prefix(3).map(\.name)
if !snapshot.diaries.isEmpty {
chips.append(String(appLoc: "日记 ×\(snapshot.diaries.count)"))
}
return RetrievalSummary(
chips: chips,
indicatorCount: snapshot.indicators.count,
reportCount: snapshot.reports.count,
symptomCount: snapshot.symptoms.count,
diaryCount: snapshot.diaries.count
)
}
}
enum Event { enum Event {
case phaseChanged(Phase) case phaseChanged(Phase)
case retrieved(RetrievalSummary)
case token(TokenChunk) case token(TokenChunk)
case completed(persistentID: PersistentIdentifier) case completed(persistentID: PersistentIdentifier)
// .failed stream throw, Event // .failed stream throw, Event
@@ -80,6 +128,7 @@ struct HealthExportService {
// Phase 2: // Phase 2:
continuation.yield(.phaseChanged(.retrieving)) continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieve(intent: intent, ctx: modelContext) let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
try Task.checkCancellation() try Task.checkCancellation()
// Phase 3: // Phase 3:
@@ -178,9 +227,10 @@ struct HealthExportService {
} }
/// , /// ,
/// : .retrieved(), .token ; .phaseChanged / .completed
func answer(question: String, func answer(question: String,
conversation: [HealthExportDialogueTurn], conversation: [HealthExportDialogueTurn],
in modelContext: ModelContext) -> AsyncThrowingStream<TokenChunk, Error> { in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
AsyncThrowingStream { continuation in AsyncThrowingStream { continuation in
let task = Task { @MainActor in let task = Task { @MainActor in
do { do {
@@ -191,6 +241,7 @@ struct HealthExportService {
} }
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext) let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
let dataJSON = Self.serializeData(snapshot: snapshot) let dataJSON = Self.serializeData(snapshot: snapshot)
let transcript = HealthExportDialogueTurn.transcript(from: conversation) let transcript = HealthExportDialogueTurn.transcript(from: conversation)
let prompt = HealthExportPrompts.dialogueAnswer( let prompt = HealthExportPrompts.dialogueAnswer(
@@ -209,7 +260,7 @@ struct HealthExportService {
if clean.count > displayed.count, clean.hasPrefix(displayed) { if clean.count > displayed.count, clean.hasPrefix(displayed) {
let delta = String(clean.dropFirst(displayed.count)) let delta = String(clean.dropFirst(displayed.count))
displayed = clean displayed = clean
continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate)) continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
} else if clean != displayed { } else if clean != displayed {
displayed = clean displayed = clean
} }
@@ -245,6 +296,7 @@ struct HealthExportService {
continuation.yield(.phaseChanged(.retrieving)) continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext) let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
let dataJSON = Self.serializeData(snapshot: snapshot) let dataJSON = Self.serializeData(snapshot: snapshot)
let transcript = HealthExportDialogueTurn.transcript(from: conversation) let transcript = HealthExportDialogueTurn.transcript(from: conversation)
try Task.checkCancellation() try Task.checkCancellation()
@@ -398,6 +450,8 @@ struct HealthExportService {
var reports: [Report] var reports: [Report]
var diaries: [DiaryEntry] var diaries: [DiaryEntry]
var profile: UserProfile var profile: UserProfile
/// () AI current_meds
var medications: [Medication] = []
/// (, LLM) ## /// (, LLM) ##
var trends: [ExportTrend] = [] var trends: [ExportTrend] = []
} }
@@ -478,6 +532,9 @@ struct HealthExportService {
// Profile() // Profile()
let profile = UserProfileStore.loadOrCreate(in: ctx) let profile = UserProfileStore.loadOrCreate(in: ctx)
// (, AI current_meds)
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
// (, LLM) // (, LLM)
// in-window ; indicators series // in-window ; indicators series
let trends = ExportTrendBuilder.build( let trends = ExportTrendBuilder.build(
@@ -494,6 +551,7 @@ struct HealthExportService {
reports: reports, reports: reports,
diaries: diaries, diaries: diaries,
profile: profile, profile: profile,
medications: medications,
trends: trends trends: trends
) )
} }
@@ -509,6 +567,7 @@ struct HealthExportService {
let indicators = (try? ctx.fetch(indicatorDesc)) ?? [] let indicators = (try? ctx.fetch(indicatorDesc)) ?? []
let diaries = (try? ctx.fetch(diaryDesc)) ?? [] let diaries = (try? ctx.fetch(diaryDesc)) ?? []
let profile = UserProfileStore.loadOrCreate(in: ctx) let profile = UserProfileStore.loadOrCreate(in: ctx)
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt) let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt)
let fromDate = dates.min() ?? Date() let fromDate = dates.min() ?? Date()
@@ -529,6 +588,7 @@ struct HealthExportService {
reports: [], reports: [],
diaries: diaries, diaries: diaries,
profile: profile, profile: profile,
medications: medications,
trends: trends trends: trends
) )
} }
@@ -559,7 +619,11 @@ struct HealthExportService {
if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies } if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions } if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory } if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory }
if !profile.currentMedications.isEmpty { profDict["current_meds"] = profile.currentMedications } // current_meds (Medication); profile.currentMedications
let medNames = snapshot.medications.map { m in
m.detailLine.isEmpty ? m.name : "\(m.name) \(m.detailLine)"
}
if !medNames.isEmpty { profDict["current_meds"] = medNames }
root["profile"] = profDict root["profile"] = profDict
// symptoms // symptoms
@@ -629,7 +693,8 @@ struct HealthExportService {
/// :///, profile /// :///, profile
/// LLM,, /// LLM,,
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool { static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, s.diaries.isEmpty else { guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty,
s.diaries.isEmpty, s.medications.isEmpty else {
return false return false
} }
let p = s.profile let p = s.profile
@@ -641,10 +706,9 @@ struct HealthExportService {
&& p.allergies.isEmpty && p.allergies.isEmpty
&& p.chronicConditions.isEmpty && p.chronicConditions.isEmpty
&& p.familyHistory.isEmpty && p.familyHistory.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 +716,12 @@ struct HealthExportService {
return """ return """
\(title) \(title)
> 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据患者原话,未做任何推断。 > 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据本人原话,未做任何推断。
## 主诉 ## 主诉
\(complaintLine) \(complaintLine)
## 患者背景 ## 本人背景
无记录 无记录
## 近期症状(按时间倒序) ## 近期症状(按时间倒序)
@@ -669,7 +733,7 @@ struct HealthExportService {
## 在服药与过敏 ## 在服药与过敏
无记录 无记录
## 患者疑问 ## 本人疑问
无记录 无记录
""" """
} }

View File

@@ -0,0 +1,114 @@
import Foundation
/// (, UserProfile.currentMedications )
struct ParsedMedication: Sendable, Identifiable {
let id = UUID()
var name: String
var strength: String // , "80mg×7"
var usage: String // , ",1,2"
/// UserProfile.currentMedications ,
/// (placeholder ": 80mg qd")
var entryText: String {
var s = name.trimmingCharacters(in: .whitespaces)
let st = strength.trimmingCharacters(in: .whitespaces)
let u = usage.trimmingCharacters(in: .whitespaces)
if !st.isEmpty { s += " \(st)" }
if !u.isEmpty { s += " · \(u)" }
return s
}
}
/// :OCR LLM(MNN/SME2 )
/// CaptureService.recognizeIndicators :UI AIRuntime(§3.1),
/// CaptureError,UI 退(§3.2)
/// actor CaptureService: AIRuntime(actor),
actor MedicationScanService {
static let shared = MedicationScanService()
private init() {}
/// // OCR [ParsedMedication]
/// (MainActor) OCR , UIImage actor
func recognizeMedications(fromOCRText text: String) async throws -> [ParsedMedication] {
do {
try await AIRuntime.shared.prepare() // LLM( VL AIRuntime )
} catch {
throw CaptureError.modelNotReady
}
let prompt = MedicationPrompts.medicationsFromText(text)
var collected = ""
do {
// 1-2 ,512 token ; AIRuntime
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512)
for try await chunk in stream {
collected += chunk.text
}
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
let cleaned = CaptureService.stripThink(collected)
do {
return try Self.parseMedicationsJSON(cleaned)
} catch let CaptureError.parseFailed(msg) {
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
throw CaptureError.parseFailed("\(msg)〔前缀:\(preview)")
} catch {
throw CaptureError.parseFailed("\(error)")
}
}
// MARK: - JSON parse(static 便)
/// `{"medications":[...]}` `[...]`
/// (),UI ;JSON
static func parseMedicationsJSON(_ raw: String) throws -> [ParsedMedication] {
let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: raw))
guard let data = jsonString.data(using: .utf8) else {
throw CaptureError.parseFailed("非 UTF-8 输出")
}
let obj: Any
do {
obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
}
let rawList: [[String: Any]]
if let dict = obj as? [String: Any] {
rawList = arrayValue(dict, keys: ["medications", "meds", "drugs", "药品", "用药", "items"])
} else if let arr = obj as? [[String: Any]] {
rawList = arr
} else {
throw CaptureError.parseFailed("根节点既不是对象也不是数组")
}
var seen = Set<String>()
return rawList.compactMap { parseMedication($0) }.filter { seen.insert($0.name).inserted }
}
private static func parseMedication(_ d: [String: Any]) -> ParsedMedication? {
guard let name = stringValue(d, keys: ["name", "drug", "medication", "药名", "药品", "名称"])?
.trimmingCharacters(in: .whitespaces),
!name.isEmpty else { return nil }
let strength = stringValue(d, keys: ["strength", "spec", "specification", "规格", "剂量"]) ?? ""
let usage = stringValue(d, keys: ["usage", "dosage", "用法", "用量", "用法用量"]) ?? ""
return ParsedMedication(name: name,
strength: strength.trimmingCharacters(in: .whitespaces),
usage: usage.trimmingCharacters(in: .whitespaces))
}
private static func stringValue(_ d: [String: Any], keys: [String]) -> String? {
for key in keys {
if let s = d[key] as? String { return s }
if let n = d[key] as? NSNumber { return n.stringValue }
}
return nil
}
private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] {
for key in keys {
if let arr = d[key] as? [[String: Any]] { return arr }
}
return []
}
}

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

@@ -80,20 +80,24 @@ enum ReminderService {
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines) let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
let h = reminder.hour, m = reminder.minute let h = reminder.hour, m = reminder.minute
let slots: [Slot] // :,(suffix ,)
switch reminder.frequency { var slots: [Slot] = []
for f in reminder.frequencies {
switch f {
case .daily: case .daily:
slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))] slots.append(Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m)))
case .weekly: case .weekly:
slots = reminder.weekdays.map { wd in slots += reminder.weekdays.map { wd in
Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd)) Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd))
} }
case .monthly: case .monthly:
slots = [Slot(suffix: "monthly", slots += reminder.monthlyDays.map { d in
dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))] Slot(suffix: "m\(d)", dc: DateComponents(day: d, hour: h, minute: m))
}
case .yearly: case .yearly:
slots = [Slot(suffix: "yearly", slots.append(Slot(suffix: "yearly",
dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))] dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m)))
}
} }
await schedule( await schedule(
idBase: "\(customIdPrefix)\(reminder.id.uuidString)", idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
@@ -146,11 +150,13 @@ enum ReminderService {
} }
} }
/// idBase pending (daily/monthly/yearly + 7 weekday,) /// idBase pending ,:
/// daily / yearly / monthly + 7 weekday(w1...w7)+ 31 (m1...m31)
private static func cancelBase(_ idBase: String) { private static func cancelBase(_ idBase: String) {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"] var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"]
ids += (1...7).map { "\(idBase).w\($0)" } ids += (1...7).map { "\(idBase).w\($0)" }
ids += (1...31).map { "\(idBase).m\($0)" }
center.removePendingNotificationRequests(withIdentifiers: ids) center.removePendingNotificationRequests(withIdentifiers: ids)
} }
} }

View File

@@ -0,0 +1,61 @@
import Foundation
import SwiftData
/// (§3.1: AIRuntime,UI )
/// :();
/// : summary VL
@MainActor
final class ReportInsightService {
static let shared = ReportInsightService()
private init() {}
/// ID,
private var inFlight: Set<String> = []
func pregenerateIfNeeded(report: Report, in ctx: ModelContext) async {
guard (report.summary ?? "").isEmpty, !report.indicators.isEmpty else { return }
let key = String(describing: report.persistentModelID)
guard !inFlight.contains(key) else { return }
inFlight.insert(key)
defer { inFlight.remove(key) }
do {
try await AIRuntime.shared.prepare()
} catch {
return // :,
}
let prompt = InsightPrompts.reportPlainSummary(
title: report.title,
typeLabel: report.type.label,
indicatorLines: Self.indicatorLines(for: report.indicators)
)
var collected = ""
do {
let stream = await AIRuntime.shared.generate(
prompt: prompt, maxTokens: 200, priority: .background)
for try await chunk in stream { collected += chunk.text }
} catch {
return // (CancellationError):,
}
let text = HealthExportService.stripThinkBlocks(collected)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, (report.summary ?? "").isEmpty else { return }
report.summary = text
try? ctx.save()
}
/// ( range)status;, 15 prompt
static func indicatorLines(for indicators: [Indicator]) -> String {
let sorted = indicators.sorted {
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
}
return sorted.prefix(15).map { i in
var line = "\(i.name) \(i.value)"
if !i.unit.isEmpty { line += " \(i.unit)" }
if !i.range.isEmpty { line += "(参考 \(i.range))" }
line += " \(i.status.rawValue)"
return line
}.joined(separator: "\n")
}
}

View File

@@ -0,0 +1,162 @@
import Foundation
import Speech
import AVFoundation
/// (spec 2026-06-10-voice-diary)
/// AVAudioEngine buffer SFSpeechAudioBufferRecognitionRequest,
/// `requiresOnDeviceRecognition = true` ,;****
///
/// :start(onPartial:) partial;stop() 稿
/// :DiaryQuickSheet MainActor , MainActor;
/// audio tap 线,,线 Task { @MainActor }
final class SpeechDictationService {
enum DictationError: Error, LocalizedError {
case unavailable
case audioEngineStartFailed(String)
var errorDescription: String? {
switch self {
case .unavailable:
return String(appLoc: "本机不支持端侧语音识别")
case .audioEngineStartFailed(let m):
return String(appLoc: "录音启动失败:\(m)")
}
}
}
/// `prefix` `partial` ,便
/// :;;(/),
/// ,
static func merge(prefix: String, partial: String) -> String {
if partial.isEmpty { return prefix }
if prefix.isEmpty { return partial }
if prefix.last?.isWhitespace == true { return prefix + partial }
return prefix + " " + partial
}
/// ;(demo 使)
private static func makeRecognizer() -> SFSpeechRecognizer? {
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {
return r
}
if let r = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")),
r.supportsOnDeviceRecognition {
return r
}
return nil
}
/// false(/) UI mic ,
static var isAvailable: Bool { makeRecognizer() != nil }
private let audioEngine = AVAudioEngine()
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
/// ;isFinal didFinishstop() final partial
private var latestText = ""
private var didFinish = false
private(set) var isRecording = false
/// + false
func requestAuthorization() async -> Bool {
let speech = await withCheckedContinuation { (c: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
SFSpeechRecognizer.requestAuthorization { c.resume(returning: $0) }
}
guard speech == .authorized else { return false }
return await AVAudioApplication.requestRecordPermission()
}
/// + partial 线()
func start(onPartial: @escaping (String) -> Void) throws {
guard !isRecording else { return }
guard let recognizer = Self.makeRecognizer(), recognizer.isAvailable else {
throw DictationError.unavailable
}
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
try session.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
throw DictationError.audioEngineStartFailed(error.localizedDescription)
}
let request = SFSpeechAudioBufferRecognitionRequest()
request.requiresOnDeviceRecognition = true // 线:
request.shouldReportPartialResults = true
request.addsPunctuation = true
self.request = request
latestText = ""
didFinish = false
let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
// tap 线: request, self
input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
request.append(buffer)
}
audioEngine.prepare()
do {
try audioEngine.start()
} catch {
input.removeTap(onBus: 0)
deactivateSession()
throw DictationError.audioEngineStartFailed(error.localizedDescription)
}
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
// 线 线
Task { @MainActor in
guard let self else { return }
if let result {
self.latestText = result.bestTranscription.formattedString
onPartial(self.latestText)
if result.isFinal { self.didFinish = true }
}
if error != nil { self.didFinish = true }
}
}
isRecording = true
}
/// ,( 1.5s, partial),稿
/// partial (spec :)
func stop() async -> String {
// ( final / ):,
guard isRecording else { return latestText }
isRecording = false
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
request?.endAudio()
let deadline = Date().addingTimeInterval(1.5)
while !didFinish && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
task?.cancel()
task = nil
request = nil
deactivateSession()
return latestText
}
/// sheet :,
func abort() {
guard isRecording else { return }
isRecording = false
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
request?.endAudio()
task?.cancel()
task = nil
request = nil
deactivateSession()
}
private func deactivateSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
}

View File

@@ -0,0 +1,94 @@
import Foundation
/// AI :(140 token)+ (UserDefaults)
/// ;/
@MainActor
final class TrendInsightService {
static let shared = TrendInsightService()
private init() {}
struct Cached: Codable, Equatable {
var fingerprint: String
var text: String
var generatedAt: Date
}
nonisolated static let storePrefix = "kk.trendInsight."
/// :线 key + + + /,
nonisolated static func fingerprint(for bucket: SeriesBucket) -> String {
var parts: [String] = [bucket.id]
for line in bucket.lines {
let pts = line.points
let first = pts.first.map { Int($0.date.timeIntervalSince1970) } ?? 0
let last = pts.last.map { Int($0.date.timeIntervalSince1970) } ?? 0
let lastV = pts.last?.value ?? 0
let minV = pts.map(\.value).min() ?? 0
let maxV = pts.map(\.value).max() ?? 0
parts.append("\(line.seriesKey)#\(pts.count)#\(first)#\(last)#\(lastV)#\(minV)#\(maxV)")
}
return parts.joined(separator: "|")
}
/// (), nil
func cachedText(for bucket: SeriesBucket) -> String? {
guard let data = UserDefaults.standard.data(forKey: Self.storePrefix + bucket.id),
let c = try? JSONDecoder().decode(Cached.self, from: data),
c.fingerprint == Self.fingerprint(for: bucket) else {
return nil
}
return c.text
}
/// /,UI +
func generate(for bucket: SeriesBucket) async throws -> String {
try await AIRuntime.shared.prepare()
let prompt = InsightPrompts.trendInsight(
title: bucket.title,
unit: bucket.unit,
rangeText: Self.rangeText(for: bucket),
dataLines: Self.dataLines(for: bucket)
)
var collected = ""
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 140)
for try await chunk in stream { collected += chunk.text }
let text = HealthExportService.stripThinkBlocks(collected)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw AIRuntimeError.inferenceFailed("空输出") }
let cached = Cached(fingerprint: Self.fingerprint(for: bucket), text: text, generatedAt: .now)
if let data = try? JSONEncoder().encode(cached) {
UserDefaults.standard.set(data, forKey: Self.storePrefix + bucket.id)
}
return text
}
/// 线 24 "yyyy-MM-dd ";线() label
/// UTC :,,
nonisolated static func dataLines(for bucket: SeriesBucket) -> String {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.timeZone = TimeZone(identifier: "UTC")
df.dateFormat = "yyyy-MM-dd"
var lines: [String] = []
for line in bucket.lines {
let pts = line.points.suffix(24)
let prefix = bucket.lines.count > 1 ? "\(line.label ?? line.seriesKey):" : ""
let series = pts.map { "\(df.string(from: $0.date)) \(fmt($0.value))" }
.joined(separator: " / ")
lines.append(prefix + series)
}
return lines.joined(separator: "\n")
}
/// ", lo-hi" ()
nonisolated static func rangeText(for bucket: SeriesBucket) -> String {
guard let r = bucket.lines.first?.referenceRange else { return "" }
return ",参考 \(fmt(r.lowerBound))-\(fmt(r.upperBound))"
}
private nonisolated static func fmt(_ v: Double) -> String {
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f", v)
: String(format: "%.1f", v)
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
/// + rawValue IntentPrompts token
enum VoiceIntent: String, CaseIterable, Sendable {
case diary, medication, symptom, indicator, archive, export, reminder
}
/// :LLM(MNN/SME2 ),6 退(§3.2)
/// diary(), diary
/// , OCRService enum ;UI AIRuntime(§3.1)
/// nonisolated: MainActor, + await,线()
nonisolated enum VoiceIntentService {
static func classify(_ utterance: String) async -> VoiceIntent? {
let text = utterance.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
// ,:6s
if let intent = try? await withTimeout(seconds: 6, operation: {
try await classifyWithLLM(text)
}) {
return intent
}
return keywordMatch(text)
}
// MARK: - LLM
private static func classifyWithLLM(_ text: String) async throws -> VoiceIntent {
try await AIRuntime.shared.prepare()
let stream = await AIRuntime.shared.generate(prompt: IntentPrompts.classify(text),
maxTokens: 48)
var collected = ""
for try await chunk in stream {
collected += chunk.text
}
guard let intent = parseIntent(from: collected) else {
throw CaptureError.parseFailed("intent")
}
return intent
}
/// `{"intent":""}`:think "unknown"/ nil
static func parseIntent(from raw: String) -> VoiceIntent? {
let cleaned = CaptureService.stripThink(raw)
let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: cleaned))
if let data = jsonString.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = obj["intent"] as? String {
return VoiceIntent(rawValue: token.trimmingCharacters(in: .whitespaces).lowercased())
}
// :(diary / symptom )
let bare = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`。."))
.lowercased()
return VoiceIntent(rawValue: bare)
}
// MARK: - 退(,)
/// : reminder, reminder
/// symptom ****(),///
/// , diary
static func keywordMatch(_ text: String) -> VoiceIntent {
let t = text.lowercased()
// archive ****( / / ),
// , archive
let rules: [(VoiceIntent, [String])] = [
(.reminder, ["提醒", "别忘", "闹钟"]),
(.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
(.archive, ["化验单", "化验报告", "检查报告", "检验报告", "体检报告", "归档", "存档"]),
(.export, ["身体档案", "给医生", "健康总结", "导出"]),
(.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
"高压", "低压"]),
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼",
"咳嗽", "发烧", "发热", "头晕", "恶心", "拉肚子"]),
]
for (intent, keys) in rules {
for key in keys where t.contains(key) {
// ( / )****:
// , diary
if intent == .medication || intent == .archive, isNegated(t, keyword: key) {
continue
}
return intent
}
}
// :
return .diary
}
/// /,,
/// ,
private static let negationMarkers: Set<Character> = ["", "", "", "", "", "", ""]
static func isNegated(_ text: String, keyword: String) -> Bool {
guard let range = text.range(of: keyword) else { return false }
let preceding = text[..<range.lowerBound].suffix(2)
return preceding.contains { negationMarkers.contains($0) }
}
}
/// :operation sleep , CancellationError
nonisolated private func withTimeout<T: Sendable>(
seconds: Double,
operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw CancellationError()
}
guard let result = try await group.next() else { throw CancellationError() }
group.cancelAll()
return result
}
}

View File

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

View File

@@ -0,0 +1,45 @@
import Testing
import Foundation
@testable import
struct BenchmarkStoreTests {
/// suite, Swift Testing
private func freshDefaults(_ name: String) -> UserDefaults {
let suite = "test.kk.benchmark.\(name)"
let d = UserDefaults(suiteName: suite)!
d.removePersistentDomain(forName: suite)
return d
}
@Test func savesAndLoadsPerBackend() {
let d = freshDefaults("savesAndLoads")
let mnn = BenchmarkResult(backendLabel: "MNN · SME2", promptTokens: 30, genTokens: 80,
prefillTokensPerSecond: 120, decodeTokensPerSecond: 25,
totalSeconds: 4.2, date: .now)
let mlx = BenchmarkResult(backendLabel: "MLX · GPU", promptTokens: 30, genTokens: 80,
prefillTokensPerSecond: 300, decodeTokensPerSecond: 40,
totalSeconds: 2.5, date: .now)
BenchmarkService.save(mnn, defaults: d)
BenchmarkService.save(mlx, defaults: d)
let all = BenchmarkService.load(defaults: d)
#expect(all.count == 2)
#expect(all["MNN · SME2"]?.decodeTokensPerSecond == 25)
}
@Test func overwritesSameBackend() {
let d = freshDefaults("overwrites")
let old = BenchmarkResult(backendLabel: "MLX · GPU", promptTokens: 1, genTokens: 1,
prefillTokensPerSecond: 1, decodeTokensPerSecond: 1,
totalSeconds: 1, date: .now)
var new = old
new.decodeTokensPerSecond = 99
BenchmarkService.save(old, defaults: d)
BenchmarkService.save(new, defaults: d)
#expect(BenchmarkService.load(defaults: d)["MLX · GPU"]?.decodeTokensPerSecond == 99)
}
@Test func loadOnEmptyReturnsEmpty() {
#expect(BenchmarkService.load(defaults: freshDefaults("loadEmpty")).isEmpty)
}
}

View File

@@ -0,0 +1,60 @@
import Testing
import Foundation
@testable import
/// AI `DiaryAssistService.parseQuestions`
/// JSON : MNN ,
/// nil( suggest /), []
/// parseQuestions @MainActor struct , @MainActor
@MainActor
struct DiaryAssistParseTests {
@Test func parsesStandardWrappedJSON() {
let raw = #"{"questions":[{"dim":"","q":"?","fill":"[],"},{"dim":"","q":"?","fill":"[]"}]}"#
let qs = DiaryAssistService.parseQuestions(from: raw)
#expect(qs?.count == 2)
#expect(qs?.first?.dim == "起病诱因")
}
@Test func parsesMarkdownFenced() {
let raw = """
```json
{"questions":[{"dim":"","q":"?","fill":"[]"}]}
```
"""
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
}
@Test func parsesThinkWrapped() {
let raw = "<think>用户头痛,该问起病诱因</think>{\"questions\":[{\"dim\":\"起病诱因\",\"q\":\"何时开始?\",\"fill\":\"从[时间]\"}]}"
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
}
@Test func parsesBareArrayWithoutWrapper() {
// MNN {"questions":},
let raw = #"[{"dim":"","q":"?","fill":"[]"},{"dim":"","q":"?","fill":"[]"}]"#
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 2)
}
@Test func repairsTrailingCommaAndSmartQuotes() {
// + :repairJSON
let raw = "{“questions”:[{“dim”:“用药过敏”,“q”:“在吃什么药?”,“fill”:“在服[药名],”},]}"
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
}
@Test func emptyQuestionsArrayReturnsEmptyNotNil() {
// JSON : []( .empty, .parseFailed)
let qs = DiaryAssistService.parseQuestions(from: #"{"questions":[]}"#)
#expect(qs != nil)
#expect(qs?.isEmpty == true)
}
@Test func proseReturnsNil() {
#expect(DiaryAssistService.parseQuestions(from: "我觉得你可以多问问睡眠情况。") == nil)
}
@Test func unterminatedThinkOnlyReturnsNil() {
// JSON :strip nil( suggest )
#expect(DiaryAssistService.parseQuestions(from: "<think>嗯,用户写了头痛,我应该问") == nil)
}
}

View File

@@ -0,0 +1,28 @@
import Testing
@testable import
struct DiaryOrganizePromptTests {
@Test func organizePromptContainsTranscriptAndHardRules() {
let prompt = DiaryAssistPrompts.organize(transcript: "今天早上头晕量了血压140 90")
#expect(prompt.contains("今天早上头晕量了血压140 90"))
// 线:///, prompt
#expect(prompt.contains("数值"))
#expect(prompt.contains("药名"))
//
#expect(prompt.contains("一段通顺的话"))
#expect(prompt.contains("分行"))
// prompt :
#expect(prompt.contains("/no_think"))
}
@Test func organizePromptTruncatesLongTranscript() {
let long = String(repeating: "头晕", count: 2000) // 4000 ,
let prompt = DiaryAssistPrompts.organize(transcript: long)
// prompt organizeTranscriptLimit
let expectedTail = String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit))
#expect(prompt.contains(expectedTail))
#expect(!prompt.contains(String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit + 2))))
}
}

Some files were not shown because too many files have changed in this diff Show More