diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index b254252..892bc85 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -14,12 +14,26 @@ struct KangkangApp: App { UserProfile.self, MetricReminder.self, CustomMonitorMetric.self, + HealthExport.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { return try ModelContainer(for: schema, configurations: [config]) } catch { - fatalError("Could not create ModelContainer: \(error)") + // Demo 阶段 schema 仍在演进:旧 store 与新 schema 不兼容且无正式迁移时, + // 自动迁移会失败导致启动崩溃。这里重置本地 store 重建(测试数据可丢)。 + // ⚠️ 生产环境必须改为正式的 SwiftData 迁移方案,不能静默删数据。 + print("⚠️ ModelContainer 创建失败,重置本地 store 重建: \(error)") + let fm = FileManager.default + let storePath = config.url.path + for path in [storePath, storePath + "-wal", storePath + "-shm"] { + try? fm.removeItem(atPath: path) + } + do { + return try ModelContainer(for: schema, configurations: [config]) + } catch { + fatalError("Could not create ModelContainer even after reset: \(error)") + } } }() diff --git a/康康/Debug/DebugAIRunner.swift b/康康/Debug/DebugAIRunner.swift deleted file mode 100644 index 162676f..0000000 --- a/康康/Debug/DebugAIRunner.swift +++ /dev/null @@ -1,211 +0,0 @@ -#if DEBUG -import SwiftUI -import UIKit -import UniformTypeIdentifiers - -/// DEBUG 自检:加载 LLM 并跑一段 prompt,流式显示 token + 速率。 -/// 同时显示沙盒 Application Support 路径,方便把模型拷进去。 -struct DebugAIRunner: View { - @State private var output: String = "" - @State private var status: String = "未开始" - @State private var rate: Double = 0 - @State private var running = false - @State private var modelReady: Bool = false - @State private var importingModel = false - @State private var importError: String? - - private var appSupportPath: String { - (try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ).path) ?? "(无法获取)" - } - - private var modelExpectedPath: String { - appSupportPath + "/Models/Qwen3-1.7B-4bit" - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("DEBUG · AI 自检") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - - // 沙盒路径与模型状态卡 - VStack(alignment: .leading, spacing: 8) { - Text("模型预期路径") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Tj.Palette.text3) - Text(modelExpectedPath) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(Tj.Palette.text2) - .textSelection(.enabled) - .lineLimit(3) - HStack(spacing: 8) { - Button("复制路径") { - UIPasteboard.general.string = modelExpectedPath - } - .font(.system(size: 11)) - .buttonStyle(.borderless) - - Button(importingModel ? "导入中..." : "导入模型") { - importingModel = true - } - .font(.system(size: 11)) - .buttonStyle(.borderless) - .disabled(importingModel) - - Spacer() - - Text(modelReady ? "✓ 模型就绪" : "⚠ 模型未就绪") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(modelReady ? Tj.Palette.leaf : Tj.Palette.brick) - } - if let importError { - Text(importError) - .font(.system(size: 10)) - .foregroundStyle(Tj.Palette.brick) - .lineLimit(3) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.black.opacity(0.03)) - ) - - // 推理状态 - HStack { - Text("状态:\(status)") - Spacer() - Text(String(format: "%.1f tok/s", rate)) - .foregroundStyle(Tj.Palette.text3) - .monospaced() - } - .font(.system(size: 12)) - - Button(running ? "推理中..." : "跑一段 prompt") { - Task { await run() } - } - .buttonStyle(TjPrimaryButton()) - .disabled(running) - - ScrollView { - Text(output.isEmpty ? "(暂无输出)" : output) - .font(.system(.footnote, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - } - .frame(maxHeight: 240) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.black.opacity(0.04)) - ) - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.md) - .fill(Color.yellow.opacity(0.08)) - ) - .padding(.horizontal, 16) - .onAppear { refreshModelStatus() } - .fileImporter( - isPresented: $importingModel, - allowedContentTypes: [.folder], - allowsMultipleSelection: false - ) { result in - importModelFolder(from: result) - } - } - - private func refreshModelStatus() { - modelReady = ModelStore.shared.isReady(.llm) - } - - private func importModelFolder(from result: Result<[URL], Error>) { - defer { - refreshModelStatus() - importingModel = false - } - - do { - guard let pickedURL = try result.get().first else { - importError = "未选择模型文件夹" - return - } - - let securityScoped = pickedURL.startAccessingSecurityScopedResource() - defer { - if securityScoped { - pickedURL.stopAccessingSecurityScopedResource() - } - } - - let sourceURL = resolvedModelSourceURL(from: pickedURL) - guard FileManager.default.fileExists( - atPath: sourceURL.appendingPathComponent(ModelKind.llm.sentinelFilename).path - ) else { - importError = "请选择包含 config.json 的 Qwen3-1.7B-4bit 文件夹" - return - } - - let targetURL = ModelStore.shared.localURL(for: .llm) - let parentURL = targetURL.deletingLastPathComponent() - try FileManager.default.createDirectory(at: parentURL, withIntermediateDirectories: true) - if FileManager.default.fileExists(atPath: targetURL.path) { - try FileManager.default.removeItem(at: targetURL) - } - try FileManager.default.copyItem(at: sourceURL, to: targetURL) - importError = nil - } catch { - importError = "导入失败:\(error.localizedDescription)" - } - } - - private func resolvedModelSourceURL(from pickedURL: URL) -> URL { - let nestedURL = pickedURL.appendingPathComponent(ModelKind.llm.rawValue, isDirectory: true) - if FileManager.default.fileExists( - atPath: nestedURL.appendingPathComponent(ModelKind.llm.sentinelFilename).path - ) { - return nestedURL - } - return pickedURL - } - - @MainActor - private func run() async { - running = true - output = "" - rate = 0 - status = "加载模型..." - do { - try await AIRuntime.shared.prepare() - status = "推理中..." - - let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。" - for try await chunk in await AIRuntime.shared.generate( - prompt: prompt, - maxTokens: 200 - ) { - output += chunk.text - rate = chunk.decodeRate - } - status = "完成 ✓" - } catch { - status = "失败:\(error.localizedDescription)" - } - running = false - refreshModelStatus() - } -} - -#Preview("DebugAIRunner") { - ScrollView { - DebugAIRunner() - .padding(.vertical, 24) - } - .background(Tj.Palette.sand.ignoresSafeArea()) -} -#endif diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index 2d66cbf..5507afc 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -40,9 +40,6 @@ struct HomeView: View { OngoingSymptomsCard() .padding(.bottom, 18) - todaySummaryCard - .padding(.bottom, 18) - recentSection .padding(.bottom, 22) @@ -57,11 +54,11 @@ struct HomeView: View { private var greeting: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { - Text("5 月 25 日 · 周一") + Text(todayLine) .font(.system(size: 12)) .tracking(1) .foregroundStyle(Tj.Palette.text3) - Text("早安,林意") + Text(greetingWord) .font(.tjTitle()) .foregroundStyle(Tj.Palette.text) } @@ -71,48 +68,19 @@ struct HomeView: View { } } - private var todaySummaryCard: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 10) { - Text("今日 · 摘记") - .font(.system(size: 12, weight: .semibold)) - .tracking(0.3) - .foregroundStyle(Tj.Palette.brick) - .fixedSize() - Rectangle() - .fill(Tj.Palette.line) - .frame(height: 1) - Text("本机摘要") - .font(.system(size: 11)) - .foregroundStyle(Tj.Palette.text3) - .fixedSize() - } - .padding(.bottom, 10) + private var todayLine: String { + let f = DateFormatter() + f.locale = Locale(identifier: "zh_CN") + f.dateFormat = "M 月 d 日 · EEE" + return f.string(from: Date()) + } - Text("上次体检后,\(Text("低密度脂蛋白").underline(color: Tj.Palette.brick).foregroundColor(Tj.Palette.text))持续偏高已 3 个月。建议本周记录一次空腹血脂。") - .font(.tjSerifBody()) - .foregroundStyle(Tj.Palette.text) - .lineSpacing(6) - .padding(.bottom, 14) - - HStack(spacing: 14) { - Button("记录今日") {} - .buttonStyle(TjPrimaryButton(height: 34, fontSize: 13, horizontalPadding: 14)) - Button("查看趋势") {} - .buttonStyle(TjGhostButton(height: 34, fontSize: 13, horizontalPadding: 14)) - } + private var greetingWord: String { + switch Calendar.current.component(.hour, from: Date()) { + case 5..<12: return "早安" + case 12..<18: return "下午好" + default: return "晚上好" } - .padding(.leading, 20) - .padding(.trailing, 18) - .padding(.vertical, 18) - .background( - Tj.Palette.paper - .overlay(alignment: .leading) { - Tj.Palette.brick.frame(width: 3) - } - ) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1) } private var recentSection: some View { @@ -168,13 +136,13 @@ struct HomeView: View { Button(action: onTapArchive) { HStack(spacing: 14) { - TjPlaceholder(label: "档案 · 12") + TjPlaceholder(label: "档案 · \(reports.count)") .frame(width: 56, height: 56) VStack(alignment: .leading, spacing: 2) { Text("我的报告档案") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Tj.Palette.text) - Text("12 份 · 218 项指标 · 端侧加密") + Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) } diff --git a/康康/Features/Me/AboutView.swift b/康康/Features/Me/AboutView.swift new file mode 100644 index 0000000..755c7d9 --- /dev/null +++ b/康康/Features/Me/AboutView.swift @@ -0,0 +1,161 @@ +import SwiftUI + +/// 「我的 · 关于」——本软件基本介绍、使用注意与免责声明。 +/// 纯静态阅读页,不调任何 Service / AIRuntime,复用现有 DesignSystem token。 +struct AboutView: View { + /// 真实读取 Bundle 版本号,避免硬编码与实际发版脱节。 + private var versionText: String { + let info = Bundle.main.infoDictionary + let short = info?["CFBundleShortVersionString"] as? String ?? "0.1" + let build = info?["CFBundleVersion"] as? String + if let build, !build.isEmpty, build != short { + return "v\(short) (\(build))" + } + return "v\(short)" + } + + var body: some View { + ScrollView { + VStack(spacing: 16) { + header + + section(icon: "sparkles", title: "这是什么") { + paragraph( + "康康是一款以本地优先为设计原则的个人健康影像档案工具。" + + "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;" + + "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。" + ) + } + + section(icon: "checklist", title: "主要功能") { + bullet("拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档") + bullet("通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明") + bullet("长期趋势:关注的指标可生成折线图和简要解读") + bullet("本地问答:基于你自己的档案问答,引用可点击回链到原记录") + bullet("隐私优先:健康数据不上传、无需注册账号") + } + + section(icon: "lock.shield", title: "隐私保护") { + bullet("AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。") + bullet("原图与数据库采用系统级文件加密,随设备锁屏受到保护。") + bullet("支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。") + bullet("可选开启 Face ID 启动锁,进一步保护隐私。") + } + + section(icon: "exclamationmark.triangle", title: "使用注意", tint: Tj.Palette.amber) { + bullet("本地 AI 模型体积较大(约 3GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;" + + "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。") + bullet("AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对," + + "并以原始报告 / 化验单为准。") + bullet("AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。") + bullet("数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。") + } + + section(icon: "hand.raised", title: "免责声明", tint: Tj.Palette.brick) { + bullet("康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。") + bullet("App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考," + + "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。") + bullet("任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。") + bullet("如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。") + bullet("在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。") + } + + Text("康康 · 本地优先的健康档案 · \(versionText)") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + .padding(.top, 4) + + Spacer(minLength: 32) + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .background(Tj.Palette.sand) + .navigationTitle("关于") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Header + + @ViewBuilder private var header: some View { + VStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.sand2) + Image(systemName: "heart.text.square.fill") + .font(.system(size: 34)) + .foregroundStyle(Tj.Palette.brick) + } + .frame(width: 72, height: 72) + + Text("康康") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + + Text("本地优先的个人健康影像档案") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + + Text(versionText) + .font(.tjMono()) + .foregroundStyle(Tj.Palette.text3) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .padding(.horizontal, 16) + .tjCard() + } + + // MARK: - Section builders + + @ViewBuilder + private func section( + icon: String, + title: String, + tint: Color = Tj.Palette.text2, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(tint) + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + } + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .tjCard() + } + + @ViewBuilder private func paragraph(_ text: String) -> some View { + Text(text) + .font(.system(size: 14)) + .foregroundStyle(Tj.Palette.text2) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder private func bullet(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(Tj.Palette.text3) + .frame(width: 5, height: 5) + .padding(.top, 7) + Text(text) + .font(.system(size: 14)) + .foregroundStyle(Tj.Palette.text2) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +#Preview { + NavigationStack { + AboutView() + } +} diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index bcccea3..8987d42 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -12,6 +12,12 @@ struct MeView: View { private var profile: UserProfile? { profiles.first } private var enabledReminderCount: Int { reminders.filter(\.enabled).count } + /// 真实读取 Bundle 版本号,与「关于」页保持一致。 + private var appVersionText: String { + let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1" + return "v\(short)" + } + var body: some View { NavigationStack { ScrollView { @@ -23,14 +29,14 @@ struct MeView: View { settingsCard(title: "Face ID 启动锁", detail: "关闭", icon: "faceid") - settingsCard(title: "关于", - detail: "v0.1 · W2", - icon: "info.circle") - - #if DEBUG - DebugAIRunner() - .padding(.top, 8) - #endif + NavigationLink { + AboutView() + } label: { + settingsCard(title: "关于", + detail: appVersionText, + icon: "info.circle") + } + .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 20) diff --git a/康康/Features/Me/ModelManagementView.swift b/康康/Features/Me/ModelManagementView.swift index 6836b41..d04df44 100644 --- a/康康/Features/Me/ModelManagementView.swift +++ b/康康/Features/Me/ModelManagementView.swift @@ -28,6 +28,19 @@ struct ModelManagementView: View { actionButtons .padding(.top, 4) + if service.states[.llm]?.phase == .ready { + NavigationLink { + ModelSelfTestView() + } label: { + HStack(spacing: 6) { + Image(systemName: "play.circle") + Text("运行推理自检") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(TjGhostButton()) + } + if let importError { Text(importError) .font(.system(size: 12)) diff --git a/康康/Features/Me/ModelSelfTestView.swift b/康康/Features/Me/ModelSelfTestView.swift new file mode 100644 index 0000000..3fa71d0 --- /dev/null +++ b/康康/Features/Me/ModelSelfTestView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +/// 模型推理自检:加载 LLM 跑一段固定 prompt,流式显示输出 + tok/s。 +/// 模型就绪后从「我的 · 模型管理」进入,用于现场快速验证本地推理是否正常。 +struct ModelSelfTestView: View { + @State private var output = "" + @State private var phase: Phase = .idle + @State private var rate: Double = 0 + + private enum Phase: Equatable { + case idle, loading, running, done, failed(String) + + var label: String { + switch self { + case .idle: return "未开始" + case .loading: return "加载模型…" + case .running: return "推理中…" + case .done: return "完成 ✓" + case .failed(let m): return "失败:\(m)" + } + } + } + + private let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。" + + private var isBusy: Bool { phase == .loading || phase == .running } + + private var statusColor: Color { + switch phase { + case .failed: return Tj.Palette.brick + case .done: return Tj.Palette.leaf + default: return Tj.Palette.text2 + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("测试 PROMPT") + .font(.system(size: 11, weight: .semibold)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + Text(prompt) + .font(.system(size: 14)) + .foregroundStyle(Tj.Palette.text) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .tjCard() + + HStack { + Text(phase.label) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(statusColor) + .lineLimit(1) + Spacer() + if rate > 0 { + Text(String(format: "%.1f tok/s", rate)) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + + Button { + Task { await run() } + } label: { + Text(isBusy ? "运行中…" : "运行推理自检").frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + .disabled(isBusy) + + ScrollView { + Text(output.isEmpty ? "(暂无输出)" : output) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(Tj.Palette.text) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(12) + } + .frame(maxHeight: 280) + .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) + ) + + Spacer() + } + .padding(16) + .background(Tj.Palette.sand.ignoresSafeArea()) + .navigationTitle("推理自检") + .navigationBarTitleDisplayMode(.inline) + } + + @MainActor + private func run() async { + output = "" + rate = 0 + phase = .loading + do { + try await AIRuntime.shared.prepare() + phase = .running + for try await chunk in await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200) { + output += chunk.text + rate = chunk.decodeRate + } + phase = .done + } catch { + phase = .failed(error.localizedDescription) + } + } +} + +#Preview { + NavigationStack { ModelSelfTestView() } +}