Compare commits

...

50 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
186 changed files with 26224 additions and 1391 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())

3
.gitignore vendored
View File

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

View File

@@ -22,9 +22,9 @@
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
| 持久化 | SwiftData | 见 §5 数据模型 |
| 图表 | Swift Charts | iOS 16+ 原生 |
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 |
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
| 模型 | **Qwen3.5-2B**(一个多模态模型,文本+视觉一肩挑) | 真机主用:`taobao-mnn/Qwen3.5-2B-MNN`(~1.2GB);MLX 兜底:`mlx-community/Qwen3.5-2B-4bit`(~1.7GB)。**已废弃**:Qwen3-1.7B / Qwen2.5-VL-3B / Qwen3-VL-4B(4B 实测过慢退回 2B) |
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
| Face ID | LocalAuthentication | |
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
@@ -38,13 +38,14 @@
### 3.1 模块边界(强制)
```
UI → CaptureService / AskService / TrendService → AIRuntime → MLX
UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX
Persistence
```
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
- **`AIRuntime``actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
- **`AIRuntime``actor` 单例,串行化**。同一时刻只允许一个推理任务(模型共享内存/Metal 显存,并发会 OOM 被 jetsam 杀)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。**真正落地**是 actor 内信号量闸门 `acquireGate()/releaseGate()`,所有占显存的重活(解码 + 模型加载)进入前先 await,且加载 VL 前先卸 LLM。
- **引擎选择**:`InferenceEngine.current` 由偏好(`.auto`/`.mnn`/`.mlx`)+ 设备可用性解析,真机默认 `.mnn`(SME2/NEON),模拟器回退 `.mlx`
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
### 3.2 VL pipeline(拍一张 = 一条流程)
@@ -66,7 +67,7 @@ VL prompt 必须:
### 3.3 RAG(结构化检索,不做 embedding)
**两段式调用**:
1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
1. 用 Qwen3.5-2B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
@@ -84,7 +85,9 @@ VL prompt 必须:
## 4. 模型分发
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
- **用户面只有一个模型**:Qwen3.5-2B-MNN(~1.2GB,`ModelKind.userFacing = [.mnnLLM]`)。多模态,文本+视觉全包,下载全部 / 就绪计数只算它
- MLX 兜底版 Qwen3.5-2B-4bit(~1.7GB)仅模拟器与兜底用,不展示、不计入「下载全部」,但旁路导入仍可单独导
- WiFi 提示必须有
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
@@ -259,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
| 周次 | 必交付 |
|---|---|
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |

View File

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

View File

@@ -0,0 +1,11 @@
import WidgetKit
import SwiftUI
/// KangkangWidget extension
/// W5 Live Activity , ActivityConfiguration Bundle
@main
struct KangkangWidgetBundle: WidgetBundle {
var body: some Widget {
PinnedIndicatorsWidget()
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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,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 文件。

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -53,7 +53,8 @@ xcodebuild archive \
-configuration "$CONFIGURATION" \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE_PATH" \
-allowProvisioningUpdates
-allowProvisioningUpdates \
-skipMacroValidation
echo "==> [2/3] 生成 ExportOptions.plist..."
cat > "$EXPORT_PLIST" <<EOF

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,12 +431,14 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
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;
@@ -423,8 +446,10 @@
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -439,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;
@@ -450,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";
@@ -464,12 +490,14 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
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;
@@ -477,8 +505,10 @@
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -493,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;
@@ -504,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";
@@ -516,12 +547,13 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
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;
@@ -543,12 +575,13 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
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;
@@ -569,12 +602,13 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
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;
@@ -595,12 +629,13 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
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;
@@ -659,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 */
@@ -672,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

@@ -1,13 +1,13 @@
{
"originHash" : "6b8265ebd61c6fdfca835dd1f90f17439ca9abc5c11a8b7b5db8790be0349e4d",
"originHash" : "170c2e0a1709c1ce83072661d2e376c3474847f0e091357712d91122c6658589",
"pins" : [
{
"identity" : "gzipswift",
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/1024jp/GzipSwift",
"location" : "https://github.com/mattt/EventSource.git",
"state" : {
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05",
"version" : "6.0.1"
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
"version" : "1.4.1"
}
},
{
@@ -15,17 +15,35 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift",
"state" : {
"revision" : "072b684acaae80b6a463abab3a103732f33774bf",
"version" : "0.29.1"
"revision" : "dc43e62d7055353c7f99fa071a4e71d29dfddc44",
"version" : "0.31.4"
}
},
{
"identity" : "mlx-swift-examples",
"identity" : "mlx-swift-lm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift-examples",
"location" : "https://github.com/ml-explore/mlx-swift-lm",
"state" : {
"revision" : "9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631",
"version" : "2.29.1"
"revision" : "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"
}
},
{
@@ -33,8 +51,26 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a",
"version" : "1.5.1"
"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"
}
},
{
@@ -46,6 +82,15 @@
"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",
@@ -55,13 +100,40 @@
"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" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2",
"version" : "1.0.0"
"revision" : "58c4bc11963a140358d791f678a60a2745a23146",
"version" : "1.2.1"
}
},
{
"identity" : "yyjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ibireme/yyjson.git",
"state" : {
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
"version" : "0.12.0"
}
}
],

View File

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

View File

@@ -15,6 +15,13 @@ enum AIRuntimeError: Error, LocalizedError {
}
}
/// interactive = (//);
/// background = (),
nonisolated enum InferencePriority: Sendable, Equatable {
case interactive
case background
}
actor AIRuntime {
static let shared = AIRuntime()
@@ -27,11 +34,32 @@ actor AIRuntime {
private(set) var status: Status = .notReady
private(set) var vlStatus: Status = .notReady
private(set) var lastDecodeRate: Double = 0
/// (;)
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;
@@ -42,30 +70,56 @@ actor AIRuntime {
// actor (count = 1):( + )
// await acquireGate(), releaseGate()actor
// gateBusy / gateWaiters
private struct GateWaiter {
let priority: InferencePriority
let cont: CheckedContinuation<Void, Never>
}
private var gateBusy = false
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
private var gateHolderPriority: InferencePriority = .interactive
private var preemptRequested = false
private var gateWaiters: [GateWaiter] = []
private func acquireGate() async {
/// interactive background ; FIFO,
nonisolated static func gateInsertionIndex(of priority: InferencePriority,
in waiting: [InferencePriority]) -> Int {
guard priority == .interactive else { return waiting.count }
return waiting.firstIndex(of: .background) ?? waiting.count
}
private func acquireGate(_ priority: InferencePriority = .interactive) async {
if !gateBusy {
gateBusy = true
gateHolderPriority = priority
return
}
// : token CancellationError
if priority == .interactive, gateHolderPriority == .background {
preemptRequested = true
}
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
gateWaiters.append(cont)
let idx = Self.gateInsertionIndex(of: priority, in: gateWaiters.map(\.priority))
gateWaiters.insert(GateWaiter(priority: priority, cont: cont), at: idx)
}
// releaseGate (gateBusy true)
}
private func releaseGate() {
preemptRequested = false
if gateWaiters.isEmpty {
gateBusy = false
} else {
// ,gateBusy true,
let next = gateWaiters.removeFirst()
next.resume()
gateHolderPriority = next.priority
next.cont.resume()
}
}
/// token :
private func shouldPreempt(_ priority: InferencePriority) -> Bool {
priority == .background && preemptRequested
}
private init() {}
/// App : MLX GPU , reuse cache
@@ -74,11 +128,11 @@ actor AIRuntime {
nonisolated static func configureMLXMemory() {
#if !targetEnvironment(simulator)
// 256MB cache : 3GB MB
MLX.GPU.set(cacheLimit: 256 * 1024 * 1024)
MLX.Memory.cacheLimit = 256 * 1024 * 1024
#endif
}
/// ,
/// ( MLX/GPU,Gemma 4 E2B),
func prepare() async throws {
// ,
// return: ready, generate
@@ -121,7 +175,10 @@ actor AIRuntime {
/// await prepare()
/// :, actor LLMSession await
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
/// priority = .background token (CancellationError )
func generate(prompt: String,
maxTokens: Int = 256,
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
// actor ,Task 访 self.status / self.llmSession
let snapshotStatus = status
let snapshotSession = llmSession
@@ -133,7 +190,9 @@ actor AIRuntime {
return
}
// : LLM VL / ,
await self.acquireGate()
await self.acquireGate(priority)
// defer / / ; early-return
defer { self.releaseGate() }
do {
// session.generate actor , await
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
@@ -141,26 +200,74 @@ actor AIRuntime {
// (UI)/, checkCancellation Task 退,
// session onTermination, MLX , GPU
try Task.checkCancellation()
// Task generate() , AIRuntime actor ;
// actor recordRate await
self.recordRate(chunk.decodeRate)
// :, 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)"))
}
// / / (checkCancellation catch ),
// ,
self.releaseGate()
}
// / Task( LLMSession / HealthExportService )
continuation.onTermination = { _ in task.cancel() }
}
}
private func recordRate(_ rate: Double) {
if rate > 0 { lastDecodeRate = rate }
// 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
@@ -172,14 +279,15 @@ actor AIRuntime {
}
if vlStatus == .ready { return }
// prepare(): isComplete (),
guard ModelStore.shared.isComplete(for: .vl) else {
// MLX VL .llm Qwen3.5-2B (VLMModelFactory qwen3_5 ),
// Qwen3-VL-4B isComplete ,
guard ModelStore.shared.isComplete(for: .llm) else {
vlStatus = .error("VL 模型未就绪")
throw AIRuntimeError.notReady
}
// :( LLM ), LLM + VL
// App 退
// App 退
await acquireGate()
defer { releaseGate() }
if vlStatus == .ready { return }
@@ -190,7 +298,7 @@ actor AIRuntime {
vlStatus = .loading
do {
let session = try await VLSession.load(
folderURL: ModelStore.shared.localURL(for: .vl)
folderURL: ModelStore.shared.localURL(for: .llm)
)
self.vlSession = session
vlStatus = .ready
@@ -208,7 +316,7 @@ actor AIRuntime {
guard llmSession != nil else { return }
llmSession = nil
status = .notReady
MLX.GPU.clearCache()
MLX.Memory.clearCache()
}
/// VL, ModelContainer MLX
@@ -216,7 +324,7 @@ actor AIRuntime {
guard vlSession != nil else { return }
vlSession = nil
vlStatus = .notReady
MLX.GPU.clearCache()
MLX.Memory.clearCache()
}
/// JSON ( VLPrompts.reportExtraction )

View File

@@ -28,6 +28,7 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
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>?
@@ -77,24 +78,33 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
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)
if offset > 0 {
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
}
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
// didCompleteWithError ( delegate , didReceive)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
lock.lock()
self.continuation = cont
lock.unlock()
session.dataTask(with: request).resume()
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)
@@ -119,9 +129,27 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
completionHandler(.cancel)
} else {
completionHandler(.allow)
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(

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

View File

@@ -2,12 +2,22 @@ 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-examples 2.29.1(commit 9bff95ca) API
/// 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
}
@@ -26,10 +36,20 @@ actor LLMSession {
}
/// ( 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)
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
)
}
@@ -45,10 +65,16 @@ actor LLMSession {
let task = Task {
do {
try await Self.withDeviceOverride {
// : App "/JSON ", JSON
// 0.3 + topP 0.85 JSON ( MNN set_config )
// repetitionPenalty: + ,()
// ;1.1 + 64 token ( MNN penalty )
let parameters = GenerateParameters(
maxTokens: maxTokens,
temperature: Float(0.6),
topP: Float(0.9)
temperature: Float(0.3),
topP: Float(0.85),
repetitionPenalty: Float(1.1),
repetitionContextSize: 64
)
try await container.perform { (context: ModelContext) in
@@ -72,9 +98,14 @@ actor LLMSession {
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
continuation.yield(TokenChunk(text: text, decodeRate: rate))
case .info:
case .info(let info):
// ,
break
await self.record(GenerateStats(
promptTokens: info.promptTokenCount,
genTokens: info.generationTokenCount,
prefillSeconds: info.promptTime,
decodeSeconds: info.generateTime
))
case .toolCall:
// ,switch

View File

@@ -0,0 +1,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

View File

@@ -11,23 +11,54 @@ struct ModelFile: Equatable, Sendable {
/// ,
/// docs/superpowers/specs/2026-05-29-model-download-design.md A
nonisolated enum ModelManifest {
/// Caddy ( HTTPS )
/// Caddy `.vl`(,)退; / MLX
/// IP( App ATS ): http://101.132.124.52:5244/
static let baseURL = URL(string: "https://file.myv0.com/")!
/// (ModelScope) id;`resolve/master` 302 OSS,
/// OSS Range,穿( 206 + )(2026-06 )
/// :MNN `MNN`( HuggingFace taobao-mnn);MLX 沿 mlx-community
static func modelScopeRepo(for kind: ModelKind) -> String? {
switch kind {
case .llm: return "mlx-community/gemma-4-e2b-it-4bit" // ,
case .mnnLLM: return "MNN/Qwen3.5-2B-MNN" // ,
case .vl: return nil // , / ,
}
}
/// hf-mirror(hf-mirror.com)退 id mlx-community HF org/name ;
/// MNN HuggingFace (taobao-mnn),,退revision HF `main`
static func huggingFaceMirrorRepo(for kind: ModelKind) -> String? {
switch kind {
case .llm: return kind.huggingFaceRepo // mlx-community/gemma-4-e2b-it-4bit
case .vl, .mnnLLM: return nil
}
}
static func files(for kind: ModelKind) -> [ModelFile] {
switch kind {
case .llm:
// gemma-4-e2b-it-4bit:Gemma 4 E2B(instruct,4bit)config.json model_type = "gemma4",
// LLMModelFactory "gemma4" (mlx-swift-lm 3.31.4 ;Gemma4Model
// language_model ,/)e2b ( gemma4_unified 12B)
// ModelScope mlx-community/gemma-4-e2b-it-4bit blob
//(Range Content-Range ,2026-07-14 / hf-mirror )
// 2026-07 checkpoint( 3.581G3.551G,config/tokenizer_config/
// processor_config/index ;model_type gemma44bit,)
// FileDownloader sizeMismatch,,
// README.md / .gitattributes / configuration.json( ModelScope ,MLX
// )Gemma 4 3n : tokenizer.model / special_tokens_map.json,
// tokenizer_config.json (chat template chat_template.jinja),
// processor_config.json mlx-swift-lm ,
return [
ModelFile(path: "config.json", bytes: 937),
ModelFile(path: "model.safetensors", bytes: 968_080_210),
ModelFile(path: "model.safetensors.index.json", bytes: 49_731),
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
ModelFile(path: "tokenizer_config.json", bytes: 9_706),
ModelFile(path: "vocab.json", bytes: 2_776_833),
ModelFile(path: "merges.txt", bytes: 1_671_853),
ModelFile(path: "special_tokens_map.json", bytes: 613),
ModelFile(path: "added_tokens.json", bytes: 707),
ModelFile(path: "config.json", bytes: 6_395),
ModelFile(path: "generation_config.json", bytes: 208),
ModelFile(path: "model.safetensors", bytes: 3_550_670_554),
ModelFile(path: "model.safetensors.index.json", bytes: 218_323),
ModelFile(path: "tokenizer.json", bytes: 32_169_626),
ModelFile(path: "tokenizer_config.json", bytes: 2_740),
ModelFile(path: "chat_template.jinja", bytes: 17_336),
ModelFile(path: "processor_config.json", bytes: 1_316),
]
case .vl:
// Qwen3-VL-4B-Instruct-4bit: mlx-community blob
@@ -52,6 +83,19 @@ nonisolated enum ModelManifest {
ModelFile(path: "preprocessor_config.json", bytes: 782),
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
]
case .mnnLLM:
// taobao-mnn/Qwen3.5-2B-MNN MNN (HF API ,2026-06)
// :config.json(MNN llm )+ llm_config.json()+ llm.mnn()
// + llm.mnn.weight( ~1.1GB)+ tokenizer.txt + visual.mnn(, mllm)
// README/.gitattributes dump(llm.mnn.json / export_args.json)
return [
ModelFile(path: "config.json", bytes: 652),
ModelFile(path: "llm_config.json", bytes: 8_692),
ModelFile(path: "llm.mnn", bytes: 2_148_136),
ModelFile(path: "llm.mnn.weight", bytes: 1_176_647_702),
ModelFile(path: "tokenizer.txt", bytes: 6_465_727),
ModelFile(path: "visual.mnn", bytes: 488_096),
]
}
}
@@ -59,10 +103,35 @@ nonisolated enum ModelManifest {
files(for: kind).reduce(0) { $0 + $1.bytes }
}
/// URL = baseURL / <> / <>
/// **** URL(= ) `resolve/master`
/// `fileURLs` + hf-mirror 退;
/// ()
static func fileURL(for kind: ModelKind, file: ModelFile) -> URL {
baseURL
.appendingPathComponent(kind.rawValue, isDirectory: true)
.appendingPathComponent(file.path)
fileURLs(for: kind, file: file)[0]
}
/// :**(ModelScope,resolve/master),hf-mirror(resolve/main)退**
/// mlx-community , `.part`
/// ( `Range: bytes=offset-` ,): + hf-mirror 退
/// `.vl`( / hf 退) `baseURL`()
static func fileURLs(for kind: ModelKind, file: ModelFile) -> [URL] {
var urls: [URL] = []
if let repo = modelScopeRepo(for: kind) {
urls.append(
URL(string: "https://modelscope.cn/models/\(repo)/resolve/master/")!
.appendingPathComponent(file.path))
}
if let hfRepo = huggingFaceMirrorRepo(for: kind) {
urls.append(
URL(string: "https://hf-mirror.com/\(hfRepo)/resolve/main/")!
.appendingPathComponent(file.path))
}
if urls.isEmpty {
urls.append(
baseURL
.appendingPathComponent(kind.rawValue, isDirectory: true)
.appendingPathComponent(file.path))
}
return urls
}
}

View File

@@ -1,14 +1,22 @@
import Foundation
nonisolated enum ModelKind: String, CaseIterable {
/// HuggingFace mlx-community , Models/
case llm = "Qwen3-1.7B-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
/// Models/ / CDN
/// Qwen3.5-2B Gemma-3n **Gemma 4 E2B( 4bit, MLX/GPU)**:
/// - llm:****MLX , LLMModelFactory `gemma4`
/// (mlx-swift-lm 3.31.4 ;`Gemma4Model` `language_model` ,/)
/// e2b ( gemma4_unified 12B), OCR + LLM
/// - vl:, switch,/
/// - mnnLLM: MNN/SME2 ;,/
case llm = "gemma-4-e2b-it-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
case mnnLLM = "Qwen3.5-2B-MNN"
var displayName: String {
switch self {
case .llm: return "Qwen3-1.7B"
case .vl: return "Qwen3-VL-4B"
case .llm: return "Gemma 4 E2B (MLX)"
case .vl: return "Qwen3-VL-4B"
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
}
}
@@ -17,6 +25,10 @@ nonisolated enum ModelKind: String, CaseIterable {
///
var sentinelFilename: String { "config.json" }
/// : / /
/// Gemma 4 E2B(MLX,).vl/.mnnLLM ,
static let userFacing: [ModelKind] = [.llm]
}
/// `@unchecked Sendable`:rootURL let, filesystem(线),

View File

@@ -0,0 +1,54 @@
import Foundation
/// prompt(2026-06-28)
///
/// `DiaryAssistPrompts.organize`(),:
/// ****( + ),,便/
///
/// 线( §1 / §10):
/// - ****, App /,
///
/// - (140/90 140/90)
enum ConsultationPrompts {
/// 稿()2B context :
static let organizeTranscriptLimit = 2000
static func organize(transcript: String) -> String {
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
return """
你是健康记录助手。下面是用户在医院/诊所就诊时的录音转写原话,通常包含本人和医生两个人的对话,
可能口语化、有重复、缺标点、说话人没标注。请把它整理成一份清晰的【问诊小结】,方便本人日后回看。
硬性规则:
- 这只是**转写整理**:只复述录音里【确实说过】的内容,【绝对不许】自己新增诊断、用药、剂量、频次或「建议就医」。
- 输出语言必须与【录音转写原话】一致:原话是中文就用中文,是英文就用英文,对应语言不翻译(小节名可保留中文标签)。
- 数值、单位、药名、剂量、时间【一字不改】——原话说 140/90 就写 140/90,说一天两次就写一天两次。
- 录音里没提到的小节就【整节省略】,不要写「无」「未提及」之类占位,也不要硬凑。
- 区分说话人:本人的主诉用第一人称「我」;医生说的话写成「医生:…」。
- 不确定/听不清的地方保留原样,不要脑补。
按下面这些小节整理(只保留录音里出现过的,用「小节名:内容」分行):
主诉 —— 本人这次来看什么、哪里不舒服
现病史 —— 起病时间、过程、变化
医生判断 —— 医生当场说的判断或解释(原话复述,不替医生下结论)
医生建议 —— 医生给的检查、复查、生活建议
用药 —— 医生提到的药名/用法(原样复述,不补剂量)
复查与注意 —— 下次复查时间、注意事项
只输出整理后的小结正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
示例(转写:就那个我最近老是胸口闷上楼梯就喘大概有半个月了医生说听着心率有点快先做个心电图和验血下周三来复查这两天别太累):
主诉:最近胸口闷,上楼梯就喘。
现病史:已持续约半个月。
医生判断:医生:听着心率有点快。
医生建议:先做心电图和验血。
复查与注意:下周三复查,这两天别太累。
【录音转写原话】:
\(trimmed)
Output: /no_think
"""
}
}

View File

@@ -11,12 +11,32 @@ enum DiaryAssistPrompts {
/// ;UI
/// /, few-shot
/// :** + **,(prompt ),
/// /,**** `localizedDimension`
static let dimensions: [String] = [
"起病诱因", "症状性质", "伴随症状", "加重缓解",
"持续频率", "既往家族史", "用药过敏", "生活方式",
]
/// - content:
/// , UI ()
/// `String(appLoc:)` : key Localizable.xcstrings
/// dim(),
nonisolated static func localizedDimension(_ dim: String) -> String {
let d = dim.trimmingCharacters(in: .whitespacesAndNewlines)
switch d {
case "起病诱因": return String(appLoc: "起病诱因")
case "症状性质": return String(appLoc: "症状性质")
case "伴随症状": return String(appLoc: "伴随症状")
case "加重缓解": return String(appLoc: "加重缓解")
case "持续频率": return String(appLoc: "持续频率")
case "既往家族史": return String(appLoc: "既往家族史")
case "用药过敏": return String(appLoc: "用药过敏")
case "生活方式": return String(appLoc: "生活方式")
default: return d
}
}
/// - content:
/// - coveredDimensions: (),
///
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
@@ -30,8 +50,8 @@ enum DiaryAssistPrompts {
: "\n- 已问过的维度【不要再问】:\(covered.joined(separator: ""))。本轮只能从这些还没问的维度里挑:\(allowedLine)"
return """
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
请从医生问诊角度提出 3-4 个最值得追问的问题,帮患者把这条记录补全。
你是社区医生的小助手。用户写了一段身体状态的健康记录,信息可能不够完整。
请从医生问诊角度提出 3-4 个最值得追问的问题,帮用户把这条记录补全。
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
1. 起病诱因 —— 何时开始、有无诱因
@@ -72,6 +92,48 @@ enum DiaryAssistPrompts {
本轮可选维度:\(allowedLine)
【最新记录】:
\(content)
\(aiOutputLanguageDirective())
(注意:dim 维度名仍固定用上面清单里的中文原名;只有 q 和 fill 面向用户的文字用上述语言。)
Output: /no_think
"""
}
// MARK: -
/// 稿()2B context :
static let organizeTranscriptLimit = 1200
/// 稿稿: ;
/// :
/// 线(spec 2026-06-10-voice-diary §2):,
/// 2B 140/90 130/90 , few-shot
static func organize(transcript: String) -> String {
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
return """
你是健康记录助手。下面是用户口述身体状态的语音转写原话,可能口语化、有重复、缺标点。
请把它整理成一条清晰的健康日记。
硬性规则:
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
- 输出语言必须与【口述原话】完全一致:原话是中文就用中文,是英文就用英文,是日文/韩文就用对应语言,不要翻译。
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
- 不诊断、不给用药建议、不写「建议就医」。
- 只输出整理后的日记正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
示例 1(口述:那个今天早上起来有点头晕然后我量了下血压140 90比平时高一点没吃早饭就出门了):
今天早上起来有点头晕,量了血压 140/90,比平时高一点。没吃早饭就出门了。
示例 2(口述:今天头晕了一上午下午好点了血压早上量的140 90嗯缬沙坦吃了降脂药忘了吃早饭没吃中午吃的清淡晚上散步了半小时):
症状:头晕了一上午,下午好转。
血压:早上 140/90。
用药:缬沙坦已服,降脂药忘服。
饮食:早饭未吃,午餐清淡。
运动:晚上散步半小时。
【口述原话】:
\(trimmed)
Output: /no_think
"""

View File

@@ -0,0 +1,143 @@
import Foundation
/// LLM prompt:,
/// ,
///
/// (穿 prompt):
///
///
/// 线沿 `DiaryAssistPrompts.organize`:///;
/// § aiOutputLanguageDirective(App/Localization.swift)
///
/// nonisolated:,;`DiaryChatService` nonisolated
/// (roundsUsed/isWrappedUp) `maxRounds`, MainActor
nonisolated enum DiaryChatPrompts {
/// ()
static let maxRounds = 4
/// 稿(),2B/E2B context
static let distillTranscriptLimit = 2000
/// AI (View , LLM)
static var staticOpening: String { String(appLoc: "今天身体感觉怎么样?想到什么就说什么,我帮你记着。") }
// MARK: -
/// ,: + 1
static func opening(dataJSON: String) -> String {
"""
你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。
用户刚打开「聊着记」,你要说第一句话:先自然地打个招呼,再基于下面【健康记录】里最近一条记录,像老朋友一样关心地问 1 个问题。
铁律:
- 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造。
- 数值、日期、药名必须与记录完全一致,一个字都不能改。
- 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
- 最多 2 句话,只问 1 个问题;不用列表、不用 Markdown、不用表情符号。
- 记录里 diaries / indicators 全为空时,不提任何具体记录,只简单问今天感觉怎么样。
示例 1(记录里有「2026-07-11 日记:头疼了一下午」):
前天你记过头疼了一下午,现在好些了吗?
示例 2(记录里有血压 145/92 偏高):
昨天量的血压 145/92 有点偏高,今天有没有再量一次?
示例 3(记录为空):
嗨,今天身体感觉怎么样?想到什么就跟我说说,我帮你记下来。
【健康记录】:
\(dataJSON)
直接输出这句话,不要引号、不要解释、不要 <think> 标签。
\(aiOutputLanguageDirective())
/no_think
"""
}
// MARK: -
/// ,:,
/// - roundsLeft: ;> 0 ,== 0
static func reply(transcript: String, latest: String, dataJSON: String, roundsLeft: Int) -> String {
// Swift ,:,
let roundRule: String = roundsLeft > 0
? "本轮还可以追问,最多问 1 个新问题。追问只围绕「把这条记录补完整」:时间、部位、程度、吃没吃药、睡眠饮食等;可以结合【健康记录】做有记忆的追问(如「上次你记过 XX,这次也一样吗」)。"
: "【不要再提任何问题】。用 1-2 句话温和收尾:先接住用户刚说的话,再告诉 TA 今天先聊到这儿,点下面的「聊完了」,我帮你整理成一条记录。"
return """
你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。
你正在「聊着记」里陪用户聊天。任务:先用一小句接住用户刚说的话(共情或确认),再按【本轮规则】决定要不要追问。
铁律:
- 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造;数值、日期、药名必须与记录完全一致。
- 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
- 总共最多 2 句话;不用列表、不用 Markdown、不用表情符号。
【本轮规则】:
\(roundRule)
示例(还能追问,用户说「下午头就不疼了,不过晚上只睡了五个小时」):
不疼了就好。昨晚只睡五个小时,是入睡难还是半夜醒了?
示例(收尾):
好,记下啦。今天先聊到这儿,点下面的「聊完了」,我帮你把这些整理成一条记录。
【健康记录】:
\(dataJSON)
【对话】:
\(transcript.isEmpty ? "" : transcript)
【用户刚说的】:
\(latest)
直接输出你要说的话,不要引号、不要解释、不要 <think> 标签。\(aiOutputLanguageDirective())
/no_think
"""
}
// MARK: -
/// 稿线 `DiaryAssistPrompts.organize`:
/// ,**** aiOutputLanguageDirective:
static func distill(transcript: String) -> String {
let trimmed = String(transcript.prefix(distillTranscriptLimit))
return """
你是「康康」,用户健康 App 里陪伴记录的小伙伴。下面是你和用户在「聊着记」里的对话稿。
请把用户说的话蒸馏成一条健康日记草稿。
硬性规则:
- 【只取「我:」说的内容作为事实】——「康康:」的话只是提问和陪伴,绝不能把康康提到的记录、猜测或问题当成用户今天发生的事写进日记。
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
- 输出语言必须与「我:」的原话完全一致:原话是中文就用中文,是英文就用英文,是日文/韩文就用对应语言,不要翻译。
- 用第一人称;去掉口头语和重复;不加入对话里没有的事实。
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
- 不诊断、不给用药建议、不写「建议就医」。
- 只输出日记正文,不要开头语、不要解释、不要 markdown 围栏、不要 <think> 标签。
示例 1(单方面 → 成段):
我: 今天头有点晕早上量了血压140 90
康康: 记下啦。上次你记过没吃早饭,今天吃了吗?
我: 吃了吃了 喝了粥
输出:
今天头有点晕,早上量了血压 140/90。早饭吃了,喝了粥。
示例 2(多方面 → 分行;注意康康的话不进日记):
我: 今天头晕了一上午下午好点了血压早上量的140 90缬沙坦吃了
康康: 记下啦。上次你血压也偏高,是不是又没睡好?昨晚睡了几个小时?
我: 就睡了五个小时中午吃的清淡
输出:
症状:头晕了一上午,下午好转。
血压:早上 140/90。
用药:缬沙坦已服。
睡眠:只睡了五个小时。
饮食:午餐清淡。
【对话记录】:
\(trimmed)
Output: /no_think
"""
}
}

View File

@@ -70,19 +70,20 @@ enum HealthExportPrompts {
- 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
- 「主诉」「患者疑问」可参考【患者原话】,但不得加入原话与数据里都没有的症状。
- 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
- diaries 里 kind 为「问诊」的是既往看医生的问诊记录,可据此补充「主诉」「本人背景」,但同样只搬运、不编造。
输出格式:
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
- 不给诊断意见、用药建议或「建议就医」。全文中文,简洁,医生 30 秒能扫完。
- 不给诊断意见、用药建议或「建议就医」。简洁,医生 30 秒能扫完(输出语言见文末说明)
- 严格按以下 6 段(顺序与标题固定):
\(labelLine)
## 主诉
## 患者背景
## 本人背景
## 近期症状(按时间倒序)
## 关键指标(异常项优先)
## 在服药与过敏
## 患者疑问
## 本人疑问
—— 格式示例(只示范「无记录」与数值写法,内容请勿照抄)——
真实数据:{"profile":{},"symptoms":[],"indicators":[{"name":"","value":"38.5","unit":"","range":"36-37.2","status":"high","date":"2026-05-01"}],"reports":[],"diaries":[],"time_window":{"from":"2026-04-02","to":"2026-05-02"}}
@@ -90,7 +91,7 @@ enum HealthExportPrompts {
# 就诊摘要 — 近期健康摘要
## 主诉
无记录
## 患者背景
## 本人背景
无记录
## 近期症状(按时间倒序)
无记录
@@ -98,7 +99,7 @@ enum HealthExportPrompts {
⚠️ 体温 38.5 ℃(参考 36-37.2,2026-05-01)
## 在服药与过敏
无记录
## 患者疑问
## 本人疑问
无记录
—— 示例结束(以上咳嗽/体温等仅示范格式,切勿出现在你的输出里)——
@@ -107,10 +108,10 @@ enum HealthExportPrompts {
【真实数据】:
\(dataJSON)
患者原话】:\(userPrompt)
本人原话】:\(userPrompt)
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
直接输出 Markdown,不要思考过程,不要 <think> 标签:
直接输出 Markdown,不要思考过程,不要 <think> 标签:\(aiOutputLanguageDirective())
/no_think
"""
}
@@ -141,7 +142,7 @@ enum HealthExportPrompts {
【用户最新问题】:
\(latestQuestion)
直接输出中文回答,不要 Markdown 标题,不要 <think>:
直接输出回答,不要 Markdown 标题,不要 <think>:\(aiOutputLanguageDirective())
/no_think
"""
}
@@ -156,20 +157,23 @@ enum HealthExportPrompts {
铁律:
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
- 禁止编造数字、日期、症状、药物、检查结果、诊断。
- 日期一律照搬【本地健康记录】JSON 里的完整 `date` 字段(格式 yyyy-MM-dd,即年-月-日);严禁只写年份或省略月、日。多轮对话里若把日期说得不全,一律以 JSON 的完整日期为准。
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。
- JSON 里没有的信息,对应小节写「无记录」。
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
输出要求:
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
- 中文,简洁,医生 30 秒能扫完。
- 简洁,医生 30 秒能扫完(输出语言见文末说明)
- 「相关健康日记」每条单独一行,格式为「2026-05-01:正文摘要」,日期照抄 JSON 的 date 字段,精确到日。
- diaries 里 kind 为「问诊」的是既往问诊记录,优先列入「相关健康日记」并标注「(问诊)」。
- 严格按以下段落:
# 就诊摘要
## 本次想解决的问题
## 相关健康日记
## 相关指标
## 已知背景
## 患者关心的问题
## 本人关心的问题
## 可带给医生确认的要点
【本地健康记录】:
@@ -178,7 +182,7 @@ enum HealthExportPrompts {
【多轮对话】:
\(transcript.isEmpty ? "" : transcript)
直接输出 Markdown,不要思考过程,不要 <think>:
直接输出 Markdown,不要思考过程,不要 <think>:\(aiOutputLanguageDirective())
/no_think
"""
}

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import Foundation
/// VL (Qwen3-VL) / prompt
/// : JSON,markdown
/// CaptureService 退(§3.2 退线)
enum VLPrompts {
nonisolated enum VLPrompts {
/// JSON ( prompt ):
/// ```
@@ -31,12 +31,38 @@ enum VLPrompts {
/// VL "", few-shot ,
/// prompt,退
static func reportExtraction(today: Date = .now) -> String {
/// ocrText Vision OCR Vision
/// 2B ;
static func reportExtraction(today: Date = .now, ocrText: String = "") -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
let ocrSection: String
if ocrText.isEmpty {
ocrSection = ""
} else {
ocrSection = """
OCR 参考文本(系统对同一报告做文字识别的结果,可能有错字、串行或漏行;版面与表格结构以图片为准,但数值、小数点以 OCR 文字更可靠):
\(clipOCR(ocrText))
"""
}
return reportExtractionTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_SECTION}}", with: ocrSection)
}
/// OCR : prompt (2B )
static func clipOCR(_ text: String, limit: Int = 1800) -> String {
guard text.count > limit else { return text }
let clipped = String(text.prefix(limit))
if let lastNewline = clipped.lastIndex(of: "\n") {
return String(clipped[..<lastNewline]) + "\n(后续内容过长已截断)"
}
return clipped + "\n(后续内容过长已截断)"
}
private static let reportExtractionTemplate: String = #"""
@@ -84,13 +110,120 @@ JSON schema(严格):
输入: 一份春季体检,3 项可读
输出:
{"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]}
{{OCR_SECTION}}
现在请识别图片并输出 JSON:
"""#
// MARK: - ()
// MARK: - (OCR ParsedReport, LLM)
/// :/****()
/// C2/ : Gemma 4 E2B (e2b ,
/// gemma4_unified 12B), Vision OCR , LLM meta +
/// reportExtraction() schema, source_page / source_box OCR ,
/// ,
static func reportExtractionFromText(_ ocrText: String, today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return reportExtractionFromTextTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 2200))
}
private static let reportExtractionFromTextTemplate: String = #"""
你是医学体检/化验报告识别助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声或换行混乱。
请从中提取报告的元信息与所有指标,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string,
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
"report_date": "YYYY-MM-DD",
"institution": string,
"page_count": number,
"summary": string,
"indicators": [
{
"name": string,
"value": string,
"unit": string,
"range": string,
"status": "high" | "low" | "normal"
}
]
}
规则:
- status 优先看箭头/标记(↑/H/偏高 → "high",↓/L/偏低 → "low");没有标记时用 value 与 range 比较;都没有 → "normal"
- range 保留原文(如 "< 3.40""3.9 - 6.1""0 - 5");OCR 把破折号写成 "--" / "~" 都归一成 " - ";没有参考范围就填 ""
- 凡是「指标名 + 明确数值」可读的都要提取——**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样提取。只有数值本身是乱码无法判断才跳过,绝不发明指标,同一指标只输出一次。
- title / institution / summary 读不出就填 "";report_date 挑「报告/检查/采样日期」其一统一成 YYYY-MM-DD,只有年月就补 -01,实在读不出填上面给出的「今天」({{TODAY}})。
- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"
- summary 用一句话概括整体(如「血脂偏高,其余正常」);页眉、医生签名、栏目标题、OCR 噪声一律忽略。
示例 OCR 文本:
协和医院体检中心 健康体检报告 体检日期:2026-04-12 低密度脂蛋白 3.84 mmol/L <3.40 ↑ 空腹血糖 5.2 mmol/L 3.9-6.1
对应输出:
{"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":",","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
现在请解析下面这段 OCR 文本,只输出 JSON。
OCR 文本:
{{OCR_TEXT}}
"""#
// MARK: - · meta(///,)
/// :,****( 2B OOM / )
/// Vision OCR , LLM meta (~50 token),
/// ,UI ( / ),
static func reportMetaFromText(_ ocrText: String, today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return reportMetaTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 1500))
}
private static let reportMetaTemplate: String = #"""
你是体检/化验报告归档助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声。
请只提取这份报告的「元信息」,**不要提取任何具体指标/数值**。只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string, // 报告抬头,如 "";读不出就填 ""
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
"report_date": "YYYY-MM-DD", // 报告/采样/体检日期;实在读不出就填 ""
"institution": string // 医院/体检机构名;读不出就填 ""
}
规则:
- 只输出上面 4 个字段,绝不输出 indicators / 数值 / 参考范围。
- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"
- 日期挑「报告日期 / 检查日期 / 采样日期」其一,统一成 YYYY-MM-DD;只有年月就补 -01;读不出填 ""
- institution 取医院/体检中心全称,去掉「检验科/报告单」等栏目词;读不出填 ""
- 不要编造;读不出的字段填 ""
示例 OCR 文本:
协和医院体检中心 健康体检报告 姓名:张三 体检日期:2026-04-12 低密度脂蛋白 3.84 ↑ ...
输出:
{"title":"","type":"checkup","report_date":"2026-04-12","institution":""}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:
{{OCR_TEXT}}
"""#
// MARK: - ()
/// :/****()
/// indicators ,// , Report
static func regionExtraction(today: Date = .now) -> String {
let f = DateFormatter()

View File

@@ -2,6 +2,10 @@ import Foundation
import MLX
import MLXVLM
import MLXLMCommon
// 3.x HF Hub/ opt-in ( LLMSession )
import MLXHuggingFace
import HuggingFace
import Tokenizers
/// MLX VL (Qwen3-VL)
/// LLMSession actor , AIRuntime
@@ -25,8 +29,11 @@ actor VLSession {
/// VL ( config.json + weights + tokenizer + processor)
static func load(folderURL: URL) async throws -> VLSession {
let configuration = ModelConfiguration(directory: folderURL)
// 3.31.4: LLMSession, Downloader()+ HF
let container = try await withDeviceOverride {
try await VLMModelFactory.shared.loadContainer(
from: #hubDownloader(),
using: #huggingFaceTokenizerLoader(),
configuration: configuration
)
}

View File

@@ -24,6 +24,7 @@ struct KangkangApp: App {
CustomMonitorMetric.self,
HealthExport.self,
CustomReminder.self,
Medication.self,
])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
// store .completeUnlessOpen (§6),
@@ -102,6 +103,9 @@ struct KangkangApp: App {
// / ,( tjScaled )
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
}
// ( sand) light:,
// Text/TextField .primary ,()
.preferredColorScheme(.light)
}
.modelContainer(sharedModelContainer)
}

View File

@@ -118,6 +118,48 @@ extension String {
}
}
// MARK: - AI ( Service/Prompt MainActor )
/// , LLM prompt :
/// AI / / ,
/// `appLocLocale`(LanguageManager nonisolated );
/// `system` ,,
nonisolated enum AIOutputLanguage: String {
case zhHans, en, ja, ko
/// appLocLocale system(autoupdatingCurrent)
static var current: AIOutputLanguage {
let code = appLocLocale.language.languageCode?.identifier ?? "zh"
switch code {
case "en": return .en
case "ja": return .ja
case "ko": return .ko
default: return .zhHans // zh (few-shot )
}
}
/// prompt (,)
var promptName: String {
switch self {
case .zhHans: return "简体中文"
case .en: return "English"
case .ja: return "日本語"
case .ko: return "한국어"
}
}
/// prompt (few-shot ,)
/// ( / / ),=
var outputDirective: String {
self == .zhHans ? "" : "\n\n重要:请全程用\(promptName)输出所有面向用户的文字(JSON 的字段值也用\(promptName)),不要用中文。"
}
}
/// 便:( `AIOutputLanguage.outputDirective`)
nonisolated func aiOutputLanguageDirective() -> String {
AIOutputLanguage.current.outputDirective
}
// MARK: - Bundle ( Text / NSLocalizedString )
/// key(,)

View File

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

View File

@@ -0,0 +1,52 @@
import SwiftUI
/// Apple Intelligence 线:,
/// AppAI ( AI /)
///
/// :线 `Tj.Palette` AI (
/// Apple ),; UI §9 token
///
/// `TimelineView(.animation)` `.onAppear` + `repeatForever`:线
/// (tok/s 0.5s ), `repeatForever`
/// / TimelineView ,
/// ,
struct AIFlowBar: View {
var height: CGFloat = 3
/// (),
var cycle: Double = 0.6
private static let base: [Color] = [
Color(red: 0.35, green: 0.47, blue: 0.98), //
Color(red: 0.62, green: 0.36, blue: 0.92), //
Color(red: 0.96, green: 0.40, blue: 0.62), //
Color(red: 1.00, green: 0.55, blue: 0.30), //
Color(red: 0.30, green: 0.80, blue: 0.92), //
]
/// ( 11 stop,):,
/// ,
private static let gradient: Gradient = {
let colors = base + base + [base[0]]
let last = CGFloat(colors.count - 1)
return Gradient(stops: colors.enumerated().map { i, c in
Gradient.Stop(color: c, location: CGFloat(i) / last)
})
}()
var body: some View {
TimelineView(.animation) { timeline in
GeometryReader { geo in
let w = geo.size.width
let t = timeline.date.timeIntervalSinceReferenceDate
let progress = CGFloat(t.truncatingRemainder(dividingBy: cycle) / cycle)
Capsule()
.fill(LinearGradient(gradient: Self.gradient,
startPoint: .leading, endPoint: .trailing))
.frame(width: w * 2)
.offset(x: -w * progress)
}
}
.frame(height: height)
.clipShape(Capsule())
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -117,11 +117,17 @@ struct HealthExportRow: View {
.tjCard()
}
static func relativeDate(_ d: Date) -> String {
/// formatter:RelativeDateTimeFormatter , new
/// Locale.current(),,
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.locale = Locale.current
f.unitsStyle = .full
return f.localizedString(for: d, relativeTo: .now)
return f
}()
static func relativeDate(_ d: Date) -> String {
relativeFormatter.localizedString(for: d, relativeTo: .now)
}
}

View File

@@ -19,9 +19,17 @@ struct HealthExportSheet: View {
@State private var error: Error?
@State private var completed: Bool = false
@State private var copiedFlash: Bool = false
@State private var lastScrollAt: Date = .distantPast //
@State private var answeringTurnID: UUID?
@State private var retrieval: HealthExportService.RetrievalSummary?
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
@FocusState private var questionFocused: Bool
//
@State private var promptStore = QuickPromptStore.shared
@State private var showAddPrompt = false
@State private var newPromptText = ""
init(initialPrompt: String = "") {
self.initialPrompt = initialPrompt
}
@@ -33,10 +41,16 @@ struct HealthExportSheet: View {
!isGeneratingReport &&
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
///
private var hasUserContent: Bool {
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
}
/// :,()
private var canGenerateReport: Bool {
!isAnswering &&
!isGeneratingReport &&
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
(hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
var body: some View {
@@ -44,7 +58,7 @@ struct HealthExportSheet: View {
header
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
LazyVStack(alignment: .leading, spacing: 18) {
introSection
ForEach(turns) { turn in
@@ -63,15 +77,15 @@ struct HealthExportSheet: View {
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
.onChange(of: content) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
// content / turns token ,;
// ~8Hz,
.onChange(of: content) { _, _ in throttledScrollToBottom(proxy) }
.onChange(of: turns) { _, _ in throttledScrollToBottom(proxy) }
.onChange(of: completed) { _, done in
if done { scrollToBottom(proxy) }
}
.onChange(of: turns) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
.onChange(of: answeringTurnID) { _, id in
if id == nil { scrollToBottom(proxy) }
}
}
if completed {
@@ -88,6 +102,75 @@ struct HealthExportSheet: View {
questionFocused = true
}
.onDisappear { task?.cancel() }
.alert("添加快捷问答", isPresented: $showAddPrompt) {
TextField("输入一句常用问题…", text: $newPromptText)
Button("取消", role: .cancel) { newPromptText = "" }
Button("添加") {
promptStore.add(prompt: newPromptText)
newPromptText = ""
}
} message: {
Text("保存后点一下,就能把这句话填进输入框")
}
}
// MARK: -
/// + chip ; chip , ,
private var quickPromptRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(promptStore.all) { p in
quickPromptChip(p)
}
addQuickPromptChip
}
.padding(.vertical, 1) // chip , ScrollView
}
}
private func quickPromptChip(_ p: QuickPrompt) -> some View {
Button {
draftQuestion = p.prompt
questionFocused = true
} label: {
Text(p.title)
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.sand2))
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
}
.buttonStyle(.plain)
.contextMenu {
if !p.isBuiltin {
Button(role: .destructive) {
promptStore.delete(p)
} label: {
Label("删除", systemImage: "trash")
}
}
}
}
private var addQuickPromptChip: some View {
Button { showAddPrompt = true } label: {
Label("自定义", systemImage: "plus")
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.paper))
.overlay(
Capsule().strokeBorder(
Tj.Palette.line,
style: StrokeStyle(lineWidth: 1, dash: [3])
)
)
}
.buttonStyle(.plain)
}
// MARK: - Header
@@ -128,14 +211,7 @@ struct HealthExportSheet: View {
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
VStack(alignment: .leading, spacing: 6) {
Text("例:最近血压波动大吗?")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
quickPromptRow
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
.font(.tjScaled( 11))
@@ -161,12 +237,17 @@ struct HealthExportSheet: View {
Text(turn.role.transcriptLabel)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
if !isUser, let summary = turnRetrievals[turn.id] {
RetrievalChipsView(summary: summary)
}
if turn.id == answeringTurnID && turn.text.isEmpty {
HStack(spacing: 8) {
ProgressView()
Text("正在查看本地记录…")
VStack(alignment: .leading, spacing: 8) {
Text(turnRetrievals[turn.id] == nil
? "正在查看本地记录…"
: "正在根据这些记录回答…")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
AIFlowBar()
}
} else {
Text(turn.text)
@@ -196,6 +277,11 @@ struct HealthExportSheet: View {
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
MarkdownView(text: content)
if completed {
Divider().background(Tj.Palette.lineSoft)
AIDisclaimerFooter()
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -220,6 +306,9 @@ struct HealthExportSheet: View {
arrow
phasePill(.generating)
}
if let retrieval {
RetrievalChipsView(summary: retrieval)
}
if phase == .generating && rate > 0 {
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
.font(.tjScaled( 11, design: .monospaced))
@@ -229,6 +318,9 @@ struct HealthExportSheet: View {
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
// AI :线( AI )
AIFlowBar().padding(.top, 2)
}
}
@@ -267,7 +359,7 @@ struct HealthExportSheet: View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
Text(friendlyMessage(for: err))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text)
}
@@ -291,7 +383,7 @@ struct HealthExportSheet: View {
}
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: content) {
ShareLink(item: AIDisclaimer.appended(to: content)) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.tjScaled( 13, weight: .semibold))
.tracking(1)
@@ -319,7 +411,7 @@ struct HealthExportSheet: View {
private var composer: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
TextField("继续提问补充情况…", text: $draftQuestion, axis: .vertical)
TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
.font(.tjScaled( 14))
.lineLimit(1...4)
.padding(.horizontal, 12)
@@ -342,12 +434,28 @@ struct HealthExportSheet: View {
.accessibilityLabel("发送问题")
}
Button { startReportGeneration() } label: {
Label("生成整理报告", systemImage: "doc.text.below.ecg")
if isGeneratingReport {
Button { stopGeneration() } label: {
Label("停止生成", systemImage: "stop.fill")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.brick, lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
Button { startReportGeneration() } label: {
Label("生成整理报告", systemImage: "doc.text.below.ecg")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
.disabled(!canGenerateReport)
.opacity(canGenerateReport ? 1 : 0.45)
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
.disabled(!canGenerateReport)
.opacity(canGenerateReport ? 1 : 0.45)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
@@ -380,16 +488,33 @@ struct HealthExportSheet: View {
task?.cancel()
task = Task { @MainActor in
do {
for try await chunk in stream {
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
for try await event in stream {
switch event {
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) {
turnRetrievals[assistantTurn.id] = summary
}
case .token(let chunk):
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .phaseChanged, .completed:
break
}
}
answeringTurnID = nil
questionFocused = true
} catch {
answeringTurnID = nil
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
questionFocused = true
if error is CancellationError { return }
#if DEBUG
print("[HealthExport] answer failed: \(error)")
#endif
// ;, AI
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
turns[idx].text = String(appLoc: "这次没能回答上来,请换个说法再试一次。")
}
}
}
}
@@ -402,10 +527,20 @@ struct HealthExportSheet: View {
private func startReportGeneration() {
guard canGenerateReport else { return }
questionFocused = false
// :(/),
//
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
if !draft.isEmpty {
turns.append(.user(draft))
draftQuestion = ""
}
content = ""
rate = 0 // , tok/s
error = nil
completed = false
retrieval = nil
phase = .retrieving
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
@@ -416,6 +551,8 @@ struct HealthExportSheet: View {
switch event {
case .phaseChanged(let ph):
phase = ph
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
case .token(let chunk):
content += chunk.text
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
@@ -424,6 +561,10 @@ struct HealthExportSheet: View {
}
}
} catch {
if error is CancellationError { return }
#if DEBUG
print("[HealthExport] export failed: \(error)")
#endif
self.error = error
self.phase = nil
}
@@ -435,6 +576,17 @@ struct HealthExportSheet: View {
startReportGeneration()
}
/// :,()
private func stopGeneration() {
task?.cancel()
task = nil
phase = nil
rate = 0
completed = false
content = ""
retrieval = nil
}
private func reset() {
task?.cancel()
task = nil
@@ -444,11 +596,13 @@ struct HealthExportSheet: View {
error = nil
completed = false
answeringTurnID = nil
retrieval = nil
turnRetrievals = [:]
questionFocused = true
}
private func copy() {
UIPasteboard.general.string = content
UIPasteboard.general.string = AIDisclaimer.appended(to: content)
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
@@ -459,6 +613,65 @@ struct HealthExportSheet: View {
task?.cancel()
dismiss()
}
// MARK: -
private func scrollToBottom(_ proxy: ScrollViewProxy) {
lastScrollAt = Date()
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
/// : token , 8Hz, token
private func throttledScrollToBottom(_ proxy: ScrollViewProxy) {
guard Date().timeIntervalSince(lastScrollAt) > 0.12 else { return }
scrollToBottom(proxy)
}
/// ()
private func friendlyMessage(for error: Error) -> String {
if error is CancellationError { return "" }
return String(appLoc: "这次没能生成成功,请稍后重试。")
}
}
// MARK: - chips( RAG )
/// RAG :N + chips
/// ( embedding) (§12 3)
private struct RetrievalChipsView: View {
let summary: HealthExportService.RetrievalSummary
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if summary.totalCount == 0 {
Text("本地档案中暂无相关记录,将仅按你的描述整理")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
} else {
Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录"))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.leaf)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in
Text(chip)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
}
}
.padding(.vertical, 1)
}
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// MARK: - Markdown ()
@@ -578,15 +791,30 @@ struct MarkdownView: View {
return nil
}
/// :,
/// markdown ,,
private final class AttrBox { let value: AttributedString; init(_ v: AttributedString) { value = v } }
private static let inlineCache: NSCache<NSString, AttrBox> = {
let c = NSCache<NSString, AttrBox>()
c.countLimit = 256
return c
}()
private func inline(_ s: String) -> AttributedString {
// :(),
// , AttributedString(markdown:)
if !s.contains(where: { $0 == "*" || $0 == "_" || $0 == "[" || $0 == "`" }) {
return AttributedString(s)
}
let key = s as NSString
if let hit = Self.inlineCache.object(forKey: key) { return hit.value }
// **bold** / *italic* / [text](url) AttributedString markdown
if let attr = try? AttributedString(
let attr = (try? AttributedString(
markdown: s,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return attr
}
return AttributedString(s)
)) ?? AttributedString(s)
Self.inlineCache.setObject(AttrBox(attr), forKey: key)
return attr
}
// MARK: -
@@ -641,9 +869,9 @@ struct MarkdownView: View {
# 就诊摘要 — 感冒就诊
## 主诉
患者男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
本人男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
## 患者背景
## 本人背景
- 高血压 2 年
- 在服药:**缬沙坦 80mg qd**
- 过敏:青霉素

View File

@@ -0,0 +1,92 @@
import Foundation
import Observation
/// :
/// 3 (),()
struct QuickPrompt: Identifiable, Codable, Equatable {
let id: UUID
var title: String // chip
var prompt: String //
var isBuiltin: Bool
init(id: UUID = UUID(), title: String, prompt: String, isBuiltin: Bool) {
self.id = id
self.title = title
self.prompt = prompt
self.isBuiltin = isBuiltin
}
}
/// : + (UserDefaults JSON, SwiftData schema )
/// UI 便, SwiftData
@Observable
final class QuickPromptStore {
static let shared = QuickPromptStore()
private let defaults = UserDefaults.standard
private let storageKey = "kk.quickPrompts.custom.v1"
private(set) var custom: [QuickPrompt]
private init() {
if let data = defaults.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([QuickPrompt].self, from: data) {
custom = decoded
} else {
custom = []
}
}
/// , chip
var all: [QuickPrompt] { Self.builtins + custom }
/// ;
func add(prompt rawPrompt: String) {
let trimmed = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
custom.append(QuickPrompt(title: Self.deriveTitle(trimmed),
prompt: trimmed,
isBuiltin: false))
persist()
}
/// ()
func delete(_ p: QuickPrompt) {
guard !p.isBuiltin else { return }
custom.removeAll { $0.id == p.id }
persist()
}
private func persist() {
if let data = try? JSONEncoder().encode(custom) {
defaults.set(data, forKey: storageKey)
}
}
/// :, 8 ,
static func deriveTitle(_ prompt: String) -> String {
let oneLine = prompt.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespaces)
let head = oneLine.prefix(8)
return oneLine.count > 8 ? "\(head)" : String(head)
}
/// 3 (): / / ,线
static let builtins: [QuickPrompt] = [
QuickPrompt(
title: "就诊摘要",
prompt: "根据我最近的身体症状,结合历史指标,整理一份让门诊医生快速了解我情况的就诊摘要。",
isBuiltin: true
),
QuickPrompt(
title: "趋势解读",
prompt: "把我血压最近半年的变化讲清楚:是变好还是变差、要注意什么。",
isBuiltin: true
),
QuickPrompt(
title: "速答清单",
prompt: "把我的过敏史、正在吃的药、慢性病整理成一句话清单,方便就诊时快速回答医生。",
isBuiltin: true
),
]
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,489 @@
import SwiftUI
import SwiftData
/// (RAG)
///
/// ,( / / / ),100%
/// `HealthExportService.answer`( dialogueAnswer prompt + ),
/// `CompanionAvatarView` ,
///
/// : `ChatTurn`, 10 ,(§12#3 RAG )
/// :UI AIRuntime(线 #3), `HealthExportService`;(线 #5)
struct HealthChatView: View {
let onClose: () -> Void
@Environment(\.modelContext) private var ctx
/// ( + )assistant =
@State private var turns: [HealthExportDialogueTurn] = []
/// assistant , RAG
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
@State private var draft = ""
/// assistant id; nil = (/)
@State private var answeringTurnID: UUID?
@State private var rate: Double = 0
@State private var task: Task<Void, Never>?
@FocusState private var inputFocused: Bool
// MARK: ( SpeechDictationService,)
@State private var isRecording = false
/// @State:struct View let , stop()
@State private var dictation = SpeechDictationService()
@State private var micDeniedAlert = false
private var isAnswering: Bool { answeringTurnID != nil }
/// (answer );
private var modelReady: Bool { ModelStore.shared.isComplete(for: .llm) }
/// :listening;/ tokenthinking;speaking;idle
private var mood: CompanionAvatarView.Mood {
if isRecording { return .listening }
guard let id = answeringTurnID else { return .idle }
let streaming = turns.first { $0.id == id }?.text.isEmpty == false
return streaming ? .speaking : .thinking
}
/// :,
private let starters = [
"帮我总结一下最近的身体状况",
"我最近的血压怎么样?",
"上一次体检有哪些异常?",
"最近的血糖有变化吗?"
]
var body: some View {
VStack(spacing: 0) {
header
transcript
}
.background(Tj.Palette.sand.ignoresSafeArea())
.safeAreaInset(edge: .bottom) { inputBar }
.onAppear(perform: loadHistory)
.onDisappear {
task?.cancel()
dictation.abort()
}
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $micDeniedAlert) {
Button(String(appLoc: "前往设置")) {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button(String(appLoc: "取消"), role: .cancel) {}
} message: {
Text("语音提问全程在本机完成,声音和文字都不会上传。")
}
}
// MARK: - ( + )
private var header: some View {
HStack(spacing: 12) {
CompanionAvatarView(mood: mood, size: 44)
VStack(alignment: .leading, spacing: 1) {
Text("康康")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(headerSubtitle)
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer(minLength: 8)
Button(action: onClose) {
Image(systemName: "xmark")
.font(.tjScaled(14, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var headerSubtitle: String {
if isRecording { return String(appLoc: "正在听你说…") }
if isAnswering {
return rate > 0
? String(appLoc: "正在翻你的记录 · \(String(format: "%.1f", rate)) tok/s")
: String(appLoc: "正在翻你的记录…")
}
return String(appLoc: "问问你的健康记录 · 全程在本机")
}
// MARK: -
private var transcript: some View {
ScrollViewReader { proxy in
ScrollView {
if turns.isEmpty {
emptyState
.padding(.top, 28)
.padding(.horizontal, 20)
} else {
LazyVStack(alignment: .leading, spacing: 14) {
ForEach(turns) { turn in
bubble(turn)
}
Color.clear.frame(height: 1).id(bottomAnchor)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
}
// token / ,
.onChange(of: turns.last?.text) { _, _ in scrollToBottom(proxy) }
.onChange(of: turns.count) { _, _ in scrollToBottom(proxy) }
}
}
private let bottomAnchor = "chat.bottom"
private func scrollToBottom(_ proxy: ScrollViewProxy) {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(bottomAnchor, anchor: .bottom)
}
}
@ViewBuilder
private func bubble(_ turn: HealthExportDialogueTurn) -> some View {
if turn.role == .user {
HStack {
Spacer(minLength: 40)
Text(turn.text)
.font(.tjScaled(14))
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.ink))
}
} else {
VStack(alignment: .leading, spacing: 8) {
// + = token ,,
if turn.text.isEmpty && answeringTurnID == turn.id {
thinkingBubble
} else {
// Markdown ( / / ),
// MarkdownView(, token )
MarkdownView(text: turn.text)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper))
.overlay(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1))
}
if let summary = turnRetrievals[turn.id], summary.totalCount > 0 {
retrievalChips(summary)
}
}
.padding(.trailing, 32)
}
}
private var thinkingBubble: some View {
HStack(spacing: 5) {
Text("正在查阅你的记录")
.font(.tjScaled(13))
.foregroundStyle(Tj.Palette.text3)
ProgressView().scaleEffect(0.7)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper))
.overlay(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1))
}
/// RAG :(§12#3 )
private func retrievalChips(_ summary: HealthExportService.RetrievalSummary) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("查阅了本机 \(summary.totalCount) 条记录")
.font(.tjScaled(10, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
FlowChips(items: summary.chips)
}
.padding(.leading, 2)
}
// MARK: - ()
private var emptyState: some View {
VStack(spacing: 16) {
CompanionAvatarView(mood: .idle, size: 110)
VStack(spacing: 6) {
Text("我是康康")
.font(.tjScaled(20, weight: .semibold, design: .serif))
.foregroundStyle(Tj.Palette.text)
Text(modelReady
? String(appLoc: "问我关于你健康记录的事,我只看你本机的数据来回答")
: String(appLoc: "先到「我的 · 模型管理」下载模型,就能和我聊了"))
.font(.tjScaled(13))
.foregroundStyle(Tj.Palette.text3)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 12)
if modelReady {
VStack(spacing: 10) {
ForEach(starters, id: \.self) { q in
Button { send(q) } label: {
HStack {
Text(q)
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Spacer()
Image(systemName: "arrow.up.right")
.font(.tjScaled(11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.tjCard(bordered: true)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.padding(.top, 4)
}
}
.frame(maxWidth: .infinity)
}
// MARK: -
private var inputBar: some View {
HStack(spacing: 10) {
if SpeechDictationService.isAvailable {
Button(action: toggleMic) {
Image(systemName: isRecording ? "stop.fill" : "mic.fill")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(isRecording ? Tj.Palette.paper : Tj.Palette.text3)
.frame(width: 40, height: 40)
.background(Circle().fill(isRecording ? Tj.Palette.brick : Tj.Palette.sand2))
}
.buttonStyle(.plain)
.disabled(isAnswering)
.opacity(isAnswering ? 0.4 : 1)
}
TextField(String(appLoc: "问问你的健康记录…"), text: $draft, axis: .vertical)
.font(.tjScaled(14))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1...4)
.focused($inputFocused)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Capsule().fill(Tj.Palette.sand2))
.disabled(isRecording)
Button { send(draft) } label: {
Image(systemName: "arrow.up")
.font(.tjScaled(16, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 40, height: 40)
.background(Circle().fill(canSend ? Tj.Palette.ink : Tj.Palette.line))
}
.buttonStyle(.plain)
.disabled(!canSend)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var canSend: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !isAnswering && !isRecording && modelReady
}
// MARK: - / ( HealthExportSheet.sendQuestion)
private func send(_ raw: String) {
let question = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !question.isEmpty, !isAnswering, modelReady else { return }
draft = ""
inputFocused = false
let userTurn = HealthExportDialogueTurn.user(question)
let assistantTurn = HealthExportDialogueTurn.assistant("")
turns.append(userTurn)
turns.append(assistantTurn)
answeringTurnID = assistantTurn.id
rate = 0
// : context ,/
let conversationForPrompt = Array(
turns.filter { $0.id != assistantTurn.id }.suffix(8)
)
let stream = HealthExportService.shared.answer(
question: question,
conversation: conversationForPrompt,
in: ctx
)
task?.cancel()
task = Task { @MainActor in
do {
for try await event in stream {
switch event {
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) {
turnRetrievals[assistantTurn.id] = summary
}
case .token(let chunk):
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .phaseChanged, .completed:
break
}
}
answeringTurnID = nil
persist(question: question, answerID: assistantTurn.id)
} catch {
answeringTurnID = nil
if error is CancellationError { return }
// ;, AI (线 #5)
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
turns[idx].text = String(appLoc: "这次没能回答上来,请换个说法再试一次。")
}
}
}
}
private func appendToTurn(id: UUID, text: String) {
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
turns[idx].text += text
}
/// ChatTurn() / ,
private func persist(question: String, answerID: UUID) {
guard let answer = turns.first(where: { $0.id == answerID })?.text
.trimmingCharacters(in: .whitespacesAndNewlines), !answer.isEmpty else { return }
let turn = ChatTurn(question: question, answer: answer, decodeRate: rate)
ctx.insert(turn)
try? ctx.save()
}
// MARK: -
/// 10 , onAppear ,,
private func loadHistory() {
var desc = FetchDescriptor<ChatTurn>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
desc.fetchLimit = 10
let recent = (try? ctx.fetch(desc)) ?? []
guard !recent.isEmpty else { return }
var loaded: [HealthExportDialogueTurn] = []
for ct in recent.reversed() {
loaded.append(.user(ct.question))
loaded.append(.assistant(ct.answer))
}
turns = loaded
}
// MARK: -
private func toggleMic() {
if isRecording {
Task { @MainActor in
let final = await dictation.stop()
isRecording = false
let merged = final.trimmingCharacters(in: .whitespacesAndNewlines)
if !merged.isEmpty { draft = merged }
}
} else {
Task { @MainActor in
guard await dictation.requestAuthorization() else {
micDeniedAlert = true
return
}
do {
inputFocused = false
draft = ""
try dictation.start { partial in draft = partial }
isRecording = true
} catch {
isRecording = false
}
}
}
}
}
/// chip (, iOS 16+ Layout)
private struct FlowChips: View {
let items: [String]
var body: some View {
FlowLayout(spacing: 6) {
ForEach(items, id: \.self) { chip in
Text(chip)
.font(.tjScaled(10, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
}
}
}
}
/// :, chip,
private struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
for sub in subviews {
let size = sub.sizeThatFits(.unspecified)
if x + size.width > maxWidth, x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
return CGSize(width: maxWidth == .infinity ? x : maxWidth, height: y + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let maxWidth = bounds.width
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
for sub in subviews {
let size = sub.sizeThatFits(.unspecified)
if x + size.width > maxWidth, x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
sub.place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y),
anchor: .topLeading, proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
#Preview {
HealthChatView(onClose: {})
}

View File

@@ -0,0 +1,130 @@
import SwiftUI
import AVFoundation
import Combine
/// Vault (2026-06-28)/ + +
/// Vault ,App ;(/),
/// seek():,demo
struct ConsultationAudioPlayer: View {
let asset: Asset
@State private var player: AVAudioPlayer?
@State private var isPlaying = false
@State private var currentTime: Double = 0
@State private var duration: Double = 0
@State private var loadFailed = false
/// /;()AVAudioPlayer isPlaying false
private let ticker = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "waveform")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
Text(String(appLoc: "问诊录音"))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(String(appLoc: "本机存储"))
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
if loadFailed {
Text("录音无法读取")
.font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
HStack(spacing: 12) {
Button(action: togglePlay) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.tjScaled( 16, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 40, height: 40)
.background(Circle().fill(Tj.Palette.ink2))
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 6) {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Tj.Palette.sand2)
Capsule().fill(Tj.Palette.ink2)
.frame(width: geo.size.width * progressFraction)
}
}
.frame(height: 5)
HStack {
Text(Self.timeText(currentTime))
Spacer()
Text(Self.timeText(duration))
}
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.onAppear(perform: load)
.onDisappear {
player?.stop()
isPlaying = false
}
.onReceive(ticker) { _ in
guard let p = player, isPlaying else { return }
currentTime = p.currentTime
if !p.isPlaying { // :
isPlaying = false
currentTime = 0
p.currentTime = 0
}
}
}
private var progressFraction: CGFloat {
guard duration > 0 else { return 0 }
return CGFloat(min(max(currentTime / duration, 0), 1))
}
private func load() {
guard player == nil, !loadFailed else { return }
do {
let url = try FileVault.shared.absoluteURL(forReading: asset.relativePath)
let p = try AVAudioPlayer(contentsOf: url)
p.prepareToPlay()
player = p
duration = p.duration
} catch {
loadFailed = true
}
}
private func togglePlay() {
guard let p = player else { return }
if isPlaying {
p.pause()
isPlaying = false
} else {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
try? AVAudioSession.sharedInstance().setActive(true)
p.play()
isPlaying = true
}
}
private static func timeText(_ seconds: Double) -> String {
let s = Int(seconds.rounded())
return String(format: "%d:%02d", s / 60, s % 60)
}
}

View File

@@ -0,0 +1,657 @@
import SwiftUI
import SwiftData
/// sheet(2026-06-28)
///
/// : (`ConsultationRecorder`) AI
/// (`DiaryAssistService.organizeConsultation`) tag DiaryEntry
/// ( audio/m4a Asset Vault)
///
/// 线:UI AIRuntime( DiaryAssistService); + ;
/// AI 退(#5);,
/// (/) 退,
struct ConsultationSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
enum Phase: Equatable { case idle, recording, transcribing, organizing, review }
@State private var phase: Phase = .idle
@State private var liveTranscript = ""
@State private var recordingSeconds = 0
/// (稿;)
@State private var note = ""
/// :退,
@State private var rawTranscript = ""
/// (tmp m4a) importFile Vault; onDisappear
@State private var audioTempURL: URL?
@State private var createdAt: Date = .now
@State private var decodeRate: Double = 0
@State private var voiceNote: String?
@State private var deniedAlert = false
@State private var saved = false
@State private var recordTask: Task<Void, Never>?
@State private var organizeTask: Task<Void, Never>?
@State private var watchdog: Task<Void, Never>?
/// @State:struct View let , stop()
/// ( DiaryQuickSheet.dictation )
@State private var recorder = ConsultationRecorder()
@FocusState private var noteFocused: Bool
/// 10 ()
private let maxSeconds = 600
/// (,)
private var manualMode: Bool { !ConsultationRecorder.isAvailable }
private var canSave: Bool {
!note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
VStack(spacing: 0) {
grabber
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
if let note = voiceNote { noteBanner(note) }
phaseContent
}
.padding(.horizontal, 20)
.padding(.top, 4)
.padding(.bottom, 8)
}
.scrollDismissesKeyboard(.interactively)
bottomBar
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.onDisappear(perform: cleanup)
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $deniedAlert) {
Button(String(appLoc: "前往设置")) {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button(String(appLoc: "取消"), role: .cancel) {}
} message: {
Text("问诊录音全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
}
}
// MARK: - Header
private var grabber: some View {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
}
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 2) {
Text("记录问诊")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("录音 → 本机转写 → AI 整理成问诊小结")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
}
// MARK: - Phase content
@ViewBuilder
private var phaseContent: some View {
switch phase {
case .idle:
if manualMode { manualEntry } else { idleStart }
case .recording:
recordingCard
case .transcribing:
transcribingCard
case .organizing:
organizingCard
case .review:
reviewArea
}
}
/// : + ( / / )
private var idleStart: some View {
VStack(spacing: 18) {
Button(action: startRecording) {
VStack(spacing: 10) {
ZStack {
Circle().fill(Tj.Palette.ink2)
.frame(width: 76, height: 76)
.shadow(color: Tj.Palette.ink2.opacity(0.3), radius: 12, y: 4)
Image(systemName: "mic.fill")
.font(.tjScaled( 30, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
}
Text("开始录音")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 22)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 10) {
bullet("stethoscope", String(appLoc: "看医生时录下来,事后不怕记不全"))
bullet("waveform", String(appLoc: "录音结束后在本机转写,原声存进加密档案"))
bullet("sparkles", String(appLoc: "康康自动整理成主诉/医生建议/复查小节"))
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
Text("仅供个人记录,不构成诊断或用药建议。")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
.padding(.top, 6)
}
private func bullet(_ icon: String, _ text: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: icon)
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
.frame(width: 20)
Text(text)
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
}
/// : + +
private var recordingCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "waveform")
.font(.tjScaled( 13, 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.timeText(recordingSeconds))
.font(.tjScaled( 13, design: .monospaced))
.foregroundStyle(recordingSeconds >= maxSeconds - 30 ? Tj.Palette.brick : Tj.Palette.text3)
}
// 线(SenseVoice): + ,
VStack(spacing: 10) {
Image(systemName: "waveform")
.font(.tjScaled( 44, weight: .regular))
.foregroundStyle(Tj.Palette.ink2)
.symbolEffect(.variableColor.iterative, options: .repeating)
Text("正在录音,结束后会在本机把整段录音转成文字")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, minHeight: 150)
.padding(.vertical, 8)
Button(action: stopAndOrganize) {
HStack(spacing: 8) {
Image(systemName: "stop.circle.fill")
Text("结束并整理")
}
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
/// :线( SenseVoice,退),
private var transcribingCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "waveform")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.variableColor.iterative, options: .repeating)
Text(SenseVoiceASRService.isAvailable
? String(appLoc: "正在转写录音 · 本地 SenseVoice")
: String(appLoc: "正在转写录音 · 本机识别"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
}
Text("录音已结束,正在本机把整段录音转成文字,请稍候…")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
AIFlowBar()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
/// AI : + + ()
private var organizingCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.pulse, options: .repeating)
Text(decodeRate > 0
? String(format: String(appLoc: "正在整理问诊小结 · %.1f tok/s"), decodeRate)
: String(appLoc: "正在整理问诊小结 · 本地推理"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button(String(appLoc: "用原话")) { cancelOrganize() }
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.buttonStyle(.plain)
}
Text(rawTranscript)
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(6)
AIFlowBar()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
/// : + + ( / ) +
private var reviewArea: some View {
VStack(alignment: .leading, spacing: 14) {
if audioTempURL != nil {
HStack(spacing: 6) {
Image(systemName: "waveform.circle.fill")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.ink2)
Text("录音已保存,可在记录详情里回放")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(String(appLoc: "问诊小结"))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
Spacer()
if decodeRate > 0 {
Text(String(format: "%.1f tok/s", decodeRate))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
TextField(String(appLoc: "整理后的问诊小结,可在这里修改…"),
text: $note, axis: .vertical)
.font(.tjScaled( 15))
.lineLimit(6...16)
.focused($noteFocused)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
HStack(spacing: 10) {
if !rawTranscript.isEmpty && note != rawTranscript {
reviewChip("arrow.uturn.backward", String(appLoc: "改用原话")) {
withAnimation(.snappy(duration: 0.18)) { note = rawTranscript }
}
}
if !rawTranscript.isEmpty {
reviewChip("arrow.clockwise", String(appLoc: "重新整理")) { reorganize() }
}
Spacer(minLength: 0)
}
}
VStack(alignment: .leading, spacing: 8) {
Text(String(appLoc: "时间"))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
AIDisclaimerFooter()
}
}
private func reviewChip(_ icon: String, _ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 4) {
Image(systemName: icon).font(.tjScaled( 10, weight: .semibold))
Text(label).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)
}
/// ():
private var manualEntry: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "info.circle")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text("本机暂不支持录音转写,可手动记录这次问诊")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
TextField(String(appLoc: "记录这次问诊:主诉、医生说了什么、用药、复查…"),
text: $note, axis: .vertical)
.font(.tjScaled( 15))
.lineLimit(6...16)
.focused($noteFocused)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
.padding(.top, 6)
}
// MARK: - Bottom bar
@ViewBuilder
private var bottomBar: some View {
switch phase {
case .idle where manualMode:
HStack(spacing: 8) {
Button(String(appLoc: "取消")) { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
Button(String(appLoc: "保存")) { save() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
.disabled(!canSave)
.opacity(canSave ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
case .idle:
// :, header ,
EmptyView()
case .review:
HStack(spacing: 8) {
Button(String(appLoc: "重新录音")) { restartRecording() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
Button(String(appLoc: "保存")) { save() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
.disabled(!canSave)
.opacity(canSave ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
case .recording, .transcribing, .organizing:
EmptyView()
}
}
private func noteBanner(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.amber)
Text(text).font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
}
}
// MARK: - Actions
private func startRecording() {
voiceNote = nil
noteFocused = false
recordTask = Task { @MainActor in
guard await recorder.requestAuthorization() else {
deniedAlert = true
return
}
do {
liveTranscript = ""
recordingSeconds = 0
try recorder.start { partial in liveTranscript = partial }
withAnimation(.snappy(duration: 0.2)) { phase = .recording }
watchdog = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled, phase == .recording else { return }
recordingSeconds += 1
if recordingSeconds >= maxSeconds { stopAndOrganize(); return }
}
}
} catch {
#if DEBUG
print("[Consultation] recorder start failed: \(error)")
#endif
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
phase = .idle
}
}
}
private func stopAndOrganize() {
guard phase == .recording else { return }
watchdog?.cancel()
organizeTask = Task { @MainActor in
let result = await recorder.stop()
audioTempURL = result.audioTempURL
// (SFSpeech,) SenseVoice / 退稿
var sfFallback = result.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
if sfFallback.isEmpty {
sfFallback = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
}
// 线: SenseVoice(MNN), / / 退 SFSpeech(线 #5)
withAnimation(.snappy(duration: 0.2)) { phase = .transcribing }
var transcript = ""
if SenseVoiceASRService.isAvailable, let url = result.audioTempURL {
transcript = (try? await SenseVoiceASRService.shared.transcribe(audioFileURL: url)) ?? ""
}
if Task.isCancelled { return }
if transcript.isEmpty { transcript = sfFallback }
rawTranscript = transcript
guard !transcript.isEmpty else {
// :;()
voiceNote = String(appLoc: "没听清,可手动补充或重新录音")
note = ""
withAnimation(.snappy(duration: 0.2)) { phase = .review }
return
}
withAnimation(.snappy(duration: 0.2)) { phase = .organizing }
await runOrganize(transcript: transcript)
}
}
/// LLM ;,/退(线 #5)
private func runOrganize(transcript: String) async {
do {
let organized = try await DiaryAssistService.shared.organizeConsultation(transcript: transcript)
guard !Task.isCancelled else { return }
note = organized.text
decodeRate = organized.decodeRate
} catch is CancellationError {
return // cancelOrganize 退
} catch {
note = transcript
voiceNote = String(appLoc: "AI 整理没成功,已填入录音原文")
}
withAnimation(.snappy(duration: 0.2)) { phase = .review }
}
private func cancelOrganize() {
guard phase == .organizing else { return }
organizeTask?.cancel()
note = rawTranscript
withAnimation(.snappy(duration: 0.2)) { phase = .review }
}
/// : LLM()
private func reorganize() {
guard !rawTranscript.isEmpty else { return }
organizeTask?.cancel()
decodeRate = 0
withAnimation(.snappy(duration: 0.2)) { phase = .organizing }
organizeTask = Task { @MainActor in
await runOrganize(transcript: rawTranscript)
}
}
/// :/,
private func restartRecording() {
organizeTask?.cancel()
if let url = audioTempURL { try? FileManager.default.removeItem(at: url) }
audioTempURL = nil
note = ""
rawTranscript = ""
liveTranscript = ""
decodeRate = 0
voiceNote = nil
withAnimation(.snappy(duration: 0.2)) { phase = .idle }
}
private func save() {
let content = note.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
let entry = DiaryEntry(content: content,
createdAt: createdAt,
tags: [DiaryEntry.consultationTag])
ctx.insert(entry)
// Vault Asset(:)
if let temp = audioTempURL,
let savedAsset = try? FileVault.shared.importFile(at: temp, preferredExtension: "m4a") {
let asset = Asset(relativePath: savedAsset.relativePath,
mimeType: "audio/m4a",
bytes: savedAsset.bytes)
ctx.insert(asset)
entry.assets.append(asset)
audioTempURL = nil // ,cleanup
}
try? ctx.save()
saved = true
dismiss()
}
/// :
private func cleanup() {
recordTask?.cancel()
organizeTask?.cancel()
watchdog?.cancel()
recorder.abort()
if !saved, let url = audioTempURL {
try? FileManager.default.removeItem(at: url)
}
}
private static func timeText(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
#Preview {
ConsultationSheet()
.modelContainer(for: [DiaryEntry.self, Asset.self], inMemory: true)
}

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