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:
parent
f42bd37eae
commit
18e555e338
8 changed files with 864 additions and 40 deletions
|
|
@ -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` (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
|
#### `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
|
||||||
|
|
|
||||||
231
internal/portfolio/conversations.go
Normal file
231
internal/portfolio/conversations.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
214
internal/server/conversations_test.go
Normal file
214
internal/server/conversations_test.go
Normal 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
|
||||||
|
|
@ -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 != "" {
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue