1982 lines
80 KiB
Swift
1982 lines
80 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 showShortcuts: Bool = false
|
|
var showSkills: 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
|
|
}
|
|
|
|
// Append active agent skills (SKILL.md-style behavioral instructions)
|
|
let activeSkills = settings.agentSkills.filter { $0.isActive }
|
|
if !activeSkills.isEmpty {
|
|
prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n"
|
|
for skill in activeSkills {
|
|
prompt += "### \(skill.name)\n\n\(skill.content)\n\n"
|
|
let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id)
|
|
if !files.isEmpty {
|
|
prompt += "**Skill Data Files:**\n\n"
|
|
for (name, content) in files {
|
|
let ext = URL(fileURLWithPath: name).pathExtension.lowercased()
|
|
prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return prompt
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
init() {
|
|
// Load settings
|
|
self.currentProvider = settings.defaultProvider
|
|
self.onlineMode = settings.onlineMode
|
|
self.memoryEnabled = settings.memoryEnabled
|
|
self.mcpEnabled = settings.mcpEnabled
|
|
|
|
// Load command history from database
|
|
if let history = try? DatabaseService.shared.loadCommandHistory() {
|
|
self.commandHistory = history.map { $0.input }
|
|
self.historyIndex = self.commandHistory.count
|
|
}
|
|
|
|
// Load models on startup
|
|
Task {
|
|
await loadAvailableModels()
|
|
}
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Switch to a different provider (from header dropdown)
|
|
func changeProvider(_ newProvider: Settings.Provider) {
|
|
guard newProvider != currentProvider else { return }
|
|
Log.ui.info("Switching provider to \(newProvider.rawValue)")
|
|
settings.defaultProvider = newProvider
|
|
currentProvider = newProvider
|
|
selectedModel = nil
|
|
availableModels = []
|
|
Task { await loadAvailableModels() }
|
|
}
|
|
|
|
/// Start a new conversation
|
|
func newConversation() {
|
|
messages = []
|
|
sessionStats = SessionStats()
|
|
inputText = ""
|
|
}
|
|
|
|
/// Re-sync local state from SettingsService (called when Settings sheet dismisses)
|
|
func syncFromSettings() {
|
|
let newProvider = settings.defaultProvider
|
|
let providerChanged = currentProvider != newProvider
|
|
currentProvider = newProvider
|
|
onlineMode = settings.onlineMode
|
|
memoryEnabled = settings.memoryEnabled
|
|
mcpEnabled = settings.mcpEnabled
|
|
mcpStatus = mcpEnabled ? "MCP" : nil
|
|
|
|
if providerChanged {
|
|
selectedModel = nil
|
|
availableModels = []
|
|
Task { await loadAvailableModels() }
|
|
}
|
|
}
|
|
|
|
func loadAvailableModels() async {
|
|
isLoadingModels = true
|
|
|
|
do {
|
|
guard let provider = providerRegistry.getCurrentProvider() else {
|
|
Log.ui.warning("No API key configured for current provider")
|
|
isLoadingModels = false
|
|
showSystemMessage("⚠️ No API key configured. Add your API key in Settings to load models.")
|
|
return
|
|
}
|
|
|
|
let models = try await provider.listModels()
|
|
availableModels = models
|
|
|
|
// Select model priority: saved default > current selection > first available
|
|
if let defaultModelId = settings.defaultModel,
|
|
let defaultModel = models.first(where: { $0.id == defaultModelId }) {
|
|
selectedModel = defaultModel
|
|
} else if selectedModel == nil, let firstModel = models.first {
|
|
selectedModel = firstModel
|
|
}
|
|
isLoadingModels = false
|
|
|
|
} catch {
|
|
Log.api.error("Failed to load models: \(error.localizedDescription)")
|
|
isLoadingModels = false
|
|
showSystemMessage("⚠️ Could not load models: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func sendMessage() {
|
|
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
|
|
|
|
let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)
|
|
|
|
// Handle slash escape: "//" becomes "/"
|
|
var effectiveInput = trimmedInput
|
|
if effectiveInput.hasPrefix("//") {
|
|
effectiveInput = String(effectiveInput.dropFirst())
|
|
} else if effectiveInput.hasPrefix("/") {
|
|
// Check if it's a slash command
|
|
handleCommand(effectiveInput)
|
|
inputText = ""
|
|
return
|
|
}
|
|
|
|
// Parse file attachments
|
|
let (cleanText, filePaths) = effectiveInput.parseFileAttachments()
|
|
|
|
// Read file attachments from disk
|
|
let attachments: [FileAttachment]? = filePaths.isEmpty ? nil : readFileAttachments(filePaths)
|
|
|
|
// Create user message
|
|
let userMessage = Message(
|
|
role: .user,
|
|
content: cleanText,
|
|
tokens: cleanText.estimateTokens(),
|
|
cost: nil,
|
|
timestamp: Date(),
|
|
attachments: attachments,
|
|
modelId: selectedModel?.id
|
|
)
|
|
|
|
messages.append(userMessage)
|
|
sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)
|
|
|
|
// Generate embedding for user message
|
|
generateEmbeddingForMessage(userMessage)
|
|
|
|
// Add to command history (in-memory and database)
|
|
commandHistory.append(trimmedInput)
|
|
historyIndex = commandHistory.count
|
|
DatabaseService.shared.saveCommandHistory(input: trimmedInput)
|
|
|
|
// Clear input
|
|
inputText = ""
|
|
|
|
// Check auto-save triggers in background
|
|
Task {
|
|
await checkAutoSaveTriggersAfterMessage(cleanText)
|
|
}
|
|
|
|
// Generate real AI response
|
|
generateAIResponse(to: cleanText, attachments: userMessage.attachments)
|
|
}
|
|
|
|
func cancelGeneration() {
|
|
streamingTask?.cancel()
|
|
streamingTask = nil
|
|
isGenerating = false
|
|
cancelAutoContinue() // Also cancel any pending auto-continue
|
|
}
|
|
|
|
func startAutoContinue() {
|
|
isAutoContinuing = true
|
|
autoContinueCountdown = 5
|
|
|
|
autoContinueTask = Task { @MainActor in
|
|
// Countdown from 5 to 1
|
|
for i in (1...5).reversed() {
|
|
if Task.isCancelled {
|
|
isAutoContinuing = false
|
|
return
|
|
}
|
|
autoContinueCountdown = i
|
|
try? await Task.sleep(for: .seconds(1))
|
|
}
|
|
|
|
// Continue the task
|
|
isAutoContinuing = false
|
|
autoContinueCountdown = 0
|
|
|
|
let continuePrompt = "Please continue from where you left off."
|
|
|
|
// Add user message
|
|
let userMessage = Message(
|
|
role: .user,
|
|
content: continuePrompt,
|
|
tokens: nil,
|
|
cost: nil,
|
|
timestamp: Date(),
|
|
attachments: nil,
|
|
responseTime: nil,
|
|
wasInterrupted: false,
|
|
modelId: selectedModel?.id
|
|
)
|
|
messages.append(userMessage)
|
|
|
|
// Continue generation
|
|
generateAIResponse(to: continuePrompt, attachments: nil)
|
|
}
|
|
}
|
|
|
|
func cancelAutoContinue() {
|
|
autoContinueTask?.cancel()
|
|
autoContinueTask = nil
|
|
isAutoContinuing = false
|
|
autoContinueCountdown = 0
|
|
}
|
|
|
|
func clearChat() {
|
|
messages.removeAll()
|
|
sessionStats.reset()
|
|
showSystemMessage("Chat cleared")
|
|
}
|
|
|
|
func loadConversation(_ conversation: Conversation) {
|
|
do {
|
|
guard let (_, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
|
|
showSystemMessage("Could not load conversation '\(conversation.name)'")
|
|
return
|
|
}
|
|
|
|
messages.removeAll()
|
|
sessionStats.reset()
|
|
messages = loadedMessages
|
|
|
|
// Rebuild session stats from loaded messages
|
|
for msg in loadedMessages {
|
|
sessionStats.addMessage(
|
|
inputTokens: msg.role == .user ? msg.tokens : nil,
|
|
outputTokens: msg.role == .assistant ? msg.tokens : nil,
|
|
cost: msg.cost
|
|
)
|
|
}
|
|
|
|
showSystemMessage("Loaded conversation '\(conversation.name)'")
|
|
|
|
// Auto-switch to the provider/model this conversation was created with
|
|
if let modelId = conversation.primaryModel {
|
|
Task { await switchToConversationModel(modelId) }
|
|
}
|
|
} catch {
|
|
showSystemMessage("Failed to load: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/// Infer which provider owns a given model ID based on naming conventions.
|
|
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
|
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
|
if modelId.contains("/") { return .openrouter }
|
|
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
|
if modelId.hasPrefix("claude-") { return .anthropic }
|
|
// OpenAI direct
|
|
if modelId.hasPrefix("gpt-") || modelId.hasPrefix("o1") || modelId.hasPrefix("o3")
|
|
|| modelId.hasPrefix("dall-e-") || modelId.hasPrefix("chatgpt-") { return .openai }
|
|
// Ollama uses short local names with no vendor prefix
|
|
return .ollama
|
|
}
|
|
|
|
/// Silently switch provider + model to match a loaded conversation.
|
|
/// Shows a system message only on failure or on a successful switch.
|
|
@MainActor
|
|
private func switchToConversationModel(_ modelId: String) async {
|
|
guard let targetProvider = inferProvider(from: modelId) else {
|
|
showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model")
|
|
return
|
|
}
|
|
|
|
guard providerRegistry.hasValidAPIKey(for: targetProvider) else {
|
|
showSystemMessage("⚠️ No API key for \(targetProvider.displayName) — keeping current model")
|
|
return
|
|
}
|
|
|
|
// Switch provider if needed, or load models if not yet loaded
|
|
if targetProvider != currentProvider || availableModels.isEmpty {
|
|
if targetProvider != currentProvider {
|
|
settings.defaultProvider = targetProvider
|
|
currentProvider = targetProvider
|
|
selectedModel = nil
|
|
availableModels = []
|
|
providerRegistry.clearCache()
|
|
}
|
|
|
|
isLoadingModels = true
|
|
do {
|
|
guard let provider = providerRegistry.getProvider(for: targetProvider) else {
|
|
isLoadingModels = false
|
|
showSystemMessage("⚠️ Could not connect to \(targetProvider.displayName) — keeping current model")
|
|
return
|
|
}
|
|
availableModels = try await provider.listModels()
|
|
isLoadingModels = false
|
|
} catch {
|
|
isLoadingModels = false
|
|
showSystemMessage("⚠️ Could not load \(targetProvider.displayName) models — keeping current model")
|
|
return
|
|
}
|
|
}
|
|
|
|
guard let model = availableModels.first(where: { $0.id == modelId }) else {
|
|
showSystemMessage("⚠️ Model '\(modelId)' not available — keeping current model")
|
|
return
|
|
}
|
|
|
|
guard selectedModel?.id != modelId else { return } // already on it, no message needed
|
|
|
|
selectedModel = model
|
|
showSystemMessage("Switched to \(model.name) · \(targetProvider.displayName)")
|
|
}
|
|
|
|
func retryLastMessage() {
|
|
guard let lastUserMessage = messages.last(where: { $0.role == .user }) else {
|
|
showSystemMessage("No previous message to retry")
|
|
return
|
|
}
|
|
|
|
// Remove last assistant response if exists
|
|
if let lastMessage = messages.last, lastMessage.role == .assistant {
|
|
messages.removeLast()
|
|
}
|
|
|
|
generateAIResponse(to: lastUserMessage.content, attachments: lastUserMessage.attachments)
|
|
}
|
|
|
|
func toggleMessageStar(messageId: UUID) {
|
|
// 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 "/shortcuts":
|
|
showShortcuts = true
|
|
|
|
case "/skills":
|
|
showSkills = true
|
|
|
|
case "/mcp":
|
|
handleMCPCommand(args: args)
|
|
|
|
default:
|
|
// Check user-defined shortcuts
|
|
if let shortcut = settings.userShortcuts.first(where: { $0.command == cmd.lowercased() }) {
|
|
let userInput = args.joined(separator: " ")
|
|
let prompt = shortcut.needsInput
|
|
? shortcut.template.replacingOccurrences(of: "{{input}}", with: userInput)
|
|
: shortcut.template
|
|
let msg = Message(
|
|
role: .user,
|
|
content: prompt,
|
|
tokens: prompt.estimateTokens(),
|
|
cost: nil,
|
|
timestamp: Date(),
|
|
attachments: nil,
|
|
modelId: selectedModel?.id
|
|
)
|
|
messages.append(msg)
|
|
sessionStats.addMessage(inputTokens: msg.tokens, outputTokens: nil, cost: nil)
|
|
generateEmbeddingForMessage(msg)
|
|
generateAIResponse(to: prompt, attachments: nil)
|
|
return
|
|
}
|
|
showSystemMessage("Unknown command: \(cmd)\nType /help for available commands")
|
|
}
|
|
}
|
|
|
|
// MARK: - AI Response Generation
|
|
|
|
private func generateAIResponse(to prompt: String, attachments: [FileAttachment]?) {
|
|
// Get provider
|
|
guard let provider = providerRegistry.getCurrentProvider() else {
|
|
Log.ui.warning("Cannot generate: no API key configured")
|
|
showSystemMessage("❌ No API key configured. Please add your API key in Settings.")
|
|
return
|
|
}
|
|
|
|
guard let modelId = selectedModel?.id else {
|
|
Log.ui.warning("Cannot generate: no model selected")
|
|
showSystemMessage("❌ No model selected. Please select a model first.")
|
|
return
|
|
}
|
|
|
|
Log.ui.info("Sending message: model=\(modelId), messages=\(self.messages.count)")
|
|
|
|
// Dispatch to tool-aware path when MCP is enabled with folders or Anytype is enabled
|
|
// Skip for image generation models — they don't support tool calling
|
|
let mcp = MCPService.shared
|
|
let mcpActive = mcpEnabled || settings.mcpEnabled
|
|
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
|
|
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
|
if modelSupportTools && (anytypeActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
|
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
|
return
|
|
}
|
|
|
|
isGenerating = true
|
|
|
|
// Cancel any existing task
|
|
streamingTask?.cancel()
|
|
|
|
// Start streaming
|
|
streamingTask = Task {
|
|
let startTime = Date()
|
|
var messageId: UUID?
|
|
do {
|
|
// Create empty assistant message for streaming
|
|
let assistantMessage = Message(
|
|
role: .assistant,
|
|
content: "",
|
|
tokens: nil,
|
|
cost: nil,
|
|
timestamp: Date(),
|
|
attachments: nil,
|
|
modelId: modelId,
|
|
isStreaming: true
|
|
)
|
|
messageId = assistantMessage.id
|
|
|
|
// Already on MainActor
|
|
messages.append(assistantMessage)
|
|
|
|
// Build chat request AFTER adding the assistant message
|
|
// Only include messages up to (but not including) the streaming assistant message
|
|
var messagesToSend = Array(messages.dropLast()) // Remove the empty assistant message
|
|
|
|
// Web search via our WebSearchService
|
|
// Append results to last user message content (matching Python oAI approach)
|
|
if onlineMode && currentProvider != .openrouter && !messagesToSend.isEmpty {
|
|
if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
|
|
Log.search.info("Running web search for \(currentProvider.displayName)")
|
|
let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
|
|
if !results.isEmpty {
|
|
let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
|
|
messagesToSend[lastUserIdx].content += searchContext
|
|
Log.search.info("Injected \(results.count) search results into user message")
|
|
}
|
|
}
|
|
}
|
|
|
|
let isImageGen = selectedModel?.capabilities.imageGeneration ?? false
|
|
if isImageGen {
|
|
Log.ui.info("Image generation mode for model \(modelId)")
|
|
}
|
|
|
|
// Smart context selection
|
|
let contextStrategy: SelectionStrategy
|
|
if !memoryEnabled {
|
|
contextStrategy = .lastMessageOnly
|
|
} else if settings.contextSelectionEnabled {
|
|
contextStrategy = .smart
|
|
} else {
|
|
contextStrategy = .allMessages
|
|
}
|
|
|
|
let contextWindow = ContextSelectionService.shared.selectContext(
|
|
allMessages: messagesToSend,
|
|
strategy: contextStrategy,
|
|
maxTokens: selectedModel?.contextLength ?? settings.contextMaxTokens,
|
|
currentQuery: messagesToSend.last?.content
|
|
)
|
|
|
|
if contextWindow.excludedCount > 0 {
|
|
Log.ui.info("Smart context: selected \(contextWindow.messages.count) messages (\(contextWindow.totalTokens) tokens), excluded \(contextWindow.excludedCount)")
|
|
}
|
|
|
|
// Build system prompt with summaries (if any)
|
|
var finalSystemPrompt = effectiveSystemPrompt
|
|
if !contextWindow.summaries.isEmpty {
|
|
let summariesText = contextWindow.summaries.enumerated().map { index, summary in
|
|
"[Previous conversation summary (part \(index + 1)):]\\n\(summary)"
|
|
}.joined(separator: "\n\n")
|
|
|
|
finalSystemPrompt = summariesText + "\n\n---\n\n" + effectiveSystemPrompt
|
|
}
|
|
|
|
let chatRequest = ChatRequest(
|
|
messages: contextWindow.messages,
|
|
model: modelId,
|
|
stream: settings.streamEnabled,
|
|
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
|
|
temperature: settings.temperature > 0 ? settings.temperature : nil,
|
|
topP: nil,
|
|
systemPrompt: finalSystemPrompt,
|
|
tools: nil,
|
|
onlineMode: onlineMode,
|
|
imageGeneration: isImageGen
|
|
)
|
|
|
|
if isImageGen {
|
|
// Image generation: use non-streaming request
|
|
// Image models don't reliably support streaming
|
|
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
|
messages[index].content = ThinkingVerbs.random()
|
|
}
|
|
|
|
let nonStreamRequest = ChatRequest(
|
|
messages: chatRequest.messages,
|
|
model: chatRequest.model,
|
|
stream: false,
|
|
maxTokens: chatRequest.maxTokens,
|
|
temperature: chatRequest.temperature,
|
|
imageGeneration: true
|
|
)
|
|
let response = try await provider.chat(request: nonStreamRequest)
|
|
let responseTime = Date().timeIntervalSince(startTime)
|
|
|
|
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
|
messages[index].content = response.content
|
|
messages[index].isStreaming = false
|
|
messages[index].generatedImages = response.generatedImages
|
|
messages[index].responseTime = responseTime
|
|
|
|
if let usage = response.usage {
|
|
messages[index].tokens = usage.completionTokens
|
|
if let model = selectedModel {
|
|
let 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
|
|
var resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded)
|
|
|
|
// If not found, try iCloud Drive path
|
|
if !fm.fileExists(atPath: resolvedPath) {
|
|
let icloudBase = (("~/Library/Mobile Documents" as NSString).expandingTildeInPath as NSString)
|
|
let candidate = icloudBase.appendingPathComponent(rawPath)
|
|
if fm.fileExists(atPath: candidate) {
|
|
resolvedPath = candidate
|
|
}
|
|
}
|
|
|
|
// Check file exists
|
|
guard fm.fileExists(atPath: resolvedPath) else {
|
|
showSystemMessage("⚠️ File not found: \(rawPath)")
|
|
continue
|
|
}
|
|
|
|
// Check file size
|
|
guard let attrs = try? fm.attributesOfItem(atPath: resolvedPath),
|
|
let fileSize = attrs[.size] as? Int else {
|
|
showSystemMessage("⚠️ Cannot read file: \(rawPath)")
|
|
continue
|
|
}
|
|
|
|
if fileSize > maxFileSize {
|
|
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
|
|
showSystemMessage("⚠️ File too large (\(sizeMB) MB, max 10 MB): \(rawPath)")
|
|
continue
|
|
}
|
|
|
|
let type = FileAttachment.typeFromExtension(resolvedPath)
|
|
|
|
switch type {
|
|
case .image, .pdf:
|
|
// Read as raw data
|
|
guard let data = fm.contents(atPath: resolvedPath) else {
|
|
showSystemMessage("⚠️ Could not read file: \(rawPath)")
|
|
continue
|
|
}
|
|
attachments.append(FileAttachment(path: rawPath, type: type, data: data))
|
|
|
|
case .text:
|
|
// Read as string
|
|
guard let content = try? String(contentsOfFile: resolvedPath, encoding: .utf8) else {
|
|
showSystemMessage("⚠️ Could not read file as text: \(rawPath)")
|
|
continue
|
|
}
|
|
|
|
var finalContent = content
|
|
// Truncate large text files
|
|
if content.utf8.count > maxTextSize {
|
|
let lines = content.components(separatedBy: "\n")
|
|
if lines.count > 600 {
|
|
let head = lines.prefix(500).joined(separator: "\n")
|
|
let tail = lines.suffix(100).joined(separator: "\n")
|
|
let omitted = lines.count - 600
|
|
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
|
|
}
|
|
}
|
|
|
|
attachments.append(FileAttachment(path: rawPath, type: .text, data: finalContent.data(using: .utf8)))
|
|
}
|
|
}
|
|
|
|
return attachments
|
|
}
|
|
|
|
// MARK: - Text Tool Call Parsing
|
|
|
|
/// Fallback parser for models that write tool calls as text instead of using structured tool_calls.
|
|
/// Handles two patterns:
|
|
/// tool_name{"arg": "val"} (no space between name and args)
|
|
/// tool_name({"arg": "val"}) (with wrapping parens)
|
|
private func parseTextToolCalls(from content: String) -> [ToolCallInfo] {
|
|
var results: [ToolCallInfo] = []
|
|
|
|
// Match: word_chars optionally followed by ( then { ... } optionally followed by )
|
|
// Use a broad pattern and validate JSON manually
|
|
let pattern = #"([a-z_][a-z0-9_]*)\s*\(?\s*(\{[\s\S]*?\})\s*\)?"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
|
return []
|
|
}
|
|
|
|
let nsContent = content as NSString
|
|
let matches = regex.matches(in: content, range: NSRange(location: 0, length: nsContent.length))
|
|
let knownTools = Set(MCPService.shared.getToolSchemas().map { $0.function.name })
|
|
|
|
for match in matches {
|
|
guard let nameRange = Range(match.range(at: 1), in: content),
|
|
let argsRange = Range(match.range(at: 2), in: content) else { continue }
|
|
|
|
let name = String(content[nameRange])
|
|
let argsStr = String(content[argsRange])
|
|
|
|
// Only handle known tool names to avoid false positives
|
|
guard knownTools.contains(name) else { continue }
|
|
|
|
// Validate the JSON
|
|
guard let _ = try? JSONSerialization.jsonObject(with: Data(argsStr.utf8)) else { continue }
|
|
|
|
Log.ui.info("Parsed text tool call: \(name)")
|
|
results.append(ToolCallInfo(id: UUID().uuidString, type: "function", functionName: name, arguments: argsStr))
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// MARK: - MCP Command Handling
|
|
|
|
private func handleMCPCommand(args: [String]) {
|
|
let mcp = MCPService.shared
|
|
guard let sub = args.first?.lowercased() else {
|
|
showSystemMessage("Usage: /mcp on|off|status|add|remove|list")
|
|
return
|
|
}
|
|
|
|
switch sub {
|
|
case "on":
|
|
mcpEnabled = true
|
|
settings.mcpEnabled = true
|
|
mcpStatus = "MCP"
|
|
showSystemMessage("MCP enabled (\(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered)")
|
|
|
|
case "off":
|
|
mcpEnabled = false
|
|
settings.mcpEnabled = false
|
|
mcpStatus = nil
|
|
showSystemMessage("MCP disabled")
|
|
|
|
case "add":
|
|
if args.count >= 2 {
|
|
let path = args.dropFirst().joined(separator: " ")
|
|
if let error = mcp.addFolder(path) {
|
|
showSystemMessage("MCP: \(error)")
|
|
} else {
|
|
showSystemMessage("MCP: Added folder — \(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered")
|
|
}
|
|
} else {
|
|
showSystemMessage("Usage: /mcp add <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
|
|
var systemParts: [String] = []
|
|
let mcpActive = mcpEnabled || settings.mcpEnabled
|
|
|
|
if mcpActive && !mcp.allowedFolders.isEmpty {
|
|
let folderList = mcp.allowedFolders.joined(separator: "\n - ")
|
|
var capabilities = "You can read files, list directories, and search for files."
|
|
var writeCapabilities: [String] = []
|
|
if mcp.canWriteFiles { writeCapabilities.append("write and edit files") }
|
|
if mcp.canDeleteFiles { writeCapabilities.append("delete files") }
|
|
if mcp.canCreateDirectories { writeCapabilities.append("create directories") }
|
|
if mcp.canMoveFiles { writeCapabilities.append("move and copy files") }
|
|
if !writeCapabilities.isEmpty {
|
|
capabilities += " You can also \(writeCapabilities.joined(separator: ", "))."
|
|
}
|
|
systemParts.append("You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths.")
|
|
}
|
|
|
|
if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
|
|
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
|
|
}
|
|
|
|
var systemContent = systemParts.joined(separator: "\n\n")
|
|
|
|
// Append the complete system prompt (default + custom)
|
|
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
|
|
|
|
var messagesToSend: [Message] = memoryEnabled
|
|
? messages.filter { $0.role != .system }
|
|
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
|
|
|
|
// Web search via our WebSearchService
|
|
// Append results to last user message content (matching Python oAI approach)
|
|
if onlineMode && currentProvider != .openrouter {
|
|
if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
|
|
Log.search.info("Running web search for tool-aware path (\(currentProvider.displayName))")
|
|
let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
|
|
if !results.isEmpty {
|
|
let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
|
|
messagesToSend[lastUserIdx].content += searchContext
|
|
Log.search.info("Injected \(results.count) search results into user message")
|
|
}
|
|
}
|
|
}
|
|
|
|
let systemPrompt: [String: Any] = [
|
|
"role": "system",
|
|
"content": systemContent
|
|
]
|
|
|
|
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
|
|
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
|
|
if hasAttachments {
|
|
var contentArray: [[String: Any]] = [["type": "text", "text": msg.content]]
|
|
for attachment in msg.attachments ?? [] {
|
|
guard let data = attachment.data else { continue }
|
|
switch attachment.type {
|
|
case .image, .pdf:
|
|
let base64String = data.base64EncodedString()
|
|
let dataURL = "data:\(attachment.mimeType);base64,\(base64String)"
|
|
contentArray.append(["type": "image_url", "image_url": ["url": dataURL]])
|
|
case .text:
|
|
let filename = (attachment.path as NSString).lastPathComponent
|
|
let textContent = String(data: data, encoding: .utf8) ?? ""
|
|
contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
|
|
}
|
|
}
|
|
return ["role": msg.role.rawValue, "content": contentArray]
|
|
}
|
|
return ["role": msg.role.rawValue, "content": msg.content]
|
|
}
|
|
|
|
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
|
|
var finalContent = ""
|
|
var totalUsage: ChatResponse.Usage?
|
|
var hitIterationLimit = false // Track if we exited due to hitting the limit
|
|
|
|
for iteration in 0..<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
|
|
// Also parse text-based tool calls for models that don't use structured tool_calls
|
|
let structuredCalls = response.toolCalls ?? []
|
|
let textCalls = structuredCalls.isEmpty ? parseTextToolCalls(from: response.content) : []
|
|
let toolCalls = structuredCalls.isEmpty ? textCalls : structuredCalls
|
|
|
|
guard !toolCalls.isEmpty else {
|
|
// No tool calls — this is the final text response
|
|
// Strip any unparseable tool call text from display
|
|
finalContent = response.content
|
|
break
|
|
}
|
|
|
|
// Show what tools the model is calling
|
|
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
|
|
showSystemMessage("🔧 Calling: \(toolNames)")
|
|
|
|
let usingTextCalls = !textCalls.isEmpty
|
|
|
|
if usingTextCalls {
|
|
// Text-based tool calls: keep assistant message as-is (the text content)
|
|
apiMessages.append(["role": "assistant", "content": response.content])
|
|
} else {
|
|
// Structured tool_calls: append assistant message with tool_calls field
|
|
var assistantMsg: [String: Any] = ["role": "assistant"]
|
|
if !response.content.isEmpty {
|
|
assistantMsg["content"] = response.content
|
|
}
|
|
let toolCallDicts: [[String: Any]] = toolCalls.map { tc in
|
|
[
|
|
"id": tc.id,
|
|
"type": tc.type,
|
|
"function": [
|
|
"name": tc.functionName,
|
|
"arguments": tc.arguments
|
|
]
|
|
]
|
|
}
|
|
assistantMsg["tool_calls"] = toolCallDicts
|
|
apiMessages.append(assistantMsg)
|
|
}
|
|
|
|
// Execute each tool and append results
|
|
var toolResultLines: [String] = []
|
|
for tc in toolCalls {
|
|
if Task.isCancelled {
|
|
wasCancelled = true
|
|
break
|
|
}
|
|
|
|
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
|
|
let resultJSON: String
|
|
if let data = try? JSONSerialization.data(withJSONObject: result),
|
|
let str = String(data: data, encoding: .utf8) {
|
|
// Cap tool results at 50 KB to avoid HTTP 413 on the next API call
|
|
let maxBytes = 50_000
|
|
if str.utf8.count > maxBytes {
|
|
let truncated = String(str.utf8.prefix(maxBytes))!
|
|
resultJSON = truncated + "\n... (result truncated, use a smaller limit or more specific query)"
|
|
} else {
|
|
resultJSON = str
|
|
}
|
|
} else {
|
|
resultJSON = "{\"error\": \"Failed to serialize result\"}"
|
|
}
|
|
|
|
if usingTextCalls {
|
|
// Inject results as a user message for text-call models
|
|
toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)")
|
|
} else {
|
|
apiMessages.append([
|
|
"role": "tool",
|
|
"tool_call_id": tc.id,
|
|
"name": tc.functionName,
|
|
"content": resultJSON
|
|
])
|
|
}
|
|
}
|
|
|
|
if usingTextCalls && !toolResultLines.isEmpty {
|
|
let combined = toolResultLines.joined(separator: "\n\n")
|
|
apiMessages.append(["role": "user", "content": combined])
|
|
}
|
|
|
|
// If this was the last iteration, note it
|
|
if iteration == maxIterations - 1 {
|
|
hitIterationLimit = true // We're exiting with pending tool calls
|
|
finalContent = response.content.isEmpty
|
|
? "[Tool loop reached maximum iterations]"
|
|
: response.content
|
|
}
|
|
}
|
|
|
|
// Check for cancellation one more time after loop exits
|
|
if Task.isCancelled {
|
|
wasCancelled = true
|
|
}
|
|
|
|
// Display the final response as an assistant message
|
|
let responseTime = Date().timeIntervalSince(startTime)
|
|
let assistantMessage = Message(
|
|
role: .assistant,
|
|
content: finalContent,
|
|
tokens: totalUsage?.completionTokens,
|
|
cost: nil,
|
|
timestamp: Date(),
|
|
attachments: nil,
|
|
responseTime: responseTime,
|
|
wasInterrupted: wasCancelled,
|
|
modelId: modelId
|
|
)
|
|
messages.append(assistantMessage)
|
|
|
|
// Calculate cost
|
|
if let usage = totalUsage, let model = selectedModel {
|
|
let 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
|
|
}
|
|
}
|
|
}
|