feat: integrate ApiPilot tools and add light-theme voice UI
This commit is contained in:
@@ -35,6 +35,19 @@ function reportChatDebug(hypothesisId, location, msg, 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() {
|
||||
@@ -58,6 +71,11 @@ export class ChatView {
|
||||
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;
|
||||
@@ -103,6 +121,55 @@ export class ChatView {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
@@ -213,6 +280,18 @@ export class ChatView {
|
||||
* 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}
|
||||
*/
|
||||
@@ -220,7 +299,27 @@ export class ChatView {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -417,6 +516,186 @@ export class ChatView {
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user