```
feat(iOS): 更新MNN后端模型配置优化性能 将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本 实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX 兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。 ```
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
| 图表 | Swift Charts | iOS 16+ 原生 |
|
||||||
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
|
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
|
||||||
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
||||||
| LLM | MNN 主:Qwen3.5-4B(`taobao-mnn/Qwen3.5-4B-MNN`,~2.64GiB);MLX 兜底:Qwen3.5-2B-4bit | 文本生成、关键词抽取、趋势解读 |
|
| LLM | MNN 主(iPhone17+/SME2):Qwen3.5-2B(`taobao-mnn/Qwen3.5-2B-MNN`,~1.1GiB);MLX 兜底:Qwen3.5-2B-4bit | 文本生成、关键词抽取、趋势解读。4B 实测过慢已退回 2B |
|
||||||
| VL | Qwen3-VL-4B-Instruct 4bit (MLX `mlx-community/Qwen3-VL-4B-Instruct-4bit`) | 拍照→结构化指标。MNN VL 需 OMNI 构建,暂走 MLX |
|
| VL | Qwen3-VL-4B-Instruct 4bit (MLX `mlx-community/Qwen3-VL-4B-Instruct-4bit`) | 拍照→结构化指标。MNN VL 需 OMNI 构建,暂走 MLX |
|
||||||
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||||
| Face ID | LocalAuthentication | |
|
| Face ID | LocalAuthentication | |
|
||||||
|
|||||||
210
docs/踩坑与排查记录.md
Normal file
210
docs/踩坑与排查记录.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# 康康 · 踩坑与排查记录
|
||||||
|
|
||||||
|
> 本地推理 / SwiftData / 端侧模型这类问题不好复现也不好搜,踩过的坑按统一模板记在这里,方便回查。
|
||||||
|
> 新增条目往最上面加(倒序),模板见文末。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-09 · 生成身体档案报告时,LLM 逐行复读死循环
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
多轮「身体档案」对话点生成报告后,「## 关键指标」整段陷入死循环:同一行
|
||||||
|
`⚠️ 收缩压 (107 mmHg)` 连续重复几十遍,最后被 maxTokens 截断成半行「⚠️ 收缩」。
|
||||||
|
(本质是小模型 **repetition / degeneration loop**,不是数据真有几十条。)
|
||||||
|
|
||||||
|
### 根因(确认)
|
||||||
|
采样器**完全没有重复惩罚**,叠加低温 → 几乎必然复读。两个后端都有问题:
|
||||||
|
|
||||||
|
| 后端 | 位置 | 原配置 | 问题 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MNN(主) | `MNNLLMBridge.mm` `initWithConfigPath` 的 `set_config` | `temperature 0.3, topP 0.85` | 无 `penalty` |
|
||||||
|
| MLX(兜底) | `LLMSession.swift` `GenerateParameters` | `temperature 0.3, topP 0.85` | 无 `repetitionPenalty` |
|
||||||
|
|
||||||
|
关键细节(读 MNN 源码 `transformers/llm/engine/src/`):
|
||||||
|
- `llmconfig.hpp`:`mixed_samplers` 默认 `{topK, tfs, typical, topP, min_p, temperature}` —— **不含 `penalty`**;
|
||||||
|
`penalty` / `ngram_factor` 默认 `1.0`(=全关)。
|
||||||
|
- `sampler.cpp` `configMixed`:只会把 `penalty`「**移到链首(如果存在)**」,**不会自动插入**。
|
||||||
|
所以光设 `"penalty":1.1` 没用,必须把 `"penalty"` 显式写进 `mixed_samplers`。
|
||||||
|
- `sampler.cpp` `stepPenalty`:`repetition_penalty` 对 logits 乘法惩罚;**n-gram 命中整段重复时惩罚直接升到 `max_penalty`** —— 这正是掐断「整行复读」最有效的开关。
|
||||||
|
|
||||||
|
**为什么低温反而更糟**:temperature 0.3 接近贪心,一旦吐出 `收缩压 (107 mmHg)\n`,
|
||||||
|
最高概率的后续就是再吐一遍同样的行,无惩罚就永远出不来。
|
||||||
|
|
||||||
|
### 排查过程(可复用思路)
|
||||||
|
1. 看现象先判定是「数据重复」还是「生成复读」—— 被截断成半行 `收缩` 说明是 token 级复读,不是数据。
|
||||||
|
2. `grep -niE "penalty|temperature|top_?p|sampler" 康康/AI/` 一把定位两个后端的采样配置 → 都没 penalty。
|
||||||
|
3. 不猜 MNN 配置键,直接读构建用的源码 `MNN_SRC=/Users/xuhuayong/apps/MNN-src`
|
||||||
|
的 `llmconfig.hpp` / `sampler.cpp`,确认键名、默认值、`mixed_samplers` 不自动插 penalty。
|
||||||
|
4. MLX 侧读 SPM checkout 的 `MLXLMCommon/Evaluate.swift`,确认 `GenerateParameters` 有
|
||||||
|
`repetitionPenalty: Float?` + `repetitionContextSize: Int`。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- **MNN** `MNNLLMBridge.mm`:`set_config` 显式开重复惩罚 +
|
||||||
|
把 `penalty` 放进 mixed 链首:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"jinja":{"context":{"enable_thinking":false}},
|
||||||
|
"sampler_type":"mixed",
|
||||||
|
"mixed_samplers":["penalty","topK","topP","temperature"],
|
||||||
|
"temperature":0.3,"topP":0.85,"topK":40,
|
||||||
|
"penalty":1.1,"n_gram":8,"ngram_factor":1.05
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(注意:JSON merge-patch 对数组是**整体替换**,所以这里会覆盖掉默认 `mixed_samplers`,符合预期。)
|
||||||
|
- **MLX** `LLMSession.swift`:`GenerateParameters(..., repetitionPenalty: 1.1, repetitionContextSize: 64)`。
|
||||||
|
|
||||||
|
取值都偏保守:`penalty 1.1` / `ngram_factor 1.05` 是业界常用档(MNN 自带 omni 默认 1.05),
|
||||||
|
低温 + 轻惩罚既能掐复读,又不破坏 JSON / 结构化输出的稳定性。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- `xcodebuild ... -destination generic/platform=iOS` 编译通过(两个后端均编进)。
|
||||||
|
- ⚠️ **真机/模拟器跑一遍多轮导出生成报告**,确认不再复读 —— 复读属推理期行为,单测覆盖不到,必须实跑。
|
||||||
|
|
||||||
|
### 预防 / 相关注意
|
||||||
|
- 任何新增的「长文本生成」(非 JSON 抽取)都走同一套带惩罚的采样参数,别再裸 temperature。
|
||||||
|
- **相关隐患(未修,留观)**:`HealthExportService.retrieveDialogueSnapshot` 取指标时
|
||||||
|
**没有 `prefix` 截断**(窗口检索版 `retrieve` 截了 `prefix(20)`)。指标极多时 prompt 会膨胀、
|
||||||
|
也更易诱发复读。若复发,优先给 dialogue snapshot 也加上限。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 以下几条据 W1–W2(2026-05~06)记忆补记,细节以代码/提交为准。
|
||||||
|
|
||||||
|
## 2026-06-09 · MNN 路径 Qwen3.5 强制思考,只吐 `<think>` / JSON 解析失败
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
MNN 真机路径上模型自检只显示 `<think>` 思考过程,AI 辅助拿不到 JSON(解析失败);
|
||||||
|
同样的 prompt 走 MLX 兜底却正常。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
模型自带 `config.json`(taobao-mnn 预转换件)写死 `"jinja":{"context":{"enable_thinking":true}}`,
|
||||||
|
Qwen3.5 聊天模板据此每个 assistant 回合硬塞 `<think>\n` 开思考,吞掉 token 预算。
|
||||||
|
**prompt 里的 `/no_think` 对 MNN 无效** —— 模板只读 `enable_thinking`,不看文本软开关。
|
||||||
|
只在真机爆是因为 MLX 经 swift-transformers 套模板时不传 `enable_thinking` → 走 else 空 think 块,天然不思考。
|
||||||
|
(这点从仓库代码看不出来,config.json 是下载/旁路导入的模型产物,不在 git 里。)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
`MNNLLMBridge.mm` 在 `createLLM` 后、`load()` 前 merge-patch 关闭:
|
||||||
|
`set_config("{\"jinja\":{\"context\":{\"enable_thinking\":false}}}")`。不改模型文件、不动字节校验。`stripThink` 保留兜底。
|
||||||
|
|
||||||
|
### 预防
|
||||||
|
再遇 MNN 只出思考 / JSON 解析失败,先查 `config.json` 的 `enable_thinking`,别去调 `/no_think` 或加大预算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-07 · 「记录指标·拍照识别」VL 直读化验单不稳 → 改 Vision OCR + LLM
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
Qwen-VL 直读密集小字化验单经常返回 `{"indicators":[]}`(读不出指标)。
|
||||||
|
|
||||||
|
### 根因 / 决策
|
||||||
|
小模型 VL 对密集中文小字不稳。改链路:`DocumentScanner 整页扫描 → Apple Vision OCR(zh-Hans/Hant/en)
|
||||||
|
→ Qwen3 LLM 解析(VLPrompts.indicatorsFromText)→ stripThink → parseIndicatorsJSON → 确认页人工校对 → 存`。
|
||||||
|
Vision OCR 是系统框架、100% 本地,不违反隐私红线。
|
||||||
|
|
||||||
|
### 预防
|
||||||
|
这条路**不要改回 VL 直读**。VL 仍只用于「体检报告归档」整份解读,两者分开。OCR 行分组偶有错位,靠确认页人工校正兜底。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-01 · git 全量 push 撞 HTTP 413(历史里有 165MB 构建产物)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
`git push` 到 myv0(Gitea 反代有上传体积限制)报 **HTTP 413**。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
旧 commit 误把 `build/` 构建产物提交进库(最大单文件 xcarchive DWARF **165MB**),后来虽 `git rm --cached` + `.gitignore`,
|
||||||
|
但对象仍留在历史 → `.git` 87MB,全量 push 超反代上限。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
对主仓库 `git filter-repo --path build/ --invert-paths --force` 从全历史剥离 → `.git` 87M→2.9M,不再 413。
|
||||||
|
注意:① 重写了所有 commit hash(内容不变),旧克隆需重新 clone;② filter-repo 会移除所有 remote,事后须重新 `git remote add origin`;③ 凭证不写入 `.git/config`。
|
||||||
|
|
||||||
|
### 预防
|
||||||
|
`build/` 必须在 `.gitignore`;别把构建产物 / 大二进制提交进库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-31 · 快拍 VL 识别时 App 自动退出(jetsam OOM,非崩溃)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
iPhone 15 Pro Max 上 VL 识别时 App 直接退出。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
不是代码崩溃(catch 只切 warning 屏,Swift 报错不会杀进程),是 **OS 内存超限 jetsam kill**。三因叠加:
|
||||||
|
① 无 entitlement(8GB 设备默认单 App 上限 ~3GB,VL ~3GB 常驻冲过);② 从不卸载模型(LLM ~1GB + VL ~3GB 同驻 → 4GB+);③ 没设 MLX cache 上限。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
① 新建 `康康.entitlements` 加 `com.apple.developer.kernel.increased-memory-limit=true`;
|
||||||
|
② `AIRuntime` 加 `unloadLLM/unloadVL` 做**常驻互斥**(两大模型永不同时驻留)+ actor 内**串行推理闸门**(GPU 同一时刻只一个解码/加载);
|
||||||
|
③ `GPU.set(cacheLimit: 256MB)`,启动调一次。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
编译 + 单测通过。⚠️ **真机 OOM 是否真消失仍需 iPhone 15 Pro Max 实测**(本机无法跑真机)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-30 · 每次重打包 SwiftData 数据被清空
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
W2 期每次重新打包安装,本地数据全没了。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
`KangkangApp.swift` 里 `ModelContainer` 创建失败的 catch 块原本**直接删 store 文件**。
|
||||||
|
SwiftData 只对纯增量改动自动轻量迁移;一旦 schema 改动超纲(最常见:**给已存在 `@Model` 新增「非可选且无内联默认值」属性**)→ 迁移抛错 → 进 catch → 删库。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
catch 改为把旧 store(含 `-wal`/`-shm`)挪到 `Application Support/StoreBackups/<时间戳>/` 再重建,不删除。
|
||||||
|
|
||||||
|
### 预防
|
||||||
|
给已存在 `@Model` 加属性**一律给可选或内联默认值**(如 `var x: String = "daily"`),才走轻量迁移。正式发布前升级为 `VersionedSchema` + `SchemaMigrationPlan`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## (无明确日期)· 编辑 Localizable.xcstrings 炸出上万行噪声 diff
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
改 `Localizable.xcstrings` 新增 3 个 key,却产生 ~15000 行 diff。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
仓库里该文件是 **Xcode 规范格式**(`"key" : {` 冒号两侧带空格、2 空格缩进、key 按 Xcode 排序、结尾无换行);
|
||||||
|
用 `python json.dump(indent=2)` 重写会把分隔符变成 `": "` 且顺序不同 → 几乎每行都 diff。
|
||||||
|
|
||||||
|
### 修复 / 正确做法
|
||||||
|
基于 HEAD 原始文本做**文本插入**:把新 key 块按 Xcode 格式(` "<key>" : ` + `separators=(',', ' : ')` 的 value)拼到 strings 段末尾,保持结尾无换行。**不要整文件 json.dump 回写**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:命令行编译方式(排查时拿真实错误/警告)
|
||||||
|
|
||||||
|
- 系统默认是 Command Line Tools,裸 `xcodebuild` 不可用,需显式指向完整 Xcode:
|
||||||
|
`export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`
|
||||||
|
- **必须用独立 derivedDataPath**(如 `-derivedDataPath /tmp/kk-derived-xxx`),否则和 Xcode 抢同一把 `build.db` 锁报 `database is locked`(不是代码错)。
|
||||||
|
- 增量编译会吞警告:要看某文件警告先 `touch` 它强制重编,再 grep `error:|warning:|BUILD (SUCCEEDED|FAILED)`。
|
||||||
|
- 工程是 Swift 5 + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`;跨到 `nonisolated` 调 MainActor 成员的隔离警告(标 "error in Swift 6 mode")在 Swift 5 下不阻塞构建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模板(复制下面这段新增条目)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## YYYY-MM-DD · 一句话标题
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
(用户看到什么 / 怎么触发)
|
||||||
|
|
||||||
|
### 根因(确认)
|
||||||
|
(定位到的真正原因,不是猜测;贴关键文件:行)
|
||||||
|
|
||||||
|
### 排查过程
|
||||||
|
(怎么一步步定位的,方便下次复用思路)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
(改了什么,贴 diff 要点或配置)
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
(怎么确认修好了;不能单测的要写明需实跑)
|
||||||
|
|
||||||
|
### 预防 / 相关注意
|
||||||
|
(怎么避免再犯;顺带发现的隐患)
|
||||||
|
```
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
# 关键 flag:
|
# 关键 flag:
|
||||||
# MNN_BUILD_LLM=ON —— 编入 llm 引擎(并导出 llm/llm.hpp),自动开 MNN_LOW_MEMORY
|
# MNN_BUILD_LLM=ON —— 编入 llm 引擎(并导出 llm/llm.hpp),自动开 MNN_LOW_MEMORY
|
||||||
# MNN_BUILD_LLM_OMNI=ON —— VL(图→文)所需:多模态 Omni + OpenCV 图像解码。
|
# MNN_BUILD_LLM_OMNI=ON —— VL(图→文)所需:多模态 Omni + OpenCV 图像解码。
|
||||||
# 统一模型(Qwen3.5-4B-MNN 一肩挑文本+视觉)必须开。
|
# 统一模型(Qwen3.5-2B-MNN 一肩挑文本+视觉)必须开。
|
||||||
# MNN_SME2=ON —— CMake 默认 ON,A19/iPhone17 运行时经 KleidiAI 自动启用,A17 回退 NEON
|
# MNN_SME2=ON —— CMake 默认 ON,A19/iPhone17 运行时经 KleidiAI 自动启用,A17 回退 NEON
|
||||||
# MNN_METAL=OFF —— 考核走 CPU+SME2,关 Metal 保持精简
|
# MNN_METAL=OFF —— 考核走 CPU+SME2,关 Metal 保持精简
|
||||||
set -e
|
set -e
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ actor AIRuntime {
|
|||||||
private var vlSession: VLSession?
|
private var vlSession: VLSession?
|
||||||
|
|
||||||
// MARK: - MNN 后端(CPU/SME2,挑战赛考核路径)
|
// MARK: - MNN 后端(CPU/SME2,挑战赛考核路径)
|
||||||
// .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-4B 多模态 MNN 模型全包(已实测)。
|
// .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型全包(已实测)。
|
||||||
// 模拟器无 MNN,VL 回退 MLX 的 Qwen3-VL-4B。
|
// 模拟器无 MNN,VL 回退 MLX 的 Qwen3-VL-4B。
|
||||||
private let mnn = MNNBackend()
|
private let mnn = MNNBackend()
|
||||||
private(set) var mnnStatus: Status = .notReady
|
private(set) var mnnStatus: Status = .notReady
|
||||||
/// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-4B-MNN)。
|
/// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-2B-MNN)。
|
||||||
nonisolated static var mnnModelFolder: URL {
|
nonisolated static var mnnModelFolder: URL {
|
||||||
ModelStore.shared.localURL(for: .mnnLLM)
|
ModelStore.shared.localURL(for: .mnnLLM)
|
||||||
}
|
}
|
||||||
@@ -266,7 +266,7 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
if vlStatus == .ready { return }
|
if vlStatus == .ready { return }
|
||||||
|
|
||||||
// MLX VL 改用 .llm 的 Qwen3.5-4B 多模态(VLMModelFactory 走 qwen3_5 视觉路径),
|
// MLX VL 改用 .llm 的 Qwen3.5-2B 多模态(VLMModelFactory 走 qwen3_5 视觉路径),
|
||||||
// 不再单独需要 Qwen3-VL-4B。用 isComplete 排除半下载,与下载服务判据一致。
|
// 不再单独需要 Qwen3-VL-4B。用 isComplete 排除半下载,与下载服务判据一致。
|
||||||
guard ModelStore.shared.isComplete(for: .llm) else {
|
guard ModelStore.shared.isComplete(for: .llm) else {
|
||||||
vlStatus = .error("VL 模型未就绪")
|
vlStatus = .error("VL 模型未就绪")
|
||||||
@@ -274,7 +274,7 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。
|
// 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。
|
||||||
// —— 这正是「异常项快拍识别时 App 自动退出」的主因防护。
|
// —— 这正是「指标速记识别时 App 自动退出」的主因防护。
|
||||||
await acquireGate()
|
await acquireGate()
|
||||||
defer { releaseGate() }
|
defer { releaseGate() }
|
||||||
if vlStatus == .ready { return }
|
if vlStatus == .ready { return }
|
||||||
|
|||||||
@@ -26,16 +26,52 @@ nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
|
|||||||
|
|
||||||
private static let key = "kk.inferenceEngine"
|
private static let key = "kk.inferenceEngine"
|
||||||
|
|
||||||
/// 当前选择。无效/不可用时回退到 .mlx(保证总有可用引擎)。真机默认 .mnn。
|
/// 由偏好(可能是 .auto)解析出的、本次调用实际使用的具体引擎。
|
||||||
|
/// AIRuntime / MeView 等消费方只看这个,永远拿到 .mnn 或 .mlx。
|
||||||
|
/// 解析后仍做一次可用性兜底,保证总有可用引擎。
|
||||||
static var current: InferenceEngine {
|
static var current: InferenceEngine {
|
||||||
get {
|
let resolved = preference.resolved
|
||||||
let raw = UserDefaults.standard.string(forKey: key)
|
return resolved.isAvailable ? resolved : .mlx
|
||||||
let chosen = raw.flatMap(InferenceEngine.init(rawValue:)) ?? .mnn
|
|
||||||
return chosen.isAvailable ? chosen : .mlx
|
|
||||||
}
|
|
||||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
||||||
static var cpuSupportsSME2: Bool { MNNLLMBridge.cpuSupportsSME2() }
|
static var cpuSupportsSME2: Bool { MNNLLMBridge.cpuSupportsSME2() }
|
||||||
|
|
||||||
|
// MARK: - 用户偏好(auto / mnn / mlx)
|
||||||
|
|
||||||
|
/// 用户在设置页的选择。默认 .auto:按本机配置自动择优。
|
||||||
|
/// 与具体引擎共用同一 UserDefaults key——历史写入的 "mnn"/"mlx" 仍兼容。
|
||||||
|
static var preference: EnginePreference {
|
||||||
|
get {
|
||||||
|
let raw = UserDefaults.standard.string(forKey: key)
|
||||||
|
return raw.flatMap(EnginePreference.init(rawValue:)) ?? .auto
|
||||||
|
}
|
||||||
|
set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推理引擎的「用户偏好」,比具体引擎多一个 .auto。
|
||||||
|
/// - auto:按本机配置自动选——真机优先 MNN(考核路径,含 SME2/NEON),
|
||||||
|
/// MNN 不可用(模拟器)时回退 MLX。
|
||||||
|
nonisolated enum EnginePreference: String, CaseIterable, Sendable {
|
||||||
|
case auto
|
||||||
|
case mnn
|
||||||
|
case mlx
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .auto: return "自动"
|
||||||
|
case .mnn: return InferenceEngine.mnn.displayName
|
||||||
|
case .mlx: return InferenceEngine.mlx.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把偏好解析成具体引擎(不做可用性兜底,那一步留给 `InferenceEngine.current`)。
|
||||||
|
var resolved: InferenceEngine {
|
||||||
|
switch self {
|
||||||
|
case .mnn: return .mnn
|
||||||
|
case .mlx: return .mlx
|
||||||
|
case .auto: return InferenceEngine.mnn.isAvailable ? .mnn : .mlx
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,16 @@ actor LLMSession {
|
|||||||
let task = Task {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
try await Self.withDeviceOverride {
|
try await Self.withDeviceOverride {
|
||||||
|
// 低温:本 App 文本任务多为"直答/JSON 抽取",高温随机性会经常吐成非 JSON。
|
||||||
|
// 0.3 + topP 0.85 让输出更确定、JSON 更稳(与 MNN set_config 降温对齐)。
|
||||||
|
// repetitionPenalty:低温 + 无惩罚时,长文本(如「关键指标」列表)会逐行复读
|
||||||
|
// 进入死循环;1.1 的重复惩罚 + 64 token 上下文窗口掐断复读(与 MNN penalty 对齐)。
|
||||||
let parameters = GenerateParameters(
|
let parameters = GenerateParameters(
|
||||||
maxTokens: maxTokens,
|
maxTokens: maxTokens,
|
||||||
temperature: Float(0.6),
|
temperature: Float(0.3),
|
||||||
topP: Float(0.9)
|
topP: Float(0.85),
|
||||||
|
repetitionPenalty: Float(1.1),
|
||||||
|
repetitionContextSize: 64
|
||||||
)
|
)
|
||||||
|
|
||||||
try await container.perform { (context: ModelContext) in
|
try await container.perform { (context: ModelContext) in
|
||||||
|
|||||||
@@ -127,6 +127,22 @@ private:
|
|||||||
_cancel = false;
|
_cancel = false;
|
||||||
_llm = Llm::createLLM(std::string(configPath.UTF8String));
|
_llm = Llm::createLLM(std::string(configPath.UTF8String));
|
||||||
if (_llm == nullptr) return nil;
|
if (_llm == nullptr) return nil;
|
||||||
|
// load 前以 merge-patch 调三件事(只翻这几个叶子,保留 chat_template 等其余配置):
|
||||||
|
// ① enable_thinking=false:config.json 默认 true,模板会给每个 assistant 回合硬塞
|
||||||
|
// <think>\n 开启思考,吞掉 token 预算并污染 JSON(prompt 里的 /no_think 对此模板无效)。
|
||||||
|
// ② 降温:config.json 默认 temperature=1.0 对结构化 JSON 太高,随机性大→经常吐成非 JSON。
|
||||||
|
// 本 App 所有任务都是"直答/JSON",压到 0.3 + topP 0.85 让输出更确定、JSON 更稳。
|
||||||
|
// ③ 重复惩罚:MNN 默认 mixed_samplers 不含 "penalty"、penalty/ngram_factor=1.0(全关),
|
||||||
|
// 叠加低温 → 长文本(如「关键指标」列表)会陷入逐行复读死循环(收缩压 107 mmHg ×N)。
|
||||||
|
// 显式把 "penalty" 放进 mixed 链首,开 repetition penalty(1.1)+ n-gram 惩罚(ngram_factor 1.05):
|
||||||
|
// n-gram 命中整段重复时惩罚升到 max_penalty,直接掐断逐行复读。
|
||||||
|
_llm->set_config("{"
|
||||||
|
"\"jinja\":{\"context\":{\"enable_thinking\":false}},"
|
||||||
|
"\"sampler_type\":\"mixed\","
|
||||||
|
"\"mixed_samplers\":[\"penalty\",\"topK\",\"topP\",\"temperature\"],"
|
||||||
|
"\"temperature\":0.3,\"topP\":0.85,\"topK\":40,"
|
||||||
|
"\"penalty\":1.1,\"n_gram\":8,\"ngram_factor\":1.05"
|
||||||
|
"}");
|
||||||
_loaded = _llm->load();
|
_loaded = _llm->load();
|
||||||
if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; }
|
if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; }
|
||||||
return self;
|
return self;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Foundation
|
|||||||
/// MNN(CPU / SME2)推理后端,封装 `MNNLLMBridge` 的文本流式生成。
|
/// MNN(CPU / SME2)推理后端,封装 `MNNLLMBridge` 的文本流式生成。
|
||||||
/// 与 `LLMSession`/`VLSession` 同款 actor 隔离;跨调用的串行化由上游 `AIRuntime` 闸门保证。
|
/// 与 `LLMSession`/`VLSession` 同款 actor 隔离;跨调用的串行化由上游 `AIRuntime` 闸门保证。
|
||||||
///
|
///
|
||||||
/// 文本与视觉(图→文)由同一个 Qwen3.5-4B 多模态 MNN 模型承担:`generate` 走文本,
|
/// 文本与视觉(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型承担:`generate` 走文本,
|
||||||
/// `analyze` 把图片拼成 <img> 标签交给 Omni 内核 imread 解码(需 OMNI 构建,xcframework 已含)。
|
/// `analyze` 把图片拼成 <img> 标签交给 Omni 内核 imread 解码(需 OMNI 构建,xcframework 已含)。
|
||||||
/// 已实测可用,真机走此单模型全包路径;模拟器无 MNN,VL 仍回退 MLX(见 `AIRuntime`)。
|
/// 已实测可用,真机走此单模型全包路径;模拟器无 MNN,VL 仍回退 MLX(见 `AIRuntime`)。
|
||||||
actor MNNBackend {
|
actor MNNBackend {
|
||||||
|
|||||||
@@ -18,18 +18,20 @@ nonisolated enum ModelManifest {
|
|||||||
static func files(for kind: ModelKind) -> [ModelFile] {
|
static func files(for kind: ModelKind) -> [ModelFile] {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .llm:
|
case .llm:
|
||||||
// Qwen3.5-4B-4bit:多模态仓库,MLX 兜底用它同时做文本(LLMModelFactory qwen3_5 文本路径)
|
// Qwen3.5-2B-4bit:多模态仓库,但走 LLMModelFactory 的 qwen3_5 文本路径加载。
|
||||||
// 与视觉(VLMModelFactory qwen3_5)。字节数取自 mlx-community/Qwen3.5-4B-4bit
|
// 字节数取自 mlx-community/Qwen3.5-2B-4bit 仓库实际 blob 大小(HF API,2026-06 核对)。
|
||||||
// 仓库实际 blob 大小(HF API,2026-06 核对)。镜像全部运行文件(含视觉预处理配置),
|
// 该仓库 tokenizer 体系为 vocab.json + tokenizer.json(无 merges.txt /
|
||||||
// 排除 README.md / .gitattributes。
|
// special_tokens_map.json / added_tokens.json),chat_template 改为 .jinja。
|
||||||
|
// 一并镜像视觉预处理配置(preprocessor / processor / video_preprocessor),
|
||||||
|
// 文本加载用不到但体积可忽略,保持与仓库一致避免漏文件。
|
||||||
return [
|
return [
|
||||||
ModelFile(path: "config.json", bytes: 3_366),
|
ModelFile(path: "config.json", bytes: 3_113),
|
||||||
ModelFile(path: "model.safetensors", bytes: 3_034_300_695),
|
ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
|
||||||
ModelFile(path: "model.safetensors.index.json", bytes: 101_944),
|
ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
|
||||||
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
|
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
|
||||||
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
|
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
|
||||||
ModelFile(path: "vocab.json", bytes: 6_722_759),
|
ModelFile(path: "vocab.json", bytes: 6_722_759),
|
||||||
ModelFile(path: "chat_template.jinja", bytes: 7_756),
|
ModelFile(path: "chat_template.jinja", bytes: 7_755),
|
||||||
ModelFile(path: "preprocessor_config.json", bytes: 390),
|
ModelFile(path: "preprocessor_config.json", bytes: 390),
|
||||||
ModelFile(path: "processor_config.json", bytes: 1_300),
|
ModelFile(path: "processor_config.json", bytes: 1_300),
|
||||||
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
|
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
|
||||||
@@ -58,18 +60,17 @@ nonisolated enum ModelManifest {
|
|||||||
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
|
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
|
||||||
]
|
]
|
||||||
case .mnnLLM:
|
case .mnnLLM:
|
||||||
// taobao-mnn/Qwen3.5-4B-MNN 预转换 MNN 格式(HF API 实测,2026-06)。
|
// taobao-mnn/Qwen3.5-2B-MNN 预转换 MNN 格式(HF API 实测,2026-06)。
|
||||||
// 运行时必需:config.json(MNN llm 配置)+ llm_config.json(超参)+ llm.mnn(图)
|
// 运行时必需:config.json(MNN llm 配置)+ llm_config.json(超参)+ llm.mnn(图)
|
||||||
// + llm.mnn.weight(量化权重 ~2.45GB)+ tokenizer.txt + visual.mnn/visual.mnn.weight(多模态,
|
// + llm.mnn.weight(量化权重 ~1.1GB)+ tokenizer.txt + visual.mnn(多模态,文本路径不用但配置含 mllm)。
|
||||||
// 文本路径不用但配置含 mllm,带上避免缺文件)。排除 README/.gitattributes 与可读 dump。
|
// 排除 README/.gitattributes 与可读 dump(llm.mnn.json / export_args.json)。
|
||||||
return [
|
return [
|
||||||
ModelFile(path: "config.json", bytes: 652),
|
ModelFile(path: "config.json", bytes: 652),
|
||||||
ModelFile(path: "llm_config.json", bytes: 8_693),
|
ModelFile(path: "llm_config.json", bytes: 8_692),
|
||||||
ModelFile(path: "llm.mnn", bytes: 3_651_096),
|
ModelFile(path: "llm.mnn", bytes: 2_148_136),
|
||||||
ModelFile(path: "llm.mnn.weight", bytes: 2_629_387_626),
|
ModelFile(path: "llm.mnn.weight", bytes: 1_176_647_702),
|
||||||
ModelFile(path: "tokenizer.txt", bytes: 6_465_727),
|
ModelFile(path: "tokenizer.txt", bytes: 6_465_727),
|
||||||
ModelFile(path: "visual.mnn", bytes: 488_096),
|
ModelFile(path: "visual.mnn", bytes: 488_096),
|
||||||
ModelFile(path: "visual.mnn.weight", bytes: 196_768_960),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import Foundation
|
|||||||
|
|
||||||
nonisolated enum ModelKind: String, CaseIterable {
|
nonisolated enum ModelKind: String, CaseIterable {
|
||||||
/// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。
|
/// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。
|
||||||
/// 同一个 Qwen3.5-4B,两种格式两种引擎:
|
/// 同一个 Qwen3.5-2B,两种格式两种引擎:
|
||||||
/// - mnnLLM:MNN(CPU/SME2,考核路径)文本+视觉一肩挑,taobao-mnn 预转换。真机主用,只露它。
|
/// - mnnLLM:MNN(CPU/SME2,考核路径)文本+视觉一肩挑,taobao-mnn 预转换。iPhone17+(A19/SME2)主用,只露它。
|
||||||
/// - llm:MLX(GPU)兜底,Qwen3.5-4B-4bit 多模态(同时兜底文本与视觉,走 qwen3_5)。
|
/// - llm:MLX(GPU)兜底,Qwen3.5-2B-4bit 多模态(同时兜底文本与视觉,走 qwen3_5)。
|
||||||
/// - vl:已废弃(MLX VL 改走 .llm 多模态),保留枚举避免动一圈穷举 switch,不再下载/展示。
|
/// - vl:已废弃(MLX VL 改走 .llm 多模态),保留枚举避免动一圈穷举 switch,不再下载/展示。
|
||||||
case llm = "Qwen3.5-4B-4bit"
|
case llm = "Qwen3.5-2B-4bit"
|
||||||
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
||||||
case mnnLLM = "Qwen3.5-4B-MNN"
|
case mnnLLM = "Qwen3.5-2B-MNN"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .llm: return "Qwen3.5-4B (MLX)"
|
case .llm: return "Qwen3.5-2B (MLX)"
|
||||||
case .vl: return "Qwen3-VL-4B"
|
case .vl: return "Qwen3-VL-4B"
|
||||||
case .mnnLLM: return "Qwen3.5-4B (MNN/SME2)"
|
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ nonisolated enum ModelKind: String, CaseIterable {
|
|||||||
var sentinelFilename: String { "config.json" }
|
var sentinelFilename: String { "config.json" }
|
||||||
|
|
||||||
/// 面向用户的模型集合:模型管理页 / 下载全部 / 就绪计数对外只暴露统一的
|
/// 面向用户的模型集合:模型管理页 / 下载全部 / 就绪计数对外只暴露统一的
|
||||||
/// Qwen3.5-4B(MNN,文本+视觉全包,真机走它)。
|
/// Qwen3.5-2B(MNN,文本+视觉全包,iPhone17+ 走它)。
|
||||||
/// MLX 的 .llm/.vl 仅作模拟器与兜底路径,保留枚举与下载能力(旁路导入仍可单独导),
|
/// MLX 的 .llm/.vl 仅作模拟器与兜底路径,保留枚举与下载能力(旁路导入仍可单独导),
|
||||||
/// 但不在「我的 · 模型管理」展示,也不计入「下载全部」与就绪计数。
|
/// 但不在「我的 · 模型管理」展示,也不计入「下载全部」与就绪计数。
|
||||||
static let userFacing: [ModelKind] = [.mnnLLM]
|
static let userFacing: [ModelKind] = [.mnnLLM]
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ JSON schema(严格):
|
|||||||
现在请识别图片并输出 JSON:
|
现在请识别图片并输出 JSON:
|
||||||
"""#
|
"""#
|
||||||
|
|
||||||
// MARK: - 局部小框识别(异常项快拍)
|
// MARK: - 局部小框识别(指标速记)
|
||||||
|
|
||||||
/// 异常项快拍专用:输入是报告/化验单的**局部照片**(常常只有一两行指标)。
|
/// 指标速记专用:输入是报告/化验单的**局部照片**(常常只有一两行指标)。
|
||||||
/// 只要 indicators 数组,不要报告标题/机构/日期等元信息 —— 这条路径只存数值,不建 Report。
|
/// 只要 indicators 数组,不要报告标题/机构/日期等元信息 —— 这条路径只存数值,不建 Report。
|
||||||
static func regionExtraction(today: Date = .now) -> String {
|
static func regionExtraction(today: Date = .now) -> String {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ struct KangkangApp: App {
|
|||||||
// 语言 / 字体档位切换 → 整树重建,即时生效(固定字号经 tjScaled 读新倍率)。
|
// 语言 / 字体档位切换 → 整树重建,即时生效(固定字号经 tjScaled 读新倍率)。
|
||||||
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
|
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
|
||||||
}
|
}
|
||||||
|
// 设计系统是纯浅色(背景恒为 sand)。锁定 light:否则系统深色模式下,
|
||||||
|
// 未显式设色的 Text/TextField 走 .primary 变白,在浅背景上看不见(如日记输入框)。
|
||||||
|
.preferredColorScheme(.light)
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
.modelContainer(sharedModelContainer)
|
||||||
}
|
}
|
||||||
|
|||||||
34
康康/DesignSystem/AIDisclaimer.swift
Normal file
34
康康/DesignSystem/AIDisclaimer.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 全 App 统一的 AI 免责声明(上架合规:医疗类内容必须声明不做诊断)。
|
||||||
|
/// 任何展示 AI 生成的归纳/解读/建议文本的地方,都应在结果末尾附上 `AIDisclaimerFooter`;
|
||||||
|
/// 离开 App 的文本(复制/分享)用 `AIDisclaimer.appended(to:)` 把声明带上。
|
||||||
|
enum AIDisclaimer {
|
||||||
|
/// 面向用户展示的完整声明文本。
|
||||||
|
static let text =
|
||||||
|
"本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考," +
|
||||||
|
"不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。"
|
||||||
|
|
||||||
|
/// 复制/分享时把声明追加到正文末尾(分隔线 + 声明),让文本离开 App 也带着免责。
|
||||||
|
static func appended(to body: String) -> String {
|
||||||
|
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return "\(trimmed)\n\n———\n\(text)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 结果下方的免责声明脚注:小字、弱色、信息图标。放在任何 AI 生成文本块末尾。
|
||||||
|
struct AIDisclaimerFooter: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.tjScaled( 10))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(AIDisclaimer.text)
|
||||||
|
.font(.tjScaled( 10))
|
||||||
|
.lineSpacing(2)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
康康/DesignSystem/AIFlowBar.swift
Normal file
45
康康/DesignSystem/AIFlowBar.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Apple Intelligence 式多彩流光线:蓝→紫→粉→橙→青,横向无缝循环流动。
|
||||||
|
/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待)。
|
||||||
|
///
|
||||||
|
/// 注意:这条线的颜色是刻意走出 `Tj.Palette` 单色系统的 AI 高光点缀(应产品要求的
|
||||||
|
/// Apple 风格),仅此组件如此;其余 UI 仍严格守 §9 单色 token。
|
||||||
|
struct AIFlowBar: View {
|
||||||
|
var height: CGFloat = 3
|
||||||
|
/// 流动一整圈的秒数,越小越快。
|
||||||
|
var cycle: Double = 1.0
|
||||||
|
|
||||||
|
@State private var phase: CGFloat = 0
|
||||||
|
|
||||||
|
/// 颜色重复一遍:offset 走完一个整段时首尾同色,循环无缝。
|
||||||
|
private static let flow: [Color] = {
|
||||||
|
let base: [Color] = [
|
||||||
|
Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝
|
||||||
|
Color(red: 0.62, green: 0.36, blue: 0.92), // 紫
|
||||||
|
Color(red: 0.96, green: 0.40, blue: 0.62), // 粉
|
||||||
|
Color(red: 1.00, green: 0.55, blue: 0.30), // 橙
|
||||||
|
Color(red: 0.30, green: 0.80, blue: 0.92), // 青
|
||||||
|
]
|
||||||
|
return base + base
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let w = geo.size.width
|
||||||
|
Capsule()
|
||||||
|
.fill(LinearGradient(colors: Self.flow,
|
||||||
|
startPoint: .leading, endPoint: .trailing))
|
||||||
|
.frame(width: w * 2)
|
||||||
|
.offset(x: phase)
|
||||||
|
.onAppear {
|
||||||
|
phase = 0
|
||||||
|
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
|
||||||
|
phase = -w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: height)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ struct ArchiveListView: View {
|
|||||||
@State private var filter: TimelineKind? = nil
|
@State private var filter: TimelineKind? = nil
|
||||||
@State private var endingSymptom: Symptom?
|
@State private var endingSymptom: Symptom?
|
||||||
@State private var selectedEntry: TimelineEntry?
|
@State private var selectedEntry: TimelineEntry?
|
||||||
|
@State private var selectedGroup: IndicatorGroup?
|
||||||
@State private var route: Route?
|
@State private var route: Route?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -109,6 +110,9 @@ struct ArchiveListView: View {
|
|||||||
TimelineEntryDetailView(detail: d)
|
TimelineEntryDetailView(detail: d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(item: $selectedGroup) { group in
|
||||||
|
IndicatorSeriesDetailView(group: group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -123,9 +127,14 @@ struct ArchiveListView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
} else {
|
} else {
|
||||||
// 其余条目(报告/指标/日记/已结束症状):点 → 只读详情
|
// 其余条目:指标 → 同类聚合详情(横向翻页 + 趋势);报告/日记/已结束症状 → 只读详情
|
||||||
Button {
|
Button {
|
||||||
if detail(for: entry) != nil { selectedEntry = entry }
|
guard let d = detail(for: entry) else { return }
|
||||||
|
switch d {
|
||||||
|
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
|
||||||
|
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
|
||||||
|
default: selectedEntry = entry
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
TimelineRow(entry: entry)
|
TimelineRow(entry: entry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ struct HealthExportDetailView: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AIDisclaimerFooter()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
@@ -117,7 +119,7 @@ struct HealthExportDetailView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||||
|
|
||||||
ShareLink(item: export.content) {
|
ShareLink(item: AIDisclaimer.appended(to: export.content)) {
|
||||||
Label("分享", systemImage: "square.and.arrow.up")
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
.font(.tjScaled( 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
@@ -149,7 +151,7 @@ struct HealthExportDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func copy() {
|
private func copy() {
|
||||||
UIPasteboard.general.string = export.content
|
UIPasteboard.general.string = AIDisclaimer.appended(to: export.content)
|
||||||
copiedFlash = true
|
copiedFlash = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||||
copiedFlash = false
|
copiedFlash = false
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ struct HealthExportSheet: View {
|
|||||||
@State private var answeringTurnID: UUID?
|
@State private var answeringTurnID: UUID?
|
||||||
@FocusState private var questionFocused: Bool
|
@FocusState private var questionFocused: Bool
|
||||||
|
|
||||||
|
// 快捷问答
|
||||||
|
@State private var promptStore = QuickPromptStore.shared
|
||||||
|
@State private var showAddPrompt = false
|
||||||
|
@State private var newPromptText = ""
|
||||||
|
|
||||||
init(initialPrompt: String = "") {
|
init(initialPrompt: String = "") {
|
||||||
self.initialPrompt = initialPrompt
|
self.initialPrompt = initialPrompt
|
||||||
}
|
}
|
||||||
@@ -33,10 +38,16 @@ struct HealthExportSheet: View {
|
|||||||
!isGeneratingReport &&
|
!isGeneratingReport &&
|
||||||
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
}
|
||||||
|
/// 已有有效用户对话内容。
|
||||||
|
private var hasUserContent: Bool {
|
||||||
|
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可生成报告:有对话内容,或输入框里有文字(允许跳过多轮对话直接生成)。
|
||||||
private var canGenerateReport: Bool {
|
private var canGenerateReport: Bool {
|
||||||
!isAnswering &&
|
!isAnswering &&
|
||||||
!isGeneratingReport &&
|
!isGeneratingReport &&
|
||||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
(hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -88,6 +99,75 @@ struct HealthExportSheet: View {
|
|||||||
questionFocused = true
|
questionFocused = true
|
||||||
}
|
}
|
||||||
.onDisappear { task?.cancel() }
|
.onDisappear { task?.cancel() }
|
||||||
|
.alert("添加快捷问答", isPresented: $showAddPrompt) {
|
||||||
|
TextField("输入一句常用问题…", text: $newPromptText)
|
||||||
|
Button("取消", role: .cancel) { newPromptText = "" }
|
||||||
|
Button("添加") {
|
||||||
|
promptStore.add(prompt: newPromptText)
|
||||||
|
newPromptText = ""
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("保存后点一下,就能把这句话填进输入框")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 快捷问答
|
||||||
|
|
||||||
|
/// 内置 + 自定义快捷问答 chip 行;点 chip 填入输入框,末尾「+ 自定义」追加,长按自定义删除。
|
||||||
|
private var quickPromptRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(promptStore.all) { p in
|
||||||
|
quickPromptChip(p)
|
||||||
|
}
|
||||||
|
addQuickPromptChip
|
||||||
|
}
|
||||||
|
.padding(.vertical, 1) // 给 chip 描边留出像素,避免被 ScrollView 裁切
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func quickPromptChip(_ p: QuickPrompt) -> some View {
|
||||||
|
Button {
|
||||||
|
draftQuestion = p.prompt
|
||||||
|
questionFocused = true
|
||||||
|
} label: {
|
||||||
|
Text(p.title)
|
||||||
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Capsule().fill(Tj.Palette.sand2))
|
||||||
|
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
if !p.isBuiltin {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
promptStore.delete(p)
|
||||||
|
} label: {
|
||||||
|
Label("删除", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addQuickPromptChip: some View {
|
||||||
|
Button { showAddPrompt = true } label: {
|
||||||
|
Label("自定义", systemImage: "plus")
|
||||||
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Capsule().fill(Tj.Palette.paper))
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(
|
||||||
|
Tj.Palette.line,
|
||||||
|
style: StrokeStyle(lineWidth: 1, dash: [3])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
@@ -128,14 +208,7 @@ struct HealthExportSheet: View {
|
|||||||
.font(.tjScaled( 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
quickPromptRow
|
||||||
Text("例:最近血压波动大吗?")
|
|
||||||
.font(.tjScaled( 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
|
|
||||||
.font(.tjScaled( 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
||||||
.font(.tjScaled( 11))
|
.font(.tjScaled( 11))
|
||||||
@@ -162,11 +235,11 @@ struct HealthExportSheet: View {
|
|||||||
.font(.tjScaled( 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
||||||
if turn.id == answeringTurnID && turn.text.isEmpty {
|
if turn.id == answeringTurnID && turn.text.isEmpty {
|
||||||
HStack(spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ProgressView()
|
|
||||||
Text("正在查看本地记录…")
|
Text("正在查看本地记录…")
|
||||||
.font(.tjScaled( 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
AIFlowBar()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(turn.text)
|
Text(turn.text)
|
||||||
@@ -196,6 +269,11 @@ struct HealthExportSheet: View {
|
|||||||
.font(.tjScaled( 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
MarkdownView(text: content)
|
MarkdownView(text: content)
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
Divider().background(Tj.Palette.lineSoft)
|
||||||
|
AIDisclaimerFooter()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -229,6 +307,9 @@ struct HealthExportSheet: View {
|
|||||||
.font(.tjScaled( 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI 计算中:多彩流光线(与日记 AI 辅助同一组件)
|
||||||
|
AIFlowBar().padding(.top, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +372,7 @@ struct HealthExportSheet: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||||
|
|
||||||
ShareLink(item: content) {
|
ShareLink(item: AIDisclaimer.appended(to: content)) {
|
||||||
Label("分享", systemImage: "square.and.arrow.up")
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
.font(.tjScaled( 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
@@ -319,7 +400,7 @@ struct HealthExportSheet: View {
|
|||||||
private var composer: some View {
|
private var composer: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical)
|
TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
|
||||||
.font(.tjScaled( 14))
|
.font(.tjScaled( 14))
|
||||||
.lineLimit(1...4)
|
.lineLimit(1...4)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -342,12 +423,28 @@ struct HealthExportSheet: View {
|
|||||||
.accessibilityLabel("发送问题")
|
.accessibilityLabel("发送问题")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button { startReportGeneration() } label: {
|
if isGeneratingReport {
|
||||||
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
Button { stopGeneration() } label: {
|
||||||
|
Label("停止生成", systemImage: "stop.fill")
|
||||||
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.brick, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
Button { startReportGeneration() } label: {
|
||||||
|
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||||
|
.disabled(!canGenerateReport)
|
||||||
|
.opacity(canGenerateReport ? 1 : 0.45)
|
||||||
}
|
}
|
||||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
|
||||||
.disabled(!canGenerateReport)
|
|
||||||
.opacity(canGenerateReport ? 1 : 0.45)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
@@ -402,6 +499,15 @@ struct HealthExportSheet: View {
|
|||||||
private func startReportGeneration() {
|
private func startReportGeneration() {
|
||||||
guard canGenerateReport else { return }
|
guard canGenerateReport else { return }
|
||||||
questionFocused = false
|
questionFocused = false
|
||||||
|
|
||||||
|
// 直接生成:输入框里有文字(快捷问答/手输)就把它作为一条诉求追加进对话,
|
||||||
|
// 不必先走多轮问答 —— 用户点一下「生成报告」即可。
|
||||||
|
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !draft.isEmpty {
|
||||||
|
turns.append(.user(draft))
|
||||||
|
draftQuestion = ""
|
||||||
|
}
|
||||||
|
|
||||||
content = ""
|
content = ""
|
||||||
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||||
error = nil
|
error = nil
|
||||||
@@ -435,6 +541,16 @@ struct HealthExportSheet: View {
|
|||||||
startReportGeneration()
|
startReportGeneration()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 停止正在进行的报告生成:取消推理任务,回到可重新生成的干净态(已写的诉求保留在对话里)。
|
||||||
|
private func stopGeneration() {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
phase = nil
|
||||||
|
rate = 0
|
||||||
|
completed = false
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
|
||||||
private func reset() {
|
private func reset() {
|
||||||
task?.cancel()
|
task?.cancel()
|
||||||
task = nil
|
task = nil
|
||||||
@@ -448,7 +564,7 @@ struct HealthExportSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func copy() {
|
private func copy() {
|
||||||
UIPasteboard.general.string = content
|
UIPasteboard.general.string = AIDisclaimer.appended(to: content)
|
||||||
copiedFlash = true
|
copiedFlash = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||||
copiedFlash = false
|
copiedFlash = false
|
||||||
|
|||||||
92
康康/Features/Archive/QuickPrompt.swift
Normal file
92
康康/Features/Archive/QuickPrompt.swift
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// 「身体档案」里的快捷问答:点一下把一句常用问题填进输入框。
|
||||||
|
/// 内置 3 条(不可删),用户可自定义追加(可删)。
|
||||||
|
struct QuickPrompt: Identifiable, Codable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var title: String // chip 上显示的短标签
|
||||||
|
var prompt: String // 点击后填入输入框的完整问题
|
||||||
|
var isBuiltin: Bool
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), title: String, prompt: String, isBuiltin: Bool) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.prompt = prompt
|
||||||
|
self.isBuiltin = isBuiltin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 快捷问答存储:内置常量 + 自定义条目(UserDefaults JSON,无 SwiftData schema 迁移风险)。
|
||||||
|
/// 自定义条目只是 UI 便利项、不是健康记录,故不进 SwiftData。
|
||||||
|
@Observable
|
||||||
|
final class QuickPromptStore {
|
||||||
|
static let shared = QuickPromptStore()
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private let storageKey = "kk.quickPrompts.custom.v1"
|
||||||
|
|
||||||
|
private(set) var custom: [QuickPrompt]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
if let data = defaults.data(forKey: storageKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([QuickPrompt].self, from: data) {
|
||||||
|
custom = decoded
|
||||||
|
} else {
|
||||||
|
custom = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内置在前、自定义在后,供 chip 行展示。
|
||||||
|
var all: [QuickPrompt] { Self.builtins + custom }
|
||||||
|
|
||||||
|
/// 追加一条自定义问答。空白忽略;标签自动取问题前几个字。
|
||||||
|
func add(prompt rawPrompt: String) {
|
||||||
|
let trimmed = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
custom.append(QuickPrompt(title: Self.deriveTitle(trimmed),
|
||||||
|
prompt: trimmed,
|
||||||
|
isBuiltin: false))
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除一条自定义问答(内置不可删)。
|
||||||
|
func delete(_ p: QuickPrompt) {
|
||||||
|
guard !p.isBuiltin else { return }
|
||||||
|
custom.removeAll { $0.id == p.id }
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
if let data = try? JSONEncoder().encode(custom) {
|
||||||
|
defaults.set(data, forKey: storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自定义条目的短标签:压成单行,取前 8 个字,超出补省略号。
|
||||||
|
static func deriveTitle(_ prompt: String) -> String {
|
||||||
|
let oneLine = prompt.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let head = oneLine.prefix(8)
|
||||||
|
return oneLine.count > 8 ? "\(head)…" : String(head)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内置 3 条(首屏):覆盖「就诊 / 解读 / 速查」三类,数据依赖稳、不碰诊断红线。
|
||||||
|
static let builtins: [QuickPrompt] = [
|
||||||
|
QuickPrompt(
|
||||||
|
title: "就诊摘要",
|
||||||
|
prompt: "根据我最近的身体症状,结合历史指标,整理一份让门诊医生快速了解我情况的就诊摘要。",
|
||||||
|
isBuiltin: true
|
||||||
|
),
|
||||||
|
QuickPrompt(
|
||||||
|
title: "趋势解读",
|
||||||
|
prompt: "把我血压最近半年的变化讲清楚:是变好还是变差、要注意什么。",
|
||||||
|
isBuiltin: true
|
||||||
|
),
|
||||||
|
QuickPrompt(
|
||||||
|
title: "速答清单",
|
||||||
|
prompt: "把我的过敏史、正在吃的药、慢性病整理成一句话清单,方便就诊时快速回答医生。",
|
||||||
|
isBuiltin: true
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -182,6 +182,7 @@ struct DiaryQuickSheet: View {
|
|||||||
questionRow(index: roundLocalIndex(at: idx), question: q)
|
questionRow(index: roundLocalIndex(at: idx), question: q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AIDisclaimerFooter()
|
||||||
}
|
}
|
||||||
|
|
||||||
if exhaustedNote {
|
if exhaustedNote {
|
||||||
@@ -212,30 +213,12 @@ struct DiaryQuickSheet: View {
|
|||||||
? String(appLoc: "让 AI 帮我想想还能记什么")
|
? String(appLoc: "让 AI 帮我想想还能记什么")
|
||||||
: String(appLoc: "先写几个字,AI 来帮忙补充"),
|
: String(appLoc: "先写几个字,AI 来帮忙补充"),
|
||||||
enabled: canRequestSuggest,
|
enabled: canRequestSuggest,
|
||||||
|
prominent: true,
|
||||||
action: requestSuggestions
|
action: requestSuggestions
|
||||||
)
|
)
|
||||||
|
|
||||||
case .loading:
|
case .loading:
|
||||||
HStack(spacing: 10) {
|
assistLoadingIndicator
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
Text("AI 思考中… 本地推理,通常 5-10 秒")
|
|
||||||
.font(.tjScaled( 13))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
Spacer()
|
|
||||||
Button("取消") { cancelSuggestions() }
|
|
||||||
.font(.tjScaled( 12, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 11)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.fill(Tj.Palette.paper)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
case .ready:
|
case .ready:
|
||||||
assistPrimaryButton(
|
assistPrimaryButton(
|
||||||
@@ -273,26 +256,25 @@ struct DiaryQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 辅助主按钮。`prominent` 为真走实心强调样式(填充 brick + 白字 + 轻投影,一眼可点),
|
||||||
|
/// 否则走低调描边样式(用于 .ready 的「再问一轮」)。
|
||||||
private func assistPrimaryButton(icon: String,
|
private func assistPrimaryButton(icon: String,
|
||||||
label: String,
|
label: String,
|
||||||
enabled: Bool,
|
enabled: Bool,
|
||||||
|
prominent: Bool = false,
|
||||||
action: @escaping () -> Void) -> some View {
|
action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
Text(label)
|
Text(label)
|
||||||
}
|
}
|
||||||
.font(.tjScaled( 13, weight: .semibold))
|
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
|
||||||
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
.foregroundStyle(prominent
|
||||||
|
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
|
||||||
|
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 11)
|
.padding(.vertical, prominent ? 14 : 11)
|
||||||
.background(
|
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.strokeBorder(
|
|
||||||
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
|
||||||
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
@@ -300,6 +282,58 @@ struct DiaryQuickSheet: View {
|
|||||||
.disabled(!enabled)
|
.disabled(!enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
if prominent {
|
||||||
|
shape
|
||||||
|
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
|
||||||
|
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
|
||||||
|
radius: 8, x: 0, y: 3)
|
||||||
|
} else {
|
||||||
|
shape
|
||||||
|
.strokeBorder(
|
||||||
|
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
||||||
|
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// .loading 等待态:安静的 paper 卡片,底部一条细窄的不确定进度条来回滑动(Linear/Vercel 式极简)。
|
||||||
|
/// 不用高亮扫光、不填强调色,避免刺眼;只靠细线 + sparkles 轻脉冲传达「在算」。
|
||||||
|
private var assistLoadingIndicator: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
|
Text(lastRate > 0
|
||||||
|
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
|
||||||
|
: String(appLoc: "AI 生成中 · 本地推理"))
|
||||||
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button("取消") { cancelSuggestions() }
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 11)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
AIFlowBar().padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
/// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
|
/// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
|
||||||
private func roundLocalIndex(at idx: Int) -> Int {
|
private func roundLocalIndex(at idx: Int) -> Int {
|
||||||
let target = questions[idx].round
|
let target = questions[idx].round
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 顶部「拍照识别」入口:并入原「异常项快拍」。点后由 RootView 切到相机 VL 流程。
|
/// 顶部「拍照识别」入口:并入原「指标速记」。点后由 RootView 切到相机 VL 流程。
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var cameraEntrySection: some View {
|
private var cameraEntrySection: some View {
|
||||||
if let onRequestCamera {
|
if let onRequestCamera {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import SwiftUI
|
|||||||
/// 推理引擎设置:在 MNN(CPU/SME2,考核路径)与 MLX(GPU,兜底)间切换,并展示 SME2 探测状态。
|
/// 推理引擎设置:在 MNN(CPU/SME2,考核路径)与 MLX(GPU,兜底)间切换,并展示 SME2 探测状态。
|
||||||
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
||||||
struct InferenceSettingsView: View {
|
struct InferenceSettingsView: View {
|
||||||
@AppStorage("kk.inferenceEngine") private var engineRaw = InferenceEngine.mnn.rawValue
|
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
|
||||||
|
|
||||||
private var selected: InferenceEngine {
|
private var selected: EnginePreference {
|
||||||
InferenceEngine(rawValue: engineRaw) ?? .mnn
|
EnginePreference(rawValue: engineRaw) ?? .auto
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -21,7 +21,7 @@ struct InferenceSettingsView: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
ForEach(InferenceEngine.allCases, id: \.self) { engine in
|
ForEach(EnginePreference.allCases, id: \.self) { engine in
|
||||||
engineRow(engine)
|
engineRow(engine)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +34,8 @@ struct InferenceSettingsView: View {
|
|||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func engineRow(_ engine: InferenceEngine) -> some View {
|
private func engineRow(_ engine: EnginePreference) -> some View {
|
||||||
let available = engine.isAvailable
|
let available = isAvailable(engine)
|
||||||
let isOn = (selected == engine)
|
let isOn = (selected == engine)
|
||||||
return Button {
|
return Button {
|
||||||
guard available else { return }
|
guard available else { return }
|
||||||
@@ -44,7 +44,7 @@ struct InferenceSettingsView: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(isOn ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
Circle().fill(isOn ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
Image(systemName: engine == .mnn ? "cpu.fill" : "bolt.fill")
|
Image(systemName: iconName(engine))
|
||||||
.font(.tjScaled(18))
|
.font(.tjScaled(18))
|
||||||
.foregroundStyle(isOn ? Tj.Palette.ink : Tj.Palette.text2)
|
.foregroundStyle(isOn ? Tj.Palette.ink : Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
@@ -74,8 +74,35 @@ struct InferenceSettingsView: View {
|
|||||||
.disabled(!available)
|
.disabled(!available)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func subtitle(_ engine: InferenceEngine, available: Bool) -> String {
|
/// .auto 永远可用;具体引擎看自身可用性。
|
||||||
|
private func isAvailable(_ engine: EnginePreference) -> Bool {
|
||||||
switch engine {
|
switch engine {
|
||||||
|
case .auto: return true
|
||||||
|
case .mnn: return InferenceEngine.mnn.isAvailable
|
||||||
|
case .mlx: return InferenceEngine.mlx.isAvailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconName(_ engine: EnginePreference) -> String {
|
||||||
|
switch engine {
|
||||||
|
case .auto: return "wand.and.stars"
|
||||||
|
case .mnn: return "cpu.fill"
|
||||||
|
case .mlx: return "bolt.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 不可用)")
|
||||||
|
}
|
||||||
case .mnn:
|
case .mnn:
|
||||||
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
|
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
|
||||||
return InferenceEngine.cpuSupportsSME2
|
return InferenceEngine.cpuSupportsSME2
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ struct ModelManagementView: View {
|
|||||||
@State private var showCellularConfirm = false
|
@State private var showCellularConfirm = false
|
||||||
@State private var showImporter = false
|
@State private var showImporter = false
|
||||||
@State private var importError: String?
|
@State private var importError: String?
|
||||||
@AppStorage(QuickRegionRecognitionEngine.storageKey)
|
|
||||||
private var quickRegionEngineRaw = QuickRegionRecognitionEngine.defaultValue.rawValue
|
|
||||||
|
|
||||||
private let monitor = NWPathMonitor()
|
private let monitor = NWPathMonitor()
|
||||||
private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
|
private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
|
||||||
@@ -27,8 +25,6 @@ struct ModelManagementView: View {
|
|||||||
modelCard(kind)
|
modelCard(kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
recognitionEngineCard
|
|
||||||
|
|
||||||
actionButtons
|
actionButtons
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
@@ -80,46 +76,6 @@ struct ModelManagementView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 拍照识别引擎
|
|
||||||
|
|
||||||
private var selectedRecognitionEngine: QuickRegionRecognitionEngine {
|
|
||||||
QuickRegionRecognitionEngine(storedValue: quickRegionEngineRaw)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var recognitionEngineCard: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack(alignment: .top, spacing: 10) {
|
|
||||||
ZStack {
|
|
||||||
Circle().fill(Tj.Palette.sand2)
|
|
||||||
Image(systemName: "camera.metering.center.weighted")
|
|
||||||
.font(.tjScaled( 18))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
}
|
|
||||||
.frame(width: 38, height: 38)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
Text("异常项拍照识别")
|
|
||||||
.font(.tjScaled( 15, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
Text(selectedRecognitionEngine.detail)
|
|
||||||
.font(.tjScaled( 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker("异常项拍照识别", selection: $quickRegionEngineRaw) {
|
|
||||||
ForEach(QuickRegionRecognitionEngine.allCases) { engine in
|
|
||||||
Text(engine.title).tag(engine.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
}
|
|
||||||
.padding(14)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.tjCard()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 模型卡片
|
// MARK: - 模型卡片
|
||||||
|
|
||||||
private func modelCard(_ kind: ModelKind) -> some View {
|
private func modelCard(_ kind: ModelKind) -> some View {
|
||||||
@@ -198,7 +154,7 @@ struct ModelManagementView: View {
|
|||||||
} else if allReady {
|
} else if allReady {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
Text("Qwen3.5-4B 已就绪")
|
Text("Qwen3.5-2B 已就绪")
|
||||||
}
|
}
|
||||||
.font(.tjScaled( 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
|||||||
@@ -421,7 +421,10 @@ private struct EntryInputField: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
TextField(placeholder, text: $text, axis: .vertical)
|
TextField(placeholder, text: $text, axis: .vertical)
|
||||||
.lineLimit(1...4)
|
.lineLimit(1...5)
|
||||||
|
.foregroundStyle(Tj.Palette.text) // 固定深色:避免深色模式下继承系统 .primary 变白看不清
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
.frame(minHeight: 40, alignment: .top) // 初始就有聊天框体量,内容多了随 axis 增长
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 异常项快拍 · 统一流程。
|
/// 指标速记 · 统一流程。
|
||||||
/// 整幅单拍(真机)/ 相册(模拟器)→ 静态图手动框选 → 框内 OCR+LLM 抽指标 → 核对 → 存独立 Indicator。
|
/// 整幅单拍(真机)/ 相册(模拟器)→ 静态图手动框选 → 框内 OCR+LLM 抽指标 → 核对 → 存独立 Indicator。
|
||||||
///
|
///
|
||||||
/// 状态机:
|
/// 状态机:
|
||||||
@@ -15,8 +15,6 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
let onClose: () -> Void
|
let onClose: () -> Void
|
||||||
|
|
||||||
@AppStorage(QuickRegionRecognitionEngine.storageKey)
|
|
||||||
private var recognitionEngineRaw = QuickRegionRecognitionEngine.defaultValue.rawValue
|
|
||||||
@State private var phase: Phase = .idle
|
@State private var phase: Phase = .idle
|
||||||
|
|
||||||
enum Phase {
|
enum Phase {
|
||||||
@@ -59,7 +57,7 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
onCancel: { onClose() },
|
onCancel: { onClose() },
|
||||||
onRetake: { phase = .idle }
|
onRetake: { phase = .idle }
|
||||||
)
|
)
|
||||||
.navigationTitle(String(appLoc: "核对异常项"))
|
.navigationTitle(String(appLoc: "核对指标"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
@@ -97,29 +95,18 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 识别(框内子图 → OCR → LLM)
|
// MARK: - 识别(框内子图 → Vision OCR → Qwen3 整理)
|
||||||
|
|
||||||
/// 对已裁好的框内子图跑识别。失败/超时返回提示文案,绝不抛出(由 RegionAdjustView 展示)。
|
/// 对已裁好的框内子图跑识别。失败/超时返回提示文案,绝不抛出(由 RegionAdjustView 展示)。
|
||||||
/// 链路由「我的 → 模型管理 → 拍照识别引擎」决定:
|
/// 固定链路:Vision 端侧 OCR 出文字 → Qwen3 跑一次结构化整理抽指标。
|
||||||
/// - Apple Vision:Vision 端侧 OCR → Qwen3-1.7B 结构化抽指标
|
/// (旧的「大模型直读」VL 路径已移除:端侧看图慢且易卡,OCR→整理又快又准。)
|
||||||
/// - Qwen3-VL:局部图片 → Qwen3-VL 直接结构化抽指标
|
|
||||||
private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
||||||
let engine = QuickRegionRecognitionEngine(storedValue: recognitionEngineRaw)
|
|
||||||
switch engine {
|
|
||||||
case .appleVision:
|
|
||||||
return await recognizeWithAppleVision(image)
|
|
||||||
case .qwenVL:
|
|
||||||
return await recognizeWithQwenVL(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recognizeWithAppleVision(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
|
||||||
do {
|
do {
|
||||||
let text = try await OCRService.recognizeText(in: image)
|
let text = try await OCRService.recognizeText(in: image)
|
||||||
if Task.isCancelled { return ([], nil) } // 超时:文案由调用方给
|
if Task.isCancelled { return ([], nil) } // 超时:文案由调用方给
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---")
|
NSLog("KKDBG-OCR region text:\n%@\n--- end OCR ---", trimmed)
|
||||||
#endif
|
#endif
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
return ([], String(appLoc: "没识别到文字,挪一下框再试"))
|
return ([], String(appLoc: "没识别到文字,挪一下框再试"))
|
||||||
@@ -139,30 +126,6 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recognizeWithQwenVL(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
|
||||||
let prepared = RegionImageCropper.prepareForQwenVL(image)
|
|
||||||
guard let data = prepared.jpegData(compressionQuality: 0.95) else {
|
|
||||||
return ([], String(appLoc: "图片编码失败,手动补充"))
|
|
||||||
}
|
|
||||||
#if DEBUG
|
|
||||||
print("🖼️ [Qwen3-VL region] prepared image=\(Int(prepared.size.width))x\(Int(prepared.size.height)), bytes=\(data.count)")
|
|
||||||
#endif
|
|
||||||
do {
|
|
||||||
let parsed = try await CaptureService.shared.recognizeRegion(imageData: data)
|
|
||||||
if Task.isCancelled { return ([], nil) }
|
|
||||||
let items = Self.buildItems(from: parsed)
|
|
||||||
return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil)
|
|
||||||
} catch CaptureError.modelNotReady {
|
|
||||||
return ([], String(appLoc: "模型未就绪,请在模型管理下载或切回 Apple Vision"))
|
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
|
||||||
return ([], String(appLoc: "解析失败:\(msg)"))
|
|
||||||
} catch let CaptureError.inferenceFailed(msg) {
|
|
||||||
return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)"))
|
|
||||||
} catch {
|
|
||||||
return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// LLM 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。
|
/// LLM 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。
|
||||||
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
|
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
|
||||||
let mapped = parsed.map {
|
let mapped = parsed.map {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 异常项快拍 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。
|
/// 指标速记 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。
|
||||||
/// 与「记录指标」自由输入落库一致 —— 每个勾选项 = 一条独立 Indicator。
|
/// 与「记录指标」自由输入落库一致 —— 每个勾选项 = 一条独立 Indicator。
|
||||||
struct QuickRegionConfirmView: View {
|
struct QuickRegionConfirmView: View {
|
||||||
let image: UIImage?
|
let image: UIImage?
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum QuickRegionRecognitionEngine: String, CaseIterable, Identifiable, Sendable {
|
|
||||||
case appleVision
|
|
||||||
case qwenVL
|
|
||||||
|
|
||||||
static let storageKey = "quickRegionRecognitionEngine"
|
|
||||||
static let defaultValue: QuickRegionRecognitionEngine = .appleVision
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
init(storedValue: String) {
|
|
||||||
self = QuickRegionRecognitionEngine(rawValue: storedValue) ?? Self.defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .appleVision: return String(appLoc: "Apple Vision")
|
|
||||||
case .qwenVL: return String(appLoc: "大模型直读")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var detail: String {
|
|
||||||
switch self {
|
|
||||||
case .appleVision:
|
|
||||||
return String(appLoc: "系统 OCR + 文本模型解析")
|
|
||||||
case .qwenVL:
|
|
||||||
return String(appLoc: "Qwen3.5-4B 多模态直接看图(MNN/MLX)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 异常项快拍 · 静态图框选识别。
|
/// 指标速记 · 静态图框选识别。
|
||||||
/// 拍/选一张后,在静态照片上手动拖动 + 缩放一个方框,点「识别」只对框内做 OCR+LLM。
|
/// 拍/选一张后,在静态照片上手动拖动 + 缩放一个方框,点「识别」只对框内做 OCR+LLM。
|
||||||
/// 可反复挪框重识别,满意后进入核对页;0 项也能进核对手动补(失败回退红线)。
|
/// 可反复挪框重识别,满意后进入核对页;0 项也能进核对手动补(失败回退红线)。
|
||||||
struct RegionAdjustView: View {
|
struct RegionAdjustView: View {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import AVFoundation
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// 异常项快拍 · 整幅单拍相机。
|
/// 指标速记 · 整幅单拍相机。
|
||||||
/// 全屏实时预览 + 一个快门 → 返回**整幅** upright UIImage(不裁剪)。
|
/// 全屏实时预览 + 一个快门 → 返回**整幅** upright UIImage(不裁剪)。
|
||||||
/// 拍完后由 `RegionAdjustView` 在静态图上手动框选识别区域。
|
/// 拍完后由 `RegionAdjustView` 在静态图上手动框选识别区域。
|
||||||
/// 只在真机可用(模拟器无相机,`QuickRegionCaptureFlow` 退化到 PhotoPicker)。
|
/// 只在真机可用(模拟器无相机,`QuickRegionCaptureFlow` 退化到 PhotoPicker)。
|
||||||
@@ -60,7 +60,7 @@ struct SingleShotCameraView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text("拍一张含异常指标的照片 · 拍完再框选")
|
Text("拍一张含目标指标的照片 · 拍完再框选")
|
||||||
.font(.tjScaled( 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -97,7 +97,7 @@ struct SingleShotCameraView: View {
|
|||||||
Text("相机权限未开启")
|
Text("相机权限未开启")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
Text("指标速记需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||||
.font(.tjScaled( 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -352,49 +352,6 @@ enum RegionImageCropper {
|
|||||||
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return up }
|
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return up }
|
||||||
return UIImage(cgImage: cropped, scale: up.scale, orientation: .up)
|
return UIImage(cgImage: cropped, scale: up.scale, orientation: .up)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Qwen3-VL 局部图预处理:宽而矮的小框直接喂 VL 时,processor 再缩放容易把小字压没。
|
|
||||||
/// 这里只用于 Qwen3-VL 分支,Apple Vision OCR 保持吃原始裁剪图。
|
|
||||||
static func prepareForQwenVL(_ image: UIImage,
|
|
||||||
minimumShortEdge: CGFloat = 448,
|
|
||||||
maximumLongEdge: CGFloat = 2400,
|
|
||||||
padding: CGFloat = 64) -> UIImage {
|
|
||||||
let up = image.normalizedUp()
|
|
||||||
guard let cg = up.cgImage else { return up }
|
|
||||||
|
|
||||||
let sourceSize = CGSize(width: cg.width, height: cg.height)
|
|
||||||
guard sourceSize.width > 0, sourceSize.height > 0 else { return up }
|
|
||||||
|
|
||||||
let short = min(sourceSize.width, sourceSize.height)
|
|
||||||
let long = max(sourceSize.width, sourceSize.height)
|
|
||||||
var scale = max(1, minimumShortEdge / short)
|
|
||||||
if long * scale > maximumLongEdge {
|
|
||||||
scale = maximumLongEdge / long
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentSize = CGSize(
|
|
||||||
width: max(1, (sourceSize.width * scale).rounded()),
|
|
||||||
height: max(1, (sourceSize.height * scale).rounded())
|
|
||||||
)
|
|
||||||
let canvasSize = CGSize(
|
|
||||||
width: contentSize.width + padding * 2,
|
|
||||||
height: contentSize.height + padding * 2
|
|
||||||
)
|
|
||||||
|
|
||||||
let format = UIGraphicsImageRendererFormat.default()
|
|
||||||
format.scale = 1
|
|
||||||
format.opaque = true
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: canvasSize, format: format)
|
|
||||||
return renderer.image { ctx in
|
|
||||||
UIColor.white.setFill()
|
|
||||||
ctx.fill(CGRect(origin: .zero, size: canvasSize))
|
|
||||||
|
|
||||||
UIImage(cgImage: cg, scale: 1, orientation: .up).draw(
|
|
||||||
in: CGRect(x: padding, y: padding,
|
|
||||||
width: contentSize.width, height: contentSize.height)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UIImage {
|
extension UIImage {
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||||
/// 注:`.quick`(异常项快拍)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。
|
/// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。
|
||||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
|
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return String(appLoc: "异常项快拍")
|
case .quick: return String(appLoc: "指标速记")
|
||||||
case .indicator: return String(appLoc: "记录指标")
|
case .indicator: return String(appLoc: "记录指标")
|
||||||
case .healthExport: return String(appLoc: "身体档案")
|
case .healthExport: return String(appLoc: "身体档案")
|
||||||
case .archive: return String(appLoc: "体检报告归档")
|
case .archive: return String(appLoc: "体检报告归档")
|
||||||
|
|||||||
457
康康/Features/Timeline/IndicatorSeriesDetailView.swift
Normal file
457
康康/Features/Timeline/IndicatorSeriesDetailView.swift
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 一个指标的「同类组」标识。决定聚合详情里收哪些历次记录、跳哪个趋势 bucket。
|
||||||
|
/// - `.series`:有 seriesKey 的长期监测指标(血糖/体重/...)。
|
||||||
|
/// - `.bloodPressure`:血压(bp.systolic + bp.diastolic 合并成一对)。
|
||||||
|
/// - `.lab`:无 seriesKey 的化验/手动指标,按 name+unit 归一化 key 聚合。
|
||||||
|
enum IndicatorGroup: Identifiable, Hashable {
|
||||||
|
case series(key: String)
|
||||||
|
case bloodPressure
|
||||||
|
case lab(key: String)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .series(let k): return "series:\(k)"
|
||||||
|
case .bloodPressure: return "bp"
|
||||||
|
case .lab(let k): return "lab:\(k)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从单条指标推断其所属同类组(与趋势页 SeriesBucket 的分组语义一致)。
|
||||||
|
static func of(_ i: Indicator) -> IndicatorGroup {
|
||||||
|
if let key = i.seriesKey, !key.isEmpty {
|
||||||
|
return key.hasPrefix("bp.") ? .bloodPressure : .series(key: key)
|
||||||
|
}
|
||||||
|
return .lab(key: SeriesBucket.normalizedKey(name: i.name, unit: i.unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同类指标聚合详情:横向翻页看该指标的历次记录,底部可跳趋势图。
|
||||||
|
/// 从「记录」页点指标条目进入。数据全部 @Query 实时,删除后翻页列表自动更新。
|
||||||
|
struct IndicatorSeriesDetailView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
|
||||||
|
let group: IndicatorGroup
|
||||||
|
|
||||||
|
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||||
|
private var indicators: [Indicator]
|
||||||
|
@Query private var profiles: [UserProfile]
|
||||||
|
@Query private var customMetrics: [CustomMonitorMetric]
|
||||||
|
|
||||||
|
@State private var selection: String?
|
||||||
|
@State private var showTrend = false
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
@State private var evidenceTarget: Indicator?
|
||||||
|
|
||||||
|
// MARK: - 数据
|
||||||
|
|
||||||
|
/// 聚合详情里的一页:单值指标一条;血压一对。
|
||||||
|
private enum Record: Identifiable {
|
||||||
|
case single(Indicator)
|
||||||
|
case bp(sys: Indicator, dia: Indicator?)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .single(let i): return "\(i.persistentModelID)"
|
||||||
|
case .bp(let s, _): return "bp-\(s.persistentModelID)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 历次血压对:以 bp.systolic 为锚,按 ±5s 配 bp.diastolic(同 TimelineEntry 合并规则)。
|
||||||
|
private var bloodPressureRecords: [Record] {
|
||||||
|
let sysList = indicators
|
||||||
|
.filter { $0.seriesKey == "bp.systolic" }
|
||||||
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
|
var usedDia = Set<PersistentIdentifier>()
|
||||||
|
return sysList.map { sys in
|
||||||
|
let dia = indicators.first {
|
||||||
|
$0.seriesKey == "bp.diastolic" &&
|
||||||
|
!usedDia.contains($0.persistentModelID) &&
|
||||||
|
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
||||||
|
}
|
||||||
|
if let dia { usedDia.insert(dia.persistentModelID) }
|
||||||
|
return .bp(sys: sys, dia: dia)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var records: [Record] {
|
||||||
|
switch group {
|
||||||
|
case .bloodPressure:
|
||||||
|
return bloodPressureRecords
|
||||||
|
case .series(let key):
|
||||||
|
return indicators
|
||||||
|
.filter { $0.seriesKey == key }
|
||||||
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
|
.map(Record.single)
|
||||||
|
case .lab(let nk):
|
||||||
|
return indicators
|
||||||
|
.filter {
|
||||||
|
($0.seriesKey ?? "").isEmpty &&
|
||||||
|
SeriesBucket.normalizedKey(name: $0.name, unit: $0.unit) == nk
|
||||||
|
}
|
||||||
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
|
.map(Record.single)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
switch group {
|
||||||
|
case .bloodPressure:
|
||||||
|
return String(appLoc: "血压")
|
||||||
|
case .series, .lab:
|
||||||
|
if case let .single(i)? = records.first { return i.name }
|
||||||
|
return String(appLoc: "指标详情")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对应的趋势 bucket(需 ≥2 个可解析数值点才存在);nil 时隐藏「查看趋势图」。
|
||||||
|
private var bucket: SeriesBucket? {
|
||||||
|
let all = SeriesBucket.build(from: indicators,
|
||||||
|
profile: profiles.first,
|
||||||
|
customMetrics: customMetrics)
|
||||||
|
switch group {
|
||||||
|
case .bloodPressure:
|
||||||
|
return all.first { $0.id == "bp" }
|
||||||
|
case .series(let key):
|
||||||
|
return all.first { b in b.lines.contains { $0.seriesKey == key } }
|
||||||
|
case .lab(let nk):
|
||||||
|
return all.first { $0.kind == .lab && $0.id == "lab:\(nk)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentIndex: Int {
|
||||||
|
records.firstIndex { $0.id == selection } ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
if records.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
TjPlaceholder(label: String(appLoc: "记录已不存在"))
|
||||||
|
.frame(width: 200, height: 120)
|
||||||
|
Spacer()
|
||||||
|
} else {
|
||||||
|
pages
|
||||||
|
pager
|
||||||
|
if bucket != nil { trendButton }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationDestination(isPresented: $showTrend) {
|
||||||
|
if let bucket { TrendDetailView(bucket: bucket) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
.onAppear { if selection == nil { selection = records.first?.id } }
|
||||||
|
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
|
||||||
|
Button(String(appLoc: "删除"), role: .destructive) { deleteCurrent() }
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("删除后无法恢复。")
|
||||||
|
}
|
||||||
|
.sheet(item: $evidenceTarget) { indicator in
|
||||||
|
if let report = indicator.report {
|
||||||
|
EvidenceImagePreview(report: report, indicator: indicator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.tjScaled(16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
Text(title)
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
if records.count > 1 {
|
||||||
|
Text("\(records.count) 条")
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
TjLockChip()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 翻页内容
|
||||||
|
|
||||||
|
private var pages: some View {
|
||||||
|
TabView(selection: $selection) {
|
||||||
|
ForEach(records) { rec in
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
recordCard(rec)
|
||||||
|
deleteButton
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.tag(Optional(rec.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func recordCard(_ rec: Record) -> some View {
|
||||||
|
switch rec {
|
||||||
|
case .single(let i): singleCard(i)
|
||||||
|
case .bp(let sys, let dia): bpCard(sys: sys, dia: dia)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func singleCard(_ i: Indicator) -> some View {
|
||||||
|
card {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
statusChip(i.status)
|
||||||
|
}
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text(i.value)
|
||||||
|
.font(.tjScaled(30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||||
|
if !i.unit.isEmpty {
|
||||||
|
Text(i.unit).font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
divider
|
||||||
|
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||||
|
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||||
|
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
|
||||||
|
if i.report != nil { evidenceButton(for: i) }
|
||||||
|
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bpCard(sys: Indicator, dia: Indicator?) -> some View {
|
||||||
|
let combined: IndicatorStatus = sys.status != .normal ? sys.status : (dia?.status ?? .normal)
|
||||||
|
return card {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
statusChip(combined)
|
||||||
|
}
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||||
|
.font(.tjScaled(30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||||
|
Text("mmHg").font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
divider
|
||||||
|
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||||
|
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 翻页器
|
||||||
|
|
||||||
|
private var pager: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
pagerArrow("chevron.left", enabled: currentIndex > 0) {
|
||||||
|
if currentIndex > 0 { selection = records[currentIndex - 1].id }
|
||||||
|
}
|
||||||
|
if records.count <= 7 {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(Array(records.enumerated()), id: \.offset) { idx, _ in
|
||||||
|
Circle()
|
||||||
|
.fill(idx == currentIndex ? Tj.Palette.ink : Tj.Palette.line)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pagerArrow("chevron.right", enabled: currentIndex < records.count - 1) {
|
||||||
|
if currentIndex < records.count - 1 { selection = records[currentIndex + 1].id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("第 \(currentIndex + 1) / 共 \(records.count) 条")
|
||||||
|
.font(.tjScaled(11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pagerArrow(_ system: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: system)
|
||||||
|
.font(.tjScaled(13, weight: .semibold))
|
||||||
|
.foregroundStyle(enabled ? Tj.Palette.text : Tj.Palette.text3.opacity(0.4))
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 趋势 / 删除
|
||||||
|
|
||||||
|
private var trendButton: some View {
|
||||||
|
Button { showTrend = true } label: {
|
||||||
|
Label(String(appLoc: "查看趋势图"), systemImage: "chart.xyaxis.line")
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.ink)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteButton: some View {
|
||||||
|
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||||
|
Label(String(appLoc: "永久删除"), systemImage: "trash")
|
||||||
|
.font(.tjScaled(12, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删当前页记录(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)。
|
||||||
|
/// 删后把 selection 落到相邻一条;删空则关闭。
|
||||||
|
private func deleteCurrent() {
|
||||||
|
guard records.indices.contains(currentIndex) else { return }
|
||||||
|
let removingIndex = currentIndex
|
||||||
|
switch records[removingIndex] {
|
||||||
|
case .single(let i):
|
||||||
|
deleteIndicator(i)
|
||||||
|
case .bp(let sys, let dia):
|
||||||
|
deleteIndicator(sys)
|
||||||
|
if let dia { deleteIndicator(dia) }
|
||||||
|
}
|
||||||
|
try? ctx.save()
|
||||||
|
|
||||||
|
let remaining = records
|
||||||
|
if remaining.isEmpty {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
let next = min(removingIndex, remaining.count - 1)
|
||||||
|
selection = remaining[next].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteIndicator(_ i: Indicator) {
|
||||||
|
if let asset = i.asset {
|
||||||
|
try? FileVault.shared.remove(relativePath: asset.relativePath)
|
||||||
|
ctx.delete(asset)
|
||||||
|
}
|
||||||
|
ctx.delete(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 复用件
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) { content() }
|
||||||
|
.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 func field(_ label: String, _ value: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Text(label).font(.tjScaled(13)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Text(value)
|
||||||
|
.font(.tjScaled(14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func evidenceButton(for indicator: Indicator) -> some View {
|
||||||
|
if indicator.hasEvidenceBox,
|
||||||
|
let page = indicator.sourcePageIndex,
|
||||||
|
let assets = indicator.report?.assets,
|
||||||
|
assets.indices.contains(page) {
|
||||||
|
Button {
|
||||||
|
evidenceTarget = indicator
|
||||||
|
} label: {
|
||||||
|
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var divider: some View {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusChip(_ s: IndicatorStatus) -> some View {
|
||||||
|
let text: String
|
||||||
|
let color: Color
|
||||||
|
let arrow: String
|
||||||
|
switch s {
|
||||||
|
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = "↑"
|
||||||
|
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = "↓"
|
||||||
|
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
|
||||||
|
}
|
||||||
|
return HStack(spacing: 3) {
|
||||||
|
if !arrow.isEmpty { Text(arrow).font(.tjScaled(11, weight: .bold)) }
|
||||||
|
Text(text).font(.tjScaled(12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Capsule().fill(color.opacity(0.14)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func dateTimeText(_ d: Date) -> String {
|
||||||
|
d.formatted(.dateTime.year().month().day().hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -420,7 +420,8 @@ struct TimelineEntryDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct EvidenceImagePreview: View {
|
/// 原图证据预览(翻页 + 高亮框)。指标详情与同类聚合详情共用,故为模块内可见。
|
||||||
|
struct EvidenceImagePreview: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
let report: Report
|
let report: Report
|
||||||
let indicator: Indicator
|
let indicator: Indicator
|
||||||
|
|||||||
@@ -1112,6 +1112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AI 思考中… 本地推理,通常 5-10 秒" : {
|
"AI 思考中… 本地推理,通常 5-10 秒" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1223,6 +1224,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"AI 生成中 · %.1f tok/s" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"AI 生成中 · 本地推理" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。" : {
|
"AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1314,9 +1321,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Apple Vision" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Apple 健康里没有可导入的生日、性别、身高或血型。" : {
|
"Apple 健康里没有可导入的生日、性别、身高或血型。" : {
|
||||||
|
|
||||||
@@ -1427,10 +1431,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Qwen3.5-4B 多模态直接看图(MNN/MLX)" : {
|
"Qwen3.5-2B 已就绪" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Qwen3.5-4B 已就绪" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"s" : {
|
"s" : {
|
||||||
@@ -2542,9 +2543,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"例:最近血压波动大吗?" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"例:最近血糖好像不稳,把过去三个月的化验单整理一下" : {
|
"例:最近血糖好像不稳,把过去三个月的化验单整理一下" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -2591,9 +2589,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"例:把我最近头晕、睡眠和指标变化整理给医生" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"例如:< 3.40 或 3.9 - 6.1" : {
|
"例如:< 3.40 或 3.9 - 6.1" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2792,6 +2787,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"保存后点一下,就能把这句话填进输入框" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"保存归档" : {
|
"保存归档" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -4342,9 +4340,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"图片编码失败,手动补充" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"在「+ 新建 → 指标记录 → %@」记录一次" : {
|
"在「+ 新建 → 指标记录 → %@」记录一次" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4623,9 +4618,6 @@
|
|||||||
},
|
},
|
||||||
"大" : {
|
"大" : {
|
||||||
|
|
||||||
},
|
|
||||||
"大模型直读" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"失眠" : {
|
"失眠" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5940,34 +5932,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"异常项快拍" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Abnormal item quick capture"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "異常項目クイック撮影"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ko" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "이상 항목 빠른 촬영"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"异常项拍照识别" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"强度" : {
|
"强度" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6306,9 +6270,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"患者" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"慢性肾病" : {
|
"慢性肾病" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6353,6 +6314,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"我" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"我的" : {
|
"我的" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6700,7 +6664,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"拍一张含异常指标的照片 · 拍完再框选" : {
|
"拍一张含目标指标的照片 · 拍完再框选" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"拍到的局部" : {
|
"拍到的局部" : {
|
||||||
@@ -7074,6 +7038,31 @@
|
|||||||
},
|
},
|
||||||
"指标详情" : {
|
"指标详情" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"指标速记" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Quick metric log"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "指標クイック記録"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "지표 빠른 기록"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"指标速记需要相机。去「设置 → 康康 → 相机」打开后再回来。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"按%lld岁调整" : {
|
"按%lld岁调整" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -7096,6 +7085,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"按本机配置选择 · 当前 MLX(MNN 不可用)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"按本机配置选择 · 当前 MNN + SME2" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"按本机配置选择 · 当前 MNN(NEON)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"推理中…" : {
|
"推理中…" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -8741,6 +8739,9 @@
|
|||||||
},
|
},
|
||||||
"查看原图位置" : {
|
"查看原图位置" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"查看趋势图" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"标准" : {
|
"标准" : {
|
||||||
|
|
||||||
@@ -8815,7 +8816,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"核对异常项" : {
|
"核对指标" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"核对识别结果" : {
|
"核对识别结果" : {
|
||||||
@@ -8908,9 +8909,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"模型未就绪,请在模型管理下载或切回 Apple Vision" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载。" : {
|
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载。" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9326,6 +9324,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"添加" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"添加你自己的长期监测项" : {
|
"添加你自己的长期监测项" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9348,6 +9349,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"添加快捷问答" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"点底部 + 号可以补一条" : {
|
"点底部 + 号可以补一条" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9929,6 +9933,16 @@
|
|||||||
},
|
},
|
||||||
"端侧 CPU(本机无 SME2,NEON 回退)" : {
|
"端侧 CPU(本机无 SME2,NEON 回退)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"第 %lld / 共 %lld 条" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "第 %1$lld / 共 %2$lld 条"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"第 %lld 轮 · 基于你刚才更新的文本 · %lld 条" : {
|
"第 %lld 轮 · 基于你刚才更新的文本 · %lld 条" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -10071,9 +10085,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"系统 OCR + 文本模型解析" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"系统:iOS 17 或更新版本。" : {
|
"系统:iOS 17 或更新版本。" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -10849,6 +10860,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"记录已不存在" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"记录指标" : {
|
"记录指标" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11657,6 +11671,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"输入一句常用问题…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"输入密码" : {
|
"输入密码" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ final class HealthExport {
|
|||||||
var inferredLabelCN: String?
|
var inferredLabelCN: String?
|
||||||
|
|
||||||
// demo 卖点凭证
|
// demo 卖点凭证
|
||||||
/// 模型 tag,如 "Qwen3.5-2B-4bit"。截图能证明本地推理。
|
/// 模型 tag,如 "Qwen3.5-2B-MNN"(iPhone17+ 主路径)或 "Qwen3.5-2B-4bit"(MLX 兜底)。截图能证明本地推理。
|
||||||
var modelTag: String
|
var modelTag: String
|
||||||
/// 末次 tok/s,对应 demo 卖点 #6 Live Activity 数据。
|
/// 末次 tok/s,对应 demo 卖点 #6 Live Activity 数据。
|
||||||
var decodeRate: Double
|
var decodeRate: Double
|
||||||
@@ -44,7 +44,7 @@ final class HealthExport {
|
|||||||
inferredTimeToDate: Date? = nil,
|
inferredTimeToDate: Date? = nil,
|
||||||
inferredIntent: String? = nil,
|
inferredIntent: String? = nil,
|
||||||
inferredLabelCN: String? = nil,
|
inferredLabelCN: String? = nil,
|
||||||
modelTag: String = "Qwen3.5-2B-4bit",
|
modelTag: String = "Qwen3.5-2B-MNN",
|
||||||
decodeRate: Double = 0) {
|
decodeRate: Double = 0) {
|
||||||
self.prompt = prompt
|
self.prompt = prompt
|
||||||
self.content = content
|
self.content = content
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ enum IndicatorStatus: String, Codable, CaseIterable {
|
|||||||
case high, low, normal
|
case high, low, normal
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 指标录入来源。manual = 「记录指标」手动录入;quickCapture = 异常项快拍(VL);report = 报告归档携带。
|
/// 指标录入来源。manual = 「记录指标」手动录入;quickCapture = 指标速记(VL);report = 报告归档携带。
|
||||||
/// 旧数据无此字段 → 默认 manual(轻量迁移)。
|
/// 旧数据无此字段 → 默认 manual(轻量迁移)。
|
||||||
enum IndicatorSource: String, Codable, CaseIterable {
|
enum IndicatorSource: String, Codable, CaseIterable {
|
||||||
case manual, quickCapture, report
|
case manual, quickCapture, report
|
||||||
@@ -14,7 +14,7 @@ enum IndicatorSource: String, Codable, CaseIterable {
|
|||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .manual: return String(appLoc: "手动记录")
|
case .manual: return String(appLoc: "手动记录")
|
||||||
case .quickCapture: return String(appLoc: "异常项快拍")
|
case .quickCapture: return String(appLoc: "指标速记")
|
||||||
case .report: return String(appLoc: "报告归档")
|
case .report: return String(appLoc: "报告归档")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ struct RootView: View {
|
|||||||
DiaryQuickSheet()
|
DiaryQuickSheet()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showIndicator) {
|
.sheet(isPresented: $showIndicator) {
|
||||||
// 「拍照识别」入口:关闭手输表单 → 打开异常项快拍 VL 流程(并入「记录指标」)。
|
// 「拍照识别」入口:关闭手输表单 → 打开指标速记 VL 流程(并入「记录指标」)。
|
||||||
IndicatorQuickSheet(onRequestCamera: {
|
IndicatorQuickSheet(onRequestCamera: {
|
||||||
showIndicator = false
|
showIndicator = false
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
|||||||
@@ -77,53 +77,6 @@ actor CaptureService {
|
|||||||
try await runVL(on: assets)
|
try await runVL(on: assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 异常项快拍:对一张**局部照片**(JPEG data)跑 VL,只抽 indicators,不建 Report、不留图。
|
|
||||||
/// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtectionUnlessOpen`),推理后 `defer` 删除 ——
|
|
||||||
/// 符合「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。
|
|
||||||
/// - 失败抛 `CaptureError`,UI 回退手动录入(§3.2 失败回退红线)。
|
|
||||||
/// 调用方(MainActor)负责把识别结果落成独立 Indicator。
|
|
||||||
func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] {
|
|
||||||
do {
|
|
||||||
try await AIRuntime.shared.prepareVL()
|
|
||||||
} catch {
|
|
||||||
throw CaptureError.modelNotReady
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
||||||
.appendingPathComponent("region-\(UUID().uuidString).jpg")
|
|
||||||
do {
|
|
||||||
// 用 .completeFileProtectionUnlessOpen 而非 .complete:VL 推理可能持续数秒,
|
|
||||||
// 期间设备若锁屏,.complete 会让读/写抛 EPERM 使快拍在锁屏下必失败;
|
|
||||||
// unlessOpen 允许已打开句柄继续访问,与 Vault(completeUnlessOpen)一致。
|
|
||||||
try imageData.write(to: tmpURL, options: [.completeFileProtectionUnlessOpen, .atomic])
|
|
||||||
} catch {
|
|
||||||
throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
defer { try? FileManager.default.removeItem(at: tmpURL) }
|
|
||||||
|
|
||||||
let raw: String
|
|
||||||
do {
|
|
||||||
raw = try await AIRuntime.shared.analyzeReport(
|
|
||||||
imageURLs: [tmpURL],
|
|
||||||
prompt: VLPrompts.regionExtraction(),
|
|
||||||
// 整张化验单可能含十余项,512 token 会截断 → 解析失败。给足额度。
|
|
||||||
maxTokens: 2048
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
throw CaptureError.inferenceFailed("\(error)")
|
|
||||||
}
|
|
||||||
#if DEBUG
|
|
||||||
print("🔎 [recognizeRegion] image bytes=\(imageData.count), VL raw output:\n\(raw)\n--- end VL raw ---")
|
|
||||||
#endif
|
|
||||||
do {
|
|
||||||
return try CaptureService.parseIndicatorsJSON(raw)
|
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
|
||||||
throw CaptureError.parseFailed(msg)
|
|
||||||
} catch {
|
|
||||||
throw CaptureError.parseFailed("\(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 「拍照识别」OCR 链路:把 Vision OCR 出的纯文本交给 LLM(Qwen3-1.7B)结构化抽指标。
|
/// 「拍照识别」OCR 链路:把 Vision OCR 出的纯文本交给 LLM(Qwen3-1.7B)结构化抽指标。
|
||||||
/// 不建 Report、不留图;失败抛 `CaptureError`,UI 回退手动录入(§3.2)。
|
/// 不建 Report、不留图;失败抛 `CaptureError`,UI 回退手动录入(§3.2)。
|
||||||
/// 调用方(MainActor)先做 OCR,再把文本传进来——OCR 不需进 actor,也避免 UIImage 跨 actor。
|
/// 调用方(MainActor)先做 OCR,再把文本传进来——OCR 不需进 actor,也避免 UIImage 跨 actor。
|
||||||
@@ -149,12 +102,19 @@ actor CaptureService {
|
|||||||
// Qwen3 可能吐 <think>…</think>,先剥掉再抠 JSON。
|
// Qwen3 可能吐 <think>…</think>,先剥掉再抠 JSON。
|
||||||
let cleaned = CaptureService.stripThink(collected)
|
let cleaned = CaptureService.stripThink(collected)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("🧠 [recognizeIndicators] LLM cleaned output:\n\(cleaned)\n--- end LLM ---")
|
// 取证:原始输出(含可能未闭合的 <think>)+ strip 后,定位「空/非法 JSON」根因。
|
||||||
|
// 用 NSLog(走统一日志)而非 print(stdout 被 Xcode lldb 接管,idevicesyslog 抓不到)。
|
||||||
|
NSLog("KKDBG-VL RAW LLM output (%d chars):\n%@\n--- end RAW ---", collected.count, collected)
|
||||||
|
NSLog("KKDBG-VL cleaned (%d chars):\n%@\n--- end cleaned ---", cleaned.count, cleaned)
|
||||||
#endif
|
#endif
|
||||||
do {
|
do {
|
||||||
return try CaptureService.parseIndicatorsJSON(cleaned)
|
return try CaptureService.parseIndicatorsJSON(cleaned)
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
throw CaptureError.parseFailed(msg)
|
// 把模型实际输出的特征带到屏幕上,便于现场定位(原始长度 / strip 后长度 / 前缀)。
|
||||||
|
let rawLen = collected.count
|
||||||
|
let cleanLen = cleaned.count
|
||||||
|
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
|
||||||
|
throw CaptureError.parseFailed("\(msg)〔raw \(rawLen)字/clean \(cleanLen)字·前缀:\(preview)〕")
|
||||||
} catch {
|
} catch {
|
||||||
throw CaptureError.parseFailed("\(error)")
|
throw CaptureError.parseFailed("\(error)")
|
||||||
}
|
}
|
||||||
@@ -213,7 +173,7 @@ actor CaptureService {
|
|||||||
// 用 extractBalancedJSON(而非只认 {} 的 extractJSONObject):VL 多项时偶尔直接吐
|
// 用 extractBalancedJSON(而非只认 {} 的 extractJSONObject):VL 多项时偶尔直接吐
|
||||||
// 裸数组 [{...},{...}],只认对象会从第一个 { 配对,只截出第一个 indicator、静默丢掉
|
// 裸数组 [{...},{...}],只认对象会从第一个 { 配对,只截出第一个 indicator、静默丢掉
|
||||||
// 其余 —— 这是影像档案核心卖点上的数据丢失。顶层是数组时整体视作 indicators。
|
// 其余 —— 这是影像档案核心卖点上的数据丢失。顶层是数组时整体视作 indicators。
|
||||||
let jsonString = extractBalancedJSON(from: raw)
|
let jsonString = repairJSON(extractBalancedJSON(from: raw))
|
||||||
guard let data = jsonString.data(using: .utf8) else {
|
guard let data = jsonString.data(using: .utf8) else {
|
||||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||||
}
|
}
|
||||||
@@ -259,7 +219,7 @@ actor CaptureService {
|
|||||||
/// 复用 `extractJSONObject` + `parseIndicator`。解析不到任何 indicator 返回空数组(不抛),
|
/// 复用 `extractJSONObject` + `parseIndicator`。解析不到任何 indicator 返回空数组(不抛),
|
||||||
/// UI 据此走「没读出指标,手动补充」分支。JSON 本身不合法才抛 `parseFailed`。
|
/// UI 据此走「没读出指标,手动补充」分支。JSON 本身不合法才抛 `parseFailed`。
|
||||||
static func parseIndicatorsJSON(_ raw: String) throws -> [ParsedReport.ParsedIndicator] {
|
static func parseIndicatorsJSON(_ raw: String) throws -> [ParsedReport.ParsedIndicator] {
|
||||||
let jsonString = extractBalancedJSON(from: raw)
|
let jsonString = repairJSON(extractBalancedJSON(from: raw))
|
||||||
guard let data = jsonString.data(using: .utf8) else {
|
guard let data = jsonString.data(using: .utf8) else {
|
||||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||||
}
|
}
|
||||||
@@ -324,6 +284,21 @@ actor CaptureService {
|
|||||||
return String(s[start...])
|
return String(s[start...])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弱模型(2B)常见 JSON 畸形的安全修复,仅在 JSONSerialization 前兜底:
|
||||||
|
/// - 中文弯引号 “ ” → 直引号 "(模型偶尔给 key/value 套全角引号)
|
||||||
|
/// - 去对象/数组尾逗号(`,}` / `,]` → `}` / `]`)
|
||||||
|
/// 修不好仍按原逻辑报错;只做结构性修正,不改字符串语义。
|
||||||
|
static func repairJSON(_ s: String) -> String {
|
||||||
|
var t = s
|
||||||
|
t = t.replacingOccurrences(of: "\u{201C}", with: "\"") // “
|
||||||
|
t = t.replacingOccurrences(of: "\u{201D}", with: "\"") // ”
|
||||||
|
if let re = try? NSRegularExpression(pattern: ",\\s*([}\\]])") {
|
||||||
|
t = re.stringByReplacingMatches(
|
||||||
|
in: t, range: NSRange(t.startIndex..., in: t), withTemplate: "$1")
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
/// 抠出第一段平衡的 JSON 值,`{...}` 或 `[...]` 以先出现者为准。
|
/// 抠出第一段平衡的 JSON 值,`{...}` 或 `[...]` 以先出现者为准。
|
||||||
/// 用于局部识别(模型可能输出 `{"indicators":[...]}` 或裸 `[...]`)。
|
/// 用于局部识别(模型可能输出 `{"indicators":[...]}` 或裸 `[...]`)。
|
||||||
/// 失败返回去围栏后的原串(后续 JSONSerialization 报错)。
|
/// 失败返回去围栏后的原串(后续 JSONSerialization 报错)。
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ struct DiaryAssistService {
|
|||||||
|
|
||||||
// 1. 去 <think>...</think>(复用 HealthExportService 的兜底)
|
// 1. 去 <think>...</think>(复用 HealthExportService 的兜底)
|
||||||
let stripped = HealthExportService.stripThinkBlocks(collected)
|
let stripped = HealthExportService.stripThinkBlocks(collected)
|
||||||
// 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject)
|
// 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject)+ 弱模型畸形修复
|
||||||
let jsonStr = CaptureService.extractJSONObject(from: stripped)
|
let jsonStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
|
||||||
guard let data = jsonStr.data(using: .utf8),
|
guard let data = jsonStr.data(using: .utf8),
|
||||||
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
|
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
|
||||||
let dict = obj as? [String: Any] else {
|
let dict = obj as? [String: Any] else {
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ struct ModelManifestTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func llmTotalBytesMatchesManifest() {
|
@Test func llmTotalBytesMatchesManifest() {
|
||||||
#expect(ModelManifest.totalBytes(for: .llm) == 3_061_129_077)
|
#expect(ModelManifest.totalBytes(for: .llm) == 1_749_079_691)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func vlTotalBytesMatchesManifest() {
|
@Test func vlTotalBytesMatchesManifest() {
|
||||||
#expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929)
|
#expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func mnnHasSevenFunctionalFiles() {
|
@Test func mnnHasSixFunctionalFiles() {
|
||||||
#expect(ModelManifest.files(for: .mnnLLM).count == 7)
|
#expect(ModelManifest.files(for: .mnnLLM).count == 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func mnnTotalBytesMatchesManifest() {
|
@Test func mnnTotalBytesMatchesManifest() {
|
||||||
#expect(ModelManifest.totalBytes(for: .mnnLLM) == 2_836_770_850)
|
#expect(ModelManifest.totalBytes(for: .mnnLLM) == 1_185_759_005)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func mnnHasEssentialRuntimeFiles() {
|
@Test func mnnHasEssentialRuntimeFiles() {
|
||||||
@@ -39,7 +39,7 @@ struct ModelManifestTests {
|
|||||||
@Test func mnnFileURLUsesRepoPath() {
|
@Test func mnnFileURLUsesRepoPath() {
|
||||||
let file = ModelFile(path: "config.json", bytes: 652)
|
let file = ModelFile(path: "config.json", bytes: 652)
|
||||||
let url = ModelManifest.fileURL(for: .mnnLLM, file: file)
|
let url = ModelManifest.fileURL(for: .mnnLLM, file: file)
|
||||||
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-4B-MNN/config.json")
|
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-2B-MNN/config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func excludesReadmeAndGitattributes() {
|
@Test func excludesReadmeAndGitattributes() {
|
||||||
@@ -62,8 +62,8 @@ struct ModelManifestTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func fileURLIsBaseSlashRepoSlashPath() {
|
@Test func fileURLIsBaseSlashRepoSlashPath() {
|
||||||
let file = ModelFile(path: "config.json", bytes: 3_366)
|
let file = ModelFile(path: "config.json", bytes: 3_113)
|
||||||
let url = ModelManifest.fileURL(for: .llm, file: file)
|
let url = ModelManifest.fileURL(for: .llm, file: file)
|
||||||
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-4B-4bit/config.json")
|
#expect(url.absoluteString == "https://file.myv0.com/Qwen3.5-2B-4bit/config.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import Testing
|
|
||||||
@testable import 康康
|
|
||||||
|
|
||||||
struct QuickRegionRecognitionEngineTests {
|
|
||||||
|
|
||||||
@Test func defaultsToAppleVisionOCR() {
|
|
||||||
#expect(QuickRegionRecognitionEngine.defaultValue == .appleVision)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func rawValuesAreStableForAppStorage() {
|
|
||||||
#expect(QuickRegionRecognitionEngine.appleVision.rawValue == "appleVision")
|
|
||||||
#expect(QuickRegionRecognitionEngine.qwenVL.rawValue == "qwenVL")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func unknownStoredValueFallsBackToDefault() {
|
|
||||||
#expect(QuickRegionRecognitionEngine(storedValue: "missing") == .appleVision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import CoreGraphics
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
@testable import 康康
|
@testable import 康康
|
||||||
|
|
||||||
/// 异常项快拍的局部裁剪几何。
|
/// 指标速记的局部裁剪几何。
|
||||||
/// 回归用例:屏上「宽而矮」的小框,必须裁出「宽 > 高」的照片 rect。
|
/// 回归用例:屏上「宽而矮」的小框,必须裁出「宽 > 高」的照片 rect。
|
||||||
/// 旧实现用 `metadataOutputRectConverted`(传感器横向坐标)套到竖屏照片 → x/y 轴对调,
|
/// 旧实现用 `metadataOutputRectConverted`(传感器横向坐标)套到竖屏照片 → x/y 轴对调,
|
||||||
/// 把宽框裁成竖窄条(2026-05-31 真机 bug)。本组用例钉住正确的纯几何映射。
|
/// 把宽框裁成竖窄条(2026-05-31 真机 bug)。本组用例钉住正确的纯几何映射。
|
||||||
@@ -113,40 +113,4 @@ final class RegionImageCropperTests: XCTestCase {
|
|||||||
.zero)
|
.zero)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Qwen3-VL 对宽而矮的局部小图更容易在 processor 缩放后丢字。
|
|
||||||
/// 进入 VL 前应把短边放大并加白边,但不拉伸内容。
|
|
||||||
func testPrepareForQwenVLUpscalesWideShortCropAndAddsPadding() {
|
|
||||||
let image = solidImage(size: CGSize(width: 320, height: 80))
|
|
||||||
|
|
||||||
let prepared = RegionImageCropper.prepareForQwenVL(image)
|
|
||||||
|
|
||||||
XCTAssertGreaterThanOrEqual(prepared.size.height, 448)
|
|
||||||
XCTAssertGreaterThan(prepared.size.width, 320)
|
|
||||||
XCTAssertEqual(prepared.size.width / prepared.size.height,
|
|
||||||
4.0,
|
|
||||||
accuracy: 0.8,
|
|
||||||
"预处理应大致保留宽条内容比例,只允许白边造成轻微变化")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPrepareForQwenVLDoesNotEnlargePastLongEdgeLimit() {
|
|
||||||
let image = solidImage(size: CGSize(width: 5000, height: 900))
|
|
||||||
|
|
||||||
let prepared = RegionImageCropper.prepareForQwenVL(image)
|
|
||||||
|
|
||||||
XCTAssertLessThanOrEqual(max(prepared.size.width, prepared.size.height), 2400 + 128)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func solidImage(size: CGSize) -> UIImage {
|
|
||||||
let format = UIGraphicsImageRendererFormat.default()
|
|
||||||
format.scale = 1
|
|
||||||
return UIGraphicsImageRenderer(size: size, format: format).image { ctx in
|
|
||||||
UIColor.white.setFill()
|
|
||||||
ctx.fill(CGRect(origin: .zero, size: size))
|
|
||||||
UIColor.black.setFill()
|
|
||||||
ctx.fill(CGRect(x: size.width * 0.1,
|
|
||||||
y: size.height * 0.35,
|
|
||||||
width: size.width * 0.8,
|
|
||||||
height: size.height * 0.3))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user