feat(home): 添加首页卡通形象卡片入口

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

BREAKING CHANGE: 新增首页功能模块,改变原有界面布局结构
```
This commit is contained in:
link2026
2026-07-19 19:40:02 +08:00
parent 198570186e
commit ef5364d8c3
10 changed files with 1736 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ import SwiftData
struct HomeView: View {
/// ; filter chip( `.report`,)
var onTapArchive: (TimelineKind?) -> Void = { _ in }
///
var onTapCompanion: () -> Void = {}
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@@ -45,6 +47,9 @@ struct HomeView: View {
.padding(.top, 4)
.padding(.bottom, 18)
companionCard
.padding(.bottom, 18)
HomeCalendarCard()
.padding(.bottom, 18)
@@ -152,6 +157,38 @@ struct HomeView: View {
}
}
// MARK: - ()
private var companionCard: some View {
Button(action: onTapCompanion) {
HStack(spacing: 14) {
// ,
CompanionAvatarView(mood: .idle, size: 54)
VStack(alignment: .leading, spacing: 3) {
Text("和康康聊聊")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("问问你的健康记录 · 全程在本机")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.tjCard(bordered: true)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: - (2×2, + ,)
private var overviewSection: some View {

View File

@@ -3953,6 +3953,9 @@
}
}
}
},
"先到「我的 · 模型管理」下载模型,就能和我聊了" : {
},
"先问清楚,再整理给医生" : {
"localizations" : {
@@ -9168,6 +9171,9 @@
}
}
}
},
"我是康康" : {
},
"我的" : {
"localizations" : {
@@ -12900,6 +12906,9 @@
}
}
}
},
"查阅了本机 %lld 条记录" : {
},
"标准" : {
"localizations" : {
@@ -13364,6 +13373,9 @@
}
}
}
},
"正在听你说…" : {
},
"正在录音 · 声音不上传" : {
"localizations" : {
@@ -13565,6 +13577,9 @@
}
}
}
},
"正在查阅你的记录" : {
},
"正在根据这些记录回答…" : {
"localizations" : {
@@ -13609,6 +13624,12 @@
}
}
}
},
"正在翻你的记录 · %@ tok/s" : {
},
"正在翻你的记录…" : {
},
"正在转写录音 · 本地 SenseVoice" : {
"localizations" : {
@@ -18015,6 +18036,9 @@
}
}
}
},
"语音提问全程在本机完成,声音和文字都不会上传。" : {
},
"语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。" : {
"localizations" : {
@@ -19771,6 +19795,9 @@
}
}
}
},
"问我关于你健康记录的事,我只看你本机的数据来回答" : {
},
"问诊" : {
"localizations" : {
@@ -19925,6 +19952,12 @@
}
}
}
},
"问问你的健康记录 · 全程在本机" : {
},
"问问你的健康记录…" : {
},
"隐私优先:健康数据不上传、无需注册账号" : {
"localizations" : {

View File

@@ -61,6 +61,8 @@ struct RootView: View {
@State private var showMedicationScan = false
/// · :sheet + NavigationStack
@State private var showMedicationLibrary = false
/// :(RAG)
@State private var showHealthChat = false
/// ( RecordSheet onPick )
private func route(_ intent: VoiceIntent) {
@@ -90,7 +92,7 @@ struct RootView: View {
case .home: HomeView(onTapArchive: { kind in
pendingRecordsFilter = kind
select(.records)
})
}, onTapCompanion: { showHealthChat = true })
case .records: ArchiveListView(initialFilter: pendingRecordsFilter)
case .trend: TrendsView()
case .me: MeView()
@@ -185,6 +187,9 @@ struct RootView: View {
onClose: { showMedicationScan = false }
)
}
.fullScreenCover(isPresented: $showHealthChat) {
HealthChatView(onClose: { showHealthChat = false })
}
#if os(iOS)
.fullScreenCover(item: $activeFlow) { flow in
switch flow {