- camera.js: getUserMedia 미리보기 패널, 전송 시 프레임(768px JPEG)을 /api/upload/image로 올려 기존 이미지 첨부 경로( → resolveImageContent)로 모델에 전달. 음성 통화 STT도 같은 전송 경로라 그대로 동작 - 메인 채팅: 📷 버튼+패널, sendChat에서 프레임 자동 첨부 - 작업실: 🎥 버튼+패널, 전송 훅·썸네일 표시·팝업에서 카메라 화면을 프로젝트 사진으로 저장 - handle-chat: 과거 턴의 카메라 프레임은 텍스트로 접어 비전 입력이 턴마다 누적되지 않게 함 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
699 lines
32 KiB
JavaScript
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, '"');
|
|
const imgErrAlt = String(event.alt || '이미지').replace(/'/g, ''').replace(/</g, '<');
|
|
const imgOnerr = `this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class="md-img-err">✖ ${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;
|