824 lines
38 KiB
JavaScript
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 "Nombre 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 Nombre: texto"). Tool calls are rendered
|
|
* as indented blocks ("TOOL: nombre" 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 (log legibile para humanos)
|
|
* UI feedback: briefly swaps the download icon for a ✓ "hecho" 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 hay mensajes aún — exportación omitida.");
|
|
}
|
|
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] Error exportando conversación:", 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();
|
|
}
|
|
}
|