Files
kangkang/康康/Features/Diary/DiaryQuickSheet.swift
link2026 9d856fcfc4 ```
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,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
2026-06-15 09:24:59 +08:00

885 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 {
@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()
}