diff --git a/package.json b/package.json index 870230a..73cf334 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallclaw", - "version": "4.1.3", + "version": "4.1.4", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "main": "dist/index.js", "bin": { diff --git a/web-ui/app.js b/web-ui/app.js index 77817ff..feb32ed 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -1490,11 +1490,32 @@ async function sendChat(queuedMessage = null) { turnThinkingSeen.add(key); turnThinkingBuffer.push(text); }; + // Declared here (not inside the try block below) so the catch block can still read it — + // a `let` bound inside try{} is out of scope in catch{}, which was silently throwing a + // ReferenceError on every abort/error and skipping the rest of the catch block entirely, + // including the cleanup after it (isThinking never reset — the actual root cause behind + // the "infinite spinner" reports, independent of any real network stall). + let partialContent = ''; + + // Stall watchdog: if the server finishes and saves the reply but the SSE 'done' event + // never reaches the client (dropped connection mid-stream, e.g. a network blip), the + // reader.read() promise just hangs forever with no error — the UI is stuck showing + // "thinking" indefinitely even though the response already exists server-side. Detect + // silence longer than STALL_TIMEOUT_MS and abort so we can reconcile against the saved + // session history instead of leaving the user staring at a dead spinner. + let lastSseActivityAt = Date.now(); + let stallRecoveryTriggered = false; + const STALL_TIMEOUT_MS = 45000; + const stallWatchdog = setInterval(() => { + if (Date.now() - lastSseActivityAt > STALL_TIMEOUT_MS) { + stallRecoveryTriggered = true; + try { currentAbortController && currentAbortController.abort(); } catch {} + } + }, 5000); try { // Use SSE fetch — stream steps live as they arrive currentAbortController = new AbortController(); - let partialContent = ''; const res = await fetch('/api/chat', { method: 'POST', @@ -1516,6 +1537,7 @@ async function sendChat(queuedMessage = null) { while (true) { const { done, value } = await reader.read(); + lastSseActivityAt = Date.now(); if (done) break; buffer += decoder.decode(value, { stream: true }); @@ -1977,7 +1999,36 @@ async function sendChat(queuedMessage = null) { } catch (err) { const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; - if (err.name === 'AbortError') { + if (err.name === 'AbortError' && stallRecoveryTriggered) { + // The stream went silent — the server may have already finished and saved the reply + // (SSE 'done' just never made it back). Check the session's real history before + // assuming the turn was lost. + addProcessEntry('warn', 'Connection stalled — checking whether the reply completed on the server...'); + let recovered = false; + try { + const r = await fetch(`/api/chat/sessions/${agentSessionId}`); + const data = await r.json(); + const hist = Array.isArray(data && data.history) ? data.history : []; + // session.ts's ChatMessage has no message id — anchor on the user message we just + // sent instead (its content is known exactly, since we're the ones who sent it), + // scanning from the end so a repeated identical prompt still resolves to *this* + // turn's occurrence. Whatever the server saved immediately after it is the + // definitive reply, no guessing about the assistant's content required. + let anchorIdx = -1; + for (let i = hist.length - 1; i >= 0; i--) { + if (hist[i].role === 'user' && hist[i].content === fullMessage) { anchorIdx = i; break; } + } + const reply = anchorIdx >= 0 ? hist[anchorIdx + 1] : null; + if (reply && reply.role === 'assistant') { + chatHistory.push({ role: 'ai', content: reply.content, steps: allSteps, mode: useAgentMode ? 'agentic' : 'chat', processEntries: turnEntries }); + addProcessEntry('info', 'Recovered the completed reply after a stalled connection.'); + recovered = true; + } + } catch {} + if (!recovered) { + chatHistory.push({ role: 'ai', content: '[연결이 끊겼습니다. 잠시 후 다시 시도해주세요.]', processEntries: turnEntries }); + } + } else if (err.name === 'AbortError') { addProcessEntry('warn', 'Generation stopped by user.'); const content = partialContent || (allSteps.length ? `[Stopped — ${allSteps.length} step${allSteps.length !== 1 ? 's' : ''} completed]` : '[Generation stopped]'); @@ -1991,6 +2042,7 @@ async function sendChat(queuedMessage = null) { persistActiveChat(); } + clearInterval(stallWatchdog); isThinking = false; if (typeof codeMonacoThrottleFlush === 'function') codeMonacoThrottleFlush(); if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; }