// // ChatViewModel.swift // oAI // // Main chat view model // 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 modelInfoTarget: ModelInfo? = nil var commandHistory: [String] = [] var historyIndex: Int = 0 var isAutoContinuing: Bool = false 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 **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 } 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 = "" } /// 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)'") } catch { showSystemMessage("Failed to load: \(error.localizedDescription)") } } 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) { // Get current starred status let isStarred = (try? DatabaseService.shared.getMessageMetadata(messageId: messageId)?.user_starred == 1) ?? false // Toggle starred status do { try DatabaseService.shared.setMessageStarred(messageId: messageId, starred: !isStarred) Log.ui.info("Message \(messageId) starred: \(!isStarred)") } catch { Log.ui.error("Failed to toggle star for message \(messageId): \(error)") } } // 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 _ = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages) showSystemMessage("Conversation saved as '\(name)'") } catch { showSystemMessage("Failed to save: \(error.localizedDescription)") } } else { showSystemMessage("Usage: /save ") } 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 "/mcp": handleMCPCommand(args: args) default: 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 // Skip for image generation models — they don't support tool calling let mcp = MCPService.shared let mcpActive = mcpEnabled || settings.mcpEnabled let modelSupportTools = selectedModel?.capabilities.tools ?? false if mcpActive && !mcp.allowedFolders.isEmpty && modelSupportTools { 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 cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) 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 cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) 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 let resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded) // 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: - 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 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: ", "))." } var systemContent = "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." // 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 ["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 guard let toolCalls = response.toolCalls, !toolCalls.isEmpty else { // No tool calls — this is the final text response finalContent = response.content break } // Show what tools the model is calling let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ") showSystemMessage("🔧 Calling: \(toolNames)") // Append assistant message with tool_calls to conversation 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 for tc in toolCalls { if Task.isCancelled { wasCancelled = true break } let result = 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) { resultJSON = str } else { resultJSON = "{\"error\": \"Failed to serialize result\"}" } apiMessages.append([ "role": "tool", "tool_call_id": tc.id, "name": tc.functionName, "content": resultJSON ]) } // 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 cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) + (Double(usage.completionTokens) * model.pricing.completion / 1_000_000) 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 } private 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 ) 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 } } }