Files
homeclaw/web-ui/js/app/chat-sse.js
T
kimandClaude Sonnet 5 57cf65fd7e feat: 채팅·작업실 라이브 카메라 — 메시지마다 현재 화면 자동 첨부
- camera.js: getUserMedia 미리보기 패널, 전송 시 프레임(768px JPEG)을 /api/upload/image로 올려
  기존 이미지 첨부 경로(![📷…](/api/files/…) → resolveImageContent)로 모델에 전달. 음성 통화 STT도
  같은 전송 경로라 그대로 동작
- 메인 채팅: 📷 버튼+패널, sendChat에서 프레임 자동 첨부
- 작업실: 🎥 버튼+패널, 전송 훅·썸네일 표시·팝업에서 카메라 화면을 프로젝트 사진으로 저장
- handle-chat: 과거 턴의 카메라 프레임은 텍스트로 접어 비전 입력이 턴마다 누적되지 않게 함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 20:37:16 +09:00

699 lines
32 KiB
JavaScript

// 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;
}
// 라이브 카메라(camera.js): 켜져 있으면 현재 프레임을 찍어 이 메시지에 자동 첨부. 큐 재전송은 제외.
let cameraMd = '';
if (typeof queuedMessage !== 'string' && typeof window.cameraFrameForSend === 'function') {
try { cameraMd = await window.cameraFrameForSend(); } catch (e) { cameraMd = ''; }
}
// 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만
let fullMessage = cameraMd ? cameraMd + (message ? '\n\n' + message : '') : message;
let displayMessage = fullMessage;
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') + (fullMessage ? '\n\n' + fullMessage : '');
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') + (displayMessage ? '\n\n' + displayMessage : '');
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, '&quot;');
const imgErrAlt = String(event.alt || '이미지').replace(/'/g, '&#39;').replace(/</g, '&lt;');
const imgOnerr = `this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; ${imgErrAlt}를 불러올 수 없습니다.</span>')`;
const imgHtml = `<img class="md-img" src="${escHtml(event.url)}" alt="${imgAlt}" loading="lazy" onclick="openImageZoom(this.src)" onerror="${imgOnerr}" />`;
// 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;