Skip to main content

ChatViewModel

The central view model for LucidPal's conversation engine. ChatViewModel owns the full message lifecycle — from user input through LLM streaming to action parsing and persistence.

Role and Responsibility

ChatViewModel is a @MainActor final class that conforms to ObservableObject. It owns everything visible in the chat screen and coordinates all background work via Swift structured concurrency.

Owned by ChatViewModelDelegated to service
messages: [ChatMessage] stateLLM inference (LLMServiceProtocol)
Input text, image/doc attachmentsSpeech recognition (SpeechServiceProtocol)
Generation / loading / speech UI flagsCalendar CRUD (CalendarServiceProtocol)
Session title and reply-to stateHistory persistence (ChatHistoryManagerProtocol)
Pinned prompts listPrompt building (SystemPromptBuilderProtocol)
In-chat search / filterHaptic feedback (HapticServiceProtocol)
Error banner + auto-dismissLive Activity (LiveActivityServiceProtocol)

Extension File Breakdown

The class is split across seven files to keep each under ~300 lines:

FileResponsibility
ChatViewModel.swiftCore state (@Published properties), init, image/doc/pinned-prompt helpers
ChatViewModelDependencies.swiftValue-type dependency bundle passed to init
ChatViewModel+MessageHandling.swiftsendMessage(), streaming, web search, finalizeResponse()
ChatViewModel+CalendarConfirmation.swiftUser confirmation/cancellation of calendar actions in message cards
ChatViewModel+Speech.swifttoggleSpeech(), confirmSpeech(), cancelSpeech()
ChatViewModel+Publishers.swiftsetupPublishers() — all Combine subscriptions
ChatViewModel+Persistence.swiftsanitizeStaleState(), clearHistory(), flushPersistence()

Key @Published Properties

PropertyDrives
messagesChat bubble list, date separators, search filter
isGeneratingSend button → stop button swap, streaming indicator
isPreparingGeneratingStatusView shown during system-prompt build
isModelLoaded / isModelLoadingInput bar enabled state, loading overlay
inputTextInput field text (bidirectional, also written by speech)
isSpeechRecording / isSpeechTranscribingMic button animation states
isAutoListeningAirPods auto-listen indicator
thinkingEnabledWhether <think> blocks are parsed and shown
replyingToQuote strip above the input bar
errorMessageError banner (auto-dismissed after ChatConstants.errorAutoDismissSeconds)
toastTransient toast notifications
suggestedPromptsPrompt chip row shown on empty state
sessionTitleNavigation bar title
pinnedPromptsPinned prompt chips above input bar

Send-Message Flow

User taps Send


sendMessage() — guard: not empty, not generating, model loaded

├─ needsVision? → prepareVisionModel() (download mmproj if missing, load model)

├─ Append ChatMessage(role: .user) to messages[]
├─ Auto-title session from first user message
├─ Snapshot historyMessages (suffix by RAM-based limit)
├─ Append ChatMessage(role: .assistant, content: "") — placeholder visible immediately

├─ await systemPromptBuilder.buildSystemPrompt()
│ └─ Prepend extracted document text if doc attachments present


streamLLMResponse()
│ withThrowingTaskGroup — race generation vs. timeout task

├─ runGenerationLoop()
│ └─ for try await token in llmService.generate(...)
│ └─ applyStreamToken() — strips <think>...</think>, updates messages[idx] in-place

└─ first task to finish wins; other is cancelled

├─ LLMError.timeout → append "*(Response timed out)*"
├─ CancellationError → remove empty placeholder
└─ other Error → set messages[idx].content = "Error: ..."


finalizeResponse()

├─ Web search: extract [WEB_SEARCH:{...}] → fetch results → synthesis pass
├─ Calendar: executeCalendarActions() → attach CalendarEventPreview[] to message
├─ Notes: executeNoteActions() → attach NotePreviews[]
├─ Contacts: executeContactsSearch() → attach ContactResults[]
├─ Habits: executeHabitActions() → attach HabitPreviews[]
└─ Reminders: executeReminderActions() → attach ReminderPreviews[]

Think-Block Parsing

applyStreamToken() handles Qwen3's <think>…</think> prefix inline during streaming:

func applyStreamToken(
_ token: String,
rawBuffer: inout String,
thinkDone: inout Bool,
showThinking: Bool,
idx: Int
)
  • While inside <think>: sets messages[idx].isThinking = true, writes to thinkingContent if thinkingEnabled
  • Once </think> is found: strips block, sets thinkDone = true, routes remaining tokens to content
  • If no <think> prefix: sets thinkDone = true immediately on first token

Calendar Confirmation Lifecycle

Calendar event previews embedded in assistant messages go through a state machine:

AI creates event → preview.state = .created / .pendingDeletion / .pendingUpdate


User sees confirmation card in ChatBubble

┌────┴─────────────────────────────────┐
│ │
confirmDeletion() cancelDeletion()
│ │
▼ ▼
state = .deleted state = .deletionCancelled

undoDeletion() → re-creates event via CalendarService


state = .restored

── Update path ──────────────────────────────────────
confirmUpdate()
└─ calendarService.applyUpdate(pendingUpdate, to: identifier)
└─ Mirror changed fields onto preview
└─ pendingUpdate = nil
└─ state = .updated / .rescheduled
(CalendarError.eventNotFound → state = .updateCancelled, pendingUpdate = nil)

cancelUpdate()
└─ state = .updateCancelled
└─ pendingUpdate = nil

── Conflict path ─────────────────────────────────────
hasConflict = true → conflict card shown
├─ keepConflict() → clears conflict indicators
├─ cancelConflict() → deletes event, state = .deleted
└─ rescheduleConflict() → applyUpdate to free slot, state = .rescheduled

findFreeSlotsForConflict() searches a 7-day window using CalendarFreeSlotEngine, merging busy windows and returning gaps matching the event's duration (capped at 4 h; 2 h for all-day events).

Speech Integration

User taps mic button


toggleSpeech()

├─ isRecording = false → startRecording() + hapticService.voiceStarted()
└─ isRecording = true → confirmSpeech()
└─ stopRecording() + hapticService.voiceDone()

cancelSpeech()
└─ suppressSpeechAutoSend = true
discardNextTranscript = true
stopRecording() + hapticService.voiceCancelled()

Live transcript updates flow through speechService.transcriptPublisher:

speechService emits partial transcript

▼ (Publisher in setupPublishers)
inputText = transcript ← unless discardNextTranscript

Auto-send on silence:

speechService.isRecordingPublisher emits false

├─ discardNextTranscript? → clear inputText, skip send
├─ suppressSpeechAutoSend? → skip send
└─ settings.speechAutoSendEnabled + inputText non-empty → sendMessage()

suppressSpeechAutoSend is set only when the user manually taps the mic to stop (preserving auto-send for the natural silence-timeout path). voiceAutoStartActive tracks AirPods-triggered auto-start sessions.

Combine Publisher Subscriptions

All subscriptions are set up in setupPublishers(), called once from init:

PublisherAction
llmService.isLoadingPublisherisModelLoading = $0
llmService.isLoadedPublisherisModelLoaded = $0; kick off generateSuggestedPrompts() if empty
llmService.isGeneratingPublisherisGenerating = $0
llmService.contextTruncatedPublisherShow "conversation trimmed" toast
speechService.isRecordingPublisherisSpeechRecording = $0; auto-send on false
speechService.isAuthorizedPublisherisSpeechAvailable = $0
speechService.isTranscribingPublisherisSpeechTranscribing = $0
speechService.transcriptPublisherinputText = transcript (filtered when discarding)
speechService.transcriptionErrorPublishererrorMessage = $0
airPodsCoordinator?.isAutoListeningPublisherisAutoListening = $0
$errorMessageAuto-dismiss after ChatConstants.errorAutoDismissSeconds
$messages (debounced)Persist to session or history manager

Persistence

On change (debounced)

$messages
.debounce(for: .seconds(ChatConstants.persistenceDebounceSeconds), scheduler: RunLoop.main)
.sink { msgs in
if sessionManager != nil {
sm.save(ChatSession(..., messages: msgs)) // → sessions/<uuid>.json
onSessionUpdated?(session.meta)
} else {
history.save(msgs) // → chat_history.json (legacy)
}
}

On app background

flushPersistence() writes synchronously (no debounce) — called by the app delegate when entering background.

On launch

init loads from session?.messages ?? historyManager.load(), then calls sanitizeStaleState():

static func sanitizeStaleState(_ messages: inout [ChatMessage]) {
// Clear stuck isThinking flags
// Replace interrupted [WEB_SEARCH:] content with "*(Search was interrupted.)*"
// Strip raw [CALENDAR_ACTION:] blocks never executed
// Remove empty assistant placeholders (killed before any token arrived)
}

Session mode replaces historyManager with NoOpChatHistoryManager so chat_history.json is never written.

ChatViewModelDependencies Pattern

ChatViewModelDependencies is a plain struct that bundles all service protocols:

struct ChatViewModelDependencies {
let llmService: any LLMServiceProtocol
let calendarService: any CalendarServiceProtocol
let settings: any AppSettingsProtocol
let systemPromptBuilder: any SystemPromptBuilderProtocol
let suggestedPromptsProvider: any SuggestedPromptsProviderProtocol
let speechService: any SpeechServiceProtocol
let hapticService: any HapticServiceProtocol
let historyManager: any ChatHistoryManagerProtocol
// Optional services — default nil
let airPodsCoordinator: (any AirPodsVoiceCoordinatorProtocol)?
let webSearchService: (any WebSearchServiceProtocol)?
let pinnedPromptsStore: (any PinnedPromptsStoreProtocol)?
let liveActivityService: (any LiveActivityServiceProtocol)?
let documentProcessor: (any DocumentProcessorProtocol)?
}

Why a bundle struct? The ChatViewModel init would otherwise take 13+ parameters. The struct reduces the call site to 5 parameters (dependencies, session, sessionManager, onSessionUpdated, pendingInput) and makes optional services explicit at the struct level with default nil values.

All required services are non-optional; optional services (AirPods, web search, live activity, document processing) default to nil so ChatViewModel gracefully degrades when a feature is unavailable or disabled.