// chat.js — Chat rendering, streaming, versioning, editing import { send, on, off } from './ws.js'; import { currentSessionId } from './sessions.js'; import { renderMarkdown, attachCodeCopyListeners, attachSvgPanelListeners, escHtml, showNotification, autoResize, } from './ui.js'; let activeSessionId = null; let isStreaming = false; let streamingBubble = null; let streamingText = ''; let autoScroll = true; let pendingAssets = []; export function setActiveSession(id) { activeSessionId = id; } export function getIsStreaming() { return isStreaming; } // ── WebSocket events ────────────────────────────────────────────────────── on('sessions:data', (msg) => { if (msg.session.id === activeSessionId) renderSession(msg.session); }); on('chat:start', (msg) => { if (msg.sessionId === activeSessionId) onChatStart(); }); on('chat:token', (msg) => { if (msg.sessionId === activeSessionId) onToken(msg.token); }); on('chat:done', (msg) => { if (msg.sessionId === activeSessionId) onChatDone(msg); }); on('chat:aborted', (msg) => { if (msg.sessionId === activeSessionId) onChatAborted(msg); }); on('chat:error', (msg) => { if (msg.sessionId === activeSessionId) onChatError(msg.error); }); on('chat:asset', (msg) => { if (msg.sessionId === activeSessionId) appendAsset(msg.asset); }); on('chat:toolCall', (msg) => { if (msg.sessionId === activeSessionId) handleLiveToolCall(msg.call); }); on('chat:messageEdited', (msg) => { if (msg.sessionId === activeSessionId) renderHistory(msg.history); }); on('chat:versionSelected', (msg) => { if (msg.sessionId === activeSessionId) renderHistory(msg.history); }); // Reconnect: reload current session instead of resetting to welcome on('ws:connected', () => { if (activeSessionId && !isStreaming) { send({ type: 'sessions:get', sessionId: activeSessionId }); } }); // ── Views ───────────────────────────────────────────────────────────────── export function renderSession(session) { if (!session || !session.history?.length) { showWelcome(); return; } showChat(); renderHistory(session.history); } function showWelcome() { document.getElementById('welcome-view')?.classList.remove('hidden'); document.getElementById('chat-view')?.classList.add('hidden'); document.getElementById('bottom-input-bar')?.classList.add('hidden'); } function showChat() { document.getElementById('welcome-view')?.classList.add('hidden'); document.getElementById('chat-view')?.classList.remove('hidden'); document.getElementById('bottom-input-bar')?.classList.remove('hidden'); } // ── Full history render ─────────────────────────────────────────────────── /** * Version model: * * The server history is a flat array. Each user message can have a * `versions` array (previous edits) and `currentVersionIdx`. When the * user switches versions on a user message at index N, the server returns * a new history where everything from N onwards reflects the selected * version's branch. We trust the server's returned history completely and * just re-render it. * * Version navigator is shown ONLY on user messages (since assistant * responses are implicitly tied to the user version that triggered them). * The navigator label shows which version of the user prompt is active. */ export function renderHistory(history) { showChat(); const box = document.getElementById('chat-messages'); if (!box) return; box.innerHTML = ''; for (let i = 0; i < history.length; i++) { const msg = history[i]; // Strip session-naming XML tags from display const cleanMsg = { ...msg, content: stripSessionTag(msg.content) }; if (cleanMsg.role === 'user') appendUserMsg(box, cleanMsg, i); else if (cleanMsg.role === 'assistant') appendAssistantMsg(box, cleanMsg, i); else if (cleanMsg.role === 'image') appendMediaMsg(box, 'image', cleanMsg.content); else if (cleanMsg.role === 'video') appendMediaMsg(box, 'video', cleanMsg.content); else if (cleanMsg.role === 'audio') appendMediaMsg(box, 'audio', cleanMsg.content); } if (typeof renderMathInElement !== 'undefined') { try { renderMathInElement(box, { delimiters: [ { left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }, { left: '\\(', right: '\\)', display: false }, { left: '\\[', right: '\\]', display: true }, ], throwOnError: false, }); } catch {} } if (autoScroll) box.scrollTop = box.scrollHeight; } function stripSessionTag(content) { if (typeof content !== 'string') return content; return content.replace(/[\s\S]*?<\/session_name>/gi, '').trim(); } // ── Message renderers ───────────────────────────────────────────────────── function appendUserMsg(box, msg, index) { const wrap = makeWrap(index); const bubble = document.createElement('div'); bubble.className = 'msg-user'; const text = msgText(msg.content); const imgs = msgImages(msg.content); bubble.innerHTML = renderMarkdown(text); attachCodeCopyListeners(bubble); attachSvgPanelListeners(bubble); imgs.forEach(src => { const img = document.createElement('img'); img.src = src; img.alt = 'Attached image'; img.style.cssText = 'max-width:100%;max-height:260px;border-radius:8px;margin-top:8px;display:block;cursor:pointer;'; img.addEventListener('click', () => openImageModal(src)); bubble.appendChild(img); }); wrap.appendChild(bubble); // Version navigator below the bubble (only on user messages) if (msg.versions?.length > 1) { wrap.appendChild(buildVersionNav(msg, index)); } // Action buttons below the bubble, right-aligned wrap.appendChild(buildActions([ { icon: copyIcon(), title: 'Copy', fn: () => copyText(text) }, { icon: editIcon(), title: 'Edit', fn: () => startUserEdit(wrap, index, msg, text) }, ], 'right')); box.appendChild(wrap); } function appendAssistantMsg(box, msg, index) { const wrap = makeWrap(index); const bubble = document.createElement('div'); bubble.className = 'msg-assistant'; bubble.innerHTML = renderMarkdown(msg.content || ''); attachCodeCopyListeners(bubble); attachSvgPanelListeners(bubble); if (msg.toolCalls?.length) { const chipRow = document.createElement('div'); chipRow.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;'; msg.toolCalls.forEach(c => chipRow.appendChild(buildToolChip(c))); bubble.appendChild(chipRow); } wrap.appendChild(bubble); // Action buttons below the bubble, left-aligned, with top padding wrap.appendChild(buildActions([ { icon: copyIcon(), title: 'Copy', fn: () => copyText(msg.content || '') }, { icon: editIcon(), title: 'Edit', fn: () => startAssistantEdit(wrap, index, msg) }, ], 'left')); box.appendChild(wrap); } function appendMediaMsg(box, type, content) { const wrap = document.createElement('div'); wrap.className = 'msg-media'; if (type === 'image') { const img = document.createElement('img'); img.src = content; img.alt = 'Generated image'; img.addEventListener('click', () => openImageModal(content)); wrap.appendChild(img); wrap.appendChild(dlBtn(() => dlMedia(content, 'image.png'))); } else if (type === 'video') { const v = document.createElement('video'); v.src = content; v.controls = true; v.preload = 'metadata'; wrap.appendChild(v); wrap.appendChild(dlBtn(() => dlMedia(content, 'video.mp4'))); } else if (type === 'audio') { const a = document.createElement('audio'); a.src = content; a.controls = true; a.preload = 'metadata'; a.style.width = '100%'; wrap.appendChild(a); const db = dlBtn(() => dlMedia(content, 'audio.mp3')); db.style.cssText += ';position:static;margin-top:6px;opacity:1;'; wrap.appendChild(db); } box.appendChild(wrap); } // ── Builders ────────────────────────────────────────────────────────────── function makeWrap(index) { const w = document.createElement('div'); w.className = 'msg-group'; w.dataset.index = index; return w; } /** * Action buttons rendered below the message bubble. * For user messages (side='right'): right-aligned row below the bubble. * For assistant messages (side='left'): left-aligned row below the bubble, * with a small top padding to separate from content. */ function buildActions(items, side = 'left') { const div = document.createElement('div'); div.className = 'msg-actions' + (side === 'right' ? ' msg-actions-right' : ' msg-actions-left'); items.forEach(({ icon, title, fn }) => { const btn = document.createElement('button'); btn.className = 'msg-action-btn'; btn.title = title; btn.innerHTML = icon; btn.addEventListener('click', e => { e.stopPropagation(); fn(); }); div.appendChild(btn); }); return div; } /** * Version navigator — shown only on user messages. * Selecting a version tells the server to swap the history branch; * the server returns the full updated history via chat:versionSelected. */ function buildVersionNav(msg, index) { const nav = document.createElement('div'); nav.className = 'msg-version-nav'; const total = msg.versions.length; const cur = (msg.currentVersionIdx ?? 0) + 1; const prev = document.createElement('button'); prev.innerHTML = '‹'; prev.title = 'Previous version'; prev.disabled = cur <= 1; prev.addEventListener('click', () => send({ type: 'chat:selectVersion', sessionId: activeSessionId, messageIndex: index, versionIdx: (msg.currentVersionIdx ?? 0) - 1 })); const lbl = document.createElement('span'); lbl.textContent = `${cur} / ${total}`; lbl.style.cssText = 'min-width:36px;text-align:center;'; const next = document.createElement('button'); next.innerHTML = '›'; next.title = 'Next version'; next.disabled = cur >= total; next.addEventListener('click', () => send({ type: 'chat:selectVersion', sessionId: activeSessionId, messageIndex: index, versionIdx: (msg.currentVersionIdx ?? 0) + 1 })); nav.appendChild(prev); nav.appendChild(lbl); nav.appendChild(next); return nav; } function buildToolChip(call) { const names = { ollama_search: 'Web Search', read_web_page: 'Read Page', generate_image: 'Image Gen', generate_video: 'Video Gen', generate_audio: 'Audio Gen' }; const icons = { ollama_search: '🔍', read_web_page: '📄', generate_image: '🖼️', generate_video: '🎬', generate_audio: '🎵' }; const chip = document.createElement('button'); chip.className = 'msg-tool-call'; chip.innerHTML = `${icons[call.name] || '🔧'}${escHtml(names[call.name] || call.name)}`; chip.addEventListener('click', () => import('./modals.js').then(m => m.showToolCallModal(call))); return chip; } function dlBtn(fn) { const btn = document.createElement('button'); btn.className = 'media-download-btn'; btn.textContent = 'Download'; btn.addEventListener('click', fn); return btn; } // ── SVG icons ───────────────────────────────────────────────────────────── function copyIcon() { return ``; } function editIcon() { return ``; } function searchIcon() { return ``; } function imageIcon() { return ``; } function videoIcon() { return ``; } function audioIcon() { return ``; } function attachIcon() { return ``; } function sendSvg() { return ``; } // ── Inline editing ──────────────────────────────────────────────────────── /** * User message edit: replaces bubble content with a textarea + toolbar * (tool toggles + attach) + Cancel/Send buttons. * On Send: edits the message on the server, then triggers a new chat:send. */ function startUserEdit(wrap, index, msg, originalText) { const bubble = wrap.querySelector('.msg-user'); if (!bubble) return; const originalHTML = bubble.innerHTML; bubble.innerHTML = ''; bubble.classList.add('editing-user'); // Textarea const ta = makeUserEditTextarea(originalText); bubble.appendChild(ta); // File preview row (for newly attached files during edit) const editAttachments = []; const filePreviewRow = document.createElement('div'); filePreviewRow.className = 'edit-file-preview-row'; bubble.appendChild(filePreviewRow); // Bottom toolbar: tools + attach on left, cancel+send on right const toolbar = document.createElement('div'); toolbar.className = 'edit-toolbar'; // --- Tool toggles (mirror current state from bottom input bar) --- const toolRow = document.createElement('div'); toolRow.className = 'edit-tool-row'; const toolDefs = [ { key: 'webSearch', label: 'Search', icon: searchIcon() }, { key: 'imageGen', label: 'Image', icon: imageIcon() }, { key: 'videoGen', label: 'Video', icon: videoIcon() }, { key: 'audioGen', label: 'Audio', icon: audioIcon() }, ]; toolDefs.forEach(({ key, label, icon }) => { const btn = document.createElement('button'); btn.className = 'tool-btn-sm edit-tool-btn'; btn.dataset.tool = key; btn.title = label; btn.innerHTML = `${icon}${label}`; // Sync initial active state from the main toolbar const mainBtn = document.querySelector(`#bottom-input-bar [data-tool="${key}"]`); if (mainBtn?.classList.contains('active')) btn.classList.add('active'); btn.addEventListener('click', () => btn.classList.toggle('active')); toolRow.appendChild(btn); }); // Attach button const attachBtn = document.createElement('button'); attachBtn.className = 'edit-attach-btn'; attachBtn.title = 'Attach file or image'; attachBtn.innerHTML = attachIcon(); attachBtn.addEventListener('click', e => { e.stopPropagation(); openEditAttachMenu(e, attachBtn, editAttachments, filePreviewRow); }); toolRow.appendChild(attachBtn); toolbar.appendChild(toolRow); // Cancel + Send const btnRow = document.createElement('div'); btnRow.className = 'edit-btn-row'; const cancelBtn = makeBtn('Cancel', 'btn-ghost'); cancelBtn.addEventListener('click', () => { bubble.classList.remove('editing-user'); bubble.innerHTML = originalHTML; attachCodeCopyListeners(bubble); }); const sendBtn = document.createElement('button'); sendBtn.className = 'btn-primary edit-send-btn'; sendBtn.innerHTML = `${sendSvg()} Send`; sendBtn.addEventListener('click', () => { const newContent = ta.value.trim(); if (!newContent && editAttachments.length === 0) return; sendBtn.disabled = true; // Build the tools state from the edit toolbar const tools = {}; toolbar.querySelectorAll('[data-tool]').forEach(b => { tools[b.dataset.tool] = b.classList.contains('active'); }); send({ type: 'chat:editMessage', sessionId: activeSessionId, messageIndex: index, newContent: buildEditContent(newContent, editAttachments), role: 'user', }); const handler = (editMsg) => { if (editMsg.sessionId !== activeSessionId || editMsg.messageIndex !== index) return; off('chat:messageEdited', handler); send({ type: 'chat:send', sessionId: activeSessionId, tools }); }; on('chat:messageEdited', handler); }); btnRow.appendChild(cancelBtn); btnRow.appendChild(sendBtn); toolbar.appendChild(btnRow); bubble.appendChild(toolbar); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } function buildEditContent(text, attachments) { if (attachments.length === 0) return text; const images = attachments.filter(a => a.type === 'image'); const files = attachments.filter(a => a.type === 'text'); let fullText = text; if (files.length > 0) { fullText += '\n\n
Attached Files\n'; for (const f of files) fullText += `\n
${f.name}\n\n\`\`\`\n${f.content}\n\`\`\`\n\n
\n`; fullText += '
'; } if (images.length > 0) { return [ { type: 'text', text: fullText }, ...images.map(img => ({ type: 'image_url', image_url: { url: `data:${img.mimeType};base64,${img.base64}` } })), ]; } return fullText; } function openEditAttachMenu(e, triggerEl, editAttachments, filePreviewRow) { e.preventDefault(); // Reuse hidden file inputs const fileInput = document.getElementById('file-input'); const imageInput = document.getElementById('image-input'); if (!fileInput || !imageInput) return; const menu = document.getElementById('attach-context-menu'); if (!menu) return; menu.innerHTML = ''; for (const item of [ { label: '📄 Upload file', onClick: () => { const handler = async function() { for (const file of fileInput.files) { const text = await file.text(); editAttachments.push({ type: 'text', name: file.name, content: text }); } fileInput.value = ''; fileInput.removeEventListener('change', handler); renderEditFilePreview(editAttachments, filePreviewRow); }; fileInput.addEventListener('change', handler); fileInput.click(); }, }, { label: '🖼️ Upload image', onClick: () => { const handler = async function() { for (const file of imageInput.files) { const dataUrl = await new Promise((res, rej) => { const reader = new FileReader(); reader.onload = () => res(reader.result); reader.onerror = rej; reader.readAsDataURL(file); }); const comma = dataUrl.indexOf(','); const mimeType = dataUrl.slice(5, dataUrl.indexOf(';')); const base64 = dataUrl.slice(comma + 1); editAttachments.push({ type: 'image', name: file.name, base64, mimeType }); } imageInput.value = ''; imageInput.removeEventListener('change', handler); renderEditFilePreview(editAttachments, filePreviewRow); }; imageInput.addEventListener('change', handler); imageInput.click(); }, }, ]) { const el = document.createElement('div'); el.className = 'context-item'; el.textContent = item.label; el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick(); }); menu.appendChild(el); } menu.classList.remove('hidden'); const rect = triggerEl.getBoundingClientRect(); const mh = 80; menu.style.left = `${Math.max(8, rect.left)}px`; menu.style.top = `${rect.top - mh - 8}px`; setTimeout(() => document.addEventListener('click', () => menu.classList.add('hidden'), { once: true }), 0); } function renderEditFilePreview(attachments, row) { row.innerHTML = ''; if (attachments.length === 0) { row.style.display = 'none'; return; } row.style.display = 'flex'; attachments.forEach((a, i) => { const wrap = document.createElement('div'); wrap.className = 'attach-preview-item'; if (a.type === 'image') { const img = document.createElement('img'); img.src = `data:${a.mimeType};base64,${a.base64}`; img.alt = a.name; wrap.appendChild(img); } else { const chip = document.createElement('div'); chip.className = 'pasted-chip'; chip.innerHTML = `📄${escHtml(a.name)}`; wrap.appendChild(chip); } const rm = document.createElement('button'); rm.className = 'attach-preview-remove'; rm.textContent = '×'; rm.addEventListener('click', e => { e.stopPropagation(); attachments.splice(i, 1); renderEditFilePreview(attachments, row); }); wrap.appendChild(rm); row.appendChild(wrap); }); } function startAssistantEdit(wrap, index, msg) { const bubble = wrap.querySelector('.msg-assistant'); if (!bubble) return; const originalContent = msg.content || ''; const originalHTML = bubble.innerHTML; bubble.classList.add('editing'); bubble.innerHTML = ''; const ta = makeAssistantEditTextarea(originalContent); bubble.appendChild(ta); const btns = document.createElement('div'); btns.className = 'edit-actions'; const cancelBtn = makeBtn('Cancel', 'btn-ghost'); cancelBtn.addEventListener('click', () => { bubble.classList.remove('editing'); bubble.innerHTML = originalHTML; attachCodeCopyListeners(bubble); attachSvgPanelListeners(bubble); }); const saveBtn = makeBtn('Save', 'btn-primary'); saveBtn.addEventListener('click', () => { const newContent = ta.value.trim(); if (!newContent) return; saveBtn.disabled = true; send({ type: 'chat:editMessage', sessionId: activeSessionId, messageIndex: index, newContent, role: 'assistant' }); }); btns.appendChild(cancelBtn); btns.appendChild(saveBtn); bubble.appendChild(btns); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } function makeUserEditTextarea(value) { const ta = document.createElement('textarea'); ta.value = value; ta.style.cssText = [ 'width:100%', 'background:transparent', 'border:none', 'outline:none', 'color:var(--text)', 'font:inherit', 'font-size:15px', 'resize:none', 'line-height:1.6', 'min-height:26px', 'overflow-y:hidden', 'display:block', ].join(';'); const resize = () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; }; ta.addEventListener('input', resize); requestAnimationFrame(resize); return ta; } function makeAssistantEditTextarea(value) { const ta = document.createElement('textarea'); ta.value = value; ta.style.cssText = [ 'width:100%', 'background:transparent', 'border:none', 'outline:none', 'color:var(--text)', 'font:inherit', 'font-size:14px', 'resize:none', 'line-height:1.6', 'min-height:120px', 'overflow-y:auto', 'display:block', 'padding-bottom:8px', ].join(';'); const resize = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, window.innerHeight * 0.6) + 'px'; }; ta.addEventListener('input', resize); requestAnimationFrame(resize); return ta; } function makeBtn(label, cls) { const btn = document.createElement('button'); btn.textContent = label; btn.className = cls; btn.style.cssText += ';font-size:12px;padding:5px 12px;'; return btn; } // ── Streaming ───────────────────────────────────────────────────────────── function onChatStart() { isStreaming = true; streamingText = ''; pendingAssets = []; showChat(); const box = document.getElementById('chat-messages'); if (!box) return; streamingBubble = document.createElement('div'); streamingBubble.className = 'msg-assistant msg-generating'; const thinking = document.createElement('div'); thinking.className = 'msg-thinking'; for (let i = 0; i < 3; i++) { const d = document.createElement('div'); d.className = 'thinking-dot'; thinking.appendChild(d); } streamingBubble.appendChild(thinking); box.appendChild(streamingBubble); if (autoScroll) box.scrollTop = box.scrollHeight; updateSendBtn(true); } function onToken(token) { if (!streamingBubble) return; streamingText += token; streamingBubble.querySelector('.msg-thinking')?.remove(); // Strip session naming tag during streaming display const displayText = stripSessionTag(processDisplay(streamingText)); streamingBubble.innerHTML = renderMarkdown(displayText); attachCodeCopyListeners(streamingBubble); if (autoScroll) { const box = document.getElementById('chat-messages'); if (box) box.scrollTop = box.scrollHeight; } } function onChatDone(msg) { isStreaming = false; streamingBubble?.classList.remove('msg-generating'); streamingBubble = null; streamingText = ''; updateSendBtn(false); if (msg.history) { renderHistory(msg.history); if (pendingAssets.length > 0) { const box = document.getElementById('chat-messages'); if (box) { pendingAssets.forEach(asset => appendMediaMsg(box, asset.role, asset.content)); if (autoScroll) box.scrollTop = box.scrollHeight; } pendingAssets = []; } } } function onChatAborted(msg) { isStreaming = false; if (streamingBubble) { streamingBubble.classList.remove('msg-generating'); const note = document.createElement('div'); note.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:6px;'; note.textContent = '⚠ Interrupted'; streamingBubble.appendChild(note); streamingBubble = null; } updateSendBtn(false); if (msg.history) { renderHistory(msg.history); if (pendingAssets.length > 0) { const box = document.getElementById('chat-messages'); if (box) pendingAssets.forEach(asset => appendMediaMsg(box, asset.role, asset.content)); } } pendingAssets = []; } function onChatError(err) { isStreaming = false; if (streamingBubble) { streamingBubble.classList.remove('msg-generating'); streamingBubble.querySelector('.msg-thinking')?.remove(); const note = document.createElement('div'); note.style.cssText = 'color:#f87171;font-size:13px;margin-top:6px;'; note.textContent = `⚠ Error: ${err}`; streamingBubble.appendChild(note); streamingBubble = null; } updateSendBtn(false); } function handleLiveToolCall(call) { if (!streamingBubble) return; const names = { ollama_search: 'Searching web…', read_web_page: 'Reading page…', generate_image: 'Generating image…', generate_video: 'Generating video…', generate_audio: 'Generating audio…' }; if (call.state === 'pending') { streamingBubble.querySelector('.msg-thinking')?.remove(); if (!streamingBubble.querySelector(`[data-tcid="${call.id}"]`)) { const badge = document.createElement('div'); badge.className = 'msg-tool-call'; badge.style.pointerEvents = 'none'; badge.setAttribute('data-tcid', call.id); badge.innerHTML = `🔧${names[call.name] || call.name}`; streamingBubble.appendChild(badge); } } else if (call.state === 'resolved' || call.state === 'canceled') { streamingBubble.querySelector(`[data-tcid="${call.id}"]`)?.remove(); } } function appendAsset(asset) { const box = document.getElementById('chat-messages'); if (!box) return; pendingAssets.push(asset); appendMediaMsg(box, asset.role, asset.content); if (autoScroll) box.scrollTop = box.scrollHeight; } // ── Submit ──────────────────────────────────────────────────────────────── export function submitMessage(text, attachments = []) { if (!text.trim() && attachments.length === 0) return; if (isStreaming) { send({ type: 'chat:stop' }); return; } if (!activeSessionId) return; const images = attachments.filter(a => a.type === 'image'); const textFiles= attachments.filter(a => a.type === 'text'); let fullText = text; if (textFiles.length > 0) { fullText += '\n\n
Attached Files\n'; for (const f of textFiles) fullText += `\n
${f.name}\n\n\`\`\`\n${f.content}\n\`\`\`\n\n
\n`; fullText += '
'; } let content; if (images.length > 0) { content = [ { type: 'text', text: fullText }, ...images.map(img => ({ type: 'image_url', image_url: { url: `data:${img.mimeType};base64,${img.base64}` } })), ]; } else { content = fullText; } // Append optimistic user bubble const box = document.getElementById('chat-messages'); if (box) { const wrap = makeWrap(-1); const bubble = document.createElement('div'); bubble.className = 'msg-user'; bubble.innerHTML = renderMarkdown(fullText); images.forEach(img => { const el = document.createElement('img'); el.src = `data:${img.mimeType};base64,${img.base64}`; el.style.cssText = 'max-width:100%;max-height:200px;border-radius:8px;margin-top:6px;display:block;'; bubble.appendChild(el); }); wrap.appendChild(bubble); box.appendChild(wrap); if (autoScroll) box.scrollTop = box.scrollHeight; } send({ type: 'chat:send', sessionId: activeSessionId, content, tools: getActiveTools(), clientId: localStorage.getItem('ipai_client_id') || '' }); } // ── Utils ───────────────────────────────────────────────────────────────── function getActiveTools() { const tools = {}; document.querySelectorAll('[data-tool]').forEach(btn => { if (btn.dataset.tool) tools[btn.dataset.tool] = btn.classList.contains('active'); }); return tools; } function msgText(content) { if (typeof content === 'string') return content; return content.filter(p => p.type === 'text').map(p => p.text).join('\n'); } function msgImages(content) { if (typeof content === 'string') return []; return content.filter(p => p.type === 'image_url').map(p => p.image_url.url); } function processDisplay(text) { let result = '', i = 0; while (i < text.length) { const start = text.indexOf('```svg', i); if (start === -1) { result += text.slice(i); break; } result += text.slice(i, start) + '[SVG Image]'; const end = text.indexOf('```', start + 6); if (end === -1) break; i = end + 3; } return result; } function updateSendBtn(streaming) { document.querySelectorAll('#bottom-send-btn, #center-send-btn').forEach(btn => { btn.innerHTML = streaming ? '' : ''; btn.classList.toggle('stop', streaming); }); } function copyText(text) { navigator.clipboard.writeText(text).then( () => showNotification({ type: 'success', message: 'Copied', duration: 1500 }), () => showNotification({ type: 'error', message: 'Copy failed', duration: 1500 }) ); } function dlMedia(dataUrl, filename) { const a = document.createElement('a'); a.href = dataUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); } function openImageModal(src) { import('./modals.js').then(m => m.openImageModal(src)); } // Scroll tracking document.getElementById('chat-view')?.addEventListener('scroll', e => { const el = e.target; autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 60; }, true);