// // ChatViewModel.swift // oAI // // Main chat view model // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import Foundation import os import SwiftUI @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 modelInfoTarget: ModelInfo? = nil var commandHistory: [String] = [] var historyIndex: Int = 0 var isAutoContinuing: Bool = false // 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 } var autoContinueCountdown: Int = 0 // MARK: - Auto-Save Tracking private var conversationStartTime: Date? private var lastMessageTime: Date? private var idleCheckTimer: Timer? // MARK: - Private State private var streamingTask: Task? private var autoContinueTask: 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. ## 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 { // Check if user wants to replace the default prompt entirely (BYOP mode) if 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 // Add tool-specific guidelines if MCP is enabled (tools are available) if mcpEnabled { prompt += toolUsageGuidelines } // Append custom prompt if in append mode and custom prompt exists if 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) 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() } } // 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 func newConversation() { 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 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 } 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) // 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 = "" // Check auto-save triggers in background Task { await checkAutoSaveTriggersAfterMessage(cleanText) } // Generate real AI response generateAIResponse(to: cleanText, attachments: userMessage.attachments) } func cancelGeneration() { streamingTask?.cancel() streamingTask = nil isGenerating = false cancelAutoContinue() // Also cancel any pending auto-continue } func startAutoContinue() { isAutoContinuing = true autoContinueCountdown = 5 autoContinueTask = Task { @MainActor in // Countdown from 5 to 1 for i in (1...5).reversed() { if Task.isCancelled { isAutoContinuing = false return } autoContinueCountdown = i try? await Task.sleep(for: .seconds(1)) } // Continue the task isAutoContinuing = false autoContinueCountdown = 0 let continuePrompt = "Please continue from where you left off." // Add user message let userMessage = Message( role: .user, content: continuePrompt, tokens: nil, cost: nil, timestamp: Date(), attachments: nil, responseTime: nil, wasInterrupted: false, modelId: selectedModel?.id ) messages.append(userMessage) // Continue generation generateAIResponse(to: continuePrompt, attachments: nil) } } func cancelAutoContinue() { autoContinueTask?.cancel() autoContinueTask = nil isAutoContinuing = false autoContinueCountdown = 0 } func clearChat() { messages.removeAll() sessionStats.reset() showSystemMessage("Chat cleared") } func loadConversation(_ 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() messages = loadedMessages // 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. private func inferProvider(from modelId: String) -> Settings.Provider? { // 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 = 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, shows NSAlert to name if not. func saveFromMenu() { let chatMessages = messages.filter { $0.role != .system } guard !chatMessages.isEmpty else { return } if currentConversationName != nil { quickSave() } else { #if os(macOS) let alert = NSAlert() alert.messageText = "Save Chat" alert.informativeText = "Enter a name for this conversation:" alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Cancel") let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) input.placeholderString = "Conversation name…" alert.accessoryView = input alert.window.initialFirstResponder = input guard alert.runModal() == .alertFirstButtonReturn else { return } let name = input.stringValue.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } do { let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages) currentConversationId = saved.id currentConversationName = name savedMessageCount = chatMessages.count showSystemMessage("Saved as \"\(name)\"") } catch { showSystemMessage("Save failed: \(error.localizedDescription)") } #endif } } /// 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("Memory \(memoryEnabled ? "enabled" : "disabled")") } else { showSystemMessage("Usage: /memory on|off") } case "/online": if let arg = args.first?.lowercased() { onlineMode = arg == "on" showSystemMessage("Online mode \(onlineMode ? "enabled" : "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|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 "/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 modelSupportTools = selectedModel?.capabilities.tools ?? false if modelSupportTools && (anytypeActive || (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 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 ) 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 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 ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) : nil messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) } } } } else { // Regular text: stream response var fullContent = "" 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 content = chunk.deltaContent { fullContent += content if let index = messages.firstIndex(where: { $0.id == messageId }) { messages[index].content = fullContent } } 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 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 ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) : 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\(mcp.allowedFolders.count == 1 ? "" : "s") 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\(mcp.allowedFolders.count == 1 ? "" : "s") 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\(folders == 1 ? "" : "s"), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")") default: showSystemMessage("MCP subcommands: on, off, status, add, remove, list, write") } } // MARK: - AI Response with Tool Calls private func generateAIResponseWithTools(provider: AIProvider, modelId: String) { let mcp = MCPService.shared isGenerating = true streamingTask?.cancel() streamingTask = Task { let startTime = Date() var wasCancelled = false do { let tools = mcp.getToolSchemas() // 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.") } var systemContent = systemParts.joined(separator: "\n\n") // Append the complete system prompt (default + custom) systemContent += "\n\n---\n\n" + effectiveSystemPrompt var messagesToSend: [Message] = memoryEnabled ? messages.filter { $0.role != .system } : [messages.last(where: { $0.role == .user })].compactMap { $0 } // Web search via our WebSearchService // Append results to last user message content (matching Python oAI approach) if onlineMode && currentProvider != .openrouter { if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) { Log.search.info("Running web search for tool-aware path (\(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 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]] = [["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] } return ["role": msg.role.rawValue, "content": msg.content] } let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit var finalContent = "" var totalUsage: ChatResponse.Usage? var hitIterationLimit = false // Track if we exited due to hitting the limit 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 text response // Strip any unparseable tool call text from display finalContent = response.content break } // Show what tools the model is calling let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ") showSystemMessage("🔧 Calling: \(toolNames)") 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 tc in toolCalls { if Task.isCancelled { wasCancelled = true break } let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments) 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\"}" } 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.isEmpty ? "[Tool loop reached maximum iterations]" : response.content } } // Check for cancellation one more time after loop exits if Task.isCancelled { wasCancelled = true } // Display the final response as an assistant message let responseTime = Date().timeIntervalSince(startTime) let assistantMessage = Message( role: .assistant, content: finalContent, tokens: totalUsage?.completionTokens, cost: nil, timestamp: Date(), attachments: nil, responseTime: responseTime, wasInterrupted: wasCancelled, modelId: modelId ) 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 ? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) : 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 we hit the iteration limit and weren't cancelled, start auto-continue if hitIterationLimit && !wasCancelled { 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 } } } private func showSystemMessage(_ text: String) { let message = Message( role: .system, content: text, tokens: nil, cost: nil, timestamp: Date(), attachments: nil ) messages.append(message) } // 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." } // 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 } // 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 } let content: String switch format { case "md", "markdown": content = chatMessages.map { msg in let header = msg.role == .user ? "**User**" : "**Assistant**" return "\(header)\n\n\(msg.content)" }.joined(separator: "\n\n---\n\n") 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 or json.") return } // Write to Downloads folder let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory let fileURL = downloads.appendingPathComponent(filename) do { try content.write(to: fileURL, atomically: true, encoding: .utf8) showSystemMessage("Exported to \(fileURL.path)") } catch { showSystemMessage("Export failed: \(error.localizedDescription)") } } // 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 } } /// Check if conversation should be auto-saved based on criteria func shouldAutoSave() -> Bool { // Check if auto-save is enabled guard settings.syncEnabled && settings.syncAutoSave else { return false } // Check if sync is configured guard settings.syncConfigured else { return false } // Check if repository is cloned guard GitSyncService.shared.syncStatus.isCloned else { return false } // Check minimum message count let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant } guard chatMessages.count >= settings.syncAutoSaveMinMessages else { return false } // Check if already auto-saved this conversation // (prevent duplicate saves) if let lastSavedId = settings.syncLastAutoSaveConversationId { // Create a hash of current conversation to detect if it's the same one let currentHash = chatMessages.map { $0.content }.joined() if lastSavedId == currentHash { return false // Already saved this exact conversation } } return true } /// Auto-save the current conversation with background summarization func autoSaveConversation() async { guard shouldAutoSave() else { return } Log.ui.info("Auto-saving conversation...") // Get summary in background (hidden from user) let summary = await summarizeConversationInBackground() // Use summary as name, or fallback to timestamp let conversationName: String if let summary = summary, !summary.isEmpty { conversationName = summary } else { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm" conversationName = "Conversation - \(formatter.string(from: Date()))" } // Save the conversation do { let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant } let conversation = try DatabaseService.shared.saveConversation( name: conversationName, messages: chatMessages ) currentConversationId = conversation.id currentConversationName = conversationName savedMessageCount = chatMessages.count Log.ui.info("Auto-saved conversation: \(conversationName)") // Check if progressive summarization is needed Task { await checkAndSummarizeOldMessages(conversationId: conversation.id) } // Generate embeddings for messages that don't have them yet if settings.embeddingsEnabled { Task { for message in chatMessages { // Skip if already embedded if let _ = try? EmbeddingService.shared.getMessageEmbedding(messageId: message.id) { continue } // Generate embedding (this will now succeed since message is in DB) generateEmbeddingForMessage(message) } } } // Mark as saved to prevent duplicate saves let conversationHash = chatMessages.map { $0.content }.joined() settings.syncLastAutoSaveConversationId = conversationHash // Trigger auto-sync (export + push) Task { await performAutoSync() } } catch { Log.ui.error("Auto-save failed: \(error.localizedDescription)") } } /// Perform auto-sync: export + push to git (debounced) private func performAutoSync() async { await GitSyncService.shared.autoSync() } // MARK: - Smart Triggers /// Update conversation tracking times func updateConversationTracking() { let now = Date() if conversationStartTime == nil { conversationStartTime = now } lastMessageTime = now // Restart idle timer if enabled if settings.syncAutoSaveOnIdle { startIdleTimer() } } /// Start or restart the idle timer private func startIdleTimer() { // Cancel existing timer idleCheckTimer?.invalidate() let idleMinutes = settings.syncAutoSaveIdleMinutes let idleSeconds = TimeInterval(idleMinutes * 60) // Schedule new timer idleCheckTimer = Timer.scheduledTimer(withTimeInterval: idleSeconds, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in await self?.onIdleTimeout() } } } /// Called when idle timeout is reached private func onIdleTimeout() async { guard settings.syncAutoSaveOnIdle else { return } Log.ui.info("Idle timeout reached - triggering auto-save") await autoSaveConversation() } /// Detect goodbye phrases in user message func detectGoodbyePhrase(in text: String) -> Bool { let lowercased = text.lowercased() let goodbyePhrases = [ "bye", "goodbye", "bye bye", "thanks", "thank you", "thx", "ty", "that's all", "thats all", "that'll be all", "done", "i'm done", "we're done", "see you", "see ya", "catch you later", "have a good", "have a nice" ] return goodbyePhrases.contains { phrase in // Check for whole word match (not substring) let pattern = "\\b\(NSRegularExpression.escapedPattern(for: phrase))\\b" return lowercased.range(of: pattern, options: .regularExpression) != nil } } /// Trigger auto-save when user switches models func onModelSwitch(from oldModel: ModelInfo?, to newModel: ModelInfo?) async { guard settings.syncAutoSaveOnModelSwitch else { return } guard oldModel != nil else { return } // Don't save on first model selection Log.ui.info("Model switch detected - triggering auto-save") await autoSaveConversation() } /// Trigger auto-save on app quit func onAppWillTerminate() async { guard settings.syncAutoSaveOnAppQuit else { return } Log.ui.info("App quit detected - triggering auto-save") await autoSaveConversation() } /// Check and trigger auto-save after user message func checkAutoSaveTriggersAfterMessage(_ text: String) async { // Update tracking updateConversationTracking() // Check for goodbye phrase if detectGoodbyePhrase(in: text) { Log.ui.info("Goodbye phrase detected - triggering auto-save") // Wait a bit to see if user continues try? await Task.sleep(for: .seconds(30)) // Check if they sent another message in the meantime if let lastTime = lastMessageTime, Date().timeIntervalSince(lastTime) < 25 { Log.ui.info("User continued chatting - skipping goodbye auto-save") return } await autoSaveConversation() } } // MARK: - Embedding Generation /// Generate embedding for a message in the background func generateEmbeddingForMessage(_ message: Message) { guard settings.embeddingsEnabled else { return } guard message.content.count > 20 else { return } // Skip very short messages Task { do { // Use user's selected provider, or fall back to best available guard let provider = EmbeddingService.shared.getSelectedProvider() else { Log.api.warning("No embedding providers available - skipping embedding generation") return } 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 { // Check if it's a foreign key constraint error (message not saved to DB yet) 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)") } } } } /// 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)") } } /// 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 } } }