From 4526de291437574eaf266c336f6c0326c19901c4 Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 14 Jul 2026 11:31:52 +0900 Subject: [PATCH] =?UTF-8?q?v4.1.4:=20=EB=A9=94=EC=9D=B8=EC=B1=97=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=20=EC=8A=A4=ED=94=BC=EB=84=88=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20+=20SSE=20=EC=8A=A4=ED=86=A8?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 진짜 원인: partialContent가 try{} 블록 안에서 let 선언돼 catch{}에서 참조되던 스코프 버그. fetch abort/에러 시 catch 블록 자체가 ReferenceError로 죽어서 뒤따르는 정리 코드(isThinking=false 등)가 전혀 실행되지 않고 스피너가 영원히 돌았음(직접 재현 확인). 선언을 try 밖으로 이동해 해결 - SSE 스톨 워치독 추가: 45초간 스트림에 아무 데이터도 없으면 자동 abort 후 세션 히스토리를 재조회해 서버가 이미 완료·저장한 응답이 있으면 복구 표시, 없으면 재시도 안내. 방금 보낸 사용자 메시지(fullMessage, 내용을 정확히 아는 값)를 기준점 삼아 뒤에서부터 매칭 — AI 응답 내용 추측/비교보다 안정적 - 부수 개선: 이메일 첨부 SSE 파일 링크 푸시 로직 등 기존 코드는 변경 없음 Co-Authored-By: Claude Sonnet 5 --- package.json | 2 +- web-ui/app.js | 56 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) 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; }