Files
kangkang/康康/Features/Diary/DiaryQuickSheet.swift
link2026 30f75dc2cd ```
feat(DiaryQuickSheet): 添加AI追问问答功能和底部协作入口

- 新增currentAnswer状态管理追问输入,添加answerFocused状态独立处理键盘避让
- 移除键盘工具条的关心条,将AI协作入口固定到底部按钮
- 添加完整的问答式追问卡片组件,支持自由回答输入和加入日记功能
- 修改prompt阶段行为,不再在正文区显示邀请横幅
- 更新recordCurrent为answerCurrent,实现问题+答案一同加入日记的逻辑
- 调整底部操作栏布局,间距和内边距优化

refactor(InferenceSettingsView): 性能自检改为内联展开模式

- 将性能自检视图从导航链接改为当前页就地展开
- 添加showSelfTest状态控制展开收起动画
- 支持ModelSelfTestView内联嵌入模式,去除外层导航和背景

chore(Localizable): 同步更新本地化字符串资源

- 添加新的UI文本:加入日记、在这儿写下你的回答、康康帮你一起填等
- 修复部分字符串位置调整和翻译映射问题
- 同步更新多语言版本的翻译内容

style(RootView): 优化记一笔标签页视觉设计

- 为记一笔标签添加语音识别角标标识
- 使用麦克风图标配合加号突出长按语音直达功能
```
2026-06-17 10:05:32 +08:00

884 lines
42 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import SwiftData
/// sheet
/// DiaryEntry @Model;UI/, AI :
/// Qwen3 3-4 ,
/// q LLM ; row +
struct DiaryQuickSheet: View {
/// : 2×2 ,
/// false
var directWrite: Bool = false
@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 currentAnswer: String = ""
@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 questions (
/// coveredDims,),
@State private var skippedQuestionIDs: Set<UUID> = []
/// () true,
@State private var exhaustedNote = false
/// sheet detent large,
/// medium,()
@State private var detent: PresentationDetent = .large
@FocusState private var contentFocused: Bool
/// contentFocused :,
/// ,
@FocusState private var answerFocused: 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 }
// MARK: - (care bar)
/// : phase + + ,
/// ,
private enum CareState {
case hidden // / ,
case prompt // ,
case thinking //
case asking(DiaryAssistService.Question) //
case caughtUp(exhausted: Bool) // ;exhausted=西
case failed(String)
}
/// / ()
private var pendingQuestions: [DiaryAssistService.Question] {
questions.filter { !$0.adopted && !skippedQuestionIDs.contains($0.id) }
}
private var currentCareQuestion: DiaryAssistService.Question? { pendingQuestions.first }
private var careState: CareState {
if voicePhase != .idle { return .hidden }
switch phase {
case .loading:
return .thinking
case .failed(let err):
return .failed(err.localizedDescription)
case .idle:
return hasContent ? .prompt : .hidden
case .ready:
if let q = currentCareQuestion { return .asking(q) }
return hasContent ? .caughtUp(exhausted: exhaustedNote) : .hidden
}
}
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("记录身体状态 · 康康在一旁帮你想还能记点啥")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("本机保存")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 10)
// ( / / / ):
// ,,
//(/)
modeSelector
.animation(.snappy(duration: 0.22), value: showModeSelector)
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: "exclamationmark.circle.fill")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.amber)
Text(note)
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text2)
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()
}
}
.padding(.horizontal, 20)
.padding(.bottom, 6)
}
.scrollDismissesKeyboard(.interactively)
HStack(spacing: 8) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
// :AI ,(spec 2026-06-17)
// flexible ;
Button(action: requestSuggestions) {
HStack(spacing: 5) {
Image(systemName: "sparkles")
.font(.tjScaled( 13, weight: .semibold))
.symbolEffect(.pulse, options: .repeating, isActive: isLoading)
Text(isLoading ? String(appLoc: "想想中…") : String(appLoc: "康康帮你一起填"))
.font(.tjScaled( 14, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.foregroundStyle(canRequestSuggest ? Tj.Palette.brick : Tj.Palette.text3)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick.opacity(canRequestSuggest ? 0.12 : 0.05))
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.brick.opacity(canRequestSuggest ? 0.4 : 0.15),
lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
Button("保存") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
.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()
}
.onAppear {
// :,,
// sheet ,
guard directWrite else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
contentFocused = true
}
}
.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: - (care bar)
/// :() careState,
/// AI ,
@ViewBuilder
private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) {
if !contentFocused {
switch careState {
case .hidden, .prompt:
// prompt():AI
//,
EmptyView()
default:
// ( / / / ): + tok/s +
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "康康帮你记"))
Spacer(minLength: 0)
if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
careBarRow(compact: false)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.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)
)
}
}
}
// AI ,(,)
if !questions.isEmpty {
AIDisclaimerFooter()
}
}
// /( ),
.animation(.snappy(duration: 0.22), value: contentFocused)
}
/// `compact = true` ();
/// `compact = false` () careState
@ViewBuilder
private func careBarRow(compact: Bool) -> some View {
switch careState {
case .hidden:
EmptyView()
case .prompt:
Button(action: requestSuggestions) {
careCapsule(icon: "sparkles",
text: String(appLoc: "让康康帮你把这条记得更全"),
tint: Tj.Palette.brick, style: .soft, compact: compact)
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
case .thinking:
VStack(alignment: .leading, spacing: 9) {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.symbolEffect(.pulse, options: .repeating)
Text(lastRate > 0
? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate)
: String(appLoc: "康康在想想…"))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
Button(action: cancelSuggestions) {
Text("")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
// (Apple 线, / AIFlowBar)
// ,;keyboard toolbar(compact) GeometryReader
// , requestSuggestions ,thinking , compact
if !compact {
AIFlowBar()
}
}
case .asking(let q):
// ,asking
askingCard(q)
case .caughtUp(let exhausted):
Button(action: requestSuggestions) {
careCapsule(
icon: exhausted ? "checkmark.seal.fill" : "sparkles",
text: exhausted
? String(appLoc: "主要的都帮你问到啦 · 再想想?")
: String(appLoc: "还想到几个想问你 · 再来一轮"),
tint: exhausted ? Tj.Palette.leaf : Tj.Palette.brick,
style: .soft, compact: compact)
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
case .failed(let msg):
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.brick)
Text(msg)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Spacer(minLength: 0)
Button(action: requestSuggestions) {
Text("重试")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(.plain)
}
}
}
private enum CareCapsuleStyle { case filled, soft }
/// filled = ;soft = (,prompt / caughtUp )
private func careCapsule(icon: String, text: String, tint: Color,
style: CareCapsuleStyle, compact: Bool) -> some View {
HStack(spacing: 5) {
Image(systemName: icon)
.font(.tjScaled( compact ? 11 : 12, weight: .semibold))
Text(text)
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.lineLimit(1)
}
.foregroundStyle(style == .filled ? Tj.Palette.paper : tint)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12)))
.contentShape(Capsule())
}
/// ( / )
private var answerTrimmed: String {
currentAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func answerPlaceholder(for q: DiaryAssistService.Question) -> String {
q.dim.isEmpty
? String(appLoc: "在这儿写下你的回答…")
: String(appLoc: "说说「\(q.dim)」的情况…")
}
/// ():
/// · :(spec 2026-06-17)
@ViewBuilder
private func askingCard(_ q: DiaryAssistService.Question) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "text.bubble.fill")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(q.q)
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 6)
if pendingQuestions.count > 1 {
Text(String(format: String(appLoc: "还有 %d 个"), pendingQuestions.count - 1))
.font(.tjScaled( 10, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
}
TextField(answerPlaceholder(for: q), text: $currentAnswer, axis: .vertical)
.font(.tjScaled( 13))
.lineLimit(1...4)
.focused($answerFocused)
.submitLabel(.done)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(answerFocused ? Tj.Palette.brick.opacity(0.5) : Tj.Palette.line,
lineWidth: 1)
)
HStack(spacing: 10) {
Button { skipCurrent(q) } label: {
Text("跳过这条")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
Spacer(minLength: 0)
Button { answerCurrent(q) } label: {
HStack(spacing: 5) {
Image(systemName: "text.append")
.font(.tjScaled( 12, weight: .semibold))
Text("加入日记")
.font(.tjScaled( 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Capsule().fill(answerTrimmed.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(answerTrimmed.isEmpty)
}
}
}
// MARK: - Actions
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
/// + +
/// / ,,
private var showModeSelector: Bool {
!directWrite && !contentFocused && !hasContent
}
/// (2×2):()/ (+)/ ()/
@ViewBuilder
private var modeSelector: some View {
if showModeSelector {
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)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
/// ( / / )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 {
#if DEBUG
print("[DiaryVoice] dictation start failed: \(error)")
#endif
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
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,
/// ,
/// ****:,+
/// (,)
/// :
///· :(answerCurrent),
private func requestSuggestions() {
suggestTask?.cancel()
contentFocused = false
answerFocused = false
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims)
exhaustedNote = false
withAnimation(.snappy(duration: 0.2)) { 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
}
/// :· :,
/// ,(,)
/// question.dim(//); dim
/// 退· +(spec 2026-06-17)
private func answerCurrent(_ question: DiaryAssistService.Question) {
let answer = currentAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !answer.isEmpty else { return }
let dim = question.dim.trimmingCharacters(in: .whitespacesAndNewlines)
appendToContent(dim.isEmpty ? "· \(answer)" : "· \(dim):\(answer)")
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
questions[idx].adopted = true
}
currentAnswer = ""
// ;,
if pendingQuestions.isEmpty { answerFocused = false }
}
/// :, coveredDims,
/// prompt , questions ;
/// ()
private func skipCurrent(_ question: DiaryAssistService.Question) {
skippedQuestionIDs.insert(question.id)
currentAnswer = ""
if pendingQuestions.isEmpty { answerFocused = false }
}
/// (,)
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()
}