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.
This commit is contained in:
Victor Hugo Vargas 2026-07-17 00:56:35 -07:00
parent f42bd37eae
commit 18e555e338
8 changed files with 864 additions and 40 deletions

View file

@ -105,13 +105,20 @@ The bot responds with accurate information extracted from the projects' markdown
"messages": [ "messages": [
{"role": "user", "content": "What projects does Victor have?"} {"role": "user", "content": "What projects does Victor have?"}
], ],
"stream": true "stream": true,
"conversation_id": "57f4aa3c7fab466bc4de9c43b296903e"
} }
``` ```
| Field | Required | Notes |
|---|---|---|
| `messages` | yes | At least one user message; alternation is not enforced. |
| `stream` | no, default `true` | `false` returns a single JSON body instead of SSE. |
| `conversation_id` | no | Hex string. If omitted, the server mints a new one and returns it (see below). Pass an existing ID to keep the thread. |
**Response (SSE):** **Response (SSE):**
``` ```
data: {"type":"start","conversation_id":"abc123"} data: {"type":"start","conversation_id":"57f4aa3c7fab466bc4de9c43b296903e"}
data: {"type":"chunk","content":"Victor"} data: {"type":"chunk","content":"Victor"}
data: {"type":"chunk","content":" has"} data: {"type":"chunk","content":" has"}
@ -123,15 +130,73 @@ data: {"type":"sources","documents":["rony-harness.md","rony-llm-agent.md"]}
data: {"type":"done","usage":{"input_tokens":245,"output_tokens":38}} data: {"type":"done","usage":{"input_tokens":245,"output_tokens":38}}
``` ```
The `conversation_id` in the `start` event is what the client should store
(see §3.4 — *Conversation persistence*). When the client passed an
existing ID the server echoes it back; otherwise it's freshly minted.
**Without streaming** (`"stream": false`): **Without streaming** (`"stream": false`):
```json ```json
{ {
"conversation_id": "57f4aa3c7fab466bc4de9c43b296903e",
"content": "Victor has several projects...", "content": "Victor has several projects...",
"sources": ["rony-harness.md", "rony-llm-agent.md"], "sources": ["rony-harness.md", "rony-llm-agent.md"],
"usage": {"input_tokens": 245, "output_tokens": 38} "usage": {"input_tokens": 245, "output_tokens": 38}
} }
``` ```
#### `GET /api/conversations` — List recent conversations
Returns the most recent conversation summaries, newest first. Useful for a
"show my chats" sidebar in a custom UI.
**Query params:**
- `limit` (1200, default 50)
**Response:**
```json
{
"count": 2,
"conversations": [
{
"id": "57f4aa3c7fab466bc4de9c43b296903e",
"created_at": "2026-07-17T05:02:07Z",
"updated_at": "2026-07-17T05:04:31Z",
"preview": "What projects does Victor have?"
}
]
}
```
#### `GET /api/conversations/{id}` — Fetch one conversation
Returns the full history of a conversation with all messages in
chronological order.
**Response (200):**
```json
{
"id": "57f4aa3c7fab466bc4de9c43b296903e",
"created_at": "2026-07-17T05:02:07Z",
"updated_at": "2026-07-17T05:04:31Z",
"messages": [
{"id": 1, "role": "user", "content": "What projects does Victor have?", "created_at": "..."},
{"id": 2, "role": "assistant", "content": "Victor has several projects...", "sources": ["..."], "created_at": "..."}
]
}
```
**Response (404):** when the ID is unknown (e.g. server DB was wiped or the
client lost sync). The widget treats this as "start fresh".
> ⚠️ **Auth note:** the conversation ID is the only access token. For a
> public bot this is fine; for private contexts add auth at the proxy layer
> (e.g. require a session cookie before forwarding to this endpoint).
#### `DELETE /api/conversations/{id}` — Delete a conversation
Removes the conversation and all its messages (cascade). Returns 204 on
success, 404 if the ID doesn't exist.
#### `POST /api/reindex` — Re-index portfolio #### `POST /api/reindex` — Re-index portfolio
Useful when files in `data/projects/` are modified. Useful when files in `data/projects/` are modified.
@ -341,6 +406,62 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler {
} }
``` ```
### 3.4 Conversation persistence
The bot persists conversation threads in the same SQLite database as the
RAG index (`./data/portfolio.db`). Schema lives in `internal/portfolio/conversations.go`.
```sql
CREATE TABLE conversations (
id TEXT PRIMARY KEY, -- 16-byte random hex (32 chars)
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL, -- user | assistant | system
content TEXT NOT NULL,
sources TEXT, -- JSON array, nullable
created_at INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX idx_messages_conv ON messages(conversation_id, id);
```
**Lifecycle:**
| When | What |
|---|---|
| `POST /api/chat` (no `conversation_id`) | Server mints a new hex ID, returns it in the `start` SSE event (or `conversation_id` field of the JSON response) |
| `POST /api/chat` (with `conversation_id`) | Server reuses the existing row; both user message and assistant reply are appended |
| User message | Persisted **before** the LLM runs, so it survives a model failure |
| Assistant message | Persisted **after** the stream completes, with the RAG sources attached |
| `GET /api/conversations/{id}` | Returns the full thread; 404 if unknown |
| `DELETE /api/conversations/{id}` | Cascade-deletes messages |
**Client responsibilities:**
1. On the first message, omit `conversation_id`. Capture the one the server
returns in the `start` SSE event.
2. Store it client-side (`localStorage["rony-chat-conv"]` in the widget).
3. On every subsequent message, send the ID back.
4. On page load, if you have a stored ID, call `GET /api/conversations/{id}`
to restore the thread. If 404, clear the stored ID and start fresh.
The widget (`web/chat-widget.js`) implements all four steps. Any other
client (a custom React component, an Astro endpoint, a CLI replay tool)
follows the same protocol.
**Auth model:**
The conversation ID is the only access token for `GET /api/conversations/{id}`.
It is 128 bits of random entropy, so guessing one is infeasible. For a
public portfolio bot this is the right trade-off — anyone who knows the
URL can read its history. For private contexts, add an auth layer in front
of the bot (proxy) that gates the conversation endpoints.
--- ---
## 🧠 4. RAG (Retrieval-Augmented Generation) ## 🧠 4. RAG (Retrieval-Augmented Generation)
@ -764,10 +885,9 @@ Theming is via CSS custom properties on `.rony-chat-widget-root` (see `web/chat-
### 5.6 What the widget doesn't do (yet) ### 5.6 What the widget doesn't do (yet)
- **Conversation persistence** — each visit is a fresh conversation. Bot is stateless.
- **Richer markdown** (tables, images) — the built-in renderer handles the common cases; for full CommonMark, swap `renderMarkdown` in `chat-widget.js` for `marked` or `markdown-it`. - **Richer markdown** (tables, images) — the built-in renderer handles the common cases; for full CommonMark, swap `renderMarkdown` in `chat-widget.js` for `marked` or `markdown-it`.
- **Mobile swipe-to-dismiss** — panel goes full-screen on phones. - **Mobile swipe-to-dismiss** — panel goes full-screen on phones.
- **Conversation history sidebar** — only the active conversation is shown. - **Conversation history sidebar** — only the active conversation is shown (the backend exposes `GET /api/conversations` for a future sidebar).
--- ---
@ -1089,16 +1209,18 @@ rony-chat-bot/
├── internal/ ├── internal/
│ ├── server/ # HTTP handlers │ ├── server/ # HTTP handlers
│ │ ├── server.go # chi router + middleware │ │ ├── server.go # chi router + middleware
│ │ ├── handlers.go # /api/chat, /api/health, /api/info, /api/reindex │ │ ├── handlers.go # /api/chat, /api/health, /api/info, /api/reindex, /api/conversations
│ │ ├── conversations_test.go # round-trip, continue, list, 404, delete, streaming
│ │ └── middleware.go # RequestID, Logging, CORS, RateLimit │ │ └── middleware.go # RequestID, Logging, CORS, RateLimit
│ │ │ │
│ ├── agent/ # LLM client + RAG runner │ ├── agent/ # LLM client + RAG runner
│ │ ├── runner.go # Stream wrapper, RAG injection into system prompt │ │ ├── runner.go # Stream wrapper, RAG injection into system prompt
│ │ └── client.go # NewClient factory: llamacpp / ollama / openai / anthropic │ │ └── client.go # NewClient factory: llamacpp / ollama / openai / anthropic
│ │ │ │
│ ├── portfolio/ # RAG: markdown → SQLite FTS5 │ ├── portfolio/ # RAG: markdown → SQLite FTS5 + conversation persistence
│ │ ├── chunker.go # Heading-based splitter │ │ ├── chunker.go # Heading-based splitter
│ │ ├── indexer.go # Store: schema, Reindex, Search (BM25) │ │ ├── indexer.go # Store: schema, Reindex, Search (BM25)
│ │ ├── conversations.go # Conversation + Message CRUD, persisted alongside RAG
│ │ └── chunker_test.go / store_test.go │ │ └── chunker_test.go / store_test.go
│ │ │ │
│ ├── persona/ # Persona bridge to rony-llm-agent │ ├── persona/ # Persona bridge to rony-llm-agent

View file

@ -0,0 +1,231 @@
package portfolio
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
)
// Conversation is a thread of messages between one user and the bot.
type Conversation struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Messages []Message `json:"messages"`
}
// Message is a single turn in a conversation.
type Message struct {
ID int64 `json:"id"`
Role string `json:"role"` // "user" | "assistant" | "system"
Content string `json:"content"`
Sources []string `json:"sources,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ConversationSummary is the lightweight listing shape (no messages).
type ConversationSummary struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Preview string `json:"preview"` // first ~80 chars of the first user message
}
const conversationSchema = `
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
sources TEXT, -- JSON array, nullable
created_at INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, id);
`
// ErrConversationNotFound is returned when a conversation ID doesn't exist.
var ErrConversationNotFound = errors.New("conversation not found")
// CreateConversation makes a new empty conversation and returns its ID.
// The ID is a UUID-ish hex string (crypto/rand based) — unguessable in
// practice, so it can serve as the access token for the GET endpoint.
func (s *Store) CreateConversation(ctx context.Context) (string, error) {
id, err := newConvID()
if err != nil {
return "", err
}
now := time.Now().Unix()
if _, err := s.db.ExecContext(ctx,
`INSERT INTO conversations (id, created_at, updated_at) VALUES (?, ?, ?)`,
id, now, now); err != nil {
return "", fmt.Errorf("create conversation: %w", err)
}
return id, nil
}
// TouchConversation updates the updated_at timestamp. Called after every
// message so ListConversations can sort by recency.
func (s *Store) TouchConversation(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE conversations SET updated_at = ? WHERE id = ?`,
time.Now().Unix(), id)
return err
}
// SaveMessage appends a message to a conversation and bumps updated_at.
// The conversation must exist (use CreateConversation first or pass an
// existing ID). Sources may be nil.
func (s *Store) SaveMessage(ctx context.Context, convID, role, content string, sources []string) error {
if convID == "" {
return errors.New("convID required")
}
var sourcesJSON sql.NullString
if len(sources) > 0 {
b, err := json.Marshal(sources)
if err != nil {
return fmt.Errorf("marshal sources: %w", err)
}
sourcesJSON = sql.NullString{String: string(b), Valid: true}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO messages (conversation_id, role, content, sources, created_at) VALUES (?, ?, ?, ?, ?)`,
convID, role, content, sourcesJSON, time.Now().Unix()); err != nil {
return fmt.Errorf("insert message: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE conversations SET updated_at = ? WHERE id = ?`,
time.Now().Unix(), convID); err != nil {
return fmt.Errorf("touch conversation: %w", err)
}
return tx.Commit()
}
// GetConversation returns a conversation with all its messages in
// chronological order. Returns ErrConversationNotFound if the ID is unknown.
func (s *Store) GetConversation(ctx context.Context, id string) (*Conversation, error) {
var c Conversation
var createdUnix, updatedUnix int64
err := s.db.QueryRowContext(ctx,
`SELECT id, created_at, updated_at FROM conversations WHERE id = ?`, id,
).Scan(&c.ID, &createdUnix, &updatedUnix)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrConversationNotFound
}
if err != nil {
return nil, fmt.Errorf("select conversation: %w", err)
}
c.CreatedAt = time.Unix(createdUnix, 0).UTC()
c.UpdatedAt = time.Unix(updatedUnix, 0).UTC()
rows, err := s.db.QueryContext(ctx,
`SELECT id, role, content, sources, created_at FROM messages WHERE conversation_id = ? ORDER BY id ASC`, id)
if err != nil {
return nil, fmt.Errorf("select messages: %w", err)
}
defer rows.Close()
for rows.Next() {
var m Message
var sourcesStr sql.NullString
var createdUnix int64
if err := rows.Scan(&m.ID, &m.Role, &m.Content, &sourcesStr, &createdUnix); err != nil {
return nil, err
}
if sourcesStr.Valid {
if err := json.Unmarshal([]byte(sourcesStr.String), &m.Sources); err != nil {
return nil, fmt.Errorf("unmarshal sources: %w", err)
}
}
m.CreatedAt = time.Unix(createdUnix, 0).UTC()
c.Messages = append(c.Messages, m)
}
return &c, rows.Err()
}
// ListConversations returns the most recent conversations, newest first.
// Useful for a "show my chats" UI. Limit caps the result; pass 0 for default
// (50). Each entry includes a short preview from the first user message.
func (s *Store) ListConversations(ctx context.Context, limit int) ([]ConversationSummary, error) {
if limit <= 0 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `
SELECT c.id, c.created_at, c.updated_at,
(SELECT content FROM messages m
WHERE m.conversation_id = c.id AND m.role = 'user'
ORDER BY m.id ASC LIMIT 1) AS preview
FROM conversations c
ORDER BY c.updated_at DESC
LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list conversations: %w", err)
}
defer rows.Close()
var out []ConversationSummary
for rows.Next() {
var cs ConversationSummary
var createdUnix, updatedUnix int64
var preview sql.NullString
if err := rows.Scan(&cs.ID, &createdUnix, &updatedUnix, &preview); err != nil {
return nil, err
}
cs.CreatedAt = time.Unix(createdUnix, 0).UTC()
cs.UpdatedAt = time.Unix(updatedUnix, 0).UTC()
if preview.Valid {
cs.Preview = truncateRunes(preview.String, 80)
}
out = append(out, cs)
}
return out, rows.Err()
}
// DeleteConversation removes a conversation and all its messages (cascade).
// Returns ErrConversationNotFound if the ID didn't exist.
func (s *Store) DeleteConversation(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM conversations WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrConversationNotFound
}
return nil
}
func truncateRunes(s string, n int) string {
if len([]rune(s)) <= n {
return s
}
r := []rune(s)
return string(r[:n]) + "…"
}
// newConvID returns a 16-byte random hex string (32 chars). Unguessable
// in practice; doubles as the access token for GET /api/conversations/:id.
func newConvID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
return hex.EncodeToString(b[:]), nil
}

View file

@ -53,7 +53,7 @@ func OpenStore(dbPath string) (*Store, error) {
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err) return nil, fmt.Errorf("create db dir: %w", err)
} }
dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn) db, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err) return nil, fmt.Errorf("open sqlite: %w", err)
@ -63,6 +63,10 @@ func OpenStore(dbPath string) (*Store, error) {
_ = db.Close() _ = db.Close()
return nil, fmt.Errorf("create schema: %w", err) return nil, fmt.Errorf("create schema: %w", err)
} }
if _, err := db.ExecContext(context.Background(), conversationSchema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("create conversation schema: %w", err)
}
return &Store{db: db, chunkSize: 500}, nil return &Store{db: db, chunkSize: 500}, nil
} }

View file

@ -0,0 +1,214 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
)
func TestConversationRoundTrip(t *testing.T) {
url, _ := newTestServer(t)
// 1) POST /api/chat with a brand-new session — server creates a conv.
body := strings.NewReader(`{
"messages":[{"role":"user","content":"hola, humano"}],
"stream":false
}`)
resp, err := http.Post(url+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var cr ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
t.Fatal(err)
}
if cr.ConversationID == "" {
t.Fatal("response should include a conversation_id")
}
if cr.Content == "" {
t.Error("response content is empty")
}
// 2) GET /api/conversations/:id — should return both user + assistant messages.
resp2, err := http.Get(url + "/api/conversations/" + cr.ConversationID)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != 200 {
t.Fatalf("get conversation status = %d", resp2.StatusCode)
}
var conv portfolio.Conversation
if err := json.NewDecoder(resp2.Body).Decode(&conv); err != nil {
t.Fatal(err)
}
if conv.ID != cr.ConversationID {
t.Errorf("id = %q, want %q", conv.ID, cr.ConversationID)
}
if len(conv.Messages) < 2 {
t.Fatalf("expected >= 2 messages, got %d", len(conv.Messages))
}
if conv.Messages[0].Role != "user" || conv.Messages[0].Content != "hola, humano" {
t.Errorf("first message = %+v", conv.Messages[0])
}
if conv.Messages[1].Role != "assistant" {
t.Errorf("second message role = %q, want assistant", conv.Messages[1].Role)
}
if conv.Messages[1].Content == "" {
t.Error("assistant message content is empty")
}
// Created + updated timestamps should be set
if conv.CreatedAt.IsZero() {
t.Error("created_at is zero")
}
if conv.UpdatedAt.Before(conv.CreatedAt) {
t.Errorf("updated_at (%s) < created_at (%s)", conv.UpdatedAt, conv.CreatedAt)
}
}
func TestConversationContinue(t *testing.T) {
url, _ := newTestServer(t)
// First turn: create the conversation.
body := strings.NewReader(`{
"messages":[{"role":"user","content":"primera"}],
"stream":false
}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
var cr1 ChatResponse
_ = json.NewDecoder(resp.Body).Decode(&cr1)
resp.Body.Close()
// Second turn: pass the same conversation_id and add a new user message.
body2 := strings.NewReader(`{
"messages":[{"role":"user","content":"primera"},
{"role":"assistant","content":"respuesta 1"},
{"role":"user","content":"segunda"}],
"conversation_id":"` + cr1.ConversationID + `",
"stream":false
}`)
resp2, _ := http.Post(url+"/api/chat", "application/json", body2)
var cr2 ChatResponse
_ = json.NewDecoder(resp2.Body).Decode(&cr2)
resp2.Body.Close()
if cr2.ConversationID != cr1.ConversationID {
t.Errorf("server changed the conversation id: %q → %q", cr1.ConversationID, cr2.ConversationID)
}
// GET should now have 4 messages: user1, assistant1, user2, assistant2.
resp3, _ := http.Get(url + "/api/conversations/" + cr1.ConversationID)
var conv portfolio.Conversation
_ = json.NewDecoder(resp3.Body).Decode(&conv)
resp3.Body.Close()
if len(conv.Messages) != 4 {
t.Errorf("expected 4 messages, got %d", len(conv.Messages))
}
}
func TestListConversations(t *testing.T) {
url, _ := newTestServer(t)
// Create two conversations.
for _, msg := range []string{"primera conversación", "segunda conversación"} {
body := strings.NewReader(`{"messages":[{"role":"user","content":"` + msg + `"}],"stream":false}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
resp.Body.Close()
}
resp, err := http.Get(url + "/api/conversations")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
var list struct {
Count int `json:"count"`
Conversations []portfolio.ConversationSummary `json:"conversations"`
}
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if list.Count < 2 {
t.Errorf("count = %d, want >= 2", list.Count)
}
}
func TestGetConversationNotFound(t *testing.T) {
url, _ := newTestServer(t)
resp, err := http.Get(url + "/api/conversations/nonexistent")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 404 {
t.Errorf("status = %d, want 404", resp.StatusCode)
}
}
func TestDeleteConversation(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"to be deleted"}],"stream":false}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
var cr ChatResponse
_ = json.NewDecoder(resp.Body).Decode(&cr)
resp.Body.Close()
del, err := http.NewRequest("DELETE", url+"/api/conversations/"+cr.ConversationID, nil)
if err != nil {
t.Fatal(err)
}
delResp, err := http.DefaultClient.Do(del)
if err != nil {
t.Fatal(err)
}
delResp.Body.Close()
if delResp.StatusCode != 204 {
t.Errorf("delete status = %d, want 204", delResp.StatusCode)
}
// GET should now 404.
get, _ := http.Get(url + "/api/conversations/" + cr.ConversationID)
defer get.Body.Close()
if get.StatusCode != 404 {
t.Errorf("get after delete status = %d, want 404", get.StatusCode)
}
}
func TestChatStreamingIncludesConversationID(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"stream test"}],"stream":true}`)
resp, err := http.Post(url+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
events := parseSSE(t, resp.Body)
if len(events) == 0 {
t.Fatal("no SSE events received")
}
if events[0].event != "start" {
t.Fatalf("first event = %q, want start", events[0].event)
}
var start map[string]any
if err := json.Unmarshal([]byte(events[0].data), &start); err != nil {
t.Fatal(err)
}
if start["conversation_id"] == "" || start["conversation_id"] == nil {
t.Error("start event missing conversation_id")
}
}
// keep the import used
var _ = time.Second

View file

@ -1,13 +1,13 @@
package server package server
import ( import (
"crypto/rand" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@ -31,6 +31,10 @@ func NewHandlers(cfg *config.Config, runner *agent.Runner, store *portfolio.Stor
type ChatRequest struct { type ChatRequest struct {
Messages []ChatMessage `json:"messages"` Messages []ChatMessage `json:"messages"`
Stream *bool `json:"stream,omitempty"` Stream *bool `json:"stream,omitempty"`
// ConversationID is optional. If empty, the server creates a new
// conversation and returns its ID in the response (or in the SSE
// `start` event). Pass an existing ID to continue a previous thread.
ConversationID string `json:"conversation_id,omitempty"`
} }
type ChatMessage struct { type ChatMessage struct {
@ -39,6 +43,7 @@ type ChatMessage struct {
} }
type ChatResponse struct { type ChatResponse struct {
ConversationID string `json:"conversation_id"`
Content string `json:"content"` Content string `json:"content"`
Sources []string `json:"sources,omitempty"` Sources []string `json:"sources,omitempty"`
Usage streaming.Usage `json:"usage"` Usage streaming.Usage `json:"usage"`
@ -62,12 +67,43 @@ func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) {
if req.Stream != nil { if req.Stream != nil {
stream = *req.Stream stream = *req.Stream
} }
history := toAgentMessages(req.Messages)
if stream { // Resolve or create the conversation. If the client passed a non-empty
h.streamChat(w, r, history) // ID we use it as-is; otherwise we mint a new one.
ctx := r.Context()
convID := req.ConversationID
if convID == "" {
var err error
convID, err = h.store.CreateConversation(ctx)
if err != nil {
http.Error(w, "create conversation: "+err.Error(), http.StatusInternalServerError)
return return
} }
h.completeChat(w, r, history) }
// Persist the incoming user message. Even if the LLM fails afterward
// the user sees their question in the conversation history.
if userMsg := lastUserMessage(req.Messages); userMsg != "" {
if err := h.store.SaveMessage(ctx, convID, "user", userMsg, nil); err != nil {
slog.Error("save user message", "err", err)
}
}
history := toAgentMessages(req.Messages)
if stream {
h.streamChat(w, r, history, convID)
return
}
h.completeChat(w, r, history, convID)
}
func lastUserMessage(msgs []ChatMessage) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "user" {
return msgs[i].Content
}
}
return ""
} }
func toAgentMessages(in []ChatMessage) []agent.Message { func toAgentMessages(in []ChatMessage) []agent.Message {
@ -100,7 +136,9 @@ func (req *ChatRequest) validate() error {
// streamChat drives the LLM agent and forwards each chunk to the SSE // streamChat drives the LLM agent and forwards each chunk to the SSE
// stream using the protocol described in docs/architecture.md §3.2. // stream using the protocol described in docs/architecture.md §3.2.
func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []agent.Message) { // convID is the persistent conversation ID — passed in from Chat() after
// resolve-or-create. The assistant reply is saved on success.
func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
@ -114,7 +152,6 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
} }
ctx := r.Context() ctx := r.Context()
convID := newConvID()
if err := streaming.WriteStart(w, convID); err != nil { if err := streaming.WriteStart(w, convID); err != nil {
slog.Error("sse start failed", "err", err) slog.Error("sse start failed", "err", err)
return return
@ -127,12 +164,14 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
_ = streaming.WriteError(w, "rag: "+err.Error()) _ = streaming.WriteError(w, "rag: "+err.Error())
return return
} }
var sources []string
if ragContext != "" { if ragContext != "" {
sources := extractSources(ragContext) sources = extractSources(ragContext)
_ = streaming.WriteSources(w, sources) _ = streaming.WriteSources(w, sources)
} }
// Stream from the model. // Stream from the model, collecting the full reply as we go.
var full strings.Builder
for chunk, err := range h.runner.Stream(ctx, history) { for chunk, err := range h.runner.Stream(ctx, history) {
if err != nil { if err != nil {
slog.Error("llm stream", "err", err) slog.Error("llm stream", "err", err)
@ -140,11 +179,13 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
return return
} }
if chunk.Delta != "" { if chunk.Delta != "" {
full.WriteString(chunk.Delta)
if err := streaming.WriteChunk(w, chunk.Delta); err != nil { if err := streaming.WriteChunk(w, chunk.Delta); err != nil {
return return
} }
} }
if chunk.FinishReason != "" && chunk.Usage.TotalTokens > 0 { if chunk.FinishReason != "" && chunk.Usage.TotalTokens > 0 {
h.persistAssistant(ctx, convID, full.String(), sources)
_ = streaming.WriteDone(w, streaming.Usage{ _ = streaming.WriteDone(w, streaming.Usage{
InputTokens: chunk.Usage.InputTokens, InputTokens: chunk.Usage.InputTokens,
OutputTokens: chunk.Usage.OutputTokens, OutputTokens: chunk.Usage.OutputTokens,
@ -155,16 +196,30 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
// Final usage chunk may come on the last iteration; if we never saw a // Final usage chunk may come on the last iteration; if we never saw a
// finish_reason + usage in-band, surface what we recorded. // finish_reason + usage in-band, surface what we recorded.
usage := h.runner.LastUsage() usage := h.runner.LastUsage()
h.persistAssistant(ctx, convID, full.String(), sources)
_ = streaming.WriteDone(w, streaming.Usage{ _ = streaming.WriteDone(w, streaming.Usage{
InputTokens: usage.InputTokens, InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens, OutputTokens: usage.OutputTokens,
}) })
} }
func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message) { // persistAssistant saves the full assistant reply. Best-effort: a failure
// here doesn't fail the user's request (the response is already streamed).
func (h *Handlers) persistAssistant(ctx context.Context, convID, content string, sources []string) {
if strings.TrimSpace(content) == "" {
return
}
if err := h.store.SaveMessage(ctx, convID, "assistant", content, sources); err != nil {
slog.Error("save assistant message", "err", err, "conv", convID)
}
}
func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
var full strings.Builder var full strings.Builder
for chunk, err := range h.runner.Stream(r.Context(), history) { var sources []string
for chunk, err := range h.runner.Stream(ctx, history) {
if err != nil { if err != nil {
http.Error(w, "llm: "+err.Error(), http.StatusBadGateway) http.Error(w, "llm: "+err.Error(), http.StatusBadGateway)
return return
@ -172,16 +227,19 @@ func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history
full.WriteString(chunk.Delta) full.WriteString(chunk.Delta)
} }
usage := h.runner.LastUsage() usage := h.runner.LastUsage()
_, ragContext, _ := h.runner.BuildMessages(r.Context(), history) _, ragContext, _ := h.runner.BuildMessages(ctx, history)
if ragContext != "" {
sources = extractSources(ragContext)
}
h.persistAssistant(ctx, convID, full.String(), sources)
resp := ChatResponse{ resp := ChatResponse{
ConversationID: convID,
Content: full.String(), Content: full.String(),
Usage: streaming.Usage{ Usage: streaming.Usage{
InputTokens: usage.InputTokens, InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens, OutputTokens: usage.OutputTokens,
}, },
} Sources: sources,
if ragContext != "" {
resp.Sources = extractSources(ragContext)
} }
_ = json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
} }
@ -217,12 +275,6 @@ func truncate(s string, n int) string {
return s[:n] + "…" return s[:n] + "…"
} }
func newConvID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func (h *Handlers) Info(w http.ResponseWriter, _ *http.Request) { func (h *Handlers) Info(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
p := h.cfg.DefaultProvider() p := h.cfg.DefaultProvider()
@ -263,6 +315,102 @@ func (h *Handlers) Reindex(w http.ResponseWriter, r *http.Request) {
}) })
} }
// ---- Conversation REST endpoints -------------------------------------------
// GetConversation returns the full history of one conversation. The
// conversation ID is treated as a bearer token: anyone who knows it can
// read the history. For a public bot this is fine; for private contexts
// add auth at the proxy layer.
func (h *Handlers) GetConversation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := conversationIDFromPath(r.URL.Path)
if id == "" {
http.Error(w, "missing conversation id", http.StatusBadRequest)
return
}
conv, err := h.store.GetConversation(r.Context(), id)
if errors.Is(err, portfolio.ErrConversationNotFound) {
http.Error(w, "conversation not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "get conversation: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(conv)
}
// ListConversations returns the most recent N conversation summaries.
// Useful for a "show my chats" sidebar in the widget or a custom UI.
func (h *Handlers) ListConversations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 50
if s := r.URL.Query().Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
limit = n
}
}
items, err := h.store.ListConversations(r.Context(), limit)
if err != nil {
http.Error(w, "list conversations: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if items == nil {
items = []portfolio.ConversationSummary{}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"conversations": items,
"count": len(items),
})
}
// DeleteConversation removes a conversation and all its messages.
func (h *Handlers) DeleteConversation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := conversationIDFromPath(r.URL.Path)
if id == "" {
http.Error(w, "missing conversation id", http.StatusBadRequest)
return
}
err := h.store.DeleteConversation(r.Context(), id)
if errors.Is(err, portfolio.ErrConversationNotFound) {
http.Error(w, "conversation not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "delete conversation: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// conversationIDFromPath pulls the ID out of "/api/conversations/{id}".
// chi does this with chi.URLParam(r, "id"); for clarity (and so this
// handler works even without chi) we do it by hand.
func conversationIDFromPath(path string) string {
const prefix = "/api/conversations/"
if !strings.HasPrefix(path, prefix) {
return ""
}
id := strings.TrimPrefix(path, prefix)
// strip trailing slash and any further segments
if i := strings.IndexByte(id, '/'); i >= 0 {
id = id[:i]
}
return id
}
func firstNonEmpty(vals ...string) string { func firstNonEmpty(vals ...string) string {
for _, v := range vals { for _, v := range vals {
if v != "" { if v != "" {

View file

@ -37,6 +37,9 @@ func New(cfg *config.Config, h *Handlers) *Server {
r.Post("/reindex", h.Reindex) r.Post("/reindex", h.Reindex)
r.Get("/health", h.Health) r.Get("/health", h.Health)
r.Get("/info", h.Info) r.Get("/info", h.Info)
r.Get("/conversations", h.ListConversations)
r.Get("/conversations/{id}", h.GetConversation)
r.Delete("/conversations/{id}", h.DeleteConversation)
}) })
srv := &http.Server{ srv := &http.Server{

View file

@ -128,9 +128,10 @@ export default function RootLayout({ children }) {
The widget expects the bot to: The widget expects the bot to:
1. Expose `POST /api/chat` accepting `{ messages, stream }` (see `docs/architecture.md` §3.1). 1. Expose `POST /api/chat` accepting `{ messages, stream, conversation_id? }` (see `docs/architecture.md` §3.1).
2. Stream SSE events: `start`, `chunk`, `sources`, `done`, `error` (see `docs/architecture.md` §3.2). 2. Stream SSE events: `start` (with `conversation_id`), `chunk`, `sources`, `done`, `error` (see `docs/architecture.md` §3.2).
3. Allow the page's origin via `cors_origins` in the bot's config. 3. Expose `GET /api/conversations/{id}` for history restore (returns 404 if unknown).
4. Allow the page's origin via `cors_origins` in the bot's config.
## Running the example locally ## Running the example locally
@ -156,9 +157,49 @@ Modern browsers (Chrome/Edge 90+, Firefox 90+, Safari 15+). Uses:
No polyfills, no transpilation. 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:
1. **First message** — the server mints a new `conversation_id` and returns
it in the `start` SSE event. The widget saves it to
`localStorage["rony-chat-conv"]`.
2. **Subsequent messages** — the widget sends the saved ID with every
request, so the server keeps appending to the same thread.
3. **Page reload** — on load, the widget reads the stored ID and calls
`GET /api/conversations/{id}` to restore the full history.
4. **Server lost the conversation** (e.g. DB was wiped) — the GET returns
404. The widget clears `localStorage` and 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):
```html
<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) ## What's not in the widget (yet)
- **Conversation persistence** — each visit is a fresh conversation. The bot is stateless; add a `conversation_id` cookie + server-side history if you want continuity.
- **Markdown images / tables** — the renderer handles paragraphs, lists, code, links, bold/italic. Tables and images render as raw text. For richer output, swap `renderMarkdown` for `marked` or `markdown-it`. - **Markdown images / tables** — the renderer handles paragraphs, lists, code, links, bold/italic. Tables and images render as raw text. For richer output, swap `renderMarkdown` for `marked` or `markdown-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. - **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. - **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/conversations` for a future sidebar.

View file

@ -50,6 +50,7 @@
}; };
var LANG_KEY = "rony-chat-lang"; var LANG_KEY = "rony-chat-lang";
var CONV_KEY = "rony-chat-conv";
function pickInitialLang() { function pickInitialLang() {
var saved = null; var saved = null;
@ -63,6 +64,37 @@
try { localStorage.setItem(LANG_KEY, lang); } catch (e) {} 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 ---------------------------------------------------------------- // ---- Config ----------------------------------------------------------------
function readConfig() { function readConfig() {
@ -226,6 +258,7 @@
var busy = false; var busy = false;
var abortCtrl = null; var abortCtrl = null;
var lang = pickInitialLang(); var lang = pickInitialLang();
var convID = loadConvID();
// ---- Language handling ----------------------------------------------- // ---- Language handling -----------------------------------------------
@ -258,6 +291,21 @@
}); });
} }
// 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 -------------------------------------------------- // ---- Chat behavior --------------------------------------------------
var lastStatusKey = "online"; var lastStatusKey = "online";
@ -269,6 +317,9 @@
function setOpen(open) { function setOpen(open) {
widget.setAttribute("data-open", open ? "true" : "false"); 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) { if (open && !history.length && cfg.greeting) {
appendMessage("assistant", cfg.greeting); appendMessage("assistant", cfg.greeting);
history.push({ role: "assistant", content: cfg.greeting }); history.push({ role: "assistant", content: cfg.greeting });
@ -317,7 +368,11 @@
fetch(cfg.apiUrl + "/api/chat", { fetch(cfg.apiUrl + "/api/chat", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history, stream: true }), body: JSON.stringify({
messages: history,
stream: true,
conversation_id: convID || undefined,
}),
signal: abortCtrl.signal, signal: abortCtrl.signal,
}).then(function (resp) { }).then(function (resp) {
if (!resp.ok) { if (!resp.ok) {
@ -329,7 +384,13 @@
} catch (e) { } catch (e) {
return; return;
} }
if (type === "chunk" && payload.content) { 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); assistantDiv.insertBefore(document.createTextNode(payload.content), caret);
$messages.scrollTop = $messages.scrollHeight; $messages.scrollTop = $messages.scrollHeight;
} else if (type === "sources" && Array.isArray(payload.documents)) { } else if (type === "sources" && Array.isArray(payload.documents)) {