Compare commits
113 Commits
770dd6bedf
...
feat/mnn-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47e62b658 | ||
|
|
0fd469f5c0 | ||
|
|
ef5364d8c3 | ||
|
|
198570186e | ||
|
|
32180d7c0e | ||
|
|
558682c8f8 | ||
|
|
404abbf10b | ||
|
|
e179a369f6 | ||
|
|
30f75dc2cd | ||
|
|
abacf5c4f5 | ||
|
|
52db6fb85a | ||
|
|
de19d7abcd | ||
|
|
b3777d508d | ||
|
|
9d856fcfc4 | ||
|
|
6c6a950140 | ||
|
|
f58d6064ba | ||
|
|
c3f8ec400c | ||
|
|
69de5faf4b | ||
|
|
477a64ecb4 | ||
|
|
6405733358 | ||
|
|
2e27677f80 | ||
|
|
2e90139df7 | ||
|
|
77139f5e32 | ||
|
|
0dd60d6021 | ||
|
|
43cdde9bab | ||
|
|
0a824610cf | ||
|
|
7e8e692695 | ||
|
|
3f9a2af279 | ||
|
|
a65c63947b | ||
|
|
8494e51823 | ||
|
|
070e016f81 | ||
|
|
8c8599e77d | ||
|
|
b7e8ab33ec | ||
|
|
db327afd79 | ||
|
|
5eb724ab86 | ||
|
|
cfeb25247a | ||
|
|
26a7d53b1b | ||
|
|
e603738330 | ||
|
|
7f0a76098a | ||
|
|
b79ae54b7b | ||
|
|
ca5a3fa38b | ||
|
|
836f3d4234 | ||
|
|
b919404412 | ||
|
|
ddfd474bb3 | ||
|
|
cbacd9461a | ||
|
|
39b1521f00 | ||
|
|
9da3fbc87e | ||
|
|
f6c0ba7077 | ||
|
|
afc6a79dd7 | ||
|
|
06484d09ff | ||
|
|
ac11aa0f99 | ||
|
|
77a4ee1c37 | ||
|
|
074d99715d | ||
|
|
60b6ad6d65 | ||
|
|
675c33bea1 | ||
|
|
77697e1600 | ||
|
|
30f97b3535 | ||
|
|
3798efa48d | ||
|
|
bff7cfd4b6 | ||
|
|
32e7c25ed7 | ||
|
|
d72a1fec17 | ||
|
|
db7cc1bba7 | ||
|
|
adb589af16 | ||
|
|
da6223e051 | ||
|
|
40155de709 | ||
|
|
7ad41c5f09 | ||
|
|
dad9d43486 | ||
|
|
d2c77d5c51 | ||
|
|
910ca99f21 | ||
|
|
062c027c77 | ||
|
|
6ccbe4ac55 | ||
|
|
fe80e112af | ||
|
|
5f8f492f0e | ||
|
|
599d39af35 | ||
|
|
1b01923c8e | ||
|
|
39edc25dc1 | ||
|
|
37b47b2076 | ||
|
|
e2fb631b96 | ||
|
|
0f38bf585b | ||
|
|
3dcb792131 | ||
|
|
9a6d21100b | ||
|
|
7ede38ae06 | ||
|
|
22cf4bcefe | ||
|
|
bb08243aa9 | ||
|
|
b80fae35c9 | ||
|
|
e3ad24ac0e | ||
|
|
b63b26bce5 | ||
|
|
b1b8d0a8c7 | ||
|
|
2e728dcd24 | ||
|
|
46b69cf8e1 | ||
|
|
e4a68a1bdd | ||
|
|
53da442424 | ||
|
|
44ed01acf4 | ||
|
|
9419e8158f | ||
|
|
57536e5319 | ||
|
|
a3e758cf83 | ||
|
|
acfdaa1f4f | ||
|
|
9fbd31458c | ||
|
|
a02679a623 | ||
|
|
f5f78e36a6 | ||
|
|
1ee512dce1 | ||
|
|
ad1b045e12 | ||
|
|
ef0fbeac97 | ||
|
|
193e478425 | ||
|
|
771b28e7ef | ||
|
|
e7cdb45472 | ||
|
|
4dcd951821 | ||
|
|
d40cb7d1e0 | ||
|
|
ad6fb660f0 | ||
|
|
0739ccea2b | ||
|
|
d704a9eb78 | ||
|
|
2b6c4b9726 | ||
|
|
c050865db5 |
73
.agents/skills/vibe-submission-collector/SKILL.md
Normal 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.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Vibe 选手材料收集"
|
||||
short_description: "分轮收集必填联系与证书信息,按需解释字段并生成统一材料包"
|
||||
default_prompt: "使用 $vibe-submission-collector 通过分轮问答帮我收集参赛资料和礼品邮寄信息。"
|
||||
@@ -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、流程、支付、隐私、交互或信息结构做出的调整。
|
||||
- **本地化研究或测试证据**:当地用户反馈、访谈、公开研究、测试记录或其他依据。
|
||||
- **是否经过当地用户测试**:是否让目标地区或文化背景的真实用户体验过作品。
|
||||
- **根据测试做出的修改**:团队根据本地用户反馈实际调整了哪些内容。
|
||||
|
||||
## 演示视频
|
||||
|
||||
- **2–5 分钟演示视频链接**:可在线播放或下载的视频地址,视频文件不要直接放入材料包。
|
||||
- **Demo 演示**:视频中需要展示核心功能实际运行,而不只是口头或 PPT 说明。
|
||||
- **用户问题说明**:说明目标用户、使用场景和需要解决的问题。
|
||||
- **Google 技术应用说明**:说明使用了哪些 Google 技术以及它们参与了什么核心能力。
|
||||
- **创新点说明**:展示技术或产品体验与常见方案的差异。
|
||||
- **本地化说明**:说明针对目标国家、地区或文化做出的具体适配。
|
||||
- **视频时间点**:对应内容在视频中出现的时间位置;选填,用于帮助评委快速定位。
|
||||
|
||||
## 原创性与访问声明
|
||||
|
||||
- **作品是否在比赛前已经存在**:如实选择作品在比赛开始前是否已有原型、代码、产品或上线版本;必填。
|
||||
- **比赛期间新增了哪些内容**:说明本次比赛期间新完成的功能、设计、技术或验证工作;必填。
|
||||
- **原创性确认**:确认作品由本人或团队完成,并对提交内容的原创性负责;必填。
|
||||
- **同意评委访问和测试**:授权组委会和评委在评审期内打开链接、使用测试账号并体验作品;必填。
|
||||
|
||||
## 附件索引
|
||||
|
||||
- **附件路径**:小型证据文件在材料包中的相对位置。
|
||||
- **附件类型**:用户研究、技术、本地化或其他证据类别。
|
||||
- **附件说明**:说明附件证明什么内容,避免评委仅凭文件名猜测。
|
||||
@@ -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 2–5 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.
|
||||
661
.agents/skills/vibe-submission-collector/scripts/submission_tool.py
Executable file
@@ -0,0 +1,661 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize, render, and validate Vibe-a-thon submission packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SCHEMA = "vibe-a-thon-submission/v3"
|
||||
LEGACY_SCHEMAS = {"vibe-a-thon-submission/v1", "vibe-a-thon-submission/v2"}
|
||||
REQUIRED_MARKDOWN = [
|
||||
"01-overview.md",
|
||||
"02-user-problem.md",
|
||||
"03-product-demo.md",
|
||||
"04-google-technology.md",
|
||||
"05-market-localization.md",
|
||||
"06-innovation.md",
|
||||
"07-declarations.md",
|
||||
]
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def valid_url(value: str) -> bool:
|
||||
parsed = urlparse(value or "")
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
|
||||
def blank_submission(submission_id: str, title: str) -> dict:
|
||||
return {
|
||||
"schema_version": SCHEMA,
|
||||
"submission_id": submission_id,
|
||||
"updated_at": now_iso(),
|
||||
"basic": {
|
||||
"title": title,
|
||||
"team_name": "",
|
||||
"track": "",
|
||||
"one_liner": "",
|
||||
"contestant_name_certificate": "",
|
||||
"has_team": None,
|
||||
"team_member_names_certificate": [],
|
||||
"contact": {"name": "", "email": "", "phone": "", "wechat": ""},
|
||||
"members": [],
|
||||
"target_markets": [],
|
||||
"languages": [],
|
||||
"status": "",
|
||||
"xiaohongshu_url": "",
|
||||
},
|
||||
"problem": {
|
||||
"target_users": "",
|
||||
"scenario": "",
|
||||
"current_workaround": "",
|
||||
"pain_points": [],
|
||||
"evidence_summary": "",
|
||||
"evidence_links": [],
|
||||
"research_count": 0,
|
||||
"representative_feedback": "",
|
||||
"unvalidated_assumptions": [],
|
||||
},
|
||||
"solution": {"summary": "", "user_value": "", "user_flow": [], "core_features": []},
|
||||
"demo": {
|
||||
"url": "",
|
||||
"access_method": "",
|
||||
"recommended_environment": "",
|
||||
"experience_steps": [],
|
||||
"expected_duration_minutes": None,
|
||||
"known_issues": [],
|
||||
"fallback_download_url": "",
|
||||
"published_channel": "",
|
||||
"published_product_name": "",
|
||||
"support_contact": "",
|
||||
"availability_confirmed": False,
|
||||
"automated_testing_consent": False,
|
||||
"access_restricted": False,
|
||||
"private_access_file": "private/08-test-access.md",
|
||||
},
|
||||
"video": {
|
||||
"url": "",
|
||||
"duration_minutes": None,
|
||||
"contents_confirmed": {"demo": False, "user_problem": False, "google_technology": False, "innovation": False, "localization": False},
|
||||
"timecodes": {"problem": "", "demo": "", "google_technology": "", "innovation": "", "localization": ""},
|
||||
},
|
||||
"google_technology": [],
|
||||
"technical": {
|
||||
"architecture_file": "",
|
||||
"repository_url": "",
|
||||
"commit_id": "",
|
||||
"code_evidence_paths": [],
|
||||
"deployment_notes": "",
|
||||
"dependencies": [],
|
||||
"confidentiality_notes": "",
|
||||
},
|
||||
"innovation": {"technical": [], "product_experience": []},
|
||||
"market": {
|
||||
"first_market": "",
|
||||
"market_rationale": "",
|
||||
"user_acquisition": "",
|
||||
"usage_conditions": "",
|
||||
"barriers": [],
|
||||
"cost_range": "",
|
||||
"social_impact_path": "",
|
||||
"impact_metrics": [],
|
||||
"six_month_plan": "",
|
||||
},
|
||||
"localization": {
|
||||
"target_culture": "",
|
||||
"habits": [],
|
||||
"user_concerns": [],
|
||||
"adaptations": [],
|
||||
"evidence": [],
|
||||
"tested_with_local_users": False,
|
||||
"test_count": 0,
|
||||
"changes_after_testing": "",
|
||||
},
|
||||
"declarations": {
|
||||
"existed_before_contest": None,
|
||||
"new_work_during_contest": "",
|
||||
"originality_confirmed": False,
|
||||
"review_access_consent": False,
|
||||
},
|
||||
"attachments": [],
|
||||
}
|
||||
|
||||
|
||||
def load_submission(folder: Path) -> dict:
|
||||
path = folder / "00-submission.json"
|
||||
if not path.is_file():
|
||||
raise ValueError(f"missing {path}")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("schema_version") in LEGACY_SCHEMAS | {SCHEMA}:
|
||||
basic = data.setdefault("basic", {})
|
||||
basic.setdefault("contestant_name_certificate", basic.get("contact", {}).get("name", ""))
|
||||
basic.setdefault("team_member_names_certificate", [
|
||||
member.get("name", "") for member in basic.get("members", []) if isinstance(member, dict) and member.get("name")
|
||||
])
|
||||
basic.setdefault("has_team", bool(basic.get("team_member_names_certificate")))
|
||||
demo = data.setdefault("demo", {})
|
||||
demo.setdefault("published_channel", "")
|
||||
demo.setdefault("published_product_name", "")
|
||||
video = data.setdefault("video", {})
|
||||
video.setdefault("contents_confirmed", {"demo": False, "user_problem": False, "google_technology": False, "innovation": False, "localization": False})
|
||||
if data.get("schema_version") in LEGACY_SCHEMAS:
|
||||
data["schema_version"] = SCHEMA
|
||||
return data
|
||||
|
||||
|
||||
def bullets(values, empty="未提供") -> str:
|
||||
if not values:
|
||||
return f"- {empty}"
|
||||
result = []
|
||||
for value in values:
|
||||
if isinstance(value, dict):
|
||||
result.append(f"- {json.dumps(value, ensure_ascii=False)}")
|
||||
else:
|
||||
result.append(f"- {value}")
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def text(value, empty="未提供") -> str:
|
||||
if value is None or value == "":
|
||||
return empty
|
||||
if isinstance(value, bool):
|
||||
return "是" if value else "否"
|
||||
return str(value)
|
||||
|
||||
|
||||
def render(folder: Path, data: dict) -> None:
|
||||
basic, problem, solution = data["basic"], data["problem"], data["solution"]
|
||||
demo, video = data["demo"], data["video"]
|
||||
tech, market, loc = data["technical"], data["market"], data["localization"]
|
||||
decl = data["declarations"]
|
||||
|
||||
overview = f"""# 作品概览
|
||||
|
||||
- 作品编号:{text(data['submission_id'])}
|
||||
- 作品名称:{text(basic['title'])}
|
||||
- 团队名称:{text(basic['team_name'])}
|
||||
- 选手姓名(获奖证书):{text(basic['contestant_name_certificate'])}
|
||||
- 参赛形式:{'团队参赛' if basic['has_team'] is True else '个人参赛' if basic['has_team'] is False else '未提供'}
|
||||
- 团队成员姓名(获奖证书):{', '.join(basic['team_member_names_certificate']) or '未提供'}
|
||||
- 赛道:{text(basic['track'])}
|
||||
- 当前状态:{text(basic['status'])}
|
||||
- 一句话介绍:{text(basic['one_liner'])}
|
||||
- 目标市场:{', '.join(basic['target_markets']) or '未提供'}
|
||||
- 产品语言:{', '.join(basic['languages']) or '未提供'}
|
||||
- 小红书链接:{text(basic['xiaohongshu_url'])}
|
||||
|
||||
## 团队成员
|
||||
|
||||
{bullets(basic['members'])}
|
||||
|
||||
## 联系人
|
||||
|
||||
- 姓名:{text(basic['contact']['name'])}
|
||||
- 邮箱:{text(basic['contact']['email'])}
|
||||
- 手机:{text(basic['contact']['phone'])}
|
||||
- 微信:{text(basic['contact']['wechat'])}
|
||||
"""
|
||||
user_problem = f"""# 用户问题
|
||||
|
||||
## 目标用户
|
||||
|
||||
{text(problem['target_users'])}
|
||||
|
||||
## 使用场景
|
||||
|
||||
{text(problem['scenario'])}
|
||||
|
||||
## 当前解决方式
|
||||
|
||||
{text(problem['current_workaround'])}
|
||||
|
||||
## 具体痛点
|
||||
|
||||
{bullets(problem['pain_points'])}
|
||||
|
||||
## 问题证据
|
||||
|
||||
{text(problem['evidence_summary'])}
|
||||
|
||||
{bullets(problem['evidence_links'], '未提供证据链接')}
|
||||
|
||||
- 访谈或测试人数:{text(problem['research_count'], '0')}
|
||||
- 代表性反馈:{text(problem['representative_feedback'])}
|
||||
|
||||
## 尚未验证的假设
|
||||
|
||||
{bullets(problem['unvalidated_assumptions'])}
|
||||
"""
|
||||
feature_lines = []
|
||||
for index, feature in enumerate(solution["core_features"], 1):
|
||||
feature_lines.append(
|
||||
f"### {index}. {text(feature.get('name'))}\n\n"
|
||||
f"- 解决的问题:{text(feature.get('problem'))}\n"
|
||||
f"- 输入:{text(feature.get('input'))}\n"
|
||||
f"- 系统处理:{text(feature.get('processing'))}\n"
|
||||
f"- 输出:{text(feature.get('output'))}\n"
|
||||
f"- 当前状态:{text(feature.get('runnable_status'))}\n"
|
||||
f"- 证据位置:{text(feature.get('evidence_location'))}"
|
||||
)
|
||||
step_lines = []
|
||||
for index, step in enumerate(demo["experience_steps"], 1):
|
||||
step_lines.append(f"{index}. {text(step.get('action'))}\n - 预期结果:{text(step.get('expected_result'))}")
|
||||
timecodes = video["timecodes"]
|
||||
product_demo = f"""# 产品与 Demo
|
||||
|
||||
## 解决方案
|
||||
|
||||
{text(solution['summary'])}
|
||||
|
||||
## 用户价值
|
||||
|
||||
{text(solution['user_value'])}
|
||||
|
||||
## 用户流程
|
||||
|
||||
{bullets(solution['user_flow'])}
|
||||
|
||||
## 核心功能
|
||||
|
||||
{chr(10).join(feature_lines) or '未提供'}
|
||||
|
||||
## Demo
|
||||
|
||||
- Demo链接:{text(demo['url'])}
|
||||
- 访问方式:{text(demo['access_method'])}
|
||||
- 推荐环境:{text(demo['recommended_environment'])}
|
||||
- 预计体验时长:{text(demo['expected_duration_minutes'])} 分钟
|
||||
- 备用下载链接:{text(demo['fallback_download_url'])}
|
||||
- 已发布渠道:{text(demo['published_channel'])}
|
||||
- 已发布产品名称:{text(demo['published_product_name'])}
|
||||
- 技术支持联系人:{text(demo['support_contact'])}
|
||||
- 受限访问:{text(demo['access_restricted'])}
|
||||
- 测试账号文件:{text(demo['private_access_file'])}
|
||||
|
||||
### 体验步骤
|
||||
|
||||
{chr(10).join(step_lines) or '未提供'}
|
||||
|
||||
### 已知问题
|
||||
|
||||
{bullets(demo['known_issues'])}
|
||||
|
||||
## 演示视频
|
||||
|
||||
- 视频链接:{text(video['url'])}
|
||||
- 时长:{text(video['duration_minutes'])} 分钟
|
||||
- 包含 Demo 演示:{text(video['contents_confirmed']['demo'])}
|
||||
- 包含用户问题:{text(video['contents_confirmed']['user_problem'])}
|
||||
- 包含 Google 技术说明:{text(video['contents_confirmed']['google_technology'])}
|
||||
- 包含创新点说明:{text(video['contents_confirmed']['innovation'])}
|
||||
- 包含本地化说明:{text(video['contents_confirmed']['localization'])}
|
||||
- 用户问题:{text(timecodes['problem'])}
|
||||
- Demo:{text(timecodes['demo'])}
|
||||
- Google技术:{text(timecodes['google_technology'])}
|
||||
- 创新点:{text(timecodes['innovation'])}
|
||||
- 本地化:{text(timecodes['localization'])}
|
||||
"""
|
||||
google_lines = []
|
||||
for index, item in enumerate(data["google_technology"], 1):
|
||||
google_lines.append(
|
||||
f"## {index}. {text(item.get('name'))}\n\n"
|
||||
f"- 具体能力或模型:{text(item.get('capability'))}\n"
|
||||
f"- 对应功能:{text(item.get('feature'))}\n"
|
||||
f"- 输入:{text(item.get('input'))}\n"
|
||||
f"- 处理:{text(item.get('processing'))}\n"
|
||||
f"- 输出:{text(item.get('output'))}\n"
|
||||
f"- 核心程度:{text(item.get('role'))}\n"
|
||||
f"- 选型原因:{text(item.get('selection_reason'))}\n"
|
||||
f"- 移除后的影响:{text(item.get('removal_impact'))}\n"
|
||||
f"- 证据位置:{text(item.get('evidence_location'))}"
|
||||
)
|
||||
google_technology = f"""# Google 技术与实现证据
|
||||
|
||||
{chr(10).join(google_lines) or '未提供 Google 技术记录'}
|
||||
|
||||
## 技术材料
|
||||
|
||||
- 架构图:{text(tech['architecture_file'])}
|
||||
- 代码仓库:{text(tech['repository_url'])}
|
||||
- 评审版本:{text(tech['commit_id'])}
|
||||
- 部署说明:{text(tech['deployment_notes'])}
|
||||
- 保密限制:{text(tech['confidentiality_notes'])}
|
||||
|
||||
### 核心代码或证据位置
|
||||
|
||||
{bullets(tech['code_evidence_paths'])}
|
||||
|
||||
### 外部依赖
|
||||
|
||||
{bullets(tech['dependencies'])}
|
||||
"""
|
||||
market_localization = f"""# 出海市场、社会价值与本地化
|
||||
|
||||
## 出海落地
|
||||
|
||||
- 首个市场:{text(market['first_market'])}
|
||||
- 市场选择原因:{text(market['market_rationale'])}
|
||||
- 首批用户获取方式:{text(market['user_acquisition'])}
|
||||
- 使用条件:{text(market['usage_conditions'])}
|
||||
- 成本范围:{text(market['cost_range'])}
|
||||
- 六个月计划:{text(market['six_month_plan'])}
|
||||
|
||||
### 主要障碍
|
||||
|
||||
{bullets(market['barriers'])}
|
||||
|
||||
## 社会价值
|
||||
|
||||
{text(market['social_impact_path'])}
|
||||
|
||||
### 影响指标
|
||||
|
||||
{bullets(market['impact_metrics'])}
|
||||
|
||||
## 本地化
|
||||
|
||||
- 目标文化或地区:{text(loc['target_culture'])}
|
||||
- 是否经过当地用户测试:{text(loc['tested_with_local_users'])}
|
||||
- 测试人数:{text(loc['test_count'], '0')}
|
||||
- 测试后的修改:{text(loc['changes_after_testing'])}
|
||||
|
||||
### 当地习惯
|
||||
|
||||
{bullets(loc['habits'])}
|
||||
|
||||
### 用户顾虑
|
||||
|
||||
{bullets(loc['user_concerns'])}
|
||||
|
||||
### 非翻译型适配
|
||||
|
||||
{bullets(loc['adaptations'])}
|
||||
|
||||
### 本地化证据
|
||||
|
||||
{bullets(loc['evidence'])}
|
||||
"""
|
||||
innovation_lines = []
|
||||
for label, values in [("技术创新", data["innovation"]["technical"]), ("产品与体验创新", data["innovation"]["product_experience"])]:
|
||||
innovation_lines.append(f"## {label}")
|
||||
if not values:
|
||||
innovation_lines.append("\n未提供")
|
||||
for index, item in enumerate(values, 1):
|
||||
innovation_lines.append(
|
||||
f"\n### {index}. {text(item.get('title'))}\n\n"
|
||||
f"- 常见做法:{text(item.get('common_approach'))}\n"
|
||||
f"- 本作品的不同:{text(item.get('difference'))}\n"
|
||||
f"- 实际效果:{text(item.get('effect'))}\n"
|
||||
f"- 证据位置:{text(item.get('evidence_location'))}"
|
||||
)
|
||||
declarations = f"""# 原创性与合规声明
|
||||
|
||||
- 赛前已存在:{text(decl['existed_before_contest'])}
|
||||
- 比赛期间新增:{text(decl['new_work_during_contest'])}
|
||||
- 原创性确认:{text(decl['originality_confirmed'])}
|
||||
- 评委访问和测试授权:{text(decl['review_access_consent'])}
|
||||
|
||||
## 小型附件索引
|
||||
|
||||
{bullets(data['attachments'])}
|
||||
"""
|
||||
outputs = {
|
||||
"01-overview.md": overview,
|
||||
"02-user-problem.md": user_problem,
|
||||
"03-product-demo.md": product_demo,
|
||||
"04-google-technology.md": google_technology,
|
||||
"05-market-localization.md": market_localization,
|
||||
"06-innovation.md": "# 创新说明\n\n" + "\n".join(innovation_lines) + "\n",
|
||||
"07-declarations.md": declarations,
|
||||
}
|
||||
for filename, content in outputs.items():
|
||||
(folder / filename).write_text(content.strip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def validate(folder: Path, data: dict) -> dict:
|
||||
errors, warnings = [], []
|
||||
|
||||
def missing(value) -> bool:
|
||||
return value is None or value == "" or value == []
|
||||
|
||||
def warn_missing(value, label):
|
||||
if missing(value):
|
||||
warnings.append(f"missing scoring information: {label}")
|
||||
|
||||
if missing(data.get("schema_version")):
|
||||
errors.append("missing schema_version")
|
||||
if data.get("schema_version") != SCHEMA:
|
||||
errors.append(f"unsupported schema_version: {data.get('schema_version')}")
|
||||
if missing(data.get("submission_id")):
|
||||
errors.append("missing submission_id")
|
||||
|
||||
basic = data.get("basic", {})
|
||||
warn_missing(basic.get("contact", {}).get("wechat"), "微信号")
|
||||
if missing(basic.get("contestant_name_certificate")):
|
||||
errors.append("missing basic.contestant_name_certificate")
|
||||
if missing(basic.get("contact", {}).get("phone")):
|
||||
errors.append("missing basic.contact.phone")
|
||||
if not isinstance(basic.get("has_team"), bool):
|
||||
errors.append("basic.has_team must be answered true or false")
|
||||
elif basic.get("has_team") and missing(basic.get("team_member_names_certificate")):
|
||||
errors.append("missing basic.team_member_names_certificate for team entry")
|
||||
elif not basic.get("has_team") and basic.get("team_member_names_certificate"):
|
||||
warnings.append("inconsistent administrative information: 个人参赛不应填写团队成员证书姓名")
|
||||
warn_missing(basic.get("track"), "参赛赛道")
|
||||
if basic.get("track") and basic["track"] not in {"真", "善", "美"}:
|
||||
warnings.append("invalid scoring information: 参赛赛道应为真、善或美")
|
||||
warn_missing(basic.get("status"), "当前状态")
|
||||
warn_missing(basic.get("one_liner"), "一句话介绍")
|
||||
warn_missing(basic.get("contact", {}).get("email"), "邮箱")
|
||||
warn_missing(basic.get("target_markets"), "目标国家或地区")
|
||||
warn_missing(basic.get("xiaohongshu_url"), "小红书作品链接")
|
||||
if basic.get("xiaohongshu_url") and not valid_url(basic["xiaohongshu_url"]):
|
||||
warnings.append("invalid optional URL: 小红书作品链接")
|
||||
|
||||
contact = basic.get("contact", {})
|
||||
if not contact.get("email") and not contact.get("wechat"):
|
||||
warnings.append("missing administrative information: 邮箱或微信号")
|
||||
|
||||
problem = data.get("problem", {})
|
||||
warn_missing(problem.get("target_users"), "具体目标用户")
|
||||
warn_missing(problem.get("scenario"), "典型使用场景")
|
||||
warn_missing(problem.get("current_workaround"), "用户当前解决方式")
|
||||
warn_missing(problem.get("pain_points"), "核心用户痛点和现有方式不足")
|
||||
if not problem.get("evidence_summary") and not problem.get("evidence_links"):
|
||||
warnings.append("scoring evidence missing: 用户问题证据")
|
||||
|
||||
solution = data.get("solution", {})
|
||||
warn_missing(solution.get("summary"), "解决方案概述")
|
||||
warn_missing(solution.get("user_value"), "用户价值")
|
||||
warn_missing(solution.get("user_flow"), "完整用户流程")
|
||||
warn_missing(solution.get("core_features"), "核心功能")
|
||||
|
||||
demo = data.get("demo", {})
|
||||
warn_missing(demo.get("url"), "Demo或可运行文件链接")
|
||||
if demo.get("url") and not valid_url(demo["url"]):
|
||||
warnings.append("invalid optional URL: Demo链接")
|
||||
if demo.get("published_channel") and not demo.get("published_product_name"):
|
||||
warnings.append("incomplete scoring information: 已发布渠道缺少产品名称")
|
||||
if demo.get("published_product_name") and not demo.get("published_channel"):
|
||||
warnings.append("incomplete scoring information: 已发布产品缺少发布渠道")
|
||||
warn_missing(demo.get("access_method"), "Demo访问方式")
|
||||
warn_missing(demo.get("recommended_environment"), "推荐运行环境")
|
||||
warn_missing(demo.get("expected_duration_minutes"), "预计体验时间")
|
||||
steps = demo.get("experience_steps", [])
|
||||
if not steps:
|
||||
warnings.append("missing scoring information: Demo体验步骤")
|
||||
elif not 3 <= len(steps) <= 5:
|
||||
warnings.append("incomplete scoring information: Demo体验步骤建议为3至5步")
|
||||
for index, step in enumerate(steps, 1):
|
||||
if missing(step.get("action")):
|
||||
warnings.append(f"incomplete scoring information: Demo第{index}步缺少操作")
|
||||
if missing(step.get("expected_result")):
|
||||
warnings.append(f"incomplete scoring information: Demo第{index}步缺少预期结果")
|
||||
if not demo.get("availability_confirmed"):
|
||||
warnings.append("missing scoring information: Demo评审期可用确认")
|
||||
if demo.get("access_restricted") and not (folder / demo.get("private_access_file", "")).is_file():
|
||||
warnings.append("restricted Demo has no private test-access file")
|
||||
if not demo.get("fallback_download_url"):
|
||||
warnings.append("missing scoring information: 备用Demo或下载链接")
|
||||
elif not valid_url(demo["fallback_download_url"]):
|
||||
warnings.append("invalid optional URL: 备用Demo或下载链接")
|
||||
|
||||
video = data.get("video", {})
|
||||
warn_missing(video.get("url"), "2至5分钟演示视频链接")
|
||||
if video.get("url") and not valid_url(video["url"]):
|
||||
warnings.append("invalid optional URL: 演示视频链接")
|
||||
duration = video.get("duration_minutes")
|
||||
if duration is None:
|
||||
warnings.append("missing scoring information: 演示视频时长")
|
||||
elif not 2 <= duration <= 5:
|
||||
warnings.append("incomplete scoring information: 演示视频应为2至5分钟")
|
||||
video_contents = video.get("contents_confirmed", {})
|
||||
for key, label in [("demo", "Demo演示"), ("user_problem", "用户问题"), ("google_technology", "Google技术说明"), ("innovation", "创新点说明"), ("localization", "本地化说明")]:
|
||||
if not video_contents.get(key):
|
||||
warnings.append(f"missing scoring information: 视频未确认包含{label}")
|
||||
|
||||
google_items = data.get("google_technology", [])
|
||||
if not google_items:
|
||||
warnings.append("missing scoring information: Google技术应用")
|
||||
required_google = ["name", "capability", "feature", "input", "processing", "output", "role", "selection_reason", "removal_impact", "evidence_location"]
|
||||
for index, item in enumerate(google_items, 1):
|
||||
for key in required_google:
|
||||
if missing(item.get(key)):
|
||||
warnings.append(f"incomplete scoring information: Google技术第{index}项缺少{key}")
|
||||
if item.get("role") and item["role"] not in {"核心", "支撑"}:
|
||||
warnings.append(f"invalid scoring information: Google技术第{index}项角色应为核心或支撑")
|
||||
|
||||
innovation = data.get("innovation", {})
|
||||
warn_missing(innovation.get("technical"), "技术创新")
|
||||
warn_missing(innovation.get("product_experience"), "产品与体验创新")
|
||||
|
||||
market = data.get("market", {})
|
||||
warn_missing(market.get("first_market"), "首个计划进入的市场")
|
||||
warn_missing(market.get("market_rationale"), "市场选择原因")
|
||||
warn_missing(market.get("user_acquisition"), "首批用户获取方式")
|
||||
warn_missing(market.get("usage_conditions"), "用户使用条件")
|
||||
warn_missing(market.get("barriers"), "主要落地障碍")
|
||||
|
||||
localization = data.get("localization", {})
|
||||
warn_missing(localization.get("target_culture"), "目标文化或地区")
|
||||
warn_missing(localization.get("habits"), "当地用户习惯")
|
||||
warn_missing(localization.get("user_concerns"), "当地用户情绪、顾虑或限制")
|
||||
warn_missing(localization.get("adaptations"), "语言翻译之外的产品适配")
|
||||
if not localization.get("tested_with_local_users"):
|
||||
warnings.append("scoring evidence missing: 未经过当地用户测试")
|
||||
if not localization.get("evidence"):
|
||||
warnings.append("scoring evidence missing: 本地化研究或测试证据")
|
||||
|
||||
declarations = data.get("declarations", {})
|
||||
if not isinstance(declarations.get("existed_before_contest"), bool):
|
||||
errors.append("declarations.existed_before_contest must be answered true or false")
|
||||
if missing(declarations.get("new_work_during_contest")):
|
||||
errors.append("missing declarations.new_work_during_contest")
|
||||
if declarations.get("originality_confirmed") is not True:
|
||||
errors.append("declarations.originality_confirmed must be true")
|
||||
if declarations.get("review_access_consent") is not True:
|
||||
errors.append("declarations.review_access_consent must be true")
|
||||
|
||||
for path in folder.rglob("*"):
|
||||
if path.is_file() and path.stat().st_size > 10 * 1024 * 1024:
|
||||
warnings.append(f"large local file should be a cloud link: {path.relative_to(folder)}")
|
||||
|
||||
shipping_path = folder / "private/09-gift-shipping.json"
|
||||
if not shipping_path.is_file():
|
||||
warnings.append("missing administrative information: 礼品邮寄信息文件")
|
||||
else:
|
||||
try:
|
||||
shipping = json.loads(shipping_path.read_text(encoding="utf-8"))
|
||||
for key, label in [("recipient_name", "礼品收件人姓名"), ("phone", "礼品收件电话"), ("address", "礼品邮寄地址")]:
|
||||
if missing(shipping.get(key)):
|
||||
warnings.append(f"missing administrative information: {label}")
|
||||
except json.JSONDecodeError:
|
||||
warnings.append("invalid administrative information: 礼品邮寄信息文件不是有效JSON")
|
||||
return {"schema_version": SCHEMA, "submission_id": data.get("submission_id"), "valid": not errors, "errors": errors, "warnings": warnings}
|
||||
|
||||
|
||||
def command_init(args) -> int:
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{1,63}", args.id):
|
||||
raise ValueError("submission ID must be 2-64 filesystem-safe characters")
|
||||
folder = Path(args.root).expanduser().resolve() / args.id
|
||||
if folder.exists() and any(folder.iterdir()):
|
||||
raise ValueError(f"refusing to overwrite non-empty folder: {folder}")
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
for subdir in ["private", "evidence/user-research", "evidence/technical", "evidence/localization"]:
|
||||
(folder / subdir).mkdir(parents=True, exist_ok=True)
|
||||
data = blank_submission(args.id, args.title)
|
||||
(folder / "00-submission.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
(folder / "private/08-test-access.md").write_text(
|
||||
"# 评审测试账号(敏感)\n\n"
|
||||
"> 仅填写专用临时测试账号。禁止填写 API Key、个人密码、生产凭据或真实用户数据。\n\n"
|
||||
"- Demo:\n- 测试账号:\n- 临时密码:\n- 有效期:\n- 额外说明:\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(folder / "private/09-gift-shipping.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "vibe-a-thon-gift-shipping/v1",
|
||||
"submission_id": args.id,
|
||||
"recipient_name": "",
|
||||
"phone": "",
|
||||
"address": "",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
render(folder, data)
|
||||
print(folder)
|
||||
return 0
|
||||
|
||||
|
||||
def command_render(args) -> int:
|
||||
folder = Path(args.folder).expanduser().resolve()
|
||||
data = load_submission(folder)
|
||||
data["updated_at"] = now_iso()
|
||||
(folder / "00-submission.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
render(folder, data)
|
||||
print(f"rendered {folder}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_validate(args) -> int:
|
||||
folder = Path(args.folder).expanduser().resolve()
|
||||
result = validate(folder, load_submission(folder))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result["valid"] else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
init_parser = subparsers.add_parser("init")
|
||||
init_parser.add_argument("--root", required=True)
|
||||
init_parser.add_argument("--id", required=True)
|
||||
init_parser.add_argument("--title", default="")
|
||||
init_parser.set_defaults(func=command_init)
|
||||
render_parser = subparsers.add_parser("render")
|
||||
render_parser.add_argument("folder")
|
||||
render_parser.set_defaults(func=command_render)
|
||||
validate_parser = subparsers.add_parser("validate")
|
||||
validate_parser.add_argument("folder")
|
||||
validate_parser.set_defaults(func=command_validate)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
return args.func(args)
|
||||
except (ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
6
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
/build/
|
||||
# 大模型素材:本地下载用于上传到 OpenList,不入库(~3GB)
|
||||
/Models/
|
||||
/build/
|
||||
.DS_Store
|
||||
|
||||
# MNN 预编译二进制:由 scripts/build-mnn-xcframework.sh 本地生成,不入库防历史膨胀
|
||||
/Frameworks/MNN.xcframework/
|
||||
|
||||
293
AGENTS.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# 康康 —— 工程前提
|
||||
|
||||
> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。
|
||||
> 任何 IDE/Codex 会话开始干活前,先读这份文件。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
- **名字**:康康(对内代号 Kangkang)
|
||||
- **形态**:iOS 原生 App,SwiftUI + SwiftData
|
||||
- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答
|
||||
- **目标用户**:不愿把体检/化验报告交给云端的普通人
|
||||
- **明确不做**:医疗诊断、剂量推荐、急诊判断、医生预约、社交、广告、内购、数据上云、账号系统
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈 / 选型(已锁定,不要再讨论)
|
||||
|
||||
| 项 | 选型 | 备注 |
|
||||
|---|---|---|
|
||||
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
||||
| 持久化 | SwiftData | 见 §5 数据模型 |
|
||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
||||
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
|
||||
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
||||
| 模型 | **Qwen3.5-2B**(一个多模态模型,文本+视觉一肩挑) | 真机主用:`taobao-mnn/Qwen3.5-2B-MNN`(~1.2GB);MLX 兜底:`mlx-community/Qwen3.5-2B-4bit`(~1.7GB)。**已废弃**:Qwen3-1.7B / Qwen2.5-VL-3B / Qwen3-VL-4B(4B 实测过慢退回 2B) |
|
||||
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||
| Face ID | LocalAuthentication | |
|
||||
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
||||
|
||||
**不引入**:任何云服务 SDK、任何 embedding 模型(RAG 用结构化检索,不用语义)、任何账号系统、任何分析 SDK。
|
||||
|
||||
---
|
||||
|
||||
## 3. AI 链路核心规则
|
||||
|
||||
### 3.1 模块边界(强制)
|
||||
|
||||
```
|
||||
UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX
|
||||
↓
|
||||
Persistence
|
||||
```
|
||||
|
||||
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务(模型共享内存/Metal 显存,并发会 OOM 被 jetsam 杀)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。**真正落地**是 actor 内信号量闸门 `acquireGate()/releaseGate()`,所有占显存的重活(解码 + 模型加载)进入前先 await,且加载 VL 前先卸 LLM。
|
||||
- **引擎选择**:`InferenceEngine.current` 由偏好(`.auto`/`.mnn`/`.mlx`)+ 设备可用性解析,真机默认 `.mnn`(SME2/NEON),模拟器回退 `.mlx`。
|
||||
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
||||
|
||||
### 3.2 VL pipeline(拍一张 = 一条流程)
|
||||
|
||||
**重要**:快拍(1.x) 和 报告归档(2.x) 已经合并成统一 `CaptureService`,UI 不再有 A1-A3 和 B1-B4 两条独立路径。流程:
|
||||
|
||||
```
|
||||
拍照 → 写 Vault(加密目录) → VL 推理(要求输出 JSON,含 kind=single|report)
|
||||
→ 解析容错(失败回退到手动录入,不卡死)
|
||||
→ 单项走 A2ConfirmView,整份走 B3MetaView
|
||||
→ 保存到 Indicator/Report + 关联 Asset
|
||||
```
|
||||
|
||||
VL prompt 必须:
|
||||
- 明确要求"只输出 JSON,不要解释"
|
||||
- 带 2 个 few-shot 示例(单项 + 多项)
|
||||
- 异常状态由 VL 模型基于参考范围直接判断,不要再二次调用 LLM
|
||||
|
||||
### 3.3 RAG(结构化检索,不做 embedding)
|
||||
|
||||
**两段式调用**:
|
||||
1. 用 Qwen3.5-2B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
||||
|
||||
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
||||
**引用回链**:回答中 `[1][2]` 后处理为可点击 Pill,点击跳源记录详情。
|
||||
|
||||
### 3.4 Live Activity
|
||||
|
||||
- VL 推理 / RAG 生成开始时启动 Activity
|
||||
- 每 0.5s 通过 `AIRuntime.lastDecodeRate` 推送 tok/s
|
||||
- 推理完成保留 2s 显示"已完成 · 0.8s"再 dismiss
|
||||
- **只能真机测**,模拟器不显示。W5 末预留时间。
|
||||
|
||||
---
|
||||
|
||||
## 4. 模型分发
|
||||
|
||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||
- **用户面只有一个模型**:Qwen3.5-2B-MNN(~1.2GB,`ModelKind.userFacing = [.mnnLLM]`)。多模态,文本+视觉全包,下载全部 / 就绪计数只算它
|
||||
- MLX 兜底版 Qwen3.5-2B-4bit(~1.7GB)仅模拟器与兜底用,不展示、不计入「下载全部」,但旁路导入仍可单独导
|
||||
- WiFi 提示必须有
|
||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据模型(SwiftData)
|
||||
|
||||
**当前 schema(2026-05-26)**:7 个 @Model。
|
||||
|
||||
```swift
|
||||
@Model class Indicator {
|
||||
name, value, unit, range, statusRaw, note, capturedAt,
|
||||
report: Report?, asset: Asset?,
|
||||
pinned: Bool, // 长期监测自动 true,Trends 默认展示
|
||||
seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... 长期指标分组 key
|
||||
}
|
||||
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt,
|
||||
indicators: [Indicator] cascade,
|
||||
assets: [Asset] cascade }
|
||||
@Model class DiaryEntry { content, createdAt, tags: [String] }
|
||||
@Model class Symptom { name, startedAt, endedAt?, note?, severity 1-5, tags, createdAt }
|
||||
@Model class Asset { relativePath, mimeType, bytes, createdAt }
|
||||
@Model class ChatTurn { question, answer, referencedIndicatorIDs, referencedReportIDs, createdAt, decodeRate }
|
||||
|
||||
@Model class UserProfile { // 全 App 单例(UserProfileStore.loadOrCreate)
|
||||
birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw,
|
||||
allergies, chronicConditions, familyHistory, currentMedications,
|
||||
updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
**原图存储**: `Asset` 只存元数据 + 相对路径,真实 JPEG 落在 `Application Support/Vault/`,目录用 `.completeFileProtection`(iOS 硬件级加密,不要自己造 AES 轮子)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全 / 隐私(已收敛 — 不要扩展)
|
||||
|
||||
| 做 | 不做 |
|
||||
|---|---|
|
||||
| `Application Support/Vault/` 全目录 `.completeFileProtection` | 自实现 AES 加密 |
|
||||
| SwiftData store 文件 `.completeFileProtection` | |
|
||||
| Face ID 启动锁(可选开关,默认关) | |
|
||||
| 永久删除(SwiftData 硬删 + Asset 文件 unlink) | |
|
||||
| 离线运行(自然结果,不用单独做) | |
|
||||
| | 截屏黑屏防护(iOS 没有官方 API,不做) |
|
||||
| | 加密 ZIP 导出 |
|
||||
|
||||
唯一的"导出"是 **9.4 分享文字摘要**(只分享解读文本,不带原图)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 信息架构
|
||||
|
||||
```
|
||||
TabBar: [主页] [记录] [+ 新建] [趋势] [我的]
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于
|
||||
│ │ │ └─ 折线图 + AI 一句话解读
|
||||
│ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状
|
||||
│ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组)
|
||||
└─ 问候 + 今日摘要 + 进行中症状 + 最近时间线
|
||||
```
|
||||
|
||||
- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab
|
||||
- "+ 新建" 是 sheet 不是 Tab
|
||||
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
|
||||
- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势),
|
||||
下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入
|
||||
- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页
|
||||
|
||||
### 7.1 档案库 C1 / C2 导航(看的一半)
|
||||
|
||||
录入流程(拍照→VL→编辑→存)只是"录的一半"。**"看的一半"由 C1 列表 + C2 详情承担**——这是 demo 的核心看点之一,不能砍。
|
||||
|
||||
```
|
||||
首页 "我的报告档案" 卡 ──push──► C1 ArchiveListView
|
||||
│
|
||||
│ 分类 chip:全部/体检/化验/影像/处方
|
||||
│ 按 reportDate 年份分组,卡片显示异常 chip
|
||||
│
|
||||
└──push──► C2 ReportDetailView
|
||||
│
|
||||
├─ Tab "原图":TabView(.page) 翻页 + 长按保存
|
||||
├─ Tab "解读":数字摘要(总/高/低/正常)
|
||||
│ + AI 整体摘要
|
||||
│ + 对比上次(同类型上一份 Report diff)
|
||||
└─ Tab "指标":Indicator 列表,异常优先
|
||||
|
||||
C2 底部两个动作:
|
||||
├─ "关联到趋势" ──► 把本报告内未 pinned 的 Indicator 批量 pinned = true,Trends 默认展示
|
||||
└─ "重新解读" ──► CaptureService.reanalyze(report:),重跑 VL 覆盖 summary/indicators
|
||||
|
||||
其他进入 C2 的入口:
|
||||
• ChatTurn 引用 Pill 点击(referencedReportIDs)
|
||||
• 趋势页数据点 tap → 跳到该点来源 Report 的 C2
|
||||
• HomeView 时间线点报告类条目
|
||||
```
|
||||
|
||||
### 7.2 对比上次("对比上次"=报告对比,已加回)
|
||||
|
||||
C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算,不再调 LLM**:
|
||||
|
||||
- 找出"同 `typeRaw` 的上一份 Report"(`reportDate < current AND ORDER BY DESC LIMIT 1`)
|
||||
- 同名 `Indicator` 配对,数值 diff:`Δ` 绝对值 + 百分比 + 升/降箭头
|
||||
- 标红:跨越参考范围边界(原本正常→偏高,或反过来)
|
||||
- 文案模板拼装,不走 LLM,响应即时
|
||||
- 若无上一份,该区块隐藏
|
||||
|
||||
---
|
||||
|
||||
## 8. 现有代码状态(2026-05-25)
|
||||
|
||||
```
|
||||
康康/
|
||||
├── App/KangkangApp.swift ✅ SwiftData container 已建
|
||||
├── RootView.swift ✅ 3 Tab + RecordSheet 已建
|
||||
├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn
|
||||
├── DesignSystem/ ✅ Tokens + Components,沿用
|
||||
└── Features/
|
||||
├── Home/ ✅ HomeView 静态 UI,数据未接
|
||||
├── Quick/A1-A3 🔧 待合并进 UnifiedCaptureFlow
|
||||
├── Archive/B1-B4 🔧 B1 砍,B2 改用 VisionKit DocumentCamera
|
||||
├── Record/RecordSheet ✅ 入口选择 UI
|
||||
├── Trends/ ❌ 只有 placeholder
|
||||
└── Me/ ❌ 只有 placeholder
|
||||
|
||||
待建:
|
||||
├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore + TokenChunk ✅;VLSession + Prompts/ ❌
|
||||
├── Debug/DebugAIRunner.swift ✅ DEBUG-only AI 自检入口
|
||||
├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService
|
||||
├── Persistence/FileVault.swift ✅ 原图加密目录管理
|
||||
├── Security/AppLock.swift ❌ Face ID 启动锁
|
||||
├── Features/Ask/ ❌ AskSheet (RAG 问答 UI)
|
||||
├── Features/Archive/
|
||||
│ ├── ArchiveListView ❌ C1 档案列表(分类 chip + 年份分组)
|
||||
│ └── ReportDetailView ❌ C2 报告详情(三 Tab:原图/解读/指标 + 对比上次)
|
||||
├── Features/Capture/
|
||||
│ └── UnifiedCaptureFlow ❌ 替代 QuickCaptureFlow,状态机驱动 A1→VL→A2/B3
|
||||
├── Features/Onboarding/ ❌ 首启动隐私承诺 + 模型下载
|
||||
└── LiveActivity/ ❌ WidgetExtension target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 设计系统约束
|
||||
|
||||
- **不要新增颜色 token**。所有颜色走 `Tj.Palette.*` (sand / paper / ink / brick / leaf / line / text / text3)
|
||||
- **不要新增字体大小**。走 `Font.tjTitle()` / `tjH2()` / `tjSerifBody()` / 系统 size
|
||||
- **圆角走 `Tj.Radius.*`**,卡片走 `.tjCard()` modifier
|
||||
- 按钮走 `TjPrimaryButton` / `TjGhostButton`
|
||||
|
||||
新加 View 时先看 `DesignSystem/Components.swift`,有现成的不要复刻。
|
||||
|
||||
---
|
||||
|
||||
## 10. 不能跨越的红线
|
||||
|
||||
写代码前必读:
|
||||
|
||||
1. **不引入云服务**——任何 SDK 都不行,包括崩溃上报、分析、灰度
|
||||
2. **不自己实现密码学**——`.completeFileProtection` 已经够
|
||||
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
||||
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
||||
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
|
||||
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)
|
||||
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
||||
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
||||
|
||||
---
|
||||
|
||||
## 11. 6 周时间表
|
||||
|
||||
| 周次 | 必交付 |
|
||||
|---|---|
|
||||
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
|
||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
||||
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
||||
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
||||
| W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) |
|
||||
| W5 中 | **ReportCompareService** + C2 解读 Tab "对比上次" 区块 |
|
||||
| W5 末 | Face ID、永久删除、首页时间线接入真数据、Live Activity(真机) |
|
||||
| W6 | 模型管理页、首启动下载流程、UI polish、demo 视频、PPT |
|
||||
|
||||
**P0 新增项**(从 C 区视觉稿补回):C1 档案列表、C2 报告详情三 Tab、对比上次、关联到趋势、重新解读
|
||||
|
||||
**P1**(必须做完):Live Activity、分享文字摘要(9.4)、首启动隐私承诺页
|
||||
|
||||
**P2**(余力做):—— 任何 P2/P3 都暂时不做,清单里 11/12/13/14/15/17/18/19 全部 deferred(注意:16.1 报告对比已升 P0)
|
||||
|
||||
**砍 P1 决策顺序**(任何一周延期触发):Live Activity → Onboarding 简化 → 分享摘要 → 模型管理页 polish。**绝不动 C1/C2/对比上次**——视觉稿都做了,demo PPT 也要展示,这是核心卖点之一。
|
||||
|
||||
---
|
||||
|
||||
## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做)
|
||||
|
||||
1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意
|
||||
2. 100% 本地 + SME2 加速 — 技术亮点
|
||||
3. 本地 RAG 长期记忆 — 端侧不可替代性
|
||||
4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立
|
||||
5. AI 趋势解读 — 长期价值
|
||||
6. Live Activity 实时 tok/s — 现场记忆点
|
||||
|
||||
每写一个功能,问自己:这条提升了上面哪一项?如果都没有,就别做。
|
||||
103
CLAUDE.md
@@ -1,4 +1,4 @@
|
||||
# 康记 / 体己 —— 工程前提
|
||||
# 康康 —— 工程前提
|
||||
|
||||
> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。
|
||||
> 任何 IDE/Claude 会话开始干活前,先读这份文件。
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
- **名字**:康记(对内代号 体己 / Tiji)
|
||||
- **名字**:康康(对内代号 Kangkang)
|
||||
- **形态**:iOS 原生 App,SwiftUI + SwiftData
|
||||
- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答
|
||||
- **目标用户**:不愿把体检/化验报告交给云端的普通人
|
||||
@@ -22,9 +22,12 @@
|
||||
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
||||
| 持久化 | SwiftData | 见 §5 数据模型 |
|
||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
||||
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
|
||||
| LLM | Qwen3-1.7B (MLX 4bit 量化) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
|
||||
| VL | Qwen2.5-VL-3B (MLX 4bit 量化) | ~2.0GB,负责拍照→结构化指标 |
|
||||
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
|
||||
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
||||
| **统一模型(文本+视觉)** | **Qwen3.5-2B 多模态,一个模型全包** | 同一个 Qwen3.5-2B 同时做文本生成 / 关键词抽取 / 趋势解读 **和** 拍照→结构化指标。两种格式两种引擎,按设备选(见下两行)。代码:`ModelKind` |
|
||||
| ├ MNN 主(iPhone17+/SME2) | `taobao-mnn/Qwen3.5-2B-MNN`(~1.1GiB,含 `visual.mnn`) | 挑战赛考核路径,真机默认。文本 + 图→文都走它。`ModelKind.mnnLLM`,唯一对用户暴露(`userFacing`) |
|
||||
| └ MLX 兜底 / 模拟器 | `mlx-community/Qwen3.5-2B-4bit`(~1.7GB,多模态) | Metal GPU。走 `qwen3_5`,文本与 VL 复用同一模型。`ModelKind.llm`。4B 实测过慢已退回 2B |
|
||||
| ~~VL(独立)~~ | ~~`mlx-community/Qwen3-VL-4B-Instruct-4bit`~~ **已废弃** | MLX VL 已改复用统一 Qwen3.5-2B 多模态;`ModelKind.vl` 仅保留枚举避免动穷举 switch,不再下载/展示 |
|
||||
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||
| Face ID | LocalAuthentication | |
|
||||
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
||||
@@ -38,13 +41,13 @@
|
||||
### 3.1 模块边界(强制)
|
||||
|
||||
```
|
||||
UI → CaptureService / AskService / TrendService → AIRuntime → MLX
|
||||
UI → CaptureService / AskService / TrendService → AIRuntime → MNN(主) / MLX(兜底)
|
||||
↓
|
||||
Persistence
|
||||
```
|
||||
|
||||
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
|
||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务(`InferenceEngine` 选 MNN/SME2 主或 MLX/GPU 兜底,共享内存/显存,并发会 OOM)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
|
||||
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
||||
|
||||
### 3.2 VL pipeline(拍一张 = 一条流程)
|
||||
@@ -66,7 +69,7 @@ VL prompt 必须:
|
||||
### 3.3 RAG(结构化检索,不做 embedding)
|
||||
|
||||
**两段式调用**:
|
||||
1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||
1. 用统一 Qwen3.5-2B(MNN 主 / MLX 兜底)抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
||||
|
||||
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
||||
@@ -84,7 +87,7 @@ VL prompt 必须:
|
||||
## 4. 模型分发
|
||||
|
||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||
- 总体积 ~3GB,WiFi 提示必须有
|
||||
- **用户侧只下载统一模型 Qwen3.5-2B(MNN,~1.1GiB,含视觉)**——不再是 ~4GB 两模型。`ModelKind.userFacing = [.mnnLLM]`,「下载全部」/ 就绪计数只算它。MLX 兜底模型 `Qwen3.5-2B-4bit`(~1.7GB)仅模拟器 / 旁路导入用,不计入用户下载;`Qwen3-VL-4B` 已废弃,不再分发。WiFi 提示仍保留
|
||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||
|
||||
@@ -92,37 +95,27 @@ VL prompt 必须:
|
||||
|
||||
## 5. 数据模型(SwiftData)
|
||||
|
||||
现有 3 个 `@Model`,要新增 2 个:
|
||||
**当前 schema(2026-05-26)**:7 个 @Model。
|
||||
|
||||
```swift
|
||||
// 已有(在 Models/Models.swift)
|
||||
@Model class Indicator { name, value, unit, range, statusRaw, note, capturedAt }
|
||||
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt }
|
||||
@Model class DiaryEntry { content, createdAt }
|
||||
|
||||
// 待加字段
|
||||
// Indicator + report: Report? 反向关系
|
||||
// Indicator + asset: Asset? 关联原图
|
||||
// Indicator + pinned: Bool C2 "关联到趋势" 后置 true,Trends 默认展示 pinned 指标
|
||||
// Report + indicators: [Indicator] @Relationship cascade
|
||||
// Report + assets: [Asset] @Relationship cascade
|
||||
// DiaryEntry + tags: [String] VL/LLM 抽取的标签
|
||||
|
||||
// 待加 @Model
|
||||
@Model class Asset {
|
||||
var relativePath: String // 相对 Vault/ 的路径
|
||||
var mimeType: String
|
||||
var bytes: Int
|
||||
var createdAt: Date
|
||||
@Model class Indicator {
|
||||
name, value, unit, range, statusRaw, note, capturedAt,
|
||||
report: Report?, asset: Asset?,
|
||||
pinned: Bool, // 长期监测自动 true,Trends 默认展示
|
||||
seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... 长期指标分组 key
|
||||
}
|
||||
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt,
|
||||
indicators: [Indicator] cascade,
|
||||
assets: [Asset] cascade }
|
||||
@Model class DiaryEntry { content, createdAt, tags: [String] }
|
||||
@Model class Symptom { name, startedAt, endedAt?, note?, severity 1-5, tags, createdAt }
|
||||
@Model class Asset { relativePath, mimeType, bytes, createdAt }
|
||||
@Model class ChatTurn { question, answer, referencedIndicatorIDs, referencedReportIDs, createdAt, decodeRate }
|
||||
|
||||
@Model class ChatTurn {
|
||||
var question: String
|
||||
var answer: String
|
||||
var referencedIndicatorIDs: [String]
|
||||
var referencedReportIDs: [String]
|
||||
var createdAt: Date
|
||||
var decodeRate: Double // 该轮问答推理速度,Me 页性能展示
|
||||
@Model class UserProfile { // 全 App 单例(UserProfileStore.loadOrCreate)
|
||||
birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw,
|
||||
allergies, chronicConditions, familyHistory, currentMedications,
|
||||
updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,18 +142,21 @@ VL prompt 必须:
|
||||
## 7. 信息架构
|
||||
|
||||
```
|
||||
TabBar: [首页] [+ 记录] [趋势] [我的]
|
||||
│ │ │ │
|
||||
│ │ │ └─ 模型管理 / Face ID / 关于
|
||||
│ │ └─ 折线图 + AI 一句话解读
|
||||
│ └─ Modal: 选择 拍一张 / 写日记 / 问问看
|
||||
└─ 问候 + 今日摘要 + 时间线 + 影像档案入口
|
||||
TabBar: [主页] [记录] [+ 新建] [趋势] [我的]
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于
|
||||
│ │ │ └─ 折线图 + AI 一句话解读
|
||||
│ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状
|
||||
│ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组)
|
||||
└─ 问候 + 今日摘要 + 进行中症状 + 最近时间线
|
||||
```
|
||||
|
||||
- **3 Tab 不变**,中间 + 号是 Sheet
|
||||
- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab
|
||||
- "+ 新建" 是 sheet 不是 Tab
|
||||
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
|
||||
- "问问看"入口除了在 RecordSheet 里,首页摘要卡片下方也有一个常驻入口
|
||||
- 历史时间线在首页下半部分,不单独开 Tab
|
||||
- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势),
|
||||
下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入
|
||||
- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页
|
||||
|
||||
### 7.1 档案库 C1 / C2 导航(看的一半)
|
||||
|
||||
@@ -205,8 +201,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
## 8. 现有代码状态(2026-05-25)
|
||||
|
||||
```
|
||||
体己/
|
||||
├── App/TijiApp.swift ✅ SwiftData container 已建
|
||||
康康/
|
||||
├── App/KangkangApp.swift ✅ SwiftData container 已建
|
||||
├── RootView.swift ✅ 3 Tab + RecordSheet 已建
|
||||
├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn
|
||||
├── DesignSystem/ ✅ Tokens + Components,沿用
|
||||
@@ -219,9 +215,10 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
└── Me/ ❌ 只有 placeholder
|
||||
|
||||
待建:
|
||||
├── AI/ ❌ AIRuntime, LLMSession, VLSession, Prompts/
|
||||
├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore + TokenChunk ✅;VLSession + Prompts/ ❌
|
||||
├── Debug/DebugAIRunner.swift ✅ DEBUG-only AI 自检入口
|
||||
├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService
|
||||
├── Persistence/FileVault.swift ❌ 原图加密目录管理
|
||||
├── Persistence/FileVault.swift ✅ 原图加密目录管理
|
||||
├── Security/AppLock.swift ❌ Face ID 启动锁
|
||||
├── Features/Ask/ ❌ AskSheet (RAG 问答 UI)
|
||||
├── Features/Archive/
|
||||
@@ -255,7 +252,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
||||
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
||||
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
|
||||
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**例外**:报告对比(16.1)已加回,见 §7.2
|
||||
6. **新功能必须问"清单里有吗"**——清单外的功能(多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)、**用药提醒**(记录 · 用药记录点药 → 复用自由提醒 `CustomReminder` / `CustomReminderEditSheet`,只到点提示,**仍不给剂量/频次建议**,守 §1 "不做剂量推荐")
|
||||
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
||||
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
||||
|
||||
@@ -265,8 +262,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
|
||||
| 周次 | 必交付 |
|
||||
|---|---|
|
||||
| W1 末 / **W2 当前** | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
|
||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果) |
|
||||
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
|
||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
||||
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
||||
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
||||
| W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) |
|
||||
@@ -287,7 +284,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
||||
## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做)
|
||||
|
||||
1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意
|
||||
2. 100% 本地 + SME2 加速 — 技术亮点
|
||||
2. 100% 本地 + **MNN + Arm SME2 端侧 CPU 加速**(挑战赛考核点,MLX/GPU 兜底) — 技术亮点
|
||||
3. 本地 RAG 长期记忆 — 端侧不可替代性
|
||||
4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立
|
||||
5. AI 趋势解读 — 长期价值
|
||||
|
||||
11
KangkangWidget-src/KangkangWidgetBundle.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
/// KangkangWidget extension 入口。
|
||||
/// W5 做 Live Activity 时,把 ActivityConfiguration 也注册进这个 Bundle。
|
||||
@main
|
||||
struct KangkangWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
PinnedIndicatorsWidget()
|
||||
}
|
||||
}
|
||||
249
KangkangWidget-src/PinnedIndicatorsWidget.swift
Normal file
@@ -0,0 +1,249 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - 快照模型(主 App 的独立拷贝)
|
||||
//
|
||||
// ⚠️ 同步契约:与主 App `康康/Persistence/WidgetSnapshot.swift` 字段必须一致。
|
||||
// extension 不引主 App 代码(免去 target membership 配置),改字段时两边一起改。
|
||||
|
||||
private struct WidgetSnapshot: Codable, Equatable {
|
||||
struct Item: Codable, Equatable {
|
||||
var name: String
|
||||
var value: String
|
||||
var unit: String
|
||||
var statusRaw: String // high|low|normal
|
||||
var capturedAt: Date
|
||||
}
|
||||
|
||||
var updatedAt: Date
|
||||
var items: [Item]
|
||||
|
||||
static let appGroupID = "group.com.xuhuayong.kangkang"
|
||||
static let storeKey = "kk.widget.snapshot.v1"
|
||||
|
||||
static func load() -> WidgetSnapshot? {
|
||||
guard let defaults = UserDefaults(suiteName: appGroupID),
|
||||
let data = defaults.data(forKey: storeKey) else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 调色(镜像主 App Tj.Palette,extension 不引 DesignSystem)
|
||||
|
||||
private enum KkColor {
|
||||
static let sand = Color(red: 0.976, green: 0.969, blue: 0.949)
|
||||
static let ink = Color(red: 0.165, green: 0.153, blue: 0.137)
|
||||
static let text = Color(red: 0.149, green: 0.137, blue: 0.118)
|
||||
static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384)
|
||||
static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580)
|
||||
static let brick = Color(red: 0.886, green: 0.388, blue: 0.314) // high
|
||||
static let amber = Color(red: 0.871, green: 0.627, blue: 0.314) // low
|
||||
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) // normal
|
||||
}
|
||||
|
||||
private func statusColor(_ raw: String) -> Color {
|
||||
switch raw {
|
||||
case "high": return KkColor.brick
|
||||
case "low": return KkColor.amber
|
||||
default: return KkColor.leaf
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
private struct PinnedEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let items: [WidgetSnapshot.Item]
|
||||
let updatedAt: Date?
|
||||
}
|
||||
|
||||
private struct PinnedProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> PinnedEntry {
|
||||
PinnedEntry(date: .now, items: Self.sampleItems, updatedAt: .now)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (PinnedEntry) -> Void) {
|
||||
if context.isPreview {
|
||||
completion(placeholder(in: context))
|
||||
} else {
|
||||
completion(currentEntry())
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<PinnedEntry>) -> Void) {
|
||||
// 数据由主 App 写快照后 reloadAllTimelines 主动推;这里 30 分钟兜底刷一次
|
||||
// (只为让"x 天前"的相对时间不至于太陈旧)。
|
||||
let entry = currentEntry()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
|
||||
private func currentEntry() -> PinnedEntry {
|
||||
let snap = WidgetSnapshot.load()
|
||||
return PinnedEntry(date: .now, items: snap?.items ?? [], updatedAt: snap?.updatedAt)
|
||||
}
|
||||
|
||||
static let sampleItems: [WidgetSnapshot.Item] = [
|
||||
.init(name: "收缩压", value: "128", unit: "mmHg", statusRaw: "normal",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 5)),
|
||||
.init(name: "空腹血糖", value: "6.4", unit: "mmol/L", statusRaw: "high",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 30)),
|
||||
.init(name: "体重", value: "68.5", unit: "kg", statusRaw: "normal",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 50)),
|
||||
.init(name: "尿酸", value: "486", unit: "μmol/L", statusRaw: "high",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 80)),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private struct PinnedIndicatorsView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: PinnedEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.items.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
switch family {
|
||||
case .systemMedium: mediumView
|
||||
default: smallView
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) { KkColor.sand }
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
Text("在康康里关注指标后\n这里会显示最新值")
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
|
||||
/// 小尺寸:首条放大 + 其余最多 2 条小行。
|
||||
private var smallView: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
header
|
||||
if let first = entry.items.first {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(first.name)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||
Text(first.value)
|
||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(statusColor(first.statusRaw))
|
||||
Text(first.unit)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(entry.items.dropFirst().prefix(2), id: \.name) { item in
|
||||
compactRow(item)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
/// 中尺寸:两列网格,最多 6 条。
|
||||
private var mediumView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
|
||||
alignment: .leading, spacing: 8) {
|
||||
ForEach(entry.items.prefix(6), id: \.name) { item in
|
||||
gridCell(item)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text("康康 · 长期监测")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
Spacer()
|
||||
if let updatedAt = entry.updatedAt {
|
||||
Text(updatedAt, style: .relative)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func compactRow(_ item: WidgetSnapshot.Item) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor(item.statusRaw))
|
||||
.frame(width: 5, height: 5)
|
||||
Text(item.name)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 2)
|
||||
Text(item.value)
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(KkColor.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func gridCell(_ item: WidgetSnapshot.Item) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor(item.statusRaw))
|
||||
.frame(width: 5, height: 5)
|
||||
Text(item.name)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||
Text(item.value)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(KkColor.text)
|
||||
Text(item.unit)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct PinnedIndicatorsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "PinnedIndicatorsWidget", provider: PinnedProvider()) { entry in
|
||||
PinnedIndicatorsView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("长期监测")
|
||||
.description("展示你关注的健康指标最新值。数据 100% 在本机。")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("small", as: .systemSmall) {
|
||||
PinnedIndicatorsWidget()
|
||||
} timeline: {
|
||||
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
|
||||
}
|
||||
|
||||
#Preview("medium", as: .systemMedium) {
|
||||
PinnedIndicatorsWidget()
|
||||
} timeline: {
|
||||
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
|
||||
}
|
||||
52
docs/Widget接入步骤.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 桌面 Widget 接入步骤(约 3 分钟,Xcode 操作)
|
||||
|
||||
代码已全部写好。主 App 侧(快照写入 + RootView hook)已自动编译生效;
|
||||
Widget extension 需要你在 Xcode 里建一次 target,再放入两个源文件。
|
||||
|
||||
## 1. 创建 Widget Extension target
|
||||
|
||||
1. Xcode 打开 `康康.xcodeproj` → 菜单 **File → New → Target…**
|
||||
2. 选 **iOS → Widget Extension**,点 Next
|
||||
3. Product Name 填 **`KangkangWidget`**
|
||||
- ❌ 不勾 "Include Live Activity"(W5 做 Live Activity 时再往这个 target 里加,Bundle 入口已留好注释)
|
||||
- ❌ 不勾 "Include Configuration App Intent"(我们用 StaticConfiguration)
|
||||
4. 点 Finish;弹出 "Activate scheme?" 选 **Activate**
|
||||
|
||||
## 2. 替换模板代码
|
||||
|
||||
Xcode 会在工程根目录生成 `KangkangWidget/` 文件夹(含模板 swift 文件)。
|
||||
|
||||
1. 删除模板生成的所有 `.swift` 文件(`KangkangWidget.swift`、`KangkangWidgetBundle.swift`、`AppIntent.swift` 等,**保留 `Info.plist` 和 Assets**),选 "Move to Trash"
|
||||
2. 把 `KangkangWidget-src/` 里的两个文件拖进 Xcode 的 `KangkangWidget` 文件夹(勾选 target:KangkangWidget):
|
||||
- `KangkangWidgetBundle.swift`
|
||||
- `PinnedIndicatorsWidget.swift`
|
||||
3. 拖完后可删掉暂存目录 `KangkangWidget-src/`
|
||||
|
||||
## 3. 配置 App Group(两个 target 都要)
|
||||
|
||||
数据通过 App Group UserDefaults 传递,ID 固定为 **`group.com.xuhuayong.kangkang`**。
|
||||
|
||||
1. 选中工程 → target **康康** → Signing & Capabilities → **+ Capability → App Groups** → + 添加 `group.com.xuhuayong.kangkang`
|
||||
2. target **KangkangWidget** → 同样添加 App Groups → 勾选同一个 `group.com.xuhuayong.kangkang`
|
||||
3. KangkangWidget 的 **iOS Deployment Target 改成 17.0**(模板默认可能更高)
|
||||
|
||||
> 个人开发者账号下 App Group 会自动注册;如签名报错,在两个 target 的 Signing 里确认 Team 一致。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
1. scheme 切回 **康康**,跑真机/模拟器
|
||||
2. 进 App(首页出现即写入快照),回到桌面 → 长按 → 添加小组件 → 找 **康康 · 长期监测**
|
||||
3. 小/中两个尺寸都支持。没有任何 pinned 指标时显示引导文案;
|
||||
在趋势页关注指标(或 C2「关联到趋势」)后,回桌面即可看到最新值
|
||||
|
||||
## 故障排查
|
||||
|
||||
- **小组件空白/不出现**:先确认两个 target 的 App Group 勾的是同一个 ID;再确认主 App 至少前台打开过一次(快照由主 App 写)
|
||||
- **数据不更新**:快照在 App 进后台时刷新;强杀 App 不触发 `scenePhase == .background`,正常 Home 手势退出即可
|
||||
- **编译报 `containerBackground` 不存在**:KangkangWidget 的 Deployment Target 没改成 17.0
|
||||
|
||||
## 架构备忘(给后续会话)
|
||||
|
||||
- 主 App 写快照:`康康/Persistence/WidgetSnapshot.swift`(数据契约)+ `WidgetSnapshotRefresher.swift`(pinned 指标 → App Group,RootView 在启动和进后台时调用)
|
||||
- Widget 读快照:`KangkangWidget/PinnedIndicatorsWidget.swift` 内有 `WidgetSnapshot` 的**独立拷贝**(extension 不引主 App 代码)。⚠️ 改字段两边同步
|
||||
- Widget 不读 SwiftData:store 有文件保护且在主 App 沙盒,extension 锁屏时读不到;快照 = 最后一次看到的值,锁屏也能显示
|
||||
41
docs/design/AppIcon-source.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<filter id="wordShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#0f3f33" flood-opacity="0.42"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="1024" fill="#8ED9E4"/>
|
||||
<circle cx="748" cy="286" r="124" fill="#FFF1A8"/>
|
||||
<circle cx="748" cy="286" r="76" fill="#FFFFFF"/>
|
||||
|
||||
<path d="M0 426C210 350 404 377 592 500C731 592 875 608 1024 506V1024H0V426Z" fill="#2C7E79"/>
|
||||
<path d="M0 612C226 533 436 536 624 631C774 710 903 690 1024 580V1024H0V612Z" fill="#1F6761"/>
|
||||
<path d="M0 780C232 678 436 641 634 693C799 743 924 711 1024 594V1024H0V780Z" fill="#53A247"/>
|
||||
<path d="M0 888C232 807 447 780 656 825C812 863 931 825 1024 722V1024H0V888Z" fill="#82CC52"/>
|
||||
<path d="M318 1024C506 861 725 727 1024 604V1024H318Z" fill="#B2D95E"/>
|
||||
|
||||
<path
|
||||
d="M188 560H268L324 416L428 704L500 560H594"
|
||||
fill="none"
|
||||
stroke="#F4FFFC"
|
||||
stroke-width="36"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.96"/>
|
||||
|
||||
<text
|
||||
x="512"
|
||||
y="846"
|
||||
fill="#FFFFFF"
|
||||
stroke="#145D46"
|
||||
stroke-width="5"
|
||||
stroke-opacity="0.68"
|
||||
paint-order="stroke fill"
|
||||
font-family="Hiragino Sans GB, Songti SC, Helvetica Neue, Arial, sans-serif"
|
||||
font-size="136"
|
||||
font-weight="600"
|
||||
text-anchor="middle"
|
||||
letter-spacing="8"
|
||||
filter="url(#wordShadow)">康康</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
65
docs/legal/privacy-policy.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 康康KK 隐私政策
|
||||
|
||||
生效日期:2026-05-31
|
||||
|
||||
康康KK 是一款本地优先的个人健康记录工具。本政策说明我们如何处理你的信息。
|
||||
|
||||
## 我们收集的信息
|
||||
|
||||
康康KK 不要求注册账号,不内置广告 SDK,不使用第三方分析 SDK,也不会主动将你的健康记录上传到我们的服务器。
|
||||
|
||||
你可以在 App 内自行记录或导入以下信息:
|
||||
|
||||
- 健康指标,例如血压、血糖、血脂、体重等。
|
||||
- 体检、化验报告或其他健康资料照片。
|
||||
- 症状记录、健康日记和个人资料。
|
||||
- 本地提醒设置。
|
||||
|
||||
这些信息默认保存在你的设备本地。
|
||||
|
||||
## 权限用途
|
||||
|
||||
康康KK 可能请求以下系统权限:
|
||||
|
||||
- 相机:用于拍摄体检、化验报告或其他健康资料。
|
||||
- 相册:用于读取你主动选择导入的报告或照片。
|
||||
- Face ID:用于可选的本地 App 启动锁。
|
||||
- 通知:用于你主动设置的本地提醒。
|
||||
|
||||
我们不会因为这些权限而访问与你选择无关的内容。
|
||||
|
||||
## AI 模型下载
|
||||
|
||||
康康KK 的本地 AI 功能需要下载模型文件。下载模型时,App 会连接模型文件服务器获取模型资源。模型下载请求可能包含常规网络信息,例如 IP 地址、请求时间和设备网络环境产生的技术日志。
|
||||
|
||||
健康记录、报告照片、症状和日记不会因为下载模型而上传。
|
||||
|
||||
## 数据存储
|
||||
|
||||
健康记录和导入的资料默认保存在设备本地。App 使用 iOS 系统提供的文件保护能力保护本地文件。你可以在 App 内删除记录;删除后,相关本地数据会从 App 数据库或文件目录中移除。
|
||||
|
||||
如果你通过系统备份、迁移或其他第三方工具处理设备数据,相关行为受对应服务或工具的政策约束。
|
||||
|
||||
## 数据共享
|
||||
|
||||
康康KK 不出售个人数据,不将健康记录用于广告追踪,也不会与第三方广告或分析服务共享你的健康数据。
|
||||
|
||||
只有在你主动使用系统分享功能时,相关内容才会由你选择的系统分享目标处理。
|
||||
|
||||
## 医疗说明
|
||||
|
||||
康康KK 是健康信息记录与整理工具,并非医疗器械。App 内的 AI 解读、趋势分析或问答内容仅供日常记录参考,不构成医疗诊断、治疗建议、用药或剂量建议,也不能替代医生、药师或其他专业人员的意见。任何健康决策请咨询专业医疗人员。
|
||||
|
||||
## 儿童隐私
|
||||
|
||||
康康KK 不面向儿童提供专门服务。未成年人使用本 App 时应取得监护人同意。
|
||||
|
||||
## 联系我们
|
||||
|
||||
如果你对本隐私政策有疑问,可以通过以下邮箱联系我们:
|
||||
|
||||
xuhuayong@gmail.com
|
||||
|
||||
## 政策更新
|
||||
|
||||
我们可能会根据功能变化或法律要求更新本政策。更新后的政策会在 App 或公开页面中展示。
|
||||
107
docs/release/Google赛道-重定位与改造说明.md
Normal 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 秒,比任何文案都值钱。
|
||||
81
docs/release/app-store-metadata.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# App Store Metadata
|
||||
|
||||
## App Name
|
||||
|
||||
康康KK
|
||||
|
||||
## Subtitle
|
||||
|
||||
本地优先的个人健康档案
|
||||
|
||||
## Promotional Text
|
||||
|
||||
把体检报告、化验指标、症状和日记整理在本机。无需账号,健康数据默认不上传。
|
||||
|
||||
## Description
|
||||
|
||||
康康KK 是一款本地优先的个人健康记录工具,帮助你把体检报告、化验指标、症状、日记和趋势整理在同一个地方。
|
||||
|
||||
你可以手动记录常见健康指标,拍照归档体检或化验报告,在时间线里回顾每次记录,也可以把重点指标加入趋势页,查看长期变化。
|
||||
|
||||
主要功能:
|
||||
|
||||
- 健康指标记录:记录血压、血糖、血脂、体重等常见指标,也支持自定义指标。
|
||||
- 报告与照片归档:通过相机或相册导入体检、化验报告照片,保存到本机档案。
|
||||
- 症状与日记:记录身体感受、症状变化和就医前想补充的信息。
|
||||
- 趋势回顾:把长期关注的指标加入趋势页,查看变化曲线。
|
||||
- 本地优先:无需注册账号,健康记录默认保存在设备本地。
|
||||
- 可选本地 AI:下载模型后,可在设备本地辅助整理和通俗解释健康记录。
|
||||
|
||||
隐私与安全:
|
||||
|
||||
康康KK 不提供账号系统,不内置广告或第三方分析 SDK。健康数据默认保存在你的设备上。相机和相册权限仅用于导入你选择的报告或照片;Face ID 可用于本地 App 启动锁。
|
||||
|
||||
重要说明:
|
||||
|
||||
康康KK 是健康信息记录与整理工具,并非医疗器械。App 内的任何 AI 解读、趋势分析或问答内容仅供日常记录参考,不构成医疗诊断、治疗建议、用药或剂量建议,也不能替代医生、药师或其他专业人员的意见。任何健康决策请咨询专业医疗人员,并以原始报告和专业意见为准。
|
||||
|
||||
## Keywords
|
||||
|
||||
健康记录,体检报告,化验单,血压,血糖,健康档案,症状记录,健康日记,本地AI,隐私
|
||||
|
||||
## What's New
|
||||
|
||||
首次发布:支持健康指标、症状、日记和体检/化验报告的本地记录与趋势查看。
|
||||
|
||||
## Support URL
|
||||
|
||||
TODO: Add a public support URL before App Store submission.
|
||||
|
||||
## Privacy Policy URL
|
||||
|
||||
TODO: Add a public privacy policy URL before App Store submission.
|
||||
|
||||
## Category
|
||||
|
||||
Primary: Medical
|
||||
|
||||
Secondary: Health & Fitness
|
||||
|
||||
## Age Rating Notes
|
||||
|
||||
No gambling, no unrestricted web access, no user-generated public content, no commerce, no alcohol/tobacco/drug promotion, no medical treatment instructions. The app stores personal health records and includes medical disclaimers.
|
||||
|
||||
## App Review Notes
|
||||
|
||||
No login is required.
|
||||
|
||||
KangkangKK is a local-first personal health record app. It is not a medical device and does not provide diagnosis, treatment, medication, dosage, emergency triage, or doctor appointment services.
|
||||
|
||||
Suggested review steps:
|
||||
|
||||
1. Launch the app.
|
||||
2. Tap the center + button.
|
||||
3. Add a manual health metric, symptom, or diary entry.
|
||||
4. View saved entries in the Records tab.
|
||||
5. View charts in the Trends tab.
|
||||
6. Open Me > About to review privacy and medical disclaimer information.
|
||||
|
||||
Camera and photo library permissions are used only when the reviewer chooses to import photos of lab reports or health documents. The app stores user records locally on device.
|
||||
|
||||
AI features are optional. They require downloading local models from the Model Management page and may require a higher-memory device. If the models are not downloaded, the app will show a model-not-ready state; this is expected and does not block the core record-management flows.
|
||||
462
docs/release/build_creative_proposal_doc.py
Normal 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 原生 App,SwiftUI + SwiftData。"),
|
||||
("目标用户", "不愿把体检报告、化验单、症状和用药记录交给云端的普通用户。"),
|
||||
("核心主张", "健康数据默认留在手机里,本地模型负责整理、解释和检索。"),
|
||||
("技术主线", "Qwen3.5-2B + MNN + Arm SME2/NEON,MLX 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.swift:MNN/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 三种偏好。", "真机优先 MNN,MNN 不可用时自动回退 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, "隐私保护:报告原图只保存到本地 Vault,SwiftData 存结构化记录;使用 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()
|
||||
BIN
docs/release/pics/01-字体大小设置.png
Normal file
|
After Width: | Height: | Size: 842 KiB |
BIN
docs/release/pics/02-Xcode开发调试界面.png
Normal file
|
After Width: | Height: | Size: 955 KiB |
BIN
docs/release/pics/03-健康日记-语音输入键盘.jpg
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
docs/release/pics/04-关于页面-项目说明.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/release/pics/05-字体大小设置-副本.png
Normal file
|
After Width: | Height: | Size: 842 KiB |
BIN
docs/release/pics/06-推理引擎-MNN-SME2性能自检.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/release/pics/07-模型管理-Qwen已就绪.png
Normal file
|
After Width: | Height: | Size: 556 KiB |
BIN
docs/release/pics/08-提醒列表-服药提醒.png
Normal file
|
After Width: | Height: | Size: 437 KiB |
BIN
docs/release/pics/09-趋势详情-体重AI解读.png
Normal file
|
After Width: | Height: | Size: 737 KiB |
BIN
docs/release/pics/10-身体档案-已知背景与指标趋势.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
docs/release/pics/11-身体档案-就诊摘要生成结果.png
Normal file
|
After Width: | Height: | Size: 357 KiB |
BIN
docs/release/pics/12-药品库-编辑药品.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/release/pics/13-记录指标-长期监测预设.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
docs/release/pics/14-健康日记-AI追问补充.png
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
docs/release/pics/15-健康日记-本地AI整理中.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
docs/release/pics/16-用药记录-记录用药表单.png
Normal file
|
After Width: | Height: | Size: 587 KiB |
BIN
docs/release/pics/17-新建菜单-记录入口列表.png
Normal file
|
After Width: | Height: | Size: 523 KiB |
BIN
docs/release/pics/18-症状开始-常见症状表单.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
docs/release/pics/19-报告归档-核对报告信息.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/release/pics/20-药品识别-核对药品.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
docs/release/pics/21-首页-健康日历趋势提醒.png
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
docs/release/pics/22-框选异常指标-本地识别中.png
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
83
docs/release/sensevoice-integration.md
Normal 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 层不动。
|
||||
BIN
docs/release/xhs-9grid/00-9宫格总览.png
Normal file
|
After Width: | Height: | Size: 489 KiB |
BIN
docs/release/xhs-9grid/01-封面-本地健康AI档案.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
docs/release/xhs-9grid/02-拍报告自动变档案.png
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
docs/release/xhs-9grid/03-Qwen-MNN-SME2性能自检.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/release/xhs-9grid/04-身体档案就诊摘要.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
docs/release/xhs-9grid/05-记录指标到趋势解读.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
docs/release/xhs-9grid/06-药盒识别入药品库.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
docs/release/xhs-9grid/07-语音日记AI追问.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
docs/release/xhs-9grid/08-记录入口和症状追踪.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
docs/release/xhs-9grid/09-开发记录端侧推理.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
137
docs/release/小红书文案.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 康康 · 小红书发布文案(比赛评审用)
|
||||
|
||||
> 使用说明:
|
||||
> - `◻︎` 处填真机实测数字(打开 我的 → 模型管理 → 性能自检,截图同时把数字抄进来)
|
||||
> - `#比赛官方话题#` 和 `@官方账号` 替换成组委会指定的话题和账号(评审通常按官方话题检索作品,**漏带话题可能查不到你的帖子**)
|
||||
> - 主推版做主帖;技术版可隔 2~3 天发第二篇,小红书对"同一项目多角度连发"权重友好
|
||||
> - 发布时间建议:工作日 12:00–13:30 或 20:00–22: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. 主帖发出后把链接填进比赛报名系统/问卷(如果章程要求回填链接)
|
||||
86
docs/release/小红书长文-Google赛道版.md
Normal 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 生成。
|
||||
318
docs/release/小红书长文-项目介绍.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# 康康 · 小红书长文正文与 9 宫格方案
|
||||
|
||||
> 参考风格:「Day5|30小时,用 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 #独立开发 #数字健康
|
||||
BIN
docs/release/康康创意方案文档.docx
Normal file
41
docs/research/mnn-kv-cache-prefix.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# MNN 前缀 KV Cache 调研(2026-06-10)
|
||||
|
||||
## 结论
|
||||
|
||||
当前打包的 MNN.xcframework 已暴露 prefix cache 能力,技术上可以把每个场景**固定的
|
||||
system prompt + few-shot 模板**的 prefill 结果缓存到磁盘,二次调用跳过这部分 prefill。
|
||||
**建议 W6 polish 阶段、用性能自检卡量化 prefill 占比之后再决定是否接入**;当前瓶颈在
|
||||
decode 而非二次 prefill,优先级低于 C1/C2/Live Activity。
|
||||
|
||||
## 依据(`Frameworks/MNN.xcframework/ios-arm64/MNN.framework/Headers/llm/llm.hpp`)
|
||||
|
||||
| API | 行号 | 含义 |
|
||||
|---|---|---|
|
||||
| `bool setPrefixCacheFile(const std::string& filename, int flag = 0)` | :161 | 指定前缀缓存文件;配套私有成员 `mPrefixCacheMode` / `mPrefixLength` / `mIsPrefixFileExist` / `completePrefixWrite()`(:250-255)印证:命中时 prefill 只算增量部分 |
|
||||
| `bool reuse_kv()` | :171 | 读 config 开关 `reuse_kv`,多轮对话内复用 KV(同一会话增量 prefill) |
|
||||
| `void syncPromptCache(const ChatMessages&)` | :176 | decode 结束后同步缓存文本——注释明确说明 cache 在 generate() 后自更新,此接口供做过后处理(如 deleteThinkPart)的调用方提供更准确版本 |
|
||||
| `void setKVCacheInfo(size_t add, size_t remove, ...)` / `eraseHistory(begin, end)` | :158-160 | 更底层的 KV 区间管理,可做部分历史擦除 |
|
||||
|
||||
## 对本项目的适用性
|
||||
|
||||
- 我们所有调用都是「固定模板前缀 + 可变数据后缀」的单轮 `response()`,与 prefix cache
|
||||
的模型吻合。
|
||||
- 模板体量(估):报告识别 ~900 tok、导出报告 ~700 tok、意图抽取 ~300 tok。
|
||||
按性能自检卡实测的 prefill 速率推算,每次调用预计省 **1~3s**。
|
||||
- 多场景共用一个 cache 文件是否支持多前缀未知;最坏情况只对单一场景(建议选「报告识别」,
|
||||
模板最长、调用最频繁)生效。
|
||||
|
||||
## 风险
|
||||
|
||||
1. `flag` 参数语义在头文件无注释,需读 MNN 源码或实验确认。
|
||||
2. OMNI(多模态)分支下行为未验证——我们的 MNN 模型是 Omni 构建。
|
||||
3. cache 文件与模型权重版本绑定:模型更新/重下载后必须失效,否则可能输出乱码。
|
||||
4. `<img>` 标签在 prompt 前部(`analyzeImages` 把图片标签拼在最前),意味着报告识别场景的
|
||||
"固定前缀" 实际不固定 —— **文本场景(导出/意图抽取)才是干净的 prefix cache 候选**。
|
||||
|
||||
## 建议的接入步骤(W6,如性能自检显示 prefill 占比 >30%)
|
||||
|
||||
1. `MNNLLMBridge` init 后调 `setPrefixCacheFile(<AppSupport>/mnn-prefix.cache)`(仅文本场景)。
|
||||
2. 真机 A/B:同一导出报告各跑 3 次,对比 `LlmContext.prefill_us`。
|
||||
3. 异常处理:加载失败或输出劣化时删除 cache 文件并禁用,回退现状。
|
||||
4. `ModelDownloadService.importModel` / 重下载路径上顺手删除旧 cache 文件。
|
||||
113
docs/superpowers/notes/2026-05-25-mlx-api-corrections.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# MLX-Swift-Examples API 核对(2026-05-25)
|
||||
|
||||
研究产出来源:`https://github.com/ml-explore/mlx-swift-examples` tag `2.29.1`,commit `9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631`。
|
||||
|
||||
W2 plan Task 6 的 LLMSession 草稿与真实 API 有 4 处偏差,**Task 6 必须用本文修正版,不要回头读 plan 里的草稿**。
|
||||
|
||||
## 关键修正
|
||||
|
||||
| 项 | 草稿 | 真实 API |
|
||||
|---|---|---|
|
||||
| `ModelConfiguration(directory:)` | ✓ | ✓ 一致 |
|
||||
| `LLMModelFactory.shared.loadContainer(configuration:)` | ✓ | ✓ 一致(`hub` / `progressHandler` 有默认值) |
|
||||
| `container.perform { context in ... }` | 未类型化 | context 是 `ModelContext` struct(具体类型);`processor: any UserInputProcessor` |
|
||||
| `MLXLMCommon.generate(...)` 调用语义 | `try MLXLMCommon.generate(...)` 后内部 `for await` | **同上,只需 `try`(无 `await`)**;**返回 `AsyncStream<Generation>`(非 throwing)** |
|
||||
| `Generation` 枚举 case | 只列了 `.chunk(String)` 和 `.info(...)` | **还有 `.toolCall(ToolCall)`,switch 必须穷举** |
|
||||
| `GenerateParameters` | 只传 `temperature / topP`,`maxTokens` 在草稿用 `produced >= maxTokens break` 控制 | **`maxTokens` 必须传 GenerateParameters**;`temperature` / `topP` 是 `Float` 不是 `Double` |
|
||||
| 取消 | 草稿没处理 | **必须** `continuation.onTermination = { _ in task.cancel() }` |
|
||||
| `UserInput` 构造 | `LMInput.init(prompt:)` | `UserInput(prompt: prompt)` → `context.processor.prepare(input: userInput)` → `LMInput` |
|
||||
|
||||
## 修正版 LLMSession.swift(Task 6 直接抄)
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import MLX
|
||||
import MLXLLM
|
||||
import MLXLMCommon
|
||||
|
||||
actor LLMSession {
|
||||
let container: ModelContainer
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.container = container
|
||||
}
|
||||
|
||||
static func load(folderURL: URL) async throws -> LLMSession {
|
||||
let configuration = ModelConfiguration(directory: folderURL)
|
||||
let container = try await LLMModelFactory.shared.loadContainer(
|
||||
configuration: configuration
|
||||
)
|
||||
return LLMSession(container: container)
|
||||
}
|
||||
|
||||
/// 流式生成。返回的 AsyncThrowingStream 取消时,内部 Task 也会取消。
|
||||
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
let parameters = GenerateParameters(
|
||||
maxTokens: maxTokens,
|
||||
temperature: Float(0.6),
|
||||
topP: Float(0.9)
|
||||
)
|
||||
|
||||
try await container.perform { (context: ModelContext) in
|
||||
let userInput = UserInput(prompt: prompt)
|
||||
let lmInput = try await context.processor.prepare(input: userInput)
|
||||
|
||||
let start = Date()
|
||||
var produced = 0
|
||||
|
||||
for await event in try MLXLMCommon.generate(
|
||||
input: lmInput,
|
||||
parameters: parameters,
|
||||
context: context
|
||||
) {
|
||||
if Task.isCancelled { break }
|
||||
|
||||
switch event {
|
||||
case .chunk(let text):
|
||||
produced += 1
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||
|
||||
case .info:
|
||||
// 生成完成统计,是流的最后一个事件
|
||||
break
|
||||
|
||||
case .toolCall:
|
||||
// 纯文本生成不会触发,switch 穷举
|
||||
break
|
||||
}
|
||||
}
|
||||
MLX.GPU.synchronize()
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 与 AIRuntime 的对接
|
||||
|
||||
`AIRuntime.swift`(W2-T5 提交的 `4dcd951` + `e7cdb45`)已经预设:
|
||||
|
||||
```swift
|
||||
let session = try await LLMSession.load(folderURL: ModelStore.shared.localURL(for: .llm))
|
||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
```
|
||||
|
||||
签名匹配,Task 6 不改 AIRuntime。
|
||||
|
||||
## 真实模型 HF 仓库名
|
||||
|
||||
- LLM: `mlx-community/Qwen3-1.7B-4bit`(沙盒目录:`Qwen3-1.7B-4bit`)
|
||||
- VL: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`(沙盒目录:`Qwen2.5-VL-3B-Instruct-4bit`)
|
||||
|
||||
注:plan 文档 Task 6 里写的是带 "MLX-" 中缀的旧名,**已弃用**。ModelKind rawValue 已在 commit `771b28e` 修正。
|
||||
@@ -18,23 +18,23 @@
|
||||
|
||||
| 路径 | 职责 |
|
||||
|---|---|
|
||||
| `体己/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate |
|
||||
| `体己/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 |
|
||||
| `体己/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 |
|
||||
| `体己/AI/TokenChunk.swift` | 流式数据结构 |
|
||||
| `体己/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 |
|
||||
| `体己/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 |
|
||||
| `体己Tests/FileVaultTests.swift` | FileVault 单元测试 |
|
||||
| `体己Tests/ModelStoreTests.swift` | ModelStore 单元测试 |
|
||||
| `康康/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate |
|
||||
| `康康/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 |
|
||||
| `康康/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 |
|
||||
| `康康/AI/TokenChunk.swift` | 流式数据结构 |
|
||||
| `康康/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 |
|
||||
| `康康/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 |
|
||||
| `康康Tests/FileVaultTests.swift` | FileVault 单元测试 |
|
||||
| `康康Tests/ModelStoreTests.swift` | ModelStore 单元测试 |
|
||||
|
||||
### 修改
|
||||
|
||||
| 路径 | 改什么 |
|
||||
|---|---|
|
||||
| `体己/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags |
|
||||
| `体己/App/TijiApp.swift` | Schema 加入两个新 @Model |
|
||||
| `体己/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner |
|
||||
| `体己.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples |
|
||||
| `康康/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags |
|
||||
| `康康/App/KangkangApp.swift` | Schema 加入两个新 @Model |
|
||||
| `康康/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner |
|
||||
| `康康.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples |
|
||||
|
||||
### 不动(W2 不碰)
|
||||
|
||||
@@ -45,15 +45,15 @@
|
||||
## Task 1:Xcode 项目加入 MLX Swift SPM 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `体己.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编)
|
||||
- Modify: `康康.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编)
|
||||
|
||||
- [ ] **Step 1:打开 Xcode 项目**
|
||||
- [x] **Step 1:打开 Xcode 项目**
|
||||
|
||||
```bash
|
||||
open /Users/xuhuayong/apps/体己/体己.xcodeproj
|
||||
open /Users/xuhuayong/apps/康康/康康.xcodeproj
|
||||
```
|
||||
|
||||
- [ ] **Step 2:加入 MLX Swift 依赖**
|
||||
- [x] **Step 2:加入 MLX Swift 依赖**
|
||||
|
||||
在 Xcode → File → Add Package Dependencies → 输入 URL:
|
||||
|
||||
@@ -61,14 +61,14 @@ open /Users/xuhuayong/apps/体己/体己.xcodeproj
|
||||
https://github.com/ml-explore/mlx-swift
|
||||
```
|
||||
|
||||
选 "Up to Next Major" → 添加,勾选这些 product 加到 **体己** target:
|
||||
选 "Up to Next Major" → 添加,勾选这些 product 加到 **康康** target:
|
||||
- `MLX`
|
||||
- `MLXFast`
|
||||
- `MLXNN`
|
||||
- `MLXOptimizers`
|
||||
- `MLXRandom`
|
||||
|
||||
- [ ] **Step 3:加入 mlx-swift-examples(含 LLM 工具)**
|
||||
- [x] **Step 3:加入 mlx-swift-examples(含 LLM 工具)**
|
||||
|
||||
继续 Add Package Dependencies,URL:
|
||||
|
||||
@@ -76,25 +76,25 @@ https://github.com/ml-explore/mlx-swift
|
||||
https://github.com/ml-explore/mlx-swift-examples
|
||||
```
|
||||
|
||||
勾选 `MLXLLM` 和 `MLXLMCommon` 加到 **体己** target。
|
||||
勾选 `MLXLLM` 和 `MLXLMCommon` 加到 **康康** target。
|
||||
|
||||
- [ ] **Step 4:确认 Build Settings**
|
||||
- [x] **Step 4:确认 Build Settings**
|
||||
|
||||
Xcode → 体己 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
|
||||
Xcode → 康康 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
|
||||
|
||||
体己 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
|
||||
康康 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
|
||||
|
||||
- [ ] **Step 5:Build 验证**
|
||||
- [x] **Step 5:Build 验证**
|
||||
|
||||
Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。
|
||||
|
||||
Expected:Build Succeeded,无依赖错误。
|
||||
|
||||
- [ ] **Step 6:提交**
|
||||
- [x] **Step 6:提交**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/体己
|
||||
git add 体己.xcodeproj
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
git add 康康.xcodeproj
|
||||
git commit -m "build: add MLX Swift SPM dependencies"
|
||||
```
|
||||
|
||||
@@ -103,11 +103,11 @@ git commit -m "build: add MLX Swift SPM dependencies"
|
||||
## Task 2:扩展 Models.swift —— Asset 与 ChatTurn
|
||||
|
||||
**Files:**
|
||||
- Modify: `体己/Models/Models.swift`(全文重写)
|
||||
- Modify: `康康/Models/Models.swift`(全文重写)
|
||||
|
||||
- [ ] **Step 1:把 Models.swift 替换为新内容**
|
||||
- [x] **Step 1:把 Models.swift 替换为新内容**
|
||||
|
||||
打开 `体己/Models/Models.swift`,**整文件替换**为:
|
||||
打开 `康康/Models/Models.swift`,**整文件替换**为:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
@@ -268,9 +268,9 @@ final class ChatTurn {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:更新 TijiApp.swift Schema**
|
||||
- [x] **Step 2:更新 KangkangApp.swift Schema**
|
||||
|
||||
打开 `体己/App/TijiApp.swift`,替换 Schema 数组:
|
||||
打开 `康康/App/KangkangApp.swift`,替换 Schema 数组:
|
||||
|
||||
```swift
|
||||
let schema = Schema([
|
||||
@@ -282,7 +282,7 @@ let schema = Schema([
|
||||
])
|
||||
```
|
||||
|
||||
- [ ] **Step 3:删模拟器沙盒(破坏性迁移)**
|
||||
- [x] **Step 3:删模拟器沙盒(破坏性迁移)**
|
||||
|
||||
在 Mac 上:
|
||||
|
||||
@@ -293,16 +293,16 @@ xcrun simctl erase all
|
||||
|
||||
(也可以在 Simulator → Device → Erase All Content and Settings)
|
||||
|
||||
- [ ] **Step 4:Build & Run 验证**
|
||||
- [x] **Step 4:Build & Run 验证**
|
||||
|
||||
Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。
|
||||
|
||||
Expected:App 启动到 RootView,无 fatalError。
|
||||
|
||||
- [ ] **Step 5:提交**
|
||||
- [x] **Step 5:提交**
|
||||
|
||||
```bash
|
||||
git add 体己/Models/Models.swift 体己/App/TijiApp.swift
|
||||
git add 康康/Models/Models.swift 康康/App/KangkangApp.swift
|
||||
git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag"
|
||||
```
|
||||
|
||||
@@ -311,17 +311,17 @@ git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship,
|
||||
## Task 3:FileVault —— 加密目录读写(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `体己/Persistence/FileVault.swift`
|
||||
- Test: `体己Tests/FileVaultTests.swift`
|
||||
- Create: `康康/Persistence/FileVault.swift`
|
||||
- Test: `康康Tests/FileVaultTests.swift`
|
||||
|
||||
- [ ] **Step 1:写失败的测试**
|
||||
- [x] **Step 1:写失败的测试**
|
||||
|
||||
创建 `体己Tests/FileVaultTests.swift`:
|
||||
创建 `康康Tests/FileVaultTests.swift`:
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import 体己
|
||||
@testable import 康康
|
||||
|
||||
@MainActor
|
||||
struct FileVaultTests {
|
||||
@@ -369,15 +369,15 @@ struct FileVaultTests {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:运行测试,确认 fail**
|
||||
- [x] **Step 2:运行测试,确认 fail**
|
||||
|
||||
Xcode ⌘U 跑测试(在模拟器上跑)。
|
||||
|
||||
Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。
|
||||
|
||||
- [ ] **Step 3:写最小 FileVault 实现**
|
||||
- [x] **Step 3:写最小 FileVault 实现**
|
||||
|
||||
创建 `体己/Persistence/FileVault.swift`:
|
||||
创建 `康康/Persistence/FileVault.swift`:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
@@ -454,22 +454,22 @@ final class FileVault {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4:把 FileVault.swift 加入 体己 target**
|
||||
- [x] **Step 4:把 FileVault.swift 加入 康康 target**
|
||||
|
||||
Xcode 右键 `体己/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "体己"。
|
||||
Xcode 右键 `康康/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "康康"。
|
||||
|
||||
把 FileVaultTests.swift 拖进 体己Tests target,确认 Target Membership 勾选 "体己Tests"。
|
||||
把 FileVaultTests.swift 拖进 康康Tests target,确认 Target Membership 勾选 "康康Tests"。
|
||||
|
||||
- [ ] **Step 5:跑测试,确认全 pass**
|
||||
- [x] **Step 5:跑测试,确认全 pass**
|
||||
|
||||
Xcode ⌘U。
|
||||
|
||||
Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。
|
||||
|
||||
- [ ] **Step 6:提交**
|
||||
- [x] **Step 6:提交**
|
||||
|
||||
```bash
|
||||
git add 体己/Persistence/FileVault.swift 体己Tests/FileVaultTests.swift 体己.xcodeproj
|
||||
git add 康康/Persistence/FileVault.swift 康康Tests/FileVaultTests.swift 康康.xcodeproj
|
||||
git commit -m "feat(persistence): add FileVault with complete file protection"
|
||||
```
|
||||
|
||||
@@ -478,17 +478,17 @@ git commit -m "feat(persistence): add FileVault with complete file protection"
|
||||
## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `体己/AI/ModelStore.swift`
|
||||
- Test: `体己Tests/ModelStoreTests.swift`
|
||||
- Create: `康康/AI/ModelStore.swift`
|
||||
- Test: `康康Tests/ModelStoreTests.swift`
|
||||
|
||||
- [ ] **Step 1:写失败的测试**
|
||||
- [x] **Step 1:写失败的测试**
|
||||
|
||||
创建 `体己Tests/ModelStoreTests.swift`:
|
||||
创建 `康康Tests/ModelStoreTests.swift`:
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import 体己
|
||||
@testable import 康康
|
||||
|
||||
@MainActor
|
||||
struct ModelStoreTests {
|
||||
@@ -531,13 +531,13 @@ struct ModelStoreTests {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:运行测试,确认 fail**
|
||||
- [x] **Step 2:运行测试,确认 fail**
|
||||
|
||||
⌘U → expect `Cannot find 'ModelStore'`.
|
||||
|
||||
- [ ] **Step 3:写 ModelStore 实现**
|
||||
- [x] **Step 3:写 ModelStore 实现**
|
||||
|
||||
创建 `体己/AI/ModelStore.swift`:
|
||||
创建 `康康/AI/ModelStore.swift`:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
@@ -619,21 +619,21 @@ final class ModelStore {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4:Xcode 中把文件加入 target**
|
||||
- [x] **Step 4:Xcode 中把文件加入 target**
|
||||
|
||||
右键 `体己/` → New Group "AI" → 拖入 ModelStore.swift,勾 "体己" target。
|
||||
ModelStoreTests.swift 拖入 体己Tests target。
|
||||
右键 `康康/` → New Group "AI" → 拖入 ModelStore.swift,勾 "康康" target。
|
||||
ModelStoreTests.swift 拖入 康康Tests target。
|
||||
|
||||
- [ ] **Step 5:跑测试,全绿**
|
||||
- [x] **Step 5:跑测试,全绿**
|
||||
|
||||
⌘U。
|
||||
|
||||
Expected:3 个测试全 pass。
|
||||
|
||||
- [ ] **Step 6:提交**
|
||||
- [x] **Step 6:提交**
|
||||
|
||||
```bash
|
||||
git add 体己/AI/ModelStore.swift 体己Tests/ModelStoreTests.swift 体己.xcodeproj
|
||||
git add 康康/AI/ModelStore.swift 康康Tests/ModelStoreTests.swift 康康.xcodeproj
|
||||
git commit -m "feat(ai): add ModelStore with path management and bundle seed"
|
||||
```
|
||||
|
||||
@@ -642,12 +642,12 @@ git commit -m "feat(ai): add ModelStore with path management and bundle seed"
|
||||
## Task 5:TokenChunk + AIRuntime actor 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `体己/AI/TokenChunk.swift`
|
||||
- Create: `体己/AI/AIRuntime.swift`
|
||||
- Create: `康康/AI/TokenChunk.swift`
|
||||
- Create: `康康/AI/AIRuntime.swift`
|
||||
|
||||
本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。
|
||||
|
||||
- [ ] **Step 1:创建 TokenChunk.swift**
|
||||
- [x] **Step 1:创建 TokenChunk.swift**
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
@@ -658,7 +658,7 @@ struct TokenChunk: Sendable {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:创建 AIRuntime.swift 骨架**
|
||||
- [x] **Step 2:创建 AIRuntime.swift 骨架**
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
@@ -754,19 +754,19 @@ actor AIRuntime {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:确认 Build 失败原因合理**
|
||||
- [x] **Step 3:确认 Build 失败原因合理**
|
||||
|
||||
⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。
|
||||
|
||||
这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。
|
||||
|
||||
- [ ] **Step 4:把文件加入 target**
|
||||
- [x] **Step 4:把文件加入 target**
|
||||
|
||||
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "体己" target。
|
||||
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "康康" target。
|
||||
|
||||
(此时 Build 还是失败,正常)
|
||||
|
||||
- [ ] **Step 5:暂不提交**
|
||||
- [x] **Step 5:暂不提交**
|
||||
|
||||
等 Task 6 完成、Build 通过后一起提交。
|
||||
|
||||
@@ -775,7 +775,7 @@ actor AIRuntime {
|
||||
## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B
|
||||
|
||||
**Files:**
|
||||
- Create: `体己/AI/LLMSession.swift`
|
||||
- Create: `康康/AI/LLMSession.swift`
|
||||
|
||||
**预先准备(开发者手动一次)**:
|
||||
|
||||
@@ -785,7 +785,7 @@ actor AIRuntime {
|
||||
|
||||
具体路径在 App 启动时打印,见 Step 5。
|
||||
|
||||
- [ ] **Step 1:在终端下载模型(脚本一次性)**
|
||||
- [x] **Step 1:在终端下载模型(脚本一次性)**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/tiji-models && cd ~/tiji-models
|
||||
@@ -796,9 +796,9 @@ huggingface-cli download mlx-community/Qwen3-1.7B-MLX-4bit \
|
||||
|
||||
Expected:目录里有 `config.json` / `model.safetensors` / `tokenizer.json` 等。
|
||||
|
||||
- [ ] **Step 2:写 LLMSession 实现**
|
||||
- [x] **Step 2:写 LLMSession 实现**
|
||||
|
||||
创建 `体己/AI/LLMSession.swift`:
|
||||
创建 `康康/AI/LLMSession.swift`:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
@@ -866,11 +866,11 @@ actor LLMSession {
|
||||
|
||||
> **注**:`MLXLMCommon` 的具体 API 版本可能在 GenerateParameters/stream 处略有差异。如果 Step 4 编译报错,查看 mlx-swift-examples 仓库 `Libraries/MLXLLM` 的最新示例,以仓库示例为准小幅调整。
|
||||
|
||||
- [ ] **Step 3:把 LLMSession.swift 加入 体己 target**
|
||||
- [x] **Step 3:把 LLMSession.swift 加入 康康 target**
|
||||
|
||||
拖入 AI group,确认 Target Membership。
|
||||
|
||||
- [ ] **Step 4:Build,期望成功**
|
||||
- [x] **Step 4:Build,期望成功**
|
||||
|
||||
⌘B。
|
||||
|
||||
@@ -878,9 +878,9 @@ Expected:Build Succeeded。
|
||||
|
||||
若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。
|
||||
|
||||
- [ ] **Step 5:在 TijiApp 启动时打印沙盒路径(临时调试)**
|
||||
- [x] **Step 5:在 KangkangApp 启动时打印沙盒路径(临时调试)**
|
||||
|
||||
打开 `体己/App/TijiApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`:
|
||||
打开 `康康/App/KangkangApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`:
|
||||
|
||||
```swift
|
||||
.onAppear {
|
||||
@@ -901,7 +901,7 @@ Expected:Build Succeeded。
|
||||
📁 App Support: /Users/.../data/Containers/Data/Application/<UUID>/Library/Application Support
|
||||
```
|
||||
|
||||
- [ ] **Step 6:把模型拷到沙盒**
|
||||
- [x] **Step 6:把模型拷到沙盒**
|
||||
|
||||
```bash
|
||||
APP_SUPPORT="<上面控制台打印的路径>"
|
||||
@@ -909,10 +909,10 @@ mkdir -p "$APP_SUPPORT/Models"
|
||||
cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/"
|
||||
```
|
||||
|
||||
- [ ] **Step 7:提交(本任务 + Task 5 一起)**
|
||||
- [x] **Step 7:提交(本任务 + Task 5 一起)**
|
||||
|
||||
```bash
|
||||
git add 体己/AI/ 体己/App/TijiApp.swift 体己.xcodeproj
|
||||
git add 康康/AI/ 康康/App/KangkangApp.swift 康康.xcodeproj
|
||||
git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
|
||||
```
|
||||
|
||||
@@ -921,12 +921,12 @@ git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
|
||||
## Task 7:DebugAIRunner —— DEBUG 测试入口
|
||||
|
||||
**Files:**
|
||||
- Create: `体己/Debug/DebugAIRunner.swift`
|
||||
- Modify: `体己/Features/Me/MeView.swift`
|
||||
- Create: `康康/Debug/DebugAIRunner.swift`
|
||||
- Modify: `康康/Features/Me/MeView.swift`
|
||||
|
||||
- [ ] **Step 1:创建 DebugAIRunner**
|
||||
- [x] **Step 1:创建 DebugAIRunner**
|
||||
|
||||
`体己/Debug/DebugAIRunner.swift`:
|
||||
`康康/Debug/DebugAIRunner.swift`:
|
||||
|
||||
```swift
|
||||
#if DEBUG
|
||||
@@ -998,9 +998,9 @@ struct DebugAIRunner: View {
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 2:在 MeView 末尾挂上(仅 DEBUG)**
|
||||
- [x] **Step 2:在 MeView 末尾挂上(仅 DEBUG)**
|
||||
|
||||
打开 `体己/Features/Me/MeView.swift`,把现有内容整体替换为:
|
||||
打开 `康康/Features/Me/MeView.swift`,把现有内容整体替换为:
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
@@ -1025,18 +1025,18 @@ struct MeView: View {
|
||||
#Preview { MeView() }
|
||||
```
|
||||
|
||||
- [ ] **Step 3:在 Xcode 中加入文件**
|
||||
- [x] **Step 3:在 Xcode 中加入文件**
|
||||
|
||||
右键 `体己/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "体己" target。
|
||||
右键 `康康/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "康康" target。
|
||||
|
||||
- [ ] **Step 4:Build,确认 OK**
|
||||
- [x] **Step 4:Build,确认 OK**
|
||||
|
||||
⌘B → Expected: Build Succeeded。
|
||||
|
||||
- [ ] **Step 5:提交**
|
||||
- [x] **Step 5:提交**
|
||||
|
||||
```bash
|
||||
git add 体己/Debug/ 体己/Features/Me/MeView.swift 体己.xcodeproj
|
||||
git add 康康/Debug/ 康康/Features/Me/MeView.swift 康康.xcodeproj
|
||||
git commit -m "chore(debug): add AI self-test runner in MeView (DEBUG only)"
|
||||
```
|
||||
|
||||
@@ -1092,15 +1092,15 @@ git commit --allow-empty -m "milestone: W2 LLM 自检通过 (simulator)"
|
||||
## Task 9:加一组 schema 重建烟测(防回归)
|
||||
|
||||
**Files:**
|
||||
- Create: `体己Tests/ModelsSchemaTests.swift`
|
||||
- Create: `康康Tests/ModelsSchemaTests.swift`
|
||||
|
||||
- [ ] **Step 1:写 schema 烟测**
|
||||
- [x] **Step 1:写 schema 烟测**
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
import SwiftData
|
||||
import Foundation
|
||||
@testable import 体己
|
||||
@testable import 康康
|
||||
|
||||
@MainActor
|
||||
struct ModelsSchemaTests {
|
||||
@@ -1179,7 +1179,7 @@ struct ModelsSchemaTests {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:加入 体己Tests target,跑测试**
|
||||
- [x] **Step 2:加入 康康Tests target,跑测试**
|
||||
|
||||
⌘U。
|
||||
|
||||
@@ -1187,10 +1187,10 @@ Expected:3 个测试全 pass。
|
||||
|
||||
若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。
|
||||
|
||||
- [ ] **Step 3:提交**
|
||||
- [x] **Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add 体己Tests/ModelsSchemaTests.swift 体己.xcodeproj
|
||||
git add 康康Tests/ModelsSchemaTests.swift 康康.xcodeproj
|
||||
git commit -m "test(models): add schema smoke tests for relationships and cascade"
|
||||
```
|
||||
|
||||
|
||||
1626
docs/superpowers/plans/2026-06-10-competition-optimizations.md
Normal file
930
docs/superpowers/plans/2026-06-10-voice-diary.md
Normal file
@@ -0,0 +1,930 @@
|
||||
# 语音健康日记 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在「健康记录」(`DiaryQuickSheet`)加语音输入:iOS 端侧流式语音识别实时转写,停止后由本地 LLM(Qwen3.5-2B,经 AIRuntime)整理成健康日记草稿,追加进输入框,可一键回退原话。
|
||||
|
||||
**Architecture:** `DiaryQuickSheet`(mic 按钮 + 状态机)→ `SpeechDictationService`(新,AVAudioEngine + SFSpeechRecognizer 端侧流式转写,不落盘音频)→ `DiaryAssistService.organize(transcript:)`(新方法,经 AIRuntime actor 队列)。Spec:`docs/superpowers/specs/2026-06-10-voice-diary-design.md`。
|
||||
|
||||
**Tech Stack:** SwiftUI、Speech framework(`requiresOnDeviceRecognition = true`)、AVFoundation、Swift Testing(`康康Tests`)。
|
||||
|
||||
**工程约定(执行前必读):**
|
||||
- 工程是 Xcode 16 同步组(`PBXFileSystemSynchronizedRootGroup`):`康康/`、`康康Tests/` 下新建文件**自动入 target,不要改 pbxproj 的文件列表**(权限键除外,见 Task 1)。
|
||||
- CLI 编译/测试必须:`export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`,且加 `-derivedDataPath ./build/cli-dd`(避免和 Xcode 抢 build.db 锁)。
|
||||
- 工程 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`:类型默认 MainActor;系统回调闭包(audio tap、recognitionTask handler)是 nonisolated,**闭包内只碰局部捕获变量,回主线程用 `Task { @MainActor in }`**。
|
||||
- 用户可见文案用 `String(appLoc: "...")`;字号用 `Font.tjScaled(...)`,禁止裸 `.system(size:)`;颜色只用 `Tj.Palette.*`。**不要手改 `Localizable.xcstrings`**(键缺失时回退键名本身,中文键名即兜底文案)。
|
||||
- `git status` 里已有 `康康/Localizable.xcstrings` 的无关改动——**任何 commit 都不要带上它**(逐文件 `git add`)。
|
||||
- spec 偏差说明(已确认的两处小调整):① CLAUDE.md 提到的 `DebugAIRunner` 已不在工程中,prompt 自检改为 `康康Tests` 单元测试 + 真机手测清单;② mic 按钮放「内容」section 标签行右侧(而非输入框内右下角 overlay),避免与文字重叠,仍属"输入框旁"。
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 建独立分支
|
||||
|
||||
**Files:** 无(纯 git)
|
||||
|
||||
- [x] **Step 1: 从当前分支建 `feat/voice-diary`**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
git checkout -b feat/voice-diary
|
||||
```
|
||||
|
||||
Expected: `Switched to a new branch 'feat/voice-diary'`(`Localizable.xcstrings` 的本地改动会跟着工作区走,不影响)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 新增麦克风 + 语音识别权限描述(pbxproj)
|
||||
|
||||
**Files:**
|
||||
- Modify: `康康.xcodeproj/project.pbxproj:430`、`康康.xcodeproj/project.pbxproj:486`(Debug + Release 两个构建配置)
|
||||
|
||||
pbxproj 的 `INFOPLIST_KEY_*` 按字母序排列:Microphone 插在 `NSHealthUpdateUsageDescription` 之后,SpeechRecognition 插在 `NSPhotoLibraryUsageDescription` 之后。每个锚点行在文件中出现 **2 次**(Debug/Release),用 replace_all 一次改两处。
|
||||
|
||||
- [x] **Step 1: 插入 NSMicrophoneUsageDescription(replace_all)**
|
||||
|
||||
用 Edit 工具,`replace_all: true`:
|
||||
|
||||
old_string(注意行首是 4 个 tab):
|
||||
```
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||
```
|
||||
|
||||
new_string:
|
||||
```
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||
```
|
||||
|
||||
- [x] **Step 2: 插入 NSSpeechRecognitionUsageDescription(replace_all)**
|
||||
|
||||
old_string:
|
||||
```
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||
```
|
||||
|
||||
new_string:
|
||||
```
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||
```
|
||||
|
||||
- [x] **Step 3: 验证两个键各出现 2 次**
|
||||
|
||||
```bash
|
||||
grep -c "NSMicrophoneUsageDescription\|NSSpeechRecognitionUsageDescription" 康康.xcodeproj/project.pbxproj
|
||||
```
|
||||
|
||||
Expected: `4`
|
||||
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康.xcodeproj/project.pbxproj
|
||||
git commit -m "feat(语音日记): 新增麦克风与语音识别权限描述(端侧识别文案)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: organize prompt(TDD)
|
||||
|
||||
**Files:**
|
||||
- Test: `康康Tests/DiaryOrganizePromptTests.swift`(新建)
|
||||
- Modify: `康康/AI/Prompts/DiaryAssistPrompts.swift`(文件末尾 `}` 前加方法)
|
||||
|
||||
- [x] **Step 1: 写失败测试**
|
||||
|
||||
新建 `康康Tests/DiaryOrganizePromptTests.swift`:
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct DiaryOrganizePromptTests {
|
||||
@Test func organizePromptContainsTranscriptAndHardRules() {
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: "今天早上头晕量了血压140 90")
|
||||
|
||||
#expect(prompt.contains("今天早上头晕量了血压140 90"))
|
||||
// 健康数据红线:数值/单位/药名/时间不许改,必须写进 prompt
|
||||
#expect(prompt.contains("数值"))
|
||||
#expect(prompt.contains("药名"))
|
||||
// 自适应样式两条规则都在
|
||||
#expect(prompt.contains("一段通顺的话"))
|
||||
#expect(prompt.contains("分行"))
|
||||
// 项目 prompt 规范:禁思考标签
|
||||
#expect(prompt.contains("/no_think"))
|
||||
}
|
||||
|
||||
@Test func organizePromptTruncatesLongTranscript() {
|
||||
let long = String(repeating: "头晕", count: 2000) // 4000 字符,超过上限
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: long)
|
||||
|
||||
// 整条 prompt 里口述部分被截断到 organizeTranscriptLimit
|
||||
let expectedTail = String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit))
|
||||
#expect(prompt.contains(expectedTail))
|
||||
#expect(!prompt.contains(String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit + 2))))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 跑测试确认编译失败(方法还不存在)**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:'康康Tests/DiaryOrganizePromptTests' \
|
||||
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: 编译错误 `type 'DiaryAssistPrompts' has no member 'organize'`(TEST FAILED)。
|
||||
|
||||
- [x] **Step 3: 实现 organize prompt**
|
||||
|
||||
在 `康康/AI/Prompts/DiaryAssistPrompts.swift` 的 enum 末尾(`suggest` 方法后、收尾 `}` 前)加:
|
||||
|
||||
```swift
|
||||
// MARK: - 语音口述 → 日记整理
|
||||
|
||||
/// 口述转写稿截断上限(字符)。2B 模型 context 保护:超长口述只取前面部分。
|
||||
static let organizeTranscriptLimit = 1200
|
||||
|
||||
/// 把语音转写稿整理成健康日记草稿。自适应样式:内容少 → 一段通顺的话;
|
||||
/// 多方面 → 按「方面:内容」分行。
|
||||
/// 红线(spec §2):只重组语言,严禁增删改任何数值、单位、药名、时间——
|
||||
/// 2B 模型把 140/90 改成 130/90 即健康数据事故,所以规则放第一条并配 few-shot 强化。
|
||||
static func organize(transcript: String) -> String {
|
||||
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
|
||||
return """
|
||||
你是健康记录助手。下面是用户口述身体状态的语音转写原话,可能口语化、有重复、缺标点。
|
||||
请把它整理成一条清晰的健康日记。
|
||||
|
||||
硬性规则:
|
||||
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
||||
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
|
||||
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
||||
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
||||
- 不诊断、不给用药建议、不写「建议就医」。
|
||||
- 只输出整理后的日记正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
|
||||
|
||||
示例 1(口述:那个今天早上起来有点头晕然后我量了下血压140 90比平时高一点没吃早饭就出门了):
|
||||
今天早上起来有点头晕,量了血压 140/90,比平时高一点。没吃早饭就出门了。
|
||||
|
||||
示例 2(口述:今天头晕了一上午下午好点了血压早上量的140 90嗯缬沙坦吃了降脂药忘了吃早饭没吃中午吃的清淡晚上散步了半小时):
|
||||
症状:头晕了一上午,下午好转。
|
||||
血压:早上 140/90。
|
||||
用药:缬沙坦已服,降脂药忘服。
|
||||
饮食:早饭未吃,午餐清淡。
|
||||
运动:晚上散步半小时。
|
||||
|
||||
【口述原话】:
|
||||
\(trimmed)
|
||||
|
||||
Output: /no_think
|
||||
"""
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: 跑测试确认通过**
|
||||
|
||||
同 Step 2 命令。Expected: `** TEST SUCCEEDED **`,2 个用例通过。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康Tests/DiaryOrganizePromptTests.swift 康康/AI/Prompts/DiaryAssistPrompts.swift
|
||||
git commit -m "feat(语音日记): organize prompt(自适应样式 + 数值不可改红线)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: DiaryAssistService.organize
|
||||
|
||||
**Files:**
|
||||
- Modify: `康康/Services/DiaryAssistService.swift:99` 之后(`suggest` 方法后、struct 收尾 `}` 前)
|
||||
|
||||
无新单测(纯转发 AIRuntime,LLM 行为靠真机手测;解析逻辑只有 strip + trim,复用已测过的 `stripThinkBlocks`)。
|
||||
|
||||
- [x] **Step 1: 加 organize 方法**
|
||||
|
||||
在 `suggest` 方法的收尾 `}` 之后、struct 收尾 `}` 之前加:
|
||||
|
||||
```swift
|
||||
/// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。
|
||||
/// 失败(模型未就绪 / 输出为空)抛错,调用方回退为直接使用原话,不卡死。
|
||||
/// 与 suggest 同样走 AIRuntime actor 队列,自然与追问/拍照串行。
|
||||
func organize(transcript: String) async throws -> (text: String, decodeRate: Double) {
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
} catch {
|
||||
throw AssistError.modelNotReady
|
||||
}
|
||||
|
||||
let prompt = DiaryAssistPrompts.organize(transcript: transcript)
|
||||
var collected = ""
|
||||
var lastRate: Double = 0
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||
}
|
||||
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw AssistError.empty }
|
||||
return (text, lastRate)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编译验证**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康/Services/DiaryAssistService.swift
|
||||
git commit -m "feat(语音日记): DiaryAssistService.organize 转写稿整理"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: SpeechDictationService(端侧流式转写)
|
||||
|
||||
**Files:**
|
||||
- Create: `康康/Services/SpeechDictationService.swift`
|
||||
|
||||
硬件绑定,无单测;模拟器路径(`isAvailable == false`)与真机路径在 Task 7 手测。
|
||||
|
||||
- [x] **Step 1: 新建 SpeechDictationService.swift**
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import Speech
|
||||
import AVFoundation
|
||||
|
||||
/// 端侧流式语音转写(spec 2026-06-10-voice-diary)。
|
||||
/// AVAudioEngine 麦克风 buffer → SFSpeechAudioBufferRecognitionRequest,
|
||||
/// `requiresOnDeviceRecognition = true` 硬性端侧,识别内容不出设备;**不落盘任何音频**。
|
||||
///
|
||||
/// 生命周期:start(onPartial:) 开始录音并实时回调 partial;stop() 结束并返回最终稿。
|
||||
/// 调用方:DiaryQuickSheet。工程默认 MainActor 隔离,本类型即 MainActor;
|
||||
/// audio tap 与识别回调在系统线程,闭包内只碰局部捕获对象,回主线程统一走 Task { @MainActor }。
|
||||
final class SpeechDictationService {
|
||||
|
||||
enum DictationError: Error, LocalizedError {
|
||||
case unavailable
|
||||
case audioEngineStartFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
return String(appLoc: "本机不支持端侧语音识别")
|
||||
case .audioEngineStartFailed(let m):
|
||||
return String(appLoc: "录音启动失败:\(m)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先系统语言;系统语言不支持端侧时兜底中文(demo 机即使系统是英文也能用)。
|
||||
private static func makeRecognizer() -> SFSpeechRecognizer? {
|
||||
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {
|
||||
return r
|
||||
}
|
||||
if let r = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")),
|
||||
r.supportsOnDeviceRecognition {
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 本机是否支持端侧识别。false(模拟器/老机型)时 UI 隐藏 mic 入口,静默降级。
|
||||
static var isAvailable: Bool { makeRecognizer() != nil }
|
||||
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
/// 识别回调持续刷新;isFinal 或出错时置 didFinish。stop() 用「final 优先、partial 兜底」。
|
||||
private var latestText = ""
|
||||
private var didFinish = false
|
||||
|
||||
private(set) var isRecording = false
|
||||
|
||||
/// 麦克风 + 语音识别两个权限一起申请。任一被拒返回 false。
|
||||
func requestAuthorization() async -> Bool {
|
||||
let speech = await withCheckedContinuation { (c: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { c.resume(returning: $0) }
|
||||
}
|
||||
guard speech == .authorized else { return false }
|
||||
return await AVAudioApplication.requestRecordPermission()
|
||||
}
|
||||
|
||||
/// 开始录音 + 流式识别。partial 结果在主线程回调(录音面板实时字幕)。
|
||||
func start(onPartial: @escaping (String) -> Void) throws {
|
||||
guard !isRecording else { return }
|
||||
guard let recognizer = Self.makeRecognizer(), recognizer.isAvailable else {
|
||||
throw DictationError.unavailable
|
||||
}
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
throw DictationError.audioEngineStartFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.requiresOnDeviceRecognition = true // 红线:识别不出设备
|
||||
request.shouldReportPartialResults = true
|
||||
request.addsPunctuation = true
|
||||
self.request = request
|
||||
latestText = ""
|
||||
didFinish = false
|
||||
|
||||
let input = audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
// tap 在音频线程跑:只碰局部捕获的 request,不碰 self
|
||||
input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
|
||||
request.append(buffer)
|
||||
}
|
||||
audioEngine.prepare()
|
||||
do {
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
input.removeTap(onBus: 0)
|
||||
deactivateSession()
|
||||
throw DictationError.audioEngineStartFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
// 系统线程 → 主线程
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if let result {
|
||||
self.latestText = result.bestTranscription.formattedString
|
||||
onPartial(self.latestText)
|
||||
if result.isFinal { self.didFinish = true }
|
||||
}
|
||||
if error != nil { self.didFinish = true }
|
||||
}
|
||||
}
|
||||
isRecording = true
|
||||
}
|
||||
|
||||
/// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回最终稿。
|
||||
/// 中途识别出错时已拿到的 partial 一样返回(spec 错误表:照常进整理流程)。
|
||||
func stop() async -> String {
|
||||
guard isRecording else { return "" }
|
||||
isRecording = false
|
||||
|
||||
audioEngine.stop()
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
request?.endAudio()
|
||||
|
||||
let deadline = Date().addingTimeInterval(1.5)
|
||||
while !didFinish && Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
task?.cancel()
|
||||
task = nil
|
||||
request = nil
|
||||
deactivateSession()
|
||||
return latestText
|
||||
}
|
||||
|
||||
/// 用户直接关 sheet 时的清理:不关心结果,立即停。
|
||||
func abort() {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
audioEngine.stop()
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
request?.endAudio()
|
||||
task?.cancel()
|
||||
task = nil
|
||||
request = nil
|
||||
deactivateSession()
|
||||
}
|
||||
|
||||
private func deactivateSession() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编译验证**
|
||||
|
||||
同 Task 3 Step 2 命令。Expected: `BUILD SUCCEEDED`。若出现 actor 隔离 warning(标注 error in Swift 6 language mode 的不阻塞),按提示把回调内对 self 的访问收进 `Task { @MainActor in }`,不许用 `nonisolated(unsafe)` 糊。
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康/Services/SpeechDictationService.swift
|
||||
git commit -m "feat(语音日记): SpeechDictationService 端侧流式转写(不落盘音频)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: DiaryVoicePanel(录音/整理面板视图)
|
||||
|
||||
**Files:**
|
||||
- Create: `康康/Features/Diary/DiaryVoicePanel.swift`
|
||||
|
||||
纯展示组件,状态全部外部传入,DiaryQuickSheet(已 600+ 行)不再膨胀。
|
||||
|
||||
- [x] **Step 1: 新建 DiaryVoicePanel.swift**
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
/// 「健康记录」语音输入面板(spec 2026-06-10-voice-diary)。
|
||||
/// 两种模式:recording(实时字幕 + 计时 + 停止)/ organizing(AI 整理中,可取消)。
|
||||
/// 纯展示:状态由 DiaryQuickSheet 持有并传入。
|
||||
struct DiaryVoicePanel: View {
|
||||
enum Mode: Equatable {
|
||||
case recording(elapsedSeconds: Int)
|
||||
case organizing
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
/// recording 时为实时字幕;organizing 时为已定稿的转写稿(置灰展示)。
|
||||
let transcript: String
|
||||
let onStop: () -> Void
|
||||
let onCancelOrganize: () -> Void
|
||||
|
||||
/// 录音上限 3 分钟(超时由 DiaryQuickSheet 的看门狗触发 onStop)。
|
||||
static let maxRecordingSeconds = 180
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header
|
||||
transcriptArea
|
||||
if case .recording = mode {
|
||||
stopButton
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
if mode == .organizing {
|
||||
AIFlowBar().padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
switch mode {
|
||||
case .recording(let elapsed):
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
Text("正在听 · 识别在本机完成")
|
||||
.font(.tjScaled(13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Text(Self.format(elapsed))
|
||||
.font(.tjScaled(12, design: .monospaced))
|
||||
.foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30
|
||||
? Tj.Palette.brick : Tj.Palette.text3)
|
||||
}
|
||||
case .organizing:
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
Text("AI 整理中 · 本地推理")
|
||||
.font(.tjScaled(13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Button("取消") { onCancelOrganize() }
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var transcriptArea: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(showsIndicators: false) {
|
||||
Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript)
|
||||
.font(.tjScaled(14))
|
||||
.foregroundStyle(transcriptColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Color.clear.frame(height: 1).id("tail")
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.onChange(of: transcript) { _, _ in
|
||||
proxy.scrollTo("tail", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var transcriptColor: Color {
|
||||
if transcript.isEmpty { return Tj.Palette.text3 }
|
||||
return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text
|
||||
}
|
||||
|
||||
private var stopButton: some View {
|
||||
Button(action: onStop) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "stop.circle.fill")
|
||||
Text("说完了,整理成日记")
|
||||
}
|
||||
.font(.tjScaled(14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.brick)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private static func format(_ seconds: Int) -> String {
|
||||
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("录音中") {
|
||||
DiaryVoicePanel(mode: .recording(elapsedSeconds: 23),
|
||||
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||
onStop: {}, onCancelOrganize: {})
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("整理中") {
|
||||
DiaryVoicePanel(mode: .organizing,
|
||||
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||
onStop: {}, onCancelOrganize: {})
|
||||
.padding()
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编译验证**
|
||||
|
||||
同 Task 3 Step 2 命令。Expected: `BUILD SUCCEEDED`。
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康/Features/Diary/DiaryVoicePanel.swift
|
||||
git commit -m "feat(语音日记): DiaryVoicePanel 录音/整理面板"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DiaryQuickSheet 接入(mic 按钮 + 状态机 + 回退 pill)
|
||||
|
||||
**Files:**
|
||||
- Modify: `康康/Features/Diary/DiaryQuickSheet.swift`
|
||||
|
||||
改 5 处:① 状态 + 录音流程函数;② 「内容」标签行加 mic 按钮;③ 输入框下方挂面板 / 提示条 / 回退 pill;④ `canRequestSuggest` 把 organizing 排除;⑤ onDisappear 清理。
|
||||
|
||||
- [x] **Step 1: 加语音状态(`@FocusState` 行之后、`hasContent` 之前)**
|
||||
|
||||
在 `DiaryQuickSheet.swift:38`(`@FocusState private var contentFocused: Bool`)之后插入:
|
||||
|
||||
```swift
|
||||
|
||||
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
||||
|
||||
enum VoicePhase: Equatable { case idle, recording, organizing }
|
||||
@State private var voicePhase: VoicePhase = .idle
|
||||
@State private var liveTranscript = ""
|
||||
@State private var recordingSeconds = 0
|
||||
/// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。
|
||||
@State private var rawTranscript: String?
|
||||
/// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。
|
||||
/// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。
|
||||
@State private var organizedAppended: String?
|
||||
/// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。
|
||||
@State private var voiceNote: String?
|
||||
@State private var voiceDeniedAlert = false
|
||||
@State private var voiceFlowTask: Task<Void, Never>?
|
||||
@State private var recordingWatchdog: Task<Void, Never>?
|
||||
private let dictation = SpeechDictationService()
|
||||
```
|
||||
|
||||
- [x] **Step 2: 「内容」标签行加 mic 按钮**
|
||||
|
||||
把(`DiaryQuickSheet.swift:79-80` 附近):
|
||||
|
||||
```swift
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel(String(appLoc: "内容"))
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```swift
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
sectionLabel(String(appLoc: "内容"))
|
||||
Spacer()
|
||||
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||||
Button(action: startVoice) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.tjScaled(11, weight: .semibold))
|
||||
Text("说一段")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().strokeBorder(
|
||||
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
|
||||
lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading) // AI 追问生成中不抢 AIRuntime 队列
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`TextField` 那段不动,仍在该 VStack 内。)
|
||||
|
||||
- [x] **Step 3: 输入框下方挂面板 / 提示条 / 回退 pill**
|
||||
|
||||
在 TextField 的 `.overlay(...)` 闭合后、该 VStack 的收尾 `}` 之前(即原 `DiaryQuickSheet.swift:95` `)` 与 `:96` `}` 之间)插入:
|
||||
|
||||
```swift
|
||||
|
||||
if voicePhase != .idle {
|
||||
DiaryVoicePanel(
|
||||
mode: voicePhase == .organizing
|
||||
? .organizing
|
||||
: .recording(elapsedSeconds: recordingSeconds),
|
||||
transcript: liveTranscript,
|
||||
onStop: stopVoiceAndOrganize,
|
||||
onCancelOrganize: cancelOrganize
|
||||
)
|
||||
}
|
||||
|
||||
if let note = voiceNote {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(note)
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
if let organized = organizedAppended,
|
||||
rawTranscript != nil,
|
||||
content.range(of: organized) != nil {
|
||||
Button(action: revertToRawTranscript) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.uturn.backward")
|
||||
.font(.tjScaled(10, weight: .semibold))
|
||||
Text("改用原话")
|
||||
.font(.tjScaled(11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: organizing 期间禁用「AI 追问」+ 关 sheet 清理 + 权限 alert**
|
||||
|
||||
把 `DiaryQuickSheet.swift:48`:
|
||||
|
||||
```swift
|
||||
private var canRequestSuggest: Bool { hasContent && !isLoading }
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```swift
|
||||
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||||
```
|
||||
|
||||
把 `DiaryQuickSheet.swift:146`:
|
||||
|
||||
```swift
|
||||
.onDisappear { suggestTask?.cancel() }
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```swift
|
||||
.onDisappear {
|
||||
suggestTask?.cancel()
|
||||
voiceFlowTask?.cancel()
|
||||
recordingWatchdog?.cancel()
|
||||
dictation.abort()
|
||||
}
|
||||
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
|
||||
Button(String(appLoc: "前往设置")) {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 5: 加流程函数(`// MARK: - Actions` 区,`requestSuggestions` 之前)**
|
||||
|
||||
在 `DiaryQuickSheet.swift` 的 `sectionLabel` 函数后插入:
|
||||
|
||||
```swift
|
||||
|
||||
// MARK: 语音输入流程
|
||||
|
||||
private func startVoice() {
|
||||
contentFocused = false
|
||||
voiceNote = nil
|
||||
voiceFlowTask = Task { @MainActor in
|
||||
guard await dictation.requestAuthorization() else {
|
||||
voiceDeniedAlert = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
liveTranscript = ""
|
||||
recordingSeconds = 0
|
||||
try dictation.start { partial in liveTranscript = partial }
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
|
||||
// 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致)
|
||||
recordingWatchdog = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled, voicePhase == .recording else { return }
|
||||
recordingSeconds += 1
|
||||
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
|
||||
stopVoiceAndOrganize()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
voiceNote = error.localizedDescription
|
||||
voicePhase = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopVoiceAndOrganize() {
|
||||
guard voicePhase == .recording else { return }
|
||||
recordingWatchdog?.cancel()
|
||||
voiceFlowTask = Task { @MainActor in
|
||||
let transcript = (await dictation.stop())
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
liveTranscript = transcript
|
||||
guard !transcript.isEmpty else {
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
voiceNote = String(appLoc: "没听清,再试一次")
|
||||
return
|
||||
}
|
||||
rawTranscript = transcript
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
|
||||
do {
|
||||
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
|
||||
guard !Task.isCancelled else { return }
|
||||
appendToContent(result.text)
|
||||
organizedAppended = result.text
|
||||
lastRate = result.decodeRate
|
||||
} catch is CancellationError {
|
||||
// cancelOrganize 已处理回退,这里只收尾
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||||
organizedAppended = nil
|
||||
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。
|
||||
private func cancelOrganize() {
|
||||
guard voicePhase == .organizing else { return }
|
||||
voiceFlowTask?.cancel()
|
||||
if let raw = rawTranscript {
|
||||
appendToContent(raw)
|
||||
organizedAppended = nil
|
||||
voiceNote = String(appLoc: "已取消整理,填入原话")
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||
}
|
||||
|
||||
/// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。
|
||||
private func revertToRawTranscript() {
|
||||
guard let raw = rawTranscript,
|
||||
let organized = organizedAppended,
|
||||
let range = content.range(of: organized, options: .backwards) else { return }
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
content = content.replacingCharacters(in: range, with: raw)
|
||||
organizedAppended = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 6: 编译验证(touch 强制重编拿全量警告)**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
touch 康康/Features/Diary/DiaryQuickSheet.swift
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||
|
||||
- [x] **Step 7: 跑全量单测(确认没碰坏别的)**
|
||||
|
||||
```bash
|
||||
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: `** TEST SUCCEEDED **`。
|
||||
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add 康康/Features/Diary/DiaryQuickSheet.swift
|
||||
git commit -m "feat(语音日记): DiaryQuickSheet 接入语音输入(录音→整理→回退原话)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 验证与手测清单
|
||||
|
||||
**Files:** 无新增代码
|
||||
|
||||
- [x] **Step 1: 模拟器降级路径验证**
|
||||
|
||||
模拟器跑 App(或 Xcode Preview `DiaryQuickSheet`),打开「+ 新建 → 写日记」:
|
||||
- `SpeechDictationService.isAvailable` 在模拟器多半为 false → 「说一段」按钮应**整体不显示**,其余功能照旧。
|
||||
- 若模拟器恰好支持端侧识别(部分 macOS/Xcode 组合会),按钮出现也算通过——继续验证录音面板出现、无崩溃即可。
|
||||
|
||||
- [ ] **Step 2: 真机手测清单(连 iPhone 跑,逐项确认)**
|
||||
|
||||
1. 首次点「说一段」→ 依次弹语音识别 + 麦克风两个系统权限框,文案是 Task 1 写的端侧说明
|
||||
2. 拒绝权限 → 再点按钮弹「前往设置」alert,能跳系统设置
|
||||
3. 录音中:实时字幕逐字上屏、计时走动、说话时 waveform 动画
|
||||
4. 点「说完了,整理成日记」→ 面板转「AI 整理中」(AIFlowBar 流动)→ 整理稿**追加**进输入框(已有手打内容不被覆盖)
|
||||
5. 口述含数值(如"血压一百四九十")→ 整理稿数值未被改动(说 3 条不同口述各验一次)
|
||||
6. 「改用原话」pill 出现;点击 → 整理稿被替换为原始转写稿;再手动编辑正文该段 → pill 消失
|
||||
7. 飞行模式(模型已下载)→ 全流程照常,验证 100% 本地
|
||||
8. 一个字不说就点停止 → 「没听清,再试一次」,回 idle 不卡死
|
||||
9. 模型未下载(或长按删除模型后)→ 整理失败 → 原话直接入框 + 提示
|
||||
10. 录音中直接下滑关 sheet → 无崩溃,再次打开正常
|
||||
11. 「AI 整理中」点取消 → 原话入框 + 「已取消整理,填入原话」
|
||||
|
||||
- [ ] **Step 3: 把手测结果记进 commit(若有 fix,随 fix 一起提)**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "test(语音日记): 真机手测清单通过(见 plan Task 7)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:权限(T1)、organize prompt + 自适应 + 数值红线(T2)、Service(T3)、端侧转写不落盘 + 3 分钟上限 + zh 兜底(T4)、面板 + 实时字幕(T5)、mic 入口 + 状态机 + 追加不覆盖 + 改用原话 + 全部错误回退 + organizing 禁用追问(T6)、手测含飞行模式/空转写/取消(T7)。spec 各节均有对应任务。
|
||||
- **占位符**:无 TBD/TODO;所有代码步骤给了完整代码。
|
||||
- **类型一致性**:`SpeechDictationService.isAvailable/requestAuthorization/start(onPartial:)/stop()/abort()` 在 T4 定义、T6 使用一致;`DiaryVoicePanel.Mode`/`maxRecordingSeconds` T5 定义、T6 使用一致;`organize(transcript:) -> (text:, decodeRate:)` T3 定义、T6 解构一致;`AssistError` 复用现有定义。
|
||||
296
docs/superpowers/plans/2026-06-10-voice-export-composer.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 「身体档案」输入框语音听写 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在「身体档案」(`HealthExportSheet`)底部聊天输入框加端侧语音听写:点 mic 开始、识别文字实时流进输入框、再点停止,不调 LLM、不自动发送。
|
||||
|
||||
**Architecture:** 复用 `SpeechDictationService`(@State 持有);新增 static 纯函数 `merge(prefix:partial:)` 处理"已有文字 + 听写文字"拼接(唯一可单测逻辑);`HealthExportSheet` 加 6 个 @State + mic 按钮 + 3 个流程函数。Spec:`docs/superpowers/specs/2026-06-10-voice-export-composer-design.md`。
|
||||
|
||||
**Tech Stack:** SwiftUI、Speech(经 SpeechDictationService)、Swift Testing。
|
||||
|
||||
**工程约定:** 同 `2026-06-10-voice-diary.md` 的「执行前必读」(同步组免改 pbxproj、CLI 用 `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` + `-derivedDataPath ./build/cli-dd`、全量并行测试不可信要 `-only-testing` 定点跑、commit 逐文件 add 不带 `Localizable.xcstrings`)。**当前环境注意**:xcode-select 已指向完整 Xcode 且许可证未接受——`git` 用 `DEVELOPER_DIR=/Library/Developer/CommandLineTools` 前缀绕过;`xcodebuild` 必须先让用户跑 `sudo xcodebuild -license accept`。直接在 `feat/mnn-sme2-runtime` 分支上做(上一功能合并后该分支即集成分支,不再另开分支避免并发会话分支错位)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `merge(prefix:partial:)`(TDD)
|
||||
|
||||
**Files:**
|
||||
- Test: `康康Tests/SpeechDictationMergeTests.swift`(新建)
|
||||
- Modify: `康康/Services/SpeechDictationService.swift`(`isAvailable` 之后加 static 方法)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
新建 `康康Tests/SpeechDictationMergeTests.swift`:
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct SpeechDictationMergeTests {
|
||||
@Test func emptyPrefixReturnsPartial() {
|
||||
#expect(SpeechDictationService.merge(prefix: "", partial: "今天头晕") == "今天头晕")
|
||||
}
|
||||
|
||||
@Test func plainPrefixJoinsWithSpace() {
|
||||
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "新听写")
|
||||
== "已有内容 新听写")
|
||||
}
|
||||
|
||||
@Test func whitespaceTerminatedPrefixConcatsDirectly() {
|
||||
#expect(SpeechDictationService.merge(prefix: "第一行\n", partial: "新听写")
|
||||
== "第一行\n新听写")
|
||||
}
|
||||
|
||||
@Test func emptyPartialKeepsPrefix() {
|
||||
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "") == "已有内容")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认编译失败**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:'康康Tests/SpeechDictationMergeTests' \
|
||||
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|TEST (SUCCEEDED|FAILED)" | head -5
|
||||
```
|
||||
|
||||
Expected: `error: type 'SpeechDictationService' has no member 'merge'`(TEST FAILED)。
|
||||
|
||||
- [ ] **Step 3: 实现 merge**
|
||||
|
||||
在 `康康/Services/SpeechDictationService.swift` 的 `static var isAvailable` 行之后加:
|
||||
|
||||
```swift
|
||||
|
||||
/// 听写文本拼接:听写开始时输入框已有 prefix,partial 持续拼在其后。
|
||||
/// prefix 空 → 直接用 partial;prefix 以空白/换行结尾 → 直接连接;否则补一个空格。
|
||||
static func merge(prefix: String, partial: String) -> String {
|
||||
guard !partial.isEmpty else { return prefix }
|
||||
guard !prefix.isEmpty else { return partial }
|
||||
if let last = prefix.unicodeScalars.last,
|
||||
CharacterSet.whitespacesAndNewlines.contains(last) {
|
||||
return prefix + partial
|
||||
}
|
||||
return prefix + " " + partial
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
同 Step 2 命令。Expected: `** TEST SUCCEEDED **`,4 个用例通过。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
DEVELOPER_DIR=/Library/Developer/CommandLineTools git add 康康Tests/SpeechDictationMergeTests.swift 康康/Services/SpeechDictationService.swift
|
||||
DEVELOPER_DIR=/Library/Developer/CommandLineTools git commit -m "feat(语音听写): SpeechDictationService.merge 前缀拼接(TDD)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: HealthExportSheet 接入
|
||||
|
||||
**Files:**
|
||||
- Modify: `康康/Features/Archive/HealthExportSheet.swift`(状态区 :27-30、canAsk :38、canGenerateReport :49、快捷问答 chip :133、onDisappear :103、alert :104、composer :410)
|
||||
|
||||
- [ ] **Step 1: 加听写状态(「快捷问答」状态块之后、`init` 之前)**
|
||||
|
||||
在 `@State private var newPromptText = ""` 之后插入:
|
||||
|
||||
```swift
|
||||
|
||||
// 语音听写(spec 2026-06-10-voice-export-composer)。
|
||||
// dictation 必须 @State:struct View 重建时普通 let 会换新实例(日记踩过的坑)。
|
||||
@State private var dictation = SpeechDictationService()
|
||||
@State private var isDictating = false
|
||||
/// 听写开始时输入框已有文字,partial 始终拼在它后面。
|
||||
@State private var dictationPrefix = ""
|
||||
@State private var dictationTask: Task<Void, Never>?
|
||||
@State private var dictationWatchdog: Task<Void, Never>?
|
||||
@State private var dictationDeniedAlert = false
|
||||
/// 录音上限,与日记一致(防麦克风悬挂)。
|
||||
private static let dictationMaxSeconds = 180
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 录音中禁发送/生成/chip**
|
||||
|
||||
`canAsk` 加条件:
|
||||
|
||||
```swift
|
||||
private var canAsk: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
!isDictating &&
|
||||
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
```
|
||||
|
||||
`canGenerateReport` 的 `!isGeneratingReport &&` 后加 `!isDictating &&`。
|
||||
|
||||
快捷问答 chip 动作(`draftQuestion = p.prompt` 处)改为:
|
||||
|
||||
```swift
|
||||
guard !isDictating else { return }
|
||||
draftQuestion = p.prompt
|
||||
```
|
||||
|
||||
- [ ] **Step 3: composer 加 mic 按钮 + TextField 录音中禁用**
|
||||
|
||||
TextField 的 `.disabled(isAnswering || isGeneratingReport)` 改为 `.disabled(isAnswering || isGeneratingReport || isDictating)`。
|
||||
|
||||
TextField 与发送 Button 之间插入:
|
||||
|
||||
```swift
|
||||
|
||||
if SpeechDictationService.isAvailable {
|
||||
Button { toggleDictation() } label: {
|
||||
Image(systemName: isDictating ? "stop.fill" : "mic.fill")
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(isDictating ? Tj.Palette.paper : Tj.Palette.brick)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Circle().fill(isDictating ? Tj.Palette.brick : Tj.Palette.brickSoft))
|
||||
.symbolEffect(.pulse, options: .repeating, isActive: isDictating)
|
||||
}
|
||||
.disabled(isAnswering || isGeneratingReport)
|
||||
.accessibilityLabel(isDictating ? String(appLoc: "停止听写") : String(appLoc: "语音输入"))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 生命周期 + 权限 alert**
|
||||
|
||||
`.onDisappear { task?.cancel() }` 改为:
|
||||
|
||||
```swift
|
||||
.onDisappear {
|
||||
task?.cancel()
|
||||
dictationTask?.cancel()
|
||||
dictationWatchdog?.cancel()
|
||||
dictation.abort()
|
||||
}
|
||||
```
|
||||
|
||||
现有「添加快捷问答」alert 的 `}` 闭合之后追加:
|
||||
|
||||
```swift
|
||||
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $dictationDeniedAlert) {
|
||||
Button(String(appLoc: "前往设置")) {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("语音输入全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 流程函数(`// MARK: - Actions` 之后、`sendQuestion` 之前)**
|
||||
|
||||
```swift
|
||||
// MARK: 语音听写
|
||||
|
||||
private func toggleDictation() {
|
||||
if isDictating { stopDictation() } else { startDictation() }
|
||||
}
|
||||
|
||||
private func startDictation() {
|
||||
questionFocused = false
|
||||
dictationTask = Task { @MainActor in
|
||||
guard await dictation.requestAuthorization() else {
|
||||
dictationDeniedAlert = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
dictationPrefix = draftQuestion
|
||||
try dictation.start { partial in
|
||||
draftQuestion = SpeechDictationService.merge(prefix: dictationPrefix,
|
||||
partial: partial)
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { isDictating = true }
|
||||
dictationWatchdog = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.dictationMaxSeconds) * 1_000_000_000)
|
||||
guard !Task.isCancelled, isDictating else { return }
|
||||
stopDictation()
|
||||
}
|
||||
} catch {
|
||||
isDictating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDictation() {
|
||||
guard isDictating else { return }
|
||||
dictationWatchdog?.cancel()
|
||||
dictationTask = Task { @MainActor in
|
||||
let final = (await dictation.stop()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !final.isEmpty {
|
||||
draftQuestion = SpeechDictationService.merge(prefix: dictationPrefix,
|
||||
partial: final)
|
||||
}
|
||||
// final 为空:partial 已实时在输入框,保持现状即天然兜底(spec:不提示「没听清」)
|
||||
withAnimation(.snappy(duration: 0.2)) { isDictating = false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: touch 强制重编验证**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
touch 康康/Features/Archive/HealthExportSheet.swift
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||
|
||||
- [ ] **Step 7: 定点回归(语音相关全部测试)**
|
||||
|
||||
```bash
|
||||
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:'康康Tests/SpeechDictationMergeTests' \
|
||||
-only-testing:'康康Tests/SpeechDictationAvailabilityTests' \
|
||||
-only-testing:'康康Tests/DiaryOrganizePromptTests' \
|
||||
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "Test case.*(passed|failed)|TEST (SUCCEEDED|FAILED)"
|
||||
```
|
||||
|
||||
Expected: `** TEST SUCCEEDED **`,7 用例通过。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/xuhuayong/apps/康康
|
||||
DEVELOPER_DIR=/Library/Developer/CommandLineTools git add 康康/Features/Archive/HealthExportSheet.swift
|
||||
DEVELOPER_DIR=/Library/Developer/CommandLineTools git commit -m "feat(语音听写): 身体档案输入框听写实时上屏"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 真机手测清单
|
||||
|
||||
- [ ] **Step 1: 真机逐项确认**
|
||||
|
||||
1. 「身体档案」composer 出现 mic 按钮(模拟器不支持端侧识别时隐藏)
|
||||
2. 点 mic → 说话 → 字实时出现在输入框;输入框已有文字时保留并以空格衔接
|
||||
3. 录音中:输入框/发送/「生成整理报告」/快捷问答 chip 均不可用;mic 为红色停止态
|
||||
4. 再点 mic → 停止,文字落定,点发送正常走问答
|
||||
5. 权限拒绝 → alert 跳设置
|
||||
6. 录音中直接关 sheet → 无崩溃、麦克风指示灯熄灭
|
||||
7. 3 分钟自动停止
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:merge 纯函数+单测(T1)、@State 持有/实时上屏/停止落定/空结果保持现状(T2 S5)、mic 隐藏与禁用矩阵(T2 S2-S3)、权限 alert + onDisappear abort + 看门狗(T2 S4-S5)、真机清单(T3)。无缺口。
|
||||
- **占位符**:无;所有代码步骤给全。
|
||||
- **类型一致性**:`merge(prefix:partial:)` T1 定义、T2 S5 调用一致;`dictationMaxSeconds`/`isDictating`/`dictationPrefix` 命名前后一致;`SpeechDictationService.isAvailable/requestAuthorization/start/stop/abort` 与现有实现签名一致。
|
||||
42
docs/superpowers/retros/2026-05-31-w2.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# W2 Retro · 2026-05-31
|
||||
|
||||
> 范围:2026-05-19(W2 起)→ 2026-05-25(W2 中段写,W3 周一前回看修订)。本次 retro 在 W2 中段写,主要是周末批量收尾的留痕。
|
||||
|
||||
## Status
|
||||
|
||||
| 风险/里程碑 | 状态 | 备注 |
|
||||
|---|---|---|
|
||||
| R1 · MLX 跑通 | ⚠️ 部分通过 | LLMSession.load 通过 Swift Testing 烟测,真实 tok/s 待用户手动 DebugAIRunner 验证 |
|
||||
| R4 · Schema 迁移 | ✅ 通过 | 5 + 1(Symptom)个 @Model,3 + 2 个关系烟测全绿 |
|
||||
| 本周里程碑 · AI 基座骨架 | ✅ | AIRuntime / LLMSession / ModelStore / FileVault 全部交付,build 干净 0 warning |
|
||||
|
||||
## 速度基线
|
||||
- 模拟器(iPhone 17 Sim, Apple Silicon Mac):**TBD**(W3 周一前由 xuhuayong 在 macOS Designed for iPad 内点 DebugAIRunner 填入)
|
||||
- 真机 iPhone 15+:**待 W3 验证**(本周未连真机,模型只 sideload 到 macOS sandbox)
|
||||
|
||||
> 验收门槛:模拟器 < 5 tok/s 触发 R1 红线(换 llama.cpp,W2 plan revert)。当前烟测路径无法测速,需 manual。
|
||||
|
||||
## 计划外完成
|
||||
- **Symptom 模块**:新增 @Model + Start/End sheets + OngoingSymptomsCard。这是 CLAUDE.md §10 红线 #6 "新功能必须问'清单里有吗'" 的例外,由产品负责人决定加入。
|
||||
- **Timeline 统一时间线**:TimelineEntry + TimelineRow + DateSection + TimelineGrouping,被 HomeView 和 ArchiveListView 共享。
|
||||
- **ArchiveListView 提前打底**(原计划 W4):接 @Query 拉 Indicator/Report/Diary/Symptom,filter chips + 年/月分组 + 空态。
|
||||
- **AppIcon**:Light/Dark/Tinted 三套 9 sizes + SVG 源。
|
||||
- **Swift 6 并发清扫**:`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` 下,把 ModelStore / FileVault / ModelKind 显式标 nonisolated,LLMSession 用 task-scoped Device.withDefaultDevice 替代 deprecated API。
|
||||
|
||||
## 计划内缺口
|
||||
- **Task 8 Step 1-2 自检与速度基线**:延后到用户 manual 验证。
|
||||
- **Task 8 Step 3 真机连测**:延后到 W3。
|
||||
- **Task 10 Step 2 §8 状态更新**:已在本 retro commit 内一起完成。
|
||||
|
||||
## 学到的
|
||||
1. **`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` 会把跨边界类型/方法都默认推到 MainActor**,跟 actor (如 AIRuntime) 互操作时必须显式 `nonisolated` 整条调用链。`@unchecked Sendable` 不自动解锁实例方法的 isolation。
|
||||
2. **iOS Simulator app sandbox 阻止读 Mac 用户目录**,集成测试无法直接验证真实推理;Mac Designed for iPad 又卡 code signing。W3 把 LLM 接口拆 SPM target 后才能写 host-fs 集成测试。
|
||||
3. **`Device.withDefaultDevice` 是 TaskLocal,跨 actor 传递正常**,但跨 Task(如 AsyncStream 的 detached Task)需要在 inner Task 内重新 `withDefaultDevice`。
|
||||
4. **MLX Swift API 比 mlx-swift-examples 文档稳定**,真正卡的是 Swift 6 并发系统,不是 MLX 本身。
|
||||
|
||||
## 下周(W3)前置准备
|
||||
- [ ] 用户在 macOS App 内点 DebugAIRunner,把实际 tok/s 填进本 retro 的"速度基线"段
|
||||
- [ ] 准备 5–10 张真实化验单照片(W4 VL 回归测用),放进 ~/tiji-models/test-reports/
|
||||
- [ ] 准备 20 条危险问句(W3 末医疗话术安全测试)
|
||||
- [ ] 决定是否把 LLM 接口拆 SPM target(便于真实推理集成测试)
|
||||
- [ ] W3 plan 周一动笔,把 Symptom + Timeline 写进 spec
|
||||
@@ -1,4 +1,4 @@
|
||||
# 康记 / 体己 —— 功能设计 Spec(v1.0)
|
||||
# 康康 —— 功能设计 Spec(v1.0)
|
||||
|
||||
**日期**:2026-05-25
|
||||
**状态**:Draft, 已与产品方对齐 §1-§6
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 0. 概要
|
||||
|
||||
康记是一个 iOS 原生健康影像档案 App,**100% 端侧 AI 推理**,基于 SwiftUI + SwiftData + MLX Swift,目标 6 周交付决赛 demo。本 spec 把原始功能清单收敛为 **方案 B**:核心 5 模块 + Live Activity + 分享摘要,其余 P2/P3 全部 deferred。
|
||||
康康是一个 iOS 原生健康影像档案 App,**100% 端侧 AI 推理**,基于 SwiftUI + SwiftData + MLX Swift,目标 6 周交付决赛 demo。本 spec 把原始功能清单收敛为 **方案 B**:核心 5 模块 + Live Activity + 分享摘要,其余 P2/P3 全部 deferred。
|
||||
|
||||
**5 大核心模块**
|
||||
|
||||
@@ -77,7 +77,7 @@ Persistence
|
||||
### 2.1 `AIRuntime` 接口
|
||||
|
||||
```
|
||||
体己/AI/
|
||||
康康/AI/
|
||||
├── AIRuntime.swift // actor 单例,推理串行化
|
||||
├── ModelStore.swift // 模型路径管理 + 下载 + bundle 旁路
|
||||
├── LLMSession.swift // Qwen3-1.7B 文本生成,流式
|
||||
@@ -112,7 +112,7 @@ struct TokenChunk {
|
||||
|
||||
| 项 | 决策 |
|
||||
|---|---|
|
||||
| 模型来源 | HuggingFace MLX 社区版 Qwen3-1.7B-MLX-4bit + Qwen2.5-VL-3B-MLX-4bit |
|
||||
| 模型来源 | HuggingFace `mlx-community/Qwen3-1.7B-4bit` + `mlx-community/Qwen2.5-VL-3B-Instruct-4bit` |
|
||||
| 体积 | LLM ~1.0GB + VL ~2.0GB ≈ 3GB |
|
||||
| 存储 | `Application Support/Models/`,`URLSession.downloadTask` + 断点续传 |
|
||||
| 首启动 | 启动屏 → 隐私承诺 → "下载模型"页(进度 + WiFi 提示) → 主界面 |
|
||||
@@ -376,7 +376,7 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence
|
||||
### 4.3 服务层文件
|
||||
|
||||
```
|
||||
体己/AI/ [7.5d]
|
||||
康康/AI/ [7.5d]
|
||||
├── AIRuntime.swift 2d
|
||||
├── ModelStore.swift 1d
|
||||
├── LLMSession.swift 1d
|
||||
@@ -387,17 +387,17 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence
|
||||
├── KeywordExtraction.swift
|
||||
└── TrendNarrative.swift
|
||||
|
||||
体己/Services/ [4.5d]
|
||||
康康/Services/ [4.5d]
|
||||
├── CaptureService.swift 1.5d
|
||||
├── AskService.swift 1.5d
|
||||
├── TrendService.swift 1d
|
||||
└── ReportCompareService.swift 0.5d
|
||||
|
||||
体己/Persistence/ [1d]
|
||||
康康/Persistence/ [1d]
|
||||
├── FileVault.swift 0.5d
|
||||
└── PermanentDelete.swift 0.5d
|
||||
|
||||
体己/Security/ [0.5d]
|
||||
康康/Security/ [0.5d]
|
||||
└── AppLock.swift 0.5d
|
||||
```
|
||||
|
||||
|
||||
122
docs/superpowers/specs/2026-05-26-hide-monitor-preset-design.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Hide Monitor Preset · 设计 v1
|
||||
|
||||
> 「记录指标」sheet 长期监测预设(`MonitorMetric`)支持隐藏
|
||||
>
|
||||
> 日期:2026-05-26 · 状态:approved by user(2026-05-26 对话)
|
||||
> 关联:[CLAUDE.md](../../../CLAUDE.md) §7,[Monitor+Profile spec](./2026-05-26-monitor-and-profile-design.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
`IndicatorQuickSheet`「长期监测(进趋势)」分组由 `MonitorMetric.allCases` 渲染,目前 6 个硬编码 case(血压/空腹血糖/餐后血糖/体温/心率/血氧)无法隐藏,与下方 `CustomMonitorMetric`(可长按编辑/删除)体验不一致。
|
||||
|
||||
用户场景:不测血氧、不测血压的人想清理 grid;但**不能误删历史数据**——已经测过的折线在 Trends 里还要看。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 长按 `MonitorMetric` tile → contextMenu 出"隐藏"
|
||||
- 已隐藏的 tile 从 grid 过滤掉,但已有 `Indicator` 记录、Trends 折线、`MetricReminder` 全不动
|
||||
- 提供可逆恢复入口
|
||||
|
||||
## 3. 非目标(YAGNI)
|
||||
|
||||
- ❌ 化验项快捷预设(labPresets)同款功能 — 本次不动
|
||||
- ❌ 「我的」里集中管理页 — grid 上就近恢复即可
|
||||
- ❌ 批量隐藏 / 拖拽排序
|
||||
- ❌ 二次确认弹窗 — 隐藏可逆,不需要
|
||||
- ❌ 隐藏时联动关掉对应 `MetricReminder` — 用户没说,保守不动
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
`UserProfile` 增加一个字段:
|
||||
|
||||
```swift
|
||||
var hiddenPresetMetrics: [String] = [] // 存 MonitorMetric.rawValue
|
||||
```
|
||||
|
||||
- 类型沿用 `[String]`,跟 `allergies` / `chronicConditions` 一致,SwiftData 自动 transformable
|
||||
- init 默认 `[]`,无 migration 风险
|
||||
- 写入用 `UserProfile.updatedAt = .now`
|
||||
|
||||
为什么不另开 `@Model HiddenPresetMetric`:8 个 case 的隐藏标记只是 UI 偏好,放 Profile 单例最自然,避免新 entity + 关联查询。
|
||||
|
||||
## 5. UI 行为
|
||||
|
||||
### 5.1 隐藏入口
|
||||
|
||||
`IndicatorQuickSheet.monitorTile(_:)` 加 `.contextMenu`:
|
||||
|
||||
```swift
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
hideMonitor(m)
|
||||
} label: {
|
||||
Label("隐藏", systemImage: "eye.slash")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`hideMonitor` 把 `m.rawValue` 加入 `profile.hiddenPresetMetrics`,save,grid 因 `@Query` 重渲染。被隐藏的 tile 若当前选中,要 `clearMonitor()` 复位。
|
||||
|
||||
### 5.2 grid 过滤
|
||||
|
||||
```swift
|
||||
ForEach(MonitorMetric.allCases.filter { !hiddenSet.contains($0.rawValue) }) { m in
|
||||
monitorTile(m)
|
||||
}
|
||||
```
|
||||
|
||||
`hiddenSet` = `Set(profile?.hiddenPresetMetrics ?? [])`,computed property。
|
||||
|
||||
### 5.3 恢复入口
|
||||
|
||||
`monitorGridSection` 顶部 section label 一行:
|
||||
|
||||
```
|
||||
长期监测(进趋势) 已隐藏 3 ›
|
||||
```
|
||||
|
||||
- chip 仅当 `hiddenSet.nonEmpty` 显示
|
||||
- 点 chip → `.sheet` 弹一个轻量列表(`.medium` detent)
|
||||
- 列表项:每个被隐藏的 `MonitorMetric` 显示 icon + displayName + 右侧"显示"按钮
|
||||
- 点"显示" → `profile.hiddenPresetMetrics.removeAll { $0 == m.rawValue }` + save
|
||||
- 列表空了自动 dismiss
|
||||
|
||||
### 5.4 边界
|
||||
|
||||
- 全部 6 个都隐藏:section 还在(label + chip + addCustomTile),不消失
|
||||
- 隐藏不影响:Trends 折线、`Indicator` 列表查询、`MetricReminder` 调度
|
||||
- `UserProfileStore.loadOrCreate` 已保证 profile 存在,无 nil 分支
|
||||
- `@Query private var profiles: [UserProfile]` 已在 sheet 里,直接取 `profiles.first`
|
||||
|
||||
## 6. 文件改动清单
|
||||
|
||||
1. `Models/UserProfile.swift` — 加 `hiddenPresetMetrics: [String]` 字段 + init 默认值
|
||||
2. `Features/Indicator/IndicatorQuickSheet.swift`
|
||||
- `monitorGridSection`: 过滤 + 顶部 chip
|
||||
- `monitorTile`: 加 contextMenu
|
||||
- 新增 `hideMonitor(_:)` / `unhideMonitor(_:)` / `hiddenSet`
|
||||
- 新增 `HiddenMonitorRestoreSheet` 子 View(同文件内,私有)
|
||||
|
||||
不动:`MonitorMetric.swift`、`CustomMetricEditor.swift`、Trends、`ReminderService`、`MeView`。
|
||||
|
||||
## 7. 测试 / 验证手段
|
||||
|
||||
无单测目标(全 UI 行为)。手测点:
|
||||
|
||||
- [ ] 长按血压 tile → 出现"隐藏",点了 grid 里消失
|
||||
- [ ] 顶部 chip "已隐藏 1" 出现,数字正确
|
||||
- [ ] 点 chip → 弹列表,有 1 行血压,点"显示"恢复
|
||||
- [ ] 全部 6 个隐藏 → grid 只剩 addCustomTile + 自定义指标,不崩
|
||||
- [ ] 隐藏期间去 Trends,血压折线仍在
|
||||
- [ ] 隐藏前若血压已选中,隐藏后选中态清空、字段清空
|
||||
- [ ] 重启 App,隐藏状态持久
|
||||
|
||||
## 8. 红线核查(CLAUDE.md §10)
|
||||
|
||||
- ✅ 不引入云
|
||||
- ✅ 不动 AIRuntime / Service 边界
|
||||
- ✅ 不动 SwiftData 既有 `Indicator` schema
|
||||
- ✅ Tab / RecordSheet 骨架不动
|
||||
- ✅ 不是清单外功能,是对 §7 grid 的小改良
|
||||
434
docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Monitor + Profile · 设计 v1
|
||||
|
||||
> 长期格式化指标录入(`.indicator` 入口预设 + 自由)+ 个人资料(年龄、性别、健康背景、用药)
|
||||
>
|
||||
> 日期:2026-05-26 · 状态:approved by user,进入实施
|
||||
> 关联:[CLAUDE.md](../../../CLAUDE.md) §5 §7 §10;[W2 retro](../retros/2026-05-31-w2.md) 计划外完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前缺口
|
||||
|
||||
康康现有的 4 个记录 kind(`quick` 拍照、`archive` 归档、`diary` 文字、`symptom` 持续症状)都是**事件型**——一次性记录,不假设后续会重复同一指标。但血压/血糖/体重这类**长期监测**类需求:
|
||||
|
||||
- 用户每天/每周测,数值规律地重复
|
||||
- 需要趋势(W4-W5 计划的 Trends 页)
|
||||
- 不需要拍照(已是格式化数字)
|
||||
- 参考范围依赖个人 demographic(老人血压标准放宽)
|
||||
|
||||
同时,App 启动以来一直没有用户基础信息持久化的位置。LLM 给出趋势解读时缺乏 demographic context("LDL 偏高"对 35 岁健康男和 70 岁糖尿病患者风险完全不同)。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
- **一个统一的"手动录入指标"入口**:用户已加 `.indicator` case,本设计把 7 个预设(血压/血糖/体重/...)和「自由输入」合并进这个 sheet
|
||||
- **个人资料卡**:在「我的」加一张资料卡,push 进 Form 编辑页,4 项核心 + 健康背景 + 用药
|
||||
- **联动**:参考范围按 Profile 个性化(目前规则只覆盖"老人血压"一例,后续可扩)
|
||||
|
||||
### 1.3 非目标(YAGNI)
|
||||
|
||||
- ❌ Trends 页升级(本次只打通数据通路,留给 W4-W5)
|
||||
- ❌ 提醒/通知功能(到点测量推送)
|
||||
- ❌ HealthKit 导入
|
||||
- ❌ 多 Profile / 给家人记
|
||||
- ❌ AppLock / Face ID(W5 末统一实现)
|
||||
- ❌ 单位切换(kg/lb,mmol/L vs mg/dL)
|
||||
- ❌ 紧急联系人
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 Indicator 扩字段
|
||||
|
||||
```swift
|
||||
@Model final class Indicator {
|
||||
// 现有字段不动:name/value/unit/range/statusRaw/note/capturedAt/report/asset/pinned
|
||||
|
||||
var seriesKey: String? // 新增。预设录入填 "bp.systolic" / "glucose.fasting" / ...
|
||||
// 自由输入和 VL/Report 来源的 Indicator 都为 nil
|
||||
}
|
||||
```
|
||||
|
||||
**为什么用 String 而非 enum**:`seriesKey` 跨设备/版本要稳定,enum 改名会破坏老数据;String 用命名空间约定(`bp.*` / `glucose.*`)即可。
|
||||
|
||||
**为什么不新建 @Model**:复用 Indicator 让 Trends/Timeline/ReportCompareService 一次写完受益,避免分裂查询路径。
|
||||
|
||||
### 2.2 UserProfile @Model
|
||||
|
||||
```swift
|
||||
@Model final class UserProfile {
|
||||
// —— 核心 4 项 ——
|
||||
var birthYear: Int? // 1990 等。隐私考虑只存"年",不存月日
|
||||
var biologicalSexRaw: String // "" / "male" / "female"
|
||||
var heightCM: Int?
|
||||
var bloodTypeRaw: String // "" / "A" / "B" / "AB" / "O"
|
||||
|
||||
// —— 健康背景 ——
|
||||
var allergies: [String] // 自由文本数组
|
||||
var chronicConditions: [String] // 预设 + 自定义混合
|
||||
var familyHistory: [String] // 自由文本数组
|
||||
|
||||
// —— 当前用药 ——
|
||||
var currentMedications: [String]
|
||||
|
||||
var updatedAt: Date
|
||||
|
||||
init(birthYear: Int? = nil, /* ... */) { /* ... */ }
|
||||
}
|
||||
|
||||
extension UserProfile {
|
||||
enum Sex: String { case male, female, undisclosed = "" }
|
||||
var sex: Sex { Sex(rawValue: biologicalSexRaw) ?? .undisclosed }
|
||||
|
||||
/// 当前年龄(无 birthYear 时 nil)
|
||||
var age: Int? {
|
||||
guard let y = birthYear else { return nil }
|
||||
return Calendar.current.component(.year, from: .now) - y
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 单例策略
|
||||
|
||||
UserProfile 全 App 单一实例,通过 helper 保证:
|
||||
|
||||
```swift
|
||||
enum UserProfileStore {
|
||||
@MainActor
|
||||
static func loadOrCreate(in ctx: ModelContext) -> UserProfile {
|
||||
let descriptor = FetchDescriptor<UserProfile>()
|
||||
if let existing = try? ctx.fetch(descriptor).first { return existing }
|
||||
let new = UserProfile()
|
||||
ctx.insert(new)
|
||||
try? ctx.save()
|
||||
return new
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
任何 View 用 `@Query` 拉,空了再调 loadOrCreate。MeView 启动时调一次,确保后续 @Query 必拿到。
|
||||
|
||||
### 2.4 Schema 注册
|
||||
|
||||
`KangkangApp.swift` 的 schema 加入 `UserProfile.self`。Indicator 加字段是 additive change,SwiftData 自动迁移(给老 row 的 seriesKey 填 nil)。
|
||||
|
||||
---
|
||||
|
||||
## 3. MonitorMetric Catalog
|
||||
|
||||
`Features/Monitor/MonitorMetric.swift`,8 个预设(血压算 1 个 case,内部展开 2 条 Indicator):
|
||||
|
||||
```swift
|
||||
enum MonitorMetric: String, CaseIterable, Identifiable {
|
||||
case bloodPressure // bp.systolic + bp.diastolic
|
||||
case fastingGlucose // glucose.fasting
|
||||
case postprandialGlucose // glucose.postprandial
|
||||
case weight // weight
|
||||
case temperature // temperature
|
||||
case heartRate // heart_rate
|
||||
case spo2 // spo2
|
||||
case height // height(录入后回写 UserProfile.heightCM)
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String { /* "血压" / "空腹血糖" / ... */ }
|
||||
var icon: String { /* SF Symbol */ }
|
||||
var fields: [Field] // 1 或 2 个字段定义
|
||||
}
|
||||
|
||||
extension MonitorMetric {
|
||||
struct Field {
|
||||
let seriesKey: String // 如 "bp.systolic"
|
||||
let label: String // 如 "收缩压"
|
||||
let unit: String // 如 "mmHg"
|
||||
let placeholder: String // 如 "120"
|
||||
let baseRange: ClosedRange<Double>? // nil 表示不算 status(体重/身高)
|
||||
}
|
||||
|
||||
/// 返回该 metric 在给定 profile 下的参考范围(可能跟 baseRange 不同)
|
||||
func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange<Double>? {
|
||||
// 目前唯一规则:bp 在 age >= 65 时上限放宽到 150 / 90
|
||||
if let age = profile?.age, age >= 65,
|
||||
field.seriesKey == "bp.systolic" {
|
||||
return 90...150
|
||||
}
|
||||
if let age = profile?.age, age >= 65,
|
||||
field.seriesKey == "bp.diastolic" {
|
||||
return 60...90 // 暂不调
|
||||
}
|
||||
return field.baseRange
|
||||
}
|
||||
|
||||
/// 根据值算 status(value 在范围内 → normal,上 → high,下 → low,无范围 → normal)
|
||||
static func status(value: Double, in range: ClosedRange<Double>?) -> IndicatorStatus {
|
||||
guard let r = range else { return .normal }
|
||||
if value > r.upperBound { return .high }
|
||||
if value < r.lowerBound { return .low }
|
||||
return .normal
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 Profile-aware 规则
|
||||
|
||||
本次仅实现 1 条规则(老人收缩压上限 140→150),目的是**展示联动机制**,不追求医学完备。未来扩规则只改 `effectiveRange` 函数,不动调用方。
|
||||
|
||||
---
|
||||
|
||||
## 4. UI
|
||||
|
||||
### 4.1 IndicatorRecordSheet(替代之前提的 MonitorRecordSheet)
|
||||
|
||||
`Features/Indicator/IndicatorRecordSheet.swift`,被 RootView 在 `.indicator` case 弹出。
|
||||
|
||||
**布局**:
|
||||
|
||||
```
|
||||
[拖动条]
|
||||
"记录指标 · 本地处理"
|
||||
|
||||
[2 列 grid]
|
||||
┌─────────┐ ┌─────────┐
|
||||
│ 血压 │ │ 空腹血糖│
|
||||
│ 收/舒 │ │ 3.9-6.1 │
|
||||
└─────────┘ └─────────┘
|
||||
┌─────────┐ ┌─────────┐
|
||||
│ 体重 │ │ 体温 │
|
||||
└─────────┘ └─────────┘
|
||||
... (共 7 预设)
|
||||
┌─────────┐ ┌─────────┐
|
||||
│ 心率 │ │ + 自由 │
|
||||
└─────────┘ └─────────┘
|
||||
|
||||
—— 选中 metric 后,grid 下方展开 ——
|
||||
|
||||
【血压】参考范围:90-140 / 60-90 mmHg(成人通用)
|
||||
[收缩压 _____ mmHg]
|
||||
[舒张压 _____ mmHg]
|
||||
status chip 实时显示
|
||||
|
||||
[保存按钮]
|
||||
```
|
||||
|
||||
**关键交互**:
|
||||
- 进入 sheet 时无选中,grid 全展示
|
||||
- 点预设 → 高亮卡片 + 下方展开输入区
|
||||
- 切换 metric → 数值清空(避免血压数值串到血糖)
|
||||
- 选「+ 自由输入」→ 展开 4 个字段:名称 / 数值 / 单位 / 参考范围(string)
|
||||
- 保存:
|
||||
- 血压 → 2 条 Indicator(同 capturedAt + 各自 seriesKey)
|
||||
- 单字段预设 → 1 条 Indicator(seriesKey 填)
|
||||
- 身高预设 → 1 条 Indicator + 回写 UserProfile.heightCM
|
||||
- 自由输入 → 1 条 Indicator(seriesKey 为 nil,name 用户输入)
|
||||
|
||||
**Profile-aware 提示**:
|
||||
- 若 `effectiveRange` 跟 `baseRange` 不同,参考范围一行末尾小字:"按你的年龄(67)调整"
|
||||
- 若 `effectiveRange` 与 baseRange 相同 / 无 Profile,正常显示
|
||||
|
||||
### 4.2 MeView 改造
|
||||
|
||||
```
|
||||
[ScrollView]
|
||||
|
||||
┌─────────────────────────────────┐
|
||||
│ 个人资料 更多 →│
|
||||
│ 38岁 · 男 · 175cm · A型 │
|
||||
│ (未设置时:"点这里完善你的资料") │
|
||||
└─────────────────────────────────┘
|
||||
↓ tap push
|
||||
|
||||
┌─────────────────────────────────┐
|
||||
│ 模型管理 未配置 → │ (W6 stub)
|
||||
└─────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────┐
|
||||
│ Face ID 启动锁 关闭 → │ (W5 stub)
|
||||
└─────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────┐
|
||||
│ 关于 → │ (链接到隐私承诺 placeholder)
|
||||
└─────────────────────────────────┘
|
||||
|
||||
#if DEBUG
|
||||
DebugAIRunner
|
||||
#endif
|
||||
```
|
||||
|
||||
stub 卡片本次只放占位 + 文案,push 进去是空页或 placeholder。
|
||||
|
||||
### 4.3 ProfileEditView
|
||||
|
||||
`Features/Profile/ProfileEditView.swift`,Form 风格:
|
||||
|
||||
```
|
||||
导航标题:个人资料
|
||||
|
||||
—— 基本 ——
|
||||
出生年份 [picker 1900-2026]
|
||||
性别 [男 / 女 / 不愿透露 segmented]
|
||||
身高 [TextField + cm]
|
||||
血型 [A / B / AB / O / 不知道 picker]
|
||||
|
||||
—— 健康背景 ——
|
||||
过敏史 [chips + add field]
|
||||
慢病 [8 预设 chips 多选 + 自定义 add]
|
||||
家族史 [chips + add field]
|
||||
|
||||
—— 当前用药 ——
|
||||
[列表 + add row + 行内 swipe-to-delete]
|
||||
|
||||
(保存即时,无显式 Save 按钮——边改边写)
|
||||
```
|
||||
|
||||
慢病 8 预设:`高血压 / 糖尿病 / 冠心病 / 高血脂 / 甲状腺疾病 / 哮喘 / 慢性肾病 / 抑郁/焦虑`
|
||||
|
||||
### 4.4 Timeline 行内合并(顺手)
|
||||
|
||||
`Features/Timeline/TimelineEntry.swift`,`from(indicator:)` 增加配对逻辑:
|
||||
|
||||
```swift
|
||||
static func from(indicators: [Indicator]) -> [TimelineEntry] {
|
||||
// 旧版只 map,新版需要查找 bp.systolic 配对 bp.diastolic
|
||||
// 算法:同 capturedAt(到秒)+ bp.* prefix → 合并;其他单条直接 map
|
||||
}
|
||||
```
|
||||
|
||||
ArchiveListView 和 HomeView 的 `mapped` 表达式从 `indicators.map(...)` 改为 `TimelineEntry.from(indicators:)`(批处理)。
|
||||
|
||||
合并后的 TimelineEntry:
|
||||
- title: "血压"
|
||||
- subtitle: "120 / 80 mmHg"
|
||||
- trailing: 异常时显示"偏高"或"正常"
|
||||
|
||||
非 bp.* 的 series 不合并,逐条显示("空腹血糖 5.4 mmol/L" / "体重 68 kg")。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联动:Profile ↔ Monitor
|
||||
|
||||
### 5.1 调用路径
|
||||
|
||||
```
|
||||
IndicatorRecordSheet
|
||||
↓ @Query UserProfile (单例)
|
||||
MonitorMetric.effectiveRange(for: field, profile: profile)
|
||||
↓
|
||||
- 显示个性化参考范围
|
||||
- 保存时 MonitorMetric.status(value:, in: effectiveRange) 算 statusRaw
|
||||
```
|
||||
|
||||
### 5.2 未来扩展点
|
||||
|
||||
`effectiveRange` 是唯一规则入口,扩规则只动这个函数。规则示例(本次不实现):
|
||||
- 性别 → 血红蛋白、肌酐参考范围不同
|
||||
- 慢病 → 糖尿病患者血糖目标更严
|
||||
- 年龄分段 → 儿童体温、心率范围
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试
|
||||
|
||||
### 6.1 新建 `康康Tests/UserProfileTests.swift`
|
||||
|
||||
- `freshProfileHasNilDemographics()` — 新建 profile,字段都 nil/空数组
|
||||
- `ageComputedFromBirthYear()` — 1985 → 41 岁(2026 当前年)
|
||||
- `sexParsesEnumFromRaw()` — male/female/空 → 三种 enum
|
||||
- `loadOrCreateReturnsExistingSingleton()` — 第二次 call 不创建新 row
|
||||
- `arrayFieldsRoundtripThroughSwiftData()` — chronicConditions 存读
|
||||
|
||||
### 6.2 新建 `康康Tests/MonitorMetricTests.swift`
|
||||
|
||||
- `allMetricsHaveAtLeastOneField()`
|
||||
- `bpHasTwoFields()`
|
||||
- `statusHighWhenAboveUpper()` / `statusLowWhenBelowLower()` / `statusNormalWhenInside()` / `statusNormalWhenRangeNil()`
|
||||
- `bpUpperBoundShiftsForElderly()` — age 67 时 bp.systolic 上限 = 150
|
||||
- `bpUpperBoundUnchangedWhenNoProfile()` — profile 为 nil 时上限 = 140
|
||||
- `nonBPSeriesUnaffectedByProfile()` — 血糖范围不随年龄变
|
||||
|
||||
### 6.3 扩 `康康Tests/ModelsSchemaTests.swift`
|
||||
|
||||
- `userProfileSchemaPersistsAcrossSave()`
|
||||
- `indicatorSeriesKeyRoundtrip()`
|
||||
- `cascadeStillWorksWithSeriesKey()` — Report 删除时,关联 Indicator(无论 seriesKey)都删
|
||||
|
||||
### 6.4 扩 `康康Tests/TimelineGroupingTests.swift`
|
||||
|
||||
- `bpSystolicAndDiastolicMergeIntoSingleEntry()`
|
||||
- `nonBPSeriesStayAsSeparateEntries()`
|
||||
- `bpAtDifferentTimesDoNotMerge()` — capturedAt 差 > 5 秒不合并
|
||||
|
||||
预期总测试数:11(profile 5)+ 7(metric)+ 3(schema)+ 3(timeline)= 18 个新测试。
|
||||
|
||||
---
|
||||
|
||||
## 7. 不变项与守恒检查
|
||||
|
||||
- ✅ §10.1 不引入云服务 — 完全本地
|
||||
- ✅ §10.2 不自实现密码学 — SwiftData store 已有 file protection
|
||||
- ✅ §10.3 UI 不直接调 AIRuntime — 本设计不涉及 AI
|
||||
- ✅ §10.4 AIRuntime actor — 不涉及
|
||||
- ✅ §10.5 VL/LLM prompt — 不涉及
|
||||
- ⚠️ §10.6 新功能必须问"清单里有吗" — Monitor 和 Profile 都是清单外,**已跟用户确认加入**
|
||||
- ✅ §10.7 不重构现有骨架 — 不动 RootView / RecordSheet 骨架(只补 case 处理),不动 DesignSystem
|
||||
- ✅ §10.8 C2 ≠ B3 — 不涉及
|
||||
|
||||
---
|
||||
|
||||
## 8. 文件清单
|
||||
|
||||
### 新建(6)
|
||||
|
||||
| 路径 | 职责 |
|
||||
|---|---|
|
||||
| `康康/Models/UserProfile.swift` | UserProfile @Model + Sex enum + age computed + loadOrCreate helper |
|
||||
| `康康/Features/Monitor/MonitorMetric.swift` | 8 metric catalog + effectiveRange + status 算法 |
|
||||
| `康康/Features/Indicator/IndicatorRecordSheet.swift` | 预设 grid + 自由输入合一的录入 sheet |
|
||||
| `康康/Features/Profile/ProfileEditView.swift` | Form 编辑页 |
|
||||
| `康康Tests/UserProfileTests.swift` | 5 测试 |
|
||||
| `康康Tests/MonitorMetricTests.swift` | 7 测试 |
|
||||
|
||||
### 修改(7)
|
||||
|
||||
| 路径 | 改什么 |
|
||||
|---|---|
|
||||
| `康康/Models/Models.swift` | Indicator 加 `seriesKey: String?`,初始化器加默认值 nil |
|
||||
| `康康/App/KangkangApp.swift` | schema 加 `UserProfile.self` |
|
||||
| `康康/Features/Me/MeView.swift` | 加 ProfileCard + 3 个 stub 卡片 |
|
||||
| `康康/RootView.swift` | `.indicator` case 接 IndicatorRecordSheet 弹出 |
|
||||
| `康康/Features/Timeline/TimelineEntry.swift` | 加 `from(indicators:)` 批处理 + bp 配对 |
|
||||
| `康康Tests/ModelsSchemaTests.swift` | 3 个新测试 |
|
||||
| `康康Tests/TimelineGroupingTests.swift` | 3 个新测试 |
|
||||
|
||||
### 文档(2)
|
||||
|
||||
| 路径 | 改什么 |
|
||||
|---|---|
|
||||
| `CLAUDE.md` | §5 加 UserProfile @Model + Indicator seriesKey;§7 IA 加 Profile 入口;§11 时间表加备注;§10.6 例外清单加 Monitor + Profile |
|
||||
| `docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md` | 本文件 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收
|
||||
|
||||
- [ ] App build & test 全绿,0 警告
|
||||
- [ ] DEBUG 启动 → 我的 → 个人资料 → 填年龄 + 性别 + 身高 + 血型,push back 显示在 ProfileCard
|
||||
- [ ] DEBUG 启动 → + 号 → 指标记录 → 选血压 → 输 145/85 → 保存 → 在首页时间线看到合并的"血压 145/85"行
|
||||
- [ ] 把 UserProfile birthYear 改成 1955(70 岁) → 再次进血压录入 → 顶部小字显示"按你的年龄(70)调整",参考范围 90-150 / 60-90
|
||||
- [ ] 录入身高 175 → 个人资料卡片自动显示 175cm
|
||||
- [ ] 18 个新测试全绿
|
||||
|
||||
---
|
||||
|
||||
## 10. 估时
|
||||
|
||||
- 数据层(UserProfile + Indicator.seriesKey + schema 注册):20 分钟
|
||||
- MonitorMetric catalog + effectiveRange:20 分钟
|
||||
- IndicatorRecordSheet UI:25 分钟
|
||||
- ProfileEditView + MeView 改造:25 分钟
|
||||
- Timeline 合并:15 分钟
|
||||
- 18 测试:30 分钟
|
||||
- CLAUDE.md + 提交整理:15 分钟
|
||||
|
||||
**总计 ~150 分钟**(2.5 小时)。
|
||||
@@ -0,0 +1,430 @@
|
||||
# 导出身体档案 — 设计文档
|
||||
|
||||
**日期**:2026-05-27 (W2)
|
||||
**作者**:link2026 + Claude
|
||||
**关联卖点**:#1 影像档案系统、#2 100% 本地、#3 本地 RAG 长期记忆、#4 隐私三件套、#6 Live Activity tok/s
|
||||
**优先级**:P0(打通 RAG 链路 + demo 主要演示场景)
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
在「记录」Tab 顶部增加「导出身体档案」入口,用户输入自然语言主诉(如「我感冒 3 天,把最近一个月给医生看」),完全本地的两段式 RAG 把 SwiftData 里相关的指标 / 报告 / 症状 / 日记 / 个人资料检索并生成给医生看的 Markdown 摘要,可复制、分享、查看历史。
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户故事
|
||||
|
||||
> 周日晚上,我感冒第 3 天还没好。明早要去社区医院,医生只有 5 分钟问诊,我想把过去一个月的体温记录、上次体检的关键异常项、在服的降压药、家族过敏史一次性整理出来给医生。我不想把这些数据上传到任何云。
|
||||
|
||||
成功标准:
|
||||
|
||||
- 输入 prompt → 30 秒内出现首字 → 90 秒内完整生成
|
||||
- 输出 Markdown 包含主诉 / 患者背景 / 近期症状 / 关键指标 / 在服药与过敏 / 患者疑问
|
||||
- 一键复制到微信发给医生,或直接 AirDrop / 邮件分享
|
||||
- 重启 App 后能看到历史导出
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围
|
||||
|
||||
**做**:
|
||||
|
||||
- 记录 Tab 右上角 toolbar「导出」按钮
|
||||
- ArchiveListView 顶部「我的导出」横向卡区(有历史时显示,前 3 条 + 查看全部)
|
||||
- 全屏 sheet:prompt 输入 / Phase 指示 / 流式 Markdown / 完成后复制+分享+重新生成
|
||||
- 历史列表页 + 详情页
|
||||
- 两段式 RAG 链路:Qwen3-1.7B 抽意图 → SwiftData 结构化检索 → Qwen3-1.7B 生成 Markdown
|
||||
- 新 `HealthExport` @Model + Schema 注册
|
||||
- 引用回链(referencedXxxIDs,W3 再做点击跳转)
|
||||
|
||||
**不做**:
|
||||
|
||||
- embedding / 向量检索
|
||||
- 跨设备同步、云端备份
|
||||
- PDF 导出(W6 余力再说)
|
||||
- 给医生的诊断建议 / 用药建议(红线 §10.1)
|
||||
- 自动定期导出(此版无 schedule)
|
||||
|
||||
---
|
||||
|
||||
## 4. 架构
|
||||
|
||||
```
|
||||
┌─ UI ─────────────────────────────────────────────────────┐
|
||||
│ ArchiveListView │
|
||||
│ ├─ .toolbar trailing: "导出" 图标按钮 │
|
||||
│ └─ 顶部横向卡区 HealthExportRecentStrip(有历史时显示)│
|
||||
│ │ │
|
||||
│ └─→ HealthExportSheet (full-screen cover) │
|
||||
│ ├─ prompt TextEditor │
|
||||
│ ├─ Phase 状态条 │
|
||||
│ ├─ Markdown 流式渲染 │
|
||||
│ └─ Actions: 复制 / 分享 / 重新生成 │
|
||||
│ │
|
||||
│ HealthExportListView (NavigationLink "查看全部") │
|
||||
│ └─ 全部历史(@Query DESC)→ HealthExportDetailView │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
↑ Event 流
|
||||
┌─ Service ────────────────────────────────────────────────┐
|
||||
│ HealthExportService (struct, DI ModelContext + Runtime) │
|
||||
│ func export(prompt:) -> AsyncThrowingStream<Event> │
|
||||
│ Event = .phaseChanged(Phase) | .token(TokenChunk) │
|
||||
│ | .completed(HealthExport) | .failed(Error) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
↑ 串行排队
|
||||
┌─ AI 层 (已存在) ──────────────────────────────────────────┐
|
||||
│ AIRuntime(actor 单例)→ LLMSession 串行两次调用 │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
↑ 检索
|
||||
┌─ Persistence (SwiftData) ────────────────────────────────┐
|
||||
│ Indicator / Report / Symptom / DiaryEntry / │
|
||||
│ UserProfile / HealthExport(新增) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**红线对齐**(CLAUDE.md §10):
|
||||
|
||||
- UI 不直接调 AIRuntime,只与 HealthExportService 通讯 ✅
|
||||
- AIRuntime 仍是 actor 单例,两段调用在它的队列内串行,与 CaptureService / 未来的 AskService 互不抢占 GPU ✅
|
||||
- 两个 prompt 都带 few-shot + 失败回退 ✅
|
||||
- 不引入云服务、不自实现密码学、不重构现有 Tab/RecordSheet ✅
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
新增 `Models/HealthExport.swift`:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model final class HealthExport {
|
||||
var id: UUID = UUID()
|
||||
var prompt: String = "" // 用户原始输入
|
||||
var content: String = "" // 生成的 Markdown 全文
|
||||
var createdAt: Date = .now
|
||||
|
||||
// 引用回链(对齐 §3.3)
|
||||
var referencedIndicatorIDs: [UUID] = []
|
||||
var referencedReportIDs: [UUID] = []
|
||||
var referencedSymptomIDs: [UUID] = []
|
||||
var referencedDiaryIDs: [UUID] = []
|
||||
|
||||
// 意图抽取快照(供"重新生成"复用,不再调一次 LLM)
|
||||
var inferredTimeFromDate: Date?
|
||||
var inferredTimeToDate: Date?
|
||||
var inferredIntent: String?
|
||||
|
||||
// demo 卖点凭证
|
||||
var modelTag: String = "Qwen3-1.7B-4bit"
|
||||
var decodeRate: Double = 0 // 末次 tok/s
|
||||
|
||||
init() {}
|
||||
}
|
||||
```
|
||||
|
||||
**Schema 注册**:`App/KangkangApp.swift` 的 `ModelContainer(for:)` 加入 `HealthExport.self`(增表是 SwiftData 兼容变更,无需手写迁移)。
|
||||
|
||||
**为什么 `referenced*IDs` 用 `[UUID]` 而不是 SwiftData 关系**:
|
||||
导出是历史快照,源 Indicator / Report 可能后续被用户永久删除(§10.4);弱关联避免 cascade 影响历史导出本身。点击跳转时,源记录若已不存在,UI 显示「记录已删除」灰态。
|
||||
|
||||
---
|
||||
|
||||
## 6. 状态机 + 数据流
|
||||
|
||||
`HealthExportService.export(prompt:)` 是 `AsyncThrowingStream<Event, Error>`:
|
||||
|
||||
```swift
|
||||
enum Phase: String {
|
||||
case extractingIntent // 理解意图
|
||||
case retrieving // 检索数据
|
||||
case generating // 撰写报告
|
||||
case completed
|
||||
}
|
||||
|
||||
enum Event {
|
||||
case phaseChanged(Phase)
|
||||
case token(TokenChunk)
|
||||
case completed(HealthExport)
|
||||
case failed(Error)
|
||||
}
|
||||
```
|
||||
|
||||
**流程**:
|
||||
|
||||
```
|
||||
.idle
|
||||
│ user tap 生成
|
||||
▼
|
||||
phaseChanged(.extractingIntent)
|
||||
│ LLMSession.generate(prompt: INTENT_PROMPT, maxTokens: 120)
|
||||
│ 失败 → 回退到默认 {time_range_days: 30, keywords: [], symptom_keywords: []}
|
||||
▼
|
||||
phaseChanged(.retrieving)
|
||||
│ 同步 SwiftData 查询:
|
||||
│ - Indicator where capturedAt ∈ [from, to], 可选按 keyword 过滤 name/seriesKey
|
||||
│ - Report where reportDate ∈ [from, to]
|
||||
│ - Symptom where startedAt <= to AND (endedAt == nil OR endedAt >= from)
|
||||
│ - DiaryEntry where createdAt ∈ [from, to] AND content contains any symptom_keyword
|
||||
│ (privacy 过滤:无主诉相关关键词的日记不入 prompt;
|
||||
│ 若 symptom_keywords 为空,则一律不包含日记 —— 安全默认)
|
||||
│ - UserProfile 单例,无条件包含
|
||||
▼
|
||||
phaseChanged(.generating)
|
||||
│ 拼 GENERATION_PROMPT(把上一步结果序列化为简短结构)
|
||||
│ LLMSession.generate(prompt:, maxTokens: 1024)
|
||||
│ for token in stream: yield .token(chunk)
|
||||
▼
|
||||
phaseChanged(.completed)
|
||||
│ build HealthExport(prompt, content, referencedIDs, inferred*, decodeRate)
|
||||
│ modelContext.insert + try modelContext.save()
|
||||
▼
|
||||
.completed(healthExport)
|
||||
```
|
||||
|
||||
**取消语义**:UI 关闭 sheet → stream 被取消 → 中间态不入库。
|
||||
|
||||
**与 AIRuntime 互斥**:HealthExportService 在 `AIRuntime` 的 actor 函数里调度两次 LLM 调用;若此时 CaptureService 正在跑 VL,自然在 actor 队列里等待。Phase indicator 在 UI 上显示「排队中」(可选,W3 polish)。
|
||||
|
||||
---
|
||||
|
||||
## 7. Prompt 设计
|
||||
|
||||
两个 prompt 都放在 `AI/Prompts/HealthExportPrompts.swift`,带 2 个 few-shot。
|
||||
|
||||
### 7.1 意图抽取(Qwen3-1.7B,~120 token 输出)
|
||||
|
||||
```text
|
||||
你是健康数据助手。读用户的请求,只输出严格 JSON,不要任何解释或 Markdown。
|
||||
|
||||
字段:
|
||||
{
|
||||
"time_range_days": int, // 时间窗,默认 30
|
||||
"keywords": [string], // 指标关键词(中文,如"血压"/"血糖"/"体温")
|
||||
"symptom_keywords": [string], // 症状关键词
|
||||
"intent": string // 简短意图标签
|
||||
}
|
||||
|
||||
示例 1:
|
||||
User: 我感冒3天了,要把最近一个月的健康情况给医生看
|
||||
Output: {"time_range_days":30,"keywords":["体温","血压","脉搏"],"symptom_keywords":["感冒","咳嗽","咽喉痛","发烧"],"intent":"cold_consult"}
|
||||
|
||||
示例 2:
|
||||
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
|
||||
Output: {"time_range_days":90,"keywords":["血糖","糖化血红蛋白","胰岛素"],"symptom_keywords":[],"intent":"glucose_review"}
|
||||
|
||||
User: {{USER_PROMPT}}
|
||||
Output:
|
||||
```
|
||||
|
||||
**解析容错**:
|
||||
- 非 JSON → 抓 `{…}` 之间的子串再试一次
|
||||
- 仍失败 → 用默认 `{30, [], []}`,继续流程,不报错给用户
|
||||
|
||||
### 7.2 报告生成(Qwen3-1.7B,maxTokens 1024)
|
||||
|
||||
```text
|
||||
你正在帮患者撰写一份给社区医生看的就诊摘要。
|
||||
要求:
|
||||
- 输出 Markdown,严格按下方结构
|
||||
- 只用「数据」中提供的信息,数据缺失就写"无记录"
|
||||
- 不要给诊断意见、不要给用药建议、不要写"建议就医"
|
||||
- 引用具体数值时保留单位和参考范围
|
||||
- 全文中文,简洁,医生 30 秒能扫完
|
||||
|
||||
结构:
|
||||
# 就诊摘要 — {{INTENT_LABEL_CN}}
|
||||
## 主诉
|
||||
## 患者背景
|
||||
## 近期症状(按时间倒序)
|
||||
## 关键指标(异常项优先)
|
||||
## 在服药与过敏
|
||||
## 患者疑问
|
||||
|
||||
数据:
|
||||
{{SERIALIZED_DATA_JSON}}
|
||||
|
||||
患者原话:{{USER_PROMPT}}
|
||||
|
||||
现在生成:
|
||||
```
|
||||
|
||||
**SERIALIZED_DATA_JSON 结构**(给 LLM 看的精简结构):
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": {
|
||||
"age": 38, "sex": "男", "height_cm": 172,
|
||||
"allergies": ["青霉素"],
|
||||
"chronic": ["高血压(2 年)"],
|
||||
"family_history": ["父亲冠心病"],
|
||||
"current_meds": ["缬沙坦 80mg qd"]
|
||||
},
|
||||
"symptoms": [
|
||||
{"name": "感冒", "started": "2026-05-24", "severity": 2,
|
||||
"ongoing": true, "note": "鼻塞、低烧"}
|
||||
],
|
||||
"indicators": [
|
||||
{"name": "收缩压", "value": 142, "unit": "mmHg", "range": "<140",
|
||||
"status": "high", "date": "2026-05-26"}
|
||||
],
|
||||
"reports": [
|
||||
{"title": "年度体检", "type": "physical", "date": "2026-04-12",
|
||||
"institution": "瑞金医院"}
|
||||
],
|
||||
"diaries": [
|
||||
{"date": "2026-05-25", "excerpt": "夜里两点醒了一次,头痛 7/10"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. UI 详细设计
|
||||
|
||||
### 8.1 ArchiveListView 改动
|
||||
|
||||
- toolbar trailing 加按钮:`Image(systemName: "doc.text.below.ecg") "导出"`
|
||||
- 在 `List` 顶部插入 `HealthExportRecentStrip()`(若 `@Query HealthExport` 非空)
|
||||
- 横向卡区,3 条最近导出 + 末尾「查看全部 →」卡,点击进入 `HealthExportListView`
|
||||
|
||||
### 8.2 HealthExportSheet (full-screen cover)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ ✕ 导出身体档案 本地·永不上传 │ Header
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 例:我感冒3天了,把最近一个月给医生看 │ Hint
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ (多行 TextEditor,~6 行) │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ [ 生成报告 ] │ TjPrimaryButton
|
||||
├──────────────────────────────────────────────┤
|
||||
│ ●─○─○ 理解意图 │ Phase pill,
|
||||
│ │ 生成时显示
|
||||
│ 本地推理 · Qwen3 · 24.3 tok/s │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ # 就诊摘要 — 感冒就诊 │
|
||||
│ ## 主诉 │ Markdown 流式
|
||||
│ 患者男,38 岁…… │ 渲染(原生
|
||||
│ …(打字机效果)… │ Text(LocalizedStringKey))
|
||||
│ │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ [ 复制 ] [ 分享 ] [ 重新生成 ] │ 完成后才显示
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 「分享」用系统 `ShareLink(item: content)`,导出纯文本
|
||||
- 「重新生成」复用同一 `prompt` + `inferred*` 字段,跳过意图抽取,直接走 retrieving + generating
|
||||
- 持久化时机:`.completed` 事件触发时由 Service 立即 `insert + save`;sheet 关闭只是 dismiss 视图,不再写库
|
||||
- 生成中按 ✕ → 取消 stream → 不入库;已生成完成后按 ✕ → 仅 dismiss(数据已在库中)
|
||||
|
||||
### 8.3 HealthExportListView
|
||||
|
||||
简单的 `List` + `@Query(sort: \.createdAt, order: .reverse)`,每条显示:
|
||||
- 标题:`HealthExport.prompt` 截断到 60 字
|
||||
- 副标题:`relativeDate(createdAt)` + `tok/s` 标签
|
||||
- 滑动删除
|
||||
|
||||
### 8.4 HealthExportDetailView
|
||||
|
||||
- 只读 Markdown(复用 sheet 的渲染组件)
|
||||
- 顶部信息条:生成时间 / 模型 tag / tok/s
|
||||
- toolbar:复制 / 分享 / 删除
|
||||
- W3 再补:`referenced*IDs` 转 Pill,点击跳源记录(此 spec 不阻塞)
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
| 情况 | 行为 |
|
||||
|---|---|
|
||||
| 模型未就绪 | toolbar 按钮置灰 + 副标题「模型未就绪,前往下载」(对齐 §4) |
|
||||
| 意图抽取 JSON 解析失败 | 默认 `{30 days, [], []}` 兜底,流程继续,不报错给用户 |
|
||||
| SwiftData 查询为空 | 数据段填 `"无记录"`,LLM 仍生成结构化"无明显异常"摘要 |
|
||||
| 生成 stream 中途取消 | Service 抛 `CancellationError`,UI 显示「已取消」,不入库 |
|
||||
| 生成超时 (>120s) | `Task.withTimeout` 超时取消,UI 同取消逻辑 |
|
||||
| LLM 抛错(显存等) | UI 显示「生成失败:{msg}」+ 重试按钮 |
|
||||
| `modelContext.save` 失败 | 仅日志,UI 仍展示文本,提示「保存失败,请重试」 |
|
||||
|
||||
**安全:** 全程不调用任何网络;`HealthExport` 持久化继承 §6 的 `.completeFileProtection`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
**单元(`HealthExportServiceTests`)**:
|
||||
|
||||
- mock `AIRuntime` 协议(新增 `protocol AIRuntimeProtocol`,actor 单例符合该协议)
|
||||
- 给定固定 SwiftData in-memory + 已知 Indicator/Symptom → 验证 referencedIDs 正确
|
||||
- 意图抽取返回非 JSON → 验证回退到默认 30 天
|
||||
- 验证 Phase 转换顺序:`.extractingIntent → .retrieving → .generating → .completed`
|
||||
- 取消语义:在 `.generating` 阶段取消 → 不入库
|
||||
|
||||
**Preview**:
|
||||
|
||||
- `HealthExportSheet` 用 mock service 吐预设 Markdown(打字机视效在 Preview 即可看到)
|
||||
- `HealthExportListView` 用 3 条 fake `HealthExport`
|
||||
|
||||
**真机验收**(W3 末):
|
||||
|
||||
- 在 16 inch M3 Max 模拟器上跑通(simulator 走 CPU,慢但能跑通流程)
|
||||
- 真机 iPhone 15 Pro:首字 ≤ 10s,完整生成 ≤ 60s,tok/s ≥ 20
|
||||
- 关 WiFi + 飞行模式仍能正常生成(隐私三件套 demo 关键)
|
||||
|
||||
---
|
||||
|
||||
## 11. 与现有/未来代码的关系
|
||||
|
||||
- **复用**:`AIRuntime` / `LLMSession` / `TokenChunk` / `Tj.*` Design System
|
||||
- **铺路**:`HealthExportService` 的两段式 RAG 工程模式直接复用给 W3 的 `AskService`(只需替换 generation prompt + 输出形态)
|
||||
- **不冲突**:`CaptureService` 在 AIRuntime 队列里和本服务串行;两者不会同时占 GPU
|
||||
- **不影响**:`ArchiveListView` / `RecordSheet` / 现有 7 个 @Model 都不需要重构
|
||||
|
||||
---
|
||||
|
||||
## 12. 取舍记录
|
||||
|
||||
| 决策 | 选择 | 拒绝的方案 | 理由 |
|
||||
|---|---|---|---|
|
||||
| 入口位置 | 「记录」Tab toolbar | RecordSheet 加一项 | 语义:RecordSheet 是「写入」,导出是「读出」 |
|
||||
| 数据范围 | Indicator+Report+Symptom+Profile+Diary | 仅 Indicator+Report | 「感冒 3 天」需要 Symptom;医生需要 Profile;Diary 由 LLM 关键词过滤后入 prompt,降低隐私风险 |
|
||||
| 历史位置 | ArchiveListView 顶部横向卡区 + 查看全部 | 「我的」Tab 加历史入口 | 路径更短;符合「记录 Tab=身体档案」语义 |
|
||||
| Pipeline | 严格两段式 RAG | 单段 LLM / 模板化 | 准确性 + 复用给 AskService + demo 卖点 #3 |
|
||||
| Markdown 渲染 | SwiftUI 原生 `Text(LocalizedStringKey)` | 第三方 Markdown 库 | YAGNI;W6 polish 时再评估 |
|
||||
| referenced 关联 | `[UUID]` 弱关联 | SwiftData 关系 | 历史快照 vs 源记录可被永久删除 |
|
||||
| Live Activity | 此版只在 Service 暴露 decodeRate,UI 显示数字 | 此版直接接 ActivityKit | W5 真机阶段统一接,与 AskService 共用一套 Activity |
|
||||
|
||||
---
|
||||
|
||||
## 13. 排期估算(放在 W2 末 ~ W3 初)
|
||||
|
||||
| 步骤 | 工作量 |
|
||||
|---|---|
|
||||
| HealthExport @Model + Schema 注册 | 0.5h |
|
||||
| HealthExportPrompts(两个 prompt + few-shot 调试) | 2h |
|
||||
| HealthExportService(状态机 + 两段调用 + 检索) | 4h |
|
||||
| HealthExportSheet(输入 + Phase + 流式渲染 + 三按钮) | 3h |
|
||||
| ArchiveListView toolbar + RecentStrip | 1.5h |
|
||||
| HealthExportListView + DetailView | 1.5h |
|
||||
| 单元测试 + 真机验收 | 2h |
|
||||
| **合计** | **~14h ≈ 2 个工作日** |
|
||||
|
||||
也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。
|
||||
|
||||
---
|
||||
|
||||
## 14. 修订记录:防编造加固(2026-05-30)
|
||||
|
||||
**现象**:导出摘要出现整份虚构病例(疲劳/盗汗/血红蛋白98/阿司匹林…),不符任何真实记录。
|
||||
|
||||
**根因(双重)**:① §数据范围里「Diary 由关键词过滤后入 prompt」在泛化请求(无症状词,如「最近身体异常」)下把日记**全部清空** → 真实记录没进 prompt;② 数据稀疏时,1.7B 在固定 6 段模板上**凭训练先验脑补**完整病例(对「只用数据/缺失写无记录」这类约束遵循差)。
|
||||
|
||||
**修复(三层,客户端硬保证为主)**:
|
||||
1. **检索**:`retrieve` 改为——有症状词→按词过滤(保留隐私);无症状词→纳入时间窗内最近 5 条日记,确保真实记录进 prompt。
|
||||
2. **空数据硬兜底**:`isEffectivelyEmpty` 判定无任何记录且 profile 空时,**跳过 LLM**,用 `fallbackReport` 产出确定性「6 段全无记录、主诉仅照搬原话」的摘要,从根上杜绝空数据编造。
|
||||
3. **prompt 重写**:从「撰写」改为「抽取/搬运」框架;反编造铁律首尾各一遍;加一条**稀疏 few-shot** 教模型「缺失写无记录、数值原样照搬」。
|
||||
|
||||
**残留限制**:部分数据(如仅 1 条日记)仍走 LLM,强约束 + few-shot 大幅降低但不能 100% 杜绝小模型臆造;后续可加生成后数值校验。
|
||||
180
docs/superpowers/specs/2026-05-29-model-download-design.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 模型自动下载功能设计(2026-05-29)
|
||||
|
||||
> 让用户在「我的 · 模型管理」页一键从自建 HTTPS 服务下载两个 MLX 模型,支持断点续传、
|
||||
> 进度展示和现场重装的旁路导入兜底。对应 CLAUDE.md §4「模型分发」与 W6「首启动下载流程」的核心部分。
|
||||
|
||||
## 1. 背景与现状
|
||||
|
||||
- 模型加载链路已通:`LLMSession`/`VLSession` 用 `ModelConfiguration(directory:)` 从沙盒
|
||||
`Application Support/Models/<repo>/` 读取,`AIRuntime.prepare()/prepareVL()` 在
|
||||
`ModelStore.isReady()` 为假时抛 `notReady`。
|
||||
- **缺口**:没有任何下载实现。`ModelStore` 只有 `isReady()` 判定 + `seedFromBundle()` 占位;
|
||||
唯一能装模型的路径是 DEBUG-only 的 `DebugAIRunner` 手动 `fileImporter`(且只导 LLM,漏 VL)。
|
||||
- `MeView` 已预留「模型管理」卡片(`detail="未配置"`,icon `cpu`),尚未连接任何界面。
|
||||
- `HealthExportService` 的未就绪文案已写「请先到『我的 · 模型管理』下载」,落点早有预期。
|
||||
- 无 Onboarding / 首启动流程。
|
||||
|
||||
## 2. 服务器素材(已就绪并验证)
|
||||
|
||||
- 自建 Caddy 静态文件服务,文件根 `/srv/models/`。
|
||||
- base URL:**`https://file.myv0.com/`**(用户自建反代,标准 HTTPS)。
|
||||
- 备选:`http://101.132.124.52:5244/`(纯 IP,需 App 端 ATS 例外;域名挂了时用)。
|
||||
- 已验证:`config.json` 返回 200;两个 `model.safetensors` 均支持 Range(`206` + `Accept-Ranges: bytes`),
|
||||
反代返回的总大小与本机精确一致(LLM 968080210、VL 3073720461),未截断大文件。
|
||||
- 服务器 24 个真实文件,字节数与本机逐一匹配(LLM 984015687、VL 3089713215)。
|
||||
|
||||
## 3. 范围
|
||||
|
||||
**做**:模型管理页(分模型卡片)、HTTPS 断点续传下载、大小校验、蜂窝网络提示、
|
||||
旁路文件导入(LLM + VL)、MeView 接入、AI 入口未就绪「前往下载」引导。
|
||||
|
||||
**不做(YAGNI)**:首启动 Onboarding、启动自动后台下载、哈希校验(大小校验够)、
|
||||
Live Activity 下载进度(Live Activity 是推理时的 tok/s,单独功能)、并行多文件下载。
|
||||
|
||||
## 4. 架构(方案 A:独立 Service + ModelStore 保持纯存储)
|
||||
|
||||
```
|
||||
ModelManagementView (UI)
|
||||
→ ModelDownloadService (@MainActor @Observable,下载编排 + 进度状态)
|
||||
→ ModelStore (文件路径 / 就绪判定 / 旁路导入)
|
||||
→ URLSession (HTTPS 分块下载)
|
||||
```
|
||||
|
||||
- 符合 §3.1 模块边界:UI 不直接碰 `URLSession`,只观察 Service 发布的状态。
|
||||
- `ModelDownloadService` 与现有 `CaptureService`/`AskService` 并列。
|
||||
- `ModelStore` 继续只管「模型在哪 / 是否就绪 / 旁路拷入」,不引入网络职责。
|
||||
|
||||
### 4.1 下载状态模型
|
||||
|
||||
```swift
|
||||
enum DownloadPhase: Equatable {
|
||||
case idle // 待下载
|
||||
case downloading // 下载中
|
||||
case verifying // 校验中
|
||||
case ready // 已就绪
|
||||
case failed(String) // 失败 · 可重试
|
||||
}
|
||||
|
||||
struct DownloadState: Equatable {
|
||||
var phase: DownloadPhase
|
||||
var receivedBytes: Int
|
||||
var totalBytes: Int
|
||||
var bytesPerSecond: Double
|
||||
var fraction: Double { totalBytes > 0 ? Double(receivedBytes) / Double(totalBytes) : 0 }
|
||||
}
|
||||
```
|
||||
|
||||
`ModelDownloadService` 持有 `var states: [ModelKind: DownloadState]`,`@MainActor` 更新,UI 观察。
|
||||
|
||||
## 5. 数据:硬编码 manifest
|
||||
|
||||
```swift
|
||||
struct ModelFile { let path: String; let bytes: Int } // path 相对模型目录
|
||||
|
||||
enum ModelManifest {
|
||||
static let baseURL = URL(string: "https://file.myv0.com/")!
|
||||
static func files(for kind: ModelKind) -> [ModelFile]
|
||||
static func totalBytes(for kind: ModelKind) -> Int // files.reduce
|
||||
}
|
||||
```
|
||||
|
||||
- 只列**加载必需**的功能文件,排除纯文档 `README.md` / `.gitattributes`(省下载)。
|
||||
- 文件 URL = `baseURL / kind.rawValue / file.path`。
|
||||
- `bytes` 用于总进度计算与下载后**逐文件大小校验**。
|
||||
- 精确清单见附录 A。
|
||||
|
||||
## 6. 下载流程(断点续传,应对 3GB 单文件)
|
||||
|
||||
逐文件**串行**下载,单文件级续传用 **HTTP Range + 追加写**(比 `URLSession.resumeData` 更可控,
|
||||
app 重启也能续):
|
||||
|
||||
1. 目标 `Models/<repo>/<file>` 已存在且 size 匹配 → 跳过(粗粒度续传)。
|
||||
2. 否则下到 `Models/<repo>/<file>.part`:已下字节数 = `.part` 当前大小,
|
||||
发 `Range: bytes=<已下>-` 请求,`URLSession` data delegate 流式 `FileHandle` 追加写。
|
||||
3. 完成后校验 `.part` 大小 == manifest `bytes`,原子 `rename` 去掉 `.part` 后缀。
|
||||
4. 该模型全部文件就位 → `ModelStore.isReady` 自然为真。
|
||||
|
||||
- 串行(一次一个文件):不抢 MLX 资源、进度计算清晰。
|
||||
- 总进度 = 已完成字节 / `totalBytes(for:)`;速度用滑动窗口算 bytes/s。
|
||||
- 支持「暂停」:取消当前 task,`.part` 保留,下次从断点续。
|
||||
|
||||
## 7. UI
|
||||
|
||||
### 7.1 `ModelManagementView`(分模型卡片)
|
||||
|
||||
- 两张卡:
|
||||
- **Qwen3-1.7B · 文本解读**(约 939 MB)
|
||||
- **Qwen2.5-VL-3B · 拍照识别**(约 2.9 GB)
|
||||
- 每张卡显示:状态 `待下载 / 下载中 xx% · x.x MB/s / 校验中 / 已就绪 ✅ / 失败 · 重试`
|
||||
+ 进度条(原生 `ProgressView` + `Tj.Palette`)+ 大小。
|
||||
- 顶部总操作 `下载全部模型`(`TjPrimaryButton`);下载中切为 `暂停`。
|
||||
- **蜂窝网络提示**:`NWPathMonitor` 检测到非 WiFi,开下前弹确认("约 3.9GB,建议 WiFi 下载")。
|
||||
- 底部 `从文件导入`(`TjGhostButton`)→ 旁路导入。
|
||||
- 复用 `.tjCard` / `TjBadge` / `TjLockChip`,不新增设计 token(§9)。
|
||||
|
||||
### 7.2 旁路导入(现场重装兜底)
|
||||
|
||||
把 `DebugAIRunner` 的 `fileImporter` 逻辑转正进 Service / `ModelStore`:
|
||||
|
||||
- 选文件夹 → 校验含 `config.json` → 拷入 `Models/<repo>/`。
|
||||
- **补上 VL**(现在 DEBUG 只导 LLM)。
|
||||
- 按所选文件夹名匹配 `ModelKind.rawValue` 自动识别是 LLM 还是 VL;不匹配时提示选择。
|
||||
|
||||
## 8. 接入点
|
||||
|
||||
- `MeView` 「模型管理」卡片 → `NavigationLink` 到 `ModelManagementView`;
|
||||
`detail` 动态显示 `已就绪 / 未下载 / 下载中 xx%`。
|
||||
- **AI 入口未就绪引导**(§4 要求):`DiaryQuickSheet`、`UnifiedCaptureFlow`、`HealthExport`
|
||||
的「模型未就绪」错误态补 `前往下载` 按钮,跳 `ModelManagementView`。
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
- 网络中断 → 卡片转 `失败 · 重试`,保留 `.part` 供下次续传,不卡死、不删已下数据。
|
||||
- 校验失败(size 不符)→ 删该文件重下。
|
||||
- 旁路导入选错文件夹(无 `config.json`)→ 提示,不写入。
|
||||
- base URL 不可达 → 失败态,文案提示检查网络。
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
- 单元测试(用 `URLProtocol` mock 网络,不碰真 MLX / SwiftData):
|
||||
- `ModelManifest.totalBytes` 计算正确。
|
||||
- 续传偏移计算:`.part` 已有 N 字节时请求 `Range: bytes=N-`。
|
||||
- 大小校验:size 不符判失败。
|
||||
- `DownloadState.fraction` 边界(totalBytes=0)。
|
||||
- `ModelStore.isReady` 在文件齐全 / 缺失时的判定。
|
||||
- UI 手动验证:模拟器跑下载流程(指向真实 base URL 或 mock)。
|
||||
|
||||
## 附录 A:精确文件清单(功能文件,排除 README/.gitattributes)
|
||||
|
||||
### Qwen3-1.7B-4bit(9 文件,984,013,244 字节,≈939 MB)
|
||||
|
||||
| path | bytes |
|
||||
|---|---|
|
||||
| config.json | 937 |
|
||||
| model.safetensors | 968080210 |
|
||||
| model.safetensors.index.json | 49731 |
|
||||
| tokenizer.json | 11422654 |
|
||||
| tokenizer_config.json | 9706 |
|
||||
| vocab.json | 2776833 |
|
||||
| merges.txt | 1671853 |
|
||||
| special_tokens_map.json | 613 |
|
||||
| added_tokens.json | 707 |
|
||||
|
||||
### Qwen2.5-VL-3B-Instruct-4bit(11 文件,3,089,710,883 字节,≈2.9 GB)
|
||||
|
||||
| path | bytes |
|
||||
|---|---|
|
||||
| config.json | 1659 |
|
||||
| model.safetensors | 3073720461 |
|
||||
| model.safetensors.index.json | 108307 |
|
||||
| tokenizer.json | 11421896 |
|
||||
| tokenizer_config.json | 7256 |
|
||||
| vocab.json | 2776833 |
|
||||
| merges.txt | 1671853 |
|
||||
| special_tokens_map.json | 613 |
|
||||
| added_tokens.json | 605 |
|
||||
| chat_template.json | 1050 |
|
||||
| preprocessor_config.json | 350 |
|
||||
|
||||
> 注:进度分母 = 本表功能文件 `bytes` 之和(已排除 README/.gitattributes)。
|
||||
> 服务器上含 README/.gitattributes 的全量为 LLM 984,015,687 / VL 3,089,713,215 字节,仅作素材核对参照。
|
||||
146
docs/superpowers/specs/2026-05-30-custom-reminder-design.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 自由周期提醒(CustomReminder)— 设计文档
|
||||
|
||||
**日期**:2026-05-30(W2)
|
||||
**作者**:link2026 + Claude
|
||||
**关联卖点**:#4 隐私三件套之外的实用粘性功能(本地通知,无云)
|
||||
**优先级**:用户明确要求(注:§10.6「用药提醒」原列默认不做,本轮经讨论确认要做,按最小可用实现)
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
让用户新建**自由文案的周期性本地提醒**(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」),与现有「指标记录提醒」(去录某项指标)并存但相互独立。完全本地 `UserNotifications`,不引云。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认的设计决策
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|---|---|
|
||||
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
|
||||
| 周期粒度 | **每日 / 每周选几天 / 每月某日 / 每年某月某日**(2026-05-30 用户反转原「不做按月/按年」决策)。仍不做「每 N 天间隔」/一次性 |
|
||||
| 时间选择 | 常用时间快捷预设(8:00/12:00/18:00/22:00 chip)+ 保留 `DatePicker` 精调 |
|
||||
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
|
||||
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
|
||||
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
|
||||
| 多语言 | 所有固定文案走 `String(appLoc:)`,新增中文 key 补 en/ja/ko 到 `Localizable.xcstrings` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
`Models/Models.swift` 新增:
|
||||
|
||||
```swift
|
||||
@Model final class CustomReminder {
|
||||
enum Frequency: String { case daily, weekly, monthly, yearly } // 嵌套枚举
|
||||
@Attribute(.unique) var id: UUID
|
||||
var title: String // 用户文案:"跑步5公里"
|
||||
var note: String // 可选备注 → 通知正文
|
||||
var hour: Int // 0...23
|
||||
var minute: Int // 0...59
|
||||
var weekdays: [Int] // 1=日…7=六,仅 weekly 用(复用 MetricReminder 约定)
|
||||
var frequencyRaw: String = "daily" // Frequency 原始值(内联默认 → 走轻量迁移)
|
||||
var dayOfMonth: Int = 1 // monthly / yearly 用,1...31
|
||||
var month: Int = 1 // yearly 用,1...12
|
||||
var enabled: Bool
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
// computed: frequency(get/set 包 frequencyRaw)/ isEveryDay / frequencyLabel(分档)/ timeLabel
|
||||
}
|
||||
```
|
||||
|
||||
Schema 已含 `CustomReminder.self`。**本轮只给已存在的 `CustomReminder` 加 3 个带内联默认值的属性 → SwiftData 自动轻量迁移,不触发删库兜底(见 §10)。**
|
||||
|
||||
四档语义 → iOS `UNCalendarNotificationTrigger(repeats:true)`:
|
||||
| 频率 | DateComponents | 通知数 | id 后缀 |
|
||||
|---|---|---|---|
|
||||
| daily | hour,minute | 1 | `.daily` |
|
||||
| weekly | hour,minute,weekday ×N | N | `.w<weekday>` |
|
||||
| monthly | day,hour,minute | 1 | `.monthly` |
|
||||
| yearly | month,day,hour,minute | 1 | `.yearly` |
|
||||
|
||||
边界:iOS 重复触发**不顺延**。monthly 选 29/30/31 → 无此日的月份跳过(UI 给浅色提示);yearly 的「日」选项按所选月份最大天数动态收口(避免「4月31日」永不触发),仅闰年 2/29 给提示。
|
||||
|
||||
---
|
||||
|
||||
## 4. 通知调度(ReminderService 泛化)
|
||||
|
||||
抽出私有共享核心,两种提醒复用:
|
||||
|
||||
```swift
|
||||
private static func schedule(idBase:title:body:hour:minute:weekdays:thread:) async
|
||||
static func sync(_ custom: CustomReminder) async // 新增
|
||||
static func cancel(customId: UUID) // 新增
|
||||
static func sync(_ metric: MetricReminder) async // 现有,内部改走共享核心,行为不变
|
||||
```
|
||||
|
||||
- custom 通知:`title` = 提醒标题,`body` = 备注(空则用默认文案「到点啦,记得完成」)。
|
||||
- id 前缀 `kangkang.custom.<uuid>.w<weekday>`(与指标的 `kangkang.reminder.<metricId>.w<weekday>` 不冲突)。
|
||||
- 保存时调 `requestAuthorization()`;被拒则提示去系统设置。
|
||||
|
||||
---
|
||||
|
||||
## 5. UI
|
||||
|
||||
### 5.1 `CustomReminderEditSheet`(新增)
|
||||
创建 / 编辑共用。字段:
|
||||
- 标题 TextField(占位:「做点什么?例:跑步5公里 / 吃2片护肝片」),空标题禁用保存。
|
||||
- 备注 TextField(可选)。
|
||||
- 时间 DatePicker(.hourAndMinute)。
|
||||
- 周几选择(复用 RemindersListView 的 chip 行)。
|
||||
- 保存 / 取消;编辑态多一个「删除提醒」。
|
||||
保存:写 SwiftData → 请求通知权限 → `ReminderService.sync(custom)`。
|
||||
|
||||
### 5.2 `RemindersListView`(改造为提醒中心)
|
||||
- 顶部「+ 新建提醒」按钮 → 打开 `CustomReminderEditSheet`(create)。
|
||||
- 「我的提醒」区:`@Query CustomReminder`,每行点开走编辑 sheet,行上 Toggle 控 enabled。
|
||||
- 「指标记录提醒」区:`@Query MetricReminder`,保持现有内联编辑不变(仅非空时显示区头)。
|
||||
- 表头副文案、空状态文案更新。
|
||||
|
||||
---
|
||||
|
||||
## 6. 多语言
|
||||
|
||||
新增中文 key + en/ja/ko 译文写入 `Localizable.xcstrings`(源语言 zh-Hans,key 即中文)。脚本只增不改,已存在的 key 跳过。复用已有 key:时间/保存/取消/删除提醒/每天/已关闭/周几名等。用户输入的标题/备注是数据,不翻译。
|
||||
|
||||
---
|
||||
|
||||
## 7. 文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `Models/Models.swift` | `CustomReminder` +`Frequency` 枚举 +`frequencyRaw/dayOfMonth/month`(均带内联默认)+ 分档 `frequencyLabel` |
|
||||
| `App/KangkangApp.swift` | **持久化兜底改造**:迁移失败时由「删库」改为「挪到 `StoreBackups/<时间戳>/` 再重建」(见 §10) |
|
||||
| `Services/ReminderService.swift` | 调度核心泛化为 `Slot(suffix,DateComponents)` 列表;custom sync 按 frequency 分档;`cancelBase` 覆盖 daily/monthly/yearly/w1-7 |
|
||||
| `Features/Me/CustomReminderEditSheet.swift` | 频率分段 Picker + 各档子控件(周几 / 日 / 月+日)+ 时间快捷预设行 |
|
||||
| `Features/Me/RemindersListView.swift` | 不变(`frequencyLabel` 来自模型) |
|
||||
| `Localizable.xcstrings` | 新增 11 个 key × en/ja/ko |
|
||||
|
||||
---
|
||||
|
||||
## 8. 红线对齐
|
||||
|
||||
- 不引云、不碰密码学(纯本地通知)✅
|
||||
- 不重构 Tab/RecordSheet 骨架 ✅
|
||||
- §10.6「用药提醒默认不做」→ 已讨论确认,最小实现(无贪睡/铃声/间隔)✅
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收(真机)
|
||||
|
||||
① 新建「每天 20:00 跑步 5 公里」→ 列表出现 → 到点收到本地通知(标题=跑步5公里);② 改时间/周几即时重排;③ 关闭 Toggle 取消通知;④ 删除清除 pending;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管;⑦ **每月/每年**:切频率后子控件随之变化,边界提示出现;改频率后旧档 pending 通知被清掉(不留孤儿);⑧ **时间预设**:点 8:00/12:00/18:00/22:00 即填,精调仍可用。
|
||||
|
||||
---
|
||||
|
||||
## 10. 顺带修复:重打包数据丢失(根因 + 方案)
|
||||
|
||||
**问题**:Demo 期每次改 schema 重打包,SwiftData 数据被清空。
|
||||
|
||||
**根因(单点)**:`App/KangkangApp.swift` 的 `ModelContainer` 创建 catch 块**直接删 store 文件**。SwiftData 只对**纯增量**改动自动轻量迁移;一旦某次改动超纲(最常见:给已存在的 `@Model` 新增「非可选且无内联默认值」的属性),自动迁移抛错 → 落入 catch → 删库。W2 几乎每次都在改 schema,故体感「每次都丢」。
|
||||
|
||||
**方案(两层)**:
|
||||
1. **治本**:新增 `@Model` 属性一律「可选」或「内联默认值」(本轮 3 个新字段都给了 `= "daily"` / `= 1`)→ 走轻量迁移、不进 catch、数据保留。
|
||||
2. **兜底**:catch 不再删库,改为把旧 store(含 `-wal`/`-shm`)**挪到 `Application Support/StoreBackups/<时间戳>/`** 再重建——App 仍能启动,旧数据可手动恢复;挪不动才降级删除。
|
||||
|
||||
⚠️ 正式发布前仍应升级为 `VersionedSchema` + `SchemaMigrationPlan` 的正式迁移(注释已就地标注)。
|
||||
130
docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Face ID 启动锁 — 设计文档
|
||||
|
||||
**日期**:2026-05-30(W2)
|
||||
**作者**:link2026 + Claude
|
||||
**关联卖点**:#4 隐私三件套(系统级加密 + Face ID + 永久删除)
|
||||
**优先级**:P1(CLAUDE.md §6 / §8 / §11,原排期 W5 末,提前实现)
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
可选的 Face ID/Touch ID 启动锁(默认关)。开启后,冷启动与「后台超过 1 分钟再回前台」都需要系统认证才能进入 App;失败可用设备密码兜底。完全基于系统 `LocalAuthentication`,不自造任何密码学(对齐红线 §10.2)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计决策(已与用户确认)
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|---|---|
|
||||
| 锁屏时机 | 冷启动 + 后台超过宽限才重锁 |
|
||||
| 后台宽限 | 60 秒 |
|
||||
| 认证策略 | `.deviceOwnerAuthentication`(Face ID/Touch ID 优先,自动跳设备密码兜底,避免锁死) |
|
||||
| 默认状态 | 关(§6) |
|
||||
| 开关位置 | 「我的」Tab 现有的 Face ID 卡,改为可交互 Toggle |
|
||||
| 任务切换器隐私遮罩 | 加,**仅锁开启时生效**(进 `.inactive`/`.background` 盖品牌遮罩,防多任务快照泄露;默认关用户无感) |
|
||||
|
||||
**关于 §6「截屏黑屏防护…不做」**:那条针对的是**截图防护**(iOS 无官方 API);本设计的任务切换器遮罩是 `.inactive` 盖视图,是官方支持的标准做法,性质不同。
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构
|
||||
|
||||
```
|
||||
KangkangApp
|
||||
└─ WindowGroup { AppLockContainer { RootView() } } ← 仅包一层,RootView 零改动(§10.7)
|
||||
│
|
||||
┌─────────────┴──────────────────────────────┐
|
||||
│ AppLockContainer<Content> │
|
||||
│ @Environment(\.scenePhase) │
|
||||
│ 渲染 content │
|
||||
│ .overlay { if isLocked → LockScreen}│
|
||||
│ .overlay { else if showsCover → PrivacyCover}│
|
||||
│ onAppear → handleAppear(); │
|
||||
│ onChange(scenePhase) → handleScenePhase() │
|
||||
└─────────────────────────────────────────────┘
|
||||
│ 读写
|
||||
┌─────────────┴──────────────────────────────┐
|
||||
│ AppLock.shared (@MainActor @Observable) │ ← Security/AppLock.swift
|
||||
│ enabled ←→ UserDefaults("faceIDLockEnabled")│
|
||||
│ isLocked / showsPrivacyCover │
|
||||
│ biometryAvailable / biometryLabel │
|
||||
│ gracePeriod = 60s,lastBackgroundedAt │
|
||||
│ authenticate() / enableWithAuth() / disable()│
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
单例写法与项目既有 `ModelDownloadService.shared` 一致(`@MainActor @Observable final class` + `static let shared`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 触发逻辑(状态机)
|
||||
|
||||
| scenePhase / 事件 | 行为 |
|
||||
|---|---|
|
||||
| 容器 `onAppear`(冷启动) | `enabled` 为真且尚未冷启动锁过 → `isLocked = true` + 触发认证 |
|
||||
| `.background` | `lastBackgroundedAt = now`;`showsPrivacyCover = enabled` |
|
||||
| `.inactive`(任务切换器) | `showsPrivacyCover = enabled && !isLocked` |
|
||||
| `.active` | 隐藏遮罩;若 `enabled && !isLocked && 离开 > 60s` → `isLocked = true`;若 `isLocked` → 触发认证;清空 `lastBackgroundedAt` |
|
||||
| 认证成功 | `isLocked = false` |
|
||||
| 认证失败/取消 | 保持锁定,锁屏提供「解锁」按钮重试(`isAuthenticating` 防重入,不重复弹窗) |
|
||||
|
||||
冷启动时 scenePhase 初值为 `.active` 不触发 `onChange`,由 `handleAppear()` 负责冷启动锁;两路触发由 `isAuthenticating` 守卫去重。
|
||||
|
||||
---
|
||||
|
||||
## 5. 能力探测与兜底
|
||||
|
||||
- `refreshAvailability()`:`LAContext.canEvaluatePolicy(.deviceOwnerAuthentication)` → `biometryAvailable`;读 `biometryType` 决定文案(Face ID / Touch ID / 密码)。
|
||||
- 设备未设密码/无生物识别 → `biometryAvailable = false`,「我的」开关置灰,副标题「本设备未设置 Face ID 或密码」。
|
||||
- 认证全程系统弹窗;失败/取消不抛错给 UI,只是停留锁屏。
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `康康/Security/AppLock.swift` | **新增**:单例 + LAContext 封装 + 触发逻辑 |
|
||||
| `康康/Security/AppLockContainer.swift` | **新增**:包裹层 + scenePhase 驱动 + 两个 overlay |
|
||||
| `康康/Security/LockScreenView.swift` | **新增**:`LockScreenView` + `PrivacyCoverView` |
|
||||
| `康康/App/KangkangApp.swift` | `RootView()` → `AppLockContainer { RootView() }` |
|
||||
| `康康/Features/Me/MeView.swift` | 静态 Face ID 卡 → 可交互 Toggle 卡 |
|
||||
| `康康.xcodeproj/project.pbxproj` | 加 `INFOPLIST_KEY_NSFaceIDUsageDescription`(Debug + Release) |
|
||||
|
||||
工程用文件系统同步组,新增 `Security/` 下的源文件自动纳入编译,无需手改 pbxproj 注册。
|
||||
|
||||
---
|
||||
|
||||
## 7. UI
|
||||
|
||||
锁屏(`LockScreenView`,全遮罩,走 Tj tokens):
|
||||
|
||||
```
|
||||
🔒 (lock glyph)
|
||||
康康 已锁定
|
||||
你的健康档案已加密保护
|
||||
[ Face ID 解锁 ] ← onAppear 自动触发一次认证;按钮文案随设备能力变
|
||||
```
|
||||
|
||||
隐私遮罩(`PrivacyCoverView`):品牌色底 + app 名,无交互,仅用于遮挡多任务快照。
|
||||
|
||||
「我的」Face ID 卡:Toggle 开启时先认证一次(成功才置 `enabled`),关闭直接关。副标题动态:「已开启 · Face ID」/「关闭」/「本设备未设置 Face ID 或密码」。
|
||||
|
||||
---
|
||||
|
||||
## 8. 红线对齐(CLAUDE.md §10)
|
||||
|
||||
- 不自造密码学,只用系统 `LocalAuthentication` ✅
|
||||
- 默认关,可选开关 ✅
|
||||
- 不引云 ✅
|
||||
- 不重构 Tab/RecordSheet 骨架,只加一层包裹 ✅
|
||||
- 清单内功能(§6/§8/§11 明列 Face ID 启动锁)✅
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试与验收
|
||||
|
||||
- 单元测试价值低(核心是系统弹窗 + scenePhase),不强求;`AppLock` 的宽限判定逻辑可抽纯函数测(可选)。
|
||||
- **真机验收**:① 开关开启走 Face ID;② 杀进程冷启动需认证;③ 后台 <60s 回来不锁、>60s 回来锁;④ 多任务切换器快照被遮罩;⑤ 关 Face ID 录入(模拟失败)能跳设备密码;⑥ 默认关时全程无感。
|
||||
- 模拟器:Features → Face ID → Enrolled / Matching Face 可模拟。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 异常项快拍(局部小框 + VL 识别)— 设计
|
||||
|
||||
> 日期:2026-05-31 · 分支:feat/w2-ai-foundation
|
||||
> 需求:异常项快拍要拍摄局部,采用小框拍局部,用 Qwen-VL 识别被拍区域→检测项目结构化数据;
|
||||
> 存储前用户确认;最后只存参数和异常值,可和「记录指标」统一保存。
|
||||
|
||||
## 1. 现状与缺口
|
||||
|
||||
- `RecordSheet.quick`(标题「异常项快拍」)已存在,但 `RootView.recordFlow(.quick)` 当前直接路由到
|
||||
`UnifiedCaptureFlow` —— 与「体检报告归档」(`.archive`)完全一样,走的是整页文档扫描,**没有局部小框**,
|
||||
也会把整份当 `Report` + 原图存档。这与需求(局部 / 只存数值 / 不留图 / 并入指标)不符。
|
||||
- `Features/Quick/` 下 `A1ViewfinderView` / `A2ConfirmView` / `SmartFramer` / `QuickCaptureFlow` /
|
||||
`A3BatchView` 均为早期 mockup,全树无外部引用(纯孤儿)。`A1ViewfinderView` 有小框引导和 AVFoundation
|
||||
预览,但**快门未接线**(`capturePhoto()` 从不触发)、**不裁剪**。
|
||||
|
||||
## 2. 目标流程
|
||||
|
||||
```
|
||||
RecordSheet(.quick)
|
||||
→ QuickRegionCaptureFlow(状态机)
|
||||
├ 真机: RegionCameraView(实时预览 + 居中小框 + 快门 → 裁剪到小框的 UIImage)
|
||||
└ 模拟器: PhotoPickerSheet(无小框,整图送 VL)
|
||||
→ CaptureService.recognizeRegion(imageData:) ──actor──► AIRuntime.analyzeReport ─► VLSession
|
||||
↑ VLPrompts.regionExtraction()
|
||||
→ QuickRegionConfirmView(逐项可编辑 + 勾选纳入 + 测量时间;异常项高亮置顶)
|
||||
→ 保存:勾选项各插入一条独立 Indicator(无 Report、无 Asset);ctx.save()
|
||||
```
|
||||
|
||||
红线遵守:UI 不直接调 `AIRuntime`,经 `CaptureService`(§3.1);`AIRuntime` actor 串行(复用既有 VL 路径,
|
||||
不新增并发);无新增 `@Model`,不触发 SwiftData 迁移。
|
||||
|
||||
## 3. 组件
|
||||
|
||||
### 3.1 RegionCameraView.swift(新建,取代 A1ViewfinderView)
|
||||
- AVFoundation 实时预览,`videoGravity = .resizeAspectFill`。
|
||||
- 居中**局部小框**(屏宽 ~84% × 高 ~140pt,虚线框 + 半透明遮罩挖空),提示「把异常项放进框里 · 对准一两行」。
|
||||
- 底部快门键、顶部取消键。
|
||||
- 拍照后:`previewLayer.metadataOutputRectConverted(fromLayerRect: 小框rect)` → 归一化裁剪 rect;
|
||||
先把照片方向 bake 成 `.up`,再按归一化 rect 裁 `CGImage`,回调裁剪后的 `UIImage`。
|
||||
- 相机权限:被拒时显示「去设置开启相机」态。
|
||||
- 纯函数 `RegionImageCropper.crop(_:normalizedRect:)` + `UIImage.normalizedUp()`,与 View 解耦便于推理/复用。
|
||||
|
||||
### 3.2 VLPrompts.regionExtraction()(加进 VLPrompts.swift)
|
||||
- 说明「这是报告的局部照片,可能只有一两行指标」。
|
||||
- 严格 JSON,只要 `{"indicators":[{name,value,unit,range,status}]}`,**不要**报告元信息。
|
||||
- status 由 value 与 range 自判;range 保留原文;不发明指标,看不清整行跳过。
|
||||
- 2 个 few-shot(单行 / 两行)。
|
||||
|
||||
### 3.3 CaptureService.recognizeRegion(imageData: Data)(加进 CaptureService.swift)
|
||||
- 把 JPEG 写临时文件(`NSTemporaryDirectory`,`.completeFileProtection`),`defer` 删除。
|
||||
- `prepareVL()` → `analyzeReport(imageURLs:[temp], prompt: regionExtraction())`。
|
||||
- 新增 `parseIndicatorsJSON(_:)`:复用 `extractJSONObject` + `parseIndicator`,抽出 `indicators` 数组,
|
||||
返回 `[ParsedReport.ParsedIndicator]`。失败抛 `CaptureError`(UI 回退手动录入)。
|
||||
|
||||
### 3.4 QuickRegionCaptureFlow.swift(新建,状态机)
|
||||
- `Phase { idle, analyzing(UIImage), confirm(items, warning) }`。
|
||||
- 裁剪图 → analyzing → Task:JPEG 编码 → `recognizeRegion` → confirm。
|
||||
- 30s 超时哨兵 → confirm(空 + warning);各类错误 → confirm(空 + warning)。
|
||||
- 无 Vault 资产需清理(临时文件已在 service 内删除);取消即关闭。
|
||||
|
||||
### 3.5 QuickRegionConfirmView.swift(新建,确认 UI)
|
||||
- 头部「核对异常项 · 只存数值,不保留照片」+ 内存中的裁剪缩略图(仅核对用,**不持久化**)。
|
||||
- 测量时间 DatePicker(默认 now)。
|
||||
- 指标列表:逐项可编辑(name/value/unit/range/status)+ 勾选「纳入保存」。
|
||||
异常(high/low)项红色高亮、置顶、默认勾选;正常项默认也勾选(用户可取消),体现「只存参数和异常值」由用户掌控。
|
||||
- 「加一项」手动补充(VL 空结果回退)。
|
||||
- 底栏:取消 / 保存到记录(N 项)。
|
||||
|
||||
### 3.6 RootView 路由
|
||||
- `.quick → QuickRegionCaptureFlow(onClose:)`(原为 `UnifiedCaptureFlow`)。
|
||||
|
||||
### 3.7 清理
|
||||
- 删除 5 个孤儿 mockup:A1ViewfinderView / A2ConfirmView / SmartFramer / QuickCaptureFlow / A3BatchView。
|
||||
|
||||
## 4. 数据落库
|
||||
|
||||
- 每个勾选项 → 一条 `Indicator(name,value,unit,range,status,capturedAt,note=nil,pinned=false,seriesKey=nil)`。
|
||||
- 不建 `Report`,不存 `Asset`(原图丢弃)→ 符合「最后只存参数和异常值」。
|
||||
- 与「记录指标」自由输入路径落库一致(同一 Indicator 表,进记录时间线;不带 seriesKey 不强制进趋势)。
|
||||
|
||||
## 5. 取舍
|
||||
|
||||
- **裁剪 vs 整图**:需求明确「小框拍局部 / 识别被拍区域」,故真机裁剪到小框(也提升小目标 VL 准确率、降 token)。
|
||||
模拟器无实时小框 → 退化为整图(与既有 UnifiedCaptureFlow 模拟器退化一致)。
|
||||
- **不留图**:遵循「只存参数和异常值」与隐私基线,临时文件推理后即删,不写 Vault、不建 Asset。
|
||||
- **正常项是否保存**:默认全部勾选、异常项高亮,正常项可手动取消 —— 不静默丢弃用户可能想留的读数。
|
||||
- **不动既有归档流程**:UnifiedCaptureFlow / B3 / C2 不变;本功能只重写 `.quick` 这一条路径。
|
||||
@@ -0,0 +1,117 @@
|
||||
# 导出身体档案 — 指标趋势段 设计
|
||||
|
||||
> 2026-06-07 · 在「导出身体档案」(`HealthExportService`)的输出里,为本次就诊相关、且有历史记录的指标补一段确定性计算的趋势摘要。
|
||||
|
||||
## 背景
|
||||
|
||||
当前导出是「快照式」:`HealthExportService.retrieve()` 在时间窗内每个指标只取最近一条,`serializeData()` 序列化成单点数值(name/value/unit/range/status/date),交给 LLM 拼成「## 关键指标」一段。医生看不到指标随时间的变化方向。
|
||||
|
||||
需求:导出要带上**相关指标的趋势信息**(同一指标多次记录的变化)。
|
||||
|
||||
## 决策(已与用户确认)
|
||||
|
||||
| 维度 | 决定 |
|
||||
|---|---|
|
||||
| 覆盖范围 | 本次就诊相关(命中关键词或异常)且时间窗内有 **≥2 次**记录的指标 |
|
||||
| 粒度 | 一行摘要(首值→末值 + 方向箭头 + 时间跨度 + 次数) |
|
||||
| 生成方式 | **确定性计算**(模板拼装,不经 LLM),与 `ReportCompareService` 同思路,零编造风险 |
|
||||
| 呈现位置 | LLM 输出 6 段之后,**追加**独立一段 `## 指标趋势`;无数据则整段省略 |
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
retrieve() ──► 全量 in-window 指标(裁剪前) ┐
|
||||
└─► 相关指标集(裁剪后,决定哪些 series 出趋势) ┤
|
||||
▼
|
||||
ExportTrendBuilder.build(...) → [TrendSummary]
|
||||
▼
|
||||
Snapshot.trends ──► export() 在 completed 前追加 "## 指标趋势"
|
||||
```
|
||||
|
||||
LLM 链路(prompt / `serializeData`)**完全不变**——趋势不进 JSON,LLM 不知情。
|
||||
|
||||
## 组件
|
||||
|
||||
### 1. `TrendSummary`(值类型)
|
||||
|
||||
一个 series 的趋势结果。字段:
|
||||
|
||||
- `title: String` — 显示名(如「收缩压」「血压」)
|
||||
- `unit: String`
|
||||
- `firstValue: Double`、`lastValue: Double`
|
||||
- `firstDate: Date`、`lastDate: Date`
|
||||
- `count: Int` — 时间窗内记录次数
|
||||
- `direction: Direction`(`.up` / `.down` / `.flat`)
|
||||
- `range: String` — 参考范围原文(可空)
|
||||
- `flagged: Bool` — 末值仍异常 **或** 跨越参考范围边界,为真时行首加 `⚠️`
|
||||
|
||||
方法 `line() -> String`,一行中文,格式:
|
||||
|
||||
```
|
||||
收缩压 152→138 mmHg ↓(参考 90-140),近 21 天 4 次
|
||||
```
|
||||
|
||||
- 方向箭头:`.up` → `↑`、`.down` → `↓`、`.flat` → `→`
|
||||
- `flagged` 为真前缀 `⚠️ `
|
||||
- `range` 为空时省略「(参考 …)」括号
|
||||
- 数值用与现有指标一致的格式化(去掉无意义小数;血压等整数不带小数点)
|
||||
|
||||
> 血压合并行:`title` = 「血压」,数值写成「收缩/舒张」对,如 `血压 152/96→138/88 mmHg ↓…`;方向以收缩压为准。
|
||||
|
||||
### 2. `ExportTrendBuilder`(纯函数,可单测)
|
||||
|
||||
```swift
|
||||
enum ExportTrendBuilder {
|
||||
static func build(allInWindow: [Indicator],
|
||||
relevant: [Indicator]) -> [TrendSummary]
|
||||
}
|
||||
```
|
||||
|
||||
逻辑:
|
||||
|
||||
1. **确定相关 series**:从 `relevant` 收集 series 标识(优先 `seriesKey`,无则 `name|unit`)。
|
||||
2. **分组全量点**:把 `allInWindow` 按同一 series 标识分组;血压 `bp.systolic` + `bp.diastolic` 归到合成 series「血压」。
|
||||
3. **过滤**:只保留(a)属于相关 series、(b)点数 ≥2 的组。
|
||||
4. 每组按 `capturedAt` 升序,取首/末点,算:
|
||||
- `direction`:相对变化 `|last-first|/first`,<5% → `.flat`,否则按符号 `.up`/`.down`(first 为 0 时退化按绝对差判定)
|
||||
- `flagged`:末点 `status != .normal`,或首点 normal 而末点非 normal(或反之,跨界)
|
||||
- `count`、`firstDate`、`lastDate`、`range`(取末点的 range)
|
||||
5. 排序:`flagged` 优先,其次按 `lastDate` 倒序。
|
||||
6. 返回 `[TrendSummary]`。
|
||||
|
||||
数值解析复用现有方式(`Double(indicator.value)`);解析失败的点跳过,若有效点 <2 则该 series 不出趋势。
|
||||
|
||||
### 3. 接入 `HealthExportService`
|
||||
|
||||
- `Snapshot` 加 `trends: [TrendSummary]`。
|
||||
- `retrieve()`:在现有第 268 行 fetch 全量 in-window 指标后,保留该全量列表;裁剪逻辑不变得到 `indicators`(相关集);调用 `ExportTrendBuilder.build(allInWindow: 全量, relevant: indicators)` 填入 `Snapshot.trends`。
|
||||
- `serializeData()`:**不改**(趋势不进 LLM)。
|
||||
- `export()`:在发出 `completed` 事件、把内容存进 `HealthExport.content` 之前,若 `snapshot.trends` 非空,把 `## 指标趋势` 段追加到 LLM markdown 末尾。空数据兜底路径(`isEffectivelyEmpty`)trends 自然为空,不追加。
|
||||
|
||||
`## 指标趋势` 段渲染:
|
||||
|
||||
```markdown
|
||||
## 指标趋势
|
||||
⚠️ 收缩压 152→138 mmHg ↓(参考 90-140),近 21 天 4 次
|
||||
空腹血糖 6.8→6.2 mmol/L ↓(参考 3.9-6.1),近 28 天 3 次
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
`ExportTrendBuilder.build` 是纯函数,单测覆盖:
|
||||
|
||||
- 升 / 降 / 平稳(阈值边界)方向判定
|
||||
- 血压双 series 合并成一行
|
||||
- 点数 <2 的 series 被过滤
|
||||
- 不相关 series(不在 relevant 集)被过滤
|
||||
- 跨参考范围边界 → `flagged = true`
|
||||
- 数值无法解析的点被跳过
|
||||
|
||||
## 不做
|
||||
|
||||
- 不改 LLM prompt / `serializeData`(零编造风险的前提)
|
||||
- 不引入 embedding、不加新颜色/字体 token
|
||||
- 不改导出 UI 布局(仅输出内容多一段;`HealthExportSheet` / `HealthExportDetailView` 的 `MarkdownView` 已能渲染新段落)
|
||||
- 不做逐点列表 / 峰谷均值(本次只要一行摘要)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# 趋势大改 + 健康日历移至主页 — 设计文档
|
||||
|
||||
> 日期:2026-06-07 · 状态:已定方案(用户授权直接实现,免确认)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前「趋势」Tab(`TrendsView.swift`)把两件事混在一起:
|
||||
|
||||
1. **健康日历**(月/年视图 + 当日详情)—— 占据页面上半部分。
|
||||
2. **长期监测折线图**(`seriesSection`)—— 页面下半部分。
|
||||
|
||||
两个问题:
|
||||
|
||||
- **日历放错了地方**。它是「总览记录情况」的入口,更适合放在主页(用户每天第一眼看的页面),而不是埋在趋势 Tab 里。
|
||||
- **趋势能力太弱**。`SeriesBucket.build` **只按 `seriesKey` 分桶**,因此只有 8 个长期监测预设(血压/血糖/体温…)和自定义指标能成图。所有**没有 seriesKey 的指标**——报告里解析出来的化验项、VL 快拍、自由输入——即使在多份报告里反复出现(如「血红蛋白」体检了 3 次),也**完全看不到趋势**。
|
||||
|
||||
### 目标
|
||||
|
||||
1. **健康日历移到主页**:主页新增一张紧凑的「健康日历」卡(当前周的横条 + 本月记录摘要),点击展开完整的月/年总览页(可切月视图/年视图、看当日详情)。
|
||||
2. **趋势 Tab 重构**:对**任何出现 ≥2 次的指标**(不限于长期监测预设)做时间序列查看。趋势页变成一个「可成趋势的指标」总览列表(分长期监测 / 化验指标两段),点任一项进入详情页:大图表 + 参考范围带 + 统计摘要(最新/最高/最低/平均/对比上次)+ 时间范围筛选 + 数据点列表(点击跳当日详情)。
|
||||
|
||||
### 非目标(本次不做)
|
||||
|
||||
- **AI 趋势解读**:需要 AIRuntime + TrendService 跑通,风险大、与本次「时间序列查看」正交。本次预留 UI 位但不接 LLM,留作后续。
|
||||
- 不改 SwiftData schema(无 @Model 字段变更,规避迁移丢数据风险)。
|
||||
- 不改 `Localizable.xcstrings`(新文案用 `String(appLoc: "中文")`,无对应词条时优雅回退到中文 key,符合既有大量用法;避免 xcstrings 噪声 diff)。
|
||||
- 不动 TabBar 5 槽骨架、不动录入流程。
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```
|
||||
主页 HomeView
|
||||
└─ HomeCalendarCard(自包含 @Query) ← 新增
|
||||
当前周横条 + "本月 N 天有记录" + chevron
|
||||
tap → fullScreenCover(CalendarOverviewView) ← 新增(从 TrendsView 抽出)
|
||||
|
||||
趋势 TrendsView(重写)
|
||||
└─ TrendSeriesList:两段 section
|
||||
├─ 长期监测(kind=.monitor:seriesKey 分桶,含血压合并/自定义)
|
||||
└─ 化验指标趋势(kind=.lab:按 name+unit 分桶,≥2 点)
|
||||
每行 TrendRow:名称 + 最新值/状态 + mini sparkline + 条数·跨度
|
||||
tap → TrendDetailView(bucket) ← 新增
|
||||
大图表 + 参考范围带 + 时间范围 chips + 统计摘要 + 数据点列表
|
||||
数据点 tap → DayDetailSheet(date)(复用)
|
||||
```
|
||||
|
||||
数据层只扩展 `SeriesBucket.build`,UI 层新增 4 个文件、改 2 个文件、删 1 段。
|
||||
|
||||
## 3. 数据层:`SeriesBucket` 扩展
|
||||
|
||||
文件:`Features/Trends/SeriesBucket.swift`(改)
|
||||
|
||||
### 3.1 新增 `kind` 区分两段
|
||||
|
||||
```swift
|
||||
enum SeriesKind { case monitor, lab } // monitor=长期监测预设/自定义/血压;lab=按名分组的化验项
|
||||
|
||||
struct SeriesBucket: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let unit: String
|
||||
let lines: [SeriesLine]
|
||||
let latestDate: Date
|
||||
let kind: SeriesKind // 新增
|
||||
let sourceIndicatorIDs: [String] // 新增:本桶包含的 Indicator persistentModelID 字符串,供详情页定位来源
|
||||
// ... SeriesLine / Point 不变
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `build` 流程改为两段
|
||||
|
||||
1. **seriesKey 段(原逻辑,kind=.monitor)**:血压合并、单系列预设、自定义。这些桶里的 Indicator 标记为「已消费」。
|
||||
2. **name 段(新,kind=.lab)**:对**所有没有 seriesKey** 的 Indicator,按 `normalizedKey(name, unit)` 分桶;每桶 ≥ `minPoints` 才保留。参考范围从该桶**最新一条** Indicator 的 `range` 字符串解析。
|
||||
3. 两段合并返回,各自按 `latestDate` 倒序。详情/列表按 `kind` 分段。
|
||||
|
||||
```swift
|
||||
// name 归一化:trim + 小写 + 折叠内部空白;unit 同样 trim。key = "name|unit"
|
||||
static func normalizedKey(name: String, unit: String) -> String
|
||||
|
||||
// 解析参考范围字符串 → ClosedRange<Double>?
|
||||
// 支持 "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1";单边("<5.2"/">40"/"≤120")暂返回 nil(图不画带,正常)
|
||||
static func parseRange(_ raw: String) -> ClosedRange<Double>?
|
||||
```
|
||||
|
||||
> **去重**:有 seriesKey 的指标只进 monitor 段;无 seriesKey 的只进 lab 段。即使同名也不混。
|
||||
> **状态着色**:lab 段每个 Point 的 `status` 直接取 Indicator.status(已由 VL/录入判定),无需重算。
|
||||
|
||||
## 4. UI:健康日历移至主页
|
||||
|
||||
### 4.1 `CalendarOverviewView`(新文件 `Features/Calendar/CalendarOverviewView.swift`)
|
||||
|
||||
把现 `TrendsView` 的日历部分**原样抽出**为独立页:`modeSwitch`(月/年)+ `anchorBar`(◀ 年月 ▶)+ `calendarBody`(`CalendarMonthGrid`/`CalendarYearGrid`)+ `legend` + 月视图下的 `dayDetailInline`。
|
||||
|
||||
- 自带 `@Query`(indicators/reports/diaries/symptoms/profiles/customMetrics)。
|
||||
- 接收可选 `initialDate`(从主页某天进入时定位选中)。
|
||||
- 包在 `NavigationStack`,标题「健康日历」,右上「完成」关闭(用于 fullScreenCover)。
|
||||
- `CalendarMonthGrid` / `CalendarYearGrid` / `CalendarMarkers` / `DayDetailSheet` **不改**,直接复用。
|
||||
|
||||
### 4.2 `HomeCalendarCard`(新文件 `Features/Home/HomeCalendarCard.swift`)
|
||||
|
||||
自包含组件(对齐 `TodayRemindersCard` 模式):
|
||||
|
||||
- 自带 `@Query`,`CalendarData.build` 计算标记。
|
||||
- **当前周横条**:周一→周日 7 个紧凑日格(日期数字 + 标记圆点,复用 `DayMarks` 颜色规则:异常红 / 报告灰 / 正常绿 / 日记浅灰;有进行中症状则该格底色淡 amber)。今天高亮。
|
||||
- 顶部标题「健康日历」+ 右侧「本月 N 天有记录 ›」。
|
||||
- 整卡可点 → `fullScreenCover(CalendarOverviewView())`;点某一天 → 带 `initialDate` 进入。
|
||||
- 样式走 `.tjCard()`,放在主页 `greeting` 之后、`TodayRemindersCard` 之前。
|
||||
|
||||
### 4.3 `HomeView` 改动
|
||||
|
||||
`body` 的 VStack 在 `greeting` 后插入 `HomeCalendarCard()`。其余不动。
|
||||
|
||||
## 5. UI:趋势 Tab 重构
|
||||
|
||||
### 5.1 `TrendsView`(重写)
|
||||
|
||||
移除所有日历相关代码(已迁到主页)。新结构:
|
||||
|
||||
- header「趋势」。
|
||||
- 若无可成趋势的桶 → 空状态(「还没有可成趋势的指标 / 同一指标记录满 2 次后会出现在这里」)。
|
||||
- 否则两段:
|
||||
- **长期监测**(`kind == .monitor`):标题 + 计数。
|
||||
- **化验指标趋势**(`kind == .lab`):标题 + 计数。
|
||||
- 每段 `ForEach` 渲染 `TrendRow`,点击 push/present `TrendDetailView`。
|
||||
- 导航:`TrendsView` 包 `NavigationStack`,行用 `NavigationLink` 进详情(趋势 Tab 当前无 NavigationStack,新增之)。
|
||||
|
||||
### 5.2 `TrendRow`(新文件 `Features/Trends/TrendRow.swift`)
|
||||
|
||||
紧凑行:
|
||||
|
||||
- 左:指标名 + 「N 条 · 近 X 个月」副标题。
|
||||
- 中:mini sparkline(小号 `Chart`,height≈36,无坐标轴,单/双线,异常点红)。
|
||||
- 右:最新值 + 单位(异常红)+ chevron。
|
||||
- `.tjCard(bordered: true)`。
|
||||
|
||||
### 5.3 `TrendDetailView`(新文件 `Features/Trends/TrendDetailView.swift`)
|
||||
|
||||
接收 `bucket: SeriesBucket`,自带 `@Query` 用于数据点→来源跳转。
|
||||
|
||||
- **大图表**(height≈220):复用 `SeriesChartCard` 的绘制逻辑(参考范围带 + catmullRom 折线 + 点 + 双线图例),但加坐标轴、按所选时间范围裁剪 domain。
|
||||
- **时间范围 chips**:全部 / 近1年 / 近6月 / 近3月(仅当跨度 > 该范围才显示对应 chip)。切换裁剪图表点 + 重算 domain + 重算统计。
|
||||
- **统计摘要卡**:最新值(带状态)/ 对比上次(Δ 绝对值+百分比+升降箭头,跨参考范围边界标红)/ 最低 / 最高 / 平均 / 记录数 / 时间跨度。文案模板拼装,不走 LLM。
|
||||
- **AI 解读占位**:一行灰字「AI 解读即将上线」(预留,不接 LLM)。
|
||||
- **数据点列表**(倒序):日期 + 值+单位 + 状态箭头/徽章;`onTapGesture` → `DayDetailSheet(date:)`(复用现有 sheet,给出当天来源上下文)。
|
||||
- 标题 = bucket.title。
|
||||
|
||||
血压(双线)在详情页:统计摘要按「收缩/舒张」分别给最新值;列表每行显示「收缩/舒张」两值。
|
||||
|
||||
## 6. 受影响文件清单
|
||||
|
||||
**新增**
|
||||
- `Features/Calendar/CalendarOverviewView.swift`
|
||||
- `Features/Home/HomeCalendarCard.swift`
|
||||
- `Features/Trends/TrendRow.swift`
|
||||
- `Features/Trends/TrendDetailView.swift`
|
||||
|
||||
**修改**
|
||||
- `Features/Trends/SeriesBucket.swift`(加 kind / sourceIndicatorIDs / name 段 / parseRange)
|
||||
- `Features/Trends/TrendsView.swift`(删日历,重写为趋势列表 + NavigationStack)
|
||||
- `Features/Home/HomeView.swift`(插入 HomeCalendarCard)
|
||||
- `康康.xcodeproj/project.pbxproj`(新文件加入 target — 若用 file-system-synchronized group 则免改;需确认)
|
||||
|
||||
**不改**:`CalendarMonthGrid/YearGrid/Markers/DayDetailSheet`、`SeriesChartCard`(详情页复用其绘制思路,可抽 helper 或直接内置)、Models、xcstrings、RootView/TabBar、录入流程。
|
||||
|
||||
## 7. 验证
|
||||
|
||||
- 构建无错误/无新警告(`DEVELOPER_DIR` 指完整 Xcode,touch 强制重编 — 见记忆 build-from-cli)。
|
||||
- 主页:日历卡显示当前周标记;点卡进总览;月/年切换;点某天→当日详情正确。
|
||||
- 趋势:制造同名指标 ≥2 条(如手动录两次「血红蛋白」或两份报告同含一项)→ 出现在「化验指标趋势」段;预设监测仍在「长期监测」段;详情图表/统计/数据点跳转正确;血压双线正常。
|
||||
- 空状态:全新库时两个页面都给出友好空态。
|
||||
|
||||
## 8. 风险与回退
|
||||
|
||||
- **range 解析覆盖不全**:单边区间("<5.2")暂不画带,图仍可用 —— 可接受,后续增强。
|
||||
- **lab 段噪声**:同名但单位不同的指标会分成两桶(key 含 unit)—— 正确行为。若用户名字录入不一致(「血红蛋白」vs「Hb」)会分开 —— demo 可接受,不做模糊归并。
|
||||
- **pbxproj**:若新文件未自动入 target,构建会报 missing symbol;届时手动加 build file 引用。
|
||||
121
docs/superpowers/specs/2026-06-10-voice-diary-design.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 语音健康日记(语音转文字 + AI 整理)设计
|
||||
|
||||
> 2026-06-10 · 在「健康记录」(`DiaryQuickSheet`)里加语音输入:iOS 端侧语音识别实时转写,停止后由本地 LLM 整理成健康日记草稿,可编辑后保存。
|
||||
|
||||
## 背景
|
||||
|
||||
「健康记录」目前只能手打文字(`DiaryQuickSheet` → `DiaryEntry`),已有「AI 医生角度多轮追问」辅助。口述比打字门槛低得多,尤其适合身体不适时记录。
|
||||
|
||||
现有两个本地模型(Qwen3.5-2B 文本、Qwen3-VL 视觉)都没有音频编码器,无法做 ASR;引入 Whisper 类模型要 +0.5~1.5GB 体积和一条新推理链路,不可接受。`SFSpeechRecognizer` 支持强制端侧识别(`requiresOnDeviceRecognition = true`),中文质量够用、零体积,与「100% 本地」卖点完全一致。
|
||||
|
||||
## 决策(已与用户确认)
|
||||
|
||||
| 维度 | 决定 |
|
||||
|---|---|
|
||||
| 交互形态 | 说完 → 自动调 LLM 整理成日记草稿(非纯听写) |
|
||||
| 整理样式 | 自适应:口述短 → 一段通顺的话;口述长且多方面 → 自动分点 |
|
||||
| 入口 | `DiaryQuickSheet` 输入框旁麦克风按钮(不动 RecordSheet 骨架) |
|
||||
| 转写链路 | 流式实时转写(AVAudioEngine buffer → 实时字幕),不落盘音频 |
|
||||
| ASR 引擎 | `SFSpeechRecognizer` 端侧;不引入 Whisper;不做云端回退 |
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
DiaryQuickSheet(mic 按钮 + 录音面板)
|
||||
├─► SpeechDictationService(新)── AVAudioEngine + SFSpeechRecognizer(端侧)
|
||||
└─► DiaryAssistService.organize(transcript:)(新方法)──► AIRuntime ──► MNN/MLX
|
||||
```
|
||||
|
||||
符合模块边界:UI 不直接碰 AIRuntime;语音采集是系统能力,封装成独立 Service。
|
||||
|
||||
## 组件
|
||||
|
||||
### 1. `SpeechDictationService`(新,`Services/`,`@MainActor`)
|
||||
|
||||
封装 AVAudioEngine 麦克风采集 + `SFSpeechAudioBufferRecognitionRequest` 流式识别。
|
||||
|
||||
接口:
|
||||
|
||||
- `static var isAvailable: Bool` — 本机是否支持**端侧**中文识别(`supportsOnDeviceRecognition` + locale 检查;模拟器/老机型为 false)
|
||||
- `func requestAuthorization() async -> Bool` — 麦克风 + 语音识别两个权限一起申请
|
||||
- `func start(onPartial: @escaping (String) -> Void) throws` — 开始录音,partial 结果实时回调(录音面板字幕)
|
||||
- `func stop() async -> String` — 停止并返回最终转写稿
|
||||
|
||||
实现要点:
|
||||
|
||||
- `requiresOnDeviceRecognition = true`(硬性,识别内容不出设备)
|
||||
- `addsPunctuation = true`(自动标点)
|
||||
- locale 跟随系统,不支持端侧时 `isAvailable = false`
|
||||
- **不写任何音频文件**,buffer 即用即弃
|
||||
- 录音上限 3 分钟,到点自动 stop
|
||||
|
||||
### 2. `DiaryAssistService.organize(transcript:)`(新方法)
|
||||
|
||||
```swift
|
||||
func organize(transcript: String) async throws -> (text: String, decodeRate: Double)
|
||||
```
|
||||
|
||||
- prompt 加在 `AI/Prompts/DiaryAssistPrompts.swift`:`organizePrompt(transcript:)`
|
||||
- few-shot 两例:短口述 → 一段第一人称通顺文本;长口述(症状/用药/饮食多方面)→ 分点
|
||||
- **硬性约束写进 prompt:只重组语言,不得增删改任何数值、单位、药名、时间**(健康数据,2B 模型改数即事故)
|
||||
- 转写稿超长先截断(保护 context),非流式,await 完整结果
|
||||
- 走 AIRuntime actor 队列,与「多轮追问」「拍照识别」自然串行
|
||||
|
||||
### 3. `DiaryQuickSheet` UI 改动
|
||||
|
||||
- 内容输入框 trailing 加 mic 按钮(`isAvailable == false` 时整个隐藏)
|
||||
- 录音态:输入框下方展开录音面板 —— 实时字幕区 + 脉冲动画(sparkles/waveform `symbolEffect`)+「停止」按钮
|
||||
- 整理态:面板转「AI 整理中」(复用 `AIFlowBar` + tok/s),可取消
|
||||
- 完成:整理稿**追加**进输入框(沿用 `appendToContent`,不覆盖已写内容);面板收起
|
||||
- 完成后显示一次性「改用原话」pill:点击把刚追加的整理稿替换为原始转写稿(原始稿在本次 sheet 生命周期内持有;再次录音或手动编辑该段后 pill 消失)
|
||||
- 整理稿入框后,既有「AI 多轮追问」功能照常可用,无需特殊处理
|
||||
|
||||
## 状态机
|
||||
|
||||
```
|
||||
idle ──(点 mic,权限 OK)──► recording ──(停止/3min 到点)──► organizing ──► done(回 idle)
|
||||
```
|
||||
|
||||
- 实时字幕只显示在录音面板,**停止前不进输入框**
|
||||
- `organizing` 期间 mic 按钮与「AI 追问」按钮禁用(AIRuntime 串行,避免排队困惑)
|
||||
|
||||
## 错误处理(红线 #5:全部有回退,不卡死)
|
||||
|
||||
| 故障 | 行为 |
|
||||
|---|---|
|
||||
| 权限被拒 | 弹说明 alert + 「前往设置」跳系统设置 |
|
||||
| 本机不支持端侧识别(含模拟器) | mic 按钮隐藏,静默降级为纯手打 |
|
||||
| 识别中途出错 | 已拿到的 partial 文本照常进 organizing |
|
||||
| 转写结果为空 | 提示「没听清,再试一次」,回 idle |
|
||||
| LLM 未就绪 / 整理失败 | **原始转写稿直接追加进输入框** + 提示「AI 整理失败,已填入原话」 |
|
||||
|
||||
不做云端识别回退(红线 #1:不引入云服务)。
|
||||
|
||||
## 权限(project.pbxproj 新增两条 INFOPLIST_KEY)
|
||||
|
||||
- `NSMicrophoneUsageDescription`:康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。
|
||||
- `NSSpeechRecognitionUsageDescription`:语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。
|
||||
|
||||
## 测试
|
||||
|
||||
- `organize` prompt:`DebugAIRunner` 加自检入口(短/长两条样例口述,肉眼验自适应样式 + 数值不被改动)
|
||||
- 录音链路:真机手测清单(权限首次申请、录音字幕、3 分钟自动停、整理失败回退、「改用原话」)
|
||||
- 模拟器:验证 `isAvailable == false` 时 mic 按钮隐藏
|
||||
|
||||
## 范围边界(不做)
|
||||
|
||||
- 症状 / AI 问答的语音入口
|
||||
- 音频文件保存或回放
|
||||
- Whisper / 任何新模型
|
||||
- Live Activity 集成(前台短流程,无必要)
|
||||
- 多语言听写优化(locale 跟系统,不支持即降级)
|
||||
|
||||
## 卖点映射(§12)
|
||||
|
||||
1. 降低记录门槛 → 卖点 1(影像档案之外的日常记录闭环)
|
||||
2. 「系统端侧 ASR + 本地 LLM 整理」全链路不出设备 → 卖点 2(100% 本地)
|
||||
3. 日记语料变多 → 卖点 3(本地 RAG 长期记忆)
|
||||
|
||||
## 排期
|
||||
|
||||
清单外新功能(红线 #6),本设计即立项讨论结论。工作量约 1~1.5 天,独立小分支插队,不挤占 C1/VL 主线。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 「身体档案」输入框语音输入 设计
|
||||
|
||||
> 2026-06-10 · 在「身体档案」(`HealthExportSheet`)底部聊天输入框加端侧语音听写,复用 `SpeechDictationService`,识别文字实时流进输入框。
|
||||
|
||||
## 背景
|
||||
|
||||
「身体档案」composer 是聊天式输入(提问/诉求 → 发送 → LLM 对话/生成报告)。与日记不同,这里输入的内容马上交给 LLM,**不需要"整理"加工**;口述原话直接进输入框即正确行为(类似系统键盘听写)。
|
||||
|
||||
## 决策(已与用户确认)
|
||||
|
||||
| 维度 | 决定 |
|
||||
|---|---|
|
||||
| 交互 | 听写直接流进输入框:点 mic 开始,实时上屏;再点停止;用户自查后手动发送 |
|
||||
| LLM | 不调用(无整理步骤、不自动发送) |
|
||||
| 复用 | `SpeechDictationService`(**@State 持有**,防视图重建丢实例)、权限 alert 文案、3 分钟看门狗、onDisappear abort |
|
||||
| UI | mic 按钮放 TextField 与发送键之间;`isAvailable == false` 隐藏;录音中变红色停止态(脉冲动画) |
|
||||
|
||||
## 组件
|
||||
|
||||
### 1. `SpeechDictationService.merge(prefix:partial:)`(新,static 纯函数)
|
||||
|
||||
听写文本拼接规则,唯一可单测的逻辑:
|
||||
- `prefix` 为空 → 返回 `partial`
|
||||
- `prefix` 以空白/换行结尾 → `prefix + partial`
|
||||
- 其余 → `prefix + " " + partial`
|
||||
|
||||
### 2. `HealthExportSheet` 改动
|
||||
|
||||
- `@State dictation` + `isDictating` + `dictationPrefix` + 看门狗 Task
|
||||
- 点 mic:申请权限(拒绝 → alert 跳设置,与日记同文案)→ 记录 `dictationPrefix = draftQuestion` → start,每个 partial:`draftQuestion = merge(prefix:partial:)`
|
||||
- 再点:`stop()`,最终稿同 merge 落定;**stop 返回空时保留输入框现状**(partial 已实时在框里,天然兜底,不提示「没听清」)
|
||||
- 3 分钟看门狗自动停(防麦克风悬挂)
|
||||
|
||||
## 冲突防护
|
||||
|
||||
- 录音中:TextField 与发送按钮、「生成整理报告」按钮禁用(防手输与 partial 互相覆盖、防录音中发送)
|
||||
- `isAnswering / isGeneratingReport` 时 mic 禁用
|
||||
- `onDisappear` abort
|
||||
|
||||
## 测试
|
||||
|
||||
- `merge(prefix:partial:)` 3 个单测(空前缀 / 空白结尾前缀 / 普通前缀)
|
||||
- 真机手测:听写上屏、停止落定、已有文字保留、权限拒绝、3 分钟自动停
|
||||
|
||||
## 不做(YAGNI)
|
||||
|
||||
快捷问答弹窗 / 个人资料 Form 等其他输入处的语音;自动发送;录音面板;LLM 整理。
|
||||
116
docs/小红书创作笔记-GDG出海创想赛.md
Normal 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
|
||||
|
||||
---
|
||||
|
||||
## 二、配图建议(小红书是图片优先,建议 6–9 张)
|
||||
|
||||
小红书笔记权重很吃图,按「过程 → 效果」讲故事。推荐顺序:
|
||||
|
||||
| 顺序 | 内容 | 说明 |
|
||||
|---|---|---|
|
||||
| 封面 | 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 出海创想赛作品」,方便主办方检索
|
||||
210
docs/踩坑与排查记录.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 康康 · 踩坑与排查记录
|
||||
|
||||
> 本地推理 / SwiftData / 端侧模型这类问题不好复现也不好搜,踩过的坑按统一模板记在这里,方便回查。
|
||||
> 新增条目往最上面加(倒序),模板见文末。
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-09 · 生成身体档案报告时,LLM 逐行复读死循环
|
||||
|
||||
### 现象
|
||||
多轮「身体档案」对话点生成报告后,「## 关键指标」整段陷入死循环:同一行
|
||||
`⚠️ 收缩压 (107 mmHg)` 连续重复几十遍,最后被 maxTokens 截断成半行「⚠️ 收缩」。
|
||||
(本质是小模型 **repetition / degeneration loop**,不是数据真有几十条。)
|
||||
|
||||
### 根因(确认)
|
||||
采样器**完全没有重复惩罚**,叠加低温 → 几乎必然复读。两个后端都有问题:
|
||||
|
||||
| 后端 | 位置 | 原配置 | 问题 |
|
||||
|---|---|---|---|
|
||||
| MNN(主) | `MNNLLMBridge.mm` `initWithConfigPath` 的 `set_config` | `temperature 0.3, topP 0.85` | 无 `penalty` |
|
||||
| MLX(兜底) | `LLMSession.swift` `GenerateParameters` | `temperature 0.3, topP 0.85` | 无 `repetitionPenalty` |
|
||||
|
||||
关键细节(读 MNN 源码 `transformers/llm/engine/src/`):
|
||||
- `llmconfig.hpp`:`mixed_samplers` 默认 `{topK, tfs, typical, topP, min_p, temperature}` —— **不含 `penalty`**;
|
||||
`penalty` / `ngram_factor` 默认 `1.0`(=全关)。
|
||||
- `sampler.cpp` `configMixed`:只会把 `penalty`「**移到链首(如果存在)**」,**不会自动插入**。
|
||||
所以光设 `"penalty":1.1` 没用,必须把 `"penalty"` 显式写进 `mixed_samplers`。
|
||||
- `sampler.cpp` `stepPenalty`:`repetition_penalty` 对 logits 乘法惩罚;**n-gram 命中整段重复时惩罚直接升到 `max_penalty`** —— 这正是掐断「整行复读」最有效的开关。
|
||||
|
||||
**为什么低温反而更糟**:temperature 0.3 接近贪心,一旦吐出 `收缩压 (107 mmHg)\n`,
|
||||
最高概率的后续就是再吐一遍同样的行,无惩罚就永远出不来。
|
||||
|
||||
### 排查过程(可复用思路)
|
||||
1. 看现象先判定是「数据重复」还是「生成复读」—— 被截断成半行 `收缩` 说明是 token 级复读,不是数据。
|
||||
2. `grep -niE "penalty|temperature|top_?p|sampler" 康康/AI/` 一把定位两个后端的采样配置 → 都没 penalty。
|
||||
3. 不猜 MNN 配置键,直接读构建用的源码 `MNN_SRC=/Users/xuhuayong/apps/MNN-src`
|
||||
的 `llmconfig.hpp` / `sampler.cpp`,确认键名、默认值、`mixed_samplers` 不自动插 penalty。
|
||||
4. MLX 侧读 SPM checkout 的 `MLXLMCommon/Evaluate.swift`,确认 `GenerateParameters` 有
|
||||
`repetitionPenalty: Float?` + `repetitionContextSize: Int`。
|
||||
|
||||
### 修复
|
||||
- **MNN** `MNNLLMBridge.mm`:`set_config` 显式开重复惩罚 +
|
||||
把 `penalty` 放进 mixed 链首:
|
||||
```jsonc
|
||||
{
|
||||
"jinja":{"context":{"enable_thinking":false}},
|
||||
"sampler_type":"mixed",
|
||||
"mixed_samplers":["penalty","topK","topP","temperature"],
|
||||
"temperature":0.3,"topP":0.85,"topK":40,
|
||||
"penalty":1.1,"n_gram":8,"ngram_factor":1.05
|
||||
}
|
||||
```
|
||||
(注意:JSON merge-patch 对数组是**整体替换**,所以这里会覆盖掉默认 `mixed_samplers`,符合预期。)
|
||||
- **MLX** `LLMSession.swift`:`GenerateParameters(..., repetitionPenalty: 1.1, repetitionContextSize: 64)`。
|
||||
|
||||
取值都偏保守:`penalty 1.1` / `ngram_factor 1.05` 是业界常用档(MNN 自带 omni 默认 1.05),
|
||||
低温 + 轻惩罚既能掐复读,又不破坏 JSON / 结构化输出的稳定性。
|
||||
|
||||
### 验证
|
||||
- `xcodebuild ... -destination generic/platform=iOS` 编译通过(两个后端均编进)。
|
||||
- ⚠️ **真机/模拟器跑一遍多轮导出生成报告**,确认不再复读 —— 复读属推理期行为,单测覆盖不到,必须实跑。
|
||||
|
||||
### 预防 / 相关注意
|
||||
- 任何新增的「长文本生成」(非 JSON 抽取)都走同一套带惩罚的采样参数,别再裸 temperature。
|
||||
- **相关隐患(未修,留观)**:`HealthExportService.retrieveDialogueSnapshot` 取指标时
|
||||
**没有 `prefix` 截断**(窗口检索版 `retrieve` 截了 `prefix(20)`)。指标极多时 prompt 会膨胀、
|
||||
也更易诱发复读。若复发,优先给 dialogue snapshot 也加上限。
|
||||
|
||||
---
|
||||
|
||||
> 以下几条据 W1–W2(2026-05~06)记忆补记,细节以代码/提交为准。
|
||||
|
||||
## 2026-06-09 · MNN 路径 Qwen3.5 强制思考,只吐 `<think>` / JSON 解析失败
|
||||
|
||||
### 现象
|
||||
MNN 真机路径上模型自检只显示 `<think>` 思考过程,AI 辅助拿不到 JSON(解析失败);
|
||||
同样的 prompt 走 MLX 兜底却正常。
|
||||
|
||||
### 根因
|
||||
模型自带 `config.json`(taobao-mnn 预转换件)写死 `"jinja":{"context":{"enable_thinking":true}}`,
|
||||
Qwen3.5 聊天模板据此每个 assistant 回合硬塞 `<think>\n` 开思考,吞掉 token 预算。
|
||||
**prompt 里的 `/no_think` 对 MNN 无效** —— 模板只读 `enable_thinking`,不看文本软开关。
|
||||
只在真机爆是因为 MLX 经 swift-transformers 套模板时不传 `enable_thinking` → 走 else 空 think 块,天然不思考。
|
||||
(这点从仓库代码看不出来,config.json 是下载/旁路导入的模型产物,不在 git 里。)
|
||||
|
||||
### 修复
|
||||
`MNNLLMBridge.mm` 在 `createLLM` 后、`load()` 前 merge-patch 关闭:
|
||||
`set_config("{\"jinja\":{\"context\":{\"enable_thinking\":false}}}")`。不改模型文件、不动字节校验。`stripThink` 保留兜底。
|
||||
|
||||
### 预防
|
||||
再遇 MNN 只出思考 / JSON 解析失败,先查 `config.json` 的 `enable_thinking`,别去调 `/no_think` 或加大预算。
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-07 · 「记录指标·拍照识别」VL 直读化验单不稳 → 改 Vision OCR + LLM
|
||||
|
||||
### 现象
|
||||
Qwen-VL 直读密集小字化验单经常返回 `{"indicators":[]}`(读不出指标)。
|
||||
|
||||
### 根因 / 决策
|
||||
小模型 VL 对密集中文小字不稳。改链路:`DocumentScanner 整页扫描 → Apple Vision OCR(zh-Hans/Hant/en)
|
||||
→ Qwen3 LLM 解析(VLPrompts.indicatorsFromText)→ stripThink → parseIndicatorsJSON → 确认页人工校对 → 存`。
|
||||
Vision OCR 是系统框架、100% 本地,不违反隐私红线。
|
||||
|
||||
### 预防
|
||||
这条路**不要改回 VL 直读**。VL 仍只用于「体检报告归档」整份解读,两者分开。OCR 行分组偶有错位,靠确认页人工校正兜底。
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-01 · git 全量 push 撞 HTTP 413(历史里有 165MB 构建产物)
|
||||
|
||||
### 现象
|
||||
`git push` 到 myv0(Gitea 反代有上传体积限制)报 **HTTP 413**。
|
||||
|
||||
### 根因
|
||||
旧 commit 误把 `build/` 构建产物提交进库(最大单文件 xcarchive DWARF **165MB**),后来虽 `git rm --cached` + `.gitignore`,
|
||||
但对象仍留在历史 → `.git` 87MB,全量 push 超反代上限。
|
||||
|
||||
### 修复
|
||||
对主仓库 `git filter-repo --path build/ --invert-paths --force` 从全历史剥离 → `.git` 87M→2.9M,不再 413。
|
||||
注意:① 重写了所有 commit hash(内容不变),旧克隆需重新 clone;② filter-repo 会移除所有 remote,事后须重新 `git remote add origin`;③ 凭证不写入 `.git/config`。
|
||||
|
||||
### 预防
|
||||
`build/` 必须在 `.gitignore`;别把构建产物 / 大二进制提交进库。
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-31 · 快拍 VL 识别时 App 自动退出(jetsam OOM,非崩溃)
|
||||
|
||||
### 现象
|
||||
iPhone 15 Pro Max 上 VL 识别时 App 直接退出。
|
||||
|
||||
### 根因
|
||||
不是代码崩溃(catch 只切 warning 屏,Swift 报错不会杀进程),是 **OS 内存超限 jetsam kill**。三因叠加:
|
||||
① 无 entitlement(8GB 设备默认单 App 上限 ~3GB,VL ~3GB 常驻冲过);② 从不卸载模型(LLM ~1GB + VL ~3GB 同驻 → 4GB+);③ 没设 MLX cache 上限。
|
||||
|
||||
### 修复
|
||||
① 新建 `康康.entitlements` 加 `com.apple.developer.kernel.increased-memory-limit=true`;
|
||||
② `AIRuntime` 加 `unloadLLM/unloadVL` 做**常驻互斥**(两大模型永不同时驻留)+ actor 内**串行推理闸门**(GPU 同一时刻只一个解码/加载);
|
||||
③ `GPU.set(cacheLimit: 256MB)`,启动调一次。
|
||||
|
||||
### 验证
|
||||
编译 + 单测通过。⚠️ **真机 OOM 是否真消失仍需 iPhone 15 Pro Max 实测**(本机无法跑真机)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-30 · 每次重打包 SwiftData 数据被清空
|
||||
|
||||
### 现象
|
||||
W2 期每次重新打包安装,本地数据全没了。
|
||||
|
||||
### 根因
|
||||
`KangkangApp.swift` 里 `ModelContainer` 创建失败的 catch 块原本**直接删 store 文件**。
|
||||
SwiftData 只对纯增量改动自动轻量迁移;一旦 schema 改动超纲(最常见:**给已存在 `@Model` 新增「非可选且无内联默认值」属性**)→ 迁移抛错 → 进 catch → 删库。
|
||||
|
||||
### 修复
|
||||
catch 改为把旧 store(含 `-wal`/`-shm`)挪到 `Application Support/StoreBackups/<时间戳>/` 再重建,不删除。
|
||||
|
||||
### 预防
|
||||
给已存在 `@Model` 加属性**一律给可选或内联默认值**(如 `var x: String = "daily"`),才走轻量迁移。正式发布前升级为 `VersionedSchema` + `SchemaMigrationPlan`。
|
||||
|
||||
---
|
||||
|
||||
## (无明确日期)· 编辑 Localizable.xcstrings 炸出上万行噪声 diff
|
||||
|
||||
### 现象
|
||||
改 `Localizable.xcstrings` 新增 3 个 key,却产生 ~15000 行 diff。
|
||||
|
||||
### 根因
|
||||
仓库里该文件是 **Xcode 规范格式**(`"key" : {` 冒号两侧带空格、2 空格缩进、key 按 Xcode 排序、结尾无换行);
|
||||
用 `python json.dump(indent=2)` 重写会把分隔符变成 `": "` 且顺序不同 → 几乎每行都 diff。
|
||||
|
||||
### 修复 / 正确做法
|
||||
基于 HEAD 原始文本做**文本插入**:把新 key 块按 Xcode 格式(` "<key>" : ` + `separators=(',', ' : ')` 的 value)拼到 strings 段末尾,保持结尾无换行。**不要整文件 json.dump 回写**。
|
||||
|
||||
---
|
||||
|
||||
## 附:命令行编译方式(排查时拿真实错误/警告)
|
||||
|
||||
- 系统默认是 Command Line Tools,裸 `xcodebuild` 不可用,需显式指向完整 Xcode:
|
||||
`export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`
|
||||
- **必须用独立 derivedDataPath**(如 `-derivedDataPath /tmp/kk-derived-xxx`),否则和 Xcode 抢同一把 `build.db` 锁报 `database is locked`(不是代码错)。
|
||||
- 增量编译会吞警告:要看某文件警告先 `touch` 它强制重编,再 grep `error:|warning:|BUILD (SUCCEEDED|FAILED)`。
|
||||
- 工程是 Swift 5 + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`;跨到 `nonisolated` 调 MainActor 成员的隔离警告(标 "error in Swift 6 mode")在 Swift 5 下不阻塞构建。
|
||||
|
||||
---
|
||||
|
||||
## 模板(复制下面这段新增条目)
|
||||
|
||||
```markdown
|
||||
## YYYY-MM-DD · 一句话标题
|
||||
|
||||
### 现象
|
||||
(用户看到什么 / 怎么触发)
|
||||
|
||||
### 根因(确认)
|
||||
(定位到的真正原因,不是猜测;贴关键文件:行)
|
||||
|
||||
### 排查过程
|
||||
(怎么一步步定位的,方便下次复用思路)
|
||||
|
||||
### 修复
|
||||
(改了什么,贴 diff 要点或配置)
|
||||
|
||||
### 验证
|
||||
(怎么确认修好了;不能单测的要写明需实跑)
|
||||
|
||||
### 预防 / 相关注意
|
||||
(怎么避免再犯;顺带发现的隐患)
|
||||
```
|
||||
112
scripts/build-launch.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT="${PROJECT:-$ROOT_DIR/康康.xcodeproj}"
|
||||
SCHEME="${SCHEME:-康康}"
|
||||
APP_NAME="${APP_NAME:-$SCHEME}"
|
||||
CONFIGURATION="${CONFIGURATION:-Debug}"
|
||||
BUNDLE_ID="${BUNDLE_ID:-com.xuhuayong.kangkang}"
|
||||
DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$ROOT_DIR/build/DerivedData}"
|
||||
SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 16 Pro}"
|
||||
SCREENSHOT_PATH="${SCREENSHOT_PATH:-$ROOT_DIR/build/screenshots/${SCHEME}-launch.png}"
|
||||
|
||||
require_tool() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "error: required tool not found: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_full_xcode() {
|
||||
local developer_dir
|
||||
developer_dir="$(xcode-select -p 2>/dev/null || true)"
|
||||
if [[ "$developer_dir" != *"/Xcode.app/Contents/Developer"* ]]; then
|
||||
cat >&2 <<EOF
|
||||
error: active developer directory is not a full Xcode install:
|
||||
${developer_dir:-<unset>}
|
||||
|
||||
Select Xcode before running this script:
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
extract_udid() {
|
||||
sed -n 's/.*(\([0-9A-Fa-f-]\{36\}\)).*/\1/p' | head -n 1
|
||||
}
|
||||
|
||||
find_simulator_udid() {
|
||||
if [[ -n "${SIMULATOR_UDID:-}" ]]; then
|
||||
echo "$SIMULATOR_UDID"
|
||||
return
|
||||
fi
|
||||
|
||||
local udid
|
||||
udid="$(xcrun simctl list devices available | grep -F "$SIMULATOR_NAME" | extract_udid || true)"
|
||||
if [[ -n "$udid" ]]; then
|
||||
echo "$udid"
|
||||
return
|
||||
fi
|
||||
|
||||
udid="$(
|
||||
xcrun simctl list devices available |
|
||||
awk '/-- iOS / { in_ios = 1; next } /-- / { in_ios = 0 } in_ios && /iPhone/ { print; exit }' |
|
||||
extract_udid || true
|
||||
)"
|
||||
if [[ -n "$udid" ]]; then
|
||||
echo "$udid"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "error: no available iOS simulator found. Install an iPhone simulator in Xcode." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
main() {
|
||||
require_tool xcode-select
|
||||
require_tool xcodebuild
|
||||
require_tool xcrun
|
||||
require_full_xcode
|
||||
|
||||
local simulator_udid app_path
|
||||
simulator_udid="$(find_simulator_udid)"
|
||||
|
||||
echo "Project: $PROJECT"
|
||||
echo "Scheme: $SCHEME"
|
||||
echo "Configuration: $CONFIGURATION"
|
||||
echo "Simulator: ${SIMULATOR_UDID:-$SIMULATOR_NAME} ($simulator_udid)"
|
||||
|
||||
xcodebuild \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIGURATION" \
|
||||
-destination "id=$simulator_udid" \
|
||||
-derivedDataPath "$DERIVED_DATA_PATH" \
|
||||
build
|
||||
|
||||
app_path="$DERIVED_DATA_PATH/Build/Products/${CONFIGURATION}-iphonesimulator/${APP_NAME}.app"
|
||||
if [[ ! -d "$app_path" ]]; then
|
||||
echo "error: built app not found at $app_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
xcrun simctl boot "$simulator_udid" >/dev/null 2>&1 || true
|
||||
xcrun simctl bootstatus "$simulator_udid" -b
|
||||
if [[ "${OPEN_SIMULATOR:-1}" == "1" ]]; then
|
||||
open -a Simulator --args -CurrentDeviceUDID "$simulator_udid"
|
||||
fi
|
||||
|
||||
xcrun simctl install "$simulator_udid" "$app_path"
|
||||
xcrun simctl launch "$simulator_udid" "$BUNDLE_ID"
|
||||
|
||||
mkdir -p "$(dirname "$SCREENSHOT_PATH")"
|
||||
sleep "${SCREENSHOT_DELAY_SECONDS:-2}"
|
||||
xcrun simctl io "$simulator_udid" screenshot "$SCREENSHOT_PATH"
|
||||
|
||||
echo "Launched $BUNDLE_ID"
|
||||
echo "Screenshot: $SCREENSHOT_PATH"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
51
scripts/build-mnn-xcframework.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
# 构建 MNN.xcframework(device arm64 + simulator arm64),含 LLM 引擎 + SME2。
|
||||
# 产物输出到 康康/../Frameworks/MNN.xcframework(被 .gitignore,不入库,防历史膨胀)。
|
||||
#
|
||||
# 用法:
|
||||
# MNN_SRC=/path/to/MNN sh scripts/build-mnn-xcframework.sh
|
||||
# 需求:CMake 3.14+、Xcode、约 10-40 分钟。
|
||||
#
|
||||
# 关键 flag:
|
||||
# MNN_BUILD_LLM=ON —— 编入 llm 引擎(并导出 llm/llm.hpp),自动开 MNN_LOW_MEMORY
|
||||
# MNN_BUILD_LLM_OMNI=ON —— VL(图→文)所需:多模态 Omni + OpenCV 图像解码。
|
||||
# 统一模型(Qwen3.5-2B-MNN 一肩挑文本+视觉)必须开。
|
||||
# MNN_SME2=ON —— CMake 默认 ON,A19/iPhone17 运行时经 KleidiAI 自动启用,A17 回退 NEON
|
||||
# MNN_METAL=OFF —— 考核走 CPU+SME2,关 Metal 保持精简
|
||||
set -e
|
||||
|
||||
MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}"
|
||||
OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/Frameworks"
|
||||
TOOLCHAIN_NEW="${MNN_SRC}/cmake/ios.toolchain.new.cmake"
|
||||
EXTRA="-DMNN_BUILD_LLM=ON -DMNN_BUILD_LLM_OMNI=ON -DMNN_METAL=OFF -DMNN_ARM82=true -DMNN_SME2=ON"
|
||||
COMMON="-DCMAKE_BUILD_TYPE=Release -DENABLE_BITCODE=0 -DMNN_AAPL_FMWK=1 -DMNN_SEP_BUILD=0 -DMNN_BUILD_SHARED_LIBS=false -DMNN_USE_THREAD_POOL=OFF"
|
||||
|
||||
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
|
||||
cd "$MNN_SRC"
|
||||
|
||||
# 新版 ios-cmake toolchain(支持 SIMULATORARM64;MNN 自带的旧版只支持 x86_64 模拟器)
|
||||
if [ ! -f "$TOOLCHAIN_NEW" ]; then
|
||||
curl -sL "https://raw.githubusercontent.com/leetal/ios-cmake/master/ios.toolchain.cmake" -o "$TOOLCHAIN_NEW"
|
||||
fi
|
||||
|
||||
# device arm64
|
||||
rm -rf build-dev-arm64 && mkdir build-dev-arm64 && cd build-dev-arm64
|
||||
cmake .. $COMMON $EXTRA -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_NEW" -DPLATFORM=OS64 -DDEPLOYMENT_TARGET=17.0
|
||||
make MNN -j16
|
||||
cd ..
|
||||
|
||||
# simulator arm64
|
||||
rm -rf build-sim-arm64 && mkdir build-sim-arm64 && cd build-sim-arm64
|
||||
cmake .. $COMMON $EXTRA -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_NEW" -DPLATFORM=SIMULATORARM64 -DDEPLOYMENT_TARGET=17.0
|
||||
make MNN -j16
|
||||
cd ..
|
||||
|
||||
# 合成 xcframework
|
||||
rm -rf "$OUT_DIR/MNN.xcframework"
|
||||
mkdir -p "$OUT_DIR"
|
||||
xcrun xcodebuild -create-xcframework \
|
||||
-framework build-dev-arm64/MNN.framework \
|
||||
-framework build-sim-arm64/MNN.framework \
|
||||
-output "$OUT_DIR/MNN.xcframework"
|
||||
|
||||
echo "✅ 输出: $OUT_DIR/MNN.xcframework"
|
||||
52
scripts/build-sherpa-mnn-xcframework.sh
Executable 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"
|
||||
65
scripts/convert-sensevoice-mnn.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/sh
|
||||
# 把官方 SenseVoice ONNX 模型转换成 MNN 格式,产出「记录问诊」端侧转写要用的两件套:
|
||||
# SenseVoice/model.mnn —— MNNConvert 量化转换(weightQuant 8bit)的图
|
||||
# SenseVoice/tokens.txt —— id↔token 映射(直接拷 onnx 包里的)
|
||||
#
|
||||
# 产物目录可:
|
||||
# - 旁路导入:拷到模拟器/真机沙盒 Application Support/Models/SenseVoice/(demo 现场重装兜底);
|
||||
# - 或上传到你的镜像,后续做成 App 内下载项(本期默认旁路导入,见集成文档)。
|
||||
#
|
||||
# 用法:
|
||||
# MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/convert-sensevoice-mnn.sh
|
||||
#
|
||||
# 需求:MNNConvert(从 MNN 源码 -DMNN_BUILD_CONVERTER=ON 编出)、wget/curl、tar。
|
||||
set -e
|
||||
|
||||
MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}"
|
||||
OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/build/SenseVoice"
|
||||
WORK_DIR="$(cd "$(dirname "$0")/.." && pwd)/build/sensevoice-src"
|
||||
MODEL_TAR="sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
|
||||
MODEL_URL="https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/${MODEL_TAR}.tar.bz2"
|
||||
|
||||
# ① 找 MNNConvert。
|
||||
MNNCONVERT="${MNNCONVERT:-${MNN_SRC}/build/MNNConvert}"
|
||||
if [ ! -x "$MNNCONVERT" ]; then
|
||||
echo "❌ 找不到 MNNConvert:$MNNCONVERT"
|
||||
echo " 先编译转换器(在 MNN 源码里):"
|
||||
echo " cd \"$MNN_SRC\" && mkdir -p build && cd build \\"
|
||||
echo " && cmake .. -DMNN_BUILD_CONVERTER=ON -DMNN_LOW_MEMORY=ON -DMNN_SEP_BUILD=OFF && make MNNConvert -j8"
|
||||
echo " 或 export MNNCONVERT=/path/to/MNNConvert 后重跑。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ② 下载 + 解包 SenseVoice ONNX(含 fp32 model.onnx / int8 / tokens.txt)。
|
||||
mkdir -p "$WORK_DIR"
|
||||
cd "$WORK_DIR"
|
||||
if [ ! -d "$MODEL_TAR" ]; then
|
||||
echo "⬇️ 下载 SenseVoice ONNX 模型…"
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -c "$MODEL_URL"
|
||||
else
|
||||
curl -L -O "$MODEL_URL"
|
||||
fi
|
||||
tar xvf "${MODEL_TAR}.tar.bz2"
|
||||
fi
|
||||
|
||||
SRC_ONNX="${WORK_DIR}/${MODEL_TAR}/model.onnx"
|
||||
SRC_TOKENS="${WORK_DIR}/${MODEL_TAR}/tokens.txt"
|
||||
[ -f "$SRC_ONNX" ] || { echo "❌ 缺 model.onnx(应为 fp32,勿用 int8):$SRC_ONNX"; exit 1; }
|
||||
[ -f "$SRC_TOKENS" ] || { echo "❌ 缺 tokens.txt:$SRC_TOKENS"; exit 1; }
|
||||
|
||||
# ③ 转换:fp32 onnx → mnn(权重 8bit 量化,降体积+配合 MNN_LOW_MEMORY 降运行内存)。
|
||||
# 红线:转 fp32 的 model.onnx,不要转 model.int8.onnx(README 明确)。
|
||||
mkdir -p "$OUT_DIR"
|
||||
echo "🔧 转换 model.onnx → model.mnn(weightQuant 8bit)…"
|
||||
"$MNNCONVERT" -f ONNX \
|
||||
--modelFile "$SRC_ONNX" \
|
||||
--MNNModel "$OUT_DIR/model.mnn" \
|
||||
--weightQuantBits=8 --weightQuantBlock=64
|
||||
|
||||
cp "$SRC_TOKENS" "$OUT_DIR/tokens.txt"
|
||||
|
||||
echo "✅ 输出: $OUT_DIR/{model.mnn, tokens.txt}"
|
||||
echo "安装到沙盒(见 docs/release/sensevoice-integration.md):"
|
||||
echo " - 模拟器:拷到 ~/Library/Developer/CoreSimulator/.../Application Support/Models/SenseVoice/"
|
||||
echo " - 真机:经「我的 · 模型管理」旁路导入,或预拷进沙盒 Models/SenseVoice/"
|
||||
68
scripts/fetch-qwen3vl.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# 下载 Qwen3-VL-4B-Instruct-4bit(MLX 4bit)全量文件到本地镜像目录,并逐个校验字节数。
|
||||
# 字节数权威来源:康康/AI/ModelManifest.swift(HF API blobs=true,2026-05 核对)。
|
||||
# 用法: bash scripts/fetch-qwen3vl.sh
|
||||
set -uo pipefail
|
||||
|
||||
REPO="mlx-community/Qwen3-VL-4B-Instruct-4bit"
|
||||
BASE="https://huggingface.co/${REPO}/resolve/main"
|
||||
# 目标 = 康康仓库内的 Models/(已被 .gitignore 忽略,App 旁路导入也认这个目录名)。
|
||||
# 可用环境变量 KK_MODELS_DIR 覆盖根目录(如指向另一块盘)。
|
||||
ROOT="${KK_MODELS_DIR:-/Users/xuhuayong/apps/康康/Models}"
|
||||
DEST="$ROOT/Qwen3-VL-4B-Instruct-4bit"
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# 文件名:期望字节数(与 ModelManifest.swift 的 .vl 清单一一对应)
|
||||
FILES=(
|
||||
"config.json:7137"
|
||||
"model.safetensors:3093767283"
|
||||
"model.safetensors.index.json:64742"
|
||||
"tokenizer.json:11422654"
|
||||
"tokenizer_config.json:5445"
|
||||
"vocab.json:2776833"
|
||||
"merges.txt:1671853"
|
||||
"special_tokens_map.json:613"
|
||||
"added_tokens.json:707"
|
||||
"generation_config.json:269"
|
||||
"chat_template.json:5502"
|
||||
"chat_template.jinja:5292"
|
||||
"preprocessor_config.json:782"
|
||||
"video_preprocessor_config.json:817"
|
||||
)
|
||||
|
||||
fsize() { stat -f%z "$1" 2>/dev/null || echo 0; }
|
||||
|
||||
fail=0
|
||||
for entry in "${FILES[@]}"; do
|
||||
name="${entry%%:*}"; want="${entry##*:}"; out="$DEST/$name"
|
||||
if [[ -f "$out" && "$(fsize "$out")" == "$want" ]]; then
|
||||
echo "SKIP $name (已完整 $want)"; continue
|
||||
fi
|
||||
echo "GET $name (期望 $want 字节)"
|
||||
curl -fL -C - --retry 5 --retry-delay 3 --connect-timeout 30 \
|
||||
-o "$out" "$BASE/$name" || { echo " !! 下载失败 $name"; fail=1; continue; }
|
||||
have="$(fsize "$out")"
|
||||
if [[ "$have" != "$want" ]]; then
|
||||
echo " !! 字节不符 $name: 实得 $have / 期望 $want"; fail=1
|
||||
else
|
||||
echo " OK $name $have"
|
||||
fi
|
||||
done
|
||||
|
||||
# 大权重额外做 SHA256 校验(HF LFS oid,密码学级,字节数相同也能查出脏数据)。
|
||||
WEIGHT_SHA="90eeb02604181dbcccd0a30a1f550a4a8928ca7dcbee4aee1449239306cfdfca"
|
||||
if [[ -f "$DEST/model.safetensors" ]]; then
|
||||
echo "校验 model.safetensors SHA256(约需 10 余秒)..."
|
||||
got="$(shasum -a 256 "$DEST/model.safetensors" | awk '{print $1}')"
|
||||
if [[ "$got" == "$WEIGHT_SHA" ]]; then
|
||||
echo " ✓ SHA256 匹配"
|
||||
else
|
||||
echo " !! SHA256 不符: 实得 $got / 期望 $WEIGHT_SHA"; fail=1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "================================================"
|
||||
total=$(du -sh "$DEST" 2>/dev/null | cut -f1)
|
||||
echo "目录: $DEST (合计 $total)"
|
||||
if [[ "$fail" == "0" ]]; then echo "✅ 全部 14 个文件下载并校验通过(权重含 SHA256)"; else echo "❌ 有文件失败,重跑本脚本可断点续传"; fi
|
||||
exit "$fail"
|
||||
89
scripts/release-testflight.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键发布 TestFlight:archive → export → 上传 App Store Connect
|
||||
# 用法:
|
||||
# ./scripts/release-testflight.sh # 用当前 build 号
|
||||
# BUMP=1 ./scripts/release-testflight.sh # 自动递增 build 号后再发布
|
||||
# 认证:依赖 Xcode 已登录的 Apple ID(Xcode → Settings → Accounts)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT="${PROJECT:-$ROOT_DIR/康康.xcodeproj}"
|
||||
SCHEME="${SCHEME:-康康}"
|
||||
CONFIGURATION="${CONFIGURATION:-Release}"
|
||||
BUILD_DIR="$ROOT_DIR/build/Release"
|
||||
ARCHIVE_PATH="$BUILD_DIR/${SCHEME}.xcarchive"
|
||||
EXPORT_PATH="$BUILD_DIR/export"
|
||||
EXPORT_PLIST="$BUILD_DIR/ExportOptions.plist"
|
||||
TEAM_ID="${TEAM_ID:-F2C8C774FG}"
|
||||
|
||||
require_full_xcode() {
|
||||
local developer_dir
|
||||
developer_dir="$(xcode-select -p 2>/dev/null || true)"
|
||||
if [[ "$developer_dir" != *"/Xcode.app/Contents/Developer"* ]]; then
|
||||
cat >&2 <<EOF
|
||||
error: 当前 developer directory 不是完整 Xcode:
|
||||
${developer_dir:-<unset>}
|
||||
请先执行:
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_full_xcode
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# 可选:递增 build 号
|
||||
if [[ "${BUMP:-0}" == "1" ]]; then
|
||||
CURRENT=$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||
NEXT=$((CURRENT + 1))
|
||||
sed -i '' "s/CURRENT_PROJECT_VERSION = $CURRENT;/CURRENT_PROJECT_VERSION = $NEXT;/g" "$PROJECT/project.pbxproj"
|
||||
echo "==> Build 号: $CURRENT → $NEXT"
|
||||
fi
|
||||
|
||||
BUILD_NUM=$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||
VERSION=$(sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||
echo "==> 发布 v$VERSION ($BUILD_NUM)"
|
||||
|
||||
echo "==> [1/3] Archive..."
|
||||
rm -rf "$ARCHIVE_PATH"
|
||||
xcodebuild archive \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIGURATION" \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-allowProvisioningUpdates \
|
||||
-skipMacroValidation
|
||||
|
||||
echo "==> [2/3] 生成 ExportOptions.plist..."
|
||||
cat > "$EXPORT_PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>destination</key>
|
||||
<string>upload</string>
|
||||
<key>teamID</key>
|
||||
<string>$TEAM_ID</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
echo "==> [3/3] Export 并上传 App Store Connect..."
|
||||
rm -rf "$EXPORT_PATH"
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportOptionsPlist "$EXPORT_PLIST" \
|
||||
-exportPath "$EXPORT_PATH" \
|
||||
-allowProvisioningUpdates
|
||||
|
||||
echo ""
|
||||
echo "✅ v$VERSION ($BUILD_NUM) 已上传。App Store Connect 处理完成后(约 5-15 分钟)即可在 TestFlight 分发。"
|
||||
echo " https://appstoreconnect.apple.com/apps"
|
||||
53
scripts/upload-qwen3vl.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# 把本地 Models/Qwen3-VL-4B-Instruct-4bit/ 的 14 个文件上传到模型分发服务器,
|
||||
# 使 App 的「模型管理 · 下载」能拉到新 VL 模型(否则用户点下载会 404)。
|
||||
#
|
||||
# 服务器:Caddy(file_server browse),web 根 = /srv/models,SSH = root@101.132.124.52。
|
||||
# App 下载 URL 形如:https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/<file>
|
||||
# → openresty(终止 HTTPS)回源到 Caddy :80(root /srv/models)。
|
||||
# → 所以远端目标目录 = /srv/models/Qwen3-VL-4B-Instruct-4bit/。
|
||||
#
|
||||
# 认证:已用 ssh-copy-id 装好本机公钥,走免密 key;脚本内不含任何密码。
|
||||
# 用法: bash scripts/upload-qwen3vl.sh
|
||||
set -euo pipefail
|
||||
|
||||
LOCAL_DIR="/Users/xuhuayong/apps/康康/Models/Qwen3-VL-4B-Instruct-4bit"
|
||||
SSH_HOST="root@101.132.124.52"
|
||||
REMOTE_ROOT="/srv/models"
|
||||
REMOTE_SUBDIR="Qwen3-VL-4B-Instruct-4bit"
|
||||
REMOTE_DIR="$REMOTE_ROOT/$REMOTE_SUBDIR"
|
||||
|
||||
# 上传前本地完整性自检(逐字节,14 文件全 SKIP 才算齐)。
|
||||
bash "$(dirname "$0")/fetch-qwen3vl.sh" >/dev/null || { echo "本地文件不完整,先跑 fetch-qwen3vl.sh 修复再上传"; exit 1; }
|
||||
echo "本地 14 文件校验通过,开始上传 → $SSH_HOST:$REMOTE_DIR/"
|
||||
|
||||
ssh -o ConnectTimeout=20 "$SSH_HOST" "mkdir -p '$REMOTE_DIR'"
|
||||
|
||||
# rsync 断点续传(-P=--partial --progress),--inplace 适合大文件。
|
||||
# 注意:macOS 自带 rsync 2.6.9 不支持 --info=progress2,用 -P 即可。
|
||||
rsync -avP --inplace \
|
||||
-e "ssh -o ConnectTimeout=20" \
|
||||
"$LOCAL_DIR/" "$SSH_HOST:$REMOTE_DIR/"
|
||||
|
||||
echo "✅ rsync 上传完成,开始远端校验..."
|
||||
|
||||
# 远端逐文件大小核对(与本地 ModelManifest 的 14 文件一致)。
|
||||
ssh "$SSH_HOST" "cd '$REMOTE_DIR' && ls -la && echo '--- 总大小 ---' && du -sh ."
|
||||
|
||||
cat <<'TIP'
|
||||
──────────────────────────────────────────────
|
||||
上传完成。建议再从公网验证一次(应全部 HTTP 200,content-length 与本地一致):
|
||||
|
||||
for f in config.json model.safetensors model.safetensors.index.json \
|
||||
tokenizer.json tokenizer_config.json vocab.json merges.txt \
|
||||
special_tokens_map.json added_tokens.json generation_config.json \
|
||||
chat_template.json chat_template.jinja preprocessor_config.json \
|
||||
video_preprocessor_config.json; do
|
||||
curl -sI "https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/$f" \
|
||||
| awk -v F="$f" '/^HTTP/{c=$2} tolower($1)=="content-length:"{s=$2} END{printf "%-32s %s %s\n",F,c,s}'
|
||||
done
|
||||
|
||||
旧模型 Qwen2.5-VL-3B 仍在服务器上;确认新版可用后再删旧目录:
|
||||
ssh root@101.132.124.52 'rm -rf /srv/models/Qwen2.5-VL-3B-Instruct-4bit'
|
||||
──────────────────────────────────────────────
|
||||
TIP
|
||||
@@ -1,620 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
5E463D092FC403BC0089145B /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5E463CF12FC403BB0089145B /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5E463CF82FC403BB0089145B;
|
||||
remoteInfo = "体己";
|
||||
};
|
||||
5E463D132FC403BC0089145B /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5E463CF12FC403BB0089145B /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5E463CF82FC403BB0089145B;
|
||||
remoteInfo = "体己";
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
5E463CF92FC403BB0089145B /* 体己.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "体己.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5E463D082FC403BC0089145B /* 体己Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "体己Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5E463D122FC403BC0089145B /* 体己UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "体己UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
5E463CFB2FC403BB0089145B /* 体己 */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "体己";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5E463D0B2FC403BC0089145B /* 体己Tests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "体己Tests";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5E463D152FC403BC0089145B /* 体己UITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "体己UITests";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
5E463CF62FC403BB0089145B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E463D052FC403BC0089145B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E463D0F2FC403BC0089145B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
5E463CF02FC403BB0089145B = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5E463CFB2FC403BB0089145B /* 体己 */,
|
||||
5E463D0B2FC403BC0089145B /* 体己Tests */,
|
||||
5E463D152FC403BC0089145B /* 体己UITests */,
|
||||
5E463CFA2FC403BB0089145B /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5E463CFA2FC403BB0089145B /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5E463CF92FC403BB0089145B /* 体己.app */,
|
||||
5E463D082FC403BC0089145B /* 体己Tests.xctest */,
|
||||
5E463D122FC403BC0089145B /* 体己UITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
5E463CF82FC403BB0089145B /* 体己 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5E463D1C2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己" */;
|
||||
buildPhases = (
|
||||
5E463CF52FC403BB0089145B /* Sources */,
|
||||
5E463CF62FC403BB0089145B /* Frameworks */,
|
||||
5E463CF72FC403BB0089145B /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
5E463CFB2FC403BB0089145B /* 体己 */,
|
||||
);
|
||||
name = "体己";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "体己";
|
||||
productReference = 5E463CF92FC403BB0089145B /* 体己.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
5E463D072FC403BC0089145B /* 体己Tests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5E463D1F2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己Tests" */;
|
||||
buildPhases = (
|
||||
5E463D042FC403BC0089145B /* Sources */,
|
||||
5E463D052FC403BC0089145B /* Frameworks */,
|
||||
5E463D062FC403BC0089145B /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5E463D0A2FC403BC0089145B /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
5E463D0B2FC403BC0089145B /* 体己Tests */,
|
||||
);
|
||||
name = "体己Tests";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "体己Tests";
|
||||
productReference = 5E463D082FC403BC0089145B /* 体己Tests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
5E463D112FC403BC0089145B /* 体己UITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5E463D222FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己UITests" */;
|
||||
buildPhases = (
|
||||
5E463D0E2FC403BC0089145B /* Sources */,
|
||||
5E463D0F2FC403BC0089145B /* Frameworks */,
|
||||
5E463D102FC403BC0089145B /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5E463D142FC403BC0089145B /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
5E463D152FC403BC0089145B /* 体己UITests */,
|
||||
);
|
||||
name = "体己UITests";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "体己UITests";
|
||||
productReference = 5E463D122FC403BC0089145B /* 体己UITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
5E463CF12FC403BB0089145B /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
5E463CF82FC403BB0089145B = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
};
|
||||
5E463D072FC403BC0089145B = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
TestTargetID = 5E463CF82FC403BB0089145B;
|
||||
};
|
||||
5E463D112FC403BC0089145B = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
TestTargetID = 5E463CF82FC403BB0089145B;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "体己" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 5E463CF02FC403BB0089145B;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
5E463CF82FC403BB0089145B /* 体己 */,
|
||||
5E463D072FC403BC0089145B /* 体己Tests */,
|
||||
5E463D112FC403BC0089145B /* 体己UITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
5E463CF72FC403BB0089145B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E463D062FC403BC0089145B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E463D102FC403BC0089145B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
5E463CF52FC403BB0089145B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E463D042FC403BC0089145B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E463D0E2FC403BC0089145B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
5E463D0A2FC403BC0089145B /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5E463CF82FC403BB0089145B /* 体己 */;
|
||||
targetProxy = 5E463D092FC403BC0089145B /* PBXContainerItemProxy */;
|
||||
};
|
||||
5E463D142FC403BC0089145B /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5E463CF82FC403BB0089145B /* 体己 */;
|
||||
targetProxy = 5E463D132FC403BC0089145B /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
5E463D1A2FC403BC0089145B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5E463D1B2FC403BC0089145B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
5E463D1D2FC403BC0089145B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5E463D1E2FC403BC0089145B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
5E463D202FC403BC0089145B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--Tests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/体己.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/体己";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5E463D212FC403BC0089145B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--Tests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/体己.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/体己";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
5E463D232FC403BC0089145B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--UITests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TEST_TARGET_NAME = "体己";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5E463D242FC403BC0089145B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--UITests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TEST_TARGET_NAME = "体己";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "体己" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5E463D1A2FC403BC0089145B /* Debug */,
|
||||
5E463D1B2FC403BC0089145B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
5E463D1C2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5E463D1D2FC403BC0089145B /* Debug */,
|
||||
5E463D1E2FC403BC0089145B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
5E463D1F2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己Tests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5E463D202FC403BC0089145B /* Debug */,
|
||||
5E463D212FC403BC0089145B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
5E463D222FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己UITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5E463D232FC403BC0089145B /* Debug */,
|
||||
5E463D242FC403BC0089145B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>体己.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// 体己
|
||||
//
|
||||
// Created by Tim on 2026/5/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var items: [Item]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||
} label: {
|
||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
#if os(macOS)
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
#endif
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
Text("Select an item")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(timestamp: Date())
|
||||
modelContext.insert(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(items[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self, inMemory: true)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//
|
||||
// Item.swift
|
||||
// 体己
|
||||
//
|
||||
// Created by Tim on 2026/5/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Item {
|
||||
var timestamp: Date
|
||||
|
||||
init(timestamp: Date) {
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// __App.swift
|
||||
// 体己
|
||||
//
|
||||
// Created by Tim on 2026/5/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct __App: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Item.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@
|
||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
|
||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
|
||||
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; };
|
||||
FEED000000000000DEAD0007 /* MLXHuggingFace in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0008 /* MLXHuggingFace */; };
|
||||
FEED000000000000DEAD0009 /* Tokenizers in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD000A /* Tokenizers */; };
|
||||
FEED000000000000DEAD000B /* HuggingFace in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD000C /* HuggingFace */; };
|
||||
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -33,6 +37,7 @@
|
||||
5E463CF92FC403BB0089145B /* 康康.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "康康.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5E463D082FC403BC0089145B /* 康康Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5E463D122FC403BC0089145B /* 康康UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FEEDFACE000000000000F001 /* MNN.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MNN.xcframework; path = Frameworks/MNN.xcframework; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -61,6 +66,10 @@
|
||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
|
||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
|
||||
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
|
||||
FEED000000000000DEAD0007 /* MLXHuggingFace in Frameworks */,
|
||||
FEED000000000000DEAD0009 /* Tokenizers in Frameworks */,
|
||||
FEED000000000000DEAD000B /* HuggingFace in Frameworks */,
|
||||
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -88,6 +97,7 @@
|
||||
5E463D0B2FC403BC0089145B /* 康康Tests */,
|
||||
5E463D152FC403BC0089145B /* 康康UITests */,
|
||||
5E463CFA2FC403BB0089145B /* Products */,
|
||||
FEEDFACE000000000000F001 /* MNN.xcframework */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -124,6 +134,9 @@
|
||||
FEED000000000000DEAD0003 /* MLXLLM */,
|
||||
FEED000000000000DEAD0004 /* MLXLMCommon */,
|
||||
FEED000000000000DEAD0006 /* MLXVLM */,
|
||||
FEED000000000000DEAD0008 /* MLXHuggingFace */,
|
||||
FEED000000000000DEAD000A /* Tokenizers */,
|
||||
FEED000000000000DEAD000C /* HuggingFace */,
|
||||
);
|
||||
productName = "康康";
|
||||
productReference = 5E463CF92FC403BB0089145B /* 康康.app */;
|
||||
@@ -183,7 +196,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
LastUpgradeCheck = 2650;
|
||||
TargetAttributes = {
|
||||
5E463CF82FC403BB0089145B = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
@@ -211,7 +224,9 @@
|
||||
mainGroup = 5E463CF02FC403BB0089145B;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */,
|
||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
|
||||
5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */,
|
||||
5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
|
||||
@@ -292,6 +307,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -321,6 +337,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -344,6 +361,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -354,6 +372,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -383,6 +402,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
@@ -399,6 +419,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
@@ -410,19 +431,25 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "康康";
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -437,7 +464,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -448,6 +475,7 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -462,19 +490,25 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "康康";
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -489,7 +523,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -500,6 +534,7 @@
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -512,12 +547,13 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -539,12 +575,13 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -565,12 +602,13 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -591,12 +629,13 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -655,12 +694,28 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */ = {
|
||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/ml-explore/mlx-swift-examples";
|
||||
repositoryURL = "https://github.com/ml-explore/mlx-swift-lm";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.29.1;
|
||||
kind = exactVersion;
|
||||
version = 3.31.4;
|
||||
};
|
||||
};
|
||||
5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/huggingface/swift-transformers";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 1.2.1;
|
||||
};
|
||||
};
|
||||
5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/huggingface/swift-huggingface.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.9.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@@ -668,19 +723,34 @@
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
FEED000000000000DEAD0003 /* MLXLLM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||
productName = MLXLLM;
|
||||
};
|
||||
FEED000000000000DEAD0004 /* MLXLMCommon */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||
productName = MLXLMCommon;
|
||||
};
|
||||
FEED000000000000DEAD0006 /* MLXVLM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||
productName = MLXVLM;
|
||||
};
|
||||
FEED000000000000DEAD0008 /* MLXHuggingFace */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||
productName = MLXHuggingFace;
|
||||
};
|
||||
FEED000000000000DEAD000A /* Tokenizers */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */;
|
||||
productName = Tokenizers;
|
||||
};
|
||||
FEED000000000000DEAD000C /* HuggingFace */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */;
|
||||
productName = HuggingFace;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"originHash" : "170c2e0a1709c1ce83072661d2e376c3474847f0e091357712d91122c6658589",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mattt/EventSource.git",
|
||||
"state" : {
|
||||
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
|
||||
"version" : "1.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mlx-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ml-explore/mlx-swift",
|
||||
"state" : {
|
||||
"revision" : "dc43e62d7055353c7f99fa071a4e71d29dfddc44",
|
||||
"version" : "0.31.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mlx-swift-lm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ml-explore/mlx-swift-lm",
|
||||
"state" : {
|
||||
"revision" : "bd4b7434e6bdb588c7ef55706ff8904cb7fd4c57",
|
||||
"version" : "3.31.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
|
||||
"version" : "1.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
|
||||
"version" : "1.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
|
||||
"version" : "4.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-huggingface",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-huggingface.git",
|
||||
"state" : {
|
||||
"revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-jinja",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-jinja.git",
|
||||
"state" : {
|
||||
"revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc",
|
||||
"version" : "2.3.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "cd3e1152083706d77b223fb29110e590efcc70c0",
|
||||
"version" : "2.101.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-numerics",
|
||||
"state" : {
|
||||
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
||||
"state" : {
|
||||
"revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1",
|
||||
"version" : "603.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1",
|
||||
"version" : "1.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-transformers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-transformers",
|
||||
"state" : {
|
||||
"revision" : "58c4bc11963a140358d791f678a60a2745a23146",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "yyjson",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ibireme/yyjson.git",
|
||||
"state" : {
|
||||
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
|
||||
"version" : "0.12.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
102
康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme
Normal file
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
|
||||
BuildableName = "康康.app"
|
||||
BlueprintName = "康康"
|
||||
ReferencedContainer = "container:康康.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5E463D072FC403BC0089145B"
|
||||
BuildableName = "康康Tests.xctest"
|
||||
BlueprintName = "康康Tests"
|
||||
ReferencedContainer = "container:康康.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5E463D112FC403BC0089145B"
|
||||
BuildableName = "康康UITests.xctest"
|
||||
BlueprintName = "康康UITests"
|
||||
ReferencedContainer = "container:康康.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
|
||||
BuildableName = "康康.app"
|
||||
BlueprintName = "康康"
|
||||
ReferencedContainer = "container:康康.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
|
||||
BuildableName = "康康.app"
|
||||
BlueprintName = "康康"
|
||||
ReferencedContainer = "container:康康.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>康康.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>5E463CF82FC403BB0089145B</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>5E463D072FC403BC0089145B</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>5E463D112FC403BC0089145B</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
351
康康/AI/AIRuntime.swift
Normal file
@@ -0,0 +1,351 @@
|
||||
import Foundation
|
||||
import MLX
|
||||
|
||||
enum AIRuntimeError: Error, LocalizedError {
|
||||
case notReady
|
||||
case modelLoadFailed(String)
|
||||
case inferenceFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notReady: return String(appLoc: "AI 模型尚未准备好")
|
||||
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
|
||||
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 推理优先级。interactive = 用户正在屏幕前等(识别/问答/自检);
|
||||
/// background = 预生成(报告摘要等),排队让行、解码中可被协作式抢占。
|
||||
nonisolated enum InferencePriority: Sendable, Equatable {
|
||||
case interactive
|
||||
case background
|
||||
}
|
||||
|
||||
actor AIRuntime {
|
||||
static let shared = AIRuntime()
|
||||
|
||||
enum Status: Sendable, Equatable {
|
||||
case notReady
|
||||
case loading
|
||||
case ready
|
||||
case error(String)
|
||||
}
|
||||
|
||||
private(set) var status: Status = .notReady
|
||||
private(set) var vlStatus: Status = .notReady
|
||||
|
||||
/// 末次文本生成的性能统计(性能自检页消费;两后端归一)。
|
||||
private(set) var lastGenerateStats: GenerateStats?
|
||||
|
||||
/// 当前实际生效的后端标签(性能自检 / PPT 截图用)。
|
||||
/// 端侧统一走 MLX/GPU(Gemma 4 E2B);MNN/SME2 LLM 路径已移除。
|
||||
var activeBackendLabel: String {
|
||||
#if targetEnvironment(simulator)
|
||||
return "MLX · CPU(模拟器)"
|
||||
#else
|
||||
return "MLX · GPU"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var llmSession: LLMSession?
|
||||
private var vlSession: VLSession?
|
||||
|
||||
// MARK: - Gemini 云端后端(hybrid:端侧 Gemma 默认,云端按需增强)
|
||||
// 云端调用不占本机显存,不进 OOM 闸门,可与端侧推理并发。用于「云端深度解读 / 多语言」
|
||||
// 与「拍报告/药盒多模态读图」——后者恢复端侧 Gemma 4(MLX 文本版)丢掉的真·视觉能力。
|
||||
private let gemini = GeminiBackend()
|
||||
/// 云端是否可用(用户已开启 + 有 key)。UI 与调用方据此决定走云还是端侧。
|
||||
nonisolated var cloudAvailable: Bool { CloudAI.isConfigured }
|
||||
/// 云端后端标签(性能自检 / 截图用)。
|
||||
nonisolated static var cloudLabel: String { "Gemini · \(CloudAI.model)" }
|
||||
|
||||
// MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地)
|
||||
//
|
||||
// actor 只串行化「方法入口」,但 generate() 同步返回流、真正解码在内部 Task;
|
||||
// analyzeReport 也在 await 期间让出 actor。若不加闸门,LLM 流正在解码时触发 VL,
|
||||
// 两个模型会同时在 GPU 上解码 → 冲过单 App 内存上限被 jetsam 杀
|
||||
//(MEMORY 记录的「in-flight 流并发窄口」)。
|
||||
//
|
||||
// 这里用 actor 内信号量(count = 1):所有「会占显存的重活」(解码 + 模型加载)
|
||||
// 进入前先 await acquireGate(),结束后 releaseGate()。actor 串行执行保证
|
||||
// gateBusy / gateWaiters 的读写天然无并发。
|
||||
private struct GateWaiter {
|
||||
let priority: InferencePriority
|
||||
let cont: CheckedContinuation<Void, Never>
|
||||
}
|
||||
private var gateBusy = false
|
||||
private var gateHolderPriority: InferencePriority = .interactive
|
||||
private var preemptRequested = false
|
||||
private var gateWaiters: [GateWaiter] = []
|
||||
|
||||
/// interactive 排到所有 background 等待者之前;同优先级保持 FIFO。纯函数,单测覆盖。
|
||||
nonisolated static func gateInsertionIndex(of priority: InferencePriority,
|
||||
in waiting: [InferencePriority]) -> Int {
|
||||
guard priority == .interactive else { return waiting.count }
|
||||
return waiting.firstIndex(of: .background) ?? waiting.count
|
||||
}
|
||||
|
||||
private func acquireGate(_ priority: InferencePriority = .interactive) async {
|
||||
if !gateBusy {
|
||||
gateBusy = true
|
||||
gateHolderPriority = priority
|
||||
return
|
||||
}
|
||||
// 前台请求撞上后台持有者:请其让位 —— 后台解码循环在下一个 token 抛 CancellationError。
|
||||
if priority == .interactive, gateHolderPriority == .background {
|
||||
preemptRequested = true
|
||||
}
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||
let idx = Self.gateInsertionIndex(of: priority, in: gateWaiters.map(\.priority))
|
||||
gateWaiters.insert(GateWaiter(priority: priority, cont: cont), at: idx)
|
||||
}
|
||||
// 被 releaseGate 唤醒时即已持有闸门(gateBusy 保持 true)。
|
||||
}
|
||||
|
||||
private func releaseGate() {
|
||||
preemptRequested = false
|
||||
if gateWaiters.isEmpty {
|
||||
gateBusy = false
|
||||
} else {
|
||||
// 把闸门直接交给队首等待者,gateBusy 维持 true,不留空窗。
|
||||
let next = gateWaiters.removeFirst()
|
||||
gateHolderPriority = next.priority
|
||||
next.cont.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台持有者每收到一个 token 查一次:前台在排队就让位。
|
||||
private func shouldPreempt(_ priority: InferencePriority) -> Bool {
|
||||
priority == .background && preemptRequested
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
/// App 启动时调用一次:给 MLX 的 GPU 缓冲池设上限,避免 reuse cache 在大模型常驻之上
|
||||
/// 继续膨胀、把峰值推过单 App 内存上限。仅真机生效(模拟器走 CPU,且部分 Metal 路径会 abort)。
|
||||
/// 与 increased-memory-limit entitlement + LLM/VL 互斥卸载配合,三管齐下防 jetsam OOM。
|
||||
nonisolated static func configureMLXMemory() {
|
||||
#if !targetEnvironment(simulator)
|
||||
// 256MB cache 上限:够复用、不至于在 3GB 模型之上再囤几百 MB 空闲缓冲。
|
||||
MLX.Memory.cacheLimit = 256 * 1024 * 1024
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 加载文本模型(端侧 MLX/GPU,Gemma 4 E2B)。首次调用会真正加载,后续幂等。
|
||||
func prepare() async throws {
|
||||
// 已有其他调用方在加载时,轮询等其结束再判定结果。
|
||||
// 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的
|
||||
// `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。
|
||||
while status == .loading {
|
||||
try await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
if status == .ready { return }
|
||||
|
||||
// 用 isComplete(逐文件字节校验)而非 isReady(只看 config.json):config.json 最小最先下完,
|
||||
// 半下载时 isReady 仍 true 会让加载在残缺 safetensors 上崩溃。与 ModelDownloadService 的
|
||||
// 完成判据保持一致(它也用 isComplete)。
|
||||
guard ModelStore.shared.isComplete(for: .llm) else {
|
||||
status = .error("LLM 模型未就绪")
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
// 进闸门:等所有在跑的推理(可能是 VL 解码)结束,再卸 VL + 载 LLM,
|
||||
// 避免「VL 解码 + LLM 加载」内存峰值叠加 OOM。
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
// 拿到闸门后复查:排队期间可能已被别的调用方加载好,避免重复 load。
|
||||
if status == .ready { return }
|
||||
|
||||
// OOM 闸门(§3.1):LLM(~1GB)与 VL(~3GB)不可同时常驻,叠加会冲过单 App 内存上限被 jetsam 杀。
|
||||
unloadVL()
|
||||
|
||||
status = .loading
|
||||
do {
|
||||
let session = try await LLMSession.load(
|
||||
folderURL: ModelStore.shared.localURL(for: .llm)
|
||||
)
|
||||
self.llmSession = session
|
||||
status = .ready
|
||||
} catch {
|
||||
status = .error("\(error)")
|
||||
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式生成。调用前应先 await prepare()。
|
||||
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
||||
/// priority = .background 时排队让行、解码中可被前台请求按 token 抢占(CancellationError 透传)。
|
||||
func generate(prompt: String,
|
||||
maxTokens: Int = 256,
|
||||
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
||||
let snapshotStatus = status
|
||||
let snapshotSession = llmSession
|
||||
|
||||
return AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
guard snapshotStatus == .ready, let session = snapshotSession else {
|
||||
continuation.finish(throwing: AIRuntimeError.notReady)
|
||||
return
|
||||
}
|
||||
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
||||
await self.acquireGate(priority)
|
||||
// defer 保证正常结束 / 异常 / 取消都释放闸门;杜绝未来新增 early-return 导致全局推理死锁。
|
||||
defer { self.releaseGate() }
|
||||
do {
|
||||
// session.generate 跨 actor 边界,需要 await
|
||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
for try await chunk in stream {
|
||||
// 消费者(UI)提前关闭/取消时,下面的 checkCancellation 让本 Task 尽快退出,
|
||||
// 连带丢弃 session 流并触发其 onTermination,停止底层 MLX 解码,不空耗 GPU。
|
||||
try Task.checkCancellation()
|
||||
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出。
|
||||
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
self.lastGenerateStats = await session.lastStats
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
// 取消/抢占以 CancellationError 透传,调用方据此区分「让位」与「真失败」。
|
||||
continuation.finish(throwing: CancellationError())
|
||||
} catch {
|
||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||
}
|
||||
}
|
||||
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gemini 云端(hybrid 增强)
|
||||
|
||||
/// 云端流式生成(深度解读 / 多语言)。不进 OOM 闸门,可与端侧并发。
|
||||
/// 调用前应确认 `cloudAvailable`;失败时调用方回退端侧 `generate`。
|
||||
func generateCloud(prompt: String, maxTokens: Int = 512) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
let stream = await self.gemini.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
for try await chunk in stream {
|
||||
try Task.checkCancellation()
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
self.lastGenerateStats = await self.gemini.lastStats
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
continuation.finish(throwing: CancellationError())
|
||||
} catch {
|
||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 云端多模态识别:图片直传 Gemini 读出结构化文本(通常 JSON)。
|
||||
/// 恢复端侧丢掉的真·视觉读图;失败时调用方回退端侧 OCR+文本(§3.2)。
|
||||
func analyzeReportCloud(imageURLs: [URL], prompt: String, maxTokens: Int = 1024) async throws -> String {
|
||||
do {
|
||||
return try await gemini.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
|
||||
} catch {
|
||||
throw AIRuntimeError.inferenceFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 端侧 ASR(SenseVoice via sherpa-mnn)互斥入口
|
||||
|
||||
/// 给端侧语音转写(SenseVoice,占 CPU + 内存)用:进推理闸门串行 + 卸掉常驻 LLM/VL 腾内存,
|
||||
/// 避免与文本/视觉模型同时常驻冲过单 App 内存上限被 jetsam 杀(§3.1 OOM 防护)。
|
||||
///
|
||||
/// 问诊流程是「先转写 → 再 organize(会重载 LLM)」严格串行,所以这里卸掉 LLM 是安全的:
|
||||
/// body 跑完一次转写返回后,调用方走 DiaryAssistService.organizeConsultation 时会自然重新 prepare。
|
||||
/// body 内部应自行 hop 到后台线程做阻塞解码(本 actor 在 await 期间不阻塞,但闸门保持持有,
|
||||
/// 其它推理请求会排队等待,杜绝并发占内存)。
|
||||
func runExclusiveForASR<T: Sendable>(_ body: @Sendable () async throws -> T) async rethrows -> T {
|
||||
await acquireGate(.interactive)
|
||||
defer { releaseGate() }
|
||||
unloadLLM()
|
||||
unloadVL()
|
||||
return try await body()
|
||||
}
|
||||
|
||||
// MARK: - VL
|
||||
|
||||
/// 加载 VL 模型。幂等,首调真正 load。
|
||||
func prepareVL() async throws {
|
||||
while vlStatus == .loading {
|
||||
try await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
if vlStatus == .ready { return }
|
||||
|
||||
// MLX VL 改用 .llm 的 Qwen3.5-2B 多模态(VLMModelFactory 走 qwen3_5 视觉路径),
|
||||
// 不再单独需要 Qwen3-VL-4B。用 isComplete 排除半下载,与下载服务判据一致。
|
||||
guard ModelStore.shared.isComplete(for: .llm) else {
|
||||
vlStatus = .error("VL 模型未就绪")
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
// 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。
|
||||
// —— 这正是「指标速记识别时 App 自动退出」的主因防护。
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
if vlStatus == .ready { return }
|
||||
|
||||
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。
|
||||
unloadLLM()
|
||||
|
||||
vlStatus = .loading
|
||||
do {
|
||||
let session = try await VLSession.load(
|
||||
folderURL: ModelStore.shared.localURL(for: .llm)
|
||||
)
|
||||
self.vlSession = session
|
||||
vlStatus = .ready
|
||||
} catch {
|
||||
vlStatus = .error("\(error)")
|
||||
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 卸载(OOM 闸门)
|
||||
|
||||
/// 卸载 LLM,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。
|
||||
/// 注:只在持有推理闸门时调用(prepareVL 内),此刻不会有 LLM 流在解码,卸载即时生效。
|
||||
private func unloadLLM() {
|
||||
guard llmSession != nil else { return }
|
||||
llmSession = nil
|
||||
status = .notReady
|
||||
MLX.Memory.clearCache()
|
||||
}
|
||||
|
||||
/// 卸载 VL,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。
|
||||
private func unloadVL() {
|
||||
guard vlSession != nil else { return }
|
||||
vlSession = nil
|
||||
vlStatus = .notReady
|
||||
MLX.Memory.clearCache()
|
||||
}
|
||||
|
||||
/// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。
|
||||
/// 调用方负责解析 + 失败回退(§3.2)。
|
||||
/// 推理闸门保证本调用与 LLM.generate() 的解码串行,不会同时占显存 OOM。
|
||||
func analyzeReport(imageURLs: [URL],
|
||||
prompt: String,
|
||||
maxTokens: Int = 512) async throws -> String {
|
||||
guard vlStatus == .ready, let session = vlSession else {
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
do {
|
||||
return try await session.analyze(
|
||||
imageURLs: imageURLs,
|
||||
prompt: prompt,
|
||||
maxTokens: maxTokens
|
||||
)
|
||||
} catch {
|
||||
throw AIRuntimeError.inferenceFailed("\(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
186
康康/AI/FileDownloader.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
import Foundation
|
||||
|
||||
enum DownloadError: Error, LocalizedError {
|
||||
case badStatus(Int)
|
||||
case sizeMismatch(expected: Int, got: Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badStatus(let code):
|
||||
return String(appLoc: "下载失败(HTTP \(code))")
|
||||
case .sizeMismatch(let expected, let got):
|
||||
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载单个文件,支持 HTTP Range 断点续传 + 完成后大小校验。
|
||||
/// 用 `URLSessionDataDelegate` 把响应体分块写入 `.part`,完成后原子改名为成品。
|
||||
///
|
||||
/// 注意:文件大小一律用 `FileManager.attributesOfItem` 读取,**不用**
|
||||
/// `URL.resourceValues(.fileSizeKey)` —— 后者会把结果缓存在 URL 实例上,
|
||||
/// 续传时先读 offset 再读 finalSize 会拿到下载前的陈旧大小,导致误判校验失败。
|
||||
///
|
||||
/// 一个实例一次处理一个文件(串行)。共享状态用锁保证可见性。
|
||||
final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
||||
private let configuration: URLSessionConfiguration
|
||||
|
||||
private let lock = NSLock()
|
||||
private var handle: FileHandle?
|
||||
private var written: Int = 0
|
||||
private var expectedTotal: Int = 0
|
||||
private var onProgress: ((Int) -> Void)?
|
||||
private var responseError: Error?
|
||||
private var continuation: CheckedContinuation<Void, Error>?
|
||||
|
||||
init(configuration: URLSessionConfiguration = .default) {
|
||||
self.configuration = configuration
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 不走 URL 资源值缓存的文件大小读取。
|
||||
static func fileSize(at url: URL) -> Int {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? Int else { return 0 }
|
||||
return size
|
||||
}
|
||||
|
||||
/// 从 `url` 下载到 `destination`。若存在 `destination.part` 则发 Range 请求续传;
|
||||
/// 完成后校验总大小 == `expectedBytes`,通过则原子改名为 `destination`。
|
||||
nonisolated func download(
|
||||
from url: URL,
|
||||
to destination: URL,
|
||||
expectedBytes: Int,
|
||||
onProgress: (@Sendable (Int) -> Void)? = nil
|
||||
) async throws {
|
||||
let fm = FileManager.default
|
||||
let part = destination.appendingPathExtension("part")
|
||||
|
||||
// 成品已存在且大小正确 → 跳过
|
||||
if Self.fileSize(at: destination) == expectedBytes,
|
||||
fm.fileExists(atPath: destination.path) {
|
||||
return
|
||||
}
|
||||
|
||||
try fm.createDirectory(
|
||||
at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
|
||||
var offset = 0
|
||||
if fm.fileExists(atPath: part.path) {
|
||||
offset = Self.fileSize(at: part)
|
||||
} else {
|
||||
fm.createFile(atPath: part.path, contents: nil)
|
||||
}
|
||||
|
||||
let fileHandle = try FileHandle(forWritingTo: part)
|
||||
try fileHandle.seekToEnd()
|
||||
|
||||
lock.withLock {
|
||||
self.handle = fileHandle
|
||||
self.written = offset
|
||||
self.expectedTotal = expectedBytes
|
||||
self.onProgress = onProgress
|
||||
self.responseError = nil
|
||||
}
|
||||
|
||||
// offset 0 也发 `bytes=0-`:诱导服务器回 206 + Content-Range 报告资源总大小,
|
||||
// didReceive 里与清单比对不符立即失败 —— 上游仓库更新导致大小漂移时(2026-07-14 实发),
|
||||
// 秒级报 sizeMismatch,而不是把 3.5GB 下完才在收尾校验发现。
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
||||
|
||||
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
// 句柄在 didCompleteWithError 内关闭(同一 delegate 队列,串行于 didReceive)。
|
||||
do {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
lock.lock()
|
||||
self.continuation = cont
|
||||
lock.unlock()
|
||||
session.dataTask(with: request).resume()
|
||||
}
|
||||
} catch let error as DownloadError {
|
||||
// 响应头快速失败的大小不符:删 .part,防止换源续传时把两个版本的字节拼进同一文件。
|
||||
// badStatus(如临时 503)保留 .part,便于重试续传。
|
||||
if case .sizeMismatch = error { try? fm.removeItem(at: part) }
|
||||
throw error
|
||||
}
|
||||
|
||||
let finalSize = Self.fileSize(at: part)
|
||||
guard finalSize == expectedBytes else {
|
||||
try? fm.removeItem(at: part)
|
||||
throw DownloadError.sizeMismatch(expected: expectedBytes, got: finalSize)
|
||||
}
|
||||
|
||||
if fm.fileExists(atPath: destination.path) {
|
||||
try fm.removeItem(at: destination)
|
||||
}
|
||||
try fm.moveItem(at: part, to: destination)
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDataDelegate (全部在串行 delegate 队列执行)
|
||||
|
||||
nonisolated func urlSession(
|
||||
_ session: URLSession, dataTask: URLSessionDataTask,
|
||||
didReceive response: URLResponse,
|
||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||
) {
|
||||
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
||||
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
|
||||
completionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
// 快速失败:206 的 Content-Range(`bytes a-b/total`)报告了资源真实总大小,与清单不符
|
||||
// 说明服务器上的文件版本变了,立即取消(收尾校验必失败,没必要下完)。
|
||||
// 只信 206 —— 200 的 Content-Length 可能是压缩后长度(gzip),比对会误伤,仍走收尾校验。
|
||||
if let http = response as? HTTPURLResponse, http.statusCode == 206,
|
||||
let contentRange = http.value(forHTTPHeaderField: "Content-Range"),
|
||||
let totalPart = contentRange.split(separator: "/").last,
|
||||
let serverTotal = Int(totalPart) {
|
||||
lock.lock()
|
||||
let expected = expectedTotal
|
||||
lock.unlock()
|
||||
if serverTotal != expected {
|
||||
lock.lock()
|
||||
responseError = DownloadError.sizeMismatch(expected: expected, got: serverTotal)
|
||||
lock.unlock()
|
||||
completionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
nonisolated func urlSession(
|
||||
_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data
|
||||
) {
|
||||
lock.lock()
|
||||
try? handle?.write(contentsOf: data)
|
||||
written += data.count
|
||||
let progress = written
|
||||
let callback = onProgress
|
||||
lock.unlock()
|
||||
callback?(progress)
|
||||
}
|
||||
|
||||
nonisolated func urlSession(
|
||||
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
|
||||
) {
|
||||
lock.lock()
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
let cont = continuation
|
||||
continuation = nil
|
||||
let respErr = responseError
|
||||
lock.unlock()
|
||||
|
||||
if let respErr {
|
||||
cont?.resume(throwing: respErr)
|
||||
} else if let error {
|
||||
cont?.resume(throwing: error)
|
||||
} else {
|
||||
cont?.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
215
康康/AI/GeminiBackend.swift
Normal 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?
|
||||
}
|
||||
19
康康/AI/GenerateStats.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// 单次生成的性能统计,两后端(MNN / MLX)归一。
|
||||
/// MNN 取自 LlmContext(prefill_us / decode_us);MLX 取自 GenerateCompletionInfo。
|
||||
struct GenerateStats: Sendable, Equatable {
|
||||
var promptTokens: Int
|
||||
var genTokens: Int
|
||||
/// prefill(读入 prompt)耗时,秒。
|
||||
var prefillSeconds: Double
|
||||
/// decode(逐 token 生成)耗时,秒。
|
||||
var decodeSeconds: Double
|
||||
|
||||
var prefillTokensPerSecond: Double {
|
||||
prefillSeconds > 0 ? Double(promptTokens) / prefillSeconds : 0
|
||||
}
|
||||
var decodeTokensPerSecond: Double {
|
||||
decodeSeconds > 0 ? Double(genTokens) / decodeSeconds : 0
|
||||
}
|
||||
}
|
||||
129
康康/AI/LLMSession.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
import MLX
|
||||
import MLXLLM
|
||||
import MLXLMCommon
|
||||
// mlx-swift-lm 3.x 把 HF Hub/分词器栈拆成 opt-in 宏(MLXHuggingFace),
|
||||
// `#hubDownloader()` / `#huggingFaceTokenizerLoader()` 展开处需要 HuggingFace / Tokenizers 在作用域。
|
||||
import MLXHuggingFace
|
||||
import HuggingFace
|
||||
import Tokenizers
|
||||
|
||||
/// 封装 MLX 语言模型的流式生成,actor 保证单线程访问。
|
||||
/// 基于 mlx-swift-lm 3.31.4 的 API(MLXLLM / MLXLMCommon)。
|
||||
actor LLMSession {
|
||||
let container: ModelContainer
|
||||
|
||||
/// 末次生成统计(取自流末尾的 .info 完成事件,性能自检用)。
|
||||
private(set) var lastStats: GenerateStats?
|
||||
|
||||
private func record(_ s: GenerateStats) { lastStats = s }
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.container = container
|
||||
}
|
||||
|
||||
/// 在 simulator 把默认设备强切为 CPU(MLX 的 Metal backend 在部分 Sim 路径会 abort)。
|
||||
/// 真机走 body 默认设备(GPU/ANE)。
|
||||
/// 用 task-scoped `withDefaultDevice`,TaskLocal 会传递到 child Task / actor 调用。
|
||||
private static func withDeviceOverride<R>(
|
||||
_ body: () async throws -> R
|
||||
) async rethrows -> R {
|
||||
#if targetEnvironment(simulator)
|
||||
return try await Device.withDefaultDevice(.cpu, body)
|
||||
#else
|
||||
return try await body()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 从本地目录加载模型(包含 config.json + weights + tokenizer)。
|
||||
/// Gemma 4 改了回合标记:聊天回合以 `<turn|>`(token 106)结束(3n 时代是 `<end_of_turn>`),
|
||||
/// 而分词器自带的 eos 仍是 `<eos>`(1);不显式补 `<turn|>` 会让解码停不下来、把 maxTokens
|
||||
/// 跑满还吐回合分隔噪声。与上游 mlx-swift-lm 预置 `gemma4_e2b_it_4bit` 的 extraEOSTokens 一致。
|
||||
static func load(folderURL: URL) async throws -> LLMSession {
|
||||
let configuration = ModelConfiguration(
|
||||
directory: folderURL,
|
||||
extraEOSTokens: ["<turn|>"]
|
||||
)
|
||||
// 3.31.4:loadContainer 需显式传入 Downloader + TokenizerLoader。本地目录配置(.directory)
|
||||
// 不会真正下载(resolve 直接用目录),Downloader 仅占位;分词器由 HF AutoTokenizer 从目录加载。
|
||||
let container = try await withDeviceOverride {
|
||||
try await LLMModelFactory.shared.loadContainer(
|
||||
from: #hubDownloader(),
|
||||
using: #huggingFaceTokenizerLoader(),
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
return LLMSession(container: container)
|
||||
}
|
||||
|
||||
/// 流式生成。返回的 AsyncThrowingStream 被取消时,内部 Task 也会取消。
|
||||
/// - Parameters:
|
||||
/// - prompt: 原始 prompt 文本(经 processor 转 LMInput)
|
||||
/// - maxTokens: 最大 token 数,由 GenerateParameters 控制
|
||||
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
try await Self.withDeviceOverride {
|
||||
// 低温:本 App 文本任务多为"直答/JSON 抽取",高温随机性会经常吐成非 JSON。
|
||||
// 0.3 + topP 0.85 让输出更确定、JSON 更稳(与 MNN set_config 降温对齐)。
|
||||
// repetitionPenalty:低温 + 无惩罚时,长文本(如「关键指标」列表)会逐行复读
|
||||
// 进入死循环;1.1 的重复惩罚 + 64 token 上下文窗口掐断复读(与 MNN penalty 对齐)。
|
||||
let parameters = GenerateParameters(
|
||||
maxTokens: maxTokens,
|
||||
temperature: Float(0.3),
|
||||
topP: Float(0.85),
|
||||
repetitionPenalty: Float(1.1),
|
||||
repetitionContextSize: 64
|
||||
)
|
||||
|
||||
try await container.perform { (context: ModelContext) in
|
||||
let userInput = UserInput(prompt: prompt)
|
||||
let lmInput = try await context.processor.prepare(input: userInput)
|
||||
|
||||
let start = Date()
|
||||
var produced = 0
|
||||
|
||||
for await event in try MLXLMCommon.generate(
|
||||
input: lmInput,
|
||||
parameters: parameters,
|
||||
context: context
|
||||
) {
|
||||
if Task.isCancelled { break }
|
||||
|
||||
switch event {
|
||||
case .chunk(let text):
|
||||
produced += 1
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||
|
||||
case .info(let info):
|
||||
// 生成完成统计,是流的最后一个事件
|
||||
await self.record(GenerateStats(
|
||||
promptTokens: info.promptTokenCount,
|
||||
genTokens: info.generationTokenCount,
|
||||
prefillSeconds: info.promptTime,
|
||||
decodeSeconds: info.generateTime
|
||||
))
|
||||
|
||||
case .toolCall:
|
||||
// 纯文本生成不会触发,switch 穷举
|
||||
break
|
||||
}
|
||||
}
|
||||
// 注:研究笔记里曾建议尾部 MLX.GPU.synchronize() 以确保
|
||||
// GPU 操作全部完成。但 AsyncStream 已经 yield 真实解码后的
|
||||
// 文字,GPU 是否完全空闲不影响数据正确性。去掉此调用同时省
|
||||
// 一份 transitive import MLX 的依赖,简化 SPM 链接。
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
40
康康/AI/MNN/SenseVoiceBridge.h
Normal 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
|
||||
112
康康/AI/MNN/SenseVoiceBridge.mm
Normal 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
|
||||