rony-chat-bot/web/chat-widget.js

397 lines
14 KiB
JavaScript
Raw Normal View History

// 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();
}
})();