Compare commits

...

113 Commits

Author SHA1 Message Date
link2026
c47e62b658 fix(concurrency): 三个 enum 标 nonisolated
工程开了 SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor,这三个纯值类型
enum 会被推成 MainActor 隔离,跨 actor 使用时报错:

- CustomMetricNameConflict (CustomMetricEditor)
- SeriesKind (SeriesBucket)
- ExportTrendBuilder.Direction

已随 v1.1 (build 6) 一同编译上传。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:11:03 +08:00
link2026
0fd469f5c0 chore(release): 发布 v1.1 (build 6) 到 TestFlight
- 版本号 1.0/5 → 1.1/6:距上次发布 40 天,期间换了模型栈(Gemma 4)、
  加了云端 Gemini 与问诊转写,按功能级更新升 marketing version
- release-testflight.sh 补 -skipMacroValidation:mlx-swift-lm 3.31.4 的宏
  在 CLI 非交互环境会卡住宏信任校验,导致 archive 无法完成

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:10:53 +08:00
link2026
ef5364d8c3 ```
feat(home): 添加首页卡通形象卡片入口

- 新增 onTapCompanion 回调属性用于处理点击卡通形象卡片事件
- 在首页添加 companionCard 视图组件,显示"和康康聊聊"功能入口
- 实现卡通形象卡片样式,包含头像、标题和描述文本
- 集成 HealthChatView 全屏覆盖页面用于健康记录问答功能
- 添加相关国际化字符串支持

BREAKING CHANGE: 新增首页功能模块,改变原有界面布局结构
```
2026-07-19 19:40:02 +08:00
link2026
198570186e 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
- 修正错误描述
```
2026-07-14 13:07:15 +08:00
link2026
32180d7c0e 根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
```
docs(readme): 更新文档说明

- 添加项目使用指南
- 完善API接口说明
- 修正错误的配置示例
```
2026-07-13 18:47:11 +08:00
link2026
558682c8f8 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
```

注意:由于未提供具体的代码差异信息,以上为示例格式。请提供实际的代码变更内容以生成准确的commit message。
2026-07-01 10:30:22 +08:00
link2026
404abbf10b 根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
```
docs(readme): 更新文档说明

- 添加了项目使用说明
- 完善了配置项描述
- 修正了示例代码格式
```

注意:由于您提供的code differences部分为空,无法获取具体的代码变更信息来生成准确的commit message。请提供实际的代码差异信息以便生成更精确的提交消息。
2026-07-01 10:02:16 +08:00
link2026
e179a369f6 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
chore(config): 更新项目配置文件

- 调整开发环境配置参数
- 优化构建流程设置
- 更新依赖包版本管理
```
2026-07-01 08:03:35 +08:00
link2026
30f75dc2cd ```
feat(DiaryQuickSheet): 添加AI追问问答功能和底部协作入口

- 新增currentAnswer状态管理追问输入,添加answerFocused状态独立处理键盘避让
- 移除键盘工具条的关心条,将AI协作入口固定到底部按钮
- 添加完整的问答式追问卡片组件,支持自由回答输入和加入日记功能
- 修改prompt阶段行为,不再在正文区显示邀请横幅
- 更新recordCurrent为answerCurrent,实现问题+答案一同加入日记的逻辑
- 调整底部操作栏布局,间距和内边距优化

refactor(InferenceSettingsView): 性能自检改为内联展开模式

- 将性能自检视图从导航链接改为当前页就地展开
- 添加showSelfTest状态控制展开收起动画
- 支持ModelSelfTestView内联嵌入模式,去除外层导航和背景

chore(Localizable): 同步更新本地化字符串资源

- 添加新的UI文本:加入日记、在这儿写下你的回答、康康帮你一起填等
- 修复部分字符串位置调整和翻译映射问题
- 同步更新多语言版本的翻译内容

style(RootView): 优化记一笔标签页视觉设计

- 为记一笔标签添加语音识别角标标识
- 使用麦克风图标配合加号突出长按语音直达功能
```
2026-06-17 10:05:32 +08:00
link2026
abacf5c4f5 ```
feat(diary): 优化日记AI协助交互体验

- 添加promptBanner作为未开始协作时的醒目邀请横幅,包含圆形图标、标题和说明文字
- 重构assistSection使用switch语句处理careState不同状态,区分隐藏、prompt和其他状态
- 增加动画过渡效果消除聚焦/失焦切换时的布局跳动
- 优化thinking状态下的UI展示,添加AIFlowBar彩色呼吸条显示推理进度
- 修改requestSuggestions逻辑,进入推理时收起键盘以完整显示协作卡片

refactor(inference): 优化性能自检界面样式

- 将性能自检入口改为描边动作按钮(TjGhostButton),与引擎选择在视觉上区分开
- 调整未就绪状态下的禁用样式和提示文案

feat(localization): 添加新的本地化字符串

- 新增"追踪"和"记一笔"的多语言翻译,包括英语、日语和韩语

fix(diary): 增强AI问答解析稳定性

- 将最大token数从400提升至512,避免中文问题JSON被截断导致解析失败
- 实现salvageQuestionObjects方法作为终极兜底机制,逐个解析平衡的{...}对象
- 当外层wrapper解析失败时,仍可救回内部已闭合的问题对象,确保用户不被AI错误卡住

test(diary): 补充AI问答解析测试用例

- 添加截断对象恢复测试,验证maxTokens截断时前序完整问题的救回能力
- 添加wrapper key错误情况的恢复测试,确保模型输出格式异常时的容错性
```
2026-06-17 09:21:47 +08:00
link2026
52db6fb85a ```
feat(AI): 切换模型下载源至魔搭官方仓库

- 添加 modelScopeRepo 函数,为不同模型类型配置魔搭官方仓库ID
- 修改 fileURL 函数,主模型(MNN/MLX)优先使用魔搭官方源,
  已废弃的VL模型回退自建镜像
- 更新文档注释,说明魔搭OSS支持Range请求和断点续传特性

refactor(i18n): 优化AI相关界面文本和提示信息

- 新增数值验证相关的国际化字符串
- 调整AI整理失败提示文案,更明确表达原意
- 更新AI解读不可用提示,引导用户前往模型管理页面
- 补充删除操作的相关确认提示文本
- 添加录音权限错误提示和识别失败重试建议

chore(i18n): 清理过时的解析失败提示文本
```
2026-06-17 09:00:09 +08:00
link2026
de19d7abcd 根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
```
docs(readme): 更新文档说明

- 添加了项目使用指南
- 完善了API接口说明
- 修正了一些文字错误
```

注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。
2026-06-17 08:35:59 +08:00
link2026
b3777d508d 根据提供的信息,由于没有具体的代码差异内容,我将生成一个通用的提交消息模板:
```
chore(project): 更新项目配置文件

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:04:15 +08:00
link2026
6405733358 docs(plan): 比赛优化五件套计划 — 全部勾选(余 9.3 真机手测)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:13:24 +08:00
link2026
2e27677f80 test: 修正两处断言旧行为的存量测试(患者→我 文案、lab 段归并)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
2e90139df7 docs(AI): MNN prefix KV cache 调研 — setPrefixCacheFile 可用,建议 W6 量化后接入
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
77139f5e32 feat(Capture): 报告识别注入 Vision OCR 参考文本,提升 2B 多模态数字准确率
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
0dd60d6021 feat(Trends): AI 趋势解读上线 — 数据指纹缓存,秒开不重算
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
43cdde9bab feat(Capture): 归档后后台预生成大白话摘要,详情页秒开 + 兜底重试
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:12:48 +08:00
link2026
0a824610cf docs(plan): 勾选已完成步骤(余真机手测)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:53:25 +08:00
link2026
7e8e692695 test(语音日记): 端侧识别可用性探测冒烟测试(模拟器降级路径)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:51:53 +08:00
link2026
3f9a2af279 feat(Ask): 检索过程可视化 — RAG 命中记录以 chips 展示,生成前先看见
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:42:59 +08:00
link2026
a65c63947b feat(Me): 性能自检卡 — 后端标识 + prefill/decode 实测 + 引擎对比存档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:42:59 +08:00
link2026
8494e51823 feat(AI): 推理闸门双优先级 — 前台插队、后台按 token 让位;暴露统计与后端标签
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:42:59 +08:00
link2026
070e016f81 feat(AI): 两后端归一的 GenerateStats(prefill/decode 实测统计)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:42:59 +08:00
link2026
8c8599e77d feat(语音日记): DiaryQuickSheet 接入语音输入(录音→整理→回退原话)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:24:49 +08:00
link2026
b7e8ab33ec feat(语音日记): DiaryVoicePanel 录音/整理面板
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:13:54 +08:00
link2026
db327afd79 feat(语音日记): SpeechDictationService 端侧流式转写(不落盘音频)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:12:54 +08:00
link2026
5eb724ab86 feat(语音日记): DiaryAssistService.organize 转写稿整理
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:11:55 +08:00
link2026
cfeb25247a feat(语音日记): organize prompt(自适应样式 + 数值不可改红线)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:11:15 +08:00
link2026
26a7d53b1b feat(语音日记): 新增麦克风与语音识别权限描述(端侧识别文案)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:07:38 +08:00
link2026
e603738330 docs(plan): 语音健康日记实施计划
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:05:59 +08:00
link2026
7f0a76098a docs(spec): 语音健康日记(端侧 ASR + LLM 整理)设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 05:41:29 +08:00
link2026
b79ae54b7b ```
feat(iOS): 更新MNN后端模型配置优化性能

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

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

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

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

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

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

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

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

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

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

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

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

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

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

device BUILD SUCCEEDED,警告 0。

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

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

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

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

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

模拟器 ModelManifestTests TEST SUCCEEDED。

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

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

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

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

模拟器 BUILD SUCCEEDED,0 error。

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:00:28 +08:00
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
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
link2026
910ca99f21 feat(me,home): 模型推理自检 + 启动容错 + 首页假数据清理
- KangkangApp: ModelContainer 创建失败时重置本地 store 重建,
  避免 demo 阶段 schema 演进导致旧真机启动崩溃(注:生产需正式迁移)
- ModelSelfTestView: 正式的推理自检页(固定 prompt + 流式输出 + tok/s),
  仅当 LLM 模型就绪时从「模型管理」出现入口
- 删除 DEBUG-only 的 DebugAIRunner,自检转正为就绪后可见的正式入口
- HomeView: 删除写死的「今日摘记」假数据卡;问候改为按时段动态
  (早安/下午好/晚上好)+ 当天日期;影像档案数字接真实 @Query 计数
- MeView: 模型管理卡动态状态 + 关于页接真实版本号(用户改动一并纳入)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:02:35 +08:00
link2026
062c027c77 feat(models): 模型自动下载(我的·模型管理) + 断点续传 + 旁路导入
实现 spec(2026-05-29-model-download-design)的模型分发功能:
- ModelManifest: 硬编码功能文件清单 + base URL https://file.myv0.com/
- FileDownloader: URLSessionDataDelegate 分块写盘,HTTP Range 断点续传 + 大小校验
  (根因修复:URL.resourceValues 会缓存文件大小,续传时先读 offset 再读 finalSize
   会拿到下载前的陈旧值导致校验误判;改用 FileManager.attributesOfItem)
- ModelDownloadService: @MainActor @Observable 编排逐文件下载,聚合进度/速度,
  支持下载全部/暂停/重试,以及旁路文件导入
- ModelStore: 新增 fileURL/localBytes/isComplete(可注入清单)/importModel(补 VL)
- ModelManagementView: 分模型卡片(状态/进度/速度) + 下载全部/暂停
  + NWPathMonitor 蜂窝提示 + 从文件导入(离线兜底)
- MeView: 模型管理卡改 NavigationLink + 动态状态(已就绪/下载中/N就绪)

测试(Swift Testing): Manifest 清单/字节数、Store 路径/校验/导入、
DownloadState、FileDownloader(URLProtocol mock:下载/Range续传/大小校验)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 23:19:51 +08:00
link2026
6ccbe4ac55 docs(spec): 模型自动下载功能设计(2026-05-29)
新增「我的·模型管理」页模型下载功能设计:
- 独立 ModelDownloadService + ModelStore 保持纯存储(§3.1)
- HTTPS 断点续传(Range+追加写)、分模型卡片进度、大小校验
- 旁路文件导入兜底(补 VL)、AI 入口未就绪「前往下载」引导
- base URL https://file.myv0.com/,含精确 24 文件清单

并加 .gitignore 忽略本地模型素材目录 /Models/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:12:19 +08:00
link2026
fe80e112af docs(spec): 导出身体档案功能设计(2026-05-27)
记录 Tab 顶部入口 + 全屏 sheet,两段式本地 RAG(意图抽取 → 结构化检索 → Markdown 生成),
新增 HealthExport @Model 持久化历史。给 W3 AskService 铺路。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:03:35 +08:00
link2026
5f8f492f0e feat(indicator): 长期监测预设支持长按隐藏 + 恢复
- UserProfile 加 hiddenPresetMetrics: [String],存被隐藏的 MonitorMetric.rawValue
- IndicatorQuickSheet monitorTile 加 contextMenu 隐藏入口
- section label 右侧"已隐藏 N 个 ›"chip 触发 HiddenMonitorRestoreSheet
- 纯 UI 过滤,不动 Indicator 历史 / Trends 折线 / MetricReminder

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:47:55 +08:00
link2026
599d39af35 docs(spec): 长期监测预设支持隐藏(2026-05-26)
UserProfile 加 hiddenPresetMetrics 字段;IndicatorQuickSheet
长按 tile 出 contextMenu 隐藏,顶部 chip 显示已隐藏数 + 恢复入口。
历史数据/Trends/Reminder 全不动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:37:34 +08:00
link2026
1b01923c8e feat(capture): 统一报告捕获流程并集成视觉语言模型识别
- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程
- 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理
- 实现 AIRuntime 中 VL 模型的准备和分析功能
- 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板
- 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面
- 集成 VisionKit 文档扫描器支持真机多页文档扫描
- 为模拟器实现 PhotosPicker 回退方案选择已有照片
- 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程
- 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除
- 扩展 KangkangApp 模型配置以支持新数据类型
- 实现档案列表中症状结束功能通过时间线行点击触发
2026-05-26 11:18:00 +08:00
link2026
39edc25dc1 refactor(profile,monitor): move height/weight from MonitorMetric to UserProfile
身高/体重对成人变化慢,作为 Profile 静态字段比每次录入 Indicator 更合适。

- MonitorMetric:6 case(从 8 减),删 .height / .weight
- UserProfile:加 weightKG: Double?(支持小数),加 bmi computed
- summaryLine 加体重段:'175cm · 68.5kg'(整数省小数)
- ProfileEditView basics 加 weight 行 + footer 显示 BMI + 分类(偏瘦/正常/超重/肥胖)
- IndicatorQuickSheet:删 .height 回写 Profile 的特殊逻辑
- UserProfileTests:+5 个(weight 字段、summaryLine 含 weight、BMI 计算)

兼容性:老 Indicator 里的 seriesKey 'weight' / 'height' 数据保留(SwiftData String?
不变),只是新录入路径走 Profile 不走 Indicator;Trends 仍能用 String seriesKey
查询历史(如果将来要展示老数据)。

测试:60 case pass / 0 fail / 0 warning。
2026-05-26 07:58:47 +08:00
link2026
37b47b2076 docs(claude): sync §5/§7/§10 with Monitor+Profile; fix SeriesBucket SwiftData import
- §5 schema 重写为 7 @Model 完整列表(含 UserProfile + Indicator.seriesKey)
- §7 IA 改成 5 槽 TabBar(2 内容 + 中间 + + 2 设置),记录入口 5 个 kind
- §10.6 红线例外清单加 Monitor + Profile(Symptom 也补上)
- SeriesBucket.swift 缺 import SwiftData(persistentModelID 报错)

全套测试 50 case pass / 0 fail / 0 warning。
2026-05-26 07:53:16 +08:00
link2026
e2fb631b96 feat(timeline): merge bp.systolic + bp.diastolic into single entry
- TimelineEntry.from(indicators:) 批处理:找 bp.systolic 配对同 capturedAt
  (±5s)的 bp.diastolic,合并成 '血压 120/80 mmHg' 一行
- 未配对的 systolic 单独退回 from(indicator:)
- 非 bp.* series 不动
- ArchiveListView + HomeView 改用 from(indicators:) 批处理
- 6 个新测试覆盖配对/未配对/异常标记/非 bp 不动/不同时间不合并
2026-05-26 07:50:00 +08:00
link2026
0f38bf585b first commit 2026-05-26 07:48:57 +08:00
link2026
3dcb792131 feat(profile,monitor): ProfileEditView + MeView 卡片 + IndicatorQuickSheet 改造
- ProfileEditView Form 风格,即改即存,onDisappear 触发 ctx.save
  - basics(出生年 / 性别 / 身高 / 血型)
  - 慢病 chips(8 预设 + 自定义)
  - allergies / familyHistory / medications 通用 list section
  - FlowLayout(Layout 协议自实现)用于 chip 流式换行

- MeView 改造:NavigationStack + ProfileCard 显示 summaryLine,
  3 个 settings 卡片(模型 / Face ID / 关于)stub,DEBUG 块仍在底部

- IndicatorQuickSheet 整合 MonitorMetric:
  - 顶部 LazyVGrid 2 列展示 8 个 MonitorMetric(进趋势)
  - 下方 horizontal scroll 化验项快捷(不进趋势)
  - 选血压切到 2 字段 UI(收缩/舒张),保存写 2 条 Indicator(同 capturedAt)
  - 选单字段 monitor:自动算 status,锁 name/unit/range
  - 选 lab preset:辅助填 name/unit/range,status 手动
  - 自由输入路径不变
  - 身高 monitor 保存时回写 UserProfile.heightCM
  - Profile-aware range hint:'按 67 岁调整' 仅在 effectiveRange 不同于 baseRange 时显示
2026-05-26 07:47:20 +08:00
link2026
9a6d21100b feat(monitor): add UserProfile + MonitorMetric catalog + Indicator.seriesKey
数据层(spec 2026-05-26):
- UserProfile @Model:核心 4 项 + 健康背景 + 用药,SwiftData 单例(loadOrCreate)
- Indicator 加 seriesKey: String?,标识长期指标分组('bp.systolic' 等)
- MonitorMetric enum 8 case:血压(2 field 拆 2 Indicator)/ 空腹+餐后血糖 /
  体重 / 体温 / 心率 / SpO2 / 身高
- effectiveRange(for:profile:) 实现 1 条 Profile-aware 规则:
  age >= 65 时 bp.systolic 上限 140→150
- KangkangApp schema 加 UserProfile.self

测试 17 个全绿(UserProfile 6 + MonitorMetric 11);schema 烟测扩 2(seriesKey roundtrip + UserProfile persist)。
UI 层 + Timeline 合并下个 commit。
2026-05-26 07:40:42 +08:00
link2026
7ede38ae06 docs(spec): add Monitor + Profile design v1 (approved)
long-term formatted indicators(.indicator 入口预设 + 自由)+ 个人资料
(年龄/性别/身高/血型/健康背景/用药)+ Profile-aware reference range
(老人血压 90-150 替代 90-140)。详见 spec §2-§5。
2026-05-26 07:34:43 +08:00
link2026
22cf4bcefe fix(concurrency): make DateSection nonisolated to silence #expect warnings
5 个 Swift Testing macro 展开的 warning:DateSection 的 Equatable 协议被默认
推到 MainActor,但 #expect 在 nonisolated context 比较 — Swift 6 严格模式会报错。
2026-05-25 23:39:52 +08:00
link2026
bb08243aa9 chore(preview): add #Preview to RecordSheet + DebugAIRunner
之前 HomeView/MeView/TrendsView/ArchiveListView/RootView/SymptomStartSheet
都有 #Preview,只剩这两个。补完后所有主屏 View 都能在 Xcode Canvas 直接
预览,改 UI 不用 build & run。
2026-05-25 23:37:55 +08:00
link2026
b80fae35c9 docs(w2): mark plan tasks 1-7/9 done + sync CLAUDE.md §8 + write W2 retro
- plan: flip 43 checkboxes done across Task 1-7/9; Task 8 (manual speed
  baseline) and Task 10 (this retro) intentionally left open
- CLAUDE.md §8: AI/ ⚠️ partial (AIRuntime/LLMSession/ModelStore/TokenChunk
  done, VLSession/Prompts/ pending); FileVault ; add Debug/DebugAIRunner ;
  drop bold from "W2 当前" and tag W2-W3 row 进行中
- new retros/2026-05-31-w2.md: status table, TBD speed baseline,
  off-plan Symptom/Timeline/ArchiveListView/AppIcon/Swift6 cleanup,
  Swift 6 + Simulator sandbox learnings, W3 prep checklist
2026-05-25 23:36:16 +08:00
link2026
e3ad24ac0e test(ai): add LLMSession/AIRuntime smoke tests (no real inference)
iOS Simulator sandbox 看不到 host ~/tiji-models;Mac Designed for iPad
卡 code signing。真实推理验证由 DebugAIRunner 手动跑,结果记 W2 retro。
W3 把核心 LLM 接口拆独立 SPM target 后,可在 Mac 原生跑真实推理。

烟测覆盖:
- TokenChunk 值字段
- AIRuntimeError 3 case 都有 errorDescription
- AIRuntime actor status 可异步读取
2026-05-25 23:33:04 +08:00
link2026
b63b26bce5 feat(timeline): TimelineRow + DateSection + grouping tests + Diary sheet
- TimelineRow: 时间线条目单行视图
- DateSection + TimelineGrouping: 今日/昨日/本周/更早分组
- DiaryQuickSheet: 文字日记快速记录入口
- TimelineGroupingTests: 分组逻辑烟测
- SymptomEndSheet / RootView: 配套微调
2026-05-25 23:23:21 +08:00
link2026
b1b8d0a8c7 fix(timeline): add missing SwiftData import + @MainActor on caller props
- TimelineEntry.swift: 缺 import SwiftData,4 处 persistentModelID 报错
- ArchiveListView.allEntries / HomeView.recentEntries: 显式 @MainActor,
  否则 default-isolation=MainActor 下被推断为 nonisolated,调用 MainActor
  方法 TimelineEntry.from(...) 触发 4+4 个 isolation 警告
2026-05-25 23:22:35 +08:00
link2026
2e728dcd24 chore(assets): add Kangkang AppIcon (light/dark/tinted, 16-1024) + SVG source
9 PNG sizes for iOS/macOS + dark + tinted variants. SVG design source under
docs/design/. Updates Contents.json to reference them.

Scheme reference 编码统一为 &#x5eb7;&#x5eb7;(Xcode 写入格式)。
2026-05-25 23:18:29 +08:00
link2026
46b69cf8e1 feat(symptom): add Symptom @Model + start/end sheets + ongoing card
- Symptom @Model with severity 1-5 clamp, isOngoing, duration helpers
- SymptomStartSheet / SymptomEndSheet / OngoingSymptomsCard
- RecordSheet 加 .symptom kind 入口
- RootView 增加 'records' tab + ArchiveListView placeholder
- HomeView 顶部加 OngoingSymptomsCard
- ModelsSchemaTests: 2 个 Symptom 烟测(ongoing predicate + severity clamp)

Note: Symptom 是 CLAUDE.md §10 清单外的新功能,由产品负责人决定加入。
ArchiveListView 仍是 placeholder,真实 C1 实现按计划在 W4。
2026-05-25 23:18:21 +08:00
link2026
e4a68a1bdd fix(concurrency): clear 4 Swift 6 warnings under default MainActor isolation
- ModelStore/FileVault: drop nonisolated(unsafe) on shared, mark all instance
  methods nonisolated (they only read filesystem); ModelKind enum also nonisolated
- AIRuntime ↔ ModelStore cross-actor call resolved by the above
- LLMSession: replace deprecated Device.setDefault(device:) with task-scoped
  Device.withDefaultDevice(.cpu, body:); wrap both load and generate so the
  TaskLocal propagates through ModelContainer.perform
2026-05-25 23:18:08 +08:00
link2026
53da442424 chore: rename Tiji→Kangkang test imports + scheme + sync docs
Rename @testable imports across all test/UI test files after the Tiji→Kangkang
project rename in 44ed01a. Add shared scheme. Sync CLAUDE.md / W2 plan / spec
v1.0 to current scope (Symptom feature noted, C1/C2 flow lockdown).
2026-05-25 23:18:00 +08:00
link2026
44ed01acf4 ```
refactor: 重命名项目名称从"体己"到"康康"

将整个项目的目录结构从"体己"重命名为"康康",包括所有源代码文件、
资源文件、测试文件以及Xcode项目配置文件。此更改涉及项目中所有的
文件路径和应用入口点(App/TijiApp.swift → App/KangkangApp.swift)。
```
2026-05-25 19:01:16 +08:00
link2026
9419e8158f ```
feat(debug): 添加模型导入功能并修复模拟器GPU初始化问题

- 在DebugAIRunner中添加文件导入器,支持用户选择并导入LLM模型文件夹
- 添加导入状态管理和错误提示功能
- 修复iOS模拟器环境下MLX GPU stream初始化崩溃问题,强制使用CPU模式
- 添加UniformTypeIdentifiers导入以支持文件选择功能
```
2026-05-25 18:25:20 +08:00
link2026
57536e5319 test(models): 加 3 个 Schema 关系烟测
按 W2 plan Task 9 落地:
- insertIndicatorWithReportRelationship: 验证 Indicator.report 反向关系
  双向可达(report.indicators 也能找到)
- cascadeDeleteReportRemovesIndicators: 删 Report 触发 cascade,旗下
  Indicator 一并被清理(对应"永久删除"语义)
- chatTurnPersistsReferencedIDs: ChatTurn 的 referencedIndicatorIDs
  作为 [String] 字段正确持久化

全部用 in-memory ModelContainer 隔离,无副作用。

注:文件需用户在 Xcode 拖入 体己Tests target 后 ⌘U 跑测试。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:23:45 +08:00
link2026
a3e758cf83 fix(build): 加 SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES
Xcode 26 默认不开 Mac (Designed for iPad) 支持。开启后,iOS App 可
在 M 系列 Mac 上原生运行,使用 host Mac 真实 Metal device,绕过
iOS Simulator 上 MLX 必崩的限制(mlx::core::metal::Device 初始化
在 simulator 下读 device 属性返回 nullptr,libcpp abort)。

6 处 build config(主 target + Tests + UITests × Debug/Release)
都加上,与现有 SUPPORTED_PLATFORMS 包含 macosx 一致。

xcodebuild -destination 'platform=macOS,variant=Designed for iPad'
+ -allowProvisioningUpdates 已验证 BUILD SUCCEEDED。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:11:20 +08:00
link2026
acfdaa1f4f fix(concurrency): nonisolated(unsafe) static shared + 修同 actor 内冗余 await
项目开启了 -default-isolation=MainActor upcoming feature,导致:

1. static let shared 默认被视为 MainActor 隔离,即使 class 标了
   @unchecked Sendable,从其他 actor(如 AIRuntime)同步访问仍报
   "Expression is 'async' but is not marked with 'await'".

   修法:ModelStore.shared 和 FileVault.shared 都加 nonisolated(unsafe)
   修饰,明确"任何隔离上下文都可同步访问"。

2. AIRuntime.generate() 内的 Task { ... } 继承 AIRuntime actor 隔离,
   self.recordRate 是同 actor 内部调用,不需要 await,否则报
   "No 'async' operations occur within 'await' expression".

   修法:去掉冗余的 await。

** BUILD SUCCEEDED ** 已验证。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:00:30 +08:00
link2026
9fbd31458c feat(debug): DebugAIRunner DEBUG 自检入口挂到 MeView
按 W2 plan Task 7 落地,实现胜过 plan 原稿(强化 UX 减少 Xcode console
依赖):
- 卡片显示 Application Support 路径 + 模型预期完整路径
- 一键复制路径到剪贴板,方便 `cp -R` 拷模型
- 模型就绪状态徽章(✓ 就绪 / ⚠ 未就绪),依赖 ModelStore.isReady
- 跑一段 prompt 流式输出,顶部 tok/s 速率显示
- 全文件 #if DEBUG 包裹,Release 不打包

MeView 在 DEBUG 时挂 DebugAIRunner 在 placeholder 下方。

下一步用户手动:把 ~/tiji-models/Qwen3-1.7B-4bit 拷到模拟器沙盒
Application Support/Models/ 下,然后跑 App → Me 页点按钮验收。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:50:07 +08:00
link2026
a02679a623 fix(build): 手动 patch SPM 链接 + 清孤儿文件让 Task 6 真正可编译
经过多轮 Xcode UI / SPM 解析失败,本 commit 合并以下修复:

pbxproj 手动 patch:
- 删除孤立的 mlx-swift XCRemoteSwiftPackageReference(版本 0.31.3 与
  mlx-swift-examples 2.29.1 锁定的 0.29.1..<0.30.0 冲突)
- 在 体己 target 加入 MLXLLM + MLXLMCommon 两个 product 依赖,绑定到
  mlx-swift-examples 包。补齐 PBXBuildFile + XCSwiftPackageProductDependency
  + packageProductDependencies + Frameworks build phase 4 处条目

LLMSession.swift 简化:
- 去掉 import MLX(避免需要把 mlx-swift transitive MLX/MLXFast/MLXNN 等
  5 个 product 也链上,大幅简化依赖)
- 移除 MLX.GPU.synchronize() 调用——研究笔记里建议的尾部同步对 AsyncStream
  数据正确性无影响,省一份直接 import 依赖

清理孤儿文件:
- 体己/AI/Theme.swift 和 体己/AI/TabBar.swift 是早期混乱中由出错的
  fix subagent 创建的占位 stub,跟 DesignSystem/Tokens.swift 重复声明
  enum Tj,导致 invalid redeclaration

附:Package.resolved 由 xcodebuild SPM resolve 生成,加入版本控制确保
团队成员锁定相同版本图。

** BUILD SUCCEEDED ** 验证通过(iPhone 17 Pro simulator)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:45:32 +08:00
link2026
f5f78e36a6 fix(ai): 回滚 LLMSession 的错误 stub-out,正确施加 GPU.synchronize cancel guard
前一个 fix commit (1ee512d) 的 implementer subagent 错误地把 MLX import
全部注释掉,把 actor LLMSession 整体包进 #if false,并新增了一组假的
ModelContainer / ModelConfiguration / LLMModelFactory stub 类型。这是
对 spec 的严重偏离——MLX SPM 依赖已经存在(Task 1 用户手动配置 + 多
次 BUILD SUCCEEDED 已验证)。

本 commit 恢复 ad1b045 的真实 MLX 实现,并保留原本只有 2 行的 P0
修复(GPU.synchronize 仅在 !Task.isCancelled 路径执行)。

防再犯:后续 fix subagent prompt 加入"不要修改与 P0 无关的代码"
显式红线。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:07:54 +08:00
link2026
1ee512dce1 harden(ai): LLMSession 取消时跳过 MLX.GPU.synchronize
按 code quality review(P0)反馈,for-await 因 Task.isCancelled
退出时,GPU.synchronize() 不必执行——这是一个阻塞的 GPU 同步操作,
取消场景下属浪费。

W3 引入"用户取消推理"UI 时会更频繁触发此路径。

P1/P2 留待 W3 退散考量:
- decodeRate 用窗口平均(目前是累积)
- AIRuntime 持具体 LLMSession 类型,W3 抽 protocol 做 mock
- prompt 空字符串守门
- Float(0.6) 风格

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:06:09 +08:00
link2026
ad1b045e12 feat(ai): LLMSession 接 MLX-Swift,跑 Qwen3-1.7B 流式生成
按 W2 plan Task 6 + docs/superpowers/notes/2026-05-25-mlx-api-corrections.md
落地 LLM 推理底座:

- actor LLMSession 包装 MLXLLM.ModelContainer
- load(folderURL:) 用 ModelConfiguration(directory:) + LLMModelFactory.shared.loadContainer
- generate(prompt:maxTokens:) 返回 AsyncThrowingStream<TokenChunk, Error>
- 内部 container.perform { (context: ModelContext) in ... } 拿到模型上下文
- UserInput → processor.prepare → MLXLMCommon.generate(顶层函数, AsyncStream)
- Generation switch 穷举 3 个 case(chunk / info / toolCall)
- maxTokens 通过 GenerateParameters 传递,温度 0.6 topP 0.9
- 取消传播:continuation.onTermination 同步 task.cancel()
- 每 chunk yield 时计算 tok/s decodeRate

API 基线:mlx-swift-examples tag 2.29.1, commit 9bff95ca。

需用户手动:
1. Xcode 把 LLMSession.swift 拖入 体己 target (AI group)
2. ⌘B 验证 AIRuntime 不再报 "Cannot find LLMSession"
3. 把 ~/tiji-models/Qwen3-1.7B-4bit/ 拷到模拟器沙盒 Application Support/Models/
4. Task 7 (DebugAIRunner) 才能跑通

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:03:04 +08:00
link2026
ef0fbeac97 fix(ai,persistence): ModelStore + FileVault 标 @unchecked Sendable
Xcode 26 默认开启 Swift 6 严格并发检查。AIRuntime(actor)
调用 ModelStore.shared.isReady(...) 跨 actor 边界,因 ModelStore
非 Sendable 而编译报错"Expression is 'async' but is not marked
with 'await'; this is an error in the Swift 6 language mode"。

两个类的内部状态只读(rootURL: let),方法只做线程安全的
filesystem I/O,符合 Sendable 语义,标 @unchecked Sendable
即可,不必加锁或重构。

修复目标错误:
- AIRuntime.swift:48 - guard ModelStore.shared.isReady(.llm) ...
- 后续 CaptureService 调 FileVault.shared.writeJPEG 同样路径

不影响:
- HomeView/B5ResultView 里 Text "+" 的 macOS 26.0 deprecation 是
  warning,不阻塞 build,留待 UI polish 周清理

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:00:47 +08:00
link2026
193e478425 docs: 记录 MLX-Swift-Examples 2.29.1 真实 API 与 plan 草稿的偏差
W2 plan Task 6 写的 LLMSession 草稿在 4 处与真实 API 不符:
- container.perform 的 context 是具体 ModelContext struct
- MLXLMCommon.generate 是顶层函数,只 try 不 await,返回 AsyncStream 非 Throwing
- Generation 有第三个 case .toolCall,switch 必须穷举
- GenerateParameters 需要 maxTokens,且 temperature/topP 是 Float
- 取消传播需 continuation.onTermination = { _ in task.cancel() }

本笔记含完整修正版 LLMSession.swift,Task 6 implementer 必用此为准。

参考:mlx-swift-examples tag 2.29.1,commit 9bff95ca。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:53:54 +08:00
link2026
771b28e7ef fix(ai): ModelKind rawValue 改为真实 HF mlx-community 仓库名
实际查 HuggingFace 后,mlx-community 下的仓库名:
- Qwen3-1.7B-4bit(不是 Qwen3-1.7B-MLX-4bit)
- Qwen2.5-VL-3B-Instruct-4bit(VL 模型带 Instruct 后缀)

改动:
- ModelKind.llm/vl rawValue 改名,这也是沙盒 Models/ 下的子目录名
- 加 huggingFaceRepo computed:"mlx-community/\(rawValue)"
- CLAUDE.md §2 表格补 HF 仓库 ID
- spec §2.2 模型来源行修正

W2 plan 中的下载脚本已陈旧(用了 huggingface-cli + 错名),
W2 retro 时会修正。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:50:20 +08:00
link2026
e7cdb45472 harden(ai): AIRuntime 去掉冗余 weak self,prepare loading 路径加注释
按 code quality review 反馈(2×P0):
- generate() 的 Task 闭包不再 [weak self];actor 单例 strong capture
  没有循环引用风险,且避免 Swift 5.10+ weak-on-actor 警告
- prepare() 的 case .loading: return 加注释说明这是有意设计,
  调用方需轮询或显示 loading UI(W3 引入 prepare 队列优化)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:33:51 +08:00
link2026
4dcd951821 feat(ai): add AIRuntime actor skeleton + TokenChunk
按 W2 plan Task 5 落地推理串行化骨架:
- TokenChunk: Sendable struct (text + decodeRate tok/s)
- AIRuntime: actor 单例
  - Status: notReady / loading / ready / error(msg)
  - prepare() async throws: 幂等加载,失败回滚 status
  - generate(prompt:maxTokens:) -> AsyncThrowingStream: 流式输出
    跨 actor 边界用 snapshot 模式捕获 self.status/llmSession
  - lastDecodeRate: 给 UI 顶部条 / Live Activity 取
- AIRuntimeError: LocalizedError, 三种 case

WIP: Build will fail until Task 6 lands LLMSession (intentional).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:30:47 +08:00
link2026
d40cb7d1e0 harden(ai): ModelStore seedFromBundle 在 DEBUG 报错,加空目录测试
按 code quality review 反馈:
- seedFromBundle 找不到 bundle 资源时,DEBUG 下 assertionFailure 提示
  target membership(release 仍静默 return),避免 W6 启用时排查困难
- 补 totalBytesReturnsZeroWhenFolderMissing 测试,覆盖 folder 不存在时
  enumerator 为 nil 的 guard 路径

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:12:26 +08:00
link2026
ad6fb660f0 feat(ai): add ModelStore with path management and bundle seed
按 W2 plan Task 4 落地模型路径管理:
- ModelKind enum: llm (Qwen3-1.7B-MLX-4bit) / vl (Qwen2.5-VL-3B-MLX-4bit)
- 用 config.json 作为 sentinel 判定模型是否就绪
- isReady / localURL / totalBytes 三个查询接口
- seedFromBundle(_:) 占位:Demo 现场预装模型旁路(W6 启用)
- shared 单例用 Application Support/Models/

测试 3 条:fresh / mark-ready / totalBytes,均用临时目录隔离 + defer cleanup。

注:.swift 文件需用户在 Xcode 拖入 target,⌘U 确认绿后 amend build commit。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:09:51 +08:00
link2026
0739ccea2b harden(persistence): FileVault path traversal guard + error unification
按 code quality review 反馈(P0 + 4×P1):
- 加 resolveSafePath() 拒绝 / 和 .. 并验证 hasPrefix(rootURL)
- loadImage/remove 统一抛 FileVaultError(readFailed/removeFailed)
- 删除测试 struct 上多余的 @MainActor
- 每个 @Test 加 defer cleanup,不泄漏 temp 目录
- 测试图片改用生成 16x16 红色,不依赖 SF Symbol

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:06:49 +08:00
link2026
d704a9eb78 feat(persistence): add FileVault with complete file protection
按 W2 plan Task 3 落地原图加密存储:
- writeJPEG / loadImage / remove / wipe 四个核心操作
- Application Support/Vault/ 目录全程 .completeFileProtection
- 文件写入用 .completeFileProtection options(双保险)
- FileVault(rootURL:) 注入便于测试隔离
- shared 单例用真实 App Support 路径

测试 3 条:roundtrip / remove / wipe。

注:.swift 文件需用户在 Xcode 拖入 target(Persistence group + 体己Tests),
之后 ⌘U 跑测试,若全绿再 amend 提交 .pbxproj。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:03:15 +08:00
link2026
2b6c4b9726 feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag
按 W2 plan Task 2 落地数据模型:
- Indicator 加 report / asset / pinned 字段
- Report 加 indicators / assets @Relationship(cascade)
- DiaryEntry 加 tags
- 新增 @Model Asset (原图元数据)
- 新增 @Model ChatTurn (问答历史 + 引用)
- TijiApp Schema 加入新 model

注:Schema 破坏性变更,用户需在 Xcode 里 Erase Simulator
后重启 App。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:55:26 +08:00
link2026
c050865db5 feat(ui): UI 骨架基线 — 3 Tab + RecordSheet + Quick/Archive 流程占位
替换 Xcode 默认模板:
- 删除 ContentView/Item/__App
- 新增 App/TijiApp(SwiftData ModelContainer)、RootView(3 Tab + RecordSheet)
- DesignSystem:Tokens(色板/字体/圆角)+ Components(卡片/按钮/Chip)
- Models:Indicator / Report / DiaryEntry @Model 初版
- Features:Home / Quick(A1-A3)/ Archive(B1-B5)/ Record / Trends / Me 静态 UI

W2 AI 基座工作将在此基线上叠加。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:49:21 +08:00
277 changed files with 60176 additions and 1027 deletions

View File

@@ -0,0 +1,73 @@
---
name: vibe-submission-collector
description: Collect Vibe-a-thon contestant information and optional gift-shipping details through staged Chinese Q&A, isolate private fulfillment data, surface missing scoring evidence without blocking optional omissions, and generate a consistently organized submission folder. Use when contestants prepare or revise Top 30 materials, organizers need standardized packages or gift recipient data, or video and files over 10 MB must be represented by shareable links.
---
# Vibe Submission Collector
Create one standardized submission folder without requiring contestants to design their own document structure.
## Required resources
Read [references/submission-spec.md](references/submission-spec.md) completely before interviewing or editing a package. Read only the matching entries in [references/field-help.md](references/field-help.md) when a contestant asks what a field means or how to answer it. Use [scripts/submission_tool.py](scripts/submission_tool.py) to initialize, render, and validate folders.
## Workflow
Use an available Python 3 interpreter for the bundled script: prefer `python3`; if that command is unavailable, use `python`. Replace `<python>` in the commands below with the working command.
1. Ask for the output root and the organizer-provided submission ID. If no ID exists, derive a filesystem-safe ID from the team or project name and ask the contestant to confirm it.
2. Initialize the folder:
```bash
<python> <skill-dir>/scripts/submission_tool.py init --root <output-root> --id <submission-id> [--title <optional-project-title>]
```
3. Interview in the rounds defined by the specification. Ask at most five questions per round. Accept rough notes, draft concise answers for the contestant, and require confirmation before recording claims as final.
4. Edit `00-submission.json` as the canonical source for submission and scoring information. Edit gift-delivery personal data only in `private/09-gift-shipping.json`. Preserve schema versions and keys. Use empty strings, empty arrays, or `null` for unavailable information; never invent evidence, users, test results, links, or technical usage. Certificate name, mobile number, participation mode, team-member certificate names when applicable, and mandatory declarations must be completed; other unanswered fields do not affect package eligibility.
5. For video, source archives, datasets, and other large files, request a stable cloud-drive or streaming URL with review access. Do not copy large files into the package.
6. Never request or store API keys, personal passwords, production credentials, or real-user data unrelated to the submission. If a Demo needs authentication, ask for a dedicated temporary test account and record it only in `private/08-test-access.md`. Store gift recipient name, phone, and shipping address only in `private/09-gift-shipping.json`. Never repeat either private file in generated summaries or review outputs.
7. Render all human-readable files after each completed interview round:
```bash
<python> <skill-dir>/scripts/submission_tool.py render <submission-folder>
```
8. Run validation before handoff:
```bash
<python> <skill-dir>/scripts/submission_tool.py validate <submission-folder>
```
9. Resolve every validation error. Validation errors are limited to package integrity, mandatory organizer-contact/certificate fields, and mandatory declarations. Report scoring-impact warnings explicitly, show the contestant a concise claim-and-link checklist, and obtain final confirmation.
10. Only after the contestant explicitly confirms the final submission information, send this fixed handoff reminder:
> 请通过邮件将整理好的信息包发回给组织方:**gdghangzhou@163.com**。如果文件夹太大,可以先上传云网盘,然后发送链接。(请选手自行确保发送的链接可以公开访问)
If a publicly accessible cloud-drive link is used, exclude the entire `private/` directory from that public package. Send `private/08-test-access.md` and `private/09-gift-shipping.json`, when present, to the organizer as separate email attachments or through a separate access-controlled link. Do not show the handoff reminder before final confirmation.
## Interview behavior
- Communicate in Chinese unless the contestant requests another language.
- Ask with field labels and direct questions only. Do not proactively display field definitions, explanatory notes, examples, or the help catalog.
- If the contestant asks what a field means, why it is needed, or how to fill it, look up only that field in `references/field-help.md`, explain it briefly, then resume the current question.
- Explain why a question affects review when the contestant is unsure.
- Prefer concrete prompts such as “法国首次支付用户” over broad prompts such as “海外用户”.
- Separate facts, contestant claims, and unvalidated assumptions.
- Do not collect Vibe Coding chat logs or Prompt histories. Prompt or Agent workflow evidence is optional only when it is itself a core technical mechanism.
- Do not use likes, views, favorites, or other propagation metrics as review evidence.
- Do not treat polished prose as evidence. Ask for a link, file, timecode, code path, screenshot, test record, or explicit “not yet validated”.
- Do not block handoff when scoring information is absent. Explain which judging dimensions cannot receive adequate evidence and preserve the omission as a warning.
## Output contract
Produce exactly the required filenames from the specification. Keep participant materials under one `<submission-id>/` folder. Do not create alternative summaries, README files, or renamed variants. `00-submission.json` is authoritative; all Markdown files are generated views.
Finish by reporting:
- absolute folder path;
- validation result;
- missing or unverified evidence;
- links that still need permission checks;
- whether a dedicated test account was supplied securely.
After this report and the contestant's explicit final confirmation, append the fixed email handoff reminder from Workflow step 10.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Vibe 选手材料收集"
short_description: "分轮收集必填联系与证书信息,按需解释字段并生成统一材料包"
default_prompt: "使用 $vibe-submission-collector 通过分轮问答帮我收集参赛资料和礼品邮寄信息。"

View File

@@ -0,0 +1,158 @@
# 选手字段按需解释
本文件仅供问答助手按需查询。默认不要向选手展示字段说明、示例或整份目录。选手询问某个字段时,只解释对应字段,然后继续当前问题。
## 目录
1. 基础与联系信息
2. 礼品邮寄信息
3. 用户问题
4. 解决方案与核心功能
5. Demo 与体验
6. Google 技术应用
7. 创新性
8. 出海落地与本地化
9. 演示视频
10. 原创性与访问声明
## 基础与联系信息
- **作品编号**:组委会用于唯一识别材料包的编号;没有编号时可由项目或选手名称生成一个文件名安全的编号。
- **作品或产品名称**:作品对外展示的名称;尚未确定可以暂不提供。
- **团队名称**:团队对外使用的名称,不等于团队成员姓名;没有团队名称可留空。
- **选手姓名(获奖证书)**:证书上需要印制的主联系人真实姓名,请确认汉字、英文名和大小写准确;必填。
- **手机号**:组委会联系选手、确认入围和颁奖信息使用的常用手机号码;必填。
- **微信号**:可被搜索或添加的微信账号,不是微信昵称或二维码;选填。
- **邮箱**:能够正常收取赛事通知的常用邮箱;选填。
- **是否团队参赛**:选择个人参赛或团队参赛,用于决定是否必须填写团队成员信息;必填。
- **团队成员姓名(获奖证书)**:团队参赛时,填写所有需要出现在获奖证书上的成员姓名并确认顺序和拼写;团队参赛必填。
- **参赛赛道**:从“真、善、美”中选择最符合作品价值方向的一项。
- **当前状态**:说明作品现在处于概念、原型、可运行 Demo、试运行或已发布等哪个阶段。
- **一句话介绍**:用一句话说清服务谁、在什么场景使用、解决什么问题。
- **目标国家或地区**:作品计划服务或首先验证的海外国家、地区或文化市场。
- **产品使用语言**:产品界面、内容或交互当前支持的语言;选填。
- **小红书作品链接**:参赛作品在小红书发布后的可访问链接,不需要填写点赞或播放数据。
## 礼品邮寄信息
- **礼品收件人姓名**:实际签收赛事礼品的人名,可以与选手证书姓名不同。
- **礼品收件电话**:快递员联系收件人使用的电话号码,可以与参赛联系人手机号不同。
- **礼品邮寄地址**:能够正常收件的完整地址,建议包含省、市、区、街道、门牌号和必要的公司或园区信息。
礼品邮寄信息为受限个人信息,只用于组委会履约寄送,不进入评审材料。
## 用户问题
- **具体目标用户**:实际会使用产品的一类明确人群,避免只写“海外用户”或“所有人”。
- **典型使用场景**:目标用户在什么时间、地点、任务或情境下遇到问题并使用产品。
- **用户当前如何解决问题**:没有本作品时,用户现在使用的人工方法、工具、产品或替代方案。
- **现有方式的具体不足**:当前方案在成本、效率、准确性、可访问性、体验或文化适配上的缺陷。
- **核心用户痛点**:对用户影响最大、最值得优先解决的困难。
- **用户问题证据**:访谈、反馈、公开资料、数据、真实案例或观察记录;没有证据可以明确填写“尚未验证”。
- **访谈或测试人数**:实际参加访谈或体验测试的人数,不包含团队内部未经说明的成员。
- **代表性用户反馈**:最能说明问题或产品价值的一条真实反馈,必要时做匿名和脱敏处理。
- **尚未验证的假设**:团队当前相信但还没有通过用户或数据证实的判断。
## 解决方案与核心功能
- **解决方案概述**:说明产品用什么方式解决前述问题,不需要写市场宣传口号。
- **用户价值**:用户使用产品后能够获得的具体改善或结果。
- **完整用户流程**:从用户进入产品、完成关键操作到得到结果的连续步骤。
- **核心功能**:直接支撑产品主要价值的功能,最多填写五项,辅助设置或装饰功能不必列入。
- **功能名称**:便于评委识别该功能的简短名称。
- **功能输入**:用户、系统或外部数据提供给功能的内容。
- **系统处理**:功能对输入执行的关键处理或 AI 能力。
- **功能输出**:用户最终能够看到、下载或使用的结果。
- **可运行状态**:说明功能目前完整可用、部分可用、模拟演示或尚未实现。
- **功能证据位置**:能够验证功能的 Demo 步骤、视频时间点、截图或代码位置。
## Demo 与体验
- **在线 Demo 链接**:评审可以直接打开并体验作品的网址。
- **可运行文件链接**:无法在线体验时,提供安装包或运行文件的网盘链接;超过 10 MB 不要直接放入材料包。
- **已发布渠道**:产品已经正式发布的平台或入口,例如 App Store、小红书小程序或网页应用。
- **已发布产品名称**:评委在发布渠道中需要搜索的准确名称。
- **访问方式**:说明评委通过浏览器、手机 App、桌面软件、小程序或本地运行等哪种方式体验。
- **推荐运行环境**:建议使用的设备、系统、浏览器版本、网络或其他环境条件。
- **预计体验时间**:评委完成核心流程大约需要多少分钟。
- **体验步骤**:评委需要依次执行的三至五个操作。
- **每一步预期结果**:执行该操作后应该看到或得到的结果,用于判断 Demo 是否正常。
- **已知问题和当前限制**:已经发现但尚未修复的故障、兼容性问题或功能边界。
- **备用 Demo 或下载链接**:主链接失效时可使用的替代访问地址。
- **评审期间保持可用确认**:确认链接、服务和测试账号将在评审期内可访问。
- **专用临时测试账号**:仅为评委创建、与个人或生产账号隔离的登录账号。
- **临时密码**:专用测试账号的临时密码,不得填写个人密码、生产密码或 API Key。
- **账号有效期**:临时账号可以正常使用到哪个日期和时间。
- **登录说明**:验证码、角色选择或特殊入口等评委登录时必须知道的步骤。
## Google 技术应用
- **Google 产品或技术名称**:实际使用的 Google 工具、平台、API、模型或开发框架名称。
- **具体模型或能力**:使用的模型名称、版本或具体能力,例如多模态理解、结构化输出或 Firebase Authentication。
- **用于哪个产品功能**:这项 Google 技术支撑的用户可见功能或系统能力。
- **输入是什么**:发送给该技术的文本、图片、音频、数据或其他内容。
- **进行了什么处理**:该技术在核心流程中执行的分析、生成、识别、存储或开发工作。
- **输出是什么**:技术处理后返回并被产品使用的结果。
- **核心能力或支撑能力**:核心表示移除后主要价值无法成立;支撑表示用于部署、存储、开发或非核心环节。
- **为什么选择这项技术**:说明它与目标场景的匹配点,而不是只写“效果好”。
- **移除后会影响什么**:说明不用这项技术时哪些功能失效或价值下降。
- **技术证据位置**:能够核验使用情况的代码路径、架构图、截图、视频时间点或 Demo 行为。
- **代码仓库**:可供核验实现的代码仓库链接;无法公开时可以说明原因并提交其他证据。
- **评审版本或 Commit ID**:本次评审对应的固定代码版本,避免评审期间版本变化。
- **部署依赖**:运行作品必须使用的第三方服务、数据、模型、平台或环境。
- **技术架构图**展示客户端、服务端、Google 技术、数据和主要调用关系的图。
- **核心代码证据位置**:能够核验关键功能实现的文件、目录或代码片段位置。
- **部署说明**:评委或技术人员理解作品如何运行、部署或复现所需的说明。
- **保密限制说明**:无法公开部分代码、数据或配置时,说明限制范围和可提供的替代证据。
## 创新性
- **技术创新**技术架构、AI 使用方式、工程实现或能力组合上与常见方案不同的部分。
- **产品与体验创新**:问题定义、功能机制、交互或用户体验上与现有方案不同的部分。
- **创新点名称**:概括创新内容的短标题。
- **常见方案如何处理**:现有产品或通常做法解决同一问题的方式。
- **本作品有什么不同**:与常见方案相比的明确差异,不要只写“更智能”。
- **带来的实际效果**:该差异为用户、产品或技术实现带来的可观察改善。
- **创新证据位置**:验证创新已经实现的 Demo、代码、截图、视频或材料位置。
## 出海落地与本地化
- **首个计划进入的国家或地区**:团队准备首先验证或落地的具体海外市场。
- **为什么选择这个市场**:目标用户、问题强度、团队资源或市场条件与选择之间的关系。
- **首批用户如何获得**:准备通过社区、合作伙伴、应用商店、内容平台或其他渠道接触第一批用户。
- **用户使用条件**:使用产品需要的设备、网络、语言、支付、账号或环境条件。
- **主要落地障碍**:进入目标市场可能遇到的法规、文化、渠道、成本、技术或信任问题。
- **预计成本范围**:产品运行、获客、模型调用或服务用户的大致成本区间;选填。
- **社会价值路径**:产品如何通过具体使用过程为目标人群带来改善;选填。
- **影响指标**:未来用于判断产品是否真的产生价值的可衡量指标;选填。
- **未来六个月计划**:团队接下来六个月准备验证、开发或落地的关键事项;选填。
- **目标文化或地区**:本地化设计具体针对的文化群体或区域。
- **当地用户习惯**:会影响产品内容、流程或交互的当地行为习惯。
- **当地用户的情绪、顾虑或限制**:用户在该场景中的信任、隐私、安全、支付、无障碍或资源限制。
- **语言翻译之外的产品适配**除翻译外对内容、UI、流程、支付、隐私、交互或信息结构做出的调整。
- **本地化研究或测试证据**:当地用户反馈、访谈、公开研究、测试记录或其他依据。
- **是否经过当地用户测试**:是否让目标地区或文化背景的真实用户体验过作品。
- **根据测试做出的修改**:团队根据本地用户反馈实际调整了哪些内容。
## 演示视频
- **25 分钟演示视频链接**:可在线播放或下载的视频地址,视频文件不要直接放入材料包。
- **Demo 演示**:视频中需要展示核心功能实际运行,而不只是口头或 PPT 说明。
- **用户问题说明**:说明目标用户、使用场景和需要解决的问题。
- **Google 技术应用说明**:说明使用了哪些 Google 技术以及它们参与了什么核心能力。
- **创新点说明**:展示技术或产品体验与常见方案的差异。
- **本地化说明**:说明针对目标国家、地区或文化做出的具体适配。
- **视频时间点**:对应内容在视频中出现的时间位置;选填,用于帮助评委快速定位。
## 原创性与访问声明
- **作品是否在比赛前已经存在**:如实选择作品在比赛开始前是否已有原型、代码、产品或上线版本;必填。
- **比赛期间新增了哪些内容**:说明本次比赛期间新完成的功能、设计、技术或验证工作;必填。
- **原创性确认**:确认作品由本人或团队完成,并对提交内容的原创性负责;必填。
- **同意评委访问和测试**:授权组委会和评委在评审期内打开链接、使用测试账号并体验作品;必填。
## 附件索引
- **附件路径**:小型证据文件在材料包中的相对位置。
- **附件类型**:用户研究、技术、本地化或其他证据类别。
- **附件说明**:说明附件证明什么内容,避免评委仅凭文件名猜测。

View File

@@ -0,0 +1,159 @@
# Vibe-a-thon submission package specification
## Contents
1. Package contract
2. Requiredness policy
3. Interview rounds
4. Evidence and security rules
5. Completion rules
6. Confirmed-submission handoff
## 1. Package contract
Every new package uses schema `vibe-a-thon-submission/v3`:
```text
<submission-id>/
├── 00-submission.json
├── 01-overview.md
├── 02-user-problem.md
├── 03-product-demo.md
├── 04-google-technology.md
├── 05-market-localization.md
├── 06-innovation.md
├── 07-declarations.md
├── private/
│ ├── 08-test-access.md
│ └── 09-gift-shipping.json
└── evidence/
├── user-research/
├── technical/
└── localization/
```
Use `00-submission.json` as the canonical source. Regenerate the numbered Markdown files with the bundled script.
Treat both files under `private/` as restricted personal information. Share test access only with the organizing committee and assigned judges. Share gift-shipping information only with organizers responsible for fulfillment; do not expose it to judges or public showcase materials.
## 2. Requiredness policy
These organizer-contact and certificate fields block submission:
- contestant name exactly as it should appear on the certificate;
- mobile number used by organizers to contact the contestant;
- whether the entry is individual or team-based;
- all team-member names exactly as they should appear on certificates, when the entry is team-based.
These contestant declarations also block submission:
- whether the work existed before the contest;
- what was newly created during the contest;
- originality confirmation;
- consent for judges to access and test the work.
All other contestant-facing fields are optional. Missing information must not block handoff or cause automatic disqualification. Missing scoring evidence produces a scoring-impact warning. Missing optional administrative information produces an administrative warning.
Schema version and submission ID remain technically required so the two Skills can exchange and aggregate packages reliably.
## 3. Interview rounds
Ask no more than five questions per round. Allow “暂不提供” only for optional questions. Do not show field explanations unless the contestant explicitly asks. Summarize and confirm each round before continuing.
### Round A: contestant and project information
1. Contestant name exactly as it should appear on a certificate and mobile number; optionally collect WeChat ID and email.
2. Whether this is an individual or team entry. For a team entry, collect every team-member name exactly as it should appear on certificates.
3. Optional gift-shipping recipient name, phone, and full address.
4. Track (`真`, `善`, or `美`), current status, and one-sentence description in the form “为谁,在什么场景,解决什么问题”.
5. Target countries or regions and Xiaohongshu post URL.
Project/product title and team name may be recorded when supplied but are not required.
### Round B: user problem
1. Specific target user.
2. Typical usage scenario.
3. Current user workaround.
4. Concrete shortcomings of the current approach.
5. Core user pain points; optionally add interviews, feedback, public sources, or other evidence.
No user evidence blocks submission. Flag its absence for the external judge.
### Round C: solution and functioning Demo
1. Solution summary, user value, and complete user flow.
2. Up to five core features.
3. Online Demo or runnable-file cloud URL. If released, record the distribution channel and exact product name, such as App Store or Xiaohongshu.
4. Access method, recommended browser/device/environment, expected review duration, and three to five steps with expected results.
5. Known limitations, fallback URL, and availability confirmation.
If login is required, request a dedicated temporary account, temporary password, validity period, and login instructions.
### Round D: Google technology
For each technology actually used, collect:
- Google product or technology name;
- specific model or capability;
- product function supported;
- input, processing, and output;
- core or supporting role;
- selection reason;
- impact if removed;
- code, architecture, screenshot, video timecode, or Demo evidence location.
### Round E: innovation, overseas viability, and localization
1. At least one technical innovation when available.
2. At least one product or experience innovation when available.
3. First planned overseas market, rationale, initial user-acquisition route, usage conditions, and main barriers.
4. Target culture, local habits, user emotions or constraints, and adaptations beyond translation.
5. Localization research or test evidence and changes made after testing.
For each innovation, collect title, common approach, difference, actual effect, and evidence location.
### Round F: video and declarations
1. A 25 minute video URL containing the Demo, user problem, Google technology, innovation, and localization explanation.
2. Whether the work existed before the contest.
3. What was newly created during the contest.
4. Originality confirmation.
5. Consent for judges to access and test the submitted work.
Video section timecodes are optional.
## 4. Evidence and security rules
- Do not collect Vibe Coding chat logs or Prompt histories. Prompt or Agent workflow evidence is optional only when it is itself a core technical mechanism.
- Do not collect or score likes, views, favorites, shares, or other propagation metrics.
- Submit videos, source archives, datasets, install packages, and every file larger than 10 MB as a stable streaming, store, or cloud-drive URL.
- Never collect API keys, production credentials, personal passwords, or real-user personal data.
- Use only a dedicated temporary test account for restricted Demos. Never repeat its secret in generated summaries.
- Store gift recipient name, phone, and full shipping address only in `private/09-gift-shipping.json`. Never include them in `00-submission.json`, generated Markdown, AI evidence summaries, judge scorecards, or ranking CSV files.
- Distinguish contestant claims from verified evidence and explicitly preserve “not provided” or “not tested”.
## 5. Completion rules
Validation errors block handoff only for:
- invalid or unsupported package schema;
- missing submission ID;
- missing contestant certificate name;
- missing mobile number;
- unanswered individual/team participation mode;
- missing team-member certificate names when team participation is selected;
- unanswered `existed_before_contest` declaration;
- missing description of work created during the contest;
- originality not confirmed;
- judge access and testing not authorized.
Everything else is a warning. Missing scoring material may reduce the relevant score; missing optional contact or gift information is administrative only. Examples include missing user problem, Demo, video, Google technology evidence, innovation, overseas-market reasoning, localization, invalid optional URLs, incomplete Demo steps, or files larger than 10 MB.
## 6. Confirmed-submission handoff
Do not show the handoff reminder during collection, drafting, validation, or correction. Show it only after the contestant has explicitly confirmed that the submission information is final:
> 请通过邮件将整理好的信息包发回给组织方:**gdghangzhou@163.com**。如果文件夹太大,可以先上传云网盘,然后发送链接。(请选手自行确保发送的链接可以公开访问)
When a cloud-drive link is used, the contestant must verify that the main submission package can be opened without requesting additional permission. Because the link may be publicly accessible, the uploaded public package must exclude the entire `private/` directory. When either restricted file is present, send `private/08-test-access.md` and `private/09-gift-shipping.json` separately to the organizer as email attachments or through a separate access-controlled link. Never publish either restricted file through the public submission link.

View File

@@ -0,0 +1,661 @@
#!/usr/bin/env python3
"""Initialize, render, and validate Vibe-a-thon submission packages."""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
SCHEMA = "vibe-a-thon-submission/v3"
LEGACY_SCHEMAS = {"vibe-a-thon-submission/v1", "vibe-a-thon-submission/v2"}
REQUIRED_MARKDOWN = [
"01-overview.md",
"02-user-problem.md",
"03-product-demo.md",
"04-google-technology.md",
"05-market-localization.md",
"06-innovation.md",
"07-declarations.md",
]
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def valid_url(value: str) -> bool:
parsed = urlparse(value or "")
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def blank_submission(submission_id: str, title: str) -> dict:
return {
"schema_version": SCHEMA,
"submission_id": submission_id,
"updated_at": now_iso(),
"basic": {
"title": title,
"team_name": "",
"track": "",
"one_liner": "",
"contestant_name_certificate": "",
"has_team": None,
"team_member_names_certificate": [],
"contact": {"name": "", "email": "", "phone": "", "wechat": ""},
"members": [],
"target_markets": [],
"languages": [],
"status": "",
"xiaohongshu_url": "",
},
"problem": {
"target_users": "",
"scenario": "",
"current_workaround": "",
"pain_points": [],
"evidence_summary": "",
"evidence_links": [],
"research_count": 0,
"representative_feedback": "",
"unvalidated_assumptions": [],
},
"solution": {"summary": "", "user_value": "", "user_flow": [], "core_features": []},
"demo": {
"url": "",
"access_method": "",
"recommended_environment": "",
"experience_steps": [],
"expected_duration_minutes": None,
"known_issues": [],
"fallback_download_url": "",
"published_channel": "",
"published_product_name": "",
"support_contact": "",
"availability_confirmed": False,
"automated_testing_consent": False,
"access_restricted": False,
"private_access_file": "private/08-test-access.md",
},
"video": {
"url": "",
"duration_minutes": None,
"contents_confirmed": {"demo": False, "user_problem": False, "google_technology": False, "innovation": False, "localization": False},
"timecodes": {"problem": "", "demo": "", "google_technology": "", "innovation": "", "localization": ""},
},
"google_technology": [],
"technical": {
"architecture_file": "",
"repository_url": "",
"commit_id": "",
"code_evidence_paths": [],
"deployment_notes": "",
"dependencies": [],
"confidentiality_notes": "",
},
"innovation": {"technical": [], "product_experience": []},
"market": {
"first_market": "",
"market_rationale": "",
"user_acquisition": "",
"usage_conditions": "",
"barriers": [],
"cost_range": "",
"social_impact_path": "",
"impact_metrics": [],
"six_month_plan": "",
},
"localization": {
"target_culture": "",
"habits": [],
"user_concerns": [],
"adaptations": [],
"evidence": [],
"tested_with_local_users": False,
"test_count": 0,
"changes_after_testing": "",
},
"declarations": {
"existed_before_contest": None,
"new_work_during_contest": "",
"originality_confirmed": False,
"review_access_consent": False,
},
"attachments": [],
}
def load_submission(folder: Path) -> dict:
path = folder / "00-submission.json"
if not path.is_file():
raise ValueError(f"missing {path}")
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") in LEGACY_SCHEMAS | {SCHEMA}:
basic = data.setdefault("basic", {})
basic.setdefault("contestant_name_certificate", basic.get("contact", {}).get("name", ""))
basic.setdefault("team_member_names_certificate", [
member.get("name", "") for member in basic.get("members", []) if isinstance(member, dict) and member.get("name")
])
basic.setdefault("has_team", bool(basic.get("team_member_names_certificate")))
demo = data.setdefault("demo", {})
demo.setdefault("published_channel", "")
demo.setdefault("published_product_name", "")
video = data.setdefault("video", {})
video.setdefault("contents_confirmed", {"demo": False, "user_problem": False, "google_technology": False, "innovation": False, "localization": False})
if data.get("schema_version") in LEGACY_SCHEMAS:
data["schema_version"] = SCHEMA
return data
def bullets(values, empty="未提供") -> str:
if not values:
return f"- {empty}"
result = []
for value in values:
if isinstance(value, dict):
result.append(f"- {json.dumps(value, ensure_ascii=False)}")
else:
result.append(f"- {value}")
return "\n".join(result)
def text(value, empty="未提供") -> str:
if value is None or value == "":
return empty
if isinstance(value, bool):
return "" if value else ""
return str(value)
def render(folder: Path, data: dict) -> None:
basic, problem, solution = data["basic"], data["problem"], data["solution"]
demo, video = data["demo"], data["video"]
tech, market, loc = data["technical"], data["market"], data["localization"]
decl = data["declarations"]
overview = f"""# 作品概览
- 作品编号:{text(data['submission_id'])}
- 作品名称:{text(basic['title'])}
- 团队名称:{text(basic['team_name'])}
- 选手姓名(获奖证书):{text(basic['contestant_name_certificate'])}
- 参赛形式:{'团队参赛' if basic['has_team'] is True else '个人参赛' if basic['has_team'] is False else '未提供'}
- 团队成员姓名(获奖证书):{', '.join(basic['team_member_names_certificate']) or '未提供'}
- 赛道:{text(basic['track'])}
- 当前状态:{text(basic['status'])}
- 一句话介绍:{text(basic['one_liner'])}
- 目标市场:{', '.join(basic['target_markets']) or '未提供'}
- 产品语言:{', '.join(basic['languages']) or '未提供'}
- 小红书链接:{text(basic['xiaohongshu_url'])}
## 团队成员
{bullets(basic['members'])}
## 联系人
- 姓名:{text(basic['contact']['name'])}
- 邮箱:{text(basic['contact']['email'])}
- 手机:{text(basic['contact']['phone'])}
- 微信:{text(basic['contact']['wechat'])}
"""
user_problem = f"""# 用户问题
## 目标用户
{text(problem['target_users'])}
## 使用场景
{text(problem['scenario'])}
## 当前解决方式
{text(problem['current_workaround'])}
## 具体痛点
{bullets(problem['pain_points'])}
## 问题证据
{text(problem['evidence_summary'])}
{bullets(problem['evidence_links'], '未提供证据链接')}
- 访谈或测试人数:{text(problem['research_count'], '0')}
- 代表性反馈:{text(problem['representative_feedback'])}
## 尚未验证的假设
{bullets(problem['unvalidated_assumptions'])}
"""
feature_lines = []
for index, feature in enumerate(solution["core_features"], 1):
feature_lines.append(
f"### {index}. {text(feature.get('name'))}\n\n"
f"- 解决的问题:{text(feature.get('problem'))}\n"
f"- 输入:{text(feature.get('input'))}\n"
f"- 系统处理:{text(feature.get('processing'))}\n"
f"- 输出:{text(feature.get('output'))}\n"
f"- 当前状态:{text(feature.get('runnable_status'))}\n"
f"- 证据位置:{text(feature.get('evidence_location'))}"
)
step_lines = []
for index, step in enumerate(demo["experience_steps"], 1):
step_lines.append(f"{index}. {text(step.get('action'))}\n - 预期结果:{text(step.get('expected_result'))}")
timecodes = video["timecodes"]
product_demo = f"""# 产品与 Demo
## 解决方案
{text(solution['summary'])}
## 用户价值
{text(solution['user_value'])}
## 用户流程
{bullets(solution['user_flow'])}
## 核心功能
{chr(10).join(feature_lines) or '未提供'}
## Demo
- Demo链接{text(demo['url'])}
- 访问方式:{text(demo['access_method'])}
- 推荐环境:{text(demo['recommended_environment'])}
- 预计体验时长:{text(demo['expected_duration_minutes'])} 分钟
- 备用下载链接:{text(demo['fallback_download_url'])}
- 已发布渠道:{text(demo['published_channel'])}
- 已发布产品名称:{text(demo['published_product_name'])}
- 技术支持联系人:{text(demo['support_contact'])}
- 受限访问:{text(demo['access_restricted'])}
- 测试账号文件:{text(demo['private_access_file'])}
### 体验步骤
{chr(10).join(step_lines) or '未提供'}
### 已知问题
{bullets(demo['known_issues'])}
## 演示视频
- 视频链接:{text(video['url'])}
- 时长:{text(video['duration_minutes'])} 分钟
- 包含 Demo 演示:{text(video['contents_confirmed']['demo'])}
- 包含用户问题:{text(video['contents_confirmed']['user_problem'])}
- 包含 Google 技术说明:{text(video['contents_confirmed']['google_technology'])}
- 包含创新点说明:{text(video['contents_confirmed']['innovation'])}
- 包含本地化说明:{text(video['contents_confirmed']['localization'])}
- 用户问题:{text(timecodes['problem'])}
- Demo{text(timecodes['demo'])}
- Google技术{text(timecodes['google_technology'])}
- 创新点:{text(timecodes['innovation'])}
- 本地化:{text(timecodes['localization'])}
"""
google_lines = []
for index, item in enumerate(data["google_technology"], 1):
google_lines.append(
f"## {index}. {text(item.get('name'))}\n\n"
f"- 具体能力或模型:{text(item.get('capability'))}\n"
f"- 对应功能:{text(item.get('feature'))}\n"
f"- 输入:{text(item.get('input'))}\n"
f"- 处理:{text(item.get('processing'))}\n"
f"- 输出:{text(item.get('output'))}\n"
f"- 核心程度:{text(item.get('role'))}\n"
f"- 选型原因:{text(item.get('selection_reason'))}\n"
f"- 移除后的影响:{text(item.get('removal_impact'))}\n"
f"- 证据位置:{text(item.get('evidence_location'))}"
)
google_technology = f"""# Google 技术与实现证据
{chr(10).join(google_lines) or '未提供 Google 技术记录'}
## 技术材料
- 架构图:{text(tech['architecture_file'])}
- 代码仓库:{text(tech['repository_url'])}
- 评审版本:{text(tech['commit_id'])}
- 部署说明:{text(tech['deployment_notes'])}
- 保密限制:{text(tech['confidentiality_notes'])}
### 核心代码或证据位置
{bullets(tech['code_evidence_paths'])}
### 外部依赖
{bullets(tech['dependencies'])}
"""
market_localization = f"""# 出海市场、社会价值与本地化
## 出海落地
- 首个市场:{text(market['first_market'])}
- 市场选择原因:{text(market['market_rationale'])}
- 首批用户获取方式:{text(market['user_acquisition'])}
- 使用条件:{text(market['usage_conditions'])}
- 成本范围:{text(market['cost_range'])}
- 六个月计划:{text(market['six_month_plan'])}
### 主要障碍
{bullets(market['barriers'])}
## 社会价值
{text(market['social_impact_path'])}
### 影响指标
{bullets(market['impact_metrics'])}
## 本地化
- 目标文化或地区:{text(loc['target_culture'])}
- 是否经过当地用户测试:{text(loc['tested_with_local_users'])}
- 测试人数:{text(loc['test_count'], '0')}
- 测试后的修改:{text(loc['changes_after_testing'])}
### 当地习惯
{bullets(loc['habits'])}
### 用户顾虑
{bullets(loc['user_concerns'])}
### 非翻译型适配
{bullets(loc['adaptations'])}
### 本地化证据
{bullets(loc['evidence'])}
"""
innovation_lines = []
for label, values in [("技术创新", data["innovation"]["technical"]), ("产品与体验创新", data["innovation"]["product_experience"])]:
innovation_lines.append(f"## {label}")
if not values:
innovation_lines.append("\n未提供")
for index, item in enumerate(values, 1):
innovation_lines.append(
f"\n### {index}. {text(item.get('title'))}\n\n"
f"- 常见做法:{text(item.get('common_approach'))}\n"
f"- 本作品的不同:{text(item.get('difference'))}\n"
f"- 实际效果:{text(item.get('effect'))}\n"
f"- 证据位置:{text(item.get('evidence_location'))}"
)
declarations = f"""# 原创性与合规声明
- 赛前已存在:{text(decl['existed_before_contest'])}
- 比赛期间新增:{text(decl['new_work_during_contest'])}
- 原创性确认:{text(decl['originality_confirmed'])}
- 评委访问和测试授权:{text(decl['review_access_consent'])}
## 小型附件索引
{bullets(data['attachments'])}
"""
outputs = {
"01-overview.md": overview,
"02-user-problem.md": user_problem,
"03-product-demo.md": product_demo,
"04-google-technology.md": google_technology,
"05-market-localization.md": market_localization,
"06-innovation.md": "# 创新说明\n\n" + "\n".join(innovation_lines) + "\n",
"07-declarations.md": declarations,
}
for filename, content in outputs.items():
(folder / filename).write_text(content.strip() + "\n", encoding="utf-8")
def validate(folder: Path, data: dict) -> dict:
errors, warnings = [], []
def missing(value) -> bool:
return value is None or value == "" or value == []
def warn_missing(value, label):
if missing(value):
warnings.append(f"missing scoring information: {label}")
if missing(data.get("schema_version")):
errors.append("missing schema_version")
if data.get("schema_version") != SCHEMA:
errors.append(f"unsupported schema_version: {data.get('schema_version')}")
if missing(data.get("submission_id")):
errors.append("missing submission_id")
basic = data.get("basic", {})
warn_missing(basic.get("contact", {}).get("wechat"), "微信号")
if missing(basic.get("contestant_name_certificate")):
errors.append("missing basic.contestant_name_certificate")
if missing(basic.get("contact", {}).get("phone")):
errors.append("missing basic.contact.phone")
if not isinstance(basic.get("has_team"), bool):
errors.append("basic.has_team must be answered true or false")
elif basic.get("has_team") and missing(basic.get("team_member_names_certificate")):
errors.append("missing basic.team_member_names_certificate for team entry")
elif not basic.get("has_team") and basic.get("team_member_names_certificate"):
warnings.append("inconsistent administrative information: 个人参赛不应填写团队成员证书姓名")
warn_missing(basic.get("track"), "参赛赛道")
if basic.get("track") and basic["track"] not in {"", "", ""}:
warnings.append("invalid scoring information: 参赛赛道应为真、善或美")
warn_missing(basic.get("status"), "当前状态")
warn_missing(basic.get("one_liner"), "一句话介绍")
warn_missing(basic.get("contact", {}).get("email"), "邮箱")
warn_missing(basic.get("target_markets"), "目标国家或地区")
warn_missing(basic.get("xiaohongshu_url"), "小红书作品链接")
if basic.get("xiaohongshu_url") and not valid_url(basic["xiaohongshu_url"]):
warnings.append("invalid optional URL: 小红书作品链接")
contact = basic.get("contact", {})
if not contact.get("email") and not contact.get("wechat"):
warnings.append("missing administrative information: 邮箱或微信号")
problem = data.get("problem", {})
warn_missing(problem.get("target_users"), "具体目标用户")
warn_missing(problem.get("scenario"), "典型使用场景")
warn_missing(problem.get("current_workaround"), "用户当前解决方式")
warn_missing(problem.get("pain_points"), "核心用户痛点和现有方式不足")
if not problem.get("evidence_summary") and not problem.get("evidence_links"):
warnings.append("scoring evidence missing: 用户问题证据")
solution = data.get("solution", {})
warn_missing(solution.get("summary"), "解决方案概述")
warn_missing(solution.get("user_value"), "用户价值")
warn_missing(solution.get("user_flow"), "完整用户流程")
warn_missing(solution.get("core_features"), "核心功能")
demo = data.get("demo", {})
warn_missing(demo.get("url"), "Demo或可运行文件链接")
if demo.get("url") and not valid_url(demo["url"]):
warnings.append("invalid optional URL: Demo链接")
if demo.get("published_channel") and not demo.get("published_product_name"):
warnings.append("incomplete scoring information: 已发布渠道缺少产品名称")
if demo.get("published_product_name") and not demo.get("published_channel"):
warnings.append("incomplete scoring information: 已发布产品缺少发布渠道")
warn_missing(demo.get("access_method"), "Demo访问方式")
warn_missing(demo.get("recommended_environment"), "推荐运行环境")
warn_missing(demo.get("expected_duration_minutes"), "预计体验时间")
steps = demo.get("experience_steps", [])
if not steps:
warnings.append("missing scoring information: Demo体验步骤")
elif not 3 <= len(steps) <= 5:
warnings.append("incomplete scoring information: Demo体验步骤建议为3至5步")
for index, step in enumerate(steps, 1):
if missing(step.get("action")):
warnings.append(f"incomplete scoring information: Demo第{index}步缺少操作")
if missing(step.get("expected_result")):
warnings.append(f"incomplete scoring information: Demo第{index}步缺少预期结果")
if not demo.get("availability_confirmed"):
warnings.append("missing scoring information: Demo评审期可用确认")
if demo.get("access_restricted") and not (folder / demo.get("private_access_file", "")).is_file():
warnings.append("restricted Demo has no private test-access file")
if not demo.get("fallback_download_url"):
warnings.append("missing scoring information: 备用Demo或下载链接")
elif not valid_url(demo["fallback_download_url"]):
warnings.append("invalid optional URL: 备用Demo或下载链接")
video = data.get("video", {})
warn_missing(video.get("url"), "2至5分钟演示视频链接")
if video.get("url") and not valid_url(video["url"]):
warnings.append("invalid optional URL: 演示视频链接")
duration = video.get("duration_minutes")
if duration is None:
warnings.append("missing scoring information: 演示视频时长")
elif not 2 <= duration <= 5:
warnings.append("incomplete scoring information: 演示视频应为2至5分钟")
video_contents = video.get("contents_confirmed", {})
for key, label in [("demo", "Demo演示"), ("user_problem", "用户问题"), ("google_technology", "Google技术说明"), ("innovation", "创新点说明"), ("localization", "本地化说明")]:
if not video_contents.get(key):
warnings.append(f"missing scoring information: 视频未确认包含{label}")
google_items = data.get("google_technology", [])
if not google_items:
warnings.append("missing scoring information: Google技术应用")
required_google = ["name", "capability", "feature", "input", "processing", "output", "role", "selection_reason", "removal_impact", "evidence_location"]
for index, item in enumerate(google_items, 1):
for key in required_google:
if missing(item.get(key)):
warnings.append(f"incomplete scoring information: Google技术第{index}项缺少{key}")
if item.get("role") and item["role"] not in {"核心", "支撑"}:
warnings.append(f"invalid scoring information: Google技术第{index}项角色应为核心或支撑")
innovation = data.get("innovation", {})
warn_missing(innovation.get("technical"), "技术创新")
warn_missing(innovation.get("product_experience"), "产品与体验创新")
market = data.get("market", {})
warn_missing(market.get("first_market"), "首个计划进入的市场")
warn_missing(market.get("market_rationale"), "市场选择原因")
warn_missing(market.get("user_acquisition"), "首批用户获取方式")
warn_missing(market.get("usage_conditions"), "用户使用条件")
warn_missing(market.get("barriers"), "主要落地障碍")
localization = data.get("localization", {})
warn_missing(localization.get("target_culture"), "目标文化或地区")
warn_missing(localization.get("habits"), "当地用户习惯")
warn_missing(localization.get("user_concerns"), "当地用户情绪、顾虑或限制")
warn_missing(localization.get("adaptations"), "语言翻译之外的产品适配")
if not localization.get("tested_with_local_users"):
warnings.append("scoring evidence missing: 未经过当地用户测试")
if not localization.get("evidence"):
warnings.append("scoring evidence missing: 本地化研究或测试证据")
declarations = data.get("declarations", {})
if not isinstance(declarations.get("existed_before_contest"), bool):
errors.append("declarations.existed_before_contest must be answered true or false")
if missing(declarations.get("new_work_during_contest")):
errors.append("missing declarations.new_work_during_contest")
if declarations.get("originality_confirmed") is not True:
errors.append("declarations.originality_confirmed must be true")
if declarations.get("review_access_consent") is not True:
errors.append("declarations.review_access_consent must be true")
for path in folder.rglob("*"):
if path.is_file() and path.stat().st_size > 10 * 1024 * 1024:
warnings.append(f"large local file should be a cloud link: {path.relative_to(folder)}")
shipping_path = folder / "private/09-gift-shipping.json"
if not shipping_path.is_file():
warnings.append("missing administrative information: 礼品邮寄信息文件")
else:
try:
shipping = json.loads(shipping_path.read_text(encoding="utf-8"))
for key, label in [("recipient_name", "礼品收件人姓名"), ("phone", "礼品收件电话"), ("address", "礼品邮寄地址")]:
if missing(shipping.get(key)):
warnings.append(f"missing administrative information: {label}")
except json.JSONDecodeError:
warnings.append("invalid administrative information: 礼品邮寄信息文件不是有效JSON")
return {"schema_version": SCHEMA, "submission_id": data.get("submission_id"), "valid": not errors, "errors": errors, "warnings": warnings}
def command_init(args) -> int:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{1,63}", args.id):
raise ValueError("submission ID must be 2-64 filesystem-safe characters")
folder = Path(args.root).expanduser().resolve() / args.id
if folder.exists() and any(folder.iterdir()):
raise ValueError(f"refusing to overwrite non-empty folder: {folder}")
folder.mkdir(parents=True, exist_ok=True)
for subdir in ["private", "evidence/user-research", "evidence/technical", "evidence/localization"]:
(folder / subdir).mkdir(parents=True, exist_ok=True)
data = blank_submission(args.id, args.title)
(folder / "00-submission.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
(folder / "private/08-test-access.md").write_text(
"# 评审测试账号(敏感)\n\n"
"> 仅填写专用临时测试账号。禁止填写 API Key、个人密码、生产凭据或真实用户数据。\n\n"
"- Demo\n- 测试账号:\n- 临时密码:\n- 有效期:\n- 额外说明:\n",
encoding="utf-8",
)
(folder / "private/09-gift-shipping.json").write_text(
json.dumps(
{
"schema_version": "vibe-a-thon-gift-shipping/v1",
"submission_id": args.id,
"recipient_name": "",
"phone": "",
"address": "",
},
ensure_ascii=False,
indent=2,
) + "\n",
encoding="utf-8",
)
render(folder, data)
print(folder)
return 0
def command_render(args) -> int:
folder = Path(args.folder).expanduser().resolve()
data = load_submission(folder)
data["updated_at"] = now_iso()
(folder / "00-submission.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
render(folder, data)
print(f"rendered {folder}")
return 0
def command_validate(args) -> int:
folder = Path(args.folder).expanduser().resolve()
result = validate(folder, load_submission(folder))
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["valid"] else 1
def main() -> int:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
init_parser = subparsers.add_parser("init")
init_parser.add_argument("--root", required=True)
init_parser.add_argument("--id", required=True)
init_parser.add_argument("--title", default="")
init_parser.set_defaults(func=command_init)
render_parser = subparsers.add_parser("render")
render_parser.add_argument("folder")
render_parser.set_defaults(func=command_render)
validate_parser = subparsers.add_parser("validate")
validate_parser.add_argument("folder")
validate_parser.set_defaults(func=command_validate)
args = parser.parse_args()
try:
return args.func(args)
except (ValueError, KeyError, json.JSONDecodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

6
.gitignore vendored
View File

@@ -1,3 +1,7 @@
/build/
# 大模型素材:本地下载用于上传到 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 — 现场记忆点
每写一个功能,问自己:这条提升了上面哪一项?如果都没有,就别做。

103
CLAUDE.md
View File

@@ -1,4 +1,4 @@
# 康记 / 体己 —— 工程前提
# 康 —— 工程前提
> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。
> 任何 IDE/Claude 会话开始干活前,先读这份文件。
@@ -7,7 +7,7 @@
## 1. 产品定位
- **名字**:康(对内代号 体己 / Tiji)
- **名字**:康(对内代号 Kangkang)
- **形态**:iOS 原生 App,SwiftUI + SwiftData
- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答
- **目标用户**:不愿把体检/化验报告交给云端的普通人
@@ -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 (MLX 4bit 量化) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
| VL | Qwen2.5-VL-3B (MLX 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 现场重装时用)
@@ -92,37 +95,27 @@ VL prompt 必须:
## 5. 数据模型(SwiftData)
现有 3`@Model`,要新增 2 个:
**当前 schema(2026-05-26)**:7@Model
```swift
// ( Models/Models.swift)
@Model class Indicator { name, value, unit, range, statusRaw, note, capturedAt }
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt }
@Model class DiaryEntry { content, createdAt }
//
// Indicator + report: Report?
// Indicator + asset: Asset?
// Indicator + pinned: Bool C2 "" true,Trends pinned
// Report + indicators: [Indicator] @Relationship cascade
// Report + assets: [Asset] @Relationship cascade
// DiaryEntry + tags: [String] VL/LLM
// @Model
@Model class Asset {
var relativePath: String // Vault/
var mimeType: String
var bytes: Int
var createdAt: Date
@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 ChatTurn {
var question: String
var answer: String
var referencedIndicatorIDs: [String]
var referencedReportIDs: [String]
var createdAt: Date
var decodeRate: Double // ,Me
@Model class UserProfile { // App (UserProfileStore.loadOrCreate)
birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw,
allergies, chronicConditions, familyHistory, currentMedications,
updatedAt
}
```
@@ -149,18 +142,21 @@ VL prompt 必须:
## 7. 信息架构
```
TabBar: [页] [+ 记录] [趋势] [我的]
│ │ │ │
│ │ │ └─ 模型管理 / Face ID / 关于
│ │ └─ 折线图 + AI 一句话解读
└─ Modal: 选择 拍一张 / 写日记 / 问问看
└─ 问候 + 今日摘要 + 时间线 + 影像档案入口
TabBar: [页] [记录] [+ 新建] [趋势] [我的]
│ │ │ │
│ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于
│ │ └─ 折线图 + AI 一句话解读
│ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状
│ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组)
└─ 问候 + 今日摘要 + 进行中症状 + 最近时间线
```
- **3 Tab 不变**,中间 + 号是 Sheet
- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab
- "+ 新建" 是 sheet 不是 Tab
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
- "问问看"入口除了在 RecordSheet 里,首页摘要卡片下方也有一个常驻入口
- 历史时间线在首页下半部分,不单独开 Tab
- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势),
下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入
- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页
### 7.1 档案库 C1 / C2 导航(看的一半)
@@ -205,8 +201,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
## 8. 现有代码状态(2026-05-25)
```
体己/
├── App/TijiApp.swift ✅ SwiftData container 已建
康康/
├── App/KangkangApp.swift ✅ SwiftData container 已建
├── RootView.swift ✅ 3 Tab + RecordSheet 已建
├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn
├── DesignSystem/ ✅ Tokens + Components,沿用
@@ -219,9 +215,10 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
└── Me/ ❌ 只有 placeholder
待建:
├── AI/ AIRuntime, LLMSession, VLSession, Prompts/
├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore + TokenChunk ✅;VLSession + Prompts/
├── Debug/DebugAIRunner.swift ✅ DEBUG-only AI 自检入口
├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService
├── Persistence/FileVault.swift 原图加密目录管理
├── Persistence/FileVault.swift 原图加密目录管理
├── Security/AppLock.swift ❌ Face ID 启动锁
├── Features/Ask/ ❌ AskSheet (RAG 问答 UI)
├── Features/Archive/
@@ -255,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
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 浏览(读),不要合并复用主框架
@@ -265,8 +262,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
| 周次 | 必交付 |
|---|---|
| W1 末 / **W2 当前** | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果) |
| 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 + 重新解读) |
@@ -287,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)
}

0
README.md Normal file
View File

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,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<filter id="wordShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#0f3f33" flood-opacity="0.42"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="#8ED9E4"/>
<circle cx="748" cy="286" r="124" fill="#FFF1A8"/>
<circle cx="748" cy="286" r="76" fill="#FFFFFF"/>
<path d="M0 426C210 350 404 377 592 500C731 592 875 608 1024 506V1024H0V426Z" fill="#2C7E79"/>
<path d="M0 612C226 533 436 536 624 631C774 710 903 690 1024 580V1024H0V612Z" fill="#1F6761"/>
<path d="M0 780C232 678 436 641 634 693C799 743 924 711 1024 594V1024H0V780Z" fill="#53A247"/>
<path d="M0 888C232 807 447 780 656 825C812 863 931 825 1024 722V1024H0V888Z" fill="#82CC52"/>
<path d="M318 1024C506 861 725 727 1024 604V1024H318Z" fill="#B2D95E"/>
<path
d="M188 560H268L324 416L428 704L500 560H594"
fill="none"
stroke="#F4FFFC"
stroke-width="36"
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.96"/>
<text
x="512"
y="846"
fill="#FFFFFF"
stroke="#145D46"
stroke-width="5"
stroke-opacity="0.68"
paint-order="stroke fill"
font-family="Hiragino Sans GB, Songti SC, Helvetica Neue, Arial, sans-serif"
font-size="136"
font-weight="600"
text-anchor="middle"
letter-spacing="8"
filter="url(#wordShadow)">康康</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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,107 @@
# 康康 · Google 赛道重定位与改造说明
> 面向「入围 Top 30」评审表(完成度与传播 25% / Google AI 深度 25% / 创新与 Vibe Coding 20% / Tech for Good 出海 20% / 第 5 维度 ~10% 待补全)。
> 本文是把原 MNN/SME2 赛道作品改投 Google 赛道的定位与技术映射。改造日期:2026-07-01。
---
## 0. 一句话定位(新)
**A privacy-first, offline-capable health companion for low-connectivity and underserved communities — understand your medical reports and medicines even without a doctor or internet, in your own language.**
中文:**给弱网/缺医地区与跨国语言障碍人群的隐私优先健康助手——没有医生、没有网络,也能读懂自己的化验单和药盒,用你的母语。**
> 注意叙事转向:旧版讲「中国人不想把体检报告传云端」(纯国内隐私焦虑);新版讲「全球低资源人群 + 跨境语言障碍」(出海 + Tech for Good)。同一套 App、同一套 UI/数据,**主要改模型底座 + 叙事**,代码改动集中在 AI 层。
---
## 1. hybrid 架构:为什么这样用 Google 技术(评审 25% 核心)
评审明确要求「Google 技术参与**核心能力构建**,不是包装卖点」并「说明**为什么**用这些 Google 技术」。康康的回答:
| 能力 | 用的 Google 技术 | 为什么非它不可 |
|---|---|---|
| **端侧离线推理(默认)** | **Gemma-3n E2B**(Google 开源多模态小模型,端侧 4bit) | 弱网/无网地区要「飞行模式也能用」。Gemma-3n 是 Google 专为手机优化的开源模型,**离线、隐私、不依赖账号**——这是低资源场景的刚需,云模型做不到。 |
| **读报告/药盒原图 → 结构化**(联网增强) | **Gemini 2.5 Flash 多模态 API** | 端侧小模型读密集化验单小字不稳;Gemini 多模态能直接读图出结构化指标 + 证据位置框。**这是产品第一卖点「拍一张→变档案」的真正承载者。** |
| **深度解读 / 多语言** | **Gemini 2.5 Flash** | 跨国用户拿到外语报告,需要「翻译 + 大白话解读」。Gemini 的多语言与推理能力正对口出海语言障碍痛点。 |
| **Prompt 设计 / 调试** | **Google AI Studio** | 所有 Gemini/Gemma 的 prompt 在 AI Studio 里迭代;API key 也由 AI Studio 免费签发。 |
**hybrid 的产品逻辑**:隐私优先——**默认端侧 Gemma-3n,数据不出设备**;只有用户在「我的 · 云端 AI」**主动开启**后,「读原图 / 深度解读 / 多语言」才走 Gemini 云端。**断网或额度耗尽自动回退端侧,功能不中断。** 这既拿满「Google 产品深度」,又守住隐私品牌,还天然适配「时有时无的网络」这一低资源现实。
> 代码落点:`GeminiBackend.swift`(REST 直调,SSE 流式 + 多模态)、`AIRuntime.generateCloud / analyzeReportCloud`(不进 OOM 闸门,可与端侧并发)、`CaptureService.runVL`(云端优先读图、失败回退端侧 OCR+文本)、`InferenceSettingsView`(云端开关 + key)。
### 生产级升级路径(写给评委看工程成熟度)
demo 用 AI Studio 直发 key 的 REST 方案(零新增 SPM 依赖、即时可编译)。生产应升级到 **Firebase AI Logic**(Swift SDK):App Check 防盗用、客户端不裸存 key、内建端侧↔云端 hybrid 回退。迁移点已在 `GeminiBackend` 注释标注,换 SDK 不动上层 Service。
---
## 2. 重定位:目标用户 / 市场 / 影响路径(评审 Tech for Good 20% + 创新 20%)
评审要「真实人群 / 弱势 / 低资源 / 全球化」+「海外落地可行性,不只是善意叙事」+「目标用户、场景、影响路径、后续扩展」。
- **目标人群**
1. 低资源/弱网地区居民:缺医少药、网络不稳,拿到化验单无人解读。
2. 跨境人群:移民、留学生、外派、旅居者,面对**外语**医疗文件看不懂。
3. 慢病/老人照护者:替家人留存报告、复查前整理重点。
- **真实问题**:看不懂(术语+外语)、找不到(报告散落)、不敢传(隐私)、没医生在身边。
- **海外落地可行性(不是善意叙事)**
- 离线即可用 → 适配低带宽市场,不依赖稳定网络与数据套餐。
- 隐私默认端侧 → 契合 GDPR 等严监管市场的健康数据合规。
- 多语言 → 一套产品覆盖多语种,边际成本低。
- **影响路径**:个人读懂自己 → 家庭健康档案管理 → 低资源社区的基层健康工作者辅助工具。
- **后续扩展**:更多语种、更多文档类型(疫苗本/处方/影像)、与本地公共健康项目对接、可选导出给当地医生。
---
## 3. 逐维度自检(改造后该拿的分)
| 维度 | 权重 | 改造后状态 |
|---|---|---|
| 完成度与传播 | 25% | 可运行 App + 闭环(拍报告→识别→档案→趋势→问答→摘要);Gemini 恢复真·读图。**待补:3-5 分钟 demo 视频(见 §4)+ 重写图文(§5)** |
| Google AI 深度 | 25% | Gemma-3n(端侧)+ Gemini 多模态(读图)+ Gemini(深度/多语言)+ AI Studio。**均在核心链路,非包装。** |
| 创新 & Vibe Coding | 20% | hybrid 隐私优先 + 离线可用 + 多语言;Claude Code/Gemini Code Assist 全程 vibe coding。叙事已转出海。 |
| Tech for Good 出海 | 20% | 低资源/弱网/跨国语言障碍,见 §2。 |
| 第 5 维度(~10%) | ? | **未知,待补全(见 §6 开放项)** |
---
## 4. 现场 Demo 脚本(3-5 分钟,先证「能跑」)
> 评审第一条:核心功能可实际运行,3-5 分钟讲清主要价值。把最强证据放最前。
1. **(0:00-0:30)问题**:一句话——「全球很多人拿到化验单看不懂,身边没医生,也不敢把隐私报告传云端。」
2. **(0:30-1:30)拍一张 → 变档案**:现场拍/导入一张报告 → Gemini 多模态读出结构化指标 + 异常高亮 → 存进档案。**这是第一卖点。**
3. **(1:30-2:15)飞行模式离线**:开飞行模式,演示端侧 Gemma-3n 仍能做文本解读/问答——「没网也能用」。这是低资源场景的杀手锏。
4. **(2:15-3:00)多语言**:拿一张**外语**报告 → Gemini 翻译 + 母语大白话解读。出海痛点直球。
5. **(3:00-3:45)长期价值**:趋势页 AI 解读 + 「就诊前 30 秒整理重点」摘要。
6. **(3:45-4:30)隐私 + 技术收尾**:「我的 · 云端 AI」开关讲 hybrid;一句话点出 Gemma-3n + Gemini + AI Studio。
视频素材:片头/转场可用 **Veo / Google Flow** 生成;录屏需真机配合(端侧 + 飞行模式只能真机)。
---
## 5. 传播物料改写要点(小红书图文)
- 标题从「MNN/SME2 端侧」改为「不上传、断网也能用的健康 AI(Gemma + Gemini)」。
- 9 宫格替换:把旧「MNN-SME2 性能自检」图换成「云端 AI 设置页(Gemini 开关)」+「飞行模式离线生成」+「外语报告→母语解读」。
- 正文三个创新点改为:① 隐私优先 hybrid;② 端侧离线可用(低资源);③ 多语言出海。
- 删除一切「100% 本地、不上云」绝对化表述,统一为「隐私优先,云端可选」。
---
## 6. 你需要做的事 + 开放项(不阻塞已完成代码)
**必须由你操作(我无法代办)**
1.**aistudio.google.com** 免费签发 Gemini API Key。
2. App「我的 · 推理引擎 · 云端 AI · Gemini」打开开关、粘贴 key。
3. 真机录 demo 视频(端侧 + 飞行模式只能真机)。
**开放项(需要你提供信息)**
4. **评分表第 5 个维度(~10%)被截断**——把完整评审标准或比赛出处发我,补进 §3,别再漏考核点。
5. **平台权衡**:当前保 iOS。若评委更看重 Android 生态(Android Studio/Flutter/Gemini Nano),可再评估,但 2-3 周内不建议重写。
---
## 7. 一句话给你自己
上一个比赛输在「为命题做的东西没按被考核的点证明」。这次的红线是:**Gemini 必须在核心链路里被看到在跑(读图/多语言),而不是设置页里一个没人点的开关。** demo 视频里 Gemini 读出一张报告的那 10 秒,比任何文案都值钱。

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,462 @@
from __future__ import annotations
from pathlib import Path
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Inches, Pt, RGBColor
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "docs" / "release" / "康康创意方案文档.docx"
PICS = ROOT / "docs" / "release" / "xhs-9grid"
BLUE = RGBColor(46, 116, 181)
DARK_BLUE = RGBColor(31, 77, 120)
INK = RGBColor(31, 31, 31)
MUTED = RGBColor(95, 95, 95)
LIGHT = "F4F6F9"
PALE_BLUE = "E8EEF5"
GREEN = RGBColor(58, 112, 76)
BRICK = RGBColor(154, 78, 65)
def set_run_font(run, size=None, bold=None, color=None, font="PingFang SC"):
run.font.name = font
run._element.rPr.rFonts.set(qn("w:ascii"), "Calibri")
run._element.rPr.rFonts.set(qn("w:hAnsi"), "Calibri")
run._element.rPr.rFonts.set(qn("w:eastAsia"), font)
if size is not None:
run.font.size = Pt(size)
if bold is not None:
run.bold = bold
if color is not None:
run.font.color.rgb = color
def set_paragraph_font(paragraph, size=11, color=INK, font="PingFang SC"):
for run in paragraph.runs:
set_run_font(run, size=size, color=color, font=font)
def set_cell_shading(cell, fill):
tc_pr = cell._tc.get_or_add_tcPr()
shd = tc_pr.find(qn("w:shd"))
if shd is None:
shd = OxmlElement("w:shd")
tc_pr.append(shd)
shd.set(qn("w:fill"), fill)
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
tc = cell._tc
tc_pr = tc.get_or_add_tcPr()
tc_mar = tc_pr.first_child_found_in("w:tcMar")
if tc_mar is None:
tc_mar = OxmlElement("w:tcMar")
tc_pr.append(tc_mar)
for m, v in [("top", top), ("start", start), ("bottom", bottom), ("end", end)]:
node = tc_mar.find(qn(f"w:{m}"))
if node is None:
node = OxmlElement(f"w:{m}")
tc_mar.append(node)
node.set(qn("w:w"), str(v))
node.set(qn("w:type"), "dxa")
def set_table_geometry(table, widths):
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = False
tbl = table._tbl
tbl_pr = tbl.tblPr
tbl_w = tbl_pr.find(qn("w:tblW"))
if tbl_w is None:
tbl_w = OxmlElement("w:tblW")
tbl_pr.append(tbl_w)
tbl_w.set(qn("w:w"), str(sum(widths)))
tbl_w.set(qn("w:type"), "dxa")
tbl_grid = tbl.tblGrid
if tbl_grid is None:
tbl_grid = OxmlElement("w:tblGrid")
tbl.append(tbl_grid)
for child in list(tbl_grid):
tbl_grid.remove(child)
for width in widths:
grid_col = OxmlElement("w:gridCol")
grid_col.set(qn("w:w"), str(width))
tbl_grid.append(grid_col)
for row in table.rows:
for idx, cell in enumerate(row.cells):
tc_pr = cell._tc.get_or_add_tcPr()
tc_w = tc_pr.find(qn("w:tcW"))
if tc_w is None:
tc_w = OxmlElement("w:tcW")
tc_pr.append(tc_w)
tc_w.set(qn("w:w"), str(widths[idx]))
tc_w.set(qn("w:type"), "dxa")
set_cell_margins(cell)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
def table_border(table, color="DADCE0", size="6"):
tbl_pr = table._tbl.tblPr
borders = tbl_pr.first_child_found_in("w:tblBorders")
if borders is None:
borders = OxmlElement("w:tblBorders")
tbl_pr.append(borders)
for edge in ["top", "left", "bottom", "right", "insideH", "insideV"]:
tag = f"w:{edge}"
element = borders.find(qn(tag))
if element is None:
element = OxmlElement(tag)
borders.append(element)
element.set(qn("w:val"), "single")
element.set(qn("w:sz"), size)
element.set(qn("w:space"), "0")
element.set(qn("w:color"), color)
def add_para(doc, text="", style=None, size=11, bold=False, color=INK, align=None, after=8):
p = doc.add_paragraph(style=style)
p.paragraph_format.space_after = Pt(after)
p.paragraph_format.line_spacing = 1.25
if align is not None:
p.alignment = align
run = p.add_run(text)
set_run_font(run, size=size, bold=bold, color=color)
return p
def add_heading(doc, text, level=1):
p = doc.add_paragraph(style=f"Heading {level}")
p.paragraph_format.keep_with_next = True
run = p.add_run(text)
if level == 1:
set_run_font(run, 16, True, BLUE)
p.paragraph_format.space_before = Pt(18)
p.paragraph_format.space_after = Pt(10)
elif level == 2:
set_run_font(run, 13, True, BLUE)
p.paragraph_format.space_before = Pt(12)
p.paragraph_format.space_after = Pt(6)
else:
set_run_font(run, 12, True, DARK_BLUE)
p.paragraph_format.space_before = Pt(8)
p.paragraph_format.space_after = Pt(4)
return p
def add_bullet(doc, text, level=0):
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.375)
p.paragraph_format.first_line_indent = Inches(-0.194)
p.paragraph_format.space_after = Pt(4)
p.paragraph_format.line_spacing = 1.208
run = p.add_run(text)
set_run_font(run, 10.5, color=INK)
return p
def add_callout(doc, title, body, fill=LIGHT, title_color=DARK_BLUE):
table = doc.add_table(rows=1, cols=1)
set_table_geometry(table, [9360])
table_border(table, color="E1E5EA", size="4")
cell = table.cell(0, 0)
set_cell_shading(cell, fill)
cell.text = ""
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(4)
r = p.add_run(title)
set_run_font(r, 11, True, title_color)
p2 = cell.add_paragraph()
p2.paragraph_format.space_after = Pt(0)
p2.paragraph_format.line_spacing = 1.22
r2 = p2.add_run(body)
set_run_font(r2, 10.5, color=INK)
doc.add_paragraph().paragraph_format.space_after = Pt(4)
return table
def add_label_table(doc, rows, widths=(2300, 7060), header=None):
table = doc.add_table(rows=0, cols=2)
set_table_geometry(table, list(widths))
table_border(table, color="DADCE0", size="5")
if header:
row = table.add_row()
row.cells[0].merge(row.cells[1])
cell = row.cells[0]
set_cell_shading(cell, PALE_BLUE)
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
r = p.add_run(header)
set_run_font(r, 10.5, True, DARK_BLUE)
for label, value in rows:
row = table.add_row()
for cell in row.cells:
set_cell_shading(cell, "FFFFFF")
p0 = row.cells[0].paragraphs[0]
p0.paragraph_format.space_after = Pt(0)
r0 = p0.add_run(label)
set_run_font(r0, 10, True, DARK_BLUE)
p1 = row.cells[1].paragraphs[0]
p1.paragraph_format.space_after = Pt(0)
p1.paragraph_format.line_spacing = 1.18
r1 = p1.add_run(value)
set_run_font(r1, 10, color=INK)
doc.add_paragraph().paragraph_format.space_after = Pt(4)
return table
def add_matrix(doc, headers, rows, widths):
table = doc.add_table(rows=1, cols=len(headers))
set_table_geometry(table, widths)
table_border(table, color="DADCE0", size="5")
for idx, h in enumerate(headers):
cell = table.cell(0, idx)
set_cell_shading(cell, PALE_BLUE)
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
r = p.add_run(h)
set_run_font(r, 9.5, True, DARK_BLUE)
for row_data in rows:
row = table.add_row()
for idx, text in enumerate(row_data):
cell = row.cells[idx]
set_cell_shading(cell, "FFFFFF")
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.16
r = p.add_run(text)
set_run_font(r, 9.2, color=INK)
doc.add_paragraph().paragraph_format.space_after = Pt(4)
return table
def setup_styles(doc):
styles = doc.styles
normal = styles["Normal"]
normal.font.name = "Calibri"
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC")
normal.font.size = Pt(11)
normal.font.color.rgb = INK
normal.paragraph_format.space_after = Pt(8)
normal.paragraph_format.line_spacing = 1.333
normal.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
for style_name in ["Heading 1", "Heading 2", "Heading 3", "List Bullet"]:
style = styles[style_name]
style.font.name = "Calibri"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC")
def set_header_footer(section):
header = section.header.paragraphs[0]
header.text = ""
header.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = header.add_run("康康 Kangkang 创意方案")
set_run_font(run, 9, color=MUTED)
footer = section.footer.paragraphs[0]
footer.text = ""
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = footer.add_run("本方案仅描述健康记录与科普式解读能力,不构成医疗诊断或用药建议")
set_run_font(run, 8.5, color=MUTED)
def add_title_page(doc):
add_para(doc, "手机上的 AI 创意方案", size=12, bold=True, color=GREEN, align=WD_ALIGN_PARAGRAPH.CENTER, after=10)
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
title.paragraph_format.space_after = Pt(6)
r = title.add_run("康康:本地优先的个人健康档案")
set_run_font(r, 24, True, DARK_BLUE)
subtitle = doc.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
subtitle.paragraph_format.space_after = Pt(18)
r2 = subtitle.add_run("100% 本地推理 · 个人健康影像档案 · 大白话解读 · 结构化 RAG 问答")
set_run_font(r2, 12.5, color=MUTED)
add_label_table(
doc,
[
("项目形态", "iOS 原生 AppSwiftUI + SwiftData。"),
("目标用户", "不愿把体检报告、化验单、症状和用药记录交给云端的普通用户。"),
("核心主张", "健康数据默认留在手机里,本地模型负责整理、解释和检索。"),
("技术主线", "Qwen3.5-2B + MNN + Arm SME2/NEONMLX Swift 作为模拟器和兜底后端。"),
("边界声明", "不做医疗诊断、剂量推荐、急诊判断、医生预约、账号系统或数据上云。"),
],
header="项目概览",
)
add_callout(
doc,
"一句话创意",
"把体检报告、化验指标、症状、日记和用药记录收进一个本地健康档案,再让手机端本地大模型用普通人能听懂的语言帮助整理、回顾和就诊前准备。",
fill="F7FAF7",
title_color=GREEN,
)
doc.add_page_break()
def add_sources(doc):
add_heading(doc, "依据来源", 1)
add_bullet(doc, "AGENTS.md项目定位、技术选型、AI 链路、数据模型、隐私边界和六周 demo 目标。")
add_bullet(doc, "康康/AI/InferenceEngine.swift 与 AIRuntime.swiftMNN/MLX 双后端、SME2 探测、actor 串行推理闸门。")
add_bullet(doc, "康康/Services/HealthExportService.swift本地 RAG、意图抽取、结构化检索、就诊摘要生成和失败兜底。")
add_bullet(doc, "docs/release/app-store-metadata.md 与小红书项目介绍:用户价值、隐私表达和非医疗器械边界。")
def main():
doc = Document()
section = doc.sections[0]
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1)
section.right_margin = Inches(1)
section.header_distance = Inches(0.492)
section.footer_distance = Inches(0.492)
setup_styles(doc)
set_header_footer(section)
add_title_page(doc)
add_heading(doc, "1. 应用场景", 1)
add_para(doc, "康康面向的是普通人的日常健康信息管理,而不是医院内部系统或专业诊疗工具。产品重点放在体检后、复查前、慢性指标长期观察、看医生前准备等高频生活场景。")
add_matrix(
doc,
["场景", "用户动作", "康康提供的价值"],
[
("体检或化验后", "拍摄报告或从相册导入。", "本地识别关键指标、参考范围和异常状态,形成可检索的报告档案。"),
("长期指标记录", "记录血压、血糖、体重、血脂等指标。", "自动沉淀趋势曲线,用大白话说明最近变化,降低读图门槛。"),
("症状与日记", "随手记身体感受、睡眠、疼痛、用药等。", "把零散记录整理成时间线,必要时由本地 AI 追问补全。"),
("就诊前准备", "输入“把最近一个月整理给医生看”。", "本地检索症状、指标、报告和用药,生成可复制/分享的就诊摘要。"),
("家庭健康管理", "为父母或自己保存报告和复查信息。", "减少纸质报告丢失、相册翻找和口头回忆遗漏。"),
],
[1600, 2600, 5160],
)
add_heading(doc, "2. 用户痛点", 1)
add_bullet(doc, "看不懂:体检报告和化验单充满缩写、箭头、参考范围,普通人很难快速判断哪些值得关注。")
add_bullet(doc, "找不到:健康信息散落在纸质报告、相册截图、备忘录和聊天记录里,复查或就医时很难完整回溯。")
add_bullet(doc, "不敢传:健康报告包含年龄、医院、检查结果、既往问题等高度敏感信息,上传给云端 AI 会带来隐私顾虑。")
add_bullet(doc, "记不全:看医生时常常忘记症状持续时间、近期指标变化、在服药物和过敏史。")
add_bullet(doc, "工具割裂:传统健康记录 App 重记录轻解释AI 聊天工具会解释但不沉淀长期个人档案。")
doc.add_page_break()
add_heading(doc, "3. 技术方案", 1)
add_heading(doc, "3.1 模型选型", 2)
add_para(doc, "主模型选择 Qwen3.5-2B原因是它在移动端内存和速度预算内更适合 demo 落地,同时承担文本解读和视觉识别两类任务,避免拆分多套模型造成下载、存储和加载复杂度上升。")
add_label_table(
doc,
[
("主路径模型", "Qwen3.5-2B-MNN约 1.2GB,面向真机端侧推理。"),
("兜底模型", "MLX Swift 对应的 Qwen3.5-2B-4bit主要用于模拟器、调试和 MNN 不可用时的兜底。"),
("能力覆盖", "报告/药盒等图片识别、健康文本整理、趋势解释、身体档案问答和就诊摘要。"),
("取舍逻辑", "不用更大模型追求极限效果,而是优先保证端侧速度、内存可控、下载可接受和 demo 可复现。"),
],
header="模型选型摘要",
)
add_heading(doc, "3.2 推理框架", 2)
add_para(doc, "推理框架采用双后端策略:真机主路径为阿里开源 MNN面向挑战赛的 Qwen + MNN + SME2 端侧 CPU 推理MLX Swift 作为 Apple 官方 Metal GPU 兜底,用于模拟器和对照测试。")
add_matrix(
doc,
["模块", "方案", "作用"],
[
("MNNBackend", "ObjC++ 桥接 MNN LLM 能力。", "在真机上加载 Qwen3.5-2B-MNN支撑文本生成与视觉分析。"),
("InferenceEngine", "auto / mnn / mlx 三种偏好。", "真机优先 MNNMNN 不可用时自动回退 MLX。"),
("AIRuntime", "actor 单例 + 推理闸门。", "串行化模型加载、文本生成和视觉推理,避免并发 OOM。"),
("ModelStore", "Application Support/Models。", "管理模型下载、就绪校验和现场旁路导入。"),
],
[1900, 3200, 4260],
)
add_heading(doc, "3.3 端侧适配思路", 2)
add_bullet(doc, "设备优先级:支持 MNN 的真机默认走 MNN支持 SME2 的 A19/iPhone17 走 SME2 加速,旧设备回退 NEON模拟器走 MLX。")
add_bullet(doc, "内存控制AIRuntime 使用 actor 内信号量,保证同一时间只有一个重推理任务占用模型内存;加载 LLM 前卸载 VL加载 MNN 前卸载 MLX 侧模型。")
add_bullet(doc, "体验兜底:模型未就绪时 App 仍可启动AI 入口提示前往模型管理;意图抽取失败时回退近 30 天全表扫描;识别失败时回退手动录入。")
add_bullet(doc, "结构化 RAG不引入 embedding 模型,先由本地模型抽取意图和关键词,再用 SwiftData 检索指标、报告、症状和日记,最后拼接上下文生成回答。")
add_bullet(doc, "隐私保护:报告原图只保存到本地 VaultSwiftData 存结构化记录;使用 iOS completeFileProtection、Face ID 启动锁和永久删除。")
add_heading(doc, "4. 创新点", 1)
add_matrix(
doc,
["创新点", "说明", "差异化价值"],
[
("本地优先健康 AI", "AI 不依赖云端 API核心解读和问答在手机内完成。", "解决健康数据上传焦虑,形成隐私可信的 AI 体验。"),
("影像档案 + AI 解读闭环", "拍报告、识别、归档、趋势、问答、摘要串成一个系统。", "不是单点 OCR 或聊天框,而是长期可用的个人健康档案。"),
("MNN + SME2 端侧推理", "将 Qwen 模型通过 MNN 接入 iPhone CPU/SME2 路径。", "展示大模型在移动 CPU 上可复现运行的技术亮点。"),
("轻量结构化 RAG", "利用 SwiftData 已有结构化记录检索,不额外引入 embedding 模型。", "降低端侧存储和计算成本,响应更可控。"),
("明确医疗边界", "只做记录、整理和科普式解释,不做诊断、用药和急诊判断。", "既保留实用价值,也降低合规和误导风险。"),
],
[1800, 3900, 3660],
)
add_heading(doc, "5. 预期效果", 1)
add_callout(
doc,
"用户侧效果",
"用户可以把报告、指标、症状和用药从碎片化记录变成可检索、可回顾、可解释的个人健康档案;在看医生前,用一份摘要减少遗漏和重复描述。",
fill="F7FAF7",
title_color=GREEN,
)
add_callout(
doc,
"技术侧效果",
"验证 Qwen3.5-2B 在 MNN + SME2/NEON 端侧 CPU 路径上的可用性,形成模型下载、引擎选择、性能自检、推理串行、失败兜底和本地数据检索的一体化方案。",
fill="F4F6F9",
title_color=DARK_BLUE,
)
add_callout(
doc,
"展示侧效果",
"比赛或路演时可以通过飞行模式、本地模型管理页、性能自检 tok/s、报告识别前后对比和身体档案摘要清晰证明“不是云端套壳而是端侧 AI 创意应用”。",
fill="FFF6F2",
title_color=BRICK,
)
add_heading(doc, "6. 方案完整性", 1)
add_para(doc, "康康的完整性体现在产品、技术和安全边界三条线同时闭合:用户能完成从采集到回顾再到就诊摘要的闭环;技术上有模型、运行时、数据、服务层和 UI 的分层;安全上明确不引入云服务、不做账号、不做自研密码学。")
add_matrix(
doc,
["层级", "已覆盖能力", "设计原则"],
[
("采集层", "拍报告、记录指标、写日记、记症状、药品识别。", "入口轻,失败可手动补录。"),
("数据层", "SwiftData 模型Indicator、Report、DiaryEntry、Symptom、Asset、ChatTurn、UserProfile 等。", "结构化保存,便于检索和趋势计算。"),
("AI 层", "CaptureService、HealthExportService、TrendInsightService 经 AIRuntime 调用本地模型。", "UI 不直连模型,推理统一排队。"),
("展示层", "首页、记录、趋势、我的、模型管理、身体档案摘要。", "围绕 demo 核心卖点组织信息。"),
("隐私层", "本地 Vault、completeFileProtection、Face ID、永久删除、无账号无云。", "使用系统能力,不自造密码学。"),
],
[1500, 5000, 2860],
)
add_heading(doc, "7. 风险与边界控制", 1)
add_bullet(doc, "AI 输出仅作为健康记录整理与科普式解释,不作为医疗诊断、治疗、用药或剂量建议。")
add_bullet(doc, "识别结果必须允许用户核对和编辑,避免把模型错误直接落成事实。")
add_bullet(doc, "端侧模型受设备性能、内存和电量影响,需要模型未就绪、加载失败、低性能设备等状态提示。")
add_bullet(doc, "健康类内容表达要避免“治疗”“诊断”“疗效”等容易误导的词汇。")
# Visual appendix with the generated 9-grid overview if available.
overview = PICS / "00-9宫格总览.png"
if overview.exists():
doc.add_page_break()
add_heading(doc, "附录:展示素材示意", 1)
add_para(doc, "以下为小红书 9 宫格展示图总览,可用于说明产品闭环与技术亮点。", size=10.5, color=MUTED)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run()
run.add_picture(str(overview), width=Inches(5.6))
OUT.parent.mkdir(parents=True, exist_ok=True)
doc.save(OUT)
print(OUT)
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

View File

@@ -0,0 +1,83 @@
# 记录问诊 · 本地 SenseVoice 转写接入
> 「记录问诊」录完整段录音后,在本机用 **SenseVoice**(经 **sherpa-mnn** 跑在 **MNN 后端**)离线转写成文字,
> 再交本地 LLM 整理成问诊小结。全程本机、无网络。
>
> 代码已全部就位且**默认编译通过**:未接入 sherpa-mnn 时 `SenseVoiceBridge` 走桩,问诊自动回退系统端侧识别(SFSpeech)。
> 本文档是把真实 SenseVoice 跑起来的「构建 + 设备验证」步骤。
## 架构(按本项目模块边界 §3.1)
```
ConsultationSheet(UI)
→ 录音(ConsultationRecorder,m4a 落 Vault)
→ SenseVoiceASRService.transcribe(file) // 离线整段转写,无实时字幕
→ AIRuntime.runExclusiveForASR { … } // 进推理闸门 + 卸常驻 LLM/VL 腾内存(防 OOM)
→ SenseVoiceBridge(ObjC++) // sherpa-mnn C-API
→ sherpa-mnn → MNN 后端(CPU/SME2)
→ DiaryAssistService.organizeConsultation(text) // 本地 LLM 整理成问诊小结
→ 存为带「问诊」tag 的 DiaryEntry(录音挂 audio Asset)
```
- 引擎/模型未就绪 → 自动回退 SFSpeech;再不行 → 手动文字录入。任何一步都不卡死(红线 #5)。
- SenseVoice 是**非流式**:录音中只显示声纹动效,不显示实时字幕,结束后整段转写。
## 一、构建 sherpa-mnn.xcframework
```sh
MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/build-sherpa-mnn-xcframework.sh
```
- 复用 MNN 源码自带的 `apps/frameworks/sherpa-mnn/build-ios.sh`,产出 `Frameworks/sherpa-mnn.xcframework`
(已含 device arm64 + simulator,libtool 合并好的静态库 + `Headers/`)。
- 若 sherpa cmake 报找不到 MNN:先 `sh scripts/build-mnn-xcframework.sh` 构建 MNN,
`export MNN_LIB_DIR=<含 libMNN.a + include 的目录>` 重跑。
- Apple Silicon 上若不需要 Intel 模拟器,可在 `build-ios.sh` 里去掉 `simulator_x86_64` 段加速。
## 二、加进 Xcode 工程
1.`Frameworks/sherpa-mnn.xcframework` 拖进 target → **Frameworks, Libraries, and Embedded Content**,
**Do Not Embed**(静态库)。
2. **Build Settings → Header Search Paths** 追加(recursive,Debug + Release):
```
$(PROJECT_DIR)/Frameworks/sherpa-mnn.xcframework/Headers
```
这样 `SenseVoiceBridge.mm` 里的 `#if __has_include(<sherpa-mnn/c-api/c-api.h>)` 命中,自动切到真实实现。
3. sherpa-mnn 用到 C++ 标准库,确保 target 的 **Other Linker Flags** 含 `-lc++`(通常已隐式链接)。
4. `MNN.xcframework` 仍需在工程里(sherpa-mnn 依赖它)。即便主 LLM 已切 Gemma-3n/MLX,
**不要从工程移除 MNN.xcframework**,否则问诊转写退回 SFSpeech。
> 工程用 PBXFileSystemSynchronizedRootGroup:`SenseVoiceBridge.{h,mm}` 放在 `康康/AI/MNN/` 下已自动参与编译,
> 桥接已在 `康康/康康-Bridging-Header.h` 暴露给 Swift,无需手改 pbxproj。
## 三、转换并安装 SenseVoice 模型
```sh
MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/convert-sensevoice-mnn.sh
```
产出 `build/SenseVoice/{model.mnn, tokens.txt}`(转的是 **fp32** `model.onnx`,8bit 权重量化;勿转 int8 版)。
安装到沙盒 `Application Support/Models/SenseVoice/`:
- **模拟器**:拷到
`~/Library/Developer/CoreSimulator/Devices/<id>/data/Containers/Data/Application/<id>/Library/Application Support/Models/SenseVoice/`
- **真机**:经「我的 · 模型管理」旁路导入,或用调试构建预拷进沙盒。
就位判定:`SenseVoiceASRService.isModelInstalled`(model.mnn + tokens.txt 都在)。
引擎+模型都就绪后 `SenseVoiceASRService.isAvailable == true`,问诊即走 SenseVoice。
## 四、设备验证
1. 真机打开「记一笔 → 记录问诊 → 开始录音」,说几句(含数值/药名,验数字保真)。
2. 「结束并整理」后应看到「正在转写录音 · 本地 SenseVoice」,而非「本机识别」。
3. 转写稿交本地 LLM 整理成问诊小结,保存后在记录详情里能回放原声、看小结。
4. 故意不装模型 → 应自动回退「本机识别」(SFSpeech),功能仍可用。
## 备注:与 Gemma-3n/MLX 主线的关系
- 转写(SenseVoice/MNN)与文本生成(Gemma-3n/MLX)互相独立:ASR 用 MNN 后端,LLM 整理用 MLX。
二者经 `AIRuntime` 闸门**串行**,不会同时常驻内存(§3.1)。
- 这是目前工程里**唯一**仍依赖 MNN 的路径。若决定彻底移除 MNN,问诊转写会退回 SFSpeech;
桥接的 `__has_include` 守卫保证那时仍能编译。
- 若希望改走 sherpa-onnx(onnxruntime,不依赖 MNN),只需把 `SenseVoiceBridge.mm` 里的
`SherpaMnn*` C-API 换成对应的 `SherpaOnnx*`(结构同名),其余 Swift 层不动。

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

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,86 @@
# 康康 · 小红书长文(Google 赛道版)
> 改写自原 MNN 版。核心转向:出海 / 低资源 / 多语言 / Google 技术(Gemma + Gemini)。
> 发布前替换:比赛官方话题、@官方账号、真机数字、demo 视频。
---
## 标题备选
1. 我做了个 App:化验单看不懂?拍一下,断网也能读
2. 把 Google 的 AI 塞进手机,帮你读懂外语体检报告
3. Day_ | 不上传、断网也能用的健康 AI(Gemma + Gemini)
4. 缺医、弱网、看不懂报告——我用端侧 AI 做了个解法
推荐主标题:**Day_ | 不上传、断网也能用的健康 AI**
封面副字:**Gemma 端侧离线 + Gemini 读图/翻译 · 你的母语**
---
## 正文主稿
很多人拿到一张化验单,第一反应是:这些箭头、缩写、参考范围,到底哪项要紧?
如果身边没有医生,或者报告还是外语的,就更难。而最常见的「拍给云端 AI」方式,又得把姓名、年龄、医院、检查结果这些最私密的信息传上去。
所以我做了「康康」——一个**默认在手机本地跑、隐私优先**的健康助手。目标人群不只是国内,而是**弱网/缺医地区,和在国外看不懂医疗文件的人**。
它现在能完成一个完整闭环:
拍一张报告 → 读出指标(数值、单位、参考范围、异常)→ 存成可检索的档案 → 看趋势 → 问它「最近哪些不正常、就诊前帮我整理重点」。
技术上我用的是 **Google 的 AI,双路 hybrid**:
- **端侧默认:Gemma-3n**(Google 开源的手机端多模态小模型)。**飞行模式也能用**——这对网络不稳的地方是刚需。数据不出设备。
- **联网增强:Gemini 2.5 Flash**。你主动开启后,**拍报告原图直接让 Gemini 读图**出结构化指标;**外语报告**也能 Gemini 翻译 + 用你的母语讲明白。
- 断网或额度用尽,**自动回退端侧**,功能不中断。
为什么是这套组合?因为低资源场景同时要「离线能用」和「读得准、能翻译」两件事:前者只有端侧小模型能给(Gemma-3n),后者需要强多模态和多语言(Gemini)。一个负责隐私与可用性,一个负责能力,正好互补。
我特别在意一点:这不是一个「会聊天的健康机器人」,而是把健康资料变成**你自己的长期记忆**——不用再翻相册找去年的化验单,看医生前 30 秒就能整理出重点。它不替你诊断,也不给用药建议,只负责把你已有的信息收好、读懂、讲清楚。
整个 App 是我用 AI 辅助(vibe coding)在几周内做出来的。
健康数据,应该先属于你自己——哪怕没有网络,哪怕你在异国他乡。
#比赛官方话题# #端侧AI #Gemma #Gemini #GoogleAI #本地大模型 #健康管理 #出海 #隐私保护 #iOS开发
---
## 9 宫格图片排序(Google 赛道)
| 顺序 | 图片 | 图中文字 | 作用 |
|---|---|---|---|
| 1 | 首页 + 封面大字 | 不上传、断网也能用的健康 AI | 先讲是什么 |
| 2 | 拍报告 → Gemini 读图结构化 | 拍一张,报告变档案 | 第一卖点(Gemini 核心) |
| 3 | 飞行模式 + 正在生成 | 断网也能 AI 解读 | 低资源杀手锏(Gemma 端侧) |
| 4 | 外语报告 → 母语解读 | 看不懂的外语报告,翻译+讲明白 | 出海痛点(Gemini 多语言) |
| 5 | 我的 · 云端 AI(Gemini 开关) | 隐私优先,云端可选 | hybrid + Google 产品证据 |
| 6 | 趋势页 AI 解读 | 长期变化看得懂 | 长期价值 |
| 7 | 就诊摘要 | 看医生前自动整理 | 应用价值 |
| 8 | 药盒识别 | 药盒也能入档 | 扩展场景 |
| 9 | 架构/开发图 | Gemma + Gemini + SwiftUI | 收尾,vibe coding |
---
## 封面字方案
**技术向**
- 主字:把 Google AI 塞进手机
- 副字:Gemma 端侧离线 + Gemini 读图翻译
- 角标:断网也能用
**大众向**
- 主字:化验单看不懂?拍一下
- 副字:不上传,断网也能读,还能翻译外语报告
- 角标:几周独立开发 demo
---
## 仍需补的素材(对应评审「完成度与传播」)
1. **Gemini 读一张报告的 10 秒录屏**——最值钱的证据,务必有。
2. 飞行模式 + 端侧生成。
3. 外语报告 → 母语解读。
4. 云端 AI 设置页(Gemini 开关)。
5. 片头/转场可用 Veo / Google Flow 生成。

View File

@@ -0,0 +1,318 @@
# 康康 · 小红书长文正文与 9 宫格方案
> 参考风格:「Day530小时,用 AI 开发的 APP 完工了」这类独立开发记录口吻。
> 使用前替换:比赛官方话题、@官方账号、真机 tok/s 数字、开发耗时。
---
## 标题备选
1. Day42我做了个不上传的健康 AI App
2. 6 周做完:体检报告 AI 解读,但不联网
3. 把大模型塞进 iPhone,做了个健康档案 App
4. 体检报告别再乱拍给云端 AI 了
5. 用 MNN 在本地跑健康 AI,我把 demo 做完了
推荐主标题:
**Day42我做了个不上传的健康 AI App**
副标题可放封面小字:
**Qwen + MNN + SME2,体检报告在手机本地解读**
---
## 正文主稿
Day42,这个 6 周 demo 终于能完整跑起来了。
我做了一个叫「康康」的 iOS App。它的目标很简单:把体检报告、化验指标、症状、日记和用药记录整理成一个本地健康档案,再让 AI 用大白话帮你看懂。
但我给自己加了一条硬限制:
**健康数据不上传。**
这件事一开始听起来有点拧巴。因为现在最常见的 AI 解读方式,就是把报告拍照发给云端模型。可体检报告里有姓名、年龄、医院、检查指标、既往问题,基本是一个人最私密的数据之一。
所以我想做一个反过来的版本:
不是把报告交给云端 AI,而是把 AI 放进手机里。
---
这版「康康」现在能完成一个比较完整的闭环:
1. 拍一张体检报告或化验单
2. 本地识别图片里的指标
3. 把数值、单位、参考范围、异常状态整理成结构化档案
4. 后续可以看趋势,比如体重、血糖、血脂、血压
5. 也可以问它:最近哪些指标异常?就诊前帮我整理一份摘要
整个过程不需要账号,不接云服务,没有广告 SDK,也没有第三方分析 SDK。
数据存在 iPhone 本地。报告原图进本地 Vault,SwiftData 存结构化记录,Face ID 可以给 App 加启动锁。
---
技术上最核心的一点是端侧推理。
这次我主路径用的是:
**Qwen3.5-2B + MNN + iPhone CPU/SME2**
MNN 是阿里开源的推理框架。我的目标不是“能接上一个 API 就算 AI App”,而是让模型真的在手机本地吐 token。
App 里做了一个推理引擎页,可以看到当前后端:
- 自动模式:真机优先走 MNN
- MNN:CPU + SME2/NEON
- MLX:兜底和对照
在支持 SME2 的设备上,MNN 会走端侧 CPU 加速。性能自检页会跑固定 prompt,直接显示 prefill 和 decode 速度。这里不是 PPT 数字,是 App 内可以复现的实测。
目前真机结果:
**读入:122 tok/s,生成:33.0 tok/s,总耗时:1.1s**
这个速度已经足够支撑健康档案里的短问答、趋势解读和报告整理。
---
为了让它不是“套壳聊天框”,我把 AI 链路拆成了几层:
UI 不直接调模型。
拍照识别走 CaptureService,身体档案问答走 HealthExportService,趋势解读走 TrendInsightService,最后统一进入 AIRuntime。
AIRuntime 是 actor,里面做了推理闸门。同一时间只允许一个重推理任务占模型内存。因为端侧模型最怕的不是慢,而是多个任务同时加载,内存峰值上去以后直接被系统杀掉。
这也是做端侧 AI 和做云端 API 最大的区别之一:
云端是请求排队。
手机上是内存、功耗、模型加载、UI 响应一起排队。
---
我觉得这个项目真正有价值的地方,不是“AI 能不能讲两句漂亮话”,而是它把健康资料变成了自己的长期记忆。
比如:
你不用再翻相册找去年那张化验单。
也不用每次看医生前,临时回忆“我最近到底哪几项不正常”。
你可以把症状、用药、指标、报告都记在一个地方,需要时生成一份就诊摘要。它不会替医生判断,也不会给剂量建议,只负责把你已有的信息整理清楚。
对普通人来说,这比一个会聊天的健康机器人更实用。
---
这次我最想表达的创新点有三个:
第一,健康 AI 应该默认本地优先。
不是所有数据都适合上传。健康数据尤其应该有“不出设备也能用”的选择。
第二,端侧大模型已经能做完整产品闭环。
不是只跑一个 demo prompt,而是接进真实 App:模型管理、性能自检、拍照识别、趋势解读、问答摘要、失败兜底、隐私设置,这些都要一起工作。
第三,RAG 不一定非要 embedding。
康康里的健康记录本来就是结构化数据。指标名、时间、报告类型、症状、用药都很明确。很多问题可以先抽取意图,再从 SwiftData 里查相关记录,最后让本地模型生成回答。这样更轻,也更适合手机。
---
现在还有很多地方可以继续打磨,比如报告详情页、飞行模式演示图、Live Activity 的实时 tok/s、更多真机性能对比。
但这版已经证明了一件事:
**个人健康档案 + 本地大模型 + MNN 端侧推理,是可以组成一个完整应用的。**
如果说云端 AI 更像一个很聪明的外部顾问,那我希望康康更像一个安静放在手机里的私人健康档案员。
它不替你诊断。
不替医生做决定。
它只帮你把自己的身体记录收好、看懂、在需要的时候讲清楚。
这就是我这次做「康康」的原因。
参加 #比赛官方话题# 的作品记录。后面如果有时间,我会继续补飞行模式演示、Live Activity 和完整 demo 视频。
声明:康康只做健康信息记录、整理和科普式解读,不是医疗器械,不提供诊断、治疗、用药或剂量建议。任何健康决策请咨询专业医生。
---
## 精简版正文
我做了一个叫「康康」的 iOS App。
它能把体检报告、化验指标、症状、日记和用药记录整理成一个本地健康档案,再用 AI 帮你做大白话解读。
最关键的是:不上传。
我不想把体检报告这种高度隐私的数据拍给云端模型,所以这次把 Qwen3.5-2B 放进了手机本地,主推理路径用 MNN,在支持 SME2 的 iPhone 上走端侧 CPU 加速。
现在 App 里已经能看到完整闭环:
拍报告 → 本地识别 → 结构化指标 → 趋势图 → 身体档案问答 → 就诊摘要。
技术上做了双后端:
- MNN:真机主路径,CPU/SME2
- MLX:模拟器和兜底
App 里还有性能自检页,当前真机实测:
读入 122 tok/s,生成 33.0 tok/s,总耗时 1.1s。
我觉得这个项目最有意思的地方,不是做了一个 AI 聊天框,而是把本地大模型真正接进了健康档案系统。
它不会替你诊断,也不会给用药建议。它只做三件事:
帮你收好记录,帮你看懂变化,帮你在看医生前把重点讲清楚。
健康数据应该先属于自己。
这就是我做「康康」的原因。
#比赛官方话题# #端侧AI #MNN #Qwen #本地大模型 #健康管理 #iOS开发 #SwiftUI #隐私保护 #独立开发
---
## 9 宫格图片排序
### 当前素材可发版
| 顺序 | 图片 | 封面字/图中文字 | 作用 |
|---|---|---|---|
| 1 | `21-首页-健康日历趋势提醒.png` | 不上传的健康 AI 档案 | 封面,先讲产品是什么 |
| 2 | `19-报告归档-核对报告信息.png` | 拍报告,自动变档案 | 展示核心创意:报告归档 |
| 3 | `22-框选异常指标-本地识别中.png` | 图片在本地识别 | 展示视觉识别过程 |
| 4 | `06-推理引擎-MNN-SME2性能自检.png` | Qwen + MNN + SME2 | 技术证据和比赛亮点 |
| 5 | `07-模型管理-Qwen已就绪.png` | 1.19GB 模型在手机里 | 证明不是云端 API |
| 6 | `11-身体档案-就诊摘要生成结果.png` | 就诊前 30 秒整理重点 | 展示应用价值 |
| 7 | `09-趋势详情-体重AI解读.png` | 长期趋势,AI 讲人话 | 展示长期使用价值 |
| 8 | `20-药品识别-核对药品.png` | 药盒也能本地识别 | 展示扩展场景 |
| 9 | `02-Xcode开发调试界面.png` | 6 周独立开发记录 | 收尾,增强真实开发感 |
### 如果补图后的更强版
| 顺序 | 建议图片 | 封面字/图中文字 |
|---|---|---|
| 1 | 首页 + 大字封面 | 不上传的健康 AI 档案 |
| 2 | 飞行模式 + 正在生成 | 断网也能 AI 解读 |
| 3 | 报告拍照前后对比 | 拍一下,报告变档案 |
| 4 | 报告详情三 Tab | 原图、解读、指标都留存 |
| 5 | MNN 性能自检 | Qwen + MNN + SME2 |
| 6 | 模型管理页 | 模型真的在本机 |
| 7 | 身体档案摘要 | 看医生前自动整理 |
| 8 | 趋势页 | 长期变化看得懂 |
| 9 | Xcode/架构图 | SwiftUI + SwiftData + MNN |
---
## 封面字方案
### 方案 A:大众向
主字:
**体检报告 AI 解读**
副字:
**不联网,不上传,只在手机里跑**
角标:
**6 周独立开发 demo**
### 方案 B:技术向
主字:
**把 Qwen 塞进 iPhone**
副字:
**MNN + SME2 本地推理健康档案**
角标:
**decode 33.0 tok/s**
### 方案 C:参考 Day 风格
主字:
**Day42 做完一个本地健康 AI**
副字:
**体检报告拍一下,但不交给云端**
角标:
**SwiftUI + MNN + Qwen**
推荐使用方案 C,更贴近你给的参考笔记风格,同时还能保留项目技术点。
---
## 每张图建议加字
1. `21-首页-健康日历趋势提醒.png`
- 主字:Day42 做完一个本地健康 AI
- 小字:体检报告、指标、症状、用药都存在手机里
2. `19-报告归档-核对报告信息.png`
- 主字:拍报告,自动变档案
- 小字:原图和结构化信息一起保存
3. `22-框选异常指标-本地识别中.png`
- 主字:图片识别也在本地
- 小字:异常指标可框选识别
4. `06-推理引擎-MNN-SME2性能自检.png`
- 主字:Qwen + MNN + SME2
- 小字:生成 33.0 tok/s
5. `07-模型管理-Qwen已就绪.png`
- 主字:模型真的在手机里
- 小字:Qwen3.5-2B 本地就绪
6. `11-身体档案-就诊摘要生成结果.png`
- 主字:看医生前自动整理
- 小字:症状、指标、用药一份摘要带走
7. `09-趋势详情-体重AI解读.png`
- 主字:长期变化看得懂
- 小字:折线图 + AI 大白话解读
8. `20-药品识别-核对药品.png`
- 主字:药盒也能入档
- 小字:只记录,不提供用药建议
9. `02-Xcode开发调试界面.png`
- 主字:不是套壳聊天框
- 小字:SwiftUI + SwiftData + MNN 端侧推理
---
## 仍建议补充的截图
1. 飞行模式打开 + App 正在流式生成,这是“不上传”的最强证据。
2. 报告详情页三 Tab:原图 / 解读 / 指标,用于证明档案浏览闭环。
3. Face ID 启动锁页面,用于补足隐私三件套。
4. 如果 Live Activity 已能跑,补锁屏 tok/s,非常适合比赛记忆点。
---
## 标签
#比赛官方话题# #端侧AI #MNN #Qwen #本地大模型 #健康管理 #体检报告解读 #隐私保护 #iOS开发 #SwiftUI #独立开发 #数字健康

Binary file not shown.

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 文件。

View File

@@ -0,0 +1,113 @@
# MLX-Swift-Examples API 核对(2026-05-25)
研究产出来源:`https://github.com/ml-explore/mlx-swift-examples` tag `2.29.1`,commit `9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631`
W2 plan Task 6 的 LLMSession 草稿与真实 API 有 4 处偏差,**Task 6 必须用本文修正版,不要回头读 plan 里的草稿**。
## 关键修正
| 项 | 草稿 | 真实 API |
|---|---|---|
| `ModelConfiguration(directory:)` | ✓ | ✓ 一致 |
| `LLMModelFactory.shared.loadContainer(configuration:)` | ✓ | ✓ 一致(`hub` / `progressHandler` 有默认值) |
| `container.perform { context in ... }` | 未类型化 | context 是 `ModelContext` struct(具体类型);`processor: any UserInputProcessor` |
| `MLXLMCommon.generate(...)` 调用语义 | `try MLXLMCommon.generate(...)` 后内部 `for await` | **同上,只需 `try`(无 `await`)**;**返回 `AsyncStream<Generation>`(非 throwing)** |
| `Generation` 枚举 case | 只列了 `.chunk(String)``.info(...)` | **还有 `.toolCall(ToolCall)`,switch 必须穷举** |
| `GenerateParameters` | 只传 `temperature / topP`,`maxTokens` 在草稿用 `produced >= maxTokens break` 控制 | **`maxTokens` 必须传 GenerateParameters**;`temperature` / `topP``Float` 不是 `Double` |
| 取消 | 草稿没处理 | **必须** `continuation.onTermination = { _ in task.cancel() }` |
| `UserInput` 构造 | `LMInput.init(prompt:)` | `UserInput(prompt: prompt)``context.processor.prepare(input: userInput)``LMInput` |
## 修正版 LLMSession.swift(Task 6 直接抄)
```swift
import Foundation
import MLX
import MLXLLM
import MLXLMCommon
actor LLMSession {
let container: ModelContainer
init(container: ModelContainer) {
self.container = container
}
static func load(folderURL: URL) async throws -> LLMSession {
let configuration = ModelConfiguration(directory: folderURL)
let container = try await LLMModelFactory.shared.loadContainer(
configuration: configuration
)
return LLMSession(container: container)
}
/// AsyncThrowingStream , Task
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let parameters = GenerateParameters(
maxTokens: maxTokens,
temperature: Float(0.6),
topP: Float(0.9)
)
try await container.perform { (context: ModelContext) in
let userInput = UserInput(prompt: prompt)
let lmInput = try await context.processor.prepare(input: userInput)
let start = Date()
var produced = 0
for await event in try MLXLMCommon.generate(
input: lmInput,
parameters: parameters,
context: context
) {
if Task.isCancelled { break }
switch event {
case .chunk(let text):
produced += 1
let elapsed = Date().timeIntervalSince(start)
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
continuation.yield(TokenChunk(text: text, decodeRate: rate))
case .info:
// ,
break
case .toolCall:
// ,switch
break
}
}
MLX.GPU.synchronize()
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
```
## 与 AIRuntime 的对接
`AIRuntime.swift`(W2-T5 提交的 `4dcd951` + `e7cdb45`)已经预设:
```swift
let session = try await LLMSession.load(folderURL: ModelStore.shared.localURL(for: .llm))
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
```
签名匹配,Task 6 不改 AIRuntime。
## 真实模型 HF 仓库名
- LLM: `mlx-community/Qwen3-1.7B-4bit`(沙盒目录:`Qwen3-1.7B-4bit`)
- VL: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`(沙盒目录:`Qwen2.5-VL-3B-Instruct-4bit`)
注:plan 文档 Task 6 里写的是带 "MLX-" 中缀的旧名,**已弃用**。ModelKind rawValue 已在 commit `771b28e` 修正。

View File

@@ -18,23 +18,23 @@
| 路径 | 职责 |
|---|---|
| `体己/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate |
| `体己/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 |
| `体己/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 |
| `体己/AI/TokenChunk.swift` | 流式数据结构 |
| `体己/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 |
| `体己/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 |
| `体己Tests/FileVaultTests.swift` | FileVault 单元测试 |
| `体己Tests/ModelStoreTests.swift` | ModelStore 单元测试 |
| `康康/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate |
| `康康/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 |
| `康康/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 |
| `康康/AI/TokenChunk.swift` | 流式数据结构 |
| `康康/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 |
| `康康/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 |
| `康康Tests/FileVaultTests.swift` | FileVault 单元测试 |
| `康康Tests/ModelStoreTests.swift` | ModelStore 单元测试 |
### 修改
| 路径 | 改什么 |
|---|---|
| `体己/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags |
| `体己/App/TijiApp.swift` | Schema 加入两个新 @Model |
| `体己/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner |
| `体己.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples |
| `康康/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags |
| `康康/App/KangkangApp.swift` | Schema 加入两个新 @Model |
| `康康/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner |
| `康康.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples |
### 不动(W2 不碰)
@@ -45,15 +45,15 @@
## Task 1:Xcode 项目加入 MLX Swift SPM 依赖
**Files:**
- Modify: `体己.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编)
- Modify: `康康.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编)
- [ ] **Step 1:打开 Xcode 项目**
- [x] **Step 1:打开 Xcode 项目**
```bash
open /Users/xuhuayong/apps/体己/体己.xcodeproj
open /Users/xuhuayong/apps/康康/康康.xcodeproj
```
- [ ] **Step 2:加入 MLX Swift 依赖**
- [x] **Step 2:加入 MLX Swift 依赖**
在 Xcode → File → Add Package Dependencies → 输入 URL:
@@ -61,14 +61,14 @@ open /Users/xuhuayong/apps/体己/体己.xcodeproj
https://github.com/ml-explore/mlx-swift
```
选 "Up to Next Major" → 添加,勾选这些 product 加到 **体己** target:
选 "Up to Next Major" → 添加,勾选这些 product 加到 **康康** target:
- `MLX`
- `MLXFast`
- `MLXNN`
- `MLXOptimizers`
- `MLXRandom`
- [ ] **Step 3:加入 mlx-swift-examples(含 LLM 工具)**
- [x] **Step 3:加入 mlx-swift-examples(含 LLM 工具)**
继续 Add Package Dependencies,URL:
@@ -76,25 +76,25 @@ https://github.com/ml-explore/mlx-swift
https://github.com/ml-explore/mlx-swift-examples
```
勾选 `MLXLLM``MLXLMCommon` 加到 **体己** target。
勾选 `MLXLLM``MLXLMCommon` 加到 **康康** target。
- [ ] **Step 4:确认 Build Settings**
- [x] **Step 4:确认 Build Settings**
Xcode → 体己 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
Xcode → 康康 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
体己 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
康康 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
- [ ] **Step 5:Build 验证**
- [x] **Step 5:Build 验证**
Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。
Expected:Build Succeeded,无依赖错误。
- [ ] **Step 6:提交**
- [x] **Step 6:提交**
```bash
cd /Users/xuhuayong/apps/体己
git add 体己.xcodeproj
cd /Users/xuhuayong/apps/康康
git add 康康.xcodeproj
git commit -m "build: add MLX Swift SPM dependencies"
```
@@ -103,11 +103,11 @@ git commit -m "build: add MLX Swift SPM dependencies"
## Task 2:扩展 Models.swift —— Asset 与 ChatTurn
**Files:**
- Modify: `体己/Models/Models.swift`(全文重写)
- Modify: `康康/Models/Models.swift`(全文重写)
- [ ] **Step 1:把 Models.swift 替换为新内容**
- [x] **Step 1:把 Models.swift 替换为新内容**
打开 `体己/Models/Models.swift`,**整文件替换**为:
打开 `康康/Models/Models.swift`,**整文件替换**为:
```swift
import Foundation
@@ -268,9 +268,9 @@ final class ChatTurn {
}
```
- [ ] **Step 2:更新 TijiApp.swift Schema**
- [x] **Step 2:更新 KangkangApp.swift Schema**
打开 `体己/App/TijiApp.swift`,替换 Schema 数组:
打开 `康康/App/KangkangApp.swift`,替换 Schema 数组:
```swift
let schema = Schema([
@@ -282,7 +282,7 @@ let schema = Schema([
])
```
- [ ] **Step 3:删模拟器沙盒(破坏性迁移)**
- [x] **Step 3:删模拟器沙盒(破坏性迁移)**
在 Mac 上:
@@ -293,16 +293,16 @@ xcrun simctl erase all
(也可以在 Simulator → Device → Erase All Content and Settings)
- [ ] **Step 4:Build & Run 验证**
- [x] **Step 4:Build & Run 验证**
Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。
Expected:App 启动到 RootView,无 fatalError。
- [ ] **Step 5:提交**
- [x] **Step 5:提交**
```bash
git add 体己/Models/Models.swift 体己/App/TijiApp.swift
git add 康康/Models/Models.swift 康康/App/KangkangApp.swift
git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag"
```
@@ -311,17 +311,17 @@ git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship,
## Task 3:FileVault —— 加密目录读写(TDD)
**Files:**
- Create: `体己/Persistence/FileVault.swift`
- Test: `体己Tests/FileVaultTests.swift`
- Create: `康康/Persistence/FileVault.swift`
- Test: `康康Tests/FileVaultTests.swift`
- [ ] **Step 1:写失败的测试**
- [x] **Step 1:写失败的测试**
创建 `体己Tests/FileVaultTests.swift`:
创建 `康康Tests/FileVaultTests.swift`:
```swift
import Testing
import UIKit
@testable import
@testable import
@MainActor
struct FileVaultTests {
@@ -369,15 +369,15 @@ struct FileVaultTests {
}
```
- [ ] **Step 2:运行测试,确认 fail**
- [x] **Step 2:运行测试,确认 fail**
Xcode ⌘U 跑测试(在模拟器上跑)。
Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。
- [ ] **Step 3:写最小 FileVault 实现**
- [x] **Step 3:写最小 FileVault 实现**
创建 `体己/Persistence/FileVault.swift`:
创建 `康康/Persistence/FileVault.swift`:
```swift
import Foundation
@@ -454,22 +454,22 @@ final class FileVault {
}
```
- [ ] **Step 4:把 FileVault.swift 加入 体己 target**
- [x] **Step 4:把 FileVault.swift 加入 康康 target**
Xcode 右键 `体己/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "体己"。
Xcode 右键 `康康/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "康康"。
把 FileVaultTests.swift 拖进 体己Tests target,确认 Target Membership 勾选 "体己Tests"。
把 FileVaultTests.swift 拖进 康康Tests target,确认 Target Membership 勾选 "康康Tests"。
- [ ] **Step 5:跑测试,确认全 pass**
- [x] **Step 5:跑测试,确认全 pass**
Xcode ⌘U。
Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。
- [ ] **Step 6:提交**
- [x] **Step 6:提交**
```bash
git add 体己/Persistence/FileVault.swift 体己Tests/FileVaultTests.swift 体己.xcodeproj
git add 康康/Persistence/FileVault.swift 康康Tests/FileVaultTests.swift 康康.xcodeproj
git commit -m "feat(persistence): add FileVault with complete file protection"
```
@@ -478,17 +478,17 @@ git commit -m "feat(persistence): add FileVault with complete file protection"
## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD)
**Files:**
- Create: `体己/AI/ModelStore.swift`
- Test: `体己Tests/ModelStoreTests.swift`
- Create: `康康/AI/ModelStore.swift`
- Test: `康康Tests/ModelStoreTests.swift`
- [ ] **Step 1:写失败的测试**
- [x] **Step 1:写失败的测试**
创建 `体己Tests/ModelStoreTests.swift`:
创建 `康康Tests/ModelStoreTests.swift`:
```swift
import Testing
import Foundation
@testable import
@testable import
@MainActor
struct ModelStoreTests {
@@ -531,13 +531,13 @@ struct ModelStoreTests {
}
```
- [ ] **Step 2:运行测试,确认 fail**
- [x] **Step 2:运行测试,确认 fail**
⌘U → expect `Cannot find 'ModelStore'`.
- [ ] **Step 3:写 ModelStore 实现**
- [x] **Step 3:写 ModelStore 实现**
创建 `体己/AI/ModelStore.swift`:
创建 `康康/AI/ModelStore.swift`:
```swift
import Foundation
@@ -619,21 +619,21 @@ final class ModelStore {
}
```
- [ ] **Step 4:Xcode 中把文件加入 target**
- [x] **Step 4:Xcode 中把文件加入 target**
右键 `体己/` → New Group "AI" → 拖入 ModelStore.swift,勾 "体己" target。
ModelStoreTests.swift 拖入 体己Tests target。
右键 `康康/` → New Group "AI" → 拖入 ModelStore.swift,勾 "康康" target。
ModelStoreTests.swift 拖入 康康Tests target。
- [ ] **Step 5:跑测试,全绿**
- [x] **Step 5:跑测试,全绿**
⌘U。
Expected:3 个测试全 pass。
- [ ] **Step 6:提交**
- [x] **Step 6:提交**
```bash
git add 体己/AI/ModelStore.swift 体己Tests/ModelStoreTests.swift 体己.xcodeproj
git add 康康/AI/ModelStore.swift 康康Tests/ModelStoreTests.swift 康康.xcodeproj
git commit -m "feat(ai): add ModelStore with path management and bundle seed"
```
@@ -642,12 +642,12 @@ git commit -m "feat(ai): add ModelStore with path management and bundle seed"
## Task 5:TokenChunk + AIRuntime actor 骨架
**Files:**
- Create: `体己/AI/TokenChunk.swift`
- Create: `体己/AI/AIRuntime.swift`
- Create: `康康/AI/TokenChunk.swift`
- Create: `康康/AI/AIRuntime.swift`
本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。
- [ ] **Step 1:创建 TokenChunk.swift**
- [x] **Step 1:创建 TokenChunk.swift**
```swift
import Foundation
@@ -658,7 +658,7 @@ struct TokenChunk: Sendable {
}
```
- [ ] **Step 2:创建 AIRuntime.swift 骨架**
- [x] **Step 2:创建 AIRuntime.swift 骨架**
```swift
import Foundation
@@ -754,19 +754,19 @@ actor AIRuntime {
}
```
- [ ] **Step 3:确认 Build 失败原因合理**
- [x] **Step 3:确认 Build 失败原因合理**
⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。
这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。
- [ ] **Step 4:把文件加入 target**
- [x] **Step 4:把文件加入 target**
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "体己" target。
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "康康" target。
(此时 Build 还是失败,正常)
- [ ] **Step 5:暂不提交**
- [x] **Step 5:暂不提交**
等 Task 6 完成、Build 通过后一起提交。
@@ -775,7 +775,7 @@ actor AIRuntime {
## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B
**Files:**
- Create: `体己/AI/LLMSession.swift`
- Create: `康康/AI/LLMSession.swift`
**预先准备(开发者手动一次)**:
@@ -785,7 +785,7 @@ actor AIRuntime {
具体路径在 App 启动时打印,见 Step 5。
- [ ] **Step 1:在终端下载模型(脚本一次性)**
- [x] **Step 1:在终端下载模型(脚本一次性)**
```bash
mkdir -p ~/tiji-models && cd ~/tiji-models
@@ -796,9 +796,9 @@ huggingface-cli download mlx-community/Qwen3-1.7B-MLX-4bit \
Expected:目录里有 `config.json` / `model.safetensors` / `tokenizer.json` 等。
- [ ] **Step 2:写 LLMSession 实现**
- [x] **Step 2:写 LLMSession 实现**
创建 `体己/AI/LLMSession.swift`:
创建 `康康/AI/LLMSession.swift`:
```swift
import Foundation
@@ -866,11 +866,11 @@ actor LLMSession {
> **注**:`MLXLMCommon` 的具体 API 版本可能在 GenerateParameters/stream 处略有差异。如果 Step 4 编译报错,查看 mlx-swift-examples 仓库 `Libraries/MLXLLM` 的最新示例,以仓库示例为准小幅调整。
- [ ] **Step 3:把 LLMSession.swift 加入 体己 target**
- [x] **Step 3:把 LLMSession.swift 加入 康康 target**
拖入 AI group,确认 Target Membership。
- [ ] **Step 4:Build,期望成功**
- [x] **Step 4:Build,期望成功**
⌘B。
@@ -878,9 +878,9 @@ Expected:Build Succeeded。
若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。
- [ ] **Step 5:在 TijiApp 启动时打印沙盒路径(临时调试)**
- [x] **Step 5:在 KangkangApp 启动时打印沙盒路径(临时调试)**
打开 `体己/App/TijiApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`:
打开 `康康/App/KangkangApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`:
```swift
.onAppear {
@@ -901,7 +901,7 @@ Expected:Build Succeeded。
📁 App Support: /Users/.../data/Containers/Data/Application/<UUID>/Library/Application Support
```
- [ ] **Step 6:把模型拷到沙盒**
- [x] **Step 6:把模型拷到沙盒**
```bash
APP_SUPPORT="<上面控制台打印的路径>"
@@ -909,10 +909,10 @@ mkdir -p "$APP_SUPPORT/Models"
cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/"
```
- [ ] **Step 7:提交(本任务 + Task 5 一起)**
- [x] **Step 7:提交(本任务 + Task 5 一起)**
```bash
git add 体己/AI/ 体己/App/TijiApp.swift 体己.xcodeproj
git add 康康/AI/ 康康/App/KangkangApp.swift 康康.xcodeproj
git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
```
@@ -921,12 +921,12 @@ git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
## Task 7:DebugAIRunner —— DEBUG 测试入口
**Files:**
- Create: `体己/Debug/DebugAIRunner.swift`
- Modify: `体己/Features/Me/MeView.swift`
- Create: `康康/Debug/DebugAIRunner.swift`
- Modify: `康康/Features/Me/MeView.swift`
- [ ] **Step 1:创建 DebugAIRunner**
- [x] **Step 1:创建 DebugAIRunner**
`体己/Debug/DebugAIRunner.swift`:
`康康/Debug/DebugAIRunner.swift`:
```swift
#if DEBUG
@@ -998,9 +998,9 @@ struct DebugAIRunner: View {
#endif
```
- [ ] **Step 2:在 MeView 末尾挂上(仅 DEBUG)**
- [x] **Step 2:在 MeView 末尾挂上(仅 DEBUG)**
打开 `体己/Features/Me/MeView.swift`,把现有内容整体替换为:
打开 `康康/Features/Me/MeView.swift`,把现有内容整体替换为:
```swift
import SwiftUI
@@ -1025,18 +1025,18 @@ struct MeView: View {
#Preview { MeView() }
```
- [ ] **Step 3:在 Xcode 中加入文件**
- [x] **Step 3:在 Xcode 中加入文件**
右键 `体己/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "体己" target。
右键 `康康/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "康康" target。
- [ ] **Step 4:Build,确认 OK**
- [x] **Step 4:Build,确认 OK**
⌘B → Expected: Build Succeeded。
- [ ] **Step 5:提交**
- [x] **Step 5:提交**
```bash
git add 体己/Debug/ 体己/Features/Me/MeView.swift 体己.xcodeproj
git add 康康/Debug/ 康康/Features/Me/MeView.swift 康康.xcodeproj
git commit -m "chore(debug): add AI self-test runner in MeView (DEBUG only)"
```
@@ -1092,15 +1092,15 @@ git commit --allow-empty -m "milestone: W2 LLM 自检通过 (simulator)"
## Task 9:加一组 schema 重建烟测(防回归)
**Files:**
- Create: `体己Tests/ModelsSchemaTests.swift`
- Create: `康康Tests/ModelsSchemaTests.swift`
- [ ] **Step 1:写 schema 烟测**
- [x] **Step 1:写 schema 烟测**
```swift
import Testing
import SwiftData
import Foundation
@testable import
@testable import
@MainActor
struct ModelsSchemaTests {
@@ -1179,7 +1179,7 @@ struct ModelsSchemaTests {
}
```
- [ ] **Step 2:加入 体己Tests target,跑测试**
- [x] **Step 2:加入 康康Tests target,跑测试**
⌘U。
@@ -1187,10 +1187,10 @@ Expected:3 个测试全 pass。
若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。
- [ ] **Step 3:提交**
- [x] **Step 3:提交**
```bash
git add 体己Tests/ModelsSchemaTests.swift 体己.xcodeproj
git add 康康Tests/ModelsSchemaTests.swift 康康.xcodeproj
git commit -m "test(models): add schema smoke tests for relationships and cascade"
```

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
# W2 Retro · 2026-05-31
> 范围:2026-05-19(W2 起)→ 2026-05-25(W2 中段写,W3 周一前回看修订)。本次 retro 在 W2 中段写,主要是周末批量收尾的留痕。
## Status
| 风险/里程碑 | 状态 | 备注 |
|---|---|---|
| R1 · MLX 跑通 | ⚠️ 部分通过 | LLMSession.load 通过 Swift Testing 烟测,真实 tok/s 待用户手动 DebugAIRunner 验证 |
| R4 · Schema 迁移 | ✅ 通过 | 5 + 1(Symptom)个 @Model,3 + 2 个关系烟测全绿 |
| 本周里程碑 · AI 基座骨架 | ✅ | AIRuntime / LLMSession / ModelStore / FileVault 全部交付,build 干净 0 warning |
## 速度基线
- 模拟器(iPhone 17 Sim, Apple Silicon Mac):**TBD**(W3 周一前由 xuhuayong 在 macOS Designed for iPad 内点 DebugAIRunner 填入)
- 真机 iPhone 15+:**待 W3 验证**(本周未连真机,模型只 sideload 到 macOS sandbox)
> 验收门槛:模拟器 < 5 tok/s 触发 R1 红线(换 llama.cpp,W2 plan revert)。当前烟测路径无法测速,需 manual。
## 计划外完成
- **Symptom 模块**:新增 @Model + Start/End sheets + OngoingSymptomsCard。这是 CLAUDE.md §10 红线 #6 "新功能必须问'清单里有吗'" 的例外,由产品负责人决定加入。
- **Timeline 统一时间线**:TimelineEntry + TimelineRow + DateSection + TimelineGrouping,被 HomeView 和 ArchiveListView 共享。
- **ArchiveListView 提前打底**(原计划 W4):接 @Query 拉 Indicator/Report/Diary/Symptom,filter chips + 年/月分组 + 空态。
- **AppIcon**:Light/Dark/Tinted 三套 9 sizes + SVG 源。
- **Swift 6 并发清扫**:`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` 下,把 ModelStore / FileVault / ModelKind 显式标 nonisolated,LLMSession 用 task-scoped Device.withDefaultDevice 替代 deprecated API。
## 计划内缺口
- **Task 8 Step 1-2 自检与速度基线**:延后到用户 manual 验证。
- **Task 8 Step 3 真机连测**:延后到 W3。
- **Task 10 Step 2 §8 状态更新**:已在本 retro commit 内一起完成。
## 学到的
1. **`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` 会把跨边界类型/方法都默认推到 MainActor**,跟 actor (如 AIRuntime) 互操作时必须显式 `nonisolated` 整条调用链。`@unchecked Sendable` 不自动解锁实例方法的 isolation。
2. **iOS Simulator app sandbox 阻止读 Mac 用户目录**,集成测试无法直接验证真实推理;Mac Designed for iPad 又卡 code signing。W3 把 LLM 接口拆 SPM target 后才能写 host-fs 集成测试。
3. **`Device.withDefaultDevice` 是 TaskLocal,跨 actor 传递正常**,但跨 Task(如 AsyncStream 的 detached Task)需要在 inner Task 内重新 `withDefaultDevice`
4. **MLX Swift API 比 mlx-swift-examples 文档稳定**,真正卡的是 Swift 6 并发系统,不是 MLX 本身。
## 下周(W3)前置准备
- [ ] 用户在 macOS App 内点 DebugAIRunner,把实际 tok/s 填进本 retro 的"速度基线"段
- [ ] 准备 510 张真实化验单照片(W4 VL 回归测用),放进 ~/tiji-models/test-reports/
- [ ] 准备 20 条危险问句(W3 末医疗话术安全测试)
- [ ] 决定是否把 LLM 接口拆 SPM target(便于真实推理集成测试)
- [ ] W3 plan 周一动笔,把 Symptom + Timeline 写进 spec

View File

@@ -1,4 +1,4 @@
# 康记 / 体己 —— 功能设计 Spec(v1.0)
# 康 —— 功能设计 Spec(v1.0)
**日期**:2026-05-25
**状态**:Draft, 已与产品方对齐 §1-§6
@@ -8,7 +8,7 @@
## 0. 概要
是一个 iOS 原生健康影像档案 App,**100% 端侧 AI 推理**,基于 SwiftUI + SwiftData + MLX Swift,目标 6 周交付决赛 demo。本 spec 把原始功能清单收敛为 **方案 B**:核心 5 模块 + Live Activity + 分享摘要,其余 P2/P3 全部 deferred。
是一个 iOS 原生健康影像档案 App,**100% 端侧 AI 推理**,基于 SwiftUI + SwiftData + MLX Swift,目标 6 周交付决赛 demo。本 spec 把原始功能清单收敛为 **方案 B**:核心 5 模块 + Live Activity + 分享摘要,其余 P2/P3 全部 deferred。
**5 大核心模块**
@@ -77,7 +77,7 @@ Persistence
### 2.1 `AIRuntime` 接口
```
体己/AI/
康康/AI/
├── AIRuntime.swift // actor 单例,推理串行化
├── ModelStore.swift // 模型路径管理 + 下载 + bundle 旁路
├── LLMSession.swift // Qwen3-1.7B 文本生成,流式
@@ -112,7 +112,7 @@ struct TokenChunk {
| 项 | 决策 |
|---|---|
| 模型来源 | HuggingFace MLX 社区版 Qwen3-1.7B-MLX-4bit + Qwen2.5-VL-3B-MLX-4bit |
| 模型来源 | HuggingFace `mlx-community/Qwen3-1.7B-4bit` + `mlx-community/Qwen2.5-VL-3B-Instruct-4bit` |
| 体积 | LLM ~1.0GB + VL ~2.0GB ≈ 3GB |
| 存储 | `Application Support/Models/`,`URLSession.downloadTask` + 断点续传 |
| 首启动 | 启动屏 → 隐私承诺 → "下载模型"页(进度 + WiFi 提示) → 主界面 |
@@ -376,7 +376,7 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence
### 4.3 服务层文件
```
体己/AI/ [7.5d]
康康/AI/ [7.5d]
├── AIRuntime.swift 2d
├── ModelStore.swift 1d
├── LLMSession.swift 1d
@@ -387,17 +387,17 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence
├── KeywordExtraction.swift
└── TrendNarrative.swift
体己/Services/ [4.5d]
康康/Services/ [4.5d]
├── CaptureService.swift 1.5d
├── AskService.swift 1.5d
├── TrendService.swift 1d
└── ReportCompareService.swift 0.5d
体己/Persistence/ [1d]
康康/Persistence/ [1d]
├── FileVault.swift 0.5d
└── PermanentDelete.swift 0.5d
体己/Security/ [0.5d]
康康/Security/ [0.5d]
└── AppLock.swift 0.5d
```

View File

@@ -0,0 +1,122 @@
# Hide Monitor Preset · 设计 v1
> 「记录指标」sheet 长期监测预设(`MonitorMetric`)支持隐藏
>
> 日期:2026-05-26 · 状态:approved by user(2026-05-26 对话)
> 关联:[CLAUDE.md](../../../CLAUDE.md) §7,[Monitor+Profile spec](./2026-05-26-monitor-and-profile-design.md)
---
## 1. 背景
`IndicatorQuickSheet`「长期监测(进趋势)」分组由 `MonitorMetric.allCases` 渲染,目前 6 个硬编码 case(血压/空腹血糖/餐后血糖/体温/心率/血氧)无法隐藏,与下方 `CustomMonitorMetric`(可长按编辑/删除)体验不一致。
用户场景:不测血氧、不测血压的人想清理 grid;但**不能误删历史数据**——已经测过的折线在 Trends 里还要看。
## 2. 目标
- 长按 `MonitorMetric` tile → contextMenu 出"隐藏"
- 已隐藏的 tile 从 grid 过滤掉,但已有 `Indicator` 记录、Trends 折线、`MetricReminder` 全不动
- 提供可逆恢复入口
## 3. 非目标(YAGNI)
- ❌ 化验项快捷预设(labPresets)同款功能 — 本次不动
- ❌ 「我的」里集中管理页 — grid 上就近恢复即可
- ❌ 批量隐藏 / 拖拽排序
- ❌ 二次确认弹窗 — 隐藏可逆,不需要
- ❌ 隐藏时联动关掉对应 `MetricReminder` — 用户没说,保守不动
## 4. 数据模型
`UserProfile` 增加一个字段:
```swift
var hiddenPresetMetrics: [String] = [] // MonitorMetric.rawValue
```
- 类型沿用 `[String]`,跟 `allergies` / `chronicConditions` 一致,SwiftData 自动 transformable
- init 默认 `[]`,无 migration 风险
- 写入用 `UserProfile.updatedAt = .now`
为什么不另开 `@Model HiddenPresetMetric`:8 个 case 的隐藏标记只是 UI 偏好,放 Profile 单例最自然,避免新 entity + 关联查询。
## 5. UI 行为
### 5.1 隐藏入口
`IndicatorQuickSheet.monitorTile(_:)``.contextMenu`:
```swift
.contextMenu {
Button(role: .destructive) {
hideMonitor(m)
} label: {
Label("隐藏", systemImage: "eye.slash")
}
}
```
`hideMonitor``m.rawValue` 加入 `profile.hiddenPresetMetrics`,save,grid 因 `@Query` 重渲染。被隐藏的 tile 若当前选中,要 `clearMonitor()` 复位。
### 5.2 grid 过滤
```swift
ForEach(MonitorMetric.allCases.filter { !hiddenSet.contains($0.rawValue) }) { m in
monitorTile(m)
}
```
`hiddenSet` = `Set(profile?.hiddenPresetMetrics ?? [])`,computed property。
### 5.3 恢复入口
`monitorGridSection` 顶部 section label 一行:
```
长期监测(进趋势) 已隐藏 3
```
- chip 仅当 `hiddenSet.nonEmpty` 显示
- 点 chip → `.sheet` 弹一个轻量列表(`.medium` detent)
- 列表项:每个被隐藏的 `MonitorMetric` 显示 icon + displayName + 右侧"显示"按钮
- 点"显示" → `profile.hiddenPresetMetrics.removeAll { $0 == m.rawValue }` + save
- 列表空了自动 dismiss
### 5.4 边界
- 全部 6 个都隐藏:section 还在(label + chip + addCustomTile),不消失
- 隐藏不影响:Trends 折线、`Indicator` 列表查询、`MetricReminder` 调度
- `UserProfileStore.loadOrCreate` 已保证 profile 存在,无 nil 分支
- `@Query private var profiles: [UserProfile]` 已在 sheet 里,直接取 `profiles.first`
## 6. 文件改动清单
1. `Models/UserProfile.swift` — 加 `hiddenPresetMetrics: [String]` 字段 + init 默认值
2. `Features/Indicator/IndicatorQuickSheet.swift`
- `monitorGridSection`: 过滤 + 顶部 chip
- `monitorTile`: 加 contextMenu
- 新增 `hideMonitor(_:)` / `unhideMonitor(_:)` / `hiddenSet`
- 新增 `HiddenMonitorRestoreSheet` 子 View(同文件内,私有)
不动:`MonitorMetric.swift``CustomMetricEditor.swift`、Trends、`ReminderService``MeView`
## 7. 测试 / 验证手段
无单测目标(全 UI 行为)。手测点:
- [ ] 长按血压 tile → 出现"隐藏",点了 grid 里消失
- [ ] 顶部 chip "已隐藏 1" 出现,数字正确
- [ ] 点 chip → 弹列表,有 1 行血压,点"显示"恢复
- [ ] 全部 6 个隐藏 → grid 只剩 addCustomTile + 自定义指标,不崩
- [ ] 隐藏期间去 Trends,血压折线仍在
- [ ] 隐藏前若血压已选中,隐藏后选中态清空、字段清空
- [ ] 重启 App,隐藏状态持久
## 8. 红线核查(CLAUDE.md §10)
- ✅ 不引入云
- ✅ 不动 AIRuntime / Service 边界
- ✅ 不动 SwiftData 既有 `Indicator` schema
- ✅ Tab / RecordSheet 骨架不动
- ✅ 不是清单外功能,是对 §7 grid 的小改良

View File

@@ -0,0 +1,434 @@
# Monitor + Profile · 设计 v1
> 长期格式化指标录入(`.indicator` 入口预设 + 自由)+ 个人资料(年龄、性别、健康背景、用药)
>
> 日期:2026-05-26 · 状态:approved by user,进入实施
> 关联:[CLAUDE.md](../../../CLAUDE.md) §5 §7 §10;[W2 retro](../retros/2026-05-31-w2.md) 计划外完成
---
## 1. 背景与目标
### 1.1 当前缺口
康康现有的 4 个记录 kind(`quick` 拍照、`archive` 归档、`diary` 文字、`symptom` 持续症状)都是**事件型**——一次性记录,不假设后续会重复同一指标。但血压/血糖/体重这类**长期监测**类需求:
- 用户每天/每周测,数值规律地重复
- 需要趋势(W4-W5 计划的 Trends 页)
- 不需要拍照(已是格式化数字)
- 参考范围依赖个人 demographic(老人血压标准放宽)
同时,App 启动以来一直没有用户基础信息持久化的位置。LLM 给出趋势解读时缺乏 demographic context("LDL 偏高"对 35 岁健康男和 70 岁糖尿病患者风险完全不同)。
### 1.2 目标
- **一个统一的"手动录入指标"入口**:用户已加 `.indicator` case,本设计把 7 个预设(血压/血糖/体重/...)和「自由输入」合并进这个 sheet
- **个人资料卡**:在「我的」加一张资料卡,push 进 Form 编辑页,4 项核心 + 健康背景 + 用药
- **联动**:参考范围按 Profile 个性化(目前规则只覆盖"老人血压"一例,后续可扩)
### 1.3 非目标(YAGNI)
- ❌ Trends 页升级(本次只打通数据通路,留给 W4-W5)
- ❌ 提醒/通知功能(到点测量推送)
- ❌ HealthKit 导入
- ❌ 多 Profile / 给家人记
- ❌ AppLock / Face ID(W5 末统一实现)
- ❌ 单位切换(kg/lb,mmol/L vs mg/dL)
- ❌ 紧急联系人
---
## 2. 数据模型
### 2.1 Indicator 扩字段
```swift
@Model final class Indicator {
// :name/value/unit/range/statusRaw/note/capturedAt/report/asset/pinned
var seriesKey: String? // "bp.systolic" / "glucose.fasting" / ...
// VL/Report Indicator nil
}
```
**为什么用 String 而非 enum**:`seriesKey` 跨设备/版本要稳定,enum 改名会破坏老数据;String 用命名空间约定(`bp.*` / `glucose.*`)即可。
**为什么不新建 @Model**:复用 Indicator 让 Trends/Timeline/ReportCompareService 一次写完受益,避免分裂查询路径。
### 2.2 UserProfile @Model
```swift
@Model final class UserProfile {
// 4
var birthYear: Int? // 1990 "",
var biologicalSexRaw: String // "" / "male" / "female"
var heightCM: Int?
var bloodTypeRaw: String // "" / "A" / "B" / "AB" / "O"
//
var allergies: [String] //
var chronicConditions: [String] // +
var familyHistory: [String] //
//
var currentMedications: [String]
var updatedAt: Date
init(birthYear: Int? = nil, /* ... */) { /* ... */ }
}
extension UserProfile {
enum Sex: String { case male, female, undisclosed = "" }
var sex: Sex { Sex(rawValue: biologicalSexRaw) ?? .undisclosed }
/// ( birthYear nil)
var age: Int? {
guard let y = birthYear else { return nil }
return Calendar.current.component(.year, from: .now) - y
}
}
```
### 2.3 单例策略
UserProfile 全 App 单一实例,通过 helper 保证:
```swift
enum UserProfileStore {
@MainActor
static func loadOrCreate(in ctx: ModelContext) -> UserProfile {
let descriptor = FetchDescriptor<UserProfile>()
if let existing = try? ctx.fetch(descriptor).first { return existing }
let new = UserProfile()
ctx.insert(new)
try? ctx.save()
return new
}
}
```
任何 View 用 `@Query` 拉,空了再调 loadOrCreate。MeView 启动时调一次,确保后续 @Query 必拿到。
### 2.4 Schema 注册
`KangkangApp.swift` 的 schema 加入 `UserProfile.self`。Indicator 加字段是 additive change,SwiftData 自动迁移(给老 row 的 seriesKey 填 nil)。
---
## 3. MonitorMetric Catalog
`Features/Monitor/MonitorMetric.swift`,8 个预设(血压算 1 个 case,内部展开 2 条 Indicator):
```swift
enum MonitorMetric: String, CaseIterable, Identifiable {
case bloodPressure // bp.systolic + bp.diastolic
case fastingGlucose // glucose.fasting
case postprandialGlucose // glucose.postprandial
case weight // weight
case temperature // temperature
case heartRate // heart_rate
case spo2 // spo2
case height // height( UserProfile.heightCM)
var id: String { rawValue }
var displayName: String { /* "" / "" / ... */ }
var icon: String { /* SF Symbol */ }
var fields: [Field] // 1 2
}
extension MonitorMetric {
struct Field {
let seriesKey: String // "bp.systolic"
let label: String // ""
let unit: String // "mmHg"
let placeholder: String // "120"
let baseRange: ClosedRange<Double>? // nil status(/)
}
/// metric profile ( baseRange )
func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange<Double>? {
// :bp age >= 65 150 / 90
if let age = profile?.age, age >= 65,
field.seriesKey == "bp.systolic" {
return 90...150
}
if let age = profile?.age, age >= 65,
field.seriesKey == "bp.diastolic" {
return 60...90 //
}
return field.baseRange
}
/// status(value normal, high, low, normal)
static func status(value: Double, in range: ClosedRange<Double>?) -> IndicatorStatus {
guard let r = range else { return .normal }
if value > r.upperBound { return .high }
if value < r.lowerBound { return .low }
return .normal
}
}
```
### 3.1 Profile-aware 规则
本次仅实现 1 条规则(老人收缩压上限 140→150),目的是**展示联动机制**,不追求医学完备。未来扩规则只改 `effectiveRange` 函数,不动调用方。
---
## 4. UI
### 4.1 IndicatorRecordSheet(替代之前提的 MonitorRecordSheet)
`Features/Indicator/IndicatorRecordSheet.swift`,被 RootView 在 `.indicator` case 弹出。
**布局**:
```
[拖动条]
"记录指标 · 本地处理"
[2 列 grid]
┌─────────┐ ┌─────────┐
│ 血压 │ │ 空腹血糖│
│ 收/舒 │ │ 3.9-6.1 │
└─────────┘ └─────────┘
┌─────────┐ ┌─────────┐
│ 体重 │ │ 体温 │
└─────────┘ └─────────┘
... (共 7 预设)
┌─────────┐ ┌─────────┐
│ 心率 │ │ + 自由 │
└─────────┘ └─────────┘
—— 选中 metric 后,grid 下方展开 ——
【血压】参考范围:90-140 / 60-90 mmHg(成人通用)
[收缩压 _____ mmHg]
[舒张压 _____ mmHg]
status chip 实时显示
[保存按钮]
```
**关键交互**:
- 进入 sheet 时无选中,grid 全展示
- 点预设 → 高亮卡片 + 下方展开输入区
- 切换 metric → 数值清空(避免血压数值串到血糖)
- 选「+ 自由输入」→ 展开 4 个字段:名称 / 数值 / 单位 / 参考范围(string)
- 保存:
- 血压 → 2 条 Indicator(同 capturedAt + 各自 seriesKey)
- 单字段预设 → 1 条 Indicator(seriesKey 填)
- 身高预设 → 1 条 Indicator + 回写 UserProfile.heightCM
- 自由输入 → 1 条 Indicator(seriesKey 为 nil,name 用户输入)
**Profile-aware 提示**:
-`effectiveRange``baseRange` 不同,参考范围一行末尾小字:"按你的年龄(67)调整"
-`effectiveRange` 与 baseRange 相同 / 无 Profile,正常显示
### 4.2 MeView 改造
```
[ScrollView]
┌─────────────────────────────────┐
│ 个人资料 更多 →│
│ 38岁 · 男 · 175cm · A型 │
│ (未设置时:"点这里完善你的资料") │
└─────────────────────────────────┘
↓ tap push
┌─────────────────────────────────┐
│ 模型管理 未配置 → │ (W6 stub)
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Face ID 启动锁 关闭 → │ (W5 stub)
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 关于 → │ (链接到隐私承诺 placeholder)
└─────────────────────────────────┘
#if DEBUG
DebugAIRunner
#endif
```
stub 卡片本次只放占位 + 文案,push 进去是空页或 placeholder。
### 4.3 ProfileEditView
`Features/Profile/ProfileEditView.swift`,Form 风格:
```
导航标题:个人资料
—— 基本 ——
出生年份 [picker 1900-2026]
性别 [男 / 女 / 不愿透露 segmented]
身高 [TextField + cm]
血型 [A / B / AB / O / 不知道 picker]
—— 健康背景 ——
过敏史 [chips + add field]
慢病 [8 预设 chips 多选 + 自定义 add]
家族史 [chips + add field]
—— 当前用药 ——
[列表 + add row + 行内 swipe-to-delete]
(保存即时,无显式 Save 按钮——边改边写)
```
慢病 8 预设:`高血压 / 糖尿病 / 冠心病 / 高血脂 / 甲状腺疾病 / 哮喘 / 慢性肾病 / 抑郁/焦虑`
### 4.4 Timeline 行内合并(顺手)
`Features/Timeline/TimelineEntry.swift`,`from(indicator:)` 增加配对逻辑:
```swift
static func from(indicators: [Indicator]) -> [TimelineEntry] {
// map, bp.systolic bp.diastolic
// : capturedAt()+ bp.* prefix ; map
}
```
ArchiveListView 和 HomeView 的 `mapped` 表达式从 `indicators.map(...)` 改为 `TimelineEntry.from(indicators:)`(批处理)。
合并后的 TimelineEntry:
- title: "血压"
- subtitle: "120 / 80 mmHg"
- trailing: 异常时显示"偏高"或"正常"
非 bp.* 的 series 不合并,逐条显示("空腹血糖 5.4 mmol/L" / "体重 68 kg")。
---
## 5. 联动:Profile ↔ Monitor
### 5.1 调用路径
```
IndicatorRecordSheet
↓ @Query UserProfile (单例)
MonitorMetric.effectiveRange(for: field, profile: profile)
- 显示个性化参考范围
- 保存时 MonitorMetric.status(value:, in: effectiveRange) 算 statusRaw
```
### 5.2 未来扩展点
`effectiveRange` 是唯一规则入口,扩规则只动这个函数。规则示例(本次不实现):
- 性别 → 血红蛋白、肌酐参考范围不同
- 慢病 → 糖尿病患者血糖目标更严
- 年龄分段 → 儿童体温、心率范围
---
## 6. 测试
### 6.1 新建 `康康Tests/UserProfileTests.swift`
- `freshProfileHasNilDemographics()` — 新建 profile,字段都 nil/空数组
- `ageComputedFromBirthYear()` — 1985 → 41 岁(2026 当前年)
- `sexParsesEnumFromRaw()` — male/female/空 → 三种 enum
- `loadOrCreateReturnsExistingSingleton()` — 第二次 call 不创建新 row
- `arrayFieldsRoundtripThroughSwiftData()` — chronicConditions 存读
### 6.2 新建 `康康Tests/MonitorMetricTests.swift`
- `allMetricsHaveAtLeastOneField()`
- `bpHasTwoFields()`
- `statusHighWhenAboveUpper()` / `statusLowWhenBelowLower()` / `statusNormalWhenInside()` / `statusNormalWhenRangeNil()`
- `bpUpperBoundShiftsForElderly()` — age 67 时 bp.systolic 上限 = 150
- `bpUpperBoundUnchangedWhenNoProfile()` — profile 为 nil 时上限 = 140
- `nonBPSeriesUnaffectedByProfile()` — 血糖范围不随年龄变
### 6.3 扩 `康康Tests/ModelsSchemaTests.swift`
- `userProfileSchemaPersistsAcrossSave()`
- `indicatorSeriesKeyRoundtrip()`
- `cascadeStillWorksWithSeriesKey()` — Report 删除时,关联 Indicator(无论 seriesKey)都删
### 6.4 扩 `康康Tests/TimelineGroupingTests.swift`
- `bpSystolicAndDiastolicMergeIntoSingleEntry()`
- `nonBPSeriesStayAsSeparateEntries()`
- `bpAtDifferentTimesDoNotMerge()` — capturedAt 差 > 5 秒不合并
预期总测试数:11(profile 5)+ 7(metric)+ 3(schema)+ 3(timeline)= 18 个新测试。
---
## 7. 不变项与守恒检查
- ✅ §10.1 不引入云服务 — 完全本地
- ✅ §10.2 不自实现密码学 — SwiftData store 已有 file protection
- ✅ §10.3 UI 不直接调 AIRuntime — 本设计不涉及 AI
- ✅ §10.4 AIRuntime actor — 不涉及
- ✅ §10.5 VL/LLM prompt — 不涉及
- ⚠️ §10.6 新功能必须问"清单里有吗" — Monitor 和 Profile 都是清单外,**已跟用户确认加入**
- ✅ §10.7 不重构现有骨架 — 不动 RootView / RecordSheet 骨架(只补 case 处理),不动 DesignSystem
- ✅ §10.8 C2 ≠ B3 — 不涉及
---
## 8. 文件清单
### 新建(6)
| 路径 | 职责 |
|---|---|
| `康康/Models/UserProfile.swift` | UserProfile @Model + Sex enum + age computed + loadOrCreate helper |
| `康康/Features/Monitor/MonitorMetric.swift` | 8 metric catalog + effectiveRange + status 算法 |
| `康康/Features/Indicator/IndicatorRecordSheet.swift` | 预设 grid + 自由输入合一的录入 sheet |
| `康康/Features/Profile/ProfileEditView.swift` | Form 编辑页 |
| `康康Tests/UserProfileTests.swift` | 5 测试 |
| `康康Tests/MonitorMetricTests.swift` | 7 测试 |
### 修改(7)
| 路径 | 改什么 |
|---|---|
| `康康/Models/Models.swift` | Indicator 加 `seriesKey: String?`,初始化器加默认值 nil |
| `康康/App/KangkangApp.swift` | schema 加 `UserProfile.self` |
| `康康/Features/Me/MeView.swift` | 加 ProfileCard + 3 个 stub 卡片 |
| `康康/RootView.swift` | `.indicator` case 接 IndicatorRecordSheet 弹出 |
| `康康/Features/Timeline/TimelineEntry.swift` | 加 `from(indicators:)` 批处理 + bp 配对 |
| `康康Tests/ModelsSchemaTests.swift` | 3 个新测试 |
| `康康Tests/TimelineGroupingTests.swift` | 3 个新测试 |
### 文档(2)
| 路径 | 改什么 |
|---|---|
| `CLAUDE.md` | §5 加 UserProfile @Model + Indicator seriesKey;§7 IA 加 Profile 入口;§11 时间表加备注;§10.6 例外清单加 Monitor + Profile |
| `docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md` | 本文件 |
---
## 9. 验收
- [ ] App build & test 全绿,0 警告
- [ ] DEBUG 启动 → 我的 → 个人资料 → 填年龄 + 性别 + 身高 + 血型,push back 显示在 ProfileCard
- [ ] DEBUG 启动 → + 号 → 指标记录 → 选血压 → 输 145/85 → 保存 → 在首页时间线看到合并的"血压 145/85"行
- [ ] 把 UserProfile birthYear 改成 1955(70 岁) → 再次进血压录入 → 顶部小字显示"按你的年龄(70)调整",参考范围 90-150 / 60-90
- [ ] 录入身高 175 → 个人资料卡片自动显示 175cm
- [ ] 18 个新测试全绿
---
## 10. 估时
- 数据层(UserProfile + Indicator.seriesKey + schema 注册):20 分钟
- MonitorMetric catalog + effectiveRange:20 分钟
- IndicatorRecordSheet UI:25 分钟
- ProfileEditView + MeView 改造:25 分钟
- Timeline 合并:15 分钟
- 18 测试:30 分钟
- CLAUDE.md + 提交整理:15 分钟
**总计 ~150 分钟**(2.5 小时)。

View File

@@ -0,0 +1,430 @@
# 导出身体档案 — 设计文档
**日期**:2026-05-27 (W2)
**作者**:link2026 + Claude
**关联卖点**:#1 影像档案系统、#2 100% 本地、#3 本地 RAG 长期记忆、#4 隐私三件套、#6 Live Activity tok/s
**优先级**:P0(打通 RAG 链路 + demo 主要演示场景)
---
## 1. 一句话定位
在「记录」Tab 顶部增加「导出身体档案」入口,用户输入自然语言主诉(如「我感冒 3 天,把最近一个月给医生看」),完全本地的两段式 RAG 把 SwiftData 里相关的指标 / 报告 / 症状 / 日记 / 个人资料检索并生成给医生看的 Markdown 摘要,可复制、分享、查看历史。
---
## 2. 用户故事
> 周日晚上,我感冒第 3 天还没好。明早要去社区医院,医生只有 5 分钟问诊,我想把过去一个月的体温记录、上次体检的关键异常项、在服的降压药、家族过敏史一次性整理出来给医生。我不想把这些数据上传到任何云。
成功标准:
- 输入 prompt → 30 秒内出现首字 → 90 秒内完整生成
- 输出 Markdown 包含主诉 / 患者背景 / 近期症状 / 关键指标 / 在服药与过敏 / 患者疑问
- 一键复制到微信发给医生,或直接 AirDrop / 邮件分享
- 重启 App 后能看到历史导出
---
## 3. 范围
**做**:
- 记录 Tab 右上角 toolbar「导出」按钮
- ArchiveListView 顶部「我的导出」横向卡区(有历史时显示,前 3 条 + 查看全部)
- 全屏 sheet:prompt 输入 / Phase 指示 / 流式 Markdown / 完成后复制+分享+重新生成
- 历史列表页 + 详情页
- 两段式 RAG 链路:Qwen3-1.7B 抽意图 → SwiftData 结构化检索 → Qwen3-1.7B 生成 Markdown
-`HealthExport` @Model + Schema 注册
- 引用回链(referencedXxxIDs,W3 再做点击跳转)
**不做**:
- embedding / 向量检索
- 跨设备同步、云端备份
- PDF 导出(W6 余力再说)
- 给医生的诊断建议 / 用药建议(红线 §10.1)
- 自动定期导出(此版无 schedule)
---
## 4. 架构
```
┌─ UI ─────────────────────────────────────────────────────┐
│ ArchiveListView │
│ ├─ .toolbar trailing: "导出" 图标按钮 │
│ └─ 顶部横向卡区 HealthExportRecentStrip(有历史时显示)│
│ │ │
│ └─→ HealthExportSheet (full-screen cover) │
│ ├─ prompt TextEditor │
│ ├─ Phase 状态条 │
│ ├─ Markdown 流式渲染 │
│ └─ Actions: 复制 / 分享 / 重新生成 │
│ │
│ HealthExportListView (NavigationLink "查看全部") │
│ └─ 全部历史(@Query DESC)→ HealthExportDetailView │
└────────────────────────────────────────────────────────┘
↑ Event 流
┌─ Service ────────────────────────────────────────────────┐
│ HealthExportService (struct, DI ModelContext + Runtime) │
│ func export(prompt:) -> AsyncThrowingStream<Event> │
│ Event = .phaseChanged(Phase) | .token(TokenChunk) │
│ | .completed(HealthExport) | .failed(Error) │
└────────────────────────────────────────────────────────┘
↑ 串行排队
┌─ AI 层 (已存在) ──────────────────────────────────────────┐
│ AIRuntime(actor 单例)→ LLMSession 串行两次调用 │
└────────────────────────────────────────────────────────┘
↑ 检索
┌─ Persistence (SwiftData) ────────────────────────────────┐
│ Indicator / Report / Symptom / DiaryEntry / │
│ UserProfile / HealthExport(新增) │
└────────────────────────────────────────────────────────┘
```
**红线对齐**(CLAUDE.md §10):
- UI 不直接调 AIRuntime,只与 HealthExportService 通讯 ✅
- AIRuntime 仍是 actor 单例,两段调用在它的队列内串行,与 CaptureService / 未来的 AskService 互不抢占 GPU ✅
- 两个 prompt 都带 few-shot + 失败回退 ✅
- 不引入云服务、不自实现密码学、不重构现有 Tab/RecordSheet ✅
---
## 5. 数据模型
新增 `Models/HealthExport.swift`:
```swift
import Foundation
import SwiftData
@Model final class HealthExport {
var id: UUID = UUID()
var prompt: String = "" //
var content: String = "" // Markdown
var createdAt: Date = .now
// ( §3.3)
var referencedIndicatorIDs: [UUID] = []
var referencedReportIDs: [UUID] = []
var referencedSymptomIDs: [UUID] = []
var referencedDiaryIDs: [UUID] = []
// ("", LLM)
var inferredTimeFromDate: Date?
var inferredTimeToDate: Date?
var inferredIntent: String?
// demo
var modelTag: String = "Qwen3-1.7B-4bit"
var decodeRate: Double = 0 // tok/s
init() {}
}
```
**Schema 注册**:`App/KangkangApp.swift``ModelContainer(for:)` 加入 `HealthExport.self`(增表是 SwiftData 兼容变更,无需手写迁移)。
**为什么 `referenced*IDs` 用 `[UUID]` 而不是 SwiftData 关系**:
导出是历史快照,源 Indicator / Report 可能后续被用户永久删除(§10.4);弱关联避免 cascade 影响历史导出本身。点击跳转时,源记录若已不存在,UI 显示「记录已删除」灰态。
---
## 6. 状态机 + 数据流
`HealthExportService.export(prompt:)``AsyncThrowingStream<Event, Error>`:
```swift
enum Phase: String {
case extractingIntent //
case retrieving //
case generating //
case completed
}
enum Event {
case phaseChanged(Phase)
case token(TokenChunk)
case completed(HealthExport)
case failed(Error)
}
```
**流程**:
```
.idle
│ user tap 生成
phaseChanged(.extractingIntent)
│ LLMSession.generate(prompt: INTENT_PROMPT, maxTokens: 120)
│ 失败 → 回退到默认 {time_range_days: 30, keywords: [], symptom_keywords: []}
phaseChanged(.retrieving)
│ 同步 SwiftData 查询:
│ - Indicator where capturedAt ∈ [from, to], 可选按 keyword 过滤 name/seriesKey
│ - Report where reportDate ∈ [from, to]
│ - Symptom where startedAt <= to AND (endedAt == nil OR endedAt >= from)
│ - DiaryEntry where createdAt ∈ [from, to] AND content contains any symptom_keyword
│ (privacy 过滤:无主诉相关关键词的日记不入 prompt;
│ 若 symptom_keywords 为空,则一律不包含日记 —— 安全默认)
│ - UserProfile 单例,无条件包含
phaseChanged(.generating)
│ 拼 GENERATION_PROMPT(把上一步结果序列化为简短结构)
│ LLMSession.generate(prompt:, maxTokens: 1024)
│ for token in stream: yield .token(chunk)
phaseChanged(.completed)
│ build HealthExport(prompt, content, referencedIDs, inferred*, decodeRate)
│ modelContext.insert + try modelContext.save()
.completed(healthExport)
```
**取消语义**:UI 关闭 sheet → stream 被取消 → 中间态不入库。
**与 AIRuntime 互斥**:HealthExportService 在 `AIRuntime` 的 actor 函数里调度两次 LLM 调用;若此时 CaptureService 正在跑 VL,自然在 actor 队列里等待。Phase indicator 在 UI 上显示「排队中」(可选,W3 polish)。
---
## 7. Prompt 设计
两个 prompt 都放在 `AI/Prompts/HealthExportPrompts.swift`,带 2 个 few-shot。
### 7.1 意图抽取(Qwen3-1.7B,~120 token 输出)
```text
你是健康数据助手。读用户的请求,只输出严格 JSON,不要任何解释或 Markdown。
字段:
{
"time_range_days": int, // 时间窗,默认 30
"keywords": [string], // 指标关键词(中文,如"血压"/"血糖"/"体温")
"symptom_keywords": [string], // 症状关键词
"intent": string // 简短意图标签
}
示例 1:
User: 我感冒3天了,要把最近一个月的健康情况给医生看
Output: {"time_range_days":30,"keywords":["体温","血压","脉搏"],"symptom_keywords":["感冒","咳嗽","咽喉痛","发烧"],"intent":"cold_consult"}
示例 2:
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
Output: {"time_range_days":90,"keywords":["血糖","糖化血红蛋白","胰岛素"],"symptom_keywords":[],"intent":"glucose_review"}
User: {{USER_PROMPT}}
Output:
```
**解析容错**:
- 非 JSON → 抓 `{…}` 之间的子串再试一次
- 仍失败 → 用默认 `{30, [], []}`,继续流程,不报错给用户
### 7.2 报告生成(Qwen3-1.7B,maxTokens 1024)
```text
你正在帮患者撰写一份给社区医生看的就诊摘要。
要求:
- 输出 Markdown,严格按下方结构
- 只用「数据」中提供的信息,数据缺失就写"无记录"
- 不要给诊断意见、不要给用药建议、不要写"建议就医"
- 引用具体数值时保留单位和参考范围
- 全文中文,简洁,医生 30 秒能扫完
结构:
# 就诊摘要 — {{INTENT_LABEL_CN}}
## 主诉
## 患者背景
## 近期症状(按时间倒序)
## 关键指标(异常项优先)
## 在服药与过敏
## 患者疑问
数据:
{{SERIALIZED_DATA_JSON}}
患者原话:{{USER_PROMPT}}
现在生成:
```
**SERIALIZED_DATA_JSON 结构**(给 LLM 看的精简结构):
```json
{
"profile": {
"age": 38, "sex": "男", "height_cm": 172,
"allergies": ["青霉素"],
"chronic": ["高血压(2 年)"],
"family_history": ["父亲冠心病"],
"current_meds": ["缬沙坦 80mg qd"]
},
"symptoms": [
{"name": "感冒", "started": "2026-05-24", "severity": 2,
"ongoing": true, "note": "鼻塞、低烧"}
],
"indicators": [
{"name": "收缩压", "value": 142, "unit": "mmHg", "range": "<140",
"status": "high", "date": "2026-05-26"}
],
"reports": [
{"title": "年度体检", "type": "physical", "date": "2026-04-12",
"institution": "瑞金医院"}
],
"diaries": [
{"date": "2026-05-25", "excerpt": "夜里两点醒了一次,头痛 7/10"}
]
}
```
---
## 8. UI 详细设计
### 8.1 ArchiveListView 改动
- toolbar trailing 加按钮:`Image(systemName: "doc.text.below.ecg") "导出"`
-`List` 顶部插入 `HealthExportRecentStrip()`(若 `@Query HealthExport` 非空)
- 横向卡区,3 条最近导出 + 末尾「查看全部 →」卡,点击进入 `HealthExportListView`
### 8.2 HealthExportSheet (full-screen cover)
```
┌──────────────────────────────────────────────┐
│ ✕ 导出身体档案 本地·永不上传 │ Header
├──────────────────────────────────────────────┤
│ 例:我感冒3天了,把最近一个月给医生看 │ Hint
│ ┌──────────────────────────────────────────┐ │
│ │ (多行 TextEditor,~6 行) │ │
│ └──────────────────────────────────────────┘ │
│ [ 生成报告 ] │ TjPrimaryButton
├──────────────────────────────────────────────┤
│ ●─○─○ 理解意图 │ Phase pill,
│ │ 生成时显示
│ 本地推理 · Qwen3 · 24.3 tok/s │
├──────────────────────────────────────────────┤
│ # 就诊摘要 — 感冒就诊 │
│ ## 主诉 │ Markdown 流式
│ 患者男,38 岁…… │ 渲染(原生
│ …(打字机效果)… │ Text(LocalizedStringKey))
│ │
├──────────────────────────────────────────────┤
│ [ 复制 ] [ 分享 ] [ 重新生成 ] │ 完成后才显示
└──────────────────────────────────────────────┘
```
- 「分享」用系统 `ShareLink(item: content)`,导出纯文本
- 「重新生成」复用同一 `prompt` + `inferred*` 字段,跳过意图抽取,直接走 retrieving + generating
- 持久化时机:`.completed` 事件触发时由 Service 立即 `insert + save`;sheet 关闭只是 dismiss 视图,不再写库
- 生成中按 ✕ → 取消 stream → 不入库;已生成完成后按 ✕ → 仅 dismiss(数据已在库中)
### 8.3 HealthExportListView
简单的 `List` + `@Query(sort: \.createdAt, order: .reverse)`,每条显示:
- 标题:`HealthExport.prompt` 截断到 60 字
- 副标题:`relativeDate(createdAt)` + `tok/s` 标签
- 滑动删除
### 8.4 HealthExportDetailView
- 只读 Markdown(复用 sheet 的渲染组件)
- 顶部信息条:生成时间 / 模型 tag / tok/s
- toolbar:复制 / 分享 / 删除
- W3 再补:`referenced*IDs` 转 Pill,点击跳源记录(此 spec 不阻塞)
---
## 9. 错误处理
| 情况 | 行为 |
|---|---|
| 模型未就绪 | toolbar 按钮置灰 + 副标题「模型未就绪,前往下载」(对齐 §4) |
| 意图抽取 JSON 解析失败 | 默认 `{30 days, [], []}` 兜底,流程继续,不报错给用户 |
| SwiftData 查询为空 | 数据段填 `"无记录"`,LLM 仍生成结构化"无明显异常"摘要 |
| 生成 stream 中途取消 | Service 抛 `CancellationError`,UI 显示「已取消」,不入库 |
| 生成超时 (>120s) | `Task.withTimeout` 超时取消,UI 同取消逻辑 |
| LLM 抛错(显存等) | UI 显示「生成失败:{msg}」+ 重试按钮 |
| `modelContext.save` 失败 | 仅日志,UI 仍展示文本,提示「保存失败,请重试」 |
**安全:** 全程不调用任何网络;`HealthExport` 持久化继承 §6 的 `.completeFileProtection`
---
## 10. 测试策略
**单元(`HealthExportServiceTests`)**:
- mock `AIRuntime` 协议(新增 `protocol AIRuntimeProtocol`,actor 单例符合该协议)
- 给定固定 SwiftData in-memory + 已知 Indicator/Symptom → 验证 referencedIDs 正确
- 意图抽取返回非 JSON → 验证回退到默认 30 天
- 验证 Phase 转换顺序:`.extractingIntent → .retrieving → .generating → .completed`
- 取消语义:在 `.generating` 阶段取消 → 不入库
**Preview**:
- `HealthExportSheet` 用 mock service 吐预设 Markdown(打字机视效在 Preview 即可看到)
- `HealthExportListView` 用 3 条 fake `HealthExport`
**真机验收**(W3 末):
- 在 16 inch M3 Max 模拟器上跑通(simulator 走 CPU,慢但能跑通流程)
- 真机 iPhone 15 Pro:首字 ≤ 10s,完整生成 ≤ 60s,tok/s ≥ 20
- 关 WiFi + 飞行模式仍能正常生成(隐私三件套 demo 关键)
---
## 11. 与现有/未来代码的关系
- **复用**:`AIRuntime` / `LLMSession` / `TokenChunk` / `Tj.*` Design System
- **铺路**:`HealthExportService` 的两段式 RAG 工程模式直接复用给 W3 的 `AskService`(只需替换 generation prompt + 输出形态)
- **不冲突**:`CaptureService` 在 AIRuntime 队列里和本服务串行;两者不会同时占 GPU
- **不影响**:`ArchiveListView` / `RecordSheet` / 现有 7 个 @Model 都不需要重构
---
## 12. 取舍记录
| 决策 | 选择 | 拒绝的方案 | 理由 |
|---|---|---|---|
| 入口位置 | 「记录」Tab toolbar | RecordSheet 加一项 | 语义:RecordSheet 是「写入」,导出是「读出」 |
| 数据范围 | Indicator+Report+Symptom+Profile+Diary | 仅 Indicator+Report | 「感冒 3 天」需要 Symptom;医生需要 Profile;Diary 由 LLM 关键词过滤后入 prompt,降低隐私风险 |
| 历史位置 | ArchiveListView 顶部横向卡区 + 查看全部 | 「我的」Tab 加历史入口 | 路径更短;符合「记录 Tab=身体档案」语义 |
| Pipeline | 严格两段式 RAG | 单段 LLM / 模板化 | 准确性 + 复用给 AskService + demo 卖点 #3 |
| Markdown 渲染 | SwiftUI 原生 `Text(LocalizedStringKey)` | 第三方 Markdown 库 | YAGNI;W6 polish 时再评估 |
| referenced 关联 | `[UUID]` 弱关联 | SwiftData 关系 | 历史快照 vs 源记录可被永久删除 |
| Live Activity | 此版只在 Service 暴露 decodeRate,UI 显示数字 | 此版直接接 ActivityKit | W5 真机阶段统一接,与 AskService 共用一套 Activity |
---
## 13. 排期估算(放在 W2 末 ~ W3 初)
| 步骤 | 工作量 |
|---|---|
| HealthExport @Model + Schema 注册 | 0.5h |
| HealthExportPrompts(两个 prompt + few-shot 调试) | 2h |
| HealthExportService(状态机 + 两段调用 + 检索) | 4h |
| HealthExportSheet(输入 + Phase + 流式渲染 + 三按钮) | 3h |
| ArchiveListView toolbar + RecentStrip | 1.5h |
| HealthExportListView + DetailView | 1.5h |
| 单元测试 + 真机验收 | 2h |
| **合计** | **~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,180 @@
# 模型自动下载功能设计2026-05-29
> 让用户在「我的 · 模型管理」页一键从自建 HTTPS 服务下载两个 MLX 模型,支持断点续传、
> 进度展示和现场重装的旁路导入兜底。对应 CLAUDE.md §4「模型分发」与 W6「首启动下载流程」的核心部分。
## 1. 背景与现状
- 模型加载链路已通:`LLMSession`/`VLSession``ModelConfiguration(directory:)` 从沙盒
`Application Support/Models/<repo>/` 读取,`AIRuntime.prepare()/prepareVL()`
`ModelStore.isReady()` 为假时抛 `notReady`
- **缺口**:没有任何下载实现。`ModelStore` 只有 `isReady()` 判定 + `seedFromBundle()` 占位;
唯一能装模型的路径是 DEBUG-only 的 `DebugAIRunner` 手动 `fileImporter`(且只导 LLM漏 VL
- `MeView` 已预留「模型管理」卡片(`detail="未配置"`icon `cpu`),尚未连接任何界面。
- `HealthExportService` 的未就绪文案已写「请先到『我的 · 模型管理』下载」,落点早有预期。
- 无 Onboarding / 首启动流程。
## 2. 服务器素材(已就绪并验证)
- 自建 Caddy 静态文件服务,文件根 `/srv/models/`
- base URL**`https://file.myv0.com/`**(用户自建反代,标准 HTTPS
- 备选:`http://101.132.124.52:5244/`(纯 IP需 App 端 ATS 例外;域名挂了时用)。
- 已验证:`config.json` 返回 200两个 `model.safetensors` 均支持 Range`206` + `Accept-Ranges: bytes`
反代返回的总大小与本机精确一致LLM 968080210、VL 3073720461未截断大文件。
- 服务器 24 个真实文件字节数与本机逐一匹配LLM 984015687、VL 3089713215
## 3. 范围
**做**模型管理页分模型卡片、HTTPS 断点续传下载、大小校验、蜂窝网络提示、
旁路文件导入LLM + VL、MeView 接入、AI 入口未就绪「前往下载」引导。
**不做YAGNI**:首启动 Onboarding、启动自动后台下载、哈希校验大小校验够
Live Activity 下载进度Live Activity 是推理时的 tok/s单独功能、并行多文件下载。
## 4. 架构(方案 A独立 Service + ModelStore 保持纯存储)
```
ModelManagementView (UI)
→ ModelDownloadService (@MainActor @Observable下载编排 + 进度状态)
→ ModelStore (文件路径 / 就绪判定 / 旁路导入)
→ URLSession (HTTPS 分块下载)
```
- 符合 §3.1 模块边界UI 不直接碰 `URLSession`,只观察 Service 发布的状态。
- `ModelDownloadService` 与现有 `CaptureService`/`AskService` 并列。
- `ModelStore` 继续只管「模型在哪 / 是否就绪 / 旁路拷入」,不引入网络职责。
### 4.1 下载状态模型
```swift
enum DownloadPhase: Equatable {
case idle //
case downloading //
case verifying //
case ready //
case failed(String) // ·
}
struct DownloadState: Equatable {
var phase: DownloadPhase
var receivedBytes: Int
var totalBytes: Int
var bytesPerSecond: Double
var fraction: Double { totalBytes > 0 ? Double(receivedBytes) / Double(totalBytes) : 0 }
}
```
`ModelDownloadService` 持有 `var states: [ModelKind: DownloadState]``@MainActor` 更新UI 观察。
## 5. 数据:硬编码 manifest
```swift
struct ModelFile { let path: String; let bytes: Int } // path
enum ModelManifest {
static let baseURL = URL(string: "https://file.myv0.com/")!
static func files(for kind: ModelKind) -> [ModelFile]
static func totalBytes(for kind: ModelKind) -> Int // files.reduce
}
```
- 只列**加载必需**的功能文件,排除纯文档 `README.md` / `.gitattributes`(省下载)。
- 文件 URL = `baseURL / kind.rawValue / file.path`
- `bytes` 用于总进度计算与下载后**逐文件大小校验**。
- 精确清单见附录 A。
## 6. 下载流程(断点续传,应对 3GB 单文件)
逐文件**串行**下载,单文件级续传用 **HTTP Range + 追加写**(比 `URLSession.resumeData` 更可控,
app 重启也能续):
1. 目标 `Models/<repo>/<file>` 已存在且 size 匹配 → 跳过(粗粒度续传)。
2. 否则下到 `Models/<repo>/<file>.part`:已下字节数 = `.part` 当前大小,
`Range: bytes=<已下>-` 请求,`URLSession` data delegate 流式 `FileHandle` 追加写。
3. 完成后校验 `.part` 大小 == manifest `bytes`,原子 `rename` 去掉 `.part` 后缀。
4. 该模型全部文件就位 → `ModelStore.isReady` 自然为真。
- 串行(一次一个文件):不抢 MLX 资源、进度计算清晰。
- 总进度 = 已完成字节 / `totalBytes(for:)`;速度用滑动窗口算 bytes/s。
- 支持「暂停」:取消当前 task`.part` 保留,下次从断点续。
## 7. UI
### 7.1 `ModelManagementView`(分模型卡片)
- 两张卡:
- **Qwen3-1.7B · 文本解读**(约 939 MB
- **Qwen2.5-VL-3B · 拍照识别**(约 2.9 GB
- 每张卡显示:状态 `待下载 / 下载中 xx% · x.x MB/s / 校验中 / 已就绪 ✅ / 失败 · 重试`
+ 进度条(原生 `ProgressView` + `Tj.Palette`+ 大小。
- 顶部总操作 `下载全部模型``TjPrimaryButton`);下载中切为 `暂停`
- **蜂窝网络提示**`NWPathMonitor` 检测到非 WiFi开下前弹确认"约 3.9GB,建议 WiFi 下载")。
- 底部 `从文件导入``TjGhostButton`)→ 旁路导入。
- 复用 `.tjCard` / `TjBadge` / `TjLockChip`,不新增设计 token§9
### 7.2 旁路导入(现场重装兜底)
`DebugAIRunner``fileImporter` 逻辑转正进 Service / `ModelStore`
- 选文件夹 → 校验含 `config.json` → 拷入 `Models/<repo>/`
- **补上 VL**(现在 DEBUG 只导 LLM
- 按所选文件夹名匹配 `ModelKind.rawValue` 自动识别是 LLM 还是 VL不匹配时提示选择。
## 8. 接入点
- `MeView` 「模型管理」卡片 → `NavigationLink``ModelManagementView`
`detail` 动态显示 `已就绪 / 未下载 / 下载中 xx%`
- **AI 入口未就绪引导**§4 要求):`DiaryQuickSheet``UnifiedCaptureFlow``HealthExport`
的「模型未就绪」错误态补 `前往下载` 按钮,跳 `ModelManagementView`
## 9. 错误处理
- 网络中断 → 卡片转 `失败 · 重试`,保留 `.part` 供下次续传,不卡死、不删已下数据。
- 校验失败size 不符)→ 删该文件重下。
- 旁路导入选错文件夹(无 `config.json`)→ 提示,不写入。
- base URL 不可达 → 失败态,文案提示检查网络。
## 10. 测试策略
- 单元测试(用 `URLProtocol` mock 网络,不碰真 MLX / SwiftData
- `ModelManifest.totalBytes` 计算正确。
- 续传偏移计算:`.part` 已有 N 字节时请求 `Range: bytes=N-`
- 大小校验size 不符判失败。
- `DownloadState.fraction` 边界totalBytes=0
- `ModelStore.isReady` 在文件齐全 / 缺失时的判定。
- UI 手动验证:模拟器跑下载流程(指向真实 base URL 或 mock
## 附录 A精确文件清单功能文件排除 README/.gitattributes
### Qwen3-1.7B-4bit9 文件984,013,244 字节≈939 MB
| path | bytes |
|---|---|
| config.json | 937 |
| model.safetensors | 968080210 |
| model.safetensors.index.json | 49731 |
| tokenizer.json | 11422654 |
| tokenizer_config.json | 9706 |
| vocab.json | 2776833 |
| merges.txt | 1671853 |
| special_tokens_map.json | 613 |
| added_tokens.json | 707 |
### Qwen2.5-VL-3B-Instruct-4bit11 文件3,089,710,883 字节≈2.9 GB
| path | bytes |
|---|---|
| config.json | 1659 |
| model.safetensors | 3073720461 |
| model.safetensors.index.json | 108307 |
| tokenizer.json | 11421896 |
| tokenizer_config.json | 7256 |
| vocab.json | 2776833 |
| merges.txt | 1671853 |
| special_tokens_map.json | 613 |
| added_tokens.json | 605 |
| chat_template.json | 1050 |
| preprocessor_config.json | 350 |
> 注:进度分母 = 本表功能文件 `bytes` 之和(已排除 README/.gitattributes
> 服务器上含 README/.gitattributes 的全量为 LLM 984,015,687 / VL 3,089,713,215 字节,仅作素材核对参照。

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,116 @@
# 小红书创作笔记 · GDG 出海创想赛
> 用途:小红书发布「创作笔记」,展示创作过程 + 最终效果,并在报名表提交作品链接。
> 发布必带话题:**#GDG出海创想赛 #Google开发者大会**
> 建议:先发笔记 → 复制笔记链接 → 填报名表。
---
## 一、正文(可直接复制发布)
### 📌 标题(三选一,都够炸)
1. 我用 Google 的 Gemma 做了个「看得懂体检报告」的 App还全程不上云 🔒
2. 6 周独立开发丨一个把化验单读成大白话的本地 AI 健康 App
3. 拍一张体检报告AI 帮我读懂 + 存档 + 追趋势100% 本地)📱
### ✍️ 正文
家里体检报告、化验单越攒越多,看不懂又不敢乱传网上😮‍💨
所以我做了一个 App —— **康康**,一个「只属于你自己」的健康影像档案本 📖
它最特别的地方:**AI 全程在手机里跑,报告不出你的手机。**
用的是 Google 的 **Gemma 4** 端侧模型(跑在 iPhone 本地),
需要更强解读时,才可选接 **Gemini** 云端增强 —— 而且**默认是关的**,开不开你说了算。
隐私优先,这是我做它的底线 ✅
—— 它能干这些事 👇
📷 **拍一张,自动读懂 + 归档**
对着体检报告 / 化验单拍照AI 自动把「血压 128、血糖 5.6、甘油三酯偏高…」这些指标结构化提取出来,异常项直接标红,一份份归进档案库。不用手动录入。
💬 **像聊天一样问自己的健康**
「我最近血糖怎么样?」「上次体检哪些偏高?」直接问,它翻你自己的历史记录来答,还能点引用跳回原始报告。这是本地 RAG —— 你的健康长期记忆。
📈 **趋势一眼看 + AI 一句话解读**
血压、血糖、体重这些长期指标自动连成折线图AI 补一句大白话解读,不是干巴巴的数字。
🗂 **报告档案 + 对比上次**
按体检 / 化验 / 影像 / 处方分类归档,还能和「上一份同类报告」自动对比:哪项升了、哪项降了、哪项从正常变偏高,一目了然。
🩺 **还有这些贴心的**
· 问诊录音自动转文字,看病时不用手忙脚乱记
· 症状 / 日记 / 用药提醒,随手记一笔
· 一键导出健康摘要,看医生时直接给(只给文字,不给原图)
🔐 **隐私三件套**
系统级硬件加密 + Face ID 启动锁 + 永久删除。你的健康数据,真·你自己的。
—— 创作过程我也放进图里了,从第一版画的信息架构,到 Gemma 在真机吐出第一个 token 的那一刻再到现在的完整界面6 周一点点长出来的 🌱
这是我给 **GDG 出海创想赛** 交的作品,做的时候一直在想一件事:
**AI 能不能既聪明,又让人放心?** 康康就是我的答案。
有同样「不想把健康数据交出去」的朋友吗?评论区聊聊你会用吗 👇
#GDG出海创想赛 #Google开发者大会 #独立开发 #AI应用 #端侧AI #Gemma #Gemini #健康管理 #隐私保护 #iOS开发 #SwiftUI
---
## 二、配图建议(小红书是图片优先,建议 69 张)
小红书笔记权重很吃图,按「过程 → 效果」讲故事。推荐顺序:
| 顺序 | 内容 | 说明 |
|---|---|---|
| 封面 | App 首页真机截图 + 大字标题「不上云的 AI 健康档案」 | 封面决定点击率,字要大、对比强 |
| 图 2 | 拍报告 → AI 自动提取指标的对比图(原图 vs 结构化结果) | 最有冲击力的「效果」页 |
| 图 3 | 健康问答聊天界面(带引用回链的那种) | 展示本地 RAG |
| 图 4 | 趋势折线图 + AI 解读那一句 | 长期价值 |
| 图 5 | 报告详情「对比上次」区块 | 差异化亮点 |
| 图 6 | 隐私设置页Face ID / 云端 AI 默认关 开关) | 强化「隐私优先」记忆点 |
| 图 7 | 创作过程:手绘/白板信息架构草图 | 「创作笔记」硬要求:展示过程 |
| 图 8 | 创作过程Gemma 在真机吐第一个 token 的截图 / tok/s 画面 | 技术真实感,扣 Google 主题 |
| 图 9 | 6 周时间线 / 从草图到成品的拼图 | 收尾,强化「独立开发」故事 |
> 小技巧:截图统一加个手机边框、统一背景色,成套出现更高级。
---
## 三、核心卖点速记(写文案 / 答评论时对齐口径)
按对评委和用户的重要性排序:
1. **影像档案系统** —— 拍一张AI 读懂 + 结构化归档(核心创意)
2. **Google AI 全家桶** —— 端侧 Gemma 4 打底 + 可选 Gemini 云端增强(扣赛题)
3. **本地 RAG 健康长期记忆** —— 问自己的历史数据,端侧不可替代
4. **隐私三件套** —— 系统级加密 + Face ID + 永久删除(信任)
5. **AI 趋势解读** —— 长期健康价值
6. **对比上次** —— 报告 diff即时可见
一句话定位:
> **康康 —— 一个 100% 本地推理的个人健康影像档案,帮你把看不懂的体检报告读成大白话,还全程不出手机。**
---
## 四、明确「不做」的边界(被问到时的标准回答)
避免被误解成医疗产品,也是负责任 AI 的加分项:
- ❌ 不做医疗诊断、不给用药剂量 / 频次建议、不做急诊判断
- ❌ 不上传数据、不做账号系统、不接任何云分析 SDK
- ✅ 只做「记录 + 读懂 + 追踪」,把判断权留给你和你的医生
> 标准话术:「康康是健康**记录**工具,不是诊断工具。它帮你读懂和留存数据,但看病还是要找医生。」
---
## 五、发布 & 报名 Checklist
- [ ] 笔记正文含话题 **#GDG出海创想赛 #Google开发者大会**
- [ ] 配图 ≥ 6 张,含「创作过程」页(报名硬要求)
- [ ] 封面字大、卖点清晰
- [ ] 发布后复制笔记链接
- [ ] 打开报名表,提交作品链接 + 个人信息
- [ ] 可选正文提一句「Google 出海创想赛作品」,方便主办方检索

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"

View File

@@ -0,0 +1,52 @@
#!/bin/sh
# 构建 sherpa-mnn.xcframework(端侧 SenseVoice ASR,跑在 MNN 后端),供「记录问诊」离线转写用。
# 产物:Frameworks/sherpa-mnn.xcframework(device arm64 + simulator),被 .gitignore 不入库。
#
# 这是「全代码已就位、构建/真机验证由你来跑」中的构建步骤(详见 docs/release/sensevoice-integration.md)。
# App 侧 SenseVoiceBridge.mm 用 __has_include(<sherpa-mnn/c-api/c-api.h>) 探测:
# - 未接入(当前默认)→ 编为桩,问诊自动回退系统端侧识别(SFSpeech),不影响编译/运行;
# - 接入本脚本产物 + 设好 HEADER_SEARCH_PATHS → 自动切到真实 SenseVoice。
#
# 用法:
# MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/build-sherpa-mnn-xcframework.sh
#
# 需求:已 clone MNN 源码(含 apps/frameworks/sherpa-mnn)、CMake、Xcode。约 15-40 分钟。
set -e
MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}"
SHERPA_DIR="${MNN_SRC}/apps/frameworks/sherpa-mnn"
APP_FRAMEWORKS="$(cd "$(dirname "$0")/.." && pwd)/Frameworks"
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
[ -d "$SHERPA_DIR" ] || { echo "❌ 找不到 sherpa-mnn:$SHERPA_DIR(检查 MNN_SRC)"; exit 1; }
# ① 准备 MNN 静态库供 sherpa-mnn 链接。
# sherpa-mnn 的 build-ios.sh 用 MNN_LIB_DIR 找 libMNN.a + 头文件。
# 这里复用本项目的 MNN device/sim 构建产物;若尚未构建,先跑 scripts/build-mnn-xcframework.sh。
# 注意:sherpa 的 build-ios.sh 默认还会编 simulator x86_64,Apple Silicon 上通常用不到——
# 如只在 arm64 Mac/真机验证,可在 sherpa 的 build-ios.sh 里去掉 simulator_x86_64 段,加快构建。
if [ -z "${MNN_LIB_DIR}" ]; then
echo " 未显式指定 MNN_LIB_DIR,默认用 MNN_SRC 内的构建产物目录。"
echo " 若 sherpa cmake 报找不到 MNN,请先构建 MNN(scripts/build-mnn-xcframework.sh)"
echo " 并 export MNN_LIB_DIR=指向含 libMNN.a + include 的目录。"
export MNN_LIB_DIR="${MNN_SRC}/project/ios/build"
fi
echo "MNN_LIB_DIR=${MNN_LIB_DIR}"
# ② 调 sherpa-mnn 自带的 iOS 构建(已含 libtool 合并 + create-xcframework + 拷头到 Headers/)。
cd "$SHERPA_DIR"
sh build-ios.sh
[ -d "$SHERPA_DIR/sherpa-mnn.xcframework" ] || { echo "❌ 构建未产出 sherpa-mnn.xcframework"; exit 1; }
# ③ 拷进 App 的 Frameworks/(供 Xcode 链接 + HEADER_SEARCH_PATHS 指向其 Headers/)。
mkdir -p "$APP_FRAMEWORKS"
rm -rf "$APP_FRAMEWORKS/sherpa-mnn.xcframework"
cp -R "$SHERPA_DIR/sherpa-mnn.xcframework" "$APP_FRAMEWORKS/"
echo "✅ 输出: $APP_FRAMEWORKS/sherpa-mnn.xcframework"
echo "下一步(Xcode,见 docs/release/sensevoice-integration.md):"
echo " 1) 把 sherpa-mnn.xcframework 拖进 target 的 Frameworks, Libraries, and Embedded Content(Do Not Embed,静态库)"
echo " 2) 在 Build Settings → HEADER_SEARCH_PATHS 追加(recursive):"
echo " \$(PROJECT_DIR)/Frameworks/sherpa-mnn.xcframework/Headers"
echo " 3) 转换并安装 SenseVoice 模型:sh scripts/convert-sensevoice-mnn.sh"

View File

@@ -0,0 +1,65 @@
#!/bin/sh
# 把官方 SenseVoice ONNX 模型转换成 MNN 格式,产出「记录问诊」端侧转写要用的两件套:
# SenseVoice/model.mnn —— MNNConvert 量化转换(weightQuant 8bit)的图
# SenseVoice/tokens.txt —— id↔token 映射(直接拷 onnx 包里的)
#
# 产物目录可:
# - 旁路导入:拷到模拟器/真机沙盒 Application Support/Models/SenseVoice/(demo 现场重装兜底);
# - 或上传到你的镜像,后续做成 App 内下载项(本期默认旁路导入,见集成文档)。
#
# 用法:
# MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/convert-sensevoice-mnn.sh
#
# 需求:MNNConvert(从 MNN 源码 -DMNN_BUILD_CONVERTER=ON 编出)、wget/curl、tar。
set -e
MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}"
OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/build/SenseVoice"
WORK_DIR="$(cd "$(dirname "$0")/.." && pwd)/build/sensevoice-src"
MODEL_TAR="sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
MODEL_URL="https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/${MODEL_TAR}.tar.bz2"
# ① 找 MNNConvert。
MNNCONVERT="${MNNCONVERT:-${MNN_SRC}/build/MNNConvert}"
if [ ! -x "$MNNCONVERT" ]; then
echo "❌ 找不到 MNNConvert:$MNNCONVERT"
echo " 先编译转换器(在 MNN 源码里):"
echo " cd \"$MNN_SRC\" && mkdir -p build && cd build \\"
echo " && cmake .. -DMNN_BUILD_CONVERTER=ON -DMNN_LOW_MEMORY=ON -DMNN_SEP_BUILD=OFF && make MNNConvert -j8"
echo " 或 export MNNCONVERT=/path/to/MNNConvert 后重跑。"
exit 1
fi
# ② 下载 + 解包 SenseVoice ONNX(含 fp32 model.onnx / int8 / tokens.txt)。
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"
if [ ! -d "$MODEL_TAR" ]; then
echo "⬇️ 下载 SenseVoice ONNX 模型…"
if command -v wget >/dev/null 2>&1; then
wget -c "$MODEL_URL"
else
curl -L -O "$MODEL_URL"
fi
tar xvf "${MODEL_TAR}.tar.bz2"
fi
SRC_ONNX="${WORK_DIR}/${MODEL_TAR}/model.onnx"
SRC_TOKENS="${WORK_DIR}/${MODEL_TAR}/tokens.txt"
[ -f "$SRC_ONNX" ] || { echo "❌ 缺 model.onnx(应为 fp32,勿用 int8):$SRC_ONNX"; exit 1; }
[ -f "$SRC_TOKENS" ] || { echo "❌ 缺 tokens.txt:$SRC_TOKENS"; exit 1; }
# ③ 转换:fp32 onnx → mnn(权重 8bit 量化,降体积+配合 MNN_LOW_MEMORY 降运行内存)。
# 红线:转 fp32 的 model.onnx,不要转 model.int8.onnx(README 明确)。
mkdir -p "$OUT_DIR"
echo "🔧 转换 model.onnx → model.mnn(weightQuant 8bit)…"
"$MNNCONVERT" -f ONNX \
--modelFile "$SRC_ONNX" \
--MNNModel "$OUT_DIR/model.mnn" \
--weightQuantBits=8 --weightQuantBlock=64
cp "$SRC_TOKENS" "$OUT_DIR/tokens.txt"
echo "✅ 输出: $OUT_DIR/{model.mnn, tokens.txt}"
echo "安装到沙盒(见 docs/release/sensevoice-integration.md):"
echo " - 模拟器:拷到 ~/Library/Developer/CoreSimulator/.../Application Support/Models/SenseVoice/"
echo " - 真机:经「我的 · 模型管理」旁路导入,或预拷进沙盒 Models/SenseVoice/"

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"

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

@@ -0,0 +1,89 @@
#!/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 \
-skipMacroValidation
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

@@ -1,620 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXContainerItemProxy section */
5E463D092FC403BC0089145B /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5E463CF12FC403BB0089145B /* Project object */;
proxyType = 1;
remoteGlobalIDString = 5E463CF82FC403BB0089145B;
remoteInfo = "体己";
};
5E463D132FC403BC0089145B /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5E463CF12FC403BB0089145B /* Project object */;
proxyType = 1;
remoteGlobalIDString = 5E463CF82FC403BB0089145B;
remoteInfo = "体己";
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
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; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
5E463CFB2FC403BB0089145B /* 体己 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "体己";
sourceTree = "<group>";
};
5E463D0B2FC403BC0089145B /* 体己Tests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "体己Tests";
sourceTree = "<group>";
};
5E463D152FC403BC0089145B /* 体己UITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "体己UITests";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
5E463CF62FC403BB0089145B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D052FC403BC0089145B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D0F2FC403BC0089145B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
5E463CF02FC403BB0089145B = {
isa = PBXGroup;
children = (
5E463CFB2FC403BB0089145B /* 体己 */,
5E463D0B2FC403BC0089145B /* 体己Tests */,
5E463D152FC403BC0089145B /* 体己UITests */,
5E463CFA2FC403BB0089145B /* Products */,
);
sourceTree = "<group>";
};
5E463CFA2FC403BB0089145B /* Products */ = {
isa = PBXGroup;
children = (
5E463CF92FC403BB0089145B /* 体己.app */,
5E463D082FC403BC0089145B /* 体己Tests.xctest */,
5E463D122FC403BC0089145B /* 体己UITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
5E463CF82FC403BB0089145B /* 体己 */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5E463D1C2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己" */;
buildPhases = (
5E463CF52FC403BB0089145B /* Sources */,
5E463CF62FC403BB0089145B /* Frameworks */,
5E463CF72FC403BB0089145B /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
5E463CFB2FC403BB0089145B /* 体己 */,
);
name = "体己";
packageProductDependencies = (
);
productName = "体己";
productReference = 5E463CF92FC403BB0089145B /* 体己.app */;
productType = "com.apple.product-type.application";
};
5E463D072FC403BC0089145B /* 体己Tests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5E463D1F2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己Tests" */;
buildPhases = (
5E463D042FC403BC0089145B /* Sources */,
5E463D052FC403BC0089145B /* Frameworks */,
5E463D062FC403BC0089145B /* Resources */,
);
buildRules = (
);
dependencies = (
5E463D0A2FC403BC0089145B /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
5E463D0B2FC403BC0089145B /* 体己Tests */,
);
name = "体己Tests";
packageProductDependencies = (
);
productName = "体己Tests";
productReference = 5E463D082FC403BC0089145B /* 体己Tests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
5E463D112FC403BC0089145B /* 体己UITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5E463D222FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己UITests" */;
buildPhases = (
5E463D0E2FC403BC0089145B /* Sources */,
5E463D0F2FC403BC0089145B /* Frameworks */,
5E463D102FC403BC0089145B /* Resources */,
);
buildRules = (
);
dependencies = (
5E463D142FC403BC0089145B /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
5E463D152FC403BC0089145B /* 体己UITests */,
);
name = "体己UITests";
packageProductDependencies = (
);
productName = "体己UITests";
productReference = 5E463D122FC403BC0089145B /* 体己UITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
5E463CF12FC403BB0089145B /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
5E463CF82FC403BB0089145B = {
CreatedOnToolsVersion = 26.0.1;
};
5E463D072FC403BC0089145B = {
CreatedOnToolsVersion = 26.0.1;
TestTargetID = 5E463CF82FC403BB0089145B;
};
5E463D112FC403BC0089145B = {
CreatedOnToolsVersion = 26.0.1;
TestTargetID = 5E463CF82FC403BB0089145B;
};
};
};
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "体己" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 5E463CF02FC403BB0089145B;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
5E463CF82FC403BB0089145B /* 体己 */,
5E463D072FC403BC0089145B /* 体己Tests */,
5E463D112FC403BC0089145B /* 体己UITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
5E463CF72FC403BB0089145B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D062FC403BC0089145B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D102FC403BC0089145B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5E463CF52FC403BB0089145B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D042FC403BC0089145B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D0E2FC403BC0089145B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
5E463D0A2FC403BC0089145B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5E463CF82FC403BB0089145B /* 体己 */;
targetProxy = 5E463D092FC403BC0089145B /* PBXContainerItemProxy */;
};
5E463D142FC403BC0089145B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5E463CF82FC403BB0089145B /* 体己 */;
targetProxy = 5E463D132FC403BC0089145B /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
5E463D1A2FC403BC0089145B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
5E463D1B2FC403BC0089145B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
5E463D1D2FC403BC0089145B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"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;
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 = "tiji.--";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
5E463D1E2FC403BC0089145B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"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;
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 = "tiji.--";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
5E463D202FC403BC0089145B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--Tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
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";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/体己.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/体己";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
5E463D212FC403BC0089145B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--Tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
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";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/体己.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/体己";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
5E463D232FC403BC0089145B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--UITests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
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";
TEST_TARGET_NAME = "体己";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
5E463D242FC403BC0089145B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--UITests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
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";
TEST_TARGET_NAME = "体己";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "体己" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5E463D1A2FC403BC0089145B /* Debug */,
5E463D1B2FC403BC0089145B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5E463D1C2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5E463D1D2FC403BC0089145B /* Debug */,
5E463D1E2FC403BC0089145B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5E463D1F2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己Tests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5E463D202FC403BC0089145B /* Debug */,
5E463D212FC403BC0089145B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5E463D222FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己UITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5E463D232FC403BC0089145B /* Debug */,
5E463D242FC403BC0089145B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -1,14 +0,0 @@
<?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>SchemeUserState</key>
<dict>
<key>体己.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -1,85 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,66 +0,0 @@
//
// ContentView.swift
//
//
// Created by Tim on 2026/5/25.
//
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
}
#if os(macOS)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
#endif
.toolbar {
#if os(iOS)
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
#endif
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
}
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
}

View File

@@ -1,18 +0,0 @@
//
// Item.swift
//
//
// Created by Tim on 2026/5/25.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}

View File

@@ -1,32 +0,0 @@
//
// __App.swift
//
//
// Created by Tim on 2026/5/25.
//
import SwiftUI
import SwiftData
@main
struct __App: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}

View File

@@ -10,6 +10,10 @@
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 */; };
FEED000000000000DEAD0007 /* MLXHuggingFace in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0008 /* MLXHuggingFace */; };
FEED000000000000DEAD0009 /* Tokenizers in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD000A /* Tokenizers */; };
FEED000000000000DEAD000B /* HuggingFace in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD000C /* HuggingFace */; };
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -33,6 +37,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 +66,10 @@
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
FEED000000000000DEAD0007 /* MLXHuggingFace in Frameworks */,
FEED000000000000DEAD0009 /* Tokenizers in Frameworks */,
FEED000000000000DEAD000B /* HuggingFace in Frameworks */,
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -88,6 +97,7 @@
5E463D0B2FC403BC0089145B /* 康康Tests */,
5E463D152FC403BC0089145B /* 康康UITests */,
5E463CFA2FC403BB0089145B /* Products */,
FEEDFACE000000000000F001 /* MNN.xcframework */,
);
sourceTree = "<group>";
};
@@ -124,6 +134,9 @@
FEED000000000000DEAD0003 /* MLXLLM */,
FEED000000000000DEAD0004 /* MLXLMCommon */,
FEED000000000000DEAD0006 /* MLXVLM */,
FEED000000000000DEAD0008 /* MLXHuggingFace */,
FEED000000000000DEAD000A /* Tokenizers */,
FEED000000000000DEAD000C /* HuggingFace */,
);
productName = "康康";
productReference = 5E463CF92FC403BB0089145B /* 康康.app */;
@@ -183,7 +196,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
LastUpgradeCheck = 2650;
TargetAttributes = {
5E463CF82FC403BB0089145B = {
CreatedOnToolsVersion = 26.0.1;
@@ -211,7 +224,9 @@
mainGroup = 5E463CF02FC403BB0089145B;
minimizedProjectReferenceProxies = 1;
packageReferences = (
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */,
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */,
5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
@@ -292,6 +307,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -321,6 +337,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -344,6 +361,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -354,6 +372,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -383,6 +402,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_NS_ASSERTIONS = NO;
@@ -399,6 +419,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;
@@ -410,19 +431,25 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 6;
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;
@@ -437,7 +464,7 @@
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;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -448,6 +475,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -462,19 +490,25 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 6;
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;
@@ -489,7 +523,7 @@
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;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -500,6 +534,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -512,12 +547,13 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -539,12 +575,13 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -565,12 +602,13 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -591,12 +629,13 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -655,12 +694,28 @@
/* 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 = 3.31.4;
};
};
5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/huggingface/swift-transformers";
requirement = {
kind = exactVersion;
version = 1.2.1;
};
};
5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/huggingface/swift-huggingface.git";
requirement = {
kind = exactVersion;
version = 0.9.0;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -668,19 +723,34 @@
/* 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;
};
FEED000000000000DEAD0008 /* MLXHuggingFace */ = {
isa = XCSwiftPackageProductDependency;
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
productName = MLXHuggingFace;
};
FEED000000000000DEAD000A /* Tokenizers */ = {
isa = XCSwiftPackageProductDependency;
package = 5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */;
productName = Tokenizers;
};
FEED000000000000DEAD000C /* HuggingFace */ = {
isa = XCSwiftPackageProductDependency;
package = 5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */;
productName = HuggingFace;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 5E463CF12FC403BB0089145B /* Project object */;

View File

@@ -0,0 +1,141 @@
{
"originHash" : "170c2e0a1709c1ce83072661d2e376c3474847f0e091357712d91122c6658589",
"pins" : [
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/EventSource.git",
"state" : {
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
"version" : "1.4.1"
}
},
{
"identity" : "mlx-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift",
"state" : {
"revision" : "dc43e62d7055353c7f99fa071a4e71d29dfddc44",
"version" : "0.31.4"
}
},
{
"identity" : "mlx-swift-lm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift-lm",
"state" : {
"revision" : "bd4b7434e6bdb588c7ef55706ff8904cb7fd4c57",
"version" : "3.31.4"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
"version" : "1.7.1"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
"version" : "1.3.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
}
},
{
"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",
"location" : "https://github.com/huggingface/swift-jinja.git",
"state" : {
"revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc",
"version" : "2.3.6"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "cd3e1152083706d77b223fb29110e590efcc70c0",
"version" : "2.101.2"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax.git",
"state" : {
"revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1",
"version" : "603.0.2"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1",
"version" : "1.7.2"
}
},
{
"identity" : "swift-transformers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-transformers",
"state" : {
"revision" : "58c4bc11963a140358d791f678a60a2745a23146",
"version" : "1.2.1"
}
},
{
"identity" : "yyjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ibireme/yyjson.git",
"state" : {
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
"version" : "0.12.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
BuildableName = "&#x5eb7;&#x5eb7;.app"
BlueprintName = "&#x5eb7;&#x5eb7;"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463D072FC403BC0089145B"
BuildableName = "&#x5eb7;&#x5eb7;Tests.xctest"
BlueprintName = "&#x5eb7;&#x5eb7;Tests"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463D112FC403BC0089145B"
BuildableName = "&#x5eb7;&#x5eb7;UITests.xctest"
BlueprintName = "&#x5eb7;&#x5eb7;UITests"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
BuildableName = "&#x5eb7;&#x5eb7;.app"
BlueprintName = "&#x5eb7;&#x5eb7;"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
BuildableName = "&#x5eb7;&#x5eb7;.app"
BlueprintName = "&#x5eb7;&#x5eb7;"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,32 @@
<?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>SchemeUserState</key>
<dict>
<key>康康.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>5E463CF82FC403BB0089145B</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>5E463D072FC403BC0089145B</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>5E463D112FC403BC0089145B</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

351
康康/AI/AIRuntime.swift Normal file
View File

@@ -0,0 +1,351 @@
import Foundation
import MLX
enum AIRuntimeError: Error, LocalizedError {
case notReady
case modelLoadFailed(String)
case inferenceFailed(String)
var errorDescription: String? {
switch self {
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()
enum Status: Sendable, Equatable {
case notReady
case loading
case ready
case error(String)
}
private(set) var status: Status = .notReady
private(set) var vlStatus: Status = .notReady
/// (;)
private(set) var lastGenerateStats: GenerateStats?
/// ( / PPT )
/// MLX/GPU(Gemma 4 E2B);MNN/SME2 LLM
var activeBackendLabel: String {
#if targetEnvironment(simulator)
return "MLX · CPU(模拟器)"
#else
return "MLX · GPU"
#endif
}
private var llmSession: LLMSession?
private var vlSession: VLSession?
// MARK: - Gemini (hybrid: Gemma ,)
// , OOM , /
// / Gemma 4(MLX )·
private let gemini = GeminiBackend()
/// ( + key)UI
nonisolated var cloudAvailable: Bool { CloudAI.isConfigured }
/// ( / )
nonisolated static var cloudLabel: String { "Gemini · \(CloudAI.model)" }
// 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() {}
/// 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
}
/// ( MLX/GPU,Gemma 4 E2B),
func prepare() async throws {
// ,
// 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(
folderURL: ModelStore.shared.localURL(for: .llm)
)
self.llmSession = session
status = .ready
} catch {
status = .error("\(error)")
throw AIRuntimeError.modelLoadFailed("\(error)")
}
}
/// await prepare()
/// :, actor LLMSession await
/// priority = .background token (CancellationError )
func generate(prompt: String,
maxTokens: Int = 256,
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
// actor ,Task 访 self.status / self.llmSession
let snapshotStatus = status
let snapshotSession = llmSession
return AsyncThrowingStream { continuation in
let task = Task {
guard snapshotStatus == .ready, let session = snapshotSession else {
continuation.finish(throwing: AIRuntimeError.notReady)
return
}
// : LLM VL / ,
await self.acquireGate(priority)
// defer / / ; early-return
defer { self.releaseGate() }
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() }
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)"))
}
}
// / Task( LLMSession / HealthExportService )
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - Gemini (hybrid )
/// ( / ) OOM ,
/// `cloudAvailable`;退 `generate`
func generateCloud(prompt: String, maxTokens: Int = 512) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let stream = await self.gemini.generate(prompt: prompt, maxTokens: maxTokens)
for try await chunk in stream {
try Task.checkCancellation()
continuation.yield(chunk)
}
self.lastGenerateStats = await self.gemini.lastStats
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: CancellationError())
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
/// : Gemini ( JSON)
/// ·;退 OCR+(§3.2)
func analyzeReportCloud(imageURLs: [URL], prompt: String, maxTokens: Int = 1024) async throws -> String {
do {
return try await gemini.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
} catch {
throw AIRuntimeError.inferenceFailed("\(error)")
}
}
// MARK: - ASR(SenseVoice via sherpa-mnn)
/// (SenseVoice, CPU + ): + LLM/VL ,
/// / App jetsam (§3.1 OOM )
///
/// organize( LLM), LLM :
/// body , DiaryAssistService.organizeConsultation prepare
/// body hop 线( actor await ,,
/// ,)
func runExclusiveForASR<T: Sendable>(_ body: @Sendable () async throws -> T) async rethrows -> T {
await acquireGate(.interactive)
defer { releaseGate() }
unloadLLM()
unloadVL()
return try await body()
}
// MARK: - VL
/// VL , load
func prepareVL() async throws {
while vlStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if vlStatus == .ready { return }
// 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()
vlStatus = .loading
do {
let session = try await VLSession.load(
folderURL: ModelStore.shared.localURL(for: .llm)
)
self.vlSession = session
vlStatus = .ready
} catch {
vlStatus = .error("\(error)")
throw AIRuntimeError.modelLoadFailed("\(error)")
}
}
// 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)
/// LLM.generate() , OOM
func analyzeReport(imageURLs: [URL],
prompt: String,
maxTokens: Int = 512) async throws -> String {
guard vlStatus == .ready, let session = vlSession else {
throw AIRuntimeError.notReady
}
await acquireGate()
defer { releaseGate() }
do {
return try await session.analyze(
imageURLs: imageURLs,
prompt: prompt,
maxTokens: maxTokens
)
} catch {
throw AIRuntimeError.inferenceFailed("\(error)")
}
}
}

View File

@@ -0,0 +1,186 @@
import Foundation
enum DownloadError: Error, LocalizedError {
case badStatus(Int)
case sizeMismatch(expected: Int, got: Int)
var errorDescription: String? {
switch self {
case .badStatus(let code):
return String(appLoc: "下载失败(HTTP \(code))")
case .sizeMismatch(let expected, let got):
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
}
}
}
/// , HTTP Range +
/// `URLSessionDataDelegate` `.part`,
///
/// : `FileManager.attributesOfItem` ,****
/// `URL.resourceValues(.fileSizeKey)` URL ,
/// offset finalSize ,
///
/// ()
final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private let configuration: URLSessionConfiguration
private let lock = NSLock()
private var handle: FileHandle?
private var written: Int = 0
private var expectedTotal: Int = 0
private var onProgress: ((Int) -> Void)?
private var responseError: Error?
private var continuation: CheckedContinuation<Void, Error>?
init(configuration: URLSessionConfiguration = .default) {
self.configuration = configuration
super.init()
}
/// URL
static func fileSize(at url: URL) -> Int {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? Int else { return 0 }
return size
}
/// `url` `destination` `destination.part` Range ;
/// == `expectedBytes`, `destination`
nonisolated func download(
from url: URL,
to destination: URL,
expectedBytes: Int,
onProgress: (@Sendable (Int) -> Void)? = nil
) async throws {
let fm = FileManager.default
let part = destination.appendingPathExtension("part")
//
if Self.fileSize(at: destination) == expectedBytes,
fm.fileExists(atPath: destination.path) {
return
}
try fm.createDirectory(
at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
var offset = 0
if fm.fileExists(atPath: part.path) {
offset = Self.fileSize(at: part)
} else {
fm.createFile(atPath: part.path, contents: nil)
}
let fileHandle = try FileHandle(forWritingTo: part)
try fileHandle.seekToEnd()
lock.withLock {
self.handle = fileHandle
self.written = offset
self.expectedTotal = expectedBytes
self.onProgress = onProgress
self.responseError = nil
}
// offset 0 `bytes=0-`: 206 + Content-Range ,
// didReceive (2026-07-14 ),
// sizeMismatch, 3.5GB
var request = URLRequest(url: url)
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
// didCompleteWithError ( delegate , didReceive)
do {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
lock.lock()
self.continuation = cont
lock.unlock()
session.dataTask(with: request).resume()
}
} catch let error as DownloadError {
// : .part,
// badStatus( 503) .part,便
if case .sizeMismatch = error { try? fm.removeItem(at: part) }
throw error
}
let finalSize = Self.fileSize(at: part)
guard finalSize == expectedBytes else {
try? fm.removeItem(at: part)
throw DownloadError.sizeMismatch(expected: expectedBytes, got: finalSize)
}
if fm.fileExists(atPath: destination.path) {
try fm.removeItem(at: destination)
}
try fm.moveItem(at: part, to: destination)
}
// MARK: - URLSessionDataDelegate ( delegate )
nonisolated func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
completionHandler(.cancel)
return
}
// :206 Content-Range(`bytes a-b/total`),
// ,(,)
// 206 200 Content-Length (gzip),,
if let http = response as? HTTPURLResponse, http.statusCode == 206,
let contentRange = http.value(forHTTPHeaderField: "Content-Range"),
let totalPart = contentRange.split(separator: "/").last,
let serverTotal = Int(totalPart) {
lock.lock()
let expected = expectedTotal
lock.unlock()
if serverTotal != expected {
lock.lock()
responseError = DownloadError.sizeMismatch(expected: expected, got: serverTotal)
lock.unlock()
completionHandler(.cancel)
return
}
}
completionHandler(.allow)
}
nonisolated func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data
) {
lock.lock()
try? handle?.write(contentsOf: data)
written += data.count
let progress = written
let callback = onProgress
lock.unlock()
callback?(progress)
}
nonisolated func urlSession(
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
) {
lock.lock()
try? handle?.close()
handle = nil
let cont = continuation
continuation = nil
let respErr = responseError
lock.unlock()
if let respErr {
cont?.resume(throwing: respErr)
} else if let error {
cont?.resume(throwing: error)
} else {
cont?.resume()
}
}
}

View File

@@ -0,0 +1,215 @@
import Foundation
/// AI(Google Gemini)**:**, · AI
/// AI Studio API key key UserDefaults(), Info.plist
/// `GEMINI_API_KEY` / (),
///
/// :demo AI Studio API key REST , SPM ;
/// Firebase AI Logic(App Check key hybrid),
/// `docs/release`
nonisolated enum CloudAI {
private static let enabledKey = "cloud_ai_gemini_enabled"
private static let apiKeyKey = "cloud_ai_gemini_key"
private static let modelKey = "cloud_ai_gemini_model"
/// :便(+)
static let defaultModel = "gemini-2.5-flash"
/// ( false )
static var isEnabled: Bool {
get { UserDefaults.standard.bool(forKey: enabledKey) }
set { UserDefaults.standard.set(newValue, forKey: enabledKey) }
}
/// Gemini API key,(Info.plist / )
static var apiKey: String? {
get {
if let k = UserDefaults.standard.string(forKey: apiKeyKey),
!k.trimmingCharacters(in: .whitespaces).isEmpty { return k }
if let k = Bundle.main.object(forInfoDictionaryKey: "GEMINI_API_KEY") as? String,
!k.isEmpty { return k }
if let k = ProcessInfo.processInfo.environment["GEMINI_API_KEY"],
!k.isEmpty { return k }
return nil
}
set { UserDefaults.standard.set(newValue, forKey: apiKeyKey) }
}
static var model: String {
get { UserDefaults.standard.string(forKey: modelKey) ?? defaultModel }
set { UserDefaults.standard.set(newValue, forKey: modelKey) }
}
/// : + key退
static var isConfigured: Bool {
isEnabled && apiKey != nil
}
}
enum GeminiError: Error, LocalizedError {
case notConfigured
case http(Int, String)
case decode(String)
var errorDescription: String? {
switch self {
case .notConfigured: return String(appLoc: "云端 AI 未配置(请在「我的 · 云端 AI」开启并填入 key)")
case .http(let c, let m): return String(appLoc: "Gemini 请求失败(\(c)):\(m)")
case .decode(let m): return String(appLoc: "Gemini 响应解析失败:\(m)")
}
}
}
/// Google Gemini REST(URLSession) Generative Language API:
/// - `generate``streamGenerateContent`(SSE ), /
/// - `analyze``generateContent`(),/ Gemini
/// Gemma 4(MLX )·(§)
///
/// ,** AIRuntime OOM **,
actor GeminiBackend {
private let endpointBase = "https://generativelanguage.googleapis.com/v1beta/models"
private(set) var lastStats: GenerateStats?
// MARK: -
/// Task
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
guard let key = CloudAI.apiKey else { throw GeminiError.notConfigured }
let model = CloudAI.model
var req = URLRequest(url: URL(string:
"\(endpointBase)/\(model):streamGenerateContent?alt=sse&key=\(key)")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try Self.requestBody(textParts: [prompt],
imageParts: [],
maxTokens: maxTokens)
let (bytes, response) = try await URLSession.shared.bytes(for: req)
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
var body = ""
for try await line in bytes.lines { body += line }
throw GeminiError.http(http.statusCode, String(body.prefix(300)))
}
let start = Date()
var firstAt: Date?
var produced = 0
var usage: GeminiResponse.Usage?
for try await line in bytes.lines {
if Task.isCancelled { break }
guard line.hasPrefix("data:") else { continue }
let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
guard !payload.isEmpty, payload != "[DONE]",
let data = payload.data(using: .utf8) else { continue }
let chunk = try? JSONDecoder().decode(GeminiResponse.self, from: data)
if let u = chunk?.usageMetadata { usage = u }
let text = chunk?.candidates?.first?.content?.parts?
.compactMap(\.text).joined() ?? ""
guard !text.isEmpty else { continue }
if firstAt == nil { firstAt = Date() }
produced += 1
let elapsed = Date().timeIntervalSince(firstAt ?? start)
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
continuation.yield(TokenChunk(text: text, decodeRate: rate))
}
// :prefill = chunk ;decode = ;token usageMetadata
let ttf = (firstAt ?? Date()).timeIntervalSince(start)
let total = Date().timeIntervalSince(start)
self.lastStats = GenerateStats(
promptTokens: usage?.promptTokenCount ?? 0,
genTokens: usage?.candidatesTokenCount ?? produced,
prefillSeconds: max(ttf, 0.0001),
decodeSeconds: max(total - ttf, 0.0001)
)
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: CancellationError())
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - ( )
/// : + prompt ( JSON),
/// + 退(§3.2)
func analyze(imageURLs: [URL], prompt: String, maxTokens: Int) async throws -> String {
guard let key = CloudAI.apiKey else { throw GeminiError.notConfigured }
let model = CloudAI.model
var req = URLRequest(url: URL(string:
"\(endpointBase)/\(model):generateContent?key=\(key)")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try Self.requestBody(textParts: [prompt],
imageParts: imageURLs,
maxTokens: maxTokens)
let (data, response) = try await URLSession.shared.data(for: req)
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
let body = String(data: data, encoding: .utf8) ?? ""
throw GeminiError.http(http.statusCode, String(body.prefix(300)))
}
let decoded = try JSONDecoder().decode(GeminiResponse.self, from: data)
if let msg = decoded.error?.message { throw GeminiError.http(0, msg) }
guard let text = decoded.candidates?.first?.content?.parts?
.compactMap(\.text).joined(), !text.isEmpty else {
throw GeminiError.decode(String(appLoc: "无文本返回"))
}
if let u = decoded.usageMetadata {
self.lastStats = GenerateStats(promptTokens: u.promptTokenCount ?? 0,
genTokens: u.candidatesTokenCount ?? 0,
prefillSeconds: 0.0001, decodeSeconds: 0.0001)
}
return text
}
// MARK: -
private static func requestBody(textParts: [String],
imageParts: [URL],
maxTokens: Int) throws -> Data {
var parts: [[String: Any]] = textParts.map { ["text": $0] }
for url in imageParts {
guard let raw = try? Data(contentsOf: url) else { continue }
// ,;Vault JPEG
let mime = url.pathExtension.lowercased() == "png" ? "image/png" : "image/jpeg"
parts.append(["inline_data": ["mime_type": mime,
"data": raw.base64EncodedString()]])
}
let body: [String: Any] = [
"contents": [["role": "user", "parts": parts]],
"generationConfig": [
"maxOutputTokens": maxTokens,
"temperature": 0.3,
"topP": 0.85
]
]
return try JSONSerialization.data(withJSONObject: body)
}
}
/// Gemini `GenerateContentResponse`
/// nonisolated: MainActor , Decodable MainActor,
/// GeminiBackend (Swift 6 )
private nonisolated struct GeminiResponse: Decodable {
nonisolated struct Candidate: Decodable { let content: Content? }
nonisolated struct Content: Decodable { let parts: [Part]? }
nonisolated struct Part: Decodable { let text: String? }
nonisolated struct Usage: Decodable {
let promptTokenCount: Int?
let candidatesTokenCount: Int?
}
nonisolated struct APIError: Decodable { let message: String? }
let candidates: [Candidate]?
let usageMetadata: Usage?
let error: APIError?
}

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

129
康康/AI/LLMSession.swift Normal file
View File

@@ -0,0 +1,129 @@
import Foundation
import MLX
import MLXLLM
import MLXLMCommon
// mlx-swift-lm 3.x HF Hub/ opt-in (MLXHuggingFace),
// `#hubDownloader()` / `#huggingFaceTokenizerLoader()` HuggingFace / Tokenizers
import MLXHuggingFace
import HuggingFace
import Tokenizers
/// MLX ,actor 线访
/// mlx-swift-lm 3.31.4 API(MLXLLM / 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
}
/// simulator CPU(MLX Metal backend Sim abort)
/// body (GPU/ANE)
/// task-scoped `withDefaultDevice`,TaskLocal child Task / actor
private static func withDeviceOverride<R>(
_ body: () async throws -> R
) async rethrows -> R {
#if targetEnvironment(simulator)
return try await Device.withDefaultDevice(.cpu, body)
#else
return try await body()
#endif
}
/// ( config.json + weights + tokenizer)
/// Gemma 4 : `<turn|>`(token 106)(3n `<end_of_turn>`),
/// eos `<eos>`(1); `<turn|>` maxTokens
/// mlx-swift-lm `gemma4_e2b_it_4bit` extraEOSTokens
static func load(folderURL: URL) async throws -> LLMSession {
let configuration = ModelConfiguration(
directory: folderURL,
extraEOSTokens: ["<turn|>"]
)
// 3.31.4:loadContainer Downloader + TokenizerLoader(.directory)
// (resolve ),Downloader ; HF AutoTokenizer
let container = try await withDeviceOverride {
try await LLMModelFactory.shared.loadContainer(
from: #hubDownloader(),
using: #huggingFaceTokenizerLoader(),
configuration: configuration
)
}
return LLMSession(container: container)
}
/// AsyncThrowingStream , Task
/// - Parameters:
/// - prompt: prompt ( processor LMInput)
/// - maxTokens: token , GenerateParameters
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
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.3),
topP: Float(0.85),
repetitionPenalty: Float(1.1),
repetitionContextSize: 64
)
try await container.perform { (context: ModelContext) in
let userInput = UserInput(prompt: prompt)
let lmInput = try await context.processor.prepare(input: userInput)
let start = Date()
var produced = 0
for await event in try MLXLMCommon.generate(
input: lmInput,
parameters: parameters,
context: context
) {
if Task.isCancelled { break }
switch event {
case .chunk(let text):
produced += 1
let elapsed = Date().timeIntervalSince(start)
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
continuation.yield(TokenChunk(text: text, decodeRate: rate))
case .info(let info):
// ,
await self.record(GenerateStats(
promptTokens: info.promptTokenCount,
genTokens: info.generationTokenCount,
prefillSeconds: info.promptTime,
decodeSeconds: info.generateTime
))
case .toolCall:
// ,switch
break
}
}
// : MLX.GPU.synchronize()
// GPU AsyncStream yield
// ,GPU
// transitive import MLX , SPM
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}

View File

@@ -0,0 +1,40 @@
//
// SenseVoiceBridge.h
// 康康
//
// Objective-C 封装:端侧 SenseVoice ASR(语音→文字),经 sherpa-mnn 在 MNN 后端跑。
// 「记录问诊」用它把整段录音离线转写成文字,再交本地 LLM 整理成问诊小结。
//
// 与 MNNLLMBridge 同一套思路:真实实现在 .mm 里以 C 调用 <sherpa-mnn/c-api/c-api.h>;
// 当工程尚未链接 sherpa-mnn(当前默认)时,以 __has_include 编为桩(isAvailable 返回 NO),
// 上层 SenseVoiceASRService 自动回退到系统端侧识别(SFSpeech),App 不受影响。
// 构建/接入 sherpa-mnn.xcframework 的步骤见 docs/release/sensevoice-integration.md。
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface SenseVoiceBridge : NSObject
/// 本构建是否含真实 sherpa-mnn ASR(已链接 + 非桩=YES)。NO 时上层回退系统识别。
+ (BOOL)isAvailable;
/// 用转换好的 SenseVoice MNN 模型 + tokens 创建离线识别器(贪心解码)。
/// modelPath: MNNConvert 产出的 model.mnn;tokensPath: tokens.txt;
/// language: "auto" / "zh" / "en" / "ja" / "ko" / "yue"。失败返回 nil。
/// 识别器随实例常驻,dealloc 时释放——调用方按「一次问诊建一个」用,用完即释放降内存峰值。
- (nullable instancetype)initWithModelPath:(NSString *)modelPath
tokensPath:(NSString *)tokensPath
language:(NSString *)language;
/// 转写一段单声道 PCM(float32,取值 [-1, 1])。sherpa 内部按 sampleRate 重采样到 16k 再抽 fbank。
/// samples 在本调用期间需保持有效。返回识别文本(可能含 <|lang|> 等标签,由上层清洗);失败返回 nil。
/// 同步阻塞直到解码结束——调用方务必放到后台线程,且经 AIRuntime 闸门与 LLM/VL 互斥(防 OOM)。
- (nullable NSString *)transcribeSamples:(const float *)samples
count:(int)count
sampleRate:(int)sampleRate;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,112 @@
//
// SenseVoiceBridge.mm
// 康康
//
// ObjC++ 实现。链接 sherpa-mnn 时走真实 C-API;否则编为桩(返回不可用,上层回退系统识别)。
//
// 可用性闸门:仅当能找到 sherpa-mnn 的 C-API 头时才编真实路径。这样在尚未接入 sherpa-mnn
// 的工程里本文件也能正常编过(走桩),接入后(把 sherpa-mnn.xcframework 加进 Frameworks/ 并
// 让 HEADER_SEARCH_PATHS 找到其头)自动切到真实实现。见 docs/release/sensevoice-integration.md。
//
#import "SenseVoiceBridge.h"
#if __has_include(<sherpa-mnn/c-api/c-api.h>)
#define KK_SHERPA_MNN_AVAILABLE 1
#else
#define KK_SHERPA_MNN_AVAILABLE 0
#endif
#if !KK_SHERPA_MNN_AVAILABLE
// ============ 桩:工程未链接 sherpa-mnn ============
@implementation SenseVoiceBridge
+ (BOOL)isAvailable { return NO; }
- (nullable instancetype)initWithModelPath:(NSString *)modelPath
tokensPath:(NSString *)tokensPath
language:(NSString *)language { return nil; }
- (nullable NSString *)transcribeSamples:(const float *)samples
count:(int)count
sampleRate:(int)sampleRate { return nil; }
@end
#else
// ============ 真实:sherpa-mnn 离线 SenseVoice ============
#include <sherpa-mnn/c-api/c-api.h>
#include <string>
@implementation SenseVoiceBridge {
const SherpaMnnOfflineRecognizer *_recognizer;
}
+ (BOOL)isAvailable { return YES; }
- (nullable instancetype)initWithModelPath:(NSString *)modelPath
tokensPath:(NSString *)tokensPath
language:(NSString *)language {
self = [super init];
if (!self) return nil;
std::string model = modelPath.UTF8String;
std::string tokens = tokensPath.UTF8String;
std::string lang = language.length ? language.UTF8String : "auto";
SherpaMnnOfflineSenseVoiceModelConfig senseVoice;
memset(&senseVoice, 0, sizeof(senseVoice));
senseVoice.model = model.c_str();
senseVoice.language = lang.c_str();
senseVoice.use_itn = 1; // 逆文本规整:把「一百四十」之类还原成 140,数值更利于阅读/给医生看
SherpaMnnOfflineModelConfig modelConfig;
memset(&modelConfig, 0, sizeof(modelConfig));
modelConfig.tokens = tokens.c_str();
modelConfig.num_threads = 2; // 端侧保守取 2,避免与 UI 抢核
modelConfig.debug = 0;
modelConfig.provider = "cpu"; // sherpa-mnn 后端即 MNN(CPU/SME2),provider 维持 "cpu"
modelConfig.sense_voice = senseVoice;
SherpaMnnOfflineRecognizerConfig config;
memset(&config, 0, sizeof(config));
config.decoding_method = "greedy_search";
config.model_config = modelConfig;
_recognizer = SherpaMnnCreateOfflineRecognizer(&config);
if (_recognizer == nullptr) return nil;
return self;
}
- (void)dealloc {
if (_recognizer) {
SherpaMnnDestroyOfflineRecognizer(_recognizer);
_recognizer = nullptr;
}
}
- (nullable NSString *)transcribeSamples:(const float *)samples
count:(int)count
sampleRate:(int)sampleRate {
if (_recognizer == nullptr || samples == nullptr || count <= 0) return nil;
const SherpaMnnOfflineStream *stream = SherpaMnnCreateOfflineStream(_recognizer);
if (stream == nullptr) return nil;
SherpaMnnAcceptWaveformOffline(stream, sampleRate, samples, count);
SherpaMnnDecodeOfflineStream(_recognizer, stream);
NSString *text = nil;
const SherpaMnnOfflineRecognizerResult *result =
SherpaMnnGetOfflineStreamResult(stream);
if (result) {
if (result->text) {
text = [NSString stringWithUTF8String:result->text];
}
SherpaMnnDestroyOfflineRecognizerResult(result);
}
SherpaMnnDestroyOfflineStream(stream);
return text;
}
@end
#endif

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