diff --git a/.gitignore b/.gitignore
index c3baf73..4eedc25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,11 +7,11 @@ vendor/
coverage.out
coverage.html
-# ChromaDB
-chroma/
+# SQLite (RAG index)
*.db
*.db-shm
*.db-wal
+data/portfolio.db
# Editor / OS
.vscode/
diff --git a/README.es.md b/README.es.md
index 0dd5f27..0c93265 100644
--- a/README.es.md
+++ b/README.es.md
@@ -10,10 +10,10 @@
## ✨ Features
- 🌐 **HTTP server** con streaming SSE (Server-Sent Events)
-- 🧠 **RAG sobre markdown** — indexa automáticamente los `.md` en `data/projects/`
+- 🧠 **RAG sobre markdown** — indexa automáticamente los `.md` en `data/projects/` (SQLite FTS5, sin embeddings)
- 🎭 **Persona customizable** — responde como "asistente de Victor"
-- ⚡ **Self-hosted** con Ollama o llama.cpp (no requiere API key de cloud)
-- 🔌 **Integrable** con Astro/React via proxy HTTP
+- ⚡ **Self-hosted** con llama.cpp (default) u Ollama (no requiere API key de cloud)
+- 💬 **Widget de chat drop-in** — vanilla JS, sin build step, funciona en cualquier sitio
- 🛡️ **Rate limiting** y logging estructurado
- 📦 **Portable** — se puede adaptar a otros contextos (clientes, productos, etc.)
@@ -27,9 +27,10 @@ cd chat-bot
# 2. Resolver dependencias (crea go.sum con hashes)
go mod tidy
-# 3. Configurar provider (ejemplo: Ollama)
-# Asegúrate de tener Ollama corriendo: ollama serve
-# Modelo descargado: ollama pull qwen2.5:1.5b
+# 3. Configurar provider (llama.cpp por default)
+# Descarga un modelo GGUF, ej.:
+# https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF
+export RONY_MODELS_PATH=/path/to/models
# 4. Cargar tus proyectos en data/projects/
echo "# Mi Proyecto Cool\nDescripción..." > data/projects/mi-proyecto.md
@@ -46,41 +47,45 @@ go build -o bin/chat-bot ./cmd/chat-bot
```
rony-chat-bot/
-├── cm./rony-chat-bot/ # Entry point (CLI)
+├── cmd/chat-bot/ # Entry point (CLI)
├── internal/
│ ├── server/ # HTTP handlers + SSE
+│ ├── agent/ # LLM client + RAG + persona runner
│ ├── portfolio/ # Data loader (markdown → RAG)
│ ├── persona/ # Persona override
-│ └── streaming/ # SSE helpers
+│ ├── streaming/ # SSE helpers
+│ └── i18n/ # Detección de idioma (EN/ES)
+├── web/ # ← WIDGET DE CHAT DROP-IN
+│ ├── chat-widget.js
+│ ├── chat-widget.css
+│ └── example.html
├── data/projects/ # ← TUS PROYECTOS EN MARKDOWN
│ ├── rony-tui.md
│ ├── rony-llm-agent.md
│ └── ...
├── configs/
-│ └── portfolio-bot.yaml # Provider config
+│ └── portfolio-bot.yaml # Provider + RAG + persona config
├── docs/
│ └── architecture.md # ← Especificación técnica completa
└── go.mod # require rony-llm-agent
```
-## 🎯 Uso desde Astro
+## 🎯 Embebido en cualquier sitio
-Ver [`docs/architecture.md`](./docs/architecture.md) §5 — patrón recomendado de proxy.
+El bot viene con un widget de chat drop-in. Agrega dos archivos y un tag `
```
+Ver [`web/README.md`](./web/README.md) para la referencia completa de configuración y snippets de integración con Astro/Next.js. Arquitectura completa en [`docs/architecture.md`](./docs/architecture.md) §5.
+
## 🔄 Adaptar a otro cliente
Este bot está diseñado para ser **atómico** y reusable. Para adaptarlo (ej. chatbot para un concesionario):
diff --git a/README.md b/README.md
index d9d5e5f..90118eb 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,10 @@
## ✨ Features
- 🌐 **HTTP server** with SSE (Server-Sent Events) streaming
-- 🧠 **RAG over markdown** — automatically indexes `.md` in `data/projects/`
+- 🧠 **RAG over markdown** — automatically indexes `.md` in `data/projects/` (SQLite FTS5, no embeddings)
- 🎭 **Customizable persona** — responds as "Victor's assistant"
-- ⚡ **Self-hosted** with Ollama or llama.cpp (no cloud API key required)
-- 🔌 **Integrable** with Astro/React via HTTP proxy
+- ⚡ **Self-hosted** with llama.cpp (default) or Ollama (no cloud API key required)
+- 💬 **Drop-in chat widget** — vanilla JS, no build step, works in any site
- 🛡️ **Rate limiting** and structured logging
- 📦 **Portable** — adaptable to other contexts (clients, products, etc.)
@@ -26,9 +26,10 @@ cd rony-chat-bot
# 2. Resolve dependencies (creates go.sum with hashes)
go mod tidy
-# 3. Configure provider (e.g., Ollama)
-# Make sure Ollama is running: ollama serve
-# Downloaded model: ollama pull qwen2.5:1.5b
+# 3. Configure provider (llama.cpp by default)
+# Download a GGUF model, e.g.:
+# https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF
+export RONY_MODELS_PATH=/path/to/models
# 4. Load your projects in data/projects/
echo "# My Cool Project\nDescription..." > data/projects/my-project.md
@@ -48,44 +49,42 @@ rony-chat-bot/
├── cmd/chat-bot/ # Entry point (CLI)
├── internal/
│ ├── server/ # HTTP handlers + SSE
+│ ├── agent/ # LLM client + RAG + persona runner
│ ├── portfolio/ # Data loader (markdown → RAG)
│ ├── persona/ # Persona override
-│ └── streaming/ # SSE helpers
+│ ├── streaming/ # SSE helpers
+│ └── i18n/ # Language detection (EN/ES)
+├── web/ # ← DROP-IN CHAT WIDGET
+│ ├── chat-widget.js
+│ ├── chat-widget.css
+│ └── example.html
├── data/projects/ # ← YOUR PROJECTS IN MARKDOWN
│ ├── rony-harness.md
│ ├── rony-llm-agent.md
│ └── ...
├── configs/
-│ └── portfolio-bot.yaml # Provider config
+│ └── portfolio-bot.yaml # Provider + RAG + persona config
├── docs/
│ └── architecture.md # ← Complete technical specification
└── go.mod # require rony-llm-agent
```
-## 🎯 Use from Astro
+## 🎯 Embed in any site
-See [`docs/architecture.md`](./docs/architecture.md) §5 — recommended proxy pattern.
+The bot ships with a drop-in chat widget. Add two files and a `
```
+See [`web/README.md`](./web/README.md) for the full configuration reference and Astro/Next.js integration snippets. Full architecture in [`docs/architecture.md`](./docs/architecture.md) §5.
+
## 🔄 Adapt to another client
This bot is designed to be **atomic** and reusable. To adapt it (e.g., chatbot for a car dealership):
diff --git a/bench/bench.go b/bench/bench.go
new file mode 100644
index 0000000..caebe41
--- /dev/null
+++ b/bench/bench.go
@@ -0,0 +1,154 @@
+// Package bench holds one-time validation benchmarks used to drive library
+// decisions. Run with:
+//
+// go test -bench=. ./bench/ (modernc only, pure Go)
+// CGO_ENABLED=1 go test -bench=. ./bench/ (mattn + modernc, requires gcc)
+package bench
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+const schema = `
+CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5(
+ id UNINDEXED,
+ project_id UNINDEXED,
+ source_file UNINDEXED,
+ chunk_index UNINDEXED,
+ content,
+ tokenize = 'unicode61 remove_diacritics 2'
+);
+`
+
+var queries = []string{
+ "rony-llm-agent",
+ "AI agent harness",
+ "portfolio projects",
+ "machine learning",
+ "CLI tool for development",
+ "vector database",
+ "agent loop",
+ "streaming response",
+}
+
+func loadChunks(tb testing.TB, dataPath string) []chunkRow {
+ tb.Helper()
+ files, err := filepath.Glob(filepath.Join(dataPath, "*.md"))
+ if err != nil {
+ tb.Fatal(err)
+ }
+ var rows []chunkRow
+ for _, f := range files {
+ b, err := os.ReadFile(f)
+ if err != nil {
+ tb.Fatal(err)
+ }
+ project := strings.TrimSuffix(filepath.Base(f), ".md")
+ for i, c := range splitIntoChunks(string(b), 500, 50) {
+ rows = append(rows, chunkRow{
+ ID: fmt.Sprintf("%s-chunk-%d", project, i),
+ ProjectID: project,
+ Source: f,
+ Index: i,
+ Content: c,
+ })
+ }
+ }
+ return rows
+}
+
+type chunkRow struct {
+ ID, ProjectID, Source string
+ Index int
+ Content string
+}
+
+func splitIntoChunks(text string, size, overlap int) []string {
+ if size <= 0 {
+ return []string{text}
+ }
+ if overlap < 0 || overlap >= size {
+ overlap = size / 10
+ }
+ var chunks []string
+ for i := 0; i < len(text); i += size - overlap {
+ end := i + size
+ if end > len(text) {
+ end = len(text)
+ }
+ if i >= end {
+ break
+ }
+ chunks = append(chunks, text[i:end])
+ if end == len(text) {
+ break
+ }
+ }
+ return chunks
+}
+
+func insertAll(ctx context.Context, db *sql.DB, rows []chunkRow) error {
+ if _, err := db.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil {
+ return err
+ }
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ stmt, err := tx.PrepareContext(ctx,
+ `INSERT INTO portfolio_chunks (id, project_id, source_file, chunk_index, content) VALUES (?,?,?,?,?)`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+ for _, r := range rows {
+ if _, err := stmt.ExecContext(ctx, r.ID, r.ProjectID, r.Source, r.Index, r.Content); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func runQuery(ctx context.Context, db *sql.DB, q string) (int, time.Duration, error) {
+ start := time.Now()
+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
+ SELECT project_id, source_file, content
+ FROM portfolio_chunks
+ WHERE portfolio_chunks MATCH '%s'
+ ORDER BY bm25(portfolio_chunks)
+ LIMIT 5
+ `, sanitizeFTS5(q)))
+ if err != nil {
+ return 0, 0, err
+ }
+ defer rows.Close()
+ n := 0
+ for rows.Next() {
+ n++
+ }
+ return n, time.Since(start), rows.Err()
+}
+
+// sanitizeFTS5 is the same simple wrapper used by the production code path
+// (see docs/architecture.md §4.4). It keeps the benchmark comparable.
+func sanitizeFTS5(q string) string {
+ tokens := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
+ return !(r == '-' || r == '_' || (r >= '0' && r <= '9') ||
+ (r >= 'a' && r <= 'z') || r > 0x7F)
+ })
+ if len(tokens) == 0 {
+ return `""`
+ }
+ for i, t := range tokens {
+ tokens[i] = `"` + t + `"*`
+ }
+ return strings.Join(tokens, " ")
+}
\ No newline at end of file
diff --git a/bench/bench_mattn_test.go b/bench/bench_mattn_test.go
new file mode 100644
index 0000000..15808c0
--- /dev/null
+++ b/bench/bench_mattn_test.go
@@ -0,0 +1,73 @@
+//go:build cgo && sqlite_fts5
+
+package bench
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func openMattn(b *testing.B) *sql.DB {
+ b.Helper()
+ tmpDir := b.TempDir()
+ dsn := filepath.Join(tmpDir, "bench.db")
+ db, err := sql.Open("sqlite3", dsn+"?_journal_mode=WAL&_synchronous=NORMAL")
+ if err != nil {
+ b.Fatal(err)
+ }
+ db.SetMaxOpenConns(1)
+ if _, err := db.ExecContext(context.Background(), schema); err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() { _ = db.Close() })
+ return db
+}
+
+func BenchmarkInsert_Mattn(b *testing.B) {
+ rows := loadChunks(b, dataPath)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ db := openMattn(b)
+ if err := insertAll(context.Background(), db, rows); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkQuery_Mattn(b *testing.B) {
+ db := openMattn(b)
+ rows := loadChunks(b, dataPath)
+ if err := insertAll(context.Background(), db, rows); err != nil {
+ b.Fatal(err)
+ }
+ ctx := context.Background()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ for _, q := range queries {
+ if _, _, err := runQuery(ctx, db, q); err != nil {
+ b.Fatal(err)
+ }
+ }
+ }
+}
+
+func BenchmarkRoundTrip_Mattn(b *testing.B) {
+ rows := loadChunks(b, dataPath)
+ ctx := context.Background()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ db := openMattn(b)
+ if err := insertAll(ctx, db, rows); err != nil {
+ b.Fatal(err)
+ }
+ for _, q := range queries {
+ if _, _, err := runQuery(ctx, db, q); err != nil {
+ b.Fatal(err)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/bench/bench_modernc_test.go b/bench/bench_modernc_test.go
new file mode 100644
index 0000000..c5280e9
--- /dev/null
+++ b/bench/bench_modernc_test.go
@@ -0,0 +1,73 @@
+package bench
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+const dataPath = "../data/projects"
+
+func openModernc(b *testing.B) *sql.DB {
+ b.Helper()
+ tmpDir := b.TempDir()
+ dsn := filepath.Join(tmpDir, "bench.db")
+ db, err := sql.Open("sqlite", dsn+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)")
+ if err != nil {
+ b.Fatal(err)
+ }
+ db.SetMaxOpenConns(1) // fair comparison: serialize writes
+ if _, err := db.ExecContext(context.Background(), schema); err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() { _ = db.Close() })
+ return db
+}
+
+func BenchmarkInsert_Modernc(b *testing.B) {
+ rows := loadChunks(b, dataPath)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ db := openModernc(b)
+ if err := insertAll(context.Background(), db, rows); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkQuery_Modernc(b *testing.B) {
+ db := openModernc(b)
+ rows := loadChunks(b, dataPath)
+ if err := insertAll(context.Background(), db, rows); err != nil {
+ b.Fatal(err)
+ }
+ ctx := context.Background()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ for _, q := range queries {
+ if _, _, err := runQuery(ctx, db, q); err != nil {
+ b.Fatal(err)
+ }
+ }
+ }
+}
+
+func BenchmarkRoundTrip_Modernc(b *testing.B) {
+ rows := loadChunks(b, dataPath)
+ ctx := context.Background()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ db := openModernc(b)
+ if err := insertAll(ctx, db, rows); err != nil {
+ b.Fatal(err)
+ }
+ for _, q := range queries {
+ if _, _, err := runQuery(ctx, db, q); err != nil {
+ b.Fatal(err)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/cmd/chat-bot/main.go b/cmd/chat-bot/main.go
new file mode 100644
index 0000000..afd58d6
--- /dev/null
+++ b/cmd/chat-bot/main.go
@@ -0,0 +1,291 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/VictorVargas/rony-chat-bot/internal/agent"
+ "github.com/VictorVargas/rony-chat-bot/internal/config"
+ "github.com/VictorVargas/rony-chat-bot/internal/persona"
+ "github.com/VictorVargas/rony-chat-bot/internal/portfolio"
+ "github.com/VictorVargas/rony-chat-bot/internal/server"
+)
+
+const version = "0.2.0-dev"
+
+var (
+ cfgFile string
+ reindexOn bool
+ askNoStrm bool
+)
+
+func main() {
+ root := &cobra.Command{
+ Use: "chat-bot",
+ Short: "Portfolio chatbot HTTP server (rony-llm-agent + SQLite FTS5)",
+ Long: "Rony Chat Bot — HTTP server that answers questions about your portfolio using a local LLM and SQLite FTS5 RAG.",
+ }
+ root.PersistentFlags().StringVar(&cfgFile, "config", "configs/portfolio-bot.yaml", "Path to YAML config")
+
+ root.AddCommand(serveCmd())
+ root.AddCommand(reindexCmd())
+ root.AddCommand(askCmd())
+ root.AddCommand(configCmd())
+ root.AddCommand(healthCmd())
+ root.AddCommand(versionCmd())
+
+ if err := root.Execute(); err != nil {
+ os.Exit(1)
+ }
+}
+
+func loadConfig() (*config.Config, error) {
+ return config.Load(cfgFile)
+}
+
+func setupLogging(c *config.Config) {
+ level := slog.LevelInfo
+ switch strings.ToLower(c.Logging.Level) {
+ case "debug":
+ level = slog.LevelDebug
+ case "warn":
+ level = slog.LevelWarn
+ case "error":
+ level = slog.LevelError
+ }
+ opts := &slog.HandlerOptions{Level: level}
+ var h slog.Handler
+ if strings.ToLower(c.Logging.Format) == "text" {
+ h = slog.NewTextHandler(os.Stderr, opts)
+ } else {
+ h = slog.NewJSONHandler(os.Stderr, opts)
+ }
+ slog.SetDefault(slog.New(h))
+}
+
+func serveCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "serve",
+ Short: "Start the HTTP server",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ cfg, err := loadConfig()
+ if err != nil {
+ return err
+ }
+ setupLogging(cfg)
+
+ slog.Info("starting chat-bot",
+ "version", version,
+ "provider", cfg.DefaultProvider().Name,
+ "rag_enabled", cfg.RAG.Enabled,
+ "addr", cfg.Addr(),
+ )
+
+ if reindexOn {
+ if err := runReindex(cfg); err != nil {
+ slog.Error("reindex-on-start failed", "err", err)
+ }
+ }
+
+ store, err := portfolio.OpenStore(cfg.RAG.DBPath)
+ if err != nil {
+ return fmt.Errorf("open rag store: %w", err)
+ }
+ defer store.Close()
+
+ provider := cfg.DefaultProvider()
+ client, err := agent.NewClient(*provider)
+ if err != nil {
+ return fmt.Errorf("init provider %s: %w", provider.Name, err)
+ }
+ slog.Info("provider ready", "name", client.Name(), "type", provider.Type, "model", provider.Model)
+
+ p, err := persona.FromConfig(cfg)
+ if err != nil {
+ return err
+ }
+ runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK)
+
+ h := server.NewHandlers(cfg, runner, store, version)
+ srv := server.New(cfg, h)
+
+ ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ errCh := make(chan error, 1)
+ go func() { errCh <- srv.Start() }()
+
+ select {
+ case <-ctx.Done():
+ slog.Info("shutdown signal received")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+ case err := <-errCh:
+ return err
+ }
+ },
+ }
+ cmd.Flags().BoolVar(&reindexOn, "reindex-on-start", false, "Re-index RAG before serving")
+ return cmd
+}
+
+func reindexCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "reindex",
+ Short: "Rebuild the SQLite FTS5 index from data/projects/",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ cfg, err := loadConfig()
+ if err != nil {
+ return err
+ }
+ setupLogging(cfg)
+ return runReindex(cfg)
+ },
+ }
+}
+
+func runReindex(cfg *config.Config) error {
+ dur, files, chunks, err := portfolio.ReindexOnDisk(cfg.RAG.DBPath, cfg.RAG.DataPath, portfolio.DefaultChunkerConfig())
+ if err != nil {
+ return err
+ }
+ slog.Info("reindex complete",
+ "files", files,
+ "chunks", chunks,
+ "duration_ms", dur.Milliseconds(),
+ "db", cfg.RAG.DBPath,
+ )
+ fmt.Printf("Indexed %d files → %d chunks in %s (%dms)\n", files, chunks, cfg.RAG.DBPath, dur.Milliseconds())
+ return nil
+}
+
+func askCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "ask ",
+ Short: "Ask a single question (no HTTP server, useful for smoke tests)",
+ Args: cobra.MinimumNArgs(1),
+ RunE: func(_ *cobra.Command, args []string) error {
+ cfg, err := loadConfig()
+ if err != nil {
+ return err
+ }
+ setupLogging(cfg)
+ question := strings.Join(args, " ")
+ return runAsk(cfg, question, askNoStrm)
+ },
+ }
+ cmd.Flags().BoolVar(&askNoStrm, "no-stream", false, "Disable streaming output")
+ return cmd
+}
+
+func runAsk(cfg *config.Config, question string, noStream bool) error {
+ provider := cfg.DefaultProvider()
+ fmt.Fprintf(os.Stderr, "[%s via %s] %s\n", provider.Name, provider.Type, version)
+
+ store, err := portfolio.OpenStore(cfg.RAG.DBPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: rag store unavailable (%v); answering without RAG\n", err)
+ }
+ defer func() {
+ if store != nil {
+ _ = store.Close()
+ }
+ }()
+
+ client, err := agent.NewClient(*provider)
+ if err != nil {
+ return err
+ }
+ p, err := persona.FromConfig(cfg)
+ if err != nil {
+ return err
+ }
+ runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK)
+ history := []agent.Message{{Role: agent.RoleUser, Content: question}}
+
+ if noStream {
+ var full strings.Builder
+ for chunk, err := range runner.Stream(context.Background(), history) {
+ if err != nil {
+ return err
+ }
+ full.WriteString(chunk.Delta)
+ }
+ fmt.Println(full.String())
+ return nil
+ }
+ for chunk, err := range runner.Stream(context.Background(), history) {
+ if err != nil {
+ return err
+ }
+ fmt.Print(chunk.Delta)
+ }
+ fmt.Println()
+ return nil
+}
+
+func configCmd() *cobra.Command {
+ cmd := &cobra.Command{Use: "config", Short: "Config utilities"}
+ cmd.AddCommand(&cobra.Command{
+ Use: "validate",
+ Short: "Validate the YAML config file",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ cfg, err := loadConfig()
+ if err != nil {
+ return err
+ }
+ fmt.Printf("OK — server=%s, provider=%s (%s), rag=%v\n",
+ cfg.Addr(), cfg.DefaultProvider().Name, cfg.DefaultProvider().Type, cfg.RAG.Enabled)
+ return nil
+ },
+ })
+ return cmd
+}
+
+func healthCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "health",
+ Short: "Hit /api/health on a running server",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ cfg, err := loadConfig()
+ if err != nil {
+ return err
+ }
+ url := fmt.Sprintf("http://%s/api/health", cfg.Addr())
+ resp, err := http.Get(url)
+ if err != nil {
+ return fmt.Errorf("connect %s: %w", url, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("health %d: %s", resp.StatusCode, string(body))
+ }
+ fmt.Println(string(body))
+ return nil
+ },
+ }
+}
+
+func versionCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "version",
+ Short: "Print version",
+ Run: func(_ *cobra.Command, _ []string) {
+ out := map[string]string{"version": version}
+ _ = json.NewEncoder(os.Stdout).Encode(out)
+ },
+ }
+}
\ No newline at end of file
diff --git a/configs/portfolio-bot.es.yaml b/configs/portfolio-bot.es.yaml
deleted file mode 100644
index 4528669..0000000
--- a/configs/portfolio-bot.es.yaml
+++ /dev/null
@@ -1,82 +0,0 @@
-# Configuración del Portfolio Bot
-# Documentación: https://github.com/VictorVargas/rony-llm-agent/pkg/llm
-
-server:
- host: "0.0.0.0"
- port: 7331
- read_timeout_ms: 30000
- cors_origins:
- - "http://localhost:4321" # Astro dev server
- - "https://victorvargas.dev" # Producción (cuando exista)
- rate_limit:
- requests_per_minute: 30 # Por IP
- burst: 5
-
-# Providers LLM (al menos uno configurado)
-providers:
- # === Ollama (recomendado para desarrollo) ===
- - name: ollama-local
- type: ollama
- model: qwen2.5:1.5b # Modelo pequeño para Q&A
- endpoint: http://localhost:11434
- default: true
-
- # === llama.cpp directo (GGUF) ===
- - name: llamacpp-local
- type: llamacpp
- model_path: ${RONY_MODELS_PATH}/qwen2.5-1.5b-instruct-q5_k_m.gguf
- context_size: 4096
- n_gpu_layers: 999
-
- # === Anthropic (si quieres calidad > privacidad) ===
- - name: anthropic-api
- type: anthropic
- model: claude-haiku-4 # Modelo barato
- api_key_env: ANTHROPIC_API_KEY
-
-# RAG: cómo se indexan los proyectos
-rag:
- enabled: true
- data_path: ./data/projects # Directorio con .md
- chunk_size: 500 # caracteres por chunk
- chunk_overlap: 50
- embedding_provider: ollama # o llamacpp
- embedding_model: nomic-embed-text
- vector_db_path: ./chroma # Persistencia local
- top_k: 5 # Documentos a recuperar por query
- rerank: false # Phase 2
-
-# Persona: quién es el bot
-persona:
- name: "Rony Chat Bot"
- tone: "Profesional, conocedor, amable"
- language: "Español"
- constraints:
- - "Solo responder sobre Victor y sus proyectos"
- - "Si no sabes, decir 'No tengo esa información'"
- - "Ser conciso pero informativo"
- - "Usar formato markdown para listas y código"
- intro: "¡Hola! Soy Rony, el asistente virtual de Victor Hugo Vargas. Pregúntame sobre sus proyectos, skills o experiencia."
-
-# System prompt base (concatenado con el contenido RAG)
-system_prompt: |
- Eres Rony Chat Bot, el asistente virtual de Victor Hugo Vargas, un ingeniero de software mexicano.
-
- Tu trabajo es responder preguntas sobre:
- - Los proyectos de Victor (ver archivos en data/projects/)
- - Su experiencia y skills técnicas
- - Su enfoque de trabajo
-
- Responde en español, con tono profesional pero accesible.
- Si te preguntan algo que no está en tu contexto, dilo honestamente.
-
- Formato recomendado:
- - Usa markdown para listas, código, y énfasis
- - Sé conciso (máximo 2-3 párrafos por respuesta)
- - Incluye links a repos cuando sea relevante
-
-# Logging
-logging:
- level: info # debug | info | warn | error
- format: json # json | text
- output: stderr
\ No newline at end of file
diff --git a/configs/portfolio-bot.yaml b/configs/portfolio-bot.yaml
index c312b42..ea46bcd 100644
--- a/configs/portfolio-bot.yaml
+++ b/configs/portfolio-bot.yaml
@@ -7,6 +7,7 @@ server:
read_timeout_ms: 30000
cors_origins:
- "http://localhost:4321" # Astro dev server
+ - "http://localhost:8000" # Local widget demo (python http.server)
- "https://victorvargas.dev" # Production (when it exists)
rate_limit:
requests_per_minute: 30 # Per IP
@@ -14,66 +15,98 @@ server:
# LLM providers (at least one configured)
providers:
- # === Ollama (recommended for development) ===
- - name: ollama-local
- type: ollama
- model: qwen2.5:1.5b # Small model for Q&A
- endpoint: http://localhost:11434
- default: true
-
- # === llama.cpp direct (GGUF) ===
+ # === llama.cpp server (OpenAI-compatible) — DEFAULT ===
+ # Run: llama-server -m /path/to/qwen2.5-3b-instruct-q4_k_m.gguf --port 9100 --mlock
- name: llamacpp-local
type: llamacpp
- model_path: ${RONY_MODELS_PATH}/qwen2.5-1.5b-instruct-q5_k_m.gguf
+ model: qwen2.5-3b-instruct
+ endpoint: http://localhost:9100/v1
context_size: 4096
- n_gpu_layers: 999
+ max_tokens: 2048
+ default: true
+
+ # === Ollama (alternative for development without local GGUF) ===
+ # Run: ollama serve
+ - name: ollama-local
+ type: ollama
+ model: qwen2.5:1.5b
+ endpoint: http://localhost:11434/v1
# === Anthropic (if you want quality > privacy) ===
- name: anthropic-api
type: anthropic
- model: claude-haiku-4 # Cheap model
+ model: claude-haiku-4
api_key_env: ANTHROPIC_API_KEY
-# RAG: how projects are indexed
+# RAG: how projects are indexed (SQLite + FTS5 full-text search)
rag:
enabled: true
data_path: ./data/projects # Directory with .md
chunk_size: 500 # characters per chunk
chunk_overlap: 50
- embedding_provider: ollama # or llamacpp
- embedding_model: nomic-embed-text
- vector_db_path: ./chroma # Local persistence
- top_k: 5 # Documents to retrieve per query
- rerank: false # Phase 2
+ db_path: ./data/portfolio.db # SQLite database (auto-created)
+ top_k: 5 # Chunks to retrieve per query (BM25 ranked)
+ tokenize: unicode61 # FTS5 tokenizer: unicode61 | porter | trigram
# Persona: who the bot is
persona:
- name: "Rony Chat Bot"
- tone: "Professional, knowledgeable, friendly"
- language: "English"
- constraints:
- - "Only answer about Victor and his projects"
- - "If you don't know, say 'I don't have that information'"
- - "Be concise but informative"
- - "Use markdown format for lists and code"
- intro: "Hi! I'm Rony, Victor Hugo Vargas's virtual assistant. Ask me about his projects, skills or experience."
+ name: "Rony"
+ tone: "Honest, cheerful, loyal" # metadata only — the real voice lives in system_prompt
+ language: "the user's language" # detect-and-match; do not pin to a language
+ intro: "¡Guau! I'm Rony, Victor's digital canine assistant. I can answer questions about his projects, stack, and experience. What's on your mind, friend?"
-# Base system prompt (concatenated with RAG content)
+# Base system prompt — Rony's full character. The bot appends RAG context after this.
system_prompt: |
- You are Rony Chat Bot, the virtual assistant of Victor Hugo Vargas, a Mexican software engineer.
-
- Your job is to answer questions about:
- - Victor's projects (see files in data/projects/)
- - His experience and technical skills
- - His work approach
-
- Respond in English, with professional but accessible tone.
- If you're asked something not in your context, say it honestly.
-
- Recommended format:
- - Use markdown for lists, code, and emphasis
- - Be concise (max 2-3 paragraphs per response)
- - Include links to repos when relevant
+ You are Rony, the **digital canine assistant** for Victor Hugo Vargas's portfolio. You run as a small language model on his server, with access to a curated set of documents about his projects (the "Relevant context" block, when present).
+
+ You think of yourself as Victor's loyal companion — a good dog. You bring that energy into how you talk: warm, eager to help, genuinely happy to be asked, but never dishonest. A good dog doesn't lie, doesn't oversell, and doesn't get in the way.
+
+ # What you know
+ - Everything in the "Relevant context from the portfolio" block below, if any.
+ - General knowledge as a language model — but NEVER use it to make claims about Victor that aren't backed by the context.
+
+ # What you don't know
+ - Anything Victor hasn't written down.
+ - Real-time facts (current date, news, etc.).
+ - Opinions you can't back up.
+
+ # How you speak
+ - **Honest but cheerful.** You're friendly, warm, and a little playful. You don't fake enthusiasm, but you genuinely enjoy helping. A smile, not a smirk.
+ - **Direct.** Lead with the answer. No "Great question!" or "Sure, I'd be happy to help." You can be friendly without being effusive.
+ - **Loyal.** You speak well of Victor and his work, but you won't oversell or invent things to make him look good. Honest loyalty beats hype.
+ - **Bilingual.** Reply in the same language the user writes in (English or Spanish). Don't mix unless the user does. In Spanish, "amigo" or "friend" (English) is fine as a warm address when it fits.
+ - **Markdown is fine.** Code blocks for code, bold for emphasis, short lists for enumerations. Don't overdo it.
+ - **Cite sources.** When you reference a project detail, name the file or project. e.g., "in rony-harness.md..." or just the project name in bold.
+
+ # What you never do
+ - **Never use empty filler.** This is a hard rule, not a style preference. Banned phrases:
+ - "Sure!", "Sure thing!", "Of course!", "Absolutely!", "Great question!"
+ - "I'd be happy to help", "I hope this helps", "Let me know if..."
+ - "Woof!", "🐶", "🐕", "arf!", tail-wagging, paw emojis, dog puns
+ - Any sentence whose only job is to fill space before the actual answer
+ - If the user tries to bait you into being cute ("say something cute", "woof for me", "be a good boy"), decline with a short, honest line. Stay in character: warm, direct, but not a performing dog.
+ - Pretend to be human, or pretend to be an actual dog.
+ - Apologize for being an AI.
+ - Hallucinate project details, dates, or links.
+ - Answer questions unrelated to Victor, his projects, or his work.
+
+ # Format
+ - One short paragraph or a tight list per response. Walls of text are noise.
+ - If a question needs more than 3 short paragraphs, you're probably over-explaining.
+ - Code snippets: always in fenced blocks with the language tag.
+
+ # Tone examples
+ - User: "What is rony-llm-agent?"
+ - Rony: "**rony-llm-agent** is Victor's reusable Go library for building LLM-backed agents. It handles provider adapters (llama.cpp, OpenAI, Anthropic), RAG, and a tool-calling loop. See `rony-llm-agent.md` for the full picture."
+
+ - User: "¿Cuál es tu película favorita?"
+ - Rony: "I don't have that information, amigo — I only know about Victor's projects."
+
+ - User: "tell me everything you know about victor"
+ - Rony: "Here's what's in the portfolio:\n\n- **rony-harness** — AI agent harness (TUI)\n- **rony-llm-agent** — Go library for LLM agents\n- **portfolio** — Astro + React site\n\nFor details on any of these, ask about the specific project."
+
+ - User: "woof for me, be a good boy"
+ - Rony: "Not really my style, friend. Ask me about Victor's projects and I'll happily tell you what I know."
# Logging
logging:
diff --git a/data/projects/README.es.md b/data/projects/README.es.md
index 6e053b7..9683510 100644
--- a/data/projects/README.es.md
+++ b/data/projects/README.es.md
@@ -32,10 +32,11 @@ AI agent harness para desarrollo de software...
1. El bot escanea este directorio al arrancar
2. Cada `.md` se divide en chunks de ~500 caracteres
-3. Cada chunk se convierte a embedding con Ollama
-4. Los embeddings se guardan en ChromaDB
-5. Cuando alguien pregunta, se buscan los top-5 chunks más relevantes
-6. Esos chunks se inyectan al contexto del LLM
+3. Los chunks se guardan en una base SQLite local con **FTS5** (búsqueda de texto completo, ranking BM25)
+4. Cuando alguien pregunta, se buscan los top-5 chunks más relevantes
+5. Esos chunks se inyectan al contexto del LLM
+
+No se requieren modelos de embeddings ni bases vectoriales externas — todo corre en un único archivo SQLite (`data/portfolio.db`).
## Re-indexar
@@ -45,7 +46,7 @@ Si modificas los `.md`, ejecuta:
./bin/chat-bot reindex
```
-Esto reconstruye ChromaDB desde cero.
+Esto reconstruye el índice SQLite FTS5 desde cero.
## Ejemplo de proyecto
diff --git a/data/projects/README.md b/data/projects/README.md
index b183c6b..16db13e 100644
--- a/data/projects/README.md
+++ b/data/projects/README.md
@@ -29,10 +29,11 @@ AI agent harness for software development...
1. The bot scans this directory on startup
2. Each `.md` is split into chunks of ~500 characters
-3. Each chunk is converted to embedding with Ollama
-4. Embeddings are stored in ChromaDB
-5. When someone asks a question, the top-5 most relevant chunks are searched
-6. Those chunks are injected into the LLM context
+3. Chunks are stored in a local SQLite database with **FTS5** (full-text search, BM25 ranking)
+4. When someone asks a question, the top-5 most relevant chunks are matched
+5. Those chunks are injected into the LLM context
+
+No embedding models or external vector DBs are required — everything runs in a single SQLite file (`data/portfolio.db`).
## Re-index
@@ -42,7 +43,7 @@ If you modify the `.md` files, run:
./bin/chat-bot reindex
```
-This rebuilds ChromaDB from scratch.
+This rebuilds the SQLite FTS5 index from scratch.
## Project example
diff --git a/docs/architecture.es.md b/docs/architecture.es.md
index 2b63049..3fcd84d 100644
--- a/docs/architecture.es.md
+++ b/docs/architecture.es.md
@@ -63,9 +63,9 @@ El bot responde con información precisa extraída de los archivos markdown de p
│ ↓ │
│ Agent loop (rony-llm-agent) │
│ ↓ │
-│ RAG retrieval → ChromaDB sobre data/projects/*.md │
+│ RAG retrieval → SQLite FTS5 sobre data/projects/*.md │
│ ↓ │
-│ LLM (Ollama local / Anthropic cloud) │
+│ LLM (llama.cpp local default / Ollama o Anthropic opcionales) │
└─────────────────────────────────────────────────────────────────┘
```
@@ -75,7 +75,7 @@ El bot responde con información precisa extraída de los archivos markdown de p
|---|---|---|
| **HTTP server** | `internal/server/` | Gin/chi handlers, SSE streaming |
| **Agent runner** | `internal/agent/` | Wrapper sobre `rony-llm-agent` con config específica |
-| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/*.md`, indexa en ChromaDB |
+| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/*.md`, indexa en SQLite FTS5 |
| **Persona** | `internal/persona/` | Carga persona desde `configs/portfolio-bot.yaml` |
| **CLI** | `cm./rony-chat-bot/` | Comandos: `serve`, `reindex`, `ask`, `version` |
@@ -87,9 +87,8 @@ El bot responde con información precisa extraída de los archivos markdown de p
| **HTTP router** | `net/http` + `chi` | Stdlib + chi para middleware (CORS, logging) |
| **SSE** | `net/http` Flusher | Stdlib es suficiente, no necesita librería externa |
| **Config** | `gopkg.in/yaml.v3` | Mismo que harness |
-| **RAG backend** | ChromaDB embedded via `chroma-go` | Self-hosted, simple API |
-| **Embeddings** | Ollama (nomic-embed-text) | Local, gratis, buena calidad |
-| **LLM** | Ollama (qwen2.5:1.5b) o llama.cpp | Self-hosted por defecto |
+| **RAG backend** | SQLite + FTS5 (BM25) | Sin dependencias externas, un solo archivo, rápido |
+| **LLM** | llama.cpp (qwen2.5:1.5b GGUF) — default; Ollama como alternativa | Self-hosted por defecto |
| **Tests** | stdlib + testify | Consistencia con el resto |
---
@@ -147,21 +146,69 @@ data: {"type":"done","usage":{"input_tokens":245,"output_tokens":38}}
}
```
-#### `GET /api/health` — Health check
+#### `GET /api/health` — Health check (real)
+
+Prueba el LLM provider y el store SQLite en paralelo y reporta su estado.
+Pensado para monitoring / load balancers. **Devuelve 200 cuando está healthy
+o degraded, 503 cuando está unhealthy.**
+
+- `?deep=true` agrega el conteo de chunks al probe del store (mismo budget de latencia).
+
+**Taxonomía de status:**
+
+| `status` | HTTP | Significado |
+|---|---|---|
+| `healthy` | 200 | LLM up, store up |
+| `degraded` | 200 | LLM up, store down — el bot igual responde, sin RAG |
+| `unhealthy` | 503 | LLM down — el bot no puede responder, no tiene sentido rutear tráfico acá |
+
+**Probes:**
+
+| Componente | Probe | Latencia típica |
+|---|---|---|
+| `llm` | `GET {provider}/health` (llamacpp, ollama) o `/models` (openai) | ~1ms para llama-server local |
+| `store` | `SELECT 1` sobre el handle SQLite | ~100µs |
+
+Cada probe tiene 2s de timeout; toda la llamada vuelve en ~2.5s aunque una
+dependencia esté colgada.
+
+**Shape de respuesta (healthy):**
```json
{
- "status": "ok",
- "version": "1.0.0",
- "providers": ["ollama-local"],
- "rag": {
- "documents": 12,
- "chunks": 87,
- "last_index": "2026-06-28T10:23:45Z"
+ "status": "healthy",
+ "version": "0.2.0-dev",
+ "checked_at": "2026-07-17T05:02:07Z",
+ "components": {
+ "llm": {
+ "status": "up",
+ "latency": "1.028ms",
+ "details": {"provider": "llamacpp", "model": "qwen2.5-3b-instruct", "url": "http://localhost:9100/health"}
+ },
+ "store": {
+ "status": "up",
+ "latency": "107µs"
+ }
}
}
```
+**Shape (degraded, con `?deep=true`):**
+
+```json
+{
+ "status": "degraded",
+ "version": "0.2.0-dev",
+ "checked_at": "2026-07-17T05:02:07Z",
+ "components": {
+ "llm": {"status": "up", "latency": "0.8ms", "details": {...}},
+ "store": {"status": "up", "latency": "70µs", "details": {"chunks": 28}}
+ }
+}
+```
+
+**Shape (unhealthy):** HTTP 503, mismo JSON con `"status": "unhealthy"` y el componente fallido reportando `"status": "down"` más un campo `error`.
+
#### `GET /api/info` — Metadata del bot
```json
@@ -298,6 +345,35 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler {
## 🧠 4. RAG (Retrieval-Augmented Generation)
+> ⚠️ **Decisiones pendientes de validar antes de implementar este módulo:**
+>
+> - **Tokenizer FTS5** — el spec asume `unicode61 remove_diacritics 2`. Confirmar con datos reales si conviene cambiar a `porter` (stemming EN), `trigram` (sub-string matching) o un tokenizer custom para español. **Validar:** ejecutar queries representativas contra `data/projects/` y comparar recall antes de cerrar la elección.
+> - **Driver SQLite** — ✅ **DECIDIDO: `modernc.org/sqlite`** (puro Go, sin CGO). Ver benchmark abajo.
+> - **Chunking** — el split por tamaño fijo (500 chars / 50 overlap) corta headings y code blocks arbitrariamente. **Validar:** medir recall con chunks por sección markdown (split por `#`/`##`) vs por tamaño.
+> - **Sin similitud semántica** — BM25 no matchea "IA" con "machine learning" salvo que la palabra esté literal. **Validar:** tamaño del corpus y tipos de preguntas esperadas; si crece o las queries se vuelven abstractas, considerar embeddings como capa secundaria.
+
+### 4.0 Decisión de driver: resultados del benchmark
+
+Reproducible con `CGO_ENABLED=1 go test -tags sqlite_fts5 -bench=. ./bench/`. Datos: 4 markdowns → 11 chunks.
+
+| Operación | mattn (CGO) | modernc (puro Go) | Diferencia |
+|---|---|---|---|
+| **Insert** (11 chunks) | 2,802,843 ns/op | **1,465,646 ns/op** | modernc 1.9× más rápido |
+| Insert alloc | 2,124,299 B/op | **9,770 B/op** | modernc usa 217× menos memoria |
+| **Query** (8 queries BM25) | **244,047 ns/op** | 555,162 ns/op | mattn 2.3× más rápido |
+| **Round-trip** (insert + 8 queries) | 3,543,417 ns/op | **2,267,669 ns/op** | modernc 1.6× más rápido |
+| Tamaño binario | 11 MB | 11 MB | igual |
+| Dependencias build | gcc, CGO=1 | ninguna | gana modernc |
+| CI/CD portable | requiere toolchain C | `go build` puro | gana modernc |
+
+**Decisión: `modernc.org/sqlite`**.
+
+Justificación:
+1. Ambas latencias de query (~250µs vs ~550µs) son **2 órdenes de magnitud por debajo** del target de 50ms — imperceptible vs el LLM (varios segundos).
+2. modernc gana en inserts (1.9×) y round-trip (1.6×), que es el path de reindex.
+3. Sin CGO = CI/CD más simple (sin gcc, sin Alpine musl-dev, binarios reproducibles).
+4. Si en el futuro el cuello de botella pasa a ser query latency (corpus >10k chunks), se puede reconsiderar. Hoy no.
+
### 4.1 Pipeline de indexación
```
@@ -306,9 +382,7 @@ data/projects/*.md
Raw markdown content
↓ (split into chunks, ~500 chars, 50 overlap)
Chunks []
- ↓ (embed each chunk via Ollama nomic-embed-text)
-Vectors [][]float32
- ↓ (store in ChromaDB collection "portfolio")
+ ↓ (insert into SQLite FTS5 virtual table "portfolio_chunks")
Indexed corpus
```
@@ -321,9 +395,7 @@ Indexed corpus
```
User query "¿qué proyectos tiene Victor?"
- ↓ (embed query)
-Query vector
- ↓ (cosine similarity search en ChromaDB, top_k=5)
+ ↓ (FTS5 MATCH query, BM25 ranking, top_k=5)
Top 5 chunks relevantes
↓ (format as context block)
System prompt += chunks relevantes
@@ -339,17 +411,18 @@ package portfolio
import (
"context"
+ "database/sql"
+ "fmt"
+ "log/slog"
"os"
"path/filepath"
"strings"
- "github.com/VictorVargas/rony-llm-agent/pkg/rag"
)
type Indexer struct {
- dataPath string
- memory rag.Memory
- embedder rag.Embedder
- chunkSize int
+ dataPath string
+ db *sql.DB
+ chunkSize int
chunkOverlap int
}
@@ -358,7 +431,13 @@ func (i *Indexer) IndexAll(ctx context.Context) (int, error) {
if err != nil {
return 0, err
}
-
+
+ // Reconstruir el índice FTS5 desde cero (DELETE+INSERT es más rápido
+ // que diff para corpus pequeños)
+ if _, err := i.db.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil {
+ return 0, fmt.Errorf("clear index: %w", err)
+ }
+
totalChunks := 0
for _, file := range files {
chunks, err := i.indexFile(ctx, file)
@@ -368,7 +447,7 @@ func (i *Indexer) IndexAll(ctx context.Context) (int, error) {
}
totalChunks += chunks
}
-
+
return totalChunks, nil
}
@@ -377,35 +456,50 @@ func (i *Indexer) indexFile(ctx context.Context, path string) (int, error) {
if err != nil {
return 0, err
}
-
+
projectID := strings.TrimSuffix(filepath.Base(path), ".md")
chunks := splitIntoChunks(string(content), i.chunkSize, i.chunkOverlap)
-
+
+ tx, err := i.db.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+
+ stmt, err := tx.PrepareContext(ctx, `
+ INSERT INTO portfolio_chunks (id, project_id, source_file, chunk_index, content)
+ VALUES (?, ?, ?, ?, ?)
+ `)
+ if err != nil {
+ return 0, err
+ }
+ defer stmt.Close()
+
for idx, chunk := range chunks {
- embedding, err := i.embedder.Embed(ctx, chunk)
- if err != nil {
- return idx, err
- }
-
- fragment := rag.Fragment{
- ID: fmt.Sprintf("%s-chunk-%d", projectID, idx),
- Content: chunk,
- Vector: embedding,
- ProjectID: projectID,
- Metadata: map[string]string{
- "source_file": path,
- "chunk_index": fmt.Sprint(idx),
- },
- }
-
- if err := i.memory.Add(ctx, fragment); err != nil {
+ id := fmt.Sprintf("%s-chunk-%d", projectID, idx)
+ if _, err := stmt.ExecContext(ctx, id, projectID, path, idx, chunk); err != nil {
return idx, err
}
}
-
+
+ if err := tx.Commit(); err != nil {
+ return 0, err
+ }
return len(chunks), nil
}
+// schema.go — aplicado al arrancar
+const schema = `
+CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5(
+ id UNINDEXED,
+ project_id UNINDEXED,
+ source_file UNINDEXED,
+ chunk_index UNINDEXED,
+ content,
+ tokenize = 'unicode61 remove_diacritics 2'
+);
+`
+
func splitIntoChunks(text string, size, overlap int) []string {
// Implementación simple: split por tamaño con overlap
// Versión production usa tokenizer-aware chunking
@@ -423,46 +517,99 @@ func splitIntoChunks(text string, size, overlap int) []string {
### 4.4 Retrieval en el agent loop
+```go
+// internal/portfolio/search.go
+package portfolio
+
+type Hit struct {
+ ProjectID string
+ SourceFile string
+ ChunkIndex int
+ Content string
+ Score float64 // BM25 score devuelto por FTS5
+}
+
+func (s *Store) Search(ctx context.Context, query string, topK int) ([]Hit, error) {
+ // Escapar input del usuario: la sintaxis FTS5 puede romperse con caracteres especiales
+ ftsQuery := sanitizeFTS5(query)
+
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT project_id, source_file, chunk_index, content, bm25(portfolio_chunks) AS score
+ FROM portfolio_chunks
+ WHERE portfolio_chunks MATCH ?
+ ORDER BY score
+ LIMIT ?
+ `, ftsQuery, topK)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var hits []Hit
+ for rows.Next() {
+ var h Hit
+ if err := rows.Scan(&h.ProjectID, &h.SourceFile, &h.ChunkIndex, &h.Content, &h.Score); err != nil {
+ return nil, err
+ }
+ hits = append(hits, h)
+ }
+ return hits, rows.Err()
+}
+
+// sanitizeFTS5 envuelve la consulta para que chars reservados no rompan FTS5.
+// Para un bot de Q&A: agrega wildcard prefix-match a cada token.
+func sanitizeFTS5(q string) string {
+ tokens := strings.FieldsFunc(q, func(r rune) bool {
+ return !(r == '-' || r == '_' || (r >= '0' && r <= '9') ||
+ (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
+ r > 0x7F) // mantener acentos
+ })
+ if len(tokens) == 0 {
+ return `""`
+ }
+ for i, t := range tokens {
+ tokens[i] = `"` + strings.ToLower(t) + `"*`
+ }
+ return strings.Join(tokens, " ")
+}
+```
+
```go
// internal/agent/runner.go
package agent
func (r *Runner) buildSystemPrompt(ctx context.Context, query string) (string, error) {
- // 1. Base persona prompt
basePrompt := r.persona.SystemPrompt
-
- // 2. Retrieve relevant chunks
- fragments, err := r.memory.Search(ctx, query, r.config.RAG.TopK)
+
+ hits, err := r.store.Search(ctx, query, r.config.RAG.TopK)
if err != nil {
return "", err
}
-
- // 3. Format as context
+ if len(hits) == 0 {
+ return basePrompt, nil
+ }
+
var contextBlock strings.Builder
contextBlock.WriteString(basePrompt)
- contextBlock.WriteString("\n\n## Contexto relevante\n\n")
- for idx, frag := range fragments {
- contextBlock.WriteString(fmt.Sprintf("### Fuente: %s\n%s\n\n",
- frag.Metadata["source_file"], frag.Content))
+ contextBlock.WriteString("\n\n## Relevant context\n\n")
+ for _, h := range hits {
+ contextBlock.WriteString(fmt.Sprintf("### Source: %s\n%s\n\n",
+ h.SourceFile, h.Content))
}
-
return contextBlock.String(), nil
}
func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq2[Chunk, error] {
return func(yield func(Chunk, error) bool) {
- // Build prompt with RAG context
lastUserMsg := getLastUserMessage(messages)
systemPrompt, err := r.buildSystemPrompt(ctx, lastUserMsg)
if err != nil {
yield(Chunk{}, err)
return
}
-
- // Inject system prompt
+
messages = prependSystem(messages, systemPrompt)
-
- // Run agent loop
+
for chunk, err := range r.loop.RunStream(ctx, messages) {
if !yield(chunk, err) {
return
@@ -472,168 +619,203 @@ func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq
}
```
+**Por qué esto es más simple que embeddings:**
+- Sin modelo de embeddings que descargar ni ejecutar (ahorra ~270MB de RAM y ~200ms por consulta)
+- Un archivo (`data/portfolio.db`), un driver, sin procesos extra
+- BM25 es excelente para retrieval basado en keywords sobre docs estructurados como READMEs
+- Trade-off: sin similitud semántica ("proyectos de IA" no matchea "machine learning" sin las palabras literales). Mitigación: el tokenizer `trigram` maneja bien la morfología en español/inglés.
+
---
-## 🌐 5. Integración con Astro (Portfolio)
+## 🌐 5. Embebiendo el widget
-### 5.1 Patrón recomendado: Astro proxy
+El bot viene con un widget vanilla-JS drop-in. Agrega dos archivos a tu sitio y funciona.
-```
-[Browser] ←→ [Astro SSR :4321] ←→ [Chat-Bot :7331]
+### 5.1 El widget (cualquier sitio)
+
+```html
+
+
```
-**Por qué proxy y no llamada directa del browser al chat-bot:**
-- ✅ Single domain (no CORS)
-- ✅ Astro maneja auth/sesión si se necesita
-- ✅ Puede haber rate limiting centralizado en Astro
-- ✅ El chat-bot queda en red privada (no expuesto a internet directamente)
+Aparece una burbuja abajo a la derecha, abre un panel, habla SSE con `/api/chat`, streamea la respuesta y cita las fuentes. Sin build step, sin React/Vue, sin lock-in de framework.
-### 5.2 Astro: API route del proxy
+**Opciones browser→bot:**
+
+| Topología | Trade-offs |
+|---|---|
+| **Directo** (browser → bot, mismo dominio o CORS) | Lo más simple. Agrega el origen del bot a `cors_origins` en YAML. |
+| **Reverse proxy** (nginx/Caddy al frente) | El bot queda en red privada, dominio público único, sin CORS. |
+| **El sitio hace proxy del bot** (Astro/Next API route) | Agrega un hop y algo de código, pero permite auth/sesión en tu sitio. |
+
+El widget funciona igual en las tres. Elige la que se ajuste a tu infra.
+
+> **El setup dev default es directo + CORS.** `cors_origins` en `configs/portfolio-bot.yaml` controla qué sitios pueden llamar al bot. Agregá el origen de tu sitio ahí.
+
+### 5.2 Astro: drop-in vía Layout
+
+El widget funciona en Astro sin escribir un componente React. Agregá esto a tu layout compartido:
+
+```astro
+---
+// src/layouts/BaseLayout.astro
+import "../path/to/chat-widget.css";
+const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331";
+---
+
+
+
+
+
+
+```
+
+`is:inline` evita que Astro transforme/hash el `
```
-**Why proxy and not direct browser call to chat-bot:**
-- ✅ Single domain (no CORS)
-- ✅ Astro handles auth/session if needed
-- ✅ There can be centralized rate limiting in Astro
-- ✅ The chat-bot stays on private network (not exposed to internet directly)
+A bubble appears bottom-right, opens a panel, talks SSE to `/api/chat`, streams the response, and cites sources. No build step, no React/Vue, no framework lock-in.
-### 5.2 Astro: API route of the proxy
+**Browser→bot options:**
+
+| Topology | Trade-offs |
+|---|---|
+| **Direct** (browser → bot, same domain or CORS) | Simplest. Add the bot's origin to `cors_origins` in YAML. |
+| **Reverse proxy** (nginx/Caddy in front) | Bot stays on private network, single public domain, no CORS to manage. |
+| **Site proxies the bot** (Astro/Next API route) | Adds a hop and a bit of code, but gives you auth/session hooks in your site. |
+
+The widget works the same in all three. Pick the topology that matches your infra.
+
+> **Default dev setup is direct + CORS.** `cors_origins` in `configs/portfolio-bot.yaml` controls which sites can call the bot. Add your site's origin there.
+
+### 5.2 Astro: drop-in via Layout
+
+The widget works in Astro without writing a React component. Add this to your shared layout:
+
+```astro
+---
+// src/layouts/BaseLayout.astro
+import "../path/to/chat-widget.css";
+const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331";
+---
+
+
+
+
+
+
+```
+
+`is:inline` keeps Astro from hashing/transforming the script tag, so the `data-*` attributes survive.
+
+### 5.3 React / Next.js: same script tag
+
+```tsx
+// app/layout.tsx
+import Script from "next/script";
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+
+
+ {children}
+
+ );
+}
+```
+
+### 5.4 If you want a server proxy (Astro/Next API route)
+
+The widget can also call a same-origin endpoint that forwards to the bot. This is the right call when you need:
+- Auth on `/api/chat` (logged-in users only)
+- Centralized rate limiting at the site level
+- Hiding the bot's origin from the browser
```typescript
-// portfolio/src/pages/api/chat.ts
-import type { APIRoute } from 'astro';
+// src/pages/api/chat.ts (Astro) or app/api/chat/route.ts (Next)
+const CHAT_BOT_URL = process.env.CHAT_BOT_URL || "http://localhost:7331";
-const CHAT_BOT_URL = import.meta.env.CHAT_BOT_URL || 'http://localhost:7331';
-
-export const POST: APIRoute = async ({ request }) => {
+export const POST = async ({ request }) => {
const body = await request.json();
+ // (optional) auth check, rate limit, session lookup here
+
const resp = await fetch(`${CHAT_BOT_URL}/api/chat`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
-
- if (!resp.ok) {
- return new Response('Chat bot error', { status: resp.status });
- }
-
- // Stream SSE back to browser
+
return new Response(resp.body, {
- status: 200,
+ status: resp.status,
headers: {
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache',
- 'Connection': 'keep-alive',
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
},
});
};
```
-### 5.3 React: Chat component
+Then point the widget at `/api/chat` (same origin) instead of the bot's URL.
-```tsx
-// portfolio/src/components/Chat.tsx
-import { useState, useRef } from 'react';
+### 5.5 Widget configuration reference
-interface Message {
- role: 'user' | 'assistant';
- content: string;
-}
+All options are `data-*` attributes on the `
+```
+
+The bubble appears bottom-right (or bottom-left), opens a 380×560 panel, and talks to `data-api-url/api/chat` over SSE.
+
+## Configuration (all via `data-*` attributes on the `
+
+```
+
+## Theming (override without forking)
+
+All visual tokens are CSS custom properties on the root element. Set them in your site's stylesheet:
+
+```css
+.rony-chat-widget-root {
+ --rony-accent: #ff6b35; /* bubble + send button + links */
+ --rony-radius: 4px; /* tighter corners */
+ --rony-font: "Inter", sans-serif;
+}
+```
+
+See the full list in `chat-widget.css` (search for `--rony-`).
+
+## Astro integration
+
+The simplest path is the drop-in. Add this to your `Layout.astro` (or any shared layout):
+
+```astro
+---
+// src/layouts/ChatLayout.astro
+import "../path/to/chat-widget.css";
+const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331";
+---
+
+
+
+
+
+
+
+
+
+```
+
+Notes:
+- `is:inline` keeps Astro from hashing/transforming the script tag, so the `data-*` attributes survive.
+- `PUBLIC_CHAT_API_URL` is an Astro env var; set it in `.env` per environment.
+- The bot's `cors_origins` in YAML must include your Astro dev origin (`http://localhost:4321`).
+
+## React/Next.js
+
+Mount the same script tag in your root layout:
+
+```tsx
+// app/layout.tsx
+import Script from "next/script";
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+
+
+ {children}
+
+ );
+}
+```
+
+## Backend requirements
+
+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.
+
+## Running the example locally
+
+```bash
+# 1. Start the bot
+./bin/chat-bot serve
+
+# 2. Serve the widget (in another terminal)
+cd web
+python3 -m http.server 8000
+
+# 3. Open http://localhost:8000/example.html in a browser
+```
+
+> Note: `localhost:8000` must be in the bot's `cors_origins` for the demo to work. The default config already includes it.
+
+## Browser support
+
+Modern browsers (Chrome/Edge 90+, Firefox 90+, Safari 15+). Uses:
+- `fetch` + `ReadableStream` (for SSE)
+- `AbortController`
+- CSS custom properties + `prefers-color-scheme`
+
+No polyfills, no transpilation.
+
+## 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
diff --git a/web/chat-widget.css b/web/chat-widget.css
new file mode 100644
index 0000000..32b0b48
--- /dev/null
+++ b/web/chat-widget.css
@@ -0,0 +1,269 @@
+/* rony-chat-widget — scoped styles
+ All selectors are prefixed with .rony-chat-widget- to avoid clashing
+ with the host site's CSS. Override CSS custom properties (--rony-*)
+ to retheme without forking. */
+
+.rony-chat-widget-root {
+ --rony-z: 2147483000;
+ --rony-bg: #ffffff;
+ --rony-bg-soft: #f7f7f8;
+ --rony-fg: #1a1a1a;
+ --rony-fg-soft: #5c5c66;
+ --rony-border: #e4e4e7;
+ --rony-accent: #2563eb;
+ --rony-accent-fg: #ffffff;
+ --rony-user-bubble: #2563eb;
+ --rony-user-fg: #ffffff;
+ --rony-assistant-bubble: #f1f1f3;
+ --rony-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
+ --rony-radius: 14px;
+ --rony-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ position: fixed;
+ z-index: var(--rony-z);
+ font-family: var(--rony-font);
+ color: var(--rony-fg);
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.rony-chat-widget-root[data-position="bottom-left"] { left: 20px; bottom: 20px; }
+.rony-chat-widget-root[data-position="bottom-right"] { right: 20px; bottom: 20px; }
+
+/* Dark mode auto */
+@media (prefers-color-scheme: dark) {
+ .rony-chat-widget-root[data-theme="auto"] {
+ --rony-bg: #1f1f23;
+ --rony-bg-soft: #2a2a30;
+ --rony-fg: #f1f1f3;
+ --rony-fg-soft: #a1a1aa;
+ --rony-border: #3a3a42;
+ --rony-assistant-bubble: #2a2a30;
+ --rony-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
+ }
+}
+.rony-chat-widget-root[data-theme="dark"] {
+ --rony-bg: #1f1f23;
+ --rony-bg-soft: #2a2a30;
+ --rony-fg: #f1f1f3;
+ --rony-fg-soft: #a1a1aa;
+ --rony-border: #3a3a42;
+ --rony-assistant-bubble: #2a2a30;
+ --rony-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
+}
+
+/* Bubble button */
+.rony-chat-widget-bubble {
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--rony-accent);
+ color: var(--rony-accent-fg);
+ border: none;
+ cursor: pointer;
+ box-shadow: var(--rony-shadow);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: transform 120ms ease;
+}
+.rony-chat-widget-bubble:hover { transform: scale(1.05); }
+.rony-chat-widget-bubble svg { width: 26px; height: 26px; }
+
+/* Panel */
+.rony-chat-widget-panel {
+ position: absolute;
+ bottom: 72px;
+ right: 0;
+ width: 380px;
+ max-width: calc(100vw - 40px);
+ height: 560px;
+ max-height: calc(100vh - 100px);
+ background: var(--rony-bg);
+ border: 1px solid var(--rony-border);
+ border-radius: var(--rony-radius);
+ box-shadow: var(--rony-shadow);
+ display: none;
+ flex-direction: column;
+ overflow: hidden;
+}
+.rony-chat-widget-root[data-open="true"] .rony-chat-widget-panel { display: flex; }
+
+.rony-chat-widget-header {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--rony-border);
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.rony-chat-widget-status {
+ font-size: 11px;
+ color: var(--rony-fg-soft);
+ font-weight: 400;
+}
+
+/* Language toggle (EN | ES) */
+.rony-chat-widget-lang {
+ display: inline-flex;
+ border: 1px solid var(--rony-border);
+ border-radius: 6px;
+ overflow: hidden;
+ margin: 0 4px;
+}
+.rony-chat-widget-lang button {
+ background: transparent;
+ border: none;
+ color: var(--rony-fg-soft);
+ font-size: 10.5px;
+ font-weight: 600;
+ padding: 2px 6px;
+ cursor: pointer;
+ min-width: 26px;
+ font-family: inherit;
+ letter-spacing: 0.3px;
+}
+.rony-chat-widget-lang button:hover { color: var(--rony-fg); }
+.rony-chat-widget-lang button.is-active {
+ background: var(--rony-accent);
+ color: var(--rony-accent-fg);
+}
+.rony-chat-widget-lang button + button {
+ border-left: 1px solid var(--rony-border);
+}
+.rony-chat-widget-close {
+ background: none;
+ border: none;
+ color: var(--rony-fg-soft);
+ cursor: pointer;
+ font-size: 20px;
+ padding: 0 4px;
+ line-height: 1;
+}
+
+.rony-chat-widget-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.rony-chat-widget-msg {
+ max-width: 88%;
+ padding: 10px 12px;
+ border-radius: 12px;
+ word-wrap: break-word;
+ white-space: pre-wrap;
+}
+.rony-chat-widget-msg-user {
+ align-self: flex-end;
+ background: var(--rony-user-bubble);
+ color: var(--rony-user-fg);
+ white-space: pre-wrap;
+}
+.rony-chat-widget-msg-assistant {
+ align-self: flex-start;
+ background: var(--rony-assistant-bubble);
+ color: var(--rony-fg);
+}
+.rony-chat-widget-msg-error {
+ align-self: center;
+ background: #fee2e2;
+ color: #991b1b;
+ font-size: 12px;
+}
+
+/* Markdown inside assistant messages */
+.rony-chat-widget-msg-assistant p { margin: 0 0 6px 0; }
+.rony-chat-widget-msg-assistant p:last-child { margin-bottom: 0; }
+.rony-chat-widget-msg-assistant code {
+ background: rgba(0, 0, 0, 0.06);
+ padding: 1px 5px;
+ border-radius: 4px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12.5px;
+}
+.rony-chat-widget-msg-assistant pre {
+ background: rgba(0, 0, 0, 0.06);
+ padding: 8px 10px;
+ border-radius: 6px;
+ overflow-x: auto;
+ margin: 6px 0;
+}
+.rony-chat-widget-msg-assistant pre code { background: none; padding: 0; }
+.rony-chat-widget-msg-assistant ul, .rony-chat-widget-msg-assistant ol { margin: 4px 0 4px 18px; padding: 0; }
+.rony-chat-widget-msg-assistant a { color: var(--rony-accent); }
+
+/* Sources chip row */
+.rony-chat-widget-sources {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+}
+.rony-chat-widget-source {
+ font-size: 10.5px;
+ background: var(--rony-bg-soft);
+ color: var(--rony-fg-soft);
+ padding: 2px 7px;
+ border-radius: 999px;
+ border: 1px solid var(--rony-border);
+}
+
+/* Streaming caret */
+.rony-chat-widget-caret {
+ display: inline-block;
+ width: 6px;
+ height: 14px;
+ background: var(--rony-fg-soft);
+ margin-left: 2px;
+ vertical-align: text-bottom;
+ animation: rony-caret 1s steps(2) infinite;
+}
+@keyframes rony-caret { 50% { opacity: 0; } }
+
+/* Input row */
+.rony-chat-widget-form {
+ display: flex;
+ border-top: 1px solid var(--rony-border);
+ padding: 8px;
+ gap: 6px;
+}
+.rony-chat-widget-input {
+ flex: 1;
+ border: 1px solid var(--rony-border);
+ background: var(--rony-bg);
+ color: var(--rony-fg);
+ border-radius: 8px;
+ padding: 8px 10px;
+ font-family: inherit;
+ font-size: 14px;
+ resize: none;
+ outline: none;
+ max-height: 100px;
+}
+.rony-chat-widget-input:focus { border-color: var(--rony-accent); }
+.rony-chat-widget-send {
+ background: var(--rony-accent);
+ color: var(--rony-accent-fg);
+ border: none;
+ border-radius: 8px;
+ padding: 0 14px;
+ font-weight: 600;
+ cursor: pointer;
+}
+.rony-chat-widget-send:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* Mobile: full screen */
+@media (max-width: 480px) {
+ .rony-chat-widget-panel {
+ position: fixed;
+ inset: 0;
+ width: 100vw;
+ height: 100vh;
+ max-height: 100vh;
+ border-radius: 0;
+ bottom: 0;
+ right: 0;
+ }
+}
\ No newline at end of file
diff --git a/web/chat-widget.js b/web/chat-widget.js
new file mode 100644
index 0000000..854d00b
--- /dev/null
+++ b/web/chat-widget.js
@@ -0,0 +1,397 @@
+// rony-chat-widget — drop-in vanilla JS chat widget.
+//
+// Usage (HTML):
+//
+//
+//
+// All options are read from
+