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 07:56:06 +00:00
|
|
|
|
// 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";
|
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 07:56:35 +00:00
|
|
|
|
var CONV_KEY = "rony-chat-conv";
|
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 07:56:06 +00:00
|
|
|
|
|
|
|
|
|
|
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) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 07:56:35 +00:00
|
|
|
|
// ---- 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); });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 07:56:06 +00:00
|
|
|
|
// ---- 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();
|
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 07:56:35 +00:00
|
|
|
|
var convID = loadConvID();
|
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 07:56:06 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 07:56:35 +00:00
|
|
|
|
// 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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 07:56:06 +00:00
|
|
|
|
// ---- 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");
|
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 07:56:35 +00:00
|
|
|
|
// 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.
|
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 07:56:06 +00:00
|
|
|
|
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" },
|
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 07:56:35 +00:00
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
messages: history,
|
|
|
|
|
|
stream: true,
|
|
|
|
|
|
conversation_id: convID || undefined,
|
|
|
|
|
|
}),
|
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 07:56:06 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
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 07:56:35 +00:00
|
|
|
|
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) {
|
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 07:56:06 +00:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|