// @ts-check /** * ChatView — owns the whole conversation surface: the slide-in history panel, * the ephemeral on-orb bubbles, and all the transcript/tool/streaming * bookkeeping. main.js wires the realtime client's events straight to the * `on*` methods here and otherwise doesn't touch chat state. * * Two parallel surfaces share one shape (see `_buildMessageEl`): * - ephemeral bubbles (`.bubble` / `.bubble-*`) fade on a timer * - persistent history (`.hist-msg` / `.hist-*`) the durable panel log * * Keying: * - user transcripts by the server's `item_id` — a speculative continuation * REUSES it, so both segments land in one row/bubble; deltas are CUMULATIVE * (each carries the full sentence so far), so we replace text wholesale. * - assistant transcripts by `response_id`, so a cancelled speculative reply * can be marked interrupted without erasing what was already shown. */ import { $, escHtml, DEBUG } from "./dom.js"; import { renderMarkdown } from "./markdown.js"; /** * @param {string} hypothesisId * @param {string} location * @param {string} msg * @param {Record} [data] */ function reportChatDebug(hypothesisId, location, msg, data = {}) { void hypothesisId; void location; void msg; void data; } const WRENCH_PATH = ``; const CHAT_BUBBLE_SVG = ``; const EMPTY_STATE_HTML = `
${CHAT_BUBBLE_SVG}Aún no hay mensajesToca el círculo y empieza a hablar
`; /** * @param {string} prefix * @param {string} [sep] * @returns {string} safe filename prefix (a-z 0-9 _ - no spaces / slashes / unicode), * sanitised so any OS accepts it. */ function _safeName(prefix, sep = "-") { const base = String(prefix || "").toLowerCase().trim() .normalize("NFKD").replace(/[^\x20-\x7E]/g, "") .replace(/[^a-z0-9]+/gi, sep || "-") .replace(new RegExp(`^${sep}+|${sep}+$`, "g"), ""); return base || "vocero"; } export class ChatView { constructor() { // In the V2 layout the chat panel lives inline in #app, shown permanently // below the orb — there is NO toggle button, close button, backdrop, or // unread badge any more. The strict $() helper THROWS when the selector is // missing, so for the optional overlay-era refs we MUST use plain // document.querySelector (returns null when absent). Only the // always-present inline refs go through $(). /** @type {HTMLButtonElement | null} */ this._chatBtn = /** @type {HTMLButtonElement | null} */ (document.querySelector("#chat-btn")); /** @type {HTMLSpanElement | null} */ this._chatBadge = /** @type {HTMLSpanElement | null} */ (document.querySelector("#chat-badge")); /** @type {HTMLElement} */ this._chatPanel = $("#chat-panel"); /** @type {HTMLElement | null} */ this._chatPanelBackdrop = /** @type {HTMLElement | null} */ (document.querySelector("#chat-panel-backdrop")); /** @type {HTMLButtonElement | null} */ this._chatPanelClose = /** @type {HTMLButtonElement | null} */ (document.querySelector("#chat-panel-close")); /** @type {HTMLElement} */ this._chatHistory = $("#chat-history"); /** @type {HTMLElement} */ this._bubbleStack = $("#bubble-stack"); /** @type {HTMLButtonElement | null} Export conversation download button in header. */ this._exportBtn = /** @type {HTMLButtonElement | null} */ (document.getElementById("chat-export-btn")); if (this._exportBtn) { this._exportBtn.addEventListener("click", () => this.exportConversation()); } // Inline chat is always "open" (permanently visible on screen). this._panelOpen = true; // ── Always-bottom scroll bookkeeping ───────────────────────────────── // Active timer for 4-step retry chain (0/30/100/250ms snap-to-bottom). /** @type {number | null} */ this._scrollTimerId = null; // Current step index inside a retry chain (0..3). this._scrollStep = 0; // ── User transcript state (keyed by item_id) ─────────────────────────── /** @type {Map} */ this._userHistByItem = new Map(); /** @type {HTMLElement | null} */ this._activeUserBubble = null; this._activeUserItemId = ""; // Monotonic counter for synthesizing unique keys when the server omits an // item_id / response_id, so id-less messages never collapse onto each other. this._anonSeq = 0; // ── Assistant transcript state (keyed by response_id) ────────────────── /** @type {Map} */ this._asstByResp = new Map(); /** Original transcript text is retained independently of rendered markup. @type {WeakMap} */ this._messageText = new WeakMap(); // ── Ephemeral bubble auto-dismiss ────────────────────────────────────── // Per-element expiry (epoch ms). A bubble fades once its expiry passes — // but only in stack order (see _reapBubbles). Refreshing the expiry keeps a // bubble alive while it updates (e.g. the live user utterance). /** @type {WeakMap} */ this._bubbleExpiry = new WeakMap(); // Single pending reaper handle: one timer for the whole stack (not one per // bubble) so dismissal is strictly oldest-first regardless of per-bubble delays. this._reaperHandle = 0; // ── New inline-chat handlers: only wire when old overlay buttons exist. if (this._chatBtn) this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel())); if (this._chatPanelClose) this._chatPanelClose.addEventListener("click", () => this._closePanel()); if (this._chatPanelBackdrop) this._chatPanelBackdrop.addEventListener("click", () => this._closePanel()); document.addEventListener("keydown", (e) => { // Old behaviour (ESC closes overlay) is a no-op on the inline permanent // panel; only execute it when a close button actually exists. if (e.key === "Escape" && this._chatPanelClose && this._panelOpen) this._closePanel(); }); } // ── User name / initials helpers ──────────────────────────────────────── /** @returns {string} display name for the current user (e.g. "Alessandro"). * Reuses extractVoceroUserName + initialsForName exposed on window by main.js * so we don't duplicate the nested-object lookup logic (profile/user/customer * keys, first+last name fallback, etc.). Falls back to "Usuario" (Spanish) * when no profile has been resolved yet — never returns "" because the * white-theme chat label is rendered into a pseudo-element that would show * a lonely timestamp otherwise. */ _resolveUserName() { const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null; const profile = /** @type {Record | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null; const full = ex ? ex(profile) : ""; if (!full) return "Usuario"; // Return ONLY the first token so the narrow chat label line fits: // "Alessandro Valenti" → "Alessandro", matches the screenshot style. const first = String(full).trim().split(/\s+/)[0] || full; return first; } /** @returns {string} 2-letter uppercase initials, e.g. "Alessandro Valenti" → "AV". */ _resolveUserInitials() { const mk = typeof window._s2sInitialsFor === "function" ? window._s2sInitialsFor : null; const profile = /** @type {Record | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null; const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null; const full = ex ? ex(profile) : ""; if (mk && full) { const init = mk(full); if (init) return init; } return "AV"; } /** @returns {string} "HH:MM" 24h using the current locale, no seconds. */ _nowStamp() { try { return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch (_) { const d = new Date(); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${hh}:${mm}`; } } /** Assistant display name — fixed Spanish label, same as the topbar brand. */ get _assistantName() { return "Vocero"; } // ── Panel ─────────────────────────────────────────────────────────────── _openPanel() { this._panelOpen = true; if (this._chatPanel) this._chatPanel.classList.add("open"); if (this._chatBadge) this._chatBadge.classList.remove("visible"); this._scrollToBottom(); } _closePanel() { // Closing disabled on the new inline-permanent layout; guard so if the // overlay UI is still in use by another variant it continues to work. if (!this._chatPanelClose) return; this._panelOpen = false; if (this._chatPanel) this._chatPanel.classList.remove("open"); } // Scroll the chat history to place the LAST message's FINAL LINE fully above // the sticky footer bar. // // DESIGN NOTES (2026-09-04, after user report "scroll non scrolla // sufficientemente"): // 1. NO early-return on "already queued". The previous coalescing flag was // the root cause of tail-clipping: the 1st incoming delta would queue a // rAF, every subsequent delta (up to ~30/frame) was SKIPPED, then the // rAF ran against the scrollHeight of the 1st delta only — so the tail // words appended later were never accounted for. // 2. Instead we CANCEL any pending timer chain and RESCHEDULE from scratch // every single time `_scrollToBottom()` is called. This means the very // last append/update call always "wins" the chain — so we scroll // against the final grown content, not an intermediate snapshot. // 3. We run 4 scroll attempts staggered at [0ms, 30ms, 100ms, 250ms]. // Layout is async: line-wrap, font metrics, the scroll anchoring reset, // and tool-row height expansions all resolve in later micro/task frames. // Attempt 0 = immediate post-append; 30 = after one layout-pass; 100 = // after any Chrome scroll-anchor reflow; 250 = after React-like deferred // re-renders (if any). At 250ms we are guaranteed stable scrollHeight. // 4. Instead of scrollTop = scrollHeight we use scrollTo({top: 9999999}). // The browser clamps automatically; using a giant literal avoids any // race where scrollHeight was read before a layout commit propagated. _scrollToBottom() { if (!this._chatHistory) return; const h = this._chatHistory; // Cancel any in-flight attempt chain and start fresh — guarantees the LAST // caller wins (so if 100 tokens arrive in 1 frame we don't run 100 chains, // we only run ONE chain: the one scheduled from the 100th token append). if (this._scrollTimerId != null) { clearTimeout(this._scrollTimerId); this._scrollTimerId = null; } // Also reset any per-chain tracking so we re-run all 4 steps from the new // point-in-time content height. this._scrollStep = 0; const snap = () => { // Use Number.MAX_SAFE_INTEGER: browsers clamp to the real max // (scrollHeight - clientHeight) automatically. This avoids ANY race // where we read scrollHeight at an intermediate layout commit, then // text wraps to more lines later and the scroll delta is short by a // few pixels (the exact bug reported by the user where the last line // was ½ visible behind the floating footer bar). const BIG = Number.MAX_SAFE_INTEGER; try { h.scrollTo({ top: BIG, behavior: "instant" }); } catch (_) { // Fallback for browsers without instant behavior option support h.scrollTop = BIG; } }; const step = () => { snap(); this._scrollStep++; // 5 attempts, staggered: next delay in ms per step index // step 0 (already run above): immediate // step 1: +30ms after one layout pass // step 2: +70ms after line-wrap reflow (most-common "one word left on // line 7" tail-clipping gets fixed here) // step 3: +150ms after Chrome scroll-anchor reflow // step 4: +250ms final after deferred tool-row / image layout // stop: step 5 reached → exit. const nextDelay = [30, 70, 150, 250][this._scrollStep]; if (nextDelay != null) { this._scrollTimerId = setTimeout(step, nextDelay); } else { // Step 4 done → release guard. The id is kept so a new incoming call // can cancel a mid-flight chain cleanly. this._scrollTimerId = null; } }; // Attempt 0 — IMMEDIATELY (same microtask as append — catches 90% of cases). step(); } _markUnread() { // New inline layout: the chat is always visible so no "unread badge" UX. // If we're running the old overlay variant we still show it. if (this._panelOpen) { this._scrollToBottom(); return; } if (this._chatBadge) this._chatBadge.classList.add("visible"); } // ── Shared rendering ────────────────────────────────────────────────────── /** * Build a role-labelled message element. Ephemeral bubbles and persistent * history rows share the same shape and differ only in their class prefix * (`bubble`/`bubble-*` vs `hist-msg`/`hist-*`). * * White-theme label + avatar data attributes (populated on both the outer * wrapper and the inner body): * wrapper .{hist-msg|bubble}.user → data-user-name / data-user-initials / data-timestamp * wrapper .{hist-msg|bubble}.assistant → data-asst-name / data-timestamp * inner .{hist|bubble}-body → data-user-name | data-asst-name + data-timestamp * * The .{hist|bubble}-body::before pseudo-element in style.css uses attr() * to render the "Name HH:MM" label aligned to the bubble corner; the * wrapper .user::before pseudo uses attr(data-user-initials) to paint the * circular avatar's 2-letter monogram. * * @param {{ container: string, prefix: string, role: "user"|"assistant", text: string, partial?: boolean }} o * @returns {HTMLElement} */ _buildMessageEl({ container, prefix, role, text, partial = false }) { const el = document.createElement("div"); el.className = `${container} ${role}`; const label = role === "user" ? "Tú" : "Asistente"; const stamp = this._nowStamp(); if (role === "user") { const uName = this._resolveUserName(); const uInit = this._resolveUserInitials(); el.setAttribute("data-user-name", uName); el.setAttribute("data-user-initials", uInit); el.setAttribute("data-timestamp", stamp); } else { el.setAttribute("data-asst-name", this._assistantName); el.setAttribute("data-timestamp", stamp); } el.innerHTML = `
${label}
`; const body = /** @type {HTMLDivElement} */ (el.lastElementChild); if (body) { this._renderMessageBody(body, text, role === "assistant"); if (role === "user") { body.setAttribute("data-user-name", el.getAttribute("data-user-name") || ""); } else { body.setAttribute("data-asst-name", this._assistantName); } body.setAttribute("data-timestamp", stamp); } return el; } /** @param {HTMLElement} body @param {string} text @param {boolean} assistant */ _renderMessageBody(body, text, assistant) { this._messageText.set(body, text); body.classList.toggle("markdown-content", assistant); if (assistant) body.replaceChildren(renderMarkdown(text)); else body.textContent = text; } // ── Ephemeral bubbles ─────────────────────────────────────────────────── /** @param {"user"|"assistant"|"tool"} role @param {string} text @returns {HTMLElement} */ _spawnBubble(role, text) { let el; if (role === "tool") { el = document.createElement("div"); el.className = "bubble tool"; el.innerHTML = `${WRENCH_PATH}${escHtml(text)}`; } else { el = this._buildMessageEl({ container: "bubble", prefix: "bubble", role, text }); } this._bubbleStack.appendChild(el); // Cap the stack at 3, but never evict the bubble the caller is still // actively updating (the live user bubble) — drop the next-oldest instead. const visible = /** @type {HTMLElement[]} */ ([...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; } /** @param {HTMLElement} el @param {string} text */ _updateBubbleText(el, text) { const t = /** @type {HTMLElement | null} */ (el.querySelector(".bubble-body")); if (t) this._renderMessageBody(t, text, el.classList.contains("assistant")); } /** @param {HTMLElement} el */ _dismissBubble(el) { if (!el || el.classList.contains("out")) return; // idempotent this._bubbleExpiry.delete(el); el.classList.remove("in"); el.classList.add("out"); const remove = () => el.remove(); el.addEventListener("transitionend", remove, { once: true }); // Fallback: transitionend never fires if the bubble's visual state didn't // change (dismissed pre-paint) or the tab is backgrounded. The transition // is 0.3s, so force removal a little after. setTimeout(remove, 400); } /** * Fade bubbles whose expiry has passed — strictly oldest-first. We walk the * stack top (oldest) to bottom (newest) and stop at the first bubble still * alive: nothing newer may leave while an older bubble is still on screen. A * bubble that keeps updating pushes its own expiry forward, so it (and * everything behind it) stays put until it finally goes quiet. */ _reapBubbles() { this._reaperHandle = 0; const now = Date.now(); const visible = /** @type {HTMLElement[]} */ ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]); let nextWake = Infinity; for (const el of visible) { const exp = this._bubbleExpiry.get(el) ?? now; // no expiry recorded → treat as due if (exp <= now) { this._dismissBubble(el); } else { // Oldest survivor isn't due yet; stop so nothing newer leaves before it. nextWake = exp; break; } } if (nextWake !== Infinity) { this._reaperHandle = setTimeout(() => this._reapBubbles(), Math.max(50, nextWake - Date.now())); } } /** * (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`. * Calling it again resets the countdown — so a bubble that keeps updating * stays on screen and only fades once it goes quiet. Removal is ordered by the * shared reaper, so the oldest bubble always disappears first. * @param {HTMLElement} el @param {number} [delay] */ _bumpDismiss(el, delay = 4000) { this._bubbleExpiry.set(el, Date.now() + delay); if (!this._reaperHandle) this._reaperHandle = setTimeout(() => this._reapBubbles(), delay); } // ── History ─────────────────────────────────────────────────────────────── /** Render the empty-state placeholder into the history panel. */ renderEmptyState() { this._chatHistory.innerHTML = EMPTY_STATE_HTML; } /** Reset the panel to the empty state and clear the unread badge. */ clear() { this.renderEmptyState(); if (this._chatBadge) this._chatBadge.classList.remove("visible"); } /** @param {"user"|"assistant"} role @param {string} text @param {boolean} partial @returns {HTMLElement} */ _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; } /** @param {HTMLElement | null} el @param {string} text @param {boolean} partial */ _updateHistMsg(el, text, partial) { if (!el) return; const body = /** @type {HTMLElement | null} */ (el.querySelector(".hist-body")); if (!body) return; this._renderMessageBody(body, text, el.classList.contains("assistant")); body.classList.toggle("partial", partial); this._scrollToBottom(); } /** * Append a tool-call row to the conversation. We only add it once the tool * has run, so the expandable toggle carries BOTH the call input and its result. * @param {string} name @param {string} argsJson @param {string} output */ _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 = `
Llamada a herramienta
Entrada
${escHtml(pretty)}
Salida
${escHtml(output || "(sin salida)")}
`; const header = /** @type {HTMLButtonElement} */ (el.querySelector(".hist-tool-header")); const body = /** @type {HTMLDivElement} */ (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(); } /** Tag an assistant history row as interrupted (user barged in mid-reply). * @param {HTMLElement | null} hist */ _markHistInterrupted(hist) { if (!hist || hist.querySelector(".hist-note")) return; hist.classList.add("interrupted"); const note = document.createElement("div"); note.className = "hist-note"; note.textContent = "Interrumpido"; hist.appendChild(note); } /** Render a captured webcam frame in the transcript (the camera tool result). * @param {string} dataUrl */ _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 = `
Captura
Captura de la cámara enviada al modelo`; const img = /** @type {HTMLImageElement} */ (el.querySelector("img")); img.src = dataUrl; this._chatHistory.appendChild(el); this._scrollToBottom(); } /** * Reset all streaming bookkeeping for session start / teardown. Pass * `dismiss` to also fade any bubbles still on screen. * @param {{ dismiss?: boolean }} [opts] */ 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(); } // ── Export / download conversation ─────────────────────────────────────── /** * Serialize the current persistent chat history (.hist-msg rows inside * #chat-history) into a structured JSON object. Covers: * - user / assistant text messages (role, text, timestamp, name, initials) * - tool-call expandable rows (role=tool, name, args, output) * - webcam / snapshot images (role=image, dataUrl) * Empty state rows (.chat-empty) and ephemeral orb bubbles are ignored. * @returns {{ * exportedAt: string, * messageCount: number, * messages: Array<{ type: string; timestamp?: string; [k: string]: unknown }>, * }} */ _serializeConversation() { /** @type {Array<{ type: string; timestamp?: string; [k: string]: unknown }>} */ const messages = []; if (!this._chatHistory) return { exportedAt: new Date().toISOString(), messageCount: 0, messages }; const rows = /** @type {HTMLElement[]} */ ([...this._chatHistory.querySelectorAll(":scope > .hist-msg")]); for (const row of rows) { const ts = row.getAttribute("data-timestamp") || undefined; if (row.classList.contains("user") || row.classList.contains("assistant")) { const body = /** @type {HTMLElement | null} */ (row.querySelector(".hist-body")); if (!body) continue; const role = row.classList.contains("user") ? "user" : "assistant"; const out = /** @type {{ type: string; text: string; timestamp?: string; name?: string; initials?: string; interrupted?: boolean }} */ ({ type: role, text: this._messageText.get(body) ?? body.textContent ?? "", }); if (ts) out.timestamp = ts; if (role === "user") { const n = row.getAttribute("data-user-name"); const i = row.getAttribute("data-user-initials"); if (n) out.name = n; if (i) out.initials = i; } if (row.classList.contains("interrupted")) out.interrupted = true; messages.push(out); } else if (row.classList.contains("tool")) { const img = /** @type {HTMLImageElement | null} */ (row.querySelector("img.hist-image")); if (img) { messages.push({ type: "image", timestamp: ts, dataUrl: img.src || "" }); } else { const nameEl = /** @type {HTMLElement | null} */ (row.querySelector(".hist-tool-name")); const blocks = /** @type {HTMLElement[]} */ ([...row.querySelectorAll(".hist-tool-block")]); const args = blocks[0]?.textContent || ""; const output = blocks[1]?.textContent || ""; messages.push({ type: "tool", timestamp: ts, name: nameEl?.textContent || "", args, output, }); } } } return { exportedAt: new Date().toISOString(), messageCount: messages.length, messages, }; } /** * Pretty-print the JSON conversation structure to a human-readable plain * text log (one entry per "HH:MM Name: text"). Tool calls are rendered * as indented blocks (tool name followed by input/output sections). * @param {ReturnType} conv * @returns {string} */ _conversationToText(conv) { const lines = []; const date = (() => { try { return new Date(conv.exportedAt).toLocaleString(); } catch { return conv.exportedAt; } })(); lines.push(`Conversación Vocero — ${date}`); lines.push(`Mensajes totales: ${conv.messageCount}`); lines.push("—".repeat(52)); lines.push(""); for (const m of conv.messages) { if (m.type === "user" || m.type === "assistant") { const label = m.type === "user" ? (m.name ? String(m.name) : "Usuario") : "Vocero"; const head = [m.timestamp, label].filter(Boolean).join(" "); lines.push(`${head}${m.interrupted ? " [interrumpida]" : ""}:`); lines.push(String(m.text || "").trim()); lines.push(""); } else if (m.type === "tool") { const head = [m.timestamp, `HERRAMIENTA: ${String(m.name || "").trim()}`].filter(Boolean).join(" "); lines.push(head); const args = String(m.args || "").trim(); const output = String(m.output || "").trim(); if (args) { lines.push(" Entrada:"); lines.push(" " + args.split("\n").join("\n ")); } if (output) { lines.push(" Salida:"); lines.push(" " + output.split("\n").join("\n ")); } lines.push(""); } else if (m.type === "image") { const head = [m.timestamp, "CAPTURA (imagen de la cámara enviada al modelo)"].filter(Boolean).join(" "); lines.push(head); lines.push(" " + String(m.dataUrl || "").slice(0, 80) + (String(m.dataUrl || "").length > 80 ? "…" : "")); lines.push(""); } } return lines.join("\n"); } /** * Trigger a browser "download" for the given string content. Uses the * classic Blob+URL+a.click pattern — no backend call, fully client-side, * works offline and never leaks the conversation content anywhere. * @param {string} content * @param {string} filename * @param {string} mime */ _downloadBlob(content, filename, mime) { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); try { const a = document.createElement("a"); a.href = url; a.download = filename; a.rel = "noopener"; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 0); } finally { setTimeout(() => URL.revokeObjectURL(url), 4000); } } /** * Public entry-point wired to the #chat-export-btn in the chat header. * Serializes the current conversation and produces TWO downloads: * 1. vocero-conversacion-YYYYMMDD-HHMMSS.json (structured data) * 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (human-readable log) * UI feedback: briefly swaps the download icon for a ✓ completion tick then * restores the original SVG so the user gets a visual ack that the export * completed (idempotent / errors are logged to console — never throws). */ exportConversation() { try { const conv = this._serializeConversation(); const profile = /** @type {Record | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null; const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null; const userName = ex && profile ? String(ex(profile) || "").trim() : ""; const stampBase = (() => { const d = new Date(); const pad = (n, w = 2) => String(n).padStart(w, "0"); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; })(); const prefix = _safeName(`vocero-conversacion${userName ? "-" + userName : ""}`, "-"); const jsonName = `${prefix}-${stampBase}.json`; const txtName = `${prefix}-${stampBase}.txt`; const jsonText = JSON.stringify(conv, null, 2); const plainText = this._conversationToText(conv); if (conv.messageCount === 0) { // Don't offer downloads of empty logs; flash the button briefly to // signal "pressed" without spamming the user with empty files. if (this._exportBtn) { this._exportBtn.classList.add("chat-export-empty"); setTimeout(() => this._exportBtn?.classList.remove("chat-export-empty"), 600); } if (typeof window !== "undefined" && typeof console !== "undefined") { console.info("[ui/chat.js:exportConversation] No messages yet — export skipped."); } return; } this._downloadBlob(jsonText, jsonName, "application/json;charset=utf-8"); this._downloadBlob(plainText, txtName, "text/plain;charset=utf-8"); // Quick visual OK: paint the button green for 650ms. Avoids modifying // innerHTML (we'd otherwise lose the SVG on mobile fast-clicks). if (this._exportBtn) { this._exportBtn.classList.add("chat-export-ok"); setTimeout(() => this._exportBtn?.classList.remove("chat-export-ok"), 650); } } catch (err) { if (typeof window !== "undefined" && typeof console !== "undefined") { console.error("[ui/chat.js:exportConversation] Conversation export failed:", err); } } } // ── Client event handlers ───────────────────────────────────────────────── /** * A streamed transcript delta (user or assistant). * @param {{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }} d */ 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") { // Group by item_id: a speculative continuation reuses the same id, so it // updates the same row/bubble. A missing id falls back to the active item // (same utterance) or a fresh unique key, never a shared sentinel that // would collapse distinct turns into one row. 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); } // One ephemeral bubble per active item. Purely timer-based: the timer is // refreshed on every delta, so it stays while the user keeps talking and // fades a few seconds after they stop — no dependency on a response ever // arriving, so it can never get stuck. 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") { // Assistant transcript arrives once, as the full text, keyed by // response_id so a cancelled speculative response can be removed later. A // missing id gets a unique key so two id-less replies never collide. 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(); } } /** * A response closed (completed or cancelled). * @param {{ responseId: string; status: string; audible?: boolean; transcript?: string }} detail */ 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)}`); // Without an id we can't target a specific response; the bubble will // auto-dismiss on its own timer regardless. if (!responseId) return; const entry = this._asstByResp.get(responseId); if (status === "cancelled") { // Keep every transcript that was received — mark it interrupted rather // than erasing it. If the `*.transcript.done` never fired, build the row // from the text carried in response.done. 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; } // Any other terminal close (completed / failed / incomplete / …): just // release the map entry. The bubble already auto-dismisses on its timer and // the history row persists as the conversation log. Crucially we do NOT // touch user state here — that lifecycle is fully independent. this._asstByResp.delete(responseId); // ALWAYS re-anchor to the last line when a response ends, since the final // punctuation line + any trailing tool-result row pushes the tail further // down than the last streamed delta did. this._scrollToBottom(); } /** The model called a tool — show an ephemeral "running" bubble. * @param {string} name */ onToolCall(name) { this._bumpDismiss(this._spawnBubble("tool", name)); this._markUnread(); this._scrollToBottom(); } /** The tool finished — append its call+result row (and any captured image). * @param {string} name @param {string} argsJson @param {string} output @param {string} [image] */ onToolResult(name, argsJson, output, image) { const startedAt = performance.now(); this._appendHistTool(name, argsJson, output); if (image) this._appendHistImage(image); // Tool rows can be tall (big JSON payloads, tables). Force a scroll after // insert so the start of the result isn't hidden off-screen above the // sticky footer; we want the LAST line of the answer always in view. this._scrollToBottom(); this._markUnread(); // #region debug-point C:chat-tool-render reportChatDebug("C", "ui/chat.js:onToolResult", "rendered tool result row", { name, argsLength: argsJson.length, outputLength: output.length, renderMs: Math.round(performance.now() - startedAt), }); // #endregion if (image) this._appendHistImage(image); // show the captured frame below the call this._markUnread(); } }