// // 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 // for // the full license text. For commercial licensing, contact Rune // Olsen via . 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 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 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? 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 return customPrompt } // 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" } } } } } return prompt } // 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 } /// 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 (_, 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 // 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 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 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 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 ") } 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 ") } 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) 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 = 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 = 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 ") } 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 ") } case "list": if mcp.allowedFolders.isEmpty { showSystemMessage("MCP: No folders registered. Use /mcp add ") } 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: - 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.. 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: 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(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 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 } } }