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)) {