From a99ebb8c304ab86159e4eca8c30677f1a7e62062 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Sat, 9 May 2026 12:39:34 -0400 Subject: [PATCH] feat: retry button for orchestrator errors + explicit client timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract orchestrator inner loop into _doOrchestrate() so the retry button can re-run without re-adding the user message to DOM or history — same pattern as the existing chat retry. Also set AsyncOpenAI(timeout=settings.timeout_local) so slow remote models (OpenRouter/DeepSeek) get the same 300s budget as local chat calls instead of the SDK default which varies by connection. Co-Authored-By: Claude Sonnet 4.6 --- cortex/openai_orchestrator.py | 2 +- cortex/static/app.js | 73 ++++++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/cortex/openai_orchestrator.py b/cortex/openai_orchestrator.py index cce3d42..00af501 100644 --- a/cortex/openai_orchestrator.py +++ b/cortex/openai_orchestrator.py @@ -405,7 +405,7 @@ def _build_client( base_url = api_url.rstrip("/") if host_type == "openwebui": base_url = base_url + "/api" - client = AsyncOpenAI(base_url=base_url, api_key=api_key) + client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=settings.timeout_local) if model_cfg.get("tools") is False: active_tools = [] else: diff --git a/cortex/static/app.js b/cortex/static/app.js index e350b33..a645a59 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -1215,24 +1215,9 @@ inputEl.focus(); } - async function sendOrchestrate() { - const text = inputEl.value.trim(); - if (!text || activeController) return; - - inputEl.value = ''; - syncHeight(); - sendBtn.style.display = 'none'; - stopBtn.style.display = 'flex'; - headerEmoji.classList.add('processing'); - - activeController = new AbortController(); - - currentHistory.push({ role: 'user', content: text }); - const userMsgDiv = addMessage('user', text); - scrollToBottom(); - - const thinkingDiv = addMessage('assistant thinking', '⚡ working…'); - + // Extracted so the retry button can call it without re-adding the + // user message to the DOM or currentHistory. + async function _doOrchestrate(text, thinkingDiv, userMsgDiv) { try { const res = await fetch('/orchestrate', { method: 'POST', @@ -1336,9 +1321,59 @@ thinkingDiv.textContent = 'Stopped.'; } else { thinkingDiv.className = 'message error'; - thinkingDiv.textContent = `Error: ${err.message}`; + thinkingDiv.innerHTML = ''; + + const errSpan = document.createElement('span'); + errSpan.textContent = `Error: ${err.message}`; + thinkingDiv.appendChild(errSpan); + + const retryBtn = document.createElement('button'); + retryBtn.className = 'retry-btn'; + retryBtn.textContent = '↺ Retry'; + retryBtn.addEventListener('click', async () => { + if (currentHistory.at(-1)?.role === 'user') currentHistory.pop(); + currentHistory.push({ role: 'user', content: text }); + + thinkingDiv.className = 'message assistant thinking'; + thinkingDiv.textContent = '⚡ working…'; + + activeController = new AbortController(); + sendBtn.style.display = 'none'; + stopBtn.style.display = 'flex'; + headerEmoji.classList.add('processing'); + + await _doOrchestrate(text, thinkingDiv, userMsgDiv); + + activeController = null; + headerEmoji.classList.remove('processing'); + sendBtn.style.display = 'block'; + stopBtn.style.display = 'none'; + inputEl.focus(); + }); + thinkingDiv.appendChild(retryBtn); } } + } + + async function sendOrchestrate() { + const text = inputEl.value.trim(); + if (!text || activeController) return; + + inputEl.value = ''; + syncHeight(); + sendBtn.style.display = 'none'; + stopBtn.style.display = 'flex'; + headerEmoji.classList.add('processing'); + + activeController = new AbortController(); + + currentHistory.push({ role: 'user', content: text }); + const userMsgDiv = addMessage('user', text); + scrollToBottom(); + + const thinkingDiv = addMessage('assistant thinking', '⚡ working…'); + + await _doOrchestrate(text, thinkingDiv, userMsgDiv); activeController = null; headerEmoji.classList.remove('processing');