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.
|
||
|---|---|---|
| .. | ||
| chat-widget.css | ||
| chat-widget.js | ||
| example.html | ||
| README.md | ||
rony-chat-widget
A drop-in vanilla-JS chat widget that talks to the Rony Chat Bot backend over Server-Sent Events. No build step, no runtime dependencies, no global CSS pollution.
Files
| File | Purpose |
|---|---|
chat-widget.js |
The widget. Self-contained ~12 KB. |
chat-widget.css |
Scoped styles, themable via CSS custom properties. |
example.html |
Standalone demo page (use with python3 -m http.server). |
Quick start (any site)
<link rel="stylesheet" href="/path/to/chat-widget.css">
<script src="/path/to/chat-widget.js"
data-api-url="https://your-chatbot.example.com"
data-title="Ask me anything"
data-greeting="Hi! Ask me about the projects."
data-position="bottom-right"
data-theme="auto"
defer></script>
The bubble appears bottom-right (or bottom-left), opens a 380×560 panel, and talks to data-api-url/api/chat over SSE.
Configuration (all via data-* attributes on the <script> tag)
| Attribute | Default | Notes |
|---|---|---|
data-api-url |
(required) | Base URL of the chat-bot, e.g. https://chat.example.com. No trailing slash. |
data-title |
"Chat" |
Header text. |
data-greeting |
"" |
First message shown when the panel opens (no greeting if empty). |
data-position |
"bottom-right" |
"bottom-right" or "bottom-left". |
data-theme |
"auto" |
"auto" (follows prefers-color-scheme), "light", or "dark". |
Language
The widget UI is bilingual (English / Spanish) with a toggle in the header.
- Initial language:
localStorage["rony-chat-lang"]if set, else detected fromnavigator.language(anything starting withes→ Spanish, else English). - Persisted across page reloads via
localStorage. - Conversation language is independent: the bot auto-detects the language of each user message and replies in that language. The toggle only changes the interface (placeholder, status, errors, send button).
- No build step: strings live in a
STRINGSobject at the top ofchat-widget.js. Add a new language by adding an entry.
To override the initial language (e.g., force English on a Spanish site):
<script>
localStorage.setItem("rony-chat-lang", "en");
</script>
<script src="chat-widget.js" data-api-url="..." defer></script>
Theming (override without forking)
All visual tokens are CSS custom properties on the root element. Set them in your site's stylesheet:
.rony-chat-widget-root {
--rony-accent: #ff6b35; /* bubble + send button + links */
--rony-radius: 4px; /* tighter corners */
--rony-font: "Inter", sans-serif;
}
See the full list in chat-widget.css (search for --rony-).
Astro integration
The simplest path is the drop-in. Add this to your Layout.astro (or any shared layout):
---
// src/layouts/ChatLayout.astro
import "../path/to/chat-widget.css";
const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331";
---
<html>
<head>
<head><slot name="head" /></head>
</head>
<body>
<slot />
<script src="/path/to/chat-widget.js"
data-api-url={apiUrl}
data-title="Ask me anything"
data-position="bottom-right"
data-theme="auto"
defer is:inline></script>
</body>
</html>
Notes:
is:inlinekeeps Astro from hashing/transforming the script tag, so thedata-*attributes survive.PUBLIC_CHAT_API_URLis an Astro env var; set it in.envper environment.- The bot's
cors_originsin YAML must include your Astro dev origin (http://localhost:4321).
React/Next.js
Mount the same script tag in your root layout:
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }) {
return (
<html>
<head>
<link rel="stylesheet" href="/chat-widget.css" />
<Script src="/chat-widget.js"
data-api-url={process.env.NEXT_PUBLIC_CHAT_API_URL}
data-title="Ask me anything"
data-position="bottom-right"
data-theme="auto"
strategy="afterInteractive" />
</head>
<body>{children}</body>
</html>
);
}
Backend requirements
The widget expects the bot to:
- Expose
POST /api/chataccepting{ messages, stream, conversation_id? }(seedocs/architecture.md§3.1). - Stream SSE events:
start(withconversation_id),chunk,sources,done,error(seedocs/architecture.md§3.2). - Expose
GET /api/conversations/{id}for history restore (returns 404 if unknown). - Allow the page's origin via
cors_originsin the bot's config.
Running the example locally
# 1. Start the bot
./bin/chat-bot serve
# 2. Serve the widget (in another terminal)
cd web
python3 -m http.server 8000
# 3. Open http://localhost:8000/example.html in a browser
Note:
localhost:8000must be in the bot'scors_originsfor the demo to work. The default config already includes it.
Browser support
Modern browsers (Chrome/Edge 90+, Firefox 90+, Safari 15+). Uses:
fetch+ReadableStream(for SSE)AbortController- CSS custom properties +
prefers-color-scheme
No polyfills, no transpilation.
Conversation persistence
The bot persists conversations on the server side (SQLite, see
docs/architecture.md §3.4). The widget handles the client side
automatically:
- First message — the server mints a new
conversation_idand returns it in thestartSSE event. The widget saves it tolocalStorage["rony-chat-conv"]. - Subsequent messages — the widget sends the saved ID with every request, so the server keeps appending to the same thread.
- Page reload — on load, the widget reads the stored ID and calls
GET /api/conversations/{id}to restore the full history. - Server lost the conversation (e.g. DB was wiped) — the GET returns
404. The widget clears
localStorageand starts a fresh thread on the next message.
Browser-scoped: localStorage is per-origin, so the same browser
keeps the thread across visits, but a different browser starts fresh.
Clearing site data resets the conversation.
Server-scoped across devices: not automatic. The conversation lives
in the SQLite DB but only the browser that created it knows its ID. If
you want cross-device continuity, persist the ID in your user profile
(e.g. after login) and pass it on initial load instead of relying on
localStorage. The backend already supports this — see
docs/architecture.md §3.4 for the protocol.
To opt out (start a fresh conversation on every page load):
<script>
localStorage.removeItem("rony-chat-conv");
</script>
<script src="chat-widget.js" data-api-url="..." defer></script>
Or expose a "new chat" button in your UI that calls
DELETE /api/conversations/{id} then clears the localStorage key.
What's not in the widget (yet)
- Markdown images / tables — the renderer handles paragraphs, lists, code, links, bold/italic. Tables and images render as raw text. For richer output, swap
renderMarkdownformarkedormarkdown-it. - Typing indicators beyond the streaming caret — the caret at the end of the streaming response is the only indicator. Good enough for short answers.
- Mobile sheet drag-to-dismiss — the panel goes full-screen on phones, but can't be swiped away. Add a swipe handler if it matters.
- Conversation history sidebar — only the active conversation is shown in the panel. The backend exposes
GET /api/conversationsfor a future sidebar.