Compare commits
14 Commits
6c6a950140
...
feat/mnn-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47e62b658 | ||
|
|
0fd469f5c0 | ||
|
|
ef5364d8c3 | ||
|
|
198570186e | ||
|
|
32180d7c0e | ||
|
|
558682c8f8 | ||
|
|
404abbf10b | ||
|
|
e179a369f6 | ||
|
|
30f75dc2cd | ||
|
|
abacf5c4f5 | ||
|
|
52db6fb85a | ||
|
|
de19d7abcd | ||
|
|
b3777d508d | ||
|
|
9d856fcfc4 |
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())
|
||||||
19
AGENTS.md
@@ -22,9 +22,9 @@
|
|||||||
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
||||||
| 持久化 | SwiftData | 见 §5 数据模型 |
|
| 持久化 | SwiftData | 见 §5 数据模型 |
|
||||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
| 图表 | Swift Charts | iOS 16+ 原生 |
|
||||||
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
|
| **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` |
|
||||||
| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
|
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
||||||
| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 |
|
| 模型 | **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` | 不要自己写透视校正 |
|
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||||
| Face ID | LocalAuthentication | |
|
| Face ID | LocalAuthentication | |
|
||||||
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
||||||
@@ -38,13 +38,14 @@
|
|||||||
### 3.1 模块边界(强制)
|
### 3.1 模块边界(强制)
|
||||||
|
|
||||||
```
|
```
|
||||||
UI → CaptureService / AskService / TrendService → AIRuntime → MLX
|
UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX
|
||||||
↓
|
↓
|
||||||
Persistence
|
Persistence
|
||||||
```
|
```
|
||||||
|
|
||||||
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
||||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
|
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务(模型共享内存/Metal 显存,并发会 OOM 被 jetsam 杀)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。**真正落地**是 actor 内信号量闸门 `acquireGate()/releaseGate()`,所有占显存的重活(解码 + 模型加载)进入前先 await,且加载 VL 前先卸 LLM。
|
||||||
|
- **引擎选择**:`InferenceEngine.current` 由偏好(`.auto`/`.mnn`/`.mlx`)+ 设备可用性解析,真机默认 `.mnn`(SME2/NEON),模拟器回退 `.mlx`。
|
||||||
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
||||||
|
|
||||||
### 3.2 VL pipeline(拍一张 = 一条流程)
|
### 3.2 VL pipeline(拍一张 = 一条流程)
|
||||||
@@ -66,7 +67,7 @@ VL prompt 必须:
|
|||||||
### 3.3 RAG(结构化检索,不做 embedding)
|
### 3.3 RAG(结构化检索,不做 embedding)
|
||||||
|
|
||||||
**两段式调用**:
|
**两段式调用**:
|
||||||
1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
1. 用 Qwen3.5-2B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||||
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
||||||
|
|
||||||
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
||||||
@@ -84,7 +85,9 @@ VL prompt 必须:
|
|||||||
## 4. 模型分发
|
## 4. 模型分发
|
||||||
|
|
||||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||||
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
|
- **用户面只有一个模型**:Qwen3.5-2B-MNN(~1.2GB,`ModelKind.userFacing = [.mnnLLM]`)。多模态,文本+视觉全包,下载全部 / 就绪计数只算它
|
||||||
|
- MLX 兜底版 Qwen3.5-2B-4bit(~1.7GB)仅模拟器与兜底用,不展示、不计入「下载全部」,但旁路导入仍可单独导
|
||||||
|
- WiFi 提示必须有
|
||||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||||
|
|
||||||
@@ -259,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
|
|
||||||
| 周次 | 必交付 |
|
| 周次 | 必交付 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
|
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
|
||||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
||||||
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
||||||
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
||||||
|
|||||||
18
CLAUDE.md
@@ -24,8 +24,10 @@
|
|||||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
| 图表 | 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 运行时(主)** | **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 |
|
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
||||||
| LLM | MNN 主(iPhone17+/SME2):Qwen3.5-2B(`taobao-mnn/Qwen3.5-2B-MNN`,~1.1GiB);MLX 兜底:Qwen3.5-2B-4bit | 文本生成、关键词抽取、趋势解读。4B 实测过慢已退回 2B |
|
| **统一模型(文本+视觉)** | **Qwen3.5-2B 多模态,一个模型全包** | 同一个 Qwen3.5-2B 同时做文本生成 / 关键词抽取 / 趋势解读 **和** 拍照→结构化指标。两种格式两种引擎,按设备选(见下两行)。代码:`ModelKind` |
|
||||||
| VL | Qwen3-VL-4B-Instruct 4bit (MLX `mlx-community/Qwen3-VL-4B-Instruct-4bit`) | 拍照→结构化指标。MNN VL 需 OMNI 构建,暂走 MLX |
|
| ├ 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` | 不要自己写透视校正 |
|
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||||
| Face ID | LocalAuthentication | |
|
| Face ID | LocalAuthentication | |
|
||||||
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
||||||
@@ -39,13 +41,13 @@
|
|||||||
### 3.1 模块边界(强制)
|
### 3.1 模块边界(强制)
|
||||||
|
|
||||||
```
|
```
|
||||||
UI → CaptureService / AskService / TrendService → AIRuntime → MLX
|
UI → CaptureService / AskService / TrendService → AIRuntime → MNN(主) / MLX(兜底)
|
||||||
↓
|
↓
|
||||||
Persistence
|
Persistence
|
||||||
```
|
```
|
||||||
|
|
||||||
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
- **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,方便测试。
|
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
||||||
|
|
||||||
### 3.2 VL pipeline(拍一张 = 一条流程)
|
### 3.2 VL pipeline(拍一张 = 一条流程)
|
||||||
@@ -67,7 +69,7 @@ VL prompt 必须:
|
|||||||
### 3.3 RAG(结构化检索,不做 embedding)
|
### 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,流式生成回答
|
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
||||||
|
|
||||||
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
||||||
@@ -85,7 +87,7 @@ VL prompt 必须:
|
|||||||
## 4. 模型分发
|
## 4. 模型分发
|
||||||
|
|
||||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||||
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
|
- **用户侧只下载统一模型 Qwen3.5-2B(MNN,~1.1GiB,含视觉)**——不再是 ~4GB 两模型。`ModelKind.userFacing = [.mnnLLM]`,「下载全部」/ 就绪计数只算它。MLX 兜底模型 `Qwen3.5-2B-4bit`(~1.7GB)仅模拟器 / 旁路导入用,不计入用户下载;`Qwen3-VL-4B` 已废弃,不再分发。WiFi 提示仍保留
|
||||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||||
|
|
||||||
@@ -250,7 +252,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
||||||
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
||||||
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
|
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
|
||||||
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)
|
6. **新功能必须问"清单里有吗"**——清单外的功能(多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)、**用药提醒**(记录 · 用药记录点药 → 复用自由提醒 `CustomReminder` / `CustomReminderEditSheet`,只到点提示,**仍不给剂量/频次建议**,守 §1 "不做剂量推荐")
|
||||||
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
||||||
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
||||||
|
|
||||||
@@ -260,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
|
|
||||||
| 周次 | 必交付 |
|
| 周次 | 必交付 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
|
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
|
||||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
||||||
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
||||||
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
||||||
|
|||||||
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 秒,比任何文案都值钱。
|
||||||
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
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 出海创想赛作品」,方便主办方检索
|
||||||
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/"
|
||||||
@@ -53,7 +53,8 @@ xcodebuild archive \
|
|||||||
-configuration "$CONFIGURATION" \
|
-configuration "$CONFIGURATION" \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$ARCHIVE_PATH" \
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
-allowProvisioningUpdates
|
-allowProvisioningUpdates \
|
||||||
|
-skipMacroValidation
|
||||||
|
|
||||||
echo "==> [2/3] 生成 ExportOptions.plist..."
|
echo "==> [2/3] 生成 ExportOptions.plist..."
|
||||||
cat > "$EXPORT_PLIST" <<EOF
|
cat > "$EXPORT_PLIST" <<EOF
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
|
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
|
||||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
|
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
|
||||||
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; };
|
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 */; };
|
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -63,6 +66,9 @@
|
|||||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
|
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
|
||||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
|
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
|
||||||
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
|
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
|
||||||
|
FEED000000000000DEAD0007 /* MLXHuggingFace in Frameworks */,
|
||||||
|
FEED000000000000DEAD0009 /* Tokenizers in Frameworks */,
|
||||||
|
FEED000000000000DEAD000B /* HuggingFace in Frameworks */,
|
||||||
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */,
|
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -128,6 +134,9 @@
|
|||||||
FEED000000000000DEAD0003 /* MLXLLM */,
|
FEED000000000000DEAD0003 /* MLXLLM */,
|
||||||
FEED000000000000DEAD0004 /* MLXLMCommon */,
|
FEED000000000000DEAD0004 /* MLXLMCommon */,
|
||||||
FEED000000000000DEAD0006 /* MLXVLM */,
|
FEED000000000000DEAD0006 /* MLXVLM */,
|
||||||
|
FEED000000000000DEAD0008 /* MLXHuggingFace */,
|
||||||
|
FEED000000000000DEAD000A /* Tokenizers */,
|
||||||
|
FEED000000000000DEAD000C /* HuggingFace */,
|
||||||
);
|
);
|
||||||
productName = "康康";
|
productName = "康康";
|
||||||
productReference = 5E463CF92FC403BB0089145B /* 康康.app */;
|
productReference = 5E463CF92FC403BB0089145B /* 康康.app */;
|
||||||
@@ -216,6 +225,8 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
|
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
|
||||||
|
5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */,
|
||||||
|
5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
|
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
|
||||||
@@ -420,7 +431,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
@@ -453,7 +464,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -479,7 +490,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
@@ -512,7 +523,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -536,13 +547,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -564,13 +575,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -591,13 +602,13 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -618,13 +629,13 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -688,7 +699,23 @@
|
|||||||
repositoryURL = "https://github.com/ml-explore/mlx-swift-lm";
|
repositoryURL = "https://github.com/ml-explore/mlx-swift-lm";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = exactVersion;
|
kind = exactVersion;
|
||||||
version = 2.31.3;
|
version = 3.31.4;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
5E9A1F882FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-transformers" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/huggingface/swift-transformers";
|
||||||
|
requirement = {
|
||||||
|
kind = exactVersion;
|
||||||
|
version = 1.2.1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
5E9A1F892FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "swift-huggingface" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/huggingface/swift-huggingface.git";
|
||||||
|
requirement = {
|
||||||
|
kind = exactVersion;
|
||||||
|
version = 0.9.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
@@ -709,6 +736,21 @@
|
|||||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||||
productName = MLXVLM;
|
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 */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
|
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "facc0ac7c70363ea20f6cd1235de91dea6b06f0d00190946045a6c8ae753abc2",
|
"originHash" : "170c2e0a1709c1ce83072661d2e376c3474847f0e091357712d91122c6658589",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "eventsource",
|
"identity" : "eventsource",
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/ml-explore/mlx-swift-lm",
|
"location" : "https://github.com/ml-explore/mlx-swift-lm",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff",
|
"revision" : "bd4b7434e6bdb588c7ef55706ff8904cb7fd4c57",
|
||||||
"version" : "2.31.3"
|
"version" : "3.31.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-asn1.git",
|
"location" : "https://github.com/apple/swift-asn1.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
|
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
|
||||||
"version" : "1.7.0"
|
"version" : "1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-atomics.git",
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
|
||||||
"version" : "1.3.0"
|
"version" : "1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,8 +51,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-collections.git",
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a",
|
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
|
||||||
"version" : "1.5.1"
|
"version" : "1.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,8 +87,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-nio.git",
|
"location" : "https://github.com/apple/swift-nio.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95",
|
"revision" : "cd3e1152083706d77b223fb29110e590efcc70c0",
|
||||||
"version" : "2.100.0"
|
"version" : "2.101.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -100,13 +100,22 @@
|
|||||||
"version" : "1.1.1"
|
"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",
|
"identity" : "swift-system",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-system.git",
|
"location" : "https://github.com/apple/swift-system.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "669763cfd5806a67e21972d7e5e2d6b80b1ea985",
|
"revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1",
|
||||||
"version" : "1.6.5"
|
"version" : "1.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,16 +34,13 @@ actor AIRuntime {
|
|||||||
|
|
||||||
private(set) var status: Status = .notReady
|
private(set) var status: Status = .notReady
|
||||||
private(set) var vlStatus: Status = .notReady
|
private(set) var vlStatus: Status = .notReady
|
||||||
private(set) var lastDecodeRate: Double = 0
|
|
||||||
|
|
||||||
/// 末次文本生成的性能统计(性能自检页消费;两后端归一)。
|
/// 末次文本生成的性能统计(性能自检页消费;两后端归一)。
|
||||||
private(set) var lastGenerateStats: GenerateStats?
|
private(set) var lastGenerateStats: GenerateStats?
|
||||||
|
|
||||||
/// 当前实际生效的后端标签(性能自检 / PPT 截图用)。
|
/// 当前实际生效的后端标签(性能自检 / PPT 截图用)。
|
||||||
|
/// 端侧统一走 MLX/GPU(Gemma 4 E2B);MNN/SME2 LLM 路径已移除。
|
||||||
var activeBackendLabel: String {
|
var activeBackendLabel: String {
|
||||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
|
||||||
return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · NEON"
|
|
||||||
}
|
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
return "MLX · CPU(模拟器)"
|
return "MLX · CPU(模拟器)"
|
||||||
#else
|
#else
|
||||||
@@ -54,15 +51,14 @@ actor AIRuntime {
|
|||||||
private var llmSession: LLMSession?
|
private var llmSession: LLMSession?
|
||||||
private var vlSession: VLSession?
|
private var vlSession: VLSession?
|
||||||
|
|
||||||
// MARK: - MNN 后端(CPU/SME2,挑战赛考核路径)
|
// MARK: - Gemini 云端后端(hybrid:端侧 Gemma 默认,云端按需增强)
|
||||||
// .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型全包(已实测)。
|
// 云端调用不占本机显存,不进 OOM 闸门,可与端侧推理并发。用于「云端深度解读 / 多语言」
|
||||||
// 模拟器无 MNN,VL 回退 MLX 的 Qwen3-VL-4B。
|
// 与「拍报告/药盒多模态读图」——后者恢复端侧 Gemma 4(MLX 文本版)丢掉的真·视觉能力。
|
||||||
private let mnn = MNNBackend()
|
private let gemini = GeminiBackend()
|
||||||
private(set) var mnnStatus: Status = .notReady
|
/// 云端是否可用(用户已开启 + 有 key)。UI 与调用方据此决定走云还是端侧。
|
||||||
/// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-2B-MNN)。
|
nonisolated var cloudAvailable: Bool { CloudAI.isConfigured }
|
||||||
nonisolated static var mnnModelFolder: URL {
|
/// 云端后端标签(性能自检 / 截图用)。
|
||||||
ModelStore.shared.localURL(for: .mnnLLM)
|
nonisolated static var cloudLabel: String { "Gemini · \(CloudAI.model)" }
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地)
|
// MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地)
|
||||||
//
|
//
|
||||||
@@ -136,18 +132,8 @@ actor AIRuntime {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载文本模型。首次调用会真正加载,后续幂等。
|
/// 加载文本模型(端侧 MLX/GPU,Gemma 4 E2B)。首次调用会真正加载,后续幂等。
|
||||||
/// 按当前引擎路由:.mnn → MNN(CPU/SME2);.mlx → 现有 MLX(GPU)。
|
|
||||||
func prepare() async throws {
|
func prepare() async throws {
|
||||||
// 选了 MNN 且模型已就绪才走 MNN;否则(选 MLX,或 MNN 模型尚未下载)回退 MLX,
|
|
||||||
// 保证过渡期 App 始终可用。引擎指示器(Phase 5)展示实际生效后端。
|
|
||||||
let mnnReady = ModelStore.shared.isComplete(for: .mnnLLM)
|
|
||||||
if InferenceEngine.current == .mnn, mnnReady {
|
|
||||||
try await prepareMNN()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 走 MLX:先卸 MNN 释放内存(单模型常驻策略)。
|
|
||||||
await unloadMNN()
|
|
||||||
// 已有其他调用方在加载时,轮询等其结束再判定结果。
|
// 已有其他调用方在加载时,轮询等其结束再判定结果。
|
||||||
// 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的
|
// 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的
|
||||||
// `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。
|
// `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。
|
||||||
@@ -187,54 +173,12 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载 MNN 文本模型。幂等。单模型常驻:载入前卸掉 MLX 的 LLM/VL 释放内存。
|
|
||||||
private func prepareMNN() async throws {
|
|
||||||
while mnnStatus == .loading {
|
|
||||||
try await Task.sleep(nanoseconds: 80_000_000)
|
|
||||||
}
|
|
||||||
if mnnStatus == .ready { return }
|
|
||||||
|
|
||||||
let folder = Self.mnnModelFolder
|
|
||||||
guard ModelStore.shared.isComplete(for: .mnnLLM) else {
|
|
||||||
mnnStatus = .error("MNN 模型未就绪")
|
|
||||||
throw AIRuntimeError.notReady
|
|
||||||
}
|
|
||||||
|
|
||||||
await acquireGate()
|
|
||||||
defer { releaseGate() }
|
|
||||||
if mnnStatus == .ready { return }
|
|
||||||
|
|
||||||
// 单模型常驻:卸 MLX LLM/VL,避免与 MNN 模型叠加占内存。
|
|
||||||
unloadLLM()
|
|
||||||
unloadVL()
|
|
||||||
|
|
||||||
mnnStatus = .loading
|
|
||||||
do {
|
|
||||||
try await mnn.load(folderURL: folder)
|
|
||||||
mnnStatus = .ready
|
|
||||||
} catch {
|
|
||||||
mnnStatus = .error("\(error)")
|
|
||||||
throw AIRuntimeError.modelLoadFailed("\(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 卸载 MNN,释放桥与权重。幂等。
|
|
||||||
private func unloadMNN() async {
|
|
||||||
guard mnnStatus != .notReady else { return }
|
|
||||||
await mnn.unload()
|
|
||||||
mnnStatus = .notReady
|
|
||||||
MLX.Memory.clearCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 流式生成。调用前应先 await prepare()。
|
/// 流式生成。调用前应先 await prepare()。
|
||||||
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
||||||
/// priority = .background 时排队让行、解码中可被前台请求按 token 抢占(CancellationError 透传)。
|
/// priority = .background 时排队让行、解码中可被前台请求按 token 抢占(CancellationError 透传)。
|
||||||
func generate(prompt: String,
|
func generate(prompt: String,
|
||||||
maxTokens: Int = 256,
|
maxTokens: Int = 256,
|
||||||
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
|
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
|
||||||
return mnnGenerate(prompt: prompt, maxTokens: maxTokens, priority: priority)
|
|
||||||
}
|
|
||||||
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
||||||
let snapshotStatus = status
|
let snapshotStatus = status
|
||||||
let snapshotSession = llmSession
|
let snapshotSession = llmSession
|
||||||
@@ -247,6 +191,8 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
||||||
await self.acquireGate(priority)
|
await self.acquireGate(priority)
|
||||||
|
// defer 保证正常结束 / 异常 / 取消都释放闸门;杜绝未来新增 early-return 导致全局推理死锁。
|
||||||
|
defer { self.releaseGate() }
|
||||||
do {
|
do {
|
||||||
// session.generate 跨 actor 边界,需要 await
|
// session.generate 跨 actor 边界,需要 await
|
||||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||||
@@ -256,9 +202,6 @@ actor AIRuntime {
|
|||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出。
|
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出。
|
||||||
if self.shouldPreempt(priority) { throw CancellationError() }
|
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||||
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
|
||||||
// 调用同 actor 的 recordRate 不需要 await
|
|
||||||
self.recordRate(chunk.decodeRate)
|
|
||||||
continuation.yield(chunk)
|
continuation.yield(chunk)
|
||||||
}
|
}
|
||||||
self.lastGenerateStats = await session.lastStats
|
self.lastGenerateStats = await session.lastStats
|
||||||
@@ -269,63 +212,68 @@ actor AIRuntime {
|
|||||||
} catch {
|
} catch {
|
||||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||||
}
|
}
|
||||||
// 正常结束 / 异常 / 取消(checkCancellation 抛出后被上面 catch 吞掉)都会走到这,
|
|
||||||
// 闸门一定释放,不会死锁后续推理。
|
|
||||||
self.releaseGate()
|
|
||||||
}
|
}
|
||||||
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
||||||
continuation.onTermination = { _ in task.cancel() }
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MNN(CPU/SME2)文本流式生成。结构与 MLX 分支一致:进闸门、串行解码、记录速率。
|
// MARK: - Gemini 云端(hybrid 增强)
|
||||||
private func mnnGenerate(prompt: String,
|
|
||||||
maxTokens: Int,
|
/// 云端流式生成(深度解读 / 多语言)。不进 OOM 闸门,可与端侧并发。
|
||||||
priority: InferencePriority) -> AsyncThrowingStream<TokenChunk, Error> {
|
/// 调用前应确认 `cloudAvailable`;失败时调用方回退端侧 `generate`。
|
||||||
let ready = (mnnStatus == .ready)
|
func generateCloud(prompt: String, maxTokens: Int = 512) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
return AsyncThrowingStream { continuation in
|
AsyncThrowingStream { continuation in
|
||||||
let task = Task {
|
let task = Task {
|
||||||
guard ready else {
|
|
||||||
continuation.finish(throwing: AIRuntimeError.notReady)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await self.acquireGate(priority)
|
|
||||||
do {
|
do {
|
||||||
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
|
let stream = await self.gemini.generate(prompt: prompt, maxTokens: maxTokens)
|
||||||
for try await chunk in stream {
|
for try await chunk in stream {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出
|
|
||||||
//(流终止触发 MNNBackend.onTermination → bridge.cancel())。
|
|
||||||
if self.shouldPreempt(priority) { throw CancellationError() }
|
|
||||||
self.recordRate(chunk.decodeRate)
|
|
||||||
continuation.yield(chunk)
|
continuation.yield(chunk)
|
||||||
}
|
}
|
||||||
self.lastGenerateStats = await self.mnn.lastStats
|
self.lastGenerateStats = await self.gemini.lastStats
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
continuation.finish(throwing: CancellationError())
|
continuation.finish(throwing: CancellationError())
|
||||||
} catch {
|
} catch {
|
||||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||||
}
|
}
|
||||||
self.releaseGate()
|
|
||||||
}
|
}
|
||||||
continuation.onTermination = { _ in task.cancel() }
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recordRate(_ rate: Double) {
|
/// 云端多模态识别:图片直传 Gemini 读出结构化文本(通常 JSON)。
|
||||||
if rate > 0 { lastDecodeRate = rate }
|
/// 恢复端侧丢掉的真·视觉读图;失败时调用方回退端侧 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
|
// MARK: - VL
|
||||||
|
|
||||||
/// 加载 VL 模型。幂等,首调真正 load。
|
/// 加载 VL 模型。幂等,首调真正 load。
|
||||||
func prepareVL() async throws {
|
func prepareVL() async throws {
|
||||||
// 选了 MNN 且多模态模型就绪:VL 复用同一个 MNN 模型(文本+视觉一肩挑),走 prepareMNN。
|
|
||||||
if InferenceEngine.current == .mnn, ModelStore.shared.isComplete(for: .mnnLLM) {
|
|
||||||
try await prepareMNN()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
while vlStatus == .loading {
|
while vlStatus == .loading {
|
||||||
try await Task.sleep(nanoseconds: 80_000_000)
|
try await Task.sleep(nanoseconds: 80_000_000)
|
||||||
}
|
}
|
||||||
@@ -346,7 +294,6 @@ actor AIRuntime {
|
|||||||
|
|
||||||
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。
|
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。
|
||||||
unloadLLM()
|
unloadLLM()
|
||||||
await unloadMNN()
|
|
||||||
|
|
||||||
vlStatus = .loading
|
vlStatus = .loading
|
||||||
do {
|
do {
|
||||||
@@ -386,16 +333,6 @@ actor AIRuntime {
|
|||||||
func analyzeReport(imageURLs: [URL],
|
func analyzeReport(imageURLs: [URL],
|
||||||
prompt: String,
|
prompt: String,
|
||||||
maxTokens: Int = 512) async throws -> String {
|
maxTokens: Int = 512) async throws -> String {
|
||||||
// 选了 MNN 且就绪:图→文走同一个 MNN 多模态模型。
|
|
||||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
|
||||||
await acquireGate()
|
|
||||||
defer { releaseGate() }
|
|
||||||
do {
|
|
||||||
return try await mnn.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
|
|
||||||
} catch {
|
|
||||||
throw AIRuntimeError.inferenceFailed("\(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard vlStatus == .ready, let session = vlSession else {
|
guard vlStatus == .ready, let session = vlSession else {
|
||||||
throw AIRuntimeError.notReady
|
throw AIRuntimeError.notReady
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
|
|||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var handle: FileHandle?
|
private var handle: FileHandle?
|
||||||
private var written: Int = 0
|
private var written: Int = 0
|
||||||
|
private var expectedTotal: Int = 0
|
||||||
private var onProgress: ((Int) -> Void)?
|
private var onProgress: ((Int) -> Void)?
|
||||||
private var responseError: Error?
|
private var responseError: Error?
|
||||||
private var continuation: CheckedContinuation<Void, Error>?
|
private var continuation: CheckedContinuation<Void, Error>?
|
||||||
@@ -77,25 +78,34 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
|
|||||||
lock.withLock {
|
lock.withLock {
|
||||||
self.handle = fileHandle
|
self.handle = fileHandle
|
||||||
self.written = offset
|
self.written = offset
|
||||||
|
self.expectedTotal = expectedBytes
|
||||||
self.onProgress = onProgress
|
self.onProgress = onProgress
|
||||||
self.responseError = nil
|
self.responseError = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// offset 0 也发 `bytes=0-`:诱导服务器回 206 + Content-Range 报告资源总大小,
|
||||||
|
// didReceive 里与清单比对不符立即失败 —— 上游仓库更新导致大小漂移时(2026-07-14 实发),
|
||||||
|
// 秒级报 sizeMismatch,而不是把 3.5GB 下完才在收尾校验发现。
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
if offset > 0 {
|
|
||||||
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
||||||
}
|
|
||||||
|
|
||||||
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||||||
defer { session.finishTasksAndInvalidate() }
|
defer { session.finishTasksAndInvalidate() }
|
||||||
|
|
||||||
// 句柄在 didCompleteWithError 内关闭(同一 delegate 队列,串行于 didReceive)。
|
// 句柄在 didCompleteWithError 内关闭(同一 delegate 队列,串行于 didReceive)。
|
||||||
|
do {
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
lock.lock()
|
lock.lock()
|
||||||
self.continuation = cont
|
self.continuation = cont
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
session.dataTask(with: request).resume()
|
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)
|
let finalSize = Self.fileSize(at: part)
|
||||||
guard finalSize == expectedBytes else {
|
guard finalSize == expectedBytes else {
|
||||||
@@ -119,9 +129,27 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
|
|||||||
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
||||||
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
|
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
|
||||||
completionHandler(.cancel)
|
completionHandler(.cancel)
|
||||||
} else {
|
return
|
||||||
completionHandler(.allow)
|
|
||||||
}
|
}
|
||||||
|
// 快速失败: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(
|
nonisolated func urlSession(
|
||||||
|
|||||||
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?
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// 端侧推理引擎选择。
|
|
||||||
/// - mnn:Qwen + MNN + SME2(CPU),挑战赛考核路径,真机默认。
|
|
||||||
/// - mlx:Qwen + MLX(Metal GPU),兜底 / 对照。模拟器只有它可用。
|
|
||||||
nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
|
|
||||||
case mnn
|
|
||||||
case mlx
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .mnn: return "MNN · CPU/SME2"
|
|
||||||
case .mlx: return "MLX · GPU"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 本构建/设备是否可用。MNN 仅 device 切片有真实内核,模拟器回退 MLX。
|
|
||||||
var isAvailable: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mlx: return true
|
|
||||||
case .mnn: return MNNLLMBridge.isAvailable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 持久化(UserDefaults,跨 actor 安全)
|
|
||||||
|
|
||||||
private static let key = "kk.inferenceEngine"
|
|
||||||
|
|
||||||
/// 由偏好(可能是 .auto)解析出的、本次调用实际使用的具体引擎。
|
|
||||||
/// AIRuntime / MeView 等消费方只看这个,永远拿到 .mnn 或 .mlx。
|
|
||||||
/// 解析后仍做一次可用性兜底,保证总有可用引擎。
|
|
||||||
static var current: InferenceEngine {
|
|
||||||
let resolved = preference.resolved
|
|
||||||
return resolved.isAvailable ? resolved : .mlx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
|
||||||
static var cpuSupportsSME2: Bool { MNNLLMBridge.cpuSupportsSME2() }
|
|
||||||
|
|
||||||
// MARK: - 用户偏好(auto / mnn / mlx)
|
|
||||||
|
|
||||||
/// 用户在设置页的选择。默认 .auto:按本机配置自动择优。
|
|
||||||
/// 与具体引擎共用同一 UserDefaults key——历史写入的 "mnn"/"mlx" 仍兼容。
|
|
||||||
static var preference: EnginePreference {
|
|
||||||
get {
|
|
||||||
let raw = UserDefaults.standard.string(forKey: key)
|
|
||||||
return raw.flatMap(EnginePreference.init(rawValue:)) ?? .auto
|
|
||||||
}
|
|
||||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 推理引擎的「用户偏好」,比具体引擎多一个 .auto。
|
|
||||||
/// - auto:按本机配置自动选——真机优先 MNN(考核路径,含 SME2/NEON),
|
|
||||||
/// MNN 不可用(模拟器)时回退 MLX。
|
|
||||||
nonisolated enum EnginePreference: String, CaseIterable, Sendable {
|
|
||||||
case auto
|
|
||||||
case mnn
|
|
||||||
case mlx
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .auto: return "自动"
|
|
||||||
case .mnn: return InferenceEngine.mnn.displayName
|
|
||||||
case .mlx: return InferenceEngine.mlx.displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把偏好解析成具体引擎(不做可用性兜底,那一步留给 `InferenceEngine.current`)。
|
|
||||||
var resolved: InferenceEngine {
|
|
||||||
switch self {
|
|
||||||
case .mnn: return .mnn
|
|
||||||
case .mlx: return .mlx
|
|
||||||
case .auto: return InferenceEngine.mnn.isAvailable ? .mnn : .mlx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,14 @@ import Foundation
|
|||||||
import MLX
|
import MLX
|
||||||
import MLXLLM
|
import MLXLLM
|
||||||
import MLXLMCommon
|
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 语言模型的流式生成,actor 保证单线程访问。
|
||||||
/// 基于 mlx-swift-examples 2.29.1(commit 9bff95ca)的 API。
|
/// 基于 mlx-swift-lm 3.31.4 的 API(MLXLLM / MLXLMCommon)。
|
||||||
actor LLMSession {
|
actor LLMSession {
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
|
|
||||||
@@ -31,10 +36,20 @@ actor LLMSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 从本地目录加载模型(包含 config.json + weights + tokenizer)。
|
/// 从本地目录加载模型(包含 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 {
|
static func load(folderURL: URL) async throws -> LLMSession {
|
||||||
let configuration = ModelConfiguration(directory: folderURL)
|
let configuration = ModelConfiguration(
|
||||||
|
directory: folderURL,
|
||||||
|
extraEOSTokens: ["<turn|>"]
|
||||||
|
)
|
||||||
|
// 3.31.4:loadContainer 需显式传入 Downloader + TokenizerLoader。本地目录配置(.directory)
|
||||||
|
// 不会真正下载(resolve 直接用目录),Downloader 仅占位;分词器由 HF AutoTokenizer 从目录加载。
|
||||||
let container = try await withDeviceOverride {
|
let container = try await withDeviceOverride {
|
||||||
try await LLMModelFactory.shared.loadContainer(
|
try await LLMModelFactory.shared.loadContainer(
|
||||||
|
from: #hubDownloader(),
|
||||||
|
using: #huggingFaceTokenizerLoader(),
|
||||||
configuration: configuration
|
configuration: configuration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
//
|
|
||||||
// MNNLLMBridge.h
|
|
||||||
// 康康
|
|
||||||
//
|
|
||||||
// Objective-C 接口,封装 MNN-LLM(Qwen)的加载与流式推理。
|
|
||||||
// 真实实现在 .mm 中以 ObjC++ 调用 <MNN/llm/llm.hpp>;模拟器下编为可用性返回 NO 的桩
|
|
||||||
// (MNN.framework 仅 device arm64 切片有真实 CPU/SME2 内核,模拟器走 MLX 兜底)。
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
/// 末次生成的性能统计(取自 MNN LlmContext)。
|
|
||||||
@interface MNNGenerateStats : NSObject
|
|
||||||
@property (nonatomic, readonly) int promptTokens;
|
|
||||||
@property (nonatomic, readonly) int genTokens;
|
|
||||||
@property (nonatomic, readonly) double prefillMs;
|
|
||||||
@property (nonatomic, readonly) double decodeMs;
|
|
||||||
/// 解码速率 tok/s = genTokens / (decodeMs/1000)。demo 卖点 #6 / Live Activity 用。
|
|
||||||
@property (nonatomic, readonly) double decodeTokensPerSecond;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface MNNLLMBridge : NSObject
|
|
||||||
|
|
||||||
/// 本构建是否含真实 MNN 运行时(device=YES,simulator 桩=NO)。
|
|
||||||
+ (BOOL)isAvailable;
|
|
||||||
/// CPU 是否支持 SME2(运行时探测);A19/iPhone17 YES,A17/iPhone15Pro NO。仅用于 UI 展示加速状态。
|
|
||||||
+ (BOOL)cpuSupportsSME2;
|
|
||||||
|
|
||||||
/// 用 MNN llm 的 config.json 路径加载模型(目录含 llm.mnn / 权重 / tokenizer)。失败返回 nil。
|
|
||||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL isLoaded;
|
|
||||||
|
|
||||||
/// 纯文本流式生成。onToken 每解码出一段文本回调一次(在调用线程,同步阻塞直到生成结束)。
|
|
||||||
/// 返回末次统计。
|
|
||||||
- (MNNGenerateStats *)generateText:(NSString *)prompt
|
|
||||||
maxTokens:(int)maxTokens
|
|
||||||
onToken:(void (^)(NSString *piece))onToken;
|
|
||||||
|
|
||||||
/// 图→文(VL,需 MNN_BUILD_LLM_OMNI 构建)。imagePaths 为本地文件路径。
|
|
||||||
/// 当前文本构建未含 OMNI 时返回 nil 并置 error。
|
|
||||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
|
|
||||||
prompt:(NSString *)prompt
|
|
||||||
maxTokens:(int)maxTokens
|
|
||||||
onToken:(void (^)(NSString *piece))onToken
|
|
||||||
error:(NSError *_Nullable *_Nullable)error;
|
|
||||||
|
|
||||||
/// 请求取消当前生成(best-effort:置标志,后续 token 不再回调)。
|
|
||||||
- (void)cancel;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
//
|
|
||||||
// MNNLLMBridge.mm
|
|
||||||
// 康康
|
|
||||||
//
|
|
||||||
// ObjC++ 实现。device 真机用 <MNN/llm/llm.hpp>;模拟器编为桩(返回不可用,上层回退 MLX)。
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "MNNLLMBridge.h"
|
|
||||||
#include <sys/sysctl.h>
|
|
||||||
|
|
||||||
// MARK: - 性能统计(私有 readwrite 重声明)
|
|
||||||
@interface MNNGenerateStats ()
|
|
||||||
@property (nonatomic, readwrite) int promptTokens;
|
|
||||||
@property (nonatomic, readwrite) int genTokens;
|
|
||||||
@property (nonatomic, readwrite) double prefillMs;
|
|
||||||
@property (nonatomic, readwrite) double decodeMs;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation MNNGenerateStats
|
|
||||||
- (double)decodeTokensPerSecond {
|
|
||||||
return self.decodeMs > 0 ? (self.genTokens / (self.decodeMs / 1000.0)) : 0;
|
|
||||||
}
|
|
||||||
@end
|
|
||||||
|
|
||||||
// MARK: - SME2 / 可用性探测(device + simulator 都可编)
|
|
||||||
|
|
||||||
static BOOL kk_sysctlFlag(const char *name) {
|
|
||||||
int64_t v = 0; size_t sz = sizeof(v);
|
|
||||||
if (sysctlbyname(name, &v, &sz, NULL, 0) != 0) return NO;
|
|
||||||
return v != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#if TARGET_OS_SIMULATOR
|
|
||||||
|
|
||||||
// ============ 模拟器桩:无真实 MNN ============
|
|
||||||
@implementation MNNLLMBridge
|
|
||||||
+ (BOOL)isAvailable { return NO; }
|
|
||||||
+ (BOOL)cpuSupportsSME2 { return NO; }
|
|
||||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath { return nil; }
|
|
||||||
- (BOOL)isLoaded { return NO; }
|
|
||||||
- (MNNGenerateStats *)generateText:(NSString *)prompt maxTokens:(int)maxTokens
|
|
||||||
onToken:(void (^)(NSString *))onToken { return [MNNGenerateStats new]; }
|
|
||||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths prompt:(NSString *)prompt
|
|
||||||
maxTokens:(int)maxTokens onToken:(void (^)(NSString *))onToken
|
|
||||||
error:(NSError **)error {
|
|
||||||
if (error) *error = [NSError errorWithDomain:@"MNN" code:-1
|
|
||||||
userInfo:@{NSLocalizedDescriptionKey: @"MNN 在模拟器不可用"}];
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
- (void)cancel {}
|
|
||||||
@end
|
|
||||||
|
|
||||||
#else
|
|
||||||
|
|
||||||
// ============ 真机:真实 MNN-LLM ============
|
|
||||||
// MNN 第三方头文件的文档注释不规范,会触发一堆 -Wdocumentation 警告(Executor/
|
|
||||||
// Tensor/Interpreter/ImageProcess.hpp)。只在解析 MNN 头时关掉该警告,不影响本项目。
|
|
||||||
#pragma clang diagnostic push
|
|
||||||
#pragma clang diagnostic ignored "-Wdocumentation"
|
|
||||||
#include <MNN/llm/llm.hpp>
|
|
||||||
#pragma clang diagnostic pop
|
|
||||||
#include <string>
|
|
||||||
#include <ostream>
|
|
||||||
#include <streambuf>
|
|
||||||
#include <atomic>
|
|
||||||
|
|
||||||
using MNN::Transformer::Llm;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
/// 把 MNN 写入 ostream 的解码文本转成 NSString 回调;按 UTF-8 完整边界聚合,避免截断多字节。
|
|
||||||
class TokenStreamBuf : public std::streambuf {
|
|
||||||
public:
|
|
||||||
TokenStreamBuf(void (^onToken)(NSString *), std::atomic<bool> *cancel)
|
|
||||||
: _onToken(onToken), _cancel(cancel) {}
|
|
||||||
void flush() {
|
|
||||||
if (_pending.empty()) return;
|
|
||||||
emitPending(); // 末尾尽力 emit(即便非完整 UTF-8 也交出去)
|
|
||||||
_pending.clear();
|
|
||||||
}
|
|
||||||
protected:
|
|
||||||
std::streamsize xsputn(const char *s, std::streamsize n) override {
|
|
||||||
append(s, (size_t)n);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
int overflow(int c) override {
|
|
||||||
if (c != EOF) { char ch = (char)c; append(&ch, 1); }
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
private:
|
|
||||||
void append(const char *s, size_t n) {
|
|
||||||
if (_cancel && _cancel->load()) return; // 已取消,吞掉不回调
|
|
||||||
_pending.append(s, n);
|
|
||||||
// 仅当整个 pending 是合法 UTF-8 才 emit(token 通常是完整字/词,边界自然对齐)
|
|
||||||
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
|
|
||||||
length:_pending.size()
|
|
||||||
encoding:NSUTF8StringEncoding];
|
|
||||||
if (str) { if (_onToken) _onToken(str); _pending.clear(); }
|
|
||||||
}
|
|
||||||
void emitPending() {
|
|
||||||
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
|
|
||||||
length:_pending.size()
|
|
||||||
encoding:NSUTF8StringEncoding];
|
|
||||||
if (str && _onToken) _onToken(str);
|
|
||||||
}
|
|
||||||
void (^_onToken)(NSString *);
|
|
||||||
std::atomic<bool> *_cancel;
|
|
||||||
std::string _pending;
|
|
||||||
};
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
@implementation MNNLLMBridge {
|
|
||||||
Llm *_llm;
|
|
||||||
std::atomic<bool> _cancel;
|
|
||||||
BOOL _loaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (BOOL)isAvailable { return YES; }
|
|
||||||
|
|
||||||
+ (BOOL)cpuSupportsSME2 {
|
|
||||||
// Apple 通过 sysctl 暴露 ARM 特性位:FEAT_SME2(A19/iPhone17+)。
|
|
||||||
return kk_sysctlFlag("hw.optional.arm.FEAT_SME2");
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath {
|
|
||||||
self = [super init];
|
|
||||||
if (!self) return nil;
|
|
||||||
_cancel = false;
|
|
||||||
_llm = Llm::createLLM(std::string(configPath.UTF8String));
|
|
||||||
if (_llm == nullptr) return nil;
|
|
||||||
// load 前以 merge-patch 调三件事(只翻这几个叶子,保留 chat_template 等其余配置):
|
|
||||||
// ① enable_thinking=false:config.json 默认 true,模板会给每个 assistant 回合硬塞
|
|
||||||
// <think>\n 开启思考,吞掉 token 预算并污染 JSON(prompt 里的 /no_think 对此模板无效)。
|
|
||||||
// ② 降温:config.json 默认 temperature=1.0 对结构化 JSON 太高,随机性大→经常吐成非 JSON。
|
|
||||||
// 本 App 所有任务都是"直答/JSON",压到 0.3 + topP 0.85 让输出更确定、JSON 更稳。
|
|
||||||
// ③ 重复惩罚:MNN 默认 mixed_samplers 不含 "penalty"、penalty/ngram_factor=1.0(全关),
|
|
||||||
// 叠加低温 → 长文本(如「关键指标」列表)会陷入逐行复读死循环(收缩压 107 mmHg ×N)。
|
|
||||||
// 显式把 "penalty" 放进 mixed 链首,开 repetition penalty(1.1)+ n-gram 惩罚(ngram_factor 1.05):
|
|
||||||
// n-gram 命中整段重复时惩罚升到 max_penalty,直接掐断逐行复读。
|
|
||||||
_llm->set_config("{"
|
|
||||||
"\"jinja\":{\"context\":{\"enable_thinking\":false}},"
|
|
||||||
"\"sampler_type\":\"mixed\","
|
|
||||||
"\"mixed_samplers\":[\"penalty\",\"topK\",\"topP\",\"temperature\"],"
|
|
||||||
"\"temperature\":0.3,\"topP\":0.85,\"topK\":40,"
|
|
||||||
"\"penalty\":1.1,\"n_gram\":8,\"ngram_factor\":1.05"
|
|
||||||
"}");
|
|
||||||
_loaded = _llm->load();
|
|
||||||
if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; }
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)dealloc {
|
|
||||||
if (_llm) { Llm::destroy(_llm); _llm = nullptr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)isLoaded { return _loaded; }
|
|
||||||
|
|
||||||
- (void)cancel { _cancel = true; }
|
|
||||||
|
|
||||||
// 统一生成:full 已是最终 prompt(文本,或含 <img>路径</img> 标签)。
|
|
||||||
// 多模态模型 createLLM 返回 Omni,response 解析 <img> 标签并对路径 CV::imread(OMNI 框架内)。
|
|
||||||
- (MNNGenerateStats *)runResponse:(NSString *)full
|
|
||||||
maxTokens:(int)maxTokens
|
|
||||||
onToken:(void (^)(NSString *))onToken {
|
|
||||||
_cancel = false;
|
|
||||||
TokenStreamBuf buf(onToken, &_cancel);
|
|
||||||
std::ostream os(&buf);
|
|
||||||
if (_llm) {
|
|
||||||
_llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens);
|
|
||||||
}
|
|
||||||
buf.flush();
|
|
||||||
return [self statsFromContext];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (MNNGenerateStats *)generateText:(NSString *)prompt
|
|
||||||
maxTokens:(int)maxTokens
|
|
||||||
onToken:(void (^)(NSString *))onToken {
|
|
||||||
return [self runResponse:prompt maxTokens:maxTokens onToken:onToken];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
|
|
||||||
prompt:(NSString *)prompt
|
|
||||||
maxTokens:(int)maxTokens
|
|
||||||
onToken:(void (^)(NSString *))onToken
|
|
||||||
error:(NSError **)error {
|
|
||||||
// 在 prompt 前拼 <img>本地路径</img>;Omni 解析标签并对路径 imread(需 OMNI 框架)。
|
|
||||||
NSMutableString *full = [NSMutableString string];
|
|
||||||
for (NSString *p in imagePaths) {
|
|
||||||
[full appendFormat:@"<img>%@</img>", p];
|
|
||||||
}
|
|
||||||
[full appendString:prompt];
|
|
||||||
return [self runResponse:full maxTokens:maxTokens onToken:onToken];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (MNNGenerateStats *)statsFromContext {
|
|
||||||
MNNGenerateStats *s = [MNNGenerateStats new];
|
|
||||||
if (_llm) {
|
|
||||||
const MNN::Transformer::LlmContext *ctx = _llm->getContext();
|
|
||||||
if (ctx) {
|
|
||||||
s.promptTokens = ctx->prompt_len;
|
|
||||||
s.genTokens = ctx->gen_seq_len;
|
|
||||||
s.prefillMs = ctx->prefill_us / 1000.0;
|
|
||||||
s.decodeMs = ctx->decode_us / 1000.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#endif
|
|
||||||
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
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// MNN(CPU / SME2)推理后端,封装 `MNNLLMBridge` 的文本流式生成。
|
|
||||||
/// 与 `LLMSession`/`VLSession` 同款 actor 隔离;跨调用的串行化由上游 `AIRuntime` 闸门保证。
|
|
||||||
///
|
|
||||||
/// 文本与视觉(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型承担:`generate` 走文本,
|
|
||||||
/// `analyze` 把图片拼成 <img> 标签交给 Omni 内核 imread 解码(需 OMNI 构建,xcframework 已含)。
|
|
||||||
/// 已实测可用,真机走此单模型全包路径;模拟器无 MNN,VL 仍回退 MLX(见 `AIRuntime`)。
|
|
||||||
actor MNNBackend {
|
|
||||||
private var bridge: MNNLLMBridge?
|
|
||||||
|
|
||||||
/// 末次生成统计(供 AIRuntime 在流结束后取走,性能自检用)。
|
|
||||||
private(set) var lastStats: GenerateStats?
|
|
||||||
|
|
||||||
private func record(_ s: GenerateStats) { lastStats = s }
|
|
||||||
|
|
||||||
var isLoaded: Bool { bridge?.isLoaded ?? false }
|
|
||||||
|
|
||||||
/// 从 MNN 模型目录加载(目录含 MNN llm 的 config.json + llm.mnn + 权重 + tokenizer)。
|
|
||||||
func load(folderURL: URL) throws {
|
|
||||||
let configPath = folderURL.appendingPathComponent("config.json").path
|
|
||||||
guard FileManager.default.fileExists(atPath: configPath) else {
|
|
||||||
throw AIRuntimeError.notReady
|
|
||||||
}
|
|
||||||
guard let b = MNNLLMBridge(configPath: configPath) else {
|
|
||||||
throw AIRuntimeError.modelLoadFailed("MNN createLLM/load 失败")
|
|
||||||
}
|
|
||||||
bridge = b
|
|
||||||
}
|
|
||||||
|
|
||||||
func unload() { bridge = nil }
|
|
||||||
|
|
||||||
/// 文本流式生成。`bridge.generateText` 同步阻塞、逐段回调,放在 detached 线程跑,
|
|
||||||
/// 把每段文本 yield 成 `TokenChunk`(含即时 tok/s)。流被取消时调用 `bridge.cancel()`。
|
|
||||||
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
|
||||||
guard let bridge else {
|
|
||||||
return AsyncThrowingStream { $0.finish(throwing: AIRuntimeError.notReady) }
|
|
||||||
}
|
|
||||||
let box = MNNUncheckedBox(bridge)
|
|
||||||
return AsyncThrowingStream { continuation in
|
|
||||||
let meter = MNNRateMeter()
|
|
||||||
let task = Task.detached(priority: .userInitiated) {
|
|
||||||
let stats = box.value.generateText(prompt, maxTokens: Int32(maxTokens)) { piece in
|
|
||||||
let rate = meter.tick()
|
|
||||||
continuation.yield(TokenChunk(text: piece, decodeRate: rate))
|
|
||||||
}
|
|
||||||
// ObjC 统计对象先抽成 Sendable 的 GenerateStats 再跨 actor 记录。
|
|
||||||
await self.record(GenerateStats(
|
|
||||||
promptTokens: Int(stats.promptTokens),
|
|
||||||
genTokens: Int(stats.genTokens),
|
|
||||||
prefillSeconds: stats.prefillMs / 1000.0,
|
|
||||||
decodeSeconds: stats.decodeMs / 1000.0
|
|
||||||
))
|
|
||||||
continuation.finish()
|
|
||||||
}
|
|
||||||
continuation.onTermination = { _ in
|
|
||||||
box.value.cancel()
|
|
||||||
task.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 图→文(VL)。一次性收集(JSON 抽取不需流式)。桥接里把图片路径拼成 <img> 标签,
|
|
||||||
/// MNN Omni 内部 imread 加载(需 OMNI 框架);blocking 调用放 detached 线程。
|
|
||||||
func analyze(imageURLs: [URL], prompt: String, maxTokens: Int) async throws -> String {
|
|
||||||
guard let bridge else { throw AIRuntimeError.notReady }
|
|
||||||
let paths = imageURLs.map(\.path)
|
|
||||||
let box = MNNUncheckedBox(bridge)
|
|
||||||
return try await withCheckedThrowingContinuation { cont in
|
|
||||||
Task.detached(priority: .userInitiated) {
|
|
||||||
let sink = MNNTextSink()
|
|
||||||
do {
|
|
||||||
let stats = try box.value.analyzeImages(paths, prompt: prompt, maxTokens: Int32(maxTokens)) { piece in
|
|
||||||
sink.append(piece)
|
|
||||||
}
|
|
||||||
await self.record(GenerateStats(
|
|
||||||
promptTokens: Int(stats.promptTokens),
|
|
||||||
genTokens: Int(stats.genTokens),
|
|
||||||
prefillSeconds: stats.prefillMs / 1000.0,
|
|
||||||
decodeSeconds: stats.decodeMs / 1000.0
|
|
||||||
))
|
|
||||||
cont.resume(returning: sink.text)
|
|
||||||
} catch {
|
|
||||||
cont.resume(throwing: AIRuntimeError.inferenceFailed(error.localizedDescription))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 单线程串行回调聚合文本,无竞争。
|
|
||||||
private nonisolated final class MNNTextSink: @unchecked Sendable {
|
|
||||||
private(set) var text = ""
|
|
||||||
func append(_ s: String) { text += s }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把非 Sendable 的 ObjC 桥对象安全带过 detached 边界。
|
|
||||||
/// 安全性来自 `AIRuntime` 闸门:同一时刻只有一个生成在跑,桥不会被并发访问。
|
|
||||||
private nonisolated struct MNNUncheckedBox<T>: @unchecked Sendable {
|
|
||||||
let value: T
|
|
||||||
init(_ value: T) { self.value = value }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 即时解码速率计:回调在单线程串行调用,内部计数无竞争。
|
|
||||||
private nonisolated final class MNNRateMeter: @unchecked Sendable {
|
|
||||||
private let start = Date()
|
|
||||||
private var produced = 0
|
|
||||||
func tick() -> Double {
|
|
||||||
produced += 1
|
|
||||||
let elapsed = Date().timeIntervalSince(start)
|
|
||||||
return elapsed > 0 ? Double(produced) / elapsed : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,30 +11,54 @@ struct ModelFile: Equatable, Sendable {
|
|||||||
/// 字节数与服务器素材逐一核对一致,见
|
/// 字节数与服务器素材逐一核对一致,见
|
||||||
/// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。
|
/// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。
|
||||||
nonisolated enum ModelManifest {
|
nonisolated enum ModelManifest {
|
||||||
/// 自建 Caddy 静态服务(用户自建 HTTPS 反代)。
|
/// 自建 Caddy 静态镜像。现仅 `.vl`(已废弃,不再下载)回退用;主模型 / MLX 兜底已切魔搭官方。
|
||||||
/// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/
|
/// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/
|
||||||
static let baseURL = URL(string: "https://file.myv0.com/")!
|
static let baseURL = URL(string: "https://file.myv0.com/")!
|
||||||
|
|
||||||
|
/// 各模型的魔搭(ModelScope)官方仓库 id。大陆可达;`resolve/master` 会 302 跳阿里 OSS,
|
||||||
|
/// OSS 支持 Range,断点续传穿透重定向(实测 206 + 精确分片)。各文件大小与本清单逐一核对一致(2026-06 实测)。
|
||||||
|
/// 注意组织名:MNN 模型在魔搭组织为 `MNN`(非 HuggingFace 的 taobao-mnn);MLX 沿用 mlx-community。
|
||||||
|
static func modelScopeRepo(for kind: ModelKind) -> String? {
|
||||||
|
switch kind {
|
||||||
|
case .llm: return "mlx-community/gemma-4-e2b-it-4bit" // 主模型,大陆可达
|
||||||
|
case .mnnLLM: return "MNN/Qwen3.5-2B-MNN" // 已停用,保留源备查
|
||||||
|
case .vl: return nil // 已废弃,不再下载 / 分发,不提供魔搭源
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hf-mirror(hf-mirror.com)回退源仓库 id。仅 mlx-community 系与 HF 同 org/name 可直接镜像;
|
||||||
|
/// MNN 源在 HuggingFace 组织名不同(taobao-mnn),且已停用,不配回退。revision 用 HF 的 `main`。
|
||||||
|
static func huggingFaceMirrorRepo(for kind: ModelKind) -> String? {
|
||||||
|
switch kind {
|
||||||
|
case .llm: return kind.huggingFaceRepo // mlx-community/gemma-4-e2b-it-4bit
|
||||||
|
case .vl, .mnnLLM: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func files(for kind: ModelKind) -> [ModelFile] {
|
static func files(for kind: ModelKind) -> [ModelFile] {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .llm:
|
case .llm:
|
||||||
// Qwen3.5-2B-4bit:多模态仓库,但走 LLMModelFactory 的 qwen3_5 文本路径加载。
|
// gemma-4-e2b-it-4bit:Gemma 4 E2B(instruct,4bit)。config.json 顶层 model_type = "gemma4",
|
||||||
// 字节数取自 mlx-community/Qwen3.5-2B-4bit 仓库实际 blob 大小(HF API,2026-06 核对)。
|
// 经 LLMModelFactory 的 "gemma4" 路径加载(mlx-swift-lm ≥3.31.4 已注册;Gemma4Model 只取
|
||||||
// 该仓库 tokenizer 体系为 vocab.json + tokenizer.json(无 merges.txt /
|
// language_model 权重,跳过视觉/音频)。e2b 检查点无视觉权重(视觉在 gemma4_unified 12B)。
|
||||||
// special_tokens_map.json / added_tokens.json),chat_template 改为 .jinja。
|
// 字节数取自 ModelScope mlx-community/gemma-4-e2b-it-4bit 仓库实际 blob 大小
|
||||||
// 一并镜像视觉预处理配置(preprocessor / processor / video_preprocessor),
|
//(Range 请求 Content-Range 总长,2026-07-14 与魔搭 / hf-mirror 双源核对一致)。
|
||||||
// 文本加载用不到但体积可忽略,保持与仓库一致避免漏文件。
|
// 上游 2026-07 中旬更新过一次 checkpoint(权重 3.581G→3.551G,config/tokenizer_config/
|
||||||
|
// processor_config/index 同步变化;model_type 仍 gemma4、4bit,加载路径不受影响)——
|
||||||
|
// 清单值过期会让 FileDownloader 校验 sizeMismatch,表现为「下载失败」,更新须双源重新核对。
|
||||||
|
// 排除 README.md / .gitattributes / configuration.json(后者为 ModelScope 元数据,MLX 加载
|
||||||
|
// 用不到)。Gemma 4 布局与 3n 不同:无 tokenizer.model / special_tokens_map.json,
|
||||||
|
// tokenizer_config.json 精简(chat template 独立在 chat_template.jinja),新增
|
||||||
|
// processor_config.json。全部运行文件按上游 mlx-swift-lm 预置仓库快照镜像,避免漏文件。
|
||||||
return [
|
return [
|
||||||
ModelFile(path: "config.json", bytes: 3_113),
|
ModelFile(path: "config.json", bytes: 6_395),
|
||||||
ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
|
ModelFile(path: "generation_config.json", bytes: 208),
|
||||||
ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
|
ModelFile(path: "model.safetensors", bytes: 3_550_670_554),
|
||||||
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
|
ModelFile(path: "model.safetensors.index.json", bytes: 218_323),
|
||||||
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
|
ModelFile(path: "tokenizer.json", bytes: 32_169_626),
|
||||||
ModelFile(path: "vocab.json", bytes: 6_722_759),
|
ModelFile(path: "tokenizer_config.json", bytes: 2_740),
|
||||||
ModelFile(path: "chat_template.jinja", bytes: 7_755),
|
ModelFile(path: "chat_template.jinja", bytes: 17_336),
|
||||||
ModelFile(path: "preprocessor_config.json", bytes: 390),
|
ModelFile(path: "processor_config.json", bytes: 1_316),
|
||||||
ModelFile(path: "processor_config.json", bytes: 1_300),
|
|
||||||
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
|
|
||||||
]
|
]
|
||||||
case .vl:
|
case .vl:
|
||||||
// Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小
|
// Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小
|
||||||
@@ -79,10 +103,35 @@ nonisolated enum ModelManifest {
|
|||||||
files(for: kind).reduce(0) { $0 + $1.bytes }
|
files(for: kind).reduce(0) { $0 + $1.bytes }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 单个文件的下载 URL = baseURL / <仓库名> / <相对路径>。
|
/// 单个文件的**首选**下载 URL(= 候选源列表的第一项)。主模型走魔搭官方 `resolve/master`。
|
||||||
|
/// 下载编排应改用 `fileURLs` 拿到「魔搭优先 + hf-mirror 回退」的有序候选源;此方法保留给
|
||||||
|
/// 只需首选源的调用方(如测试断言主源路径)。
|
||||||
static func fileURL(for kind: ModelKind, file: ModelFile) -> URL {
|
static func fileURL(for kind: ModelKind, file: ModelFile) -> URL {
|
||||||
|
fileURLs(for: kind, file: file)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个文件的有序候选下载源:**魔搭(ModelScope,resolve/master)优先,hf-mirror(resolve/main)回退**。
|
||||||
|
/// 两源同为 mlx-community 仓库同一份权重,字节完全一致 —— 断点续传的 `.part` 可跨源复用
|
||||||
|
/// (换源时发 `Range: bytes=offset-` 继续,不必重下)。用户选择:魔搭优先 + hf-mirror 回退。
|
||||||
|
/// 已废弃的 `.vl`(无魔搭 / 无 hf 回退)落到自建镜像 `baseURL`(实际不会被下载)。
|
||||||
|
static func fileURLs(for kind: ModelKind, file: ModelFile) -> [URL] {
|
||||||
|
var urls: [URL] = []
|
||||||
|
if let repo = modelScopeRepo(for: kind) {
|
||||||
|
urls.append(
|
||||||
|
URL(string: "https://modelscope.cn/models/\(repo)/resolve/master/")!
|
||||||
|
.appendingPathComponent(file.path))
|
||||||
|
}
|
||||||
|
if let hfRepo = huggingFaceMirrorRepo(for: kind) {
|
||||||
|
urls.append(
|
||||||
|
URL(string: "https://hf-mirror.com/\(hfRepo)/resolve/main/")!
|
||||||
|
.appendingPathComponent(file.path))
|
||||||
|
}
|
||||||
|
if urls.isEmpty {
|
||||||
|
urls.append(
|
||||||
baseURL
|
baseURL
|
||||||
.appendingPathComponent(kind.rawValue, isDirectory: true)
|
.appendingPathComponent(kind.rawValue, isDirectory: true)
|
||||||
.appendingPathComponent(file.path)
|
.appendingPathComponent(file.path))
|
||||||
|
}
|
||||||
|
return urls
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ import Foundation
|
|||||||
|
|
||||||
nonisolated enum ModelKind: String, CaseIterable {
|
nonisolated enum ModelKind: String, CaseIterable {
|
||||||
/// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。
|
/// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。
|
||||||
/// 同一个 Qwen3.5-2B,两种格式两种引擎:
|
/// 主模型已从 Qwen3.5-2B → Gemma-3n → **Gemma 4 E2B(端侧 4bit,只走 MLX/GPU)**:
|
||||||
/// - mnnLLM:MNN(CPU/SME2,考核路径)文本+视觉一肩挑,taobao-mnn 预转换。iPhone17+(A19/SME2)主用,只露它。
|
/// - llm:**用户唯一下载的主模型**。MLX 文本引擎,经 LLMModelFactory 的 `gemma4` 路径加载
|
||||||
/// - llm:MLX(GPU)兜底,Qwen3.5-2B-4bit 多模态(同时兜底文本与视觉,走 qwen3_5)。
|
/// (mlx-swift-lm ≥3.31.4 已注册;`Gemma4Model` 只取 `language_model` 权重,跳过视觉/音频)。
|
||||||
/// - vl:已废弃(MLX VL 改走 .llm 多模态),保留枚举避免动一圈穷举 switch,不再下载/展示。
|
/// e2b 检查点无视觉权重(视觉在 gemma4_unified 12B),故拍报告解读仍走 OCR + 文本 LLM。
|
||||||
case llm = "Qwen3.5-2B-4bit"
|
/// - vl:已废弃,保留枚举避免动一圈穷举 switch,不再下载/展示。
|
||||||
|
/// - mnnLLM:已停用。本项目已放弃 MNN/SME2 路径;保留枚举只为兼容旧引擎判断,不再分发/计入下载。
|
||||||
|
case llm = "gemma-4-e2b-it-4bit"
|
||||||
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
||||||
case mnnLLM = "Qwen3.5-2B-MNN"
|
case mnnLLM = "Qwen3.5-2B-MNN"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .llm: return "Qwen3.5-2B (MLX)"
|
case .llm: return "Gemma 4 E2B (MLX)"
|
||||||
case .vl: return "Qwen3-VL-4B"
|
case .vl: return "Qwen3-VL-4B"
|
||||||
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
|
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
|
||||||
}
|
}
|
||||||
@@ -24,11 +26,9 @@ nonisolated enum ModelKind: String, CaseIterable {
|
|||||||
/// 用于判定该模型是否已就绪的最小标志文件
|
/// 用于判定该模型是否已就绪的最小标志文件
|
||||||
var sentinelFilename: String { "config.json" }
|
var sentinelFilename: String { "config.json" }
|
||||||
|
|
||||||
/// 面向用户的模型集合:模型管理页 / 下载全部 / 就绪计数对外只暴露统一的
|
/// 面向用户的模型集合:模型管理页 / 下载全部 / 就绪计数对外只暴露统一的主模型
|
||||||
/// Qwen3.5-2B(MNN,文本+视觉全包,iPhone17+ 走它)。
|
/// Gemma 4 E2B(MLX,文本)。.vl/.mnnLLM 已停用,不展示、不计入「下载全部」与就绪计数。
|
||||||
/// MLX 的 .llm/.vl 仅作模拟器与兜底路径,保留枚举与下载能力(旁路导入仍可单独导),
|
static let userFacing: [ModelKind] = [.llm]
|
||||||
/// 但不在「我的 · 模型管理」展示,也不计入「下载全部」与就绪计数。
|
|
||||||
static let userFacing: [ModelKind] = [.mnnLLM]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
||||||
|
|||||||
54
康康/AI/Prompts/ConsultationPrompts.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 「记录问诊」录音转写 → 结构化问诊小结的 prompt(2026-06-28)。
|
||||||
|
///
|
||||||
|
/// 与 `DiaryAssistPrompts.organize`(口述日记)同源同魂,但产物不同:
|
||||||
|
/// 问诊录音里通常**两个人在说话**(本人 + 医生),要按就诊场景归到固定小节,方便日后翻看/给下一位医生看。
|
||||||
|
///
|
||||||
|
/// 红线(同 §1 / §10):
|
||||||
|
/// - 这是**转写整理**,不是 App 在做诊断或给药建议——只忠实复述录音里医生/本人说过的话,
|
||||||
|
/// 【绝对不许】自行新增任何诊断、用药、剂量、频次或「建议就医」。模型没听到的就不写。
|
||||||
|
/// - 数值、单位、药名、剂量、时间一字不改(140/90 永远是 140/90)。
|
||||||
|
enum ConsultationPrompts {
|
||||||
|
|
||||||
|
/// 转写稿截断上限(字符)。2B context 保护:超长问诊只取前段整理。
|
||||||
|
static let organizeTranscriptLimit = 2000
|
||||||
|
|
||||||
|
static func organize(transcript: String) -> String {
|
||||||
|
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
|
||||||
|
return """
|
||||||
|
你是健康记录助手。下面是用户在医院/诊所就诊时的录音转写原话,通常包含本人和医生两个人的对话,
|
||||||
|
可能口语化、有重复、缺标点、说话人没标注。请把它整理成一份清晰的【问诊小结】,方便本人日后回看。
|
||||||
|
|
||||||
|
硬性规则:
|
||||||
|
- 这只是**转写整理**:只复述录音里【确实说过】的内容,【绝对不许】自己新增诊断、用药、剂量、频次或「建议就医」。
|
||||||
|
- 输出语言必须与【录音转写原话】一致:原话是中文就用中文,是英文就用英文,对应语言不翻译(小节名可保留中文标签)。
|
||||||
|
- 数值、单位、药名、剂量、时间【一字不改】——原话说 140/90 就写 140/90,说一天两次就写一天两次。
|
||||||
|
- 录音里没提到的小节就【整节省略】,不要写「无」「未提及」之类占位,也不要硬凑。
|
||||||
|
- 区分说话人:本人的主诉用第一人称「我」;医生说的话写成「医生:…」。
|
||||||
|
- 不确定/听不清的地方保留原样,不要脑补。
|
||||||
|
|
||||||
|
按下面这些小节整理(只保留录音里出现过的,用「小节名:内容」分行):
|
||||||
|
主诉 —— 本人这次来看什么、哪里不舒服
|
||||||
|
现病史 —— 起病时间、过程、变化
|
||||||
|
医生判断 —— 医生当场说的判断或解释(原话复述,不替医生下结论)
|
||||||
|
医生建议 —— 医生给的检查、复查、生活建议
|
||||||
|
用药 —— 医生提到的药名/用法(原样复述,不补剂量)
|
||||||
|
复查与注意 —— 下次复查时间、注意事项
|
||||||
|
|
||||||
|
只输出整理后的小结正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
|
||||||
|
|
||||||
|
示例(转写:就那个我最近老是胸口闷上楼梯就喘大概有半个月了医生说听着心率有点快先做个心电图和验血下周三来复查这两天别太累):
|
||||||
|
主诉:最近胸口闷,上楼梯就喘。
|
||||||
|
现病史:已持续约半个月。
|
||||||
|
医生判断:医生:听着心率有点快。
|
||||||
|
医生建议:先做心电图和验血。
|
||||||
|
复查与注意:下周三复查,这两天别太累。
|
||||||
|
|
||||||
|
【录音转写原话】:
|
||||||
|
\(trimmed)
|
||||||
|
|
||||||
|
Output: /no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,31 @@ enum DiaryAssistPrompts {
|
|||||||
|
|
||||||
/// 固定问诊维度清单。模型每条问题必须正好归属其中一个;UI 累积已覆盖维度回传下一轮。
|
/// 固定问诊维度清单。模型每条问题必须正好归属其中一个;UI 累积已覆盖维度回传下一轮。
|
||||||
/// 顺序即展示/示例顺序,改动需同步 few-shot。
|
/// 顺序即展示/示例顺序,改动需同步 few-shot。
|
||||||
|
/// 注意:这是**受控词表 + 去重键**,模型固定输出中文维度名(prompt 已约束),
|
||||||
|
/// 内部匹配/去重都按中文原名进行,只在**展示**时经 `localizedDimension` 翻成界面语言。
|
||||||
static let dimensions: [String] = [
|
static let dimensions: [String] = [
|
||||||
"起病诱因", "症状性质", "伴随症状", "加重缓解",
|
"起病诱因", "症状性质", "伴随症状", "加重缓解",
|
||||||
"持续频率", "既往家族史", "用药过敏", "生活方式",
|
"持续频率", "既往家族史", "用药过敏", "生活方式",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// 把中文维度名翻成界面语言,仅用于 UI 展示(如追问输入框的占位提示)。
|
||||||
|
/// 走 `String(appLoc:)` 查表:这些维度名已作为 key 收进 Localizable.xcstrings。
|
||||||
|
/// 词表外的自定义 dim(模型偶发)原样返回,不至于丢字。
|
||||||
|
nonisolated static func localizedDimension(_ dim: String) -> String {
|
||||||
|
let d = dim.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
switch d {
|
||||||
|
case "起病诱因": return String(appLoc: "起病诱因")
|
||||||
|
case "症状性质": return String(appLoc: "症状性质")
|
||||||
|
case "伴随症状": return String(appLoc: "伴随症状")
|
||||||
|
case "加重缓解": return String(appLoc: "加重缓解")
|
||||||
|
case "持续频率": return String(appLoc: "持续频率")
|
||||||
|
case "既往家族史": return String(appLoc: "既往家族史")
|
||||||
|
case "用药过敏": return String(appLoc: "用药过敏")
|
||||||
|
case "生活方式": return String(appLoc: "生活方式")
|
||||||
|
default: return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// - content: 用户当前全文。
|
/// - content: 用户当前全文。
|
||||||
/// - coveredDimensions: 之前各轮已经问过(或记录里已写明)的维度名,本轮必须避开。
|
/// - coveredDimensions: 之前各轮已经问过(或记录里已写明)的维度名,本轮必须避开。
|
||||||
/// 第一轮传空数组。
|
/// 第一轮传空数组。
|
||||||
@@ -72,7 +92,8 @@ enum DiaryAssistPrompts {
|
|||||||
本轮可选维度:\(allowedLine)
|
本轮可选维度:\(allowedLine)
|
||||||
【最新记录】:
|
【最新记录】:
|
||||||
\(content)
|
\(content)
|
||||||
|
\(aiOutputLanguageDirective())
|
||||||
|
(注意:dim 维度名仍固定用上面清单里的中文原名;只有 q 和 fill 面向用户的文字用上述语言。)
|
||||||
Output: /no_think
|
Output: /no_think
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
@@ -94,6 +115,7 @@ enum DiaryAssistPrompts {
|
|||||||
|
|
||||||
硬性规则:
|
硬性规则:
|
||||||
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
||||||
|
- 输出语言必须与【口述原话】完全一致:原话是中文就用中文,是英文就用英文,是日文/韩文就用对应语言,不要翻译。
|
||||||
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
|
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
|
||||||
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
||||||
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
||||||
|
|||||||
143
康康/AI/Prompts/DiaryChatPrompts.swift
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 「聊着记」用到的三个 LLM prompt:全屏动画小形象「康康」和用户语音聊天,
|
||||||
|
/// 基于已有健康记录做有记忆的追问,聊完蒸馏成一条健康日记。
|
||||||
|
///
|
||||||
|
/// 人设铁律(贯穿三个 prompt):康康是「陪伴记录的小伙伴」不是医生——
|
||||||
|
/// 不诊断、不荐药、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
|
||||||
|
///
|
||||||
|
/// 数值红线沿用 `DiaryAssistPrompts.organize`:蒸馏时严禁增删改任何数值/单位/药名/时间;
|
||||||
|
/// 语言规则对齐 §「不用「患者」称呼」与 aiOutputLanguageDirective(App/Localization.swift)。
|
||||||
|
///
|
||||||
|
/// nonisolated:纯字符串拼装,无共享状态;`DiaryChatService` 的 nonisolated 纯函数
|
||||||
|
/// (roundsUsed/isWrappedUp)要引用 `maxRounds`,不标会被默认 MainActor 隔离挡住。
|
||||||
|
nonisolated enum DiaryChatPrompts {
|
||||||
|
|
||||||
|
/// 收尾前允许的追问总轮数(开场那句提问不计入)。
|
||||||
|
static let maxRounds = 4
|
||||||
|
|
||||||
|
/// 对话稿截断上限(字符),2B/E2B 端侧模型 context 保护。
|
||||||
|
static let distillTranscriptLimit = 2000
|
||||||
|
|
||||||
|
/// AI 全挂时的确定性开场白(View 兜底用,不经 LLM)。
|
||||||
|
static var staticOpening: String { String(appLoc: "今天身体感觉怎么样?想到什么就说什么,我帮你记着。") }
|
||||||
|
|
||||||
|
// MARK: - 开场白
|
||||||
|
|
||||||
|
/// 用户刚进「聊着记」,康康说的第一句话:自然打招呼 + 基于最近一条记录关心地追问 1 个问题。
|
||||||
|
static func opening(dataJSON: String) -> String {
|
||||||
|
"""
|
||||||
|
你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。
|
||||||
|
用户刚打开「聊着记」,你要说第一句话:先自然地打个招呼,再基于下面【健康记录】里最近一条记录,像老朋友一样关心地问 1 个问题。
|
||||||
|
|
||||||
|
铁律:
|
||||||
|
- 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造。
|
||||||
|
- 数值、日期、药名必须与记录完全一致,一个字都不能改。
|
||||||
|
- 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
|
||||||
|
- 最多 2 句话,只问 1 个问题;不用列表、不用 Markdown、不用表情符号。
|
||||||
|
- 记录里 diaries / indicators 全为空时,不提任何具体记录,只简单问今天感觉怎么样。
|
||||||
|
|
||||||
|
示例 1(记录里有「2026-07-11 日记:头疼了一下午」):
|
||||||
|
前天你记过头疼了一下午,现在好些了吗?
|
||||||
|
|
||||||
|
示例 2(记录里有血压 145/92 偏高):
|
||||||
|
昨天量的血压 145/92 有点偏高,今天有没有再量一次?
|
||||||
|
|
||||||
|
示例 3(记录为空):
|
||||||
|
嗨,今天身体感觉怎么样?想到什么就跟我说说,我帮你记下来。
|
||||||
|
|
||||||
|
【健康记录】:
|
||||||
|
\(dataJSON)
|
||||||
|
|
||||||
|
直接输出这句话,不要引号、不要解释、不要 <think> 标签。
|
||||||
|
\(aiOutputLanguageDirective())
|
||||||
|
/no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 追问回复
|
||||||
|
|
||||||
|
/// 用户每说一句,康康的回复:先接住,再按剩余轮次决定是否追问。
|
||||||
|
/// - roundsLeft: 收尾前还剩几轮追问机会;> 0 可再追问,== 0 必须温和收尾。
|
||||||
|
static func reply(transcript: String, latest: String, dataJSON: String, roundsLeft: Int) -> String {
|
||||||
|
// 轮次分支在 Swift 里拼,不让模型自己算:两段互斥,收尾分支绝不出现「追问」字样。
|
||||||
|
let roundRule: String = roundsLeft > 0
|
||||||
|
? "本轮还可以追问,最多问 1 个新问题。追问只围绕「把这条记录补完整」:时间、部位、程度、吃没吃药、睡眠饮食等;可以结合【健康记录】做有记忆的追问(如「上次你记过 XX,这次也一样吗」)。"
|
||||||
|
: "【不要再提任何问题】。用 1-2 句话温和收尾:先接住用户刚说的话,再告诉 TA 今天先聊到这儿,点下面的「聊完了」,我帮你整理成一条记录。"
|
||||||
|
return """
|
||||||
|
你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。
|
||||||
|
你正在「聊着记」里陪用户聊天。任务:先用一小句接住用户刚说的话(共情或确认),再按【本轮规则】决定要不要追问。
|
||||||
|
|
||||||
|
铁律:
|
||||||
|
- 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造;数值、日期、药名必须与记录完全一致。
|
||||||
|
- 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
|
||||||
|
- 总共最多 2 句话;不用列表、不用 Markdown、不用表情符号。
|
||||||
|
|
||||||
|
【本轮规则】:
|
||||||
|
\(roundRule)
|
||||||
|
|
||||||
|
示例(还能追问,用户说「下午头就不疼了,不过晚上只睡了五个小时」):
|
||||||
|
不疼了就好。昨晚只睡五个小时,是入睡难还是半夜醒了?
|
||||||
|
|
||||||
|
示例(收尾):
|
||||||
|
好,记下啦。今天先聊到这儿,点下面的「聊完了」,我帮你把这些整理成一条记录。
|
||||||
|
|
||||||
|
【健康记录】:
|
||||||
|
\(dataJSON)
|
||||||
|
|
||||||
|
【对话】:
|
||||||
|
\(transcript.isEmpty ? "无" : transcript)
|
||||||
|
|
||||||
|
【用户刚说的】:
|
||||||
|
\(latest)
|
||||||
|
|
||||||
|
直接输出你要说的话,不要引号、不要解释、不要 <think> 标签。\(aiOutputLanguageDirective())
|
||||||
|
/no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 蒸馏成日记
|
||||||
|
|
||||||
|
/// 聊完后把整段对话蒸馏成一条健康日记草稿。结构与红线对齐 `DiaryAssistPrompts.organize`:
|
||||||
|
/// 只搬运不改写、严禁增删改数值,且**不拼** aiOutputLanguageDirective——语言跟「我:」原话走。
|
||||||
|
static func distill(transcript: String) -> String {
|
||||||
|
let trimmed = String(transcript.prefix(distillTranscriptLimit))
|
||||||
|
return """
|
||||||
|
你是「康康」,用户健康 App 里陪伴记录的小伙伴。下面是你和用户在「聊着记」里的对话稿。
|
||||||
|
请把用户说的话蒸馏成一条健康日记草稿。
|
||||||
|
|
||||||
|
硬性规则:
|
||||||
|
- 【只取「我:」说的内容作为事实】——「康康:」的话只是提问和陪伴,绝不能把康康提到的记录、猜测或问题当成用户今天发生的事写进日记。
|
||||||
|
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
||||||
|
- 输出语言必须与「我:」的原话完全一致:原话是中文就用中文,是英文就用英文,是日文/韩文就用对应语言,不要翻译。
|
||||||
|
- 用第一人称;去掉口头语和重复;不加入对话里没有的事实。
|
||||||
|
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
||||||
|
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
||||||
|
- 不诊断、不给用药建议、不写「建议就医」。
|
||||||
|
- 只输出日记正文,不要开头语、不要解释、不要 markdown 围栏、不要 <think> 标签。
|
||||||
|
|
||||||
|
示例 1(单方面 → 成段):
|
||||||
|
我: 今天头有点晕早上量了血压140 90
|
||||||
|
康康: 记下啦。上次你记过没吃早饭,今天吃了吗?
|
||||||
|
我: 吃了吃了 喝了粥
|
||||||
|
输出:
|
||||||
|
今天头有点晕,早上量了血压 140/90。早饭吃了,喝了粥。
|
||||||
|
|
||||||
|
示例 2(多方面 → 分行;注意康康的话不进日记):
|
||||||
|
我: 今天头晕了一上午下午好点了血压早上量的140 90缬沙坦吃了
|
||||||
|
康康: 记下啦。上次你血压也偏高,是不是又没睡好?昨晚睡了几个小时?
|
||||||
|
我: 就睡了五个小时中午吃的清淡
|
||||||
|
输出:
|
||||||
|
症状:头晕了一上午,下午好转。
|
||||||
|
血压:早上 140/90。
|
||||||
|
用药:缬沙坦已服。
|
||||||
|
睡眠:只睡了五个小时。
|
||||||
|
饮食:午餐清淡。
|
||||||
|
|
||||||
|
【对话记录】:
|
||||||
|
\(trimmed)
|
||||||
|
|
||||||
|
Output: /no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,10 +71,11 @@ enum HealthExportPrompts {
|
|||||||
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
|
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
|
||||||
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
|
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
|
||||||
- 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
|
- 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
|
||||||
|
- diaries 里 kind 为「问诊」的是既往看医生的问诊记录,可据此补充「主诉」「本人背景」,但同样只搬运、不编造。
|
||||||
|
|
||||||
输出格式:
|
输出格式:
|
||||||
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
|
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
|
||||||
- 不给诊断意见、用药建议或「建议就医」。全文中文,简洁,医生 30 秒能扫完。
|
- 不给诊断意见、用药建议或「建议就医」。简洁,医生 30 秒能扫完(输出语言见文末说明)。
|
||||||
- 严格按以下 6 段(顺序与标题固定):
|
- 严格按以下 6 段(顺序与标题固定):
|
||||||
\(labelLine)
|
\(labelLine)
|
||||||
## 主诉
|
## 主诉
|
||||||
@@ -110,7 +111,7 @@ enum HealthExportPrompts {
|
|||||||
【本人原话】:\(userPrompt)
|
【本人原话】:\(userPrompt)
|
||||||
|
|
||||||
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
|
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
|
||||||
直接输出 Markdown,不要思考过程,不要 <think> 标签:
|
直接输出 Markdown,不要思考过程,不要 <think> 标签:\(aiOutputLanguageDirective())
|
||||||
/no_think
|
/no_think
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
@@ -141,7 +142,7 @@ enum HealthExportPrompts {
|
|||||||
【用户最新问题】:
|
【用户最新问题】:
|
||||||
\(latestQuestion)
|
\(latestQuestion)
|
||||||
|
|
||||||
直接输出中文回答,不要 Markdown 标题,不要 <think>:
|
直接输出回答,不要 Markdown 标题,不要 <think>:\(aiOutputLanguageDirective())
|
||||||
/no_think
|
/no_think
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
@@ -156,13 +157,16 @@ enum HealthExportPrompts {
|
|||||||
铁律:
|
铁律:
|
||||||
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
|
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
|
||||||
- 禁止编造数字、日期、症状、药物、检查结果、诊断。
|
- 禁止编造数字、日期、症状、药物、检查结果、诊断。
|
||||||
|
- 日期一律照搬【本地健康记录】JSON 里的完整 `date` 字段(格式 yyyy-MM-dd,即年-月-日);严禁只写年份或省略月、日。多轮对话里若把日期说得不全,一律以 JSON 的完整日期为准。
|
||||||
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。
|
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。
|
||||||
- JSON 里没有的信息,对应小节写「无记录」。
|
- JSON 里没有的信息,对应小节写「无记录」。
|
||||||
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
|
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
|
||||||
|
|
||||||
输出要求:
|
输出要求:
|
||||||
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
|
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
|
||||||
- 中文,简洁,医生 30 秒能扫完。
|
- 简洁,医生 30 秒能扫完(输出语言见文末说明)。
|
||||||
|
- 「相关健康日记」每条单独一行,格式为「2026-05-01:正文摘要」,日期照抄 JSON 的 date 字段,精确到日。
|
||||||
|
- diaries 里 kind 为「问诊」的是既往问诊记录,优先列入「相关健康日记」并标注「(问诊)」。
|
||||||
- 严格按以下段落:
|
- 严格按以下段落:
|
||||||
# 就诊摘要
|
# 就诊摘要
|
||||||
## 本次想解决的问题
|
## 本次想解决的问题
|
||||||
@@ -178,7 +182,7 @@ enum HealthExportPrompts {
|
|||||||
【多轮对话】:
|
【多轮对话】:
|
||||||
\(transcript.isEmpty ? "无" : transcript)
|
\(transcript.isEmpty ? "无" : transcript)
|
||||||
|
|
||||||
直接输出 Markdown,不要思考过程,不要 <think>:
|
直接输出 Markdown,不要思考过程,不要 <think>:\(aiOutputLanguageDirective())
|
||||||
/no_think
|
/no_think
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ nonisolated enum InsightPrompts {
|
|||||||
现在的报告:\(title)(\(typeLabel))
|
现在的报告:\(title)(\(typeLabel))
|
||||||
指标:
|
指标:
|
||||||
\(indicatorLines)
|
\(indicatorLines)
|
||||||
只输出 2~3 句正文。/no_think
|
只输出 2~3 句正文。\(aiOutputLanguageDirective())/no_think
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ nonisolated enum InsightPrompts {
|
|||||||
|
|
||||||
现在的记录:
|
现在的记录:
|
||||||
\(dataLines)
|
\(dataLines)
|
||||||
只输出 1~2 句正文。/no_think
|
只输出 1~2 句正文。\(aiOutputLanguageDirective())/no_think
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ nonisolated enum IntentPrompts {
|
|||||||
|
|
||||||
分类(只能选下面其中一个):
|
分类(只能选下面其中一个):
|
||||||
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
|
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
|
||||||
- "medication" 记录用药、拍药盒、吃了什么药
|
- "medication" 记一次用药/服药、吃了什么药、拍药盒(凡涉及「吃药/服药/用药」都归这里)
|
||||||
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…)
|
- "symptom" 记录身体症状,哪里不舒服(头疼、咳嗽、发烧、头晕…),与吃药无关
|
||||||
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
|
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
|
||||||
- "archive" 归档整份体检报告/化验单(拍报告存档)
|
- "archive" 归档整份体检报告/化验单(拍报告存档)
|
||||||
- "export" 生成给医生看的身体档案/健康总结
|
- "export" 生成给医生看的身体档案/健康总结
|
||||||
@@ -24,15 +24,30 @@ nonisolated enum IntentPrompts {
|
|||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
|
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
|
||||||
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"。
|
- 凡是「记录/记一次用药、服药、吃药、吃了药」→ "medication",哪怕没说具体药名。
|
||||||
|
- 「记录/记一次」+ 动作时,先看这个动作是什么(吃药→medication、量血压→indicator、
|
||||||
|
哪里疼→symptom),不要因为出现「记录」二字就归类成 symptom。
|
||||||
|
- 明确说出具体身体症状(头疼、咳嗽、发烧、头晕、拉肚子…)才算 "symptom";
|
||||||
|
与吃药/用药无关。只是泛泛说今天的状态、心情、饮食、睡眠、累不累、舒不舒服 → "diary"。
|
||||||
- 既像日记又提到具体数值时,以数值为准 → "indicator"。
|
- 既像日记又提到具体数值时,以数值为准 → "indicator"。
|
||||||
|
- 含否定或「忘了/没顾上」的吃药(「没吃药」「忘了吃药」「不用吃药」)不是记录用药 → "diary"。
|
||||||
|
- 只有明确要「拍下/存档这份报告或化验单」时才算 "archive";只是顺口提到体检或报告
|
||||||
|
(「下周去体检」「医生说报告没问题」)不要归 archive,按日记或提醒处理。
|
||||||
|
- 拿不准、又不明确属于其它类别时,默认 "diary"(日记是最常见、最自由的入口)。
|
||||||
|
尤其 "medication" 和 "archive" 会直接打开相机,把握不大时宁可归 "diary",不要误开相机。
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
"帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"}
|
"帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"}
|
||||||
"我今天有点头疼,想记录一下" → {"intent":"symptom"}
|
"我今天有点头疼,想记录一下" → {"intent":"symptom"}
|
||||||
|
"我要记录一次用药" → {"intent":"medication"}
|
||||||
|
"记一下今天吃的药" → {"intent":"medication"}
|
||||||
|
"今天有点累,感觉不太舒服" → {"intent":"diary"}
|
||||||
"刚买了一盒降压药,拍一下存进去" → {"intent":"medication"}
|
"刚买了一盒降压药,拍一下存进去" → {"intent":"medication"}
|
||||||
"今天睡得不错,写个日记" → {"intent":"diary"}
|
"今天睡得不错,写个日记" → {"intent":"diary"}
|
||||||
"把这份体检报告存档" → {"intent":"archive"}
|
"把这份体检报告存档" → {"intent":"archive"}
|
||||||
|
"今天太忙,忘了吃药" → {"intent":"diary"}
|
||||||
|
"下周打算去做个体检" → {"intent":"diary"}
|
||||||
|
"医生说我报告没什么大问题" → {"intent":"diary"}
|
||||||
"每天早上八点提醒我量血压" → {"intent":"reminder"}
|
"每天早上八点提醒我量血压" → {"intent":"reminder"}
|
||||||
"整理一份给医生看的健康总结" → {"intent":"export"}
|
"整理一份给医生看的健康总结" → {"intent":"export"}
|
||||||
|
|
||||||
|
|||||||
@@ -7,42 +7,55 @@ import Foundation
|
|||||||
nonisolated enum MedicationPrompts {
|
nonisolated enum MedicationPrompts {
|
||||||
|
|
||||||
static func medicationsFromText(_ ocrText: String) -> String {
|
static func medicationsFromText(_ ocrText: String) -> String {
|
||||||
|
// ≤5 张合并 OCR,放宽到 2400(单张 1200 偏小,易把背面用法截掉)。
|
||||||
medicationsFromTextTemplate
|
medicationsFromTextTemplate
|
||||||
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 1200))
|
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 2400))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let medicationsFromTextTemplate: String = #"""
|
private static let medicationsFromTextTemplate: String = #"""
|
||||||
你是药品包装识别助手。下面是对一张药盒、药品说明书或处方单做 OCR 得到的纯文本,可能有错字、换行混乱或无关噪声。
|
你是药品包装识别助手。下面是对一种药品的多张照片(药盒正面/背面/说明书/处方单)做 OCR 得到的纯文本,各张之间用「---」分隔,可能有错字、换行混乱或无关噪声。
|
||||||
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
JSON schema(严格):
|
JSON schema(严格):
|
||||||
{
|
{
|
||||||
"medications": [
|
"medications": [
|
||||||
{
|
{
|
||||||
"name": string, // 药品通用名或商品名,如 "缬沙坦胶囊"
|
"name": string, // 药品名,见下方「name 怎么填」
|
||||||
"strength": string, // 规格,如 "80mg"、"0.5g×24片";识别不出填 ""
|
"strength": string, // 规格,如 "80mg"、"0.5g×24片";识别不出填 ""
|
||||||
"usage": string // 用法用量,如 "每日一次,一次一粒";包装上没有就填 ""
|
"usage": string // 用法用量,如 "每日一次,一次一粒";包装上没有就填 ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
规则:
|
name 怎么填(关键,别搞混):
|
||||||
- 只提取药品本身;"国药准字"批准文号、生产厂家、批号、有效期、条形码一律忽略。
|
- 药品名 = 通用名(化学/药典名),这是要填进 name 的主体。中文药名照中文写,英文药名(如 "Metformin"、"Amoxicillin")就照英文原样抄,不要翻译、不要丢。
|
||||||
- 一张药盒通常只有 1 种药;处方单可能有多种,都要提取。
|
- 若包装上同时印有商品名/商标名(厂商起的牌子名,如 "代文""泰诺""Tylenol"),把它放在通用名后的括号里,例如 "缬沙坦胶囊(代文)"。只读到商品名、读不到通用名时,就直接用商品名当 name。
|
||||||
|
- 生产厂家/公司名/品牌 LOGO 文字(如 "XX药业有限公司""诺华""拜耳")不是药名,一律不要当 name,也不要塞进括号。
|
||||||
|
|
||||||
|
通用规则:
|
||||||
|
- 只提取药品本身;"国药准字"批准文号、生产厂家、批号、有效期、条形码、贮藏、二维码一律忽略。
|
||||||
|
- 多张照片通常是同一种药的不同面,合并成一条,不要因为来自不同照片就重复输出;处方单可能有多种药,才分多条。
|
||||||
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
|
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
|
||||||
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
|
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
|
||||||
- 同一药品只输出一次。
|
- 同一药品只输出一次。
|
||||||
|
|
||||||
示例 1(药盒):
|
示例 1(药盒,含商品名 + 厂商):
|
||||||
输入 OCR 文本: 缬沙坦胶囊 80mg×7粒 国药准字H20103521 XX药业有限公司
|
输入 OCR 文本: 代文 缬沙坦胶囊 80mg×7粒 国药准字H20103521 北京诺华制药有限公司
|
||||||
输出:
|
输出:
|
||||||
{"medications":[{"name":"缬沙坦胶囊","strength":"80mg×7粒","usage":""}]}
|
{"medications":[{"name":"缬沙坦胶囊(代文)","strength":"80mg×7粒","usage":""}]}
|
||||||
|
|
||||||
示例 2(说明书含用法):
|
示例 2(说明书含用法):
|
||||||
输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用
|
输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用
|
||||||
输出:
|
输出:
|
||||||
{"medications":[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次,随餐服用"}]}
|
{"medications":[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次,随餐服用"}]}
|
||||||
|
|
||||||
|
示例 3(英文药名,正反两张合并):
|
||||||
|
输入 OCR 文本: Amoxicillin Capsules 500mg GSK
|
||||||
|
---
|
||||||
|
Dosage: Take one capsule three times daily
|
||||||
|
输出:
|
||||||
|
{"medications":[{"name":"Amoxicillin","strength":"500mg","usage":"Take one capsule three times daily"}]}
|
||||||
|
|
||||||
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
|
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
|
||||||
|
|
||||||
OCR 文本:
|
OCR 文本:
|
||||||
|
|||||||
@@ -112,6 +112,113 @@ JSON schema(严格):
|
|||||||
{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]}
|
{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]}
|
||||||
{{OCR_SECTION}}
|
{{OCR_SECTION}}
|
||||||
现在请识别图片并输出 JSON:
|
现在请识别图片并输出 JSON:
|
||||||
|
"""#
|
||||||
|
|
||||||
|
// MARK: - 报告整份解读(OCR 文本 → 完整 ParsedReport,纯文本 LLM)
|
||||||
|
|
||||||
|
/// C2「重新解读」/ 拍照整份识别走这条:主模型 Gemma 4 E2B 无视觉权重(e2b 检查点仅文本,
|
||||||
|
/// 视觉在 gemma4_unified 12B),故先 Vision OCR 出纯文本,再交文本 LLM 一次抽出报告级 meta + 全部指标。
|
||||||
|
/// 与 reportExtraction(多模态)同 schema,但去掉 source_page / source_box —— OCR 文本没有版面坐标,
|
||||||
|
/// 让模型编框会污染原图证据高亮,统一不输出。
|
||||||
|
static func reportExtractionFromText(_ ocrText: String, today: Date = .now) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
let todayStr = f.string(from: today)
|
||||||
|
return reportExtractionFromTextTemplate
|
||||||
|
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||||
|
.replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 2200))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let reportExtractionFromTextTemplate: String = #"""
|
||||||
|
你是医学体检/化验报告识别助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声或换行混乱。
|
||||||
|
请从中提取报告的元信息与所有指标,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"title": string,
|
||||||
|
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
|
||||||
|
"report_date": "YYYY-MM-DD",
|
||||||
|
"institution": string,
|
||||||
|
"page_count": number,
|
||||||
|
"summary": string,
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"value": string,
|
||||||
|
"unit": string,
|
||||||
|
"range": string,
|
||||||
|
"status": "high" | "low" | "normal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- status 优先看箭头/标记(↑/H/偏高 → "high",↓/L/偏低 → "low");没有标记时用 value 与 range 比较;都没有 → "normal"。
|
||||||
|
- range 保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5");OCR 把破折号写成 "--" / "~" 都归一成 " - ";没有参考范围就填 ""。
|
||||||
|
- 凡是「指标名 + 明确数值」可读的都要提取——**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样提取。只有数值本身是乱码无法判断才跳过,绝不发明指标,同一指标只输出一次。
|
||||||
|
- title / institution / summary 读不出就填 "";report_date 挑「报告/检查/采样日期」其一统一成 YYYY-MM-DD,只有年月就补 -01,实在读不出填上面给出的「今天」({{TODAY}})。
|
||||||
|
- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"。
|
||||||
|
- summary 用一句话概括整体(如「血脂偏高,其余正常」);页眉、医生签名、栏目标题、OCR 噪声一律忽略。
|
||||||
|
|
||||||
|
示例 OCR 文本:
|
||||||
|
协和医院体检中心 健康体检报告 体检日期:2026-04-12 低密度脂蛋白 3.84 mmol/L <3.40 ↑ 空腹血糖 5.2 mmol/L 3.9-6.1
|
||||||
|
对应输出:
|
||||||
|
{"title":"健康体检报告","type":"checkup","report_date":"2026-04-12","institution":"协和医院体检中心","page_count":1,"summary":"血脂偏高,血糖正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
|
||||||
|
|
||||||
|
现在请解析下面这段 OCR 文本,只输出 JSON。
|
||||||
|
|
||||||
|
OCR 文本:
|
||||||
|
{{OCR_TEXT}}
|
||||||
|
"""#
|
||||||
|
|
||||||
|
// MARK: - 报告归档 · 轻量 meta(只抽日期/机构/类型/标题,不识别指标)
|
||||||
|
|
||||||
|
/// 报告归档新链路:只保存原图,**不逐项识别指标**(逐项多模态识别在 2B 上易 OOM / 卡死)。
|
||||||
|
/// 用 Vision OCR 出纯文本后,交文本 LLM 只抽报告级 meta —— 输出极小(~50 token),快且稳。
|
||||||
|
/// 识别不到就留空,UI 用占位(今天 / 空机构),用户可手填。
|
||||||
|
static func reportMetaFromText(_ ocrText: String, today: Date = .now) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
let todayStr = f.string(from: today)
|
||||||
|
return reportMetaTemplate
|
||||||
|
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||||
|
.replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 1500))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let reportMetaTemplate: String = #"""
|
||||||
|
你是体检/化验报告归档助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声。
|
||||||
|
请只提取这份报告的「元信息」,**不要提取任何具体指标/数值**。只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"title": string, // 报告抬头,如 "春季年度体检";读不出就填 ""
|
||||||
|
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
|
||||||
|
"report_date": "YYYY-MM-DD", // 报告/采样/体检日期;实在读不出就填 ""
|
||||||
|
"institution": string // 医院/体检机构名;读不出就填 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 只输出上面 4 个字段,绝不输出 indicators / 数值 / 参考范围。
|
||||||
|
- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"。
|
||||||
|
- 日期挑「报告日期 / 检查日期 / 采样日期」其一,统一成 YYYY-MM-DD;只有年月就补 -01;读不出填 ""。
|
||||||
|
- institution 取医院/体检中心全称,去掉「检验科/报告单」等栏目词;读不出填 ""。
|
||||||
|
- 不要编造;读不出的字段填 ""。
|
||||||
|
|
||||||
|
示例 OCR 文本:
|
||||||
|
协和医院体检中心 健康体检报告 姓名:张三 体检日期:2026-04-12 低密度脂蛋白 3.84 ↑ ...
|
||||||
|
输出:
|
||||||
|
{"title":"健康体检报告","type":"checkup","report_date":"2026-04-12","institution":"协和医院体检中心"}
|
||||||
|
|
||||||
|
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
|
||||||
|
|
||||||
|
OCR 文本:
|
||||||
|
{{OCR_TEXT}}
|
||||||
"""#
|
"""#
|
||||||
|
|
||||||
// MARK: - 局部小框识别(指标速记)
|
// MARK: - 局部小框识别(指标速记)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import Foundation
|
|||||||
import MLX
|
import MLX
|
||||||
import MLXVLM
|
import MLXVLM
|
||||||
import MLXLMCommon
|
import MLXLMCommon
|
||||||
|
// 3.x HF Hub/分词器栈拆成 opt-in 宏(见 LLMSession 注释)。
|
||||||
|
import MLXHuggingFace
|
||||||
|
import HuggingFace
|
||||||
|
import Tokenizers
|
||||||
|
|
||||||
/// 封装 MLX VL 模型(Qwen3-VL)的图像 → 文本推理。
|
/// 封装 MLX VL 模型(Qwen3-VL)的图像 → 文本推理。
|
||||||
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
||||||
@@ -25,8 +29,11 @@ actor VLSession {
|
|||||||
/// 从本地目录加载 VL 模型(包含 config.json + weights + tokenizer + processor)。
|
/// 从本地目录加载 VL 模型(包含 config.json + weights + tokenizer + processor)。
|
||||||
static func load(folderURL: URL) async throws -> VLSession {
|
static func load(folderURL: URL) async throws -> VLSession {
|
||||||
let configuration = ModelConfiguration(directory: folderURL)
|
let configuration = ModelConfiguration(directory: folderURL)
|
||||||
|
// 3.31.4:同 LLMSession,显式传 Downloader(占位)+ HF 分词器加载器。
|
||||||
let container = try await withDeviceOverride {
|
let container = try await withDeviceOverride {
|
||||||
try await VLMModelFactory.shared.loadContainer(
|
try await VLMModelFactory.shared.loadContainer(
|
||||||
|
from: #hubDownloader(),
|
||||||
|
using: #huggingFaceTokenizerLoader(),
|
||||||
configuration: configuration
|
configuration: configuration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ struct KangkangApp: App {
|
|||||||
CustomMonitorMetric.self,
|
CustomMonitorMetric.self,
|
||||||
HealthExport.self,
|
HealthExport.self,
|
||||||
CustomReminder.self,
|
CustomReminder.self,
|
||||||
|
Medication.self,
|
||||||
])
|
])
|
||||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
// 建库后给 store 文件补 .completeUnlessOpen 保护(§6),两条创建路径共用。
|
// 建库后给 store 文件补 .completeUnlessOpen 保护(§6),两条创建路径共用。
|
||||||
|
|||||||
@@ -118,6 +118,48 @@ extension String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - AI 输出语言(供 Service/Prompt 层在非 MainActor 上下文读取当前界面语言)
|
||||||
|
|
||||||
|
/// 当前界面语言解析出的「输出语言」,给本地 LLM 的 prompt 用:
|
||||||
|
/// 让 AI 生成的追问 / 解读 / 摘要跟随用户所选界面语言,而不是永远中文。
|
||||||
|
/// 判定只看 `appLocLocale`(LanguageManager 切换时刷新的 nonisolated 快照);
|
||||||
|
/// `system` 跟随系统时,按系统首选语言归一到最接近的受支持语言,兜底中文。
|
||||||
|
nonisolated enum AIOutputLanguage: String {
|
||||||
|
case zhHans, en, ja, ko
|
||||||
|
|
||||||
|
/// 从当前 appLocLocale 解析。system(autoupdatingCurrent)时读系统语言码。
|
||||||
|
static var current: AIOutputLanguage {
|
||||||
|
let code = appLocLocale.language.languageCode?.identifier ?? "zh"
|
||||||
|
switch code {
|
||||||
|
case "en": return .en
|
||||||
|
case "ja": return .ja
|
||||||
|
case "ko": return .ko
|
||||||
|
default: return .zhHans // zh 及其它未支持语言统一回中文(few-shot 也是中文)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// prompt 里对模型自称的目标语言名(用该语言自己的说法,减少模型误判)。
|
||||||
|
var promptName: String {
|
||||||
|
switch self {
|
||||||
|
case .zhHans: return "简体中文"
|
||||||
|
case .en: return "English"
|
||||||
|
case .ja: return "日本語"
|
||||||
|
case .ko: return "한국어"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 追加到 prompt 末尾的「用某语言输出」硬指令。中文时返回空串(few-shot 已是中文,不必额外声明)。
|
||||||
|
/// 用于「生成给用户看的内容」(追问 / 解读 / 摘要),让输出语言=界面语言。
|
||||||
|
var outputDirective: String {
|
||||||
|
self == .zhHans ? "" : "\n\n重要:请全程用\(promptName)输出所有面向用户的文字(JSON 的字段值也用\(promptName)),不要用中文。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 便捷:当前界面语言的「用某语言输出」指令(见 `AIOutputLanguage.outputDirective`)。
|
||||||
|
nonisolated func aiOutputLanguageDirective() -> String {
|
||||||
|
AIOutputLanguage.current.outputDirective
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Bundle 运行时重定向(供 Text / NSLocalizedString 双保险)
|
// MARK: - Bundle 运行时重定向(供 Text / NSLocalizedString 双保险)
|
||||||
|
|
||||||
/// 关联对象 key(需稳定地址,文件级全局即可)。
|
/// 关联对象 key(需稳定地址,文件级全局即可)。
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import SwiftUI
|
|||||||
/// 任何展示 AI 生成的归纳/解读/建议文本的地方,都应在结果末尾附上 `AIDisclaimerFooter`;
|
/// 任何展示 AI 生成的归纳/解读/建议文本的地方,都应在结果末尾附上 `AIDisclaimerFooter`;
|
||||||
/// 离开 App 的文本(复制/分享)用 `AIDisclaimer.appended(to:)` 把声明带上。
|
/// 离开 App 的文本(复制/分享)用 `AIDisclaimer.appended(to:)` 把声明带上。
|
||||||
enum AIDisclaimer {
|
enum AIDisclaimer {
|
||||||
/// 面向用户展示的完整声明文本。
|
/// 面向用户展示的完整声明文本(随「我的 · 语言」本地化)。
|
||||||
static let text =
|
/// 注意:必须是**单个** `String(appLoc:)` key(勿再用字符串拼接),否则无法整句进 String Catalog 翻译;
|
||||||
"本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考," +
|
/// 且展示处用 `Text(AIDisclaimer.text)`(传 String 变量),SwiftUI 不会再做本地化 —— 这里先本地化好。
|
||||||
"不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。"
|
static var text: String {
|
||||||
|
String(appLoc: "本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考,不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。")
|
||||||
|
}
|
||||||
|
|
||||||
/// 复制/分享时把声明追加到正文末尾(分隔线 + 声明),让文本离开 App 也带着免责。
|
/// 复制/分享时把声明追加到正文末尾(分隔线 + 声明),让文本离开 App 也带着免责。
|
||||||
static func appended(to body: String) -> String {
|
static func appended(to body: String) -> String {
|
||||||
|
|||||||
@@ -1,42 +1,49 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Apple Intelligence 式多彩流光线:蓝→紫→粉→橙→青,横向无缝循环流动。
|
/// Apple Intelligence 式多彩流光线:蓝→紫→粉→橙→青,横向无缝循环流动。
|
||||||
/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待)。
|
/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待等)。
|
||||||
///
|
///
|
||||||
/// 注意:这条线的颜色是刻意走出 `Tj.Palette` 单色系统的 AI 高光点缀(应产品要求的
|
/// 注意:这条线的颜色是刻意走出 `Tj.Palette` 单色系统的 AI 高光点缀(应产品要求的
|
||||||
/// Apple 风格),仅此组件如此;其余 UI 仍严格守 §9 单色 token。
|
/// Apple 风格),仅此组件如此;其余 UI 仍严格守 §9 单色 token。
|
||||||
|
///
|
||||||
|
/// 驱动方式用 `TimelineView(.animation)` 而非 `.onAppear` + `repeatForever`:这条线出现的
|
||||||
|
/// 场景(流式回答、tok/s 每 0.5s 刷新等)父视图都在高频重绘,隐式 `repeatForever` 动画会被
|
||||||
|
/// 反复打断/重置 → 看起来「几乎不动」。TimelineView 按显示刷新率直接从时间算偏移,与父视图
|
||||||
|
/// 重绘完全解耦,任何场景下都匀速流动。
|
||||||
struct AIFlowBar: View {
|
struct AIFlowBar: View {
|
||||||
var height: CGFloat = 3
|
var height: CGFloat = 3
|
||||||
/// 流动一整圈的秒数,越小越快。
|
/// 颜色平移一整圈(一个完整色序)的秒数,越小越快。
|
||||||
var cycle: Double = 1.0
|
var cycle: Double = 0.6
|
||||||
|
|
||||||
@State private var phase: CGFloat = 0
|
private static let base: [Color] = [
|
||||||
|
|
||||||
/// 颜色重复一遍:offset 走完一个整段时首尾同色,循环无缝。
|
|
||||||
private static let flow: [Color] = {
|
|
||||||
let base: [Color] = [
|
|
||||||
Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝
|
Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝
|
||||||
Color(red: 0.62, green: 0.36, blue: 0.92), // 紫
|
Color(red: 0.62, green: 0.36, blue: 0.92), // 紫
|
||||||
Color(red: 0.96, green: 0.40, blue: 0.62), // 粉
|
Color(red: 0.96, green: 0.40, blue: 0.62), // 粉
|
||||||
Color(red: 1.00, green: 0.55, blue: 0.30), // 橙
|
Color(red: 1.00, green: 0.55, blue: 0.30), // 橙
|
||||||
Color(red: 0.30, green: 0.80, blue: 0.92), // 青
|
Color(red: 0.30, green: 0.80, blue: 0.92), // 青
|
||||||
]
|
]
|
||||||
return base + base
|
|
||||||
|
/// 色序重复两遍并以首色收尾(共 11 个 stop,均匀分布):一个色周期恰好占据画布宽度,
|
||||||
|
/// 平移一个画布宽度后首尾同色,循环完全无缝。
|
||||||
|
private static let gradient: Gradient = {
|
||||||
|
let colors = base + base + [base[0]]
|
||||||
|
let last = CGFloat(colors.count - 1)
|
||||||
|
return Gradient(stops: colors.enumerated().map { i, c in
|
||||||
|
Gradient.Stop(color: c, location: CGFloat(i) / last)
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
TimelineView(.animation) { timeline in
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let w = geo.size.width
|
let w = geo.size.width
|
||||||
|
let t = timeline.date.timeIntervalSinceReferenceDate
|
||||||
|
let progress = CGFloat(t.truncatingRemainder(dividingBy: cycle) / cycle)
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(LinearGradient(colors: Self.flow,
|
.fill(LinearGradient(gradient: Self.gradient,
|
||||||
startPoint: .leading, endPoint: .trailing))
|
startPoint: .leading, endPoint: .trailing))
|
||||||
.frame(width: w * 2)
|
.frame(width: w * 2)
|
||||||
.offset(x: phase)
|
.offset(x: -w * progress)
|
||||||
.onAppear {
|
|
||||||
phase = 0
|
|
||||||
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
|
|
||||||
phase = -w
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: height)
|
.frame(height: height)
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ enum Tj {
|
|||||||
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518)
|
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518)
|
||||||
static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
|
static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
|
||||||
static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059)
|
static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059)
|
||||||
|
// 数据折线主色:低饱和「桉叶 / 鼠尾草」青绿,沉静、契合健康、在米色背景上有高级感。
|
||||||
|
// 替代原本近黑的 ink 折线;异常点仍用 brick 红点点缀,冷线 + 暖点对比清楚。
|
||||||
|
static let teal = Color(red: 0.337, green: 0.529, blue: 0.494)
|
||||||
|
static let tealSoft = Color(red: 0.808, green: 0.878, blue: 0.863)
|
||||||
|
// 统一柔和阴影色:暖灰褐,低饱和。代替近黑的 ink 阴影,投影更柔、不发脏。
|
||||||
|
static let shadow = Color(red: 0.376, green: 0.345, blue: 0.298)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Radius {
|
enum Radius {
|
||||||
@@ -64,7 +70,7 @@ extension View {
|
|||||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0)
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0)
|
||||||
)
|
)
|
||||||
.shadow(color: bordered ? .clear : Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.05),
|
.shadow(color: bordered ? .clear : Tj.Palette.shadow.opacity(0.06),
|
||||||
radius: 2, x: 0, y: 1)
|
radius: 2, x: 0, y: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
康康/DesignSystem/VaultImage.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 从加密 Vault 异步加载并降采样显示原图的通用组件。
|
||||||
|
///
|
||||||
|
/// 替代「在 body 里 `try? FileVault.shared.loadImage(...)` 同步读盘 + 全量解码」的旧写法,
|
||||||
|
/// 解决两个真实问题:
|
||||||
|
/// 1. **OOM**:全分辨率位图(4000×3000 ≈ 48MB)进内存,翻几页就触发 jetsam。这里按 `maxPixel`
|
||||||
|
/// 降采样,缩略图几百 KB,全屏图几 MB。
|
||||||
|
/// 2. **主线程卡顿**:读盘 + JPEG 解码在主线程会掉帧。这里放到后台线程,主线程只拿结果绘制。
|
||||||
|
///
|
||||||
|
/// 区分「加载中」与「读取失败」两态:加载中显示中性占位,只有真正失败才显示「原图无法读取」,
|
||||||
|
/// 不会一打开就闪一下吓人的错误文案。`content` 拿到 `UIImage`(而非 `Image`),
|
||||||
|
/// 方便需要 `image.size` 的调用方(如证据高亮 overlay)按真实宽高比定位。
|
||||||
|
struct VaultImage<Content: View, Placeholder: View>: View {
|
||||||
|
let relativePath: String
|
||||||
|
/// 降采样目标最大边(像素)。缩略图给 ~400,全屏查看器给 ~2000。
|
||||||
|
var maxPixel: CGFloat = 1024
|
||||||
|
|
||||||
|
@ViewBuilder var content: (UIImage) -> Content
|
||||||
|
/// 占位回调,`isLoading == true` 表示仍在加载,`false` 表示加载完成但失败。
|
||||||
|
@ViewBuilder var placeholder: (_ isLoading: Bool) -> Placeholder
|
||||||
|
|
||||||
|
@State private var image: UIImage?
|
||||||
|
@State private var loading = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let image {
|
||||||
|
content(image)
|
||||||
|
} else {
|
||||||
|
placeholder(loading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// id 变了(TabView 翻到新页 / 行复用换 asset / 同一 path 改目标尺寸)就重新加载;
|
||||||
|
// 把 maxPixel 纳入 id(与 FileVault 缓存 key 同构),避免同 path 切尺寸时不刷新显示旧图。
|
||||||
|
.task(id: "\(relativePath)@\(Int(maxPixel))") {
|
||||||
|
loading = true
|
||||||
|
let path = relativePath
|
||||||
|
let mp = maxPixel
|
||||||
|
let loaded = await Task.detached(priority: .userInitiated) {
|
||||||
|
try? FileVault.shared.loadDownsampledImage(relativePath: path, maxPixelSize: mp)
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
image = loaded
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,33 +23,42 @@ struct ArchiveListView: View {
|
|||||||
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||||
private var metricReminders: [MetricReminder]
|
private var metricReminders: [MetricReminder]
|
||||||
|
|
||||||
|
@Query(sort: \Medication.updatedAt, order: .reverse)
|
||||||
|
private var medications: [Medication]
|
||||||
|
|
||||||
/// 记录页内的 push 目的地。用单个 `navigationDestination(item:)` 驱动——
|
/// 记录页内的 push 目的地。用单个 `navigationDestination(item:)` 驱动——
|
||||||
/// 多个 `navigationDestination(isPresented:)` 并存时 SwiftUI 行为未定义(会误触发)。
|
/// 多个 `navigationDestination(isPresented:)` 并存时 SwiftUI 行为未定义(会误触发)。
|
||||||
private enum Route: Hashable { case exports, reminders }
|
private enum Route: Hashable { case exports, reminders, medicationLibrary }
|
||||||
|
|
||||||
@State private var filter: TimelineKind? = nil
|
@State private var filter: TimelineKind? = nil
|
||||||
@State private var endingSymptom: Symptom?
|
@State private var endingSymptom: Symptom?
|
||||||
|
|
||||||
|
/// 默认无参;从首页「我的报告档案」进入时传 `.report` 预选分类 chip。
|
||||||
|
/// 仅在视图创建时生效(RootView 切 tab 会重建 ArchiveListView)。
|
||||||
|
init(initialFilter: TimelineKind? = nil) {
|
||||||
|
_filter = State(initialValue: initialFilter)
|
||||||
|
}
|
||||||
@State private var selectedEntry: TimelineEntry?
|
@State private var selectedEntry: TimelineEntry?
|
||||||
@State private var selectedGroup: IndicatorGroup?
|
@State private var selectedGroup: IndicatorGroup?
|
||||||
@State private var route: Route?
|
@State private var route: Route?
|
||||||
|
|
||||||
|
/// 顶部搜索:点放大镜展开搜索框,按条目标题(指标/报告/症状/日记名)实时过滤,与分类 chip 叠加。
|
||||||
|
@State private var searching = false
|
||||||
|
@State private var query = ""
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private var allEntries: [TimelineEntry] {
|
private var allEntries: [TimelineEntry] {
|
||||||
let mapped =
|
let mapped =
|
||||||
TimelineEntry.from(indicators: indicators) +
|
TimelineEntry.aggregatedIndicators(indicators) +
|
||||||
reports.map(TimelineEntry.from(report:)) +
|
reports.map(TimelineEntry.from(report:)) +
|
||||||
diaries.map(TimelineEntry.from(diary:)) +
|
diaries.map(TimelineEntry.from(diary:)) +
|
||||||
symptoms.map(TimelineEntry.from(symptom:))
|
symptoms.map(TimelineEntry.from(symptom:))
|
||||||
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
|
let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
|
||||||
return filtered.sorted { $0.date > $1.date }
|
let q = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
let byQuery = q.isEmpty ? byKind : byKind.filter { $0.title.localizedCaseInsensitiveContains(q) }
|
||||||
|
return byQuery.sorted { $0.date > $1.date }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var grouped: [(section: DateSection, items: [TimelineEntry])] {
|
|
||||||
TimelineGrouping.group(allEntries)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var totalCount: Int { allEntries.count }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
content
|
content
|
||||||
@@ -57,33 +66,49 @@ struct ArchiveListView: View {
|
|||||||
switch route {
|
switch route {
|
||||||
case .exports: HealthExportListView()
|
case .exports: HealthExportListView()
|
||||||
case .reminders: RemindersListView()
|
case .reminders: RemindersListView()
|
||||||
|
case .medicationLibrary: MedicationLibraryView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
// 聚合(含血压配对 O(m²))+ 分类/搜索过滤在一次 body 内只算一次。原先 .isEmpty、分组、
|
||||||
header
|
// 计数各调一遍 allEntries,等于全表聚合三次;搜索时每次按键都翻三倍,这里收敛成一次。
|
||||||
|
let entries = allEntries
|
||||||
|
let groups = TimelineGrouping.group(entries)
|
||||||
|
return VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header(total: entries.count)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
if reminderTotal > 0 {
|
if reminderTotal > 0 {
|
||||||
reminderBoard
|
reminderBoard
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 药品库入口:始终显示——它是「管理常用药」的浏览/管理目的地,空库时也要能找到来添加。
|
||||||
|
medicationBoard
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
filterChips
|
||||||
|
.padding(.bottom, searching ? 10 : 14)
|
||||||
|
|
||||||
|
if searching {
|
||||||
|
searchField
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
filterChips
|
if entries.isEmpty {
|
||||||
.padding(.bottom, 14)
|
|
||||||
|
|
||||||
if allEntries.isEmpty {
|
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
|
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
|
||||||
ForEach(grouped, id: \.section) { group in
|
ForEach(groups, id: \.section) { group in
|
||||||
Section {
|
Section {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
ForEach(group.items) { entry in
|
ForEach(group.items) { entry in
|
||||||
@@ -149,12 +174,12 @@ struct ArchiveListView: View {
|
|||||||
diaries: diaries, symptoms: symptoms)
|
diaries: diaries, symptoms: symptoms)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private func header(total: Int) -> some View {
|
||||||
HStack(alignment: .lastTextBaseline) {
|
HStack(alignment: .lastTextBaseline) {
|
||||||
Text("记录")
|
Text("记录")
|
||||||
.font(.tjTitle(26))
|
.font(.tjTitle(26))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条"))
|
Text(total == 0 ? "" : String(appLoc: "\(total) 条"))
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -173,9 +198,57 @@ struct ArchiveListView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
searchToggle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var searchToggle: some View {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.18)) {
|
||||||
|
searching.toggle()
|
||||||
|
if !searching { query = "" }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: searching ? "xmark" : "magnifyingglass")
|
||||||
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索记录"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchField: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
TextField(String(appLoc: "搜索指标 / 报告 / 症状名"), text: $query)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
if !query.isEmpty {
|
||||||
|
Button { query = "" } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 提醒任务汇总卡
|
// MARK: - 提醒任务汇总卡
|
||||||
|
|
||||||
/// 两类提醒(自由 + 指标记录)合计,含已关闭。
|
/// 两类提醒(自由 + 指标记录)合计,含已关闭。
|
||||||
@@ -241,13 +314,73 @@ struct ArchiveListView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 药品库入口卡
|
||||||
|
|
||||||
|
/// 主标题:空库「药品库」,有药「药品库 · N 种常用药」。
|
||||||
|
private var medicationCountLabel: String {
|
||||||
|
medications.isEmpty
|
||||||
|
? String(appLoc: "药品库")
|
||||||
|
: String(appLoc: "药品库 · \(medications.count) 种常用药")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 副标题:空库给引导文案;有药取前 3 个药名预览(药名是用户数据,不本地化)。
|
||||||
|
private var medicationPreviewLine: String {
|
||||||
|
if medications.isEmpty { return String(appLoc: "拍药盒或手动添加常用药") }
|
||||||
|
let names = medications.prefix(3).map(\.name).joined(separator: " · ")
|
||||||
|
return medications.count > 3 ? names + " …" : names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击进药品库(MedicationLibraryView,push 形态)统一管理;卡片本身只展示。
|
||||||
|
private var medicationBoard: some View {
|
||||||
|
Button { route = .medicationLibrary } label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(medications.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
|
||||||
|
Image(systemName: "pills.fill")
|
||||||
|
.font(.tjScaled( 16))
|
||||||
|
.foregroundStyle(medications.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(medicationCountLabel)
|
||||||
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(medicationPreviewLine)
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分类过滤条(2026-06-28 重组):把原先扁平的一排标签按语义归三组——
|
||||||
|
/// 自述(日记/症状/问诊)· 检查(指标/报告)· 用药——组间加竖线分隔、每枚标签带图标 + 类别色,
|
||||||
|
/// 让「一堆标签」读成有结构的三块。过滤逻辑仍是单选某个 TimelineKind(选中再点取消)。
|
||||||
private var filterChips: some View {
|
private var filterChips: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
|
allChip
|
||||||
ForEach(TimelineKind.allCases) { kind in
|
ForEach(TimelineKind.Group.allCases) { group in
|
||||||
chip(label: kind.label, selected: filter == kind) {
|
groupSeparator
|
||||||
filter = filter == kind ? nil : kind
|
// 单成员组(用药)不另标组名:组名与唯一标签重复,直接出标签即可。
|
||||||
|
if group.kinds.count > 1 {
|
||||||
|
groupCaption(group.caption)
|
||||||
|
}
|
||||||
|
ForEach(group.kinds) { kind in
|
||||||
|
kindChip(kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,23 +388,53 @@ struct ArchiveListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
private var allChip: some View {
|
||||||
Button(action: action) {
|
let selected = filter == nil
|
||||||
Text(label)
|
return Button { filter = nil } label: {
|
||||||
|
Text(String(appLoc: "全部"))
|
||||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(
|
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
|
||||||
Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
|
.overlay(Capsule().strokeBorder(selected ? Color.clear : Tj.Palette.line, lineWidth: 1))
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 单枚分类标签:图标 + 名称,用该类别的 accent 着色;选中填充 accent。
|
||||||
|
private func kindChip(_ kind: TimelineKind) -> some View {
|
||||||
|
let selected = filter == kind
|
||||||
|
return Button { filter = selected ? nil : kind } label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: kind.icon)
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.foregroundStyle(selected ? Tj.Palette.paper : kind.accent)
|
||||||
|
Text(kind.label)
|
||||||
|
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Capsule().fill(selected ? kind.accent : Tj.Palette.paper))
|
||||||
|
.overlay(Capsule().strokeBorder(selected ? Color.clear : Tj.Palette.line, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupSeparator: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||||
|
.fill(Tj.Palette.lineSoft)
|
||||||
|
.frame(width: 1, height: 18)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func groupCaption(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(section.label)
|
Text(section.label)
|
||||||
@@ -291,13 +454,19 @@ struct ArchiveListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 14) {
|
let q = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
let isSearchMiss = !q.isEmpty
|
||||||
|
return VStack(spacing: 14) {
|
||||||
Spacer()
|
Spacer()
|
||||||
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
TjPlaceholder(label: isSearchMiss
|
||||||
|
? String(appLoc: "没有匹配「\(q)」的记录")
|
||||||
|
: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||||
.frame(width: 240, height: 140)
|
.frame(width: 240, height: 140)
|
||||||
|
if !isSearchMiss {
|
||||||
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||||
.font(.tjScaled( 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@@ -117,11 +117,17 @@ struct HealthExportRow: View {
|
|||||||
.tjCard()
|
.tjCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
static func relativeDate(_ d: Date) -> String {
|
/// 复用单个 formatter:RelativeDateTimeFormatter 初始化较贵,列表每行每次重绘都 new 会累积开销。
|
||||||
|
/// 用系统 Locale.current(与原实现一致),进程内不变,可安全缓存。
|
||||||
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||||
let f = RelativeDateTimeFormatter()
|
let f = RelativeDateTimeFormatter()
|
||||||
f.locale = Locale.current
|
f.locale = Locale.current
|
||||||
f.unitsStyle = .full
|
f.unitsStyle = .full
|
||||||
return f.localizedString(for: d, relativeTo: .now)
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func relativeDate(_ d: Date) -> String {
|
||||||
|
relativeFormatter.localizedString(for: d, relativeTo: .now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct HealthExportSheet: View {
|
|||||||
@State private var error: Error?
|
@State private var error: Error?
|
||||||
@State private var completed: Bool = false
|
@State private var completed: Bool = false
|
||||||
@State private var copiedFlash: Bool = false
|
@State private var copiedFlash: Bool = false
|
||||||
|
@State private var lastScrollAt: Date = .distantPast // 流式滚动节流时间戳
|
||||||
@State private var answeringTurnID: UUID?
|
@State private var answeringTurnID: UUID?
|
||||||
@State private var retrieval: HealthExportService.RetrievalSummary?
|
@State private var retrieval: HealthExportService.RetrievalSummary?
|
||||||
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
||||||
@@ -57,7 +58,7 @@ struct HealthExportSheet: View {
|
|||||||
header
|
header
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
LazyVStack(alignment: .leading, spacing: 18) {
|
||||||
introSection
|
introSection
|
||||||
|
|
||||||
ForEach(turns) { turn in
|
ForEach(turns) { turn in
|
||||||
@@ -76,15 +77,15 @@ struct HealthExportSheet: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
.onChange(of: content) { _, _ in
|
// 流式期间 content / turns 每 token 都变,逐次动画滚动会造成布局抖动;
|
||||||
withAnimation(.easeOut(duration: 0.12)) {
|
// 节流到 ~8Hz,并在生成完成时补一次滚动确保停在底部。
|
||||||
proxy.scrollTo("bottom", anchor: .bottom)
|
.onChange(of: content) { _, _ in throttledScrollToBottom(proxy) }
|
||||||
}
|
.onChange(of: turns) { _, _ in throttledScrollToBottom(proxy) }
|
||||||
}
|
.onChange(of: completed) { _, done in
|
||||||
.onChange(of: turns) { _, _ in
|
if done { scrollToBottom(proxy) }
|
||||||
withAnimation(.easeOut(duration: 0.12)) {
|
|
||||||
proxy.scrollTo("bottom", anchor: .bottom)
|
|
||||||
}
|
}
|
||||||
|
.onChange(of: answeringTurnID) { _, id in
|
||||||
|
if id == nil { scrollToBottom(proxy) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if completed {
|
if completed {
|
||||||
@@ -358,7 +359,7 @@ struct HealthExportSheet: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
Text(err.localizedDescription)
|
Text(friendlyMessage(for: err))
|
||||||
.font(.tjScaled( 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
@@ -504,8 +505,16 @@ struct HealthExportSheet: View {
|
|||||||
questionFocused = true
|
questionFocused = true
|
||||||
} catch {
|
} catch {
|
||||||
answeringTurnID = nil
|
answeringTurnID = nil
|
||||||
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
|
|
||||||
questionFocused = true
|
questionFocused = true
|
||||||
|
if error is CancellationError { return }
|
||||||
|
#if DEBUG
|
||||||
|
print("[HealthExport] answer failed: \(error)")
|
||||||
|
#endif
|
||||||
|
// 已有部分回答就保留;否则给一句友好兜底,绝不把技术异常当成 AI 的回答展示。
|
||||||
|
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
|
||||||
|
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
turns[idx].text = String(appLoc: "这次没能回答上来,请换个说法再试一次。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,6 +561,10 @@ struct HealthExportSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if error is CancellationError { return }
|
||||||
|
#if DEBUG
|
||||||
|
print("[HealthExport] export failed: \(error)")
|
||||||
|
#endif
|
||||||
self.error = error
|
self.error = error
|
||||||
self.phase = nil
|
self.phase = nil
|
||||||
}
|
}
|
||||||
@@ -600,6 +613,27 @@ struct HealthExportSheet: View {
|
|||||||
task?.cancel()
|
task?.cancel()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 滚动
|
||||||
|
|
||||||
|
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||||
|
lastScrollAt = Date()
|
||||||
|
withAnimation(.easeOut(duration: 0.12)) {
|
||||||
|
proxy.scrollTo("bottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 节流滚动:流式 token 高频触发,限制到约 8Hz,避免每 token 一次动画布局。
|
||||||
|
private func throttledScrollToBottom(_ proxy: ScrollViewProxy) {
|
||||||
|
guard Date().timeIntervalSince(lastScrollAt) > 0.12 else { return }
|
||||||
|
scrollToBottom(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把推理过程中抛出的技术异常翻译成用户能看懂的一句话(取消不算错误)。
|
||||||
|
private func friendlyMessage(for error: Error) -> String {
|
||||||
|
if error is CancellationError { return "" }
|
||||||
|
return String(appLoc: "这次没能生成成功,请稍后重试。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 检索结果 chips(本地 RAG 可视化)
|
// MARK: - 检索结果 chips(本地 RAG 可视化)
|
||||||
@@ -757,16 +791,31 @@ struct MarkdownView: View {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 行内样式缓存:流式生成时整段会被反复重渲染,缓存命中可避免对同一行重复跑昂贵的
|
||||||
|
/// markdown 解析。键为行文本,稳定的历史行命中缓存,只有正在增长的尾行才真正解析。
|
||||||
|
private final class AttrBox { let value: AttributedString; init(_ v: AttributedString) { value = v } }
|
||||||
|
private static let inlineCache: NSCache<NSString, AttrBox> = {
|
||||||
|
let c = NSCache<NSString, AttrBox>()
|
||||||
|
c.countLimit = 256
|
||||||
|
return c
|
||||||
|
}()
|
||||||
|
|
||||||
private func inline(_ s: String) -> AttributedString {
|
private func inline(_ s: String) -> AttributedString {
|
||||||
|
// 快路径:整段报告里绝大多数行(标题、普通条目、纯正文)都没有内联标记,
|
||||||
|
// 直接走纯文本,跳过 AttributedString(markdown:) —— 这是流式期间最大的一笔开销。
|
||||||
|
if !s.contains(where: { $0 == "*" || $0 == "_" || $0 == "[" || $0 == "`" }) {
|
||||||
|
return AttributedString(s)
|
||||||
|
}
|
||||||
|
let key = s as NSString
|
||||||
|
if let hit = Self.inlineCache.object(forKey: key) { return hit.value }
|
||||||
// **bold** / *italic* / [text](url) 走 AttributedString markdown 解析
|
// **bold** / *italic* / [text](url) 走 AttributedString markdown 解析
|
||||||
if let attr = try? AttributedString(
|
let attr = (try? AttributedString(
|
||||||
markdown: s,
|
markdown: s,
|
||||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
) {
|
)) ?? AttributedString(s)
|
||||||
|
Self.inlineCache.setObject(AttrBox(attr), forKey: key)
|
||||||
return attr
|
return attr
|
||||||
}
|
}
|
||||||
return AttributedString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 行级解析
|
// MARK: - 行级解析
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ struct CaptureReviewForm: View {
|
|||||||
@State var parsed: ParsedReport
|
@State var parsed: ParsedReport
|
||||||
let assets: [FileVault.SavedAsset]
|
let assets: [FileVault.SavedAsset]
|
||||||
let warning: String?
|
let warning: String?
|
||||||
|
/// 归档模式:只存原图 + 基本信息(标题/类型/日期/机构),隐藏指标区与摘要。
|
||||||
|
/// 报告归档不再逐项识别(逐项多模态在 2B 上易 OOM 卡死),见 CaptureService.extractReportMeta。
|
||||||
|
var metaOnly: Bool = false
|
||||||
let onSave: (ParsedReport) -> Void
|
let onSave: (ParsedReport) -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
/// 「重新识别」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
/// 「重新识别信息」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
||||||
var onReanalyze: (() -> Void)? = nil
|
var onReanalyze: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -23,7 +26,9 @@ struct CaptureReviewForm: View {
|
|||||||
pageThumbnails
|
pageThumbnails
|
||||||
}
|
}
|
||||||
metaSection
|
metaSection
|
||||||
|
if !metaOnly {
|
||||||
indicatorSection
|
indicatorSection
|
||||||
|
}
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
@@ -68,13 +73,20 @@ struct CaptureReviewForm: View {
|
|||||||
private var pageThumbnails: some View {
|
private var pageThumbnails: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
|
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
|
||||||
|
if metaOnly {
|
||||||
|
Text("原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
||||||
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
|
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
|
||||||
Image(uiImage: img)
|
Image(uiImage: img).resizable().scaledToFill()
|
||||||
.resizable()
|
} placeholder: { _ in
|
||||||
.scaledToFill()
|
Tj.Palette.paper
|
||||||
|
}
|
||||||
.frame(width: 84, height: 110)
|
.frame(width: 84, height: 110)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
@@ -86,7 +98,6 @@ struct CaptureReviewForm: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - meta(title / type / date / institution / summary)
|
// MARK: - meta(title / type / date / institution / summary)
|
||||||
|
|
||||||
@@ -117,11 +128,13 @@ struct CaptureReviewForm: View {
|
|||||||
labeledField(String(appLoc: "机构(可选)")) {
|
labeledField(String(appLoc: "机构(可选)")) {
|
||||||
TextField("如:协和医院", text: $parsed.institution)
|
TextField("如:协和医院", text: $parsed.institution)
|
||||||
}
|
}
|
||||||
|
if !metaOnly {
|
||||||
labeledField(String(appLoc: "摘要(可选)")) {
|
labeledField(String(appLoc: "摘要(可选)")) {
|
||||||
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
||||||
.lineLimit(1...3)
|
.lineLimit(1...3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
.overlay(fieldBorder)
|
.overlay(fieldBorder)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ struct UnifiedCaptureFlow: View {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .idle: return String(appLoc: "拍摄报告")
|
case .idle: return String(appLoc: "拍摄报告")
|
||||||
case .analyzing: return String(appLoc: "本地识别中…")
|
case .analyzing: return String(appLoc: "本地识别中…")
|
||||||
case .editing: return String(appLoc: "核对识别结果")
|
case .editing: return String(appLoc: "核对报告信息")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +86,7 @@ struct UnifiedCaptureFlow: View {
|
|||||||
parsed: parsed,
|
parsed: parsed,
|
||||||
assets: assets,
|
assets: assets,
|
||||||
warning: warning,
|
warning: warning,
|
||||||
|
metaOnly: true, // 归档只存原图 + meta,不逐项识别(§见 CaptureService.extractReportMeta)
|
||||||
onSave: { final in saveAll(parsed: final, assets: assets) },
|
onSave: { final in saveAll(parsed: final, assets: assets) },
|
||||||
onCancel: cancelAll,
|
onCancel: cancelAll,
|
||||||
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
|
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
|
||||||
@@ -152,10 +153,21 @@ struct UnifiedCaptureFlow: View {
|
|||||||
phase = .analyzing(images: images, assets: nil)
|
phase = .analyzing(images: images, assets: nil)
|
||||||
let timeout = analyzeTimeoutSeconds
|
let timeout = analyzeTimeoutSeconds
|
||||||
analyzeTask = Task {
|
analyzeTask = Task {
|
||||||
// Step 1: 先把图写进 Vault。
|
// Step 1: 把图写进 Vault(归档核心价值:先把原图存下来),并立刻降采样出预览缩略图。
|
||||||
// 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时,
|
// 整段放后台线程,JPEG 编码逐张包 autoreleasepool 让中间 Data / 位图及时回收 ——
|
||||||
// assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。
|
// 既不卡主线程,又能在写完后释放全分辨率原图,不让它贯穿整个识别期常驻(jetsam 防护)。
|
||||||
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
|
let inputBox = UncheckedImageBox(images: images)
|
||||||
|
let written: (assets: [FileVault.SavedAsset], thumbs: UncheckedImageBox) =
|
||||||
|
await Task.detached(priority: .userInitiated) {
|
||||||
|
let assets = inputBox.images.compactMap { img in
|
||||||
|
autoreleasepool { try? FileVault.shared.writeJPEG(img) }
|
||||||
|
}
|
||||||
|
let thumbs = assets.compactMap {
|
||||||
|
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
|
||||||
|
}
|
||||||
|
return (assets, UncheckedImageBox(images: thumbs))
|
||||||
|
}.value
|
||||||
|
let assets = written.assets
|
||||||
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
||||||
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
@@ -167,61 +179,50 @@ struct UnifiedCaptureFlow: View {
|
|||||||
phase = .editing(
|
phase = .editing(
|
||||||
parsed: .empty(),
|
parsed: .empty(),
|
||||||
assets: [],
|
assets: [],
|
||||||
warning: String(appLoc: "图片保存失败,手动录入并保留文本")
|
warning: String(appLoc: "图片保存失败,请重试")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 把 assets 暴露给 phase,使工具栏「取消」也能找到孤儿清理。
|
// 原图已落盘:phase 改持 600px 缩略图(释放全分辨率原图),同时把 assets 暴露给「取消」做孤儿清理。
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if case .analyzing(let imgs, _) = phase {
|
phase = .analyzing(images: written.thumbs.images, assets: assets)
|
||||||
phase = .analyzing(images: imgs, assets: assets)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: VL 推理(timeout 哨兵到点 cancel 父任务,VLSession 在下一个 token break)。
|
// Step 2: 轻量 meta 提取(OCR + 文本 LLM,只抽日期/机构/类型/标题)。
|
||||||
|
// 不再跑多模态逐项识别 —— 那在 2B 上又慢又会 OOM 卡死。watchdog 到点 cancel。
|
||||||
let watchdog = Task {
|
let watchdog = Task {
|
||||||
try? await Task.sleep(for: .seconds(timeout))
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
analyzeTask?.cancel()
|
analyzeTask?.cancel()
|
||||||
}
|
}
|
||||||
defer { watchdog.cancel() }
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
do {
|
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
|
||||||
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
await editingFallback(assets: assets,
|
await MainActor.run {
|
||||||
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
|
phase = .editing(parsed: .empty(), assets: assets,
|
||||||
|
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
phase = .editing(
|
phase = .editing(
|
||||||
parsed: parsed,
|
parsed: meta,
|
||||||
assets: assets,
|
assets: assets,
|
||||||
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
|
warning: recognized ? nil
|
||||||
|
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
|
||||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
|
||||||
} catch let CaptureError.inferenceFailed(msg) {
|
|
||||||
await editingFallback(assets: assets,
|
|
||||||
msg: Task.isCancelled
|
|
||||||
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
|
|
||||||
: String(appLoc: "推理失败:\(msg)"))
|
|
||||||
} catch CaptureError.modelNotReady {
|
|
||||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
|
|
||||||
} catch {
|
|
||||||
await editingFallback(assets: assets,
|
|
||||||
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。
|
/// 「重新识别信息」:复用已存 assets,不再写图,只重跑一次轻量 meta 提取。
|
||||||
private func reanalyze(assets: [FileVault.SavedAsset]) {
|
private func reanalyze(assets: [FileVault.SavedAsset]) {
|
||||||
analyzeTask?.cancel()
|
analyzeTask?.cancel()
|
||||||
// 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可
|
// 这里没有原始 UIImage,AnalyzingView 只把首张缩略图模糊后当背景,降采样到 600px 足够,
|
||||||
|
// 避免为「重新识别」把整页全分辨率原图(数十 MB)载进内存。
|
||||||
let thumbnails: [UIImage] = assets.compactMap {
|
let thumbnails: [UIImage] = assets.compactMap {
|
||||||
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
|
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
|
||||||
}
|
}
|
||||||
phase = .analyzing(images: thumbnails, assets: assets)
|
phase = .analyzing(images: thumbnails, assets: assets)
|
||||||
let timeout = analyzeTimeoutSeconds
|
let timeout = analyzeTimeoutSeconds
|
||||||
@@ -232,40 +233,19 @@ struct UnifiedCaptureFlow: View {
|
|||||||
}
|
}
|
||||||
defer { watchdog.cancel() }
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
do {
|
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
|
||||||
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
await editingFallback(assets: assets,
|
await MainActor.run {
|
||||||
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
|
phase = .editing(parsed: .empty(), assets: assets,
|
||||||
|
warning: String(appLoc: "识别超时,已保留原图"))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
phase = .editing(
|
phase = .editing(parsed: meta, assets: assets,
|
||||||
parsed: parsed,
|
warning: recognized ? nil
|
||||||
assets: assets,
|
: String(appLoc: "未能自动识别报告信息,可手动填写"))
|
||||||
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch CaptureError.modelNotReady {
|
|
||||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
|
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
|
||||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
|
||||||
} catch let CaptureError.inferenceFailed(msg) {
|
|
||||||
await editingFallback(assets: assets,
|
|
||||||
msg: Task.isCancelled
|
|
||||||
? String(appLoc: "识别超时(>\(timeout)s)")
|
|
||||||
: String(appLoc: "推理失败:\(msg)"))
|
|
||||||
} catch {
|
|
||||||
await editingFallback(assets: assets,
|
|
||||||
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// reanalyze 失败时回到 editing,保留 assets 但清空 parsed。
|
|
||||||
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
|
|
||||||
await MainActor.run {
|
|
||||||
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +298,11 @@ struct UnifiedCaptureFlow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跨 detached 边界安全携带 UIImage 数组:图片只读、不并发改,封装免 Sendable 报错。
|
||||||
|
private struct UncheckedImageBox: @unchecked Sendable {
|
||||||
|
let images: [UIImage]
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 分析中视图
|
// MARK: - 分析中视图
|
||||||
|
|
||||||
private struct AnalyzingView: View {
|
private struct AnalyzingView: View {
|
||||||
|
|||||||
489
康康/Features/Chat/HealthChatView.swift
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「和康康聊聊」——全局健康记录问答(RAG)。
|
||||||
|
///
|
||||||
|
/// 你问,康康基于你本机的健康记录(指标 / 报告 / 症状 / 日记)检索后作答,100% 端侧。
|
||||||
|
/// 复用 `HealthExportService.answer`(检索快照 → dialogueAnswer prompt → 流式作答 + 检索可视化),
|
||||||
|
/// 卡通形象 `CompanionAvatarView` 放在顶栏当「对话对象」,情绪随生成状态切换。
|
||||||
|
///
|
||||||
|
/// 长期记忆:每轮问答落 `ChatTurn`,进场读回最近 10 轮,跨会话续聊(§12#3 本地 RAG 长期记忆)。
|
||||||
|
/// 边界:UI 不直接调 AIRuntime(红线 #3),全部经 `HealthExportService`;失败不卡屏(红线 #5)。
|
||||||
|
struct HealthChatView: View {
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
|
||||||
|
/// 本场对话(含进场读回的历史 + 本次新问答)。assistant 空文本 = 正在生成的占位轮。
|
||||||
|
@State private var turns: [HealthExportDialogueTurn] = []
|
||||||
|
/// 每条 assistant 回答对应的「查阅到的记录」摘要,展示在气泡下方做 RAG 可视化。
|
||||||
|
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
||||||
|
@State private var draft = ""
|
||||||
|
/// 正在流式作答的 assistant 轮 id;非 nil = 正在回答(锁输入、形象进入思考/说话态)。
|
||||||
|
@State private var answeringTurnID: UUID?
|
||||||
|
@State private var rate: Double = 0
|
||||||
|
@State private var task: Task<Void, Never>?
|
||||||
|
@FocusState private var inputFocused: Bool
|
||||||
|
|
||||||
|
// MARK: 语音提问(复用 SpeechDictationService,与写日记同一套本机转写)
|
||||||
|
@State private var isRecording = false
|
||||||
|
/// 必须 @State:struct View 重建时普通 let 会换新实例,导致 stop() 落在没在录音的新服务上。
|
||||||
|
@State private var dictation = SpeechDictationService()
|
||||||
|
@State private var micDeniedAlert = false
|
||||||
|
|
||||||
|
private var isAnswering: Bool { answeringTurnID != nil }
|
||||||
|
|
||||||
|
/// 端侧模型就绪才能问(answer 走本地推理);未就绪时空态给下载指引。
|
||||||
|
private var modelReady: Bool { ModelStore.shared.isComplete(for: .llm) }
|
||||||
|
|
||||||
|
/// 形象情绪:录音→listening;检索/等首 token→thinking;流式吐字→speaking;其余→idle。
|
||||||
|
private var mood: CompanionAvatarView.Mood {
|
||||||
|
if isRecording { return .listening }
|
||||||
|
guard let id = answeringTurnID else { return .idle }
|
||||||
|
let streaming = turns.first { $0.id == id }?.text.isEmpty == false
|
||||||
|
return streaming ? .speaking : .thinking
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空态引导:点一下即作为问题发出,降低「不知道能问啥」的启动成本。
|
||||||
|
private let starters = [
|
||||||
|
"帮我总结一下最近的身体状况",
|
||||||
|
"我最近的血压怎么样?",
|
||||||
|
"上一次体检有哪些异常?",
|
||||||
|
"最近的血糖有变化吗?"
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
transcript
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.safeAreaInset(edge: .bottom) { inputBar }
|
||||||
|
.onAppear(perform: loadHistory)
|
||||||
|
.onDisappear {
|
||||||
|
task?.cancel()
|
||||||
|
dictation.abort()
|
||||||
|
}
|
||||||
|
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $micDeniedAlert) {
|
||||||
|
Button(String(appLoc: "前往设置")) {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("语音提问全程在本机完成,声音和文字都不会上传。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 顶栏(卡通形象 + 状态)
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
CompanionAvatarView(mood: mood, size: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("康康")
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(headerSubtitle)
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Button(action: onClose) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.tjScaled(14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Tj.Palette.paper)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerSubtitle: String {
|
||||||
|
if isRecording { return String(appLoc: "正在听你说…") }
|
||||||
|
if isAnswering {
|
||||||
|
return rate > 0
|
||||||
|
? String(appLoc: "正在翻你的记录 · \(String(format: "%.1f", rate)) tok/s")
|
||||||
|
: String(appLoc: "正在翻你的记录…")
|
||||||
|
}
|
||||||
|
return String(appLoc: "问问你的健康记录 · 全程在本机")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 对话区
|
||||||
|
|
||||||
|
private var transcript: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
if turns.isEmpty {
|
||||||
|
emptyState
|
||||||
|
.padding(.top, 28)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
} else {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 14) {
|
||||||
|
ForEach(turns) { turn in
|
||||||
|
bubble(turn)
|
||||||
|
}
|
||||||
|
Color.clear.frame(height: 1).id(bottomAnchor)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 每吐一个 token / 新增一轮都滚到底,保证最新内容始终可见。
|
||||||
|
.onChange(of: turns.last?.text) { _, _ in scrollToBottom(proxy) }
|
||||||
|
.onChange(of: turns.count) { _, _ in scrollToBottom(proxy) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let bottomAnchor = "chat.bottom"
|
||||||
|
|
||||||
|
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
proxy.scrollTo(bottomAnchor, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func bubble(_ turn: HealthExportDialogueTurn) -> some View {
|
||||||
|
if turn.role == .user {
|
||||||
|
HStack {
|
||||||
|
Spacer(minLength: 40)
|
||||||
|
Text(turn.text)
|
||||||
|
.font(.tjScaled(14))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.ink))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// 空文本 + 正在回答 = 首 token 未到,显示「思考」占位,不给用户空白焦虑。
|
||||||
|
if turn.text.isEmpty && answeringTurnID == turn.id {
|
||||||
|
thinkingBubble
|
||||||
|
} else {
|
||||||
|
// 康康的回答按 Markdown 渲染(加粗 / 要点列表 / 异常值高亮),
|
||||||
|
// 复用报告页那套流式优化过的 MarkdownView(带内联缓存,逐 token 重渲不卡)。
|
||||||
|
MarkdownView(text: turn.text)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let summary = turnRetrievals[turn.id], summary.totalCount > 0 {
|
||||||
|
retrievalChips(summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var thinkingBubble: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Text("正在查阅你的记录")
|
||||||
|
.font(.tjScaled(13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
ProgressView().scaleEffect(0.7)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAG 可视化:这条回答查阅了哪些本机记录(§12#3 端侧不可替代性的现场证据)。
|
||||||
|
private func retrievalChips(_ summary: HealthExportService.RetrievalSummary) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("查阅了本机 \(summary.totalCount) 条记录")
|
||||||
|
.font(.tjScaled(10, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
FlowChips(items: summary.chips)
|
||||||
|
}
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 空态(引导起手问题)
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
CompanionAvatarView(mood: .idle, size: 110)
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("我是康康")
|
||||||
|
.font(.tjScaled(20, weight: .semibold, design: .serif))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(modelReady
|
||||||
|
? String(appLoc: "问我关于你健康记录的事,我只看你本机的数据来回答")
|
||||||
|
: String(appLoc: "先到「我的 · 模型管理」下载模型,就能和我聊了"))
|
||||||
|
.font(.tjScaled(13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
|
if modelReady {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(starters, id: \.self) { q in
|
||||||
|
Button { send(q) } label: {
|
||||||
|
HStack {
|
||||||
|
Text(q)
|
||||||
|
.font(.tjScaled(13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.tjScaled(11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.tjCard(bordered: true)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 输入栏
|
||||||
|
|
||||||
|
private var inputBar: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if SpeechDictationService.isAvailable {
|
||||||
|
Button(action: toggleMic) {
|
||||||
|
Image(systemName: isRecording ? "stop.fill" : "mic.fill")
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
.foregroundStyle(isRecording ? Tj.Palette.paper : Tj.Palette.text3)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Circle().fill(isRecording ? Tj.Palette.brick : Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isAnswering)
|
||||||
|
.opacity(isAnswering ? 0.4 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(String(appLoc: "问问你的健康记录…"), text: $draft, axis: .vertical)
|
||||||
|
.font(.tjScaled(14))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1...4)
|
||||||
|
.focused($inputFocused)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Capsule().fill(Tj.Palette.sand2))
|
||||||
|
.disabled(isRecording)
|
||||||
|
|
||||||
|
Button { send(draft) } label: {
|
||||||
|
Image(systemName: "arrow.up")
|
||||||
|
.font(.tjScaled(16, weight: .bold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Circle().fill(canSend ? Tj.Palette.ink : Tj.Palette.line))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canSend)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Tj.Palette.paper)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSend: Bool {
|
||||||
|
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
&& !isAnswering && !isRecording && modelReady
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 发送 / 流式作答(镜像 HealthExportSheet.sendQuestion)
|
||||||
|
|
||||||
|
private func send(_ raw: String) {
|
||||||
|
let question = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !question.isEmpty, !isAnswering, modelReady else { return }
|
||||||
|
draft = ""
|
||||||
|
inputFocused = false
|
||||||
|
|
||||||
|
let userTurn = HealthExportDialogueTurn.user(question)
|
||||||
|
let assistantTurn = HealthExportDialogueTurn.assistant("")
|
||||||
|
turns.append(userTurn)
|
||||||
|
turns.append(assistantTurn)
|
||||||
|
answeringTurnID = assistantTurn.id
|
||||||
|
rate = 0
|
||||||
|
|
||||||
|
// 只把最近若干轮喂给模型:端侧小模型 context 有限,历史全塞会溢出/变慢。
|
||||||
|
let conversationForPrompt = Array(
|
||||||
|
turns.filter { $0.id != assistantTurn.id }.suffix(8)
|
||||||
|
)
|
||||||
|
let stream = HealthExportService.shared.answer(
|
||||||
|
question: question,
|
||||||
|
conversation: conversationForPrompt,
|
||||||
|
in: ctx
|
||||||
|
)
|
||||||
|
task?.cancel()
|
||||||
|
task = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
for try await event in stream {
|
||||||
|
switch event {
|
||||||
|
case .retrieved(let summary):
|
||||||
|
withAnimation(.snappy(duration: 0.25)) {
|
||||||
|
turnRetrievals[assistantTurn.id] = summary
|
||||||
|
}
|
||||||
|
case .token(let chunk):
|
||||||
|
appendToTurn(id: assistantTurn.id, text: chunk.text)
|
||||||
|
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||||
|
case .phaseChanged, .completed:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answeringTurnID = nil
|
||||||
|
persist(question: question, answerID: assistantTurn.id)
|
||||||
|
} catch {
|
||||||
|
answeringTurnID = nil
|
||||||
|
if error is CancellationError { return }
|
||||||
|
// 已有部分回答就保留;否则给一句友好兜底,绝不把技术异常当 AI 回答展示(红线 #5)。
|
||||||
|
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
|
||||||
|
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
turns[idx].text = String(appLoc: "这次没能回答上来,请换个说法再试一次。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendToTurn(id: UUID, text: String) {
|
||||||
|
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
turns[idx].text += text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一轮问答落库成 ChatTurn(跨会话记忆)。空回答 / 兜底话术不入库,避免污染历史。
|
||||||
|
private func persist(question: String, answerID: UUID) {
|
||||||
|
guard let answer = turns.first(where: { $0.id == answerID })?.text
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines), !answer.isEmpty else { return }
|
||||||
|
let turn = ChatTurn(question: question, answer: answer, decodeRate: rate)
|
||||||
|
ctx.insert(turn)
|
||||||
|
try? ctx.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 历史读回
|
||||||
|
|
||||||
|
/// 进场读回最近 10 轮问答,续上「长期记忆」。只在 onAppear 读一次,新问答只追加,不会重复。
|
||||||
|
private func loadHistory() {
|
||||||
|
var desc = FetchDescriptor<ChatTurn>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
|
||||||
|
desc.fetchLimit = 10
|
||||||
|
let recent = (try? ctx.fetch(desc)) ?? []
|
||||||
|
guard !recent.isEmpty else { return }
|
||||||
|
var loaded: [HealthExportDialogueTurn] = []
|
||||||
|
for ct in recent.reversed() {
|
||||||
|
loaded.append(.user(ct.question))
|
||||||
|
loaded.append(.assistant(ct.answer))
|
||||||
|
}
|
||||||
|
turns = loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 语音提问
|
||||||
|
|
||||||
|
private func toggleMic() {
|
||||||
|
if isRecording {
|
||||||
|
Task { @MainActor in
|
||||||
|
let final = await dictation.stop()
|
||||||
|
isRecording = false
|
||||||
|
let merged = final.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !merged.isEmpty { draft = merged }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard await dictation.requestAuthorization() else {
|
||||||
|
micDeniedAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
inputFocused = false
|
||||||
|
draft = ""
|
||||||
|
try dictation.start { partial in draft = partial }
|
||||||
|
isRecording = true
|
||||||
|
} catch {
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检索 chip 的自适应换行排布(简单流式布局,依赖 iOS 16+ Layout)。
|
||||||
|
private struct FlowChips: View {
|
||||||
|
let items: [String]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FlowLayout(spacing: 6) {
|
||||||
|
ForEach(items, id: \.self) { chip in
|
||||||
|
Text(chip)
|
||||||
|
.font(.tjScaled(10, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Capsule().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 极简流式布局:逐行摆放,超出可用宽度就换行。仅用于检索 chip,不追求通用。
|
||||||
|
private struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 6
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
||||||
|
let maxWidth = proposal.width ?? .infinity
|
||||||
|
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
|
||||||
|
for sub in subviews {
|
||||||
|
let size = sub.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > maxWidth, x > 0 {
|
||||||
|
x = 0
|
||||||
|
y += rowHeight + spacing
|
||||||
|
rowHeight = 0
|
||||||
|
}
|
||||||
|
x += size.width + spacing
|
||||||
|
rowHeight = max(rowHeight, size.height)
|
||||||
|
}
|
||||||
|
return CGSize(width: maxWidth == .infinity ? x : maxWidth, height: y + rowHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
||||||
|
let maxWidth = bounds.width
|
||||||
|
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
|
||||||
|
for sub in subviews {
|
||||||
|
let size = sub.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > maxWidth, x > 0 {
|
||||||
|
x = 0
|
||||||
|
y += rowHeight + spacing
|
||||||
|
rowHeight = 0
|
||||||
|
}
|
||||||
|
sub.place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y),
|
||||||
|
anchor: .topLeading, proposal: ProposedViewSize(size))
|
||||||
|
x += size.width + spacing
|
||||||
|
rowHeight = max(rowHeight, size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
HealthChatView(onClose: {})
|
||||||
|
}
|
||||||
130
康康/Features/Consultation/ConsultationAudioPlayer.swift
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Vault 内一段问诊录音的回放控件(2026-06-28)。播放/暂停 + 进度条 + 时间。
|
||||||
|
/// 录音存在加密 Vault 里,App 前台即解锁可读;读不到(损坏/异常)显示占位,不崩。
|
||||||
|
/// 不做拖拽 seek(降复杂度):进度条只读,demo 够用。
|
||||||
|
struct ConsultationAudioPlayer: View {
|
||||||
|
let asset: Asset
|
||||||
|
|
||||||
|
@State private var player: AVAudioPlayer?
|
||||||
|
@State private var isPlaying = false
|
||||||
|
@State private var currentTime: Double = 0
|
||||||
|
@State private var duration: Double = 0
|
||||||
|
@State private var loadFailed = false
|
||||||
|
|
||||||
|
/// 播放时刷新进度/时间;不播放时空转(开销可忽略)。AVAudioPlayer 播完 isPlaying 自然转 false。
|
||||||
|
private let ticker = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.ink2)
|
||||||
|
Text(String(appLoc: "问诊录音"))
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
Text(String(appLoc: "本机存储"))
|
||||||
|
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadFailed {
|
||||||
|
Text("录音无法读取")
|
||||||
|
.font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: togglePlay) {
|
||||||
|
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(.tjScaled( 16, weight: .bold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Circle().fill(Tj.Palette.ink2))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Tj.Palette.sand2)
|
||||||
|
Capsule().fill(Tj.Palette.ink2)
|
||||||
|
.frame(width: geo.size.width * progressFraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 5)
|
||||||
|
HStack {
|
||||||
|
Text(Self.timeText(currentTime))
|
||||||
|
Spacer()
|
||||||
|
Text(Self.timeText(duration))
|
||||||
|
}
|
||||||
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.onAppear(perform: load)
|
||||||
|
.onDisappear {
|
||||||
|
player?.stop()
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
.onReceive(ticker) { _ in
|
||||||
|
guard let p = player, isPlaying else { return }
|
||||||
|
currentTime = p.currentTime
|
||||||
|
if !p.isPlaying { // 播完:复位
|
||||||
|
isPlaying = false
|
||||||
|
currentTime = 0
|
||||||
|
p.currentTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressFraction: CGFloat {
|
||||||
|
guard duration > 0 else { return 0 }
|
||||||
|
return CGFloat(min(max(currentTime / duration, 0), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard player == nil, !loadFailed else { return }
|
||||||
|
do {
|
||||||
|
let url = try FileVault.shared.absoluteURL(forReading: asset.relativePath)
|
||||||
|
let p = try AVAudioPlayer(contentsOf: url)
|
||||||
|
p.prepareToPlay()
|
||||||
|
player = p
|
||||||
|
duration = p.duration
|
||||||
|
} catch {
|
||||||
|
loadFailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func togglePlay() {
|
||||||
|
guard let p = player else { return }
|
||||||
|
if isPlaying {
|
||||||
|
p.pause()
|
||||||
|
isPlaying = false
|
||||||
|
} else {
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
p.play()
|
||||||
|
isPlaying = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func timeText(_ seconds: Double) -> String {
|
||||||
|
let s = Int(seconds.rounded())
|
||||||
|
return String(format: "%d:%02d", s / 60, s % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
657
康康/Features/Consultation/ConsultationSheet.swift
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「记录问诊」录入 sheet(2026-06-28)。
|
||||||
|
///
|
||||||
|
/// 流程:开始录音 → 端侧实时转写(`ConsultationRecorder`)→ 停止 → AI 整理成问诊小结
|
||||||
|
/// (`DiaryAssistService.organizeConsultation`)→ 审阅可编辑 → 保存为带「问诊」tag 的 DiaryEntry
|
||||||
|
/// (录音作为 audio/m4a Asset 落加密 Vault)。
|
||||||
|
///
|
||||||
|
/// 守红线:UI 不直接调 AIRuntime(经 DiaryAssistService);识别 + 录音全程本机;
|
||||||
|
/// AI 整理失败回退录音原文(#5);录音落盘尽力而为,失败也照常存文字。
|
||||||
|
/// 本机不支持端侧识别(模拟器/老机型)→ 退化为手动文字录入,功能仍可用。
|
||||||
|
struct ConsultationSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
enum Phase: Equatable { case idle, recording, transcribing, organizing, review }
|
||||||
|
@State private var phase: Phase = .idle
|
||||||
|
|
||||||
|
@State private var liveTranscript = ""
|
||||||
|
@State private var recordingSeconds = 0
|
||||||
|
/// 审阅区可编辑的问诊小结(整理稿;整理失败时是录音原文)。最终落库的就是它。
|
||||||
|
@State private var note = ""
|
||||||
|
/// 最终转写原话:「改用原话」回退用,也作整理失败兜底。
|
||||||
|
@State private var rawTranscript = ""
|
||||||
|
/// 录音临时文件(tmp 里的 m4a)。保存时 importFile 搬进 Vault;未保存则 onDisappear 删掉。
|
||||||
|
@State private var audioTempURL: URL?
|
||||||
|
@State private var createdAt: Date = .now
|
||||||
|
@State private var decodeRate: Double = 0
|
||||||
|
@State private var voiceNote: String?
|
||||||
|
@State private var deniedAlert = false
|
||||||
|
@State private var saved = false
|
||||||
|
|
||||||
|
@State private var recordTask: Task<Void, Never>?
|
||||||
|
@State private var organizeTask: Task<Void, Never>?
|
||||||
|
@State private var watchdog: Task<Void, Never>?
|
||||||
|
/// 必须 @State:struct View 重建时普通 let 会换新实例,导致 stop() 落在没在录音的新实例上、
|
||||||
|
/// 而真正录音的老实例关不掉麦克风悬挂(同 DiaryQuickSheet.dictation 的注释)。
|
||||||
|
@State private var recorder = ConsultationRecorder()
|
||||||
|
@FocusState private var noteFocused: Bool
|
||||||
|
|
||||||
|
/// 录音上限 10 分钟(超时由看门狗触发停止并整理)。
|
||||||
|
private let maxSeconds = 600
|
||||||
|
|
||||||
|
/// 本机不支持端侧识别 → 手动录入模式(进入即可打字,无录音)。
|
||||||
|
private var manualMode: Bool { !ConsultationRecorder.isAvailable }
|
||||||
|
|
||||||
|
private var canSave: Bool {
|
||||||
|
!note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
grabber
|
||||||
|
header
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if let note = voiceNote { noteBanner(note) }
|
||||||
|
phaseContent
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
bottomBar
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
.onDisappear(perform: cleanup)
|
||||||
|
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $deniedAlert) {
|
||||||
|
Button(String(appLoc: "前往设置")) {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("问诊录音全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var grabber: some View {
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(width: 40, height: 4)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("记录问诊")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("录音 → 本机转写 → AI 整理成问诊小结")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
TjLockChip()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Phase content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var phaseContent: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
if manualMode { manualEntry } else { idleStart }
|
||||||
|
case .recording:
|
||||||
|
recordingCard
|
||||||
|
case .transcribing:
|
||||||
|
transcribingCard
|
||||||
|
case .organizing:
|
||||||
|
organizingCard
|
||||||
|
case .review:
|
||||||
|
reviewArea
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待开始:大录音按钮 + 三条说明(本机识别 / 可回放 / 不上传)。
|
||||||
|
private var idleStart: some View {
|
||||||
|
VStack(spacing: 18) {
|
||||||
|
Button(action: startRecording) {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Tj.Palette.ink2)
|
||||||
|
.frame(width: 76, height: 76)
|
||||||
|
.shadow(color: Tj.Palette.ink2.opacity(0.3), radius: 12, y: 4)
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.tjScaled( 30, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
Text("开始录音")
|
||||||
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.ink2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 22)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
bullet("stethoscope", String(appLoc: "看医生时录下来,事后不怕记不全"))
|
||||||
|
bullet("waveform", String(appLoc: "录音结束后在本机转写,原声存进加密档案"))
|
||||||
|
bullet("sparkles", String(appLoc: "康康自动整理成主诉/医生建议/复查小节"))
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("仅供个人记录,不构成诊断或用药建议。")
|
||||||
|
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bullet(_ icon: String, _ text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.ink2)
|
||||||
|
.frame(width: 20)
|
||||||
|
Text(text)
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 录音中:计时 + 实时字幕 + 结束按钮。
|
||||||
|
private var recordingCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
|
Text("正在录音 · 声音不上传")
|
||||||
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(Self.timeText(recordingSeconds))
|
||||||
|
.font(.tjScaled( 13, design: .monospaced))
|
||||||
|
.foregroundStyle(recordingSeconds >= maxSeconds - 30 ? Tj.Palette.brick : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 离线转写(SenseVoice)无实时字幕:录音中只给声纹动效 + 说明,结束后整段转写。
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.tjScaled( 44, weight: .regular))
|
||||||
|
.foregroundStyle(Tj.Palette.ink2)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
|
Text("正在录音,结束后会在本机把整段录音转成文字")
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 150)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Button(action: stopAndOrganize) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "stop.circle.fill")
|
||||||
|
Text("结束并整理")
|
||||||
|
}
|
||||||
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 13)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brick)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 录音转写中:整段录音离线转文字(优先本地 SenseVoice,不可用回退本机识别)。无实时字幕,统一转写。
|
||||||
|
private var transcribingCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
|
Text(SenseVoiceASRService.isAvailable
|
||||||
|
? String(appLoc: "正在转写录音 · 本地 SenseVoice")
|
||||||
|
: String(appLoc: "正在转写录音 · 本机识别"))
|
||||||
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
Text("录音已结束,正在本机把整段录音转成文字,请稍候…")
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
AIFlowBar()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 整理中:呼吸流光 + 置灰原话预览 + 取消(取消即用原话)。
|
||||||
|
private var organizingCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
|
Text(decodeRate > 0
|
||||||
|
? String(format: String(appLoc: "正在整理问诊小结 · %.1f tok/s"), decodeRate)
|
||||||
|
: String(appLoc: "正在整理问诊小结 · 本地推理"))
|
||||||
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button(String(appLoc: "用原话")) { cancelOrganize() }
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Text(rawTranscript)
|
||||||
|
.font(.tjScaled( 14))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(6)
|
||||||
|
AIFlowBar()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审阅:录音已附提示 + 可编辑小结 + (改用原话 / 重新整理) + 时间。
|
||||||
|
private var reviewArea: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
if audioTempURL != nil {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "waveform.circle.fill")
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.ink2)
|
||||||
|
Text("录音已保存,可在记录详情里回放")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text(String(appLoc: "问诊小结"))
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
if decodeRate > 0 {
|
||||||
|
Text(String(format: "%.1f tok/s", decodeRate))
|
||||||
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextField(String(appLoc: "整理后的问诊小结,可在这里修改…"),
|
||||||
|
text: $note, axis: .vertical)
|
||||||
|
.font(.tjScaled( 15))
|
||||||
|
.lineLimit(6...16)
|
||||||
|
.focused($noteFocused)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if !rawTranscript.isEmpty && note != rawTranscript {
|
||||||
|
reviewChip("arrow.uturn.backward", String(appLoc: "改用原话")) {
|
||||||
|
withAnimation(.snappy(duration: 0.18)) { note = rawTranscript }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !rawTranscript.isEmpty {
|
||||||
|
reviewChip("arrow.clockwise", String(appLoc: "重新整理")) { reorganize() }
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(String(appLoc: "时间"))
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
DatePicker("", selection: $createdAt, in: ...Date.now)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
AIDisclaimerFooter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reviewChip(_ icon: String, _ label: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: icon).font(.tjScaled( 10, weight: .semibold))
|
||||||
|
Text(label).font(.tjScaled( 11, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动模式(本机不支持识别):直接打字录入问诊小结。
|
||||||
|
private var manualEntry: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text("本机暂不支持录音转写,可手动记录这次问诊")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
TextField(String(appLoc: "记录这次问诊:主诉、医生说了什么、用药、复查…"),
|
||||||
|
text: $note, axis: .vertical)
|
||||||
|
.font(.tjScaled( 15))
|
||||||
|
.lineLimit(6...16)
|
||||||
|
.focused($noteFocused)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
DatePicker("", selection: $createdAt, in: ...Date.now)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bottom bar
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var bottomBar: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle where manualMode:
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(String(appLoc: "取消")) { dismiss() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||||
|
Button(String(appLoc: "保存")) { save() }
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||||
|
.disabled(!canSave)
|
||||||
|
.opacity(canSave ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
case .idle:
|
||||||
|
// 非手动模式的待开始页:开始录音是内容区大按钮,关闭走 header 的 ✕,底部不再占一行。
|
||||||
|
EmptyView()
|
||||||
|
case .review:
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(String(appLoc: "重新录音")) { restartRecording() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||||
|
Button(String(appLoc: "保存")) { save() }
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||||
|
.disabled(!canSave)
|
||||||
|
.opacity(canSave ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
case .recording, .transcribing, .organizing:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noteBanner(_ text: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.amber)
|
||||||
|
Text(text).font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func startRecording() {
|
||||||
|
voiceNote = nil
|
||||||
|
noteFocused = false
|
||||||
|
recordTask = Task { @MainActor in
|
||||||
|
guard await recorder.requestAuthorization() else {
|
||||||
|
deniedAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
liveTranscript = ""
|
||||||
|
recordingSeconds = 0
|
||||||
|
try recorder.start { partial in liveTranscript = partial }
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .recording }
|
||||||
|
watchdog = Task { @MainActor in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard !Task.isCancelled, phase == .recording else { return }
|
||||||
|
recordingSeconds += 1
|
||||||
|
if recordingSeconds >= maxSeconds { stopAndOrganize(); return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("[Consultation] recorder start failed: \(error)")
|
||||||
|
#endif
|
||||||
|
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
|
||||||
|
phase = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAndOrganize() {
|
||||||
|
guard phase == .recording else { return }
|
||||||
|
watchdog?.cancel()
|
||||||
|
organizeTask = Task { @MainActor in
|
||||||
|
let result = await recorder.stop()
|
||||||
|
audioTempURL = result.audioTempURL
|
||||||
|
|
||||||
|
// 系统端侧识别(SFSpeech,录音时顺带跑)作为 SenseVoice 不可用 / 失败时的自动回退稿。
|
||||||
|
var sfFallback = result.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if sfFallback.isEmpty {
|
||||||
|
sfFallback = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 离线转写整段录音:优先本地 SenseVoice(MNN),不可用 / 失败 / 空 → 回退 SFSpeech(守红线 #5)。
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .transcribing }
|
||||||
|
var transcript = ""
|
||||||
|
if SenseVoiceASRService.isAvailable, let url = result.audioTempURL {
|
||||||
|
transcript = (try? await SenseVoiceASRService.shared.transcribe(audioFileURL: url)) ?? ""
|
||||||
|
}
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
if transcript.isEmpty { transcript = sfFallback }
|
||||||
|
rawTranscript = transcript
|
||||||
|
|
||||||
|
guard !transcript.isEmpty else {
|
||||||
|
// 没听清:进审阅让用户手动补;录音(若有)仍可保存。
|
||||||
|
voiceNote = String(appLoc: "没听清,可手动补充或重新录音")
|
||||||
|
note = ""
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .review }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .organizing }
|
||||||
|
await runOrganize(transcript: transcript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用 LLM 整理;成功填小结,取消/失败回退原话(红线 #5)。供首次整理与「重新整理」复用。
|
||||||
|
private func runOrganize(transcript: String) async {
|
||||||
|
do {
|
||||||
|
let organized = try await DiaryAssistService.shared.organizeConsultation(transcript: transcript)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
note = organized.text
|
||||||
|
decodeRate = organized.decodeRate
|
||||||
|
} catch is CancellationError {
|
||||||
|
return // cancelOrganize 已处理回退
|
||||||
|
} catch {
|
||||||
|
note = transcript
|
||||||
|
voiceNote = String(appLoc: "AI 整理没成功,已填入录音原文")
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .review }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelOrganize() {
|
||||||
|
guard phase == .organizing else { return }
|
||||||
|
organizeTask?.cancel()
|
||||||
|
note = rawTranscript
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .review }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审阅区「重新整理」:从原话再跑一遍 LLM(首次整理结果不满意时)。
|
||||||
|
private func reorganize() {
|
||||||
|
guard !rawTranscript.isEmpty else { return }
|
||||||
|
organizeTask?.cancel()
|
||||||
|
decodeRate = 0
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .organizing }
|
||||||
|
organizeTask = Task { @MainActor in
|
||||||
|
await runOrganize(transcript: rawTranscript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审阅区「重新录音」:丢弃当前录音/小结,回到待开始。
|
||||||
|
private func restartRecording() {
|
||||||
|
organizeTask?.cancel()
|
||||||
|
if let url = audioTempURL { try? FileManager.default.removeItem(at: url) }
|
||||||
|
audioTempURL = nil
|
||||||
|
note = ""
|
||||||
|
rawTranscript = ""
|
||||||
|
liveTranscript = ""
|
||||||
|
decodeRate = 0
|
||||||
|
voiceNote = nil
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .idle }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let content = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !content.isEmpty else { return }
|
||||||
|
let entry = DiaryEntry(content: content,
|
||||||
|
createdAt: createdAt,
|
||||||
|
tags: [DiaryEntry.consultationTag])
|
||||||
|
ctx.insert(entry)
|
||||||
|
// 录音搬进加密 Vault 并挂为 Asset(尽力而为:失败也照常存文字)。
|
||||||
|
if let temp = audioTempURL,
|
||||||
|
let savedAsset = try? FileVault.shared.importFile(at: temp, preferredExtension: "m4a") {
|
||||||
|
let asset = Asset(relativePath: savedAsset.relativePath,
|
||||||
|
mimeType: "audio/m4a",
|
||||||
|
bytes: savedAsset.bytes)
|
||||||
|
ctx.insert(asset)
|
||||||
|
entry.assets.append(asset)
|
||||||
|
audioTempURL = nil // 已搬走,cleanup 不再删
|
||||||
|
}
|
||||||
|
try? ctx.save()
|
||||||
|
saved = true
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭时收尾:停录音、撤任务、删未保存的录音临时文件。
|
||||||
|
private func cleanup() {
|
||||||
|
recordTask?.cancel()
|
||||||
|
organizeTask?.cancel()
|
||||||
|
watchdog?.cancel()
|
||||||
|
recorder.abort()
|
||||||
|
if !saved, let url = audioTempURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func timeText(_ seconds: Int) -> String {
|
||||||
|
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ConsultationSheet()
|
||||||
|
.modelContainer(for: [DiaryEntry.self, Asset.self], inMemory: true)
|
||||||
|
}
|
||||||
188
康康/Features/Diary/CompanionAvatarView.swift
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 「聊着记」里的动画小形象「康康」——纯 SwiftUI Shape 拼出的圆脸小生物,零图片资源、零第三方库。
|
||||||
|
/// 全屏语音记录界面用它给「正在听 / 正在想 / 正在说」这几个抽象状态一个有生命感、可爱的落点。
|
||||||
|
///
|
||||||
|
/// 为什么所有连续动画都由「时间 t」纯函数驱动(而非 @State + withAnimation.repeatForever):
|
||||||
|
/// 参照本目录外 `DesignSystem/AIFlowBar.swift` 的先例——这个形象所处的场景(语音流式吐字、每 0.5s
|
||||||
|
/// 刷 tok/s)父视图在高频重绘,隐式 repeatForever 动画会被反复打断/重置 → 看起来「几乎不动」。
|
||||||
|
/// 改用 `TimelineView(.animation)` 按屏幕刷新率直接从时间算出每一帧的形变,与父视图重绘彻底解耦,
|
||||||
|
/// 任何场景下都匀速呼吸/眨眼/说话。
|
||||||
|
///
|
||||||
|
/// 呼吸缩放、眨眼、嘴形、思考点这四种连续形变全部抽成 `nonisolated static` 纯函数(只依赖 t 与 mood,
|
||||||
|
/// 无任何共享状态),既方便单测逐帧断言,也让 body 只负责把算好的数值贴到 Shape 上。mood 之间的离散
|
||||||
|
/// 差异(腮红透明度、眼睛上移、声波环有无)则用 `.animation(.snappy, value: mood)` 平滑过渡。
|
||||||
|
struct CompanionAvatarView: View {
|
||||||
|
/// nonisolated:Equatable 合成实现要在 nonisolated static 纯函数里比较 mood,
|
||||||
|
/// 不标会被默认 MainActor 隔离推成主 actor 隔离(Swift 6 报错)。
|
||||||
|
nonisolated enum Mood: Equatable { case idle, listening, thinking, speaking }
|
||||||
|
|
||||||
|
var mood: Mood
|
||||||
|
var size: CGFloat = 156
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
|
||||||
|
let t = timeline.date.timeIntervalSinceReferenceDate
|
||||||
|
ZStack {
|
||||||
|
// 1 背景光环:最底层的一圈暖沙色,给小家伙一个「站台」
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.sand2)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
|
||||||
|
// 2 倾听声波环 ×2:仅 .listening 出现,由内向外扩散淡出,第二环相位 +0.8s 错开
|
||||||
|
if mood == .listening {
|
||||||
|
Group {
|
||||||
|
soundRing(t: t, phase: 0, color: Tj.Palette.teal)
|
||||||
|
soundRing(t: t, phase: 0.8, color: Tj.Palette.tealSoft)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3-6 脸部整体(身体 + 腮红 + 眼 + 嘴):作为一个整体一起呼吸、一起歪头
|
||||||
|
face(t: t)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.scaleEffect(Self.breathScale(t: t, mood: mood), anchor: .bottom)
|
||||||
|
.rotationEffect(.degrees(headTilt(t: t)), anchor: .bottom)
|
||||||
|
|
||||||
|
// 7 思考点 ×3:仅 .thinking 出现,头顶右上斜排,循环点亮
|
||||||
|
if mood == .thinking {
|
||||||
|
thinkingDots(t: t)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.animation(.snappy(duration: 0.25), value: mood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 子视图(实例方法,默认 MainActor;只把纯函数算出的数值贴到 Shape 上)
|
||||||
|
|
||||||
|
/// 脸:身体 Ellipse 打底,再叠腮红 / 眼睛 / 嘴。整组由外层统一做呼吸缩放与歪头旋转。
|
||||||
|
private func face(t: Double) -> some View {
|
||||||
|
let blushOpacity: Double = (mood == .listening || mood == .speaking) ? 1.0 : 0.7
|
||||||
|
let eyeLift: CGFloat = (mood == .thinking) ? -0.02 * size : 0 // 思考时眼睛上移「往上看」
|
||||||
|
let mouthWidth: CGFloat = (mood == .thinking) ? 0.05 * size : 0.16 * size
|
||||||
|
|
||||||
|
return ZStack {
|
||||||
|
// 身体:柔和纸色椭圆 + 细描边 + 极浅暖影
|
||||||
|
Ellipse()
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
.frame(width: 0.78 * size, height: 0.72 * size)
|
||||||
|
.overlay(Ellipse().strokeBorder(Tj.Palette.line, lineWidth: 1.5))
|
||||||
|
.shadow(color: Tj.Palette.shadow.opacity(0.08),
|
||||||
|
radius: 0.05 * size, x: 0, y: 0.018 * size)
|
||||||
|
|
||||||
|
// 腮红 ×2
|
||||||
|
ForEach([-1.0, 1.0], id: \.self) { side in
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.brickSoft)
|
||||||
|
.frame(width: 0.09 * size, height: 0.09 * size)
|
||||||
|
.opacity(blushOpacity)
|
||||||
|
.offset(x: CGFloat(side) * 0.24 * size, y: 0.075 * size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 眼睛 ×2:竖长胶囊,眨眼 = 竖向压扁;.thinking 时整体上移
|
||||||
|
ForEach([-1.0, 1.0], id: \.self) { side in
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.ink)
|
||||||
|
.frame(width: 0.055 * size, height: 0.11 * size)
|
||||||
|
.scaleEffect(x: 1, y: Self.blinkScaleY(t: t, mood: mood))
|
||||||
|
.offset(x: CGFloat(side) * 0.155 * size, y: -0.03 * size + eyeLift)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 嘴:横胶囊,高度随状态开合;.thinking 时收成 0.05·size 小圆点
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.ink)
|
||||||
|
.frame(width: mouthWidth,
|
||||||
|
height: Self.mouthHeight(t: t, mood: mood, size: size))
|
||||||
|
.offset(y: 0.135 * size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个声波环:base 直径 0.82·size,随相位小数部分从 ×1 放大到 ×1.35 并线性淡出。
|
||||||
|
private func soundRing(t: Double, phase: Double, color: Color) -> some View {
|
||||||
|
let f = Self.frac((t + phase) / 1.6)
|
||||||
|
return Circle()
|
||||||
|
.strokeBorder(color, lineWidth: 2)
|
||||||
|
.frame(width: 0.82 * size, height: 0.82 * size)
|
||||||
|
.scaleEffect(1 + 0.35 * CGFloat(f))
|
||||||
|
.opacity(1 - f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 思考点 ×3:直径 0.05·size,沿右上 45° 斜线排开,逐个点亮成「思考中」气泡。
|
||||||
|
private func thinkingDots(t: Double) -> some View {
|
||||||
|
ZStack {
|
||||||
|
ForEach(0..<3, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.amber)
|
||||||
|
.frame(width: 0.05 * size, height: 0.05 * size)
|
||||||
|
.opacity(Self.thinkingDotOpacity(t: t, index: i))
|
||||||
|
.offset(x: (0.29 + 0.085 * CGFloat(i)) * size,
|
||||||
|
y: -(0.29 + 0.085 * CGFloat(i)) * size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 歪头角度(度):仅 .thinking 时以 2.6s 为周期在 ±2° 间轻摆,其余恒 0。
|
||||||
|
private func headTilt(t: Double) -> Double {
|
||||||
|
mood == .thinking ? 2 * sin(2 * .pi * t / 2.6) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 纯函数(nonisolated,供单测逐帧断言;只依赖 t 与 mood,无共享状态)
|
||||||
|
|
||||||
|
/// 呼吸缩放:各状态不同周期/幅度的正弦。idle 最慢最柔,speaking 最快最促。
|
||||||
|
nonisolated static func breathScale(t: Double, mood: Mood) -> CGFloat {
|
||||||
|
switch mood {
|
||||||
|
case .idle: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 3.2))
|
||||||
|
case .listening: return CGFloat(1 + 0.03 * sin(2 * .pi * t / 1.6))
|
||||||
|
case .thinking: return CGFloat(1 + 0.015 * sin(2 * .pi * t / 2.6))
|
||||||
|
case .speaking: return CGFloat(1 + 0.02 * sin(2 * .pi * t / 0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 眨眼竖向缩放:约每 3.4s 眨一次(压到 0.15 持续 0.12s),.thinking 恒 0.85 凝神不眨。
|
||||||
|
nonisolated static func blinkScaleY(t: Double, mood: Mood) -> CGFloat {
|
||||||
|
if mood == .thinking { return 0.85 }
|
||||||
|
var phase = t.truncatingRemainder(dividingBy: 3.4)
|
||||||
|
if phase < 0 { phase += 3.4 } // 兜住负 t,让单测传任意时刻都成立
|
||||||
|
return phase <= 0.12 ? 0.15 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 嘴高度:idle 微笑一条线,listening/thinking 微张,speaking 随 0.28s 周期开合。
|
||||||
|
nonisolated static func mouthHeight(t: Double, mood: Mood, size: CGFloat) -> CGFloat {
|
||||||
|
switch mood {
|
||||||
|
case .idle: return 0.035 * size
|
||||||
|
case .listening: return 0.05 * size
|
||||||
|
case .thinking: return 0.05 * size
|
||||||
|
case .speaking: return 0.03 * size + 0.05 * size * CGFloat(abs(sin(2 * .pi * t / 0.28)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 思考点透明度:三点各错相位 120° 的正弦,负半周压到 0,形成「跑马灯」逐个点亮。
|
||||||
|
nonisolated static func thinkingDotOpacity(t: Double, index: Int) -> Double {
|
||||||
|
0.25 + 0.75 * max(0.0, sin(2 * .pi * t / 0.9 - Double(index) * 2 * .pi / 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取小数部分(声波环相位用)。
|
||||||
|
private static func frac(_ x: Double) -> Double { x - floor(x) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("四种情绪") {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
ForEach(0..<4, id: \.self) { i in
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
CompanionAvatarView(
|
||||||
|
mood: [.idle, .listening, .thinking, .speaking][i],
|
||||||
|
size: 116
|
||||||
|
)
|
||||||
|
Text(["idle", "listening", "thinking", "speaking"][i])
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(36)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
}
|
||||||
591
康康/Features/Diary/DiaryCompanionView.swift
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「聊着记」——全屏动画形象「康康」与用户的语音多轮对话页。
|
||||||
|
///
|
||||||
|
/// 是什么:进场先拿一份健康数据快照(makeContextJSON)喂给 LLM,康康基于最近记录开场追问;
|
||||||
|
/// 用户按麦克风说一句 → 端侧转写 → 康康共情 + 再追问;聊够(maxRounds 轮)或用户主动收尾时,
|
||||||
|
/// 把整段对话蒸馏成一条日记草稿回传给调用方(DiaryQuickSheet 负责回填正文并关闭 cover)。
|
||||||
|
///
|
||||||
|
/// 状态机(Phase)如何转:
|
||||||
|
/// preparing ──首 token──► speaking ──流式完──► awaitingUser
|
||||||
|
/// awaitingUser ──点麦克风──► recording ──点停/看门狗──► thinking(reply)──► speaking ──► awaitingUser
|
||||||
|
/// awaitingUser ──「聊完了」──► distilling ──► onDraft(结束)
|
||||||
|
/// thinking/speaking ──reply 全空失败──► failed ──重试──► thinking
|
||||||
|
///
|
||||||
|
/// 失败链(红线 #5「不让用户卡在 AI 错误屏」):
|
||||||
|
/// · 开场 opening 失败(全空)→ 落 DiaryChatPrompts.staticOpening + 一次性 toast,继续能聊;
|
||||||
|
/// · 追问 reply 失败(全空且非取消)→ Phase.failed,可「重试」或「不聊了,把原话记下来」;
|
||||||
|
/// · 蒸馏 distill 失败 / 为空 → onDraft(fallbackDraft, usedFallback: true),填用户原话;
|
||||||
|
/// · 流已产出部分文本后再出错(含取消)→ 视同说完,把部分文本并入对话,不吓用户。
|
||||||
|
///
|
||||||
|
/// 边界:UI 不直接调 AIRuntime,全部经 DiaryChatService(红线 #3);推理串行由 AIRuntime actor 保证。
|
||||||
|
struct DiaryCompanionView: View {
|
||||||
|
/// 蒸馏(或兜底拼接)出的草稿回传;usedFallback=true 表示没走成 AI、填的是原话。
|
||||||
|
/// 调用方负责回填正文与关闭 cover;本视图 onDraft 后不要再调 onClose。
|
||||||
|
let onDraft: (_ draft: String, _ usedFallback: Bool) -> Void
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
/// 单轮回答的录音上限。单轮比整段口述短,「说一段」先例是 180s,这里收到 120s。
|
||||||
|
static let maxRecordingSeconds = 120
|
||||||
|
|
||||||
|
// MARK: - 状态机
|
||||||
|
|
||||||
|
enum Phase: Equatable {
|
||||||
|
case preparing // 进场:makeContextJSON + openingLine 流式中(首 token 前)
|
||||||
|
case speaking // 康康气泡打字机流式中(opening 或 reply)
|
||||||
|
case awaitingUser // 等用户:按 mic / 点「聊完了」
|
||||||
|
case recording // 录音中,实时字幕
|
||||||
|
case thinking // reply 已发出、首 token 未到
|
||||||
|
case distilling // 蒸馏中
|
||||||
|
case failed(String) // 可重试错误(仅 reply 全空时进入)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
|
||||||
|
@State private var phase: Phase = .preparing
|
||||||
|
@State private var turns: [HealthExportDialogueTurn] = []
|
||||||
|
/// 流式中的康康气泡文本;完成后 trim 并入 turns,随即清空。
|
||||||
|
@State private var bubbleText = ""
|
||||||
|
@State private var liveTranscript = ""
|
||||||
|
/// 整场对话复用的健康数据快照;进场 makeContextJSON 生成一次。
|
||||||
|
@State private var dataJSON = "{}"
|
||||||
|
@State private var recordingSeconds = 0
|
||||||
|
@State private var lastRate: Double = 0
|
||||||
|
/// 一次性提示条文案(开场兜底 / 没听清等),显示 2.5s 后自动清。
|
||||||
|
@State private var toast: String?
|
||||||
|
@State private var showAbandonConfirm = false
|
||||||
|
@State private var micDeniedAlert = false
|
||||||
|
/// 流式 / 蒸馏任务句柄。onDisappear 与「停」按钮据此取消,不空耗底层解码。
|
||||||
|
@State private var chatTask: Task<Void, Never>?
|
||||||
|
/// 录音计时看门狗(每秒 +1,到点自动停)。
|
||||||
|
@State private var recordingWatchdog: Task<Void, Never>?
|
||||||
|
/// 最近一次用户原话,reply 失败后「重试」复用。
|
||||||
|
@State private var pendingLatest: String?
|
||||||
|
/// .task 只跑一次的闸(startOpening 防重入)。
|
||||||
|
@State private var started = false
|
||||||
|
/// 必须 @State:struct View 重建(任何父状态变化都可能触发)时普通 let 会换成全新实例,
|
||||||
|
/// 导致 stop() 落在没在录音的新服务上返回空串(「没听清」假错误),且真正在录音的老实例
|
||||||
|
/// 关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一(照搬 DiaryQuickSheet 的约定)。
|
||||||
|
@State private var dictation = SpeechDictationService()
|
||||||
|
|
||||||
|
// MARK: - 派生
|
||||||
|
|
||||||
|
/// 形象情绪:思考类三态 → thinking,说话 → speaking,录音 → listening,其余 → idle。
|
||||||
|
private var avatarMood: CompanionAvatarView.Mood {
|
||||||
|
switch phase {
|
||||||
|
case .preparing, .thinking, .distilling: return .thinking
|
||||||
|
case .speaking: return .speaking
|
||||||
|
case .recording: return .listening
|
||||||
|
case .awaitingUser, .failed: return .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasUserTurns: Bool { turns.contains { $0.role == .user } }
|
||||||
|
private var wrappedUp: Bool { DiaryChatService.isWrappedUp(turns: turns) }
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
topBar
|
||||||
|
CompanionAvatarView(mood: avatarMood, size: 150)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.animation(.snappy(duration: 0.25), value: avatarMood)
|
||||||
|
dialogueArea
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
controlArea
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.animation(.snappy(duration: 0.22), value: phase)
|
||||||
|
AIDisclaimerFooter()
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.task { startOpening() }
|
||||||
|
.onChange(of: toast) { _, newValue in
|
||||||
|
// 一次性提示:出现 2.5s 后自动清(被新 toast 顶掉则由新 onChange 接管)。
|
||||||
|
guard newValue != nil else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||||
|
if toast == newValue { toast = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
chatTask?.cancel()
|
||||||
|
recordingWatchdog?.cancel()
|
||||||
|
dictation.abort()
|
||||||
|
}
|
||||||
|
.confirmationDialog(String(appLoc: "聊到一半,要放弃吗?"),
|
||||||
|
isPresented: $showAbandonConfirm,
|
||||||
|
titleVisibility: .visible) {
|
||||||
|
Button("把说过的记下来") {
|
||||||
|
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
|
||||||
|
}
|
||||||
|
Button("直接关闭", role: .destructive) { onClose() }
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
}
|
||||||
|
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $micDeniedAlert) {
|
||||||
|
Button(String(appLoc: "前往设置")) {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 顶栏
|
||||||
|
|
||||||
|
private var topBar: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Button(action: attemptClose) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(Circle().fill(Tj.Palette.paper))
|
||||||
|
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
.contentShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("和康康聊聊")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("聊完帮你整理成一条记录")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
roundDots
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 右上轮次指示:maxRounds 个小圆点,已用满 i 轮的点亮成 brick。
|
||||||
|
private var roundDots: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(0..<DiaryChatPrompts.maxRounds, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.fill(DiaryChatService.roundsUsed(in: turns) > i
|
||||||
|
? Tj.Palette.brick : Tj.Palette.line)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 对话区
|
||||||
|
|
||||||
|
private var dialogueArea: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(turns) { turn in
|
||||||
|
bubbleRow(text: turn.text, isUser: turn.role == .user)
|
||||||
|
}
|
||||||
|
// 流式中的康康气泡:带右下 tok/s(lastRate>0 才显示)。
|
||||||
|
if !bubbleText.isEmpty {
|
||||||
|
bubbleRow(text: bubbleText, isUser: false, rate: lastRate)
|
||||||
|
}
|
||||||
|
// 录音中的实时字幕:走用户样式,brick 半透描边区分「还没定稿」。
|
||||||
|
if phase == .recording, !liveTranscript.isEmpty {
|
||||||
|
bubbleRow(text: liveTranscript, isUser: true,
|
||||||
|
strokeColor: Tj.Palette.brick.opacity(0.5))
|
||||||
|
}
|
||||||
|
if let toast {
|
||||||
|
toastBar(toast)
|
||||||
|
}
|
||||||
|
// 滚动锚点(照搬 DiaryVoicePanel 的 tail 锚点写法)。
|
||||||
|
Color.clear.frame(height: 1).id("tail")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.onChange(of: turns.count) { _, _ in scrollToTail(proxy) }
|
||||||
|
.onChange(of: bubbleText) { _, _ in scrollToTail(proxy) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToTail(_ proxy: ScrollViewProxy) {
|
||||||
|
// 不加显式动画:流式期 bubbleText 每 token 都变,逐次动画滚动会抖(照搬 DiaryVoicePanel:94-96)。
|
||||||
|
proxy.scrollTo("tail", anchor: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条对话气泡。康康(assistant)靠左 paper 底 + lineSoft 描边;用户靠右 sand2 底。
|
||||||
|
/// - rate: 非空且 >0 时在右下角挂一行 tok/s(仅流式气泡用)。
|
||||||
|
/// - strokeColor: 覆盖默认描边(录音实时字幕用 brick 半透)。
|
||||||
|
@ViewBuilder
|
||||||
|
private func bubbleRow(text: String,
|
||||||
|
isUser: Bool,
|
||||||
|
rate: Double? = nil,
|
||||||
|
strokeColor: Color? = nil) -> some View {
|
||||||
|
let stroke = strokeColor ?? (isUser ? Color.clear : Tj.Palette.lineSoft)
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
if isUser { Spacer(minLength: 40) }
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(text)
|
||||||
|
.font(.tjScaled(15))
|
||||||
|
.lineSpacing(3)
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let rate, rate > 0 {
|
||||||
|
Text(String(format: "%.1f tok/s", rate))
|
||||||
|
.font(.tjScaled(10, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(isUser ? Tj.Palette.sand2 : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(stroke, lineWidth: 1)
|
||||||
|
)
|
||||||
|
if !isUser { Spacer(minLength: 40) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 居中一次性提示条(amber 图标 + text2 文字)。text 为已本地化字符串,直接展示。
|
||||||
|
private func toastBar(_ text: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
Text(text)
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 控制区
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var controlArea: some View {
|
||||||
|
Group {
|
||||||
|
switch phase {
|
||||||
|
case .preparing:
|
||||||
|
statusBlock(String(appLoc: "康康正在翻你的记录…"))
|
||||||
|
|
||||||
|
case .thinking:
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text("康康在想…")
|
||||||
|
.font(.tjScaled(13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
AIFlowBar().frame(maxWidth: 200)
|
||||||
|
Button("停") { stopThinking() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .distilling:
|
||||||
|
// 蒸馏最多几秒,且 onDisappear 兜底取消,不给「停」按钮。
|
||||||
|
statusBlock(String(appLoc: "康康在帮你整理…"))
|
||||||
|
|
||||||
|
case .speaking:
|
||||||
|
// 流式吐字中不放按钮,等说完自然进 awaitingUser。
|
||||||
|
Color.clear.frame(height: 1)
|
||||||
|
|
||||||
|
case .awaitingUser:
|
||||||
|
awaitingControls
|
||||||
|
|
||||||
|
case .recording:
|
||||||
|
recordingControls
|
||||||
|
|
||||||
|
case .failed(let msg):
|
||||||
|
failedControls(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(minHeight: 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「正在忙」通用块:一句状态 + 流光条。preparing / distilling 共用。
|
||||||
|
private func statusBlock(_ text: String) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(text)
|
||||||
|
.font(.tjScaled(13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
AIFlowBar().frame(maxWidth: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var awaitingControls: some View {
|
||||||
|
if wrappedUp {
|
||||||
|
// 聊够了:麦克风隐藏,只留「聊完了」主按钮收尾。
|
||||||
|
Button("聊完了,帮我记下来") { finishChat() }
|
||||||
|
.buttonStyle(TjPrimaryButton())
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Button(action: startRecording) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Tj.Palette.ink)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.tjScaled(26, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
.contentShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Text("点一下,说给康康听")
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
|
||||||
|
if hasUserTurns {
|
||||||
|
Button("聊完了,帮我记下来") { finishChat() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recordingControls: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Button(action: stopAndReply) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Tj.Palette.brick)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
.font(.tjScaled(24, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
.contentShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Text(timeString(recordingSeconds))
|
||||||
|
.font(.tjMono(12))
|
||||||
|
.foregroundStyle(recordingSeconds >= Self.maxRecordingSeconds - 20
|
||||||
|
? Tj.Palette.brick : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Text("说完了")
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failedControls(_ msg: String) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(msg) // 运行期错误描述(变量),原样展示,不再查表。
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button("重试") { retryReply() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
|
||||||
|
if hasUserTurns {
|
||||||
|
Button("不聊了,把原话记下来") {
|
||||||
|
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjGhostButton(height: 40, fontSize: 13, horizontalPadding: 18))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeString(_ seconds: Int) -> String {
|
||||||
|
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 动作:开场与流式消费
|
||||||
|
|
||||||
|
/// 进场只跑一次:生成健康数据快照,再流式拉开场白。
|
||||||
|
private func startOpening() {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
dataJSON = DiaryChatService.shared.makeContextJSON(in: ctx)
|
||||||
|
consumeBubbleStream(DiaryChatService.shared.openingLine(dataJSON: dataJSON),
|
||||||
|
isOpening: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消费一条流式回应(opening 或 reply):逐 token 灌进 bubbleText,完成后并入 turns。
|
||||||
|
/// 失败语义见文件头「失败链」;含取消在内,只要已有部分文本一律视同说完并入,不吓用户。
|
||||||
|
private func consumeBubbleStream(_ stream: AsyncThrowingStream<TokenChunk, Error>,
|
||||||
|
isOpening: Bool) {
|
||||||
|
chatTask = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
for try await chunk in stream {
|
||||||
|
if bubbleText.isEmpty, !chunk.text.isEmpty {
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .speaking }
|
||||||
|
}
|
||||||
|
bubbleText += chunk.text
|
||||||
|
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||||
|
}
|
||||||
|
// —— 正常完成 ——
|
||||||
|
let done = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
bubbleText = ""
|
||||||
|
if !done.isEmpty {
|
||||||
|
turns.append(.assistant(done))
|
||||||
|
} else if isOpening {
|
||||||
|
// 极端:正常完成却全空(streamWithFallback 通常抛 .empty,这里兜一手)。
|
||||||
|
turns.append(.assistant(DiaryChatPrompts.staticOpening))
|
||||||
|
toast = String(appLoc: "康康没连上,先用老一套开场啦")
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
|
||||||
|
} catch {
|
||||||
|
// —— 流出错(含 CancellationError) ——
|
||||||
|
let partial = bubbleText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
bubbleText = ""
|
||||||
|
if !partial.isEmpty {
|
||||||
|
// 已产出内容:视同说完并入,不算失败。
|
||||||
|
turns.append(.assistant(partial))
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
|
||||||
|
} else if isOpening {
|
||||||
|
// 开场全空:落静态开场白 + 一次性提示,继续能聊。
|
||||||
|
turns.append(.assistant(DiaryChatPrompts.staticOpening))
|
||||||
|
toast = String(appLoc: "康康没连上,先用老一套开场啦")
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
|
||||||
|
} else if error is CancellationError {
|
||||||
|
// 追问被主动取消:回等待,不算失败。
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
|
||||||
|
} else {
|
||||||
|
// 追问全空失败:可重试。
|
||||||
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
phase = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「停」:取消流式追问,回等待态(consumeBubbleStream 的取消分支也会兜到 awaitingUser)。
|
||||||
|
private func stopThinking() {
|
||||||
|
chatTask?.cancel()
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 动作:录音与追问
|
||||||
|
|
||||||
|
/// 请求权限 → 开录音 → 起看门狗。权限拒 → alert;start 抛错 → toast 提示。
|
||||||
|
private func startRecording() {
|
||||||
|
toast = nil
|
||||||
|
chatTask = Task { @MainActor in
|
||||||
|
guard await dictation.requestAuthorization() else {
|
||||||
|
micDeniedAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
liveTranscript = ""
|
||||||
|
recordingSeconds = 0
|
||||||
|
try dictation.start { partial in liveTranscript = partial }
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .recording }
|
||||||
|
// 计时 + 看门狗(照搬 DiaryQuickSheet:701 结构,到点自动 stopAndReply)。
|
||||||
|
recordingWatchdog = Task { @MainActor in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard !Task.isCancelled, phase == .recording else { return }
|
||||||
|
recordingSeconds += 1
|
||||||
|
if recordingSeconds >= Self.maxRecordingSeconds {
|
||||||
|
stopAndReply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast = String(appLoc: "没听清,再试一次")
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止录音取最终稿(空则用实时字幕兜底,照搬 DiaryQuickSheet:691-695),追加为用户轮并发起追问。
|
||||||
|
private func stopAndReply() {
|
||||||
|
guard phase == .recording else { return }
|
||||||
|
recordingWatchdog?.cancel()
|
||||||
|
chatTask = Task { @MainActor in
|
||||||
|
var text = (await dictation.stop())
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if text.isEmpty {
|
||||||
|
text = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
guard !text.isEmpty else {
|
||||||
|
toast = String(appLoc: "没听清,再试一次")
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .awaitingUser }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingLatest = text
|
||||||
|
turns.append(.user(text))
|
||||||
|
liveTranscript = ""
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .thinking }
|
||||||
|
sendReply(latest: text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发起一轮追问。transcript 用 dropLast() 去掉刚 append 的最新用户轮(它单独走 latest 参数,
|
||||||
|
/// 避免重复);roundsUsed 只数 assistant 轮,不受这条用户轮影响,直接从当前 turns 算即正确。
|
||||||
|
private func sendReply(latest: String) {
|
||||||
|
let prior = HealthExportDialogueTurn.transcript(from: Array(turns.dropLast()))
|
||||||
|
let stream = DiaryChatService.shared.reply(
|
||||||
|
transcript: prior,
|
||||||
|
latest: latest,
|
||||||
|
dataJSON: dataJSON,
|
||||||
|
roundsUsed: DiaryChatService.roundsUsed(in: turns)
|
||||||
|
)
|
||||||
|
consumeBubbleStream(stream, isOpening: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重试上一轮追问:turns 里失败那轮的用户话仍在,复用 pendingLatest。
|
||||||
|
private func retryReply() {
|
||||||
|
guard let latest = pendingLatest else { return }
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .thinking }
|
||||||
|
sendReply(latest: latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 动作:收尾与关闭
|
||||||
|
|
||||||
|
/// 「聊完了」:蒸馏整段对话成日记草稿回传。失败 / 为空 → 落用户原话(usedFallback=true)。
|
||||||
|
private func finishChat() {
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { phase = .distilling }
|
||||||
|
chatTask = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let r = try await DiaryChatService.shared.distill(turns: turns)
|
||||||
|
onDraft(r.text.isEmpty ? DiaryChatService.fallbackDraft(turns: turns) : r.text,
|
||||||
|
r.text.isEmpty)
|
||||||
|
} catch {
|
||||||
|
onDraft(DiaryChatService.fallbackDraft(turns: turns), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭:聊过内容才二次确认(避免误关丢失),否则直接关。
|
||||||
|
private func attemptClose() {
|
||||||
|
if hasUserTurns {
|
||||||
|
showAbandonConfirm = true
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
// 无真实数据时,开场 openingLine 落 staticOpening 兜底,正好看整页布局。
|
||||||
|
DiaryCompanionView(onDraft: { _, _ in }, onClose: {})
|
||||||
|
.modelContainer(for: [
|
||||||
|
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
|
||||||
|
ChatTurn.self, Symptom.self, UserProfile.self,
|
||||||
|
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
@@ -6,13 +6,19 @@ import SwiftData
|
|||||||
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
|
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
|
||||||
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
|
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
|
||||||
struct DiaryQuickSheet: View {
|
struct DiaryQuickSheet: View {
|
||||||
|
/// 语音「写日记」直达:隐藏顶部 2×2 入口选择,进入即聚焦正文直接写。
|
||||||
|
/// 默认 false —— 从「新建」菜单进入时仍保留四选入口。
|
||||||
|
var directWrite: Bool = false
|
||||||
|
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var content: String = ""
|
@State private var content: String = ""
|
||||||
@State private var createdAt: Date = .now
|
@State private var createdAt: Date = .now
|
||||||
/// 「拍药盒」分支:全屏扫描流程,确认后存为带「用药」tag 的日记。
|
/// 「拍药盒」分支:全屏扫描流程,识别后入药品库。
|
||||||
@State private var showMedicationScan = false
|
@State private var showMedicationScan = false
|
||||||
|
/// 「用药」分支:记一次服用(选药 + 剂量 + 时间),存为带「用药」tag 的日记。
|
||||||
|
@State private var showMedicationLog = false
|
||||||
/// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。
|
/// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。
|
||||||
@State private var showSymptomStart = false
|
@State private var showSymptomStart = false
|
||||||
|
|
||||||
@@ -25,21 +31,25 @@ struct DiaryQuickSheet: View {
|
|||||||
}
|
}
|
||||||
@State private var phase: AssistPhase = .idle
|
@State private var phase: AssistPhase = .idle
|
||||||
@State private var questions: [DiaryAssistService.Question] = []
|
@State private var questions: [DiaryAssistService.Question] = []
|
||||||
|
/// 当前这道追问的「自由回答」输入。采纳(加入日记)或跳过、切到下一题时清空。
|
||||||
|
@State private var currentAnswer: String = ""
|
||||||
@State private var lastRate: Double = 0
|
@State private var lastRate: Double = 0
|
||||||
@State private var currentRound: Int = 0
|
@State private var currentRound: Int = 0
|
||||||
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
||||||
@State private var coveredDims: Set<String> = []
|
@State private var coveredDims: Set<String> = []
|
||||||
@State private var suggestTask: Task<Void, Never>?
|
@State private var suggestTask: Task<Void, Never>?
|
||||||
/// 当前正在「就地填空」的 question id;nil = 没有展开的填空面板。
|
/// 关心条里被用户「跳过」的 question id。跳过的不从 questions 里删(其维度已计入
|
||||||
@State private var fillingId: UUID?
|
/// coveredDims,下一轮不会再问),只是不再排进关心条队列。
|
||||||
/// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。
|
@State private var skippedQuestionIDs: Set<UUID> = []
|
||||||
@State private var fillValues: [String] = []
|
/// 上一轮「再想想」没问出任何新维度(全被去重)时为 true,提示用户主要维度已问全。
|
||||||
/// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。
|
|
||||||
@State private var exhaustedNote = false
|
@State private var exhaustedNote = false
|
||||||
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
||||||
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
||||||
@State private var detent: PresentationDetent = .large
|
@State private var detent: PresentationDetent = .large
|
||||||
@FocusState private var contentFocused: Bool
|
@FocusState private var contentFocused: Bool
|
||||||
|
/// 追问答案输入框的聚焦态。与 contentFocused 分开:答题时聚焦的是答案框、不是正文框,
|
||||||
|
/// 两者各自独立避让键盘,互不打架。
|
||||||
|
@FocusState private var answerFocused: Bool
|
||||||
|
|
||||||
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
||||||
|
|
||||||
@@ -62,6 +72,11 @@ struct DiaryQuickSheet: View {
|
|||||||
/// 且真正在录音的老实例关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一。
|
/// 且真正在录音的老实例关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一。
|
||||||
@State private var dictation = SpeechDictationService()
|
@State private var dictation = SpeechDictationService()
|
||||||
|
|
||||||
|
// MARK: 「聊着记」形象对话模式
|
||||||
|
|
||||||
|
/// 全屏形象对话(DiaryCompanionView),聊完蒸馏成日记草稿回填正文。
|
||||||
|
@State private var showCompanionChat = false
|
||||||
|
|
||||||
private var hasContent: Bool {
|
private var hasContent: Bool {
|
||||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
}
|
||||||
@@ -73,6 +88,40 @@ struct DiaryQuickSheet: View {
|
|||||||
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||||||
private var canSubmit: Bool { hasContent }
|
private var canSubmit: Bool { hasContent }
|
||||||
|
|
||||||
|
// MARK: - 关心条(care bar)状态
|
||||||
|
|
||||||
|
/// 收敛后的「关心」状态:把 phase + 队列 + 语音占用,归并成一个枚举。
|
||||||
|
/// 键盘正上方的关心条与正文里的回落卡共用同一份状态,保证两处显示一致。
|
||||||
|
private enum CareState {
|
||||||
|
case hidden // 没内容 / 语音占用,不打扰
|
||||||
|
case prompt // 有内容、还没生成,邀请用户让康康想想
|
||||||
|
case thinking // 正在本地推理
|
||||||
|
case asking(DiaryAssistService.Question) // 当前轮到要问的一句
|
||||||
|
case caughtUp(exhausted: Bool) // 队列已清空;exhausted=这轮没问出新东西
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 还没被采纳 / 跳过的问题队列。关心条一次只露出队首一句(其余按维度排队等着)。
|
||||||
|
private var pendingQuestions: [DiaryAssistService.Question] {
|
||||||
|
questions.filter { !$0.adopted && !skippedQuestionIDs.contains($0.id) }
|
||||||
|
}
|
||||||
|
private var currentCareQuestion: DiaryAssistService.Question? { pendingQuestions.first }
|
||||||
|
|
||||||
|
private var careState: CareState {
|
||||||
|
if voicePhase != .idle { return .hidden }
|
||||||
|
switch phase {
|
||||||
|
case .loading:
|
||||||
|
return .thinking
|
||||||
|
case .failed(let err):
|
||||||
|
return .failed(err.localizedDescription)
|
||||||
|
case .idle:
|
||||||
|
return hasContent ? .prompt : .hidden
|
||||||
|
case .ready:
|
||||||
|
if let q = currentCareQuestion { return .asking(q) }
|
||||||
|
return hasContent ? .caughtUp(exhausted: exhaustedNote) : .hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Capsule()
|
Capsule()
|
||||||
@@ -86,7 +135,7 @@ struct DiaryQuickSheet: View {
|
|||||||
Text("健康记录")
|
Text("健康记录")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
Text("记录身体状态 · 康康在一旁帮你想还能记点啥")
|
||||||
.font(.tjScaled( 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
@@ -98,25 +147,12 @@ struct DiaryQuickSheet: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
// 入口三选一:写日记(本页)/ 拍药盒(存「用药」日记)/ 记症状(SymptomStartSheet)
|
// 模式入口(写日记 / 用药 / 拍药盒 / 记症状)只在「还没动笔」时露出:
|
||||||
HStack(spacing: 10) {
|
// 一旦聚焦正文或开始打字就收起,把写日记界面让出来,保持清爽
|
||||||
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
|
//(对齐用药/症状那种打开即专一表单的观感)。
|
||||||
subtitle: String(appLoc: "文字或语音"), active: true) {
|
modeSelector
|
||||||
contentFocused = true
|
.animation(.snappy(duration: 0.22), value: showModeSelector)
|
||||||
}
|
|
||||||
modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"),
|
|
||||||
subtitle: String(appLoc: "识别用药"), active: false) {
|
|
||||||
showMedicationScan = true
|
|
||||||
}
|
|
||||||
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
|
|
||||||
subtitle: String(appLoc: "持续追踪"), active: false) {
|
|
||||||
showSymptomStart = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 14)
|
|
||||||
|
|
||||||
ScrollViewReader { proxy in
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -124,6 +160,25 @@ struct DiaryQuickSheet: View {
|
|||||||
sectionLabel(String(appLoc: "内容"))
|
sectionLabel(String(appLoc: "内容"))
|
||||||
Spacer()
|
Spacer()
|
||||||
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||||||
|
// 「聊着记」:全屏形象对话模式,和「说一段」同显隐条件。
|
||||||
|
Button(action: startCompanionChat) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||||
|
.font(.tjScaled(11, weight: .semibold))
|
||||||
|
Text("聊着记")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().strokeBorder(
|
||||||
|
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
|
||||||
|
lineWidth: 1))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isLoading)
|
||||||
|
|
||||||
Button(action: startVoice) {
|
Button(action: startVoice) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "mic.fill")
|
Image(systemName: "mic.fill")
|
||||||
@@ -172,12 +227,12 @@ struct DiaryQuickSheet: View {
|
|||||||
|
|
||||||
if let note = voiceNote {
|
if let note = voiceNote {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
.font(.tjScaled(11))
|
.font(.tjScaled(11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
Text(note)
|
Text(note)
|
||||||
.font(.tjScaled(11))
|
.font(.tjScaled(11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,31 +265,45 @@ struct DiaryQuickSheet: View {
|
|||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
// 底部锚点,新一轮 question 进来后自动滚到这里
|
|
||||||
Color.clear.frame(height: 1).id("assist-bottom")
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onChange(of: questions.count) { old, new in
|
|
||||||
guard new > old else { return }
|
|
||||||
// 滚到新一轮的 round divider(让用户先看到「第 N 轮」的标签,
|
|
||||||
// 再依次看到这一轮的 questions)
|
|
||||||
let roundId = "round-\(questions[old].round)"
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
||||||
withAnimation(.easeOut(duration: 0.25)) {
|
|
||||||
proxy.scrollTo(roundId, anchor: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 8) {
|
||||||
Button("取消") { dismiss() }
|
Button("取消") { dismiss() }
|
||||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||||
|
// 「康康帮你一起填」:AI 协作主入口,移到底部和保存同一行(spec 2026-06-17)。
|
||||||
|
// 中间 flexible 占主宽、最醒目;想想中时禁用并显示进度脉冲。
|
||||||
|
Button(action: requestSuggestions) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.symbolEffect(.pulse, options: .repeating, isActive: isLoading)
|
||||||
|
Text(isLoading ? String(appLoc: "想想中…") : String(appLoc: "康康帮你一起填"))
|
||||||
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.foregroundStyle(canRequestSuggest ? Tj.Palette.brick : Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brick.opacity(canRequestSuggest ? 0.12 : 0.05))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.brick.opacity(canRequestSuggest ? 0.4 : 0.15),
|
||||||
|
lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canRequestSuggest)
|
||||||
Button("保存") { submit() }
|
Button("保存") { submit() }
|
||||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||||
.disabled(!canSubmit)
|
.disabled(!canSubmit)
|
||||||
.opacity(canSubmit ? 1 : 0.4)
|
.opacity(canSubmit ? 1 : 0.4)
|
||||||
}
|
}
|
||||||
@@ -252,18 +321,43 @@ struct DiaryQuickSheet: View {
|
|||||||
.presentationCornerRadius(Tj.Radius.xl)
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
.fullScreenCover(isPresented: $showMedicationScan) {
|
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||||
MedicationScanFlow(
|
MedicationScanFlow(
|
||||||
onSave: { entries in
|
onSave: { meds, images in
|
||||||
// 落库:「用药」日记(进记录时间线)+ 同步个人资料·当前用药。
|
// 识别后入药品库(含原图),不再写日记。服用流水走「写日记 · 用药」模式。
|
||||||
MedicationArchiver.archive(entries: entries, in: ctx)
|
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
|
||||||
dismiss()
|
dismiss()
|
||||||
},
|
},
|
||||||
onClose: { showMedicationScan = false }
|
onClose: { showMedicationScan = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $showCompanionChat) {
|
||||||
|
// 「聊着记」:和康康形象聊天,聊完蒸馏成日记草稿回填正文,由用户确认后走现有保存。
|
||||||
|
DiaryCompanionView(
|
||||||
|
onDraft: { draft, usedFallback in
|
||||||
|
appendToContent(draft)
|
||||||
|
voiceNote = usedFallback
|
||||||
|
? String(appLoc: "AI 整理没成功,已填入你聊天说的原话")
|
||||||
|
: String(appLoc: "康康把聊的内容整理好了,看一眼没问题就点保存")
|
||||||
|
showCompanionChat = false
|
||||||
|
},
|
||||||
|
onClose: { showCompanionChat = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showSymptomStart) {
|
.sheet(isPresented: $showSymptomStart) {
|
||||||
// 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。
|
// 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。
|
||||||
SymptomStartSheet()
|
SymptomStartSheet()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showMedicationLog) {
|
||||||
|
// 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。
|
||||||
|
MedicationLogSheet()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 语音「写日记」直达:进入即聚焦正文,光标直接落在输入框,免去再点一下。
|
||||||
|
// 轻微延迟等 sheet 呈现动画落定,否则首帧聚焦偶发不弹键盘。
|
||||||
|
guard directWrite else { return }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||||||
|
contentFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
suggestTask?.cancel()
|
suggestTask?.cancel()
|
||||||
voiceFlowTask?.cancel()
|
voiceFlowTask?.cancel()
|
||||||
@@ -282,180 +376,37 @@ struct DiaryQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AI 辅助区
|
// MARK: - 关心条(care bar)视图
|
||||||
|
|
||||||
|
/// 正文里的「回落卡」:键盘收起时(关心条随键盘一起消失)在这里接住同一份 careState,
|
||||||
|
/// 并承载 AI 免责声明。键盘弹起时整块让位给键盘正上方的关心条,避免两处重复。
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var assistSection: some View {
|
private var assistSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
// section header
|
if !contentFocused {
|
||||||
|
switch careState {
|
||||||
|
case .hidden, .prompt:
|
||||||
|
// prompt(有内容、还没生成)不在正文区显示卡片:AI 入口已常驻底部
|
||||||
|
//「康康帮你一起填」按钮,正文区保持干净。
|
||||||
|
EmptyView()
|
||||||
|
default:
|
||||||
|
// 已在协作(想想 / 追问 / 问完 / 失败):挂抬头 + tok/s + 关心条卡片。
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
|
sectionLabel(String(appLoc: "康康帮你记"))
|
||||||
Spacer()
|
Spacer(minLength: 0)
|
||||||
if hasQuestions {
|
|
||||||
Text("\(questions.count) 个建议")
|
|
||||||
.font(.tjScaled( 10, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
if lastRate > 0 {
|
if lastRate > 0 {
|
||||||
Text(String(format: "%.1f tok/s", lastRate))
|
Text(String(format: "%.1f tok/s", lastRate))
|
||||||
.font(.tjScaled( 10, design: .monospaced))
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
careBarRow(compact: false)
|
||||||
// 累积的 questions 列表(多轮,带轮次分隔)
|
.padding(12)
|
||||||
if hasQuestions {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
|
|
||||||
if idx == 0 || questions[idx - 1].round != q.round {
|
|
||||||
roundDivider(round: q.round,
|
|
||||||
count: questions.filter { $0.round == q.round }.count)
|
|
||||||
.id("round-\(q.round)")
|
|
||||||
}
|
|
||||||
questionRow(index: roundLocalIndex(at: idx), question: q)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AIDisclaimerFooter()
|
|
||||||
}
|
|
||||||
|
|
||||||
if exhaustedNote {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "checkmark.seal.fill")
|
|
||||||
.font(.tjScaled( 11))
|
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
|
||||||
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
|
||||||
.font(.tjScaled( 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部主操作按钮(状态机驱动)
|
|
||||||
phaseFooter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var phaseFooter: some View {
|
|
||||||
switch phase {
|
|
||||||
case .idle:
|
|
||||||
assistPrimaryButton(
|
|
||||||
icon: "sparkles",
|
|
||||||
label: canRequestSuggest
|
|
||||||
? String(appLoc: "让 AI 帮我想想还能记什么")
|
|
||||||
: String(appLoc: "先写几个字,AI 来帮忙补充"),
|
|
||||||
enabled: canRequestSuggest,
|
|
||||||
prominent: true,
|
|
||||||
action: requestSuggestions
|
|
||||||
)
|
|
||||||
|
|
||||||
case .loading:
|
|
||||||
assistLoadingIndicator
|
|
||||||
|
|
||||||
case .ready:
|
|
||||||
assistPrimaryButton(
|
|
||||||
icon: "arrow.clockwise",
|
|
||||||
label: canRequestSuggest
|
|
||||||
? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
|
|
||||||
: String(appLoc: "更新一下原文,再让 AI 继续追问"),
|
|
||||||
enabled: canRequestSuggest,
|
|
||||||
action: requestSuggestions
|
|
||||||
)
|
|
||||||
|
|
||||||
case .failed(let err):
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
|
||||||
Text(err.localizedDescription)
|
|
||||||
.font(.tjScaled( 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
Button { requestSuggestions() } label: {
|
|
||||||
Text("重试")
|
|
||||||
.font(.tjScaled( 12, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.fill(Tj.Palette.brickSoft.opacity(0.5))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 辅助主按钮。`prominent` 为真走实心强调样式(填充 brick + 白字 + 轻投影,一眼可点),
|
|
||||||
/// 否则走低调描边样式(用于 .ready 的「再问一轮」)。
|
|
||||||
private func assistPrimaryButton(icon: String,
|
|
||||||
label: String,
|
|
||||||
enabled: Bool,
|
|
||||||
prominent: Bool = false,
|
|
||||||
action: @escaping () -> Void) -> some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
|
|
||||||
.foregroundStyle(prominent
|
|
||||||
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
|
|
||||||
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, prominent ? 14 : 11)
|
|
||||||
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
|
|
||||||
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
|
|
||||||
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
if prominent {
|
|
||||||
shape
|
|
||||||
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
|
|
||||||
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
|
|
||||||
radius: 8, x: 0, y: 3)
|
|
||||||
} else {
|
|
||||||
shape
|
|
||||||
.strokeBorder(
|
|
||||||
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
|
||||||
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// .loading 等待态:安静的 paper 卡片,底部一条细窄的不确定进度条来回滑动(Linear/Vercel 式极简)。
|
|
||||||
/// 不用高亮扫光、不填强调色,避免刺眼;只靠细线 + sparkles 轻脉冲传达「在算」。
|
|
||||||
private var assistLoadingIndicator: some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.font(.tjScaled( 12, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
|
||||||
.symbolEffect(.pulse, options: .repeating)
|
|
||||||
Text(lastRate > 0
|
|
||||||
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
|
|
||||||
: String(appLoc: "AI 生成中 · 本地推理"))
|
|
||||||
.font(.tjScaled( 13, weight: .medium))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Button("取消") { cancelSuggestions() }
|
|
||||||
.font(.tjScaled( 12, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 11)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.fill(Tj.Palette.paper)
|
.fill(Tj.Palette.paper)
|
||||||
@@ -464,122 +415,192 @@ struct DiaryQuickSheet: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
)
|
)
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
AIFlowBar().padding(.horizontal, 1)
|
|
||||||
}
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
}
|
||||||
|
}
|
||||||
|
// 用过一次 AI 后,免责声明常驻(键盘弹起时关心条在上方,这条留在正文里兜合规)。
|
||||||
|
if !questions.isEmpty {
|
||||||
|
AIDisclaimerFooter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 聚焦/失焦切换(关心条在键盘上 ↔ 在正文卡片)平滑过渡,消除布局跳动。
|
||||||
|
.animation(.snappy(duration: 0.22), value: contentFocused)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
|
/// 关心条的统一渲染。`compact = true` 给键盘正上方那条(单行紧凑);
|
||||||
private func roundLocalIndex(at idx: Int) -> Int {
|
/// `compact = false` 给正文回落卡(问题可换两行、留白更松)。两处共用同一 careState 与动作。
|
||||||
let target = questions[idx].round
|
@ViewBuilder
|
||||||
var count = 0
|
private func careBarRow(compact: Bool) -> some View {
|
||||||
for i in 0...idx where questions[i].round == target {
|
switch careState {
|
||||||
count += 1
|
case .hidden:
|
||||||
}
|
EmptyView()
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 第 N 轮的分隔条 —— 让用户清楚下一轮 LLM 看到的是更新过的最新文本。
|
case .prompt:
|
||||||
private func roundDivider(round: Int, count: Int) -> some View {
|
Button(action: requestSuggestions) {
|
||||||
|
careCapsule(icon: "sparkles",
|
||||||
|
text: String(appLoc: "让康康帮你把这条记得更全"),
|
||||||
|
tint: Tj.Palette.brick, style: .soft, compact: compact)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canRequestSuggest)
|
||||||
|
|
||||||
|
case .thinking:
|
||||||
|
VStack(alignment: .leading, spacing: 9) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 6) {
|
Image(systemName: "sparkles")
|
||||||
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
|
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
Text(round == 1
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
? String(appLoc: "第 1 轮 · \(count) 条")
|
Text(lastRate > 0
|
||||||
: String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
|
? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate)
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
: String(appLoc: "康康在想想…"))
|
||||||
.tracking(0.3)
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
}
|
|
||||||
Rectangle()
|
|
||||||
.fill(Tj.Palette.line)
|
|
||||||
.frame(height: 1)
|
|
||||||
.mask(
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
ForEach(0..<60, id: \.self) { _ in
|
|
||||||
Rectangle().frame(width: 3, height: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.top, round == 1 ? 0 : 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
|
|
||||||
let adopted = question.adopted
|
|
||||||
let filling = fillingId == question.id
|
|
||||||
return VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
Text("\(index).")
|
|
||||||
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
|
|
||||||
Text(question.q)
|
|
||||||
.font(.tjScaled( 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.strikethrough(adopted, color: Tj.Palette.text3)
|
Spacer(minLength: 0)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Button(action: cancelSuggestions) {
|
||||||
Spacer(minLength: 4)
|
Text("停")
|
||||||
|
|
||||||
if adopted {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.font(.tjScaled( 10, weight: .bold))
|
|
||||||
Text("已采纳")
|
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
|
||||||
}
|
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
.background(Capsule().fill(Tj.Palette.leafSoft))
|
|
||||||
} else if !filling {
|
|
||||||
Button { adopt(question) } label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "plus.circle.fill")
|
|
||||||
.font(.tjScaled( 12))
|
|
||||||
Text("采纳")
|
|
||||||
.font(.tjScaled( 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 10)
|
}
|
||||||
.padding(.vertical, 5)
|
// 「智能推理中」的彩色呼吸条(Apple 风格流光线,与语音整理 / 报告生成共用 AIFlowBar)。
|
||||||
.background(Capsule().fill(Tj.Palette.ink))
|
// 正文协作卡片宽度确定,放这里;keyboard toolbar(compact)里 GeometryReader 取不到
|
||||||
|
// 稳定宽度,且 requestSuggestions 进推理已收键盘,thinking 必落在本卡片,无需 compact 版。
|
||||||
|
if !compact {
|
||||||
|
AIFlowBar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .asking(let q):
|
||||||
|
// 键盘工具条已移除,asking 始终落在正文里的「问答卡片」。
|
||||||
|
askingCard(q)
|
||||||
|
|
||||||
|
case .caughtUp(let exhausted):
|
||||||
|
Button(action: requestSuggestions) {
|
||||||
|
careCapsule(
|
||||||
|
icon: exhausted ? "checkmark.seal.fill" : "sparkles",
|
||||||
|
text: exhausted
|
||||||
|
? String(appLoc: "主要的都帮你问到啦 · 再想想?")
|
||||||
|
: String(appLoc: "还想到几个想问你 · 再来一轮"),
|
||||||
|
tint: exhausted ? Tj.Palette.leaf : Tj.Palette.brick,
|
||||||
|
style: .soft, compact: compact)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canRequestSuggest)
|
||||||
|
|
||||||
|
case .failed(let msg):
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(msg)
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button(action: requestSuggestions) {
|
||||||
|
Text("重试")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if filling {
|
}
|
||||||
QuestionFillPanel(
|
|
||||||
template: question.fill,
|
private enum CareCapsuleStyle { case filled, soft }
|
||||||
values: $fillValues,
|
|
||||||
onCommit: { assembled in commitAdoption(question, text: assembled) },
|
/// 关心条里的胶囊。filled = 实心强调;soft = 浅色底(邀请类,prompt / caughtUp 用)。
|
||||||
onCancel: { closeFill() }
|
private func careCapsule(icon: String, text: String, tint: Color,
|
||||||
)
|
style: CareCapsuleStyle, compact: Bool) -> some View {
|
||||||
} else if !question.fill.isEmpty && !adopted {
|
HStack(spacing: 5) {
|
||||||
HStack(alignment: .top, spacing: 4) {
|
Image(systemName: icon)
|
||||||
Text("将追加:")
|
.font(.tjScaled( compact ? 11 : 12, weight: .semibold))
|
||||||
.font(.tjScaled( 11))
|
Text(text)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
|
||||||
Text(question.fill)
|
.lineLimit(1)
|
||||||
.font(.tjScaled( 11))
|
}
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(style == .filled ? Tj.Palette.paper : tint)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12)))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前答案去掉首尾空白(判空 / 提交用)。
|
||||||
|
private var answerTrimmed: String {
|
||||||
|
currentAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func answerPlaceholder(for q: DiaryAssistService.Question) -> String {
|
||||||
|
// 维度名走 localizedDimension 翻成界面语言(模型固定输出中文维度名,展示时本地化)。
|
||||||
|
q.dim.isEmpty
|
||||||
|
? String(appLoc: "在这儿写下你的回答…")
|
||||||
|
: String(appLoc: "说说「\(DiaryAssistPrompts.localizedDimension(q.dim))」的情况…")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「问答式」追问卡片(正文回落卡里):康康问一句 → 用户自由打字回答 →
|
||||||
|
/// 「加入日记」把「· 维度:答案」追加到正文。问题与答案都完整留痕(spec 2026-06-17)。
|
||||||
|
@ViewBuilder
|
||||||
|
private func askingCard(_ q: DiaryAssistService.Question) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Image(systemName: "text.bubble.fill")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(q.q)
|
||||||
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
Spacer(minLength: 6)
|
||||||
.padding(.leading, 22)
|
if pendingQuestions.count > 1 {
|
||||||
|
Text(String(format: String(appLoc: "还有 %d 个"), pendingQuestions.count - 1))
|
||||||
|
.font(.tjScaled( 10, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(10)
|
TextField(answerPlaceholder(for: q), text: $currentAnswer, axis: .vertical)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(.tjScaled( 13))
|
||||||
|
.lineLimit(1...4)
|
||||||
|
.focused($answerFocused)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
|
.fill(Tj.Palette.sand2)
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
.strokeBorder(answerFocused ? Tj.Palette.brick.opacity(0.5) : Tj.Palette.line,
|
||||||
|
lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button { skipCurrent(q) } label: {
|
||||||
|
Text("跳过这条")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button { answerCurrent(q) } label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "text.append")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
Text("加入日记")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Capsule().fill(answerTrimmed.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(answerTrimmed.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
@@ -591,6 +612,41 @@ struct DiaryQuickSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 顶部模式入口仅在「未直达写日记 + 正文未聚焦 + 未输入任何内容」时展示。
|
||||||
|
/// 用户一旦点进正文 / 开始写,就收起入口,留出清爽的写作面。
|
||||||
|
private var showModeSelector: Bool {
|
||||||
|
!directWrite && !contentFocused && !hasContent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模式入口(2×2):写日记(本页)/ 用药(记剂量+时间)/ 拍药盒(识别入药品库)/ 记症状。
|
||||||
|
@ViewBuilder
|
||||||
|
private var modeSelector: some View {
|
||||||
|
if showModeSelector {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
|
||||||
|
GridItem(.flexible(), spacing: 10)], spacing: 10) {
|
||||||
|
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
|
||||||
|
subtitle: String(appLoc: "文字或语音"), active: true) {
|
||||||
|
contentFocused = true
|
||||||
|
}
|
||||||
|
modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
|
||||||
|
subtitle: String(appLoc: "记剂量与时间"), active: false) {
|
||||||
|
showMedicationLog = true
|
||||||
|
}
|
||||||
|
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
|
||||||
|
subtitle: String(appLoc: "识别入药品库"), active: false) {
|
||||||
|
showMedicationScan = true
|
||||||
|
}
|
||||||
|
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
|
||||||
|
subtitle: String(appLoc: "持续追踪"), active: false) {
|
||||||
|
showSymptomStart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。
|
/// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。
|
||||||
/// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。
|
/// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。
|
||||||
private func modeCard(icon: String, title: String, subtitle: String,
|
private func modeCard(icon: String, title: String, subtitle: String,
|
||||||
@@ -626,6 +682,19 @@ struct DiaryQuickSheet: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: 「聊着记」形象对话
|
||||||
|
|
||||||
|
/// 入口闸:端侧模型就绪或云增强可用才放行(§4 模型未就绪仍可启动,AI 入口提示前往下载)。
|
||||||
|
private func startCompanionChat() {
|
||||||
|
guard ModelStore.shared.isComplete(for: .llm) || AIRuntime.shared.cloudAvailable else {
|
||||||
|
voiceNote = String(appLoc: "聊着记需要 AI:请先在「我的 · 模型管理」下载模型")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentFocused = false
|
||||||
|
voiceNote = nil
|
||||||
|
showCompanionChat = true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: 语音输入流程
|
// MARK: 语音输入流程
|
||||||
|
|
||||||
private func startVoice() {
|
private func startVoice() {
|
||||||
@@ -654,7 +723,10 @@ struct DiaryQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
voiceNote = error.localizedDescription
|
#if DEBUG
|
||||||
|
print("[DiaryVoice] dictation start failed: \(error)")
|
||||||
|
#endif
|
||||||
|
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
|
||||||
voicePhase = .idle
|
voicePhase = .idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,7 +763,7 @@ struct DiaryQuickSheet: View {
|
|||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||||||
organizedAppended = nil
|
organizedAppended = nil
|
||||||
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
voiceNote = String(appLoc: "AI 整理没成功,已填入未整理的原文")
|
||||||
}
|
}
|
||||||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
}
|
}
|
||||||
@@ -722,20 +794,18 @@ struct DiaryQuickSheet: View {
|
|||||||
|
|
||||||
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
||||||
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
||||||
|
/// 进入推理即**收起键盘**:把舞台让给正文里的协作卡片,让「康康在想想」+ 彩色呼吸条
|
||||||
|
/// 的本地推理过程完整可见(键盘挡住卡片时呼吸条就白跑了,正是「推理时没有呼吸条」的成因)。
|
||||||
|
/// 出结果后在正文卡片以「问答卡片」呈现:用户自由回答、点「加入日记」把
|
||||||
|
///「· 维度:答案」追加进正文(answerCurrent),问题与答案一并留痕。
|
||||||
private func requestSuggestions() {
|
private func requestSuggestions() {
|
||||||
suggestTask?.cancel()
|
suggestTask?.cancel()
|
||||||
|
contentFocused = false
|
||||||
|
answerFocused = false
|
||||||
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let covered = Array(coveredDims)
|
let covered = Array(coveredDims)
|
||||||
// 1. 主动收起键盘 —— 否则建议面板被键盘吃掉一半
|
|
||||||
contentFocused = false
|
|
||||||
// 2. 确保 sheet 在 large(用户可能下拉到 medium 又触发 AI)
|
|
||||||
if detent != .large {
|
|
||||||
withAnimation(.snappy(duration: 0.25)) {
|
|
||||||
detent = .large
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exhaustedNote = false
|
exhaustedNote = false
|
||||||
phase = .loading
|
withAnimation(.snappy(duration: 0.2)) { phase = .loading }
|
||||||
suggestTask = Task { @MainActor in
|
suggestTask = Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let result = try await DiaryAssistService.shared.suggest(
|
let result = try await DiaryAssistService.shared.suggest(
|
||||||
@@ -807,38 +877,30 @@ struct DiaryQuickSheet: View {
|
|||||||
phase = hasQuestions ? .ready : .idle
|
phase = hasQuestions ? .ready : .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。
|
/// 「加入日记」:把用户对这道追问的【自由回答】按「· 维度:答案」追加到正文,
|
||||||
/// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
|
/// 标记已采纳,关心条自动滑到下一题(答案框清空、保持聚焦,顺势接着答)。
|
||||||
private func adopt(_ question: DiaryAssistService.Question) {
|
/// 维度名取自 question.dim(受控词表「起病诱因/症状性质/…」);模型偶尔没给 dim
|
||||||
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
|
/// 时退化为「· 答案」。这是「问题+答案一起进日记」的落点(spec 2026-06-17)。
|
||||||
// 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。
|
private func answerCurrent(_ question: DiaryAssistService.Question) {
|
||||||
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
|
let answer = currentAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return
|
guard !answer.isEmpty else { return }
|
||||||
}
|
let dim = question.dim.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
withAnimation(.snappy(duration: 0.18)) {
|
appendToContent(dim.isEmpty ? "· \(answer)" : "· \(dim):\(answer)")
|
||||||
fillingId = question.id
|
|
||||||
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 关闭填空面板(取消)。
|
|
||||||
private func closeFill() {
|
|
||||||
withAnimation(.snappy(duration: 0.18)) {
|
|
||||||
fillingId = nil
|
|
||||||
fillValues = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提交采纳:把(填好的)整句追加到正文,标记 adopted,收起面板。
|
|
||||||
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
|
|
||||||
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
||||||
withAnimation(.snappy(duration: 0.18)) {
|
|
||||||
questions[idx].adopted = true
|
questions[idx].adopted = true
|
||||||
}
|
}
|
||||||
|
currentAnswer = ""
|
||||||
|
// 还有下一题就把答案框留住、顺势接着答;答完了收键盘,把整理好的正文交还用户。
|
||||||
|
if pendingQuestions.isEmpty { answerFocused = false }
|
||||||
}
|
}
|
||||||
appendToContent(text)
|
|
||||||
fillingId = nil
|
/// 「跳过这条」:这句先不记,关心条滑到下一句。该维度已在生成时计入 coveredDims,
|
||||||
fillValues = []
|
/// 下一轮 prompt 不会再问它,所以跳过的不必从 questions 里删。清空当前答案输入;
|
||||||
|
/// 队列清空(没有下一题了)则收起键盘。
|
||||||
|
private func skipCurrent(_ question: DiaryAssistService.Question) {
|
||||||
|
skippedQuestionIDs.insert(question.id)
|
||||||
|
currentAnswer = ""
|
||||||
|
if pendingQuestions.isEmpty { answerFocused = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
||||||
|
|||||||
138
康康/Features/Diary/MedicationLogSheet.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「写日记 · 用药」:记一次服用流水 —— 选药(药品库或手输)+ 剂量 + 时间
|
||||||
|
/// → 带 `DiaryEntry.medicationTag` 的日记,进「记录」时间线的「用药」分类。
|
||||||
|
///
|
||||||
|
/// 与药品库(`Medication`,master 清单)分层:这里是「某次吃了多少、什么时候吃的」。
|
||||||
|
/// 嵌套 sheet,自带保存 / 取消(同 `SymptomStartSheet`),关闭后回到写日记页。
|
||||||
|
struct MedicationLogSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@Query(sort: \Medication.updatedAt, order: .reverse)
|
||||||
|
private var library: [Medication]
|
||||||
|
|
||||||
|
/// 选中的药品库药;手输模式为 nil。
|
||||||
|
@State private var selectedMed: Medication?
|
||||||
|
/// 手输药名(药品库为空,或想记不在库里的药)。与 selectedMed 互斥。
|
||||||
|
@State private var manualName = ""
|
||||||
|
@State private var dosage = ""
|
||||||
|
@State private var takenAt: Date = .now
|
||||||
|
|
||||||
|
/// 从药品库某个药直接拉起时预选它(药品库详情页「记录一次服用」)。默认 nil = 自由选择。
|
||||||
|
init(preselected: Medication? = nil) {
|
||||||
|
_selectedMed = State(initialValue: preselected)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resolvedName: String {
|
||||||
|
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
private var canSave: Bool { !resolvedName.isEmpty }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
if library.isEmpty {
|
||||||
|
TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $manualName)
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
} else {
|
||||||
|
ForEach(library) { m in
|
||||||
|
Button { select(m) } label: { medRow(m) }
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
TextField(String(appLoc: "或手动输入药名"), text: $manualName)
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.onChange(of: manualName) { _, v in
|
||||||
|
if !v.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
selectedMed = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("吃了哪个药")
|
||||||
|
} footer: {
|
||||||
|
if library.isEmpty {
|
||||||
|
Text("药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField(String(appLoc: "剂量,如:1 片 / 80mg"), text: $dosage)
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
} header: {
|
||||||
|
Text("剂量")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
DatePicker(String(appLoc: "时间"), selection: $takenAt, in: ...Date.now)
|
||||||
|
} header: {
|
||||||
|
Text("时间")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("记录用药")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button(String(appLoc: "取消")) { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(String(appLoc: "保存")) { save() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.disabled(!canSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func medRow(_ m: Medication) -> some View {
|
||||||
|
let on = selectedMed === m
|
||||||
|
return HStack(spacing: 10) {
|
||||||
|
Image(systemName: on ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(on ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(m.name)
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
if !m.detailLine.isEmpty {
|
||||||
|
Text(m.detailLine)
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func select(_ m: Medication) {
|
||||||
|
selectedMed = m
|
||||||
|
manualName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard canSave else { return }
|
||||||
|
// content 单行:「药名 [规格] · 剂量」。剂量进正文,时间用 createdAt 承载。
|
||||||
|
// 与 TimelineEntry.firstLine / TimelineEntryDetailView.medicationLines 单行解析兼容。
|
||||||
|
var line = resolvedName
|
||||||
|
if let s = selectedMed?.strength, !s.isEmpty { line += " \(s)" }
|
||||||
|
let dose = dosage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !dose.isEmpty { line += " · \(dose)" }
|
||||||
|
|
||||||
|
let entry = DiaryEntry(content: line, createdAt: takenAt, tags: [DiaryEntry.medicationTag])
|
||||||
|
ctx.insert(entry)
|
||||||
|
try? ctx.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
MedicationLogSheet()
|
||||||
|
.modelContainer(for: [Medication.self, DiaryEntry.self, Asset.self], inMemory: true)
|
||||||
|
}
|
||||||
@@ -46,8 +46,8 @@ struct HomeCalendarCard: View {
|
|||||||
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 本月有记录的天数(指标/报告/日记/症状任一)。
|
/// 本月有记录的天数(指标/报告/日记/症状任一)。传入已构建好的 data,避免逐天重建。
|
||||||
private var daysWithRecordsThisMonth: Int {
|
private func daysWithRecordsThisMonth(_ data: CalendarData) -> Int {
|
||||||
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
|
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
|
||||||
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
|
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
|
||||||
var n = 0
|
var n = 0
|
||||||
@@ -62,9 +62,11 @@ struct HomeCalendarCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
// CalendarData 一次性构建,供周条 + 本月摘要复用 —— 杜绝逐格(7)+ 逐天(~30)反复全表重建。
|
||||||
header
|
let calData = data
|
||||||
weekStrip
|
return VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header(calData)
|
||||||
|
weekStrip(calData)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -77,14 +79,14 @@ struct HomeCalendarCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private func header(_ data: CalendarData) -> some View {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Text("健康日历")
|
Text("健康日历")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Text(summaryLine)
|
Text(summaryLine(data))
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
@@ -94,20 +96,20 @@ struct HomeCalendarCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summaryLine: String {
|
private func summaryLine(_ data: CalendarData) -> String {
|
||||||
let n = daysWithRecordsThisMonth
|
let n = daysWithRecordsThisMonth(data)
|
||||||
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
|
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var weekStrip: some View {
|
private func weekStrip(_ data: CalendarData) -> some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(weekDays, id: \.self) { day in
|
ForEach(weekDays, id: \.self) { day in
|
||||||
dayCell(day)
|
dayCell(day, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayCell(_ day: Date) -> some View {
|
private func dayCell(_ day: Date, _ data: CalendarData) -> some View {
|
||||||
let marks = data.marks(for: day, calendar: calendar)
|
let marks = data.marks(for: day, calendar: calendar)
|
||||||
let ranges = data.ranges(touching: day, calendar: calendar)
|
let ranges = data.ranges(touching: day, calendar: calendar)
|
||||||
let isToday = calendar.isDateInToday(day)
|
let isToday = calendar.isDateInToday(day)
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
var onTapArchive: () -> Void = {}
|
/// 跳记录页;传 filter 时预选对应分类 chip(报告档案卡传 `.report`,统计磁贴按类别预选)。
|
||||||
|
var onTapArchive: (TimelineKind?) -> Void = { _ in }
|
||||||
|
/// 点首页卡通形象卡片 → 打开「和康康聊聊」全局健康记录问答。
|
||||||
|
var onTapCompanion: () -> Void = {}
|
||||||
|
|
||||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||||
private var indicators: [Indicator]
|
private var indicators: [Indicator]
|
||||||
@@ -16,22 +19,26 @@ struct HomeView: View {
|
|||||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||||
private var symptoms: [Symptom]
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
/// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。
|
@Query private var profiles: [UserProfile]
|
||||||
@State private var selectedEntry: TimelineEntry?
|
@Query private var customMetrics: [CustomMonitorMetric]
|
||||||
|
|
||||||
|
/// 点迷你趋势卡 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。
|
||||||
|
@State private var selectedGroup: IndicatorGroup?
|
||||||
|
|
||||||
|
private var profile: UserProfile? { profiles.first }
|
||||||
|
|
||||||
|
/// 主页只挑前 3 条最有代表性的趋势:长期监测优先,其次化验指标。数据不足时整段隐藏。
|
||||||
@MainActor
|
@MainActor
|
||||||
private var recentEntries: [TimelineEntry] {
|
private var featuredBuckets: [SeriesBucket] {
|
||||||
let all =
|
let all = SeriesBucket.build(from: indicators,
|
||||||
TimelineEntry.from(indicators: indicators) +
|
profile: profile,
|
||||||
reports.map(TimelineEntry.from(report:)) +
|
customMetrics: customMetrics)
|
||||||
diaries.map(TimelineEntry.from(diary:)) +
|
let monitor = all.filter { $0.kind == .monitor }
|
||||||
symptoms.map(TimelineEntry.from(symptom:))
|
let lab = all.filter { $0.kind == .lab }
|
||||||
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
|
return Array((monitor + lab).prefix(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] {
|
private var ongoingSymptomCount: Int { symptoms.filter { $0.endedAt == nil }.count }
|
||||||
TimelineGrouping.group(recentEntries)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
@@ -40,47 +47,69 @@ struct HomeView: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
companionCard
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
HomeCalendarCard()
|
HomeCalendarCard()
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
overviewSection
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
let buckets = featuredBuckets
|
||||||
|
if !buckets.isEmpty {
|
||||||
|
trendsSection(buckets)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
}
|
||||||
|
|
||||||
TodayRemindersCard()
|
TodayRemindersCard()
|
||||||
|
|
||||||
OngoingSymptomsCard()
|
OngoingSymptomsCard()
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
recentSection
|
|
||||||
.padding(.bottom, 22)
|
|
||||||
|
|
||||||
archiveSection
|
archiveSection
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
.sheet(item: $selectedEntry) { entry in
|
.sheet(item: $selectedGroup) { group in
|
||||||
if let d = TimelineDetail.resolve(
|
IndicatorSeriesDetailView(group: group)
|
||||||
for: entry,
|
|
||||||
indicators: indicators, reports: reports,
|
|
||||||
diaries: diaries, symptoms: symptoms
|
|
||||||
) {
|
|
||||||
TimelineEntryDetailView(detail: d)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 问候
|
||||||
|
|
||||||
private var greeting: some View {
|
private var greeting: some View {
|
||||||
HStack(alignment: .top) {
|
let t = TimeOfDay.current
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
return HStack(alignment: .center, spacing: 14) {
|
||||||
|
// 时段徽章:暖色圆底 + 对应图标(晨/午/夜),随时段自动切换。
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Tj.Palette.sand2)
|
||||||
|
Image(systemName: t.icon)
|
||||||
|
.font(.tjScaled( 22))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
}
|
||||||
|
.frame(width: 52, height: 52)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(todayLine)
|
Text(todayLine)
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 11))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(greetingWord)
|
// 衬线问候,编辑感更强。
|
||||||
.font(.tjTitle())
|
Text(t.word)
|
||||||
|
.font(.tjScaled( 28, weight: .semibold, design: .serif))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(t.subtitle)
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
TjLockChip()
|
TjLockChip()
|
||||||
.padding(.top, 4)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,77 +120,169 @@ struct HomeView: View {
|
|||||||
return "\(day) · \(weekday)"
|
return "\(day) · \(weekday)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var greetingWord: String {
|
/// 一天三段:驱动问候语、副标题、徽章图标,保证三者一致。
|
||||||
|
private enum TimeOfDay {
|
||||||
|
case morning, afternoon, evening
|
||||||
|
|
||||||
|
static var current: TimeOfDay {
|
||||||
switch Calendar.current.component(.hour, from: Date()) {
|
switch Calendar.current.component(.hour, from: Date()) {
|
||||||
case 5..<12: return String(appLoc: "早安")
|
case 5..<12: return .morning
|
||||||
case 12..<18: return String(appLoc: "下午好")
|
case 12..<18: return .afternoon
|
||||||
default: return String(appLoc: "晚上好")
|
default: return .evening
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recentSection: some View {
|
var word: String {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
switch self {
|
||||||
HStack(alignment: .lastTextBaseline) {
|
case .morning: return String(appLoc: "早安")
|
||||||
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
case .afternoon: return String(appLoc: "下午好")
|
||||||
Spacer()
|
case .evening: return String(appLoc: "晚上好")
|
||||||
Button(action: onTapArchive) {
|
|
||||||
Text("全部 ›")
|
|
||||||
.font(.tjScaled( 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if recentEntries.isEmpty {
|
var subtitle: String {
|
||||||
emptyRecent
|
switch self {
|
||||||
} else {
|
case .morning: return String(appLoc: "新的一天,慢慢来")
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
case .afternoon: return String(appLoc: "记得起身活动一下")
|
||||||
ForEach(recentGrouped, id: \.section) { group in
|
case .evening: return String(appLoc: "夜深了,记得早点休息")
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(group.section.label)
|
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
ForEach(group.items) { entry in
|
|
||||||
Button {
|
|
||||||
if TimelineDetail.resolve(
|
|
||||||
for: entry,
|
|
||||||
indicators: indicators, reports: reports,
|
|
||||||
diaries: diaries, symptoms: symptoms
|
|
||||||
) != nil {
|
|
||||||
selectedEntry = entry
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
TimelineRow(entry: entry)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .morning: return "sun.max.fill"
|
||||||
|
case .afternoon: return "sun.haze.fill"
|
||||||
|
case .evening: return "moon.stars.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyRecent: some View {
|
// MARK: - 卡通形象入口(点开「和康康聊聊」全局健康记录问答)
|
||||||
HStack {
|
|
||||||
Text("还没有任何记录,点底部 + 号开始第一条")
|
private var companionCard: some View {
|
||||||
.font(.tjScaled( 13))
|
Button(action: onTapCompanion) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
// 首页直接「调用」会动的卡通形象康康,点一下即进入对话。
|
||||||
|
CompanionAvatarView(mood: .idle, size: 54)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("和康康聊聊")
|
||||||
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("问问你的健康记录 · 全程在本机")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 14)
|
|
||||||
.padding(.horizontal, 16)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
.tjCard(bordered: true)
|
.tjCard(bordered: true)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 数据概览磁贴(2×2,大数字 + 图标,点进对应分类)
|
||||||
|
|
||||||
|
private var overviewSection: some View {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
|
||||||
|
GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||||||
|
statTile(icon: "doc.fill", value: reports.count,
|
||||||
|
label: String(appLoc: "报告"), tint: Tj.Palette.ink) {
|
||||||
|
onTapArchive(.report)
|
||||||
|
}
|
||||||
|
statTile(icon: "drop.fill", value: indicators.count,
|
||||||
|
label: String(appLoc: "指标"), tint: Tj.Palette.brick) {
|
||||||
|
onTapArchive(.indicator)
|
||||||
|
}
|
||||||
|
statTile(icon: "pencil", value: diaries.count,
|
||||||
|
label: String(appLoc: "日记"), tint: Tj.Palette.leaf) {
|
||||||
|
onTapArchive(.diary)
|
||||||
|
}
|
||||||
|
statTile(icon: "waveform.path.ecg", value: symptoms.count,
|
||||||
|
label: ongoingSymptomCount > 0
|
||||||
|
? String(appLoc: "症状 · \(ongoingSymptomCount) 进行中")
|
||||||
|
: String(appLoc: "症状"),
|
||||||
|
tint: Tj.Palette.amber) {
|
||||||
|
onTapArchive(.symptom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statTile(icon: String, value: Int, label: String,
|
||||||
|
tint: Color, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(tint.opacity(0.15))
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("\(value)")
|
||||||
|
.font(.tjScaled( 22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(label)
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.tjCard()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 健康趋势(迷你折线图,复用趋势页 TrendRow)
|
||||||
|
|
||||||
|
private func trendsSection(_ buckets: [SeriesBucket]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("健康趋势")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(buckets) { bucket in
|
||||||
|
Button {
|
||||||
|
selectedGroup = group(for: bucket)
|
||||||
|
} label: {
|
||||||
|
TrendRow(bucket: bucket)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SeriesBucket → 聚合详情的 IndicatorGroup(与趋势页分组语义一致)。
|
||||||
|
private func group(for bucket: SeriesBucket) -> IndicatorGroup {
|
||||||
|
if bucket.id == "bp" { return .bloodPressure }
|
||||||
|
if bucket.id.hasPrefix("lab:") { return .lab(key: String(bucket.id.dropFirst(4))) }
|
||||||
|
return .series(key: bucket.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 影像档案入口
|
||||||
|
|
||||||
private var archiveSection: some View {
|
private var archiveSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
Button(action: onTapArchive) {
|
Button { onTapArchive(.report) } label: {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
|
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ struct TodayRemindersCard: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.fill(Tj.Palette.paper)
|
.fill(Tj.Palette.paper)
|
||||||
)
|
)
|
||||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
|
.shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||||
radius: 2, x: 0, y: 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ let customMetricIconChoices: [String] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/// 名称冲突判定结果。`detectNameConflict` 返回此值用于 UI 警告。
|
/// 名称冲突判定结果。`detectNameConflict` 返回此值用于 UI 警告。
|
||||||
enum CustomMetricNameConflict: Equatable {
|
nonisolated enum CustomMetricNameConflict: Equatable {
|
||||||
case none
|
case none
|
||||||
case builtin(String) // 撞到 MonitorMetric.displayName
|
case builtin(String) // 撞到 MonitorMetric.displayName
|
||||||
case existingCustom(String) // 撞到其他 CustomMonitorMetric.name
|
case existingCustom(String) // 撞到其他 CustomMonitorMetric.name
|
||||||
@@ -64,6 +64,7 @@ struct CustomMetricEditor: View {
|
|||||||
@State private var upper: String = ""
|
@State private var upper: String = ""
|
||||||
@State private var icon: String = "circle.fill"
|
@State private var icon: String = "circle.fill"
|
||||||
@State private var hydrated = false
|
@State private var hydrated = false
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
||||||
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
|
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
|
||||||
@@ -227,13 +228,7 @@ struct CustomMetricEditor: View {
|
|||||||
|
|
||||||
private var deleteButton: some View {
|
private var deleteButton: some View {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
if let m = existing {
|
showDeleteConfirm = true
|
||||||
ReminderService.cancel(metricId: m.seriesKey)
|
|
||||||
ctx.delete(m)
|
|
||||||
try? ctx.save()
|
|
||||||
onSaved(nil)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
@@ -250,6 +245,21 @@ struct CustomMetricEditor: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
.alert(String(appLoc: "删除这项自定义指标?"), isPresented: $showDeleteConfirm) {
|
||||||
|
Button(String(appLoc: "删除"), role: .destructive) { deleteMetric() }
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("删除后不再监测该指标,已记录的历史数据仍保留。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteMetric() {
|
||||||
|
guard let m = existing else { return }
|
||||||
|
ReminderService.cancel(metricId: m.seriesKey)
|
||||||
|
ctx.delete(m)
|
||||||
|
try? ctx.save()
|
||||||
|
onSaved(nil)
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var footer: some View {
|
private var footer: some View {
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ struct IndicatorQuickSheet: View {
|
|||||||
/// nil 时(如 Preview)不显示拍照按钮。
|
/// nil 时(如 Preview)不显示拍照按钮。
|
||||||
var onRequestCamera: (() -> Void)? = nil
|
var onRequestCamera: (() -> Void)? = nil
|
||||||
|
|
||||||
|
/// 从已有指标「再记一条」时的预选目标。nil = 正常空白新建。
|
||||||
|
/// seriesKey 命中 MonitorMetric / CustomMonitorMetric → 预选对应预设(保留进趋势 + 自动判异常);
|
||||||
|
/// 否则按 name/unit/range 走自由输入预填。数值一律留空,由用户填新读数。
|
||||||
|
var prefill: Prefill? = nil
|
||||||
|
|
||||||
|
struct Prefill: Equatable {
|
||||||
|
var seriesKey: String?
|
||||||
|
var name: String = ""
|
||||||
|
var unit: String = ""
|
||||||
|
var range: String = ""
|
||||||
|
}
|
||||||
|
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Query private var profiles: [UserProfile]
|
@Query private var profiles: [UserProfile]
|
||||||
@@ -69,6 +81,32 @@ struct IndicatorQuickSheet: View {
|
|||||||
// 隐藏管理 sheet 触发态
|
// 隐藏管理 sheet 触发态
|
||||||
@State private var showHiddenSheet: Bool = false
|
@State private var showHiddenSheet: Bool = false
|
||||||
|
|
||||||
|
// 「再记一条」预选只应用一次
|
||||||
|
@State private var didApplyPrefill = false
|
||||||
|
|
||||||
|
// 顶部搜索:点放大镜展开搜索框,按名实时过滤长期监测预设 / 自定义指标 / 化验项快捷。
|
||||||
|
@State private var searchingMetrics = false
|
||||||
|
@State private var metricQuery = ""
|
||||||
|
|
||||||
|
private var isSearchingMetrics: Bool {
|
||||||
|
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
private var filteredMonitorMetrics: [MonitorMetric] {
|
||||||
|
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return visibleMonitorMetrics }
|
||||||
|
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
|
||||||
|
}
|
||||||
|
private var filteredCustomMetrics: [CustomMonitorMetric] {
|
||||||
|
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return customMetrics }
|
||||||
|
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
||||||
|
}
|
||||||
|
private var filteredLabPresets: [IndicatorPreset] {
|
||||||
|
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return labPresets }
|
||||||
|
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
||||||
|
}
|
||||||
|
|
||||||
private static var defaultReminderTime: Date {
|
private static var defaultReminderTime: Date {
|
||||||
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
||||||
}
|
}
|
||||||
@@ -91,7 +129,29 @@ struct IndicatorQuickSheet: View {
|
|||||||
selectedMonitor?.displayName ?? selectedCustom?.name
|
selectedMonitor?.displayName ?? selectedCustom?.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 数值类指标(血压 / 长期监测 / 自定义)要求填合法数字且在合理范围;
|
||||||
|
/// 化验 / 手动自由值允许非数字(如「阴性」「++」「未见异常」)。
|
||||||
|
/// 返回 nil = 通过;否则为给用户看的一句话原因。脏数据会直接画进趋势图,这里挡在入口。
|
||||||
|
private var numericValidationError: String? {
|
||||||
|
func check(_ s: String, min: Double, max: Double, field: String) -> String? {
|
||||||
|
let t = s.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !t.isEmpty else { return nil } // 空值交给下面的非空判断兜底
|
||||||
|
guard let v = Double(t), v.isFinite else { return String(appLoc: "\(field)请填数字") }
|
||||||
|
guard v >= min, v <= max else { return String(appLoc: "\(field)数值超出合理范围") }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isBP {
|
||||||
|
return check(systolic, min: 30, max: 350, field: String(appLoc: "收缩压"))
|
||||||
|
?? check(diastolic, min: 20, max: 250, field: String(appLoc: "舒张压"))
|
||||||
|
}
|
||||||
|
if isLongTermMetric { // 长期监测预设 / 自定义:必须是正数
|
||||||
|
return check(value, min: 0.0001, max: 1_000_000, field: String(appLoc: "数值"))
|
||||||
|
}
|
||||||
|
return nil // 自由输入(化验 / 手动)允许非数字
|
||||||
|
}
|
||||||
|
|
||||||
private var canSubmit: Bool {
|
private var canSubmit: Bool {
|
||||||
|
guard numericValidationError == nil else { return false }
|
||||||
if isBP {
|
if isBP {
|
||||||
return !systolic.trimmingCharacters(in: .whitespaces).isEmpty &&
|
return !systolic.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||||
!diastolic.trimmingCharacters(in: .whitespaces).isEmpty
|
!diastolic.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
@@ -124,6 +184,9 @@ struct IndicatorQuickSheet: View {
|
|||||||
statusSection
|
statusSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let validationError = numericValidationError {
|
||||||
|
validationHint(validationError)
|
||||||
|
}
|
||||||
|
|
||||||
timeSection
|
timeSection
|
||||||
noteSection
|
noteSection
|
||||||
@@ -137,6 +200,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
footer
|
footer
|
||||||
}
|
}
|
||||||
|
.onAppear { applyPrefillIfNeeded() }
|
||||||
.task(id: longTermKey) { hydrateReminder() }
|
.task(id: longTermKey) { hydrateReminder() }
|
||||||
.background(
|
.background(
|
||||||
Tj.Palette.sand
|
Tj.Palette.sand
|
||||||
@@ -161,7 +225,8 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack {
|
VStack(spacing: 12) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
Text("记录指标")
|
Text("记录指标")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
@@ -169,11 +234,55 @@ struct IndicatorQuickSheet: View {
|
|||||||
Text("本地处理 · 永不上传")
|
Text("本地处理 · 永不上传")
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
searchToggle
|
||||||
|
}
|
||||||
|
if searchingMetrics { searchField }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var searchToggle: some View {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.18)) {
|
||||||
|
searchingMetrics.toggle()
|
||||||
|
if !searchingMetrics { metricQuery = "" }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass")
|
||||||
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(searchingMetrics ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchField: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
TextField(String(appLoc: "搜索指标名"), text: $metricQuery)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
if !metricQuery.isEmpty {
|
||||||
|
Button { metricQuery = "" } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(fieldBg)
|
||||||
|
.overlay(fieldBorder)
|
||||||
|
}
|
||||||
|
|
||||||
/// 顶部「拍照识别」入口:并入原「指标速记」。点后由 RootView 切到相机 VL 流程。
|
/// 顶部「拍照识别」入口:并入原「指标速记」。点后由 RootView 切到相机 VL 流程。
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var cameraEntrySection: some View {
|
private var cameraEntrySection: some View {
|
||||||
@@ -241,13 +350,19 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
||||||
LazyVGrid(columns: columns, spacing: 8) {
|
LazyVGrid(columns: columns, spacing: 8) {
|
||||||
ForEach(visibleMonitorMetrics) { m in
|
ForEach(filteredMonitorMetrics) { m in
|
||||||
monitorTile(m)
|
monitorTile(m)
|
||||||
}
|
}
|
||||||
ForEach(customMetrics) { cm in
|
ForEach(filteredCustomMetrics) { cm in
|
||||||
customTile(cm)
|
customTile(cm)
|
||||||
}
|
}
|
||||||
addCustomTile
|
// 搜索态下不显示「自定义(新建)」格,聚焦过滤结果。
|
||||||
|
if !isSearchingMetrics { addCustomTile }
|
||||||
|
}
|
||||||
|
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
|
||||||
|
Text("没有匹配的长期监测指标")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showHiddenSheet) {
|
.sheet(isPresented: $showHiddenSheet) {
|
||||||
@@ -386,12 +501,15 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var labPresetSection: some View {
|
private var labPresetSection: some View {
|
||||||
|
// 搜索且化验项无匹配:整段隐藏(避免只剩一个空标题)。
|
||||||
|
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
|
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(labPresets) { p in
|
ForEach(filteredLabPresets) { p in
|
||||||
chip(p.name, selected: selectedLabPreset == p) {
|
chip(p.name, selected: selectedLabPreset == p) {
|
||||||
applyLab(p)
|
applyLab(p)
|
||||||
}
|
}
|
||||||
@@ -400,6 +518,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var bpFieldSection: some View {
|
private var bpFieldSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -941,6 +1060,29 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
// MARK: - apply preset
|
// MARK: - apply preset
|
||||||
|
|
||||||
|
/// 「再记一条」预选:seriesKey 命中长期监测预设 / 自定义指标则选中对应卡片(进趋势 + 自动判异常),
|
||||||
|
/// 否则按 name/unit/range 走自由输入。数值不预填——让用户填新读数。只应用一次。
|
||||||
|
private func applyPrefillIfNeeded() {
|
||||||
|
guard !didApplyPrefill, let p = prefill else { return }
|
||||||
|
didApplyPrefill = true
|
||||||
|
if let key = p.seriesKey {
|
||||||
|
if let m = MonitorMetric.allCases.first(where: { metric in
|
||||||
|
metric.fields.contains { $0.seriesKey == key }
|
||||||
|
}) {
|
||||||
|
applyMonitor(m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
|
||||||
|
applyCustom(cm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无 seriesKey 或未匹配预设(化验项 / 报告 / 自由指标):自由输入预填,不带 seriesKey,不进趋势。
|
||||||
|
name = p.name
|
||||||
|
unit = p.unit
|
||||||
|
range = p.range
|
||||||
|
}
|
||||||
|
|
||||||
private func applyMonitor(_ m: MonitorMetric) {
|
private func applyMonitor(_ m: MonitorMetric) {
|
||||||
if selectedMonitor == m {
|
if selectedMonitor == m {
|
||||||
// 取消选择
|
// 取消选择
|
||||||
@@ -1035,6 +1177,21 @@ struct IndicatorQuickSheet: View {
|
|||||||
return (s.label, s.color)
|
return (s.label, s.color)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 校验提示
|
||||||
|
|
||||||
|
private func validationHint(_ text: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(text)
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - submit
|
// MARK: - submit
|
||||||
|
|
||||||
private func submit() {
|
private func submit() {
|
||||||
|
|||||||
39
康康/Features/Indicator/RecordAnotherButton.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension IndicatorQuickSheet.Prefill {
|
||||||
|
/// 从一条已有指标推断「再记一条」的预选目标:
|
||||||
|
/// seriesKey 命中长期监测 / 自定义指标则预选对应预设(进趋势 + 自动判异常),否则按 name/unit/range 自由预填。
|
||||||
|
init(indicator i: Indicator) {
|
||||||
|
self.init(seriesKey: i.seriesKey, name: i.name, unit: i.unit, range: i.range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指标详情 / 同类聚合详情底部「再记一条」按钮:打开预选同款指标的录入表单(数值留空,由用户填新读数)。
|
||||||
|
/// 自带弹窗状态,`TimelineEntryDetailView` 与 `IndicatorSeriesDetailView` 共用同一组件。
|
||||||
|
struct RecordAnotherButton: View {
|
||||||
|
/// 按钮文案里显示的指标名(如「空腹血糖」「血压」)。
|
||||||
|
let name: String
|
||||||
|
/// 打开录入表单时的预选目标。
|
||||||
|
let prefill: IndicatorQuickSheet.Prefill
|
||||||
|
|
||||||
|
@State private var showSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button { showSheet = true } label: {
|
||||||
|
Label(String(appLoc: "再记一条「\(name)」"), systemImage: "plus.circle.fill")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.leaf.opacity(0.16))
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.sheet(isPresented: $showSheet) {
|
||||||
|
IndicatorQuickSheet(prefill: prefill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,16 @@ struct CustomMetricsListView: View {
|
|||||||
if metrics.isEmpty {
|
if metrics.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
|
// 一次性按 seriesKey 统计使用次数(O(indicators)),行内 O(1) 查表,
|
||||||
|
// 取代「每行都重扫整张指标表」的 O(metrics × indicators) N+1。
|
||||||
|
let usageCounts: [String: Int] = indicators.reduce(into: [:]) { acc, ind in
|
||||||
|
if let key = ind.seriesKey, !key.isEmpty { acc[key, default: 0] += 1 }
|
||||||
|
}
|
||||||
ForEach(metrics) { m in
|
ForEach(metrics) { m in
|
||||||
Button {
|
Button {
|
||||||
editingTarget = CustomMetricEditTarget(metric: m)
|
editingTarget = CustomMetricEditTarget(metric: m)
|
||||||
} label: {
|
} label: {
|
||||||
row(m)
|
row(m, usage: usageCounts[m.seriesKey] ?? 0)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -82,9 +87,8 @@ struct CustomMetricsListView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ m: CustomMonitorMetric) -> some View {
|
private func row(_ m: CustomMonitorMetric, usage count: Int) -> some View {
|
||||||
let count = usageCount(for: m)
|
HStack(spacing: 12) {
|
||||||
return HStack(spacing: 12) {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(Tj.Palette.leafSoft)
|
Circle().fill(Tj.Palette.leafSoft)
|
||||||
Image(systemName: m.icon)
|
Image(systemName: m.icon)
|
||||||
@@ -137,9 +141,6 @@ struct CustomMetricsListView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func usageCount(for m: CustomMonitorMetric) -> Int {
|
|
||||||
indicators.filter { $0.seriesKey == m.seriesKey }.count
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -10,22 +10,32 @@ struct CustomReminderEditSheet: View {
|
|||||||
|
|
||||||
/// nil = 新建模式。
|
/// nil = 新建模式。
|
||||||
let reminder: CustomReminder?
|
let reminder: CustomReminder?
|
||||||
|
/// 新建模式下的预填(如从「用药记录」点药进来,预填「吃药:药名」+ 用法备注)。
|
||||||
|
/// 编辑模式(reminder != nil)忽略。
|
||||||
|
private let prefillTitle: String
|
||||||
|
private let prefillNote: String
|
||||||
|
|
||||||
@State private var title = ""
|
@State private var title = ""
|
||||||
@State private var note = ""
|
@State private var note = ""
|
||||||
@State private var pickedTime: Date = .now
|
@State private var pickedTime: Date = .now
|
||||||
@State private var frequency: CustomReminder.Frequency = .daily
|
/// 多选频率(每日/每周/每月/每年 可同时勾选)。
|
||||||
|
@State private var frequencies: Set<CustomReminder.Frequency> = [.daily]
|
||||||
@State private var weekdays: Set<Int> = Set(1...7)
|
@State private var weekdays: Set<Int> = Set(1...7)
|
||||||
@State private var dayOfMonth = 1
|
/// 每月多选日期(1...31)。
|
||||||
|
@State private var monthDays: Set<Int> = [1]
|
||||||
|
@State private var dayOfMonth = 1 // 仅「每年」用
|
||||||
@State private var month = 1
|
@State private var month = 1
|
||||||
@State private var hydrated = false
|
@State private var hydrated = false
|
||||||
@State private var showAuthDeniedAlert = false
|
@State private var showAuthDeniedAlert = false
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
/// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。
|
/// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。
|
||||||
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
|
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
|
||||||
|
|
||||||
init(reminder: CustomReminder? = nil) {
|
init(reminder: CustomReminder? = nil, prefillTitle: String = "", prefillNote: String = "") {
|
||||||
self.reminder = reminder
|
self.reminder = reminder
|
||||||
|
self.prefillTitle = prefillTitle
|
||||||
|
self.prefillNote = prefillNote
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isEditing: Bool { reminder != nil }
|
private var isEditing: Bool { reminder != nil }
|
||||||
@@ -33,8 +43,9 @@ struct CustomReminderEditSheet: View {
|
|||||||
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
guard !trimmedTitle.isEmpty else { return false }
|
guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false }
|
||||||
if frequency == .weekly { return !weekdays.isEmpty }
|
if frequencies.contains(.weekly) && weekdays.isEmpty { return false }
|
||||||
|
if frequencies.contains(.monthly) && monthDays.isEmpty { return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,18 +62,12 @@ struct CustomReminderEditSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker(String(appLoc: "重复"), selection: $frequency) {
|
frequencyChips
|
||||||
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
|
|
||||||
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
|
|
||||||
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
|
|
||||||
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
|
|
||||||
frequencyDetail
|
frequencyDetail
|
||||||
} header: {
|
} header: {
|
||||||
Text("重复")
|
Text("重复")
|
||||||
|
} footer: {
|
||||||
|
Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -75,7 +80,7 @@ struct CustomReminderEditSheet: View {
|
|||||||
|
|
||||||
if isEditing {
|
if isEditing {
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) { deleteReminder() } label: {
|
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||||
Label(String(appLoc: "删除提醒"), systemImage: "trash")
|
Label(String(appLoc: "删除提醒"), systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,26 +111,69 @@ struct CustomReminderEditSheet: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
|
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
|
||||||
}
|
}
|
||||||
|
.alert(String(appLoc: "删除这条提醒?"), isPresented: $showDeleteConfirm) {
|
||||||
|
Button(String(appLoc: "删除"), role: .destructive) { deleteReminder() }
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("删除后该提醒不再触发。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 频率子控件
|
// MARK: - 频率多选 chip
|
||||||
|
|
||||||
|
private static let freqOrder: [CustomReminder.Frequency] = [.daily, .weekly, .monthly, .yearly]
|
||||||
|
|
||||||
|
private func freqLabel(_ f: CustomReminder.Frequency) -> String {
|
||||||
|
switch f {
|
||||||
|
case .daily: return String(appLoc: "每日")
|
||||||
|
case .weekly: return String(appLoc: "每周")
|
||||||
|
case .monthly: return String(appLoc: "每月")
|
||||||
|
case .yearly: return String(appLoc: "每年")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var frequencyChips: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Self.freqOrder, id: \.self) { f in
|
||||||
|
let on = frequencies.contains(f)
|
||||||
|
Button {
|
||||||
|
if on { frequencies.remove(f) } else { frequencies.insert(f) }
|
||||||
|
} label: {
|
||||||
|
Text(freqLabel(f))
|
||||||
|
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
|
||||||
|
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 32)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 频率子控件(随勾选项展开,可同时出现多组)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var frequencyDetail: some View {
|
private var frequencyDetail: some View {
|
||||||
switch frequency {
|
if frequencies.contains(.weekly) {
|
||||||
case .daily:
|
subCaption(String(appLoc: "每周 · 选星期几"))
|
||||||
EmptyView()
|
|
||||||
case .weekly:
|
|
||||||
weekdayRow
|
weekdayRow
|
||||||
case .monthly:
|
|
||||||
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
|
|
||||||
ForEach(1...31, id: \.self) { d in
|
|
||||||
Text(String(appLoc: "\(d)日")).tag(d)
|
|
||||||
}
|
}
|
||||||
|
if frequencies.contains(.monthly) {
|
||||||
|
subCaption(String(appLoc: "每月 · 选日期(可多选)"))
|
||||||
|
monthDayGrid
|
||||||
|
if monthDays.contains(where: { $0 >= 29 }) { skipHint }
|
||||||
}
|
}
|
||||||
if dayOfMonth >= 29 { skipHint }
|
if frequencies.contains(.yearly) {
|
||||||
case .yearly:
|
subCaption(String(appLoc: "每年 · 选月/日"))
|
||||||
Picker(String(appLoc: "月份"), selection: $month) {
|
Picker(String(appLoc: "月份"), selection: $month) {
|
||||||
ForEach(1...12, id: \.self) { mo in
|
ForEach(1...12, id: \.self) { mo in
|
||||||
Text(String(appLoc: "\(mo)月")).tag(mo)
|
Text(String(appLoc: "\(mo)月")).tag(mo)
|
||||||
@@ -140,6 +188,41 @@ struct CustomReminderEditSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func subCaption(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每月日期多选网格(1...31,7 列)。
|
||||||
|
private var monthDayGrid: some View {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
|
||||||
|
ForEach(1...31, id: \.self) { d in
|
||||||
|
let on = monthDays.contains(d)
|
||||||
|
Button {
|
||||||
|
if on { monthDays.remove(d) } else { monthDays.insert(d) }
|
||||||
|
} label: {
|
||||||
|
Text("\(d)")
|
||||||
|
.font(.tjScaled( 12, weight: on ? .semibold : .regular))
|
||||||
|
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||||
|
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
private var skipHint: some View {
|
private var skipHint: some View {
|
||||||
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
||||||
.font(.tjScaled( 11))
|
.font(.tjScaled( 11))
|
||||||
@@ -229,13 +312,18 @@ struct CustomReminderEditSheet: View {
|
|||||||
if let r = reminder {
|
if let r = reminder {
|
||||||
title = r.title
|
title = r.title
|
||||||
note = r.note
|
note = r.note
|
||||||
frequency = r.frequency
|
frequencies = r.frequencies
|
||||||
weekdays = Set(r.weekdays)
|
weekdays = Set(r.weekdays)
|
||||||
|
monthDays = Set(r.monthlyDays)
|
||||||
dayOfMonth = r.dayOfMonth
|
dayOfMonth = r.dayOfMonth
|
||||||
month = r.month
|
month = r.month
|
||||||
pickedTime = Calendar.current.date(
|
pickedTime = Calendar.current.date(
|
||||||
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
||||||
) ?? .now
|
) ?? .now
|
||||||
|
} else {
|
||||||
|
// 新建模式:从调用方带进来的预填(药名 / 用法)。
|
||||||
|
title = prefillTitle
|
||||||
|
note = prefillNote
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +333,7 @@ struct CustomReminderEditSheet: View {
|
|||||||
let hour = cal.component(.hour, from: pickedTime)
|
let hour = cal.component(.hour, from: pickedTime)
|
||||||
let minute = cal.component(.minute, from: pickedTime)
|
let minute = cal.component(.minute, from: pickedTime)
|
||||||
let sortedDays = weekdays.sorted()
|
let sortedDays = weekdays.sorted()
|
||||||
|
let sortedMonthDays = monthDays.sorted()
|
||||||
|
|
||||||
let target: CustomReminder
|
let target: CustomReminder
|
||||||
if let r = reminder {
|
if let r = reminder {
|
||||||
@@ -253,8 +342,9 @@ struct CustomReminderEditSheet: View {
|
|||||||
r.hour = hour
|
r.hour = hour
|
||||||
r.minute = minute
|
r.minute = minute
|
||||||
r.weekdays = sortedDays
|
r.weekdays = sortedDays
|
||||||
r.frequency = frequency
|
r.frequencies = frequencies // 写 frequenciesRaw(+ 代表 frequencyRaw)
|
||||||
r.dayOfMonth = dayOfMonth
|
r.monthlyDays = sortedMonthDays // 写 monthDays
|
||||||
|
r.dayOfMonth = dayOfMonth // 仅每年用
|
||||||
r.month = month
|
r.month = month
|
||||||
r.updatedAt = .now
|
r.updatedAt = .now
|
||||||
target = r
|
target = r
|
||||||
@@ -265,10 +355,11 @@ struct CustomReminderEditSheet: View {
|
|||||||
hour: hour,
|
hour: hour,
|
||||||
minute: minute,
|
minute: minute,
|
||||||
weekdays: sortedDays,
|
weekdays: sortedDays,
|
||||||
frequency: frequency,
|
|
||||||
dayOfMonth: dayOfMonth,
|
dayOfMonth: dayOfMonth,
|
||||||
month: month
|
month: month
|
||||||
)
|
)
|
||||||
|
new.frequencies = frequencies
|
||||||
|
new.monthlyDays = sortedMonthDays
|
||||||
ctx.insert(new)
|
ctx.insert(new)
|
||||||
target = new
|
target = new
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// 推理引擎设置:在 MNN(CPU/SME2,考核路径)与 MLX(GPU,兜底)间切换,并展示 SME2 探测状态。
|
/// 推理引擎设置:本项目主模型 Gemma 4(端侧 4bit),**端侧统一走 MLX/GPU**。
|
||||||
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
/// MNN/SME2 LLM 引擎已移除(Gemma 4 无 MNN 转换模型),不再提供引擎选择——
|
||||||
|
/// 端侧只此一种,页面改为展示当前端侧后端 + 性能自检 + 云端 AI 开关。
|
||||||
struct InferenceSettingsView: View {
|
struct InferenceSettingsView: View {
|
||||||
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
|
// 云端 AI(Gemini)开关与 key —— 键名与 CloudAI 对齐,@AppStorage 写入即被后端读到。
|
||||||
|
@AppStorage("cloud_ai_gemini_enabled") private var cloudEnabled = false
|
||||||
|
@AppStorage("cloud_ai_gemini_key") private var cloudKey = ""
|
||||||
|
@State private var modelService = ModelDownloadService.shared
|
||||||
|
/// 性能自检改为当前页就地展开,不再 push 新页面。
|
||||||
|
@State private var showSelfTest = false
|
||||||
|
|
||||||
private var selected: EnginePreference {
|
/// 性能自检需要主模型(Gemma 4,MLX)就绪。
|
||||||
EnginePreference(rawValue: engineRaw) ?? .auto
|
private var modelReady: Bool {
|
||||||
|
modelService.states[.llm]?.phase == .ready
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -21,125 +28,156 @@ struct InferenceSettingsView: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
ForEach(EnginePreference.allCases, id: \.self) { engine in
|
localEngineCard
|
||||||
engineRow(engine)
|
|
||||||
}
|
|
||||||
|
|
||||||
sme2Card
|
selfTestSection
|
||||||
|
cloudSection
|
||||||
noteCard
|
noteCard
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.onAppear { modelService.refreshStates() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func engineRow(_ engine: EnginePreference) -> some View {
|
/// 端侧后端信息卡(只此一种,不可切换):MLX · Metal GPU,端侧 Gemma 4 E2B。
|
||||||
let available = isAvailable(engine)
|
private var localEngineCard: some View {
|
||||||
let isOn = (selected == engine)
|
|
||||||
return Button {
|
|
||||||
guard available else { return }
|
|
||||||
engineRaw = engine.rawValue
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(isOn ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
Circle().fill(Tj.Palette.amber.opacity(0.25))
|
||||||
Image(systemName: iconName(engine))
|
Image(systemName: "bolt.fill")
|
||||||
.font(.tjScaled(18))
|
.font(.tjScaled(18))
|
||||||
.foregroundStyle(isOn ? Tj.Palette.ink : Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(engine.displayName)
|
Text("MLX · GPU")
|
||||||
.font(.tjScaled(15, weight: .semibold))
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(subtitle(engine, available: available))
|
Text("Metal GPU · 端侧推理 Gemma 4 E2B")
|
||||||
.font(.tjScaled(12))
|
.font(.tjScaled(12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if isOn {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.tjScaled(18))
|
.font(.tjScaled(18))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.tjCard()
|
.tjCard()
|
||||||
.opacity(available ? 1 : 0.45)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!available)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// .auto 永远可用;具体引擎看自身可用性。
|
/// 性能自检入口:它是「动作/工具」而非引擎选择,所以做成描边动作按钮(TjGhostButton),
|
||||||
private func isAvailable(_ engine: EnginePreference) -> Bool {
|
/// 从视觉类别上和上方引擎/状态卡区分开。模型未就绪时禁用并给出下载提示而非死链。
|
||||||
switch engine {
|
@ViewBuilder
|
||||||
case .auto: return true
|
private var selfTestSection: some View {
|
||||||
case .mnn: return InferenceEngine.mnn.isAvailable
|
if modelReady {
|
||||||
case .mlx: return InferenceEngine.mlx.isAvailable
|
VStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.22)) { showSelfTest.toggle() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "gauge.with.needle")
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
Text("性能自检")
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.tjScaled(13, weight: .semibold))
|
||||||
|
.rotationEffect(.degrees(showSelfTest ? 180 : 0))
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(TjGhostButton())
|
||||||
|
|
||||||
private func iconName(_ engine: EnginePreference) -> String {
|
if showSelfTest {
|
||||||
switch engine {
|
ModelSelfTestView(embedded: true)
|
||||||
case .auto: return "wand.and.stars"
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
case .mnn: return "cpu.fill"
|
|
||||||
case .mlx: return "bolt.fill"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
private func subtitle(_ engine: EnginePreference, available: Bool) -> String {
|
|
||||||
switch engine {
|
|
||||||
case .auto:
|
|
||||||
// 显示自动解析后实际命中的引擎,让用户看清「这台机选了什么」。
|
|
||||||
let resolved = engine.resolved
|
|
||||||
if resolved == .mnn {
|
|
||||||
return InferenceEngine.cpuSupportsSME2
|
|
||||||
? String(appLoc: "按本机配置选择 · 当前 MNN + SME2")
|
|
||||||
: String(appLoc: "按本机配置选择 · 当前 MNN(NEON)")
|
|
||||||
} else {
|
} else {
|
||||||
return String(appLoc: "按本机配置选择 · 当前 MLX(MNN 不可用)")
|
VStack(spacing: 8) {
|
||||||
}
|
HStack(spacing: 8) {
|
||||||
case .mnn:
|
Image(systemName: "gauge.with.needle")
|
||||||
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
return InferenceEngine.cpuSupportsSME2
|
Text("性能自检")
|
||||||
? String(appLoc: "端侧 CPU + SME2 加速 · 挑战赛考核路径")
|
|
||||||
: String(appLoc: "端侧 CPU(本机无 SME2,NEON 回退)")
|
|
||||||
case .mlx:
|
|
||||||
return String(appLoc: "Metal GPU · 兜底 / 对照")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
.opacity(0.6)
|
||||||
|
|
||||||
private var sme2Card: some View {
|
Text("模型未就绪,前往「模型管理」下载后可用")
|
||||||
let sme2 = InferenceEngine.cpuSupportsSME2
|
|
||||||
return HStack(spacing: 12) {
|
|
||||||
ZStack {
|
|
||||||
Circle().fill(sme2 ? Tj.Palette.leafSoft : Tj.Palette.sand2)
|
|
||||||
Image(systemName: sme2 ? "checkmark.seal.fill" : "minus.circle")
|
|
||||||
.font(.tjScaled(18))
|
|
||||||
.foregroundStyle(sme2 ? Tj.Palette.ink : Tj.Palette.text2)
|
|
||||||
}
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Arm SME2")
|
|
||||||
.font(.tjScaled(15, weight: .medium))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
Text(sme2 ? String(appLoc: "本设备支持,MNN 已启用 SME2 加速")
|
|
||||||
: String(appLoc: "本设备不支持(需 A19/iPhone 17+)"))
|
|
||||||
.font(.tjScaled(12))
|
.font(.tjScaled(12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cloudConfigured: Bool {
|
||||||
|
cloudEnabled && !cloudKey.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云端 AI(Gemini)开关区。hybrid 的「云端」一侧:默认关(隐私优先),
|
||||||
|
/// 开启并填入 AI Studio 的 key 后,「读报告原图 / 深度解读 / 多语言」走 Google Gemini。
|
||||||
|
private var cloudSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("云端 AI · Gemini")
|
||||||
|
.font(.tjTitle())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Toggle(isOn: $cloudEnabled) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("启用云端增强")
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("默认端侧 Gemma 4;开启后「读报告原图 / 深度解读 / 多语言」走 Google Gemini。")
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Tj.Palette.leaf)
|
||||||
|
|
||||||
|
if cloudEnabled {
|
||||||
|
SecureField(String(appLoc: "粘贴 Google AI Studio 的 API Key"), text: $cloudKey)
|
||||||
|
.font(.tjScaled(14))
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.padding(12)
|
||||||
|
.background(RoundedRectangle(cornerRadius: Tj.Radius.md).fill(Tj.Palette.sand2))
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: cloudConfigured ? "checkmark.seal.fill" : "exclamationmark.circle")
|
||||||
|
.font(.tjScaled(13))
|
||||||
|
.foregroundStyle(cloudConfigured ? Tj.Palette.leaf : Tj.Palette.text3)
|
||||||
|
Text(cloudConfigured
|
||||||
|
? String(appLoc: "已就绪 · \(AIRuntime.cloudLabel)")
|
||||||
|
: String(appLoc: "请填入 API Key(aistudio.google.com 免费获取)"))
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Text("Key 仅存本机;断网或额度用尽时自动回退端侧 Gemma 4,功能不中断。")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.tjCard()
|
.tjCard()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var noteCard: some View {
|
private var noteCard: some View {
|
||||||
Text("MNN 在端侧 CPU 上以 Arm SME2 指令集加速 Qwen 推理(本地、不上云)。切换后下一次 AI 调用生效。")
|
Text("隐私优先:默认端侧 Gemma 4(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。")
|
||||||
.font(.tjScaled(12))
|
.font(.tjScaled(12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||