diff --git a/docs/architecture.md b/docs/architecture.md
index 91e3bdc..17fbe4e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -105,13 +105,20 @@ The bot responds with accurate information extracted from the projects' markdown
"messages": [
{"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):**
```
-data: {"type":"start","conversation_id":"abc123"}
+data: {"type":"start","conversation_id":"57f4aa3c7fab466bc4de9c43b296903e"}
data: {"type":"chunk","content":"Victor"}
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}}
```
+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`):
```json
{
+ "conversation_id": "57f4aa3c7fab466bc4de9c43b296903e",
"content": "Victor has several projects...",
"sources": ["rony-harness.md", "rony-llm-agent.md"],
"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` (1–200, 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
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)
@@ -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)
-- **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`.
- **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/
│ ├── server/ # HTTP handlers
│ │ ├── 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
│ │
│ ├── agent/ # LLM client + RAG runner
│ │ ├── runner.go # Stream wrapper, RAG injection into system prompt
│ │ └── 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
│ │ ├── indexer.go # Store: schema, Reindex, Search (BM25)
+│ │ ├── conversations.go # Conversation + Message CRUD, persisted alongside RAG
│ │ └── chunker_test.go / store_test.go
│ │
│ ├── persona/ # Persona bridge to rony-llm-agent
diff --git a/internal/portfolio/conversations.go b/internal/portfolio/conversations.go
new file mode 100644
index 0000000..4b757e4
--- /dev/null
+++ b/internal/portfolio/conversations.go
@@ -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
+}
diff --git a/internal/portfolio/indexer.go b/internal/portfolio/indexer.go
index 3db139e..60dcc39 100644
--- a/internal/portfolio/indexer.go
+++ b/internal/portfolio/indexer.go
@@ -53,7 +53,7 @@ func OpenStore(dbPath string) (*Store, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
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)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
@@ -63,6 +63,10 @@ func OpenStore(dbPath string) (*Store, error) {
_ = db.Close()
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
}
diff --git a/internal/server/conversations_test.go b/internal/server/conversations_test.go
new file mode 100644
index 0000000..55c2d27
--- /dev/null
+++ b/internal/server/conversations_test.go
@@ -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
\ No newline at end of file
diff --git a/internal/server/handlers.go b/internal/server/handlers.go
index 8caab87..3d75c93 100644
--- a/internal/server/handlers.go
+++ b/internal/server/handlers.go
@@ -1,13 +1,13 @@
package server
import (
- "crypto/rand"
- "encoding/hex"
+ "context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
+ "strconv"
"strings"
"time"
@@ -29,8 +29,12 @@ func NewHandlers(cfg *config.Config, runner *agent.Runner, store *portfolio.Stor
}
type ChatRequest struct {
- Messages []ChatMessage `json:"messages"`
- Stream *bool `json:"stream,omitempty"`
+ Messages []ChatMessage `json:"messages"`
+ 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 {
@@ -39,9 +43,10 @@ type ChatMessage struct {
}
type ChatResponse struct {
- Content string `json:"content"`
- Sources []string `json:"sources,omitempty"`
- Usage streaming.Usage `json:"usage"`
+ ConversationID string `json:"conversation_id"`
+ Content string `json:"content"`
+ Sources []string `json:"sources,omitempty"`
+ Usage streaming.Usage `json:"usage"`
}
func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) {
@@ -62,12 +67,43 @@ func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) {
if req.Stream != nil {
stream = *req.Stream
}
+
+ // Resolve or create the conversation. If the client passed a non-empty
+ // 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
+ }
+ }
+
+ // 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)
+ h.streamChat(w, r, history, convID)
return
}
- h.completeChat(w, r, history)
+ 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 {
@@ -100,7 +136,9 @@ func (req *ChatRequest) validate() error {
// streamChat drives the LLM agent and forwards each chunk to the SSE
// 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("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
@@ -114,7 +152,6 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
}
ctx := r.Context()
- convID := newConvID()
if err := streaming.WriteStart(w, convID); err != nil {
slog.Error("sse start failed", "err", err)
return
@@ -127,12 +164,14 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
_ = streaming.WriteError(w, "rag: "+err.Error())
return
}
+ var sources []string
if ragContext != "" {
- sources := extractSources(ragContext)
+ sources = extractSources(ragContext)
_ = 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) {
if err != nil {
slog.Error("llm stream", "err", err)
@@ -140,11 +179,13 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
return
}
if chunk.Delta != "" {
+ full.WriteString(chunk.Delta)
if err := streaming.WriteChunk(w, chunk.Delta); err != nil {
return
}
}
if chunk.FinishReason != "" && chunk.Usage.TotalTokens > 0 {
+ h.persistAssistant(ctx, convID, full.String(), sources)
_ = streaming.WriteDone(w, streaming.Usage{
InputTokens: chunk.Usage.InputTokens,
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
// finish_reason + usage in-band, surface what we recorded.
usage := h.runner.LastUsage()
+ h.persistAssistant(ctx, convID, full.String(), sources)
_ = streaming.WriteDone(w, streaming.Usage{
InputTokens: usage.InputTokens,
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")
+ ctx := r.Context()
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 {
http.Error(w, "llm: "+err.Error(), http.StatusBadGateway)
return
@@ -172,16 +227,19 @@ func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history
full.WriteString(chunk.Delta)
}
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{
- Content: full.String(),
+ ConversationID: convID,
+ Content: full.String(),
Usage: streaming.Usage{
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
},
- }
- if ragContext != "" {
- resp.Sources = extractSources(ragContext)
+ Sources: sources,
}
_ = json.NewEncoder(w).Encode(resp)
}
@@ -217,12 +275,6 @@ func truncate(s string, n int) string {
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) {
w.Header().Set("Content-Type", "application/json")
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 {
for _, v := range vals {
if v != "" {
diff --git a/internal/server/server.go b/internal/server/server.go
index 5523cf2..21d035b 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -37,6 +37,9 @@ func New(cfg *config.Config, h *Handlers) *Server {
r.Post("/reindex", h.Reindex)
r.Get("/health", h.Health)
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{
diff --git a/web/README.md b/web/README.md
index 8d5c5c6..0a6004d 100644
--- a/web/README.md
+++ b/web/README.md
@@ -128,9 +128,10 @@ export default function RootLayout({ children }) {
The widget expects the bot to:
-1. Expose `POST /api/chat` accepting `{ messages, stream }` (see `docs/architecture.md` §3.1).
-2. Stream SSE events: `start`, `chunk`, `sources`, `done`, `error` (see `docs/architecture.md` §3.2).
-3. Allow the page's origin via `cors_origins` in the bot's config.
+1. Expose `POST /api/chat` accepting `{ messages, stream, conversation_id? }` (see `docs/architecture.md` §3.1).
+2. Stream SSE events: `start` (with `conversation_id`), `chunk`, `sources`, `done`, `error` (see `docs/architecture.md` §3.2).
+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
@@ -156,9 +157,49 @@ Modern browsers (Chrome/Edge 90+, Firefox 90+, Safari 15+). Uses:
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
+
+
+```
+
+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)
-- **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`.
- **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.
\ No newline at end of file
+- **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.
\ No newline at end of file
diff --git a/web/chat-widget.js b/web/chat-widget.js
index 854d00b..91f114f 100644
--- a/web/chat-widget.js
+++ b/web/chat-widget.js
@@ -50,6 +50,7 @@
};
var LANG_KEY = "rony-chat-lang";
+ var CONV_KEY = "rony-chat-conv";
function pickInitialLang() {
var saved = null;
@@ -63,6 +64,37 @@
try { localStorage.setItem(LANG_KEY, lang); } catch (e) {}
}
+ // ---- Conversation persistence -------------------------------------------
+ // The conversation_id is a server-issued UUID-ish string. We store it in
+ // localStorage so the same browser keeps its thread across reloads. A
+ // different browser (or cleared storage) starts a fresh thread.
+
+ function loadConvID() {
+ try { return localStorage.getItem(CONV_KEY) || ""; } catch (e) { return ""; }
+ }
+ function saveConvID(id) {
+ try { localStorage.setItem(CONV_KEY, id); } catch (e) {}
+ }
+ function clearConvID() {
+ try { localStorage.removeItem(CONV_KEY); } catch (e) {}
+ }
+
+ // Restore conversation history from the server, if any. On 404 the
+ // stored ID is dead (e.g. server DB was wiped) — clear it and start fresh.
+ function restoreHistory(convID, onDone) {
+ fetch(cfg.apiUrl + "/api/conversations/" + encodeURIComponent(convID))
+ .then(function (resp) {
+ if (resp.status === 404) { clearConvID(); onDone(null); return null; }
+ if (!resp.ok) { onDone(null); return null; }
+ return resp.json();
+ })
+ .then(function (conv) {
+ if (!conv) { onDone(null); return; }
+ onDone(conv);
+ })
+ .catch(function () { onDone(null); });
+ }
+
// ---- Config ----------------------------------------------------------------
function readConfig() {
@@ -226,6 +258,7 @@
var busy = false;
var abortCtrl = null;
var lang = pickInitialLang();
+ var convID = loadConvID();
// ---- 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 --------------------------------------------------
var lastStatusKey = "online";
@@ -269,6 +317,9 @@
function setOpen(open) {
widget.setAttribute("data-open", open ? "true" : "false");
+ // Greeting only fires on a brand-new thread. If history was
+ // restored from the server we don't want to prepend a greeting on
+ // top of the user's previous messages.
if (open && !history.length && cfg.greeting) {
appendMessage("assistant", cfg.greeting);
history.push({ role: "assistant", content: cfg.greeting });
@@ -317,7 +368,11 @@
fetch(cfg.apiUrl + "/api/chat", {
method: "POST",
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,
}).then(function (resp) {
if (!resp.ok) {
@@ -329,7 +384,13 @@
} catch (e) {
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);
$messages.scrollTop = $messages.scrollHeight;
} else if (type === "sources" && Array.isArray(payload.documents)) {