feat: 添加拍药盒功能和语音直达入口

- 实现拍药盒扫描流程,支持本地OCR识别药品信息
- 在日记页面添加拍药盒和记症状的三选一入口
- 优化按钮点击区域,确保符合苹果HIG最小命中区标准
- 添加用药记录到时间线的独立分类显示
- 实现长按+号语音直达功能,支持语音意图分类跳转
- 更新项目配置文件,启用代码分析和死代码剥离选项
- 增加多项本地化字符串支持新功能
```
This commit is contained in:
link2026
2026-06-13 09:16:25 +08:00
parent f58d6064ba
commit 6c6a950140
30 changed files with 1856 additions and 64 deletions

View File

@@ -0,0 +1,96 @@
import Foundation
/// + rawValue IntentPrompts token
enum VoiceIntent: String, CaseIterable, Sendable {
case diary, medication, symptom, indicator, archive, export, reminder
}
/// :LLM(MNN/SME2 ),6 退(§3.2)
/// nil,UI /
/// , OCRService enum ;UI AIRuntime(§3.1)
/// nonisolated: MainActor, + await,线()
nonisolated enum VoiceIntentService {
static func classify(_ utterance: String) async -> VoiceIntent? {
let text = utterance.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
// ,:6s
if let intent = try? await withTimeout(seconds: 6, operation: {
try await classifyWithLLM(text)
}) {
return intent
}
return keywordMatch(text)
}
// MARK: - LLM
private static func classifyWithLLM(_ text: String) async throws -> VoiceIntent {
try await AIRuntime.shared.prepare()
let stream = await AIRuntime.shared.generate(prompt: IntentPrompts.classify(text),
maxTokens: 48)
var collected = ""
for try await chunk in stream {
collected += chunk.text
}
guard let intent = parseIntent(from: collected) else {
throw CaptureError.parseFailed("intent")
}
return intent
}
/// `{"intent":""}`:think "unknown"/ nil
static func parseIntent(from raw: String) -> VoiceIntent? {
let cleaned = CaptureService.stripThink(raw)
let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: cleaned))
if let data = jsonString.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = obj["intent"] as? String {
return VoiceIntent(rawValue: token.trimmingCharacters(in: .whitespaces).lowercased())
}
// :(diary / symptom )
let bare = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`。."))
.lowercased()
return VoiceIntent(rawValue: bare)
}
// MARK: - 退(,)
/// : reminder, reminder
static func keywordMatch(_ text: String) -> VoiceIntent? {
let t = text.lowercased()
let rules: [(VoiceIntent, [String])] = [
(.reminder, ["提醒", "别忘", "闹钟"]),
(.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
(.archive, ["报告", "化验单", "体检", "归档"]),
(.export, ["身体档案", "给医生", "健康总结", "导出"]),
(.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
"高压", "低压"]),
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "", "",
"咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]),
(.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]),
]
for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) {
return intent
}
return nil
}
}
/// :operation sleep , CancellationError
nonisolated private func withTimeout<T: Sendable>(
seconds: Double,
operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw CancellationError()
}
guard let result = try await group.next() else { throw CancellationError() }
group.cancelAll()
return result
}
}