v4.1.4: 메인챗 무한 스피너 버그 수정 + SSE 스톨 자동복구

- 진짜 원인: partialContent가 try{} 블록 안에서 let 선언돼 catch{}에서 참조되던
  스코프 버그. fetch abort/에러 시 catch 블록 자체가 ReferenceError로 죽어서
  뒤따르는 정리 코드(isThinking=false 등)가 전혀 실행되지 않고 스피너가 영원히
  돌았음(직접 재현 확인). 선언을 try 밖으로 이동해 해결
- SSE 스톨 워치독 추가: 45초간 스트림에 아무 데이터도 없으면 자동 abort 후
  세션 히스토리를 재조회해 서버가 이미 완료·저장한 응답이 있으면 복구 표시,
  없으면 재시도 안내. 방금 보낸 사용자 메시지(fullMessage, 내용을 정확히 아는
  값)를 기준점 삼아 뒤에서부터 매칭 — AI 응답 내용 추측/비교보다 안정적
- 부수 개선: 이메일 첨부 SSE 파일 링크 푸시 로직 등 기존 코드는 변경 없음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-14 11:31:52 +09:00
co-authored by Claude Sonnet 5
parent 30db2a0a48
commit 4526de2914
2 changed files with 55 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "smallclaw", "name": "smallclaw",
"version": "4.1.3", "version": "4.1.4",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {
+54 -2
View File
@@ -1490,11 +1490,32 @@ async function sendChat(queuedMessage = null) {
turnThinkingSeen.add(key); turnThinkingSeen.add(key);
turnThinkingBuffer.push(text); 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 { try {
// Use SSE fetch — stream steps live as they arrive // Use SSE fetch — stream steps live as they arrive
currentAbortController = new AbortController(); currentAbortController = new AbortController();
let partialContent = '';
const res = await fetch('/api/chat', { const res = await fetch('/api/chat', {
method: 'POST', method: 'POST',
@@ -1516,6 +1537,7 @@ async function sendChat(queuedMessage = null) {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
lastSseActivityAt = Date.now();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
@@ -1977,7 +1999,36 @@ async function sendChat(queuedMessage = null) {
} catch (err) { } catch (err) {
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; 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.'); addProcessEntry('warn', 'Generation stopped by user.');
const content = partialContent || const content = partialContent ||
(allSteps.length ? `[Stopped — ${allSteps.length} step${allSteps.length !== 1 ? 's' : ''} completed]` : '[Generation stopped]'); (allSteps.length ? `[Stopped — ${allSteps.length} step${allSteps.length !== 1 ? 's' : ''} completed]` : '[Generation stopped]');
@@ -1991,6 +2042,7 @@ async function sendChat(queuedMessage = null) {
persistActiveChat(); persistActiveChat();
} }
clearInterval(stallWatchdog);
isThinking = false; isThinking = false;
if (typeof codeMonacoThrottleFlush === 'function') codeMonacoThrottleFlush(); if (typeof codeMonacoThrottleFlush === 'function') codeMonacoThrottleFlush();
if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; } if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; }