This commit is contained in:
2026-02-25 08:24:05 +01:00
parent 914d608d35
commit 3997f3feee
7 changed files with 504 additions and 105 deletions

View File

@@ -56,7 +56,7 @@ class ChatViewModel {
var modelInfoTarget: ModelInfo? = nil
var commandHistory: [String] = []
var historyIndex: Int = 0
var isAutoContinuing: Bool = false
private var silentContinuePrompt: String? = nil
// Save tracking
var currentConversationId: UUID? = nil
@@ -67,7 +67,6 @@ class ChatViewModel {
let chatCount = messages.filter { $0.role != .system }.count
return chatCount > 0 && chatCount != savedMessageCount
}
var autoContinueCountdown: Int = 0
// MARK: - Auto-Save Tracking
@@ -78,7 +77,6 @@ class ChatViewModel {
// 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
@@ -354,59 +352,21 @@ Don't narrate future actions ("Let me...") - just use the tools.
streamingTask?.cancel()
streamingTask = nil
isGenerating = false
cancelAutoContinue() // Also cancel any pending auto-continue
silentContinuePrompt = nil
}
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)
showSystemMessage("↩ Continuing…")
silentContinuePrompt = "Please continue from where you left off."
Task { @MainActor in
generateAIResponse(to: "", attachments: nil)
}
}
func cancelAutoContinue() {
autoContinueTask?.cancel()
autoContinueTask = nil
isAutoContinuing = false
autoContinueCountdown = 0
}
func clearChat() {
messages.removeAll()
sessionStats.reset()
MCPService.shared.resetBashSessionApproval()
showSystemMessage("Chat cleared")
}
@@ -419,6 +379,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages.removeAll()
sessionStats.reset()
MCPService.shared.resetBashSessionApproval()
messages = loadedMessages
// Rebuild session stats from loaded messages
@@ -450,6 +411,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentProvider = newProvider
settings.defaultModel = model.id
settings.defaultProvider = newProvider
MCPService.shared.resetBashSessionApproval()
}
private func inferProvider(from modelId: String) -> Settings.Provider? {
@@ -784,8 +746,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
let mcp = MCPService.shared
let mcpActive = mcpEnabled || settings.mcpEnabled
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
let bashActive = settings.bashEnabled
let modelSupportTools = selectedModel?.capabilities.tools ?? false
if modelSupportTools && (anytypeActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId)
return
}
@@ -898,7 +861,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
temperature: chatRequest.temperature,
imageGeneration: true
)
let response = try await provider.chat(request: nonStreamRequest)
let response = try await withOverloadedRetry { try await provider.chat(request: nonStreamRequest) }
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
@@ -1304,6 +1267,12 @@ Don't narrate future actions ("Let me...") - just use the tools.
return ["role": msg.role.rawValue, "content": msg.content]
}
// If this is a silent auto-continue, inject the prompt into the API call only
if let continuePrompt = silentContinuePrompt {
apiMessages.append(["role": "user", "content": continuePrompt])
silentContinuePrompt = nil
}
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
var finalContent = ""
var totalUsage: ChatResponse.Usage?
@@ -1315,13 +1284,15 @@ Don't narrate future actions ("Let me...") - just use the tools.
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
)
let response = try await withOverloadedRetry {
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 }
@@ -1536,6 +1507,11 @@ Don't narrate future actions ("Let me...") - just use the tools.
return "Server error. The provider may be experiencing issues. Try again shortly."
}
// Overloaded / 529
if desc.contains("529") || desc.lowercased().contains("overloaded") {
return "API is overloaded. Please 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."
@@ -1545,6 +1521,30 @@ Don't narrate future actions ("Let me...") - just use the tools.
return desc
}
/// Retry an async operation on overloaded (529) errors with exponential backoff.
private func withOverloadedRetry<T>(maxAttempts: Int = 4, operation: () async throws -> T) async throws -> T {
var attempt = 0
while true {
do {
return try await operation()
} catch {
let desc = error.localizedDescription
let isOverloaded = desc.contains("529") || desc.lowercased().contains("overloaded")
attempt += 1
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
let delay = Double(1 << attempt) // 2s, 4s, 8s
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
await MainActor.run {
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
}
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} else {
throw error
}
}
}
}
// MARK: - Helpers
private func showModelInfo(_ model: ModelInfo) {