rony-chat-bot/web/chat-widget.js
Victor Hugo Vargas f33708534a feat: bootstrap rony-chat-bot Go module
Initial implementation of the bot:

- cmd/chat-bot: CLI entrypoint (serve, reindex, ask, version)
- internal/agent: LLM provider client + agent runner with RAG injection
- internal/config: YAML config loader (providers, RAG, persona, server)
- internal/i18n: response-language detection (EN/ES)
- internal/persona: persona system prompt assembly from YAML
- internal/portfolio: heading-based chunker + SQLite FTS5 indexer
- internal/server: chi router with /api/chat (SSE), /api/health, /api/info,
  /api/reindex, middleware (RequestID, Logging, CORS, RateLimit)
- internal/streaming: SSE protocol helpers (start, chunk, sources, done, error)
- web/: drop-in vanilla-JS chat widget (no build, no deps) + demo + README
- bench/: reproducible driver benchmark (modernc vs mattn SQLite)
- configs/portfolio-bot.yaml: llama.cpp default provider, SQLite RAG, canine persona
- docs/architecture.md / .es.md: aligned with SQLite FTS5 + llama.cpp decisions
- data/projects/README*.md: project data documentation
- README.md / .es.md: updated for current implementation

All tests pass (go test ./...). Bot is functional end-to-end with the
configured LLM provider.
2026-07-17 00:56:06 -07:00

397 lines
No EOL
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// rony-chat-widget — drop-in vanilla JS chat widget.
//
// Usage (HTML):
// <link rel="stylesheet" href="/path/to/chat-widget.css">
// <script src="/path/to/chat-widget.js"
// data-api-url="http://localhost:7331"
// data-title="Ask me anything"
// data-position="bottom-right"
// data-theme="auto"
// defer></script>
//
// All options are read from <script data-*="..."> attributes; everything is
// optional except data-api-url. The widget is self-contained: no build step,
// no runtime dependencies, no global CSS pollution.
(function () {
"use strict";
// ---- i18n -----------------------------------------------------------------
// The widget UI is bilingual. The conversation language (what the LLM
// answers in) is decided by the user query and is unaffected by this toggle.
var STRINGS = {
en: {
placeholder: "Type a message...",
send: "Send",
online: "online",
offline: "offline",
error: "error",
errorConnect: "Could not reach the server: ",
errorGeneric: "Unknown error",
ariaOpen: "Open chat",
ariaClose: "Close",
ariaLang: "Language",
ariaSend: "Send",
},
es: {
placeholder: "Escribe un mensaje...",
send: "Enviar",
online: "conectado",
offline: "desconectado",
error: "error",
errorConnect: "No se pudo conectar al servidor: ",
errorGeneric: "Error desconocido",
ariaOpen: "Abrir chat",
ariaClose: "Cerrar",
ariaLang: "Idioma",
ariaSend: "Enviar",
},
};
var LANG_KEY = "rony-chat-lang";
function pickInitialLang() {
var saved = null;
try { saved = localStorage.getItem(LANG_KEY); } catch (e) {}
if (saved === "en" || saved === "es") return saved;
var nav = (navigator.language || "en").toLowerCase();
return nav.indexOf("es") === 0 ? "es" : "en";
}
function saveLang(lang) {
try { localStorage.setItem(LANG_KEY, lang); } catch (e) {}
}
// ---- Config ----------------------------------------------------------------
function readConfig() {
var scripts = document.querySelectorAll("script[data-api-url], script[data-rony-chat]");
var tag = scripts[scripts.length - 1] || document.currentScript || {};
var ds = tag.dataset || {};
return {
apiUrl: (ds.apiUrl || "").replace(/\/+$/, ""),
title: ds.title || "Chat",
greeting: ds.greeting || "",
position: ds.position || "bottom-right",
theme: ds.theme || "auto",
};
}
// ---- Tiny markdown subset (no dependency) ----------------------------------
// Covers: **bold**, *italic*, `code`, ```fences```, lists, [links], paragraphs.
function escapeHTML(s) {
return s.replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function renderMarkdown(src) {
var fences = [];
src = src.replace(/```([\s\S]*?)```/g, function (_, code) {
fences.push(code.replace(/^\n/, ""));
return "\u0000F" + (fences.length - 1) + "\u0000";
});
var inlines = [];
src = src.replace(/`([^`\n]+)`/g, function (_, code) {
inlines.push(code);
return "\u0000I" + (inlines.length - 1) + "\u0000";
});
src = escapeHTML(src);
src = src.replace(/\u0000I(\d+)\u0000/g, function (_, i) {
return "<code>" + escapeHTML(inlines[+i]) + "</code>";
});
src = src.replace(/\u0000F(\d+)\u0000/g, function (_, i) {
return "<pre><code>" + escapeHTML(fences[+i]) + "</code></pre>";
});
src = src.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
src = src.replace(/\*([^*]+)\*/g, "<em>$1</em>");
src = src.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, t, u) {
var safe = /^(https?:|mailto:|#|\/)/i.test(u) ? u : "#";
return '<a href="' + safe + '" target="_blank" rel="noopener noreferrer">' + t + "</a>";
});
src = src.replace(/(^|\n)((?:[-*] |\d+\. ).+(?:\n(?:[-*] |\d+\. ).+)*)/g, function (m, lead, block) {
var lines = block.split("\n");
var isOrdered = /^\d+\. /.test(lines[0]);
var tag = isOrdered ? "ol" : "ul";
var items = lines.map(function (l) {
return "<li>" + l.replace(/^[-*] |\d+\. /, "") + "</li>";
}).join("");
return lead + "<" + tag + ">" + items + "</" + tag + ">";
});
src = src
.split(/\n{2,}/)
.map(function (p) {
if (/^\s*<(pre|ul|ol|h\d|blockquote)/.test(p)) return p;
return "<p>" + p.replace(/\n/g, "<br>") + "</p>";
})
.join("\n");
return src;
}
// ---- SSE parsing -----------------------------------------------------------
function readSSE(response, onEvent, signal) {
var reader = response.body.getReader();
var decoder = new TextDecoder("utf-8");
var buffer = "";
var aborted = false;
if (signal) {
signal.addEventListener("abort", function () {
aborted = true;
try { reader.cancel(); } catch (e) {}
});
}
function pump() {
if (aborted) return;
return reader.read().then(function (r) {
if (r.done) return;
buffer += decoder.decode(r.value, { stream: true });
var idx;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
var raw = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
var ev = { event: "message", data: "" };
raw.split("\n").forEach(function (line) {
if (line.indexOf("event: ") === 0) ev.event = line.slice(7).trim();
else if (line.indexOf("data: ") === 0) ev.data += (ev.data ? "\n" : "") + line.slice(6);
});
if (ev.data) onEvent(ev.event, ev.data);
}
return pump();
});
}
return pump();
}
// ---- Widget construction --------------------------------------------------
function buildWidget(cfg) {
var root = document.createElement("div");
root.className = "rony-chat-widget-root";
root.setAttribute("data-position", cfg.position);
root.setAttribute("data-theme", cfg.theme);
root.setAttribute("data-open", "false");
root.innerHTML = [
'<div class="rony-chat-widget-panel" role="dialog" aria-label="' + escapeHTML(cfg.title) + '">',
' <div class="rony-chat-widget-header">',
' <span class="rony-chat-widget-title">' + escapeHTML(cfg.title) + '</span>',
' <div class="rony-chat-widget-lang" role="group" aria-label="Language">',
' <button type="button" data-lang-btn="en" aria-pressed="false">EN</button>',
' <button type="button" data-lang-btn="es" aria-pressed="false">ES</button>',
' </div>',
' <span class="rony-chat-widget-status" data-status>online</span>',
' <button class="rony-chat-widget-close" aria-label="Close" data-close>×</button>',
' </div>',
' <div class="rony-chat-widget-messages" data-messages></div>',
' <form class="rony-chat-widget-form" data-form>',
' <textarea class="rony-chat-widget-input" data-input rows="1" placeholder="Type a message..."></textarea>',
' <button class="rony-chat-widget-send" type="submit" data-send>Send</button>',
' </form>',
'</div>',
'<button class="rony-chat-widget-bubble" aria-label="Open chat" data-bubble>',
' <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>',
'</button>',
].join("\n");
return root;
}
function init() {
var cfg = readConfig();
if (!cfg.apiUrl) {
console.error("[rony-chat-widget] missing data-api-url on <script> tag");
return;
}
var widget = buildWidget(cfg);
document.body.appendChild(widget);
var $messages = widget.querySelector("[data-messages]");
var $input = widget.querySelector("[data-input]");
var $form = widget.querySelector("[data-form]");
var $send = widget.querySelector("[data-send]");
var $bubble = widget.querySelector("[data-bubble]");
var $close = widget.querySelector("[data-close]");
var $status = widget.querySelector("[data-status]");
var $langBtns = widget.querySelectorAll("[data-lang-btn]");
var $title = widget.querySelector(".rony-chat-widget-title");
var $panel = widget.querySelector(".rony-chat-widget-panel");
var $langGroup = widget.querySelector(".rony-chat-widget-lang");
var history = [];
var busy = false;
var abortCtrl = null;
var lang = pickInitialLang();
// ---- Language handling -----------------------------------------------
function applyLang(next) {
lang = next;
saveLang(next);
var t = STRINGS[next];
$input.placeholder = t.placeholder;
$send.textContent = t.send;
$send.setAttribute("aria-label", t.ariaSend);
$close.setAttribute("aria-label", t.ariaClose);
$bubble.setAttribute("aria-label", t.ariaOpen);
$langGroup.setAttribute("aria-label", t.ariaLang);
$panel.setAttribute("lang", next);
widget.setAttribute("data-lang", next);
for (var i = 0; i < $langBtns.length; i++) {
var active = $langBtns[i].getAttribute("data-lang-btn") === next;
$langBtns[i].setAttribute("aria-pressed", active ? "true" : "false");
$langBtns[i].classList.toggle("is-active", active);
}
// Refresh the visible status text with the new language
$status.textContent = t[lastStatusKey] || t.online;
}
applyLang(lang);
for (var j = 0; j < $langBtns.length; j++) {
$langBtns[j].addEventListener("click", function (e) {
var next = e.currentTarget.getAttribute("data-lang-btn");
if (next && next !== lang) applyLang(next);
});
}
// ---- Chat behavior --------------------------------------------------
var lastStatusKey = "online";
function setStatus(key) {
lastStatusKey = key;
$status.textContent = STRINGS[lang][key] || key;
}
function setOpen(open) {
widget.setAttribute("data-open", open ? "true" : "false");
if (open && !history.length && cfg.greeting) {
appendMessage("assistant", cfg.greeting);
history.push({ role: "assistant", content: cfg.greeting });
}
if (open) $input.focus();
}
function appendMessage(role, content) {
var div = document.createElement("div");
div.className = "rony-chat-widget-msg rony-chat-widget-msg-" + role;
if (role === "assistant") {
div.innerHTML = renderMarkdown(content);
} else {
div.textContent = content;
}
$messages.appendChild(div);
$messages.scrollTop = $messages.scrollHeight;
return div;
}
function appendError(msg) {
var div = document.createElement("div");
div.className = "rony-chat-widget-msg rony-chat-widget-msg-error";
div.textContent = msg;
$messages.appendChild(div);
$messages.scrollTop = $messages.scrollHeight;
}
function send(userText) {
if (busy || !userText.trim()) return;
busy = true;
$send.disabled = true;
$input.value = "";
autoSize();
history.push({ role: "user", content: userText });
appendMessage("user", userText);
var assistantDiv = appendMessage("assistant", "");
var caret = document.createElement("span");
caret.className = "rony-chat-widget-caret";
assistantDiv.appendChild(caret);
abortCtrl = new AbortController();
var collectedSources = [];
fetch(cfg.apiUrl + "/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history, stream: true }),
signal: abortCtrl.signal,
}).then(function (resp) {
if (!resp.ok) {
throw new Error("HTTP " + resp.status);
}
return readSSE(resp, function (type, data) {
try {
var payload = JSON.parse(data);
} catch (e) {
return;
}
if (type === "chunk" && payload.content) {
assistantDiv.insertBefore(document.createTextNode(payload.content), caret);
$messages.scrollTop = $messages.scrollHeight;
} else if (type === "sources" && Array.isArray(payload.documents)) {
collectedSources = payload.documents;
} else if (type === "done") {
var finalText = assistantDiv.textContent.replace(/\s+$/, "");
history.push({ role: "assistant", content: finalText });
if (caret.parentNode) caret.parentNode.removeChild(caret);
assistantDiv.innerHTML = renderMarkdown(finalText);
if (collectedSources.length) {
var row = document.createElement("div");
row.className = "rony-chat-widget-sources";
collectedSources.forEach(function (s) {
var chip = document.createElement("span");
chip.className = "rony-chat-widget-source";
chip.textContent = s;
row.appendChild(chip);
});
assistantDiv.appendChild(row);
}
setStatus("online");
} else if (type === "error") {
if (caret.parentNode) caret.parentNode.removeChild(caret);
appendError(payload.error || STRINGS[lang].errorGeneric);
setStatus("error");
}
}, abortCtrl.signal);
}).catch(function (err) {
if (err.name === "AbortError") return;
if (caret.parentNode) caret.parentNode.removeChild(caret);
appendError(STRINGS[lang].errorConnect + err.message);
setStatus("offline");
}).then(function () {
busy = false;
$send.disabled = false;
abortCtrl = null;
});
}
function autoSize() {
$input.style.height = "auto";
$input.style.height = Math.min($input.scrollHeight, 100) + "px";
}
$bubble.addEventListener("click", function () { setOpen(true); });
$close.addEventListener("click", function () { setOpen(false); });
$input.addEventListener("input", autoSize);
$input.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
$form.requestSubmit();
}
});
$form.addEventListener("submit", function (e) {
e.preventDefault();
send($input.value);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();