rony-chat-bot/web/chat-widget.js
Victor Hugo Vargas 18e555e338 feat: persistent conversation storage (Phase 4)
Conversations survive page reloads and work for any frontend, not just
the widget. Server-side SQLite, conversation ID as bearer token, browser
identity via localStorage.

Backend
-------
- internal/portfolio/conversations.go: schema + CRUD. Conversations and
  messages tables in the same SQLite DB as the RAG index, with
  foreign-key cascade delete. Conv IDs are 16-byte random hex
  (128 bits of entropy).
- internal/portfolio/indexer.go: applies conversation schema + enables
  foreign_keys pragma in OpenStore.
- internal/server/handlers.go: POST /api/chat accepts an optional
  conversation_id, mints one if absent, persists user message before
  the LLM runs and assistant message (with sources) after the stream
  completes. New handlers: GetConversation, ListConversations,
  DeleteConversation.
- internal/server/server.go: routes for GET /api/conversations,
  GET/DELETE /api/conversations/{id}.
- internal/server/conversations_test.go: 6 tests (round-trip, continue,
  list, 404, delete, streaming).

Widget
------
- web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"],
  includes it in the chat request body, captures new IDs from the
  server's 'start' SSE event, and calls GET /api/conversations/{id} on
  load to restore history. On 404 it clears the stored ID and starts
  fresh.

Docs
----
- docs/architecture.md: §3.1 documents the conversation_id field and
  new REST endpoints; new §3.4 covers persistence lifecycle, schema,
  client responsibilities, and auth model. §5.6 updated; filetree
  reflects the new files.
- web/README.md: new 'Conversation persistence' section explains the
  browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 00:56:35 -07:00

458 lines
No EOL
17 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";
var CONV_KEY = "rony-chat-conv";
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) {}
}
// ---- Conversation persistence -------------------------------------------
// The conversation_id is a server-issued UUID-ish string. We store it in
// localStorage so the same browser keeps its thread across reloads. A
// different browser (or cleared storage) starts a fresh thread.
function loadConvID() {
try { return localStorage.getItem(CONV_KEY) || ""; } catch (e) { return ""; }
}
function saveConvID(id) {
try { localStorage.setItem(CONV_KEY, id); } catch (e) {}
}
function clearConvID() {
try { localStorage.removeItem(CONV_KEY); } catch (e) {}
}
// Restore conversation history from the server, if any. On 404 the
// stored ID is dead (e.g. server DB was wiped) — clear it and start fresh.
function restoreHistory(convID, onDone) {
fetch(cfg.apiUrl + "/api/conversations/" + encodeURIComponent(convID))
.then(function (resp) {
if (resp.status === 404) { clearConvID(); onDone(null); return null; }
if (!resp.ok) { onDone(null); return null; }
return resp.json();
})
.then(function (conv) {
if (!conv) { onDone(null); return; }
onDone(conv);
})
.catch(function () { onDone(null); });
}
// ---- 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();
var convID = loadConvID();
// ---- 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);
});
}
// Restore conversation history from the server on first load. The
// convID came from localStorage; if the server doesn't know it
// (404), we wipe it and start a fresh thread on next send.
if (convID) {
restoreHistory(convID, function (conv) {
if (conv && conv.messages) {
for (var i = 0; i < conv.messages.length; i++) {
var m = conv.messages[i];
appendMessage(m.role, m.content);
history.push({ role: m.role, content: m.content });
}
}
});
}
// ---- 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");
// Greeting only fires on a brand-new thread. If history was
// restored from the server we don't want to prepend a greeting on
// top of the user's previous messages.
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,
conversation_id: convID || undefined,
}),
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 === "start" && payload.conversation_id) {
// Server may have minted a new id; persist it.
if (payload.conversation_id !== convID) {
convID = payload.conversation_id;
saveConvID(convID);
}
} else 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();
}
})();