Files
oai-swift/oAI/ViewModels/ChatViewModel.swift
T
rune 3414e37e24 Add per-conversation notes.md
Gives each conversation an opt-in, persistent memory file the model reads
automatically every turn and writes to on its own initiative via a fenced
```update-notes``` block in its reply — no per-write approval, matching the
Confab-as-CLAUDE.md-for-itself concept Rune wanted. /notes on|off|show,
files live in ~/Library/Application Support/oAI/notes/, embedded ID header
for future Git Sync compatibility. Adds DB migration v12.
2026-08-04 07:58:47 +02:00

2622 lines
114 KiB
Swift

//
// ChatViewModel.swift
// Confab
//
// Main chat view model
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
import os
import SwiftUI
import UniformTypeIdentifiers
#if os(macOS)
/// Backing object for the Save Chat accessory view's name field + folder picker. A plain
/// NSObject is needed here (rather than living on `ChatViewModel`) because `NSPopUpButton`/
/// `NSMenuItem` actions require an `@objc` target, and the `@Observable` `ChatViewModel` isn't one.
private final class ConversationSaveAccessory: NSObject {
let container: NSView
let nameField: NSTextField
private let folderPopup: NSPopUpButton
private var folders: [Folder]
private var entries: [(folder: Folder, depth: Int)] = [] // recomputed in rebuildMenu whenever folders changes
private var lastGoodSelection: UUID? // the folder to revert to if "New Folder…" is cancelled
init(defaultName: String, folders: [Folder], selectedFolderId: UUID?) {
self.folders = folders
self.lastGoodSelection = selectedFolderId
container = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 58))
nameField = NSTextField(frame: NSRect(x: 0, y: 30, width: 260, height: 24))
folderPopup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
super.init()
nameField.placeholderString = "Conversation name…"
nameField.stringValue = defaultName
container.addSubview(nameField)
rebuildMenu(selecting: selectedFolderId)
folderPopup.target = self
folderPopup.action = #selector(popupChanged)
container.addSubview(folderPopup)
}
private func rebuildMenu(selecting folderId: UUID?) {
entries = Folder.orderedTree(from: folders)
folderPopup.removeAllItems()
folderPopup.addItem(withTitle: "No Folder")
for entry in entries {
folderPopup.addItem(withTitle: String(repeating: " ", count: entry.depth) + entry.folder.name)
}
folderPopup.menu?.addItem(.separator())
folderPopup.addItem(withTitle: "New Folder…")
if let folderId, let idx = entries.firstIndex(where: { $0.folder.id == folderId }) {
folderPopup.selectItem(at: idx + 1)
} else {
folderPopup.selectItem(at: 0)
}
}
@objc private func popupChanged() {
let lastIndex = folderPopup.numberOfItems - 1
guard folderPopup.indexOfSelectedItem == lastIndex else {
lastGoodSelection = resolvedFolderId
return
}
// "New Folder…" was picked — prompt inline, then create + select, or revert.
let nameAlert = NSAlert()
nameAlert.messageText = "New Folder"
nameAlert.informativeText = "Enter a name for the new folder:"
nameAlert.addButton(withTitle: "Create")
nameAlert.addButton(withTitle: "Cancel")
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
nameAlert.accessoryView = field
nameAlert.window.initialFirstResponder = field
guard nameAlert.runModal() == .alertFirstButtonReturn else {
rebuildMenu(selecting: lastGoodSelection)
return
}
let newName = field.stringValue.trimmingCharacters(in: .whitespaces)
guard !newName.isEmpty, let created = try? DatabaseService.shared.createFolder(name: newName) else {
rebuildMenu(selecting: lastGoodSelection)
return
}
folders.append(created)
rebuildMenu(selecting: created.id)
lastGoodSelection = .some(created.id)
}
var resolvedFolderId: UUID? {
let idx = folderPopup.indexOfSelectedItem
guard idx >= 1, idx - 1 < entries.count else { return nil }
return entries[idx - 1].folder.id
}
}
#endif
@Observable
@MainActor
class ChatViewModel {
// MARK: - Observable State
var messages: [Message] = []
var inputText: String = ""
var isGenerating: Bool = false
var sessionStats = SessionStats()
var selectedModel: ModelInfo?
var currentProvider: Settings.Provider = .openrouter
var onlineMode: Bool = false
var memoryEnabled: Bool = true
var mcpEnabled: Bool = false
var mcpStatus: String? = nil
var availableModels: [ModelInfo] = []
var isLoadingModels: Bool = false
var showConversations: Bool = false
var showModelSelector: Bool = false
var showSettings: Bool = false
var showStats: Bool = false
var showHelp: Bool = false
var showCredits: Bool = false
var showHistory: Bool = false
var releaseNotesRequest: ReleaseNotesRequest? = nil
var showShortcuts: Bool = false
var showSkills: Bool = false
var showJarvis: Bool = false
var modelInfoTarget: ModelInfo? = nil
var commandHistory: [String] = []
var historyIndex: Int = 0
private var silentContinuePrompt: String? = nil
// Save tracking
var currentConversationId: UUID? = nil
var currentConversationName: String? = nil
private var savedMessageCount: Int = 0
// Per-conversation notes.md (see ConversationNotesService)
var notesEnabled: Bool = false
var notesFilename: String? = nil
var hasUnsavedChanges: Bool {
let chatCount = messages.filter { $0.role != .system }.count
return chatCount > 0 && chatCount != savedMessageCount
}
// MARK: - Crash-Recovery Draft
private var draftTimer: Timer?
private var lastDraftFingerprint: Int?
private var hasCheckedForCrashRecoveryDraft = false
// MARK: - Private State
private var streamingTask: Task<Void, Never>?
private let settings = SettingsService.shared
private let providerRegistry = ProviderRegistry.shared
// Default system prompt - generic for all models
private let defaultSystemPrompt = """
You are a helpful AI assistant. Follow these core principles:
## CORE BEHAVIOR
- **Accuracy First**: Never invent information. If unsure, say so clearly.
- **Ask for Clarification**: When ambiguous, ask questions before proceeding.
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
- **Complete Tasks Properly**: If you start something, finish it correctly.
- **Match the User's Language**: Always reply in the same language the user is writing in, even when the request itself involves another language (e.g. a translation or spelling request) — the target language applies to the content you produce, not to your own reply.
## FORMATTING
Always use Markdown formatting:
- **Bold** for emphasis
- Code blocks with language tags: ```python
- Headings (##, ###) for structure
- Lists for organization
## HONESTY
It's better to admit "I need more information" or "I cannot do that" than to fake completion or invent answers.
"""
// Tool-specific instructions (added when tools are available)
private let toolUsageGuidelines = """
## TOOL USAGE (You have access to tools)
**CRITICAL: Never claim to have done something without actually using the tools.**
**BAD Examples (NEVER do this):**
❌ "I've fixed the issue" → No tool calls shown
❌ "The file has been updated" → Didn't actually use the tool
❌ "Done!" → Claimed completion without evidence
**GOOD Examples (ALWAYS do this):**
✅ [Uses tool silently] → Then explain what you did
✅ Shows actual work through tool calls
✅ If you can't complete: "I found the issue, but need clarification..."
**ENFORCEMENT:**
- If a task requires using a tool → YOU MUST actually use it
- Don't just describe what you would do - DO IT
- The user can see your tool calls - they are proof you did the work
- NEVER say "fixed" or "done" without showing tool usage
**EFFICIENCY:**
- Some models have tool call limits (~25-30 per request)
- Make comprehensive changes in fewer tool calls when possible
- If approaching limits, tell the user you need to continue in phases
- Don't use tools to "verify" your work unless asked - trust your edits
**FILE EDITING — CRITICAL:**
- NEVER use write_file to rewrite a large existing file (>200 lines or >8KB)
- Large write_file calls exceed output token limits and will fail — the content will be truncated
- For existing files: ALWAYS use edit_file to replace specific sections
- Use write_file ONLY for new files or very small files (<100 lines)
- If you need to make many changes to a large file, use multiple edit_file calls
**METHODOLOGY:**
1. Use the tool to gather information (if needed)
2. Use the tool to make changes (if needed)
3. Explain what you did and why
Don't narrate future actions ("Let me...") - just use the tools.
"""
/// Builds the complete system prompt by combining default + conditional sections + custom
private var effectiveSystemPrompt: String {
// Tool guidance, the user's custom prompt, and Agent Skills all generally assume tool
// access and can easily blow past small context windows (e.g. Apple's on-device 4K
// limit) — skip all three for tool-incapable models.
let modelSupportsTools = selectedModel?.capabilities.tools ?? true
// Check if user wants to replace the default prompt entirely (BYOP mode)
if modelSupportsTools,
settings.customPromptMode == .replace,
let customPrompt = settings.systemPrompt,
!customPrompt.isEmpty {
// BYOP: use ONLY the custom prompt, but conversation notes are a non-overridable
// instruction — they must survive even when the user has replaced everything else.
return customPrompt + Self.buildNotesSection(body: currentNotesBody)
}
// Otherwise, build the prompt: default + conditional sections + custom (if append mode)
var prompt = defaultSystemPrompt
// Prepend model identity to prevent models trained on Claude data from misidentifying themselves.
// Skip for direct Anthropic/OpenAI providers — those models know who they are.
if let model = selectedModel,
currentProvider != .anthropic && currentProvider != .openai {
prompt = "You are \(model.name).\n\n" + prompt
}
// Add tool-specific guidelines if MCP is enabled (tools are available)
if mcpEnabled && modelSupportsTools {
prompt += toolUsageGuidelines
}
// Append custom prompt if in append mode and custom prompt exists
if modelSupportsTools,
settings.customPromptMode == .append,
let customPrompt = settings.systemPrompt,
!customPrompt.isEmpty {
prompt += "\n\n---\n\nAdditional Instructions:\n" + customPrompt
}
// Append active agent skills (SKILL.md-style behavioral instructions)
if modelSupportsTools {
let activeSkills = settings.agentSkills.filter { $0.isActive }
if !activeSkills.isEmpty {
prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n"
for skill in activeSkills {
prompt += "### \(skill.name)\n\n\(skill.content)\n\n"
let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id)
if !files.isEmpty {
prompt += "**Skill Data Files:**\n\n"
for (name, content) in files {
let ext = URL(fileURLWithPath: name).pathExtension.lowercased()
prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n"
}
}
}
}
}
// Append conversation notes (see ConversationNotesService). Deliberately outside the
// modelSupportsTools gate above — fenced-block writes exist specifically so tool-incapable
// models (e.g. Apple On-Device) can still use this.
prompt += Self.buildNotesSection(body: currentNotesBody)
return prompt
}
/// The current conversation's notes body if notes are enabled, nil otherwise. Empty string
/// means notes are on but nothing has been written yet.
private var currentNotesBody: String? {
guard notesEnabled, let filename = notesFilename else { return nil }
return ConversationNotesService.shared.readBody(filename: filename) ?? ""
}
/// Builds the "## Conversation Notes" system prompt section. Pure/testable: nil body means
/// notes are off for this conversation and nothing is appended.
nonisolated static func buildNotesSection(body: String?) -> String {
guard let body else { return "" }
return """
---
## Conversation Notes
You maintain a persistent memory file for this specific conversation, saved outside the chat and re-read at the start of every turn. Use it for durable facts, preferences, or context worth keeping across the whole conversation — not a transcript, not every detail.
To update it, include this block anywhere in your reply — it is invisible to the user and will not appear in the chat:
```update-notes
<the complete new contents of the notes file>
```
Only include the block when you actually want to change the notes. Each one replaces the previous contents entirely, so include everything worth keeping, not just what changed. If the user directly asks you to add, change, or remove something from the notes, comply using this same mechanism.
Current notes:
\(body.isEmpty ? "(empty — nothing saved yet)" : body)
"""
}
/// Detects a ```update-notes fenced block in a finalized assistant message, strips it from
/// the text that will actually be displayed, and returns its body separately so it can be
/// persisted via ConversationNotesService. Only call this once a message is fully finalized
/// (never on in-flight streaming deltas) — stripping mid-stream would flash the block and
/// then remove it.
nonisolated static func extractNotesUpdate(from content: String) -> (display: String, notesBody: String?) {
let pattern = #"```update-notes\s*\n([\s\S]*?)```"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)),
let bodyRange = Range(match.range(at: 1), in: content),
let fullRange = Range(match.range(at: 0), in: content)
else {
return (content, nil)
}
let body = String(content[bodyRange]).trimmingCharacters(in: .whitespacesAndNewlines)
var display = content
display.removeSubrange(fullRange)
display = display.trimmingCharacters(in: .whitespacesAndNewlines)
return (display, body)
}
/// Applies extractNotesUpdate at message finalization: writes any extracted notes body to
/// disk and returns the content with the fenced block stripped. No-op (returns content
/// unchanged) unless notes are enabled and the conversation has an ID and filename.
private func applyNotesUpdateIfNeeded(_ content: String) -> String {
guard notesEnabled, let conversationId = currentConversationId, let filename = notesFilename else {
return content
}
let (display, notesBody) = Self.extractNotesUpdate(from: content)
if let notesBody {
ConversationNotesService.shared.write(body: notesBody, filename: filename, conversationId: conversationId)
}
return display
}
// MARK: - Initialization
init() {
// Load settings
self.currentProvider = settings.defaultProvider
self.onlineMode = settings.onlineMode
self.memoryEnabled = settings.memoryEnabled
self.mcpEnabled = settings.mcpEnabled
// Load command history from database
if let history = try? DatabaseService.shared.loadCommandHistory() {
self.commandHistory = history.map { $0.input }
self.historyIndex = self.commandHistory.count
}
// Load models on startup
Task {
await loadAvailableModels()
}
startDraftTimer()
}
// MARK: - Public Methods
/// Switch to a different provider (from header dropdown)
func changeProvider(_ newProvider: Settings.Provider) {
guard newProvider != currentProvider else { return }
Log.ui.info("Switching provider to \(newProvider.rawValue)")
settings.defaultProvider = newProvider
currentProvider = newProvider
selectedModel = nil
availableModels = []
Task { await loadAvailableModels() }
}
/// Start a new conversation — gated behind the unsaved-changes prompt if needed.
func newConversation() {
#if os(macOS)
confirmDiscardIfNeeded(then: performNewConversation)
#else
performNewConversation()
#endif
}
private func performNewConversation() {
messages = []
sessionStats = SessionStats()
inputText = ""
currentConversationId = nil
currentConversationName = nil
savedMessageCount = 0
notesEnabled = false
notesFilename = nil
}
/// Re-sync local state from SettingsService (called when Settings sheet dismisses)
func syncFromSettings() {
let newProvider = settings.defaultProvider
let providerChanged = currentProvider != newProvider
currentProvider = newProvider
onlineMode = settings.onlineMode
memoryEnabled = settings.memoryEnabled
mcpEnabled = settings.mcpEnabled
mcpStatus = mcpEnabled ? "MCP" : nil
startDraftTimer()
if providerChanged {
selectedModel = nil
availableModels = []
Task { await loadAvailableModels() }
}
}
func loadAvailableModels() async {
isLoadingModels = true
do {
guard let provider = providerRegistry.getCurrentProvider() else {
Log.ui.warning("No API key configured for current provider")
isLoadingModels = false
showSystemMessage("⚠️ No API key configured. Add your API key in Settings to load models.")
return
}
let models = try await provider.listModels()
availableModels = models
// Select model priority: saved default > current selection > first available
if let defaultModelId = settings.defaultModel,
let defaultModel = models.first(where: { $0.id == defaultModelId }) {
selectedModel = defaultModel
} else if selectedModel == nil, let firstModel = models.first {
selectedModel = firstModel
}
isLoadingModels = false
} catch {
Log.api.error("Failed to load models: \(error.localizedDescription)")
isLoadingModels = false
showSystemMessage("⚠️ Could not load models: \(error.localizedDescription)")
}
}
func sendMessage() {
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
// If already generating, cancel first — new message becomes a followup in context
if isGenerating { cancelGeneration() }
let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)
// Handle slash escape: "//" becomes "/"
var effectiveInput = trimmedInput
if effectiveInput.hasPrefix("//") {
effectiveInput = String(effectiveInput.dropFirst())
} else if effectiveInput.hasPrefix("/") {
// Check if it's a slash command
handleCommand(effectiveInput)
inputText = ""
return
}
// Parse file attachments
let (cleanText, filePaths) = effectiveInput.parseFileAttachments()
// Read file attachments from disk
let attachments: [FileAttachment]? = filePaths.isEmpty ? nil : readFileAttachments(filePaths)
// Create user message
let userMessage = Message(
role: .user,
content: cleanText,
tokens: cleanText.estimateTokens(),
cost: nil,
timestamp: Date(),
attachments: attachments,
modelId: selectedModel?.id
)
messages.append(userMessage)
sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)
// Persist the crash-recovery draft immediately rather than waiting for the next
// periodic tick — otherwise a force-quit shortly after sending loses this message.
persistDraftIfChanged()
// Generate embedding for user message
generateEmbeddingForMessage(userMessage)
// Add to command history (in-memory and database)
commandHistory.append(trimmedInput)
historyIndex = commandHistory.count
DatabaseService.shared.saveCommandHistory(input: trimmedInput)
// Clear input
inputText = ""
// Generate real AI response
generateAIResponse(to: cleanText, attachments: userMessage.attachments)
}
func cancelGeneration() {
streamingTask?.cancel()
streamingTask = nil
isGenerating = false
silentContinuePrompt = nil
}
func startAutoContinue() {
silentContinuePrompt = "Please continue from where you left off."
Task { @MainActor in
generateAIResponse(to: "", attachments: nil)
}
}
/// Clear the current chat — gated behind the unsaved-changes prompt if needed.
func clearChat() {
#if os(macOS)
confirmDiscardIfNeeded(then: performClearChat)
#else
performClearChat()
#endif
}
private func performClearChat() {
messages.removeAll()
sessionStats.reset()
MCPService.shared.resetBashSessionApproval()
showSystemMessage("Chat cleared")
}
/// Load a saved conversation — gated behind the unsaved-changes prompt if needed.
func loadConversation(_ conversation: Conversation) {
#if os(macOS)
confirmDiscardIfNeeded(then: { [weak self] in self?.performLoadConversation(conversation) })
#else
performLoadConversation(conversation)
#endif
}
private func performLoadConversation(_ conversation: Conversation) {
do {
guard let (loadedConversation, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
showSystemMessage("Could not load conversation '\(conversation.name)'")
return
}
messages.removeAll()
sessionStats.reset()
MCPService.shared.resetBashSessionApproval()
messages = loadedMessages
// Track identity so ⌘S can re-save under the same name
currentConversationId = conversation.id
currentConversationName = conversation.name
savedMessageCount = loadedMessages.filter { $0.role != .system }.count
notesEnabled = loadedConversation.notesEnabled
notesFilename = loadedConversation.notesFilename
// Rebuild session stats from loaded messages
for msg in loadedMessages {
sessionStats.addMessage(
inputTokens: msg.role == .user ? msg.tokens : nil,
outputTokens: msg.role == .assistant ? msg.tokens : nil,
cost: msg.cost
)
}
showSystemMessage("Loaded conversation '\(conversation.name)'")
// Auto-switch to the provider/model this conversation was created with
if let modelId = conversation.primaryModel {
Task { await switchToConversationModel(modelId) }
}
} catch {
showSystemMessage("Failed to load: \(error.localizedDescription)")
}
}
/// Infer which provider owns a given model ID based on naming conventions.
/// Update the selected model and keep currentProvider + settings in sync.
/// Call this whenever the user picks a model in the model selector.
func selectModel(_ model: ModelInfo) {
let newProvider = Self.inferProvider(from: model.id) ?? currentProvider
selectedModel = model
currentProvider = newProvider
MCPService.shared.resetBashSessionApproval()
}
func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }
nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
// Apple Foundation Models
if modelId.hasPrefix("apple-") { return .appleOnDevice }
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
if modelId.contains("/") { return .openrouter }
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
if modelId.hasPrefix("claude-") { return .anthropic }
// OpenAI direct
if modelId.hasPrefix("gpt-") || modelId.hasPrefix("o1") || modelId.hasPrefix("o3")
|| modelId.hasPrefix("dall-e-") || modelId.hasPrefix("chatgpt-") { return .openai }
// Ollama uses short local names with no vendor prefix
return .ollama
}
/// Silently switch provider + model to match a loaded conversation.
/// Shows a system message only on failure or on a successful switch.
@MainActor
private func switchToConversationModel(_ modelId: String) async {
guard let targetProvider = Self.inferProvider(from: modelId) else {
showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model")
return
}
guard providerRegistry.hasValidAPIKey(for: targetProvider) else {
showSystemMessage("⚠️ No API key for \(targetProvider.displayName) — keeping current model")
return
}
// Switch provider if needed, or load models if not yet loaded
if targetProvider != currentProvider || availableModels.isEmpty {
if targetProvider != currentProvider {
settings.defaultProvider = targetProvider
currentProvider = targetProvider
selectedModel = nil
availableModels = []
providerRegistry.clearCache()
}
isLoadingModels = true
do {
guard let provider = providerRegistry.getProvider(for: targetProvider) else {
isLoadingModels = false
showSystemMessage("⚠️ Could not connect to \(targetProvider.displayName) — keeping current model")
return
}
availableModels = try await provider.listModels()
isLoadingModels = false
} catch {
isLoadingModels = false
showSystemMessage("⚠️ Could not load \(targetProvider.displayName) models — keeping current model")
return
}
}
guard let model = availableModels.first(where: { $0.id == modelId }) else {
showSystemMessage("⚠️ Model '\(modelId)' not available — keeping current model")
return
}
guard selectedModel?.id != modelId else { return } // already on it, no message needed
selectedModel = model
showSystemMessage("Switched to \(model.name) · \(targetProvider.displayName)")
}
func retryLastMessage() {
guard let lastUserMessage = messages.last(where: { $0.role == .user }) else {
showSystemMessage("No previous message to retry")
return
}
// Remove last assistant response if exists
if let lastMessage = messages.last, lastMessage.role == .assistant {
messages.removeLast()
}
generateAIResponse(to: lastUserMessage.content, attachments: lastUserMessage.attachments)
}
func toggleMessageStar(messageId: UUID) {
// Update in-memory state first (works for both saved and unsaved messages)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].isStarred.toggle()
let newStarred = messages[index].isStarred
// Persist to DB if the message exists there (saved conversations only)
do {
try DatabaseService.shared.setMessageStarred(messageId: messageId, starred: newStarred)
Log.ui.info("Message \(messageId) starred: \(newStarred)")
} catch {
// FK error is expected for unsaved messages — in-memory state is already updated
Log.ui.debug("Star not persisted for unsaved message \(messageId): \(error)")
}
}
}
// MARK: - Quick Save
/// Called from the File menu — re-saves if already named, prompts for name + folder if not.
func saveFromMenu() {
#if os(macOS)
attemptSaveCurrentConversation()
#endif
}
/// Always prompts for a new name (and folder) and saves a fresh copy, switching the session to that copy.
func saveAsFromMenu() {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return }
#if os(macOS)
guard let details = promptForConversationDetails(title: "Save Chat As", defaultName: currentConversationName ?? "") else { return }
do {
let saved = try DatabaseService.shared.saveConversation(
id: UUID(), name: details.name, messages: chatMessages,
primaryModel: selectedModel?.id, folderId: details.folderId
)
currentConversationId = saved.id
currentConversationName = details.name
savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() }
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
}
#endif
}
#if os(macOS)
/// Shows the "Save Chat" alert with a name field and folder picker (including inline
/// "New Folder…" creation). Returns nil if the user cancels or leaves the name empty.
private func promptForConversationDetails(title: String = "Save Chat", defaultName: String) -> (name: String, folderId: UUID?)? {
let folders = (try? DatabaseService.shared.listFolders()) ?? []
let accessory = ConversationSaveAccessory(defaultName: defaultName, folders: folders, selectedFolderId: nil)
let alert = NSAlert()
alert.messageText = title
alert.informativeText = "Enter a name for this conversation:"
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
alert.accessoryView = accessory.container
alert.window.initialFirstResponder = accessory.nameField
guard alert.runModal() == .alertFirstButtonReturn else { return nil }
let name = accessory.nameField.stringValue.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return nil }
return (name, accessory.resolvedFolderId)
}
#endif
/// Called by ConversationListView after renaming a saved conversation.
/// Updates the in-session name if the renamed conversation is currently open.
func didRenameConversation(id: UUID, newName: String) {
if currentConversationId == id {
currentConversationName = newName
}
}
/// Re-save the current conversation under its existing name, or prompt if never saved.
func quickSave() {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return }
if let id = currentConversationId, let name = currentConversationName {
// Update existing saved conversation
do {
try DatabaseService.shared.updateConversation(
id: id, name: name, messages: chatMessages,
primaryModel: selectedModel?.id
)
savedMessageCount = chatMessages.count
showSystemMessage("Saved \"\(name)\"")
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
}
} else {
showSystemMessage("No name yet — use /save <name> to save this conversation")
}
}
// MARK: - Command Handling
private func handleCommand(_ command: String) {
guard let (cmd, args) = command.parseCommand() else {
showSystemMessage("Invalid command")
return
}
switch cmd.lowercased() {
case "/help":
showHelp = true
case "/history":
showHistory = true
case "/model":
showModelSelector = true
case "/clear":
clearChat()
case "/retry":
retryLastMessage()
case "/memory":
if let arg = args.first?.lowercased() {
memoryEnabled = arg == "on"
showSystemMessage(memoryEnabled ? "Memory enabled" : "Memory disabled")
} else {
showSystemMessage("Usage: /memory on|off")
}
case "/online":
if let arg = args.first?.lowercased() {
onlineMode = arg == "on"
showSystemMessage(onlineMode ? "Online mode enabled" : "Online mode disabled")
} else {
showSystemMessage("Usage: /online on|off")
}
case "/stats":
showStats = true
case "/config", "/settings":
showSettings = true
case "/provider":
if let providerName = args.first?.lowercased() {
if let provider = Settings.Provider.allCases.first(where: { $0.rawValue == providerName }) {
currentProvider = provider
showSystemMessage("Switched to \(provider.displayName) provider")
} else {
showSystemMessage("Unknown provider: \(providerName)")
}
} else {
showSystemMessage("Current provider: \(currentProvider.displayName)")
}
case "/save":
if let name = args.first {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else {
showSystemMessage("Nothing to save — no messages in this conversation")
return
}
do {
let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
currentConversationId = saved.id
currentConversationName = name
savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Conversation saved as '\(name)'")
} catch {
showSystemMessage("Failed to save: \(error.localizedDescription)")
}
} else {
// Re-save without a name if already saved; otherwise prompt
quickSave()
}
case "/load", "/list":
showConversations = true
case "/delete":
if let name = args.first {
do {
let deleted = try DatabaseService.shared.deleteConversation(name: name)
if deleted {
showSystemMessage("Deleted conversation '\(name)'")
} else {
showSystemMessage("No conversation found with name '\(name)'")
}
} catch {
showSystemMessage("Failed to delete: \(error.localizedDescription)")
}
} else {
showSystemMessage("Usage: /delete <name>")
}
case "/export":
if args.count >= 1 {
let format = args[0].lowercased()
let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
exportConversation(format: format, filename: filename)
} else {
showSystemMessage("Usage: /export md|html|pdf|json <filename>")
}
case "/info":
if let modelId = args.first {
if let model = availableModels.first(where: { $0.id == modelId || $0.name.lowercased() == modelId.lowercased() }) {
showModelInfo(model)
} else {
showSystemMessage("Model not found: \(modelId)")
}
} else if let model = selectedModel {
showModelInfo(model)
} else {
showSystemMessage("No model selected")
}
case "/credits":
showCredits = true
case "/shortcuts":
showShortcuts = true
case "/skills":
showSkills = true
case "/jarvis":
showJarvis = true
case "/mcp":
handleMCPCommand(args: args)
case "/notes":
handleNotesCommand(args: args)
default:
// Check user-defined shortcuts
if let shortcut = settings.userShortcuts.first(where: { $0.command == cmd.lowercased() }) {
let userInput = args.joined(separator: " ")
let prompt = shortcut.needsInput
? shortcut.template.replacingOccurrences(of: "{{input}}", with: userInput)
: shortcut.template
let msg = Message(
role: .user,
content: prompt,
tokens: prompt.estimateTokens(),
cost: nil,
timestamp: Date(),
attachments: nil,
modelId: selectedModel?.id
)
messages.append(msg)
sessionStats.addMessage(inputTokens: msg.tokens, outputTokens: nil, cost: nil)
generateEmbeddingForMessage(msg)
generateAIResponse(to: prompt, attachments: nil)
return
}
showSystemMessage("Unknown command: \(cmd)\nType /help for available commands")
}
}
// MARK: - AI Response Generation
private func generateAIResponse(to prompt: String, attachments: [FileAttachment]?) {
// Get provider
guard let provider = providerRegistry.getCurrentProvider() else {
Log.ui.warning("Cannot generate: no API key configured")
showSystemMessage("❌ No API key configured. Please add your API key in Settings.")
return
}
guard let modelId = selectedModel?.id else {
Log.ui.warning("Cannot generate: no model selected")
showSystemMessage("❌ No model selected. Please select a model first.")
return
}
Log.ui.info("Sending message: model=\(modelId), messages=\(self.messages.count)")
// Dispatch to tool-aware path when MCP is enabled with folders or Anytype is enabled
// Skip for image generation models — they don't support tool calling
let mcp = MCPService.shared
let mcpActive = mcpEnabled || settings.mcpEnabled
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
let bashActive = settings.bashEnabled
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
let researchAgentsActive = settings.agentsEnabled
let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
// Dedicated images API path (OpenRouter /images endpoint — separate from chat completions)
if selectedModel?.capabilities.usesImagesAPI == true,
let orProvider = provider as? OpenRouterProvider {
generateImageAPIResponse(orProvider: orProvider, modelId: modelId, prompt: prompt)
return
}
let modelSupportTools = selectedModel?.capabilities.tools ?? false
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId)
return
}
isGenerating = true
// Cancel any existing task
streamingTask?.cancel()
// Start streaming
streamingTask = Task {
let startTime = Date()
var messageId: UUID?
do {
// Create empty assistant message for streaming
let assistantMessage = Message(
role: .assistant,
content: "",
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil,
modelId: modelId,
isStreaming: true
)
messageId = assistantMessage.id
// Already on MainActor
messages.append(assistantMessage)
// Build chat request AFTER adding the assistant message
// Only include messages up to (but not including) the streaming assistant message
var messagesToSend = Array(messages.dropLast()) // Remove the empty assistant message
// Web search via our WebSearchService
// Append results to last user message content (matching Python oAI approach)
if onlineMode && currentProvider != .openrouter && !messagesToSend.isEmpty {
if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
Log.search.info("Running web search for \(currentProvider.displayName)")
let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
if !results.isEmpty {
let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
messagesToSend[lastUserIdx].content += searchContext
Log.search.info("Injected \(results.count) search results into user message")
}
}
}
let isImageGen = selectedModel?.capabilities.imageGeneration ?? false
if isImageGen {
Log.ui.info("Image generation mode for model \(modelId)")
}
// Smart context selection
let contextStrategy: SelectionStrategy
if !memoryEnabled {
contextStrategy = .lastMessageOnly
} else if settings.contextSelectionEnabled {
contextStrategy = .smart
} else {
contextStrategy = .allMessages
}
let contextWindow = ContextSelectionService.shared.selectContext(
allMessages: messagesToSend,
strategy: contextStrategy,
maxTokens: selectedModel?.contextLength ?? settings.contextMaxTokens,
currentQuery: messagesToSend.last?.content
)
if contextWindow.excludedCount > 0 {
Log.ui.info("Smart context: selected \(contextWindow.messages.count) messages (\(contextWindow.totalTokens) tokens), excluded \(contextWindow.excludedCount)")
}
// Build system prompt with summaries (if any)
var finalSystemPrompt = effectiveSystemPrompt
if !contextWindow.summaries.isEmpty {
let summariesText = contextWindow.summaries.enumerated().map { index, summary in
"[Previous conversation summary (part \(index + 1)):]\\n\(summary)"
}.joined(separator: "\n\n")
finalSystemPrompt = summariesText + "\n\n---\n\n" + effectiveSystemPrompt
}
let reasoningConfig: ReasoningConfig? = {
guard settings.reasoningEnabled,
selectedModel?.capabilities.thinking == true,
!isImageGen else { return nil }
return ReasoningConfig(effort: settings.reasoningEffort, exclude: settings.reasoningExclude)
}()
let chatRequest = ChatRequest(
messages: contextWindow.messages,
model: modelId,
stream: settings.streamEnabled,
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
temperature: settings.temperature > 0 ? settings.temperature : nil,
topP: nil,
systemPrompt: finalSystemPrompt,
tools: nil,
onlineMode: onlineMode,
imageGeneration: isImageGen,
reasoning: reasoningConfig
)
if isImageGen {
// Image generation: use non-streaming request
// Image models don't reliably support streaming
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = ThinkingVerbs.random()
}
let nonStreamRequest = ChatRequest(
messages: chatRequest.messages,
model: chatRequest.model,
stream: false,
maxTokens: chatRequest.maxTokens,
temperature: chatRequest.temperature,
imageGeneration: true
)
let response = try await withOverloadedRetry { try await provider.chat(request: nonStreamRequest) }
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = applyNotesUpdateIfNeeded(response.content)
messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages
messages[index].responseTime = responseTime
if let usage = response.usage {
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
}
} else {
// Regular text: stream response
var fullContent = ""
var fullThinking = ""
var collectedImages: [Data] = []
var totalTokens: ChatResponse.Usage? = nil
var wasCancelled = false
for try await chunk in provider.streamChat(request: chatRequest) {
if Task.isCancelled {
wasCancelled = true
break
}
if let thinking = chunk.delta.thinking {
fullThinking += thinking
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].thinkingContent = fullThinking
}
}
if let content = chunk.deltaContent {
fullContent += content
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = fullContent
}
}
if let images = chunk.delta.images {
collectedImages.append(contentsOf: images)
}
if let usage = chunk.usage {
totalTokens = usage
}
}
// Check for cancellation one more time after loop exits
// (in case it was cancelled after the last chunk)
if Task.isCancelled {
wasCancelled = true
}
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = applyNotesUpdateIfNeeded(fullContent)
messages[index].isStreaming = false
messages[index].responseTime = responseTime
messages[index].wasInterrupted = wasCancelled
if !collectedImages.isEmpty {
messages[index].generatedImages = collectedImages
}
if let usage = totalTokens {
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
// Generate embedding for assistant message
generateEmbeddingForMessage(messages[index])
}
}
isGenerating = false
streamingTask = nil
} catch {
let responseTime = Date().timeIntervalSince(startTime)
// Check if this was a cancellation (either by checking Task state or error type)
let isCancellation = Task.isCancelled || error is CancellationError
if isCancellation, let msgId = messageId {
// Mark the message as interrupted instead of removing it
if let index = messages.firstIndex(where: { $0.id == msgId }) {
messages[index].isStreaming = false
messages[index].wasInterrupted = true
messages[index].responseTime = responseTime
}
} else if let msgId = messageId {
// For real errors, remove the empty streaming message
if let index = messages.firstIndex(where: { $0.id == msgId && $0.content.isEmpty }) {
messages.remove(at: index)
}
Log.api.error("Generation failed: \(error.localizedDescription)")
showSystemMessage("❌ \(friendlyErrorMessage(from: error))")
}
isGenerating = false
streamingTask = nil
}
}
}
// MARK: - File Attachment Reading
private let maxFileSize: Int = 10 * 1024 * 1024 // 10 MB
private let maxTextSize: Int = 50 * 1024 // 50 KB before truncation
private func readFileAttachments(_ paths: [String]) -> [FileAttachment] {
var attachments: [FileAttachment] = []
let fm = FileManager.default
for rawPath in paths {
// Expand ~ and resolve path
let expanded = (rawPath as NSString).expandingTildeInPath
var resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded)
// If not found, try iCloud Drive path
if !fm.fileExists(atPath: resolvedPath) {
let icloudBase = (("~/Library/Mobile Documents" as NSString).expandingTildeInPath as NSString)
let candidate = icloudBase.appendingPathComponent(rawPath)
if fm.fileExists(atPath: candidate) {
resolvedPath = candidate
}
}
// Check file exists
guard fm.fileExists(atPath: resolvedPath) else {
showSystemMessage("⚠️ File not found: \(rawPath)")
continue
}
// Check file size
guard let attrs = try? fm.attributesOfItem(atPath: resolvedPath),
let fileSize = attrs[.size] as? Int else {
showSystemMessage("⚠️ Cannot read file: \(rawPath)")
continue
}
if fileSize > maxFileSize {
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
showSystemMessage("⚠️ File too large (\(sizeMB) MB, max 10 MB): \(rawPath)")
continue
}
let type = FileAttachment.typeFromExtension(resolvedPath)
switch type {
case .image, .pdf:
// Read as raw data
guard let data = fm.contents(atPath: resolvedPath) else {
showSystemMessage("⚠️ Could not read file: \(rawPath)")
continue
}
attachments.append(FileAttachment(path: rawPath, type: type, data: data))
case .text:
// Read as string
guard let content = try? String(contentsOfFile: resolvedPath, encoding: .utf8) else {
showSystemMessage("⚠️ Could not read file as text: \(rawPath)")
continue
}
var finalContent = content
// Truncate large text files
if content.utf8.count > maxTextSize {
let lines = content.components(separatedBy: "\n")
if lines.count > 600 {
let head = lines.prefix(500).joined(separator: "\n")
let tail = lines.suffix(100).joined(separator: "\n")
let omitted = lines.count - 600
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
}
}
attachments.append(FileAttachment(path: rawPath, type: .text, data: finalContent.data(using: .utf8)))
}
}
return attachments
}
// MARK: - Text Tool Call Parsing
/// Fallback parser for models that write tool calls as text instead of using structured tool_calls.
/// Handles two patterns:
/// tool_name{"arg": "val"} (no space between name and args)
/// tool_name({"arg": "val"}) (with wrapping parens)
private func parseTextToolCalls(from content: String) -> [ToolCallInfo] {
var results: [ToolCallInfo] = []
// Match: word_chars optionally followed by ( then { ... } optionally followed by )
// Use a broad pattern and validate JSON manually
let pattern = #"([a-z_][a-z0-9_]*)\s*\(?\s*(\{[\s\S]*?\})\s*\)?"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return []
}
let nsContent = content as NSString
let matches = regex.matches(in: content, range: NSRange(location: 0, length: nsContent.length))
let knownTools = Set(MCPService.shared.getToolSchemas().map { $0.function.name })
for match in matches {
guard let nameRange = Range(match.range(at: 1), in: content),
let argsRange = Range(match.range(at: 2), in: content) else { continue }
let name = String(content[nameRange])
let argsStr = String(content[argsRange])
// Only handle known tool names to avoid false positives
guard knownTools.contains(name) else { continue }
// Validate the JSON
guard let _ = try? JSONSerialization.jsonObject(with: Data(argsStr.utf8)) else { continue }
Log.ui.info("Parsed text tool call: \(name)")
results.append(ToolCallInfo(id: UUID().uuidString, type: "function", functionName: name, arguments: argsStr))
}
return results
}
// MARK: - MCP Command Handling
private func handleMCPCommand(args: [String]) {
let mcp = MCPService.shared
guard let sub = args.first?.lowercased() else {
showSystemMessage("Usage: /mcp on|off|status|add|remove|list")
return
}
switch sub {
case "on":
mcpEnabled = true
settings.mcpEnabled = true
mcpStatus = "MCP"
showSystemMessage("MCP enabled (^[\(mcp.allowedFolders.count) folder](inflect: true) registered)")
case "off":
mcpEnabled = false
settings.mcpEnabled = false
mcpStatus = nil
showSystemMessage("MCP disabled")
case "add":
if args.count >= 2 {
let path = args.dropFirst().joined(separator: " ")
if let error = mcp.addFolder(path) {
showSystemMessage("MCP: \(error)")
} else {
showSystemMessage("MCP: Added folder — ^[\(mcp.allowedFolders.count) folder](inflect: true) registered")
}
} else {
showSystemMessage("Usage: /mcp add <path>")
}
case "remove":
if args.count >= 2 {
let ref = args.dropFirst().joined(separator: " ")
if let index = Int(ref) {
if mcp.removeFolder(at: index) {
showSystemMessage("MCP: Removed folder at index \(index)")
} else {
showSystemMessage("MCP: Invalid index \(index)")
}
} else {
if mcp.removeFolder(path: ref) {
showSystemMessage("MCP: Removed folder")
} else {
showSystemMessage("MCP: Folder not found: \(ref)")
}
}
} else {
showSystemMessage("Usage: /mcp remove <index|path>")
}
case "list":
if mcp.allowedFolders.isEmpty {
showSystemMessage("MCP: No folders registered. Use /mcp add <path>")
} else {
let list = mcp.allowedFolders.enumerated().map { "\($0): \($1)" }.joined(separator: "\n")
showSystemMessage("MCP folders:\n\(list)")
}
case "write":
guard args.count >= 2 else {
showSystemMessage("Usage: /mcp write on|off")
return
}
let toggle = args[1].lowercased()
if toggle == "on" {
settings.mcpCanWriteFiles = true
settings.mcpCanDeleteFiles = true
settings.mcpCanCreateDirectories = true
settings.mcpCanMoveFiles = true
showSystemMessage("MCP: All write permissions enabled (write, edit, delete, create dirs, move, copy)")
} else if toggle == "off" {
settings.mcpCanWriteFiles = false
settings.mcpCanDeleteFiles = false
settings.mcpCanCreateDirectories = false
settings.mcpCanMoveFiles = false
showSystemMessage("MCP: All write permissions disabled")
} else {
showSystemMessage("Usage: /mcp write on|off")
}
case "status":
let enabled = mcpEnabled ? "enabled" : "disabled"
let folders = mcp.allowedFolders.count
var perms: [String] = []
if settings.mcpCanWriteFiles { perms.append("write") }
if settings.mcpCanDeleteFiles { perms.append("delete") }
if settings.mcpCanCreateDirectories { perms.append("mkdir") }
if settings.mcpCanMoveFiles { perms.append("move/copy") }
let permStr = perms.isEmpty ? "read-only" : "read + \(perms.joined(separator: ", "))"
showSystemMessage("MCP: \(enabled), ^[\(folders) folder](inflect: true), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")")
default:
showSystemMessage("MCP subcommands: on, off, status, add, remove, list, write")
}
}
// MARK: - Notes Command Handling
private func handleNotesCommand(args: [String]) {
guard let sub = args.first?.lowercased() else {
showSystemMessage("Usage: /notes on|off|show")
return
}
switch sub {
case "on":
guard let conversationId = currentConversationId else {
showSystemMessage("Send a message first so this conversation is saved, then try /notes on")
return
}
do {
var filename = notesFilename
if filename == nil {
let newFilename = ConversationNotesService.shared.makeFilename(
conversationName: currentConversationName ?? "Untitled",
conversationId: conversationId
)
ConversationNotesService.shared.write(body: "", filename: newFilename, conversationId: conversationId)
try DatabaseService.shared.setNotesFilename(id: conversationId, filename: newFilename)
filename = newFilename
}
try DatabaseService.shared.setNotesEnabled(id: conversationId, enabled: true)
notesFilename = filename
notesEnabled = true
showSystemMessage("Notes enabled for this conversation")
} catch {
showSystemMessage("Failed to enable notes: \(error.localizedDescription)")
}
case "off":
guard let conversationId = currentConversationId else {
showSystemMessage("No active conversation")
return
}
do {
try DatabaseService.shared.setNotesEnabled(id: conversationId, enabled: false)
notesEnabled = false
showSystemMessage("Notes disabled for this conversation")
} catch {
showSystemMessage("Failed to disable notes: \(error.localizedDescription)")
}
case "show":
guard notesEnabled, let filename = notesFilename,
let body = ConversationNotesService.shared.readBody(filename: filename),
!body.isEmpty
else {
showSystemMessage("No notes yet for this conversation")
return
}
showSystemMessage("📝 Notes:\n\n\(body)")
default:
showSystemMessage("Usage: /notes on|off|show")
}
}
// MARK: - AI Response with Tool Calls
// MARK: - Images API Generation
private func generateImageAPIResponse(orProvider: OpenRouterProvider, modelId: String, prompt: String) {
isGenerating = true
streamingTask?.cancel()
streamingTask = Task {
let startTime = Date()
let assistantMessage = Message(
role: .assistant,
content: ThinkingVerbs.random(),
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil,
modelId: modelId,
isStreaming: true
)
let messageId = assistantMessage.id
messages.append(assistantMessage)
do {
let response = try await orProvider.generateImage(model: modelId, prompt: prompt)
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = response.content
messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages
messages[index].responseTime = responseTime
if let usage = response.usage {
messages[index].tokens = usage.completionTokens
let cost = usage.rawCostUSD
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
} catch {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
messages[index].isStreaming = false
}
Log.api.error("Images API error: \(error)")
}
isGenerating = false
}
}
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
let mcp = MCPService.shared
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
isGenerating = true
streamingTask?.cancel()
streamingTask = Task {
let startTime = Date()
var wasCancelled = false
do {
// Include web_search tool when online mode is on (not needed for OpenRouter — it handles search via :online suffix)
let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)
// Apply :online suffix for OpenRouter when online mode is active
var effectiveModelId = modelId
if onlineMode && currentProvider == .openrouter && !modelId.hasSuffix(":online") {
effectiveModelId = modelId + ":online"
}
// Build initial messages as raw dictionaries for the tool loop
var systemParts: [String] = []
let mcpActive = mcpEnabled || settings.mcpEnabled
if mcpActive && !mcp.allowedFolders.isEmpty {
let folderList = mcp.allowedFolders.joined(separator: "\n - ")
var capabilities = "You can read files, list directories, and search for files."
var writeCapabilities: [String] = []
if mcp.canWriteFiles { writeCapabilities.append("write and edit files") }
if mcp.canDeleteFiles { writeCapabilities.append("delete files") }
if mcp.canCreateDirectories { writeCapabilities.append("create directories") }
if mcp.canMoveFiles { writeCapabilities.append("move and copy files") }
if !writeCapabilities.isEmpty {
capabilities += " You can also \(writeCapabilities.joined(separator: ", "))."
}
systemParts.append("You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths.")
}
if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
}
let activeExternalServers = settings.externalMCPServers.filter { $0.isEnabled }
if !activeExternalServers.isEmpty {
let names = activeExternalServers.map { $0.name }.joined(separator: ", ")
systemParts.append("You have access to external tools from MCP servers: \(names). Their tools are prefixed with the server slug (e.g. safari_navigate_to_url). Use them proactively when the user's request relates to what those servers provide.")
}
var systemContent = systemParts.joined(separator: "\n\n")
// Append the complete system prompt (default + custom)
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
let messagesToSend: [Message] = memoryEnabled
? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
let systemPrompt: [String: Any] = [
"role": "system",
"content": systemContent
]
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments {
var contentArray: [[String: Any]] = []
if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
contentArray.append(["type": "text", "text": msg.content])
}
for attachment in msg.attachments ?? [] {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64String = data.base64EncodedString()
let dataURL = "data:\(attachment.mimeType);base64,\(base64String)"
contentArray.append(["type": "image_url", "image_url": ["url": dataURL]])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
return ["role": msg.role.rawValue, "content": contentArray]
}
let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
return ["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content]
}
// If this is a silent auto-continue, inject the prompt into the API call only
if let continuePrompt = silentContinuePrompt {
apiMessages.append(["role": "user", "content": continuePrompt])
silentContinuePrompt = nil
}
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
var finalContent = ""
var finalImages: [Data] = []
var didContinueAfterImages = false // Only inject temp-file continuation once
var totalUsage: ChatResponse.Usage?
var hitIterationLimit = false // Track if we exited due to hitting the limit
var finishedWithEmptyContent = false // Model stopped calling tools but said nothing
for iteration in 0..<maxIterations {
if Task.isCancelled {
wasCancelled = true
break
}
let response = try await withOverloadedRetry {
try await provider.chatWithToolMessages(
model: effectiveModelId,
messages: apiMessages,
tools: tools,
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
temperature: settings.temperature > 0 ? settings.temperature : nil
)
}
if let usage = response.usage { totalUsage = usage }
// Check if the model wants to call tools
// Also parse text-based tool calls for models that don't use structured tool_calls
let structuredCalls = response.toolCalls ?? []
let textCalls = structuredCalls.isEmpty ? parseTextToolCalls(from: response.content) : []
let toolCalls = structuredCalls.isEmpty ? textCalls : structuredCalls
guard !toolCalls.isEmpty else {
// No tool calls — this is the final response
finalContent = response.content
if let images = response.generatedImages { finalImages = images }
Log.ui.debug("Tools final response: content='\(response.content.prefix(80))', images=\(response.generatedImages?.count ?? 0)")
// If images were generated and tools are available, save to temp files
// and continue the loop so the model can save them to the requested path.
if !finalImages.isEmpty && !didContinueAfterImages && iteration < maxIterations - 1 {
didContinueAfterImages = true
let timestamp = Int(Date().timeIntervalSince1970)
let tempPaths: [String] = finalImages.enumerated().compactMap { i, imgData in
let path = "/tmp/confab_generated_\(timestamp)_\(i).png"
let ok = FileManager.default.createFile(atPath: path, contents: imgData)
Log.ui.debug("Saved generated image to temp: \(path) ok=\(ok)")
return ok ? path : nil
}
if !tempPaths.isEmpty {
let pathList = tempPaths.joined(separator: ", ")
let assistantContent = response.content.isEmpty ? "[Image generated]" : response.content
apiMessages.append(["role": "assistant", "content": assistantContent])
apiMessages.append(["role": "user", "content": "The image(s) have been generated and temporarily saved to: \(pathList). Please save them to the requested destination(s) using the available tools (bash or MCP write)."])
finalImages = []
finalContent = ""
continue
}
}
if finalContent.isEmpty {
// Some models (observed with Qwen via OpenRouter) stop calling tools
// after a long tool-call chain but return no summarizing text at all.
// Silently nudge a follow-up turn instead of showing a placeholder bubble.
finishedWithEmptyContent = true
}
break
}
// Show what tools the model is calling
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)")
// Initialise detail entries with inputs (results fill in below)
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
}
updateToolCallMessage(id: toolMsgId, details: toolDetails)
let usingTextCalls = !textCalls.isEmpty
if usingTextCalls {
// Text-based tool calls: keep assistant message as-is (the text content)
apiMessages.append(["role": "assistant", "content": response.content])
} else {
// Structured tool_calls: append assistant message with tool_calls field
var assistantMsg: [String: Any] = ["role": "assistant"]
if !response.content.isEmpty {
assistantMsg["content"] = response.content
}
let toolCallDicts: [[String: Any]] = toolCalls.map { tc in
[
"id": tc.id,
"type": tc.type,
"function": [
"name": tc.functionName,
"arguments": tc.arguments
]
]
}
assistantMsg["tool_calls"] = toolCallDicts
apiMessages.append(assistantMsg)
}
// Execute each tool and append results
var toolResultLines: [String] = []
for (i, tc) in toolCalls.enumerated() {
if Task.isCancelled {
wasCancelled = true
break
}
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments, agentProvider: provider, agentModelId: effectiveModelId)
let resultJSON: String
if let data = try? JSONSerialization.data(withJSONObject: result),
let str = String(data: data, encoding: .utf8) {
// Cap tool results at 50 KB to avoid HTTP 413 on the next API call
let maxBytes = 50_000
if str.utf8.count > maxBytes {
let truncated = String(str.utf8.prefix(maxBytes))!
resultJSON = truncated + "\n... (result truncated, use a smaller limit or more specific query)"
} else {
resultJSON = str
}
} else {
resultJSON = "{\"error\": \"Failed to serialize result\"}"
}
// Update the detail entry with the result so the UI can show it
toolDetails[i].result = resultJSON
updateToolCallMessage(id: toolMsgId, details: toolDetails)
if usingTextCalls {
// Inject results as a user message for text-call models
toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)")
} else {
apiMessages.append([
"role": "tool",
"tool_call_id": tc.id,
"name": tc.functionName,
"content": resultJSON
])
}
}
if usingTextCalls && !toolResultLines.isEmpty {
let combined = toolResultLines.joined(separator: "\n\n")
apiMessages.append(["role": "user", "content": combined])
}
// If this was the last iteration, note it
if iteration == maxIterations - 1 {
hitIterationLimit = true // We're exiting with pending tool calls
finalContent = response.content
}
}
// Check for cancellation one more time after loop exits
if Task.isCancelled {
wasCancelled = true
}
// If we hit the iteration limit or the model returned no text at all, silently
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
// Display the final response as an assistant message
let responseTime = Date().timeIntervalSince(startTime)
if willAutoContinue && finalContent.isEmpty {
// Nothing worth showing yet — still record usage/cost for this turn.
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
)
}
} else {
let assistantMessage = Message(
role: .assistant,
content: applyNotesUpdateIfNeeded(finalContent),
tokens: totalUsage?.completionTokens,
cost: nil,
timestamp: Date(),
attachments: nil,
responseTime: responseTime,
wasInterrupted: wasCancelled,
modelId: modelId,
generatedImages: finalImages.isEmpty ? nil : finalImages
)
messages.append(assistantMessage)
// Calculate cost
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost
}
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
)
}
}
isGenerating = false
streamingTask = nil
if willAutoContinue {
startAutoContinue()
}
} catch {
let responseTime = Date().timeIntervalSince(startTime)
// Check if this was a cancellation
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
if isCancellation {
// Create an interrupted message
let assistantMessage = Message(
role: .assistant,
content: "",
timestamp: Date(),
responseTime: responseTime,
wasInterrupted: true,
modelId: modelId
)
messages.append(assistantMessage)
} else {
Log.api.error("Tool generation failed: \(error.localizedDescription)")
showSystemMessage("❌ \(friendlyErrorMessage(from: error))")
}
isGenerating = false
streamingTask = nil
}
}
}
@discardableResult
private func showSystemMessage(_ text: String.LocalizationValue) -> UUID {
let message = Message(
role: .system,
content: String(localized: text),
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil
)
messages.append(message)
return message.id
}
private func updateToolCallMessage(id: UUID, details: [ToolCallDetail]) {
if let idx = messages.firstIndex(where: { $0.id == id }) {
messages[idx].toolCalls = details
}
}
// MARK: - Error Helpers
private func friendlyErrorMessage(from error: Error) -> String {
let desc = error.localizedDescription
// Network connectivity
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost:
return "Unable to reach the server. Check your internet connection."
case .timedOut:
return "Request timed out. Try a shorter message or different model."
case .cannotFindHost, .cannotConnectToHost:
return "Cannot connect to the server. Check your network or provider URL."
default:
break
}
}
// HTTP status codes in error messages
if desc.contains("401") || desc.contains("403") || desc.lowercased().contains("unauthorized") || desc.lowercased().contains("invalid.*key") {
return "Invalid API key. Update it in Settings (\u{2318},)."
}
if desc.contains("429") || desc.lowercased().contains("rate limit") {
return "Rate limited. Wait a moment and try again."
}
if desc.contains("404") || desc.lowercased().contains("model not found") || desc.lowercased().contains("not available") {
return "Model not available. Select a different model (\u{2318}M)."
}
if desc.contains("500") || desc.contains("502") || desc.contains("503") {
return "Server error. The provider may be experiencing issues. Try again shortly."
}
// Overloaded / 529
if desc.contains("529") || desc.lowercased().contains("overloaded") {
return "API is overloaded. Please try again shortly."
}
// Timeout patterns
if desc.lowercased().contains("timed out") || desc.lowercased().contains("timeout") {
return "Request timed out. Try a shorter message or different model."
}
// Fallback
return desc
}
/// Retry an async operation on overloaded (529) errors with exponential backoff.
private func withOverloadedRetry<T>(maxAttempts: Int = 4, operation: () async throws -> T) async throws -> T {
var attempt = 0
while true {
do {
return try await operation()
} catch {
let desc = error.localizedDescription
let isOverloaded = desc.contains("529") || desc.lowercased().contains("overloaded")
attempt += 1
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
let delay = Double(1 << attempt) // 2s, 4s, 8s
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
_ = await MainActor.run {
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
}
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} else {
throw error
}
}
}
}
// MARK: - Helpers
private func showModelInfo(_ model: ModelInfo) {
modelInfoTarget = model
}
func exportConversation(format: String, filename: String) {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else {
showSystemMessage("Nothing to export — no messages")
return
}
if format == "pdf" {
let name = currentConversationName ?? "conversation"
Task { @MainActor in
do {
let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages)
if let url = ConversationExportService.writeToDownloads(data, filename: filename) {
showSystemMessage("Exported to \(url.path)")
} else {
showSystemMessage("Export failed: could not write file")
}
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
}
}
return
}
let content: String
switch format {
case "md", "markdown":
content = ConversationExportService.markdown(messages: chatMessages)
case "html":
content = ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
case "json":
let dicts = chatMessages.map { msg -> [String: String] in
["role": msg.role.rawValue, "content": msg.content]
}
if let data = try? JSONSerialization.data(withJSONObject: dicts, options: .prettyPrinted),
let json = String(data: data, encoding: .utf8) {
content = json
} else {
showSystemMessage("Failed to encode JSON")
return
}
default:
showSystemMessage("Unsupported format: \(format). Use md, html, pdf, or json.")
return
}
if let url = ConversationExportService.writeToDownloads(content, filename: filename) {
showSystemMessage("Exported to \(url.path)")
} else {
showSystemMessage("Export failed: could not write file")
}
}
/// Same export formats as `exportConversation(format:filename:)`, but lets the user pick
/// the name and location via a native save panel instead of always writing to Downloads.
#if os(macOS)
func exportConversationWithSavePanel(format: String, defaultFilename: String) {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else {
showSystemMessage("Nothing to export — no messages")
return
}
let panel = NSSavePanel()
panel.nameFieldStringValue = defaultFilename
panel.canCreateDirectories = true
panel.allowedContentTypes = {
switch format {
case "html": return [.html]
case "pdf": return [.pdf]
default: return [UTType(filenameExtension: "md") ?? .plainText]
}
}()
if let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first {
panel.directoryURL = downloads
}
guard panel.runModal() == .OK, let url = panel.url else { return }
if format == "pdf" {
let name = currentConversationName ?? "conversation"
Task { @MainActor in
do {
let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages)
try data.write(to: url, options: .atomic)
showSystemMessage("Exported to \(url.path)")
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
}
}
return
}
let content = format == "html"
? ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
: ConversationExportService.markdown(messages: chatMessages)
do {
try content.write(to: url, atomically: true, encoding: .utf8)
showSystemMessage("Exported to \(url.path)")
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
}
}
#endif
// MARK: - Auto-Save & Background Summarization
/// Summarize the current conversation in the background (hidden from user)
/// Returns a 3-5 word title, or nil on failure
func summarizeConversationInBackground() async -> String? {
// Need at least a few messages to summarize
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
guard chatMessages.count >= 2 else {
return nil
}
// Get current provider
guard let provider = providerRegistry.getCurrentProvider() else {
Log.ui.warning("Cannot summarize: no provider configured")
return nil
}
guard let modelId = selectedModel?.id else {
Log.ui.warning("Cannot summarize: no model selected")
return nil
}
Log.ui.info("Background summarization: model=\(modelId), messages=\(chatMessages.count)")
do {
// Simplified summarization prompt
let summaryPrompt = "Create a brief 3-5 word title for this conversation. Just the title, nothing else."
// Build chat request with just the last few messages for context
let recentMessages = Array(chatMessages.suffix(10)) // Last 10 messages for context
var summaryMessages = recentMessages.map { msg in
Message(role: msg.role, content: msg.content, tokens: nil, cost: nil, timestamp: Date())
}
// Add the summary request as a user message
summaryMessages.append(Message(
role: .user,
content: summaryPrompt,
tokens: nil,
cost: nil,
timestamp: Date()
))
let chatRequest = ChatRequest(
messages: summaryMessages,
model: modelId,
stream: false, // Non-streaming for background request
maxTokens: 100, // Increased for better response
temperature: 0.3, // Lower for more focused response
topP: nil,
systemPrompt: "You are a helpful assistant that creates concise conversation titles.",
tools: nil,
onlineMode: false,
imageGeneration: false
)
// Make the request (hidden from user)
let response = try await provider.chat(request: chatRequest)
Log.ui.info("Raw summary response: '\(response.content)'")
// Extract and clean the summary
var summary = response.content
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\"", with: "")
.replacingOccurrences(of: "'", with: "")
.replacingOccurrences(of: "Title:", with: "", options: .caseInsensitive)
.replacingOccurrences(of: "title:", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
// Take only first line if multi-line response
if let firstLine = summary.components(separatedBy: .newlines).first {
summary = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
}
// Limit length
summary = String(summary.prefix(60))
Log.ui.info("Cleaned summary: '\(summary)'")
// Return nil if empty
guard !summary.isEmpty else {
Log.ui.warning("Summary is empty after cleaning")
return nil
}
return summary
} catch {
Log.ui.error("Background summarization failed: \(error.localizedDescription)")
return nil
}
}
// MARK: - Crash-Recovery Draft
/// Pure fingerprint of a message list's content, used to skip rewriting the crash-recovery
/// draft to disk when nothing has actually changed. Not stable across process launches
/// (String hashing is randomized per-run) — only ever compared within a single session.
nonisolated static func draftFingerprint(for messages: [Message]) -> Int {
messages.map { "\($0.role.rawValue)|\($0.content)" }.joined(separator: "\u{1}").hashValue
}
/// (Re)schedules the periodic draft-persistence timer using the current
/// `settings.draftRecoveryIntervalSeconds`. Call again after Settings changes it.
/// A value of 0 disables the periodic tick (and clears any existing draft).
func startDraftTimer() {
draftTimer?.invalidate()
draftTimer = nil
let interval = settings.draftRecoveryIntervalSeconds
guard interval > 0 else {
DraftRecoveryService.shared.clear()
return
}
draftTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(interval), repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.persistDraftIfChanged()
}
}
}
private func persistDraftIfChanged() {
guard settings.draftRecoveryIntervalSeconds > 0 else { return }
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
guard !chatMessages.isEmpty else { return }
let fingerprint = Self.draftFingerprint(for: chatMessages)
guard fingerprint != lastDraftFingerprint else { return }
lastDraftFingerprint = fingerprint
DraftRecoveryService.shared.save(DraftConversation(
messages: chatMessages,
conversationId: currentConversationId,
conversationName: currentConversationName,
modelId: selectedModel?.id,
savedAt: Date()
))
}
/// Called once on launch from `ContentView.onAppear` in `ContentView.swift`, deferred one
/// run-loop tick via `DispatchQueue.main.async` so the modal alert reliably presents — calling
/// it directly and undeferred from `.onAppear` was tried and failed silently. Previously called
/// from `AppDelegate.applicationDidFinishLaunching`, but that read a throwaway `ChatViewModel`
/// instance from `oAIApp.init()`'s own `@State` rather than the one actually rendered (confirmed
/// via ObjectIdentifier logging — restore appeared to work but never touched the visible chat).
/// Offers to restore a conversation left behind by a crash or force-quit. A no-op after a clean
/// shutdown, since `confirmDiscardIfNeeded` always clears the draft before New Chat/Clear/Switch/Quit proceed.
func checkForCrashRecoveryDraft() {
guard !hasCheckedForCrashRecoveryDraft else { return }
hasCheckedForCrashRecoveryDraft = true
// oAITests is app-hosted, so `xcodebuild test` launches this same app as the test host —
// without this guard, a leftover draft file on disk (e.g. from manual kill-9 testing)
// makes the test host hit this exact blocking NSAlert.runModal() with no one there to
// click it, hanging the entire test run indefinitely.
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
let loaded = DraftRecoveryService.shared.load()
Log.ui.info("checkForCrashRecoveryDraft: found draft = \(loaded != nil), messageCount = \(loaded?.messages.count ?? -1)")
guard let draft = loaded, !draft.messages.isEmpty else { return }
#if os(macOS)
// Bring the app frontmost first — this runs one tick after launch, before the app has
// necessarily activated, and an app-modal alert shown to a non-active app can end up
// behind other windows (clicks land on whatever's actually frontmost, not the alert).
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Restore unsaved conversation?"
alert.informativeText = "Confab didn't close properly last time. Would you like to restore the conversation you were working on?"
alert.addButton(withTitle: "Restore")
alert.addButton(withTitle: "Discard")
if alert.runModal() == .alertFirstButtonReturn {
messages = draft.messages
currentConversationId = draft.conversationId
currentConversationName = draft.conversationName
savedMessageCount = 0 // always treat a restored draft as unsaved
// Clear the on-disk draft now that it's loaded into memory — otherwise this exact
// file lingers forever (a clean quit only clears it via confirmDiscardIfNeeded, which
// never runs if the restored session is then quit before any new message is sent),
// and the same restore prompt reappears on every future launch no matter what the
// user picks here.
DraftRecoveryService.shared.clear()
showSystemMessage("Restored previous session (^[\(draft.messages.count) message](inflect: true))")
if let modelId = draft.modelId {
Task { await switchToConversationModel(modelId) }
}
} else {
DraftRecoveryService.shared.clear()
}
#endif
}
// MARK: - Unsaved Changes Gate
#if os(macOS)
/// Standard macOS "unsaved changes" gate. If there are no unsaved changes, `proceed()` runs
/// immediately. Otherwise shows a Save / Don't Save / Cancel alert: Save attempts an explicit
/// save (prompting for name/folder if never named) and only proceeds on success; Don't Save
/// discards the crash-recovery draft and proceeds; Cancel calls `onCancel()` — the caller's
/// original action (new chat / clear / switch / quit) must not happen.
func confirmDiscardIfNeeded(then proceed: @escaping () -> Void, onCancel: @escaping () -> Void = {}) {
guard hasUnsavedChanges else { proceed(); return }
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Do you want to save the changes you made to \"\(currentConversationName ?? "Untitled Conversation")\"?"
alert.informativeText = "Your changes will be lost if you don't save them."
alert.addButton(withTitle: "Save")
let dontSave = alert.addButton(withTitle: "Don't Save")
dontSave.keyEquivalent = "d"
dontSave.keyEquivalentModifierMask = .command
alert.addButton(withTitle: "Cancel")
switch alert.runModal() {
case .alertFirstButtonReturn:
if attemptSaveCurrentConversation() {
DraftRecoveryService.shared.clear()
proceed()
} else {
onCancel()
}
case .alertSecondButtonReturn:
DraftRecoveryService.shared.clear()
proceed()
default:
onCancel()
}
}
/// Saves the current conversation: silently re-saves in place if already named, otherwise
/// prompts for a name (and optional folder) via `promptForConversationDetails`. Returns true
/// on success (or if there was nothing to save), false if cancelled or the save failed.
@discardableResult
private func attemptSaveCurrentConversation() -> Bool {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return true }
if let id = currentConversationId, let name = currentConversationName {
do {
try DatabaseService.shared.updateConversation(
id: id, name: name, messages: chatMessages, primaryModel: selectedModel?.id
)
savedMessageCount = chatMessages.count
showSystemMessage("Saved \"\(name)\"")
Task { await GitSyncService.shared.autoSync() }
return true
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
return false
}
}
guard let details = promptForConversationDetails(defaultName: "") else { return false }
do {
let saved = try DatabaseService.shared.saveConversation(
id: UUID(), name: details.name, messages: chatMessages,
primaryModel: selectedModel?.id, folderId: details.folderId
)
currentConversationId = saved.id
currentConversationName = details.name
savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() }
return true
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
return false
}
}
#endif
// MARK: - Embedding Generation
/// Generate embedding for a single message (awaitable, no Task spawned).
/// Call this directly when you want to sequence or rate-limit requests.
private func embedMessage(_ message: Message, provider: EmbeddingProvider) async {
guard message.content.count > 20 else { return }
// Skip if already embedded
if let _ = try? EmbeddingService.shared.getMessageEmbedding(messageId: message.id) { return }
do {
let embedding = try await EmbeddingService.shared.generateEmbedding(
text: message.content,
provider: provider
)
try EmbeddingService.shared.saveMessageEmbedding(
messageId: message.id,
embedding: embedding,
model: provider.defaultModel
)
Log.api.info("Generated embedding for message \(message.id) using \(provider.displayName)")
} catch {
let errorString = String(describing: error)
if errorString.contains("FOREIGN KEY constraint failed") {
Log.api.debug("Message \(message.id) not in database yet - will embed later during save or batch operation")
} else {
Log.api.error("Failed to generate embedding for message \(message.id): \(error)")
}
}
}
/// Fire-and-forget wrapper — runs at background priority so it never blocks the chat.
func generateEmbeddingForMessage(_ message: Message) {
guard settings.embeddingsEnabled else { return }
guard message.content.count > 20 else { return }
Task(priority: .background) {
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
Log.api.warning("No embedding providers available - skipping embedding generation")
return
}
await embedMessage(message, provider: provider)
}
}
/// Batch generate embeddings for all messages in all conversations
func batchEmbedAllConversations() async {
guard settings.embeddingsEnabled else {
showSystemMessage("Embeddings are disabled. Enable them in Settings > Advanced.")
return
}
// Check if we have an embedding provider available
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
showSystemMessage("⚠️ No embedding provider available. Please configure an API key for OpenAI, OpenRouter, or Google.")
return
}
showSystemMessage("Starting batch embedding generation using \(provider.displayName)...")
let conversations = (try? DatabaseService.shared.listConversations()) ?? []
var processedMessages = 0
var skippedMessages = 0
for conv in conversations {
guard let (_, messages) = try? DatabaseService.shared.loadConversation(id: conv.id) else {
continue
}
for message in messages {
// Skip if already embedded
if let _ = try? EmbeddingService.shared.getMessageEmbedding(messageId: message.id) {
skippedMessages += 1
continue
}
// Skip very short messages
guard message.content.count > 20 else {
skippedMessages += 1
continue
}
do {
let embedding = try await EmbeddingService.shared.generateEmbedding(
text: message.content,
provider: provider
)
try EmbeddingService.shared.saveMessageEmbedding(
messageId: message.id,
embedding: embedding,
model: provider.defaultModel
)
processedMessages += 1
// Rate limit: 10 embeddings/sec
try? await Task.sleep(for: .milliseconds(100))
} catch {
Log.api.error("Failed to generate embedding for message \(message.id): \(error)")
}
}
// Generate conversation embedding
do {
try await EmbeddingService.shared.generateConversationEmbedding(conversationId: conv.id)
} catch {
Log.api.error("Failed to generate conversation embedding for \(conv.id): \(error)")
}
}
showSystemMessage("Batch embedding complete: \(processedMessages) messages processed, \(skippedMessages) skipped")
Log.ui.info("Batch embedding complete: \(processedMessages) messages, \(skippedMessages) skipped")
}
// MARK: - Progressive Summarization
/// Check if conversation needs summarization and create summaries if needed
func checkAndSummarizeOldMessages(conversationId: UUID) async {
guard settings.progressiveSummarizationEnabled else { return }
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
let threshold = settings.summarizationThreshold
guard chatMessages.count > threshold else { return }
// Calculate which chunk to summarize (messages 0 to threshold-20)
let chunkEnd = threshold - 20
guard chunkEnd > 30 else { return } // Need at least 30 messages to summarize
// Check if already summarized
if let hasSummary = try? DatabaseService.shared.hasSummaryForRange(
conversationId: conversationId,
startIndex: 0,
endIndex: chunkEnd
), hasSummary {
return // Already summarized
}
// Get messages to summarize
let messagesToSummarize = Array(chatMessages.prefix(chunkEnd))
Log.ui.info("Summarizing messages 0-\(chunkEnd) for conversation \(conversationId)")
// Generate summary
guard let summary = await summarizeMessageChunk(messagesToSummarize) else {
Log.ui.error("Failed to generate summary for conversation \(conversationId)")
return
}
// Save summary
do {
try DatabaseService.shared.saveConversationSummary(
conversationId: conversationId,
startIndex: 0,
endIndex: chunkEnd,
summary: summary,
model: selectedModel?.id,
tokenCount: summary.estimateTokens()
)
Log.ui.info("Saved summary for messages 0-\(chunkEnd)")
} catch {
Log.ui.error("Failed to save summary: \(error)")
}
}
/// Cost for one response's usage, accounting for Anthropic-style prompt-cache
/// pricing when present: cache writes cost 1.25x the base input rate, cache
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder —
/// it does not need cache tokens subtracted from it.
nonisolated static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000
let outputCost = Double(usage.completionTokens) * pricing.completion / 1_000_000
return inputCost + cacheReadCost + cacheWriteCost + outputCost
}
/// Summarize a chunk of messages into a concise summary
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
guard let provider = providerRegistry.getProvider(for: currentProvider),
let modelId = selectedModel?.id else {
return nil
}
// Combine messages into text
let combinedText = messages.map { msg in
let role = msg.role == .user ? "User" : "Assistant"
return "[\(role)]: \(msg.content)"
}.joined(separator: "\n\n")
// Create summarization prompt
let summaryPrompt = """
Please create a concise 2-3 paragraph summary of the following conversation.
Focus on the main topics discussed, key decisions made, and important information exchanged.
Do not include unnecessary details or greetings.
Conversation:
\(combinedText)
"""
let summaryMessage = Message(role: .user, content: summaryPrompt)
let request = ChatRequest(
messages: [summaryMessage],
model: modelId,
stream: false,
maxTokens: 500,
temperature: 0.3,
topP: nil,
systemPrompt: "You are a helpful assistant that creates concise, informative summaries of conversations.",
tools: nil,
onlineMode: false,
imageGeneration: false
)
do {
let response = try await provider.chat(request: request)
return response.content
} catch {
Log.api.error("Summary generation failed: \(error)")
return nil
}
}
}