| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { $, escHtml, DEBUG } from "./dom.js"; |
|
|
| const WRENCH_PATH = `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`; |
| const CHAT_BUBBLE_SVG = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`; |
| const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">No messages yet</span><span class="chat-empty-hint">Tap the orb and start talking</span></div>`; |
|
|
| export class ChatView { |
| constructor() { |
| |
| this._chatBtn = $("#chat-btn"); |
| |
| this._chatBadge = $("#chat-badge"); |
| |
| this._chatPanel = $("#chat-panel"); |
| |
| this._chatPanelBackdrop = $("#chat-panel-backdrop"); |
| |
| this._chatPanelClose = $("#chat-panel-close"); |
| |
| this._chatHistory = $("#chat-history"); |
| |
| this._bubbleStack = $("#bubble-stack"); |
|
|
| this._panelOpen = false; |
| this._scrollQueued = false; |
|
|
| |
| |
| this._userHistByItem = new Map(); |
| |
| this._activeUserBubble = null; |
| this._activeUserItemId = ""; |
| |
| |
| this._anonSeq = 0; |
|
|
| |
| |
| this._asstByResp = new Map(); |
|
|
| |
| |
| |
| |
| |
| this._bubbleExpiry = new WeakMap(); |
| |
| |
| this._reaperHandle = 0; |
|
|
| this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel())); |
| this._chatPanelClose.addEventListener("click", () => this._closePanel()); |
| this._chatPanelBackdrop.addEventListener("click", () => this._closePanel()); |
| document.addEventListener("keydown", (e) => { |
| if (e.key === "Escape" && this._panelOpen) this._closePanel(); |
| }); |
| } |
|
|
| |
|
|
| _openPanel() { |
| this._panelOpen = true; |
| this._chatPanel.classList.add("open"); |
| this._chatBadge.classList.remove("visible"); |
| this._scrollToBottom(); |
| } |
|
|
| _closePanel() { |
| this._panelOpen = false; |
| this._chatPanel.classList.remove("open"); |
| } |
|
|
| |
| |
| _scrollToBottom() { |
| if (!this._panelOpen || this._scrollQueued) return; |
| this._scrollQueued = true; |
| requestAnimationFrame(() => { |
| this._scrollQueued = false; |
| this._chatHistory.scrollTop = this._chatHistory.scrollHeight; |
| }); |
| } |
|
|
| _markUnread() { |
| if (this._panelOpen) { |
| this._scrollToBottom(); |
| return; |
| } |
| this._chatBadge.classList.add("visible"); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| _buildMessageEl({ container, prefix, role, text, partial = false }) { |
| const el = document.createElement("div"); |
| el.className = `${container} ${role}`; |
| const label = role === "user" ? "You" : "Assistant"; |
| el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}">${escHtml(text)}</div>`; |
| return el; |
| } |
|
|
| |
|
|
| |
| _spawnBubble(role, text) { |
| let el; |
| if (role === "tool") { |
| el = document.createElement("div"); |
| el.className = "bubble tool"; |
| el.innerHTML = `<svg class="bubble-tool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${WRENCH_PATH}</svg><span class="bubble-tool-text">${escHtml(text)}</span>`; |
| } else { |
| el = this._buildMessageEl({ container: "bubble", prefix: "bubble", role, text }); |
| } |
| this._bubbleStack.appendChild(el); |
| |
| |
| const visible = ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]); |
| if (visible.length > 3) { |
| this._dismissBubble(visible.find((b) => b !== this._activeUserBubble) ?? visible[0]); |
| } |
| requestAnimationFrame(() => el.classList.add("in")); |
| return el; |
| } |
|
|
| |
| _updateBubbleText(el, text) { |
| const t = el.querySelector(".bubble-body"); |
| if (t) t.textContent = text; |
| } |
|
|
| |
| _dismissBubble(el) { |
| if (!el || el.classList.contains("out")) return; |
| this._bubbleExpiry.delete(el); |
| el.classList.remove("in"); |
| el.classList.add("out"); |
| const remove = () => el.remove(); |
| el.addEventListener("transitionend", remove, { once: true }); |
| |
| |
| |
| setTimeout(remove, 400); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _reapBubbles() { |
| this._reaperHandle = 0; |
| const now = Date.now(); |
| const visible = ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]); |
| let nextWake = Infinity; |
| for (const el of visible) { |
| const exp = this._bubbleExpiry.get(el) ?? now; |
| if (exp <= now) { |
| this._dismissBubble(el); |
| } else { |
| |
| nextWake = exp; |
| break; |
| } |
| } |
| if (nextWake !== Infinity) { |
| this._reaperHandle = setTimeout(() => this._reapBubbles(), Math.max(50, nextWake - Date.now())); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _bumpDismiss(el, delay = 4000) { |
| this._bubbleExpiry.set(el, Date.now() + delay); |
| if (!this._reaperHandle) this._reaperHandle = setTimeout(() => this._reapBubbles(), delay); |
| } |
|
|
| |
|
|
| |
| renderEmptyState() { |
| this._chatHistory.innerHTML = EMPTY_STATE_HTML; |
| } |
|
|
| |
| clear() { |
| this.renderEmptyState(); |
| this._chatBadge.classList.remove("visible"); |
| } |
|
|
| |
| _appendHistMsg(role, text, partial) { |
| const empty = this._chatHistory.querySelector(".chat-empty"); |
| if (empty) empty.remove(); |
| const el = this._buildMessageEl({ container: "hist-msg", prefix: "hist", role, text, partial }); |
| this._chatHistory.appendChild(el); |
| this._scrollToBottom(); |
| return el; |
| } |
|
|
| |
| _updateHistMsg(el, text, partial) { |
| if (!el) return; |
| const body = (el.querySelector(".hist-body")); |
| if (!body) return; |
| body.textContent = text; |
| body.classList.toggle("partial", partial); |
| this._scrollToBottom(); |
| } |
|
|
| |
| |
| |
| |
| |
| _appendHistTool(name, argsJson, output) { |
| const empty = this._chatHistory.querySelector(".chat-empty"); |
| if (empty) empty.remove(); |
| let pretty = argsJson; |
| try { pretty = JSON.stringify(JSON.parse(argsJson), null, 2); } catch {} |
| const el = document.createElement("div"); |
| el.className = "hist-msg tool"; |
| el.innerHTML = ` |
| <div class="hist-role">Tool call</div> |
| <button class="hist-tool-header" aria-expanded="false"> |
| <svg class="hist-tool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${WRENCH_PATH}</svg> |
| <span class="hist-tool-name">${escHtml(name)}</span> |
| <svg class="hist-tool-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg> |
| </button> |
| <div class="hist-tool-body"> |
| <div class="hist-tool-label">Input</div> |
| <div class="hist-tool-block">${escHtml(pretty)}</div> |
| <div class="hist-tool-label">Output</div> |
| <div class="hist-tool-block hist-tool-output">${escHtml(output || "(no output)")}</div> |
| </div> |
| `; |
| const header = (el.querySelector(".hist-tool-header")); |
| const body = (el.querySelector(".hist-tool-body")); |
| header.addEventListener("click", () => { |
| const expanded = header.getAttribute("aria-expanded") === "true"; |
| header.setAttribute("aria-expanded", String(!expanded)); |
| body.classList.toggle("open", !expanded); |
| }); |
| this._chatHistory.appendChild(el); |
| this._scrollToBottom(); |
| } |
|
|
| |
| |
| _markHistInterrupted(hist) { |
| if (!hist || hist.querySelector(".hist-note")) return; |
| hist.classList.add("interrupted"); |
| const note = document.createElement("div"); |
| note.className = "hist-note"; |
| note.textContent = "Interrupted"; |
| hist.appendChild(note); |
| } |
|
|
| |
| |
| _appendHistImage(dataUrl) { |
| const empty = this._chatHistory.querySelector(".chat-empty"); |
| if (empty) empty.remove(); |
| const el = document.createElement("div"); |
| el.className = "hist-msg tool"; |
| el.innerHTML = `<div class="hist-role">Snapshot</div><img class="hist-image" alt="Webcam snapshot sent to the model" />`; |
| const img = (el.querySelector("img")); |
| img.src = dataUrl; |
| this._chatHistory.appendChild(el); |
| this._scrollToBottom(); |
| } |
|
|
| |
| |
| |
| |
| |
| reset(opts) { |
| if (opts?.dismiss) { |
| if (this._activeUserBubble) this._dismissBubble(this._activeUserBubble); |
| for (const { bubble } of this._asstByResp.values()) this._dismissBubble(bubble); |
| } |
| this._userHistByItem.clear(); |
| this._activeUserBubble = null; |
| this._activeUserItemId = ""; |
| this._asstByResp.clear(); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| onTranscript(d) { |
| if (DEBUG) console.debug(`[ui] transcript role=${d.role} partial=${d.partial} item=${d.itemId} resp=${d.responseId} text=${JSON.stringify(d.text)}`); |
|
|
| if (d.role === "user") { |
| |
| |
| |
| |
| const id = d.itemId || this._activeUserItemId || `_u${++this._anonSeq}`; |
| const text = d.text; |
|
|
| let hist = this._userHistByItem.get(id); |
| if (!hist) { |
| hist = this._appendHistMsg("user", text, d.partial); |
| this._userHistByItem.set(id, hist); |
| } else { |
| this._updateHistMsg(hist, text, d.partial); |
| } |
|
|
| |
| |
| |
| |
| if (this._activeUserItemId !== id || !this._activeUserBubble) { |
| this._activeUserBubble = this._spawnBubble("user", text); |
| this._activeUserItemId = id; |
| } else { |
| this._updateBubbleText(this._activeUserBubble, text); |
| } |
| this._bumpDismiss(this._activeUserBubble, 6000); |
| this._markUnread(); |
| } else if (d.role === "assistant") { |
| |
| |
| |
| const rid = d.responseId || `_a${++this._anonSeq}`; |
| const entry = this._asstByResp.get(rid); |
| if (!entry) { |
| const bubble = this._spawnBubble("assistant", d.text); |
| this._asstByResp.set(rid, { bubble, hist: this._appendHistMsg("assistant", d.text, false) }); |
| this._bumpDismiss(bubble); |
| } else { |
| this._updateBubbleText(entry.bubble, d.text); |
| this._updateHistMsg(entry.hist, d.text, false); |
| this._bumpDismiss(entry.bubble); |
| } |
| this._markUnread(); |
| } |
| } |
|
|
| |
| |
| |
| |
| onResponseFinished(detail) { |
| const { responseId, status, audible, transcript } = detail; |
| if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`); |
| |
| |
| if (!responseId) return; |
| const entry = this._asstByResp.get(responseId); |
|
|
| if (status === "cancelled") { |
| |
| |
| |
| let hist = entry?.hist ?? null; |
| if (!hist && transcript) { |
| hist = this._appendHistMsg("assistant", transcript, false); |
| } else if (hist && transcript) { |
| this._updateHistMsg(hist, transcript, false); |
| } |
| if (hist) this._markHistInterrupted(hist); |
| this._asstByResp.delete(responseId); |
| return; |
| } |
|
|
| |
| |
| |
| |
| this._asstByResp.delete(responseId); |
| } |
|
|
| |
| |
| onToolCall(name) { |
| this._bumpDismiss(this._spawnBubble("tool", name)); |
| this._markUnread(); |
| } |
|
|
| |
| |
| onToolResult(name, argsJson, output, image) { |
| this._appendHistTool(name, argsJson, output); |
| if (image) this._appendHistImage(image); |
| this._markUnread(); |
| } |
| } |
|
|