feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时 - 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的 SME2和A17的NEON加速 - 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制 - 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构 - 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX - 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理 - 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
885 lines
40 KiB
Swift
885 lines
40 KiB
Swift
import SwiftUI
|
||
import SwiftData
|
||
|
||
/// 「健康记录」录入 sheet。
|
||
/// 主体仍是 DiaryEntry @Model;UI/文案改为面向健康记录,并加 AI 辅助区:
|
||
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
|
||
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
|
||
struct DiaryQuickSheet: View {
|
||
@Environment(\.modelContext) private var ctx
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var content: String = ""
|
||
@State private var createdAt: Date = .now
|
||
/// 「拍药盒」分支:全屏扫描流程,识别后入药品库。
|
||
@State private var showMedicationScan = false
|
||
/// 「用药」分支:记一次服用(选药 + 剂量 + 时间),存为带「用药」tag 的日记。
|
||
@State private var showMedicationLog = false
|
||
/// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。
|
||
@State private var showSymptomStart = false
|
||
|
||
/// AI 辅助状态
|
||
enum AssistPhase {
|
||
case idle // 从未生成
|
||
case loading // 正在 LLM 调用
|
||
case ready // 有结果显示,等待下一轮 / 采纳 / 重试
|
||
case failed(Error) // 最近一次失败
|
||
}
|
||
@State private var phase: AssistPhase = .idle
|
||
@State private var questions: [DiaryAssistService.Question] = []
|
||
@State private var lastRate: Double = 0
|
||
@State private var currentRound: Int = 0
|
||
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
||
@State private var coveredDims: Set<String> = []
|
||
@State private var suggestTask: Task<Void, Never>?
|
||
/// 当前正在「就地填空」的 question id;nil = 没有展开的填空面板。
|
||
@State private var fillingId: UUID?
|
||
/// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。
|
||
@State private var fillValues: [String] = []
|
||
/// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。
|
||
@State private var exhaustedNote = false
|
||
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
||
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
||
@State private var detent: PresentationDetent = .large
|
||
@FocusState private var contentFocused: Bool
|
||
|
||
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
||
|
||
enum VoicePhase: Equatable { case idle, recording, organizing }
|
||
@State private var voicePhase: VoicePhase = .idle
|
||
@State private var liveTranscript = ""
|
||
@State private var recordingSeconds = 0
|
||
/// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。
|
||
@State private var rawTranscript: String?
|
||
/// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。
|
||
/// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。
|
||
@State private var organizedAppended: String?
|
||
/// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。
|
||
@State private var voiceNote: String?
|
||
@State private var voiceDeniedAlert = false
|
||
@State private var voiceFlowTask: Task<Void, Never>?
|
||
@State private var recordingWatchdog: Task<Void, Never>?
|
||
/// 必须 @State:struct View 重建(键盘收起/detent 变化都会触发)时普通 let 会换成
|
||
/// 全新实例,导致 stop() 落在没在录音的新服务上返回空串(「没听清」假错误),
|
||
/// 且真正在录音的老实例关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一。
|
||
@State private var dictation = SpeechDictationService()
|
||
|
||
private var hasContent: Bool {
|
||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
private var hasQuestions: Bool { !questions.isEmpty }
|
||
private var isLoading: Bool {
|
||
if case .loading = phase { return true }
|
||
return false
|
||
}
|
||
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||
private var canSubmit: Bool { hasContent }
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
Capsule()
|
||
.fill(Tj.Palette.line)
|
||
.frame(width: 40, height: 4)
|
||
.padding(.top, 10)
|
||
.padding(.bottom, 14)
|
||
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("健康记录")
|
||
.font(.tjH2())
|
||
.foregroundStyle(Tj.Palette.text)
|
||
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
||
.font(.tjScaled( 11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
Spacer()
|
||
Text("本机保存")
|
||
.font(.tjScaled( 12))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 10)
|
||
|
||
// 入口四选(2×2):写日记(本页)/ 用药(MedicationLogSheet,记剂量+时间)/
|
||
// 拍药盒(识别入药品库)/ 记症状(SymptomStartSheet)。
|
||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
|
||
GridItem(.flexible(), spacing: 10)], 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) {
|
||
showMedicationLog = true
|
||
}
|
||
modeCard(icon: "camera.viewfinder", 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
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
sectionLabel(String(appLoc: "内容"))
|
||
Spacer()
|
||
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||
Button(action: startVoice) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "mic.fill")
|
||
.font(.tjScaled(11, weight: .semibold))
|
||
Text("说一段")
|
||
.font(.tjScaled(12, weight: .semibold))
|
||
}
|
||
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(Capsule().strokeBorder(
|
||
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
|
||
lineWidth: 1))
|
||
.contentShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isLoading) // AI 追问生成中不抢 AIRuntime 队列
|
||
}
|
||
}
|
||
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
|
||
text: $content, axis: .vertical)
|
||
.lineLimit(3...8)
|
||
.focused($contentFocused)
|
||
.onChange(of: content) { _, _ in exhaustedNote = false }
|
||
.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)
|
||
)
|
||
|
||
if voicePhase != .idle {
|
||
DiaryVoicePanel(
|
||
mode: voicePhase == .organizing
|
||
? .organizing
|
||
: .recording(elapsedSeconds: recordingSeconds),
|
||
transcript: liveTranscript,
|
||
onStop: stopVoiceAndOrganize,
|
||
onCancelOrganize: cancelOrganize
|
||
)
|
||
}
|
||
|
||
if let note = voiceNote {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "info.circle")
|
||
.font(.tjScaled(11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
Text(note)
|
||
.font(.tjScaled(11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
|
||
if let organized = organizedAppended,
|
||
rawTranscript != nil,
|
||
content.range(of: organized) != nil {
|
||
Button(action: revertToRawTranscript) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "arrow.uturn.backward")
|
||
.font(.tjScaled(10, weight: .semibold))
|
||
Text("改用原话")
|
||
.font(.tjScaled(11, weight: .semibold))
|
||
}
|
||
.foregroundStyle(Tj.Palette.ink)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||
.contentShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
assistSection
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sectionLabel(String(appLoc: "时间"))
|
||
DatePicker("", selection: $createdAt, in: ...Date.now)
|
||
.datePickerStyle(.compact)
|
||
.labelsHidden()
|
||
}
|
||
// 底部锚点,新一轮 question 进来后自动滚到这里
|
||
Color.clear.frame(height: 1).id("assist-bottom")
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 6)
|
||
}
|
||
.scrollDismissesKeyboard(.interactively)
|
||
.onChange(of: questions.count) { old, new in
|
||
guard new > old else { return }
|
||
// 滚到新一轮的 round divider(让用户先看到「第 N 轮」的标签,
|
||
// 再依次看到这一轮的 questions)
|
||
let roundId = "round-\(questions[old].round)"
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||
withAnimation(.easeOut(duration: 0.25)) {
|
||
proxy.scrollTo(roundId, anchor: .top)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 12) {
|
||
Button("取消") { dismiss() }
|
||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||
Button("保存") { submit() }
|
||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||
.disabled(!canSubmit)
|
||
.opacity(canSubmit ? 1 : 0.4)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 14)
|
||
}
|
||
.background(
|
||
Tj.Palette.sand
|
||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||
.ignoresSafeArea(edges: .bottom)
|
||
)
|
||
.presentationDetents([.medium, .large], selection: $detent)
|
||
.presentationDragIndicator(.hidden)
|
||
.presentationBackground(Tj.Palette.sand)
|
||
.presentationCornerRadius(Tj.Radius.xl)
|
||
.fullScreenCover(isPresented: $showMedicationScan) {
|
||
MedicationScanFlow(
|
||
onSave: { meds, images in
|
||
// 识别后入药品库(含原图),不再写日记。服用流水走「写日记 · 用药」模式。
|
||
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
|
||
dismiss()
|
||
},
|
||
onClose: { showMedicationScan = false }
|
||
)
|
||
}
|
||
.sheet(isPresented: $showSymptomStart) {
|
||
// 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。
|
||
SymptomStartSheet()
|
||
}
|
||
.sheet(isPresented: $showMedicationLog) {
|
||
// 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。
|
||
MedicationLogSheet()
|
||
}
|
||
.onDisappear {
|
||
suggestTask?.cancel()
|
||
voiceFlowTask?.cancel()
|
||
recordingWatchdog?.cancel()
|
||
dictation.abort()
|
||
}
|
||
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
|
||
Button(String(appLoc: "前往设置")) {
|
||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
Button(String(appLoc: "取消"), role: .cancel) {}
|
||
} message: {
|
||
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||
}
|
||
}
|
||
|
||
// MARK: - AI 辅助区
|
||
|
||
@ViewBuilder
|
||
private var assistSection: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
// section header
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "sparkles")
|
||
.font(.tjScaled( 11, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.brick)
|
||
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
|
||
Spacer()
|
||
if hasQuestions {
|
||
Text("\(questions.count) 个建议")
|
||
.font(.tjScaled( 10, design: .monospaced))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
if lastRate > 0 {
|
||
Text(String(format: "%.1f tok/s", lastRate))
|
||
.font(.tjScaled( 10, design: .monospaced))
|
||
.foregroundStyle(Tj.Palette.leaf)
|
||
}
|
||
}
|
||
|
||
// 累积的 questions 列表(多轮,带轮次分隔)
|
||
if hasQuestions {
|
||
VStack(spacing: 8) {
|
||
ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
|
||
if idx == 0 || questions[idx - 1].round != q.round {
|
||
roundDivider(round: q.round,
|
||
count: questions.filter { $0.round == q.round }.count)
|
||
.id("round-\(q.round)")
|
||
}
|
||
questionRow(index: roundLocalIndex(at: idx), question: q)
|
||
}
|
||
}
|
||
AIDisclaimerFooter()
|
||
}
|
||
|
||
if exhaustedNote {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "checkmark.seal.fill")
|
||
.font(.tjScaled( 11))
|
||
.foregroundStyle(Tj.Palette.leaf)
|
||
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
||
.font(.tjScaled( 11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
|
||
// 底部主操作按钮(状态机驱动)
|
||
phaseFooter
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var phaseFooter: some View {
|
||
switch phase {
|
||
case .idle:
|
||
assistPrimaryButton(
|
||
icon: "sparkles",
|
||
label: canRequestSuggest
|
||
? String(appLoc: "让 AI 帮我想想还能记什么")
|
||
: String(appLoc: "先写几个字,AI 来帮忙补充"),
|
||
enabled: canRequestSuggest,
|
||
prominent: true,
|
||
action: requestSuggestions
|
||
)
|
||
|
||
case .loading:
|
||
assistLoadingIndicator
|
||
|
||
case .ready:
|
||
assistPrimaryButton(
|
||
icon: "arrow.clockwise",
|
||
label: canRequestSuggest
|
||
? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
|
||
: String(appLoc: "更新一下原文,再让 AI 继续追问"),
|
||
enabled: canRequestSuggest,
|
||
action: requestSuggestions
|
||
)
|
||
|
||
case .failed(let err):
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(Tj.Palette.brick)
|
||
Text(err.localizedDescription)
|
||
.font(.tjScaled( 12))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
Spacer()
|
||
}
|
||
Button { requestSuggestions() } label: {
|
||
Text("重试")
|
||
.font(.tjScaled( 12, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.ink)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.fill(Tj.Palette.brickSoft.opacity(0.5))
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 辅助主按钮。`prominent` 为真走实心强调样式(填充 brick + 白字 + 轻投影,一眼可点),
|
||
/// 否则走低调描边样式(用于 .ready 的「再问一轮」)。
|
||
private func assistPrimaryButton(icon: String,
|
||
label: String,
|
||
enabled: Bool,
|
||
prominent: Bool = false,
|
||
action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: icon)
|
||
Text(label)
|
||
}
|
||
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
|
||
.foregroundStyle(prominent
|
||
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
|
||
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, prominent ? 14 : 11)
|
||
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
|
||
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!enabled)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
|
||
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
if prominent {
|
||
shape
|
||
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
|
||
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
|
||
radius: 8, x: 0, y: 3)
|
||
} else {
|
||
shape
|
||
.strokeBorder(
|
||
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
||
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||
)
|
||
}
|
||
}
|
||
|
||
/// .loading 等待态:安静的 paper 卡片,底部一条细窄的不确定进度条来回滑动(Linear/Vercel 式极简)。
|
||
/// 不用高亮扫光、不填强调色,避免刺眼;只靠细线 + sparkles 轻脉冲传达「在算」。
|
||
private var assistLoadingIndicator: some View {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "sparkles")
|
||
.font(.tjScaled( 12, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.brick)
|
||
.symbolEffect(.pulse, options: .repeating)
|
||
Text(lastRate > 0
|
||
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
|
||
: String(appLoc: "AI 生成中 · 本地推理"))
|
||
.font(.tjScaled( 13, weight: .medium))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
Spacer(minLength: 0)
|
||
Button("取消") { cancelSuggestions() }
|
||
.font(.tjScaled( 12, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
.padding(.vertical, 11)
|
||
.padding(.horizontal, 12)
|
||
.frame(maxWidth: .infinity)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.fill(Tj.Palette.paper)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||
)
|
||
.overlay(alignment: .bottom) {
|
||
AIFlowBar().padding(.horizontal, 1)
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||
}
|
||
|
||
/// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
|
||
private func roundLocalIndex(at idx: Int) -> Int {
|
||
let target = questions[idx].round
|
||
var count = 0
|
||
for i in 0...idx where questions[i].round == target {
|
||
count += 1
|
||
}
|
||
return count
|
||
}
|
||
|
||
/// 第 N 轮的分隔条 —— 让用户清楚下一轮 LLM 看到的是更新过的最新文本。
|
||
private func roundDivider(round: Int, count: Int) -> some View {
|
||
HStack(spacing: 8) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
|
||
.font(.tjScaled( 11, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.brick)
|
||
Text(round == 1
|
||
? String(appLoc: "第 1 轮 · \(count) 条")
|
||
: String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
|
||
.font(.tjScaled( 11, weight: .semibold))
|
||
.tracking(0.3)
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
}
|
||
Rectangle()
|
||
.fill(Tj.Palette.line)
|
||
.frame(height: 1)
|
||
.mask(
|
||
HStack(spacing: 3) {
|
||
ForEach(0..<60, id: \.self) { _ in
|
||
Rectangle().frame(width: 3, height: 1)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
.padding(.top, round == 1 ? 0 : 6)
|
||
}
|
||
|
||
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
|
||
let adopted = question.adopted
|
||
let filling = fillingId == question.id
|
||
return VStack(alignment: .leading, spacing: 6) {
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Text("\(index).")
|
||
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
|
||
Text(question.q)
|
||
.font(.tjScaled( 13, weight: .medium))
|
||
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
|
||
.strikethrough(adopted, color: Tj.Palette.text3)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Spacer(minLength: 4)
|
||
|
||
if adopted {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "checkmark")
|
||
.font(.tjScaled( 10, weight: .bold))
|
||
Text("已采纳")
|
||
.font(.tjScaled( 11, weight: .semibold))
|
||
}
|
||
.foregroundStyle(Tj.Palette.leaf)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 5)
|
||
.background(Capsule().fill(Tj.Palette.leafSoft))
|
||
} else if !filling {
|
||
Button { adopt(question) } label: {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "plus.circle.fill")
|
||
.font(.tjScaled( 12))
|
||
Text("采纳")
|
||
.font(.tjScaled( 12, weight: .semibold))
|
||
}
|
||
.foregroundStyle(Tj.Palette.paper)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(Capsule().fill(Tj.Palette.ink))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
if filling {
|
||
QuestionFillPanel(
|
||
template: question.fill,
|
||
values: $fillValues,
|
||
onCommit: { assembled in commitAdoption(question, text: assembled) },
|
||
onCancel: { closeFill() }
|
||
)
|
||
} else if !question.fill.isEmpty && !adopted {
|
||
HStack(alignment: .top, spacing: 4) {
|
||
Text("将追加:")
|
||
.font(.tjScaled( 11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
Text(question.fill)
|
||
.font(.tjScaled( 11))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(.leading, 22)
|
||
}
|
||
}
|
||
.padding(10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||
)
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
private func sectionLabel(_ text: String) -> some View {
|
||
Text(text)
|
||
.font(.tjScaled( 12, weight: .semibold))
|
||
.tracking(0.3)
|
||
.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() {
|
||
contentFocused = false
|
||
voiceNote = nil
|
||
voiceFlowTask = Task { @MainActor in
|
||
guard await dictation.requestAuthorization() else {
|
||
voiceDeniedAlert = true
|
||
return
|
||
}
|
||
do {
|
||
liveTranscript = ""
|
||
recordingSeconds = 0
|
||
try dictation.start { partial in liveTranscript = partial }
|
||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
|
||
// 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致)
|
||
recordingWatchdog = Task { @MainActor in
|
||
while !Task.isCancelled {
|
||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||
guard !Task.isCancelled, voicePhase == .recording else { return }
|
||
recordingSeconds += 1
|
||
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
|
||
stopVoiceAndOrganize()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
voiceNote = error.localizedDescription
|
||
voicePhase = .idle
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopVoiceAndOrganize() {
|
||
guard voicePhase == .recording else { return }
|
||
recordingWatchdog?.cancel()
|
||
voiceFlowTask = Task { @MainActor in
|
||
// 防御兜底:服务返回空(极端情况下实例丢失/最终结果丢失)时,
|
||
// 用 @State 里的实时字幕——那就是用户亲眼看到的已识别文字。
|
||
var transcript = (await dictation.stop())
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if transcript.isEmpty {
|
||
transcript = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
liveTranscript = transcript
|
||
guard !transcript.isEmpty else {
|
||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||
voiceNote = String(appLoc: "没听清,再试一次")
|
||
return
|
||
}
|
||
rawTranscript = transcript
|
||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
|
||
do {
|
||
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
|
||
guard !Task.isCancelled else { return }
|
||
appendToContent(result.text)
|
||
organizedAppended = result.text
|
||
lastRate = result.decodeRate
|
||
} catch is CancellationError {
|
||
// cancelOrganize 已处理回退,这里只收尾
|
||
} catch {
|
||
guard !Task.isCancelled else { return }
|
||
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||
organizedAppended = nil
|
||
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
||
}
|
||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||
}
|
||
}
|
||
|
||
/// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。
|
||
private func cancelOrganize() {
|
||
guard voicePhase == .organizing else { return }
|
||
voiceFlowTask?.cancel()
|
||
if let raw = rawTranscript {
|
||
appendToContent(raw)
|
||
organizedAppended = nil
|
||
voiceNote = String(appLoc: "已取消整理,填入原话")
|
||
}
|
||
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||
}
|
||
|
||
/// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。
|
||
private func revertToRawTranscript() {
|
||
guard let raw = rawTranscript,
|
||
let organized = organizedAppended,
|
||
let range = content.range(of: organized, options: .backwards) else { return }
|
||
withAnimation(.snappy(duration: 0.18)) {
|
||
content = content.replacingCharacters(in: range, with: raw)
|
||
organizedAppended = nil
|
||
}
|
||
}
|
||
|
||
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
||
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
||
private func requestSuggestions() {
|
||
suggestTask?.cancel()
|
||
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let covered = Array(coveredDims)
|
||
// 1. 主动收起键盘 —— 否则建议面板被键盘吃掉一半
|
||
contentFocused = false
|
||
// 2. 确保 sheet 在 large(用户可能下拉到 medium 又触发 AI)
|
||
if detent != .large {
|
||
withAnimation(.snappy(duration: 0.25)) {
|
||
detent = .large
|
||
}
|
||
}
|
||
exhaustedNote = false
|
||
phase = .loading
|
||
suggestTask = Task { @MainActor in
|
||
do {
|
||
let result = try await DiaryAssistService.shared.suggest(
|
||
content: snapshotContent,
|
||
coveredDimensions: covered
|
||
)
|
||
if Task.isCancelled { return }
|
||
// 客户端硬去重(不依赖 1.7B 听话):
|
||
// ① 维度已在往轮覆盖 → 丢;② 本轮内维度重复 → 丢;③ 文本与已有近似 → 丢。
|
||
let coveredSnapshot = coveredDims
|
||
var acceptedNorms = questions.map { Self.normalize($0.q) }
|
||
var batchDims = Set<String>()
|
||
let nextRound = currentRound + 1
|
||
let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
|
||
let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let norm = Self.normalize(q.q)
|
||
if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
|
||
if !dim.isEmpty, batchDims.contains(dim) { return nil }
|
||
if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
|
||
if !dim.isEmpty { batchDims.insert(dim) }
|
||
acceptedNorms.append(norm)
|
||
var stamped = q
|
||
stamped.round = nextRound
|
||
return stamped
|
||
}
|
||
withAnimation(.snappy(duration: 0.2)) {
|
||
if fresh.isEmpty {
|
||
exhaustedNote = true // 这轮没问出任何新维度
|
||
} else {
|
||
questions.append(contentsOf: fresh)
|
||
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
|
||
currentRound = nextRound
|
||
exhaustedNote = false
|
||
}
|
||
lastRate = result.decodeRate
|
||
phase = .ready
|
||
}
|
||
} catch is CancellationError {
|
||
if !Task.isCancelled {
|
||
phase = hasQuestions ? .ready : .idle
|
||
}
|
||
} catch {
|
||
if !Task.isCancelled {
|
||
phase = .failed(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 简单归一化:去空白 + 折叠成统一形式,用于客户端去重比对。
|
||
private static func normalize(_ s: String) -> String {
|
||
s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.replacingOccurrences(of: " ", with: "")
|
||
.replacingOccurrences(of: "?", with: "?")
|
||
}
|
||
|
||
/// 近似判重:归一化后相等,或字符集 Jaccard ≥ 0.8(抓「会/下」这类换一两字的重复)。
|
||
private static func isSimilar(_ a: String, _ b: String) -> Bool {
|
||
if a == b { return true }
|
||
let sa = Set(a), sb = Set(b)
|
||
guard !sa.isEmpty, !sb.isEmpty else { return false }
|
||
let inter = sa.intersection(sb).count
|
||
let union = sa.union(sb).count
|
||
return union > 0 && Double(inter) / Double(union) >= 0.8
|
||
}
|
||
|
||
private func cancelSuggestions() {
|
||
suggestTask?.cancel()
|
||
phase = hasQuestions ? .ready : .idle
|
||
}
|
||
|
||
/// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。
|
||
/// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
|
||
private func adopt(_ question: DiaryAssistService.Question) {
|
||
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
|
||
// 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。
|
||
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
|
||
return
|
||
}
|
||
withAnimation(.snappy(duration: 0.18)) {
|
||
fillingId = question.id
|
||
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
|
||
}
|
||
}
|
||
|
||
/// 关闭填空面板(取消)。
|
||
private func closeFill() {
|
||
withAnimation(.snappy(duration: 0.18)) {
|
||
fillingId = nil
|
||
fillValues = []
|
||
}
|
||
}
|
||
|
||
/// 提交采纳:把(填好的)整句追加到正文,标记 adopted,收起面板。
|
||
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
|
||
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
||
withAnimation(.snappy(duration: 0.18)) {
|
||
questions[idx].adopted = true
|
||
}
|
||
}
|
||
appendToContent(text)
|
||
fillingId = nil
|
||
fillValues = []
|
||
}
|
||
|
||
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
||
private func appendToContent(_ text: String) {
|
||
let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !toAppend.isEmpty else { return }
|
||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.isEmpty {
|
||
content = toAppend
|
||
} else if content.hasSuffix("\n") {
|
||
content += toAppend
|
||
} else {
|
||
content += "\n" + toAppend
|
||
}
|
||
}
|
||
|
||
private func submit() {
|
||
guard canSubmit else { return }
|
||
let entry = DiaryEntry(
|
||
content: content.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
createdAt: createdAt
|
||
)
|
||
ctx.insert(entry)
|
||
try? ctx.save()
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
DiaryQuickSheet()
|
||
}
|