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.
231 lines
7.2 KiB
Go
231 lines
7.2 KiB
Go
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
|
|
}
|