// 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) => `
${escHtml(q)}
`).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) => `
${i + 1}. ${escHtml(u)}
`).join('')}
` : ''}
`; }).join(''); return `
? ${toolSteps.length} tool step${toolSteps.length !== 1 ? 's' : ''} used
`; } 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(`
⬇ ${escHtml(filename)}
`); } 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