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,43 @@
import Foundation
/// + prompt: LLM(MNN/SME2 )
/// : JSON `{"intent":""}`;/ VoiceIntentService 退(§3.2)
nonisolated enum IntentPrompts {
static func classify(_ utterance: String) -> String {
classifyTemplate.replacingOccurrences(of: "{{TEXT}}", with: String(utterance.prefix(120)))
}
private static let classifyTemplate: String = #"""
你是健康 App 的语音意图分类器。用户长按「新建」按钮说了一句话,判断 ta 想打开哪个功能。
请只输出一段合法 JSON,格式 {"intent":"<>"},不要解释、不要 markdown 围栏、不要任何前后缀文字。
分类(只能选下面其中一个):
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
- "medication" 记录用药、拍药盒、吃了什么药
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…)
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
- "archive" 归档整份体检报告/化验单(拍报告存档)
- "export" 生成给医生看的身体档案/健康总结
- "reminder" 设置周期提醒
- "unknown" 无法判断
规则:
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"
- 既像日记又提到具体数值时,以数值为准 → "indicator"
示例:
",12885" → {"intent":"indicator"}
"," → {"intent":"symptom"}
"," → {"intent":"medication"}
"," → {"intent":"diary"}
"" → {"intent":"archive"}
"" → {"intent":"reminder"}
"" → {"intent":"export"}
现在判断下面这句话,只输出 JSON。/no_think
用户的话:{{TEXT}}
"""#
}

View File

@@ -0,0 +1,51 @@
import Foundation
/// prompt:Vision OCR //,
/// LLM(Qwen,MNN/SME2 ) + +
/// : JSON; UI 退(§3.2 退线)
/// :"",/(§1 )
nonisolated enum MedicationPrompts {
static func medicationsFromText(_ ocrText: String) -> String {
medicationsFromTextTemplate
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 1200))
}
private static let medicationsFromTextTemplate: String = #"""
你是药品包装识别助手。下面是对一张药盒、药品说明书或处方单做 OCR 得到的纯文本,可能有错字、换行混乱或无关噪声。
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
JSON schema(严格):
{
"medications": [
{
"name": string, // 药品通用名或商品名,如 ""
"strength": string, // 规格,如 "80mg""0.5g×24";识别不出填 ""
"usage": string // 用法用量,如 ",";包装上没有就填 ""
}
]
}
规则:
- 只提取药品本身;""批准文号、生产厂家、批号、有效期、条形码一律忽略。
- 一张药盒通常只有 1 种药;处方单可能有多种,都要提取。
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
- 同一药品只输出一次。
示例 1(药盒):
输入 OCR 文本: 缬沙坦胶囊 80mg×7粒 国药准字H20103521 XX药业有限公司
输出:
{"medications":[{"name":"","strength":"80mg×7","usage":""}]}
示例 2(说明书含用法):
输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用
输出:
{"medications":[{"name":"","strength":"0.5g×30","usage":",1,2,"}]}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:
{{OCR_TEXT}}
"""#
}

View File

@@ -32,8 +32,14 @@ struct PhotoPickerSheet: View {
.clipShape(Capsule())
}
Button("取消", action: onCancel)
.foregroundStyle(Tj.Palette.text3)
Button(action: onCancel) {
Text("取消")
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 24)
.frame(minHeight: 44) // HIG
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if loading {
ProgressView().tint(Tj.Palette.ink)

View File

@@ -362,10 +362,16 @@ private struct AnalyzingView: View {
.foregroundStyle(Tj.Palette.amber)
}
}
Button("取消识别 · 改为手动录入", action: onCancel)
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Button(action: onCancel) {
Text("取消识别 · 改为手动录入")
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 20)
.frame(minHeight: 44) // HIG
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.top, 4)
Spacer()
}
.padding(.horizontal, 20)

View File

@@ -11,6 +11,10 @@ struct DiaryQuickSheet: View {
@State private var content: String = ""
@State private var createdAt: Date = .now
/// :,tag
@State private var showMedicationScan = false
/// : SymptomStartSheet(/,)
@State private var showSymptomStart = false
/// AI
enum AssistPhase {
@@ -92,6 +96,24 @@ struct DiaryQuickSheet: View {
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 10)
// :()/ ()/ (SymptomStartSheet)
HStack(spacing: 10) {
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true
}
modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别用药"), active: false) {
showMedicationScan = true
}
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
subtitle: String(appLoc: "持续追踪"), active: false) {
showSymptomStart = true
}
}
.padding(.horizontal, 20)
.padding(.bottom, 14)
ScrollViewReader { proxy in
@@ -228,6 +250,20 @@ struct DiaryQuickSheet: View {
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow(
onSave: { entries in
// :(线)+ ·
MedicationArchiver.archive(entries: entries, in: ctx)
dismiss()
},
onClose: { showMedicationScan = false }
)
}
.sheet(isPresented: $showSymptomStart) {
// sheet:/;,
SymptomStartSheet()
}
.onDisappear {
suggestTask?.cancel()
voiceFlowTask?.cancel()
@@ -555,6 +591,41 @@ struct DiaryQuickSheet: View {
.foregroundStyle(Tj.Palette.text2)
}
/// ( / / )active
/// : iPhone
private func modeCard(icon: String, title: String, subtitle: String,
active: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 5) {
Image(systemName: icon)
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(active ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 28, height: 28)
.background(Circle().fill(active ? Tj.Palette.ink : Tj.Palette.sand2))
Text(title)
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(subtitle)
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(active ? Tj.Palette.ink : Tj.Palette.line,
lineWidth: active ? 1.5 : 1)
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK:
private func startVoice() {

View File

@@ -0,0 +1,285 @@
import SwiftUI
import SwiftData
import UIKit
/// :/ Vision OCR LLM
/// :+ · · · ·
/// `MedicationArchiver`:(线)+
/// ,/(§1)
///
/// ( QuickRegionCaptureFlow ):
/// ```
/// idle(/) recognizing(OCR + LLM) confirm() onSave
/// / confirm( + ,)
/// ```
struct MedicationScanFlow: View {
/// (, " 80mg · ")
let onSave: ([String]) -> Void
let onClose: () -> Void
@State private var phase: Phase = .idle
/// :,
@State private var recognitionTask: Task<Void, Never>?
enum Phase {
case idle
case recognizing(image: UIImage)
case confirm(items: [EditableMedication], warning: String?)
}
struct EditableMedication: Identifiable {
let id = UUID()
var name: String
var strength: String
var usage: String
var include: Bool = true
}
var body: some View {
content
.background(Tj.Palette.sand.ignoresSafeArea())
}
@ViewBuilder
private var content: some View {
switch phase {
case .idle:
// ignoresSafeArea:,
captureEntry
case .recognizing(let image):
recognizingView(image: image)
case .confirm(let items, let warning):
NavigationStack {
MedicationConfirmView(
items: items,
warning: warning,
onSave: { saveItems($0) },
onRetake: { phase = .idle }
)
.navigationTitle("核对药品")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { onClose() }
.foregroundStyle(Tj.Palette.text)
}
}
}
}
}
// MARK: - :()/ ()
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { images in
if let first = images.first { startRecognition(first) } else { onClose() }
},
onCancel: onClose
)
#else
SingleShotCameraView(
onCapture: { startRecognition($0) },
onCancel: onClose
)
#endif
}
private func recognizingView(image: UIImage) -> some View {
VStack(spacing: 18) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 320)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.padding(.horizontal, 24)
ProgressView().tint(Tj.Palette.ink)
Text("正在本地识别药品…")
.font(.tjScaled(14))
.foregroundStyle(Tj.Palette.text2)
Text("照片与文字均不离开设备")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 退,(§3.2 )
.overlay(alignment: .topLeading) {
Button {
recognitionTask?.cancel()
onClose()
} label: {
Text("取消")
.font(.tjScaled( 16, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.padding(.horizontal, 18)
.frame(minHeight: 44)
.background(Capsule().fill(Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.padding(.leading, 16)
.padding(.top, 8)
}
}
// MARK: - ( OCR LLM )
private func startRecognition(_ image: UIImage) {
phase = .recognizing(image: image)
recognitionTask = Task {
let (items, warning) = await recognize(image)
guard !Task.isCancelled else { return } // : phase
await MainActor.run {
// :(§3.2 退线)
if items.isEmpty {
phase = .confirm(items: [EditableMedication(name: "", strength: "", usage: "")],
warning: warning ?? String(appLoc: "没读出药品,可以手动填写"))
} else {
phase = .confirm(items: items, warning: warning)
}
}
}
}
private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) {
do {
let text = try await OCRService.recognizeText(in: image)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return ([], String(appLoc: "没识别到文字,拍清楚一点再试"))
}
let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: trimmed)
let items = parsed.map {
EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage)
}
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
} catch CaptureError.modelNotReady {
return ([], String(appLoc: "AI 模型未就绪,可以手动填写"))
} catch let CaptureError.parseFailed(msg) {
return ([], String(appLoc: "解析失败:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
return ([], String(appLoc: "识别失败:\(msg)"))
} catch {
return ([], String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
// MARK: -
private func saveItems(_ items: [EditableMedication]) {
let entries = items
.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty }
.map {
ParsedMedication(name: $0.name, strength: $0.strength, usage: $0.usage).entryText
}
onSave(entries)
onClose()
}
}
// MARK: - (MainActor,SwiftData View ctx ,§3.1)
/// ,:
/// 1. tag DiaryEntry 线
/// 2. UserProfile.currentMedications() AI / prompt
@MainActor
enum MedicationArchiver {
static func archive(entries: [String], in ctx: ModelContext) {
guard !entries.isEmpty else { return }
let diary = DiaryEntry(content: entries.joined(separator: "\n"),
tags: [DiaryEntry.medicationTag])
ctx.insert(diary)
let profile = UserProfileStore.loadOrCreate(in: ctx)
for entry in entries where !profile.currentMedications.contains(entry) {
profile.currentMedications.append(entry)
}
profile.updatedAt = .now
try? ctx.save()
}
}
// MARK: -
private struct MedicationConfirmView: View {
@State var items: [MedicationScanFlow.EditableMedication]
let warning: String?
let onSave: ([MedicationScanFlow.EditableMedication]) -> Void
let onRetake: () -> Void
private var canSave: Bool {
items.contains {
$0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
}
}
var body: some View {
VStack(spacing: 0) {
Form {
if let warning {
Section {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.tjScaled(13))
.foregroundStyle(Tj.Palette.amber)
}
}
ForEach($items) { $item in
Section {
HStack {
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
.foregroundStyle(Tj.Palette.text)
Toggle("", isOn: $item.include)
.labelsHidden()
.tint(Tj.Palette.ink)
}
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength)
.foregroundStyle(Tj.Palette.text2)
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage)
.foregroundStyle(Tj.Palette.text2)
}
}
Section {
Button {
items.append(.init(name: "", strength: "", usage: ""))
} label: {
Label("再加一种", systemImage: "plus.circle")
.foregroundStyle(Tj.Palette.ink)
}
Button {
onRetake()
} label: {
Label("重拍", systemImage: "camera")
.foregroundStyle(Tj.Palette.ink)
}
} footer: {
Text("将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。")
}
}
.scrollContentBackground(.hidden)
Button {
onSave(items)
} label: {
Text("保存用药记录")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(!canSave)
.opacity(canSave ? 1 : 0.4)
.padding(.horizontal, 18)
.padding(.bottom, 12)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
}
#Preview {
MedicationScanFlow(onSave: { print($0) }, onClose: {})
}

View File

@@ -38,6 +38,7 @@ private struct ProfileEditForm: View {
@State private var healthImportDraft: HealthProfileImportDraft?
@State private var healthImportError: String?
@State private var isImportingHealthProfile = false
@State private var showMedicationScan = false
var body: some View {
Form {
@@ -88,7 +89,8 @@ private struct ProfileEditForm: View {
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
items: $profile.familyHistory)
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
items: $profile.currentMedications)
items: $profile.currentMedications,
onScan: { showMedicationScan = true })
}
.navigationTitle("个人资料")
.navigationBarTitleDisplayMode(.inline)
@@ -98,6 +100,16 @@ private struct ProfileEditForm: View {
profile.updatedAt = .now
try? ctx.save()
}
.fullScreenCover(isPresented: $showMedicationScan) {
// OCR + LLM :
// (线)+ ()
MedicationScanFlow(
onSave: { entries in
MedicationArchiver.archive(entries: entries, in: ctx)
},
onClose: { showMedicationScan = false }
)
}
.sheet(item: $healthImportDraft) { draft in
HealthProfileImportPreviewSheet(
draft: draft,
@@ -456,10 +468,27 @@ private struct StringListSection: View {
let title: String
let placeholder: String
@Binding var items: [String]
/// nil ()
var onScan: (() -> Void)? = nil
@State private var newInput = ""
var body: some View {
Section(title) {
if let onScan {
Button(action: onScan) {
HStack(spacing: 10) {
Image(systemName: "camera.viewfinder")
.foregroundStyle(Tj.Palette.ink)
VStack(alignment: .leading, spacing: 2) {
Text("拍药盒自动识别")
.foregroundStyle(Tj.Palette.text)
Text("拍药盒或说明书,本地识别药名与规格")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
}
}
ForEach(items, id: \.self) { item in
HStack {
Text(item)

View File

@@ -32,8 +32,9 @@ struct QuickRegionCaptureFlow: View {
private var content: some View {
switch phase {
case .idle:
// ignoresSafeArea:/,
// ,
captureEntry
.ignoresSafeArea()
case .adjust(let image):
RegionAdjustView(
@@ -45,7 +46,6 @@ struct QuickRegionCaptureFlow: View {
onRetake: { phase = .idle },
onCancel: { onClose() }
)
.ignoresSafeArea()
case .confirm(let image, let items, let warning):
NavigationStack {

View File

@@ -50,7 +50,11 @@ struct RegionAdjustView: View {
Text("取消")
.font(.tjScaled( 16, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.frame(minWidth: 60, minHeight: 44) // HIG ,
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer()
Text("框住异常指标")
.font(.tjScaled( 16, weight: .semibold))
@@ -63,10 +67,14 @@ struct RegionAdjustView: View {
Text("重拍")
.font(.tjScaled( 16, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.frame(minWidth: 60, minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black)
}

View File

@@ -49,13 +49,15 @@ struct SingleShotCameraView: View {
Text("取消")
.font(.tjScaled( 16, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.padding(.horizontal, 18)
.frame(minHeight: 44) // HIG
.background(Capsule().fill(.black.opacity(0.35)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
Spacer()
}
.padding(.horizontal, 18)
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()

View File

@@ -5,8 +5,15 @@ enum RecordKind: String, Identifiable, CaseIterable {
var id: String { rawValue }
/// RecordSheet () enum ,
/// :`.quick`() `.indicator`(),
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
/// :`.quick`() `.indicator`();
/// `.symptom`() `.diary`(),
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive]
/// pill( subtitle,"/")
/// :,( ProfileEditView presets )
static var diaryFeaturePills: [String] {
[String(appLoc: "写日记"), String(appLoc: "拍药盒"), String(appLoc: "记症状")]
}
var title: String {
switch self {
@@ -25,7 +32,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .indicator: return String(appLoc: "手动填写,或拍照自动识别")
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
}
@@ -93,13 +100,27 @@ struct RecordSheet: View {
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 3) {
Text(kind.title)
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(kind.subtitle)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
if kind == .diary {
// :/, pill
HStack(spacing: 5) {
ForEach(RecordKind.diaryFeaturePills, id: \.self) { pill in
Text(pill)
.font(.tjScaled( 10, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Capsule().fill(Tj.Palette.sand2))
}
}
} else {
Text(kind.subtitle)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
Spacer()
Image(systemName: "chevron.right")
@@ -111,6 +132,17 @@ struct RecordSheet: View {
}
.buttonStyle(.plain)
}
// : + ,
HStack(spacing: 5) {
Image(systemName: "mic.fill")
.font(.tjScaled( 10))
Text("下次试试长按 + ,直接说出想记的内容")
.font(.tjScaled( 11))
}
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity)
.padding(.top, 6)
}
.padding(.bottom, 22)
}

View File

@@ -0,0 +1,276 @@
import SwiftUI
import UIKit
/// + : (SpeechDictationService)
/// LLM (VoiceIntentService) RootView
///
/// :
/// ```
/// requesting() recording() classifying onResolve(intent)
/// denied / failed( / )
/// ```
/// : requiresOnDeviceRecognition, LLM
struct VoiceCommandSheet: View {
/// :RootView sheet
let onResolve: (VoiceIntent) -> Void
/// :(RecordSheet)
let onOpenMenu: () -> Void
@Environment(\.dismiss) private var dismiss
enum Phase: Equatable {
case requesting
case denied
case recording
case classifying
case failed(message: String)
}
@State private var phase: Phase = .requesting
@State private var transcript = ""
@State private var seconds = 0
/// @State ( DiaryQuickSheet ,)
@State private var dictation = SpeechDictationService()
@State private var ticker: Task<Void, Never>?
/// 20s :,
private let maxSeconds = 20
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 16)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("说出想记的内容")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("比如:记一下血压 / 我头疼 / 拍个药盒")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("全程本机")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
content
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal, 20)
buttons
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.fraction(0.5)])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.task { await begin() }
.onDisappear {
ticker?.cancel()
dictation.abort()
}
}
// MARK: -
@ViewBuilder
private var content: some View {
switch phase {
case .requesting:
ProgressView().tint(Tj.Palette.ink)
.frame(maxWidth: .infinity)
.padding(.top, 30)
case .denied:
VStack(spacing: 10) {
Image(systemName: "mic.slash")
.font(.tjScaled( 30))
.foregroundStyle(Tj.Palette.text3)
Text("需要麦克风与语音识别权限")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("语音和文字都只在本机处理,不会上传。")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Button("前往设置") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
case .recording:
VStack(spacing: 14) {
HStack(spacing: 8) {
Circle()
.fill(Tj.Palette.brick)
.frame(width: 8, height: 8)
Text("正在听 · \(seconds)s")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
}
transcriptBox(placeholder: String(appLoc: "请开口说话…"))
}
case .classifying:
VStack(spacing: 14) {
HStack(spacing: 8) {
ProgressView().tint(Tj.Palette.ink)
Text("正在理解…")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
}
transcriptBox(placeholder: "")
}
case .failed(let message):
VStack(spacing: 10) {
Image(systemName: "questionmark.bubble")
.font(.tjScaled( 28))
.foregroundStyle(Tj.Palette.text3)
Text(message)
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
.multilineTextAlignment(.center)
if !transcript.isEmpty {
Text("\(transcript)")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 12)
}
}
private func transcriptBox(placeholder: String) -> some View {
ScrollView(showsIndicators: false) {
Text(transcript.isEmpty ? placeholder : transcript)
.font(.tjScaled( 15))
.foregroundStyle(transcript.isEmpty ? Tj.Palette.text3 : Tj.Palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minHeight: 64, maxHeight: 110)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
// MARK: -
@ViewBuilder
private var buttons: some View {
switch phase {
case .recording:
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button("说完了") { finishRecording() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
}
case .failed:
HStack(spacing: 12) {
Button("打开新建菜单") { onOpenMenu() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 14, horizontalPadding: 14))
Button("再说一次") { Task { await begin() } }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14, horizontalPadding: 18))
}
case .denied:
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
case .requesting, .classifying:
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
}
}
// MARK: -
private func begin() async {
ticker?.cancel()
transcript = ""
seconds = 0
guard SpeechDictationService.isAvailable else {
phase = .failed(message: String(appLoc: "本机不支持端侧语音识别,试试下面的新建菜单"))
return
}
phase = .requesting
guard await dictation.requestAuthorization() else {
phase = .denied
return
}
do {
try dictation.start { transcript = $0 }
phase = .recording
startTicker()
} catch {
phase = .failed(message: error.localizedDescription)
}
}
private func startTicker() {
ticker = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard phase == .recording else { return }
seconds += 1
if seconds >= maxSeconds {
finishRecording()
return
}
}
}
}
private func finishRecording() {
guard phase == .recording else { return }
ticker?.cancel()
phase = .classifying
Task {
let text = await dictation.stop()
transcript = text
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
phase = .failed(message: String(appLoc: "没听到内容,再试一次?"))
return
}
if let intent = await VoiceIntentService.classify(trimmed) {
onResolve(intent)
} else {
phase = .failed(message: String(appLoc: "没听懂想记什么,再说一次,或直接选菜单"))
}
}
}
}
#Preview {
Text("bg")
.sheet(isPresented: .constant(true)) {
VoiceCommandSheet(onResolve: { print($0) }, onOpenMenu: {})
}
}

View File

@@ -3,33 +3,36 @@ import SwiftData
import Foundation
enum TimelineKind: String, CaseIterable, Identifiable {
case indicator, report, symptom, diary
case diary, symptom, indicator, medication, report
var id: String { rawValue }
var label: String {
switch self {
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
case .medication: return String(appLoc: "用药")
}
}
var icon: String {
switch self {
case .indicator: return "drop.fill"
case .report: return "doc.fill"
case .symptom: return "waveform.path.ecg"
case .diary: return "pencil"
case .indicator: return "drop.fill"
case .report: return "doc.fill"
case .symptom: return "waveform.path.ecg"
case .diary: return "pencil"
case .medication: return "pills.fill"
}
}
var accent: Color {
switch self {
case .indicator: return Tj.Palette.brick
case .report: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .diary: return Tj.Palette.leaf
case .indicator: return Tj.Palette.brick
case .report: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .diary: return Tj.Palette.leaf
case .medication: return Tj.Palette.ink
}
}
}
@@ -132,13 +135,16 @@ struct TimelineEntry: Identifiable, Hashable {
}
}
/// tag () .medication ,
/// id "diary-" :TimelineDetail.resolve diaries
static func from(diary d: DiaryEntry) -> TimelineEntry {
TimelineEntry(
let isMed = d.isMedicationLog
return TimelineEntry(
id: "diary-\(d.persistentModelID)",
kind: .diary,
kind: isMed ? .medication : .diary,
date: d.createdAt,
title: d.content.firstLine(),
subtitle: String(appLoc: "文字日记"),
subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"),
trailing: nil,
trailingIsAlert: false,
isOngoing: false

View File

@@ -22,7 +22,8 @@ enum TimelineDetail {
case .report:
return reports.first { "report-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.report)
case .diary:
case .diary, .medication:
// tag DiaryEntry,
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.diary)
case .symptom:

View File

@@ -54,6 +54,9 @@
}
}
}
},
"“%@”" : {
},
"(偏瘦)" : {
"localizations" : {
@@ -1205,6 +1208,9 @@
}
}
}
},
"AI 模型未就绪,可以手动填写" : {
},
"AI 模型未就绪,手动补充" : {
@@ -1691,6 +1697,9 @@
}
}
}
},
"下次试试长按 + ,直接说出想记的内容" : {
},
"下载中" : {
"localizations" : {
@@ -2832,6 +2841,9 @@
}
}
}
},
"保存用药记录" : {
},
"偏低" : {
"localizations" : {
@@ -3065,6 +3077,9 @@
}
}
}
},
"全程本机" : {
},
"全部" : {
"localizations" : {
@@ -3335,6 +3350,9 @@
}
}
}
},
"再加一种" : {
},
"再拍一项" : {
"extractionState" : "stale",
@@ -3358,6 +3376,9 @@
}
}
}
},
"再说一次" : {
},
"再问一轮 · 让 AI 从新角度追问" : {
"localizations" : {
@@ -3411,6 +3432,12 @@
},
"写下要整理什么,或先提问补充情况…" : {
},
"写日记" : {
},
"写日记或拍药盒记录用药 · 可让 AI 辅助" : {
},
"冠心病" : {
"localizations" : {
@@ -5115,6 +5142,9 @@
},
"导出历史" : {
},
"将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。" : {
},
"将追加:" : {
"localizations" : {
@@ -6579,6 +6609,9 @@
},
"手动记录" : {
},
"打开新建菜单" : {
},
"抑郁/焦虑" : {
"localizations" : {
@@ -6840,6 +6873,15 @@
}
}
}
},
"拍药盒" : {
},
"拍药盒或说明书,本地识别药名与规格" : {
},
"拍药盒自动识别" : {
},
"拖动方框对准要识别的指标,可拖右下角缩放" : {
@@ -6909,6 +6951,9 @@
}
}
}
},
"持续追踪" : {
},
"指标" : {
"localizations" : {
@@ -7680,6 +7725,9 @@
}
}
}
},
"文字或语音" : {
},
"文字日记" : {
"localizations" : {
@@ -8584,6 +8632,9 @@
},
"本机不支持端侧语音识别" : {
},
"本机不支持端侧语音识别,试试下面的新建菜单" : {
},
"本机保存" : {
"localizations" : {
@@ -8892,6 +8943,9 @@
},
"核对指标" : {
},
"核对药品" : {
},
"核对识别结果" : {
"localizations" : {
@@ -9071,6 +9125,9 @@
}
}
}
},
"正在听 · %llds" : {
},
"正在听 · 识别在本机完成" : {
@@ -9143,12 +9200,18 @@
}
}
}
},
"正在本地识别药品…" : {
},
"正在查看本地记录…" : {
},
"正在根据这些记录回答…" : {
},
"正在理解…" : {
},
"正常" : {
"localizations" : {
@@ -9263,6 +9326,9 @@
},
"每月%lld日" : {
},
"比如:记一下血压 / 我头疼 / 拍个药盒" : {
},
"永久删除" : {
"localizations" : {
@@ -9329,6 +9395,12 @@
}
}
}
},
"没听到内容,再试一次?" : {
},
"没听懂想记什么,再说一次,或直接选菜单" : {
},
"没听清,再试一次" : {
@@ -9357,12 +9429,18 @@
},
"没有识别到指标,点「加一项」手动补充,或返回重拍" : {
},
"没识别到文字,拍清楚一点再试" : {
},
"没识别到文字,挪一下框再试" : {
},
"没读出指标,挪一下框再试" : {
},
"没读出药品,可以手动填写" : {
},
"测试 PROMPT" : {
"localizations" : {
@@ -9501,6 +9579,9 @@
}
}
}
},
"照片与文字均不离开设备" : {
},
"特大" : {
@@ -9783,6 +9864,15 @@
}
}
}
},
"用法,如:一日一次,一次一粒" : {
},
"用药" : {
},
"用药记录" : {
},
"甲状腺疾病" : {
"localizations" : {
@@ -10764,6 +10854,9 @@
}
}
}
},
"药品名,如:缬沙坦胶囊" : {
},
"血压" : {
"localizations" : {
@@ -10833,6 +10926,9 @@
}
}
}
},
"规格,如:80mg×7粒" : {
},
"解析失败:%@" : {
@@ -11043,6 +11139,7 @@
}
},
"记录身体状态、用药、感受 · 可让 AI 辅助" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -11063,6 +11160,9 @@
}
}
}
},
"记症状" : {
},
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。" : {
"localizations" : {
@@ -11205,6 +11305,9 @@
}
}
}
},
"识别用药" : {
},
"识别超时,挪一下框再试或手动补充" : {
@@ -11318,12 +11421,21 @@
}
}
}
},
"语音和文字都只在本机处理,不会上传。" : {
},
"语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。" : {
},
"说一段" : {
},
"说出想记的内容" : {
},
"说完了" : {
},
"说完了,整理成日记" : {
@@ -11350,6 +11462,9 @@
}
}
}
},
"请开口说话…" : {
},
"请选择名为 %@ 的文件夹" : {
"localizations" : {
@@ -11677,6 +11792,9 @@
}
}
}
},
"轻点打开新建菜单,长按语音直达" : {
},
"载脂蛋白 A1" : {
"extractionState" : "stale",

View File

@@ -178,6 +178,14 @@ final class DiaryEntry {
}
}
extension DiaryEntry {
/// tag UI ,**** appLoc
/// ()线
static let medicationTag = "用药"
var isMedicationLog: Bool { tags.contains(Self.medicationTag) }
}
@Model
final class Asset {
var relativePath: String

View File

@@ -0,0 +1,44 @@
import Foundation
/// App Widget ( App Group UserDefaults )
///
/// Widget SwiftData:store App ,
/// extension ;,
///
/// :`KangkangWidget` extension
/// (extension App , Xcode target membership )
/// :KangkangWidget/PinnedIndicatorsWidget.swift
struct WidgetSnapshot: Codable, Equatable {
struct Item: Codable, Equatable {
var name: String // ""
var value: String // "128"
var unit: String // "mmHg"
var statusRaw: String // IndicatorStatus.rawValue: high|low|normal
var capturedAt: Date
}
var updatedAt: Date
var items: [Item]
// MARK: - App Group
/// App Group ID target App Groups capability
static let appGroupID = "group.com.xuhuayong.kangkang"
static let storeKey = "kk.widget.snapshot.v1"
/// App Group (capability ) nil ,App
static var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: appGroupID)
}
func save(to defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) {
guard let defaults, let data = try? JSONEncoder().encode(self) else { return }
defaults.set(data, forKey: Self.storeKey)
}
static func load(from defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) -> WidgetSnapshot? {
guard let defaults,
let data = defaults.data(forKey: storeKey) else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
import SwiftData
import WidgetKit
/// pinned App Group , WidgetKit
/// :App / (RootView)( pinned), AI
/// App Group capability no-op, App
enum WidgetSnapshotRefresher {
/// (seriesKey, name), 6
@MainActor
static func refresh(in ctx: ModelContext) {
let pinnedPredicate = #Predicate<Indicator> { $0.pinned == true }
var descriptor = FetchDescriptor<Indicator>(
predicate: pinnedPredicate,
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
)
descriptor.fetchLimit = 200 // pinned ,
guard let pinned = try? ctx.fetch(descriptor) else { return }
var seenSeries = Set<String>()
var items: [WidgetSnapshot.Item] = []
for ind in pinned { // capturedAt ,
let key = ind.seriesKey ?? ind.name
guard seenSeries.insert(key).inserted else { continue }
items.append(.init(
name: ind.name,
value: ind.value,
unit: ind.unit,
statusRaw: ind.statusRaw,
capturedAt: ind.capturedAt
))
if items.count >= 6 { break }
}
let snapshot = WidgetSnapshot(updatedAt: .now, items: items)
// , WidgetKit
if let old = WidgetSnapshot.load(), old.items == snapshot.items { return }
snapshot.save()
WidgetCenter.shared.reloadAllTimelines()
}
}

View File

@@ -1,4 +1,6 @@
import SwiftUI
import SwiftData
import UIKit
enum TjTab: String, Hashable, CaseIterable {
case home, records, trend, me
@@ -35,6 +37,8 @@ enum ActiveFlow: Identifiable {
}
struct RootView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.scenePhase) private var scenePhase
@State private var tab: TjTab = .home
/// push : tab trailing , leading
@State private var pushEdge: Edge = .trailing
@@ -45,6 +49,23 @@ struct RootView: View {
@State private var showIndicator = false
@State private var showReminders = false
@State private var showHealthExport = false
/// + :( LLM )
@State private var showVoiceCommand = false
/// :RootView MedicationScanFlow, sheet
@State private var showMedicationScan = false
/// ( RecordSheet onPick )
private func route(_ intent: VoiceIntent) {
switch intent {
case .diary: showDiary = true
case .medication: showMedicationScan = true
case .symptom: showSymptomStart = true
case .indicator: showIndicator = true
case .archive: activeFlow = .archive
case .export: showHealthExport = true
case .reminder: showReminders = true
}
}
/// tab : pushEdge, tab
/// tab ,
@@ -70,9 +91,15 @@ struct RootView: View {
TabBar(active: tab,
onTap: { select($0) },
onTapRecord: { showRecordSheet = true })
onTapRecord: { showRecordSheet = true },
onLongPressRecord: { showVoiceCommand = true })
}
.background(Tj.Palette.sand.ignoresSafeArea())
// Widget :,(,App Group no-op)
.task { WidgetSnapshotRefresher.refresh(in: ctx) }
.onChange(of: scenePhase) { _, phase in
if phase == .background { WidgetSnapshotRefresher.refresh(in: ctx) }
}
.sheet(isPresented: $showRecordSheet) {
RecordSheet { kind in
showRecordSheet = false
@@ -111,6 +138,30 @@ struct RootView: View {
.fullScreenCover(isPresented: $showHealthExport) {
HealthExportSheet()
}
.sheet(isPresented: $showVoiceCommand) {
VoiceCommandSheet(
onResolve: { intent in
showVoiceCommand = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
route(intent)
}
},
onOpenMenu: {
showVoiceCommand = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
showRecordSheet = true
}
}
)
}
.fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow(
onSave: { entries in
MedicationArchiver.archive(entries: entries, in: ctx)
},
onClose: { showMedicationScan = false }
)
}
#if os(iOS)
.fullScreenCover(item: $activeFlow) { flow in
switch flow {
@@ -137,8 +188,11 @@ private struct TabBar: View {
let active: TjTab
let onTap: (TjTab) -> Void
let onTapRecord: () -> Void
let onLongPressRecord: () -> Void
@Namespace private var indicatorNS
/// + (, ButtonStyle)
@State private var recordPressing = false
private let cornerRadius: CGFloat = 22
private let slotHeight: CGFloat = 34
@@ -201,33 +255,45 @@ private struct TabBar: View {
.buttonStyle(TabPressStyle())
}
/// + : ;
/// Button + simultaneousGesture( tap ),
/// tap / longPress + onPressingChanged
private var recordSlot: some View {
Button(action: onTapRecord) {
VStack(spacing: 4) {
ZStack {
Circle()
.fill(Tj.Palette.ink)
.overlay(
Circle()
.strokeBorder(Tj.Palette.paper, lineWidth: 2)
)
.shadow(color: Tj.Palette.ink.opacity(0.18),
radius: 4, x: 0, y: 2)
VStack(spacing: 4) {
ZStack {
Circle()
.fill(Tj.Palette.ink)
.overlay(
Circle()
.strokeBorder(Tj.Palette.paper, lineWidth: 2)
)
.shadow(color: Tj.Palette.ink.opacity(0.18),
radius: 4, x: 0, y: 2)
Image(systemName: "plus")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: slotHeight, height: slotHeight)
Text("新建")
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
Image(systemName: "plus")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.frame(width: slotHeight, height: slotHeight)
Text("新建")
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(TabPressStyle())
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.scaleEffect(recordPressing ? 0.92 : 1.0)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: recordPressing)
.onTapGesture { onTapRecord() }
.onLongPressGesture(minimumDuration: 0.45) {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
onLongPressRecord()
} onPressingChanged: { pressing in
recordPressing = pressing
}
.accessibilityElement(children: .combine)
.accessibilityLabel("新建")
.accessibilityHint("轻点打开新建菜单,长按语音直达")
}
}
//

View File

@@ -0,0 +1,114 @@
import Foundation
/// (, UserProfile.currentMedications )
struct ParsedMedication: Sendable, Identifiable {
let id = UUID()
var name: String
var strength: String // , "80mg×7"
var usage: String // , ",1,2"
/// UserProfile.currentMedications ,
/// (placeholder ": 80mg qd")
var entryText: String {
var s = name.trimmingCharacters(in: .whitespaces)
let st = strength.trimmingCharacters(in: .whitespaces)
let u = usage.trimmingCharacters(in: .whitespaces)
if !st.isEmpty { s += " \(st)" }
if !u.isEmpty { s += " · \(u)" }
return s
}
}
/// :OCR LLM(MNN/SME2 )
/// CaptureService.recognizeIndicators :UI AIRuntime(§3.1),
/// CaptureError,UI 退(§3.2)
/// actor CaptureService: AIRuntime(actor),
actor MedicationScanService {
static let shared = MedicationScanService()
private init() {}
/// // OCR [ParsedMedication]
/// (MainActor) OCR , UIImage actor
func recognizeMedications(fromOCRText text: String) async throws -> [ParsedMedication] {
do {
try await AIRuntime.shared.prepare() // LLM( VL AIRuntime )
} catch {
throw CaptureError.modelNotReady
}
let prompt = MedicationPrompts.medicationsFromText(text)
var collected = ""
do {
// 1-2 ,512 token ; AIRuntime
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512)
for try await chunk in stream {
collected += chunk.text
}
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
let cleaned = CaptureService.stripThink(collected)
do {
return try Self.parseMedicationsJSON(cleaned)
} catch let CaptureError.parseFailed(msg) {
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
throw CaptureError.parseFailed("\(msg)〔前缀:\(preview)")
} catch {
throw CaptureError.parseFailed("\(error)")
}
}
// MARK: - JSON parse(static 便)
/// `{"medications":[...]}` `[...]`
/// (),UI ;JSON
static func parseMedicationsJSON(_ raw: String) throws -> [ParsedMedication] {
let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: raw))
guard let data = jsonString.data(using: .utf8) else {
throw CaptureError.parseFailed("非 UTF-8 输出")
}
let obj: Any
do {
obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
}
let rawList: [[String: Any]]
if let dict = obj as? [String: Any] {
rawList = arrayValue(dict, keys: ["medications", "meds", "drugs", "药品", "用药", "items"])
} else if let arr = obj as? [[String: Any]] {
rawList = arr
} else {
throw CaptureError.parseFailed("根节点既不是对象也不是数组")
}
var seen = Set<String>()
return rawList.compactMap { parseMedication($0) }.filter { seen.insert($0.name).inserted }
}
private static func parseMedication(_ d: [String: Any]) -> ParsedMedication? {
guard let name = stringValue(d, keys: ["name", "drug", "medication", "药名", "药品", "名称"])?
.trimmingCharacters(in: .whitespaces),
!name.isEmpty else { return nil }
let strength = stringValue(d, keys: ["strength", "spec", "specification", "规格", "剂量"]) ?? ""
let usage = stringValue(d, keys: ["usage", "dosage", "用法", "用量", "用法用量"]) ?? ""
return ParsedMedication(name: name,
strength: strength.trimmingCharacters(in: .whitespaces),
usage: usage.trimmingCharacters(in: .whitespaces))
}
private static func stringValue(_ d: [String: Any], keys: [String]) -> String? {
for key in keys {
if let s = d[key] as? String { return s }
if let n = d[key] as? NSNumber { return n.stringValue }
}
return nil
}
private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] {
for key in keys {
if let arr = d[key] as? [[String: Any]] { return arr }
}
return []
}
}

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
}
}