1757 lines
68 KiB
Swift
1757 lines
68 KiB
Swift
//
|
|
// 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<Void, Never>?
|
|
private var autoContinueTask: Task<Void, Never>?
|
|
private let settings = SettingsService.shared
|
|
private let providerRegistry = ProviderRegistry.shared
|
|
|
|
// Default system prompt - generic for all models
|
|
private let defaultSystemPrompt = """
|
|
You are a helpful AI assistant. Follow these core principles:
|
|
|
|
## CORE BEHAVIOR
|
|
|
|
- **Accuracy First**: Never invent information. If unsure, say so clearly.
|
|
- **Ask for Clarification**: When ambiguous, ask questions before proceeding.
|
|
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
|
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
|
|
- **Complete Tasks Properly**: If you start something, finish it correctly.
|
|
|
|
## 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 <name>")
|
|
}
|
|
|
|
case "/load", "/list":
|
|
showConversations = true
|
|
|
|
case "/delete":
|
|
if let name = args.first {
|
|
do {
|
|
let deleted = try DatabaseService.shared.deleteConversation(name: name)
|
|
if deleted {
|
|
showSystemMessage("Deleted conversation '\(name)'")
|
|
} else {
|
|
showSystemMessage("No conversation found with name '\(name)'")
|
|
}
|
|
} catch {
|
|
showSystemMessage("Failed to delete: \(error.localizedDescription)")
|
|
}
|
|
} else {
|
|
showSystemMessage("Usage: /delete <name>")
|
|
}
|
|
|
|
case "/export":
|
|
if args.count >= 1 {
|
|
let format = args[0].lowercased()
|
|
let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
|
|
exportConversation(format: format, filename: filename)
|
|
} else {
|
|
showSystemMessage("Usage: /export md|json <filename>")
|
|
}
|
|
|
|
case "/info":
|
|
if let modelId = args.first {
|
|
if let model = availableModels.first(where: { $0.id == modelId || $0.name.lowercased() == modelId.lowercased() }) {
|
|
showModelInfo(model)
|
|
} else {
|
|
showSystemMessage("Model not found: \(modelId)")
|
|
}
|
|
} else if let model = selectedModel {
|
|
showModelInfo(model)
|
|
} else {
|
|
showSystemMessage("No model selected")
|
|
}
|
|
|
|
case "/credits":
|
|
showCredits = true
|
|
|
|
case "/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 <path>")
|
|
}
|
|
|
|
case "remove":
|
|
if args.count >= 2 {
|
|
let ref = args.dropFirst().joined(separator: " ")
|
|
if let index = Int(ref) {
|
|
if mcp.removeFolder(at: index) {
|
|
showSystemMessage("MCP: Removed folder at index \(index)")
|
|
} else {
|
|
showSystemMessage("MCP: Invalid index \(index)")
|
|
}
|
|
} else {
|
|
if mcp.removeFolder(path: ref) {
|
|
showSystemMessage("MCP: Removed folder")
|
|
} else {
|
|
showSystemMessage("MCP: Folder not found: \(ref)")
|
|
}
|
|
}
|
|
} else {
|
|
showSystemMessage("Usage: /mcp remove <index|path>")
|
|
}
|
|
|
|
case "list":
|
|
if mcp.allowedFolders.isEmpty {
|
|
showSystemMessage("MCP: No folders registered. Use /mcp add <path>")
|
|
} else {
|
|
let list = mcp.allowedFolders.enumerated().map { "\($0): \($1)" }.joined(separator: "\n")
|
|
showSystemMessage("MCP folders:\n\(list)")
|
|
}
|
|
|
|
case "write":
|
|
guard args.count >= 2 else {
|
|
showSystemMessage("Usage: /mcp write on|off")
|
|
return
|
|
}
|
|
let toggle = args[1].lowercased()
|
|
if toggle == "on" {
|
|
settings.mcpCanWriteFiles = true
|
|
settings.mcpCanDeleteFiles = true
|
|
settings.mcpCanCreateDirectories = true
|
|
settings.mcpCanMoveFiles = true
|
|
showSystemMessage("MCP: All write permissions enabled (write, edit, delete, create dirs, move, copy)")
|
|
} else if toggle == "off" {
|
|
settings.mcpCanWriteFiles = false
|
|
settings.mcpCanDeleteFiles = false
|
|
settings.mcpCanCreateDirectories = false
|
|
settings.mcpCanMoveFiles = false
|
|
showSystemMessage("MCP: All write permissions disabled")
|
|
} else {
|
|
showSystemMessage("Usage: /mcp write on|off")
|
|
}
|
|
|
|
case "status":
|
|
let enabled = mcpEnabled ? "enabled" : "disabled"
|
|
let folders = mcp.allowedFolders.count
|
|
var perms: [String] = []
|
|
if settings.mcpCanWriteFiles { perms.append("write") }
|
|
if settings.mcpCanDeleteFiles { perms.append("delete") }
|
|
if settings.mcpCanCreateDirectories { perms.append("mkdir") }
|
|
if settings.mcpCanMoveFiles { perms.append("move/copy") }
|
|
let permStr = perms.isEmpty ? "read-only" : "read + \(perms.joined(separator: ", "))"
|
|
showSystemMessage("MCP: \(enabled), \(folders) folder\(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..<maxIterations {
|
|
if Task.isCancelled {
|
|
wasCancelled = true
|
|
break
|
|
}
|
|
|
|
let response = try await provider.chatWithToolMessages(
|
|
model: effectiveModelId,
|
|
messages: apiMessages,
|
|
tools: tools,
|
|
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
|
|
temperature: settings.temperature > 0 ? settings.temperature : nil
|
|
)
|
|
|
|
if let usage = response.usage { totalUsage = usage }
|
|
|
|
// Check if the model wants to call tools
|
|
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
|
|
}
|
|
}
|
|
}
|