Files
kangkang/康康/Features/Diary/DiaryQuickSheet.swift
link2026 abacf5c4f5 ```
feat(diary): 优化日记AI协助交互体验

- 添加promptBanner作为未开始协作时的醒目邀请横幅,包含圆形图标、标题和说明文字
- 重构assistSection使用switch语句处理careState不同状态,区分隐藏、prompt和其他状态
- 增加动画过渡效果消除聚焦/失焦切换时的布局跳动
- 优化thinking状态下的UI展示,添加AIFlowBar彩色呼吸条显示推理进度
- 修改requestSuggestions逻辑,进入推理时收起键盘以完整显示协作卡片

refactor(inference): 优化性能自检界面样式

- 将性能自检入口改为描边动作按钮(TjGhostButton),与引擎选择在视觉上区分开
- 调整未就绪状态下的禁用样式和提示文案

feat(localization): 添加新的本地化字符串

- 新增"追踪"和"记一笔"的多语言翻译,包括英语、日语和韩语

fix(diary): 增强AI问答解析稳定性

- 将最大token数从400提升至512,避免中文问题JSON被截断导致解析失败
- 实现salvageQuestionObjects方法作为终极兜底机制,逐个解析平衡的{...}对象
- 当外层wrapper解析失败时,仍可救回内部已闭合的问题对象,确保用户不被AI错误卡住

test(diary): 补充AI问答解析测试用例

- 添加截断对象恢复测试,验证maxTokens截断时前序完整问题的救回能力
- 添加wrapper key错误情况的恢复测试,确保模型输出格式异常时的容错性
```
2026-06-17 09:21:47 +08:00

842 lines
40 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 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
// 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)
)
// :,
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
careBarRow(compact: true)
}
}
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: 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()
}
.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:
EmptyView()
case .prompt:
// : banner(,,
// )
promptBanner
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)
}
/// banner()
/// : + + + ,
/// AI (:)
private var promptBanner: some View {
Button(action: requestSuggestions) {
HStack(spacing: 11) {
Image(systemName: "sparkles")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.brick))
VStack(alignment: .leading, spacing: 2) {
Text("让康康帮你把这条记得更全")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("从医生问诊角度提几个值得补充的细节 · 本机推理")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "arrow.right")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.brick.opacity(0.45), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!canRequestSuggest)
.opacity(canRequestSuggest ? 1 : 0.5)
}
/// `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):
HStack(spacing: 10) {
Image(systemName: "text.bubble.fill")
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(q.q)
.font(.tjScaled( compact ? 13 : 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(compact ? 1 : 2)
.fixedSize(horizontal: false, vertical: !compact)
Spacer(minLength: 6)
Button { skipCurrent(q) } label: {
Text("跳过")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
Button { recordCurrent(q) } label: {
careCapsule(icon: "plus", text: String(appLoc: "记一下"),
tint: Tj.Palette.ink, style: .filled, compact: compact)
}
.buttonStyle(.plain)
}
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 = ()
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())
}
// 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,
/// ,
/// ****:,+
/// (,)
/// (recordCurrent contentFocused = true),
///
private func requestSuggestions() {
suggestTask?.cancel()
contentFocused = 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
}
/// :,,
/// `assemble(values: [])` 退
/// 便, `[]`
private func recordCurrent(_ question: DiaryAssistService.Question) {
let stub = question.fill.isEmpty
? question.q
: DiaryFillTemplate.assemble(question.fill, values: [])
appendToContent(stub)
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
questions[idx].adopted = true
}
// :,
contentFocused = true
}
/// :, coveredDims,
/// prompt , questions
private func skipCurrent(_ question: DiaryAssistService.Question) {
skippedQuestionIDs.insert(question.id)
}
/// (,)
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()
}