// chat-render.js — 채팅 메시지·아티팩트·프로세스로그·에이전트 실행 패널 렌더링, 큐잉된 프롬프트 패널.
// extracted from app.js (v4.3.19-3 split).
function renderQueuedPromptsPanel() {
const panel = document.getElementById('queued-prompts-panel');
const list = document.getElementById('queued-prompts-list');
const title = document.getElementById('queued-prompts-title');
if (!panel || !list || !title) return;
const count = queuedPrompts.length;
if (count === 0) {
panel.style.display = 'none';
list.innerHTML = '';
return;
}
panel.style.display = 'block';
title.textContent = `Queued prompts (${count})`;
list.innerHTML = queuedPrompts.map((q, i) => `
`).join('');
}
function removeQueuedPrompt(index) {
if (!Number.isInteger(index) || index < 0 || index >= queuedPrompts.length) return;
queuedPrompts.splice(index, 1);
updateQueuedPromptUI();
}
function clearQueuedPrompts() {
if (!queuedPrompts.length) return;
queuedPrompts = [];
addProcessEntry('info', 'Cleared queued prompts.');
updateQueuedPromptUI();
}
function updateQueuedPromptUI() {
const badge = document.getElementById('queued-prompts-indicator');
const sendBtn = document.getElementById('send-btn');
const input = document.getElementById('chat-input');
const count = queuedPrompts.length;
if (badge) {
if (count > 0) {
badge.style.display = 'inline';
badge.textContent = `Queued: ${count}`;
} else {
badge.style.display = 'none';
badge.textContent = '';
}
}
if (sendBtn) {
sendBtn.disabled = false;
sendBtn.title = isThinking ? `Queue next prompt${count ? ` (${count} queued)` : ''}` : 'Send';
}
if (input) {
input.placeholder = isThinking
? 'Assistant is responding. Press Enter/Send to queue next prompt.'
: 'Type a message... (Enter to send, Shift+Enter for newline)';
}
renderQueuedPromptsPanel();
}
function renderThinkBlock(thinking) {
if (!thinking || !thinking.trim()) return '';
const id = 'think_' + Math.random().toString(36).slice(2);
const wordCount = thinking.trim().split(/\s+/).length;
return `
${escHtml(thinking)}
`;
}
function toggleThink(id) {
const btn = document.getElementById(id + '_btn');
const body = document.getElementById(id + '_body');
if (!btn || !body) return;
const open = body.style.display !== 'none' && body.style.display !== '';
body.style.display = open ? 'none' : 'block';
btn.classList.toggle('open', !open);
}
function renderReactSteps(steps) {
if (!steps || steps.length === 0) return '';
const toolSteps = steps.filter(s => s.action);
if (toolSteps.length === 0) return '';
const getSearchDiagnostics = (s) => s?.toolData?.search_diagnostics || s?.diagnostics || null;
const extractUrls = (s, resultText) => {
const fromToolData = Array.isArray(s?.toolData?.results)
? s.toolData.results.map(r => String(r?.url || '').trim()).filter(u => /^https?:\/\//i.test(u))
: [];
const fromText = String(resultText || '').match(/https?:\/\/[^\s)]+/g) || [];
const merged = [...fromToolData, ...fromText.map(u => String(u).trim())];
const out = [];
const seen = new Set();
for (const u of merged) {
if (!u || seen.has(u)) continue;
seen.add(u);
out.push(u);
if (out.length >= 5) break;
}
return out;
};
const providerSummary = (diag) => {
if (!diag || !Array.isArray(diag.attempted) || !diag.attempted.length) return '';
return 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(' | ');
};
const id = 'steps_' + Math.random().toString(36).slice(2);
const items = toolSteps.map(s => {
const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
const diag = String(s.action || '') === 'web_search' ? getSearchDiagnostics(s) : null;
const query = String(diag?.query || s?.params?.query || '').trim();
const selectedProvider = String(diag?.selected_provider || s?.toolData?.provider || '').toLowerCase();
const providers = diag ? providerSummary(diag) : '';
const urls = String(s.action || '') === 'web_search' ? extractUrls(s, resultText) : [];
return `
${s.thought ? `
${escHtml(s.thought.slice(0, 120))}
` : ''}
${escHtml(s.action)}
${resultText ? `
${escHtml(resultText.slice(0, 160))}${resultText.length > 160 ? '…' : ''}
` : ''}
${diag ? `
${query ? `
query: ${escHtml(query)}
` : ''}
${selectedProvider ? `
provider hit: ${escHtml(selectedProvider)}
` : ''}
${providers ? `
providers: ${escHtml(providers)}
` : ''}
` : ''}
${String(s.action || '') === 'web_search' && !diag ? `
${selectedProvider ? `
provider hit: ${escHtml(selectedProvider)}
` : ''}
` : ''}
${urls.length ? `
sources:
${urls.slice(0, 3).map((u, i) => `
`).join('')}
` : ''}
`;
}).join('');
return `
? ${toolSteps.length} tool step${toolSteps.length !== 1 ? 's' : ''} used
${items}
`;
}
function toggleSteps(id) {
const el = document.getElementById(id);
if (!el) return;
const visible = el.style.display !== 'none';
el.style.display = visible ? 'none' : 'block';
el.previousElementSibling.textContent = `${visible ? '?' : '?'} ${el.querySelectorAll('.react-step').length} tool step${el.querySelectorAll('.react-step').length !== 1 ? 's' : ''} used`;
}
function renderFileDownloads(steps, processEntries) {
const seenUrls = new Set(); // normalized (decoded) URLs
const buttons = [];
const URL_RE = /\/api\/files\/[^\s)"'\]]+/g;
function normalizeUrl(u) {
try { return decodeURIComponent(u); } catch { return u; }
}
function extractUrls(text) {
const urls = [];
let m;
while ((m = URL_RE.exec(text)) !== null) urls.push(m[0]);
return urls;
}
// Pre-mark URLs already present in msg.content so tool-result duplicates are suppressed.
// renderMarkdown() handles content links — we must not render a second button for the same URL.
// Normalize both sides (decode percent-encoding) before comparing so Korean paths match.
if (arguments.length > 2 && arguments[2]) {
const contentText = String(arguments[2]);
if (contentText.indexOf('/api/files/') !== -1) {
for (const u of extractUrls(contentText)) seenUrls.add(normalizeUrl(u));
URL_RE.lastIndex = 0;
}
}
// Collect /api/files/ URLs from tool results only (not content — already handled above)
const allUrls = [];
// 1. msg.steps (legacy step events)
if (steps && steps.length) {
for (const s of steps) {
const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
allUrls.push(...extractUrls(resultText));
}
}
// 2. msg.processEntries (stored per-message in chatHistory)
if (processEntries && processEntries.length) {
for (const e of processEntries) {
const txt = String(e.content || '');
if (txt.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(txt));
}
}
const _imgExts = /\.(jpg|jpeg|png|gif|webp|heic|heif|bmp|svg|avif|tiff|tif)(\?|$)/i;
for (const rawUrl of allUrls) {
const normUrl = normalizeUrl(rawUrl);
if (seenUrls.has(normUrl)) continue;
seenUrls.add(normUrl);
const decoded = (() => { try { return decodeURIComponent(rawUrl); } catch { return rawUrl; } })();
if (_imgExts.test(decoded)) continue; // images shown inline, no download button needed
const filename = decoded.split('/').pop() || 'download';
const isPptx = decoded.toLowerCase().indexOf('.pptx') !== -1;
if (isPptx) {
const previewId = 'pptx-chat-' + Math.random().toString(36).substr(2, 9);
buttons.push(`
`);
} else {
buttons.push(`
⬇ ${escHtml(filename)}`);
}
}
return buttons.length ? `${buttons.join(' ')}
` : '';
}
function renderAssistantContent(content) {
const text = String(content || '');
const hasStaged = /(^|\n)(Initial chat|Execution result|Final chat):\s*/i.test(text);
if (!hasStaged) return `${renderMarkdown(text)}
`;
const chunks = text
.split(/\n\s*---\s*\n/g)
.map(s => String(s || '').trim())
.filter(Boolean);
const html = chunks.map((chunk, idx) => {
const m = chunk.match(/^(Initial chat|Execution result|Final chat):\s*\n?/i);
const title = m ? m[1] : `Section ${idx + 1}`;
const body = m ? chunk.slice(m[0].length).trim() : chunk;
return `
${escHtml(title)}
${renderMarkdown(body)}
`;
}).join('');
return `${html}
`;
}
function renderArtifacts(artifacts) {
const rows = Array.isArray(artifacts) ? artifacts : [];
if (!rows.length) return '';
const cards = rows.map((a, idx) => {
const type = String(a?.type || '').trim();
const title = String(a?.title || type || `Artifact ${idx + 1}`).trim();
const status = String(a?.status || 'ok').toLowerCase();
const pathVal = String(a?.path || '').trim();
const fromPath = String(a?.from_path || '').trim();
const toPath = String(a?.to_path || '').trim();
const summary = String(a?.summary || '').trim();
const preview = String(a?.preview || '').trim();
const files = Array.isArray(a?.files) ? a.files.slice(0, 12).map(x => String(x || '')).filter(Boolean) : [];
const dirs = Array.isArray(a?.directories) ? a.directories.slice(0, 8).map(x => String(x || '')).filter(Boolean) : [];
const locationTarget = toPath || pathVal || fromPath;
return `
${escHtml(title)}
${escHtml(status)}
${pathVal ? `
Path: ${escHtml(pathVal)}
` : ''}
${fromPath ? `
From: ${escHtml(fromPath)}
` : ''}
${toPath ? `
To: ${escHtml(toPath)}
` : ''}
${summary ? `
${escHtml(summary)}
` : ''}
${files.length ? `
Files: ${escHtml(files.join(', '))}${Array.isArray(a?.files) && a.files.length > files.length ? ' ...' : ''}
` : ''}
${dirs.length ? `
Directories: ${escHtml(dirs.join(', '))}${Array.isArray(a?.directories) && a.directories.length > dirs.length ? ' ...' : ''}
` : ''}
${preview ? `
${escHtml(preview)}
` : ''}
${locationTarget ? `
` : ''}
`;
}).join('');
return `${cards}
`;
}
async function openInFileLocation(targetPath) {
const p = String(targetPath || '').trim();
if (!p) return;
try {
const res = await fetch('/api/open-path', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ path: p }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data?.ok) {
throw new Error(String(data?.error || `HTTP ${res.status}`));
}
addProcessEntry('info', `Opened in file location: ${p}`);
} catch (err) {
addProcessEntry('error', `Open path failed: ${String(err?.message || err)}`);
}
}
// KaTeX auto-render must run AFTER the HTML is in the DOM — innerHTML doesn't execute
// embedded