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.
458 lines
No EOL
17 KiB
JavaScript
458 lines
No EOL
17 KiB
JavaScript
// 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||
}
|
||
})(); |