Files
vocero-client/s2s-client/ui/chat.js
T

824 lines
38 KiB
JavaScript

// @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";
/**
* @param {string} hypothesisId
* @param {string} location
* @param {string} msg
* @param {Record<string, unknown>} [data]
*/
function reportChatDebug(hypothesisId, location, msg, data = {}) {
void hypothesisId;
void location;
void msg;
void data;
}
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>`;
/**
* @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<string, HTMLElement>} */
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<string, { bubble: HTMLElement, hist: HTMLElement }>} */
this._asstByResp = new Map();
// ── 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<HTMLElement, number>} */
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<string, unknown> | 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<string, unknown> | 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" ? "You" : "Assistant";
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 = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}">${escHtml(text)}</div>`;
const body = /** @type {HTMLDivElement} */ (el.lastElementChild);
if (body) {
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;
}
// ── 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 = `<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);
// 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 = el.querySelector(".bubble-body");
if (t) t.textContent = text;
}
/** @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;
body.textContent = text;
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 = `
<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 = /** @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 = "Interrupted";
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 = `<div class="hist-role">Snapshot</div><img class="hist-image" alt="Webcam snapshot sent to the model" />`;
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: 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<ChatView["_serializeConversation"]>} 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(" Input:"); lines.push(" " + args.split("\n").join("\n ")); }
if (output) { lines.push(" Output:"); lines.push(" " + output.split("\n").join("\n ")); }
lines.push("");
} else if (m.type === "image") {
const head = [m.timestamp, "SNAPSHOT (imagen web-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<string, unknown> | 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();
}
}