Added a lot of functionality. Bugfixes and changes

This commit is contained in:
2026-02-15 16:46:06 +01:00
parent 2434e554f8
commit 04c9b8da1e
31 changed files with 6653 additions and 239 deletions

View File

@@ -36,51 +36,109 @@ class ChatViewModel {
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
// Default system prompt - generic for all models
private let defaultSystemPrompt = """
You are a helpful AI assistant. Follow these guidelines:
You are a helpful AI assistant. Follow these core principles:
1. **Accuracy First**: Never invent information or make assumptions. If you're unsure about something, say so clearly.
## CORE BEHAVIOR
2. **Ask for Clarification**: When a request is ambiguous or lacks necessary details, ask clarifying questions before proceeding.
- **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.
3. **Be Honest About Limitations**: If you cannot or should not help with a request, explain why clearly and politely. Don't attempt tasks outside your capabilities.
## FORMATTING
4. **Stay Grounded**: Base responses on facts and known information. Clearly distinguish between facts, informed opinions, and speculation.
Always use Markdown formatting:
- **Bold** for emphasis
- Code blocks with language tags: ```python
- Headings (##, ###) for structure
- Lists for organization
5. **Be Direct**: Provide concise, relevant answers. Avoid unnecessary preambles or apologies.
## HONESTY
6. **Use Markdown Formatting**: Always format your responses using standard Markdown syntax:
- Use **bold** for emphasis
- Use bullet points and numbered lists for organization
- Use code blocks with language tags for code (e.g., ```python)
- Use proper headings (##, ###) to structure long responses
- If the user requests output in other formats (HTML, JSON, XML, etc.), wrap those in appropriate code blocks
7. **Break Down Complex Tasks**: When working with tools (file access, search, etc.), break complex tasks into smaller, manageable steps. If a task requires many operations:
- Complete one logical step at a time
- Present findings or progress after each step
- Ask the user if you should continue to the next step
- Be mindful of tool usage limits (typically 25-30 tool calls per request)
8. **Incremental Progress**: For large codebases or complex analyses, work incrementally. Don't try to explore everything at once. Focus on what's immediately relevant to the user's question.
Remember: It's better to ask questions or admit uncertainty than to provide incorrect or fabricated information.
It's better to admit "I need more information" or "I cannot do that" than to fake completion or invent answers.
"""
/// Builds the complete system prompt by combining default + custom
// 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
if let customPrompt = settings.systemPrompt, !customPrompt.isEmpty {
// 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
}
@@ -201,7 +259,8 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
tokens: cleanText.estimateTokens(),
cost: nil,
timestamp: Date(),
attachments: attachments
attachments: attachments,
modelId: selectedModel?.id
)
messages.append(userMessage)
@@ -215,6 +274,11 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
// Clear input
inputText = ""
// Check auto-save triggers in background
Task {
await checkAutoSaveTriggersAfterMessage(cleanText)
}
// Generate real AI response
generateAIResponse(to: cleanText, attachments: userMessage.attachments)
}
@@ -223,8 +287,56 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
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()
@@ -444,6 +556,7 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
cost: nil,
timestamp: Date(),
attachments: nil,
modelId: modelId,
isStreaming: true
)
messageId = assistantMessage.id
@@ -455,9 +568,9 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
// 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 (skip Anthropic uses native search tool)
// Web search via our WebSearchService
// Append results to last user message content (matching Python oAI approach)
if onlineMode && currentProvider != .anthropic && currentProvider != .openrouter && !messagesToSend.isEmpty {
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)
@@ -490,7 +603,7 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
// 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 = "Generating image..."
messages[index].content = ThinkingVerbs.random()
}
let nonStreamRequest = ChatRequest(
@@ -810,9 +923,9 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
// Web search via our WebSearchService (skip Anthropic uses native search tool)
// Web search via our WebSearchService
// Append results to last user message content (matching Python oAI approach)
if onlineMode && currentProvider != .anthropic && currentProvider != .openrouter {
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)
@@ -833,9 +946,10 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
["role": msg.role.rawValue, "content": msg.content]
}
let maxIterations = 5
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 {
@@ -908,6 +1022,7 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
// 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
@@ -929,7 +1044,8 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
timestamp: Date(),
attachments: nil,
responseTime: responseTime,
wasInterrupted: wasCancelled
wasInterrupted: wasCancelled,
modelId: modelId
)
messages.append(assistantMessage)
@@ -950,6 +1066,11 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
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)
@@ -963,7 +1084,8 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
content: "",
timestamp: Date(),
responseTime: responseTime,
wasInterrupted: true
wasInterrupted: true,
modelId: modelId
)
messages.append(assistantMessage)
} else {
@@ -1079,4 +1201,283 @@ Remember: It's better to ask questions or admit uncertainty than to provide inco
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)")
// 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()
}
}
}