Download public/js/chat.js from sharktide/chat: direct link, hf CLI and curl.
- Browser
- Download file 33.1 kB
-
https://huggingface.co/spaces/sharktide/chat/resolve/a16dc1fe83dd17f1d78bd27d2a1c48eab000329a/public/js/chat.js
- Command line
-
hf download hf://spaces/sharktide/chat@a16dc1fe83dd17f1d78bd27d2a1c48eab000329a/public/js/chat.js
-
curl -L -o chat.js https://huggingface.co/spaces/sharktide/chat/resolve/a16dc1fe83dd17f1d78bd27d2a1c48eab000329a/public/js/chat.js
33.1 kB
| // 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(/<session_name>[\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 = `<span>${icons[call.name] || 'π§'}</span><span>${escHtml(names[call.name] || call.name)}</span>`; | |
| 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 `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`; | |
| } | |
| function editIcon() { | |
| return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`; | |
| } | |
| function searchIcon() { | |
| return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`; | |
| } | |
| function imageIcon() { | |
| return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`; | |
| } | |
| function videoIcon() { | |
| return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>`; | |
| } | |
| function audioIcon() { | |
| return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`; | |
| } | |
| function attachIcon() { | |
| return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`; | |
| } | |
| function sendSvg() { | |
| return `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21L23 12 2 3v7l15 2-15 2v7z"/></svg>`; | |
| } | |
| // ββ 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}<span>${label}</span>`; | |
| // 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<details><summary>Attached Files</summary>\n'; | |
| for (const f of files) | |
| fullText += `\n<details><summary>${f.name}</summary>\n\n\`\`\`\n${f.content}\n\`\`\`\n\n</details>\n`; | |
| fullText += '</details>'; | |
| } | |
| 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 = `<span>π</span><span>${escHtml(a.name)}</span>`; | |
| 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 = `<span>π§</span><span>${names[call.name] || call.name}</span>`; | |
| 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<details><summary>Attached Files</summary>\n'; | |
| for (const f of textFiles) | |
| fullText += `\n<details><summary>${f.name}</summary>\n\n\`\`\`\n${f.content}\n\`\`\`\n\n</details>\n`; | |
| fullText += '</details>'; | |
| } | |
| 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 | |
| ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>' | |
| : '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21L23 12 2 3v7l15 2-15 2v7z"/></svg>'; | |
| 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); |