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
318 lines
11 KiB
JavaScript
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
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;
|
|
},
|
|
};
|
|
})();
|