vanna-clubpetro/static/vanna-embed-bootstrap.js
leonardosalazar-cp 1d152c0dce Initial commit: Vanna 2.0 deployment for ClubPetro
Wrapper application around upstream Vanna with:
- Tenant-aware ChromaDB memory (per program/store)
- ClickHouse RLS runner with introspection guards
- PT-BR system prompt and chat translations
- Custom Plotly chart generator (ranked bar, datetime coercion)
- Embed bootstrap (theme pierce + i18n + markdown) shared by demo and React app
- Event sink for chat turn observability
2026-04-29 17:22:05 -03:00

318 lines
11 KiB
JavaScript

/**
* Vanna chat embed bootstrap — fonte única do JS de wiring exigido pelo
* <vanna-chat>: theme pierce (adoptedStyleSheets), tradutor PT-BR
* (MutationObserver), markdown processor pros balões, fonts, e load do
* bundle.
*
* Servido pelo Vanna server em /vanna-embed-bootstrap.js. Consumido por:
* - static/embed-demo.html (smoke-test page, mesmo origin)
* - clubpetro-frontend/.../vannaChatLoader.ts (React app, cross-origin)
*
* API pública:
* window.VannaEmbed.ensureLoaded({ baseUrl, extraCss? }) -> Promise<void>
* - baseUrl: "" pra mesma origem (demo); URL completa do Vanna server
* quando embedado em outra origem (React app).
* - extraCss: CSS opcional appended ao theme sheet (ex.: override do
* avatar logo quando o SVG mora na origem da app, não na do Vanna).
* - Idempotente: chamadas subsequentes retornam a mesma Promise.
*/
(function () {
if (window.VannaEmbed) return;
let loaderPromise = null;
const escapeHtml = (s) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const mdToHtml = (md) => {
if (!md) return "";
let s = escapeHtml(md);
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
s = s.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>");
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
s = s.replace(/(^|[^\w])_([^_\n]+)_(?!\w)/g, "$1<em>$2</em>");
s = s.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>'
);
// Headers ANTES das listas. A regex de lista consome o \n final do
// último item; rodar headers depois deixaria `### Conclusão` logo
// após um bloco de lista precedido só de `</ul>` (sem \n) e a regex
// de header (que exige ^ ou \n antes do #) falharia.
s = s.replace(
/(^|\n)(#{1,6})\s+([^\n]+)/g,
(_, lead, hashes, text) =>
`${lead}<h${hashes.length}>${text.trim()}</h${hashes.length}>`
);
s = s.replace(/(?:^|\n)((?:[-*]\s+.+(?:\n|$))+)/g, (_, block) => {
const items = block
.trim()
.split(/\n/)
.map((l) => l.replace(/^[-*]\s+/, "").trim())
.map((t) => `<li>${t}</li>`)
.join("");
return `\n<ul>${items}</ul>\n`;
});
s = s.replace(/\n/g, "<br>");
s = s.replace(/<br>\s*(<\/?(?:ul|li|h[1-6])>)/g, "$1");
s = s.replace(/(<\/?(?:ul|li|h[1-6])>)\s*<br>/g, "$1");
return s;
};
const EXACT = {
"Search...": "Buscar...",
"Export": "Baixar",
"📥 Export": "📥 Baixar",
"Export to CSV": "Baixar para CSV",
};
const REGEX = [
[/^(\d+)\s+rows?$/i, (_, n) => `${n} ${n === "1" ? "linha" : "linhas"}`],
[/^(\d+)\s+columns?$/i, (_, n) => `${n} ${n === "1" ? "coluna" : "colunas"}`],
[
/^Showing\s+(\d+)\s+of\s+(\d+)\s+rows?$/i,
(_, a, b) => `Mostrando ${a} de ${b} ${b === "1" ? "linha" : "linhas"}`,
],
];
const translate = (s) => {
if (typeof s !== "string") return s;
const t = s.trim();
if (!t) return s;
if (EXACT[t]) return s.replace(t, EXACT[t]);
for (const [re, fn] of REGEX) {
const m = t.match(re);
if (m) return s.replace(t, fn(...m));
}
return s;
};
const setupThemePierce = (baseUrl, extraCss) => {
if (window.__vannaThemeSheet) return;
const themeSheet = new CSSStyleSheet();
window.__vannaThemeSheet = themeSheet;
const desc = Object.getOwnPropertyDescriptor(
ShadowRoot.prototype,
"adoptedStyleSheets"
);
if (desc && desc.set) {
Object.defineProperty(ShadowRoot.prototype, "adoptedStyleSheets", {
configurable: true,
enumerable: true,
get() {
return desc.get.call(this);
},
set(value) {
const without = (value || []).filter((s) => s !== themeSheet);
desc.set.call(this, [...without, themeSheet]);
},
});
}
const merge = (css) => (extraCss ? css + "\n" + extraCss : css);
fetch(baseUrl + "/vanna-theme.css?t=" + Date.now())
.then((r) => r.text())
.then((css) => themeSheet.replaceSync(merge(css)))
.catch(() => {
if (extraCss) themeSheet.replaceSync(extraCss);
});
};
const setupTranslator = () => {
if (window.__vannaTranslatorArmed) return;
window.__vannaTranslatorArmed = true;
const isInsideMessageContent = (n) =>
!!n &&
(n.classList?.contains("message-content") ||
n.closest?.(".message-content"));
const processMessageContent = (el) => {
const text = el.textContent || "";
if (el.dataset.bare === text) return;
el.innerHTML = mdToHtml(text);
el.dataset.bare = el.textContent || "";
};
const walkAndTranslate = (root) => {
const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node;
while ((node = tw.nextNode())) {
const next = translate(node.nodeValue);
if (next !== node.nodeValue) node.nodeValue = next;
}
root.querySelectorAll?.("[placeholder], [title]")?.forEach((el) => {
for (const attr of ["placeholder", "title"]) {
const v = el.getAttribute(attr);
if (v == null) continue;
const next = translate(v);
if (next !== v) el.setAttribute(attr, next);
}
});
root
.querySelectorAll?.(".message-content")
?.forEach(processMessageContent);
};
const observers = new WeakSet();
const attachObserver = (root) => {
if (observers.has(root)) return;
observers.add(root);
walkAndTranslate(root);
new MutationObserver((muts) => {
for (const m of muts) {
const target = m.target;
const inMd = isInsideMessageContent(target);
if (m.type === "characterData") {
if (inMd) {
const host = target.parentElement?.closest?.(".message-content");
if (host) processMessageContent(host);
} else {
const next = translate(target.nodeValue);
if (next !== target.nodeValue) target.nodeValue = next;
}
} else if (m.type === "attributes") {
const v = target.getAttribute(m.attributeName);
const next = translate(v);
if (next !== v) target.setAttribute(m.attributeName, next);
} else {
m.addedNodes.forEach((n) => {
if (n.nodeType === 3) {
if (inMd) {
const host = target.closest?.(".message-content");
if (host) processMessageContent(host);
} else {
const next = translate(n.nodeValue);
if (next !== n.nodeValue) n.nodeValue = next;
}
} else if (n.nodeType === 1) {
walkAndTranslate(n);
if (n.shadowRoot) attachObserver(n.shadowRoot);
}
});
}
}
}).observe(root, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["placeholder", "title"],
});
};
const origAttach = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (init) {
const root = origAttach.call(this, init);
queueMicrotask(() => attachObserver(root));
return root;
};
};
// Wrap window.ResizeObserver pra capturar errors NA ORIGEM. Plotly tem
// um RO interno (vanna-components.js:113878) que dispara `_t [as relayout]`
// (kt at 22888) num node desmontado durante minimize/maximize do chat,
// e o crash propaga como `TypeError: Cannot read properties of undefined
// (reading 'width' / 'height')`. Listeners em window.error registrados
// depois do webpack-dev-server (caso CRA) não conseguem barrar o overlay
// porque o handler do dev server roda antes em capture phase. Try/catch
// dentro da própria callback garante que o error nunca escapa pra window.
// Só engole o erro específico (filename + message bate); qualquer outro
// erro continua propagando normal.
const wrapResizeObserver = () => {
if (window.__vannaResizeObserverWrapped) return;
window.__vannaResizeObserverWrapped = true;
const Original = window.ResizeObserver;
if (!Original) return;
const isPlotlyCrash = (err) => {
if (!err) return false;
const msg = (err && err.message) || String(err);
const stack = (err && err.stack) || "";
const widthHeight =
msg.indexOf("reading 'width'") !== -1 ||
msg.indexOf("reading 'height'") !== -1 ||
msg.indexOf("ResizeObserver loop") !== -1;
const fromBundle = stack.indexOf("vanna-components") !== -1;
return widthHeight && fromBundle;
};
const Wrapped = function (callback) {
const safeCallback = function (entries, observer) {
try {
callback.call(this, entries, observer);
} catch (err) {
if (!isPlotlyCrash(err)) throw err;
}
};
return new Original(safeCallback);
};
Wrapped.prototype = Original.prototype;
window.ResizeObserver = Wrapped;
};
const loadFontsOnce = () => {
if (window.__vannaFontsLoaded) return;
window.__vannaFontsLoaded = true;
const links = [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossorigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap",
},
];
for (const cfg of links) {
const link = document.createElement("link");
Object.entries(cfg).forEach(([k, v]) => link.setAttribute(k, v));
document.head.appendChild(link);
}
};
const injectBundle = (baseUrl) =>
new Promise((resolve, reject) => {
const sel = 'script[data-vanna-bundle="' + baseUrl + '"]';
const existing = document.querySelector(sel);
if (existing) {
if (existing.__loaded) return resolve();
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", () =>
reject(new Error("vanna bundle failed to load"))
);
return;
}
const script = document.createElement("script");
script.type = "module";
script.src = baseUrl + "/static/vanna-components.js";
script.dataset.vannaBundle = baseUrl;
script.onload = () => {
script.__loaded = true;
resolve();
};
script.onerror = () =>
reject(new Error("vanna bundle failed to load"));
document.head.appendChild(script);
});
window.VannaEmbed = {
ensureLoaded(config) {
if (loaderPromise) return loaderPromise;
const baseUrl = (config && config.baseUrl) || "";
const extraCss = config && config.extraCss;
loaderPromise = (async () => {
wrapResizeObserver();
setupThemePierce(baseUrl, extraCss);
setupTranslator();
loadFontsOnce();
await injectBundle(baseUrl);
await customElements.whenDefined("vanna-chat");
})();
return loaderPromise;
},
};
})();