Compare commits

..

14 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
151 changed files with 18494 additions and 2150 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())

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

@@ -24,8 +24,10 @@
| 图表 | Swift Charts | iOS 16+ 原生 |
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
| LLM | MNN 主(iPhone17+/SME2):Qwen3.5-2B(`taobao-mnn/Qwen3.5-2B-MNN`,~1.1GiB);MLX 兜底:Qwen3.5-2B-4bit | 文本生成关键词抽取趋势解读。4B 实测过慢已退回 2B |
| VL | Qwen3-VL-4B-Instruct 4bit (MLX `mlx-community/Qwen3-VL-4B-Instruct-4bit`) | 拍照→结构化指标。MNN VL 需 OMNI 构建,暂走 MLX |
| **统一模型(文本+视觉)** | **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 杀手锏,真机才能测 |
@@ -39,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(拍一张 = 一条流程)
@@ -67,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 天全表扫描",不卡死。
@@ -85,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 现场重装时用)
@@ -250,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 浏览(读),不要合并复用主框架
@@ -260,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

@@ -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,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,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,9 @@
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 */
@@ -63,6 +66,9 @@
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;
@@ -128,6 +134,9 @@
FEED000000000000DEAD0003 /* MLXLLM */,
FEED000000000000DEAD0004 /* MLXLMCommon */,
FEED000000000000DEAD0006 /* MLXVLM */,
FEED000000000000DEAD0008 /* MLXHuggingFace */,
FEED000000000000DEAD000A /* Tokenizers */,
FEED000000000000DEAD000C /* HuggingFace */,
);
productName = "康康";
productReference = 5E463CF92FC403BB0089145B /* 康康.app */;
@@ -216,6 +225,8 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */,
5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
@@ -420,7 +431,7 @@
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;
@@ -453,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;
@@ -479,7 +490,7 @@
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;
@@ -512,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;
@@ -536,13 +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;
@@ -564,13 +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;
@@ -591,13 +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;
@@ -618,13 +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;
@@ -688,7 +699,23 @@
repositoryURL = "https://github.com/ml-explore/mlx-swift-lm";
requirement = {
kind = exactVersion;
version = 2.31.3;
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 */
@@ -709,6 +736,21 @@
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,5 +1,5 @@
{
"originHash" : "facc0ac7c70363ea20f6cd1235de91dea6b06f0d00190946045a6c8ae753abc2",
"originHash" : "170c2e0a1709c1ce83072661d2e376c3474847f0e091357712d91122c6658589",
"pins" : [
{
"identity" : "eventsource",
@@ -24,8 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift-lm",
"state" : {
"revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff",
"version" : "2.31.3"
"revision" : "bd4b7434e6bdb588c7ef55706ff8904cb7fd4c57",
"version" : "3.31.4"
}
},
{
@@ -33,8 +33,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
"version" : "1.7.0"
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
"version" : "1.7.1"
}
},
{
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
"version" : "1.3.1"
}
},
{
@@ -51,8 +51,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a",
"version" : "1.5.1"
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
}
},
{
@@ -87,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95",
"version" : "2.100.0"
"revision" : "cd3e1152083706d77b223fb29110e590efcc70c0",
"version" : "2.101.2"
}
},
{
@@ -100,13 +100,22 @@
"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" : "669763cfd5806a67e21972d7e5e2d6b80b1ea985",
"version" : "1.6.5"
"revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1",
"version" : "1.7.2"
}
},
{

View File

@@ -34,16 +34,13 @@ 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 InferenceEngine.current == .mnn, mnnStatus == .ready {
return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · NEON"
}
#if targetEnvironment(simulator)
return "MLX · CPU(模拟器)"
#else
@@ -54,15 +51,14 @@ actor AIRuntime {
private var llmSession: LLMSession?
private var vlSession: VLSession?
// MARK: - MNN (CPU/SME2,)
// .mnn , VL() Qwen3.5-2B MNN ()
// MNN,VL 退 MLX Qwen3-VL-4B
private let mnn = MNNBackend()
private(set) var mnnStatus: Status = .notReady
/// MNN (/ Models/Qwen3.5-2B-MNN)
nonisolated static var mnnModelFolder: URL {
ModelStore.shared.localURL(for: .mnnLLM)
}
// MARK: - 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 )
//
@@ -136,18 +132,8 @@ actor AIRuntime {
#endif
}
/// ,
/// :.mnn MNN(CPU/SME2);.mlx MLX(GPU)
/// ( MLX/GPU,Gemma 4 E2B),
func prepare() async throws {
// MNN MNN;( MLX, MNN )退 MLX,
// App (Phase 5)
let mnnReady = ModelStore.shared.isComplete(for: .mnnLLM)
if InferenceEngine.current == .mnn, mnnReady {
try await prepareMNN()
return
}
// MLX: MNN ()
await unloadMNN()
// ,
// return: ready, generate
// `guard status == .ready` ()
@@ -187,54 +173,12 @@ actor AIRuntime {
}
}
/// MNN : MLX LLM/VL
private func prepareMNN() async throws {
while mnnStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if mnnStatus == .ready { return }
let folder = Self.mnnModelFolder
guard ModelStore.shared.isComplete(for: .mnnLLM) else {
mnnStatus = .error("MNN 模型未就绪")
throw AIRuntimeError.notReady
}
await acquireGate()
defer { releaseGate() }
if mnnStatus == .ready { return }
// : MLX LLM/VL, MNN
unloadLLM()
unloadVL()
mnnStatus = .loading
do {
try await mnn.load(folderURL: folder)
mnnStatus = .ready
} catch {
mnnStatus = .error("\(error)")
throw AIRuntimeError.modelLoadFailed("\(error)")
}
}
/// MNN,
private func unloadMNN() async {
guard mnnStatus != .notReady else { return }
await mnn.unload()
mnnStatus = .notReady
MLX.Memory.clearCache()
}
/// await prepare()
/// :, actor LLMSession await
/// priority = .background token (CancellationError )
func generate(prompt: String,
maxTokens: Int = 256,
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
if InferenceEngine.current == .mnn, mnnStatus == .ready {
return mnnGenerate(prompt: prompt, maxTokens: maxTokens, priority: priority)
}
// actor ,Task 访 self.status / self.llmSession
let snapshotStatus = status
let snapshotSession = llmSession
@@ -247,6 +191,8 @@ actor AIRuntime {
}
// : LLM VL / ,
await self.acquireGate(priority)
// defer / / ; early-return
defer { self.releaseGate() }
do {
// session.generate actor , await
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
@@ -256,9 +202,6 @@ actor AIRuntime {
try Task.checkCancellation()
// :, token 退
if self.shouldPreempt(priority) { throw CancellationError() }
// Task generate() , AIRuntime actor ;
// actor recordRate await
self.recordRate(chunk.decodeRate)
continuation.yield(chunk)
}
self.lastGenerateStats = await session.lastStats
@@ -269,63 +212,68 @@ actor AIRuntime {
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
// / / (checkCancellation catch ),
// ,
self.releaseGate()
}
// / Task( LLMSession / HealthExportService )
continuation.onTermination = { _ in task.cancel() }
}
}
/// MNN(CPU/SME2) MLX :
private func mnnGenerate(prompt: String,
maxTokens: Int,
priority: InferencePriority) -> AsyncThrowingStream<TokenChunk, Error> {
let ready = (mnnStatus == .ready)
return AsyncThrowingStream { continuation in
// MARK: - Gemini (hybrid )
/// ( / ) OOM ,
/// `cloudAvailable`;退 `generate`
func generateCloud(prompt: String, maxTokens: Int = 512) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task {
guard ready else {
continuation.finish(throwing: AIRuntimeError.notReady)
return
}
await self.acquireGate(priority)
do {
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
let stream = await self.gemini.generate(prompt: prompt, maxTokens: maxTokens)
for try await chunk in stream {
try Task.checkCancellation()
// :, token 退
//( MNNBackend.onTermination bridge.cancel())
if self.shouldPreempt(priority) { throw CancellationError() }
self.recordRate(chunk.decodeRate)
continuation.yield(chunk)
}
self.lastGenerateStats = await self.mnn.lastStats
self.lastGenerateStats = await self.gemini.lastStats
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: CancellationError())
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
self.releaseGate()
}
continuation.onTermination = { _ in task.cancel() }
}
}
private func recordRate(_ rate: Double) {
if rate > 0 { lastDecodeRate = rate }
/// : Gemini ( JSON)
/// ·;退 OCR+(§3.2)
func analyzeReportCloud(imageURLs: [URL], prompt: String, maxTokens: Int = 1024) async throws -> String {
do {
return try await gemini.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
} catch {
throw AIRuntimeError.inferenceFailed("\(error)")
}
}
// MARK: - ASR(SenseVoice via sherpa-mnn)
/// (SenseVoice, CPU + ): + LLM/VL ,
/// / App jetsam (§3.1 OOM )
///
/// organize( LLM), LLM :
/// body , DiaryAssistService.organizeConsultation prepare
/// body hop 线( actor await ,,
/// ,)
func runExclusiveForASR<T: Sendable>(_ body: @Sendable () async throws -> T) async rethrows -> T {
await acquireGate(.interactive)
defer { releaseGate() }
unloadLLM()
unloadVL()
return try await body()
}
// MARK: - VL
/// VL , load
func prepareVL() async throws {
// MNN :VL MNN (+), prepareMNN
if InferenceEngine.current == .mnn, ModelStore.shared.isComplete(for: .mnnLLM) {
try await prepareMNN()
return
}
while vlStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
@@ -346,7 +294,6 @@ actor AIRuntime {
// OOM (§3.1): VL(~3GB) LLM(~1GB), jetsam
unloadLLM()
await unloadMNN()
vlStatus = .loading
do {
@@ -386,16 +333,6 @@ actor AIRuntime {
func analyzeReport(imageURLs: [URL],
prompt: String,
maxTokens: Int = 512) async throws -> String {
// MNN : MNN
if InferenceEngine.current == .mnn, mnnStatus == .ready {
await acquireGate()
defer { releaseGate() }
do {
return try await mnn.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
} catch {
throw AIRuntimeError.inferenceFailed("\(error)")
}
}
guard vlStatus == .ready, let session = vlSession else {
throw AIRuntimeError.notReady
}

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,25 +78,34 @@ 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")
}
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
// didCompleteWithError ( delegate , didReceive)
do {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
lock.lock()
self.continuation = cont
lock.unlock()
session.dataTask(with: request).resume()
}
} catch let error as DownloadError {
// : .part,
// badStatus( 503) .part,便
if case .sizeMismatch = error { try? fm.removeItem(at: part) }
throw error
}
let finalSize = Self.fileSize(at: part)
guard finalSize == expectedBytes else {
@@ -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

@@ -1,77 +0,0 @@
import Foundation
///
/// - mnn:Qwen + MNN + SME2(CPU),,
/// - mlx:Qwen + MLX(Metal GPU), /
nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
case mnn
case mlx
var displayName: String {
switch self {
case .mnn: return "MNN · CPU/SME2"
case .mlx: return "MLX · GPU"
}
}
/// /MNN device ,退 MLX
var isAvailable: Bool {
switch self {
case .mlx: return true
case .mnn: return MNNLLMBridge.isAvailable()
}
}
// MARK: - (UserDefaults, actor )
private static let key = "kk.inferenceEngine"
/// ( .auto)使
/// AIRuntime / MeView , .mnn .mlx
/// ,
static var current: InferenceEngine {
let resolved = preference.resolved
return resolved.isAvailable ? resolved : .mlx
}
/// :CPU SME2(A19/iPhone17+) UI
static var cpuSupportsSME2: Bool { MNNLLMBridge.cpuSupportsSME2() }
// MARK: - (auto / mnn / mlx)
/// .auto:
/// UserDefaults key "mnn"/"mlx"
static var preference: EnginePreference {
get {
let raw = UserDefaults.standard.string(forKey: key)
return raw.flatMap(EnginePreference.init(rawValue:)) ?? .auto
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
}
}
/// , .auto
/// - auto: MNN(, SME2/NEON),
/// MNN ()退 MLX
nonisolated enum EnginePreference: String, CaseIterable, Sendable {
case auto
case mnn
case mlx
var displayName: String {
switch self {
case .auto: return "自动"
case .mnn: return InferenceEngine.mnn.displayName
case .mlx: return InferenceEngine.mlx.displayName
}
}
/// (, `InferenceEngine.current`)
var resolved: InferenceEngine {
switch self {
case .mnn: return .mnn
case .mlx: return .mlx
case .auto: return InferenceEngine.mnn.isAvailable ? .mnn : .mlx
}
}
}

View File

@@ -2,9 +2,14 @@ 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
@@ -31,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
)
}

View File

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

View File

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

View File

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

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

View File

@@ -11,30 +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:
// Qwen3.5-2B-4bit:, LLMModelFactory qwen3_5
// mlx-community/Qwen3.5-2B-4bit blob (HF API,2026-06 )
// tokenizer vocab.json + tokenizer.json( merges.txt /
// special_tokens_map.json / added_tokens.json),chat_template .jinja
// (preprocessor / processor / video_preprocessor),
// ,
// 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: 3_113),
ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
ModelFile(path: "vocab.json", bytes: 6_722_759),
ModelFile(path: "chat_template.jinja", bytes: 7_755),
ModelFile(path: "preprocessor_config.json", bytes: 390),
ModelFile(path: "processor_config.json", bytes: 1_300),
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
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
@@ -79,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 {
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)
.appendingPathComponent(file.path))
}
return urls
}
}

View File

@@ -2,17 +2,19 @@ import Foundation
nonisolated enum ModelKind: String, CaseIterable {
/// Models/ / CDN
/// Qwen3.5-2B,:
/// - mnnLLM:MNN(CPU/SME2,)+,taobao-mnn iPhone17+(A19/SME2),
/// - llm:MLX(GPU),Qwen3.5-2B-4bit (, qwen3_5)
/// - vl:(MLX VL .llm ), switch,/
case llm = "Qwen3.5-2B-4bit"
/// 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.5-2B (MLX)"
case .llm: return "Gemma 4 E2B (MLX)"
case .vl: return "Qwen3-VL-4B"
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
}
@@ -24,11 +26,9 @@ nonisolated enum ModelKind: String, CaseIterable {
///
var sentinelFilename: String { "config.json" }
/// : / /
/// Qwen3.5-2B(MNN,+,iPhone17+ )
/// MLX .llm/.vl ,(),
/// · ,
static let userFacing: [ModelKind] = [.mnnLLM]
/// : / /
/// 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,11 +11,31 @@ enum DiaryAssistPrompts {
/// ;UI
/// /, few-shot
/// :** + **,(prompt ),
/// /,**** `localizedDimension`
static let dimensions: [String] = [
"起病诱因", "症状性质", "伴随症状", "加重缓解",
"持续频率", "既往家族史", "用药过敏", "生活方式",
]
/// , 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: (),
///
@@ -72,7 +92,8 @@ enum DiaryAssistPrompts {
本轮可选维度:\(allowedLine)
【最新记录】:
\(content)
\(aiOutputLanguageDirective())
(注意:dim 维度名仍固定用上面清单里的中文原名;只有 q 和 fill 面向用户的文字用上述语言。)
Output: /no_think
"""
}
@@ -94,6 +115,7 @@ enum DiaryAssistPrompts {
硬性规则:
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
- 输出语言必须与【口述原话】完全一致:原话是中文就用中文,是英文就用英文,是日文/韩文就用对应语言,不要翻译。
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。

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

@@ -71,10 +71,11 @@ enum HealthExportPrompts {
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
- 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
- diaries 里 kind 为「问诊」的是既往看医生的问诊记录,可据此补充「主诉」「本人背景」,但同样只搬运、不编造。
输出格式:
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
- 不给诊断意见、用药建议或「建议就医」。全文中文,简洁,医生 30 秒能扫完。
- 不给诊断意见、用药建议或「建议就医」。简洁,医生 30 秒能扫完(输出语言见文末说明)
- 严格按以下 6 段(顺序与标题固定):
\(labelLine)
## 主诉
@@ -110,7 +111,7 @@ enum HealthExportPrompts {
【本人原话】:\(userPrompt)
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
直接输出 Markdown,不要思考过程,不要 <think> 标签:
直接输出 Markdown,不要思考过程,不要 <think> 标签:\(aiOutputLanguageDirective())
/no_think
"""
}
@@ -141,7 +142,7 @@ enum HealthExportPrompts {
【用户最新问题】:
\(latestQuestion)
直接输出中文回答,不要 Markdown 标题,不要 <think>:
直接输出回答,不要 Markdown 标题,不要 <think>:\(aiOutputLanguageDirective())
/no_think
"""
}
@@ -156,13 +157,16 @@ 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

@@ -20,7 +20,7 @@ nonisolated enum InsightPrompts {
现在的报告:\(title)(\(typeLabel))
指标:
\(indicatorLines)
只输出 2~3 句正文。/no_think
只输出 2~3 句正文。\(aiOutputLanguageDirective())/no_think
"""
}
@@ -38,7 +38,7 @@ nonisolated enum InsightPrompts {
现在的记录:
\(dataLines)
只输出 1~2 句正文。/no_think
只输出 1~2 句正文。\(aiOutputLanguageDirective())/no_think
"""
}
}

View File

@@ -14,8 +14,8 @@ nonisolated enum IntentPrompts {
分类(只能选下面其中一个):
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
- "medication"录用药、拍药盒、吃了什么药
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…)
- "medication"一次用药/服药、吃了什么药、拍药盒(凡涉及「吃药/服药/用药」都归这里)
- "symptom" 记录身体症状,哪里不舒服(头疼、咳嗽、发烧、头晕…),与吃药无关
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
- "archive" 归档整份体检报告/化验单(拍报告存档)
- "export" 生成给医生看的身体档案/健康总结
@@ -24,15 +24,30 @@ nonisolated enum IntentPrompts {
规则:
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"
- 凡是「记录/记一次用药、服药、吃药、吃了药」→ "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"}

View File

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

View File

@@ -112,6 +112,113 @@ JSON schema(严格):
{"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: - (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: - ()

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),

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

@@ -4,10 +4,12 @@ import SwiftUI
/// AI //, `AIDisclaimerFooter`;
/// App (/) `AIDisclaimer.appended(to:)`
enum AIDisclaimer {
///
static let text =
"本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考," +
"不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。"
/// ( · )
/// :**** `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 {

View File

@@ -1,42 +1,49 @@
import SwiftUI
/// Apple Intelligence 线:,
/// AppAI ( AI /)
/// 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 = 1.0
/// (),
var cycle: Double = 0.6
@State private var phase: CGFloat = 0
/// :offset ,
private static let flow: [Color] = {
let base: [Color] = [
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), //
]
return base + base
/// ( 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(colors: Self.flow,
.fill(LinearGradient(gradient: Self.gradient,
startPoint: .leading, endPoint: .trailing))
.frame(width: w * 2)
.offset(x: phase)
.onAppear {
phase = 0
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
phase = -w
}
.offset(x: -w * progress)
}
}
.frame(height: height)

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,33 +23,42 @@ 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
@@ -57,33 +66,49 @@ struct ArchiveListView: View {
switch route {
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
@@ -149,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()
@@ -173,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: -
/// ( + ),
@@ -241,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)
}
}
}
@@ -255,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)
@@ -291,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)
if !isSearchMiss {
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
}
.frame(maxWidth: .infinity)

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,6 +19,7 @@ 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] = [:]
@@ -57,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
@@ -76,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)
}
}
.onChange(of: turns) { _, _ 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: answeringTurnID) { _, id in
if id == nil { scrollToBottom(proxy) }
}
}
if completed {
@@ -358,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)
}
@@ -504,8 +505,16 @@ struct HealthExportSheet: View {
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: "这次没能回答上来,请换个说法再试一次。")
}
}
}
}
@@ -552,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
}
@@ -600,6 +613,27 @@ 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 )
@@ -757,16 +791,31 @@ 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)
) {
)) ?? AttributedString(s)
Self.inlineCache.setObject(AttrBox(attr), forKey: key)
return attr
}
return AttributedString(s)
}
// MARK: -

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
if !metaOnly {
indicatorSection
}
Spacer(minLength: 8)
actions
}
@@ -68,13 +73,20 @@ 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()
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(
@@ -86,7 +98,6 @@ struct CaptureReviewForm: View {
}
}
}
}
// MARK: - meta(title / type / date / institution / summary)
@@ -117,11 +128,13 @@ struct CaptureReviewForm: View {
labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution)
}
if !metaOnly {
labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
}
}
.padding(12)
.background(fieldBg)
.overlay(fieldBorder)

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)
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
}
return
}
await MainActor.run {
phase = .editing(
parsed: parsed,
parsed: meta,
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
warning: recognized ? nil
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
)
}
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
: String(appLoc: "推理失败:\(msg)"))
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
/// : assets,, VL
/// : assets,, meta
private func reanalyze(assets: [FileVault.SavedAsset]) {
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)
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保留原图"))
}
return
}
await MainActor.run {
phase = .editing(
parsed: parsed,
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
)
phase = .editing(parsed: meta, assets: assets,
warning: recognized ? nil
: 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)"))
}
}
}
/// reanalyze editing, assets parsed
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
}
}
@@ -318,6 +298,11 @@ struct UnifiedCaptureFlow: View {
}
}
/// detached UIImage :, Sendable
private struct UncheckedImageBox: @unchecked Sendable {
let images: [UIImage]
}
// MARK: -
private struct AnalyzingView: View {

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

View File

@@ -0,0 +1,188 @@
import SwiftUI
import Foundation
/// SwiftUI Shape ,
/// / /
///
/// t( @State + withAnimation.repeatForever):
/// `DesignSystem/AIFlowBar.swift` ( 0.5s
/// tok/s), repeatForever /
/// `TimelineView(.animation)` ,,
/// //
///
/// `nonisolated static` ( t mood,
/// ),便, body Shape mood
/// () `.animation(.snappy, value: mood)`
struct CompanionAvatarView: View {
/// nonisolated:Equatable nonisolated static mood,
/// MainActor actor (Swift 6 )
nonisolated enum Mood: Equatable { case idle, listening, thinking, speaking }
var mood: Mood
var size: CGFloat = 156
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
let t = timeline.date.timeIntervalSinceReferenceDate
ZStack {
// 1 :,
Circle()
.fill(Tj.Palette.sand2)
.frame(width: size, height: size)
// 2 ×2: .listening ,, +0.8s
if mood == .listening {
Group {
soundRing(t: t, phase: 0, color: Tj.Palette.teal)
soundRing(t: t, phase: 0.8, color: Tj.Palette.tealSoft)
}
.transition(.opacity)
}
// 3-6 ( + + + ):
face(t: t)
.frame(width: size, height: size)
.scaleEffect(Self.breathScale(t: t, mood: mood), anchor: .bottom)
.rotationEffect(.degrees(headTilt(t: t)), anchor: .bottom)
// 7 ×3: .thinking ,,
if mood == .thinking {
thinkingDots(t: t)
.transition(.opacity)
}
}
.frame(width: size, height: size)
.animation(.snappy(duration: 0.25), value: mood)
}
}
// MARK: - (, MainActor; Shape )
/// : Ellipse , / /
private func face(t: Double) -> some View {
let blushOpacity: Double = (mood == .listening || mood == .speaking) ? 1.0 : 0.7
let eyeLift: CGFloat = (mood == .thinking) ? -0.02 * size : 0 //
let mouthWidth: CGFloat = (mood == .thinking) ? 0.05 * size : 0.16 * size
return ZStack {
// : + +
Ellipse()
.fill(Tj.Palette.paper)
.frame(width: 0.78 * size, height: 0.72 * size)
.overlay(Ellipse().strokeBorder(Tj.Palette.line, lineWidth: 1.5))
.shadow(color: Tj.Palette.shadow.opacity(0.08),
radius: 0.05 * size, x: 0, y: 0.018 * size)
// ×2
ForEach([-1.0, 1.0], id: \.self) { side in
Circle()
.fill(Tj.Palette.brickSoft)
.frame(width: 0.09 * size, height: 0.09 * size)
.opacity(blushOpacity)
.offset(x: CGFloat(side) * 0.24 * size, y: 0.075 * size)
}
// ×2:, = ;.thinking
ForEach([-1.0, 1.0], id: \.self) { side in
Capsule()
.fill(Tj.Palette.ink)
.frame(width: 0.055 * size, height: 0.11 * size)
.scaleEffect(x: 1, y: Self.blinkScaleY(t: t, mood: mood))
.offset(x: CGFloat(side) * 0.155 * size, y: -0.03 * size + eyeLift)
}
// :,;.thinking 0.05·size
Capsule()
.fill(Tj.Palette.ink)
.frame(width: mouthWidth,
height: Self.mouthHeight(t: t, mood: mood, size: size))
.offset(y: 0.135 * size)
}
}
/// :base 0.82·size, ×1 ×1.35 线
private func soundRing(t: Double, phase: Double, color: Color) -> some View {
let f = Self.frac((t + phase) / 1.6)
return Circle()
.strokeBorder(color, lineWidth: 2)
.frame(width: 0.82 * size, height: 0.82 * size)
.scaleEffect(1 + 0.35 * CGFloat(f))
.opacity(1 - f)
}
/// ×3: 0.05·size,沿 45° 线,
private func thinkingDots(t: Double) -> some View {
ZStack {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Tj.Palette.amber)
.frame(width: 0.05 * size, height: 0.05 * size)
.opacity(Self.thinkingDotOpacity(t: t, index: i))
.offset(x: (0.29 + 0.085 * CGFloat(i)) * size,
y: -(0.29 + 0.085 * CGFloat(i)) * size)
}
}
}
/// (): .thinking 2.6s ±2° , 0
private func headTilt(t: Double) -> Double {
mood == .thinking ? 2 * sin(2 * .pi * t / 2.6) : 0
}
// MARK: - (nonisolated,; t mood,)
/// :/idle ,speaking
nonisolated static func breathScale(t: Double, mood: Mood) -> CGFloat {
switch mood {
case .idle: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 3.2))
case .listening: return CGFloat(1 + 0.03 * sin(2 * .pi * t / 1.6))
case .thinking: return CGFloat(1 + 0.015 * sin(2 * .pi * t / 2.6))
case .speaking: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 0.6))
}
}
/// : 3.4s ( 0.15 0.12s),.thinking 0.85
nonisolated static func blinkScaleY(t: Double, mood: Mood) -> CGFloat {
if mood == .thinking { return 0.85 }
var phase = t.truncatingRemainder(dividingBy: 3.4)
if phase < 0 { phase += 3.4 } // t,
return phase <= 0.12 ? 0.15 : 1
}
/// :idle 线,listening/thinking ,speaking 0.28s
nonisolated static func mouthHeight(t: Double, mood: Mood, size: CGFloat) -> CGFloat {
switch mood {
case .idle: return 0.035 * size
case .listening: return 0.05 * size
case .thinking: return 0.05 * size
case .speaking: return 0.03 * size + 0.05 * size * CGFloat(abs(sin(2 * .pi * t / 0.28)))
}
}
/// : 120° , 0,
nonisolated static func thinkingDotOpacity(t: Double, index: Int) -> Double {
0.25 + 0.75 * max(0.0, sin(2 * .pi * t / 0.9 - Double(index) * 2 * .pi / 3))
}
/// ()
private static func frac(_ x: Double) -> Double { x - floor(x) }
}
#Preview("四种情绪") {
HStack(spacing: 18) {
ForEach(0..<4, id: \.self) { i in
VStack(spacing: 12) {
CompanionAvatarView(
mood: [.idle, .listening, .thinking, .speaking][i],
size: 116
)
Text(["idle", "listening", "thinking", "speaking"][i])
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
}
}
}
.padding(36)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Tj.Palette.sand)
}

View File

@@ -0,0 +1,591 @@
import SwiftUI
import SwiftData
///
///
/// :(makeContextJSON) LLM,;
/// + ;(maxRounds ),
/// 稿(DiaryQuickSheet cover)
///
/// (Phase):
/// preparing token speaking awaitingUser
/// awaitingUser recording / thinking(reply) speaking awaitingUser
/// awaitingUser distilling onDraft()
/// thinking/speaking reply failed thinking
///
/// (线 #5 AI ):
/// · opening () DiaryChatPrompts.staticOpening + toast,;
/// · reply () Phase.failed,,;
/// · distill / onDraft(fallbackDraft, usedFallback: true),;
/// · () ,,
///
/// :UI AIRuntime, DiaryChatService(线 #3); AIRuntime actor
struct DiaryCompanionView: View {
/// ()稿;usedFallback=true AI
/// cover; onDraft onClose
let onDraft: (_ draft: String, _ usedFallback: Bool) -> Void
let onClose: () -> Void
/// , 180s, 120s
static let maxRecordingSeconds = 120
// MARK: -
enum Phase: Equatable {
case preparing // :makeContextJSON + openingLine ( token )
case speaking // (opening reply)
case awaitingUser // : mic /
case recording // ,
case thinking // reply token
case distilling //
case failed(String) // ( reply )
}
@Environment(\.modelContext) private var ctx
@State private var phase: Phase = .preparing
@State private var turns: [HealthExportDialogueTurn] = []
/// ; trim turns,
@State private var bubbleText = ""
@State private var liveTranscript = ""
/// ; makeContextJSON
@State private var dataJSON = "{}"
@State private var recordingSeconds = 0
@State private var lastRate: Double = 0
/// ( / ), 2.5s
@State private var toast: String?
@State private var showAbandonConfirm = false
@State private var micDeniedAlert = false
/// / onDisappear ,
@State private var chatTask: Task<Void, Never>?
/// ( +1,)
@State private var recordingWatchdog: Task<Void, Never>?
/// ,reply
@State private var pendingLatest: String?
/// .task (startOpening )
@State private var started = false
/// @State:struct View () let ,
/// stop() (),
/// @State ( DiaryQuickSheet )
@State private var dictation = SpeechDictationService()
// MARK: -
/// : thinking, speaking, listening, idle
private var avatarMood: CompanionAvatarView.Mood {
switch phase {
case .preparing, .thinking, .distilling: return .thinking
case .speaking: return .speaking
case .recording: return .listening
case .awaitingUser, .failed: return .idle
}
}
private var hasUserTurns: Bool { turns.contains { $0.role == .user } }
private var wrappedUp: Bool { DiaryChatService.isWrappedUp(turns: turns) }
// MARK: - Body
var body: some View {
VStack(spacing: 0) {
topBar
CompanionAvatarView(mood: avatarMood, size: 150)
.padding(.vertical, 14)
.animation(.snappy(duration: 0.25), value: avatarMood)
dialogueArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
controlArea
.padding(.horizontal, 20)
.padding(.top, 8)
.animation(.snappy(duration: 0.22), value: phase)
AIDisclaimerFooter()
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 10)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.task { startOpening() }
.onChange(of: toast) { _, newValue in
// : 2.5s ( toast onChange )
guard newValue != nil else { return }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_500_000_000)
if toast == newValue { toast = nil }
}
}
.onDisappear {
chatTask?.cancel()
recordingWatchdog?.cancel()
dictation.abort()
}
.confirmationDialog(String(appLoc: "聊到一半,要放弃吗?"),
isPresented: $showAbandonConfirm,
titleVisibility: .visible) {
Button("把说过的记下来") {
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
}
Button("直接关闭", role: .destructive) { onClose() }
Button("取消", role: .cancel) {}
}
.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 topBar: some View {
HStack(alignment: .center, spacing: 12) {
Button(action: attemptClose) {
Image(systemName: "xmark")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(width: 44, height: 44)
.background(Circle().fill(Tj.Palette.paper))
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
.contentShape(Circle())
}
.buttonStyle(.plain)
Spacer(minLength: 8)
VStack(spacing: 2) {
Text("和康康聊聊")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("聊完帮你整理成一条记录")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 8)
roundDots
.frame(width: 44, alignment: .trailing)
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 4)
}
/// :maxRounds , i brick
private var roundDots: some View {
HStack(spacing: 4) {
ForEach(0..<DiaryChatPrompts.maxRounds, id: \.self) { i in
Circle()
.fill(DiaryChatService.roundsUsed(in: turns) > i
? Tj.Palette.brick : Tj.Palette.line)
.frame(width: 6, height: 6)
}
}
}
// MARK: -
private var dialogueArea: some View {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 10) {
ForEach(turns) { turn in
bubbleRow(text: turn.text, isUser: turn.role == .user)
}
// : tok/s(lastRate>0 )
if !bubbleText.isEmpty {
bubbleRow(text: bubbleText, isUser: false, rate: lastRate)
}
// :,brick 稿
if phase == .recording, !liveTranscript.isEmpty {
bubbleRow(text: liveTranscript, isUser: true,
strokeColor: Tj.Palette.brick.opacity(0.5))
}
if let toast {
toastBar(toast)
}
// ( DiaryVoicePanel tail )
Color.clear.frame(height: 1).id("tail")
}
.padding(.horizontal, 20)
.padding(.vertical, 8)
}
.onChange(of: turns.count) { _, _ in scrollToTail(proxy) }
.onChange(of: bubbleText) { _, _ in scrollToTail(proxy) }
}
}
private func scrollToTail(_ proxy: ScrollViewProxy) {
// : bubbleText token ,( DiaryVoicePanel:94-96)
proxy.scrollTo("tail", anchor: .bottom)
}
/// (assistant) paper + lineSoft ; sand2
/// - rate: >0 tok/s()
/// - strokeColor: ( brick )
@ViewBuilder
private func bubbleRow(text: String,
isUser: Bool,
rate: Double? = nil,
strokeColor: Color? = nil) -> some View {
let stroke = strokeColor ?? (isUser ? Color.clear : Tj.Palette.lineSoft)
HStack(alignment: .top, spacing: 0) {
if isUser { Spacer(minLength: 40) }
VStack(alignment: .leading, spacing: 4) {
Text(text)
.font(.tjScaled(15))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
if let rate, rate > 0 {
Text(String(format: "%.1f tok/s", rate))
.font(.tjScaled(10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(isUser ? Tj.Palette.sand2 : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(stroke, lineWidth: 1)
)
if !isUser { Spacer(minLength: 40) }
}
}
/// (amber + text2 )text ,
private func toastBar(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.amber)
Text(text)
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 4)
}
// MARK: -
@ViewBuilder
private var controlArea: some View {
Group {
switch phase {
case .preparing:
statusBlock(String(appLoc: "康康正在翻你的记录…"))
case .thinking:
VStack(spacing: 10) {
Text("康康在想…")
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
AIFlowBar().frame(maxWidth: 200)
Button("") { stopThinking() }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
}
case .distilling:
// , onDisappear ,
statusBlock(String(appLoc: "康康在帮你整理…"))
case .speaking:
// , awaitingUser
Color.clear.frame(height: 1)
case .awaitingUser:
awaitingControls
case .recording:
recordingControls
case .failed(let msg):
failedControls(msg)
}
}
.frame(maxWidth: .infinity)
.frame(minHeight: 150)
}
/// : + preparing / distilling
private func statusBlock(_ text: String) -> some View {
VStack(spacing: 12) {
Text(text)
.font(.tjScaled(13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
AIFlowBar().frame(maxWidth: 200)
}
}
@ViewBuilder
private var awaitingControls: some View {
if wrappedUp {
// :,
Button("聊完了,帮我记下来") { finishChat() }
.buttonStyle(TjPrimaryButton())
} else {
VStack(spacing: 12) {
Button(action: startRecording) {
ZStack {
Circle().fill(Tj.Palette.ink)
.frame(width: 72, height: 72)
Image(systemName: "mic.fill")
.font(.tjScaled(26, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.contentShape(Circle())
}
.buttonStyle(.plain)
Text("点一下,说给康康听")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
if hasUserTurns {
Button("聊完了,帮我记下来") { finishChat() }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
}
}
}
}
private var recordingControls: some View {
VStack(spacing: 12) {
HStack(spacing: 14) {
Button(action: stopAndReply) {
ZStack {
Circle().fill(Tj.Palette.brick)
.frame(width: 72, height: 72)
Image(systemName: "stop.fill")
.font(.tjScaled(24, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.contentShape(Circle())
}
.buttonStyle(.plain)
Text(timeString(recordingSeconds))
.font(.tjMono(12))
.foregroundStyle(recordingSeconds >= Self.maxRecordingSeconds - 20
? Tj.Palette.brick : Tj.Palette.text3)
}
Text("说完了")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
}
private func failedControls(_ msg: String) -> some View {
VStack(spacing: 12) {
Text(msg) // (),,
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text2)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 10) {
Button("重试") { retryReply() }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
if hasUserTurns {
Button("不聊了,把原话记下来") {
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
}
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
}
}
}
.padding(.horizontal, 20)
}
private func timeString(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
// MARK: - :
/// :,
private func startOpening() {
guard !started else { return }
started = true
dataJSON = DiaryChatService.shared.makeContextJSON(in: ctx)
consumeBubbleStream(DiaryChatService.shared.openingLine(dataJSON: dataJSON),
isOpening: true)
}
/// (opening reply): token bubbleText, turns
/// ;,,
private func consumeBubbleStream(_ stream: AsyncThrowingStream<TokenChunk, Error>,
isOpening: Bool) {
chatTask = Task { @MainActor in
do {
for try await chunk in stream {
if bubbleText.isEmpty, !chunk.text.isEmpty {
withAnimation(.snappy(duration: 0.2)) { phase = .speaking }
}
bubbleText += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
//
let done = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines)
bubbleText = ""
if !done.isEmpty {
turns.append(.assistant(done))
} else if isOpening {
// :(streamWithFallback .empty,)
turns.append(.assistant(DiaryChatPrompts.staticOpening))
toast = String(appLoc: "康康没连上,先用老一套开场啦")
}
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
} catch {
// ( CancellationError)
let partial = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines)
bubbleText = ""
if !partial.isEmpty {
// :,
turns.append(.assistant(partial))
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
} else if isOpening {
// : + ,
turns.append(.assistant(DiaryChatPrompts.staticOpening))
toast = String(appLoc: "康康没连上,先用老一套开场啦")
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
} else if error is CancellationError {
// :,
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
} else {
// :
withAnimation(.snappy(duration: 0.2)) {
phase = .failed(error.localizedDescription)
}
}
}
}
}
/// :,(consumeBubbleStream awaitingUser)
private func stopThinking() {
chatTask?.cancel()
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
}
// MARK: - :
/// alert;start toast
private func startRecording() {
toast = nil
chatTask = Task { @MainActor in
guard await dictation.requestAuthorization() else {
micDeniedAlert = true
return
}
do {
liveTranscript = ""
recordingSeconds = 0
try dictation.start { partial in liveTranscript = partial }
withAnimation(.snappy(duration: 0.2)) { phase = .recording }
// + ( DiaryQuickSheet:701 , stopAndReply)
recordingWatchdog = 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 >= Self.maxRecordingSeconds {
stopAndReply()
return
}
}
}
} catch {
toast = String(appLoc: "没听清,再试一次")
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
}
}
}
/// 稿(, DiaryQuickSheet:691-695),
private func stopAndReply() {
guard phase == .recording else { return }
recordingWatchdog?.cancel()
chatTask = Task { @MainActor in
var text = (await dictation.stop())
.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
text = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !text.isEmpty else {
toast = String(appLoc: "没听清,再试一次")
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
return
}
pendingLatest = text
turns.append(.user(text))
liveTranscript = ""
withAnimation(.snappy(duration: 0.2)) { phase = .thinking }
sendReply(latest: text)
}
}
/// transcript dropLast() append ( latest ,
/// );roundsUsed assistant ,, turns
private func sendReply(latest: String) {
let prior = HealthExportDialogueTurn.transcript(from: Array(turns.dropLast()))
let stream = DiaryChatService.shared.reply(
transcript: prior,
latest: latest,
dataJSON: dataJSON,
roundsUsed: DiaryChatService.roundsUsed(in: turns)
)
consumeBubbleStream(stream, isOpening: false)
}
/// :turns , pendingLatest
private func retryReply() {
guard let latest = pendingLatest else { return }
withAnimation(.snappy(duration: 0.2)) { phase = .thinking }
sendReply(latest: latest)
}
// MARK: - :
/// :稿 / (usedFallback=true)
private func finishChat() {
withAnimation(.snappy(duration: 0.2)) { phase = .distilling }
chatTask = Task { @MainActor in
do {
let r = try await DiaryChatService.shared.distill(turns: turns)
onDraft(r.text.isEmpty ? DiaryChatService.fallbackDraft(turns: turns) : r.text,
r.text.isEmpty)
} catch {
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
}
}
}
/// :(),
private func attemptClose() {
if hasUserTurns {
showAbandonConfirm = true
} else {
onClose()
}
}
}
#Preview {
// , openingLine staticOpening ,
DiaryCompanionView(onDraft: { _, _ in }, onClose: {})
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}

View File

@@ -6,13 +6,19 @@ import SwiftData
/// Qwen3 3-4 ,
/// q LLM ; row +
struct DiaryQuickSheet: View {
/// : 2×2 ,
/// false
var directWrite: Bool = false
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@State private var content: String = ""
@State private var createdAt: Date = .now
/// :,tag
/// :,
@State private var showMedicationScan = false
/// :( + + ),tag
@State private var showMedicationLog = false
/// : SymptomStartSheet(/,)
@State private var showSymptomStart = false
@@ -25,21 +31,25 @@ struct DiaryQuickSheet: View {
}
@State private var phase: AssistPhase = .idle
@State private var questions: [DiaryAssistService.Question] = []
/// ()
@State private var currentAnswer: String = ""
@State private var lastRate: Double = 0
@State private var currentRound: Int = 0
/// (question.dim), prompt
@State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>?
/// question id;nil =
@State private var fillingId: UUID?
/// , =
@State private var fillValues: [String] = []
/// () true,
/// question id questions (
/// coveredDims,),
@State private var skippedQuestionIDs: Set<UUID> = []
/// () true,
@State private var exhaustedNote = false
/// sheet detent large,
/// medium,()
@State private var detent: PresentationDetent = .large
@FocusState private var contentFocused: Bool
/// contentFocused :,
/// ,
@FocusState private var answerFocused: Bool
// MARK: (spec 2026-06-10-voice-diary)
@@ -62,6 +72,11 @@ struct DiaryQuickSheet: View {
/// @State
@State private var dictation = SpeechDictationService()
// MARK:
/// (DiaryCompanionView),稿
@State private var showCompanionChat = false
private var hasContent: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@@ -73,6 +88,40 @@ struct DiaryQuickSheet: View {
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
private var canSubmit: Bool { hasContent }
// MARK: - (care bar)
/// : phase + + ,
/// ,
private enum CareState {
case hidden // / ,
case prompt // ,
case thinking //
case asking(DiaryAssistService.Question) //
case caughtUp(exhausted: Bool) // ;exhausted=西
case failed(String)
}
/// / ()
private var pendingQuestions: [DiaryAssistService.Question] {
questions.filter { !$0.adopted && !skippedQuestionIDs.contains($0.id) }
}
private var currentCareQuestion: DiaryAssistService.Question? { pendingQuestions.first }
private var careState: CareState {
if voicePhase != .idle { return .hidden }
switch phase {
case .loading:
return .thinking
case .failed(let err):
return .failed(err.localizedDescription)
case .idle:
return hasContent ? .prompt : .hidden
case .ready:
if let q = currentCareQuestion { return .asking(q) }
return hasContent ? .caughtUp(exhausted: exhaustedNote) : .hidden
}
}
var body: some View {
VStack(spacing: 0) {
Capsule()
@@ -86,7 +135,7 @@ struct DiaryQuickSheet: View {
Text("健康记录")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
Text("记录身体状态 · 康康在一旁帮你想还能记点啥")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
@@ -98,25 +147,12 @@ struct DiaryQuickSheet: View {
.padding(.horizontal, 20)
.padding(.bottom, 10)
// :()/ ()/ (SymptomStartSheet)
HStack(spacing: 10) {
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true
}
modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别用药"), active: false) {
showMedicationScan = true
}
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
subtitle: String(appLoc: "持续追踪"), active: false) {
showSymptomStart = true
}
}
.padding(.horizontal, 20)
.padding(.bottom, 14)
// ( / / / ):
// ,,
//(/)
modeSelector
.animation(.snappy(duration: 0.22), value: showModeSelector)
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
@@ -124,6 +160,25 @@ struct DiaryQuickSheet: View {
sectionLabel(String(appLoc: "内容"))
Spacer()
if SpeechDictationService.isAvailable, voicePhase == .idle {
// :,
Button(action: startCompanionChat) {
HStack(spacing: 4) {
Image(systemName: "bubble.left.and.bubble.right.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)
Button(action: startVoice) {
HStack(spacing: 4) {
Image(systemName: "mic.fill")
@@ -172,12 +227,12 @@ struct DiaryQuickSheet: View {
if let note = voiceNote {
HStack(spacing: 6) {
Image(systemName: "info.circle")
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
.foregroundStyle(Tj.Palette.amber)
Text(note)
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
}
}
@@ -210,31 +265,45 @@ struct DiaryQuickSheet: View {
.datePickerStyle(.compact)
.labelsHidden()
}
// , question
Color.clear.frame(height: 1).id("assist-bottom")
}
.padding(.horizontal, 20)
.padding(.bottom, 6)
}
.scrollDismissesKeyboard(.interactively)
.onChange(of: questions.count) { old, new in
guard new > old else { return }
// round divider( N ,
// questions)
let roundId = "round-\(questions[old].round)"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.easeOut(duration: 0.25)) {
proxy.scrollTo(roundId, anchor: .top)
}
}
}
}
HStack(spacing: 12) {
HStack(spacing: 8) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
// :AI ,(spec 2026-06-17)
// flexible ;
Button(action: requestSuggestions) {
HStack(spacing: 5) {
Image(systemName: "sparkles")
.font(.tjScaled( 13, weight: .semibold))
.symbolEffect(.pulse, options: .repeating, isActive: isLoading)
Text(isLoading ? String(appLoc: "想想中…") : String(appLoc: "康康帮你一起填"))
.font(.tjScaled( 14, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.foregroundStyle(canRequestSuggest ? Tj.Palette.brick : Tj.Palette.text3)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick.opacity(canRequestSuggest ? 0.12 : 0.05))
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.brick.opacity(canRequestSuggest ? 0.4 : 0.15),
lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
Button("保存") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
.disabled(!canSubmit)
.opacity(canSubmit ? 1 : 0.4)
}
@@ -252,18 +321,43 @@ struct DiaryQuickSheet: View {
.presentationCornerRadius(Tj.Radius.xl)
.fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow(
onSave: { entries in
// :(线)+ ·
MedicationArchiver.archive(entries: entries, in: ctx)
onSave: { meds, images in
// (), ·
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
dismiss()
},
onClose: { showMedicationScan = false }
)
}
.fullScreenCover(isPresented: $showCompanionChat) {
// :,稿,
DiaryCompanionView(
onDraft: { draft, usedFallback in
appendToContent(draft)
voiceNote = usedFallback
? String(appLoc: "AI 整理没成功,已填入你聊天说的原话")
: String(appLoc: "康康把聊的内容整理好了,看一眼没问题就点保存")
showCompanionChat = false
},
onClose: { showCompanionChat = false }
)
}
.sheet(isPresented: $showSymptomStart) {
// sheet:/;,
SymptomStartSheet()
}
.sheet(isPresented: $showMedicationLog) {
// sheet:/;()
MedicationLogSheet()
}
.onAppear {
// :,,
// sheet ,
guard directWrite else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
contentFocused = true
}
}
.onDisappear {
suggestTask?.cancel()
voiceFlowTask?.cancel()
@@ -282,180 +376,37 @@ struct DiaryQuickSheet: View {
}
}
// MARK: - AI
// MARK: - (care bar)
/// :() careState,
/// AI ,
@ViewBuilder
private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) {
// section header
if !contentFocused {
switch careState {
case .hidden, .prompt:
// prompt():AI
//,
EmptyView()
default:
// ( / / / ): + tok/s +
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
Spacer()
if hasQuestions {
Text("\(questions.count) 个建议")
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
sectionLabel(String(appLoc: "康康帮你记"))
Spacer(minLength: 0)
if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
// questions (,)
if hasQuestions {
VStack(spacing: 8) {
ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
if idx == 0 || questions[idx - 1].round != q.round {
roundDivider(round: q.round,
count: questions.filter { $0.round == q.round }.count)
.id("round-\(q.round)")
}
questionRow(index: roundLocalIndex(at: idx), question: q)
}
}
AIDisclaimerFooter()
}
if exhaustedNote {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.leaf)
Text("已覆盖主要问诊维度;补充原文后可再追问")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
// ()
phaseFooter
}
}
@ViewBuilder
private var phaseFooter: some View {
switch phase {
case .idle:
assistPrimaryButton(
icon: "sparkles",
label: canRequestSuggest
? String(appLoc: "让 AI 帮我想想还能记什么")
: String(appLoc: "先写几个字,AI 来帮忙补充"),
enabled: canRequestSuggest,
prominent: true,
action: requestSuggestions
)
case .loading:
assistLoadingIndicator
case .ready:
assistPrimaryButton(
icon: "arrow.clockwise",
label: canRequestSuggest
? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
: String(appLoc: "更新一下原文,再让 AI 继续追问"),
enabled: canRequestSuggest,
action: requestSuggestions
)
case .failed(let err):
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
Button { requestSuggestions() } label: {
Text("重试")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(.plain)
}
.padding(10)
careBarRow(compact: false)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
}
/// `prominent` ( brick + + ,),
/// ( .ready )
private func assistPrimaryButton(icon: String,
label: String,
enabled: Bool,
prominent: Bool = false,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon)
Text(label)
}
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
.foregroundStyle(prominent
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
.frame(maxWidth: .infinity)
.padding(.vertical, prominent ? 14 : 11)
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
// : contentShape (+)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
@ViewBuilder
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
if prominent {
shape
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
radius: 8, x: 0, y: 3)
} else {
shape
.strokeBorder(
enabled ? Tj.Palette.ink : Tj.Palette.line,
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
)
}
}
/// .loading : paper ,(Linear/Vercel )
/// ,;线 + sparkles
private var assistLoadingIndicator: some View {
HStack(spacing: 10) {
Image(systemName: "sparkles")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.pulse, options: .repeating)
Text(lastRate > 0
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
: String(appLoc: "AI 生成中 · 本地推理"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button("取消") { cancelSuggestions() }
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 11)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
@@ -464,122 +415,192 @@ struct DiaryQuickSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.overlay(alignment: .bottom) {
AIFlowBar().padding(.horizontal, 1)
}
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
}
// AI ,(,)
if !questions.isEmpty {
AIDisclaimerFooter()
}
}
// /( ),
.animation(.snappy(duration: 0.22), value: contentFocused)
}
/// questions list idx question, round (1-based)
private func roundLocalIndex(at idx: Int) -> Int {
let target = questions[idx].round
var count = 0
for i in 0...idx where questions[i].round == target {
count += 1
}
return count
}
/// `compact = true` ();
/// `compact = false` () careState
@ViewBuilder
private func careBarRow(compact: Bool) -> some View {
switch careState {
case .hidden:
EmptyView()
/// N LLM
private func roundDivider(round: Int, count: Int) -> some View {
case .prompt:
Button(action: requestSuggestions) {
careCapsule(icon: "sparkles",
text: String(appLoc: "让康康帮你把这条记得更全"),
tint: Tj.Palette.brick, style: .soft, compact: compact)
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
case .thinking:
VStack(alignment: .leading, spacing: 9) {
HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
.font(.tjScaled( 11, weight: .semibold))
Image(systemName: "sparkles")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(round == 1
? String(appLoc: "第 1 轮 · \(count)")
: String(appLoc: "\(round) 轮 · 基于你刚才更新的文本 · \(count)"))
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
Rectangle()
.fill(Tj.Palette.line)
.frame(height: 1)
.mask(
HStack(spacing: 3) {
ForEach(0..<60, id: \.self) { _ in
Rectangle().frame(width: 3, height: 1)
}
}
)
}
.padding(.top, round == 1 ? 0 : 6)
}
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
let adopted = question.adopted
let filling = fillingId == question.id
return VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Text("\(index).")
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
Text(question.q)
.symbolEffect(.pulse, options: .repeating)
Text(lastRate > 0
? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate)
: String(appLoc: "康康在想想…"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
.strikethrough(adopted, color: Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
if adopted {
HStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.tjScaled( 10, weight: .bold))
Text("已采纳")
.font(.tjScaled( 11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.leaf)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.leafSoft))
} else if !filling {
Button { adopt(question) } label: {
HStack(spacing: 4) {
Image(systemName: "plus.circle.fill")
.font(.tjScaled( 12))
Text("采纳")
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button(action: cancelSuggestions) {
Text("")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.ink))
.buttonStyle(.plain)
}
// (Apple 线, / AIFlowBar)
// ,;keyboard toolbar(compact) GeometryReader
// , requestSuggestions ,thinking , compact
if !compact {
AIFlowBar()
}
}
case .asking(let q):
// ,asking
askingCard(q)
case .caughtUp(let exhausted):
Button(action: requestSuggestions) {
careCapsule(
icon: exhausted ? "checkmark.seal.fill" : "sparkles",
text: exhausted
? String(appLoc: "主要的都帮你问到啦 · 再想想?")
: String(appLoc: "还想到几个想问你 · 再来一轮"),
tint: exhausted ? Tj.Palette.leaf : Tj.Palette.brick,
style: .soft, compact: compact)
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
case .failed(let msg):
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.brick)
Text(msg)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Spacer(minLength: 0)
Button(action: requestSuggestions) {
Text("重试")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(.plain)
}
}
if filling {
QuestionFillPanel(
template: question.fill,
values: $fillValues,
onCommit: { assembled in commitAdoption(question, text: assembled) },
onCancel: { closeFill() }
)
} else if !question.fill.isEmpty && !adopted {
HStack(alignment: .top, spacing: 4) {
Text("将追加:")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Text(question.fill)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
}
private enum CareCapsuleStyle { case filled, soft }
/// filled = ;soft = (,prompt / caughtUp )
private func careCapsule(icon: String, text: String, tint: Color,
style: CareCapsuleStyle, compact: Bool) -> some View {
HStack(spacing: 5) {
Image(systemName: icon)
.font(.tjScaled( compact ? 11 : 12, weight: .semibold))
Text(text)
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.lineLimit(1)
}
.foregroundStyle(style == .filled ? Tj.Palette.paper : tint)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12)))
.contentShape(Capsule())
}
/// ( / )
private var answerTrimmed: String {
currentAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func answerPlaceholder(for q: DiaryAssistService.Question) -> String {
// localizedDimension (,)
q.dim.isEmpty
? String(appLoc: "在这儿写下你的回答…")
: String(appLoc: "说说「\(DiaryAssistPrompts.localizedDimension(q.dim))」的情况…")
}
/// ():
/// · :(spec 2026-06-17)
@ViewBuilder
private func askingCard(_ q: DiaryAssistService.Question) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "text.bubble.fill")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(q.q)
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, 22)
Spacer(minLength: 6)
if pendingQuestions.count > 1 {
Text(String(format: String(appLoc: "还有 %d 个"), pendingQuestions.count - 1))
.font(.tjScaled( 10, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
TextField(answerPlaceholder(for: q), text: $currentAnswer, axis: .vertical)
.font(.tjScaled( 13))
.lineLimit(1...4)
.focused($answerFocused)
.submitLabel(.done)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
.fill(Tj.Palette.sand2)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
.strokeBorder(answerFocused ? Tj.Palette.brick.opacity(0.5) : Tj.Palette.line,
lineWidth: 1)
)
HStack(spacing: 10) {
Button { skipCurrent(q) } label: {
Text("跳过这条")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
Spacer(minLength: 0)
Button { answerCurrent(q) } label: {
HStack(spacing: 5) {
Image(systemName: "text.append")
.font(.tjScaled( 12, weight: .semibold))
Text("加入日记")
.font(.tjScaled( 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Capsule().fill(answerTrimmed.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(answerTrimmed.isEmpty)
}
}
}
// MARK: - Actions
@@ -591,6 +612,41 @@ struct DiaryQuickSheet: View {
.foregroundStyle(Tj.Palette.text2)
}
/// + +
/// / ,,
private var showModeSelector: Bool {
!directWrite && !contentFocused && !hasContent
}
/// (2×2):()/ (+)/ ()/
@ViewBuilder
private var modeSelector: some View {
if showModeSelector {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
GridItem(.flexible(), spacing: 10)], spacing: 10) {
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true
}
modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
subtitle: String(appLoc: "记剂量与时间"), active: false) {
showMedicationLog = true
}
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别入药品库"), active: false) {
showMedicationScan = true
}
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
subtitle: String(appLoc: "持续追踪"), active: false) {
showSymptomStart = true
}
}
.padding(.horizontal, 20)
.padding(.bottom, 14)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
/// ( / / )active
/// : iPhone
private func modeCard(icon: String, title: String, subtitle: String,
@@ -626,6 +682,19 @@ struct DiaryQuickSheet: View {
.buttonStyle(.plain)
}
// MARK:
/// :(§4 ,AI )
private func startCompanionChat() {
guard ModelStore.shared.isComplete(for: .llm) || AIRuntime.shared.cloudAvailable else {
voiceNote = String(appLoc: "聊着记需要 AI:请先在「我的 · 模型管理」下载模型")
return
}
contentFocused = false
voiceNote = nil
showCompanionChat = true
}
// MARK:
private func startVoice() {
@@ -654,7 +723,10 @@ struct DiaryQuickSheet: View {
}
}
} catch {
voiceNote = error.localizedDescription
#if DEBUG
print("[DiaryVoice] dictation start failed: \(error)")
#endif
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
voicePhase = .idle
}
}
@@ -691,7 +763,7 @@ struct DiaryQuickSheet: View {
guard !Task.isCancelled else { return }
appendToContent(transcript) // 线 #5:退,
organizedAppended = nil
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
voiceNote = String(appLoc: "AI 整理没成功,已填入未整理的原文")
}
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
}
@@ -722,20 +794,18 @@ struct DiaryQuickSheet: View {
/// AI (coveredDims) LLM,
/// ,
/// ****:,+
/// (,)
/// :
///· :(answerCurrent),
private func requestSuggestions() {
suggestTask?.cancel()
contentFocused = false
answerFocused = false
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims)
// 1.
contentFocused = false
// 2. sheet large( medium AI)
if detent != .large {
withAnimation(.snappy(duration: 0.25)) {
detent = .large
}
}
exhaustedNote = false
phase = .loading
withAnimation(.snappy(duration: 0.2)) { phase = .loading }
suggestTask = Task { @MainActor in
do {
let result = try await DiaryAssistService.shared.suggest(
@@ -807,38 +877,30 @@ struct DiaryQuickSheet: View {
phase = hasQuestions ? .ready : .idle
}
/// : `[]` ;( adopted)
/// q ; coveredDims, prompt
private func adopt(_ question: DiaryAssistService.Question) {
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
// :( fill 退)
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
return
}
withAnimation(.snappy(duration: 0.18)) {
fillingId = question.id
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
}
}
/// ()
private func closeFill() {
withAnimation(.snappy(duration: 0.18)) {
fillingId = nil
fillValues = []
}
}
/// :(), adopted,
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
/// :· :,
/// ,(,)
/// question.dim(//); dim
/// 退· +(spec 2026-06-17)
private func answerCurrent(_ question: DiaryAssistService.Question) {
let answer = currentAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !answer.isEmpty else { return }
let dim = question.dim.trimmingCharacters(in: .whitespacesAndNewlines)
appendToContent(dim.isEmpty ? "· \(answer)" : "· \(dim):\(answer)")
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true
}
currentAnswer = ""
// ;,
if pendingQuestions.isEmpty { answerFocused = false }
}
appendToContent(text)
fillingId = nil
fillValues = []
/// :, coveredDims,
/// prompt , questions ;
/// ()
private func skipCurrent(_ question: DiaryAssistService.Question) {
skippedQuestionIDs.insert(question.id)
currentAnswer = ""
if pendingQuestions.isEmpty { answerFocused = false }
}
/// (,)

View File

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

View File

@@ -46,8 +46,8 @@ struct HomeCalendarCard: View {
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
}
/// (///)
private var daysWithRecordsThisMonth: Int {
/// (///) data,
private func daysWithRecordsThisMonth(_ data: CalendarData) -> Int {
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
var n = 0
@@ -62,9 +62,11 @@ struct HomeCalendarCard: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
weekStrip
// CalendarData , + (7)+ (~30)
let calData = data
return VStack(alignment: .leading, spacing: 12) {
header(calData)
weekStrip(calData)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -77,14 +79,14 @@ struct HomeCalendarCard: View {
}
}
private var header: some View {
private func header(_ data: CalendarData) -> some View {
HStack(alignment: .firstTextBaseline) {
Text("健康日历")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
HStack(spacing: 3) {
Text(summaryLine)
Text(summaryLine(data))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Image(systemName: "chevron.right")
@@ -94,20 +96,20 @@ struct HomeCalendarCard: View {
}
}
private var summaryLine: String {
let n = daysWithRecordsThisMonth
private func summaryLine(_ data: CalendarData) -> String {
let n = daysWithRecordsThisMonth(data)
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
}
private var weekStrip: some View {
private func weekStrip(_ data: CalendarData) -> some View {
HStack(spacing: 6) {
ForEach(weekDays, id: \.self) { day in
dayCell(day)
dayCell(day, data)
}
}
}
private func dayCell(_ day: Date) -> some View {
private func dayCell(_ day: Date, _ data: CalendarData) -> some View {
let marks = data.marks(for: day, calendar: calendar)
let ranges = data.ranges(touching: day, calendar: calendar)
let isToday = calendar.isDateInToday(day)

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ let customMetricIconChoices: [String] = [
]
/// `detectNameConflict` UI
enum CustomMetricNameConflict: Equatable {
nonisolated enum CustomMetricNameConflict: Equatable {
case none
case builtin(String) // MonitorMetric.displayName
case existingCustom(String) // CustomMonitorMetric.name
@@ -64,6 +64,7 @@ struct CustomMetricEditor: View {
@State private var upper: String = ""
@State private var icon: String = "circle.fill"
@State private var hydrated = false
@State private var showDeleteConfirm = false
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
@@ -227,13 +228,7 @@ struct CustomMetricEditor: View {
private var deleteButton: some View {
Button(role: .destructive) {
if let m = existing {
ReminderService.cancel(metricId: m.seriesKey)
ctx.delete(m)
try? ctx.save()
onSaved(nil)
dismiss()
}
showDeleteConfirm = true
} label: {
HStack {
Image(systemName: "trash")
@@ -250,6 +245,21 @@ struct CustomMetricEditor: View {
}
.buttonStyle(.plain)
.padding(.top, 8)
.alert(String(appLoc: "删除这项自定义指标?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { deleteMetric() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("删除后不再监测该指标,已记录的历史数据仍保留。")
}
}
private func deleteMetric() {
guard let m = existing else { return }
ReminderService.cancel(metricId: m.seriesKey)
ctx.delete(m)
try? ctx.save()
onSaved(nil)
dismiss()
}
private var footer: some View {

View File

@@ -31,6 +31,18 @@ struct IndicatorQuickSheet: View {
/// nil ( Preview)
var onRequestCamera: (() -> Void)? = nil
/// nil =
/// seriesKey MonitorMetric / CustomMonitorMetric ( + );
/// name/unit/range ,
var prefill: Prefill? = nil
struct Prefill: Equatable {
var seriesKey: String?
var name: String = ""
var unit: String = ""
var range: String = ""
}
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query private var profiles: [UserProfile]
@@ -69,6 +81,32 @@ struct IndicatorQuickSheet: View {
// sheet
@State private var showHiddenSheet: Bool = false
//
@State private var didApplyPrefill = false
// :, / /
@State private var searchingMetrics = false
@State private var metricQuery = ""
private var isSearchingMetrics: Bool {
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
}
private var filteredMonitorMetrics: [MonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return visibleMonitorMetrics }
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
}
private var filteredCustomMetrics: [CustomMonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return customMetrics }
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private var filteredLabPresets: [IndicatorPreset] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return labPresets }
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private static var defaultReminderTime: Date {
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
}
@@ -91,7 +129,29 @@ struct IndicatorQuickSheet: View {
selectedMonitor?.displayName ?? selectedCustom?.name
}
/// ( / / );
/// / (++)
/// nil = ;,
private var numericValidationError: String? {
func check(_ s: String, min: Double, max: Double, field: String) -> String? {
let t = s.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty else { return nil } //
guard let v = Double(t), v.isFinite else { return String(appLoc: "\(field)请填数字") }
guard v >= min, v <= max else { return String(appLoc: "\(field)数值超出合理范围") }
return nil
}
if isBP {
return check(systolic, min: 30, max: 350, field: String(appLoc: "收缩压"))
?? check(diastolic, min: 20, max: 250, field: String(appLoc: "舒张压"))
}
if isLongTermMetric { // / :
return check(value, min: 0.0001, max: 1_000_000, field: String(appLoc: "数值"))
}
return nil // ( / )
}
private var canSubmit: Bool {
guard numericValidationError == nil else { return false }
if isBP {
return !systolic.trimmingCharacters(in: .whitespaces).isEmpty &&
!diastolic.trimmingCharacters(in: .whitespaces).isEmpty
@@ -124,6 +184,9 @@ struct IndicatorQuickSheet: View {
statusSection
}
}
if let validationError = numericValidationError {
validationHint(validationError)
}
timeSection
noteSection
@@ -137,6 +200,7 @@ struct IndicatorQuickSheet: View {
footer
}
.onAppear { applyPrefillIfNeeded() }
.task(id: longTermKey) { hydrateReminder() }
.background(
Tj.Palette.sand
@@ -161,7 +225,8 @@ struct IndicatorQuickSheet: View {
}
private var header: some View {
HStack {
VStack(spacing: 12) {
HStack(spacing: 10) {
Text("记录指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
@@ -169,11 +234,55 @@ struct IndicatorQuickSheet: View {
Text("本地处理 · 永不上传")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
searchToggle
}
if searchingMetrics { searchField }
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searchingMetrics.toggle()
if !searchingMetrics { metricQuery = "" }
}
} label: {
Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searchingMetrics ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标名"), text: $metricQuery)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !metricQuery.isEmpty {
Button { metricQuery = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(fieldBg)
.overlay(fieldBorder)
}
/// : RootView VL
@ViewBuilder
private var cameraEntrySection: some View {
@@ -241,13 +350,19 @@ struct IndicatorQuickSheet: View {
}
let columns = [GridItem(.flexible()), GridItem(.flexible())]
LazyVGrid(columns: columns, spacing: 8) {
ForEach(visibleMonitorMetrics) { m in
ForEach(filteredMonitorMetrics) { m in
monitorTile(m)
}
ForEach(customMetrics) { cm in
ForEach(filteredCustomMetrics) { cm in
customTile(cm)
}
addCustomTile
// (),
if !isSearchingMetrics { addCustomTile }
}
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
Text("没有匹配的长期监测指标")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
.sheet(isPresented: $showHiddenSheet) {
@@ -386,12 +501,15 @@ struct IndicatorQuickSheet: View {
}
}
@ViewBuilder
private var labPresetSection: some View {
// :()
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(labPresets) { p in
ForEach(filteredLabPresets) { p in
chip(p.name, selected: selectedLabPreset == p) {
applyLab(p)
}
@@ -400,6 +518,7 @@ struct IndicatorQuickSheet: View {
}
}
}
}
private var bpFieldSection: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -941,6 +1060,29 @@ struct IndicatorQuickSheet: View {
// MARK: - apply preset
/// :seriesKey / ( + ),
/// name/unit/range
private func applyPrefillIfNeeded() {
guard !didApplyPrefill, let p = prefill else { return }
didApplyPrefill = true
if let key = p.seriesKey {
if let m = MonitorMetric.allCases.first(where: { metric in
metric.fields.contains { $0.seriesKey == key }
}) {
applyMonitor(m)
return
}
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
applyCustom(cm)
return
}
}
// seriesKey ( / / ):, seriesKey,
name = p.name
unit = p.unit
range = p.range
}
private func applyMonitor(_ m: MonitorMetric) {
if selectedMonitor == m {
//
@@ -1035,6 +1177,21 @@ struct IndicatorQuickSheet: View {
return (s.label, s.color)
}
// MARK: -
private func validationHint(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.brick)
Text(text)
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.brick)
Spacer(minLength: 0)
}
.transition(.opacity)
}
// MARK: - submit
private func submit() {

View File

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

View File

@@ -18,11 +18,16 @@ struct CustomMetricsListView: View {
if metrics.isEmpty {
emptyState
} else {
// seriesKey 使(O(indicators)), O(1) ,
// O(metrics × indicators) N+1
let usageCounts: [String: Int] = indicators.reduce(into: [:]) { acc, ind in
if let key = ind.seriesKey, !key.isEmpty { acc[key, default: 0] += 1 }
}
ForEach(metrics) { m in
Button {
editingTarget = CustomMetricEditTarget(metric: m)
} label: {
row(m)
row(m, usage: usageCounts[m.seriesKey] ?? 0)
}
.buttonStyle(.plain)
}
@@ -82,9 +87,8 @@ struct CustomMetricsListView: View {
.frame(maxWidth: .infinity)
}
private func row(_ m: CustomMonitorMetric) -> some View {
let count = usageCount(for: m)
return HStack(spacing: 12) {
private func row(_ m: CustomMonitorMetric, usage count: Int) -> some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.leafSoft)
Image(systemName: m.icon)
@@ -137,9 +141,6 @@ struct CustomMetricsListView: View {
)
}
private func usageCount(for m: CustomMonitorMetric) -> Int {
indicators.filter { $0.seriesKey == m.seriesKey }.count
}
}
#Preview {

View File

@@ -10,22 +10,32 @@ struct CustomReminderEditSheet: View {
/// nil =
let reminder: CustomReminder?
/// (,:+ )
/// (reminder != nil)
private let prefillTitle: String
private let prefillNote: String
@State private var title = ""
@State private var note = ""
@State private var pickedTime: Date = .now
@State private var frequency: CustomReminder.Frequency = .daily
/// (/// )
@State private var frequencies: Set<CustomReminder.Frequency> = [.daily]
@State private var weekdays: Set<Int> = Set(1...7)
@State private var dayOfMonth = 1
/// (1...31)
@State private var monthDays: Set<Int> = [1]
@State private var dayOfMonth = 1 //
@State private var month = 1
@State private var hydrated = false
@State private var showAuthDeniedAlert = false
@State private var showDeleteConfirm = false
/// (, ): / / /
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
init(reminder: CustomReminder? = nil) {
init(reminder: CustomReminder? = nil, prefillTitle: String = "", prefillNote: String = "") {
self.reminder = reminder
self.prefillTitle = prefillTitle
self.prefillNote = prefillNote
}
private var isEditing: Bool { reminder != nil }
@@ -33,8 +43,9 @@ struct CustomReminderEditSheet: View {
title.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool {
guard !trimmedTitle.isEmpty else { return false }
if frequency == .weekly { return !weekdays.isEmpty }
guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false }
if frequencies.contains(.weekly) && weekdays.isEmpty { return false }
if frequencies.contains(.monthly) && monthDays.isEmpty { return false }
return true
}
@@ -51,18 +62,12 @@ struct CustomReminderEditSheet: View {
}
Section {
Picker(String(appLoc: "重复"), selection: $frequency) {
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
frequencyChips
frequencyDetail
} header: {
Text("重复")
} footer: {
Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。")
}
Section {
@@ -75,7 +80,7 @@ struct CustomReminderEditSheet: View {
if isEditing {
Section {
Button(role: .destructive) { deleteReminder() } label: {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(String(appLoc: "删除提醒"), systemImage: "trash")
}
}
@@ -106,26 +111,69 @@ struct CustomReminderEditSheet: View {
} message: {
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
}
.alert(String(appLoc: "删除这条提醒?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { deleteReminder() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("删除后该提醒不再触发。")
}
}
}
// MARK: -
// MARK: - chip
private static let freqOrder: [CustomReminder.Frequency] = [.daily, .weekly, .monthly, .yearly]
private func freqLabel(_ f: CustomReminder.Frequency) -> String {
switch f {
case .daily: return String(appLoc: "每日")
case .weekly: return String(appLoc: "每周")
case .monthly: return String(appLoc: "每月")
case .yearly: return String(appLoc: "每年")
}
}
private var frequencyChips: some View {
HStack(spacing: 8) {
ForEach(Self.freqOrder, id: \.self) { f in
let on = frequencies.contains(f)
Button {
if on { frequencies.remove(f) } else { frequencies.insert(f) }
} label: {
Text(freqLabel(f))
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 32)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
.listRowBackground(Color.clear)
}
// MARK: - (,)
@ViewBuilder
private var frequencyDetail: some View {
switch frequency {
case .daily:
EmptyView()
case .weekly:
if frequencies.contains(.weekly) {
subCaption(String(appLoc: "每周 · 选星期几"))
weekdayRow
case .monthly:
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
ForEach(1...31, id: \.self) { d in
Text(String(appLoc: "\(d)")).tag(d)
}
if frequencies.contains(.monthly) {
subCaption(String(appLoc: "每月 · 选日期(可多选)"))
monthDayGrid
if monthDays.contains(where: { $0 >= 29 }) { skipHint }
}
if dayOfMonth >= 29 { skipHint }
case .yearly:
if frequencies.contains(.yearly) {
subCaption(String(appLoc: "每年 · 选月/日"))
Picker(String(appLoc: "月份"), selection: $month) {
ForEach(1...12, id: \.self) { mo in
Text(String(appLoc: "\(mo)")).tag(mo)
@@ -140,6 +188,41 @@ struct CustomReminderEditSheet: View {
}
}
private func subCaption(_ text: String) -> some View {
Text(text)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear)
}
/// (1...31,7 )
private var monthDayGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
ForEach(1...31, id: \.self) { d in
let on = monthDays.contains(d)
Button {
if on { monthDays.remove(d) } else { monthDays.insert(d) }
} label: {
Text("\(d)")
.font(.tjScaled( 12, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 30)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
.listRowBackground(Color.clear)
}
private var skipHint: some View {
Text(String(appLoc: "部分月份无此日,该月将跳过"))
.font(.tjScaled( 11))
@@ -229,13 +312,18 @@ struct CustomReminderEditSheet: View {
if let r = reminder {
title = r.title
note = r.note
frequency = r.frequency
frequencies = r.frequencies
weekdays = Set(r.weekdays)
monthDays = Set(r.monthlyDays)
dayOfMonth = r.dayOfMonth
month = r.month
pickedTime = Calendar.current.date(
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
) ?? .now
} else {
// :( / )
title = prefillTitle
note = prefillNote
}
}
@@ -245,6 +333,7 @@ struct CustomReminderEditSheet: View {
let hour = cal.component(.hour, from: pickedTime)
let minute = cal.component(.minute, from: pickedTime)
let sortedDays = weekdays.sorted()
let sortedMonthDays = monthDays.sorted()
let target: CustomReminder
if let r = reminder {
@@ -253,8 +342,9 @@ struct CustomReminderEditSheet: View {
r.hour = hour
r.minute = minute
r.weekdays = sortedDays
r.frequency = frequency
r.dayOfMonth = dayOfMonth
r.frequencies = frequencies // frequenciesRaw(+ frequencyRaw)
r.monthlyDays = sortedMonthDays // monthDays
r.dayOfMonth = dayOfMonth //
r.month = month
r.updatedAt = .now
target = r
@@ -265,10 +355,11 @@ struct CustomReminderEditSheet: View {
hour: hour,
minute: minute,
weekdays: sortedDays,
frequency: frequency,
dayOfMonth: dayOfMonth,
month: month
)
new.frequencies = frequencies
new.monthlyDays = sortedMonthDays
ctx.insert(new)
target = new
}

View File

@@ -1,12 +1,19 @@
import SwiftUI
/// : MNN(CPU/SME2,) MLX(GPU,), SME2
/// ; AI (prepare/generate)
/// : Gemma 4( 4bit),** MLX/GPU**
/// MNN/SME2 LLM (Gemma 4 MNN ),
/// , + + AI
struct InferenceSettingsView: View {
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
// AI(Gemini) key CloudAI ,@AppStorage
@AppStorage("cloud_ai_gemini_enabled") private var cloudEnabled = false
@AppStorage("cloud_ai_gemini_key") private var cloudKey = ""
@State private var modelService = ModelDownloadService.shared
/// , push
@State private var showSelfTest = false
private var selected: EnginePreference {
EnginePreference(rawValue: engineRaw) ?? .auto
/// (Gemma 4,MLX)
private var modelReady: Bool {
modelService.states[.llm]?.phase == .ready
}
var body: some View {
@@ -21,125 +28,156 @@ struct InferenceSettingsView: View {
.padding(.top, 4)
.padding(.bottom, 6)
ForEach(EnginePreference.allCases, id: \.self) { engine in
engineRow(engine)
}
localEngineCard
sme2Card
selfTestSection
cloudSection
noteCard
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.onAppear { modelService.refreshStates() }
}
private func engineRow(_ engine: EnginePreference) -> some View {
let available = isAvailable(engine)
let isOn = (selected == engine)
return Button {
guard available else { return }
engineRaw = engine.rawValue
} label: {
/// (,):MLX · Metal GPU, Gemma 4 E2B
private var localEngineCard: some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(isOn ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: iconName(engine))
Circle().fill(Tj.Palette.amber.opacity(0.25))
Image(systemName: "bolt.fill")
.font(.tjScaled(18))
.foregroundStyle(isOn ? Tj.Palette.ink : Tj.Palette.text2)
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(engine.displayName)
Text("MLX · GPU")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(subtitle(engine, available: available))
Text("Metal GPU · 端侧推理 Gemma 4 E2B")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
}
Spacer()
if isOn {
Image(systemName: "checkmark.circle.fill")
.font(.tjScaled(18))
.foregroundStyle(Tj.Palette.leaf)
}
}
.padding(14)
.tjCard()
.opacity(available ? 1 : 0.45)
}
.buttonStyle(.plain)
.disabled(!available)
}
/// .auto ;
private func isAvailable(_ engine: EnginePreference) -> Bool {
switch engine {
case .auto: return true
case .mnn: return InferenceEngine.mnn.isAvailable
case .mlx: return InferenceEngine.mlx.isAvailable
/// :/,(TjGhostButton),
/// /
@ViewBuilder
private var selfTestSection: some View {
if modelReady {
VStack(spacing: 12) {
Button {
withAnimation(.easeInOut(duration: 0.22)) { showSelfTest.toggle() }
} label: {
HStack(spacing: 8) {
Image(systemName: "gauge.with.needle")
.font(.tjScaled(15, weight: .semibold))
Text("性能自检")
Image(systemName: "chevron.down")
.font(.tjScaled(13, weight: .semibold))
.rotationEffect(.degrees(showSelfTest ? 180 : 0))
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
private func iconName(_ engine: EnginePreference) -> String {
switch engine {
case .auto: return "wand.and.stars"
case .mnn: return "cpu.fill"
case .mlx: return "bolt.fill"
if showSelfTest {
ModelSelfTestView(embedded: true)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
private func subtitle(_ engine: EnginePreference, available: Bool) -> String {
switch engine {
case .auto:
// ,
let resolved = engine.resolved
if resolved == .mnn {
return InferenceEngine.cpuSupportsSME2
? String(appLoc: "按本机配置选择 · 当前 MNN + SME2")
: String(appLoc: "按本机配置选择 · 当前 MNN(NEON)")
.padding(.top, 4)
} else {
return String(appLoc: "按本机配置选择 · 当前 MLX(MNN 不可用)")
}
case .mnn:
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
return InferenceEngine.cpuSupportsSME2
? String(appLoc: "端侧 CPU + SME2 加速 · 挑战赛考核路径")
: String(appLoc: "端侧 CPU(本机无 SME2,NEON 回退)")
case .mlx:
return String(appLoc: "Metal GPU · 兜底 / 对照")
}
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "gauge.with.needle")
.font(.tjScaled(15, weight: .semibold))
Text("性能自检")
}
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.opacity(0.6)
private var sme2Card: some View {
let sme2 = InferenceEngine.cpuSupportsSME2
return HStack(spacing: 12) {
ZStack {
Circle().fill(sme2 ? Tj.Palette.leafSoft : Tj.Palette.sand2)
Image(systemName: sme2 ? "checkmark.seal.fill" : "minus.circle")
.font(.tjScaled(18))
.foregroundStyle(sme2 ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Arm SME2")
.font(.tjScaled(15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Text(sme2 ? String(appLoc: "本设备支持,MNN 已启用 SME2 加速")
: String(appLoc: "本设备不支持(需 A19/iPhone 17+)"))
Text("模型未就绪,前往「模型管理」下载后可用")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.top, 4)
}
}
private var cloudConfigured: Bool {
cloudEnabled && !cloudKey.trimmingCharacters(in: .whitespaces).isEmpty
}
/// AI(Gemini)hybrid :(),
/// AI Studio key , / / Google Gemini
private var cloudSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("云端 AI · Gemini")
.font(.tjTitle())
.foregroundStyle(Tj.Palette.text)
Spacer()
}
.padding(.top, 10)
VStack(alignment: .leading, spacing: 12) {
Toggle(isOn: $cloudEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("启用云端增强")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("默认端侧 Gemma 4;开启后「读报告原图 / 深度解读 / 多语言」走 Google Gemini。")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
}
.tint(Tj.Palette.leaf)
if cloudEnabled {
SecureField(String(appLoc: "粘贴 Google AI Studio 的 API Key"), text: $cloudKey)
.font(.tjScaled(14))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(RoundedRectangle(cornerRadius: Tj.Radius.md).fill(Tj.Palette.sand2))
HStack(spacing: 6) {
Image(systemName: cloudConfigured ? "checkmark.seal.fill" : "exclamationmark.circle")
.font(.tjScaled(13))
.foregroundStyle(cloudConfigured ? Tj.Palette.leaf : Tj.Palette.text3)
Text(cloudConfigured
? String(appLoc: "已就绪 · \(AIRuntime.cloudLabel)")
: String(appLoc: "请填入 API Key(aistudio.google.com 免费获取)"))
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
Text("Key 仅存本机;断网或额度用尽时自动回退端侧 Gemma 4,功能不中断。")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(14)
.tjCard()
}
}
private var noteCard: some View {
Text("MNN 在端侧 CPU 上以 Arm SME2 指令集加速 Qwen 推理(本地、不上云)。切换后下一次 AI 调用生效。")
Text("隐私优先:默认端侧 Gemma 4(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)

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