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:
+1
-1
@@ -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
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user