根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:

```
chore(config): 更新项目配置文件

- 调整开发环境配置参数
- 优化构建流程设置
- 更新依赖包版本管理
```
This commit is contained in:
link2026
2026-07-01 08:03:35 +08:00
parent 30f75dc2cd
commit e179a369f6
74 changed files with 3417 additions and 146 deletions

View File

@@ -0,0 +1,107 @@
# 康康 · Google 赛道重定位与改造说明
> 面向「入围 Top 30」评审表(完成度与传播 25% / Google AI 深度 25% / 创新与 Vibe Coding 20% / Tech for Good 出海 20% / 第 5 维度 ~10% 待补全)。
> 本文是把原 MNN/SME2 赛道作品改投 Google 赛道的定位与技术映射。改造日期:2026-07-01。
---
## 0. 一句话定位(新)
**A privacy-first, offline-capable health companion for low-connectivity and underserved communities — understand your medical reports and medicines even without a doctor or internet, in your own language.**
中文:**给弱网/缺医地区与跨国语言障碍人群的隐私优先健康助手——没有医生、没有网络,也能读懂自己的化验单和药盒,用你的母语。**
> 注意叙事转向:旧版讲「中国人不想把体检报告传云端」(纯国内隐私焦虑);新版讲「全球低资源人群 + 跨境语言障碍」(出海 + Tech for Good)。同一套 App、同一套 UI/数据,**主要改模型底座 + 叙事**,代码改动集中在 AI 层。
---
## 1. hybrid 架构:为什么这样用 Google 技术(评审 25% 核心)
评审明确要求「Google 技术参与**核心能力构建**,不是包装卖点」并「说明**为什么**用这些 Google 技术」。康康的回答:
| 能力 | 用的 Google 技术 | 为什么非它不可 |
|---|---|---|
| **端侧离线推理(默认)** | **Gemma-3n E2B**(Google 开源多模态小模型,端侧 4bit) | 弱网/无网地区要「飞行模式也能用」。Gemma-3n 是 Google 专为手机优化的开源模型,**离线、隐私、不依赖账号**——这是低资源场景的刚需,云模型做不到。 |
| **读报告/药盒原图 → 结构化**(联网增强) | **Gemini 2.5 Flash 多模态 API** | 端侧小模型读密集化验单小字不稳;Gemini 多模态能直接读图出结构化指标 + 证据位置框。**这是产品第一卖点「拍一张→变档案」的真正承载者。** |
| **深度解读 / 多语言** | **Gemini 2.5 Flash** | 跨国用户拿到外语报告,需要「翻译 + 大白话解读」。Gemini 的多语言与推理能力正对口出海语言障碍痛点。 |
| **Prompt 设计 / 调试** | **Google AI Studio** | 所有 Gemini/Gemma 的 prompt 在 AI Studio 里迭代;API key 也由 AI Studio 免费签发。 |
**hybrid 的产品逻辑**:隐私优先——**默认端侧 Gemma-3n,数据不出设备**;只有用户在「我的 · 云端 AI」**主动开启**后,「读原图 / 深度解读 / 多语言」才走 Gemini 云端。**断网或额度耗尽自动回退端侧,功能不中断。** 这既拿满「Google 产品深度」,又守住隐私品牌,还天然适配「时有时无的网络」这一低资源现实。
> 代码落点:`GeminiBackend.swift`(REST 直调,SSE 流式 + 多模态)、`AIRuntime.generateCloud / analyzeReportCloud`(不进 OOM 闸门,可与端侧并发)、`CaptureService.runVL`(云端优先读图、失败回退端侧 OCR+文本)、`InferenceSettingsView`(云端开关 + key)。
### 生产级升级路径(写给评委看工程成熟度)
demo 用 AI Studio 直发 key 的 REST 方案(零新增 SPM 依赖、即时可编译)。生产应升级到 **Firebase AI Logic**(Swift SDK):App Check 防盗用、客户端不裸存 key、内建端侧↔云端 hybrid 回退。迁移点已在 `GeminiBackend` 注释标注,换 SDK 不动上层 Service。
---
## 2. 重定位:目标用户 / 市场 / 影响路径(评审 Tech for Good 20% + 创新 20%)
评审要「真实人群 / 弱势 / 低资源 / 全球化」+「海外落地可行性,不只是善意叙事」+「目标用户、场景、影响路径、后续扩展」。
- **目标人群**
1. 低资源/弱网地区居民:缺医少药、网络不稳,拿到化验单无人解读。
2. 跨境人群:移民、留学生、外派、旅居者,面对**外语**医疗文件看不懂。
3. 慢病/老人照护者:替家人留存报告、复查前整理重点。
- **真实问题**:看不懂(术语+外语)、找不到(报告散落)、不敢传(隐私)、没医生在身边。
- **海外落地可行性(不是善意叙事)**
- 离线即可用 → 适配低带宽市场,不依赖稳定网络与数据套餐。
- 隐私默认端侧 → 契合 GDPR 等严监管市场的健康数据合规。
- 多语言 → 一套产品覆盖多语种,边际成本低。
- **影响路径**:个人读懂自己 → 家庭健康档案管理 → 低资源社区的基层健康工作者辅助工具。
- **后续扩展**:更多语种、更多文档类型(疫苗本/处方/影像)、与本地公共健康项目对接、可选导出给当地医生。
---
## 3. 逐维度自检(改造后该拿的分)
| 维度 | 权重 | 改造后状态 |
|---|---|---|
| 完成度与传播 | 25% | 可运行 App + 闭环(拍报告→识别→档案→趋势→问答→摘要);Gemini 恢复真·读图。**待补:3-5 分钟 demo 视频(见 §4)+ 重写图文(§5)** |
| Google AI 深度 | 25% | Gemma-3n(端侧)+ Gemini 多模态(读图)+ Gemini(深度/多语言)+ AI Studio。**均在核心链路,非包装。** |
| 创新 & Vibe Coding | 20% | hybrid 隐私优先 + 离线可用 + 多语言;Claude Code/Gemini Code Assist 全程 vibe coding。叙事已转出海。 |
| Tech for Good 出海 | 20% | 低资源/弱网/跨国语言障碍,见 §2。 |
| 第 5 维度(~10%) | ? | **未知,待补全(见 §6 开放项)** |
---
## 4. 现场 Demo 脚本(3-5 分钟,先证「能跑」)
> 评审第一条:核心功能可实际运行,3-5 分钟讲清主要价值。把最强证据放最前。
1. **(0:00-0:30)问题**:一句话——「全球很多人拿到化验单看不懂,身边没医生,也不敢把隐私报告传云端。」
2. **(0:30-1:30)拍一张 → 变档案**:现场拍/导入一张报告 → Gemini 多模态读出结构化指标 + 异常高亮 → 存进档案。**这是第一卖点。**
3. **(1:30-2:15)飞行模式离线**:开飞行模式,演示端侧 Gemma-3n 仍能做文本解读/问答——「没网也能用」。这是低资源场景的杀手锏。
4. **(2:15-3:00)多语言**:拿一张**外语**报告 → Gemini 翻译 + 母语大白话解读。出海痛点直球。
5. **(3:00-3:45)长期价值**:趋势页 AI 解读 + 「就诊前 30 秒整理重点」摘要。
6. **(3:45-4:30)隐私 + 技术收尾**:「我的 · 云端 AI」开关讲 hybrid;一句话点出 Gemma-3n + Gemini + AI Studio。
视频素材:片头/转场可用 **Veo / Google Flow** 生成;录屏需真机配合(端侧 + 飞行模式只能真机)。
---
## 5. 传播物料改写要点(小红书图文)
- 标题从「MNN/SME2 端侧」改为「不上传、断网也能用的健康 AI(Gemma + Gemini)」。
- 9 宫格替换:把旧「MNN-SME2 性能自检」图换成「云端 AI 设置页(Gemini 开关)」+「飞行模式离线生成」+「外语报告→母语解读」。
- 正文三个创新点改为:① 隐私优先 hybrid;② 端侧离线可用(低资源);③ 多语言出海。
- 删除一切「100% 本地、不上云」绝对化表述,统一为「隐私优先,云端可选」。
---
## 6. 你需要做的事 + 开放项(不阻塞已完成代码)
**必须由你操作(我无法代办)**
1.**aistudio.google.com** 免费签发 Gemini API Key。
2. App「我的 · 推理引擎 · 云端 AI · Gemini」打开开关、粘贴 key。
3. 真机录 demo 视频(端侧 + 飞行模式只能真机)。
**开放项(需要你提供信息)**
4. **评分表第 5 个维度(~10%)被截断**——把完整评审标准或比赛出处发我,补进 §3,别再漏考核点。
5. **平台权衡**:当前保 iOS。若评委更看重 Android 生态(Android Studio/Flutter/Gemini Nano),可再评估,但 2-3 周内不建议重写。
---
## 7. 一句话给你自己
上一个比赛输在「为命题做的东西没按被考核的点证明」。这次的红线是:**Gemini 必须在核心链路里被看到在跑(读图/多语言),而不是设置页里一个没人点的开关。** demo 视频里 Gemini 读出一张报告的那 10 秒,比任何文案都值钱。

View File

@@ -0,0 +1,462 @@
from __future__ import annotations
from pathlib import Path
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Inches, Pt, RGBColor
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "docs" / "release" / "康康创意方案文档.docx"
PICS = ROOT / "docs" / "release" / "xhs-9grid"
BLUE = RGBColor(46, 116, 181)
DARK_BLUE = RGBColor(31, 77, 120)
INK = RGBColor(31, 31, 31)
MUTED = RGBColor(95, 95, 95)
LIGHT = "F4F6F9"
PALE_BLUE = "E8EEF5"
GREEN = RGBColor(58, 112, 76)
BRICK = RGBColor(154, 78, 65)
def set_run_font(run, size=None, bold=None, color=None, font="PingFang SC"):
run.font.name = font
run._element.rPr.rFonts.set(qn("w:ascii"), "Calibri")
run._element.rPr.rFonts.set(qn("w:hAnsi"), "Calibri")
run._element.rPr.rFonts.set(qn("w:eastAsia"), font)
if size is not None:
run.font.size = Pt(size)
if bold is not None:
run.bold = bold
if color is not None:
run.font.color.rgb = color
def set_paragraph_font(paragraph, size=11, color=INK, font="PingFang SC"):
for run in paragraph.runs:
set_run_font(run, size=size, color=color, font=font)
def set_cell_shading(cell, fill):
tc_pr = cell._tc.get_or_add_tcPr()
shd = tc_pr.find(qn("w:shd"))
if shd is None:
shd = OxmlElement("w:shd")
tc_pr.append(shd)
shd.set(qn("w:fill"), fill)
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
tc = cell._tc
tc_pr = tc.get_or_add_tcPr()
tc_mar = tc_pr.first_child_found_in("w:tcMar")
if tc_mar is None:
tc_mar = OxmlElement("w:tcMar")
tc_pr.append(tc_mar)
for m, v in [("top", top), ("start", start), ("bottom", bottom), ("end", end)]:
node = tc_mar.find(qn(f"w:{m}"))
if node is None:
node = OxmlElement(f"w:{m}")
tc_mar.append(node)
node.set(qn("w:w"), str(v))
node.set(qn("w:type"), "dxa")
def set_table_geometry(table, widths):
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = False
tbl = table._tbl
tbl_pr = tbl.tblPr
tbl_w = tbl_pr.find(qn("w:tblW"))
if tbl_w is None:
tbl_w = OxmlElement("w:tblW")
tbl_pr.append(tbl_w)
tbl_w.set(qn("w:w"), str(sum(widths)))
tbl_w.set(qn("w:type"), "dxa")
tbl_grid = tbl.tblGrid
if tbl_grid is None:
tbl_grid = OxmlElement("w:tblGrid")
tbl.append(tbl_grid)
for child in list(tbl_grid):
tbl_grid.remove(child)
for width in widths:
grid_col = OxmlElement("w:gridCol")
grid_col.set(qn("w:w"), str(width))
tbl_grid.append(grid_col)
for row in table.rows:
for idx, cell in enumerate(row.cells):
tc_pr = cell._tc.get_or_add_tcPr()
tc_w = tc_pr.find(qn("w:tcW"))
if tc_w is None:
tc_w = OxmlElement("w:tcW")
tc_pr.append(tc_w)
tc_w.set(qn("w:w"), str(widths[idx]))
tc_w.set(qn("w:type"), "dxa")
set_cell_margins(cell)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
def table_border(table, color="DADCE0", size="6"):
tbl_pr = table._tbl.tblPr
borders = tbl_pr.first_child_found_in("w:tblBorders")
if borders is None:
borders = OxmlElement("w:tblBorders")
tbl_pr.append(borders)
for edge in ["top", "left", "bottom", "right", "insideH", "insideV"]:
tag = f"w:{edge}"
element = borders.find(qn(tag))
if element is None:
element = OxmlElement(tag)
borders.append(element)
element.set(qn("w:val"), "single")
element.set(qn("w:sz"), size)
element.set(qn("w:space"), "0")
element.set(qn("w:color"), color)
def add_para(doc, text="", style=None, size=11, bold=False, color=INK, align=None, after=8):
p = doc.add_paragraph(style=style)
p.paragraph_format.space_after = Pt(after)
p.paragraph_format.line_spacing = 1.25
if align is not None:
p.alignment = align
run = p.add_run(text)
set_run_font(run, size=size, bold=bold, color=color)
return p
def add_heading(doc, text, level=1):
p = doc.add_paragraph(style=f"Heading {level}")
p.paragraph_format.keep_with_next = True
run = p.add_run(text)
if level == 1:
set_run_font(run, 16, True, BLUE)
p.paragraph_format.space_before = Pt(18)
p.paragraph_format.space_after = Pt(10)
elif level == 2:
set_run_font(run, 13, True, BLUE)
p.paragraph_format.space_before = Pt(12)
p.paragraph_format.space_after = Pt(6)
else:
set_run_font(run, 12, True, DARK_BLUE)
p.paragraph_format.space_before = Pt(8)
p.paragraph_format.space_after = Pt(4)
return p
def add_bullet(doc, text, level=0):
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(0.375)
p.paragraph_format.first_line_indent = Inches(-0.194)
p.paragraph_format.space_after = Pt(4)
p.paragraph_format.line_spacing = 1.208
run = p.add_run(text)
set_run_font(run, 10.5, color=INK)
return p
def add_callout(doc, title, body, fill=LIGHT, title_color=DARK_BLUE):
table = doc.add_table(rows=1, cols=1)
set_table_geometry(table, [9360])
table_border(table, color="E1E5EA", size="4")
cell = table.cell(0, 0)
set_cell_shading(cell, fill)
cell.text = ""
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(4)
r = p.add_run(title)
set_run_font(r, 11, True, title_color)
p2 = cell.add_paragraph()
p2.paragraph_format.space_after = Pt(0)
p2.paragraph_format.line_spacing = 1.22
r2 = p2.add_run(body)
set_run_font(r2, 10.5, color=INK)
doc.add_paragraph().paragraph_format.space_after = Pt(4)
return table
def add_label_table(doc, rows, widths=(2300, 7060), header=None):
table = doc.add_table(rows=0, cols=2)
set_table_geometry(table, list(widths))
table_border(table, color="DADCE0", size="5")
if header:
row = table.add_row()
row.cells[0].merge(row.cells[1])
cell = row.cells[0]
set_cell_shading(cell, PALE_BLUE)
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
r = p.add_run(header)
set_run_font(r, 10.5, True, DARK_BLUE)
for label, value in rows:
row = table.add_row()
for cell in row.cells:
set_cell_shading(cell, "FFFFFF")
p0 = row.cells[0].paragraphs[0]
p0.paragraph_format.space_after = Pt(0)
r0 = p0.add_run(label)
set_run_font(r0, 10, True, DARK_BLUE)
p1 = row.cells[1].paragraphs[0]
p1.paragraph_format.space_after = Pt(0)
p1.paragraph_format.line_spacing = 1.18
r1 = p1.add_run(value)
set_run_font(r1, 10, color=INK)
doc.add_paragraph().paragraph_format.space_after = Pt(4)
return table
def add_matrix(doc, headers, rows, widths):
table = doc.add_table(rows=1, cols=len(headers))
set_table_geometry(table, widths)
table_border(table, color="DADCE0", size="5")
for idx, h in enumerate(headers):
cell = table.cell(0, idx)
set_cell_shading(cell, PALE_BLUE)
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
r = p.add_run(h)
set_run_font(r, 9.5, True, DARK_BLUE)
for row_data in rows:
row = table.add_row()
for idx, text in enumerate(row_data):
cell = row.cells[idx]
set_cell_shading(cell, "FFFFFF")
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.16
r = p.add_run(text)
set_run_font(r, 9.2, color=INK)
doc.add_paragraph().paragraph_format.space_after = Pt(4)
return table
def setup_styles(doc):
styles = doc.styles
normal = styles["Normal"]
normal.font.name = "Calibri"
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC")
normal.font.size = Pt(11)
normal.font.color.rgb = INK
normal.paragraph_format.space_after = Pt(8)
normal.paragraph_format.line_spacing = 1.333
normal.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
for style_name in ["Heading 1", "Heading 2", "Heading 3", "List Bullet"]:
style = styles[style_name]
style.font.name = "Calibri"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC")
def set_header_footer(section):
header = section.header.paragraphs[0]
header.text = ""
header.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = header.add_run("康康 Kangkang 创意方案")
set_run_font(run, 9, color=MUTED)
footer = section.footer.paragraphs[0]
footer.text = ""
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = footer.add_run("本方案仅描述健康记录与科普式解读能力,不构成医疗诊断或用药建议")
set_run_font(run, 8.5, color=MUTED)
def add_title_page(doc):
add_para(doc, "手机上的 AI 创意方案", size=12, bold=True, color=GREEN, align=WD_ALIGN_PARAGRAPH.CENTER, after=10)
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
title.paragraph_format.space_after = Pt(6)
r = title.add_run("康康:本地优先的个人健康档案")
set_run_font(r, 24, True, DARK_BLUE)
subtitle = doc.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
subtitle.paragraph_format.space_after = Pt(18)
r2 = subtitle.add_run("100% 本地推理 · 个人健康影像档案 · 大白话解读 · 结构化 RAG 问答")
set_run_font(r2, 12.5, color=MUTED)
add_label_table(
doc,
[
("项目形态", "iOS 原生 AppSwiftUI + SwiftData。"),
("目标用户", "不愿把体检报告、化验单、症状和用药记录交给云端的普通用户。"),
("核心主张", "健康数据默认留在手机里,本地模型负责整理、解释和检索。"),
("技术主线", "Qwen3.5-2B + MNN + Arm SME2/NEONMLX Swift 作为模拟器和兜底后端。"),
("边界声明", "不做医疗诊断、剂量推荐、急诊判断、医生预约、账号系统或数据上云。"),
],
header="项目概览",
)
add_callout(
doc,
"一句话创意",
"把体检报告、化验指标、症状、日记和用药记录收进一个本地健康档案,再让手机端本地大模型用普通人能听懂的语言帮助整理、回顾和就诊前准备。",
fill="F7FAF7",
title_color=GREEN,
)
doc.add_page_break()
def add_sources(doc):
add_heading(doc, "依据来源", 1)
add_bullet(doc, "AGENTS.md项目定位、技术选型、AI 链路、数据模型、隐私边界和六周 demo 目标。")
add_bullet(doc, "康康/AI/InferenceEngine.swift 与 AIRuntime.swiftMNN/MLX 双后端、SME2 探测、actor 串行推理闸门。")
add_bullet(doc, "康康/Services/HealthExportService.swift本地 RAG、意图抽取、结构化检索、就诊摘要生成和失败兜底。")
add_bullet(doc, "docs/release/app-store-metadata.md 与小红书项目介绍:用户价值、隐私表达和非医疗器械边界。")
def main():
doc = Document()
section = doc.sections[0]
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1)
section.right_margin = Inches(1)
section.header_distance = Inches(0.492)
section.footer_distance = Inches(0.492)
setup_styles(doc)
set_header_footer(section)
add_title_page(doc)
add_heading(doc, "1. 应用场景", 1)
add_para(doc, "康康面向的是普通人的日常健康信息管理,而不是医院内部系统或专业诊疗工具。产品重点放在体检后、复查前、慢性指标长期观察、看医生前准备等高频生活场景。")
add_matrix(
doc,
["场景", "用户动作", "康康提供的价值"],
[
("体检或化验后", "拍摄报告或从相册导入。", "本地识别关键指标、参考范围和异常状态,形成可检索的报告档案。"),
("长期指标记录", "记录血压、血糖、体重、血脂等指标。", "自动沉淀趋势曲线,用大白话说明最近变化,降低读图门槛。"),
("症状与日记", "随手记身体感受、睡眠、疼痛、用药等。", "把零散记录整理成时间线,必要时由本地 AI 追问补全。"),
("就诊前准备", "输入“把最近一个月整理给医生看”。", "本地检索症状、指标、报告和用药,生成可复制/分享的就诊摘要。"),
("家庭健康管理", "为父母或自己保存报告和复查信息。", "减少纸质报告丢失、相册翻找和口头回忆遗漏。"),
],
[1600, 2600, 5160],
)
add_heading(doc, "2. 用户痛点", 1)
add_bullet(doc, "看不懂:体检报告和化验单充满缩写、箭头、参考范围,普通人很难快速判断哪些值得关注。")
add_bullet(doc, "找不到:健康信息散落在纸质报告、相册截图、备忘录和聊天记录里,复查或就医时很难完整回溯。")
add_bullet(doc, "不敢传:健康报告包含年龄、医院、检查结果、既往问题等高度敏感信息,上传给云端 AI 会带来隐私顾虑。")
add_bullet(doc, "记不全:看医生时常常忘记症状持续时间、近期指标变化、在服药物和过敏史。")
add_bullet(doc, "工具割裂:传统健康记录 App 重记录轻解释AI 聊天工具会解释但不沉淀长期个人档案。")
doc.add_page_break()
add_heading(doc, "3. 技术方案", 1)
add_heading(doc, "3.1 模型选型", 2)
add_para(doc, "主模型选择 Qwen3.5-2B原因是它在移动端内存和速度预算内更适合 demo 落地,同时承担文本解读和视觉识别两类任务,避免拆分多套模型造成下载、存储和加载复杂度上升。")
add_label_table(
doc,
[
("主路径模型", "Qwen3.5-2B-MNN约 1.2GB,面向真机端侧推理。"),
("兜底模型", "MLX Swift 对应的 Qwen3.5-2B-4bit主要用于模拟器、调试和 MNN 不可用时的兜底。"),
("能力覆盖", "报告/药盒等图片识别、健康文本整理、趋势解释、身体档案问答和就诊摘要。"),
("取舍逻辑", "不用更大模型追求极限效果,而是优先保证端侧速度、内存可控、下载可接受和 demo 可复现。"),
],
header="模型选型摘要",
)
add_heading(doc, "3.2 推理框架", 2)
add_para(doc, "推理框架采用双后端策略:真机主路径为阿里开源 MNN面向挑战赛的 Qwen + MNN + SME2 端侧 CPU 推理MLX Swift 作为 Apple 官方 Metal GPU 兜底,用于模拟器和对照测试。")
add_matrix(
doc,
["模块", "方案", "作用"],
[
("MNNBackend", "ObjC++ 桥接 MNN LLM 能力。", "在真机上加载 Qwen3.5-2B-MNN支撑文本生成与视觉分析。"),
("InferenceEngine", "auto / mnn / mlx 三种偏好。", "真机优先 MNNMNN 不可用时自动回退 MLX。"),
("AIRuntime", "actor 单例 + 推理闸门。", "串行化模型加载、文本生成和视觉推理,避免并发 OOM。"),
("ModelStore", "Application Support/Models。", "管理模型下载、就绪校验和现场旁路导入。"),
],
[1900, 3200, 4260],
)
add_heading(doc, "3.3 端侧适配思路", 2)
add_bullet(doc, "设备优先级:支持 MNN 的真机默认走 MNN支持 SME2 的 A19/iPhone17 走 SME2 加速,旧设备回退 NEON模拟器走 MLX。")
add_bullet(doc, "内存控制AIRuntime 使用 actor 内信号量,保证同一时间只有一个重推理任务占用模型内存;加载 LLM 前卸载 VL加载 MNN 前卸载 MLX 侧模型。")
add_bullet(doc, "体验兜底:模型未就绪时 App 仍可启动AI 入口提示前往模型管理;意图抽取失败时回退近 30 天全表扫描;识别失败时回退手动录入。")
add_bullet(doc, "结构化 RAG不引入 embedding 模型,先由本地模型抽取意图和关键词,再用 SwiftData 检索指标、报告、症状和日记,最后拼接上下文生成回答。")
add_bullet(doc, "隐私保护:报告原图只保存到本地 VaultSwiftData 存结构化记录;使用 iOS completeFileProtection、Face ID 启动锁和永久删除。")
add_heading(doc, "4. 创新点", 1)
add_matrix(
doc,
["创新点", "说明", "差异化价值"],
[
("本地优先健康 AI", "AI 不依赖云端 API核心解读和问答在手机内完成。", "解决健康数据上传焦虑,形成隐私可信的 AI 体验。"),
("影像档案 + AI 解读闭环", "拍报告、识别、归档、趋势、问答、摘要串成一个系统。", "不是单点 OCR 或聊天框,而是长期可用的个人健康档案。"),
("MNN + SME2 端侧推理", "将 Qwen 模型通过 MNN 接入 iPhone CPU/SME2 路径。", "展示大模型在移动 CPU 上可复现运行的技术亮点。"),
("轻量结构化 RAG", "利用 SwiftData 已有结构化记录检索,不额外引入 embedding 模型。", "降低端侧存储和计算成本,响应更可控。"),
("明确医疗边界", "只做记录、整理和科普式解释,不做诊断、用药和急诊判断。", "既保留实用价值,也降低合规和误导风险。"),
],
[1800, 3900, 3660],
)
add_heading(doc, "5. 预期效果", 1)
add_callout(
doc,
"用户侧效果",
"用户可以把报告、指标、症状和用药从碎片化记录变成可检索、可回顾、可解释的个人健康档案;在看医生前,用一份摘要减少遗漏和重复描述。",
fill="F7FAF7",
title_color=GREEN,
)
add_callout(
doc,
"技术侧效果",
"验证 Qwen3.5-2B 在 MNN + SME2/NEON 端侧 CPU 路径上的可用性,形成模型下载、引擎选择、性能自检、推理串行、失败兜底和本地数据检索的一体化方案。",
fill="F4F6F9",
title_color=DARK_BLUE,
)
add_callout(
doc,
"展示侧效果",
"比赛或路演时可以通过飞行模式、本地模型管理页、性能自检 tok/s、报告识别前后对比和身体档案摘要清晰证明“不是云端套壳而是端侧 AI 创意应用”。",
fill="FFF6F2",
title_color=BRICK,
)
add_heading(doc, "6. 方案完整性", 1)
add_para(doc, "康康的完整性体现在产品、技术和安全边界三条线同时闭合:用户能完成从采集到回顾再到就诊摘要的闭环;技术上有模型、运行时、数据、服务层和 UI 的分层;安全上明确不引入云服务、不做账号、不做自研密码学。")
add_matrix(
doc,
["层级", "已覆盖能力", "设计原则"],
[
("采集层", "拍报告、记录指标、写日记、记症状、药品识别。", "入口轻,失败可手动补录。"),
("数据层", "SwiftData 模型Indicator、Report、DiaryEntry、Symptom、Asset、ChatTurn、UserProfile 等。", "结构化保存,便于检索和趋势计算。"),
("AI 层", "CaptureService、HealthExportService、TrendInsightService 经 AIRuntime 调用本地模型。", "UI 不直连模型,推理统一排队。"),
("展示层", "首页、记录、趋势、我的、模型管理、身体档案摘要。", "围绕 demo 核心卖点组织信息。"),
("隐私层", "本地 Vault、completeFileProtection、Face ID、永久删除、无账号无云。", "使用系统能力,不自造密码学。"),
],
[1500, 5000, 2860],
)
add_heading(doc, "7. 风险与边界控制", 1)
add_bullet(doc, "AI 输出仅作为健康记录整理与科普式解释,不作为医疗诊断、治疗、用药或剂量建议。")
add_bullet(doc, "识别结果必须允许用户核对和编辑,避免把模型错误直接落成事实。")
add_bullet(doc, "端侧模型受设备性能、内存和电量影响,需要模型未就绪、加载失败、低性能设备等状态提示。")
add_bullet(doc, "健康类内容表达要避免“治疗”“诊断”“疗效”等容易误导的词汇。")
# Visual appendix with the generated 9-grid overview if available.
overview = PICS / "00-9宫格总览.png"
if overview.exists():
doc.add_page_break()
add_heading(doc, "附录:展示素材示意", 1)
add_para(doc, "以下为小红书 9 宫格展示图总览,可用于说明产品闭环与技术亮点。", size=10.5, color=MUTED)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run()
run.add_picture(str(overview), width=Inches(5.6))
OUT.parent.mkdir(parents=True, exist_ok=True)
doc.save(OUT)
print(OUT)
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

View File

@@ -0,0 +1,83 @@
# 记录问诊 · 本地 SenseVoice 转写接入
> 「记录问诊」录完整段录音后,在本机用 **SenseVoice**(经 **sherpa-mnn** 跑在 **MNN 后端**)离线转写成文字,
> 再交本地 LLM 整理成问诊小结。全程本机、无网络。
>
> 代码已全部就位且**默认编译通过**:未接入 sherpa-mnn 时 `SenseVoiceBridge` 走桩,问诊自动回退系统端侧识别(SFSpeech)。
> 本文档是把真实 SenseVoice 跑起来的「构建 + 设备验证」步骤。
## 架构(按本项目模块边界 §3.1)
```
ConsultationSheet(UI)
→ 录音(ConsultationRecorder,m4a 落 Vault)
→ SenseVoiceASRService.transcribe(file) // 离线整段转写,无实时字幕
→ AIRuntime.runExclusiveForASR { … } // 进推理闸门 + 卸常驻 LLM/VL 腾内存(防 OOM)
→ SenseVoiceBridge(ObjC++) // sherpa-mnn C-API
→ sherpa-mnn → MNN 后端(CPU/SME2)
→ DiaryAssistService.organizeConsultation(text) // 本地 LLM 整理成问诊小结
→ 存为带「问诊」tag 的 DiaryEntry(录音挂 audio Asset)
```
- 引擎/模型未就绪 → 自动回退 SFSpeech;再不行 → 手动文字录入。任何一步都不卡死(红线 #5)。
- SenseVoice 是**非流式**:录音中只显示声纹动效,不显示实时字幕,结束后整段转写。
## 一、构建 sherpa-mnn.xcframework
```sh
MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/build-sherpa-mnn-xcframework.sh
```
- 复用 MNN 源码自带的 `apps/frameworks/sherpa-mnn/build-ios.sh`,产出 `Frameworks/sherpa-mnn.xcframework`
(已含 device arm64 + simulator,libtool 合并好的静态库 + `Headers/`)。
- 若 sherpa cmake 报找不到 MNN:先 `sh scripts/build-mnn-xcframework.sh` 构建 MNN,
`export MNN_LIB_DIR=<含 libMNN.a + include 的目录>` 重跑。
- Apple Silicon 上若不需要 Intel 模拟器,可在 `build-ios.sh` 里去掉 `simulator_x86_64` 段加速。
## 二、加进 Xcode 工程
1.`Frameworks/sherpa-mnn.xcframework` 拖进 target → **Frameworks, Libraries, and Embedded Content**,
**Do Not Embed**(静态库)。
2. **Build Settings → Header Search Paths** 追加(recursive,Debug + Release):
```
$(PROJECT_DIR)/Frameworks/sherpa-mnn.xcframework/Headers
```
这样 `SenseVoiceBridge.mm` 里的 `#if __has_include(<sherpa-mnn/c-api/c-api.h>)` 命中,自动切到真实实现。
3. sherpa-mnn 用到 C++ 标准库,确保 target 的 **Other Linker Flags** 含 `-lc++`(通常已隐式链接)。
4. `MNN.xcframework` 仍需在工程里(sherpa-mnn 依赖它)。即便主 LLM 已切 Gemma-3n/MLX,
**不要从工程移除 MNN.xcframework**,否则问诊转写退回 SFSpeech。
> 工程用 PBXFileSystemSynchronizedRootGroup:`SenseVoiceBridge.{h,mm}` 放在 `康康/AI/MNN/` 下已自动参与编译,
> 桥接已在 `康康/康康-Bridging-Header.h` 暴露给 Swift,无需手改 pbxproj。
## 三、转换并安装 SenseVoice 模型
```sh
MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/convert-sensevoice-mnn.sh
```
产出 `build/SenseVoice/{model.mnn, tokens.txt}`(转的是 **fp32** `model.onnx`,8bit 权重量化;勿转 int8 版)。
安装到沙盒 `Application Support/Models/SenseVoice/`:
- **模拟器**:拷到
`~/Library/Developer/CoreSimulator/Devices/<id>/data/Containers/Data/Application/<id>/Library/Application Support/Models/SenseVoice/`
- **真机**:经「我的 · 模型管理」旁路导入,或用调试构建预拷进沙盒。
就位判定:`SenseVoiceASRService.isModelInstalled`(model.mnn + tokens.txt 都在)。
引擎+模型都就绪后 `SenseVoiceASRService.isAvailable == true`,问诊即走 SenseVoice。
## 四、设备验证
1. 真机打开「记一笔 → 记录问诊 → 开始录音」,说几句(含数值/药名,验数字保真)。
2. 「结束并整理」后应看到「正在转写录音 · 本地 SenseVoice」,而非「本机识别」。
3. 转写稿交本地 LLM 整理成问诊小结,保存后在记录详情里能回放原声、看小结。
4. 故意不装模型 → 应自动回退「本机识别」(SFSpeech),功能仍可用。
## 备注:与 Gemma-3n/MLX 主线的关系
- 转写(SenseVoice/MNN)与文本生成(Gemma-3n/MLX)互相独立:ASR 用 MNN 后端,LLM 整理用 MLX。
二者经 `AIRuntime` 闸门**串行**,不会同时常驻内存(§3.1)。
- 这是目前工程里**唯一**仍依赖 MNN 的路径。若决定彻底移除 MNN,问诊转写会退回 SFSpeech;
桥接的 `__has_include` 守卫保证那时仍能编译。
- 若希望改走 sherpa-onnx(onnxruntime,不依赖 MNN),只需把 `SenseVoiceBridge.mm` 里的
`SherpaMnn*` C-API 换成对应的 `SherpaOnnx*`(结构同名),其余 Swift 层不动。

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -0,0 +1,86 @@
# 康康 · 小红书长文(Google 赛道版)
> 改写自原 MNN 版。核心转向:出海 / 低资源 / 多语言 / Google 技术(Gemma + Gemini)。
> 发布前替换:比赛官方话题、@官方账号、真机数字、demo 视频。
---
## 标题备选
1. 我做了个 App:化验单看不懂?拍一下,断网也能读
2. 把 Google 的 AI 塞进手机,帮你读懂外语体检报告
3. Day_ | 不上传、断网也能用的健康 AI(Gemma + Gemini)
4. 缺医、弱网、看不懂报告——我用端侧 AI 做了个解法
推荐主标题:**Day_ | 不上传、断网也能用的健康 AI**
封面副字:**Gemma 端侧离线 + Gemini 读图/翻译 · 你的母语**
---
## 正文主稿
很多人拿到一张化验单,第一反应是:这些箭头、缩写、参考范围,到底哪项要紧?
如果身边没有医生,或者报告还是外语的,就更难。而最常见的「拍给云端 AI」方式,又得把姓名、年龄、医院、检查结果这些最私密的信息传上去。
所以我做了「康康」——一个**默认在手机本地跑、隐私优先**的健康助手。目标人群不只是国内,而是**弱网/缺医地区,和在国外看不懂医疗文件的人**。
它现在能完成一个完整闭环:
拍一张报告 → 读出指标(数值、单位、参考范围、异常)→ 存成可检索的档案 → 看趋势 → 问它「最近哪些不正常、就诊前帮我整理重点」。
技术上我用的是 **Google 的 AI,双路 hybrid**:
- **端侧默认:Gemma-3n**(Google 开源的手机端多模态小模型)。**飞行模式也能用**——这对网络不稳的地方是刚需。数据不出设备。
- **联网增强:Gemini 2.5 Flash**。你主动开启后,**拍报告原图直接让 Gemini 读图**出结构化指标;**外语报告**也能 Gemini 翻译 + 用你的母语讲明白。
- 断网或额度用尽,**自动回退端侧**,功能不中断。
为什么是这套组合?因为低资源场景同时要「离线能用」和「读得准、能翻译」两件事:前者只有端侧小模型能给(Gemma-3n),后者需要强多模态和多语言(Gemini)。一个负责隐私与可用性,一个负责能力,正好互补。
我特别在意一点:这不是一个「会聊天的健康机器人」,而是把健康资料变成**你自己的长期记忆**——不用再翻相册找去年的化验单,看医生前 30 秒就能整理出重点。它不替你诊断,也不给用药建议,只负责把你已有的信息收好、读懂、讲清楚。
整个 App 是我用 AI 辅助(vibe coding)在几周内做出来的。
健康数据,应该先属于你自己——哪怕没有网络,哪怕你在异国他乡。
#比赛官方话题# #端侧AI #Gemma #Gemini #GoogleAI #本地大模型 #健康管理 #出海 #隐私保护 #iOS开发
---
## 9 宫格图片排序(Google 赛道)
| 顺序 | 图片 | 图中文字 | 作用 |
|---|---|---|---|
| 1 | 首页 + 封面大字 | 不上传、断网也能用的健康 AI | 先讲是什么 |
| 2 | 拍报告 → Gemini 读图结构化 | 拍一张,报告变档案 | 第一卖点(Gemini 核心) |
| 3 | 飞行模式 + 正在生成 | 断网也能 AI 解读 | 低资源杀手锏(Gemma 端侧) |
| 4 | 外语报告 → 母语解读 | 看不懂的外语报告,翻译+讲明白 | 出海痛点(Gemini 多语言) |
| 5 | 我的 · 云端 AI(Gemini 开关) | 隐私优先,云端可选 | hybrid + Google 产品证据 |
| 6 | 趋势页 AI 解读 | 长期变化看得懂 | 长期价值 |
| 7 | 就诊摘要 | 看医生前自动整理 | 应用价值 |
| 8 | 药盒识别 | 药盒也能入档 | 扩展场景 |
| 9 | 架构/开发图 | Gemma + Gemini + SwiftUI | 收尾,vibe coding |
---
## 封面字方案
**技术向**
- 主字:把 Google AI 塞进手机
- 副字:Gemma 端侧离线 + Gemini 读图翻译
- 角标:断网也能用
**大众向**
- 主字:化验单看不懂?拍一下
- 副字:不上传,断网也能读,还能翻译外语报告
- 角标:几周独立开发 demo
---
## 仍需补的素材(对应评审「完成度与传播」)
1. **Gemini 读一张报告的 10 秒录屏**——最值钱的证据,务必有。
2. 飞行模式 + 端侧生成。
3. 外语报告 → 母语解读。
4. 云端 AI 设置页(Gemini 开关)。
5. 片头/转场可用 Veo / Google Flow 生成。

View File

@@ -0,0 +1,318 @@
# 康康 · 小红书长文正文与 9 宫格方案
> 参考风格:「Day530小时,用 AI 开发的 APP 完工了」这类独立开发记录口吻。
> 使用前替换:比赛官方话题、@官方账号、真机 tok/s 数字、开发耗时。
---
## 标题备选
1. Day42我做了个不上传的健康 AI App
2. 6 周做完:体检报告 AI 解读,但不联网
3. 把大模型塞进 iPhone,做了个健康档案 App
4. 体检报告别再乱拍给云端 AI 了
5. 用 MNN 在本地跑健康 AI,我把 demo 做完了
推荐主标题:
**Day42我做了个不上传的健康 AI App**
副标题可放封面小字:
**Qwen + MNN + SME2,体检报告在手机本地解读**
---
## 正文主稿
Day42,这个 6 周 demo 终于能完整跑起来了。
我做了一个叫「康康」的 iOS App。它的目标很简单:把体检报告、化验指标、症状、日记和用药记录整理成一个本地健康档案,再让 AI 用大白话帮你看懂。
但我给自己加了一条硬限制:
**健康数据不上传。**
这件事一开始听起来有点拧巴。因为现在最常见的 AI 解读方式,就是把报告拍照发给云端模型。可体检报告里有姓名、年龄、医院、检查指标、既往问题,基本是一个人最私密的数据之一。
所以我想做一个反过来的版本:
不是把报告交给云端 AI,而是把 AI 放进手机里。
---
这版「康康」现在能完成一个比较完整的闭环:
1. 拍一张体检报告或化验单
2. 本地识别图片里的指标
3. 把数值、单位、参考范围、异常状态整理成结构化档案
4. 后续可以看趋势,比如体重、血糖、血脂、血压
5. 也可以问它:最近哪些指标异常?就诊前帮我整理一份摘要
整个过程不需要账号,不接云服务,没有广告 SDK,也没有第三方分析 SDK。
数据存在 iPhone 本地。报告原图进本地 Vault,SwiftData 存结构化记录,Face ID 可以给 App 加启动锁。
---
技术上最核心的一点是端侧推理。
这次我主路径用的是:
**Qwen3.5-2B + MNN + iPhone CPU/SME2**
MNN 是阿里开源的推理框架。我的目标不是“能接上一个 API 就算 AI App”,而是让模型真的在手机本地吐 token。
App 里做了一个推理引擎页,可以看到当前后端:
- 自动模式:真机优先走 MNN
- MNN:CPU + SME2/NEON
- MLX:兜底和对照
在支持 SME2 的设备上,MNN 会走端侧 CPU 加速。性能自检页会跑固定 prompt,直接显示 prefill 和 decode 速度。这里不是 PPT 数字,是 App 内可以复现的实测。
目前真机结果:
**读入:122 tok/s,生成:33.0 tok/s,总耗时:1.1s**
这个速度已经足够支撑健康档案里的短问答、趋势解读和报告整理。
---
为了让它不是“套壳聊天框”,我把 AI 链路拆成了几层:
UI 不直接调模型。
拍照识别走 CaptureService,身体档案问答走 HealthExportService,趋势解读走 TrendInsightService,最后统一进入 AIRuntime。
AIRuntime 是 actor,里面做了推理闸门。同一时间只允许一个重推理任务占模型内存。因为端侧模型最怕的不是慢,而是多个任务同时加载,内存峰值上去以后直接被系统杀掉。
这也是做端侧 AI 和做云端 API 最大的区别之一:
云端是请求排队。
手机上是内存、功耗、模型加载、UI 响应一起排队。
---
我觉得这个项目真正有价值的地方,不是“AI 能不能讲两句漂亮话”,而是它把健康资料变成了自己的长期记忆。
比如:
你不用再翻相册找去年那张化验单。
也不用每次看医生前,临时回忆“我最近到底哪几项不正常”。
你可以把症状、用药、指标、报告都记在一个地方,需要时生成一份就诊摘要。它不会替医生判断,也不会给剂量建议,只负责把你已有的信息整理清楚。
对普通人来说,这比一个会聊天的健康机器人更实用。
---
这次我最想表达的创新点有三个:
第一,健康 AI 应该默认本地优先。
不是所有数据都适合上传。健康数据尤其应该有“不出设备也能用”的选择。
第二,端侧大模型已经能做完整产品闭环。
不是只跑一个 demo prompt,而是接进真实 App:模型管理、性能自检、拍照识别、趋势解读、问答摘要、失败兜底、隐私设置,这些都要一起工作。
第三,RAG 不一定非要 embedding。
康康里的健康记录本来就是结构化数据。指标名、时间、报告类型、症状、用药都很明确。很多问题可以先抽取意图,再从 SwiftData 里查相关记录,最后让本地模型生成回答。这样更轻,也更适合手机。
---
现在还有很多地方可以继续打磨,比如报告详情页、飞行模式演示图、Live Activity 的实时 tok/s、更多真机性能对比。
但这版已经证明了一件事:
**个人健康档案 + 本地大模型 + MNN 端侧推理,是可以组成一个完整应用的。**
如果说云端 AI 更像一个很聪明的外部顾问,那我希望康康更像一个安静放在手机里的私人健康档案员。
它不替你诊断。
不替医生做决定。
它只帮你把自己的身体记录收好、看懂、在需要的时候讲清楚。
这就是我这次做「康康」的原因。
参加 #比赛官方话题# 的作品记录。后面如果有时间,我会继续补飞行模式演示、Live Activity 和完整 demo 视频。
声明:康康只做健康信息记录、整理和科普式解读,不是医疗器械,不提供诊断、治疗、用药或剂量建议。任何健康决策请咨询专业医生。
---
## 精简版正文
我做了一个叫「康康」的 iOS App。
它能把体检报告、化验指标、症状、日记和用药记录整理成一个本地健康档案,再用 AI 帮你做大白话解读。
最关键的是:不上传。
我不想把体检报告这种高度隐私的数据拍给云端模型,所以这次把 Qwen3.5-2B 放进了手机本地,主推理路径用 MNN,在支持 SME2 的 iPhone 上走端侧 CPU 加速。
现在 App 里已经能看到完整闭环:
拍报告 → 本地识别 → 结构化指标 → 趋势图 → 身体档案问答 → 就诊摘要。
技术上做了双后端:
- MNN:真机主路径,CPU/SME2
- MLX:模拟器和兜底
App 里还有性能自检页,当前真机实测:
读入 122 tok/s,生成 33.0 tok/s,总耗时 1.1s。
我觉得这个项目最有意思的地方,不是做了一个 AI 聊天框,而是把本地大模型真正接进了健康档案系统。
它不会替你诊断,也不会给用药建议。它只做三件事:
帮你收好记录,帮你看懂变化,帮你在看医生前把重点讲清楚。
健康数据应该先属于自己。
这就是我做「康康」的原因。
#比赛官方话题# #端侧AI #MNN #Qwen #本地大模型 #健康管理 #iOS开发 #SwiftUI #隐私保护 #独立开发
---
## 9 宫格图片排序
### 当前素材可发版
| 顺序 | 图片 | 封面字/图中文字 | 作用 |
|---|---|---|---|
| 1 | `21-首页-健康日历趋势提醒.png` | 不上传的健康 AI 档案 | 封面,先讲产品是什么 |
| 2 | `19-报告归档-核对报告信息.png` | 拍报告,自动变档案 | 展示核心创意:报告归档 |
| 3 | `22-框选异常指标-本地识别中.png` | 图片在本地识别 | 展示视觉识别过程 |
| 4 | `06-推理引擎-MNN-SME2性能自检.png` | Qwen + MNN + SME2 | 技术证据和比赛亮点 |
| 5 | `07-模型管理-Qwen已就绪.png` | 1.19GB 模型在手机里 | 证明不是云端 API |
| 6 | `11-身体档案-就诊摘要生成结果.png` | 就诊前 30 秒整理重点 | 展示应用价值 |
| 7 | `09-趋势详情-体重AI解读.png` | 长期趋势,AI 讲人话 | 展示长期使用价值 |
| 8 | `20-药品识别-核对药品.png` | 药盒也能本地识别 | 展示扩展场景 |
| 9 | `02-Xcode开发调试界面.png` | 6 周独立开发记录 | 收尾,增强真实开发感 |
### 如果补图后的更强版
| 顺序 | 建议图片 | 封面字/图中文字 |
|---|---|---|
| 1 | 首页 + 大字封面 | 不上传的健康 AI 档案 |
| 2 | 飞行模式 + 正在生成 | 断网也能 AI 解读 |
| 3 | 报告拍照前后对比 | 拍一下,报告变档案 |
| 4 | 报告详情三 Tab | 原图、解读、指标都留存 |
| 5 | MNN 性能自检 | Qwen + MNN + SME2 |
| 6 | 模型管理页 | 模型真的在本机 |
| 7 | 身体档案摘要 | 看医生前自动整理 |
| 8 | 趋势页 | 长期变化看得懂 |
| 9 | Xcode/架构图 | SwiftUI + SwiftData + MNN |
---
## 封面字方案
### 方案 A:大众向
主字:
**体检报告 AI 解读**
副字:
**不联网,不上传,只在手机里跑**
角标:
**6 周独立开发 demo**
### 方案 B:技术向
主字:
**把 Qwen 塞进 iPhone**
副字:
**MNN + SME2 本地推理健康档案**
角标:
**decode 33.0 tok/s**
### 方案 C:参考 Day 风格
主字:
**Day42 做完一个本地健康 AI**
副字:
**体检报告拍一下,但不交给云端**
角标:
**SwiftUI + MNN + Qwen**
推荐使用方案 C,更贴近你给的参考笔记风格,同时还能保留项目技术点。
---
## 每张图建议加字
1. `21-首页-健康日历趋势提醒.png`
- 主字:Day42 做完一个本地健康 AI
- 小字:体检报告、指标、症状、用药都存在手机里
2. `19-报告归档-核对报告信息.png`
- 主字:拍报告,自动变档案
- 小字:原图和结构化信息一起保存
3. `22-框选异常指标-本地识别中.png`
- 主字:图片识别也在本地
- 小字:异常指标可框选识别
4. `06-推理引擎-MNN-SME2性能自检.png`
- 主字:Qwen + MNN + SME2
- 小字:生成 33.0 tok/s
5. `07-模型管理-Qwen已就绪.png`
- 主字:模型真的在手机里
- 小字:Qwen3.5-2B 本地就绪
6. `11-身体档案-就诊摘要生成结果.png`
- 主字:看医生前自动整理
- 小字:症状、指标、用药一份摘要带走
7. `09-趋势详情-体重AI解读.png`
- 主字:长期变化看得懂
- 小字:折线图 + AI 大白话解读
8. `20-药品识别-核对药品.png`
- 主字:药盒也能入档
- 小字:只记录,不提供用药建议
9. `02-Xcode开发调试界面.png`
- 主字:不是套壳聊天框
- 小字:SwiftUI + SwiftData + MNN 端侧推理
---
## 仍建议补充的截图
1. 飞行模式打开 + App 正在流式生成,这是“不上传”的最强证据。
2. 报告详情页三 Tab:原图 / 解读 / 指标,用于证明档案浏览闭环。
3. Face ID 启动锁页面,用于补足隐私三件套。
4. 如果 Live Activity 已能跑,补锁屏 tok/s,非常适合比赛记忆点。
---
## 标签
#比赛官方话题# #端侧AI #MNN #Qwen #本地大模型 #健康管理 #体检报告解读 #隐私保护 #iOS开发 #SwiftUI #独立开发 #数字健康

Binary file not shown.

View File

@@ -0,0 +1,52 @@
#!/bin/sh
# 构建 sherpa-mnn.xcframework(端侧 SenseVoice ASR,跑在 MNN 后端),供「记录问诊」离线转写用。
# 产物:Frameworks/sherpa-mnn.xcframework(device arm64 + simulator),被 .gitignore 不入库。
#
# 这是「全代码已就位、构建/真机验证由你来跑」中的构建步骤(详见 docs/release/sensevoice-integration.md)。
# App 侧 SenseVoiceBridge.mm 用 __has_include(<sherpa-mnn/c-api/c-api.h>) 探测:
# - 未接入(当前默认)→ 编为桩,问诊自动回退系统端侧识别(SFSpeech),不影响编译/运行;
# - 接入本脚本产物 + 设好 HEADER_SEARCH_PATHS → 自动切到真实 SenseVoice。
#
# 用法:
# MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/build-sherpa-mnn-xcframework.sh
#
# 需求:已 clone MNN 源码(含 apps/frameworks/sherpa-mnn)、CMake、Xcode。约 15-40 分钟。
set -e
MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}"
SHERPA_DIR="${MNN_SRC}/apps/frameworks/sherpa-mnn"
APP_FRAMEWORKS="$(cd "$(dirname "$0")/.." && pwd)/Frameworks"
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
[ -d "$SHERPA_DIR" ] || { echo "❌ 找不到 sherpa-mnn:$SHERPA_DIR(检查 MNN_SRC)"; exit 1; }
# ① 准备 MNN 静态库供 sherpa-mnn 链接。
# sherpa-mnn 的 build-ios.sh 用 MNN_LIB_DIR 找 libMNN.a + 头文件。
# 这里复用本项目的 MNN device/sim 构建产物;若尚未构建,先跑 scripts/build-mnn-xcframework.sh。
# 注意:sherpa 的 build-ios.sh 默认还会编 simulator x86_64,Apple Silicon 上通常用不到——
# 如只在 arm64 Mac/真机验证,可在 sherpa 的 build-ios.sh 里去掉 simulator_x86_64 段,加快构建。
if [ -z "${MNN_LIB_DIR}" ]; then
echo " 未显式指定 MNN_LIB_DIR,默认用 MNN_SRC 内的构建产物目录。"
echo " 若 sherpa cmake 报找不到 MNN,请先构建 MNN(scripts/build-mnn-xcframework.sh)"
echo " 并 export MNN_LIB_DIR=指向含 libMNN.a + include 的目录。"
export MNN_LIB_DIR="${MNN_SRC}/project/ios/build"
fi
echo "MNN_LIB_DIR=${MNN_LIB_DIR}"
# ② 调 sherpa-mnn 自带的 iOS 构建(已含 libtool 合并 + create-xcframework + 拷头到 Headers/)。
cd "$SHERPA_DIR"
sh build-ios.sh
[ -d "$SHERPA_DIR/sherpa-mnn.xcframework" ] || { echo "❌ 构建未产出 sherpa-mnn.xcframework"; exit 1; }
# ③ 拷进 App 的 Frameworks/(供 Xcode 链接 + HEADER_SEARCH_PATHS 指向其 Headers/)。
mkdir -p "$APP_FRAMEWORKS"
rm -rf "$APP_FRAMEWORKS/sherpa-mnn.xcframework"
cp -R "$SHERPA_DIR/sherpa-mnn.xcframework" "$APP_FRAMEWORKS/"
echo "✅ 输出: $APP_FRAMEWORKS/sherpa-mnn.xcframework"
echo "下一步(Xcode,见 docs/release/sensevoice-integration.md):"
echo " 1) 把 sherpa-mnn.xcframework 拖进 target 的 Frameworks, Libraries, and Embedded Content(Do Not Embed,静态库)"
echo " 2) 在 Build Settings → HEADER_SEARCH_PATHS 追加(recursive):"
echo " \$(PROJECT_DIR)/Frameworks/sherpa-mnn.xcframework/Headers"
echo " 3) 转换并安装 SenseVoice 模型:sh scripts/convert-sensevoice-mnn.sh"

View File

@@ -0,0 +1,65 @@
#!/bin/sh
# 把官方 SenseVoice ONNX 模型转换成 MNN 格式,产出「记录问诊」端侧转写要用的两件套:
# SenseVoice/model.mnn —— MNNConvert 量化转换(weightQuant 8bit)的图
# SenseVoice/tokens.txt —— id↔token 映射(直接拷 onnx 包里的)
#
# 产物目录可:
# - 旁路导入:拷到模拟器/真机沙盒 Application Support/Models/SenseVoice/(demo 现场重装兜底);
# - 或上传到你的镜像,后续做成 App 内下载项(本期默认旁路导入,见集成文档)。
#
# 用法:
# MNN_SRC=/Users/xuhuayong/apps/MNN-src sh scripts/convert-sensevoice-mnn.sh
#
# 需求:MNNConvert(从 MNN 源码 -DMNN_BUILD_CONVERTER=ON 编出)、wget/curl、tar。
set -e
MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}"
OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/build/SenseVoice"
WORK_DIR="$(cd "$(dirname "$0")/.." && pwd)/build/sensevoice-src"
MODEL_TAR="sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
MODEL_URL="https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/${MODEL_TAR}.tar.bz2"
# ① 找 MNNConvert。
MNNCONVERT="${MNNCONVERT:-${MNN_SRC}/build/MNNConvert}"
if [ ! -x "$MNNCONVERT" ]; then
echo "❌ 找不到 MNNConvert:$MNNCONVERT"
echo " 先编译转换器(在 MNN 源码里):"
echo " cd \"$MNN_SRC\" && mkdir -p build && cd build \\"
echo " && cmake .. -DMNN_BUILD_CONVERTER=ON -DMNN_LOW_MEMORY=ON -DMNN_SEP_BUILD=OFF && make MNNConvert -j8"
echo " 或 export MNNCONVERT=/path/to/MNNConvert 后重跑。"
exit 1
fi
# ② 下载 + 解包 SenseVoice ONNX(含 fp32 model.onnx / int8 / tokens.txt)。
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"
if [ ! -d "$MODEL_TAR" ]; then
echo "⬇️ 下载 SenseVoice ONNX 模型…"
if command -v wget >/dev/null 2>&1; then
wget -c "$MODEL_URL"
else
curl -L -O "$MODEL_URL"
fi
tar xvf "${MODEL_TAR}.tar.bz2"
fi
SRC_ONNX="${WORK_DIR}/${MODEL_TAR}/model.onnx"
SRC_TOKENS="${WORK_DIR}/${MODEL_TAR}/tokens.txt"
[ -f "$SRC_ONNX" ] || { echo "❌ 缺 model.onnx(应为 fp32,勿用 int8):$SRC_ONNX"; exit 1; }
[ -f "$SRC_TOKENS" ] || { echo "❌ 缺 tokens.txt:$SRC_TOKENS"; exit 1; }
# ③ 转换:fp32 onnx → mnn(权重 8bit 量化,降体积+配合 MNN_LOW_MEMORY 降运行内存)。
# 红线:转 fp32 的 model.onnx,不要转 model.int8.onnx(README 明确)。
mkdir -p "$OUT_DIR"
echo "🔧 转换 model.onnx → model.mnn(weightQuant 8bit)…"
"$MNNCONVERT" -f ONNX \
--modelFile "$SRC_ONNX" \
--MNNModel "$OUT_DIR/model.mnn" \
--weightQuantBits=8 --weightQuantBlock=64
cp "$SRC_TOKENS" "$OUT_DIR/tokens.txt"
echo "✅ 输出: $OUT_DIR/{model.mnn, tokens.txt}"
echo "安装到沙盒(见 docs/release/sensevoice-integration.md):"
echo " - 模拟器:拷到 ~/Library/Developer/CoreSimulator/.../Application Support/Models/SenseVoice/"
echo " - 真机:经「我的 · 模型管理」旁路导入,或预拷进沙盒 Models/SenseVoice/"

View File

@@ -58,6 +58,15 @@ actor AIRuntime {
// MNN,VL 退 MLX Qwen3-VL-4B
private let mnn = MNNBackend()
private(set) var mnnStatus: Status = .notReady
// MARK: - Gemini (hybrid: Gemma ,)
// , OOM , /
// / Gemma-3n(MLX )·
private let gemini = GeminiBackend()
/// ( + key)UI
nonisolated var cloudAvailable: Bool { CloudAI.isConfigured }
/// ( / )
nonisolated static var cloudLabel: String { "Gemini · \(CloudAI.model)" }
/// MNN (/ Models/Qwen3.5-2B-MNN)
nonisolated static var mnnModelFolder: URL {
ModelStore.shared.localURL(for: .mnnLLM)
@@ -307,6 +316,59 @@ actor AIRuntime {
}
}
// MARK: - Gemini (hybrid )
/// ( / ) OOM ,
/// `cloudAvailable`;退 `generate`
func generateCloud(prompt: String, maxTokens: Int = 512) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let stream = await self.gemini.generate(prompt: prompt, maxTokens: maxTokens)
for try await chunk in stream {
try Task.checkCancellation()
continuation.yield(chunk)
}
self.lastGenerateStats = await self.gemini.lastStats
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: CancellationError())
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
/// : Gemini ( JSON)
/// ·;退 OCR+(§3.2)
func analyzeReportCloud(imageURLs: [URL], prompt: String, maxTokens: Int = 1024) async throws -> String {
do {
return try await gemini.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
} catch {
throw AIRuntimeError.inferenceFailed("\(error)")
}
}
// MARK: - ASR(SenseVoice via sherpa-mnn)
/// (SenseVoice, CPU + ): + LLM/VL/MNN ,
/// / 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()
await unloadMNN()
return try await body()
}
// MARK: - VL
/// VL , load

View File

@@ -0,0 +1,213 @@
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-3n(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`
private struct GeminiResponse: Decodable {
struct Candidate: Decodable { let content: Content? }
struct Content: Decodable { let parts: [Part]? }
struct Part: Decodable { let text: String? }
struct Usage: Decodable {
let promptTokenCount: Int?
let candidatesTokenCount: Int?
}
struct APIError: Decodable { let message: String? }
let candidates: [Candidate]?
let usageMetadata: Usage?
let error: APIError?
}

View File

@@ -29,9 +29,15 @@ nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
/// ( .auto)使
/// AIRuntime / MeView , .mnn .mlx
/// ,
///
/// Gemma-3n( MLX/GPU):Gemma-3n MNN(),
/// MNN 便 "mnn", MNN ,
/// 退 MLX MNN MLX
static var current: InferenceEngine {
let resolved = preference.resolved
return resolved.isAvailable ? resolved : .mlx
guard resolved.isAvailable else { return .mlx }
if resolved == .mnn, !ModelStore.shared.isComplete(for: .mnnLLM) { return .mlx }
return resolved
}
/// :CPU SME2(A19/iPhone17+) UI
@@ -52,8 +58,8 @@ nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
}
/// , .auto
/// - auto: MNN(, SME2/NEON),
/// MNN ()退 MLX
/// - auto: Gemma-3n, MLX/GPU, auto .mlx
/// (Gemma-3n MNN/SME2;MNN )
nonisolated enum EnginePreference: String, CaseIterable, Sendable {
case auto
case mnn
@@ -72,7 +78,7 @@ nonisolated enum EnginePreference: String, CaseIterable, Sendable {
switch self {
case .mnn: return .mnn
case .mlx: return .mlx
case .auto: return InferenceEngine.mnn.isAvailable ? .mnn : .mlx
case .auto: return .mlx // Gemma-3n MLX/GPU,auto MLX
}
}
}

View File

@@ -31,8 +31,13 @@ actor LLMSession {
}
/// ( config.json + weights + tokenizer)
/// Gemma-3n `<end_of_turn>`(token 106), eos `<eos>`(1);
/// `<end_of_turn>` maxTokens
static func load(folderURL: URL) async throws -> LLMSession {
let configuration = ModelConfiguration(directory: folderURL)
let configuration = ModelConfiguration(
directory: folderURL,
extraEOSTokens: ["<end_of_turn>"]
)
let container = try await withDeviceOverride {
try await LLMModelFactory.shared.loadContainer(
configuration: configuration

View File

@@ -0,0 +1,40 @@
//
// SenseVoiceBridge.h
// 康康
//
// Objective-C 封装:端侧 SenseVoice ASR(语音→文字),经 sherpa-mnn 在 MNN 后端跑。
// 「记录问诊」用它把整段录音离线转写成文字,再交本地 LLM 整理成问诊小结。
//
// 与 MNNLLMBridge 同一套思路:真实实现在 .mm 里以 C 调用 <sherpa-mnn/c-api/c-api.h>;
// 当工程尚未链接 sherpa-mnn(当前默认)时,以 __has_include 编为桩(isAvailable 返回 NO),
// 上层 SenseVoiceASRService 自动回退到系统端侧识别(SFSpeech),App 不受影响。
// 构建/接入 sherpa-mnn.xcframework 的步骤见 docs/release/sensevoice-integration.md。
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface SenseVoiceBridge : NSObject
/// 本构建是否含真实 sherpa-mnn ASR(已链接 + 非桩=YES)。NO 时上层回退系统识别。
+ (BOOL)isAvailable;
/// 用转换好的 SenseVoice MNN 模型 + tokens 创建离线识别器(贪心解码)。
/// modelPath: MNNConvert 产出的 model.mnn;tokensPath: tokens.txt;
/// language: "auto" / "zh" / "en" / "ja" / "ko" / "yue"。失败返回 nil。
/// 识别器随实例常驻,dealloc 时释放——调用方按「一次问诊建一个」用,用完即释放降内存峰值。
- (nullable instancetype)initWithModelPath:(NSString *)modelPath
tokensPath:(NSString *)tokensPath
language:(NSString *)language;
/// 转写一段单声道 PCM(float32,取值 [-1, 1])。sherpa 内部按 sampleRate 重采样到 16k 再抽 fbank。
/// samples 在本调用期间需保持有效。返回识别文本(可能含 <|lang|> 等标签,由上层清洗);失败返回 nil。
/// 同步阻塞直到解码结束——调用方务必放到后台线程,且经 AIRuntime 闸门与 LLM/VL 互斥(防 OOM)。
- (nullable NSString *)transcribeSamples:(const float *)samples
count:(int)count
sampleRate:(int)sampleRate;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,112 @@
//
// SenseVoiceBridge.mm
// 康康
//
// ObjC++ 实现。链接 sherpa-mnn 时走真实 C-API;否则编为桩(返回不可用,上层回退系统识别)。
//
// 可用性闸门:仅当能找到 sherpa-mnn 的 C-API 头时才编真实路径。这样在尚未接入 sherpa-mnn
// 的工程里本文件也能正常编过(走桩),接入后(把 sherpa-mnn.xcframework 加进 Frameworks/ 并
// 让 HEADER_SEARCH_PATHS 找到其头)自动切到真实实现。见 docs/release/sensevoice-integration.md。
//
#import "SenseVoiceBridge.h"
#if __has_include(<sherpa-mnn/c-api/c-api.h>)
#define KK_SHERPA_MNN_AVAILABLE 1
#else
#define KK_SHERPA_MNN_AVAILABLE 0
#endif
#if !KK_SHERPA_MNN_AVAILABLE
// ============ 桩:工程未链接 sherpa-mnn ============
@implementation SenseVoiceBridge
+ (BOOL)isAvailable { return NO; }
- (nullable instancetype)initWithModelPath:(NSString *)modelPath
tokensPath:(NSString *)tokensPath
language:(NSString *)language { return nil; }
- (nullable NSString *)transcribeSamples:(const float *)samples
count:(int)count
sampleRate:(int)sampleRate { return nil; }
@end
#else
// ============ 真实:sherpa-mnn 离线 SenseVoice ============
#include <sherpa-mnn/c-api/c-api.h>
#include <string>
@implementation SenseVoiceBridge {
const SherpaMnnOfflineRecognizer *_recognizer;
}
+ (BOOL)isAvailable { return YES; }
- (nullable instancetype)initWithModelPath:(NSString *)modelPath
tokensPath:(NSString *)tokensPath
language:(NSString *)language {
self = [super init];
if (!self) return nil;
std::string model = modelPath.UTF8String;
std::string tokens = tokensPath.UTF8String;
std::string lang = language.length ? language.UTF8String : "auto";
SherpaMnnOfflineSenseVoiceModelConfig senseVoice;
memset(&senseVoice, 0, sizeof(senseVoice));
senseVoice.model = model.c_str();
senseVoice.language = lang.c_str();
senseVoice.use_itn = 1; // 逆文本规整:把「一百四十」之类还原成 140,数值更利于阅读/给医生看
SherpaMnnOfflineModelConfig modelConfig;
memset(&modelConfig, 0, sizeof(modelConfig));
modelConfig.tokens = tokens.c_str();
modelConfig.num_threads = 2; // 端侧保守取 2,避免与 UI 抢核
modelConfig.debug = 0;
modelConfig.provider = "cpu"; // sherpa-mnn 后端即 MNN(CPU/SME2),provider 维持 "cpu"
modelConfig.sense_voice = senseVoice;
SherpaMnnOfflineRecognizerConfig config;
memset(&config, 0, sizeof(config));
config.decoding_method = "greedy_search";
config.model_config = modelConfig;
_recognizer = SherpaMnnCreateOfflineRecognizer(&config);
if (_recognizer == nullptr) return nil;
return self;
}
- (void)dealloc {
if (_recognizer) {
SherpaMnnDestroyOfflineRecognizer(_recognizer);
_recognizer = nullptr;
}
}
- (nullable NSString *)transcribeSamples:(const float *)samples
count:(int)count
sampleRate:(int)sampleRate {
if (_recognizer == nullptr || samples == nullptr || count <= 0) return nil;
const SherpaMnnOfflineStream *stream = SherpaMnnCreateOfflineStream(_recognizer);
if (stream == nullptr) return nil;
SherpaMnnAcceptWaveformOffline(stream, sampleRate, samples, count);
SherpaMnnDecodeOfflineStream(_recognizer, stream);
NSString *text = nil;
const SherpaMnnOfflineRecognizerResult *result =
SherpaMnnGetOfflineStreamResult(stream);
if (result) {
if (result->text) {
text = [NSString stringWithUTF8String:result->text];
}
SherpaMnnDestroyOfflineRecognizerResult(result);
}
SherpaMnnDestroyOfflineStream(stream);
return text;
}
@end
#endif

View File

@@ -20,8 +20,8 @@ nonisolated enum ModelManifest {
/// :MNN `MNN`( HuggingFace taobao-mnn);MLX 沿 mlx-community
static func modelScopeRepo(for kind: ModelKind) -> String? {
switch kind {
case .mnnLLM: return "MNN/Qwen3.5-2B-MNN"
case .llm: return "mlx-community/Qwen3.5-2B-4bit"
case .llm: return "mlx-community/gemma-3n-E2B-it-lm-4bit" // ,
case .mnnLLM: return "MNN/Qwen3.5-2B-MNN" // ,
case .vl: return nil // , / ,
}
}
@@ -29,23 +29,23 @@ nonisolated enum ModelManifest {
static func files(for kind: ModelKind) -> [ModelFile] {
switch kind {
case .llm:
// Qwen3.5-2B-4bit:, LLMModelFactory qwen3_5
// mlx-community/Qwen3.5-2B-4bit blob (HF API,2026-06 )
// tokenizer vocab.json + tokenizer.json( merges.txt /
// special_tokens_map.json / added_tokens.json),chat_template .jinja
// (preprocessor / processor / video_preprocessor),
// ,
// Gemma-3n-E2B-it-lm-4bit:Gemma-3n (text-only), LLMModelFactory
// gemma3n (mlx-swift-lm "gemma3n")MLX ,
// ModelScope mlx-community/gemma-3n-E2B-it-lm-4bit blob
//(repo/files API,2026-06 ) README.md / .gitattributes / configuration.json
//( ModelScope ,MLX )model.safetensors ,
// MLX loadWeights glob *.safetensors,index.json ,
// tokenizer.model(SentencePiece) chat_template.jinja ,/
return [
ModelFile(path: "config.json", bytes: 3_113),
ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
ModelFile(path: "vocab.json", bytes: 6_722_759),
ModelFile(path: "chat_template.jinja", bytes: 7_755),
ModelFile(path: "preprocessor_config.json", bytes: 390),
ModelFile(path: "processor_config.json", bytes: 1_300),
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
ModelFile(path: "config.json", bytes: 107_207),
ModelFile(path: "generation_config.json", bytes: 215),
ModelFile(path: "model.safetensors", bytes: 2_507_515_399),
ModelFile(path: "model.safetensors.index.json", bytes: 129_688),
ModelFile(path: "special_tokens_map.json", bytes: 769),
ModelFile(path: "tokenizer.json", bytes: 33_442_553),
ModelFile(path: "tokenizer.model", bytes: 4_696_020),
ModelFile(path: "tokenizer_config.json", bytes: 1_202_305),
ModelFile(path: "chat_template.jinja", bytes: 1_626),
]
case .vl:
// Qwen3-VL-4B-Instruct-4bit: mlx-community blob

View File

@@ -2,17 +2,19 @@ import Foundation
nonisolated enum ModelKind: String, CaseIterable {
/// Models/ / CDN
/// Qwen3.5-2B,:
/// - mnnLLM:MNN(CPU/SME2,)+,taobao-mnn iPhone17+(A19/SME2),
/// - llm:MLX(GPU),Qwen3.5-2B-4bit (, qwen3_5)
/// - vl:(MLX VL .llm ), switch,/
case llm = "Qwen3.5-2B-4bit"
/// Qwen3.5-2B **Gemma-3n E2B( 4bit, MLX/GPU)**:
/// - llm:****MLX , `gemma3n` (mlx-swift-lm )
/// Gemma-3n MLX (VLMModelFactory gemma3n), OCR + LLM
/// - vl:, switch,/
/// - mnnLLM:Gemma-3n MNN( + MatFormer MNN ),
/// MNN/SME2 ;,/
case llm = "gemma-3n-E2B-it-lm-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
case mnnLLM = "Qwen3.5-2B-MNN"
var displayName: String {
switch self {
case .llm: return "Qwen3.5-2B (MLX)"
case .llm: return "Gemma-3n E2B (MLX)"
case .vl: return "Qwen3-VL-4B"
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
}
@@ -24,11 +26,9 @@ nonisolated enum ModelKind: String, CaseIterable {
///
var sentinelFilename: String { "config.json" }
/// : / /
/// Qwen3.5-2B(MNN,+,iPhone17+ )
/// MLX .llm/.vl ,(),
/// · ,
static let userFacing: [ModelKind] = [.mnnLLM]
/// : / /
/// Gemma-3n E2B(MLX,).vl/.mnnLLM ,
static let userFacing: [ModelKind] = [.llm]
}
/// `@unchecked Sendable`:rootURL let, filesystem(线),

View File

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

View File

@@ -71,6 +71,7 @@ enum HealthExportPrompts {
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
- 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
- diaries 里 kind 为「问诊」的是既往看医生的问诊记录,可据此补充「主诉」「本人背景」,但同样只搬运、不编造。
输出格式:
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
@@ -165,6 +166,7 @@ enum HealthExportPrompts {
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
- 中文,简洁,医生 30 秒能扫完。
- 「相关健康日记」每条单独一行,格式为「2026-05-01:正文摘要」,日期照抄 JSON 的 date 字段,精确到日。
- diaries 里 kind 为「问诊」的是既往问诊记录,优先列入「相关健康日记」并标注「(问诊)」。
- 严格按以下段落:
# 就诊摘要
## 本次想解决的问题

View File

@@ -112,6 +112,66 @@ JSON schema(严格):
{"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]}
{{OCR_SECTION}}
现在请识别图片并输出 JSON:
"""#
// MARK: - (OCR ParsedReport, LLM)
/// C2/ : Gemma-3n (MLX gemma3n ),
/// 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(///,)

View File

@@ -366,13 +366,21 @@ struct ArchiveListView: View {
.buttonStyle(.plain)
}
/// (2026-06-28 ):
/// (//)· (/)· 线 + ,
/// TimelineKind()
private var filterChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
ForEach(TimelineKind.allCases) { kind in
chip(label: kind.label, selected: filter == kind) {
filter = filter == kind ? nil : kind
allChip
ForEach(TimelineKind.Group.allCases) { group in
groupSeparator
// ():,
if group.kinds.count > 1 {
groupCaption(group.caption)
}
ForEach(group.kinds) { kind in
kindChip(kind)
}
}
}
@@ -380,23 +388,53 @@ struct ArchiveListView: View {
}
}
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
private var allChip: some View {
let selected = filter == nil
return Button { filter = nil } label: {
Text(String(appLoc: "全部"))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
)
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(selected ? Color.clear : Tj.Palette.line, lineWidth: 1))
}
.buttonStyle(.plain)
}
/// : + , accent ; accent
private func kindChip(_ kind: TimelineKind) -> some View {
let selected = filter == kind
return Button { filter = selected ? nil : kind } label: {
HStack(spacing: 5) {
Image(systemName: kind.icon)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(selected ? Tj.Palette.paper : kind.accent)
Text(kind.label)
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Capsule().fill(selected ? kind.accent : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(selected ? Color.clear : Tj.Palette.line, lineWidth: 1))
}
.buttonStyle(.plain)
}
private var groupSeparator: some View {
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(Tj.Palette.lineSoft)
.frame(width: 1, height: 18)
.padding(.horizontal, 2)
}
private func groupCaption(_ text: String) -> some View {
Text(text)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
HStack {
Text(section.label)

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
import SwiftUI
/// : MNN(CPU/SME2,) MLX(GPU,), SME2
/// : Gemma-3n( 4bit), MLX/GPU
/// MNN/SME2 (Gemma-3n MNN ), MNN
/// ; AI (prepare/generate)
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
@@ -12,10 +16,9 @@ struct InferenceSettingsView: View {
EnginePreference(rawValue: engineRaw) ?? .auto
}
/// (MNN MLX )
/// (Gemma-3n,MLX)
private var modelReady: Bool {
modelService.states[.mnnLLM]?.phase == .ready
|| modelService.states[.llm]?.phase == .ready
modelService.states[.llm]?.phase == .ready
}
var body: some View {
@@ -34,8 +37,8 @@ struct InferenceSettingsView: View {
engineRow(engine)
}
sme2Card
selfTestSection
cloudSection
noteCard
}
.padding(.horizontal, 16)
@@ -134,11 +137,11 @@ struct InferenceSettingsView: View {
.disabled(!available)
}
/// .auto ;
/// .auto ;MLX MNN (Gemma-3n MNN ),
private func isAvailable(_ engine: EnginePreference) -> Bool {
switch engine {
case .auto: return true
case .mnn: return InferenceEngine.mnn.isAvailable
case .mnn: return false
case .mlx: return InferenceEngine.mlx.isAvailable
}
}
@@ -154,52 +157,74 @@ struct InferenceSettingsView: View {
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 {
return String(appLoc: "按本机配置选择 · 当前 MLX(MNN 不可用)")
}
// Gemma-3n,auto MLX
return String(appLoc: "按本机配置选择 · 当前 MLX · GPU")
case .mnn:
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
return InferenceEngine.cpuSupportsSME2
? String(appLoc: "端侧 CPU + SME2 加速 · 挑战赛考核路径")
: String(appLoc: "端侧 CPU(本机无 SME2,NEON 回退)")
return String(appLoc: "已停用:Gemma-3n 无 MNN 转换模型,统一走 MLX")
case .mlx:
return String(appLoc: "Metal GPU · 兜底 / 对照")
return String(appLoc: "Metal GPU · 端侧推理 Gemma-3n E2B")
}
}
private var sme2Card: some View {
let sme2 = InferenceEngine.cpuSupportsSME2
return HStack(spacing: 12) {
ZStack {
Circle().fill(sme2 ? Tj.Palette.leafSoft : Tj.Palette.sand2)
Image(systemName: sme2 ? "checkmark.seal.fill" : "minus.circle")
.font(.tjScaled(18))
.foregroundStyle(sme2 ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Arm SME2")
.font(.tjScaled(15, weight: .medium))
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)
Text(sme2 ? String(appLoc: "本设备支持,MNN 已启用 SME2 加速")
: String(appLoc: "本设备不支持(需 A19/iPhone 17+)"))
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
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-3n;开启后「读报告原图 / 深度解读 / 多语言」走 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-3n,功能不中断。")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(14)
.tjCard()
}
.padding(14)
.tjCard()
}
private var noteCard: some View {
Text("MNN 在端侧 CPU 上以 Arm SME2 指令集加速 Qwen 推理(本地、不上云)。切换后下一次 AI 调用生效。")
Text("隐私优先:默认端侧 Gemma-3n(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -141,7 +141,7 @@ struct ModelManagementView: View {
} else if allReady {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
Text("Qwen3.5-2B 已就绪")
Text("Gemma-3n E2B 已就绪")
}
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.leaf)
@@ -205,9 +205,9 @@ struct ModelManagementView: View {
private func subtitle(_ kind: ModelKind) -> String {
switch kind {
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答(MLX 兜底)")
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答 / 拍照识别 · MLX 端侧推理")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
case .mnnLLM: return String(appLoc: "文本解读 + 拍照识别 · MNN + SME2 端侧加速")
case .mnnLLM: return String(appLoc: "已停用 · 旧 MNN 路径")
}
}

View File

@@ -177,7 +177,7 @@ struct ModelSelfTestView: View {
}
}
}
Text("在「我的 · 推理引擎」切换引擎后再跑一次,即可对比 SME2 与 GPU")
Text("Gemma-3n E2B 在端侧 MLX(Metal GPU)推理,100% 本地、不上云")
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
}

View File

@@ -1,7 +1,7 @@
import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable {
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary, consultation
var id: String { rawValue }
/// RecordSheet () enum ,
@@ -9,7 +9,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
/// `.symptom`() `.diary`(),;
/// `.medicationLibrary`()/,Tab ,
/// (,)
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
static let displayOrder: [RecordKind] = [.diary, .consultation, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
/// pill( subtitle,"/")
/// :,( ProfileEditView presets )
@@ -24,6 +24,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .healthExport: return String(appLoc: "身体档案")
case .archive: return String(appLoc: "体检报告归档")
case .diary: return String(appLoc: "健康日记")
case .consultation: return String(appLoc: "记录问诊")
case .symptom: return String(appLoc: "记录症状")
case .reminder: return String(appLoc: "开启一个提醒")
case .medicationLibrary: return String(appLoc: "药品库")
@@ -36,6 +37,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
case .consultation: return String(appLoc: "看医生时录音,本机转写并整理成小结")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加")
@@ -48,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .healthExport: return "doc.text.below.ecg"
case .archive: return "doc.fill"
case .diary: return "heart.text.square"
case .consultation: return "stethoscope"
case .symptom: return "waveform.path.ecg"
case .reminder: return "bell.badge"
case .medicationLibrary: return "pills.fill"
@@ -60,6 +63,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .healthExport: return Tj.Palette.ink
case .archive: return Tj.Palette.ink
case .diary: return Tj.Palette.leaf
case .consultation: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .reminder: return Tj.Palette.leaf
case .medicationLibrary: return Tj.Palette.ink

View File

@@ -3,36 +3,69 @@ import SwiftData
import Foundation
enum TimelineKind: String, CaseIterable, Identifiable {
case diary, symptom, indicator, medication, report
case diary, symptom, consultation, indicator, medication, report
var id: String { rawValue }
var label: String {
switch self {
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
case .medication: return String(appLoc: "用药")
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
case .consultation: return String(appLoc: "问诊")
case .medication: return String(appLoc: "用药")
}
}
var icon: String {
switch self {
case .indicator: return "drop.fill"
case .report: return "doc.fill"
case .symptom: return "waveform.path.ecg"
case .diary: return "pencil"
case .medication: return "pills.fill"
case .indicator: return "drop.fill"
case .report: return "doc.fill"
case .symptom: return "waveform.path.ecg"
case .diary: return "pencil"
case .consultation: return "stethoscope"
case .medication: return "pills.fill"
}
}
var accent: Color {
switch self {
case .indicator: return Tj.Palette.brick
case .report: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .diary: return Tj.Palette.leaf
case .medication: return Tj.Palette.ink
case .indicator: return Tj.Palette.brick
case .report: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .diary: return Tj.Palette.leaf
case .consultation: return Tj.Palette.ink2
case .medication: return Tj.Palette.ink
}
}
}
extension TimelineKind {
/// (2026-06-28):,,
/// / /
/// TimelineKind,
enum Group: String, CaseIterable, Identifiable {
case selfLog // :(/)
case clinical // :()
case medication //
var id: String { rawValue }
/// ()(), ArchiveListView
var caption: String {
switch self {
case .selfLog: return String(appLoc: "自述")
case .clinical: return String(appLoc: "检查")
case .medication: return String(appLoc: "用药")
}
}
var kinds: [TimelineKind] {
switch self {
case .selfLog: return [.diary, .symptom, .consultation]
case .clinical: return [.indicator, .report]
case .medication: return [.medication]
}
}
}
}
@@ -165,17 +198,28 @@ struct TimelineEntry: Identifiable, Hashable {
}
}
/// tag () .medication ,
/// id "diary-" :TimelineDetail.resolve diaries
/// DiaryEntry tag : .consultation,/ .medication,
/// id "diary-" :TimelineDetail.resolve diaries
static func from(diary d: DiaryEntry) -> TimelineEntry {
let isMed = d.isMedicationLog
let kind: TimelineKind
let subtitle: String
if d.isConsultation {
kind = .consultation
subtitle = String(appLoc: "问诊记录")
} else if d.isMedicationLog {
kind = .medication
subtitle = String(appLoc: "用药记录")
} else {
kind = .diary
subtitle = String(appLoc: "文字日记")
}
return TimelineEntry(
id: "diary-\(d.persistentModelID)",
kind: isMed ? .medication : .diary,
kind: kind,
date: d.createdAt,
title: d.content.firstLine(),
subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"),
trailing: nil,
subtitle: subtitle,
trailing: d.isConsultation && d.audioAsset != nil ? String(appLoc: "录音") : nil,
trailingIsAlert: false,
isOngoing: false
)

View File

@@ -22,8 +22,8 @@ enum TimelineDetail {
case .report:
return reports.first { "report-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.report)
case .diary, .medication:
// tag DiaryEntry,
case .diary, .medication, .consultation:
// / tag DiaryEntry, diaries
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.diary)
case .symptom:
@@ -201,7 +201,9 @@ struct TimelineEntryDetailView: View {
case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情")
case .report: return String(appLoc: "报告详情")
case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
case .diary(let d):
if d.isConsultation { return String(appLoc: "问诊详情") }
return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
case .symptom: return String(appLoc: "症状详情")
}
}
@@ -377,7 +379,9 @@ struct TimelineEntryDetailView: View {
@ViewBuilder
private func diaryBody(_ d: DiaryEntry) -> some View {
if d.isMedicationLog {
if d.isConsultation {
consultationBody(d)
} else if d.isMedicationLog {
medicationBody(d)
} else {
VStack(alignment: .leading, spacing: 16) {
@@ -398,6 +402,42 @@ struct TimelineEntryDetailView: View {
}
}
// MARK: - ( + )
/// (tag ):(),
/// + (header TjLockChip );AI 稿,/
private func consultationBody(_ d: DiaryEntry) -> some View {
VStack(alignment: .leading, spacing: 16) {
if let audio = d.audioAsset {
ConsultationAudioPlayer(asset: audio)
}
card {
HStack(spacing: 6) {
Image(systemName: "stethoscope")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
Text(String(appLoc: "问诊小结"))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(Self.dateTimeText(d.createdAt))
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
divider
Text(d.content)
.font(.tjScaled( 15))
.foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
Text("内容由本机语音识别 + AI 整理,仅供个人回看,可能与原话有出入,不构成诊断或用药建议。")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - 使(// + )
/// 使(tag ): [] · + ,

View File

@@ -28,7 +28,7 @@ final class HealthExport {
var inferredLabelCN: String?
// demo
/// tag, "Qwen3.5-2B-MNN"(iPhone17+ ) "Qwen3.5-2B-4bit"(MLX )
/// tag, "gemma-3n-E2B-it-lm-4bit"(MLX )
var modelTag: String
/// tok/s, demo #6 Live Activity
var decodeRate: Double
@@ -44,7 +44,7 @@ final class HealthExport {
inferredTimeToDate: Date? = nil,
inferredIntent: String? = nil,
inferredLabelCN: String? = nil,
modelTag: String = "Qwen3.5-2B-MNN",
modelTag: String = "gemma-3n-E2B-it-lm-4bit",
decodeRate: Double = 0) {
self.prompt = prompt
self.content = content

View File

@@ -189,7 +189,16 @@ extension DiaryEntry {
/// ()线
static let medicationTag = "用药"
/// () tag: UI ,
/// 线; +
static let consultationTag = "问诊"
var isMedicationLog: Bool { tags.contains(Self.medicationTag) }
var isConsultation: Bool { tags.contains(Self.consultationTag) }
/// ( m4a) mimeType , Asset
var audioAsset: Asset? { assets.first { $0.mimeType.hasPrefix("audio") } }
}
@Model

View File

@@ -87,6 +87,32 @@ nonisolated final class FileVault: @unchecked Sendable {
return SavedAsset(relativePath: filename, bytes: data.count)
}
/// ( m4a) Vault,
/// `tmp`(), writeJPEG
/// `.complete` (§6 :Vault )move (/) writeFailed
/// + , `Asset`
nonisolated func importFile(at sourceURL: URL, preferredExtension ext: String) throws -> SavedAsset {
let fm = FileManager.default
let filename = "\(UUID().uuidString).\(ext)"
let dest = rootURL.appendingPathComponent(filename)
do {
if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) }
try fm.moveItem(at: sourceURL, to: dest)
try fm.setAttributes([.protectionKey: FileProtectionType.complete],
ofItemAtPath: dest.path)
} catch {
throw FileVaultError.writeFailed
}
let bytes = (try? fm.attributesOfItem(atPath: dest.path))?[.size] as? Int ?? 0
return SavedAsset(relativePath: filename, bytes: bytes)
}
/// Vault URL(, AVAudioPlayer )
/// ( / `..` )App ,
nonisolated func absoluteURL(forReading relativePath: String) throws -> URL {
try resolveSafePath(relativePath)
}
nonisolated func loadImage(relativePath: String) throws -> UIImage {
let url = try resolveSafePath(relativePath)
let data: Data

View File

@@ -51,6 +51,8 @@ struct RootView: View {
/// : sheet ,
@State private var diaryDirectWrite = false
@State private var showIndicator = false
/// : AI
@State private var showConsultation = false
@State private var showReminders = false
@State private var showHealthExport = false
/// + :( LLM )
@@ -122,6 +124,7 @@ struct RootView: View {
case .archive: activeFlow = .archive
case .symptom: showSymptomStart = true
case .diary: diaryDirectWrite = false; showDiary = true
case .consultation: showConsultation = true
case .indicator: showIndicator = true
case .reminder: showReminders = true
case .healthExport: showHealthExport = true
@@ -136,6 +139,9 @@ struct RootView: View {
.sheet(isPresented: $showDiary) {
DiaryQuickSheet(directWrite: diaryDirectWrite)
}
.sheet(isPresented: $showConsultation) {
ConsultationSheet()
}
.sheet(isPresented: $showIndicator) {
// : VL ()
IndicatorQuickSheet(onRequestCamera: {

View File

@@ -174,28 +174,56 @@ actor CaptureService {
return s
}
/// VL + JSON assets Vault
/// + JSON assets Vault
///
/// hybrid :
/// - **(Gemini)**: Gemini (·VL, source_box ),
/// OCR Gemma-3n(MLX ,)
/// - **线/**:退 Vision OCR(,<1s/) Gemma LLM meta+
/// (线//)退,(§3.2 退线)
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
// Vision OCR (/ ,)
let ocr = await Self.ocrReference(for: urls)
// :Gemini OCR , Gemini , OCR
if AIRuntime.shared.cloudAvailable {
do {
let raw = try await AIRuntime.shared.analyzeReportCloud(
imageURLs: urls,
prompt: VLPrompts.reportExtraction(ocrText: ocr),
maxTokens: 2048
)
return try CaptureService.parseReportJSON(
CaptureService.stripThink(raw), pageCount: assets.count)
} catch {
// 退, /
}
}
// 退: OCR ( Gemma )
guard !ocr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CaptureError.inferenceFailed(String(appLoc: "未识别到文字,无法解读"))
}
do {
try await AIRuntime.shared.prepareVL()
try await AIRuntime.shared.prepare() // LLM(OOM )
} catch {
throw CaptureError.modelNotReady
}
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
// OCR (Vision ,<1s/): 2B ,
// 退,(§3.2)
let ocr = await Self.ocrReference(for: urls)
let raw: String
var collected = ""
do {
raw = try await AIRuntime.shared.analyzeReport(
imageURLs: urls,
prompt: VLPrompts.reportExtraction(ocrText: ocr)
// , token; VL/ AIRuntime
let stream = await AIRuntime.shared.generate(
prompt: VLPrompts.reportExtractionFromText(ocr),
maxTokens: 2048
)
for try await chunk in stream { collected += chunk.text }
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
let cleaned = CaptureService.stripThink(collected)
do {
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
return try CaptureService.parseReportJSON(cleaned, pageCount: assets.count)
} catch let CaptureError.parseFailed(msg) {
throw CaptureError.parseFailed(msg)
} catch {

View File

@@ -0,0 +1,206 @@
import Foundation
import Speech
import AVFoundation
/// + (2026-06-28)
///
/// `SpeechDictationService`(,****):,
/// ** AVAudioEngine tap** :
/// `request.append(buffer)` SFSpeech (,`requiresOnDeviceRecognition = true`,线:);
/// `audioFile.write(buffer)` m4a(AAC) tmp, `FileVault.importFile` Vault
///
/// ****: `try?` ,
/// 稿 SFSpeech ,,(线 #5:退,)
///
/// MainActor , MainActor;tap 线,
/// ,线 `Task { @MainActor }`( SpeechDictationService)
final class ConsultationRecorder {
enum RecorderError: Error, LocalizedError {
case unavailable
case audioEngineStartFailed(String)
var errorDescription: String? {
switch self {
case .unavailable:
return String(appLoc: "本机不支持端侧语音识别")
case .audioEngineStartFailed(let m):
return String(appLoc: "录音启动失败:\(m)")
}
}
}
/// :稿 + ( nil,)
struct Result {
let transcript: String
/// tmp m4a; `FileVault.importFile` Vault
let audioTempURL: URL?
}
/// ;( SpeechDictationService)
private static func makeRecognizer() -> SFSpeechRecognizer? {
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {
return r
}
if let r = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")),
r.supportsOnDeviceRecognition {
return r
}
return nil
}
/// false( / ) UI 退
static var isAvailable: Bool { makeRecognizer() != nil }
private let audioEngine = AVAudioEngine()
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
/// buffer , tap ()
private var audioFile: AVAudioFile?
private var audioTempURL: URL?
private var latestText = ""
private var didFinish = false
private(set) var isRecording = false
/// + false
func requestAuthorization() async -> Bool {
let speech = await withCheckedContinuation { (c: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
SFSpeechRecognizer.requestAuthorization { c.resume(returning: $0) }
}
guard speech == .authorized else { return false }
return await AVAudioApplication.requestRecordPermission()
}
/// + partial 线()
func start(onPartial: @escaping (String) -> Void) throws {
guard !isRecording else { return }
guard let recognizer = Self.makeRecognizer(), recognizer.isAvailable else {
throw RecorderError.unavailable
}
let session = AVAudioSession.sharedInstance()
do {
// .record + ( .measurement):,
try session.setCategory(.record, mode: .default, options: .duckOthers)
try session.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
throw RecorderError.audioEngineStartFailed(error.localizedDescription)
}
let request = SFSpeechAudioBufferRecognitionRequest()
request.requiresOnDeviceRecognition = true // 线:
request.shouldReportPartialResults = true
request.addsPunctuation = true
self.request = request
latestText = ""
didFinish = false
audioFile = nil
// tmp(,); Vault
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("consult-\(UUID().uuidString).m4a")
self.audioTempURL = tempURL
let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
// tap tap :AAC processingFormat = / float ,
// tap buffer write (try? nil),()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: format.sampleRate,
AVNumberOfChannelsKey: format.channelCount,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
]
let audioFile = try? AVAudioFile(forWriting: tempURL, settings: settings)
self.audioFile = audioFile // stop() flush
// tap 线:**** request / audioFile, self(线, SpeechDictationService)
input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
request.append(buffer)
try? audioFile?.write(from: buffer)
}
audioEngine.prepare()
do {
try audioEngine.start()
} catch {
input.removeTap(onBus: 0)
deactivateSession()
throw RecorderError.audioEngineStartFailed(error.localizedDescription)
}
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
Task { @MainActor in
guard let self else { return }
if let result {
self.latestText = result.bestTranscription.formattedString
onPartial(self.latestText)
if result.isFinal { self.didFinish = true }
}
if error != nil { self.didFinish = true }
}
}
isRecording = true
}
/// ,( 1.5s, partial),稿 +
func stop() async -> Result {
guard isRecording else {
return Result(transcript: latestText, audioTempURL: finishedAudioURL())
}
isRecording = false
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
request?.endAudio()
let deadline = Date().addingTimeInterval(1.5)
while !didFinish && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
task?.cancel()
task = nil
request = nil
let url = finishedAudioURL() // ( nil) URL, flush
deactivateSession()
return Result(transcript: latestText, audioTempURL: url)
}
/// :,;
func abort() {
guard isRecording else {
cleanupTempFile()
return
}
isRecording = false
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
request?.endAudio()
task?.cancel()
task = nil
request = nil
audioFile = nil
deactivateSession()
cleanupTempFile()
}
/// (flush), URL;/ nil
private func finishedAudioURL() -> URL? {
audioFile = nil // , flush
guard let url = audioTempURL,
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let bytes = attrs[.size] as? Int, bytes > 0 else {
return nil
}
return url
}
private func cleanupTempFile() {
if let url = audioTempURL { try? FileManager.default.removeItem(at: url) }
audioTempURL = nil
}
private func deactivateSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
}

View File

@@ -201,4 +201,29 @@ struct DiaryAssistService {
guard !text.isEmpty else { throw AssistError.empty }
return (text, lastRate)
}
/// 稿(2026-06-28, ConsultationPrompts)
/// organize AIRuntime actor 退(), prompt/
/// maxTokens 700:,
func organizeConsultation(transcript: String) async throws -> (text: String, decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = ConsultationPrompts.organize(transcript: transcript)
var collected = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 700)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
let text = HealthExportService.stripThinkBlocks(collected)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { throw AssistError.empty }
return (text, lastRate)
}
}

View File

@@ -647,13 +647,21 @@ struct HealthExportService {
return d
}
// diaries
// diaries(:tag )
// kind ,
root["diaries"] = snapshot.diaries.map { d -> [String: Any] in
let excerpt = String(d.content.prefix(80))
return [
let limit = d.isConsultation ? 240 : 80
let excerpt = String(d.content.prefix(limit))
var item: [String: Any] = [
"date": df.string(from: d.createdAt),
"excerpt": excerpt
]
if d.isConsultation {
item["kind"] = "问诊"
} else if d.isMedicationLog {
item["kind"] = "用药"
}
return item
}
// LLM

View File

@@ -0,0 +1,169 @@
import Foundation
import AVFoundation
/// (SenseVoice, sherpa-mnn MNN )
///
/// **线**(,),
/// 稿 LLM(DiaryAssistService.organizeConsultation)
///
/// 线:
/// - ,(§1 / §10 #1)
/// - LLM/VL `AIRuntime.runExclusiveForASR` ****:/,
/// App jetsam (§3.1 OOM , [[airuntime-llm-vl-oom-gate]])
/// - ,退(SFSpeech),App (§10 #5)
///
/// LLM `ModelKind` , `Models/SenseVoice/` (model.mnn + tokens.txt)
/// / docs/release/sensevoice-integration.md
struct SenseVoiceASRService {
static let shared = SenseVoiceASRService()
private init() {}
enum ASRError: Error, LocalizedError {
case modelNotInstalled
case engineUnavailable
case decodeFailed
case empty
var errorDescription: String? {
switch self {
case .modelNotInstalled: return String(appLoc: "问诊转写模型未就绪")
case .engineUnavailable: return String(appLoc: "本机暂不支持本地语音转写")
case .decodeFailed: return String(appLoc: "录音解码失败")
case .empty: return String(appLoc: "没识别到语音内容")
}
}
}
// MARK: - ( LLM ModelKind, ASR )
/// `Application Support/Models/SenseVoice/`, LLM
nonisolated static var modelDir: URL {
ModelStore.shared.rootURL.appendingPathComponent("SenseVoice", isDirectory: true)
}
/// MNNConvert SenseVoice
nonisolated static var modelFile: URL { modelDir.appendingPathComponent("model.mnn") }
/// tokens.txt(idtoken )
nonisolated static var tokensFile: URL { modelDir.appendingPathComponent("tokens.txt") }
/// ( / )
nonisolated static var isModelInstalled: Bool {
let fm = FileManager.default
return fm.fileExists(atPath: modelFile.path) && fm.fileExists(atPath: tokensFile.path)
}
/// SenseVoice = (sherpa-mnn)****
/// false 退(SFSpeech)
nonisolated static var isAvailable: Bool {
SenseVoiceBridge.isAvailable() && isModelInstalled
}
// MARK: -
/// (m4a/wav/caf )线,退
/// language:"auto" (), "zh"
func transcribe(audioFileURL: URL, language: String = "auto") async throws -> String {
guard SenseVoiceBridge.isAvailable() else { throw ASRError.engineUnavailable }
guard Self.isModelInstalled else { throw ASRError.modelNotInstalled }
let modelPath = Self.modelFile.path
let tokensPath = Self.tokensFile.path
// + (CPU),线: LLM/VL ,
let raw = try await AIRuntime.shared.runExclusiveForASR {
try await Self.runOnBackground {
let decoded = try Self.decodeToMonoFloat(url: audioFileURL)
guard !decoded.samples.isEmpty else { throw ASRError.decodeFailed }
guard let bridge = SenseVoiceBridge(modelPath: modelPath,
tokensPath: tokensPath,
language: language) else {
throw ASRError.engineUnavailable
}
let text = decoded.samples.withUnsafeBufferPointer { buf -> String? in
guard let base = buf.baseAddress else { return nil }
return bridge.transcribeSamples(base,
count: Int32(buf.count),
sampleRate: Int32(decoded.sampleRate))
}
return text ?? ""
}
}
let cleaned = Self.cleanTranscript(raw)
guard !cleaned.isEmpty else { throw ASRError.empty }
return cleaned
}
// MARK: - ()
/// SenseVoice : `<|zh|><|NEUTRAL|><|Speech|><|woitn|>` trim
/// sherpa ,, LLM 稿
nonisolated static func cleanTranscript(_ raw: String) -> String {
let stripped = raw.replacingOccurrences(
of: "<\\|[^|]*\\|>", with: "", options: .regularExpression)
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - ( 16kHz float32)
private struct Decoded { let samples: [Float]; let sampleRate: Int }
/// AVAudioConverter / 16kHz float32(SenseVoice )
/// :LLM ,
nonisolated private static func decodeToMonoFloat(url: URL,
targetSampleRate: Double = 16000) throws -> Decoded {
let file = try AVAudioFile(forReading: url)
let inFormat = file.processingFormat
let frameCount = AVAudioFrameCount(file.length)
guard frameCount > 0 else { return Decoded(samples: [], sampleRate: Int(targetSampleRate)) }
guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32,
sampleRate: targetSampleRate,
channels: 1,
interleaved: false),
let converter = AVAudioConverter(from: inFormat, to: targetFormat),
let inBuffer = AVAudioPCMBuffer(pcmFormat: inFormat, frameCapacity: frameCount) else {
throw ASRError.decodeFailed
}
try file.read(into: inBuffer)
// (48k/44.1k16k),; 16k ratio>1, +
let ratio = targetSampleRate / inFormat.sampleRate
let outCapacity = AVAudioFrameCount(Double(frameCount) * ratio) + 1024
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outCapacity) else {
throw ASRError.decodeFailed
}
var fed = false
var convError: NSError?
let status = converter.convert(to: outBuffer, error: &convError) { _, inStatus in
if fed {
inStatus.pointee = .endOfStream // , flush
return nil
}
fed = true
inStatus.pointee = .haveData
return inBuffer
}
if let convError { throw convError }
guard status != .error else { throw ASRError.decodeFailed }
guard let channel = outBuffer.floatChannelData?[0] else {
return Decoded(samples: [], sampleRate: Int(targetSampleRate))
}
let n = Int(outBuffer.frameLength)
let samples = Array(UnsafeBufferPointer(start: channel, count: n))
return Decoded(samples: samples, sampleRate: Int(targetSampleRate))
}
/// QoS (, actor/线)
nonisolated private static func runOnBackground<T: Sendable>(
_ work: @escaping @Sendable () throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
do { cont.resume(returning: try work()) }
catch { cont.resume(throwing: error) }
}
}
}
}

View File

@@ -4,3 +4,4 @@
//
#import "AI/MNN/MNNLLMBridge.h"
#import "AI/MNN/SenseVoiceBridge.h"

View File

@@ -0,0 +1,68 @@
import Testing
import Foundation
@testable import
/// (2026-06-28):prompt 线 + DiaryEntry /
struct ConsultationTests {
// MARK: - Prompt
@Test func organizePromptContainsTranscriptAndHardRules() {
let prompt = ConsultationPrompts.organize(transcript: "上楼梯就喘半个月了医生说做个心电图下周三复查")
#expect(prompt.contains("上楼梯就喘半个月了医生说做个心电图下周三复查"))
// 线:/,
#expect(prompt.contains("转写整理"))
#expect(prompt.contains("一字不改"))
//
#expect(prompt.contains("主诉"))
#expect(prompt.contains("医生建议"))
#expect(prompt.contains("复查"))
// prompt :
#expect(prompt.contains("/no_think"))
}
@Test func organizePromptTruncatesLongTranscript() {
let long = String(repeating: "", count: 4000) //
let prompt = ConsultationPrompts.organize(transcript: long)
let expectedTail = String(long.prefix(ConsultationPrompts.organizeTranscriptLimit))
#expect(prompt.contains(expectedTail))
#expect(!prompt.contains(String(long.prefix(ConsultationPrompts.organizeTranscriptLimit + 2))))
}
// MARK: - DiaryEntry /
@Test func consultationTagDrivesClassification() {
let consult = DiaryEntry(content: "主诉:胸闷", tags: [DiaryEntry.consultationTag])
#expect(consult.isConsultation)
#expect(!consult.isMedicationLog)
let plain = DiaryEntry(content: "今天还好")
#expect(!plain.isConsultation)
let med = DiaryEntry(content: "缬沙坦 80mg", tags: [DiaryEntry.medicationTag])
#expect(!med.isConsultation)
#expect(med.isMedicationLog)
}
@Test func audioAssetPicksAudioMimeOnly() {
let entry = DiaryEntry(content: "主诉:头晕", tags: [DiaryEntry.consultationTag])
// Asset nil
#expect(entry.audioAsset == nil)
// +
entry.assets = [
Asset(relativePath: "a.jpg", mimeType: "image/jpeg"),
Asset(relativePath: "b.m4a", mimeType: "audio/m4a"),
]
#expect(entry.audioAsset?.relativePath == "b.m4a")
}
// MARK: - 线
@Test func consultationDiaryMapsToConsultationKind() {
let entry = DiaryEntry(content: "主诉:咳嗽\n医生建议:多喝水", tags: [DiaryEntry.consultationTag])
let timelineEntry = TimelineEntry.from(diary: entry)
#expect(timelineEntry.kind == .consultation)
}
}

View File

@@ -4,8 +4,9 @@ import Foundation
struct ModelManifestTests {
@Test func llmHasTenFunctionalFiles() {
#expect(ModelManifest.files(for: .llm).count == 10)
@Test func llmHasNineFunctionalFiles() {
// Gemma-3n E2B(text-only),9
#expect(ModelManifest.files(for: .llm).count == 9)
}
@Test func vlHasFourteenFunctionalFiles() {
@@ -13,7 +14,8 @@ struct ModelManifestTests {
}
@Test func llmTotalBytesMatchesManifest() {
#expect(ModelManifest.totalBytes(for: .llm) == 1_749_079_691)
// Gemma-3n-E2B-it-lm-4bit (ModelScope repo/files ,2026-06)
#expect(ModelManifest.totalBytes(for: .llm) == 2_547_095_782)
}
@Test func vlTotalBytesMatchesManifest() {
@@ -37,9 +39,11 @@ struct ModelManifestTests {
}
@Test func mnnFileURLUsesRepoPath() {
// .mnnLLM ModelScope ;fileURL modelscope resolve/master
let file = ModelFile(path: "config.json", bytes: 652)
let url = ModelManifest.fileURL(for: .mnnLLM, file: file)
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-2B-MNN/config.json")
#expect(url.absoluteString ==
"https://modelscope.cn/models/MNN/Qwen3.5-2B-MNN/resolve/master/config.json")
}
@Test func excludesReadmeAndGitattributes() {
@@ -61,9 +65,11 @@ struct ModelManifestTests {
#expect(vl.contains("model.safetensors"))
}
@Test func fileURLIsBaseSlashRepoSlashPath() {
let file = ModelFile(path: "config.json", bytes: 3_113)
@Test func llmFileURLUsesModelScopeRepo() {
// ModelScope resolve/master(,302 OSS Range )
let file = ModelFile(path: "config.json", bytes: 107_207)
let url = ModelManifest.fileURL(for: .llm, file: file)
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-2B-4bit/config.json")
#expect(url.absoluteString ==
"https://modelscope.cn/models/mlx-community/gemma-3n-E2B-it-lm-4bit/resolve/master/config.json")
}
}

View File

@@ -0,0 +1,49 @@
import Testing
import Foundation
@testable import
/// SenseVoice (2026-06-30):稿 + +
struct SenseVoiceASRTests {
// MARK: - 稿()
@Test func cleanStripsSenseVoiceTags() {
// SenseVoice <|lang|><|emotion|><|event|><|itn|>
let raw = "<|zh|><|NEUTRAL|><|Speech|><|woitn|>最近胸口闷,上楼梯就喘"
#expect(SenseVoiceASRService.cleanTranscript(raw) == "最近胸口闷,上楼梯就喘")
}
@Test func cleanTrimsWhitespace() {
#expect(SenseVoiceASRService.cleanTranscript(" 下周三复查 \n") == "下周三复查")
}
@Test func cleanLeavesPlainTextUntouched() {
let plain = "医生说先做心电图和验血"
#expect(SenseVoiceASRService.cleanTranscript(plain) == plain)
}
@Test func cleanHandlesTagsOnlyAsEmpty() {
// (退 / )
#expect(SenseVoiceASRService.cleanTranscript("<|en|><|HAPPY|>").isEmpty)
}
// MARK: -
@Test func modelPathsLiveUnderSenseVoiceDir() {
#expect(SenseVoiceASRService.modelDir.lastPathComponent == "SenseVoice")
#expect(SenseVoiceASRService.modelFile.lastPathComponent == "model.mnn")
#expect(SenseVoiceASRService.tokensFile.lastPathComponent == "tokens.txt")
// LLM (Application Support/Models),
#expect(SenseVoiceASRService.modelDir.deletingLastPathComponent() == ModelStore.shared.rootURL)
}
// MARK: -
@Test func unavailableWhenEngineNotLinked() {
// sherpa-mnn() ,isAvailable false,
// 退(SFSpeech)
if !SenseVoiceASRService.isModelInstalled {
#expect(SenseVoiceASRService.isAvailable == false)
}
}
}

View File

@@ -10,8 +10,14 @@ struct TimelineGroupingTests {
return Calendar(identifier: .gregorian).date(from: c)!
}()
@Test func timelineKindOrderMatchesRecordFilterChips() {
#expect(TimelineKind.allCases == [.diary, .symptom, .indicator, .medication, .report])
@Test func timelineKindGroupsCoverAllKindsInChipOrder() {
// (2026-06-28 ):
// (//) (/)
let chipOrder = TimelineKind.Group.allCases.flatMap(\.kinds)
#expect(chipOrder == [.diary, .symptom, .consultation, .indicator, .report, .medication])
// TimelineKind
#expect(Set(chipOrder) == Set(TimelineKind.allCases))
#expect(chipOrder.count == TimelineKind.allCases.count)
}
@Test func todaySection() {