Compare commits

..

56 Commits

Author SHA1 Message Date
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
link2026
ac11aa0f99 ```
feat(Quick): 异常项快拍流程重构为静态图框选识别模式

重构异常项快拍功能,将原有的局部小框拍摄改为整幅单拍后静态框选模式。
新流程为:整幅单拍/相册选择 → 静态图手动框选 → 框内OCR+LLM提取指标 → 核对 → 存储独立Indicator。

主要变更包括:
- 移除实时预览小框拍摄模式,改为整幅拍摄后手动框选
- 新增RegionAdjustView组件用于静态图框选和识别
- 更新状态机流程:idle → adjust(静态图框选) → confirm → save
- 修改识别逻辑,对框选区域进行OCR+LLM处理
- 更新相机组件为SingleShotCameraView,支持整幅拍摄
- 调整错误处理策略,识别失败时可挪框重试而非强制手动录入
- 优化本地化字符串,更新用户界面提示文案
```
2026-06-07 14:27:25 +08:00
link2026
77a4ee1c37 缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成:

```
<type>(<scope>): <subject>

<body>
```

其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
2026-06-07 14:17:18 +08:00
link2026
074d99715d docs: 导出身体档案指标趋势段设计 spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:31:54 +08:00
link2026
60b6ad6d65 缺少代码差异信息,无法生成具体的commit message。
请提供 "code differences" 的具体内容,以便我能够根据代码变更情况生成符合 Angular 规范的中文 commit message。
2026-06-07 09:40:59 +08:00
link2026
675c33bea1 ```
feat(CaptureService): 改进报告解析逻辑并添加多语言键支持

- 修改应用描述从"个人健康影像档案"到"个人健康随记"
- 添加对多种JSON键名的支持,包括中文键名(如"指标"、"项目"、"结果"等)
- 实现指标状态智能推断功能,可根据数值和参考范围自动判断高低状态
- 支持多种状态标识符,包括箭头符号(↑↓)和中英文状态词
- 增加对不同参考范围格式的解析支持(如"< 3.40"、"208 - 428"等)
- 添加相关单元测试验证中文键名和状态推断功能
```
2026-06-06 12:53:52 +08:00
link2026
77697e1600 Merge branch 'main' of https://git.myv0.com/tim/kangkang 2026-06-01 08:57:06 +08:00
link2026
30f97b3535 Merge branch 'feat/w2-ai-foundation' into main
合并 W2 AI 基座 + 代码审查修复到主干。.gitignore 取 feat 完整版(已涵盖 main 的 /build/ /Models/ .DS_Store)。
2026-06-01 08:56:34 +08:00
link2026
3798efa48d merge: resolve conflicts in .gitignore 2026-06-01 08:54:53 +08:00
link2026
770dd6bedf chore: add build, Models, and DS_Store files to gitignore 2026-06-01 08:47:43 +08:00
link2026
bff7cfd4b6 fix(core): 代码审查修复 AI 并发/隐私/解析等多处缺陷
- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因)
- prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃
- applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺)
- parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标
- 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位
- TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论)
- FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值
- 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案)
- 补 3 个回归测试,编译 + 测试全部通过
2026-06-01 08:16:14 +08:00
link2026
32e7c25ed7 ```
feat(Quick): 优化RegionCameraView裁剪算法

重构RegionImageCropper裁剪逻辑,改用纯几何aspect-fill反算方法,
将屏上小框坐标直接映射到照片像素rect,避免使用
metadataOutputRectConverted导致的坐标轴对调问题。

主要变更:
- 移除基于归一化rect的裁剪方式
- 新增cropRect函数进行几何反算
- 修复传感器横向坐标与竖屏照片方向不一致的问题
- 保持裁剪精度的同时提升算法稳定性
```
2026-05-31 23:51:53 +08:00
link2026
d72a1fec17 ```
feat(AI): 添加MLX内存管理和AI模型互斥卸载机制

为防止应用因内存溢出被系统终止,在项目中添加了MLX框架依赖,
并在应用启动时配置GPU缓存限制,设置256MB缓存上限以避免内存过度使用。

同时实现了LLM和VL模型的互斥卸载机制,确保大模型不会同时常驻内存,
通过在加载一个模型前先卸载另一个模型来控制内存使用,防止jetsam OOM。

chore(project): 配置代码签名授权文件

refactor(localization): 调整本地化字符串并清理冗余条目

修正了提醒任务和建议相关的本地化文本,调整了多个UI字符串,
清理了过时和重复的本地化条目,更新了AI识别相关的新字符串资源。
```
2026-05-31 23:22:50 +08:00
link2026
db7cc1bba7 ```
chore(project): 更新项目版本号从1到2

更新了项目配置文件中的CURRENT_PROJECT_VERSION字段,
将所有构建配置的目标版本从1升级到2
```
2026-05-31 18:42:59 +08:00
link2026
adb589af16 feat(quick): 异常项快拍改为局部小框 + VL 识别
将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径:
小框拍局部 → Qwen-VL 只抽 indicators → 用户确认逐项编辑 → 存成独立
Indicator(不建 Report、不留原图,与「记录指标」统一落库)。

- RegionCameraView: AVFoundation 实时预览 + 居中小框,快门后按
  metadataOutputRectConverted 裁剪到框内区域;含裁剪纯函数与权限态。
- VLPrompts.regionExtraction(): 局部识别 prompt,严格 JSON 只要 indicators。
- CaptureService.recognizeRegion(): 临时文件推理后即删,不写 Vault;
  新增 parseIndicatorsJSON / extractBalancedJSON 解析容错。
- QuickRegionConfirmView: 异常项高亮置顶、默认勾选,可编辑/增删/选纳入。
- QuickRegionCaptureFlow: 状态机 idle→analyzing→confirm,30s 超时回退手动。
- RootView: .quick 路由改指向新流程(.archive 仍走 UnifiedCaptureFlow)。
- 删除 5 个无引用的旧 mockup(A1/A2/A3/SmartFramer/QuickCaptureFlow)。

模拟器无相机退化为相册整图;小框裁剪坐标需真机验证。
设计见 docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:12:36 +08:00
link2026
da6223e051 chore: 停止跟踪 build/ 构建产物,补全 .gitignore
build/(xcarchive + export ipa + DerivedData)是编译/打包产物,
不该入库。git rm --cached 移出 24 个误提交文件(磁盘保留),
.gitignore 已含 build/ 规则,后续不再污染 status。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:11:56 +08:00
link2026
40155de709 ```
feat(AI): 优化AIRuntime任务取消机制并增强安全保护

- 在AI推理流中添加Task.checkCancellation()检查,使消费者取消时能快速退出
- 为异步流添加onTermination回调以取消内部Task,与LLMSession一致
- 实现SwiftData store的completeUnlessOpen文件保护,提升数据安全性
- 在store备份过程中同样应用加密保护

feat(home): 优化主页交互体验并统一详情查看功能

- 在主页"最近记录"中点击任意条目可打开只读详情sheet
- 将时间线详情解析逻辑统一收敛到TimelineDetail.resolve方法
- 修复血压条目的精确反查逻辑,避免时间窗匹配错误

feat(archive): 新增提醒任务汇总卡并完善档案库功能

- 在档案库页面新增提醒任务汇总卡,显示总数和启用状态
- 添加按更新时间倒序合并的提醒标题预览功能
- 实现RemindersListView导航路由,统一管理提醒任务
- 优化导出列表显示,优先使用中文标签展示

feat(me): 优化个人中心界面并改进语言设置体验

- 将个人中心标题改为内容文字渲染,解决导航栏背景问题
- 为语言选择器添加个性化图标,使用本族语代表字区分
- 修复语言设置视图的图标显示逻辑

feat(timeline): 新增记录详情页删除功能并优化图表显示

- 在时间线详情页添加永久删除按钮和确认弹窗
- 实现完整的删除逻辑,包括SwiftData硬删和Vault原图unlink
- 修复系列图表的数值范围计算,处理同值数据的对称留白
- 优化血压图表合并逻辑,只保留有数据点的线条

refactor(calendar): 修复DST切换导致的月份天数计算错误

- 使用calendar.range(of:.day,in:.month)替代日期间隔计算
- 避免在夏令时切换月份出现天数偏差问题

fix(ui): 修复多个UI组件的交互响应区域问题

- 为纯描边按钮和胶囊添加contentShape以扩大点击区域
- 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐
```
2026-05-31 09:25:49 +08:00
link2026
7ad41c5f09 ```
docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档

补充了关于导出摘要出现虚构病例问题的详细分析和修复方案,
包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。
```
2026-05-30 20:06:12 +08:00
link2026
dad9d43486 ```
feat: 添加自定义提醒功能并优化项目配置

- 添加 CustomReminder 模型支持自由文案周期性提醒功能
- 实现自定义提醒的 UI 界面,包括新建、编辑和列表展示
- 集成本地通知服务支持自定义提醒的时间触发
- 更新项目配置文件添加应用显示名称和加密声明
- 修正 iOS 部署目标版本从 26.0 到 17.0
- 修复 FileDownloader 中的线程安全问题
- 优化 ModelManifest 和 Localization 的并发安全性
- 扩展本地化字符串支持多语言提醒相关文本
- 调整项目支持平台范围仅保留 iphoneos 和 iphonesimulator
```
2026-05-30 11:36:29 +08:00
link2026
d2c77d5c51 feat: 国际化(i18n) en/ja/ko + App 内语言切换
主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
  + pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
  Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存

注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:28:24 +08:00
191 changed files with 35434 additions and 3215 deletions

5
.gitignore vendored
View File

@@ -1,2 +1,7 @@
# 大模型素材:本地下载用于上传到 OpenList,不入库(~3GB)
/Models/
/build/
.DS_Store
# MNN 预编译二进制:由 scripts/build-mnn-xcframework.sh 本地生成,不入库防历史膨胀
/Frameworks/MNN.xcframework/

293
AGENTS.md Normal file
View File

@@ -0,0 +1,293 @@
# 康康 —— 工程前提
> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。
> 任何 IDE/Codex 会话开始干活前,先读这份文件。
---
## 1. 产品定位
- **名字**:康康(对内代号 Kangkang)
- **形态**:iOS 原生 App,SwiftUI + SwiftData
- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答
- **目标用户**:不愿把体检/化验报告交给云端的普通人
- **明确不做**:医疗诊断、剂量推荐、急诊判断、医生预约、社交、广告、内购、数据上云、账号系统
---
## 2. 技术栈 / 选型(已锁定,不要再讨论)
| 项 | 选型 | 备注 |
|---|---|---|
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
| 持久化 | SwiftData | 见 §5 数据模型 |
| 图表 | Swift Charts | iOS 16+ 原生 |
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
| 模型 | **Qwen3.5-2B**(一个多模态模型,文本+视觉一肩挑) | 真机主用:`taobao-mnn/Qwen3.5-2B-MNN`(~1.2GB);MLX 兜底:`mlx-community/Qwen3.5-2B-4bit`(~1.7GB)。**已废弃**:Qwen3-1.7B / Qwen2.5-VL-3B / Qwen3-VL-4B(4B 实测过慢退回 2B) |
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
| Face ID | LocalAuthentication | |
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
**不引入**:任何云服务 SDK、任何 embedding 模型(RAG 用结构化检索,不用语义)、任何账号系统、任何分析 SDK。
---
## 3. AI 链路核心规则
### 3.1 模块边界(强制)
```
UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX
Persistence
```
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
- **`AIRuntime``actor` 单例,串行化**。同一时刻只允许一个推理任务(模型共享内存/Metal 显存,并发会 OOM 被 jetsam 杀)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。**真正落地**是 actor 内信号量闸门 `acquireGate()/releaseGate()`,所有占显存的重活(解码 + 模型加载)进入前先 await,且加载 VL 前先卸 LLM。
- **引擎选择**:`InferenceEngine.current` 由偏好(`.auto`/`.mnn`/`.mlx`)+ 设备可用性解析,真机默认 `.mnn`(SME2/NEON),模拟器回退 `.mlx`
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
### 3.2 VL pipeline(拍一张 = 一条流程)
**重要**:快拍(1.x) 和 报告归档(2.x) 已经合并成统一 `CaptureService`,UI 不再有 A1-A3 和 B1-B4 两条独立路径。流程:
```
拍照 → 写 Vault(加密目录) → VL 推理(要求输出 JSON,含 kind=single|report)
→ 解析容错(失败回退到手动录入,不卡死)
→ 单项走 A2ConfirmView,整份走 B3MetaView
→ 保存到 Indicator/Report + 关联 Asset
```
VL prompt 必须:
- 明确要求"只输出 JSON,不要解释"
- 带 2 个 few-shot 示例(单项 + 多项)
- 异常状态由 VL 模型基于参考范围直接判断,不要再二次调用 LLM
### 3.3 RAG(结构化检索,不做 embedding)
**两段式调用**:
1. 用 Qwen3.5-2B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
**引用回链**:回答中 `[1][2]` 后处理为可点击 Pill,点击跳源记录详情。
### 3.4 Live Activity
- VL 推理 / RAG 生成开始时启动 Activity
- 每 0.5s 通过 `AIRuntime.lastDecodeRate` 推送 tok/s
- 推理完成保留 2s 显示"已完成 · 0.8s"再 dismiss
- **只能真机测**,模拟器不显示。W5 末预留时间。
---
## 4. 模型分发
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
- **用户面只有一个模型**:Qwen3.5-2B-MNN(~1.2GB,`ModelKind.userFacing = [.mnnLLM]`)。多模态,文本+视觉全包,下载全部 / 就绪计数只算它
- MLX 兜底版 Qwen3.5-2B-4bit(~1.7GB)仅模拟器与兜底用,不展示、不计入「下载全部」,但旁路导入仍可单独导
- WiFi 提示必须有
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
---
## 5. 数据模型(SwiftData)
**当前 schema(2026-05-26)**:7 个 @Model
```swift
@Model class Indicator {
name, value, unit, range, statusRaw, note, capturedAt,
report: Report?, asset: Asset?,
pinned: Bool, // true,Trends
seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... key
}
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt,
indicators: [Indicator] cascade,
assets: [Asset] cascade }
@Model class DiaryEntry { content, createdAt, tags: [String] }
@Model class Symptom { name, startedAt, endedAt?, note?, severity 1-5, tags, createdAt }
@Model class Asset { relativePath, mimeType, bytes, createdAt }
@Model class ChatTurn { question, answer, referencedIndicatorIDs, referencedReportIDs, createdAt, decodeRate }
@Model class UserProfile { // App (UserProfileStore.loadOrCreate)
birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw,
allergies, chronicConditions, familyHistory, currentMedications,
updatedAt
}
```
**原图存储**: `Asset` 只存元数据 + 相对路径,真实 JPEG 落在 `Application Support/Vault/`,目录用 `.completeFileProtection`(iOS 硬件级加密,不要自己造 AES 轮子)。
---
## 6. 安全 / 隐私(已收敛 — 不要扩展)
| 做 | 不做 |
|---|---|
| `Application Support/Vault/` 全目录 `.completeFileProtection` | 自实现 AES 加密 |
| SwiftData store 文件 `.completeFileProtection` | |
| Face ID 启动锁(可选开关,默认关) | |
| 永久删除(SwiftData 硬删 + Asset 文件 unlink) | |
| 离线运行(自然结果,不用单独做) | |
| | 截屏黑屏防护(iOS 没有官方 API,不做) |
| | 加密 ZIP 导出 |
唯一的"导出"是 **9.4 分享文字摘要**(只分享解读文本,不带原图)。
---
## 7. 信息架构
```
TabBar: [主页] [记录] [+ 新建] [趋势] [我的]
│ │ │ │ │
│ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于
│ │ │ └─ 折线图 + AI 一句话解读
│ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状
│ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组)
└─ 问候 + 今日摘要 + 进行中症状 + 最近时间线
```
- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab
- "+ 新建" 是 sheet 不是 Tab
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势),
下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入
- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页
### 7.1 档案库 C1 / C2 导航(看的一半)
录入流程(拍照→VL→编辑→存)只是"录的一半"。**"看的一半"由 C1 列表 + C2 详情承担**——这是 demo 的核心看点之一,不能砍。
```
首页 "我的报告档案" 卡 ──push──► C1 ArchiveListView
│ 分类 chip:全部/体检/化验/影像/处方
│ 按 reportDate 年份分组,卡片显示异常 chip
└──push──► C2 ReportDetailView
├─ Tab "原图":TabView(.page) 翻页 + 长按保存
├─ Tab "解读":数字摘要(总/高/低/正常)
│ + AI 整体摘要
│ + 对比上次(同类型上一份 Report diff)
└─ Tab "指标":Indicator 列表,异常优先
C2 底部两个动作:
├─ "关联到趋势" ──► 把本报告内未 pinned 的 Indicator 批量 pinned = true,Trends 默认展示
└─ "重新解读" ──► CaptureService.reanalyze(report:),重跑 VL 覆盖 summary/indicators
其他进入 C2 的入口:
• ChatTurn 引用 Pill 点击(referencedReportIDs)
• 趋势页数据点 tap → 跳到该点来源 Report 的 C2
• HomeView 时间线点报告类条目
```
### 7.2 对比上次("对比上次"=报告对比,已加回)
C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算,不再调 LLM**:
- 找出"同 `typeRaw` 的上一份 Report"(`reportDate < current AND ORDER BY DESC LIMIT 1`)
- 同名 `Indicator` 配对,数值 diff:`Δ` 绝对值 + 百分比 + 升/降箭头
- 标红:跨越参考范围边界(原本正常→偏高,或反过来)
- 文案模板拼装,不走 LLM,响应即时
- 若无上一份,该区块隐藏
---
## 8. 现有代码状态(2026-05-25)
```
康康/
├── App/KangkangApp.swift ✅ SwiftData container 已建
├── RootView.swift ✅ 3 Tab + RecordSheet 已建
├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn
├── DesignSystem/ ✅ Tokens + Components,沿用
└── Features/
├── Home/ ✅ HomeView 静态 UI,数据未接
├── Quick/A1-A3 🔧 待合并进 UnifiedCaptureFlow
├── Archive/B1-B4 🔧 B1 砍,B2 改用 VisionKit DocumentCamera
├── Record/RecordSheet ✅ 入口选择 UI
├── Trends/ ❌ 只有 placeholder
└── Me/ ❌ 只有 placeholder
待建:
├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore + TokenChunk ✅;VLSession + Prompts/ ❌
├── Debug/DebugAIRunner.swift ✅ DEBUG-only AI 自检入口
├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService
├── Persistence/FileVault.swift ✅ 原图加密目录管理
├── Security/AppLock.swift ❌ Face ID 启动锁
├── Features/Ask/ ❌ AskSheet (RAG 问答 UI)
├── Features/Archive/
│ ├── ArchiveListView ❌ C1 档案列表(分类 chip + 年份分组)
│ └── ReportDetailView ❌ C2 报告详情(三 Tab:原图/解读/指标 + 对比上次)
├── Features/Capture/
│ └── UnifiedCaptureFlow ❌ 替代 QuickCaptureFlow,状态机驱动 A1→VL→A2/B3
├── Features/Onboarding/ ❌ 首启动隐私承诺 + 模型下载
└── LiveActivity/ ❌ WidgetExtension target
```
---
## 9. 设计系统约束
- **不要新增颜色 token**。所有颜色走 `Tj.Palette.*` (sand / paper / ink / brick / leaf / line / text / text3)
- **不要新增字体大小**。走 `Font.tjTitle()` / `tjH2()` / `tjSerifBody()` / 系统 size
- **圆角走 `Tj.Radius.*`**,卡片走 `.tjCard()` modifier
- 按钮走 `TjPrimaryButton` / `TjGhostButton`
新加 View 时先看 `DesignSystem/Components.swift`,有现成的不要复刻。
---
## 10. 不能跨越的红线
写代码前必读:
1. **不引入云服务**——任何 SDK 都不行,包括崩溃上报、分析、灰度
2. **不自己实现密码学**——`.completeFileProtection` 已经够
3. **UI 不直接调 AIRuntime**——必须经过 Service
4. **AIRuntime 必须 actor 化**——禁止 class + lock
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
---
## 11. 6 周时间表
| 周次 | 必交付 |
|---|---|
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
| W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) |
| W5 中 | **ReportCompareService** + C2 解读 Tab "对比上次" 区块 |
| W5 末 | Face ID、永久删除、首页时间线接入真数据、Live Activity(真机) |
| W6 | 模型管理页、首启动下载流程、UI polish、demo 视频、PPT |
**P0 新增项**(从 C 区视觉稿补回):C1 档案列表、C2 报告详情三 Tab、对比上次、关联到趋势、重新解读
**P1**(必须做完):Live Activity、分享文字摘要(9.4)、首启动隐私承诺页
**P2**(余力做):—— 任何 P2/P3 都暂时不做,清单里 11/12/13/14/15/17/18/19 全部 deferred(注意:16.1 报告对比已升 P0)
**砍 P1 决策顺序**(任何一周延期触发):Live Activity → Onboarding 简化 → 分享摘要 → 模型管理页 polish。**绝不动 C1/C2/对比上次**——视觉稿都做了,demo PPT 也要展示,这是核心卖点之一。
---
## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做)
1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意
2. 100% 本地 + SME2 加速 — 技术亮点
3. 本地 RAG 长期记忆 — 端侧不可替代性
4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立
5. AI 趋势解读 — 长期价值
6. Live Activity 实时 tok/s — 现场记忆点
每写一个功能,问自己:这条提升了上面哪一项?如果都没有,就别做。

View File

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

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,65 @@
# 康康KK 隐私政策
生效日期2026-05-31
康康KK 是一款本地优先的个人健康记录工具。本政策说明我们如何处理你的信息。
## 我们收集的信息
康康KK 不要求注册账号,不内置广告 SDK不使用第三方分析 SDK也不会主动将你的健康记录上传到我们的服务器。
你可以在 App 内自行记录或导入以下信息:
- 健康指标,例如血压、血糖、血脂、体重等。
- 体检、化验报告或其他健康资料照片。
- 症状记录、健康日记和个人资料。
- 本地提醒设置。
这些信息默认保存在你的设备本地。
## 权限用途
康康KK 可能请求以下系统权限:
- 相机:用于拍摄体检、化验报告或其他健康资料。
- 相册:用于读取你主动选择导入的报告或照片。
- Face ID用于可选的本地 App 启动锁。
- 通知:用于你主动设置的本地提醒。
我们不会因为这些权限而访问与你选择无关的内容。
## AI 模型下载
康康KK 的本地 AI 功能需要下载模型文件。下载模型时App 会连接模型文件服务器获取模型资源。模型下载请求可能包含常规网络信息,例如 IP 地址、请求时间和设备网络环境产生的技术日志。
健康记录、报告照片、症状和日记不会因为下载模型而上传。
## 数据存储
健康记录和导入的资料默认保存在设备本地。App 使用 iOS 系统提供的文件保护能力保护本地文件。你可以在 App 内删除记录;删除后,相关本地数据会从 App 数据库或文件目录中移除。
如果你通过系统备份、迁移或其他第三方工具处理设备数据,相关行为受对应服务或工具的政策约束。
## 数据共享
康康KK 不出售个人数据,不将健康记录用于广告追踪,也不会与第三方广告或分析服务共享你的健康数据。
只有在你主动使用系统分享功能时,相关内容才会由你选择的系统分享目标处理。
## 医疗说明
康康KK 是健康信息记录与整理工具并非医疗器械。App 内的 AI 解读、趋势分析或问答内容仅供日常记录参考,不构成医疗诊断、治疗建议、用药或剂量建议,也不能替代医生、药师或其他专业人员的意见。任何健康决策请咨询专业医疗人员。
## 儿童隐私
康康KK 不面向儿童提供专门服务。未成年人使用本 App 时应取得监护人同意。
## 联系我们
如果你对本隐私政策有疑问,可以通过以下邮箱联系我们:
xuhuayong@gmail.com
## 政策更新
我们可能会根据功能变化或法律要求更新本政策。更新后的政策会在 App 或公开页面中展示。

View File

@@ -0,0 +1,81 @@
# App Store Metadata
## App Name
康康KK
## Subtitle
本地优先的个人健康档案
## Promotional Text
把体检报告、化验指标、症状和日记整理在本机。无需账号,健康数据默认不上传。
## Description
康康KK 是一款本地优先的个人健康记录工具,帮助你把体检报告、化验指标、症状、日记和趋势整理在同一个地方。
你可以手动记录常见健康指标,拍照归档体检或化验报告,在时间线里回顾每次记录,也可以把重点指标加入趋势页,查看长期变化。
主要功能:
- 健康指标记录:记录血压、血糖、血脂、体重等常见指标,也支持自定义指标。
- 报告与照片归档:通过相机或相册导入体检、化验报告照片,保存到本机档案。
- 症状与日记:记录身体感受、症状变化和就医前想补充的信息。
- 趋势回顾:把长期关注的指标加入趋势页,查看变化曲线。
- 本地优先:无需注册账号,健康记录默认保存在设备本地。
- 可选本地 AI下载模型后可在设备本地辅助整理和通俗解释健康记录。
隐私与安全:
康康KK 不提供账号系统,不内置广告或第三方分析 SDK。健康数据默认保存在你的设备上。相机和相册权限仅用于导入你选择的报告或照片Face ID 可用于本地 App 启动锁。
重要说明:
康康KK 是健康信息记录与整理工具并非医疗器械。App 内的任何 AI 解读、趋势分析或问答内容仅供日常记录参考,不构成医疗诊断、治疗建议、用药或剂量建议,也不能替代医生、药师或其他专业人员的意见。任何健康决策请咨询专业医疗人员,并以原始报告和专业意见为准。
## Keywords
健康记录,体检报告,化验单,血压,血糖,健康档案,症状记录,健康日记,本地AI,隐私
## What's New
首次发布:支持健康指标、症状、日记和体检/化验报告的本地记录与趋势查看。
## Support URL
TODO: Add a public support URL before App Store submission.
## Privacy Policy URL
TODO: Add a public privacy policy URL before App Store submission.
## Category
Primary: Medical
Secondary: Health & Fitness
## Age Rating Notes
No gambling, no unrestricted web access, no user-generated public content, no commerce, no alcohol/tobacco/drug promotion, no medical treatment instructions. The app stores personal health records and includes medical disclaimers.
## App Review Notes
No login is required.
KangkangKK is a local-first personal health record app. It is not a medical device and does not provide diagnosis, treatment, medication, dosage, emergency triage, or doctor appointment services.
Suggested review steps:
1. Launch the app.
2. Tap the center + button.
3. Add a manual health metric, symptom, or diary entry.
4. View saved entries in the Records tab.
5. View charts in the Trends tab.
6. Open Me > About to review privacy and medical disclaimer information.
Camera and photo library permissions are used only when the reviewer chooses to import photos of lab reports or health documents. The app stores user records locally on device.
AI features are optional. They require downloading local models from the Model Management page and may require a higher-memory device. If the models are not downloaded, the app will show a model-not-ready state; this is expected and does not block the core record-management flows.

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

@@ -413,3 +413,18 @@ Output:
| **合计** | **~14h ≈ 2 个工作日** |
也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。
---
## 14. 修订记录:防编造加固(2026-05-30)
**现象**:导出摘要出现整份虚构病例(疲劳/盗汗/血红蛋白98/阿司匹林…),不符任何真实记录。
**根因(双重)**:① §数据范围里「Diary 由关键词过滤后入 prompt」在泛化请求(无症状词,如「最近身体异常」)下把日记**全部清空** → 真实记录没进 prompt;② 数据稀疏时,1.7B 在固定 6 段模板上**凭训练先验脑补**完整病例(对「只用数据/缺失写无记录」这类约束遵循差)。
**修复(三层,客户端硬保证为主)**:
1. **检索**:`retrieve` 改为——有症状词→按词过滤(保留隐私);无症状词→纳入时间窗内最近 5 条日记,确保真实记录进 prompt。
2. **空数据硬兜底**:`isEffectivelyEmpty` 判定无任何记录且 profile 空时,**跳过 LLM**,用 `fallbackReport` 产出确定性「6 段全无记录、主诉仅照搬原话」的摘要,从根上杜绝空数据编造。
3. **prompt 重写**:从「撰写」改为「抽取/搬运」框架;反编造铁律首尾各一遍;加一条**稀疏 few-shot** 教模型「缺失写无记录、数值原样照搬」。
**残留限制**:部分数据(如仅 1 条日记)仍走 LLM,强约束 + few-shot 大幅降低但不能 100% 杜绝小模型臆造;后续可加生成后数值校验。

View File

@@ -0,0 +1,146 @@
# 自由周期提醒(CustomReminder)— 设计文档
**日期**:2026-05-30(W2)
**作者**:link2026 + Claude
**关联卖点**:#4 隐私三件套之外的实用粘性功能(本地通知,无云)
**优先级**:用户明确要求(注:§10.6「用药提醒」原列默认不做,本轮经讨论确认要做,按最小可用实现)
---
## 1. 一句话定位
让用户新建**自由文案的周期性本地提醒**(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」),与现有「指标记录提醒」(去录某项指标)并存但相互独立。完全本地 `UserNotifications`,不引云。
---
## 2. 已确认的设计决策
| 决策点 | 选择 |
|---|---|
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
| 周期粒度 | **每日 / 每周选几天 / 每月某日 / 每年某月某日**(2026-05-30 用户反转原「不做按月/按年」决策)。仍不做「每 N 天间隔」/一次性 |
| 时间选择 | 常用时间快捷预设(8:00/12:00/18:00/22:00 chip)+ 保留 `DatePicker` 精调 |
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
| 多语言 | 所有固定文案走 `String(appLoc:)`,新增中文 key 补 en/ja/ko 到 `Localizable.xcstrings` |
---
## 3. 数据模型
`Models/Models.swift` 新增:
```swift
@Model final class CustomReminder {
enum Frequency: String { case daily, weekly, monthly, yearly } //
@Attribute(.unique) var id: UUID
var title: String // :"5"
var note: String //
var hour: Int // 0...23
var minute: Int // 0...59
var weekdays: [Int] // 1=7=, weekly ( MetricReminder )
var frequencyRaw: String = "daily" // Frequency ( )
var dayOfMonth: Int = 1 // monthly / yearly ,1...31
var month: Int = 1 // yearly ,1...12
var enabled: Bool
var createdAt: Date
var updatedAt: Date
// computed: frequency(get/set frequencyRaw)/ isEveryDay / frequencyLabel()/ timeLabel
}
```
Schema 已含 `CustomReminder.self`。**本轮只给已存在的 `CustomReminder` 加 3 个带内联默认值的属性 → SwiftData 自动轻量迁移,不触发删库兜底(见 §10)。**
四档语义 → iOS `UNCalendarNotificationTrigger(repeats:true)`:
| 频率 | DateComponents | 通知数 | id 后缀 |
|---|---|---|---|
| daily | hour,minute | 1 | `.daily` |
| weekly | hour,minute,weekday ×N | N | `.w<weekday>` |
| monthly | day,hour,minute | 1 | `.monthly` |
| yearly | month,day,hour,minute | 1 | `.yearly` |
边界:iOS 重复触发**不顺延**。monthly 选 29/30/31 → 无此日的月份跳过(UI 给浅色提示);yearly 的「日」选项按所选月份最大天数动态收口(避免「4月31日」永不触发),仅闰年 2/29 给提示。
---
## 4. 通知调度(ReminderService 泛化)
抽出私有共享核心,两种提醒复用:
```swift
private static func schedule(idBase:title:body:hour:minute:weekdays:thread:) async
static func sync(_ custom: CustomReminder) async //
static func cancel(customId: UUID) //
static func sync(_ metric: MetricReminder) async // ,,
```
- custom 通知:`title` = 提醒标题,`body` = 备注(空则用默认文案「到点啦,记得完成」)。
- id 前缀 `kangkang.custom.<uuid>.w<weekday>`(与指标的 `kangkang.reminder.<metricId>.w<weekday>` 不冲突)。
- 保存时调 `requestAuthorization()`;被拒则提示去系统设置。
---
## 5. UI
### 5.1 `CustomReminderEditSheet`(新增)
创建 / 编辑共用。字段:
- 标题 TextField(占位:「做点什么?例:跑步5公里 / 吃2片护肝片」),空标题禁用保存。
- 备注 TextField(可选)。
- 时间 DatePicker(.hourAndMinute)。
- 周几选择(复用 RemindersListView 的 chip 行)。
- 保存 / 取消;编辑态多一个「删除提醒」。
保存:写 SwiftData → 请求通知权限 → `ReminderService.sync(custom)`
### 5.2 `RemindersListView`(改造为提醒中心)
- 顶部「+ 新建提醒」按钮 → 打开 `CustomReminderEditSheet`(create)。
- 「我的提醒」区:`@Query CustomReminder`,每行点开走编辑 sheet,行上 Toggle 控 enabled。
- 「指标记录提醒」区:`@Query MetricReminder`,保持现有内联编辑不变(仅非空时显示区头)。
- 表头副文案、空状态文案更新。
---
## 6. 多语言
新增中文 key + en/ja/ko 译文写入 `Localizable.xcstrings`(源语言 zh-Hans,key 即中文)。脚本只增不改,已存在的 key 跳过。复用已有 key:时间/保存/取消/删除提醒/每天/已关闭/周几名等。用户输入的标题/备注是数据,不翻译。
---
## 7. 文件清单
| 文件 | 改动 |
|---|---|
| `Models/Models.swift` | `CustomReminder` +`Frequency` 枚举 +`frequencyRaw/dayOfMonth/month`(均带内联默认)+ 分档 `frequencyLabel` |
| `App/KangkangApp.swift` | **持久化兜底改造**:迁移失败时由「删库」改为「挪到 `StoreBackups/<时间戳>/` 再重建」(见 §10) |
| `Services/ReminderService.swift` | 调度核心泛化为 `Slot(suffix,DateComponents)` 列表;custom sync 按 frequency 分档;`cancelBase` 覆盖 daily/monthly/yearly/w1-7 |
| `Features/Me/CustomReminderEditSheet.swift` | 频率分段 Picker + 各档子控件(周几 / 日 / 月+日)+ 时间快捷预设行 |
| `Features/Me/RemindersListView.swift` | 不变(`frequencyLabel` 来自模型) |
| `Localizable.xcstrings` | 新增 11 个 key × en/ja/ko |
---
## 8. 红线对齐
- 不引云、不碰密码学(纯本地通知)✅
- 不重构 Tab/RecordSheet 骨架 ✅
- §10.6「用药提醒默认不做」→ 已讨论确认,最小实现(无贪睡/铃声/间隔)✅
---
## 9. 验收(真机)
① 新建「每天 20:00 跑步 5 公里」→ 列表出现 → 到点收到本地通知(标题=跑步5公里);② 改时间/周几即时重排;③ 关闭 Toggle 取消通知;④ 删除清除 pending;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管;⑦ **每月/每年**:切频率后子控件随之变化,边界提示出现;改频率后旧档 pending 通知被清掉(不留孤儿);⑧ **时间预设**:点 8:00/12:00/18:00/22:00 即填,精调仍可用。
---
## 10. 顺带修复:重打包数据丢失(根因 + 方案)
**问题**:Demo 期每次改 schema 重打包,SwiftData 数据被清空。
**根因(单点)**:`App/KangkangApp.swift``ModelContainer` 创建 catch 块**直接删 store 文件**。SwiftData 只对**纯增量**改动自动轻量迁移;一旦某次改动超纲(最常见:给已存在的 `@Model` 新增「非可选且无内联默认值」的属性),自动迁移抛错 → 落入 catch → 删库。W2 几乎每次都在改 schema,故体感「每次都丢」。
**方案(两层)**:
1. **治本**:新增 `@Model` 属性一律「可选」或「内联默认值」(本轮 3 个新字段都给了 `= "daily"` / `= 1`)→ 走轻量迁移、不进 catch、数据保留。
2. **兜底**:catch 不再删库,改为把旧 store(含 `-wal`/`-shm`)**挪到 `Application Support/StoreBackups/<时间戳>/`** 再重建——App 仍能启动,旧数据可手动恢复;挪不动才降级删除。
⚠️ 正式发布前仍应升级为 `VersionedSchema` + `SchemaMigrationPlan` 的正式迁移(注释已就地标注)。

View File

@@ -0,0 +1,130 @@
# Face ID 启动锁 — 设计文档
**日期**:2026-05-30(W2)
**作者**:link2026 + Claude
**关联卖点**:#4 隐私三件套(系统级加密 + Face ID + 永久删除)
**优先级**:P1(CLAUDE.md §6 / §8 / §11,原排期 W5 末,提前实现)
---
## 1. 一句话定位
可选的 Face ID/Touch ID 启动锁(默认关)。开启后,冷启动与「后台超过 1 分钟再回前台」都需要系统认证才能进入 App;失败可用设备密码兜底。完全基于系统 `LocalAuthentication`,不自造任何密码学(对齐红线 §10.2)。
---
## 2. 设计决策(已与用户确认)
| 决策点 | 选择 |
|---|---|
| 锁屏时机 | 冷启动 + 后台超过宽限才重锁 |
| 后台宽限 | 60 秒 |
| 认证策略 | `.deviceOwnerAuthentication`(Face ID/Touch ID 优先,自动跳设备密码兜底,避免锁死) |
| 默认状态 | 关(§6) |
| 开关位置 | 「我的」Tab 现有的 Face ID 卡,改为可交互 Toggle |
| 任务切换器隐私遮罩 | 加,**仅锁开启时生效**(进 `.inactive`/`.background` 盖品牌遮罩,防多任务快照泄露;默认关用户无感) |
**关于 §6「截屏黑屏防护…不做」**:那条针对的是**截图防护**(iOS 无官方 API);本设计的任务切换器遮罩是 `.inactive` 盖视图,是官方支持的标准做法,性质不同。
---
## 3. 架构
```
KangkangApp
└─ WindowGroup { AppLockContainer { RootView() } } ← 仅包一层,RootView 零改动(§10.7)
┌─────────────┴──────────────────────────────┐
│ AppLockContainer<Content> │
│ @Environment(\.scenePhase) │
│ 渲染 content │
│ .overlay { if isLocked → LockScreen}│
│ .overlay { else if showsCover → PrivacyCover}│
│ onAppear → handleAppear(); │
│ onChange(scenePhase) → handleScenePhase() │
└─────────────────────────────────────────────┘
│ 读写
┌─────────────┴──────────────────────────────┐
│ AppLock.shared (@MainActor @Observable) │ ← Security/AppLock.swift
│ enabled ←→ UserDefaults("faceIDLockEnabled")│
│ isLocked / showsPrivacyCover │
│ biometryAvailable / biometryLabel │
│ gracePeriod = 60s,lastBackgroundedAt │
│ authenticate() / enableWithAuth() / disable()│
└──────────────────────────────────────────────┘
```
单例写法与项目既有 `ModelDownloadService.shared` 一致(`@MainActor @Observable final class` + `static let shared`)。
---
## 4. 触发逻辑(状态机)
| scenePhase / 事件 | 行为 |
|---|---|
| 容器 `onAppear`(冷启动) | `enabled` 为真且尚未冷启动锁过 → `isLocked = true` + 触发认证 |
| `.background` | `lastBackgroundedAt = now`;`showsPrivacyCover = enabled` |
| `.inactive`(任务切换器) | `showsPrivacyCover = enabled && !isLocked` |
| `.active` | 隐藏遮罩;若 `enabled && !isLocked && 离开 > 60s``isLocked = true`;若 `isLocked` → 触发认证;清空 `lastBackgroundedAt` |
| 认证成功 | `isLocked = false` |
| 认证失败/取消 | 保持锁定,锁屏提供「解锁」按钮重试(`isAuthenticating` 防重入,不重复弹窗) |
冷启动时 scenePhase 初值为 `.active` 不触发 `onChange`,由 `handleAppear()` 负责冷启动锁;两路触发由 `isAuthenticating` 守卫去重。
---
## 5. 能力探测与兜底
- `refreshAvailability()`:`LAContext.canEvaluatePolicy(.deviceOwnerAuthentication)``biometryAvailable`;读 `biometryType` 决定文案(Face ID / Touch ID / 密码)。
- 设备未设密码/无生物识别 → `biometryAvailable = false`,「我的」开关置灰,副标题「本设备未设置 Face ID 或密码」。
- 认证全程系统弹窗;失败/取消不抛错给 UI,只是停留锁屏。
---
## 6. 文件清单
| 文件 | 改动 |
|---|---|
| `康康/Security/AppLock.swift` | **新增**:单例 + LAContext 封装 + 触发逻辑 |
| `康康/Security/AppLockContainer.swift` | **新增**:包裹层 + scenePhase 驱动 + 两个 overlay |
| `康康/Security/LockScreenView.swift` | **新增**:`LockScreenView` + `PrivacyCoverView` |
| `康康/App/KangkangApp.swift` | `RootView()``AppLockContainer { RootView() }` |
| `康康/Features/Me/MeView.swift` | 静态 Face ID 卡 → 可交互 Toggle 卡 |
| `康康.xcodeproj/project.pbxproj` | 加 `INFOPLIST_KEY_NSFaceIDUsageDescription`(Debug + Release) |
工程用文件系统同步组,新增 `Security/` 下的源文件自动纳入编译,无需手改 pbxproj 注册。
---
## 7. UI
锁屏(`LockScreenView`,全遮罩,走 Tj tokens):
```
🔒 (lock glyph)
康康 已锁定
你的健康档案已加密保护
[ Face ID 解锁 ] ← onAppear 自动触发一次认证;按钮文案随设备能力变
```
隐私遮罩(`PrivacyCoverView`):品牌色底 + app 名,无交互,仅用于遮挡多任务快照。
「我的」Face ID 卡:Toggle 开启时先认证一次(成功才置 `enabled`),关闭直接关。副标题动态:「已开启 · Face ID」/「关闭」/「本设备未设置 Face ID 或密码」。
---
## 8. 红线对齐(CLAUDE.md §10)
- 不自造密码学,只用系统 `LocalAuthentication`
- 默认关,可选开关 ✅
- 不引云 ✅
- 不重构 Tab/RecordSheet 骨架,只加一层包裹 ✅
- 清单内功能(§6/§8/§11 明列 Face ID 启动锁)✅
---
## 9. 测试与验收
- 单元测试价值低(核心是系统弹窗 + scenePhase),不强求;`AppLock` 的宽限判定逻辑可抽纯函数测(可选)。
- **真机验收**:① 开关开启走 Face ID;② 杀进程冷启动需认证;③ 后台 <60s 回来不锁、>60s 回来锁;④ 多任务切换器快照被遮罩;⑤ 关 Face ID 录入(模拟失败)能跳设备密码;⑥ 默认关时全程无感。
- 模拟器:Features → Face ID → Enrolled / Matching Face 可模拟。

View File

@@ -0,0 +1,87 @@
# 异常项快拍(局部小框 + VL 识别)— 设计
> 日期:2026-05-31 · 分支:feat/w2-ai-foundation
> 需求:异常项快拍要拍摄局部,采用小框拍局部,用 Qwen-VL 识别被拍区域→检测项目结构化数据;
> 存储前用户确认;最后只存参数和异常值,可和「记录指标」统一保存。
## 1. 现状与缺口
- `RecordSheet.quick`(标题「异常项快拍」)已存在,但 `RootView.recordFlow(.quick)` 当前直接路由到
`UnifiedCaptureFlow` —— 与「体检报告归档」(`.archive`)完全一样,走的是整页文档扫描,**没有局部小框**,
也会把整份当 `Report` + 原图存档。这与需求(局部 / 只存数值 / 不留图 / 并入指标)不符。
- `Features/Quick/``A1ViewfinderView` / `A2ConfirmView` / `SmartFramer` / `QuickCaptureFlow` /
`A3BatchView` 均为早期 mockup,全树无外部引用(纯孤儿)。`A1ViewfinderView` 有小框引导和 AVFoundation
预览,但**快门未接线**(`capturePhoto()` 从不触发)、**不裁剪**。
## 2. 目标流程
```
RecordSheet(.quick)
→ QuickRegionCaptureFlow(状态机)
├ 真机: RegionCameraView(实时预览 + 居中小框 + 快门 → 裁剪到小框的 UIImage)
└ 模拟器: PhotoPickerSheet(无小框,整图送 VL)
→ CaptureService.recognizeRegion(imageData:) ──actor──► AIRuntime.analyzeReport ─► VLSession
↑ VLPrompts.regionExtraction()
→ QuickRegionConfirmView(逐项可编辑 + 勾选纳入 + 测量时间;异常项高亮置顶)
→ 保存:勾选项各插入一条独立 Indicator(无 Report、无 Asset);ctx.save()
```
红线遵守:UI 不直接调 `AIRuntime`,经 `CaptureService`(§3.1);`AIRuntime` actor 串行(复用既有 VL 路径,
不新增并发);无新增 `@Model`,不触发 SwiftData 迁移。
## 3. 组件
### 3.1 RegionCameraView.swift(新建,取代 A1ViewfinderView)
- AVFoundation 实时预览,`videoGravity = .resizeAspectFill`
- 居中**局部小框**(屏宽 ~84% × 高 ~140pt,虚线框 + 半透明遮罩挖空),提示「把异常项放进框里 · 对准一两行」。
- 底部快门键、顶部取消键。
- 拍照后:`previewLayer.metadataOutputRectConverted(fromLayerRect: 小框rect)` → 归一化裁剪 rect;
先把照片方向 bake 成 `.up`,再按归一化 rect 裁 `CGImage`,回调裁剪后的 `UIImage`
- 相机权限:被拒时显示「去设置开启相机」态。
- 纯函数 `RegionImageCropper.crop(_:normalizedRect:)` + `UIImage.normalizedUp()`,与 View 解耦便于推理/复用。
### 3.2 VLPrompts.regionExtraction()(加进 VLPrompts.swift)
- 说明「这是报告的局部照片,可能只有一两行指标」。
- 严格 JSON,只要 `{"indicators":[{name,value,unit,range,status}]}`,**不要**报告元信息。
- status 由 value 与 range 自判;range 保留原文;不发明指标,看不清整行跳过。
- 2 个 few-shot(单行 / 两行)。
### 3.3 CaptureService.recognizeRegion(imageData: Data)(加进 CaptureService.swift)
- 把 JPEG 写临时文件(`NSTemporaryDirectory`,`.completeFileProtection`),`defer` 删除。
- `prepareVL()``analyzeReport(imageURLs:[temp], prompt: regionExtraction())`
- 新增 `parseIndicatorsJSON(_:)`:复用 `extractJSONObject` + `parseIndicator`,抽出 `indicators` 数组,
返回 `[ParsedReport.ParsedIndicator]`。失败抛 `CaptureError`(UI 回退手动录入)。
### 3.4 QuickRegionCaptureFlow.swift(新建,状态机)
- `Phase { idle, analyzing(UIImage), confirm(items, warning) }`
- 裁剪图 → analyzing → Task:JPEG 编码 → `recognizeRegion` → confirm。
- 30s 超时哨兵 → confirm(空 + warning);各类错误 → confirm(空 + warning)。
- 无 Vault 资产需清理(临时文件已在 service 内删除);取消即关闭。
### 3.5 QuickRegionConfirmView.swift(新建,确认 UI)
- 头部「核对异常项 · 只存数值,不保留照片」+ 内存中的裁剪缩略图(仅核对用,**不持久化**)。
- 测量时间 DatePicker(默认 now)。
- 指标列表:逐项可编辑(name/value/unit/range/status)+ 勾选「纳入保存」。
异常(high/low)项红色高亮、置顶、默认勾选;正常项默认也勾选(用户可取消),体现「只存参数和异常值」由用户掌控。
- 「加一项」手动补充(VL 空结果回退)。
- 底栏:取消 / 保存到记录(N 项)。
### 3.6 RootView 路由
- `.quick → QuickRegionCaptureFlow(onClose:)`(原为 `UnifiedCaptureFlow`)。
### 3.7 清理
- 删除 5 个孤儿 mockup:A1ViewfinderView / A2ConfirmView / SmartFramer / QuickCaptureFlow / A3BatchView。
## 4. 数据落库
- 每个勾选项 → 一条 `Indicator(name,value,unit,range,status,capturedAt,note=nil,pinned=false,seriesKey=nil)`
- 不建 `Report`,不存 `Asset`(原图丢弃)→ 符合「最后只存参数和异常值」。
- 与「记录指标」自由输入路径落库一致(同一 Indicator 表,进记录时间线;不带 seriesKey 不强制进趋势)。
## 5. 取舍
- **裁剪 vs 整图**:需求明确「小框拍局部 / 识别被拍区域」,故真机裁剪到小框(也提升小目标 VL 准确率、降 token)。
模拟器无实时小框 → 退化为整图(与既有 UnifiedCaptureFlow 模拟器退化一致)。
- **不留图**:遵循「只存参数和异常值」与隐私基线,临时文件推理后即删,不写 Vault、不建 Asset。
- **正常项是否保存**:默认全部勾选、异常项高亮,正常项可手动取消 —— 不静默丢弃用户可能想留的读数。
- **不动既有归档流程**:UnifiedCaptureFlow / B3 / C2 不变;本功能只重写 `.quick` 这一条路径。

View File

@@ -0,0 +1,117 @@
# 导出身体档案 — 指标趋势段 设计
> 2026-06-07 · 在「导出身体档案」(`HealthExportService`)的输出里,为本次就诊相关、且有历史记录的指标补一段确定性计算的趋势摘要。
## 背景
当前导出是「快照式」:`HealthExportService.retrieve()` 在时间窗内每个指标只取最近一条,`serializeData()` 序列化成单点数值(name/value/unit/range/status/date),交给 LLM 拼成「## 关键指标」一段。医生看不到指标随时间的变化方向。
需求:导出要带上**相关指标的趋势信息**(同一指标多次记录的变化)。
## 决策(已与用户确认)
| 维度 | 决定 |
|---|---|
| 覆盖范围 | 本次就诊相关(命中关键词或异常)且时间窗内有 **≥2 次**记录的指标 |
| 粒度 | 一行摘要(首值→末值 + 方向箭头 + 时间跨度 + 次数) |
| 生成方式 | **确定性计算**(模板拼装,不经 LLM),与 `ReportCompareService` 同思路,零编造风险 |
| 呈现位置 | LLM 输出 6 段之后,**追加**独立一段 `## 指标趋势`;无数据则整段省略 |
## 架构
```
retrieve() ──► 全量 in-window 指标(裁剪前) ┐
└─► 相关指标集(裁剪后,决定哪些 series 出趋势) ┤
ExportTrendBuilder.build(...) → [TrendSummary]
Snapshot.trends ──► export() 在 completed 前追加 "## 指标趋势"
```
LLM 链路(prompt / `serializeData`)**完全不变**——趋势不进 JSON,LLM 不知情。
## 组件
### 1. `TrendSummary`(值类型)
一个 series 的趋势结果。字段:
- `title: String` — 显示名(如「收缩压」「血压」)
- `unit: String`
- `firstValue: Double``lastValue: Double`
- `firstDate: Date``lastDate: Date`
- `count: Int` — 时间窗内记录次数
- `direction: Direction`(`.up` / `.down` / `.flat`)
- `range: String` — 参考范围原文(可空)
- `flagged: Bool` — 末值仍异常 **或** 跨越参考范围边界,为真时行首加 `⚠️`
方法 `line() -> String`,一行中文,格式:
```
收缩压 152→138 mmHg ↓(参考 90-140近 21 天 4 次
```
- 方向箭头:`.up``↑``.down``↓``.flat``→`
- `flagged` 为真前缀 `⚠️ `
- `range` 为空时省略「(参考 …)」括号
- 数值用与现有指标一致的格式化(去掉无意义小数;血压等整数不带小数点)
> 血压合并行:`title` = 「血压」,数值写成「收缩/舒张」对,如 `血压 152/96→138/88 mmHg ↓…`;方向以收缩压为准。
### 2. `ExportTrendBuilder`(纯函数,可单测)
```swift
enum ExportTrendBuilder {
static func build(allInWindow: [Indicator],
relevant: [Indicator]) -> [TrendSummary]
}
```
逻辑:
1. **确定相关 series**:从 `relevant` 收集 series 标识(优先 `seriesKey`,无则 `name|unit`)。
2. **分组全量点**:把 `allInWindow` 按同一 series 标识分组;血压 `bp.systolic` + `bp.diastolic` 归到合成 series「血压」。
3. **过滤**:只保留(a)属于相关 series、(b)点数 ≥2 的组。
4. 每组按 `capturedAt` 升序,取首/末点,算:
- `direction`:相对变化 `|last-first|/first`,<5% → `.flat`,否则按符号 `.up`/`.down`(first 为 0 时退化按绝对差判定)
- `flagged`:末点 `status != .normal`,或首点 normal 而末点非 normal(或反之,跨界)
- `count``firstDate``lastDate``range`(取末点的 range)
5. 排序:`flagged` 优先,其次按 `lastDate` 倒序。
6. 返回 `[TrendSummary]`
数值解析复用现有方式(`Double(indicator.value)`);解析失败的点跳过,若有效点 <2 则该 series 不出趋势。
### 3. 接入 `HealthExportService`
- `Snapshot``trends: [TrendSummary]`
- `retrieve()`:在现有第 268 行 fetch 全量 in-window 指标后,保留该全量列表;裁剪逻辑不变得到 `indicators`(相关集);调用 `ExportTrendBuilder.build(allInWindow: 全量, relevant: indicators)` 填入 `Snapshot.trends`
- `serializeData()`:**不改**(趋势不进 LLM)。
- `export()`:在发出 `completed` 事件、把内容存进 `HealthExport.content` 之前,若 `snapshot.trends` 非空,把 `## 指标趋势` 段追加到 LLM markdown 末尾。空数据兜底路径(`isEffectivelyEmpty`)trends 自然为空,不追加。
`## 指标趋势` 段渲染:
```markdown
## 指标趋势
⚠️ 收缩压 152→138 mmHg ↓(参考 90-140近 21 天 4 次
空腹血糖 6.8→6.2 mmol/L ↓(参考 3.9-6.1),近 28 天 3 次
```
## 测试
`ExportTrendBuilder.build` 是纯函数,单测覆盖:
- 升 / 降 / 平稳(阈值边界)方向判定
- 血压双 series 合并成一行
- 点数 <2 的 series 被过滤
- 不相关 series(不在 relevant 集)被过滤
- 跨参考范围边界 → `flagged = true`
- 数值无法解析的点被跳过
## 不做
- 不改 LLM prompt / `serializeData`(零编造风险的前提)
- 不引入 embedding、不加新颜色/字体 token
- 不改导出 UI 布局(仅输出内容多一段;`HealthExportSheet` / `HealthExportDetailView``MarkdownView` 已能渲染新段落)
- 不做逐点列表 / 峰谷均值(本次只要一行摘要)
```

View File

@@ -0,0 +1,176 @@
# 趋势大改 + 健康日历移至主页 — 设计文档
> 日期:2026-06-07 · 状态:已定方案(用户授权直接实现,免确认)
## 1. 背景与目标
当前「趋势」Tab(`TrendsView.swift`)把两件事混在一起:
1. **健康日历**(月/年视图 + 当日详情)—— 占据页面上半部分。
2. **长期监测折线图**(`seriesSection`)—— 页面下半部分。
两个问题:
- **日历放错了地方**。它是「总览记录情况」的入口,更适合放在主页(用户每天第一眼看的页面),而不是埋在趋势 Tab 里。
- **趋势能力太弱**。`SeriesBucket.build` **只按 `seriesKey` 分桶**,因此只有 8 个长期监测预设(血压/血糖/体温…)和自定义指标能成图。所有**没有 seriesKey 的指标**——报告里解析出来的化验项、VL 快拍、自由输入——即使在多份报告里反复出现(如「血红蛋白」体检了 3 次),也**完全看不到趋势**。
### 目标
1. **健康日历移到主页**:主页新增一张紧凑的「健康日历」卡(当前周的横条 + 本月记录摘要),点击展开完整的月/年总览页(可切月视图/年视图、看当日详情)。
2. **趋势 Tab 重构**:对**任何出现 ≥2 次的指标**(不限于长期监测预设)做时间序列查看。趋势页变成一个「可成趋势的指标」总览列表(分长期监测 / 化验指标两段),点任一项进入详情页:大图表 + 参考范围带 + 统计摘要(最新/最高/最低/平均/对比上次)+ 时间范围筛选 + 数据点列表(点击跳当日详情)。
### 非目标(本次不做)
- **AI 趋势解读**:需要 AIRuntime + TrendService 跑通,风险大、与本次「时间序列查看」正交。本次预留 UI 位但不接 LLM,留作后续。
- 不改 SwiftData schema(无 @Model 字段变更,规避迁移丢数据风险)。
- 不改 `Localizable.xcstrings`(新文案用 `String(appLoc: "中文")`,无对应词条时优雅回退到中文 key,符合既有大量用法;避免 xcstrings 噪声 diff)。
- 不动 TabBar 5 槽骨架、不动录入流程。
## 2. 架构总览
```
主页 HomeView
└─ HomeCalendarCard(自包含 @Query) ← 新增
当前周横条 + "本月 N 天有记录" + chevron
tap → fullScreenCover(CalendarOverviewView) ← 新增(从 TrendsView 抽出)
趋势 TrendsView(重写)
└─ TrendSeriesList:两段 section
├─ 长期监测(kind=.monitor:seriesKey 分桶,含血压合并/自定义)
└─ 化验指标趋势(kind=.lab:按 name+unit 分桶,≥2 点)
每行 TrendRow:名称 + 最新值/状态 + mini sparkline + 条数·跨度
tap → TrendDetailView(bucket) ← 新增
大图表 + 参考范围带 + 时间范围 chips + 统计摘要 + 数据点列表
数据点 tap → DayDetailSheet(date)(复用)
```
数据层只扩展 `SeriesBucket.build`,UI 层新增 4 个文件、改 2 个文件、删 1 段。
## 3. 数据层:`SeriesBucket` 扩展
文件:`Features/Trends/SeriesBucket.swift`(改)
### 3.1 新增 `kind` 区分两段
```swift
enum SeriesKind { case monitor, lab } // monitor=//;lab=
struct SeriesBucket: Identifiable {
let id: String
let title: String
let unit: String
let lines: [SeriesLine]
let latestDate: Date
let kind: SeriesKind //
let sourceIndicatorIDs: [String] // : Indicator persistentModelID ,
// ... SeriesLine / Point
}
```
### 3.2 `build` 流程改为两段
1. **seriesKey 段(原逻辑,kind=.monitor)**:血压合并、单系列预设、自定义。这些桶里的 Indicator 标记为「已消费」。
2. **name 段(新,kind=.lab)**:对**所有没有 seriesKey** 的 Indicator,按 `normalizedKey(name, unit)` 分桶;每桶 ≥ `minPoints` 才保留。参考范围从该桶**最新一条** Indicator 的 `range` 字符串解析。
3. 两段合并返回,各自按 `latestDate` 倒序。详情/列表按 `kind` 分段。
```swift
// name :trim + + ;unit trimkey = "name|unit"
static func normalizedKey(name: String, unit: String) -> String
// ClosedRange<Double>?
// "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1";("<5.2"/">40"/"120") nil(,)
static func parseRange(_ raw: String) -> ClosedRange<Double>?
```
> **去重**:有 seriesKey 的指标只进 monitor 段;无 seriesKey 的只进 lab 段。即使同名也不混。
> **状态着色**:lab 段每个 Point 的 `status` 直接取 Indicator.status(已由 VL/录入判定),无需重算。
## 4. UI:健康日历移至主页
### 4.1 `CalendarOverviewView`(新文件 `Features/Calendar/CalendarOverviewView.swift`)
把现 `TrendsView` 的日历部分**原样抽出**为独立页:`modeSwitch`(月/年)+ `anchorBar`(◀ 年月 ▶)+ `calendarBody`(`CalendarMonthGrid`/`CalendarYearGrid`)+ `legend` + 月视图下的 `dayDetailInline`
- 自带 `@Query`(indicators/reports/diaries/symptoms/profiles/customMetrics)。
- 接收可选 `initialDate`(从主页某天进入时定位选中)。
- 包在 `NavigationStack`,标题「健康日历」,右上「完成」关闭(用于 fullScreenCover)。
- `CalendarMonthGrid` / `CalendarYearGrid` / `CalendarMarkers` / `DayDetailSheet` **不改**,直接复用。
### 4.2 `HomeCalendarCard`(新文件 `Features/Home/HomeCalendarCard.swift`)
自包含组件(对齐 `TodayRemindersCard` 模式):
- 自带 `@Query`,`CalendarData.build` 计算标记。
- **当前周横条**:周一→周日 7 个紧凑日格(日期数字 + 标记圆点,复用 `DayMarks` 颜色规则:异常红 / 报告灰 / 正常绿 / 日记浅灰;有进行中症状则该格底色淡 amber)。今天高亮。
- 顶部标题「健康日历」+ 右侧「本月 N 天有记录 ›」。
- 整卡可点 → `fullScreenCover(CalendarOverviewView())`;点某一天 → 带 `initialDate` 进入。
- 样式走 `.tjCard()`,放在主页 `greeting` 之后、`TodayRemindersCard` 之前。
### 4.3 `HomeView` 改动
`body` 的 VStack 在 `greeting` 后插入 `HomeCalendarCard()`。其余不动。
## 5. UI:趋势 Tab 重构
### 5.1 `TrendsView`(重写)
移除所有日历相关代码(已迁到主页)。新结构:
- header「趋势」。
- 若无可成趋势的桶 → 空状态(「还没有可成趋势的指标 / 同一指标记录满 2 次后会出现在这里」)。
- 否则两段:
- **长期监测**(`kind == .monitor`):标题 + 计数。
- **化验指标趋势**(`kind == .lab`):标题 + 计数。
- 每段 `ForEach` 渲染 `TrendRow`,点击 push/present `TrendDetailView`
- 导航:`TrendsView``NavigationStack`,行用 `NavigationLink` 进详情(趋势 Tab 当前无 NavigationStack,新增之)。
### 5.2 `TrendRow`(新文件 `Features/Trends/TrendRow.swift`)
紧凑行:
- 左:指标名 + 「N 条 · 近 X 个月」副标题。
- 中:mini sparkline(小号 `Chart`,height≈36,无坐标轴,单/双线,异常点红)。
- 右:最新值 + 单位(异常红)+ chevron。
- `.tjCard(bordered: true)`
### 5.3 `TrendDetailView`(新文件 `Features/Trends/TrendDetailView.swift`)
接收 `bucket: SeriesBucket`,自带 `@Query` 用于数据点→来源跳转。
- **大图表**(height≈220):复用 `SeriesChartCard` 的绘制逻辑(参考范围带 + catmullRom 折线 + 点 + 双线图例),但加坐标轴、按所选时间范围裁剪 domain。
- **时间范围 chips**:全部 / 近1年 / 近6月 / 近3月(仅当跨度 > 该范围才显示对应 chip)。切换裁剪图表点 + 重算 domain + 重算统计。
- **统计摘要卡**:最新值(带状态)/ 对比上次(Δ 绝对值+百分比+升降箭头,跨参考范围边界标红)/ 最低 / 最高 / 平均 / 记录数 / 时间跨度。文案模板拼装,不走 LLM。
- **AI 解读占位**:一行灰字「AI 解读即将上线」(预留,不接 LLM)。
- **数据点列表**(倒序):日期 + 值+单位 + 状态箭头/徽章;`onTapGesture``DayDetailSheet(date:)`(复用现有 sheet,给出当天来源上下文)。
- 标题 = bucket.title。
血压(双线)在详情页:统计摘要按「收缩/舒张」分别给最新值;列表每行显示「收缩/舒张」两值。
## 6. 受影响文件清单
**新增**
- `Features/Calendar/CalendarOverviewView.swift`
- `Features/Home/HomeCalendarCard.swift`
- `Features/Trends/TrendRow.swift`
- `Features/Trends/TrendDetailView.swift`
**修改**
- `Features/Trends/SeriesBucket.swift`(加 kind / sourceIndicatorIDs / name 段 / parseRange
- `Features/Trends/TrendsView.swift`(删日历,重写为趋势列表 + NavigationStack)
- `Features/Home/HomeView.swift`(插入 HomeCalendarCard)
- `康康.xcodeproj/project.pbxproj`(新文件加入 target — 若用 file-system-synchronized group 则免改;需确认)
**不改**:`CalendarMonthGrid/YearGrid/Markers/DayDetailSheet``SeriesChartCard`(详情页复用其绘制思路,可抽 helper 或直接内置)、Models、xcstrings、RootView/TabBar、录入流程。
## 7. 验证
- 构建无错误/无新警告(`DEVELOPER_DIR` 指完整 Xcode,touch 强制重编 — 见记忆 build-from-cli)。
- 主页:日历卡显示当前周标记;点卡进总览;月/年切换;点某天→当日详情正确。
- 趋势:制造同名指标 ≥2 条(如手动录两次「血红蛋白」或两份报告同含一项)→ 出现在「化验指标趋势」段;预设监测仍在「长期监测」段;详情图表/统计/数据点跳转正确;血压双线正常。
- 空状态:全新库时两个页面都给出友好空态。
## 8. 风险与回退
- **range 解析覆盖不全**:单边区间("<5.2")暂不画带,图仍可用 —— 可接受,后续增强。
- **lab 段噪声**:同名但单位不同的指标会分成两桶(key 含 unit)—— 正确行为。若用户名字录入不一致(「血红蛋白」vs「Hb」)会分开 —— demo 可接受,不做模糊归并。
- **pbxproj**:若新文件未自动入 target,构建会报 missing symbol;届时手动加 build file 引用。

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

112
scripts/build-launch.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT="${PROJECT:-$ROOT_DIR/康康.xcodeproj}"
SCHEME="${SCHEME:-康康}"
APP_NAME="${APP_NAME:-$SCHEME}"
CONFIGURATION="${CONFIGURATION:-Debug}"
BUNDLE_ID="${BUNDLE_ID:-com.xuhuayong.kangkang}"
DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$ROOT_DIR/build/DerivedData}"
SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 16 Pro}"
SCREENSHOT_PATH="${SCREENSHOT_PATH:-$ROOT_DIR/build/screenshots/${SCHEME}-launch.png}"
require_tool() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "error: required tool not found: $1" >&2
exit 1
fi
}
require_full_xcode() {
local developer_dir
developer_dir="$(xcode-select -p 2>/dev/null || true)"
if [[ "$developer_dir" != *"/Xcode.app/Contents/Developer"* ]]; then
cat >&2 <<EOF
error: active developer directory is not a full Xcode install:
${developer_dir:-<unset>}
Select Xcode before running this script:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
EOF
exit 1
fi
}
extract_udid() {
sed -n 's/.*(\([0-9A-Fa-f-]\{36\}\)).*/\1/p' | head -n 1
}
find_simulator_udid() {
if [[ -n "${SIMULATOR_UDID:-}" ]]; then
echo "$SIMULATOR_UDID"
return
fi
local udid
udid="$(xcrun simctl list devices available | grep -F "$SIMULATOR_NAME" | extract_udid || true)"
if [[ -n "$udid" ]]; then
echo "$udid"
return
fi
udid="$(
xcrun simctl list devices available |
awk '/-- iOS / { in_ios = 1; next } /-- / { in_ios = 0 } in_ios && /iPhone/ { print; exit }' |
extract_udid || true
)"
if [[ -n "$udid" ]]; then
echo "$udid"
return
fi
echo "error: no available iOS simulator found. Install an iPhone simulator in Xcode." >&2
exit 1
}
main() {
require_tool xcode-select
require_tool xcodebuild
require_tool xcrun
require_full_xcode
local simulator_udid app_path
simulator_udid="$(find_simulator_udid)"
echo "Project: $PROJECT"
echo "Scheme: $SCHEME"
echo "Configuration: $CONFIGURATION"
echo "Simulator: ${SIMULATOR_UDID:-$SIMULATOR_NAME} ($simulator_udid)"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-destination "id=$simulator_udid" \
-derivedDataPath "$DERIVED_DATA_PATH" \
build
app_path="$DERIVED_DATA_PATH/Build/Products/${CONFIGURATION}-iphonesimulator/${APP_NAME}.app"
if [[ ! -d "$app_path" ]]; then
echo "error: built app not found at $app_path" >&2
exit 1
fi
xcrun simctl boot "$simulator_udid" >/dev/null 2>&1 || true
xcrun simctl bootstatus "$simulator_udid" -b
if [[ "${OPEN_SIMULATOR:-1}" == "1" ]]; then
open -a Simulator --args -CurrentDeviceUDID "$simulator_udid"
fi
xcrun simctl install "$simulator_udid" "$app_path"
xcrun simctl launch "$simulator_udid" "$BUNDLE_ID"
mkdir -p "$(dirname "$SCREENSHOT_PATH")"
sleep "${SCREENSHOT_DELAY_SECONDS:-2}"
xcrun simctl io "$simulator_udid" screenshot "$SCREENSHOT_PATH"
echo "Launched $BUNDLE_ID"
echo "Screenshot: $SCREENSHOT_PATH"
}
main "$@"

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"

68
scripts/fetch-qwen3vl.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# 下载 Qwen3-VL-4B-Instruct-4bit(MLX 4bit)全量文件到本地镜像目录,并逐个校验字节数。
# 字节数权威来源:康康/AI/ModelManifest.swift(HF API blobs=true,2026-05 核对)。
# 用法: bash scripts/fetch-qwen3vl.sh
set -uo pipefail
REPO="mlx-community/Qwen3-VL-4B-Instruct-4bit"
BASE="https://huggingface.co/${REPO}/resolve/main"
# 目标 = 康康仓库内的 Models/(已被 .gitignore 忽略,App 旁路导入也认这个目录名)。
# 可用环境变量 KK_MODELS_DIR 覆盖根目录(如指向另一块盘)。
ROOT="${KK_MODELS_DIR:-/Users/xuhuayong/apps/康康/Models}"
DEST="$ROOT/Qwen3-VL-4B-Instruct-4bit"
mkdir -p "$DEST"
# 文件名:期望字节数(与 ModelManifest.swift 的 .vl 清单一一对应)
FILES=(
"config.json:7137"
"model.safetensors:3093767283"
"model.safetensors.index.json:64742"
"tokenizer.json:11422654"
"tokenizer_config.json:5445"
"vocab.json:2776833"
"merges.txt:1671853"
"special_tokens_map.json:613"
"added_tokens.json:707"
"generation_config.json:269"
"chat_template.json:5502"
"chat_template.jinja:5292"
"preprocessor_config.json:782"
"video_preprocessor_config.json:817"
)
fsize() { stat -f%z "$1" 2>/dev/null || echo 0; }
fail=0
for entry in "${FILES[@]}"; do
name="${entry%%:*}"; want="${entry##*:}"; out="$DEST/$name"
if [[ -f "$out" && "$(fsize "$out")" == "$want" ]]; then
echo "SKIP $name (已完整 $want)"; continue
fi
echo "GET $name (期望 $want 字节)"
curl -fL -C - --retry 5 --retry-delay 3 --connect-timeout 30 \
-o "$out" "$BASE/$name" || { echo " !! 下载失败 $name"; fail=1; continue; }
have="$(fsize "$out")"
if [[ "$have" != "$want" ]]; then
echo " !! 字节不符 $name: 实得 $have / 期望 $want"; fail=1
else
echo " OK $name $have"
fi
done
# 大权重额外做 SHA256 校验(HF LFS oid,密码学级,字节数相同也能查出脏数据)。
WEIGHT_SHA="90eeb02604181dbcccd0a30a1f550a4a8928ca7dcbee4aee1449239306cfdfca"
if [[ -f "$DEST/model.safetensors" ]]; then
echo "校验 model.safetensors SHA256(约需 10 余秒)..."
got="$(shasum -a 256 "$DEST/model.safetensors" | awk '{print $1}')"
if [[ "$got" == "$WEIGHT_SHA" ]]; then
echo " ✓ SHA256 匹配"
else
echo " !! SHA256 不符: 实得 $got / 期望 $WEIGHT_SHA"; fail=1
fi
fi
echo "================================================"
total=$(du -sh "$DEST" 2>/dev/null | cut -f1)
echo "目录: $DEST (合计 $total)"
if [[ "$fail" == "0" ]]; then echo "✅ 全部 14 个文件下载并校验通过(权重含 SHA256)"; else echo "❌ 有文件失败,重跑本脚本可断点续传"; fi
exit "$fail"

88
scripts/release-testflight.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# 一键发布 TestFlight:archive → export → 上传 App Store Connect
# 用法:
# ./scripts/release-testflight.sh # 用当前 build 号
# BUMP=1 ./scripts/release-testflight.sh # 自动递增 build 号后再发布
# 认证:依赖 Xcode 已登录的 Apple ID(Xcode → Settings → Accounts)
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT="${PROJECT:-$ROOT_DIR/康康.xcodeproj}"
SCHEME="${SCHEME:-康康}"
CONFIGURATION="${CONFIGURATION:-Release}"
BUILD_DIR="$ROOT_DIR/build/Release"
ARCHIVE_PATH="$BUILD_DIR/${SCHEME}.xcarchive"
EXPORT_PATH="$BUILD_DIR/export"
EXPORT_PLIST="$BUILD_DIR/ExportOptions.plist"
TEAM_ID="${TEAM_ID:-F2C8C774FG}"
require_full_xcode() {
local developer_dir
developer_dir="$(xcode-select -p 2>/dev/null || true)"
if [[ "$developer_dir" != *"/Xcode.app/Contents/Developer"* ]]; then
cat >&2 <<EOF
error: 当前 developer directory 不是完整 Xcode:
${developer_dir:-<unset>}
请先执行:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
EOF
exit 1
fi
}
require_full_xcode
mkdir -p "$BUILD_DIR"
# 可选:递增 build 号
if [[ "${BUMP:-0}" == "1" ]]; then
CURRENT=$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
NEXT=$((CURRENT + 1))
sed -i '' "s/CURRENT_PROJECT_VERSION = $CURRENT;/CURRENT_PROJECT_VERSION = $NEXT;/g" "$PROJECT/project.pbxproj"
echo "==> Build 号: $CURRENT$NEXT"
fi
BUILD_NUM=$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
VERSION=$(sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
echo "==> 发布 v$VERSION ($BUILD_NUM)"
echo "==> [1/3] Archive..."
rm -rf "$ARCHIVE_PATH"
xcodebuild archive \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE_PATH" \
-allowProvisioningUpdates
echo "==> [2/3] 生成 ExportOptions.plist..."
cat > "$EXPORT_PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>upload</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
EOF
echo "==> [3/3] Export 并上传 App Store Connect..."
rm -rf "$EXPORT_PATH"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_PLIST" \
-exportPath "$EXPORT_PATH" \
-allowProvisioningUpdates
echo ""
echo "✅ v$VERSION ($BUILD_NUM) 已上传。App Store Connect 处理完成后(约 5-15 分钟)即可在 TestFlight 分发。"
echo " https://appstoreconnect.apple.com/apps"

53
scripts/upload-qwen3vl.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 把本地 Models/Qwen3-VL-4B-Instruct-4bit/ 的 14 个文件上传到模型分发服务器,
# 使 App 的「模型管理 · 下载」能拉到新 VL 模型(否则用户点下载会 404)。
#
# 服务器:Caddy(file_server browse),web 根 = /srv/models,SSH = root@101.132.124.52。
# App 下载 URL 形如:https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/<file>
# → openresty(终止 HTTPS)回源到 Caddy :80(root /srv/models)。
# → 所以远端目标目录 = /srv/models/Qwen3-VL-4B-Instruct-4bit/。
#
# 认证:已用 ssh-copy-id 装好本机公钥,走免密 key;脚本内不含任何密码。
# 用法: bash scripts/upload-qwen3vl.sh
set -euo pipefail
LOCAL_DIR="/Users/xuhuayong/apps/康康/Models/Qwen3-VL-4B-Instruct-4bit"
SSH_HOST="root@101.132.124.52"
REMOTE_ROOT="/srv/models"
REMOTE_SUBDIR="Qwen3-VL-4B-Instruct-4bit"
REMOTE_DIR="$REMOTE_ROOT/$REMOTE_SUBDIR"
# 上传前本地完整性自检(逐字节,14 文件全 SKIP 才算齐)。
bash "$(dirname "$0")/fetch-qwen3vl.sh" >/dev/null || { echo "本地文件不完整,先跑 fetch-qwen3vl.sh 修复再上传"; exit 1; }
echo "本地 14 文件校验通过,开始上传 → $SSH_HOST:$REMOTE_DIR/"
ssh -o ConnectTimeout=20 "$SSH_HOST" "mkdir -p '$REMOTE_DIR'"
# rsync 断点续传(-P=--partial --progress),--inplace 适合大文件。
# 注意:macOS 自带 rsync 2.6.9 不支持 --info=progress2,用 -P 即可。
rsync -avP --inplace \
-e "ssh -o ConnectTimeout=20" \
"$LOCAL_DIR/" "$SSH_HOST:$REMOTE_DIR/"
echo "✅ rsync 上传完成,开始远端校验..."
# 远端逐文件大小核对(与本地 ModelManifest 的 14 文件一致)。
ssh "$SSH_HOST" "cd '$REMOTE_DIR' && ls -la && echo '--- 总大小 ---' && du -sh ."
cat <<'TIP'
──────────────────────────────────────────────
上传完成。建议再从公网验证一次(应全部 HTTP 200,content-length 与本地一致):
for f in config.json model.safetensors model.safetensors.index.json \
tokenizer.json tokenizer_config.json vocab.json merges.txt \
special_tokens_map.json added_tokens.json generation_config.json \
chat_template.json chat_template.jinja preprocessor_config.json \
video_preprocessor_config.json; do
curl -sI "https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/$f" \
| awk -v F="$f" '/^HTTP/{c=$2} tolower($1)=="content-length:"{s=$2} END{printf "%-32s %s %s\n",F,c,s}'
done
旧模型 Qwen2.5-VL-3B 仍在服务器上;确认新版可用后再删旧目录:
ssh root@101.132.124.52 'rm -rf /srv/models/Qwen2.5-VL-3B-Instruct-4bit'
──────────────────────────────────────────────
TIP

View File

@@ -10,6 +10,7 @@
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; };
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -33,6 +34,7 @@
5E463CF92FC403BB0089145B /* 康康.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "康康.app"; sourceTree = BUILT_PRODUCTS_DIR; };
5E463D082FC403BC0089145B /* 康康Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
5E463D122FC403BC0089145B /* 康康UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
FEEDFACE000000000000F001 /* MNN.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MNN.xcframework; path = Frameworks/MNN.xcframework; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -61,6 +63,7 @@
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -88,6 +91,7 @@
5E463D0B2FC403BC0089145B /* 康康Tests */,
5E463D152FC403BC0089145B /* 康康UITests */,
5E463CFA2FC403BB0089145B /* Products */,
FEEDFACE000000000000F001 /* MNN.xcframework */,
);
sourceTree = "<group>";
};
@@ -183,7 +187,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
LastUpgradeCheck = 2650;
TargetAttributes = {
5E463CF82FC403BB0089145B = {
CreatedOnToolsVersion = 26.0.1;
@@ -199,16 +203,19 @@
};
};
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "康康" */;
developmentRegion = en;
developmentRegion = "zh-Hans";
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
ja,
ko,
);
mainGroup = 5E463CF02FC403BB0089145B;
minimizedProjectReferenceProxies = 1;
packageReferences = (
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */,
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
@@ -289,6 +296,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -318,6 +326,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -341,6 +350,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -351,6 +361,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -380,6 +391,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_NS_ASSERTIONS = NO;
@@ -396,6 +408,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@@ -405,14 +418,27 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "康康";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -423,24 +449,25 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
@@ -450,14 +477,27 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "康康";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -468,24 +508,25 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
@@ -495,23 +536,24 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
XROS_DEPLOYMENT_TARGET = 26.0;
};
@@ -522,23 +564,24 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
XROS_DEPLOYMENT_TARGET = 26.0;
};
@@ -548,23 +591,24 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "康康";
XROS_DEPLOYMENT_TARGET = 26.0;
};
@@ -574,23 +618,24 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "康康";
XROS_DEPLOYMENT_TARGET = 26.0;
};
@@ -638,12 +683,12 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */ = {
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ml-explore/mlx-swift-examples";
repositoryURL = "https://github.com/ml-explore/mlx-swift-lm";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.29.1;
kind = exactVersion;
version = 2.31.3;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -651,17 +696,17 @@
/* Begin XCSwiftPackageProductDependency section */
FEED000000000000DEAD0003 /* MLXLLM */ = {
isa = XCSwiftPackageProductDependency;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
productName = MLXLLM;
};
FEED000000000000DEAD0004 /* MLXLMCommon */ = {
isa = XCSwiftPackageProductDependency;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
productName = MLXLMCommon;
};
FEED000000000000DEAD0006 /* MLXVLM */ = {
isa = XCSwiftPackageProductDependency;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
productName = MLXVLM;
};
/* End XCSwiftPackageProductDependency section */

View File

@@ -1,13 +1,13 @@
{
"originHash" : "6b8265ebd61c6fdfca835dd1f90f17439ca9abc5c11a8b7b5db8790be0349e4d",
"originHash" : "facc0ac7c70363ea20f6cd1235de91dea6b06f0d00190946045a6c8ae753abc2",
"pins" : [
{
"identity" : "gzipswift",
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/1024jp/GzipSwift",
"location" : "https://github.com/mattt/EventSource.git",
"state" : {
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05",
"version" : "6.0.1"
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
"version" : "1.4.1"
}
},
{
@@ -15,17 +15,35 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift",
"state" : {
"revision" : "072b684acaae80b6a463abab3a103732f33774bf",
"version" : "0.29.1"
"revision" : "dc43e62d7055353c7f99fa071a4e71d29dfddc44",
"version" : "0.31.4"
}
},
{
"identity" : "mlx-swift-examples",
"identity" : "mlx-swift-lm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift-examples",
"location" : "https://github.com/ml-explore/mlx-swift-lm",
"state" : {
"revision" : "9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631",
"version" : "2.29.1"
"revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff",
"version" : "2.31.3"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
"version" : "1.7.0"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
@@ -37,6 +55,24 @@
"version" : "1.5.1"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
"version" : "4.5.0"
}
},
{
"identity" : "swift-huggingface",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-huggingface.git",
"state" : {
"revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
"version" : "0.9.0"
}
},
{
"identity" : "swift-jinja",
"kind" : "remoteSourceControl",
@@ -46,6 +82,15 @@
"version" : "2.3.6"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95",
"version" : "2.100.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
@@ -55,13 +100,31 @@
"version" : "1.1.1"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "669763cfd5806a67e21972d7e5e2d6b80b1ea985",
"version" : "1.6.5"
}
},
{
"identity" : "swift-transformers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-transformers",
"state" : {
"revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2",
"version" : "1.0.0"
"revision" : "58c4bc11963a140358d791f678a60a2745a23146",
"version" : "1.2.1"
}
},
{
"identity" : "yyjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ibireme/yyjson.git",
"state" : {
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
"version" : "0.12.0"
}
}
],

View File

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

View File

@@ -1,4 +1,5 @@
import Foundation
import MLX
enum AIRuntimeError: Error, LocalizedError {
case notReady
@@ -7,13 +8,20 @@ enum AIRuntimeError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .notReady: return "AI 模型尚未准备好"
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
case .inferenceFailed(let m): return "推理失败:\(m)"
case .notReady: return String(appLoc: "AI 模型尚未准备好")
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
}
}
}
/// interactive = (//);
/// background = (),
nonisolated enum InferencePriority: Sendable, Equatable {
case interactive
case background
}
actor AIRuntime {
static let shared = AIRuntime()
@@ -28,30 +36,144 @@ actor AIRuntime {
private(set) var vlStatus: Status = .notReady
private(set) var lastDecodeRate: Double = 0
/// (;)
private(set) var lastGenerateStats: GenerateStats?
/// ( / PPT )
var activeBackendLabel: String {
if InferenceEngine.current == .mnn, mnnStatus == .ready {
return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · NEON"
}
#if targetEnvironment(simulator)
return "MLX · CPU(模拟器)"
#else
return "MLX · GPU"
#endif
}
private var llmSession: LLMSession?
private var vlSession: VLSession?
// MARK: - MNN (CPU/SME2,)
// .mnn , VL() Qwen3.5-2B MNN ()
// MNN,VL 退 MLX Qwen3-VL-4B
private let mnn = MNNBackend()
private(set) var mnnStatus: Status = .notReady
/// MNN (/ Models/Qwen3.5-2B-MNN)
nonisolated static var mnnModelFolder: URL {
ModelStore.shared.localURL(for: .mnnLLM)
}
// MARK: - (§3.1 OOM )
//
// actor , generate() Task;
// analyzeReport await actor,LLM VL,
// GPU App jetsam
//(MEMORY in-flight )
//
// actor (count = 1):( + )
// await acquireGate(), releaseGate()actor
// gateBusy / gateWaiters
private struct GateWaiter {
let priority: InferencePriority
let cont: CheckedContinuation<Void, Never>
}
private var gateBusy = false
private var gateHolderPriority: InferencePriority = .interactive
private var preemptRequested = false
private var gateWaiters: [GateWaiter] = []
/// interactive background ; FIFO,
nonisolated static func gateInsertionIndex(of priority: InferencePriority,
in waiting: [InferencePriority]) -> Int {
guard priority == .interactive else { return waiting.count }
return waiting.firstIndex(of: .background) ?? waiting.count
}
private func acquireGate(_ priority: InferencePriority = .interactive) async {
if !gateBusy {
gateBusy = true
gateHolderPriority = priority
return
}
// : token CancellationError
if priority == .interactive, gateHolderPriority == .background {
preemptRequested = true
}
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
let idx = Self.gateInsertionIndex(of: priority, in: gateWaiters.map(\.priority))
gateWaiters.insert(GateWaiter(priority: priority, cont: cont), at: idx)
}
// releaseGate (gateBusy true)
}
private func releaseGate() {
preemptRequested = false
if gateWaiters.isEmpty {
gateBusy = false
} else {
// ,gateBusy true,
let next = gateWaiters.removeFirst()
gateHolderPriority = next.priority
next.cont.resume()
}
}
/// token :
private func shouldPreempt(_ priority: InferencePriority) -> Bool {
priority == .background && preemptRequested
}
private init() {}
/// ,
func prepare() async throws {
switch status {
case .ready:
return
case .loading:
// ; prepare ,
// await prepare() status, / UI
// W3 prepare
return
case .error, .notReady:
break
}
/// App : MLX GPU , reuse cache
/// App ( CPU, Metal abort)
/// increased-memory-limit entitlement + LLM/VL , jetsam OOM
nonisolated static func configureMLXMemory() {
#if !targetEnvironment(simulator)
// 256MB cache : 3GB MB
MLX.Memory.cacheLimit = 256 * 1024 * 1024
#endif
}
guard ModelStore.shared.isReady(.llm) else {
/// ,
/// :.mnn MNN(CPU/SME2);.mlx MLX(GPU)
func prepare() async throws {
// MNN MNN;( MLX, MNN )退 MLX,
// App (Phase 5)
let mnnReady = ModelStore.shared.isComplete(for: .mnnLLM)
if InferenceEngine.current == .mnn, mnnReady {
try await prepareMNN()
return
}
// MLX: MNN ()
await unloadMNN()
// ,
// return: ready, generate
// `guard status == .ready` ()
while status == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if status == .ready { return }
// isComplete() isReady( config.json):config.json ,
// isReady true safetensors ModelDownloadService
// ( isComplete)
guard ModelStore.shared.isComplete(for: .llm) else {
status = .error("LLM 模型未就绪")
throw AIRuntimeError.notReady
}
// :( VL ), VL + LLM,
// VL + LLM OOM
await acquireGate()
defer { releaseGate() }
// :, load
if status == .ready { return }
// OOM (§3.1):LLM(~1GB) VL(~3GB), App jetsam
unloadVL()
status = .loading
do {
let session = try await LLMSession.load(
@@ -65,33 +187,129 @@ actor AIRuntime {
}
}
/// MNN : MLX LLM/VL
private func prepareMNN() async throws {
while mnnStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if mnnStatus == .ready { return }
let folder = Self.mnnModelFolder
guard ModelStore.shared.isComplete(for: .mnnLLM) else {
mnnStatus = .error("MNN 模型未就绪")
throw AIRuntimeError.notReady
}
await acquireGate()
defer { releaseGate() }
if mnnStatus == .ready { return }
// : MLX LLM/VL, MNN
unloadLLM()
unloadVL()
mnnStatus = .loading
do {
try await mnn.load(folderURL: folder)
mnnStatus = .ready
} catch {
mnnStatus = .error("\(error)")
throw AIRuntimeError.modelLoadFailed("\(error)")
}
}
/// MNN,
private func unloadMNN() async {
guard mnnStatus != .notReady else { return }
await mnn.unload()
mnnStatus = .notReady
MLX.Memory.clearCache()
}
/// await prepare()
/// :, actor LLMSession await
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
/// priority = .background token (CancellationError )
func generate(prompt: String,
maxTokens: Int = 256,
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
if InferenceEngine.current == .mnn, mnnStatus == .ready {
return mnnGenerate(prompt: prompt, maxTokens: maxTokens, priority: priority)
}
// actor ,Task 访 self.status / self.llmSession
let snapshotStatus = status
let snapshotSession = llmSession
return AsyncThrowingStream { continuation in
Task {
let task = Task {
guard snapshotStatus == .ready, let session = snapshotSession else {
continuation.finish(throwing: AIRuntimeError.notReady)
return
}
// : LLM VL / ,
await self.acquireGate(priority)
do {
// session.generate actor , await
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
for try await chunk in stream {
// (UI)/, checkCancellation Task 退,
// session onTermination, MLX , GPU
try Task.checkCancellation()
// :, token 退
if self.shouldPreempt(priority) { throw CancellationError() }
// Task generate() , AIRuntime actor ;
// actor recordRate await
self.recordRate(chunk.decodeRate)
continuation.yield(chunk)
}
self.lastGenerateStats = await session.lastStats
continuation.finish()
} catch is CancellationError {
// / CancellationError ,
continuation.finish(throwing: CancellationError())
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
// / / (checkCancellation catch ),
// ,
self.releaseGate()
}
// / Task( LLMSession / HealthExportService )
continuation.onTermination = { _ in task.cancel() }
}
}
/// 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() }
}
}
@@ -103,22 +321,37 @@ actor AIRuntime {
/// VL , load
func prepareVL() async throws {
switch vlStatus {
case .ready, .loading:
// MNN :VL MNN (+), prepareMNN
if InferenceEngine.current == .mnn, ModelStore.shared.isComplete(for: .mnnLLM) {
try await prepareMNN()
return
case .error, .notReady:
break
}
while vlStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if vlStatus == .ready { return }
guard ModelStore.shared.isReady(.vl) else {
// MLX VL .llm Qwen3.5-2B (VLMModelFactory qwen3_5 ),
// Qwen3-VL-4B isComplete ,
guard ModelStore.shared.isComplete(for: .llm) else {
vlStatus = .error("VL 模型未就绪")
throw AIRuntimeError.notReady
}
// :( LLM ), LLM + VL
// App 退
await acquireGate()
defer { releaseGate() }
if vlStatus == .ready { return }
// OOM (§3.1): VL(~3GB) LLM(~1GB), jetsam
unloadLLM()
await unloadMNN()
vlStatus = .loading
do {
let session = try await VLSession.load(
folderURL: ModelStore.shared.localURL(for: .vl)
folderURL: ModelStore.shared.localURL(for: .llm)
)
self.vlSession = session
vlStatus = .ready
@@ -128,15 +361,46 @@ actor AIRuntime {
}
}
// MARK: - (OOM )
/// LLM, ModelContainer MLX
/// :(prepareVL ), LLM ,
private func unloadLLM() {
guard llmSession != nil else { return }
llmSession = nil
status = .notReady
MLX.Memory.clearCache()
}
/// VL, ModelContainer MLX
private func unloadVL() {
guard vlSession != nil else { return }
vlSession = nil
vlStatus = .notReady
MLX.Memory.clearCache()
}
/// JSON ( VLPrompts.reportExtraction )
/// + 退(§3.2)
/// AIRuntime actor, LLM.generate() , OOM
/// LLM.generate() , OOM
func analyzeReport(imageURLs: [URL],
prompt: String,
maxTokens: Int = 512) async throws -> String {
// MNN : MNN
if InferenceEngine.current == .mnn, mnnStatus == .ready {
await acquireGate()
defer { releaseGate() }
do {
return try await mnn.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
} catch {
throw AIRuntimeError.inferenceFailed("\(error)")
}
}
guard vlStatus == .ready, let session = vlSession else {
throw AIRuntimeError.notReady
}
await acquireGate()
defer { releaseGate() }
do {
return try await session.analyze(
imageURLs: imageURLs,

View File

@@ -7,9 +7,9 @@ enum DownloadError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .badStatus(let code):
return "下载失败(HTTP \(code))"
return String(appLoc: "下载失败(HTTP \(code))")
case .sizeMismatch(let expected, let got):
return "文件大小校验失败(预期 \(expected),实际 \(got))"
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
}
}
}
@@ -74,12 +74,12 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
let fileHandle = try FileHandle(forWritingTo: part)
try fileHandle.seekToEnd()
lock.lock()
self.handle = fileHandle
self.written = offset
self.onProgress = onProgress
self.responseError = nil
lock.unlock()
lock.withLock {
self.handle = fileHandle
self.written = offset
self.onProgress = onProgress
self.responseError = nil
}
var request = URLRequest(url: url)
if offset > 0 {

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 {
let container: ModelContainer
/// ( .info ,)
private(set) var lastStats: GenerateStats?
private func record(_ s: GenerateStats) { lastStats = s }
init(container: ModelContainer) {
self.container = container
}
@@ -45,10 +50,16 @@ actor LLMSession {
let task = Task {
do {
try await Self.withDeviceOverride {
// : App "/JSON ", JSON
// 0.3 + topP 0.85 JSON ( MNN set_config )
// repetitionPenalty: + ,()
// ;1.1 + 64 token ( MNN penalty )
let parameters = GenerateParameters(
maxTokens: maxTokens,
temperature: Float(0.6),
topP: Float(0.9)
temperature: Float(0.3),
topP: Float(0.85),
repetitionPenalty: Float(1.1),
repetitionContextSize: 64
)
try await container.perform { (context: ModelContext) in
@@ -72,9 +83,14 @@ actor LLMSession {
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
continuation.yield(TokenChunk(text: text, decodeRate: rate))
case .info:
case .info(let info):
// ,
break
await self.record(GenerateStats(
promptTokens: info.promptTokenCount,
genTokens: info.generationTokenCount,
prefillSeconds: info.promptTime,
decodeSeconds: info.generateTime
))
case .toolCall:
// ,switch

View File

@@ -0,0 +1,55 @@
//
// MNNLLMBridge.h
// 康康
//
// Objective-C 接口,封装 MNN-LLM(Qwen)的加载与流式推理。
// 真实实现在 .mm 中以 ObjC++ 调用 <MNN/llm/llm.hpp>;模拟器下编为可用性返回 NO 的桩
// (MNN.framework 仅 device arm64 切片有真实 CPU/SME2 内核,模拟器走 MLX 兜底)。
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// 末次生成的性能统计(取自 MNN LlmContext)。
@interface MNNGenerateStats : NSObject
@property (nonatomic, readonly) int promptTokens;
@property (nonatomic, readonly) int genTokens;
@property (nonatomic, readonly) double prefillMs;
@property (nonatomic, readonly) double decodeMs;
/// 解码速率 tok/s = genTokens / (decodeMs/1000)。demo 卖点 #6 / Live Activity 用。
@property (nonatomic, readonly) double decodeTokensPerSecond;
@end
@interface MNNLLMBridge : NSObject
/// 本构建是否含真实 MNN 运行时(device=YES,simulator 桩=NO)。
+ (BOOL)isAvailable;
/// CPU 是否支持 SME2(运行时探测);A19/iPhone17 YES,A17/iPhone15Pro NO。仅用于 UI 展示加速状态。
+ (BOOL)cpuSupportsSME2;
/// 用 MNN llm 的 config.json 路径加载模型(目录含 llm.mnn / 权重 / tokenizer)。失败返回 nil。
- (nullable instancetype)initWithConfigPath:(NSString *)configPath;
@property (nonatomic, readonly) BOOL isLoaded;
/// 纯文本流式生成。onToken 每解码出一段文本回调一次(在调用线程,同步阻塞直到生成结束)。
/// 返回末次统计。
- (MNNGenerateStats *)generateText:(NSString *)prompt
maxTokens:(int)maxTokens
onToken:(void (^)(NSString *piece))onToken;
/// 图→文(VL,需 MNN_BUILD_LLM_OMNI 构建)。imagePaths 为本地文件路径。
/// 当前文本构建未含 OMNI 时返回 nil 并置 error。
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
prompt:(NSString *)prompt
maxTokens:(int)maxTokens
onToken:(void (^)(NSString *piece))onToken
error:(NSError *_Nullable *_Nullable)error;
/// 请求取消当前生成(best-effort:置标志,后续 token 不再回调)。
- (void)cancel;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,210 @@
//
// MNNLLMBridge.mm
// 康康
//
// ObjC++ 实现。device 真机用 <MNN/llm/llm.hpp>;模拟器编为桩(返回不可用,上层回退 MLX)。
//
#import "MNNLLMBridge.h"
#include <sys/sysctl.h>
// MARK: - 性能统计(私有 readwrite 重声明)
@interface MNNGenerateStats ()
@property (nonatomic, readwrite) int promptTokens;
@property (nonatomic, readwrite) int genTokens;
@property (nonatomic, readwrite) double prefillMs;
@property (nonatomic, readwrite) double decodeMs;
@end
@implementation MNNGenerateStats
- (double)decodeTokensPerSecond {
return self.decodeMs > 0 ? (self.genTokens / (self.decodeMs / 1000.0)) : 0;
}
@end
// MARK: - SME2 / 可用性探测(device + simulator 都可编)
static BOOL kk_sysctlFlag(const char *name) {
int64_t v = 0; size_t sz = sizeof(v);
if (sysctlbyname(name, &v, &sz, NULL, 0) != 0) return NO;
return v != 0;
}
#if TARGET_OS_SIMULATOR
// ============ 模拟器桩:无真实 MNN ============
@implementation MNNLLMBridge
+ (BOOL)isAvailable { return NO; }
+ (BOOL)cpuSupportsSME2 { return NO; }
- (nullable instancetype)initWithConfigPath:(NSString *)configPath { return nil; }
- (BOOL)isLoaded { return NO; }
- (MNNGenerateStats *)generateText:(NSString *)prompt maxTokens:(int)maxTokens
onToken:(void (^)(NSString *))onToken { return [MNNGenerateStats new]; }
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths prompt:(NSString *)prompt
maxTokens:(int)maxTokens onToken:(void (^)(NSString *))onToken
error:(NSError **)error {
if (error) *error = [NSError errorWithDomain:@"MNN" code:-1
userInfo:@{NSLocalizedDescriptionKey: @"MNN 在模拟器不可用"}];
return nil;
}
- (void)cancel {}
@end
#else
// ============ 真机:真实 MNN-LLM ============
// MNN 第三方头文件的文档注释不规范,会触发一堆 -Wdocumentation 警告(Executor/
// Tensor/Interpreter/ImageProcess.hpp)。只在解析 MNN 头时关掉该警告,不影响本项目。
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdocumentation"
#include <MNN/llm/llm.hpp>
#pragma clang diagnostic pop
#include <string>
#include <ostream>
#include <streambuf>
#include <atomic>
using MNN::Transformer::Llm;
namespace {
/// 把 MNN 写入 ostream 的解码文本转成 NSString 回调;按 UTF-8 完整边界聚合,避免截断多字节。
class TokenStreamBuf : public std::streambuf {
public:
TokenStreamBuf(void (^onToken)(NSString *), std::atomic<bool> *cancel)
: _onToken(onToken), _cancel(cancel) {}
void flush() {
if (_pending.empty()) return;
emitPending(); // 末尾尽力 emit(即便非完整 UTF-8 也交出去)
_pending.clear();
}
protected:
std::streamsize xsputn(const char *s, std::streamsize n) override {
append(s, (size_t)n);
return n;
}
int overflow(int c) override {
if (c != EOF) { char ch = (char)c; append(&ch, 1); }
return c;
}
private:
void append(const char *s, size_t n) {
if (_cancel && _cancel->load()) return; // 已取消,吞掉不回调
_pending.append(s, n);
// 仅当整个 pending 是合法 UTF-8 才 emit(token 通常是完整字/词,边界自然对齐)
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
length:_pending.size()
encoding:NSUTF8StringEncoding];
if (str) { if (_onToken) _onToken(str); _pending.clear(); }
}
void emitPending() {
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
length:_pending.size()
encoding:NSUTF8StringEncoding];
if (str && _onToken) _onToken(str);
}
void (^_onToken)(NSString *);
std::atomic<bool> *_cancel;
std::string _pending;
};
} // namespace
@implementation MNNLLMBridge {
Llm *_llm;
std::atomic<bool> _cancel;
BOOL _loaded;
}
+ (BOOL)isAvailable { return YES; }
+ (BOOL)cpuSupportsSME2 {
// Apple 通过 sysctl 暴露 ARM 特性位:FEAT_SME2(A19/iPhone17+)。
return kk_sysctlFlag("hw.optional.arm.FEAT_SME2");
}
- (nullable instancetype)initWithConfigPath:(NSString *)configPath {
self = [super init];
if (!self) return nil;
_cancel = false;
_llm = Llm::createLLM(std::string(configPath.UTF8String));
if (_llm == nullptr) return nil;
// load 前以 merge-patch 调三件事(只翻这几个叶子,保留 chat_template 等其余配置):
// ① enable_thinking=false:config.json 默认 true,模板会给每个 assistant 回合硬塞
// <think>\n 开启思考,吞掉 token 预算并污染 JSON(prompt 里的 /no_think 对此模板无效)。
// ② 降温:config.json 默认 temperature=1.0 对结构化 JSON 太高,随机性大→经常吐成非 JSON。
// 本 App 所有任务都是"直答/JSON",压到 0.3 + topP 0.85 让输出更确定、JSON 更稳。
// ③ 重复惩罚:MNN 默认 mixed_samplers 不含 "penalty"、penalty/ngram_factor=1.0(全关),
// 叠加低温 → 长文本(如「关键指标」列表)会陷入逐行复读死循环(收缩压 107 mmHg ×N)。
// 显式把 "penalty" 放进 mixed 链首,开 repetition penalty(1.1)+ n-gram 惩罚(ngram_factor 1.05):
// n-gram 命中整段重复时惩罚升到 max_penalty,直接掐断逐行复读。
_llm->set_config("{"
"\"jinja\":{\"context\":{\"enable_thinking\":false}},"
"\"sampler_type\":\"mixed\","
"\"mixed_samplers\":[\"penalty\",\"topK\",\"topP\",\"temperature\"],"
"\"temperature\":0.3,\"topP\":0.85,\"topK\":40,"
"\"penalty\":1.1,\"n_gram\":8,\"ngram_factor\":1.05"
"}");
_loaded = _llm->load();
if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; }
return self;
}
- (void)dealloc {
if (_llm) { Llm::destroy(_llm); _llm = nullptr; }
}
- (BOOL)isLoaded { return _loaded; }
- (void)cancel { _cancel = true; }
// 统一生成:full 已是最终 prompt(文本,或含 <img>路径</img> 标签)。
// 多模态模型 createLLM 返回 Omni,response 解析 <img> 标签并对路径 CV::imread(OMNI 框架内)。
- (MNNGenerateStats *)runResponse:(NSString *)full
maxTokens:(int)maxTokens
onToken:(void (^)(NSString *))onToken {
_cancel = false;
TokenStreamBuf buf(onToken, &_cancel);
std::ostream os(&buf);
if (_llm) {
_llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens);
}
buf.flush();
return [self statsFromContext];
}
- (MNNGenerateStats *)generateText:(NSString *)prompt
maxTokens:(int)maxTokens
onToken:(void (^)(NSString *))onToken {
return [self runResponse:prompt maxTokens:maxTokens onToken:onToken];
}
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
prompt:(NSString *)prompt
maxTokens:(int)maxTokens
onToken:(void (^)(NSString *))onToken
error:(NSError **)error {
// 在 prompt 前拼 <img>本地路径</img>;Omni 解析标签并对路径 imread(需 OMNI 框架)。
NSMutableString *full = [NSMutableString string];
for (NSString *p in imagePaths) {
[full appendFormat:@"<img>%@</img>", p];
}
[full appendString:prompt];
return [self runResponse:full maxTokens:maxTokens onToken:onToken];
}
- (MNNGenerateStats *)statsFromContext {
MNNGenerateStats *s = [MNNGenerateStats new];
if (_llm) {
const MNN::Transformer::LlmContext *ctx = _llm->getContext();
if (ctx) {
s.promptTokens = ctx->prompt_len;
s.genTokens = ctx->gen_seq_len;
s.prefillMs = ctx->prefill_us / 1000.0;
s.decodeMs = ctx->decode_us / 1000.0;
}
}
return s;
}
@end
#endif

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

@@ -10,7 +10,7 @@ struct ModelFile: Equatable, Sendable {
/// , README.md / .gitattributes()
/// ,
/// docs/superpowers/specs/2026-05-29-model-download-design.md A
enum ModelManifest {
nonisolated enum ModelManifest {
/// Caddy ( HTTPS )
/// IP( App ATS ): http://101.132.124.52:5244/
static let baseURL = URL(string: "https://file.myv0.com/")!
@@ -18,30 +18,59 @@ enum ModelManifest {
static func files(for kind: ModelKind) -> [ModelFile] {
switch kind {
case .llm:
// Qwen3.5-2B-4bit:, LLMModelFactory qwen3_5
// mlx-community/Qwen3.5-2B-4bit blob (HF API,2026-06 )
// tokenizer vocab.json + tokenizer.json( merges.txt /
// special_tokens_map.json / added_tokens.json),chat_template .jinja
// (preprocessor / processor / video_preprocessor),
// ,
return [
ModelFile(path: "config.json", bytes: 937),
ModelFile(path: "model.safetensors", bytes: 968_080_210),
ModelFile(path: "model.safetensors.index.json", bytes: 49_731),
ModelFile(path: "config.json", bytes: 3_113),
ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
ModelFile(path: "vocab.json", bytes: 6_722_759),
ModelFile(path: "chat_template.jinja", bytes: 7_755),
ModelFile(path: "preprocessor_config.json", bytes: 390),
ModelFile(path: "processor_config.json", bytes: 1_300),
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
]
case .vl:
// Qwen3-VL-4B-Instruct-4bit: mlx-community blob
// (HF API blobs=true,2026-05 ),
// :( README.md / .gitattributes),
// mlx-vlm , VLMModelFactory
// chat_template(.json + .jinja ) video ,
// swift-transformers / Qwen3VLProcessor
return [
ModelFile(path: "config.json", bytes: 7_137),
ModelFile(path: "model.safetensors", bytes: 3_093_767_283),
ModelFile(path: "model.safetensors.index.json", bytes: 64_742),
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
ModelFile(path: "tokenizer_config.json", bytes: 9_706),
ModelFile(path: "tokenizer_config.json", bytes: 5_445),
ModelFile(path: "vocab.json", bytes: 2_776_833),
ModelFile(path: "merges.txt", bytes: 1_671_853),
ModelFile(path: "special_tokens_map.json", bytes: 613),
ModelFile(path: "added_tokens.json", bytes: 707),
ModelFile(path: "generation_config.json", bytes: 269),
ModelFile(path: "chat_template.json", bytes: 5_502),
ModelFile(path: "chat_template.jinja", bytes: 5_292),
ModelFile(path: "preprocessor_config.json", bytes: 782),
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
]
case .vl:
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: 1_659),
ModelFile(path: "model.safetensors", bytes: 3_073_720_461),
ModelFile(path: "model.safetensors.index.json", bytes: 108_307),
ModelFile(path: "tokenizer.json", bytes: 11_421_896),
ModelFile(path: "tokenizer_config.json", bytes: 7_256),
ModelFile(path: "vocab.json", bytes: 2_776_833),
ModelFile(path: "merges.txt", bytes: 1_671_853),
ModelFile(path: "special_tokens_map.json", bytes: 613),
ModelFile(path: "added_tokens.json", bytes: 605),
ModelFile(path: "chat_template.json", bytes: 1_050),
ModelFile(path: "preprocessor_config.json", bytes: 350),
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
nonisolated enum ModelKind: String, CaseIterable {
/// HuggingFace mlx-community , Models/
case llm = "Qwen3-1.7B-4bit"
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
/// Models/ / CDN
/// Qwen3.5-2B,:
/// - mnnLLM:MNN(CPU/SME2,)+,taobao-mnn iPhone17+(A19/SME2),
/// - llm:MLX(GPU),Qwen3.5-2B-4bit (, qwen3_5)
/// - vl:(MLX VL .llm ), switch,/
case llm = "Qwen3.5-2B-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
case mnnLLM = "Qwen3.5-2B-MNN"
var displayName: String {
switch self {
case .llm: return "Qwen3-1.7B"
case .vl: return "Qwen2.5-VL-3B"
case .llm: return "Qwen3.5-2B (MLX)"
case .vl: return "Qwen3-VL-4B"
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
}
}
@@ -17,6 +23,12 @@ nonisolated enum ModelKind: String, CaseIterable {
///
var sentinelFilename: String { "config.json" }
/// : / /
/// Qwen3.5-2B(MNN,+,iPhone17+ )
/// MLX .llm/.vl ,(),
/// · ,
static let userFacing: [ModelKind] = [.mnnLLM]
}
/// `@unchecked Sendable`:rootURL let, filesystem(线),
@@ -132,7 +144,7 @@ enum ModelStoreError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .missingConfig:
return "所选文件夹缺少 config.json,不是有效的模型目录"
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
}
}
}

View File

@@ -0,0 +1,119 @@
import Foundation
/// , LLM 3-4
/// JSON, question dim()+ q()+ fill()
///
/// `dim`( 2026-05-30 prompt ):
/// 1.7B ,
/// , dim,,
/// ,
enum DiaryAssistPrompts {
/// ;UI
/// /, few-shot
static let dimensions: [String] = [
"起病诱因", "症状性质", "伴随症状", "加重缓解",
"持续频率", "既往家族史", "用药过敏", "生活方式",
]
/// - content:
/// - coveredDimensions: (),
///
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
let covered = coveredDimensions.filter { !$0.isEmpty }
let coveredSet = Set(covered)
let allowed = dimensions.filter { !coveredSet.contains($0) }
let allowedLine = allowed.isEmpty ? "(已基本问全)" : allowed.joined(separator: "")
// :1.7B
let scopeRule = covered.isEmpty
? ""
: "\n- 已问过的维度【不要再问】:\(covered.joined(separator: ""))。本轮只能从这些还没问的维度里挑:\(allowedLine)"
return """
你是社区医生的小助手。用户写了一段身体状态的健康记录,信息可能不够完整。
请从医生问诊角度提出 3-4 个最值得追问的问题,帮用户把这条记录补全。
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
1. 起病诱因 —— 何时开始、有无诱因
2. 症状性质 —— 部位、性质、程度
3. 伴随症状 —— 是否伴随其他不适
4. 加重缓解 —— 什么情况下加重或缓解
5. 持续频率 —— 持续多久、多频繁、是否反复发作
6. 既往家族史 —— 以前是否有类似、家族相关史
7. 用药过敏 —— 在服药物、过敏史
8. 生活方式 —— 睡眠、饮食、运动习惯、压力
硬性规则:
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"")。\(scopeRule)
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
- 不给诊断、不给用药建议、不写「建议就医」。
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
- 至少 3 条,最多 4 条。
只输出严格 JSON,不要解释、不要 markdown 围栏、不要 <think> 标签。结构:
{"questions":[{"dim":"<>","q":"<>","fill":"<>"}]}
示例 1(第一轮,记录:头痛了一上午):
{"questions":[
{"dim":"","q":"?","fill":" [] ,"},
{"dim":"","q":"?","fill":"/ [//],"},
{"dim":"","q":"?","fill":" [],"},
{"dim":"","q":"?","fill":" [] [],"}
]}
示例 2(后续轮,已覆盖维度:起病诱因、症状性质、伴随症状):
{"questions":[
{"dim":"","q":"?","fill":"[/] [/],"},
{"dim":"","q":"?","fill":"/ [/],"},
{"dim":"","q":"?","fill":" [/,],"}
]}
现在输出 JSON。
本轮可选维度:\(allowedLine)
【最新记录】:
\(content)
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

@@ -0,0 +1,185 @@
import Foundation
/// LLM prompt:
/// 1. `intentExtraction` + /, JSON
/// 2. `reportGeneration` Markdown
///
/// `HealthExportService`(§3.2 退线:
/// JSON 30 + ,)
enum HealthExportPrompts {
// MARK: -
/// `intentExtraction(userPrompt:)`
/// :
/// ```json
/// {"time_range_days":30,
/// "keywords":["",""],
/// "symptom_keywords":["",""],
/// "intent":"cold_consult",
/// "intent_label_cn":""}
/// ```
static func intentExtraction(userPrompt: String) -> String {
"""
你是健康数据助手。读用户的请求,只输出严格 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
字段说明(全部必填):
{
"time_range_days": int, // 回溯天数,默认 30,最大 365
"keywords": [string], // 指标关键词(中文,如「血压」「血糖」「体温」「肝功」),无则 []
"symptom_keywords": [string], // 症状关键词,无则 []
"intent": string, // 英文 snake_case 标签,如 "cold_consult"
"intent_label_cn": string // 中文短语,会作为报告标题副题,如 ""
}
规则:
- 时间未指定 → 30
- 「最近一个月」→ 30,「最近三个月」→ 90,「最近半年」→ 180
- 关键词要中文,常见健康指标 / 症状词
- intent 简短,4-25 字符,小写下划线
示例 1:
User: 我感冒3天了,要把最近一个月的健康情况给医生看
Output: {"time_range_days":30,"keywords":["","",""],"symptom_keywords":["","","",""],"intent":"cold_consult","intent_label_cn":""}
示例 2:
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
Output: {"time_range_days":90,"keywords":["","",""],"symptom_keywords":[],"intent":"glucose_review","intent_label_cn":""}
现在请输出 JSON:
User: \(userPrompt)
Output: /no_think
"""
}
// MARK: -
/// `reportGeneration(userPrompt:intentLabelCN:dataJSON:)` Markdown
static func reportGeneration(userPrompt: String,
intentLabelCN: String,
dataJSON: String) -> String {
let labelLine = intentLabelCN.isEmpty
? "# 就诊摘要"
: "# 就诊摘要 — \(intentLabelCN)"
return """
你是健康数据整理员。任务是把下面【真实数据】(JSON)里**已经存在**的内容,
原样整理成一份给社区医生看的就诊摘要。这是**抽取 / 搬运**任务,不是创作。
【最重要的铁律 —— 违反即失败】
- 只能使用【真实数据】JSON 里**真实出现过**的内容。
- 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
- 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
输出格式:
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
- 不给诊断意见、用药建议或「建议就医」。全文中文,简洁,医生 30 秒能扫完。
- 严格按以下 6 段(顺序与标题固定):
\(labelLine)
## 主诉
## 本人背景
## 近期症状(按时间倒序)
## 关键指标(异常项优先)
## 在服药与过敏
## 本人疑问
—— 格式示例(只示范「无记录」与数值写法,内容请勿照抄)——
真实数据:{"profile":{},"symptoms":[],"indicators":[{"name":"","value":"38.5","unit":"","range":"36-37.2","status":"high","date":"2026-05-01"}],"reports":[],"diaries":[],"time_window":{"from":"2026-04-02","to":"2026-05-02"}}
输出:
# 就诊摘要 — 近期健康摘要
## 主诉
无记录
## 本人背景
无记录
## 近期症状(按时间倒序)
无记录
## 关键指标(异常项优先)
⚠️ 体温 38.5 ℃(参考 36-37.2,2026-05-01)
## 在服药与过敏
无记录
## 本人疑问
无记录
—— 示例结束(以上咳嗽/体温等仅示范格式,切勿出现在你的输出里)——
现在,严格根据下面这份【真实数据】生成;数据里没有的就写「无记录」,**禁止编造**:
【真实数据】:
\(dataJSON)
【本人原话】:\(userPrompt)
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
直接输出 Markdown,不要思考过程,不要 <think> 标签:
/no_think
"""
}
// MARK: -
/// , prompt
/// + ,/,
static func dialogueAnswer(latestQuestion: String,
transcript: String,
dataJSON: String) -> String {
"""
你是康康的本地健康档案助手。请根据【本地健康记录】回答用户最新问题。
铁律:
- 只能使用【本地健康记录】和【多轮对话】里已有的信息。
- 禁止诊断、禁止用药/剂量建议、禁止急诊判断。
- 数据里没有的信息,直接说「记录里没有」,不要编造。
- 重点围绕指标和健康日记做大白话解释,回答要短,最多 5 条要点。
- 如果用户的目标是给医生看,可以提示稍后点击「生成整理报告」。
【本地健康记录】:
\(dataJSON)
【多轮对话】:
\(transcript.isEmpty ? "" : transcript)
【用户最新问题】:
\(latestQuestion)
直接输出中文回答,不要 Markdown 标题,不要 <think>:
/no_think
"""
}
/// , Markdown
static func dialogueReportGeneration(transcript: String,
dataJSON: String) -> String {
"""
你是健康数据整理员。请把【多轮对话】和【本地健康记录】整理成一份给医生看的摘要报告。
这是抽取 / 搬运任务,不是医疗诊断。
铁律:
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
- 禁止编造数字、日期、症状、药物、检查结果、诊断。
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。
- JSON 里没有的信息,对应小节写「无记录」。
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
输出要求:
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
- 中文,简洁,医生 30 秒能扫完。
- 严格按以下段落:
# 就诊摘要
## 本次想解决的问题
## 相关健康日记
## 相关指标
## 已知背景
## 本人关心的问题
## 可带给医生确认的要点
【本地健康记录】:
\(dataJSON)
【多轮对话】:
\(transcript.isEmpty ? "" : transcript)
直接输出 Markdown,不要思考过程,不要 <think>:
/no_think
"""
}
}

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,43 @@
import Foundation
/// + prompt: LLM(MNN/SME2 )
/// : JSON `{"intent":""}`;/ VoiceIntentService 退(§3.2)
nonisolated enum IntentPrompts {
static func classify(_ utterance: String) -> String {
classifyTemplate.replacingOccurrences(of: "{{TEXT}}", with: String(utterance.prefix(120)))
}
private static let classifyTemplate: String = #"""
你是健康 App 的语音意图分类器。用户长按「新建」按钮说了一句话,判断 ta 想打开哪个功能。
请只输出一段合法 JSON,格式 {"intent":"<>"},不要解释、不要 markdown 围栏、不要任何前后缀文字。
分类(只能选下面其中一个):
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
- "medication" 记录用药、拍药盒、吃了什么药
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…)
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
- "archive" 归档整份体检报告/化验单(拍报告存档)
- "export" 生成给医生看的身体档案/健康总结
- "reminder" 设置周期提醒
- "unknown" 无法判断
规则:
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"
- 既像日记又提到具体数值时,以数值为准 → "indicator"
示例:
",12885" → {"intent":"indicator"}
"," → {"intent":"symptom"}
"," → {"intent":"medication"}
"," → {"intent":"diary"}
"" → {"intent":"archive"}
"" → {"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

@@ -1,9 +1,9 @@
import Foundation
/// VL (Qwen2.5-VL) / prompt
/// VL (Qwen3-VL) / prompt
/// : JSON,markdown
/// CaptureService 退(§3.2 退线)
enum VLPrompts {
nonisolated enum VLPrompts {
/// JSON ( prompt ):
/// ```
@@ -20,16 +20,56 @@ enum VLPrompts {
/// "value": "3.84",
/// "unit": "mmol/L",
/// "range": "< 3.40",
/// "status": "high|low|normal"
/// "status": "high|low|normal",
/// "source_page": 1,
/// "source_box": [0.18, 0.42, 0.68, 0.49]
/// }
/// ]
/// }
/// ```
/// `kind` UI indicators A2() B3()
static let reportExtraction: String = #"""
/// VL "", few-shot ,
/// prompt,退
/// ocrText Vision OCR Vision
/// 2B ;
static func reportExtraction(today: Date = .now, ocrText: String = "") -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
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 = #"""
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string,
@@ -44,7 +84,9 @@ JSON schema(严格):
"value": string,
"unit": string,
"range": string,
"status": "high" | "low" | "normal"
"status": "high" | "low" | "normal",
"source_page": number,
"source_box": [number, number, number, number]
}
]
}
@@ -52,20 +94,182 @@ JSON schema(严格):
规则:
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。
- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)
- 不要发明指标。看不清的整行跳过
- 无法识别的字段填空字符串(institution / summary)。
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄
- 不要发明指标。数值看不清的整行跳过;但**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样要提取,range 填 "",status 按箭头/「偏高」等标记判断。
- 化验单一般 type = "lab",体检套餐 = "checkup"
- source_page 是该指标所在图片页码,从 1 开始。
- source_box 是该指标整行在该页图片里的归一化矩形 [x,y,width,height],左上角为 (0,0),右下角为 (1,1)。尽量框住指标名、数值、单位、参考范围和异常标记所在整行;不确定位置时填 [0,0,0,0]。
示例 1(化验单 · 单项):
输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」
输出:
{"title":"","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
{"title":"","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.18,0.42,0.68,0.08]}]}
示例 2(体检 · 多项):
输入: 一份春季体检,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"},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
{"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]}
{{OCR_SECTION}}
现在请识别图片并输出 JSON:
"""#
// MARK: - · meta(///,)
/// :,****( 2B OOM / )
/// Vision OCR , LLM meta (~50 token),
/// ,UI ( / ),
static func reportMetaFromText(_ ocrText: String, today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return reportMetaTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 1500))
}
private static let reportMetaTemplate: String = #"""
你是体检/化验报告归档助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声。
请只提取这份报告的「元信息」,**不要提取任何具体指标/数值**。只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string, // 报告抬头,如 "";读不出就填 ""
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
"report_date": "YYYY-MM-DD", // 报告/采样/体检日期;实在读不出就填 ""
"institution": string // 医院/体检机构名;读不出就填 ""
}
规则:
- 只输出上面 4 个字段,绝不输出 indicators / 数值 / 参考范围。
- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"
- 日期挑「报告日期 / 检查日期 / 采样日期」其一,统一成 YYYY-MM-DD;只有年月就补 -01;读不出填 ""
- institution 取医院/体检中心全称,去掉「检验科/报告单」等栏目词;读不出填 ""
- 不要编造;读不出的字段填 ""
示例 OCR 文本:
协和医院体检中心 健康体检报告 姓名:张三 体检日期:2026-04-12 低密度脂蛋白 3.84 ↑ ...
输出:
{"title":"","type":"checkup","report_date":"2026-04-12","institution":""}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:
{{OCR_TEXT}}
"""#
// MARK: - ()
/// :/****()
/// indicators ,// , Report
static func regionExtraction(today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return regionExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
}
private static let regionExtractionTemplate: String = #"""
你是一个医学化验单识别助手。下面给你的是一张化验单/体检报告的**局部照片**,通常只框住了一两行指标。
照片内容可能是表格行,也可能是**结论页的叙述式文字**(如「九、检验:(1)总胆红素(TB): 23.0(μmol/L)↑」),两种都要提取。
请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"indicators": [
{
"name": string,
"value": string,
"unit": string,
"range": string,
"status": "high" | "low" | "normal"
}
]
}
规则:
- 凡是「指标名 + 数值」清楚可读的,都要提取——**没有参考范围不是跳过的理由**。只有数值本身看不清才跳过,绝不发明指标。
- status 判断优先级:① 文字旁的箭头或标记(↑/H/偏高 → "high",↓/L/偏低 → "low")最优先;② 没有标记时再用 value 与 range 比较;③ 都没有 → "normal"
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象;照片里没有参考范围就填 ""
- 识别不出单位/范围就填空字符串,不要编造。
- name 用规范指标名;如果同一行重复出现指标名(如「总胆红素(TB): 总胆红素: 23.0」),只取一次。
- 不要输出 title / institution / date / summary 等任何报告级字段,只输出 indicators 数组。
示例 1(表格单行):
输入: 局部照片,清楚可读「低密度脂蛋白 3.84 mmol/L 参考 <3.40 ↑」
输出:
{"indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
示例 2(表格两行):
输入: 局部照片,清楚可读「尿酸 486 μmol/L 208-428」与「空腹血糖 5.2 mmol/L 3.9-6.1」
输出:
{"indicators":[{"name":"尿","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
示例 3(结论页叙述式 · 无参考范围,只有箭头):
输入: 局部照片,体检结论文字「九、检验: (1)总胆红素(TB): 总胆红素: 23.0(μmol/L)↑」,周围还有其他结论文字
输出:
{"indicators":[{"name":"","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
现在请识别这张局部照片并输出 JSON:
"""#
// MARK: - OCR (LLM , VL)
/// : Vision OCR , Qwen3-1.7B
/// 3B VL OCR (//)
static func indicatorsFromText(_ 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 indicatorsFromTextTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_TEXT}}", with: ocrText)
}
private static let indicatorsFromTextTemplate: String = #"""
你是医学化验单/体检报告的结构化助手。下面是对一张报告做 OCR 得到的纯文本,可能有错字、错位、多余符号或换行混乱。
请从中提取所有「指标名 + 数值」,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"indicators": [
{
"name": string,
"value": string,
"unit": string,
"range": string,
"status": "high" | "low" | "normal"
}
]
}
规则:
- 只提取「有明确数值」的检验/体检指标;页眉、医院名、医生签名、采样时间、栏目标题、OCR 噪声一律忽略。
- status 判断优先级:① 文本里的箭头/标记(↑/H/偏高 → "high",↓/L/偏低 → "low")最优先;② 没有标记时用 value 与 range 比较;③ 都没有 → "normal"
- range 保留原文(如 "3.9 - 6.1""< 3.40""208 - 428");OCR 把破折号写成 "--" / "~" 都归一成 " - ";没有参考范围就填 ""
- 单位识别不出就填 "",不要编造;不要发明指标;同一指标只输出一次。
- name 用规范中文指标名(行内重复的去掉,英文缩写括注可保留)。
- 数值明显是 OCR 乱码(字母混入数字)且无法判断的,跳过该行。
示例 OCR 文本:
淋巴细胞数 3.0 1.8 -- 6.3 X10^9/L
尿酸 486 208-428 μmol/L
总胆红素(TB): 23.0 (μmol/L) ↑
对应输出:
{"indicators":[{"name":"","value":"3.0","unit":"X10^9/L","range":"1.8 - 6.3","status":"normal"},{"name":"尿","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:
{{OCR_TEXT}}
"""#
}

View File

@@ -3,7 +3,7 @@ import MLX
import MLXVLM
import MLXLMCommon
/// MLX VL (Qwen2.5-VL)
/// MLX VL (Qwen3-VL)
/// LLMSession actor , AIRuntime
actor VLSession {
let container: ModelContainer

View File

@@ -0,0 +1,64 @@
import SwiftUI
/// / : App
/// `Font.tjScaled` / `Font.tjTitle` ( App )
enum FontScale: String, CaseIterable, Identifiable {
case standard, large, extraLarge, huge
var id: String { rawValue }
/// ,
var multiplier: CGFloat {
switch self {
case .standard: return 1.0
case .large: return 1.2
case .extraLarge: return 1.4
case .huge: return 1.6
}
}
var label: String {
switch self {
case .standard: return String(appLoc: "标准")
case .large: return String(appLoc: "")
case .extraLarge: return String(appLoc: "特大")
case .huge: return String(appLoc: "超大")
}
}
var detail: String {
switch self {
case .standard: return String(appLoc: "默认字号")
case .large: return String(appLoc: "字号放大 20%")
case .extraLarge: return String(appLoc: "字号放大 40%")
case .huge: return String(appLoc: "字号放大 60%")
}
}
}
/// App ; `.id` ()
@Observable
final class FontScaleManager {
static let shared = FontScaleManager()
private let storageKey = "appFontScale"
private(set) var scale: FontScale
private init() {
let saved = UserDefaults.standard.string(forKey: storageKey)
scale = FontScale(rawValue: saved ?? "") ?? .standard
appFontScale = scale.multiplier
}
func set(_ newScale: FontScale) {
guard newScale != scale else { return }
scale = newScale
UserDefaults.standard.set(newScale.rawValue, forKey: storageKey)
appFontScale = newScale.multiplier
}
}
/// nonisolated :`Font.tjScaled` static func,
/// `FontScaleManager`(MainActor);,( Localization appLocBundle )
nonisolated(unsafe) var appFontScale: CGFloat = 1.0

View File

@@ -3,6 +3,14 @@ import SwiftData
@main
struct KangkangApp: App {
@State private var lang = LanguageManager.shared
@State private var fontScale = FontScaleManager.shared
init() {
// MLX , entitlement + LLM/VL jetsam OOM
AIRuntime.configureMLXMemory()
}
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Indicator.self,
@@ -15,31 +23,89 @@ struct KangkangApp: App {
MetricReminder.self,
CustomMonitorMetric.self,
HealthExport.self,
CustomReminder.self,
Medication.self,
])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
// store .completeUnlessOpen (§6),
func makeContainer() throws -> ModelContainer {
let container = try ModelContainer(for: schema, configurations: [config])
KangkangApp.protectStore(at: config.url)
return container
}
do {
return try ModelContainer(for: schema, configurations: [config])
return try makeContainer()
} catch {
// Demo schema : store schema ,
// store ()
// SwiftData ,
print("⚠️ ModelContainer 创建失败,重置本地 store 重建: \(error)")
let fm = FileManager.default
let storePath = config.url.path
for path in [storePath, storePath + "-wal", storePath + "-shm"] {
try? fm.removeItem(atPath: path)
}
// Demo schema : SwiftData
// (: @Model ),
// , store -wal/-shm
// App ,()
// VersionedSchema + SchemaMigrationPlan
// : @Model ,
print("⚠️ ModelContainer 创建失败,备份旧 store 后重建: \(error)")
KangkangApp.backupIncompatibleStore(at: config.url)
do {
return try ModelContainer(for: schema, configurations: [config])
return try makeContainer()
} catch {
fatalError("Could not create ModelContainer even after reset: \(error)")
fatalError("Could not create ModelContainer even after store reset: \(error)")
}
}
}()
/// SwiftData store( `-wal`/`-shm`) `.completeUnlessOpen` :
/// , SQLite ,
/// `.complete` /Live Activity 访 store CLAUDE.md §6
/// ( iOS CompleteUntilFirstUserAuthentication,)
private static func protectStore(at storeURL: URL) {
let fm = FileManager.default
for suffix in ["", "-wal", "-shm"] {
let path = storeURL.path + suffix
guard fm.fileExists(atPath: path) else { continue }
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: path)
}
}
/// schema store( `-wal` / `-shm`)
/// `Application Support/StoreBackups/<>/`,
/// ,;
private static func backupIncompatibleStore(at storeURL: URL) {
let fm = FileManager.default
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.dateFormat = "yyyyMMdd-HHmmss"
let stamp = fmt.string(from: Date())
let backupDir = storeURL.deletingLastPathComponent()
.appendingPathComponent("StoreBackups/\(stamp)", isDirectory: true)
try? fm.createDirectory(at: backupDir, withIntermediateDirectories: true)
// ()
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: backupDir.path)
for suffix in ["", "-wal", "-shm"] {
let src = URL(fileURLWithPath: storeURL.path + suffix)
guard fm.fileExists(atPath: src.path) else { continue }
let dst = backupDir.appendingPathComponent(src.lastPathComponent)
do {
try fm.moveItem(at: src, to: dst)
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: dst.path)
} catch {
try? fm.removeItem(at: src) // ,
}
}
}
var body: some Scene {
WindowGroup {
RootView()
AppLockContainer {
RootView()
.environment(\.locale, lang.locale)
// / ,( tjScaled )
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
}
// ( sand) light:,
// Text/TextField .primary ,()
.preferredColorScheme(.light)
}
.modelContainer(sharedModelContainer)
}

View File

@@ -0,0 +1,152 @@
import SwiftUI
import ObjectiveC
/// App `system` = ; .lproj / String Catalog
enum AppLanguage: String, CaseIterable, Identifiable {
case system
case zhHans = "zh-Hans"
case en
case ja
case ko
var id: String { rawValue }
/// ****(,), App
var displayName: String {
switch self {
case .system: return String(appLoc: "跟随系统")
case .zhHans: return "简体中文"
case .en: return "English"
case .ja: return "日本語"
case .ko: return "한국어"
}
}
/// nil = ; .lproj / Locale
var localeIdentifier: String? {
self == .system ? nil : rawValue
}
/// ( / A / / ),
/// , `displayName`
enum PickerIcon: Equatable {
case symbol(String) // SF Symbol
case glyph(String) //
}
var pickerIcon: PickerIcon {
switch self {
case .system: return .symbol("globe")
case .zhHans: return .glyph("")
case .en: return .glyph("A")
case .ja: return .glyph("")
case .ko: return .glyph("")
}
}
}
/// App : lproj bundle locale
/// - `Text("")` `\.locale`(+ Bundle );
/// - `String(appLoc:)` bundle/locale, `.current` ,
/// `.id(current)` ,
@Observable
final class LanguageManager {
static let shared = LanguageManager()
private let storageKey = "appLanguage"
private(set) var current: AppLanguage
/// .lproj bundle(system .main),
private(set) var lprojBundle: Bundle = .main
/// locale(system .autoupdatingCurrent)
private(set) var resolvedLocale: Locale = .autoupdatingCurrent
/// SwiftUI 使(/ + Text )
var locale: Locale { resolvedLocale }
private init() {
let saved = UserDefaults.standard.string(forKey: storageKey)
current = AppLanguage(rawValue: saved ?? "") ?? .system
apply()
}
func set(_ language: AppLanguage) {
guard language != current else { return }
current = language
UserDefaults.standard.set(language.rawValue, forKey: storageKey)
// AppleLanguages:, App
if let id = language.localeIdentifier {
UserDefaults.standard.set([id], forKey: "AppleLanguages")
} else {
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
}
apply()
}
private func apply() {
if let id = current.localeIdentifier {
resolvedLocale = Locale(identifier: id)
if let path = Bundle.main.path(forResource: id, ofType: "lproj"),
let b = Bundle(path: path) {
lprojBundle = b
} else {
lprojBundle = .main
}
} else {
resolvedLocale = .autoupdatingCurrent
lprojBundle = .main
}
Bundle.redirectMain(to: current.localeIdentifier)
// nonisolated , String(appLoc:) MainActor
appLocBundle = lprojBundle
appLocLocale = resolvedLocale
}
}
/// nonisolated :`String(appLoc:)` MainActor
/// (LocalizedError.errorDescriptionnonisolated labelstatic )
/// `LanguageManager.apply()`(MainActor),;,
nonisolated(unsafe) private var appLocBundle: Bundle = .main
nonisolated(unsafe) private var appLocLocale: Locale = .autoupdatingCurrent
extension String {
/// · ()
/// `String(localized:)`, bundle + locale,
/// `Locale.current`(/)
nonisolated init(appLoc key: String.LocalizationValue) {
self = String(localized: key, bundle: appLocBundle, locale: appLocLocale)
}
}
// MARK: - Bundle ( Text / NSLocalizedString )
/// key(,)
private var redirectBundleKey: UInt8 = 0
/// `Bundle.main` .lproj
private final class LocalizedMainBundle: Bundle, @unchecked Sendable {
override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String {
if let target = objc_getAssociatedObject(self, &redirectBundleKey) as? Bundle {
return target.localizedString(forKey: key, value: value, table: tableName)
}
return super.localizedString(forKey: key, value: value, table: tableName)
}
}
extension Bundle {
/// language == nil ()
static func redirectMain(to language: String?) {
if !(Bundle.main is LocalizedMainBundle) {
object_setClass(Bundle.main, LocalizedMainBundle.self)
}
let target: Bundle?
if let language,
let path = Bundle.main.path(forResource: language, ofType: "lproj"),
let bundle = Bundle(path: path) {
target = bundle
} else {
target = nil
}
objc_setAssociatedObject(Bundle.main, &redirectBundleKey, target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 B

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,34 @@
import SwiftUI
/// App AI (:)
/// AI //, `AIDisclaimerFooter`;
/// App (/) `AIDisclaimer.appended(to:)`
enum AIDisclaimer {
///
static let text =
"本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考," +
"不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。"
/// /(线 + ), App
static func appended(to body: String) -> String {
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
return "\(trimmed)\n\n———\n\(text)"
}
}
/// AI : AI
struct AIDisclaimerFooter: View {
var body: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "info.circle")
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
Text(AIDisclaimer.text)
.font(.tjScaled( 10))
.lineSpacing(2)
.foregroundStyle(Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}

View File

@@ -0,0 +1,45 @@
import SwiftUI
/// Apple Intelligence 线:,
/// AppAI ( AI /)
///
/// :线 `Tj.Palette` AI (
/// Apple ),; UI §9 token
struct AIFlowBar: View {
var height: CGFloat = 3
/// ,
var cycle: Double = 1.0
@State private var phase: CGFloat = 0
/// :offset ,
private static let flow: [Color] = {
let base: [Color] = [
Color(red: 0.35, green: 0.47, blue: 0.98), //
Color(red: 0.62, green: 0.36, blue: 0.92), //
Color(red: 0.96, green: 0.40, blue: 0.62), //
Color(red: 1.00, green: 0.55, blue: 0.30), //
Color(red: 0.30, green: 0.80, blue: 0.92), //
]
return base + base
}()
var body: some View {
GeometryReader { geo in
let w = geo.size.width
Capsule()
.fill(LinearGradient(colors: Self.flow,
startPoint: .leading, endPoint: .trailing))
.frame(width: w * 2)
.offset(x: phase)
.onAppear {
phase = 0
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
phase = -w
}
}
}
.frame(height: height)
.clipShape(Capsule())
}
}

View File

@@ -4,9 +4,9 @@ struct TjLockChip: View {
var body: some View {
HStack(spacing: 4) {
Image(systemName: "lock.fill")
.font(.system(size: 9, weight: .semibold))
.font(.tjScaled( 9, weight: .semibold))
Text("本地加密")
.font(.system(size: 10))
.font(.tjScaled( 10))
.tracking(0.5)
}
.foregroundStyle(Tj.Palette.paper)
@@ -44,7 +44,7 @@ struct TjBadge: View {
var style: TjBadgeStyle = .neutral
var body: some View {
Text(text)
.font(.system(size: 10, weight: .semibold))
.font(.tjScaled( 10, weight: .semibold))
.tracking(0.3)
.foregroundStyle(style.fg)
.padding(.horizontal, 7)
@@ -66,7 +66,7 @@ struct TjPlaceholder: View {
DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
Text(label)
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.tracking(0.5)
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
.multilineTextAlignment(.center)
@@ -101,7 +101,7 @@ struct TjPrimaryButton: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: fontSize, weight: .semibold))
.font(.tjScaled( fontSize, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, horizontalPadding)
@@ -118,7 +118,7 @@ struct TjGhostButton: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: fontSize, weight: .semibold))
.font(.tjScaled( fontSize, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, horizontalPadding)

View File

@@ -39,10 +39,18 @@ enum Tj {
}
extension Font {
static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) }
static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) }
static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) }
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) }
/// App `appFontScale` (/)
/// `.system(size:)` `.tjScaled(` ; +
static func tjScaled(_ size: CGFloat,
weight: Font.Weight = .regular,
design: Font.Design = .default) -> Font {
.system(size: size * appFontScale, weight: weight, design: design)
}
static func tjTitle(_ size: CGFloat = 30) -> Font { .tjScaled(size, weight: .bold) }
static func tjH2(_ size: CGFloat = 18) -> Font { .tjScaled(size, weight: .bold) }
static func tjMono(_ size: CGFloat = 11) -> Font { .tjScaled(size, design: .monospaced) }
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .tjScaled(size) }
}
extension View {

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

@@ -1,61 +0,0 @@
import SwiftUI
private enum ArchiveStep: Hashable {
case guide
case scan
case meta
case progress
case result
}
struct ArchiveFlow: View {
var onClose: () -> Void
@State private var step: ArchiveStep = .guide
@State private var capturedPages: Int = 1
@State private var totalPages: Int = 3
var body: some View {
ZStack {
switch step {
case .guide:
B1GuideView(
onSingle: { withAnimation { totalPages = 1; step = .scan } },
onMulti: { withAnimation { totalPages = 3; step = .scan } },
onSkip: onClose
)
.transition(.opacity)
case .scan:
B2ScanView(
onShoot: { capturedPages = min(capturedPages + 1, totalPages) },
onDone: { withAnimation { step = .meta } },
onClose: onClose,
page: capturedPages,
total: totalPages
)
.transition(.opacity)
case .meta:
B3MetaView(
onAnalyze: { withAnimation { step = .progress } },
onBack: { withAnimation { step = .scan } }
)
.transition(.opacity)
case .progress:
B4ProgressView(onComplete: {
withAnimation { step = .result }
})
.transition(.opacity)
case .result:
B5ResultView(
onSave: onClose,
onBack: { withAnimation { step = .meta } }
)
.transition(.opacity)
}
}
}
}

View File

@@ -14,42 +14,95 @@ struct ArchiveListView: View {
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
@Query(sort: \HealthExport.createdAt, order: .reverse)
private var exports: [HealthExport]
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
private var customReminders: [CustomReminder]
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var metricReminders: [MetricReminder]
@Query(sort: \Medication.updatedAt, order: .reverse)
private var medications: [Medication]
/// push `navigationDestination(item:)`
/// `navigationDestination(isPresented:)` SwiftUI ()
private enum Route: Hashable { case exports, reminders, medicationLibrary }
@State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom?
@State private var selectedEntry: TimelineEntry?
@State private var selectedGroup: IndicatorGroup?
@State private var route: Route?
/// :,(///), chip
@State private var searching = false
@State private var query = ""
@MainActor
private var allEntries: [TimelineEntry] {
let mapped =
TimelineEntry.from(indicators: indicators) +
TimelineEntry.aggregatedIndicators(indicators) +
reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:))
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
return filtered.sorted { $0.date > $1.date }
let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
let q = query.trimmingCharacters(in: .whitespaces)
let byQuery = q.isEmpty ? byKind : byKind.filter { $0.title.localizedCaseInsensitiveContains(q) }
return byQuery.sorted { $0.date > $1.date }
}
private var grouped: [(section: DateSection, items: [TimelineEntry])] {
TimelineGrouping.group(allEntries)
}
private var totalCount: Int { allEntries.count }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
NavigationStack {
content
.navigationDestination(item: $route) { route in
switch route {
case .exports: HealthExportListView()
case .reminders: RemindersListView()
case .medicationLibrary: MedicationLibraryView()
}
}
}
}
private var content: some View {
// ( O(m²))+ / body .isEmpty
// allEntries,;,
let entries = allEntries
let groups = TimelineGrouping.group(entries)
return VStack(alignment: .leading, spacing: 0) {
header(total: entries.count)
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 14)
filterChips
if reminderTotal > 0 {
reminderBoard
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
// :/,
medicationBoard
.padding(.horizontal, 20)
.padding(.bottom, 14)
if allEntries.isEmpty {
filterChips
.padding(.bottom, searching ? 10 : 14)
if searching {
searchField
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
if entries.isEmpty {
emptyState
} else {
ScrollView(showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
ForEach(grouped, id: \.section) { group in
ForEach(groups, id: \.section) { group in
Section {
VStack(spacing: 10) {
ForEach(group.items) { entry in
@@ -71,12 +124,21 @@ struct ArchiveListView: View {
.sheet(item: $endingSymptom) { sym in
SymptomEndSheet(symptom: sym)
}
.sheet(item: $selectedEntry) { entry in
if let d = detail(for: entry) {
TimelineEntryDetailView(detail: d)
}
}
.sheet(item: $selectedGroup) { group in
IndicatorSeriesDetailView(group: group)
}
}
@ViewBuilder
private func rowView(for entry: TimelineEntry) -> some View {
if entry.kind == .symptom, entry.isOngoing,
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
// : sheet(沿)
Button {
endingSymptom = sym
} label: {
@@ -84,26 +146,224 @@ struct ArchiveListView: View {
}
.buttonStyle(.plain)
} else {
TimelineRow(entry: entry)
// : ( + );//
Button {
guard let d = detail(for: entry) else { return }
switch d {
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
default: selectedEntry = entry
}
} label: {
TimelineRow(entry: entry)
}
.buttonStyle(.plain)
}
}
private var header: some View {
/// 线 `TimelineDetail.resolve`(/)
private func detail(for entry: TimelineEntry) -> TimelineDetail? {
TimelineDetail.resolve(for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms)
}
private func header(total: Int) -> some View {
HStack(alignment: .lastTextBaseline) {
Text("记录")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
Text(totalCount == 0 ? "" : "\(totalCount)")
.font(.system(size: 12))
Text(total == 0 ? "" : String(appLoc: "\(total)"))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
if !exports.isEmpty {
Button { route = .exports } label: {
HStack(spacing: 6) {
Image(systemName: "clock.arrow.circlepath")
.font(.tjScaled( 12, weight: .semibold))
Text("导出历史")
.font(.tjScaled( 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.ink))
}
.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: -
/// ( + ),
private var reminderTotal: Int { customReminders.count + metricReminders.count }
private var reminderEnabledCount: Int {
customReminders.filter(\.enabled).count + metricReminders.filter(\.enabled).count
}
/// updatedAt , 3 (,)
private var reminderTitlePreview: [String] {
let merged: [(title: String, at: Date)] =
customReminders.map { ($0.title, $0.updatedAt) } +
metricReminders.map { ($0.displayName, $0.updatedAt) }
return merged.sorted { $0.at > $1.at }.prefix(3).map(\.title)
}
private var reminderCountLabel: String {
reminderEnabledCount == reminderTotal
? String(appLoc: "\(reminderTotal) 个提醒任务")
: String(appLoc: "\(reminderTotal) 个提醒任务 · \(reminderEnabledCount) 个开启中")
}
private var reminderTitleLine: String {
let joined = reminderTitlePreview.joined(separator: " · ")
return reminderTotal > reminderTitlePreview.count ? joined + "" : joined
}
/// (RemindersListView);
private var reminderBoard: some View {
Button { route = .reminders } label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.tjScaled( 16))
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(reminderCountLabel)
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if !reminderTitlePreview.isEmpty {
Text(reminderTitleLine)
.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)
}
// MARK: -
/// :, · N
private var medicationCountLabel: String {
medications.isEmpty
? String(appLoc: "药品库")
: String(appLoc: "药品库 · \(medications.count) 种常用药")
}
/// :; 3 (,)
private var medicationPreviewLine: String {
if medications.isEmpty { return String(appLoc: "拍药盒或手动添加常用药") }
let names = medications.prefix(3).map(\.name).joined(separator: " · ")
return medications.count > 3 ? names + "" : names
}
/// (MedicationLibraryView,push );
private var medicationBoard: some View {
Button { route = .medicationLibrary } label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(medications.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
Image(systemName: "pills.fill")
.font(.tjScaled( 16))
.foregroundStyle(medications.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(medicationCountLabel)
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text(medicationPreviewLine)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.contentShape(Rectangle())
.tjCard()
}
.buttonStyle(.plain)
}
private var filterChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(label: "全部", selected: filter == nil) { filter = nil }
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
ForEach(TimelineKind.allCases) { kind in
chip(label: kind.label, selected: filter == kind) {
filter = filter == kind ? nil : kind
@@ -117,7 +377,7 @@ struct ArchiveListView: View {
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -134,14 +394,14 @@ struct ArchiveListView: View {
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
HStack {
Text(section.label)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text2)
Rectangle()
.fill(Tj.Palette.lineSoft)
.frame(height: 1)
Text("\(count)")
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
@@ -150,13 +410,19 @@ struct ArchiveListView: View {
}
private var emptyState: some View {
VStack(spacing: 14) {
let q = query.trimmingCharacters(in: .whitespaces)
let isSearchMiss = !q.isEmpty
return VStack(spacing: 14) {
Spacer()
TjPlaceholder(label: "还没有任何记录\n点底部 + 号开始")
TjPlaceholder(label: isSearchMiss
? String(appLoc: "没有匹配「\(q)」的记录")
: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
.frame(width: 240, height: 140)
Text(filter == nil ? "记录会按时间归类显示" : "这个类别下没有记录")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
if !isSearchMiss {
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
}
.frame(maxWidth: .infinity)
@@ -166,6 +432,8 @@ struct ArchiveListView: View {
#Preview {
ArchiveListView()
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self,
HealthExport.self, ChatTurn.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self
], inMemory: true)
}

View File

@@ -1,131 +0,0 @@
import SwiftUI
struct B1GuideView: View {
var onSingle: () -> Void
var onMulti: () -> Void
var onSkip: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
Button(action: onSkip) {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
Spacer()
Button(action: onSkip) {
Text("跳过")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.padding(8)
}
}
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 0) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.ink)
Image(systemName: "doc.text.fill")
.font(.system(size: 26, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: 60, height: 60)
.padding(.bottom, 18)
Text("归档一份\n关键报告")
.font(.system(size: 30, weight: .bold))
.lineSpacing(6)
.foregroundStyle(Tj.Palette.text)
.padding(.bottom, 12)
Text("推荐拍清晰的\(Text("整张图").underline()),多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(6)
.padding(.bottom, 26)
VStack(spacing: 12) {
OptCard(title: "单张报告", sub: "一张图,几秒搞定", hint: "化验单 · 处方", badge: nil, action: onSingle)
OptCard(title: "多页报告", sub: "像扫描文档一样翻页拍摄", hint: "体检报告 · 影像报告", badge: "推荐", action: onMulti)
}
Spacer(minLength: 18)
HStack(alignment: .top, spacing: 10) {
Image(systemName: "lock.fill")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.padding(.top, 2)
Text("所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
}
private struct OptCard: View {
let title: String
let sub: String
let hint: String
let badge: String?
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
Image(systemName: "doc.text")
.font(.system(size: 18, weight: .regular))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 8) {
Text(title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
if let badge {
TjBadge(text: badge, style: .ink)
}
}
Text("\(sub) · \(hint)")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(16)
.tjCard(bordered: true)
}
.buttonStyle(.plain)
}
}
#Preview {
B1GuideView(
onSingle: { print("单张报告") },
onMulti: { print("多页报告") },
onSkip: { print("跳过") }
)
}

View File

@@ -1,198 +0,0 @@
import SwiftUI
struct B2ScanView: View {
var onShoot: () -> Void
var onDone: () -> Void
var onClose: () -> Void
var page: Int = 2
var total: Int = 3
var body: some View {
ZStack {
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
mockPaper
DetectedEdge()
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45),
style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
.opacity(0.95)
.padding(.horizontal, 30)
.padding(.top, 140)
.padding(.bottom, 200)
.allowsHitTesting(false)
VStack(spacing: 0) {
topBar
Spacer()
detectedBadge
Spacer()
thumbnails
bottomControls
}
}
.preferredColorScheme(.dark)
}
private var mockPaper: some View {
VStack(spacing: 2) {
Text("体 检 报 告 (第 \(page) 页)")
.font(.system(size: 12, weight: .bold))
.padding(.bottom, 4)
ForEach(reportRows, id: \.0) { row in
HStack {
Text(row.0).frame(maxWidth: .infinity, alignment: .leading)
Text(row.1)
Text(row.2).foregroundStyle(Tj.Palette.text3)
}
.font(.system(size: 9, design: .monospaced))
}
}
.padding(16)
.foregroundStyle(Tj.Palette.text)
.frame(maxWidth: .infinity)
.background(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.95))
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
.rotation3DEffect(.degrees(8), axis: (x: 1, y: 0, z: 0))
.rotationEffect(.degrees(-1))
.shadow(color: .black.opacity(0.6), radius: 20, x: 0, y: 12)
.padding(.horizontal, 40)
.padding(.top, 160)
.padding(.bottom, 220)
}
private var reportRows: [(String, String, String)] {
[
("总胆固醇", "5.42", "3.105.18"),
("甘油三酯", "1.78", "0.451.70"),
("低密度脂蛋白", "3.84↑", "<3.40"),
("高密度脂蛋白", "1.21", ">1.04"),
("载脂蛋白 A1", "1.42", "1.001.60"),
("载脂蛋白 B", "1.04", "0.551.05"),
("谷丙转氨酶", "28", "950"),
("谷草转氨酶", "24", "1540"),
("空腹血糖", "5.4", "3.96.1"),
("糖化血红蛋白", "5.7", "4.06.0"),
]
}
private var topBar: some View {
HStack {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
.frame(width: 36, height: 36)
}
Spacer()
HStack(spacing: 4) {
Text("\(page)").font(.system(size: 12, design: .monospaced))
Text(" / \(total) · 像扫描文档一样对准")
.font(.system(size: 12))
}
.foregroundStyle(Color.white)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
Spacer()
Color.clear.frame(width: 36, height: 36)
}
.padding(.horizontal, 6)
.padding(.top, 50)
}
private var detectedBadge: some View {
Text("已识别边框 · 将自动透视校正")
.font(.system(size: 10, weight: .semibold))
.tracking(0.4)
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(Color(red: 0.95, green: 0.78, blue: 0.45)))
.padding(.top, 140)
}
private var thumbnails: some View {
HStack {
PageThumbStack(index: 1)
Spacer()
Text("已拍 1 页")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(.horizontal, 18)
.padding(.bottom, 24)
}
private var bottomControls: some View {
HStack {
Color.clear.frame(width: 60, height: 60)
Spacer()
Button(action: onShoot) {
ZStack {
Circle().fill(Tj.Palette.paper)
Circle().strokeBorder(Color.white.opacity(0.4), lineWidth: 4)
}
.frame(width: 72, height: 72)
}
.buttonStyle(.plain)
Spacer()
Button(action: onDone) {
Text("完成")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Capsule().fill(Color.white.opacity(0.1)))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 32)
.padding(.bottom, 40)
}
}
private struct DetectedEdge: Shape {
func path(in rect: CGRect) -> Path {
var p = Path()
let w = rect.width
let h = rect.height
p.move(to: CGPoint(x: w * 0.04, y: h * 0.05))
p.addLine(to: CGPoint(x: w * 0.92, y: h * 0.02))
p.addLine(to: CGPoint(x: w * 0.96, y: h * 0.96))
p.addLine(to: CGPoint(x: 0, y: h * 1.0))
p.closeSubpath()
return p
}
}
struct PageThumbStack: View {
let index: Int
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.7))
.frame(width: 56, height: 76)
.rotationEffect(.degrees(2))
.offset(x: 4, y: 4)
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.85))
.frame(width: 56, height: 76)
.rotationEffect(.degrees(-1))
.offset(x: 2, y: 2)
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Tj.Palette.paper)
.frame(width: 56, height: 76)
.overlay(
Text("p.\(index)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
)
.shadow(color: .black.opacity(0.4), radius: 4, x: 0, y: 2)
}
.frame(width: 64, height: 84, alignment: .topLeading)
}
}

View File

@@ -1,137 +0,0 @@
import SwiftUI
struct B3MetaView: View {
var onAnalyze: () -> Void
var onBack: () -> Void
@State private var selectedType = 0
private let types = ["体检报告", "化验单", "影像报告", "处方", "其他"]
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
Text("报告类型")
.font(.system(size: 11))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
.padding(.bottom, 8)
typeChips.padding(.bottom, 20)
FormRow(label: "报告日期", value: "2026 / 05 / 25", subtle: false)
FormRow(label: "出具机构", value: "协和医院体检中心", subtle: true)
FormRow(label: "备注", value: "春季年度体检", subtle: true)
Text("已拍页面3 页)")
.font(.system(size: 11))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 20)
.padding(.bottom, 10)
HStack(spacing: 10) {
ForEach(1...3, id: \.self) { n in
PageCard(index: n)
}
}
}
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
VStack(spacing: 8) {
Button(action: onAnalyze) {
Text("开始 AI 解读").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Text("预计耗时 58 秒 · 端侧 SME2 加速")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 18)
.padding(.bottom, 14)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
private var header: some View {
HStack(spacing: 6) {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
Text("归档信息")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 8)
}
private var typeChips: some View {
let columns = [GridItem(.adaptive(minimum: 60, maximum: 200), spacing: 8)]
return LazyVGrid(columns: columns, alignment: .leading, spacing: 8) {
ForEach(Array(types.enumerated()), id: \.offset) { idx, t in
Button { selectedType = idx } label: {
Text(t)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(idx == selectedType ? Tj.Palette.paper : Tj.Palette.text2)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule().fill(idx == selectedType ? Tj.Palette.ink : Tj.Palette.sand2)
)
}
.buttonStyle(.plain)
}
}
}
}
private struct FormRow: View {
let label: String
let value: String
let subtle: Bool
var body: some View {
HStack {
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
Spacer()
HStack(spacing: 6) {
Text(value)
.font(.system(size: 13))
.foregroundStyle(subtle ? Tj.Palette.text3 : Tj.Palette.text)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(.vertical, 12)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
}
private struct PageCard: View {
let index: Int
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
radius: 2, x: 0, y: 1)
TjPlaceholder(label: "p.\(index)", radius: 4)
.padding(6)
}
.aspectRatio(0.72, contentMode: .fit)
}
}

View File

@@ -1,293 +0,0 @@
import SwiftUI
struct B4ProgressView: View {
var onComplete: () -> Void
@State private var step: Int = 1
@State private var pulse = false
@State private var glow = false
@State private var rotate: Double = 0
@State private var elapsed: Double = 0.2
private let lineLabels = [
"正在本地识别第 1 / 3 页…",
"正在本地识别第 2 / 3 页…",
"正在本地识别第 3 / 3 页…",
"提取指标 · 共 28 项",
"生成整体摘要…",
]
var body: some View {
ZStack {
backgroundGradient.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
chip.padding(.bottom, 36)
Text("本地 AI · 正在解读")
.font(.system(size: 22, weight: .semibold))
.tracking(1)
.foregroundStyle(Color.white.opacity(0.95))
.padding(.bottom, 6)
Text("QWEN2.5-VL · ON-DEVICE · SME2")
.font(.system(size: 11, design: .monospaced))
.tracking(0.5)
.foregroundStyle(Color.white.opacity(0.55))
.padding(.bottom, 30)
lineList
.padding(.horizontal, 28)
speedBadge.padding(.top, 32)
Spacer()
Text("本地处理中 · 不会上传任何内容")
.font(.system(size: 10, design: .monospaced))
.tracking(0.5)
.foregroundStyle(Color.white.opacity(0.45))
.padding(.bottom, 30)
}
.padding(.horizontal, 28)
}
.preferredColorScheme(.dark)
.onAppear { startAnimations() }
}
private var backgroundGradient: some View {
RadialGradient(
colors: [
Color(red: 0.22, green: 0.21, blue: 0.18),
Color(red: 0.13, green: 0.12, blue: 0.10),
Color(red: 0.08, green: 0.075, blue: 0.06),
],
center: .init(x: 0.5, y: 0.3),
startRadius: 60,
endRadius: 700
)
}
private var chip: some View {
ZStack {
Circle()
.fill(Color(red: 0.93, green: 0.75, blue: 0.40).opacity(glow ? 0.18 : 0.0))
.frame(width: 176, height: 176)
.blur(radius: 30)
Circle()
.strokeBorder(Color.white.opacity(0.18),
style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.frame(width: 140, height: 140)
.rotationEffect(.degrees(rotate))
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(
LinearGradient(
colors: [Color(red: 0.36, green: 0.34, blue: 0.30),
Color(red: 0.22, green: 0.21, blue: 0.18)],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)
)
.frame(width: 96, height: 96)
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 12)
.overlay(ChipGlyph())
.overlay(alignment: .topTrailing) {
Circle()
.fill(Color(red: 0.95, green: 0.78, blue: 0.40))
.frame(width: 6, height: 6)
.opacity(pulse ? 1 : 0.35)
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.40), radius: 6)
.padding(10)
}
.scaleEffect(pulse ? 1.06 : 1.0)
.opacity(pulse ? 0.92 : 1.0)
}
}
private var lineList: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(lineLabels.enumerated()), id: \.offset) { idx, label in
LineRow(
text: label,
done: step > idx + 1,
active: step == idx + 1,
isLast: idx == lineLabels.count - 1
)
.opacity(step >= idx + 1 ? 1 : 0)
.offset(y: step >= idx + 1 ? 0 : 6)
.animation(.easeOut(duration: 0.4).delay(Double(idx) * 0.05), value: step)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var speedBadge: some View {
Text(String(format: "已处理 %.1fs · 比云端快 4.2×", elapsed))
.font(.system(size: 10, design: .monospaced))
.tracking(0.6)
.foregroundStyle(Color.white.opacity(0.75))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(Color.white.opacity(0.08)))
}
private func startAnimations() {
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
pulse.toggle()
}
withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) {
glow.toggle()
}
withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) {
rotate = 360
}
Task {
for _ in 0..<lineLabels.count {
try? await Task.sleep(nanoseconds: 900_000_000)
await MainActor.run {
withAnimation { step += 1 }
elapsed += 0.9
}
}
try? await Task.sleep(nanoseconds: 600_000_000)
await MainActor.run { onComplete() }
}
}
}
private struct LineRow: View {
let text: String
let done: Bool
let active: Bool
let isLast: Bool
@State private var dotPulse = false
var body: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(done
? Color(red: 0.95, green: 0.78, blue: 0.40)
: Color.white.opacity(0.12))
if done {
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
}
}
.frame(width: 14, height: 14)
Text(text)
.font(.system(size: 13))
.foregroundStyle(done ? Color.white.opacity(0.95) : Color.white.opacity(0.45))
if active {
Spacer()
Text("···")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Color.white.opacity(dotPulse ? 0.9 : 0.4))
.onAppear {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
dotPulse.toggle()
}
}
}
}
}
}
private struct ChipGlyph: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 5, style: .continuous)
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1.4)
.frame(width: 28, height: 28)
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color(red: 0.95, green: 0.78, blue: 0.40).opacity(0.35))
.overlay(
RoundedRectangle(cornerRadius: 2, style: .continuous)
.strokeBorder(Color(red: 0.95, green: 0.78, blue: 0.40), lineWidth: 1)
)
.frame(width: 16, height: 16)
innerCross
outerPins
}
.frame(width: 56, height: 56)
}
private var innerCross: some View {
Canvas { ctx, size in
let amber = Color(red: 0.95, green: 0.78, blue: 0.40)
let stroke = GraphicsContext.Shading.color(amber)
let cx = size.width / 2
let cy = size.height / 2
let pairs: [(CGPoint, CGPoint)] = [
(CGPoint(x: cx, y: cy - 8), CGPoint(x: cx, y: cy - 4)),
(CGPoint(x: cx, y: cy + 4), CGPoint(x: cx, y: cy + 8)),
(CGPoint(x: cx - 8, y: cy), CGPoint(x: cx - 4, y: cy)),
(CGPoint(x: cx + 4, y: cy), CGPoint(x: cx + 8, y: cy)),
]
for (s, e) in pairs {
var p = Path()
p.move(to: s)
p.addLine(to: e)
ctx.stroke(p, with: stroke, style: StrokeStyle(lineWidth: 1, lineCap: .round))
}
}
.frame(width: 56, height: 56)
}
private var outerPins: some View {
Canvas { ctx, size in
let pinColor = GraphicsContext.Shading.color(Color.white.opacity(0.45))
let cx = size.width / 2
let cy = size.height / 2
let halfChip: CGFloat = 14
let outsideStart: CGFloat = 20
let outsideEnd: CGFloat = 26
let positions: [CGFloat] = [-8, 0, 8]
for offset in positions {
// top
var p = Path()
p.move(to: CGPoint(x: cx + offset, y: cy - outsideEnd))
p.addLine(to: CGPoint(x: cx + offset, y: cy - halfChip))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// bottom
p = Path()
p.move(to: CGPoint(x: cx + offset, y: cy + halfChip))
p.addLine(to: CGPoint(x: cx + offset, y: cy + outsideEnd))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// left
p = Path()
p.move(to: CGPoint(x: cx - outsideEnd, y: cy + offset))
p.addLine(to: CGPoint(x: cx - halfChip, y: cy + offset))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// right
p = Path()
p.move(to: CGPoint(x: cx + halfChip, y: cy + offset))
p.addLine(to: CGPoint(x: cx + outsideStart + 2, y: cy + offset))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
}
}
.frame(width: 56, height: 56)
}
}
#Preview {
B4ProgressView(onComplete: {})
}

View File

@@ -1,323 +0,0 @@
import SwiftUI
struct B5IndicatorData {
let name: String
let value: String
let unit: String
let range: String
let status: IndicatorStatus
let note: String?
}
struct B5ResultView: View {
var onSave: () -> Void
var onBack: () -> Void
@State private var expandedIndex: Int? = 0
@State private var normalsExpanded = false
let abnormal: [B5IndicatorData] = [
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
note: "超过参考上限 0.44。建议关注饮食结构3 个月内复查。"),
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.451.70", status: .high, note: nil),
.init(name: "尿酸 UA", value: "428", unit: "μmol/L", range: "150420", status: .high, note: nil),
.init(name: "维生素 D", value: "18", unit: "ng/mL", range: "30100", status: .low, note: nil),
]
let normalCount = 24
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
reportMeta.padding(.bottom, 16)
summaryCard.padding(.bottom, 18)
SectionLabel("异常项", count: abnormal.count, accent: .brick)
.padding(.bottom, 10)
VStack(spacing: 8) {
ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in
IndicatorRow(item: it, expanded: expandedIndex == idx) {
withAnimation { expandedIndex = (expandedIndex == idx) ? nil : idx }
}
}
}
.padding(.bottom, 18)
SectionLabel("正常项", count: normalCount, accent: .leaf)
.padding(.bottom, 10)
normalCollapsed
}
.padding(.horizontal, 18)
.padding(.bottom, 16)
}
HStack(spacing: 10) {
Button(action: onSave) {
Text("保存归档").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Button { } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16, weight: .semibold))
}
.buttonStyle(TjGhostButton(horizontalPadding: 16))
}
.padding(.horizontal, 18)
.padding(.bottom, 14)
.padding(.top, 10)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
private var header: some View {
HStack(spacing: 6) {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
Spacer()
Button { } label: {
HStack(spacing: 4) {
Image(systemName: "photo")
Text("查看原图")
}
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.padding(8)
}
}
.padding(.horizontal, 12)
.padding(.top, 4)
}
private var reportMeta: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TjBadge(text: "体检报告", style: .ink)
Text("3 页")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
TjLockChip()
}
Text("2026 春季年度体检")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Tj.Palette.text)
Text("2026 / 05 / 25 · 协和医院体检中心")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
}
private var summaryCard: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
Text("整体摘记")
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.brick)
.fixedSize()
Rectangle().fill(Tj.Palette.line).frame(height: 1)
Text("本机摘要")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
.fixedSize()
}
.padding(.bottom, 12)
HStack(spacing: 14) {
Stat(n: "28", label: "总项")
Stat(n: "3", label: "偏高", tone: .brick)
Stat(n: "1", label: "偏低", tone: .amber)
Stat(n: "24", label: "正常", tone: .leaf)
}
.padding(.bottom, 14)
Text("本次共检测 28 项,\(Text("3 项偏高").fontWeight(.semibold).underline(color: Tj.Palette.brick))(血脂相关 2 项 + 尿酸)、\(Text("1 项偏低").fontWeight(.semibold).underline(color: Tj.Palette.amber))(维生素 D。整体趋势提示代谢风险有所抬升建议优化饮食并复查血脂。")
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.text)
.lineSpacing(6)
.padding(.bottom, 12)
TjDashedDivider().padding(.bottom, 10)
Text("仅供参考,不构成医疗建议")
.font(.system(size: 11))
.italic()
.foregroundStyle(Tj.Palette.text3)
}
.padding(.leading, 20)
.padding(.trailing, 20)
.padding(.vertical, 20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Tj.Palette.paper
.overlay(alignment: .leading) {
Tj.Palette.brick.frame(width: 3)
}
)
.clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1)
}
private var normalCollapsed: some View {
Button { withAnimation { normalsExpanded.toggle() } } label: {
HStack(spacing: 10) {
TjBadge(text: "\(normalCount)", style: .leaf)
Text("谷丙转氨酶、空腹血糖、糖化血红蛋白…")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(1)
Spacer()
Image(systemName: normalsExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.tjCard(bordered: true)
}
.buttonStyle(.plain)
}
}
private struct Stat: View {
let n: String
let label: String
var tone: Tone = .ink
enum Tone { case ink, brick, amber, leaf }
var color: Color {
switch tone {
case .ink: return Tj.Palette.text
case .brick: return Tj.Palette.brick
case .amber: return Color(red: 0.59, green: 0.45, blue: 0.27)
case .leaf: return Tj.Palette.leaf
}
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(n)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(color)
Text(label)
.font(.system(size: 10))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct SectionLabel: View {
let title: String
let count: Int
let accent: AccentKind
enum AccentKind { case brick, leaf }
init(_ title: String, count: Int, accent: AccentKind) {
self.title = title
self.count = count
self.accent = accent
}
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(accent == .brick ? Tj.Palette.brick : Tj.Palette.leaf)
.frame(width: 4, height: 14)
Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(Tj.Palette.text)
Text("· \(count)").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
}
}
}
private struct IndicatorRow: View {
let item: B5IndicatorData
let expanded: Bool
let onTap: () -> Void
var statusBadge: TjBadgeStyle {
switch item.status {
case .high: return .brick
case .low: return .amber
case .normal: return .leaf
}
}
var statusWord: String {
switch item.status {
case .high: return "偏高"
case .low: return "偏低"
case .normal: return "正常"
}
}
var valueColor: Color {
switch item.status {
case .high: return Tj.Palette.brick
case .low: return Color(red: 0.55, green: 0.45, blue: 0.32)
case .normal: return Tj.Palette.text
}
}
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(item.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
TjBadge(text: statusWord, style: statusBadge)
}
Text("范围 \(item.range) \(item.unit)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(item.value)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(valueColor)
Text(item.unit)
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
if expanded, let note = item.note {
TjDashedDivider()
Text(note)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(
item.status != .normal
? Color(red: 0.78, green: 0.68, blue: 0.48).opacity(0.5)
: Tj.Palette.lineSoft,
lineWidth: 1
)
)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,192 @@
import SwiftUI
import SwiftData
/// Markdown + / /
struct HealthExportDetailView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
let export: HealthExport
@State private var copiedFlash: Bool = false
@State private var showDeleteConfirm = false
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
metaBar
promptBlock
MarkdownView(text: export.content)
.padding(16)
.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)
)
AIDisclaimerFooter()
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
actionRow
}
.background(Tj.Palette.sand.ignoresSafeArea())
.alert("永久删除这份导出?", isPresented: $showDeleteConfirm) {
Button("删除", role: .destructive) {
ctx.delete(export)
try? ctx.save()
dismiss()
}
Button("取消", role: .cancel) {}
} message: {
Text("删除后无法恢复。源记录(指标、症状等)不受影响。")
}
}
private var header: some View {
HStack(alignment: .center, 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))
}
VStack(alignment: .leading, spacing: 2) {
Text("身体档案 · 历史导出")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text(Self.absoluteDate(export.createdAt))
.font(.tjScaled( 11))
.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)
}
}
private var metaBar: some View {
HStack(spacing: 10) {
TjBadge(text: export.modelTag, style: .neutral)
if export.decodeRate > 0 {
Text(String(format: "%.1f tok/s", export.decodeRate))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
Spacer()
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
Text("\(Self.shortDate(from))\(Self.shortDate(to))")
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private var promptBlock: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "quote.opening")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text(export.prompt)
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
}
private var actionRow: some View {
HStack(spacing: 10) {
Button { copy() } label: {
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
}
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: AIDisclaimer.appended(to: export.content)) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.tjScaled( 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
.frame(height: 44)
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
.contentShape(Capsule()) // :
}
Spacer()
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Image(systemName: "trash")
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(Tj.Palette.brick)
.frame(width: 44, height: 44)
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private func copy() {
UIPasteboard.general.string = AIDisclaimer.appended(to: export.content)
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
}
}
private static func absoluteDate(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day().hour().minute())
}
private static func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "MM-dd"
return f.string(from: d)
}
}
#Preview {
let exp = HealthExport(
prompt: "我感冒3天了,把最近一个月的健康情况给医生看",
content: """
# 就诊摘要 — 感冒就诊
## 主诉
本人男,38 岁,感冒 3 天未愈。
## 本人背景
- 高血压 2 年
- 在服药:缬沙坦 80mg qd
""",
inferredTimeFromDate: Calendar.current.date(byAdding: .day, value: -30, to: .now),
inferredTimeToDate: .now,
inferredIntent: "cold_consult",
decodeRate: 24.3
)
return HealthExportDetailView(export: exp)
}

View File

@@ -0,0 +1,137 @@
import SwiftUI
import SwiftData
/// ArchiveListView strip
struct HealthExportListView: View {
@Environment(\.modelContext) private var ctx
@Query(sort: \HealthExport.createdAt, order: .reverse)
private var exports: [HealthExport]
@State private var selected: HealthExport?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 14)
if exports.isEmpty {
empty
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(exports) { exp in
Button {
selected = exp
} label: {
HealthExportRow(export: exp)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
delete(exp)
} label: {
Label("删除", systemImage: "trash")
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("我的导出")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selected) { exp in
HealthExportDetailView(export: exp)
}
}
private var header: some View {
HStack(alignment: .lastTextBaseline) {
Text("我的导出")
.font(.tjTitle(24))
.foregroundStyle(Tj.Palette.text)
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count)"))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
TjLockChip()
}
}
private var empty: some View {
VStack(spacing: 12) {
Spacer()
TjPlaceholder(label: String(appLoc: "还没有导出过\n回到记录页右上角生成一份"))
.frame(width: 240, height: 140)
Spacer()
}
.frame(maxWidth: .infinity)
}
private func delete(_ exp: HealthExport) {
ctx.delete(exp)
try? ctx.save()
}
}
///
struct HealthExportRow: View {
let export: HealthExport
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
Text(export.promptPreview)
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 8) {
Text(Self.relativeDate(export.createdAt))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
if export.decodeRate > 0 {
Text(String(format: "%.1f tok/s", export.decodeRate))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
Spacer()
if let label = export.inferredLabelCN ?? export.inferredIntent {
TjBadge(text: label, style: .neutral)
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
}
static func relativeDate(_ d: Date) -> String {
let f = RelativeDateTimeFormatter()
f.locale = Locale.current
f.unitsStyle = .full
return f.localizedString(for: d, relativeTo: .now)
}
}
#Preview {
NavigationStack {
HealthExportListView()
}
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}

View File

@@ -0,0 +1,841 @@
import SwiftUI
import SwiftData
/// sheet
/// : running(retrieving generating) completed / failed
struct HealthExportSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// :(,W3 )
let initialPrompt: String
@State private var turns: [HealthExportDialogueTurn] = []
@State private var draftQuestion: String = ""
@State private var phase: HealthExportService.Phase?
@State private var content: String = ""
@State private var rate: Double = 0
@State private var task: Task<Void, Never>?
@State private var error: Error?
@State private var completed: Bool = false
@State private var copiedFlash: Bool = false
@State private var answeringTurnID: UUID?
@State private var retrieval: HealthExportService.RetrievalSummary?
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
@FocusState private var questionFocused: Bool
//
@State private var promptStore = QuickPromptStore.shared
@State private var showAddPrompt = false
@State private var newPromptText = ""
init(initialPrompt: String = "") {
self.initialPrompt = initialPrompt
}
private var isGeneratingReport: Bool { phase != nil && !completed && error == nil }
private var isAnswering: Bool { answeringTurnID != nil }
private var canAsk: Bool {
!isAnswering &&
!isGeneratingReport &&
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
///
private var hasUserContent: Bool {
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
}
/// :,()
private var canGenerateReport: Bool {
!isAnswering &&
!isGeneratingReport &&
(hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
var body: some View {
VStack(spacing: 0) {
header
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
introSection
ForEach(turns) { turn in
dialogueBubble(turn)
}
if isGeneratingReport { phaseIndicator }
if !content.isEmpty {
reportCard
}
if let err = error { errorRow(err) }
Color.clear.frame(height: 1).id("bottom")
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
.onChange(of: content) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: turns) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
if completed {
actionRow
} else {
composer
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
.onAppear {
if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty {
draftQuestion = initialPrompt
}
questionFocused = true
}
.onDisappear { task?.cancel() }
.alert("添加快捷问答", isPresented: $showAddPrompt) {
TextField("输入一句常用问题…", text: $newPromptText)
Button("取消", role: .cancel) { newPromptText = "" }
Button("添加") {
promptStore.add(prompt: newPromptText)
newPromptText = ""
}
} message: {
Text("保存后点一下,就能把这句话填进输入框")
}
}
// MARK: -
/// + chip ; chip , ,
private var quickPromptRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(promptStore.all) { p in
quickPromptChip(p)
}
addQuickPromptChip
}
.padding(.vertical, 1) // chip , ScrollView
}
}
private func quickPromptChip(_ p: QuickPrompt) -> some View {
Button {
draftQuestion = p.prompt
questionFocused = true
} label: {
Text(p.title)
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.sand2))
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
}
.buttonStyle(.plain)
.contextMenu {
if !p.isBuiltin {
Button(role: .destructive) {
promptStore.delete(p)
} label: {
Label("删除", systemImage: "trash")
}
}
}
}
private var addQuickPromptChip: some View {
Button { showAddPrompt = true } label: {
Label("自定义", systemImage: "plus")
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.paper))
.overlay(
Capsule().strokeBorder(
Tj.Palette.line,
style: StrokeStyle(lineWidth: 1, dash: [3])
)
)
}
.buttonStyle(.plain)
}
// MARK: - Header
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Button { close() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
VStack(alignment: .leading, spacing: 2) {
Text("身体档案")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("先问清楚,再整理给医生")
.font(.tjScaled( 11))
.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: - Dialogue
private var introSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("围绕你的指标和健康日记提问")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
quickPromptRow
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
.font(.tjScaled( 11))
.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)
)
}
private func dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View {
let isUser = turn.role == .user
return HStack(alignment: .top, spacing: 8) {
if isUser { Spacer(minLength: 44) }
VStack(alignment: .leading, spacing: 6) {
Text(turn.role.transcriptLabel)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
if !isUser, let summary = turnRetrievals[turn.id] {
RetrievalChipsView(summary: summary)
}
if turn.id == answeringTurnID && turn.text.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(turnRetrievals[turn.id] == nil
? "正在查看本地记录…"
: "正在根据这些记录回答…")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
AIFlowBar()
}
} else {
Text(turn.text)
.font(.tjScaled( 14))
.lineSpacing(3)
.foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(12)
.frame(maxWidth: 300, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(isUser ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1)
)
if !isUser { Spacer(minLength: 44) }
}
}
private var reportCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("整理好的报告")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
MarkdownView(text: content)
if completed {
Divider().background(Tj.Palette.lineSoft)
AIDisclaimerFooter()
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.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)
)
}
// MARK: - Phase indicator
private var phaseIndicator: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
phasePill(.extractingIntent)
arrow
phasePill(.retrieving)
arrow
phasePill(.generating)
}
if let retrieval {
RetrievalChipsView(summary: retrieval)
}
if phase == .generating && rate > 0 {
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
} else {
Text(phase?.label ?? "")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
// AI :线( AI )
AIFlowBar().padding(.top, 2)
}
}
private func phasePill(_ p: HealthExportService.Phase) -> some View {
let active = (p == phase)
let done = phaseOrder(p) < phaseOrder(phase ?? .extractingIntent)
let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
return Text(p.label)
.font(.tjScaled( 11, weight: active ? .semibold : .regular))
.foregroundStyle(fg)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(fill))
}
private var arrow: some View {
Image(systemName: "chevron.right")
.font(.tjScaled( 10, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
private func phaseOrder(_ p: HealthExportService.Phase) -> Int {
switch p {
case .extractingIntent: return 0
case .retrieving: return 1
case .generating: return 2
case .completed: return 3
}
}
// MARK: - Error
private func errorRow(_ err: Error) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text)
}
Button { reset() } label: { Text("返回修改") }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13))
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.6))
)
}
// MARK: - Action row (completed)
private var actionRow: some View {
HStack(spacing: 10) {
Button { copy() } label: {
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
}
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: AIDisclaimer.appended(to: content)) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.tjScaled( 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
.frame(height: 44)
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
.contentShape(Capsule()) // :
}
Spacer()
Button { regenerate() } label: {
Label("重新整理", systemImage: "arrow.clockwise")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var composer: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
.font(.tjScaled( 14))
.lineLimit(1...4)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.sand2)
)
.focused($questionFocused)
.disabled(isAnswering || isGeneratingReport)
Button { sendQuestion() } label: {
Image(systemName: "arrow.up")
.font(.tjScaled( 15, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 40, height: 40)
.background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line))
}
.disabled(!canAsk)
.accessibilityLabel("发送问题")
}
if isGeneratingReport {
Button { stopGeneration() } label: {
Label("停止生成", systemImage: "stop.fill")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.brick, lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
Button { startReportGeneration() } label: {
Label("生成整理报告", systemImage: "doc.text.below.ecg")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
.disabled(!canGenerateReport)
.opacity(canGenerateReport ? 1 : 0.45)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: - Actions
private func sendQuestion() {
let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return }
draftQuestion = ""
questionFocused = false
let userTurn = HealthExportDialogueTurn.user(question)
let assistantTurn = HealthExportDialogueTurn.assistant("")
turns.append(userTurn)
turns.append(assistantTurn)
answeringTurnID = assistantTurn.id
let conversationForPrompt = turns.filter { $0.id != assistantTurn.id }
let stream = HealthExportService.shared.answer(
question: question,
conversation: conversationForPrompt,
in: ctx
)
task?.cancel()
task = Task { @MainActor in
do {
for try await event in stream {
switch event {
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) {
turnRetrievals[assistantTurn.id] = summary
}
case .token(let chunk):
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .phaseChanged, .completed:
break
}
}
answeringTurnID = nil
questionFocused = true
} catch {
answeringTurnID = nil
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
questionFocused = true
}
}
}
private func appendToTurn(id: UUID, text: String) {
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
turns[idx].text += text
}
private func startReportGeneration() {
guard canGenerateReport else { return }
questionFocused = false
// :(/),
//
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
if !draft.isEmpty {
turns.append(.user(draft))
draftQuestion = ""
}
content = ""
rate = 0 // , tok/s
error = nil
completed = false
retrieval = nil
phase = .retrieving
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
task?.cancel()
task = Task { @MainActor in
do {
for try await event in stream {
switch event {
case .phaseChanged(let ph):
phase = ph
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
case .token(let chunk):
content += chunk.text
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .completed:
completed = true
}
}
} catch {
self.error = error
self.phase = nil
}
}
}
private func regenerate() {
completed = false
startReportGeneration()
}
/// :,()
private func stopGeneration() {
task?.cancel()
task = nil
phase = nil
rate = 0
completed = false
content = ""
retrieval = nil
}
private func reset() {
task?.cancel()
task = nil
phase = nil
content = ""
rate = 0
error = nil
completed = false
answeringTurnID = nil
retrieval = nil
turnRetrievals = [:]
questionFocused = true
}
private func copy() {
UIPasteboard.general.string = AIDisclaimer.appended(to: content)
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
}
}
private func close() {
task?.cancel()
dismiss()
}
}
// MARK: - chips( RAG )
/// RAG :N + chips
/// ( embedding) (§12 3)
private struct RetrievalChipsView: View {
let summary: HealthExportService.RetrievalSummary
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if summary.totalCount == 0 {
Text("本地档案中暂无相关记录,将仅按你的描述整理")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
} else {
Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录"))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.leaf)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in
Text(chip)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
}
}
.padding(.vertical, 1)
}
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// MARK: - Markdown ()
/// Markdown ,
/// : `# ``## ``-` `****`( AttributedString inline )
/// prompt LLM
struct MarkdownView: View {
let text: String
var body: some View {
let blocks = Self.parse(text)
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
renderBlock(block)
}
}
}
@ViewBuilder
private func renderBlock(_ block: Block) -> some View {
switch block {
case .h1(let s):
VStack(alignment: .leading, spacing: 8) {
Text(inline(s))
.font(.tjScaled( 22, weight: .bold))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Rectangle()
.fill(Tj.Palette.ink)
.frame(height: 1)
.frame(maxWidth: .infinity)
}
.padding(.top, 2)
.padding(.bottom, 4)
case .h2(let s):
HStack(alignment: .center, spacing: 8) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 3, height: 16)
Text(inline(s))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
.padding(.top, 10)
.padding(.bottom, 2)
case .bullet(let s):
if let abnormalText = Self.extractAbnormal(s) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.brick)
Text(inline(abnormalText))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.55))
)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 3)
}
} else {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Circle()
.fill(Tj.Palette.text3)
.frame(width: 4, height: 4)
.padding(.top, 6)
Text(inline(s))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, 2)
}
case .body(let s):
Text(inline(s))
.font(.tjScaled( 14))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
case .gap:
Spacer().frame(height: 4)
}
}
/// bullet , strip
/// nil()
private static func extractAbnormal(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("⚠️") {
return trimmed.replacingOccurrences(of: "⚠️", with: "")
.trimmingCharacters(in: .whitespaces)
}
// ,(),
// : 4
let negations = ["", "", ""]
let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"]
for sig in abnormalSignals {
guard let r = trimmed.range(of: sig) else { continue }
let window = String(trimmed[..<r.lowerBound].suffix(4))
if negations.contains(where: { window.contains($0) }) { continue }
return trimmed
}
return nil
}
private func inline(_ s: String) -> AttributedString {
// **bold** / *italic* / [text](url) AttributedString markdown
if let attr = try? AttributedString(
markdown: s,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return attr
}
return AttributedString(s)
}
// MARK: -
enum Block {
case h1(String)
case h2(String)
case bullet(String)
case body(String)
case gap
}
static func parse(_ raw: String) -> [Block] {
var out: [Block] = []
let lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
for line in lines {
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty {
// gap
if case .gap = out.last { continue }
out.append(.gap)
continue
}
if t.hasPrefix("# ") {
out.append(.h1(String(t.dropFirst(2))))
} else if t.hasPrefix("## ") {
out.append(.h2(String(t.dropFirst(3))))
} else if t.hasPrefix("### ") {
out.append(.h2(String(t.dropFirst(4))))
} else if t.hasPrefix("- ") || t.hasPrefix("* ") {
out.append(.bullet(String(t.dropFirst(2))))
} else {
out.append(.body(t))
}
}
return out
}
}
#Preview("HealthExportSheet · 空状态") {
HealthExportSheet()
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}
#Preview("MarkdownView · 演示") {
ScrollView {
MarkdownView(text: """
# 就诊摘要 — 感冒就诊
## 主诉
本人男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
## 本人背景
- 高血压 2 年
- 在服药:**缬沙坦 80mg qd**
- 过敏:青霉素
## 近期症状
- 2026-05-24 感冒(进行中,severity 2):鼻塞、低烧
- 2026-05-20 头痛(已结束)
## 关键指标
- ⚠️ 收缩压 142 mmHg (参考 <140) — 2026-05-26
- 体温 37.2 ℃ (参考 36-37) — 2026-05-25
""")
.padding()
}
.background(Tj.Palette.sand)
}

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

@@ -0,0 +1,291 @@
import SwiftUI
import SwiftData
enum CalendarMode: String, CaseIterable, Identifiable {
case month, year
var id: String { rawValue }
var label: String {
switch self {
case .month: return String(appLoc: "")
case .year: return String(appLoc: "")
}
}
}
/// HomeCalendarCard
/// / + + + CalendarMonthGrid / CalendarYearGrid
struct CalendarOverviewView: View {
/// ();nil
var initialDate: Date = .now
/// fullScreenCover
var onClose: (() -> Void)?
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@Query(sort: \Report.reportDate, order: .reverse)
private var reports: [Report]
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
private var diaries: [DiaryEntry]
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
@State private var mode: CalendarMode = .month
@State private var anchor: Date = .now
@State private var selectedDate: Date = .now
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale.current
return c
}()
@MainActor
private var data: CalendarData {
CalendarData.build(
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms
)
}
var body: some View {
NavigationStack {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
modeSwitch.padding(.top, 4)
anchorBar
calendarBody
legend
if mode == .month {
dayDetailInline
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle(String(appLoc: "健康日历"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
withAnimation(.snappy(duration: 0.2)) {
anchor = .now
selectedDate = .now
mode = .month
}
} label: {
Text("回到今天")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
}
ToolbarItem(placement: .topBarTrailing) {
if let onClose {
Button(action: onClose) {
Text("完成")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
}
}
}
}
.onAppear {
anchor = initialDate
selectedDate = initialDate
}
}
private var dayDetailInline: some View {
VStack(alignment: .leading, spacing: 0) {
DayDetailContent(
date: selectedDate,
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms,
showHeader: true
)
.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)
)
.animation(.snappy(duration: 0.2), value: selectedDate)
}
private var modeSwitch: some View {
HStack(spacing: 0) {
ForEach(CalendarMode.allCases) { m in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
mode = m
}
} label: {
Text(m.label)
.font(.tjScaled( 13, weight: mode == m ? .semibold : .regular))
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
Capsule().fill(mode == m ? Tj.Palette.ink : Color.clear)
)
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.frame(maxWidth: 220)
}
private var anchorBar: some View {
HStack {
Button { shiftAnchor(-1) } label: {
Image(systemName: "chevron.left")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
.background(Circle().fill(Tj.Palette.paper))
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
}
.buttonStyle(.plain)
Spacer()
Text(anchorTitle)
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
.contentTransition(.numericText())
.animation(.snappy, value: anchor)
Spacer()
Button { shiftAnchor(1) } label: {
Image(systemName: "chevron.right")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
.background(Circle().fill(Tj.Palette.paper))
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
}
.buttonStyle(.plain)
.disabled(isAnchorAtFuture)
.opacity(isAnchorAtFuture ? 0.4 : 1)
}
}
private var anchorTitle: String {
let style: Date.FormatStyle = mode == .month
? .dateTime.year().month()
: .dateTime.year()
return anchor.formatted(style)
}
@ViewBuilder
private var calendarBody: some View {
switch mode {
case .month:
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
withAnimation(.snappy(duration: 0.2)) {
selectedDate = day
}
}
.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)
)
case .year:
CalendarYearGrid(
year: calendar.component(.year, from: anchor),
data: data
) { tappedMonth in
anchor = tappedMonth
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
mode = .month
}
}
}
}
private var legend: some View {
VStack(alignment: .leading, spacing: 8) {
Text("图例")
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
HStack(spacing: 14) {
legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常"))
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
}
}
.padding(.top, 4)
}
private func legendItem(color: Color, label: String) -> some View {
HStack(spacing: 5) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(color)
.frame(width: 14, height: 6)
Text(label)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
}
}
private var isAnchorAtFuture: Bool {
switch mode {
case .month:
return calendar.isDate(anchor, equalTo: .now, toGranularity: .month) ||
anchor > .now
case .year:
let nowYear = calendar.component(.year, from: .now)
let anchorYear = calendar.component(.year, from: anchor)
return anchorYear >= nowYear
}
}
private func shiftAnchor(_ delta: Int) {
let component: Calendar.Component = (mode == .month) ? .month : .year
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
withAnimation(.snappy) {
anchor = next
if mode == .month {
if calendar.isDate(next, equalTo: .now, toGranularity: .month) {
selectedDate = .now
} else if let first = calendar.dateInterval(of: .month, for: next)?.start {
selectedDate = first
}
}
}
}
}
}
#Preview {
CalendarOverviewView(onClose: {})
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
], inMemory: true)
}

View File

@@ -8,8 +8,13 @@ struct CaptureReviewForm: View {
@State var parsed: ParsedReport
let assets: [FileVault.SavedAsset]
let warning: String?
/// : + (///),
/// ( 2B OOM ), CaptureService.extractReportMeta
var metaOnly: Bool = false
let onSave: (ParsedReport) -> Void
let onCancel: () -> Void
/// assets () nil,banner
var onReanalyze: (() -> Void)? = nil
var body: some View {
ScrollView {
@@ -21,7 +26,9 @@ struct CaptureReviewForm: View {
pageThumbnails
}
metaSection
indicatorSection
if !metaOnly {
indicatorSection
}
Spacer(minLength: 8)
actions
}
@@ -36,10 +43,22 @@ struct CaptureReviewForm: View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.amber)
Text(text)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
VStack(alignment: .leading, spacing: 8) {
Text(text)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
if let onReanalyze {
Button {
onReanalyze()
} label: {
Label("重新识别", systemImage: "arrow.clockwise")
.font(.tjScaled( 12, weight: .semibold))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
}
Spacer(minLength: 0)
}
.padding(12)
@@ -53,21 +72,27 @@ struct CaptureReviewForm: View {
private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("已保存 \(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) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 84, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
Image(uiImage: img).resizable().scaledToFill()
} placeholder: { _ in
Tj.Palette.paper
}
.frame(width: 84, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
}
@@ -78,13 +103,13 @@ struct CaptureReviewForm: View {
private var metaSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionLabel("基本信息")
sectionLabel(String(appLoc: "基本信息"))
VStack(spacing: 10) {
labeledField("标题") {
labeledField(String(appLoc: "标题")) {
TextField("如:春季年度体检", text: $parsed.title)
.textFieldStyle(.plain)
}
labeledField("类型") {
labeledField(String(appLoc: "类型")) {
Picker("", selection: $parsed.typeRaw) {
ForEach(ReportType.allCases, id: \.rawValue) { t in
Text(t.label).tag(t.rawValue)
@@ -92,20 +117,22 @@ struct CaptureReviewForm: View {
}
.pickerStyle(.segmented)
}
labeledField("报告日期") {
labeledField(String(appLoc: "报告日期")) {
DatePicker("", selection: $parsed.reportDate,
in: ...Date.now,
displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
.environment(\.locale, Locale(identifier: "zh_CN"))
.environment(\.locale, Locale.current)
}
labeledField("机构(可选)") {
labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution)
}
labeledField("摘要(可选)") {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
if !metaOnly {
labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
}
}
.padding(12)
@@ -117,7 +144,7 @@ struct CaptureReviewForm: View {
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 11, weight: .medium))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
content()
}
@@ -128,7 +155,7 @@ struct CaptureReviewForm: View {
private var indicatorSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("指标(\(parsed.indicators.count) 项)")
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
Spacer()
Button {
parsed.indicators.append(
@@ -136,34 +163,34 @@ struct CaptureReviewForm: View {
)
} label: {
Label("加一项", systemImage: "plus.circle")
.font(.system(size: 12, weight: .medium))
.font(.tjScaled( 12, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
if parsed.indicators.isEmpty {
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.padding(.vertical, 8)
} else {
VStack(spacing: 10) {
ForEach(parsed.indicators.indices, id: \.self) { idx in
indicatorRow(idx)
ForEach($parsed.indicators) { $indicator in
indicatorRow($indicator)
}
}
}
}
}
private func indicatorRow(_ idx: Int) -> some View {
let binding = $parsed.indicators[idx]
private func indicatorRow(_ binding: Binding<ParsedReport.ParsedIndicator>) -> some View {
let id = binding.wrappedValue.id
return VStack(spacing: 8) {
HStack(spacing: 8) {
TextField("指标名", text: binding.name)
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
Button(role: .destructive) {
parsed.indicators.remove(at: idx)
parsed.indicators.removeAll { $0.id == id }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(Tj.Palette.text3)
@@ -173,7 +200,7 @@ struct CaptureReviewForm: View {
HStack(spacing: 8) {
TextField("数值", text: binding.value)
.keyboardType(.decimalPad)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
.frame(maxWidth: 90)
TextField("单位", text: binding.unit)
.frame(maxWidth: 80)
@@ -233,7 +260,7 @@ struct CaptureReviewForm: View {
private func sectionLabel(_ t: String) -> some View {
Text(t)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}

View File

@@ -13,10 +13,10 @@ struct PhotoPickerSheet: View {
var body: some View {
VStack(spacing: 20) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 56))
.font(.tjScaled( 56))
.foregroundStyle(Tj.Palette.text3)
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
.multilineTextAlignment(.center)
@@ -24,7 +24,7 @@ struct PhotoPickerSheet: View {
maxSelectionCount: 5,
matching: .images) {
Text("从相册选 ≤5 张")
.font(.system(size: 14, weight: .semibold))
.font(.tjScaled( 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Tj.Palette.ink)
@@ -32,8 +32,14 @@ struct PhotoPickerSheet: View {
.clipShape(Capsule())
}
Button("取消", action: onCancel)
.foregroundStyle(Tj.Palette.text3)
Button(action: onCancel) {
Text("取消")
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 24)
.frame(minHeight: 44) // HIG
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if loading {
ProgressView().tint(Tj.Palette.ink)

View File

@@ -1,6 +1,7 @@
import SwiftUI
import SwiftData
import UIKit
import Combine
/// VL ( + )
/// , A1-A3 / B1-B5 mockup
@@ -16,11 +17,17 @@ struct UnifiedCaptureFlow: View {
@Environment(\.modelContext) private var ctx
let onClose: () -> Void
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
@State private var phase: Phase = .idle
@State private var analyzeTask: Task<Void, Never>? = nil
@State private var showTip: Bool = false
/// VL (); cancel ,UI 退
private let analyzeTimeoutSeconds: Int = 30
enum Phase {
case idle
case analyzing(images: [UIImage])
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
case editing(parsed: ParsedReport,
assets: [FileVault.SavedAsset],
warning: String?)
@@ -32,20 +39,30 @@ struct UnifiedCaptureFlow: View {
.background(Tj.Palette.sand.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { onClose() }
Button("取消") { cancelAll() }
.foregroundStyle(Tj.Palette.text)
}
}
.navigationTitle(phaseTitle)
.navigationBarTitleDisplayMode(.inline)
}
.onAppear {
if !hasSeenCaptureTip { showTip = true }
}
.sheet(isPresented: $showTip) {
CaptureTipSheet(onDismiss: {
hasSeenCaptureTip = true
showTip = false
})
.presentationDetents([.medium])
}
}
private var phaseTitle: String {
switch phase {
case .idle: return "拍摄报告"
case .analyzing: return "本地识别中…"
case .editing: return "核对识别结果"
case .idle: return String(appLoc: "拍摄报告")
case .analyzing: return String(appLoc: "本地识别中…")
case .editing: return String(appLoc: "核对报告信息")
}
}
@@ -54,21 +71,58 @@ struct UnifiedCaptureFlow: View {
switch phase {
case .idle:
captureEntry
case .analyzing(let images):
AnalyzingView(images: images)
case .analyzing(let images, _):
AnalyzingView(
images: images,
timeoutSeconds: analyzeTimeoutSeconds,
onCancel: {
analyzeTask?.cancel()
analyzeTask = nil
phase = .idle
}
)
case .editing(let parsed, let assets, let warning):
CaptureReviewForm(
parsed: parsed,
assets: assets,
warning: warning,
metaOnly: true, // + meta,(§ CaptureService.extractReportMeta)
onSave: { final in saveAll(parsed: final, assets: assets) },
onCancel: onClose
onCancel: cancelAll,
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
)
}
}
// MARK: -
/// + SwiftData Vault , sheet
/// (),
/// (§6), Vault
/// .analyzing/.editing assets;.idle ,
private func cancelAll() {
analyzeTask?.cancel()
analyzeTask = nil
switch phase {
case .idle:
break
case .analyzing(_, let maybeAssets):
if let assets = maybeAssets { removeOrphans(assets) }
case .editing(_, let assets, _):
removeOrphans(assets)
}
onClose()
}
private func removeOrphans(_ assets: [FileVault.SavedAsset]) {
for a in assets {
try? FileVault.shared.remove(relativePath: a.relativePath)
}
}
// MARK: - : /
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
@@ -95,54 +149,92 @@ struct UnifiedCaptureFlow: View {
private func startAnalyze(images: [UIImage]) {
guard !images.isEmpty else { onClose(); return }
phase = .analyzing(images: images)
Task {
do {
let result = try await CaptureService.shared.analyze(images: images)
await MainActor.run {
phase = .editing(
parsed: result.parsed,
assets: result.assets,
warning: result.parsed.isEmpty
? "识别没有读出指标,请手动补充"
: nil
)
}
} catch let CaptureError.parseFailed(msg) {
// :, indicators ,assets
await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)")
} catch let CaptureError.inferenceFailed(msg) {
await fallbackToManual(images: images, msg: "推理失败:\(msg)")
} catch let CaptureError.modelNotReady {
await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入")
} catch CaptureError.writeAssetFailed {
analyzeTask?.cancel()
phase = .analyzing(images: images, assets: nil)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
// Step 1: Vault(,)
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
// :,View dismisscancelAll
// phase .analyzing(_, nil),
if Task.isCancelled {
for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) }
return
}
guard !assets.isEmpty else {
await MainActor.run {
phase = .editing(
parsed: .empty(),
assets: [],
warning: "图片保存失败,手动录入并保留文本"
warning: String(appLoc: "图片保存失败,请重试")
)
}
} catch {
await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)")
return
}
// assets phase,使
await MainActor.run {
if case .analyzing(let imgs, _) = phase {
phase = .analyzing(images: imgs, assets: assets)
}
}
// Step 2: meta (OCR + LLM,///)
// 2B OOM watchdog cancel
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
if Task.isCancelled {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
}
return
}
await MainActor.run {
phase = .editing(
parsed: meta,
assets: assets,
warning: recognized ? nil
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
)
}
}
}
private func fallbackToManual(images: [UIImage], msg: String) async {
// 便 VL , Vault( CaptureService.analyze 1 )
// writeAsset (modelNotReady / inferenceFailed),
// ,
var assets: [FileVault.SavedAsset] = []
for img in images {
if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) }
/// : assets,, meta
private func reanalyze(assets: [FileVault.SavedAsset]) {
analyzeTask?.cancel()
// UIImage,AnalyzingView , 600px ,
// ( MB)
let thumbnails: [UIImage] = assets.compactMap {
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
}
await MainActor.run {
phase = .editing(
parsed: .empty(),
assets: assets,
warning: msg
)
phase = .analyzing(images: thumbnails, assets: assets)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
if Task.isCancelled {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保留原图"))
}
return
}
await MainActor.run {
phase = .editing(parsed: meta, assets: assets,
warning: recognized ? nil
: String(appLoc: "未能自动识别报告信息,可手动填写"))
}
}
}
@@ -151,7 +243,7 @@ struct UnifiedCaptureFlow: View {
private func saveAll(parsed final: ParsedReport,
assets: [FileVault.SavedAsset]) {
let report = Report(
title: final.title.isEmpty ? "拍摄识别" : final.title,
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
type: ReportType(rawValue: final.typeRaw) ?? .other,
reportDate: final.reportDate,
institution: final.institution.isEmpty ? nil : final.institution,
@@ -176,12 +268,21 @@ struct UnifiedCaptureFlow: View {
range: ind.range,
status: ind.status,
capturedAt: final.reportDate,
report: report
report: report,
source: .report,
sourcePageIndex: ind.sourcePageIndex,
sourceBoxX: ind.sourceBoxX,
sourceBoxY: ind.sourceBoxY,
sourceBoxWidth: ind.sourceBoxWidth,
sourceBoxHeight: ind.sourceBoxHeight
)
ctx.insert(i)
}
try? ctx.save()
// :,
// AI (/) token
Task { await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx) }
onClose()
}
}
@@ -190,6 +291,11 @@ struct UnifiedCaptureFlow: View {
private struct AnalyzingView: View {
let images: [UIImage]
let timeoutSeconds: Int
let onCancel: () -> Void
@State private var elapsed: Int = 0
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 20) {
@@ -216,13 +322,78 @@ private struct AnalyzingView: View {
Text("本地识别中")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(images.count) 页 · 100% 本地推理")
.font(.system(size: 12))
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
if elapsed >= timeoutSeconds - 5 {
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
}
}
Button(action: onCancel) {
Text("取消识别 · 改为手动录入")
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 20)
.frame(minHeight: 44) // HIG
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.top, 4)
Spacer()
}
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onReceive(tick) { _ in elapsed += 1 }
}
}
// MARK: - 使
private struct CaptureTipSheet: View {
let onDismiss: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
Image(systemName: "doc.viewfinder")
.font(.tjScaled( 28))
.foregroundStyle(Tj.Palette.ink)
Text("拍报告的小贴士")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
}
VStack(alignment: .leading, spacing: 12) {
tip(String(appLoc: "纸张铺平,避免反光、阴影"))
tip(String(appLoc: "整页入框,避免裁切到指标"))
tip(String(appLoc: "多页报告可连拍,系统自动透视校正"))
tip(String(appLoc: "识别全程在本地,图片不会上传"))
}
Spacer()
Button {
onDismiss()
} label: {
Text("我知道了,开始拍")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
}
private func tip(_ text: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Tj.Palette.leaf)
.padding(.top, 2)
Text(text)
.font(.tjSerifBody())
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
}
}

View File

@@ -1,16 +1,79 @@
import SwiftUI
import SwiftData
/// sheet
/// DiaryEntry @Model;UI/, AI :
/// Qwen3 3-4 ,
/// q LLM ; row +
struct DiaryQuickSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@State private var content: String = ""
@State private var createdAt: Date = .now
/// :,
@State private var showMedicationScan = false
/// :( + + ),tag
@State private var showMedicationLog = false
/// : SymptomStartSheet(/,)
@State private var showSymptomStart = false
private var canSubmit: Bool {
/// AI
enum AssistPhase {
case idle //
case loading // LLM
case ready // , / /
case failed(Error) //
}
@State private var phase: AssistPhase = .idle
@State private var questions: [DiaryAssistService.Question] = []
@State private var lastRate: Double = 0
@State private var currentRound: Int = 0
/// (question.dim), prompt
@State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>?
/// question id;nil =
@State private var fillingId: UUID?
/// , =
@State private var fillValues: [String] = []
/// () true,
@State private var exhaustedNote = false
/// sheet detent large,
/// medium,()
@State private var detent: PresentationDetent = .large
@FocusState private var contentFocused: Bool
// MARK: (spec 2026-06-10-voice-diary)
enum VoicePhase: Equatable { case idle, recording, organizing }
@State private var voicePhase: VoicePhase = .idle
@State private var liveTranscript = ""
@State private var recordingSeconds = 0
/// 稿,退;
@State private var rawTranscript: String?
/// 稿,
/// () pill
@State private var organizedAppended: String?
/// ( / ),
@State private var voiceNote: String?
@State private var voiceDeniedAlert = false
@State private var voiceFlowTask: Task<Void, Never>?
@State private var recordingWatchdog: Task<Void, Never>?
/// @State:struct View (/detent ) let
/// , stop() (),
/// @State
@State private var dictation = SpeechDictationService()
private var hasContent: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var hasQuestions: Bool { !questions.isEmpty }
private var isLoading: Bool {
if case .loading = phase { return true }
return false
}
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
private var canSubmit: Bool { hasContent }
var body: some View {
VStack(spacing: 0) {
@@ -21,44 +84,159 @@ struct DiaryQuickSheet: View {
.padding(.bottom, 14)
HStack {
Text("写日记")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
VStack(alignment: .leading, spacing: 2) {
Text("健康记录")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("本机保存")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
.padding(.bottom, 10)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("内容")
TextField("今天怎么样?", text: $content, axis: .vertical)
.lineLimit(4...10)
.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)
)
// (2×2):()/ (MedicationLogSheet,+)/
// ()/ (SymptomStartSheet)
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
GridItem(.flexible(), spacing: 10)], spacing: 10) {
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true
}
VStack(alignment: .leading, spacing: 8) {
sectionLabel("时间")
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
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)
Spacer(minLength: 12)
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel(String(appLoc: "内容"))
Spacer()
if SpeechDictationService.isAvailable, voicePhase == .idle {
Button(action: startVoice) {
HStack(spacing: 4) {
Image(systemName: "mic.fill")
.font(.tjScaled(11, weight: .semibold))
Text("说一段")
.font(.tjScaled(12, weight: .semibold))
}
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().strokeBorder(
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(isLoading) // AI AIRuntime
}
}
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
text: $content, axis: .vertical)
.lineLimit(3...8)
.focused($contentFocused)
.onChange(of: content) { _, _ in exhaustedNote = false }
.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)
)
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
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "时间"))
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
// , question
Color.clear.frame(height: 1).id("assist-bottom")
}
.padding(.horizontal, 20)
.padding(.bottom, 6)
}
.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) {
Button("取消") { dismiss() }
@@ -76,19 +254,619 @@ struct DiaryQuickSheet: View {
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDetents([.medium, .large], selection: $detent)
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow(
onSave: { meds, images in
// (), ·
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
dismiss()
},
onClose: { showMedicationScan = false }
)
}
.sheet(isPresented: $showSymptomStart) {
// sheet:/;,
SymptomStartSheet()
}
.sheet(isPresented: $showMedicationLog) {
// sheet:/;()
MedicationLogSheet()
}
.onDisappear {
suggestTask?.cancel()
voiceFlowTask?.cancel()
recordingWatchdog?.cancel()
dictation.abort()
}
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
Button(String(appLoc: "前往设置")) {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button(String(appLoc: "取消"), role: .cancel) {}
} message: {
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
}
}
// MARK: - AI
@ViewBuilder
private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) {
// section header
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
Spacer()
if hasQuestions {
Text("\(questions.count) 个建议")
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
// questions (,)
if hasQuestions {
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)
}
}
AIDisclaimerFooter()
}
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,
prominent: true,
action: requestSuggestions
)
case .loading:
assistLoadingIndicator
case .ready:
assistPrimaryButton(
icon: "arrow.clockwise",
label: canRequestSuggest
? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
: String(appLoc: "更新一下原文,再让 AI 继续追问"),
enabled: canRequestSuggest,
action: requestSuggestions
)
case .failed(let err):
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
Button { requestSuggestions() } label: {
Text("重试")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(.plain)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
}
/// `prominent` ( brick + + ,),
/// ( .ready )
private func assistPrimaryButton(icon: String,
label: String,
enabled: Bool,
prominent: Bool = false,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon)
Text(label)
}
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
.foregroundStyle(prominent
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
.frame(maxWidth: .infinity)
.padding(.vertical, prominent ? 14 : 11)
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
// : contentShape (+)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
@ViewBuilder
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
if prominent {
shape
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
radius: 8, x: 0, y: 3)
} else {
shape
.strokeBorder(
enabled ? Tj.Palette.ink : Tj.Palette.line,
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
)
}
}
/// .loading : paper ,(Linear/Vercel )
/// ,;线 + sparkles
private var assistLoadingIndicator: some View {
HStack(spacing: 10) {
Image(systemName: "sparkles")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.pulse, options: .repeating)
Text(lastRate > 0
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
: String(appLoc: "AI 生成中 · 本地推理"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button("取消") { cancelSuggestions() }
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 11)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.overlay(alignment: .bottom) {
AIFlowBar().padding(.horizontal, 1)
}
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
/// questions list idx question, round (1-based)
private func roundLocalIndex(at idx: Int) -> Int {
let target = questions[idx].round
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
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
/// ( / / )active
/// : iPhone
private func modeCard(icon: String, title: String, subtitle: String,
active: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 5) {
Image(systemName: icon)
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(active ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 28, height: 28)
.background(Circle().fill(active ? Tj.Palette.ink : Tj.Palette.sand2))
Text(title)
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(subtitle)
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(active ? Tj.Palette.ink : Tj.Palette.line,
lineWidth: active ? 1.5 : 1)
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK:
private func startVoice() {
contentFocused = false
voiceNote = nil
voiceFlowTask = Task { @MainActor in
guard await dictation.requestAuthorization() else {
voiceDeniedAlert = true
return
}
do {
liveTranscript = ""
recordingSeconds = 0
try dictation.start { partial in liveTranscript = partial }
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
// + 3 (,)
recordingWatchdog = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled, voicePhase == .recording else { return }
recordingSeconds += 1
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
stopVoiceAndOrganize()
return
}
}
}
} catch {
voiceNote = error.localizedDescription
voicePhase = .idle
}
}
}
private func stopVoiceAndOrganize() {
guard voicePhase == .recording else { return }
recordingWatchdog?.cancel()
voiceFlowTask = Task { @MainActor in
// :(/),
// @State
var transcript = (await dictation.stop())
.trimmingCharacters(in: .whitespacesAndNewlines)
if transcript.isEmpty {
transcript = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
}
liveTranscript = transcript
guard !transcript.isEmpty else {
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
voiceNote = String(appLoc: "没听清,再试一次")
return
}
rawTranscript = transcript
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
do {
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
guard !Task.isCancelled else { return }
appendToContent(result.text)
organizedAppended = result.text
lastRate = result.decodeRate
} catch is CancellationError {
// cancelOrganize 退,
} catch {
guard !Task.isCancelled else { return }
appendToContent(transcript) // 线 #5:退,
organizedAppended = nil
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
}
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
}
}
/// : LLM,(退)
private func cancelOrganize() {
guard voicePhase == .organizing else { return }
voiceFlowTask?.cancel()
if let raw = rawTranscript {
appendToContent(raw)
organizedAppended = nil
voiceNote = String(appLoc: "已取消整理,填入原话")
}
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
}
/// :稿稿(spec §2:LLM )
private func revertToRawTranscript() {
guard let raw = rawTranscript,
let organized = organizedAppended,
let range = content.range(of: organized, options: .backwards) else { return }
withAnimation(.snappy(duration: 0.18)) {
content = content.replacingCharacters(in: range, with: raw)
organizedAppended = nil
}
}
/// AI (coveredDims) LLM,
/// ,
private func requestSuggestions() {
suggestTask?.cancel()
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims)
// 1.
contentFocused = false
// 2. sheet large( medium AI)
if detent != .large {
withAnimation(.snappy(duration: 0.25)) {
detent = .large
}
}
exhaustedNote = false
phase = .loading
suggestTask = Task { @MainActor in
do {
let result = try await DiaryAssistService.shared.suggest(
content: snapshotContent,
coveredDimensions: covered
)
if Task.isCancelled { return }
// ( 1.7B ):
// ; ;
let coveredSnapshot = coveredDims
var acceptedNorms = questions.map { Self.normalize($0.q) }
var batchDims = Set<String>()
let nextRound = currentRound + 1
let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
let norm = Self.normalize(q.q)
if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
if !dim.isEmpty, batchDims.contains(dim) { return nil }
if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
if !dim.isEmpty { batchDims.insert(dim) }
acceptedNorms.append(norm)
var stamped = q
stamped.round = nextRound
return stamped
}
withAnimation(.snappy(duration: 0.2)) {
if fresh.isEmpty {
exhaustedNote = true //
} else {
questions.append(contentsOf: fresh)
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
currentRound = nextRound
exhaustedNote = false
}
lastRate = result.decodeRate
phase = .ready
}
} catch is CancellationError {
if !Task.isCancelled {
phase = hasQuestions ? .ready : .idle
}
} catch {
if !Task.isCancelled {
phase = .failed(error)
}
}
}
}
/// : + ,
private static func normalize(_ s: String) -> String {
s.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "?", with: "?")
}
/// :, Jaccard 0.8(/)
private static func isSimilar(_ a: String, _ b: String) -> Bool {
if a == b { return true }
let sa = Set(a), sb = Set(b)
guard !sa.isEmpty, !sb.isEmpty else { return false }
let inter = sa.intersection(sb).count
let union = sa.union(sb).count
return union > 0 && Double(inter) / Double(union) >= 0.8
}
private func cancelSuggestions() {
suggestTask?.cancel()
phase = hasQuestions ? .ready : .idle
}
/// : `[]` ;( adopted)
/// q ; coveredDims, prompt
private func adopt(_ question: DiaryAssistService.Question) {
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
// :( fill 退)
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
return
}
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 }) {
withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true
}
}
appendToContent(text)
fillingId = nil
fillValues = []
}
/// (,)
private func appendToContent(_ text: String) {
let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !toAppend.isEmpty else { return }
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
content = toAppend
} else if content.hasSuffix("\n") {
content += toAppend
} else {
content += "\n" + toAppend
}
}
private func submit() {
guard canSubmit else { return }
let entry = DiaryEntry(
@@ -100,3 +878,7 @@ struct DiaryQuickSheet: View {
dismiss()
}
}
#Preview {
DiaryQuickSheet()
}

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

View File

@@ -0,0 +1,235 @@
import SwiftUI
/// AI ( [] ,):
enum FillSegment: Equatable {
case literal(String)
/// `label` ( "" / "/");
/// `options` (`/` ,)
case slot(label: String, options: [String])
}
/// `fill` ,便
enum DiaryFillTemplate {
/// `.literal`
static func parse(_ template: String) -> [FillSegment] {
let chars = Array(template)
var segs: [FillSegment] = []
var i = 0
var literalStart = 0
func flushLiteral(upTo end: Int) {
if end > literalStart { segs.append(.literal(String(chars[literalStart..<end]))) }
}
while i < chars.count {
if chars[i] == "[",
let close = (i + 1 ..< chars.count).first(where: { chars[$0] == "]" }) {
flushLiteral(upTo: i)
let inner = String(chars[(i + 1)..<close])
segs.append(.slot(label: inner, options: options(from: inner)))
i = close + 1
literalStart = i
} else {
i += 1
}
}
flushLiteral(upTo: chars.count)
return segs
}
/// `/` (5 ) 2 ,
private static func options(from inner: String) -> [String] {
let tokens = inner.split(separator: "/")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
guard tokens.count >= 2, tokens.allSatisfy({ $0.count <= 5 }) else { return [] }
return tokens
}
///
static func slotCount(_ template: String) -> Int {
parse(template).reduce(0) { acc, seg in
if case .slot = seg { return acc + 1 }
return acc
}
}
/// `values` :,退(,)
static func assemble(_ template: String, values: [String]) -> String {
var out = ""
var idx = 0
for seg in parse(template) {
switch seg {
case .literal(let t):
out += t
case .slot(let label, _):
let v = idx < values.count
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
out += v.isEmpty ? label : v
idx += 1
}
}
return out
}
}
/// : `[]` + chip,,
/// / ****
struct QuestionFillPanel: View {
let template: String
@Binding var values: [String]
let onCommit: (String) -> Void
let onCancel: () -> Void
private var segments: [FillSegment] { DiaryFillTemplate.parse(template) }
/// + values
private var slots: [(index: Int, label: String, options: [String])] {
var result: [(Int, String, [String])] = []
var i = 0
for seg in segments {
if case let .slot(label, options) = seg {
result.append((i, label, options))
i += 1
}
}
return result
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
// :,线
previewText
.font(.tjScaled( 13))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
ForEach(slots, id: \.index) { slot in
slotEditor(index: slot.index, label: slot.label, options: slot.options)
}
HStack(spacing: 8) {
Button(action: onCancel) {
Text("取消")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
// :.plain ,
// contentShape
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
onCommit(DiaryFillTemplate.assemble(template, values: values))
} label: {
HStack(spacing: 5) {
Image(systemName: "text.append")
.font(.tjScaled( 12, weight: .semibold))
Text("加入记录")
.font(.tjScaled( 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.ink)
)
}
.buttonStyle(.plain)
}
}
.padding(.leading, 22)
.padding(.top, 2)
}
// MARK: -
/// :literal , brick ,线
private var previewText: Text {
var result = Text("")
var idx = 0
for seg in segments {
switch seg {
case .literal(let t):
result = result + Text(t).foregroundStyle(Tj.Palette.text)
case .slot(let label, _):
let v = idx < values.count
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
if v.isEmpty {
result = result + Text(label).foregroundStyle(Tj.Palette.text3).underline()
} else {
result = result + Text(v).foregroundStyle(Tj.Palette.brick).fontWeight(.semibold)
}
idx += 1
}
}
return result
}
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
if !options.isEmpty {
HStack(spacing: 6) {
ForEach(options, id: \.self) { opt in
let picked = bindingValue(index) == opt
Button { values[index] = opt } label: {
Text(opt)
.font(.tjScaled( 12, weight: picked ? .semibold : .regular))
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Capsule().fill(picked ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
Capsule().strokeBorder(Tj.Palette.line,
lineWidth: picked ? 0 : 1)
)
}
.buttonStyle(.plain)
}
Spacer(minLength: 0)
}
}
TextField(String(appLoc: "填写\(label)"), text: binding(index))
.font(.tjScaled( 13))
.padding(.horizontal, 12)
.padding(.vertical, 9)
.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 func bindingValue(_ i: Int) -> String {
i < values.count ? values[i] : ""
}
private func binding(_ i: Int) -> Binding<String> {
Binding(
get: { i < values.count ? values[i] : "" },
set: { if i < values.count { values[i] = $0 } }
)
}
}

View File

@@ -0,0 +1,176 @@
import SwiftUI
import SwiftData
/// : +
/// CalendarOverviewView / @Query( TodayRemindersCard)
struct HomeCalendarCard: View {
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@Query(sort: \Report.reportDate, order: .reverse)
private var reports: [Report]
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
private var diaries: [DiaryEntry]
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
/// (nil = )
@State private var openDay: SelectedDay?
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale.current
return c
}()
@MainActor
private var data: CalendarData {
CalendarData.build(
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms
)
}
///
private var weekDays: [Date] {
let today = calendar.startOfDay(for: .now)
let weekdayIndex = (calendar.component(.weekday, from: today) - calendar.firstWeekday + 7) % 7
guard let monday = calendar.date(byAdding: .day, value: -weekdayIndex, to: today) else {
return []
}
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
}
/// (///)
private var daysWithRecordsThisMonth: Int {
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
var n = 0
for i in 0..<count {
guard let d = calendar.date(byAdding: .day, value: i, to: interval.start) else { continue }
if data.marks(for: d, calendar: calendar).hasAnyEvent ||
!data.ranges(touching: d, calendar: calendar).isEmpty {
n += 1
}
}
return n
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
weekStrip
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard(bordered: true)
.padding(.bottom, 18)
.contentShape(Rectangle())
.onTapGesture { openDay = SelectedDay(date: .now) }
.fullScreenCover(item: $openDay) { day in
CalendarOverviewView(initialDate: day.date, onClose: { openDay = nil })
}
}
private var header: some View {
HStack(alignment: .firstTextBaseline) {
Text("健康日历")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
HStack(spacing: 3) {
Text(summaryLine)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Image(systemName: "chevron.right")
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private var summaryLine: String {
let n = daysWithRecordsThisMonth
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
}
private var weekStrip: some View {
HStack(spacing: 6) {
ForEach(weekDays, id: \.self) { day in
dayCell(day)
}
}
}
private func dayCell(_ day: Date) -> some View {
let marks = data.marks(for: day, calendar: calendar)
let ranges = data.ranges(touching: day, calendar: calendar)
let isToday = calendar.isDateInToday(day)
let hasSymptom = !ranges.isEmpty
return Button {
openDay = SelectedDay(date: day)
} label: {
VStack(spacing: 5) {
Text(weekdayLabel(day))
.font(.tjScaled( 10, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
ZStack {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(cellFill(isToday: isToday, hasSymptom: hasSymptom))
if isToday {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.strokeBorder(Tj.Palette.ink, lineWidth: 1.2)
}
Text("\(calendar.component(.day, from: day))")
.font(.tjScaled( 14, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? Tj.Palette.ink : Tj.Palette.text)
}
.frame(height: 38)
marksDots(marks)
.frame(height: 5)
}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private func marksDots(_ marks: DayMarks) -> some View {
HStack(spacing: 2) {
if marks.abnormalCount > 0 {
dot(Tj.Palette.brick)
} else if marks.normalCount > 0 {
dot(Tj.Palette.leaf)
}
if marks.reportCount > 0 { dot(Tj.Palette.ink2) }
if marks.diaryCount > 0 { dot(Tj.Palette.text3.opacity(0.7)) }
}
}
private func dot(_ color: Color) -> some View {
Circle().fill(color).frame(width: 4, height: 4)
}
private func cellFill(isToday: Bool, hasSymptom: Bool) -> Color {
if hasSymptom { return Tj.Palette.amber.opacity(0.18) }
if isToday { return Tj.Palette.sand2 }
return Tj.Palette.sand2.opacity(0.5)
}
private func weekdayLabel(_ day: Date) -> String {
let labels = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: "")
]
let idx = (calendar.component(.weekday, from: day) - calendar.firstWeekday + 7) % 7
return labels[idx]
}
}

View File

@@ -16,20 +16,21 @@ struct HomeView: View {
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
/// sheet( C1 )
@State private var selectedEntry: TimelineEntry?
/// ( + , C1 )
@State private var selectedGroup: IndicatorGroup?
@MainActor
private var recentEntries: [TimelineEntry] {
let all =
TimelineEntry.from(indicators: indicators) +
TimelineEntry.aggregatedIndicators(indicators) +
reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:))
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
}
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] {
TimelineGrouping.group(recentEntries)
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
@@ -37,6 +38,10 @@ struct HomeView: View {
.padding(.top, 4)
.padding(.bottom, 18)
HomeCalendarCard()
TodayRemindersCard()
OngoingSymptomsCard()
.padding(.bottom, 18)
@@ -49,13 +54,25 @@ struct HomeView: View {
.padding(.bottom, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.sheet(item: $selectedEntry) { entry in
if let d = TimelineDetail.resolve(
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) {
TimelineEntryDetailView(detail: d)
}
}
.sheet(item: $selectedGroup) { group in
IndicatorSeriesDetailView(group: group)
}
}
private var greeting: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(todayLine)
.font(.system(size: 12))
.font(.tjScaled( 12))
.tracking(1)
.foregroundStyle(Tj.Palette.text3)
Text(greetingWord)
@@ -69,46 +86,64 @@ struct HomeView: View {
}
private var todayLine: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日 · EEE"
return f.string(from: Date())
let now = Date()
let day = now.formatted(.dateTime.month().day())
let weekday = now.formatted(.dateTime.weekday(.abbreviated))
return "\(day) · \(weekday)"
}
private var greetingWord: String {
switch Calendar.current.component(.hour, from: Date()) {
case 5..<12: return "早安"
case 12..<18: return "下午好"
default: return "晚上好"
case 5..<12: return String(appLoc: "早安")
case 12..<18: return String(appLoc: "下午好")
default: return String(appLoc: "晚上好")
}
}
private var recentSection: some View {
VStack(alignment: .leading, spacing: 10) {
// ( O(m²)) body ,, .isEmpty
let entries = recentEntries
let groups = TimelineGrouping.group(entries)
return VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .lastTextBaseline) {
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
Button(action: onTapArchive) {
Text("全部 ")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
if recentEntries.isEmpty {
if entries.isEmpty {
emptyRecent
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(recentGrouped, id: \.section) { group in
ForEach(groups, id: \.section) { group in
VStack(alignment: .leading, spacing: 8) {
Text(group.section.label)
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
VStack(spacing: 10) {
ForEach(group.items) { entry in
TimelineRow(entry: entry)
Button {
// ( + ); C1
guard let d = TimelineDetail.resolve(
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) else { return }
switch d {
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
default: selectedEntry = entry
}
} label: {
TimelineRow(entry: entry)
}
.buttonStyle(.plain)
}
}
}
@@ -121,7 +156,7 @@ struct HomeView: View {
private var emptyRecent: some View {
HStack {
Text("还没有任何记录,点底部 + 号开始第一条")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
@@ -136,19 +171,19 @@ struct HomeView: View {
Button(action: onTapArchive) {
HStack(spacing: 14) {
TjPlaceholder(label: "档案 · \(reports.count)")
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text("我的报告档案")
.font(.system(size: 14, weight: .semibold))
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)

View File

@@ -34,12 +34,12 @@ struct RecentItemRow: View {
VStack(alignment: .leading, spacing: 2) {
Text("\(date) · \(type)")
.font(.system(size: 11))
.font(.tjScaled( 11))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
Text(name)
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
.truncationMode(.tail)
@@ -47,7 +47,7 @@ struct RecentItemRow: View {
Spacer(minLength: 8)
if let value {
Text(value)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.font(.tjScaled( 12, weight: .semibold, design: .monospaced))
.foregroundStyle(status.valueColor)
.lineLimit(1)
.fixedSize()

View File

@@ -0,0 +1,118 @@
import SwiftUI
import SwiftData
import Combine
/// :(CustomReminder)+ (MetricReminder),
/// ;(,)
/// ( EmptyView,)
/// ; (RemindersListView)
struct TodayRemindersCard: View {
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
private var customReminders: [CustomReminder]
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var metricReminders: [MetricReminder]
@State private var showingCenter = false
/// ,( OngoingSymptomsCard )
@State private var tick: Date = .now
private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
/// , + ,
private var items: [TodayItem] {
let cal = Calendar.current
var arr: [TodayItem] = []
for r in customReminders where r.occurs(on: tick, calendar: cal) {
arr.append(TodayItem(id: "c-\(r.id.uuidString)",
hour: r.hour, minute: r.minute, title: r.title))
}
for r in metricReminders where r.occurs(on: tick, calendar: cal) {
arr.append(TodayItem(id: "m-\(r.metricId)",
hour: r.hour, minute: r.minute, title: r.displayName))
}
return arr.sorted { ($0.hour, $0.minute) < ($1.hour, $1.minute) }
}
var body: some View {
let rows = items
if rows.isEmpty {
EmptyView()
} else {
VStack(alignment: .leading, spacing: 10) {
header(count: rows.count)
VStack(spacing: 8) {
ForEach(rows) { row($0) }
}
}
.padding(.bottom, 18)
.onReceive(timer) { now in tick = now }
.sheet(isPresented: $showingCenter) {
// NavigationStack ;sheet
NavigationStack { RemindersListView(presentedAsSheet: true) }
}
}
}
private func header(count: Int) -> some View {
HStack(spacing: 8) {
Circle()
.fill(Tj.Palette.amber)
.frame(width: 7, height: 7)
Text("今日提醒")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(count)")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button { showingCenter = true } label: {
Text("全部 ")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
private func row(_ item: TodayItem) -> some View {
let isPast = item.isPast(now: tick)
return HStack(spacing: 12) {
Text(item.timeLabel)
.font(.tjScaled( 14, weight: .semibold).monospacedDigit())
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
.frame(width: 46, alignment: .leading)
Image(systemName: "bell.fill")
.font(.tjScaled( 12))
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
Text(item.title)
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
radius: 2, x: 0, y: 1)
}
}
/// ()
private struct TodayItem: Identifiable {
let id: String
let hour: Int
let minute: Int
let title: String
var timeLabel: String { String(format: "%02d:%02d", hour, minute) }
/// ()
func isPast(now: Date) -> Bool {
let c = Calendar.current.dateComponents([.hour, .minute], from: now)
let nowMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0)
return hour * 60 + minute < nowMinutes
}
}

View File

@@ -21,8 +21,8 @@ enum CustomMetricNameConflict: Equatable {
var warningText: String {
switch self {
case .none: return ""
case .builtin(let n): return "\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块"
case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标"
case .builtin(let n): return String(appLoc: "\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块")
case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(n)」的自定义指标")
}
}
}
@@ -125,7 +125,7 @@ struct CustomMetricEditor: View {
Spacer()
if existing == nil {
Text("保存后会出现在录入选项里")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -133,7 +133,7 @@ struct CustomMetricEditor: View {
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("名称")
sectionLabel(String(appLoc: "名称"))
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
.padding(.horizontal, 14).padding(.vertical, 12)
.background(fieldBg)
@@ -147,10 +147,10 @@ struct CustomMetricEditor: View {
if nameConflict != .none {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
Text(nameConflict.warningText)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
@@ -161,7 +161,7 @@ struct CustomMetricEditor: View {
private var unitSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("单位(可选)")
sectionLabel(String(appLoc: "单位(可选)"))
TextField("例如:cm / 步 / 小时", text: $unit)
.autocorrectionDisabled()
.padding(.horizontal, 14).padding(.vertical, 12)
@@ -172,26 +172,26 @@ struct CustomMetricEditor: View {
private var rangeRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("参考范围(可选)")
sectionLabel(String(appLoc: "参考范围(可选)"))
Spacer()
Text("用于自动判定 正常/偏高/偏低")
.font(.system(size: 10))
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 12) {
rangeField(label: "下限", value: $lower, placeholder: "70")
rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70")
Text("").foregroundStyle(Tj.Palette.text3)
rangeField(label: "上限", value: $upper, placeholder: "90")
rangeField(label: String(appLoc: "上限"), value: $upper, placeholder: "90")
}
}
}
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
TextField(placeholder, text: value)
.keyboardType(.decimalPad)
.font(.system(size: 16, weight: .medium, design: .monospaced))
.font(.tjScaled( 16, weight: .medium, design: .monospaced))
.padding(.horizontal, 12).padding(.vertical, 10)
.background(fieldBg).overlay(fieldBorder)
}
@@ -199,7 +199,7 @@ struct CustomMetricEditor: View {
private var iconSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("图标")
sectionLabel(String(appLoc: "图标"))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
spacing: 8) {
ForEach(customMetricIconChoices, id: \.self) { sf in
@@ -207,7 +207,7 @@ struct CustomMetricEditor: View {
icon = sf
} label: {
Image(systemName: sf)
.font(.system(size: 20, weight: .medium))
.font(.tjScaled( 20, weight: .medium))
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
.frame(maxWidth: .infinity, minHeight: 44)
.background(
@@ -239,7 +239,7 @@ struct CustomMetricEditor: View {
Image(systemName: "trash")
Text("删除这项自定义指标")
}
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
@@ -282,7 +282,7 @@ struct CustomMetricEditor: View {
.strokeBorder(Tj.Palette.line, lineWidth: 1)
}
private func sectionLabel(_ t: String) -> some View {
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
Text(t).font(.tjScaled( 12, weight: .semibold)).tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}

View File

@@ -27,6 +27,22 @@ private let labPresets: [IndicatorPreset] = [
/// seriesKey, Trends
/// 3. **** name/value/unit/range ,status
struct IndicatorQuickSheet: View {
/// RootView : QuickRegionCaptureFlow(VL)
/// nil ( Preview)
var onRequestCamera: (() -> Void)? = nil
/// nil =
/// seriesKey MonitorMetric / CustomMonitorMetric ( + );
/// name/unit/range ,
var prefill: Prefill? = nil
struct Prefill: Equatable {
var seriesKey: String?
var name: String = ""
var unit: String = ""
var range: String = ""
}
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query private var profiles: [UserProfile]
@@ -65,6 +81,32 @@ struct IndicatorQuickSheet: View {
// sheet
@State private var showHiddenSheet: Bool = false
//
@State private var didApplyPrefill = false
// :, / /
@State private var searchingMetrics = false
@State private var metricQuery = ""
private var isSearchingMetrics: Bool {
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
}
private var filteredMonitorMetrics: [MonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return visibleMonitorMetrics }
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
}
private var filteredCustomMetrics: [CustomMonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return customMetrics }
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private var filteredLabPresets: [IndicatorPreset] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return labPresets }
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private static var defaultReminderTime: Date {
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
}
@@ -103,6 +145,7 @@ struct IndicatorQuickSheet: View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
cameraEntrySection
monitorGridSection
labPresetSection
Divider().padding(.vertical, 4)
@@ -132,12 +175,14 @@ struct IndicatorQuickSheet: View {
footer
}
.onAppear { applyPrefillIfNeeded() }
.task(id: longTermKey) { hydrateReminder() }
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.preferredColorScheme(.light)
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
@@ -155,23 +200,124 @@ struct IndicatorQuickSheet: View {
}
private var header: some View {
HStack {
Text("记录指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
VStack(spacing: 12) {
HStack(spacing: 10) {
Text("记录指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
searchToggle
}
if searchingMetrics { searchField }
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searchingMetrics.toggle()
if !searchingMetrics { metricQuery = "" }
}
} label: {
Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searchingMetrics ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标名"), text: $metricQuery)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !metricQuery.isEmpty {
Button { metricQuery = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(fieldBg)
.overlay(fieldBorder)
}
/// : RootView VL
@ViewBuilder
private var cameraEntrySection: some View {
if let onRequestCamera {
VStack(alignment: .leading, spacing: 10) {
Button {
onRequestCamera()
} label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick)
Image(systemName: "camera.fill")
.font(.tjScaled(18, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("拍照识别")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("拍化验单,VL 自动读出数值")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled(14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.frame(maxWidth: .infinity)
.tjCard(bordered: true)
}
.buttonStyle(.plain)
HStack(spacing: 8) {
line
Text("或手动填写")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
.fixedSize()
line
}
}
}
}
private var line: some View {
Rectangle()
.fill(Tj.Palette.lineSoft)
.frame(height: 1)
.frame(maxWidth: .infinity)
}
private var monitorGridSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("长期监测(进趋势)")
sectionLabel(String(appLoc: "长期监测(进趋势)"))
Spacer()
if !hiddenSet.isEmpty {
hiddenCountChip
@@ -179,13 +325,19 @@ struct IndicatorQuickSheet: View {
}
let columns = [GridItem(.flexible()), GridItem(.flexible())]
LazyVGrid(columns: columns, spacing: 8) {
ForEach(visibleMonitorMetrics) { m in
ForEach(filteredMonitorMetrics) { m in
monitorTile(m)
}
ForEach(customMetrics) { cm in
ForEach(filteredCustomMetrics) { cm in
customTile(cm)
}
addCustomTile
// (),
if !isSearchingMetrics { addCustomTile }
}
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
Text("没有匹配的长期监测指标")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
.sheet(isPresented: $showHiddenSheet) {
@@ -217,18 +369,18 @@ struct IndicatorQuickSheet: View {
} label: {
HStack(spacing: 10) {
Image(systemName: cm.icon)
.font(.system(size: 18, weight: .medium))
.font(.tjScaled( 18, weight: .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
VStack(alignment: .leading, spacing: 1) {
Text(cm.name)
.font(.system(size: 14, weight: selected ? .semibold : .medium))
.font(.tjScaled( 14, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.lineLimit(1)
Text("自定义")
.font(.system(size: 9, design: .monospaced))
.font(.tjScaled( 9, design: .monospaced))
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
}
Spacer()
@@ -246,13 +398,10 @@ struct IndicatorQuickSheet: View {
}
.buttonStyle(.plain)
.contextMenu {
// :()
// action , trash/,,
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) {
editingCustom = CustomMetricEditTarget(metric: cm)
} label: {
Label("编辑/删除", systemImage: "trash")
Label("编辑 / 删除", systemImage: "pencil")
}
}
}
@@ -263,14 +412,14 @@ struct IndicatorQuickSheet: View {
} label: {
HStack(spacing: 10) {
Image(systemName: "plus")
.font(.system(size: 18, weight: .semibold))
.font(.tjScaled( 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(width: 32, height: 32)
.background(
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
)
Text("自定义")
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer()
}
@@ -296,13 +445,13 @@ struct IndicatorQuickSheet: View {
} label: {
HStack(spacing: 10) {
Image(systemName: m.icon)
.font(.system(size: 18, weight: .medium))
.font(.tjScaled( 18, weight: .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
Text(m.displayName)
.font(.system(size: 14, weight: selected ? .semibold : .medium))
.font(.tjScaled( 14, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
Spacer()
}
@@ -327,14 +476,18 @@ struct IndicatorQuickSheet: View {
}
}
@ViewBuilder
private var labPresetSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("化验项快捷(不进趋势)")
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(labPresets) { p in
chip(p.name, selected: selectedLabPreset == p) {
applyLab(p)
// :()
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(filteredLabPresets) { p in
chip(p.name, selected: selectedLabPreset == p) {
applyLab(p)
}
}
}
}
@@ -345,14 +498,14 @@ struct IndicatorQuickSheet: View {
private var bpFieldSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
sectionLabel("收缩 / 舒张")
sectionLabel(String(appLoc: "收缩 / 舒张"))
Spacer()
bpRangeHint
}
HStack(spacing: 12) {
bpField(label: "收缩压", value: $systolic, placeholder: "120")
Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
bpField(label: "舒张压", value: $diastolic, placeholder: "80")
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
Text("/").font(.tjScaled( 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
Text("mmHg").foregroundStyle(Tj.Palette.text3)
}
bpStatusChips
@@ -361,10 +514,12 @@ struct IndicatorQuickSheet: View {
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
TextField(placeholder, text: value)
.keyboardType(.decimalPad)
.font(.system(size: 20, weight: .semibold, design: .monospaced))
.font(.tjScaled( 20, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.multilineTextAlignment(.center)
.padding(.vertical, 10)
.frame(width: 90)
@@ -383,11 +538,11 @@ struct IndicatorQuickSheet: View {
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
return HStack(spacing: 4) {
Text(rangeText)
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
if personalized, let age = profile?.age {
Text("· 按\(age)岁调整")
.font(.system(size: 10))
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.amber)
}
}
@@ -396,10 +551,10 @@ struct IndicatorQuickSheet: View {
private var bpStatusChips: some View {
HStack(spacing: 8) {
if let s = computedBPStatus(.systolic) {
statusBadge("收缩 " + s.label, color: s.color)
statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color)
}
if let s = computedBPStatus(.diastolic) {
statusBadge("舒张 " + s.label, color: s.color)
statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color)
}
Spacer()
}
@@ -407,9 +562,11 @@ struct IndicatorQuickSheet: View {
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("指标名")
sectionLabel(String(appLoc: "指标名"))
TextField("例如:血红蛋白", text: $name)
.textInputAutocapitalization(.never)
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
@@ -427,20 +584,24 @@ struct IndicatorQuickSheet: View {
private var valueRow: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("数值")
sectionLabel(String(appLoc: "数值"))
TextField(monitorFieldPlaceholder, text: $value)
.keyboardType(.decimalPad)
.font(.system(size: 18, weight: .semibold, design: .monospaced))
.font(.tjScaled( 18, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
}
VStack(alignment: .leading, spacing: 8) {
sectionLabel("单位")
sectionLabel(String(appLoc: "单位"))
TextField("mmol/L", text: $unit)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
@@ -455,7 +616,7 @@ struct IndicatorQuickSheet: View {
private var rangeSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("参考范围")
sectionLabel(String(appLoc: "参考范围"))
Spacer()
if let m = selectedMonitor, m != .bloodPressure {
monitorRangeHint(m)
@@ -464,6 +625,8 @@ struct IndicatorQuickSheet: View {
TextField("例如:< 3.40 或 3.9 - 6.1", text: $range)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
@@ -478,7 +641,7 @@ struct IndicatorQuickSheet: View {
return HStack(spacing: 4) {
if personalized, let age = profile?.age {
Text("\(age)岁调整")
.font(.system(size: 10))
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.amber)
}
}
@@ -486,11 +649,11 @@ struct IndicatorQuickSheet: View {
private var statusSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("状态")
sectionLabel(String(appLoc: "状态"))
HStack(spacing: 8) {
statusChip(.normal, label: "正常", color: Tj.Palette.leaf)
statusChip(.high, label: "偏高 ↑", color: Tj.Palette.brick)
statusChip(.low, label: "偏低 ↓", color: Tj.Palette.amber)
statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf)
statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick)
statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber)
}
}
}
@@ -498,12 +661,12 @@ struct IndicatorQuickSheet: View {
private var autoStatusHint: some View {
let auto = computedSingleStatus
return HStack(spacing: 8) {
sectionLabel("状态(按数值自动判)")
sectionLabel(String(appLoc: "状态(按数值自动判)"))
if let s = auto {
statusBadge(s.label, color: s.color)
} else {
Text("待输入")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -511,7 +674,7 @@ struct IndicatorQuickSheet: View {
private var timeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("测量时间")
sectionLabel(String(appLoc: "测量时间"))
DatePicker("", selection: $capturedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
@@ -520,9 +683,11 @@ struct IndicatorQuickSheet: View {
private var noteSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("备注(可选)")
sectionLabel(String(appLoc: "备注(可选)"))
TextField("例如:空腹采血", text: $note, axis: .vertical)
.lineLimit(1...3)
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
@@ -535,7 +700,7 @@ struct IndicatorQuickSheet: View {
private var reminderSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("周期提醒")
sectionLabel(String(appLoc: "周期提醒"))
Spacer()
Toggle("", isOn: $reminderEnabled)
.labelsHidden()
@@ -549,7 +714,7 @@ struct IndicatorQuickSheet: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("时间")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
DatePicker("", selection: $reminderTime,
@@ -561,22 +726,22 @@ struct IndicatorQuickSheet: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("频率")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(reminderFrequencyLabel)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
weekdayPickerRow
HStack(spacing: 8) {
quickFreqChip("每天") {
quickFreqChip(String(appLoc: "每天")) {
reminderWeekdays = Set(1...7)
}
quickFreqChip("工作日") {
quickFreqChip(String(appLoc: "工作日")) {
reminderWeekdays = Set([2, 3, 4, 5, 6])
}
quickFreqChip("周末") {
quickFreqChip(String(appLoc: "周末")) {
reminderWeekdays = Set([1, 7])
}
}
@@ -584,11 +749,11 @@ struct IndicatorQuickSheet: View {
if notifAuthBlocked {
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.brick)
} else {
Text("本机提醒 · 不发任何数据")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -600,15 +765,23 @@ struct IndicatorQuickSheet: View {
}
private var reminderFrequencyLabel: String {
if reminderWeekdays.count == 7 { return "每天" }
if reminderWeekdays.isEmpty { return "未选" }
let names = ["", "", "", "", "", "", ""]
if reminderWeekdays.count == 7 { return String(appLoc: "每天") }
if reminderWeekdays.isEmpty { return String(appLoc: "未选") }
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let sorted = reminderWeekdays.sorted()
return "每周 " + sorted.map { names[$0 - 1] }.joined()
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
}
private var weekdayPickerRow: some View {
let names = ["", "", "", "", "", "", ""]
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // (Apple Calendar )
return HStack(spacing: 6) {
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
@@ -620,7 +793,7 @@ struct IndicatorQuickSheet: View {
}
} label: {
Text(names[idx])
.font(.system(size: 13,
.font(.tjScaled( 13,
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 32)
@@ -642,7 +815,7 @@ struct IndicatorQuickSheet: View {
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 10)
.padding(.vertical, 4)
@@ -750,7 +923,7 @@ struct IndicatorQuickSheet: View {
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
@@ -758,7 +931,7 @@ struct IndicatorQuickSheet: View {
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -774,7 +947,7 @@ struct IndicatorQuickSheet: View {
manualStatus = value
} label: {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : color)
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -787,7 +960,7 @@ struct IndicatorQuickSheet: View {
private func statusBadge(_ label: String, color: Color) -> some View {
Text(label)
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 4)
@@ -827,9 +1000,9 @@ struct IndicatorQuickSheet: View {
} label: {
HStack(spacing: 3) {
Text("已隐藏 \(hiddenSet.count)")
.font(.system(size: 11, weight: .medium))
.font(.tjScaled( 11, weight: .medium))
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.font(.tjScaled( 9, weight: .semibold))
}
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 10)
@@ -862,6 +1035,29 @@ struct IndicatorQuickSheet: View {
// MARK: - apply preset
/// :seriesKey / ( + ),
/// name/unit/range
private func applyPrefillIfNeeded() {
guard !didApplyPrefill, let p = prefill else { return }
didApplyPrefill = true
if let key = p.seriesKey {
if let m = MonitorMetric.allCases.first(where: { metric in
metric.fields.contains { $0.seriesKey == key }
}) {
applyMonitor(m)
return
}
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
applyCustom(cm)
return
}
}
// seriesKey ( / / ):, seriesKey,
name = p.name
unit = p.unit
range = p.range
}
private func applyMonitor(_ m: MonitorMetric) {
if selectedMonitor == m {
//
@@ -1074,9 +1270,9 @@ struct IndicatorQuickSheet: View {
private extension IndicatorStatus {
var label: String {
switch self {
case .normal: return "正常"
case .high: return "偏高 ↑"
case .low: return "偏低 ↓"
case .normal: return String(appLoc: "正常")
case .high: return String(appLoc: "偏高 ↑")
case .low: return String(appLoc: "偏低 ↓")
}
}
@@ -1116,7 +1312,7 @@ private struct HiddenMonitorRestoreSheet: View {
.foregroundStyle(Tj.Palette.text)
Spacer()
Button("完成") { dismiss() }
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.ink)
}
.padding(.horizontal, 20)
@@ -1141,13 +1337,13 @@ private struct HiddenMonitorRestoreSheet: View {
private func row(_ m: MonitorMetric) -> some View {
HStack(spacing: 12) {
Image(systemName: m.icon)
.font(.system(size: 16, weight: .medium))
.font(.tjScaled( 16, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
Text(m.displayName)
.font(.system(size: 15, weight: .medium))
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Spacer()
@@ -1155,7 +1351,7 @@ private struct HiddenMonitorRestoreSheet: View {
Button("显示") {
onRestore(m)
}
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 14)
.padding(.vertical, 6)

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

@@ -2,6 +2,7 @@ import SwiftUI
/// · 使
/// , Service / AIRuntime, DesignSystem token
/// App Store :/
struct AboutView: View {
/// Bundle ,
private var versionText: String {
@@ -19,52 +20,66 @@ struct AboutView: View {
VStack(spacing: 16) {
header
section(icon: "sparkles", title: "这是什么") {
section(icon: "sparkles", title: String(appLoc: "这是什么")) {
paragraph(
"康康是一款以本地优先为设计原则的个人健康影像档案工具。" +
"你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;" +
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。"
String(appLoc: "康康是一款以本地优先为设计原则的个人健康随记工具。") +
String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") +
String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。")
)
}
section(icon: "checklist", title: "主要功能") {
bullet("拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档")
bullet("通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明")
bullet("长期趋势:关注的指标可生成折线图和简要解读")
bullet("本地问答:基于你自己的档案问答,引用可点击回链到原记录")
bullet("隐私优先:健康数据不上传、无需注册账号")
section(icon: "checklist", title: String(appLoc: "主要功能")) {
bullet(String(appLoc: "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档"))
bullet(String(appLoc: "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明"))
bullet(String(appLoc: "长期趋势:关注的指标可生成折线图和简要解读"))
bullet(String(appLoc: "本地问答:基于你自己的档案问答,引用可点击回链到原记录"))
bullet(String(appLoc: "隐私优先:健康数据不上传、无需注册账号"))
}
section(icon: "lock.shield", title: "隐私保护") {
bullet("AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据")
bullet("原图与数据库采用系统级文件加密,随设备锁屏受到保护。")
bullet("支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份")
bullet("可选开启 Face ID 启动锁,进一步保护隐私。")
section(icon: "iphone", title: String(appLoc: "设备要求"), tint: Tj.Palette.leaf) {
bullet(String(appLoc: "系统:iOS 17 或更新版本"))
bullet(String(appLoc: "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存,") +
String(appLoc: "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)"))
bullet(String(appLoc: "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看,") +
String(appLoc: "但本地 AI 相关功能可能无法运行。"))
}
section(icon: "exclamationmark.triangle", title: "使用注意", tint: Tj.Palette.amber) {
bullet("本地 AI 模型体积较大(约 3GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;" +
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载")
bullet("AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对," +
"并以原始报告 / 化验单为准")
bullet("AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。")
bullet("数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。")
section(icon: "lock.shield", title: String(appLoc: "隐私保护")) {
bullet(String(appLoc: "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。"))
bullet(String(appLoc: "原图与数据库采用系统级文件加密,随设备锁屏受到保护"))
bullet(String(appLoc: "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。"))
bullet(String(appLoc: "可选开启 Face ID 启动锁,进一步保护隐私"))
}
section(icon: "hand.raised", title: "免责声明", tint: Tj.Palette.brick) {
bullet("康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。")
bullet("App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考," +
"不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。")
bullet("任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。")
bullet("如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断")
bullet("在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担")
section(icon: "exclamationmark.triangle", title: String(appLoc: "使用注意"), tint: Tj.Palette.amber) {
bullet(String(appLoc: "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;") +
String(appLoc: "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。"))
bullet(String(appLoc: "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对,") +
String(appLoc: "并以原始报告 / 化验单为准。"))
bullet(String(appLoc: "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考"))
bullet(String(appLoc: "数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件"))
}
section(icon: "hand.raised", title: String(appLoc: "免责声明"), tint: Tj.Palette.brick) {
bullet(String(appLoc: "康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。"))
bullet(String(appLoc: "App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考,") +
String(appLoc: "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。"))
bullet(String(appLoc: "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。"))
bullet(String(appLoc: "如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。"))
bullet(String(appLoc: "在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。"))
}
Text("康康 · 本地优先的健康档案 · \(versionText)")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 32)
}
.padding(.horizontal, 16)
@@ -83,7 +98,7 @@ struct AboutView: View {
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.sand2)
Image(systemName: "heart.text.square.fill")
.font(.system(size: 34))
.font(.tjScaled( 34))
.foregroundStyle(Tj.Palette.brick)
}
.frame(width: 72, height: 72)
@@ -92,8 +107,8 @@ struct AboutView: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("本地优先的个人健康影像档案")
.font(.system(size: 13))
Text("本地优先的个人健康随记")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Text(versionText)
@@ -118,10 +133,10 @@ struct AboutView: View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(tint)
Text(title)
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
content()
@@ -133,7 +148,7 @@ struct AboutView: View {
@ViewBuilder private func paragraph(_ text: String) -> some View {
Text(text)
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
@@ -146,7 +161,7 @@ struct AboutView: View {
.frame(width: 5, height: 5)
.padding(.top, 7)
Text(text)
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)

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