```
feat(iOS): 更新MNN后端模型配置优化性能 将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本 实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX 兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。 ```
This commit is contained in:
@@ -30,6 +30,7 @@ struct ArchiveListView: View {
|
||||
@State private var filter: TimelineKind? = nil
|
||||
@State private var endingSymptom: Symptom?
|
||||
@State private var selectedEntry: TimelineEntry?
|
||||
@State private var selectedGroup: IndicatorGroup?
|
||||
@State private var route: Route?
|
||||
|
||||
@MainActor
|
||||
@@ -109,6 +110,9 @@ struct ArchiveListView: View {
|
||||
TimelineEntryDetailView(detail: d)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedGroup) { group in
|
||||
IndicatorSeriesDetailView(group: group)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -123,9 +127,14 @@ struct ArchiveListView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
// 其余条目(报告/指标/日记/已结束症状):点 → 只读详情
|
||||
// 其余条目:指标 → 同类聚合详情(横向翻页 + 趋势);报告/日记/已结束症状 → 只读详情
|
||||
Button {
|
||||
if detail(for: entry) != nil { selectedEntry = entry }
|
||||
guard let d = detail(for: entry) else { return }
|
||||
switch d {
|
||||
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
|
||||
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
|
||||
default: selectedEntry = entry
|
||||
}
|
||||
} label: {
|
||||
TimelineRow(entry: entry)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ struct HealthExportDetailView: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
@@ -117,7 +119,7 @@ struct HealthExportDetailView: View {
|
||||
}
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||
|
||||
ShareLink(item: export.content) {
|
||||
ShareLink(item: AIDisclaimer.appended(to: export.content)) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
@@ -149,7 +151,7 @@ struct HealthExportDetailView: View {
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
UIPasteboard.general.string = export.content
|
||||
UIPasteboard.general.string = AIDisclaimer.appended(to: export.content)
|
||||
copiedFlash = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||
copiedFlash = false
|
||||
|
||||
@@ -22,6 +22,11 @@ struct HealthExportSheet: View {
|
||||
@State private var answeringTurnID: UUID?
|
||||
@FocusState private var questionFocused: Bool
|
||||
|
||||
// 快捷问答
|
||||
@State private var promptStore = QuickPromptStore.shared
|
||||
@State private var showAddPrompt = false
|
||||
@State private var newPromptText = ""
|
||||
|
||||
init(initialPrompt: String = "") {
|
||||
self.initialPrompt = initialPrompt
|
||||
}
|
||||
@@ -33,10 +38,16 @@ struct HealthExportSheet: View {
|
||||
!isGeneratingReport &&
|
||||
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
/// 已有有效用户对话内容。
|
||||
private var hasUserContent: Bool {
|
||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
}
|
||||
|
||||
/// 可生成报告:有对话内容,或输入框里有文字(允许跳过多轮对话直接生成)。
|
||||
private var canGenerateReport: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
(hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -88,6 +99,75 @@ struct HealthExportSheet: View {
|
||||
questionFocused = true
|
||||
}
|
||||
.onDisappear { task?.cancel() }
|
||||
.alert("添加快捷问答", isPresented: $showAddPrompt) {
|
||||
TextField("输入一句常用问题…", text: $newPromptText)
|
||||
Button("取消", role: .cancel) { newPromptText = "" }
|
||||
Button("添加") {
|
||||
promptStore.add(prompt: newPromptText)
|
||||
newPromptText = ""
|
||||
}
|
||||
} message: {
|
||||
Text("保存后点一下,就能把这句话填进输入框")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 快捷问答
|
||||
|
||||
/// 内置 + 自定义快捷问答 chip 行;点 chip 填入输入框,末尾「+ 自定义」追加,长按自定义删除。
|
||||
private var quickPromptRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(promptStore.all) { p in
|
||||
quickPromptChip(p)
|
||||
}
|
||||
addQuickPromptChip
|
||||
}
|
||||
.padding(.vertical, 1) // 给 chip 描边留出像素,避免被 ScrollView 裁切
|
||||
}
|
||||
}
|
||||
|
||||
private func quickPromptChip(_ p: QuickPrompt) -> some View {
|
||||
Button {
|
||||
draftQuestion = p.prompt
|
||||
questionFocused = true
|
||||
} label: {
|
||||
Text(p.title)
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
if !p.isBuiltin {
|
||||
Button(role: .destructive) {
|
||||
promptStore.delete(p)
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var addQuickPromptChip: some View {
|
||||
Button { showAddPrompt = true } label: {
|
||||
Label("自定义", systemImage: "plus")
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(
|
||||
Capsule().strokeBorder(
|
||||
Tj.Palette.line,
|
||||
style: StrokeStyle(lineWidth: 1, dash: [3])
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -128,14 +208,7 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("例:最近血压波动大吗?")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
quickPromptRow
|
||||
|
||||
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
||||
.font(.tjScaled( 11))
|
||||
@@ -162,11 +235,11 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
||||
if turn.id == answeringTurnID && turn.text.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("正在查看本地记录…")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
AIFlowBar()
|
||||
}
|
||||
} else {
|
||||
Text(turn.text)
|
||||
@@ -196,6 +269,11 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
MarkdownView(text: content)
|
||||
|
||||
if completed {
|
||||
Divider().background(Tj.Palette.lineSoft)
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -229,6 +307,9 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
// AI 计算中:多彩流光线(与日记 AI 辅助同一组件)
|
||||
AIFlowBar().padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +372,7 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||
|
||||
ShareLink(item: content) {
|
||||
ShareLink(item: AIDisclaimer.appended(to: content)) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
@@ -319,7 +400,7 @@ struct HealthExportSheet: View {
|
||||
private var composer: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical)
|
||||
TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
|
||||
.font(.tjScaled( 14))
|
||||
.lineLimit(1...4)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -342,12 +423,28 @@ struct HealthExportSheet: View {
|
||||
.accessibilityLabel("发送问题")
|
||||
}
|
||||
|
||||
Button { startReportGeneration() } label: {
|
||||
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||
if isGeneratingReport {
|
||||
Button { stopGeneration() } label: {
|
||||
Label("停止生成", systemImage: "stop.fill")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.brick, lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button { startReportGeneration() } label: {
|
||||
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||
.disabled(!canGenerateReport)
|
||||
.opacity(canGenerateReport ? 1 : 0.45)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||
.disabled(!canGenerateReport)
|
||||
.opacity(canGenerateReport ? 1 : 0.45)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
@@ -402,6 +499,15 @@ struct HealthExportSheet: View {
|
||||
private func startReportGeneration() {
|
||||
guard canGenerateReport else { return }
|
||||
questionFocused = false
|
||||
|
||||
// 直接生成:输入框里有文字(快捷问答/手输)就把它作为一条诉求追加进对话,
|
||||
// 不必先走多轮问答 —— 用户点一下「生成报告」即可。
|
||||
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !draft.isEmpty {
|
||||
turns.append(.user(draft))
|
||||
draftQuestion = ""
|
||||
}
|
||||
|
||||
content = ""
|
||||
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||
error = nil
|
||||
@@ -435,6 +541,16 @@ struct HealthExportSheet: View {
|
||||
startReportGeneration()
|
||||
}
|
||||
|
||||
/// 停止正在进行的报告生成:取消推理任务,回到可重新生成的干净态(已写的诉求保留在对话里)。
|
||||
private func stopGeneration() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
phase = nil
|
||||
rate = 0
|
||||
completed = false
|
||||
content = ""
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
@@ -448,7 +564,7 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
UIPasteboard.general.string = content
|
||||
UIPasteboard.general.string = AIDisclaimer.appended(to: content)
|
||||
copiedFlash = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||
copiedFlash = false
|
||||
|
||||
92
康康/Features/Archive/QuickPrompt.swift
Normal file
92
康康/Features/Archive/QuickPrompt.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 「身体档案」里的快捷问答:点一下把一句常用问题填进输入框。
|
||||
/// 内置 3 条(不可删),用户可自定义追加(可删)。
|
||||
struct QuickPrompt: Identifiable, Codable, Equatable {
|
||||
let id: UUID
|
||||
var title: String // chip 上显示的短标签
|
||||
var prompt: String // 点击后填入输入框的完整问题
|
||||
var isBuiltin: Bool
|
||||
|
||||
init(id: UUID = UUID(), title: String, prompt: String, isBuiltin: Bool) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.prompt = prompt
|
||||
self.isBuiltin = isBuiltin
|
||||
}
|
||||
}
|
||||
|
||||
/// 快捷问答存储:内置常量 + 自定义条目(UserDefaults JSON,无 SwiftData schema 迁移风险)。
|
||||
/// 自定义条目只是 UI 便利项、不是健康记录,故不进 SwiftData。
|
||||
@Observable
|
||||
final class QuickPromptStore {
|
||||
static let shared = QuickPromptStore()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "kk.quickPrompts.custom.v1"
|
||||
|
||||
private(set) var custom: [QuickPrompt]
|
||||
|
||||
private init() {
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([QuickPrompt].self, from: data) {
|
||||
custom = decoded
|
||||
} else {
|
||||
custom = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 内置在前、自定义在后,供 chip 行展示。
|
||||
var all: [QuickPrompt] { Self.builtins + custom }
|
||||
|
||||
/// 追加一条自定义问答。空白忽略;标签自动取问题前几个字。
|
||||
func add(prompt rawPrompt: String) {
|
||||
let trimmed = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
custom.append(QuickPrompt(title: Self.deriveTitle(trimmed),
|
||||
prompt: trimmed,
|
||||
isBuiltin: false))
|
||||
persist()
|
||||
}
|
||||
|
||||
/// 删除一条自定义问答(内置不可删)。
|
||||
func delete(_ p: QuickPrompt) {
|
||||
guard !p.isBuiltin else { return }
|
||||
custom.removeAll { $0.id == p.id }
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(custom) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义条目的短标签:压成单行,取前 8 个字,超出补省略号。
|
||||
static func deriveTitle(_ prompt: String) -> String {
|
||||
let oneLine = prompt.replacingOccurrences(of: "\n", with: " ")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let head = oneLine.prefix(8)
|
||||
return oneLine.count > 8 ? "\(head)…" : String(head)
|
||||
}
|
||||
|
||||
/// 内置 3 条(首屏):覆盖「就诊 / 解读 / 速查」三类,数据依赖稳、不碰诊断红线。
|
||||
static let builtins: [QuickPrompt] = [
|
||||
QuickPrompt(
|
||||
title: "就诊摘要",
|
||||
prompt: "根据我最近的身体症状,结合历史指标,整理一份让门诊医生快速了解我情况的就诊摘要。",
|
||||
isBuiltin: true
|
||||
),
|
||||
QuickPrompt(
|
||||
title: "趋势解读",
|
||||
prompt: "把我血压最近半年的变化讲清楚:是变好还是变差、要注意什么。",
|
||||
isBuiltin: true
|
||||
),
|
||||
QuickPrompt(
|
||||
title: "速答清单",
|
||||
prompt: "把我的过敏史、正在吃的药、慢性病整理成一句话清单,方便就诊时快速回答医生。",
|
||||
isBuiltin: true
|
||||
),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user