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

@@ -1,5 +1,6 @@
import Foundation
import UIKit
import SwiftData
/// VL (, SwiftData )
/// Indicator/Report prompt schema
@@ -40,16 +41,14 @@ struct ParsedReport: Sendable {
/// CaptureService UI (退 vs )
enum CaptureError: Error, LocalizedError {
case modelNotReady
case writeAssetFailed
case inferenceFailed(String)
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return "VL 模型尚未就绪"
case .writeAssetFailed: return "图片保存失败"
case .inferenceFailed(let m): return "识别失败:\(m)"
case .parseFailed(let m): return "结构化失败:\(m)"
case .modelNotReady: return String(appLoc: "VL 模型尚未就绪")
case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)")
case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)")
}
}
}
@@ -60,38 +59,36 @@ actor CaptureService {
static let shared = CaptureService()
private init() {}
/// + VL + ParsedReport
/// , CaptureError;UI
/// - Returns: (ParsedReport, [FileVault.SavedAsset]) ,
/// SavedAsset Asset @Model
func analyze(images: [UIImage]) async throws
-> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) {
/// Vault Asset VL, ParsedReport
/// :
/// - UnifiedCaptureFlow (UI ,/ assets )
/// -
/// - C2(W5)
/// SwiftData (MainActor), `Report.applyReanalyzed(_:in:)`
/// @Model , Sendable actor
func reanalyze(assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
try await runVL(on: assets)
}
// 1. Vault()
let assets: [FileVault.SavedAsset]
/// VL + JSON assets Vault
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
do {
assets = try images.map { try FileVault.shared.writeJPEG($0) }
try await AIRuntime.shared.prepareVL()
} catch {
throw CaptureError.writeAssetFailed
throw CaptureError.modelNotReady
}
// 2. VL
try await AIRuntime.shared.prepareVL()
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
let raw: String
do {
raw = try await AIRuntime.shared.analyzeReport(
imageURLs: urls,
prompt: VLPrompts.reportExtraction
prompt: VLPrompts.reportExtraction()
)
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
// 3. JSON (: / )
do {
let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count)
return (parsed, assets)
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
} catch let CaptureError.parseFailed(msg) {
throw CaptureError.parseFailed(msg)
} catch {
@@ -136,7 +133,7 @@ actor CaptureService {
}
return ParsedReport(
title: title.isEmpty ? "拍摄识别" : title,
title: title.isEmpty ? String(appLoc: "拍摄识别") : title,
typeRaw: typeRaw,
reportDate: reportDate,
institution: institution,
@@ -216,3 +213,53 @@ actor CaptureService {
return .init(name: name, value: value, unit: unit, range: range, status: status)
}
}
// MARK: - Report CaptureService (MainActor )
//
// CaptureService actor, Report(@Model Sendable)
// C2UI :
// ```
// let assets = report.savedAssets
// let parsed = try await CaptureService.shared.reanalyze(assets: assets)
// report.applyReanalyzed(parsed, in: ctx)
// ```
extension Report {
/// Asset SavedAsset, CaptureService.reanalyze
var savedAssets: [FileVault.SavedAsset] {
assets.map { .init(relativePath: $0.relativePath, bytes: $0.bytes) }
}
/// VL Report
/// - indicators:,(cascade delete )
/// - summary / institution:,
/// MainActor / SwiftData
@MainActor
func applyReanalyzed(_ parsed: ParsedReport, in ctx: ModelContext) {
if !parsed.summary.isEmpty {
self.summary = parsed.summary
}
if !parsed.institution.isEmpty {
self.institution = parsed.institution
}
// indicators (cascade )
for old in indicators {
ctx.delete(old)
}
indicators.removeAll()
// indicators
for p in parsed.indicators {
let i = Indicator(
name: p.name,
value: p.value,
unit: p.unit,
range: p.range,
status: p.status,
capturedAt: reportDate,
report: self
)
ctx.insert(i)
}
try? ctx.save()
}
}

View File

@@ -0,0 +1,101 @@
import Foundation
/// AI : LLM 3-4
///
/// HealthExportService ,(< 400 token),
/// await
///
/// :DiaryQuickSheet
@MainActor
struct DiaryAssistService {
static let shared = DiaryAssistService()
private init() {}
/// fill ,
/// `dim` ( `DiaryAssistPrompts.dimensions`),
/// `adopted` UI ;`round` UI append ,
struct Question: Identifiable, Hashable {
let id: UUID
let q: String
let fill: String
let dim: String
var adopted: Bool
var round: Int
init(id: UUID = UUID(),
q: String,
fill: String,
dim: String = "",
adopted: Bool = false,
round: Int = 0) {
self.id = id
self.q = q
self.fill = fill
self.dim = dim
self.adopted = adopted
self.round = round
}
}
enum AssistError: Error, LocalizedError {
case modelNotReady
case empty
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好")
case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试")
case .parseFailed(let m): return String(appLoc: "结果解析失败:\(m)")
}
}
}
/// 3-4
/// - coveredDimensions: ,( question.dim),
/// prompt
/// : AIRuntime actor , Capture / Export GPU
func suggest(content: String,
coveredDimensions: [String] = []) async throws -> (questions: [Question], decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
var collected = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
// 1. <think>...</think>( HealthExportService )
let stripped = HealthExportService.stripThinkBlocks(collected)
// 2. JSON( CaptureService.extractJSONObject)
let jsonStr = CaptureService.extractJSONObject(from: stripped)
guard let data = jsonStr.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let dict = obj as? [String: Any] else {
throw AssistError.parseFailed("非 JSON 输出")
}
guard let rawQuestions = dict["questions"] as? [[String: Any]] else {
throw AssistError.parseFailed("缺少 questions 字段")
}
let questions = rawQuestions.compactMap { d -> Question? in
guard let q = (d["q"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
return nil
}
let fill = (d["fill"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let dim = (d["dim"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return Question(q: q, fill: fill, dim: dim)
}
guard !questions.isEmpty else { throw AssistError.empty }
return (Array(questions.prefix(4)), lastRate)
}
}

View File

@@ -0,0 +1,460 @@
import Foundation
import SwiftData
///
///
/// ( spec §6):
/// prepare extractingIntent retrieving generating completed
///
/// 线:
/// - UI AI(§3.1)
/// - LLM `AIRuntime.shared` actor , CaptureService (§3.1)
/// - JSON 30 + ,(§3.2 / spec §9)
/// - (§10)
@MainActor
struct HealthExportService {
static let shared = HealthExportService()
private init() {}
// MARK: - Public types
enum Phase: String, Sendable {
case extractingIntent
case retrieving
case generating
case completed
var label: String {
switch self {
case .extractingIntent: return String(appLoc: "理解意图")
case .retrieving: return String(appLoc: "检索数据")
case .generating: return String(appLoc: "撰写报告")
case .completed: return String(appLoc: "已完成")
}
}
}
enum Event {
case phaseChanged(Phase)
case token(TokenChunk)
case completed(persistentID: PersistentIdentifier)
// .failed stream throw, Event
}
enum ServiceError: Error, LocalizedError {
case modelNotReady
case generationFailed(String)
case cancelled
var errorDescription: String? {
switch self {
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好,请先到「我的 · 模型管理」下载。")
case .generationFailed(let m): return String(appLoc: "生成失败:\(m)")
case .cancelled: return String(appLoc: "已取消")
}
}
}
// MARK: - Entry point
/// ;UI sheet stream Service
/// MainActor
func export(prompt: String,
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
// ()
do {
try await AIRuntime.shared.prepare()
} catch {
throw ServiceError.modelNotReady
}
// Phase 1:
continuation.yield(.phaseChanged(.extractingIntent))
let intent = await Self.extractIntent(userPrompt: prompt)
try Task.checkCancellation()
// Phase 2:
continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
try Task.checkCancellation()
// Phase 3:
continuation.yield(.phaseChanged(.generating))
let dataJSON = Self.serializeData(snapshot: snapshot)
let genPrompt = HealthExportPrompts.reportGeneration(
userPrompt: prompt,
intentLabelCN: intent.labelCN,
dataJSON: dataJSON
)
// <think>...</think>
// Prompt Qwen3 `/no_think`, thinking
// + chunk + diff yield:
// - thinking ,UI generated
// - </think> ,
var rawAccum = ""
var generated = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(
prompt: genPrompt,
maxTokens: 1024
)
for try await chunk in stream {
try Task.checkCancellation()
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
rawAccum += chunk.text
let clean = Self.stripThinkBlocks(rawAccum)
if clean.count > generated.count, clean.hasPrefix(generated) {
let delta = String(clean.dropFirst(generated.count))
generated = clean
continuation.yield(.token(TokenChunk(
text: delta,
decodeRate: chunk.decodeRate
)))
} else if clean != generated {
// :() UI 退,
// generated = clean yield(退)
generated = clean
}
}
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ServiceError.generationFailed("模型未输出任何内容")
}
// Phase 4:
let export = HealthExport(
prompt: prompt,
content: generated,
referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
referencedReportIDs: snapshot.reports.map { Self.idString($0.persistentModelID) },
referencedSymptomIDs: snapshot.symptoms.map { Self.idString($0.persistentModelID) },
referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) },
inferredTimeFromDate: snapshot.fromDate,
inferredTimeToDate: snapshot.toDate,
inferredIntent: intent.intent,
decodeRate: lastRate
)
modelContext.insert(export)
do { try modelContext.save() } catch {
// UI ;(W6 telemetry)
print("[HealthExportService] save failed: \(error)")
}
continuation.yield(.phaseChanged(.completed))
continuation.yield(.completed(persistentID: export.persistentModelID))
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: ServiceError.cancelled)
} catch let e as ServiceError {
continuation.finish(throwing: e)
} catch {
continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - Phase 1: intent extraction
struct Intent: Sendable {
var timeRangeDays: Int
var keywords: [String]
var symptomKeywords: [String]
var intent: String
var labelCN: String
/// : 30 +
static let fallback = Intent(
timeRangeDays: 30,
keywords: [],
symptomKeywords: [],
intent: "general_review",
labelCN: "近期健康摘要"
)
}
/// LLM JSON, `Intent.fallback`
///
private static func extractIntent(userPrompt: String) async -> Intent {
let prompt = HealthExportPrompts.intentExtraction(userPrompt: userPrompt)
var collected = ""
do {
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200)
for try await chunk in stream {
collected += chunk.text
}
} catch {
return .fallback
}
return parseIntent(collected) ?? .fallback
}
/// JSON: `{}`,
/// (internal)
static func parseIntent(_ raw: String) -> Intent? {
let jsonString = CaptureService.extractJSONObject(from: raw)
guard let data = jsonString.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let dict = obj as? [String: Any] else {
return nil
}
let days = clampDays(dict["time_range_days"])
let keywords = stringArray(dict["keywords"])
let symptomKeywords = stringArray(dict["symptom_keywords"])
let intent = (dict["intent"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "general_review"
let labelCN = (dict["intent_label_cn"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "近期健康摘要"
return Intent(
timeRangeDays: days,
keywords: keywords,
symptomKeywords: symptomKeywords,
intent: intent.isEmpty ? "general_review" : intent,
labelCN: labelCN.isEmpty ? "近期健康摘要" : labelCN
)
}
private static func clampDays(_ raw: Any?) -> Int {
if let n = raw as? Int { return max(1, min(365, n)) }
if let n = raw as? Double { return max(1, min(365, Int(n))) }
if let s = raw as? String, let n = Int(s) { return max(1, min(365, n)) }
return 30
}
private static func stringArray(_ raw: Any?) -> [String] {
guard let arr = raw as? [Any] else { return [] }
return arr.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
// MARK: - Phase 2: retrieve
struct Snapshot {
var fromDate: Date
var toDate: Date
var indicators: [Indicator]
var symptoms: [Symptom]
var reports: [Report]
var diaries: [DiaryEntry]
var profile: UserProfile
}
/// SwiftData @MainActor
private static func retrieve(intent: Intent, ctx: ModelContext) -> Snapshot {
let toDate = Date()
let fromDate = Calendar.current.date(
byAdding: .day, value: -intent.timeRangeDays, to: toDate
) ?? toDate.addingTimeInterval(-30 * 86400)
// Indicators( + )
let indDesc = FetchDescriptor<Indicator>(
predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
)
var indicators = (try? ctx.fetch(indDesc)) ?? []
if !intent.keywords.isEmpty {
let filtered = indicators.filter { ind in
intent.keywords.contains { kw in
ind.name.localizedCaseInsensitiveContains(kw)
}
}
// ,()
let abnormal = indicators.filter { $0.status != .normal }
let combined = (filtered + abnormal).reduce(into: [Indicator]()) { acc, x in
if !acc.contains(where: { $0.persistentModelID == x.persistentModelID }) {
acc.append(x)
}
}
indicators = combined.isEmpty ? indicators : combined
}
indicators = Array(indicators.prefix(20))
// Symptoms()
let symptomDesc = FetchDescriptor<Symptom>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)
let allSymptoms = (try? ctx.fetch(symptomDesc)) ?? []
let symptoms = Array(
allSymptoms.filter { sym in
let overlapsStart = sym.startedAt <= toDate
let overlapsEnd = (sym.endedAt ?? Date.distantFuture) >= fromDate
return overlapsStart && overlapsEnd
}.prefix(10)
)
// Reports()
let reportDesc = FetchDescriptor<Report>(
predicate: #Predicate { $0.reportDate >= fromDate && $0.reportDate <= toDate },
sortBy: [SortDescriptor(\.reportDate, order: .reverse)]
)
let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))
// Diary(: symptom_keyword , prompt)
let diaries: [DiaryEntry]
if intent.symptomKeywords.isEmpty {
diaries = []
} else {
let diaryDesc = FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let all = (try? ctx.fetch(diaryDesc)) ?? []
diaries = Array(
all.filter { d in
intent.symptomKeywords.contains { kw in
d.content.localizedCaseInsensitiveContains(kw)
}
}.prefix(5)
)
}
// Profile()
let profile = UserProfileStore.loadOrCreate(in: ctx)
return Snapshot(
fromDate: fromDate,
toDate: toDate,
indicators: indicators,
symptoms: symptoms,
reports: reports,
diaries: diaries,
profile: profile
)
}
// MARK: - Phase 3: serialize data for prompt
/// Snapshot LLM JSON
/// Codable prompt key,
static func serializeData(snapshot: Snapshot) -> String {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = "yyyy-MM-dd"
let profile = snapshot.profile
var root: [String: Any] = [:]
// profile
var profDict: [String: Any] = [:]
if let age = profile.age { profDict["age"] = age }
let sexLabel = profile.sex.label
if profile.sex != .undisclosed { profDict["sex"] = sexLabel }
if let h = profile.heightCM { profDict["height_cm"] = h }
if let w = profile.weightKG {
profDict["weight_kg"] = w.truncatingRemainder(dividingBy: 1) == 0
? Int(w) : Double(round(w * 10) / 10)
}
if !profile.bloodTypeRaw.isEmpty { profDict["blood_type"] = profile.bloodTypeRaw }
if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory }
if !profile.currentMedications.isEmpty { profDict["current_meds"] = profile.currentMedications }
root["profile"] = profDict
// symptoms
root["symptoms"] = snapshot.symptoms.map { s -> [String: Any] in
var d: [String: Any] = [
"name": s.name,
"started": df.string(from: s.startedAt),
"severity": s.severity,
"ongoing": s.isOngoing
]
if let ended = s.endedAt { d["ended"] = df.string(from: ended) }
if let note = s.note, !note.isEmpty { d["note"] = note }
return d
}
// indicators
root["indicators"] = snapshot.indicators.map { i -> [String: Any] in
[
"name": i.name,
"value": i.value,
"unit": i.unit,
"range": i.range,
"status": i.status.rawValue,
"date": df.string(from: i.capturedAt)
]
}
// reports
root["reports"] = snapshot.reports.map { r -> [String: Any] in
var d: [String: Any] = [
"title": r.title,
"type": r.type.label,
"date": df.string(from: r.reportDate)
]
if let inst = r.institution, !inst.isEmpty { d["institution"] = inst }
if let sum = r.summary, !sum.isEmpty { d["summary"] = sum }
return d
}
// diaries
root["diaries"] = snapshot.diaries.map { d -> [String: Any] in
let excerpt = String(d.content.prefix(80))
return [
"date": df.string(from: d.createdAt),
"excerpt": excerpt
]
}
// LLM
root["time_window"] = [
"from": df.string(from: snapshot.fromDate),
"to": df.string(from: snapshot.toDate)
]
guard let data = try? JSONSerialization.data(
withJSONObject: root,
options: [.prettyPrinted, .sortedKeys]
),
let str = String(data: data, encoding: .utf8) else {
return "{}"
}
return str
}
// MARK: - Helpers
/// SwiftData persistentModelID
/// W3 ,()
private static func idString(_ id: PersistentIdentifier) -> String {
String(describing: id)
}
// MARK: - <think>
/// ,
/// + + diff yield,:
/// - `<think>...</think>` ( think )
/// - `<think>...`() ,
/// - Qwen3 `</think>` thinking
/// - trim, `## `
static func stripThinkBlocks(_ raw: String) -> String {
var s = raw
// 1. <think>...</think>( think )
while let openR = s.range(of: "<think>"),
let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
s.removeSubrange(openR.lowerBound..<closeR.upperBound)
}
// 2. :,
if let openR = s.range(of: "<think>") {
s = String(s[..<openR.lowerBound])
}
// 3. (Qwen3 ):
if let closeR = s.range(of: "</think>") {
s = String(s[closeR.upperBound...])
}
// 4. trim
while let first = s.first, first.isWhitespace {
s.removeFirst()
}
return s
}
}

View File

@@ -132,7 +132,7 @@ final class ModelDownloadService {
states[kind] = DownloadState(phase: .ready, receivedBytes: total,
totalBytes: total, bytesPerSecond: 0)
} else {
states[kind] = DownloadState(phase: .failed(message ?? "下载失败"),
states[kind] = DownloadState(phase: .failed(message ?? String(appLoc: "下载失败")),
receivedBytes: completedBytes(for: kind),
totalBytes: total, bytesPerSecond: 0)
}

View File

@@ -55,8 +55,8 @@ enum ReminderService {
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = "该测\(reminder.displayName)"
content.body = "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"
content.title = String(appLoc: "该测\(reminder.displayName)")
content.body = String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次")
content.sound = .default
content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)"