feat: 国际化(i18n) en/ja/ko + App 内语言切换

主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
  + pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
  Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存

注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
link2026
2026-05-30 10:28:24 +08:00
parent 910ca99f21
commit d2c77d5c51
84 changed files with 15643 additions and 699 deletions

View File

@@ -7,9 +7,9 @@ enum AIRuntimeError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .notReady: return "AI 模型尚未准备好"
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
case .inferenceFailed(let m): return "推理失败:\(m)"
case .notReady: return String(appLoc: "AI 模型尚未准备好")
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
}
}
}

View File

@@ -7,9 +7,9 @@ enum DownloadError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .badStatus(let code):
return "下载失败(HTTP \(code))"
return String(appLoc: "下载失败(HTTP \(code))")
case .sizeMismatch(let expected, let got):
return "文件大小校验失败(预期 \(expected),实际 \(got))"
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
}
}
}

View File

@@ -30,18 +30,27 @@ enum ModelManifest {
ModelFile(path: "added_tokens.json", bytes: 707),
]
case .vl:
// Qwen3-VL-4B-Instruct-4bit: mlx-community blob
// (HF API blobs=true,2026-05 ),
// :( README.md / .gitattributes),
// mlx-vlm , VLMModelFactory
// chat_template(.json + .jinja ) video ,
// swift-transformers / Qwen3VLProcessor
return [
ModelFile(path: "config.json", bytes: 1_659),
ModelFile(path: "model.safetensors", bytes: 3_073_720_461),
ModelFile(path: "model.safetensors.index.json", bytes: 108_307),
ModelFile(path: "tokenizer.json", bytes: 11_421_896),
ModelFile(path: "tokenizer_config.json", bytes: 7_256),
ModelFile(path: "config.json", bytes: 7_137),
ModelFile(path: "model.safetensors", bytes: 3_093_767_283),
ModelFile(path: "model.safetensors.index.json", bytes: 64_742),
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
ModelFile(path: "tokenizer_config.json", bytes: 5_445),
ModelFile(path: "vocab.json", bytes: 2_776_833),
ModelFile(path: "merges.txt", bytes: 1_671_853),
ModelFile(path: "special_tokens_map.json", bytes: 613),
ModelFile(path: "added_tokens.json", bytes: 605),
ModelFile(path: "chat_template.json", bytes: 1_050),
ModelFile(path: "preprocessor_config.json", bytes: 350),
ModelFile(path: "added_tokens.json", bytes: 707),
ModelFile(path: "generation_config.json", bytes: 269),
ModelFile(path: "chat_template.json", bytes: 5_502),
ModelFile(path: "chat_template.jinja", bytes: 5_292),
ModelFile(path: "preprocessor_config.json", bytes: 782),
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
]
}
}

View File

@@ -3,12 +3,12 @@ import Foundation
nonisolated enum ModelKind: String, CaseIterable {
/// HuggingFace mlx-community , Models/
case llm = "Qwen3-1.7B-4bit"
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
var displayName: String {
switch self {
case .llm: return "Qwen3-1.7B"
case .vl: return "Qwen2.5-VL-3B"
case .vl: return "Qwen3-VL-4B"
}
}
@@ -132,7 +132,7 @@ enum ModelStoreError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .missingConfig:
return "所选文件夹缺少 config.json,不是有效的模型目录"
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
}
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
/// , LLM 3-4
/// JSON, question dim()+ q()+ fill()
///
/// `dim`( 2026-05-30 prompt ):
/// 1.7B ,
/// , dim,,
/// ,
enum DiaryAssistPrompts {
/// ;UI
/// /, few-shot
static let dimensions: [String] = [
"起病诱因", "症状性质", "伴随症状", "加重缓解",
"持续频率", "既往家族史", "用药过敏", "生活方式",
]
/// - content:
/// - coveredDimensions: (),
///
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
let covered = coveredDimensions.filter { !$0.isEmpty }
let coveredLine = covered.isEmpty ? "" : covered.joined(separator: "")
let excludeRule = covered.isEmpty
? ""
: "\n- 本轮【严禁】选择这些已覆盖维度:\(covered.joined(separator: ""));只能从其余维度里挑。"
return """
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
请从医生问诊角度提出 3-4 个最值得追问的问题,帮患者把这条记录补全。
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
1. 起病诱因 —— 何时开始、有无诱因
2. 症状性质 —— 部位、性质、程度
3. 伴随症状 —— 是否伴随其他不适
4. 加重缓解 —— 什么情况下加重或缓解
5. 持续频率 —— 持续多久、多频繁、是否反复发作
6. 既往家族史 —— 以前是否有类似、家族相关史
7. 用药过敏 —— 在服药物、过敏史
8. 生活方式 —— 睡眠、饮食、运动习惯、压力
硬性规则:
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"")。\(excludeRule)
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
- 不给诊断、不给用药建议、不写「建议就医」。
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
- 至少 3 条,最多 4 条。
只输出严格 JSON,不要解释、不要 markdown 围栏、不要 <think> 标签。结构:
{"questions":[{"dim":"<>","q":"<>","fill":"<>"}]}
示例 1(第一轮,记录:头痛了一上午):
{"questions":[
{"dim":"","q":"?","fill":" [] ,"},
{"dim":"","q":"?","fill":"/ [//],"},
{"dim":"","q":"?","fill":" [],"},
{"dim":"","q":"?","fill":" [] [],"}
]}
示例 2(后续轮,已覆盖维度:起病诱因、症状性质、伴随症状):
{"questions":[
{"dim":"","q":"?","fill":"[/] [/],"},
{"dim":"","q":"?","fill":"/ [/],"},
{"dim":"","q":"?","fill":" [/,],"}
]}
现在输出 JSON。
已覆盖维度(必须避开):\(coveredLine)
【最新记录】:
\(content)
Output: /no_think
"""
}
}

View File

@@ -0,0 +1,91 @@
import Foundation
/// LLM prompt:
/// 1. `intentExtraction` + /, JSON
/// 2. `reportGeneration` Markdown
///
/// `HealthExportService`(§3.2 退线:
/// JSON 30 + ,)
enum HealthExportPrompts {
// MARK: -
/// `intentExtraction(userPrompt:)`
/// :
/// ```json
/// {"time_range_days":30,
/// "keywords":["",""],
/// "symptom_keywords":["",""],
/// "intent":"cold_consult",
/// "intent_label_cn":""}
/// ```
static func intentExtraction(userPrompt: String) -> String {
"""
你是健康数据助手。读用户的请求,只输出严格 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
字段说明(全部必填):
{
"time_range_days": int, // 回溯天数,默认 30,最大 365
"keywords": [string], // 指标关键词(中文,如「血压」「血糖」「体温」「肝功」),无则 []
"symptom_keywords": [string], // 症状关键词,无则 []
"intent": string, // 英文 snake_case 标签,如 "cold_consult"
"intent_label_cn": string // 中文短语,会作为报告标题副题,如 ""
}
规则:
- 时间未指定 → 30
- 「最近一个月」→ 30,「最近三个月」→ 90,「最近半年」→ 180
- 关键词要中文,常见健康指标 / 症状词
- intent 简短,4-25 字符,小写下划线
示例 1:
User: 我感冒3天了,要把最近一个月的健康情况给医生看
Output: {"time_range_days":30,"keywords":["","",""],"symptom_keywords":["","","",""],"intent":"cold_consult","intent_label_cn":""}
示例 2:
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
Output: {"time_range_days":90,"keywords":["","",""],"symptom_keywords":[],"intent":"glucose_review","intent_label_cn":""}
现在请输出 JSON:
User: \(userPrompt)
Output: /no_think
"""
}
// MARK: -
/// `reportGeneration(userPrompt:intentLabelCN:dataJSON:)` Markdown
static func reportGeneration(userPrompt: String,
intentLabelCN: String,
dataJSON: String) -> String {
let labelLine = intentLabelCN.isEmpty
? "# 就诊摘要"
: "# 就诊摘要 — \(intentLabelCN)"
return """
你正在帮患者撰写一份给社区医生看的就诊摘要。要求:
- 严格输出 Markdown,标题用 # / ##,不要 markdown 围栏
- 只用「数据」中给出的信息,数据缺失就写「无记录」
- 不要给诊断意见、用药建议或「建议就医」之类的话
- 引用数值时保留单位 + 参考范围,异常项前加 ⚠️
- 全文中文,简洁,医生 30 秒内能扫完
- 不要复述「数据」二字,不要输出 JSON
结构(严格按以下 6 段):
\(labelLine)
## 主诉
## 患者背景
## 近期症状(按时间倒序)
## 关键指标(异常项优先)
## 在服药与过敏
## 患者疑问
数据:
\(dataJSON)
患者原话:\(userPrompt)
现在请生成 Markdown(直接输出,不要思考过程,不要 <think> 标签):
/no_think
"""
}
}

View File

@@ -1,6 +1,6 @@
import Foundation
/// VL (Qwen2.5-VL) / prompt
/// VL (Qwen3-VL) / prompt
/// : JSON,markdown
/// CaptureService 退(§3.2 退线)
enum VLPrompts {
@@ -27,9 +27,21 @@ enum VLPrompts {
/// ```
/// `kind` UI indicators A2() B3()
static let reportExtraction: String = #"""
/// VL "", few-shot ,
/// prompt,退
static func reportExtraction(today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
}
private static let reportExtractionTemplate: String = #"""
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string,
@@ -52,7 +64,8 @@ JSON schema(严格):
规则:
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。
- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)
- 无法识别的字段填空字符串(institution / summary)。
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
- 不要发明指标。看不清的整行跳过。
- 化验单一般 type = "lab",体检套餐 = "checkup"

View File

@@ -3,7 +3,7 @@ import MLX
import MLXVLM
import MLXLMCommon
/// MLX VL (Qwen2.5-VL)
/// MLX VL (Qwen3-VL)
/// LLMSession actor , AIRuntime
actor VLSession {
let container: ModelContainer