// chat-sse.js — sendChat (SSE chat dispatch), extracted from app.js. // Loaded BEFORE app.js so the bare sendChat() references in app.js (chat-input Enter, // handleSendStop, pptx flows) resolve at call time. All dependencies remain globals // in app.js: state (isThinking, chatHistory, currentAbortController, queuedPrompts, // streamingSessionId, ...) and helpers (authHeaders, renderChatMessages, addProcessEntry, // pushProgressLine, upsertLiveStep, renderLiveStepsPanel, loadAgentSessionContext, ...). async function sendChat(queuedMessage = null) { const input = document.getElementById('chat-input'); const sendBtn = document.getElementById('send-btn'); const raw = typeof queuedMessage === 'string' ? queuedMessage : input.value; const message = String(raw || '').trim(); if (!message && stagedAttachments.length === 0) return; if (isThinking) { if (typeof queuedMessage === 'string') return; if (queuedPrompts.length >= MAX_QUEUED_PROMPTS) { addProcessEntry('warn', `Queue full (${MAX_QUEUED_PROMPTS}). Wait for current run to finish.`); return; } queuedPrompts.push(message); addProcessEntry('info', `Queued prompt #${queuedPrompts.length}. It will run automatically next.`); input.value = ''; input.style.height = 'auto'; updateQueuedPromptUI(); return; } // 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만 let fullMessage = message; let displayMessage = message; if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') { const attachParts = stagedAttachments .filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함 .map(function(a) { return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')'); }); fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : ''); const compactParts = stagedAttachments .filter(function(a) { return a.type !== 'txt-auto'; }) .map(function(a) { // [파일명](url) → renderMarkdown이 PDF/파일 버튼으로 렌더링 var line = a.url ? '[' + a.name + '](' + a.url + ')' : '📎 **' + a.name + '**'; // PDF에 txt 저장본이 있으면 txt 다운로드 링크도 추가 if (a.type === 'pdf' && a.txtUrl) { line += ' [📝 ' + a.txtName + '](' + a.txtUrl + ')'; } return line; }); displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : ''); clearStagedAttachments(); } chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage }); persistActiveChat(); if (typeof queuedMessage !== 'string') { input.value = ''; input.style.height = 'auto'; } isThinking = true; thinkingStartedAt = Date.now(); if (thinkingTicker) clearInterval(thinkingTicker); thinkingTicker = setInterval(() => { const el = document.getElementById('thinking-elapsed'); if (el) el.textContent = `${Math.round((Date.now() - thinkingStartedAt) / 1000)}s 경과`; }, 1000); streamingSessionId = activeChatSessionId; // lock bubble to this session setButtonState(true); currentPreflightStatus = ''; currentProgressLines = []; liveTaskSteps = []; if (typeof _v2LiveState !== 'undefined') { _v2LiveState.fileId = null; _v2LiveState.inBlock = false; } lastHeartbeat = { state: 'running', level: '', current_step: 'dispatch', retry_count: 0, format_violation_count: 0, message: 'Turn started', }; lastHeartbeatLogSignature = ''; processLogAutoFollow = true; rightColumnAutoFollow = true; updateHeartbeatUI(); renderLiveStepsPanel(); sendBtn.disabled = false; updateQueuedPromptUI(); renderChatMessages(); maybeAutoScrollRightColumn(true); currentTurnStartIndex = processLogEntries.length; addProcessEntry('user', message); // 서버(session.ts)가 세션 히스토리를 디스크에 영구 저장하므로 클라이언트 history 전송 불필요. // historyOverride를 보내면 서버 세션을 덮어써서 오히려 오작동 + 대용량 413 원인이 됨. const historyForAPI = undefined; const allSteps = []; let finalReply = ''; let finalArtifacts = []; const turnThinkingBuffer = []; const turnThinkingSeen = new Set(); let sawExecuteModeThisTurn = false; let sawToolActivityThisTurn = false; let turnFileLinks = []; const collectTurnThinking = (value) => { const text = String(value || '').trim(); if (!text) return; const key = text.replace(/\s+/g, ' ').trim(); if (!key || turnThinkingSeen.has(key)) return; 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(); const res = await fetch('/api/chat', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream', }), signal: currentAbortController.signal, body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined }) }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); lastSseActivityAt = Date.now(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // keep incomplete line for (const line of lines) { if (!line.startsWith('data: ')) continue; let event; try { event = JSON.parse(line.slice(6)); } catch { continue; } switch (event.type) { case 'agent_mode': lastAgentMode = event.mode || '-'; lastTurnKind = event.turnKind || lastTurnKind; if (event.mode === 'execute') sawExecuteModeThisTurn = true; addProcessEntry( 'info', `Agent mode: ${event.mode || 'unknown'}${event.turnKind ? ` (${event.turnKind})` : ''}${event.switched_from ? ` | switched_from=${event.switched_from}` : ''}${event.route_target ? ` | route_target=${event.route_target}` : ''}${event.trigger ? ` | trigger=${event.trigger}` : ''}` ); if (event.mode === 'execute') renderLiveStepsPanel(); loadAgentSessionContext(); break; case 'session_mode_locked': addProcessEntry('info', `Session mode locked: ${event.mode || 'unknown'}`); break; case 'decomposed': addProcessEntry('split', `Split into ${event.questions.length} sub-questions: ${event.questions.map((q,i) => `Q${i+1}: ${q.slice(0,40)}`).join(' | ')}`); break; case 'thinking': if (event.thinking && String(event.thinking).trim()) { const thinkingText = String(event.thinking).trim(); collectTurnThinking(thinkingText); addProcessEntry('think', thinkingText); } break; case 'token': case 'text_delta': { const token = event.text || event.content || event.delta || ''; if (token) { partialContent += token; if (window._voiceCallOnToken) window._voiceCallOnToken(partialContent); } break; } case 'info': if (event.message) addProcessEntry('info', String(event.message)); break; case 'heartbeat': { lastHeartbeat = { ...lastHeartbeat, state: event.state || lastHeartbeat.state, level: event.level || lastHeartbeat.level, current_step: event.current_step || lastHeartbeat.current_step, retry_count: Number(event.retry_count || lastHeartbeat.retry_count || 0), format_violation_count: Number(event.format_violation_count || lastHeartbeat.format_violation_count || 0), message: event.message || lastHeartbeat.message || '', }; updateHeartbeatUI(); const sig = `${lastHeartbeat.state}|${lastHeartbeat.level}|${lastHeartbeat.current_step}|${lastHeartbeat.retry_count}|${lastHeartbeat.format_violation_count}`; if (sig !== lastHeartbeatLogSignature) { const level = String(lastHeartbeat.level || '').toLowerCase(); const text = String(lastHeartbeat.message || `state=${lastHeartbeat.state} step=${lastHeartbeat.current_step}`).trim(); addProcessEntry(level === 'hard' ? 'warn' : 'info', `Heartbeat: ${text}`); lastHeartbeatLogSignature = sig; } break; } case 'ui_preflight': if (event.message) { currentPreflightStatus = String(event.message); pushProgressLine(currentPreflightStatus); renderChatMessages(); } break; case 'tool_call': { const action = String(event.action || '').trim(); const stepNum = Number(event.stepNum || 0); const args = (event.args && typeof event.args === 'object') ? event.args : null; const argsPreview = args ? JSON.stringify(args).slice(0, 260) : ''; const syntheticTag = event.synthetic ? ' [synthetic]' : ''; sawToolActivityThisTurn = true; if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}Running ${action}...`); upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), 'in_progress', 'running'); if (action) { addProcessEntry( 'tool', `${stepNum ? `Step ${stepNum}: ` : ''}${action}${syntheticTag}${argsPreview ? ` ${argsPreview}` : ''}`, args || undefined, ); } break; } case 'tool_result': { const action = String(event.action || '').trim(); const stepNum = Number(event.stepNum || 0); sawToolActivityThisTurn = true; const text = String(event.result || ''); const ok = event.error === true ? false : !/^ERROR:/i.test(text); const syntheticTag = event.synthetic ? ' [synthetic]' : ''; if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}${action} ${ok ? 'complete' : 'failed'}`); upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), ok ? 'done' : 'failed', ok ? 'completed' : text.slice(0, 120)); addProcessEntry( ok ? 'result' : 'error', `${stepNum ? `Step ${stepNum}: ` : ''}${action || 'tool'}${syntheticTag} => ${text || '(no output)'}`, ); loadAgentSessionContext(); // PPTX: capture extracted images if (ok && action === 'pdf_extract_images') pptxCaptureImages(text); break; } case 'orchestration': { try { if (typeof window !== 'undefined' && typeof window._orchEventHandler === 'function') { window._orchEventHandler(event); } } catch {} const trigger = String(event.trigger || 'unknown'); const mode = String(event.mode || 'rescue'); const route = event.route ? ` | route=${event.route}` : ''; const reason = event.reason ? ` | ${event.reason}` : ''; const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap))) ? ` | assists ${event.assist_count}/${event.assist_cap}` : ''; addProcessEntry('info', `Orchestrator: trigger=${trigger} mode=${mode}${route}${reason}${cap}`, event); if (event.preflight?.quick_plan?.length) { addProcessEntry('info', `Orchestrator plan: ${event.preflight.quick_plan[0]}`); } else if (event.advice?.next_actions?.length) { addProcessEntry('info', `Orchestrator next: ${event.advice.next_actions[0]}`); } const preflightRaw = String(event.preflight?.raw_response || '').trim(); if (preflightRaw) { addProcessEntry('think', `[Secondary AI preflight raw response]\n${preflightRaw}`); } const adviceRaw = String(event.advice?.raw_response || '').trim(); if (adviceRaw) { addProcessEntry('think', `[Secondary AI advisor raw response]\n${adviceRaw}`); } break; } case 'browser_advisor_start': { const pageType = event.page_type ? ` type=${event.page_type}` : ''; const count = Number.isFinite(Number(event.extracted_count)) ? ` | extracted=${event.extracted_count}` : ''; addProcessEntry('info', `Browser advisor start:${pageType}${count}`); break; } case 'feed_collected': { const b = Number(event.batch || 0); const added = Number(event.added || 0); const total = Number(event.total || 0); const deduped = Number(event.deduped || 0); addProcessEntry('info', `Feed collected: batch ${b} | +${added} | total ${total} | deduped ${deduped}`); break; } case 'browser_advisor_route': { const route = String(event.route || 'unknown'); const reason = event.reason ? ` | ${String(event.reason)}` : ''; const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap))) ? ` | assists ${event.assist_count}/${event.assist_cap}` : ''; const nextTool = event.next_tool?.tool ? ` | next=${event.next_tool.tool}` : ''; addProcessEntry('info', `Browser advisor route=${route}${nextTool}${reason}${cap}`, event); const rawResponse = String(event.raw_response || '').trim(); if (rawResponse) { addProcessEntry('think', `[Secondary AI browser advisor raw response]\n${rawResponse}`); } break; } case 'browser_advisor_nudge': { const route = event.route ? `[${event.route}] ` : ''; const preview = String(event.preview || '').trim(); if (preview) addProcessEntry('info', `Advisor nudge ${route}${preview}`); break; } case 'forced_retry': { const reason = String(event.reason || 'advisor requested continuation'); const retry = Number(event.retry || 0); const max = Number(event.max_retries || 0); addProcessEntry('warn', `Forced retry ${retry}/${max}: ${reason}`); break; } case 'preempt_start': { const elapsedSec = Math.max(1, Math.round(Number(event.elapsed_ms || 0) / 1000)); const thresholdSec = Math.max(1, Math.round(Number(event.threshold_ms || 0) / 1000)); addProcessEntry('warn', `Preempt start: generation stalled ${elapsedSec}s (threshold ${thresholdSec}s).`); break; } case 'preempt_killed': { const restarted = event.restarted === true; const cap = (Number.isFinite(Number(event.preempts_session)) && Number.isFinite(Number(event.preempts_session_cap))) ? ` | preempts ${event.preempts_session}/${event.preempts_session_cap}` : ''; addProcessEntry(restarted ? 'info' : 'warn', `Preempt kill/restart ${restarted ? 'completed' : 'failed'}${cap}`); break; } case 'preempt_ready': { const cap = (Number.isFinite(Number(event.preempts_session)) && Number.isFinite(Number(event.preempts_session_cap))) ? ` | preempts ${event.preempts_session}/${event.preempts_session_cap}` : ''; addProcessEntry('info', `Preempt ready: Ollama online, running rescue advisor${cap}`); break; } case 'preempt_rescue': { const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap))) ? ` | assists ${event.assist_count}/${event.assist_cap}` : ''; addProcessEntry('info', `Preempt rescue guidance injected${cap}`); break; } case 'preempt_retry': addProcessEntry('info', 'Preempt retry: re-running primary with rescue context.'); break; case 'synthesizing': pushProgressLine(`Synthesizing ${Number(event.count || 1)} answer(s)...`); addProcessEntry('synth', `Combining ${event.count} answers...`); break; case 'step': { const s = event; allSteps.push(s); // Show step number if (s.stepNum && !s.isFormatViolation && !s.finalAnswer && !s.action) { // just a step counter with no other info — skip } // Thinking block if (s.thinking) { collectTurnThinking(s.thinking); addProcessEntry('think', s.thinking); } // Format violation if (s.isFormatViolation) { addProcessEntry('warn', 'Format violation — retrying'); break; } // Tool call if (s.action && !s.toolResult) { sawToolActivityThisTurn = true; addProcessEntry('tool', `${s.action} ${JSON.stringify(s.params || {}).slice(0, 100)}`); if (s.thought) addProcessEntry('info', s.thought); } // Tool result (same step object updated) if (s.action && s.toolResult) { sawToolActivityThisTurn = true; const toolText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || ''); const isErr = toolText.startsWith('ERROR'); addProcessEntry(isErr ? 'error' : 'result', toolText); if (s.action === 'web_search') { const diag = s?.toolData?.search_diagnostics || s?.diagnostics || null; if (diag) { const query = String(diag.query || s?.params?.query || '').trim(); if (query) addProcessEntry('info', `Search query: ${query}`); if (Array.isArray(diag.attempted) && diag.attempted.length) { const providers = diag.attempted.map(a => { const p = String(a.provider || '').toLowerCase(); const status = String(a.status || '').toLowerCase(); if (status === 'success') { const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : ''; return `${p}=success${count}`; } if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`; return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`; }).join(' | '); addProcessEntry('info', `Providers: ${providers}`); } } } } // Final answer if (s.finalAnswer) { addProcessEntry('final', s.finalAnswer); partialContent = s.finalAnswer; // track for stop // partialContent just got replaced wholesale (not appended to), so // any voice-call spokenUpTo offset computed against the old string // is now meaningless — reset before feeding the new text through. if (window._voiceCallResetSpoken) window._voiceCallResetSpoken(); if (window._voiceCallOnToken) window._voiceCallOnToken(partialContent); } break; } case 'memory_suggest': // event.suggestion: { fact, reference, source_tool, source_output, actor } const s = event.suggestion || {}; addProcessEntry('memory', s.fact || '(memory suggestion)', s); addProcessEntry('info', 'A memory suggestion was created; approve to persist.'); break; case 'memory_saved': addProcessEntry(event.ok ? 'result' : 'warn', event.ok ? `Memory updated${event.key ? ` (${event.key})` : ''}.` : 'Memory update failed.'); break; case 'web_search_snippets': // event: { query, snippets } const q = event.query || '(search)'; addProcessEntry('info', `Search results: ${q}`, event.snippets || []); if (event.diagnostics && Array.isArray(event.diagnostics.attempted)) { const providers = event.diagnostics.attempted.map(a => { const p = String(a.provider || '').toLowerCase(); const status = String(a.status || '').toLowerCase(); if (status === 'success') { const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : ''; return `${p}=success${count}`; } if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`; return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`; }).join(' | '); addProcessEntry('info', `Providers: ${providers}`); } break; case 'image': if (event.url) { pushProgressLine(`Image: ${event.alt || event.url}`); const imgAlt = String(event.alt || '').replace(/"/g, '"'); const imgErrAlt = String(event.alt || '이미지').replace(/'/g, ''').replace(/✖ ${imgErrAlt}를 불러올 수 없습니다.')`; const imgHtml = `${imgAlt}`; // Append image to current streaming AI message const lastAiMsg = chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === 'ai' ? chatHistory[chatHistory.length - 1] : null; if (lastAiMsg) { lastAiMsg.content += '\n\n' + imgHtml; } else { chatHistory.push({ role: 'ai', content: imgHtml }); } renderChatMessages(); } break; case 'files': if (Array.isArray(event.links)) { for (const lnk of event.links) turnFileLinks.push(lnk); } break; case 'error': addProcessEntry('error', event.message); break; case 'usage': { const pt = Number(event.promptTokens || 0); if (pt > 0) { const idx = chatSessions.findIndex(s => s.id === activeChatSessionId); if (idx !== -1) { chatSessions[idx].contextTokens = pt; saveChatSessions(); renderSessionsList(); } } break; } // Sent before the model call, so it survives a turn that gets aborted before // a real 'usage' event (with prompt_eval_count) arrives — lets the fallback // estimate in renderSessionsList include tool-schema overhead instead of // silently ignoring it. case 'tool_overhead': { const overhead = Number(event.tokens || 0); if (overhead > 0) { const idx = chatSessions.findIndex(s => s.id === activeChatSessionId); if (idx !== -1) { chatSessions[idx].toolOverheadTokens = overhead; saveChatSessions(); } } break; } case 'done': finalReply = event.reply || ''; if (finalReply) partialContent = finalReply; finalArtifacts = Array.isArray(event.artifacts) ? event.artifacts : []; loadAgentSessionContext(); renderLiveStepsPanel(); break; case 'turn_execution_created': case 'turn_execution_updated': loadAgentSessionContext(); break; } } } if (window._voiceCallOnTurnDone) window._voiceCallOnTurnDone(finalReply || partialContent); if (finalReply) { // Append file cards for any attachments sent via the 'files' SSE event if (turnFileLinks.length > 0) { const seen = new Set(); const newLinks = turnFileLinks.filter(l => { const k = l.url || l.name; if (seen.has(k)) return false; seen.add(k); return true; }); // Only append links not already present as markdown in the reply const toAppend = newLinks.filter(l => !finalReply.includes(l.url || '')); if (toAppend.length > 0) { finalReply += '\n\n' + toAppend.map(l => `[${l.name}](${l.url})`).join('\n'); } } const finalStep = allSteps.find(s => s.finalAnswer); const mergedThinking = turnThinkingBuffer.join('\n\n').trim(); const shouldAttachThinkingPanel = sawExecuteModeThisTurn || sawToolActivityThisTurn; const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; chatHistory.push({ role: 'ai', content: finalReply, artifacts: finalArtifacts, steps: allSteps, mode: useAgentMode ? 'agentic' : 'chat', thinking: shouldAttachThinkingPanel ? (mergedThinking || finalStep?.thinking) : '', processEntries: turnEntries }); } else { const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; chatHistory.push({ role: 'ai', content: 'No response received.', processEntries: turnEntries }); } persistActiveChat(); // PPTX: detect pptx-outline block in final reply if (finalReply) { const outlineMatch = finalReply.match(/```pptx-outline\s*\n([\s\S]*?)```/); if (outlineMatch) { try { pptxSetOutline(JSON.parse(outlineMatch[1])); } catch {} } } // Auto-open code blocks from final reply in the editor if (finalReply && typeof codeAiRenderMarkdown === 'function') { const codeBlockRe = /```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```/g; let cbm; while ((cbm = codeBlockRe.exec(finalReply)) !== null) { const lang = cbm[1] || 'text'; const code = cbm[2]; if (codeIsOpenable(code, lang)) { codeAiOpenInEditor(code, lang); } } } } catch (err) { const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; 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]'); chatHistory.push({ role: 'ai', content, steps: allSteps, mode: useAgentMode ? 'agentic' : 'chat', processEntries: turnEntries }); } else { lastHeartbeat = { ...lastHeartbeat, state: 'stalled', level: 'hard', message: String(err.message || 'connection_error'), current_step: 'error' }; updateHeartbeatUI(); addProcessEntry('error', err.message); chatHistory.push({ role: 'ai', content: `Connection error: ${err.message}`, processEntries: turnEntries }); } persistActiveChat(); } clearInterval(stallWatchdog); isThinking = false; if (typeof codeMonacoThrottleFlush === 'function') codeMonacoThrottleFlush(); if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; } streamingSessionId = null; // release session lock currentAbortController = null; setButtonState(false); currentPreflightStatus = ''; currentProgressLines = []; lastHeartbeat = { ...lastHeartbeat, state: 'idle', level: '', message: '', current_step: 'done' }; updateHeartbeatUI(); currentTurnStartIndex = -1; sendBtn.disabled = false; renderChatMessages(); updateQueuedPromptUI(); const shouldPauseQueue = isFailedTurnReply(finalReply || (chatHistory[chatHistory.length - 1]?.content || '')); if (queuedPrompts.length > 0 && shouldPauseQueue) { addProcessEntry('warn', 'Queue paused because the previous turn failed/blocked. Press Send to resume queued prompts.'); } else if (queuedPrompts.length > 0) { const next = queuedPrompts.shift(); updateQueuedPromptUI(); addProcessEntry('info', `Auto-running queued prompt${queuedPrompts.length ? ` (${queuedPrompts.length} remaining)` : ''}.`); setTimeout(() => { sendChat(next); }, 0); } } // Expose globally (belt-and-suspenders; bare global already works via hoisting). if (typeof window !== "undefined") window.sendChat = sendChat;