diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d2dfef4..3aff555 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -86,9 +86,54 @@ jobs: go mod download go mod tidy + - name: Bootstrap CI test database + env: + PGHOST: ${{ env.POSTGRES_HOST }} + PGPORT: ${{ env.POSTGRES_PORT }} + PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }} + PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }} + APP_USER: ${{ secrets.POSTGRES_USER }} + APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + DB_NAME: chat_switchboard_ci + run: | + echo "━━━ CI Test Database Setup ━━━" + # Create DB for Go integration tests (admin creds) + DB_EXISTS=$(psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}';" postgres || echo "0") + if [[ "${DB_EXISTS}" != "1" ]]; then + psql -c "CREATE DATABASE ${DB_NAME} OWNER ${APP_USER};" postgres + echo "✓ Created ${DB_NAME}" + else + echo "✓ ${DB_NAME} already exists" + fi + # Extensions + psql -d "${DB_NAME}" -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' + psql -d "${DB_NAME}" -c 'CREATE EXTENSION IF NOT EXISTS "pgcrypto";' + # Grant + psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${APP_USER};" + psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${APP_USER};" + psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER};" + psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER};" + echo "✓ CI test database ready" + - name: Run tests working-directory: server - run: go test -v -race ./... + env: + PGHOST: ${{ env.POSTGRES_HOST }} + PGPORT: ${{ env.POSTGRES_PORT }} + PGUSER: ${{ secrets.POSTGRES_USER }} + PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + run: go test -v -race -count=1 ./... + + - name: Drop CI test database + if: always() + env: + PGHOST: ${{ env.POSTGRES_HOST }} + PGPORT: ${{ env.POSTGRES_PORT }} + PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }} + PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }} + run: | + psql -c "DROP DATABASE IF EXISTS chat_switchboard_ci;" postgres + echo "✓ Dropped CI test database" - name: Build check working-directory: server diff --git a/TOOLS_IMPL.md b/TOOLS_IMPL.md new file mode 100644 index 0000000..4bab1c1 --- /dev/null +++ b/TOOLS_IMPL.md @@ -0,0 +1,102 @@ +# Notes & Tool Execution Framework — Implementation + +**Version:** 0.7.2 (Phase 2 per ARCHITECTURE.md §10) +**Depends on:** channels (006), messages with tree (013) — both deployed + +--- + +## Status: Complete + +### Migration +- [x] `014_notes.sql` — notes table, tsvector full-text search, folder/tag indexes, auto-update trigger + +### Notes API (handlers/notes.go) +- [x] `POST /notes` — create note with title, content, folder, tags, source_channel_id +- [x] `GET /notes` — list with folder/tag filter, pagination +- [x] `GET /notes/:id` — get single note +- [x] `PUT /notes/:id` — update with replace/append/prepend modes +- [x] `DELETE /notes/:id` — delete +- [x] `GET /notes/search?q=` — full-text search with ts_rank + ts_headline +- [x] `GET /notes/folders` — list distinct folder paths with counts + +### Tool Type System (tools/types.go) +- [x] `ToolDef` — name, description, JSON Schema parameters (sent to LLM) +- [x] `ToolCall` — ID, name, arguments (from LLM response) +- [x] `ToolResult` — tool_call_id, name, content, is_error (fed back) +- [x] `Tool` interface — `Definition()` + `Execute(ctx, execCtx, argsJSON)` +- [x] `ExecutionContext` — userID, channelID for scoped execution +- [x] JSON Schema helpers: `Prop`, `PropEnum`, `PropArray`, `JSONSchema` + +### Tool Registry (tools/registry.go) +- [x] `Register(Tool)` — global registry, called from init() +- [x] `Get(name)` — lookup by name +- [x] `AllDefinitions()` — all registered tool schemas for LLM requests +- [x] `ExecuteCall()` — runs a single tool, returns ToolResult (never errors) +- [x] `ExecuteAll()` — batch execution +- [x] `HasTools()` — check if any tools registered + +### Note Tools (tools/notes.go) +- [x] `note_create` — create with title, content, folder, tags; links source_channel_id +- [x] `note_search` — full-text search with relevance ranking and excerpts +- [x] `note_update` — update by note_id with replace/append/prepend modes +- [x] `note_list` — list by folder/tag filter +- All registered via init() + +### Provider Tool Calling Support +- [x] `providers.Message` — extended with ToolCalls, ToolCallID, Name fields +- [x] `providers.CompletionRequest` — Tools field for function definitions +- [x] `providers.CompletionResponse` — ToolCalls field for function calls +- [x] `providers.StreamEvent` — ToolCalls accumulated on final event +- [x] OpenAI provider — full tool_calls in request/response/stream +- [x] Anthropic provider — tool_use/tool_result content blocks, input_json_delta streaming, consecutive user message merging +- [x] OpenRouter — inherits from OpenAI (delegation) +- [x] Venice — inherits from OpenAI (delegation) + +### Completion Handler Tool Loop (handlers/completion.go) +- [x] `buildToolDefs()` — converts registered tools to provider format +- [x] Tools attached when `caps.ToolCalling && tools.HasTools()` +- [x] Streaming: tool loop with max 10 iterations + - Stream text deltas normally + - On `finish_reason: "tool_calls"`: execute tools, send SSE events + - Custom SSE events: `event: tool_use` and `event: tool_result` + - Re-call provider with tool results appended to messages + - Final text response streamed normally +- [x] Non-streaming: same loop, tools executed synchronously +- [x] Safety: `maxToolIterations = 10` prevents infinite loops + +--- + +## SSE Events for Tool Execution + +The streaming completion sends custom named SSE events during tool execution. +Standard data events continue to carry OpenAI-compatible content deltas. + +| Event | Payload | When | +|-------|---------|------| +| `tool_use` | `[{"id":"...","type":"function","function":{"name":"note_create","arguments":"..."}}]` | LLM requests tool calls | +| `tool_result` | `{"tool_call_id":"...","name":"note_create","content":"...","is_error":false}` | Tool execution complete | + +The frontend can listen for these to show tool execution status. +Existing frontends that only handle `data:` events are unaffected. + +--- + +## Design Decisions + +1. **Separate packages** — `tools/` is independent of `handlers/`. Tools don't know about HTTP. The completion handler bridges them. +2. **init() registration** — Tools self-register. Adding a new tool = create file with init(), done. No central wiring. +3. **JSON Schema parameters** — Tool definitions use standard JSON Schema, matching OpenAI's function calling spec. +4. **Non-streaming tool loop** — After tool execution, re-calls use streaming. Each iteration streams text deltas. +5. **Anthropic content blocks** — Full translation: `tool_use` blocks, `tool_result` as user messages, consecutive user message merging for alternating-role requirement. +6. **Folder paths** — Normalized to `/path/` format. Root is `/`. Tree structure without a folders table. +7. **Search** — PostgreSQL tsvector with weighted ranks (title=A, content=B). No external search infrastructure needed. + +--- + +## Frontend Work + +- [x] Notes panel/modal in UI +- [x] Notes CRUD forms (create, edit, list, search) +- [x] Tool execution indicators during streaming +- [x] Parse `event: tool_use` / `event: tool_result` SSE events +- [x] Display tool activity in message bubbles diff --git a/VERSION b/VERSION index 39e898a..7486fdb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.1 +0.7.2 diff --git a/scripts/db-validate.sh b/scripts/db-validate.sh index 728eb11..99a649d 100644 --- a/scripts/db-validate.sh +++ b/scripts/db-validate.sh @@ -190,6 +190,25 @@ check_column "model_presets" "scope" check_column "model_presets" "created_by" check_column "model_presets" "is_active" +# ── Migration 013: Message Forking ─────────── +echo "" +echo "Migration 013: Message Forking" +check_column "messages" "deleted_at" +check_column "messages" "sibling_index" + +# ── Migration 014: Notes ───────────────────── +echo "" +echo "Migration 014: Notes" +check_table "notes" +check_column "notes" "user_id" +check_column "notes" "title" +check_column "notes" "content" +check_column "notes" "folder_path" +check_column "notes" "tags" +check_column "notes" "metadata" +check_column "notes" "source_channel_id" +check_column "notes" "search_vector" + # ═══════════════════════════════════════════ # ADD NEW MIGRATION CHECKS ABOVE THIS LINE # ═══════════════════════════════════════════ diff --git a/server/database/migrations/014_notes.sql b/server/database/migrations/014_notes.sql new file mode 100644 index 0000000..f67bb1a --- /dev/null +++ b/server/database/migrations/014_notes.sql @@ -0,0 +1,50 @@ +-- 014_notes.sql — Notes table with full-text search +-- +-- Notes are user-scoped persistent documents. The LLM can create, search, +-- and update them via built-in tools (note_create, note_search, etc.). +-- Full-text search uses PostgreSQL tsvector — zero additional infrastructure. +-- +-- DROP first: if a previous deployment created an incomplete version of +-- this table (e.g. missing search_vector), IF NOT EXISTS would skip and +-- the indexes/trigger would fail. Safe because 014 was never recorded +-- in schema_migrations — any existing data is from a failed attempt. + +DROP TABLE IF EXISTS notes CASCADE; + +CREATE TABLE notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + content TEXT DEFAULT '', + folder_path TEXT DEFAULT '/', + tags TEXT[] DEFAULT '{}', + metadata JSONB DEFAULT '{}', + source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL, + search_vector TSVECTOR, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_notes_user ON notes(user_id); +CREATE INDEX idx_notes_search ON notes USING GIN(search_vector); +CREATE INDEX idx_notes_folder ON notes(user_id, folder_path); +CREATE INDEX idx_notes_tags ON notes USING GIN(tags); +CREATE INDEX idx_notes_updated ON notes(user_id, updated_at DESC); + +-- Auto-update search vector from title + content +CREATE OR REPLACE FUNCTION notes_search_update_fn() +RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') || + setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B'); + NEW.updated_at := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Drop first to allow re-run +DROP TRIGGER IF EXISTS notes_search_update ON notes; +CREATE TRIGGER notes_search_update + BEFORE INSERT OR UPDATE OF title, content ON notes + FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn(); diff --git a/server/database/testhelper.go b/server/database/testhelper.go new file mode 100644 index 0000000..10cf286 --- /dev/null +++ b/server/database/testhelper.go @@ -0,0 +1,246 @@ +package database + +import ( + "database/sql" + "fmt" + "log" + "os" + "strings" + "testing" + + _ "github.com/lib/pq" +) + +// TestDB holds a connection to the test database. +// Use SetupTestDB in TestMain and pass this to subtests. +var TestDB *sql.DB + +const testDBName = "chat_switchboard_ci" + +// SetupTestDB connects to PostgreSQL, creates the CI test database (or +// connects to an existing one created by CI bootstrap), runs all +// migrations, and sets database.DB so handlers can use it. +// +// Call in TestMain: +// +// func TestMain(m *testing.M) { +// teardown := database.SetupTestDB() +// code := m.Run() +// teardown() +// os.Exit(code) +// } +// +// Requires env: TEST_DATABASE_URL (full DSN to maintenance DB, e.g. postgres) +// OR individual: PGHOST, PGPORT, PGUSER, PGPASSWORD +// +// Returns a cleanup function that drops the test database. +func SetupTestDB() func() { + mainDSN := os.Getenv("TEST_DATABASE_URL") + host := envOr("PGHOST", "") + port := envOr("PGPORT", "5432") + user := envOr("PGUSER", "") + pass := envOr("PGPASSWORD", "") + + if mainDSN == "" { + if host == "" || user == "" { + log.Println("⚠ TEST_DATABASE_URL / PGHOST+PGUSER not set — skipping DB tests") + return func() {} + } + mainDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", + host, port, user, pass) + } + + // Try to connect to admin/maintenance DB for create/drop operations + adminDB, err := sql.Open("postgres", mainDSN) + if err != nil { + log.Printf("⚠ Cannot connect to admin DB: %v — skipping DB tests", err) + return func() {} + } + if err := adminDB.Ping(); err != nil { + adminDB.Close() + log.Printf("⚠ Cannot ping admin DB: %v — skipping DB tests", err) + return func() {} + } + + // Attempt to create the test DB. If the user lacks CREATE DATABASE + // permissions (e.g. CI already created it via admin bootstrap step), + // that's fine — we'll just connect to the existing one. + createdByUs := false + adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName)) + if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil { + // Permission denied is expected in CI — the bootstrap step + // already created the DB with admin creds. Just proceed. + log.Printf("⚠ Cannot CREATE DATABASE %s (will try to connect to existing): %v", testDBName, err) + } else { + createdByUs = true + log.Printf("✓ Created test database: %s", testDBName) + } + + // Build DSN for the test DB + var testDSN string + if host != "" { + testDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, port, user, pass, testDBName) + } else { + testDSN = replaceDBName(mainDSN, testDBName) + } + + // Connect to test DB + DB, err = sql.Open("postgres", testDSN) + if err != nil { + adminDB.Close() + log.Printf("⚠ Cannot connect to test DB: %v — skipping DB tests", err) + return func() {} + } + if err := DB.Ping(); err != nil { + DB.Close() + DB = nil + adminDB.Close() + log.Printf("⚠ Cannot ping test DB: %v — skipping DB tests", err) + return func() {} + } + TestDB = DB + + // Ensure required extensions (may already exist from CI bootstrap) + for _, ext := range []string{"uuid-ossp", "pgcrypto"} { + DB.Exec(fmt.Sprintf(`CREATE EXTENSION IF NOT EXISTS "%s"`, ext)) + } + + // Wipe all tables so migrations apply cleanly + DB.Exec(` + DO $$ DECLARE r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + `) + + // Run all migrations + if err := Migrate(); err != nil { + DB.Close() + DB = nil + TestDB = nil + if createdByUs { + adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName)) + } + adminDB.Close() + log.Fatalf("❌ Migration failed on test DB: %v", err) + } + + // Return cleanup + return func() { + if DB != nil { + DB.Close() + DB = nil + TestDB = nil + } + if createdByUs { + adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName)) + log.Printf("✓ Dropped test database: %s", testDBName) + } + adminDB.Close() + } +} + +// RequireTestDB skips a test if no test database is available. +func RequireTestDB(t *testing.T) { + t.Helper() + if DB == nil || TestDB == nil { + t.Skip("requires TEST_DATABASE_URL or PGHOST+PGUSER environment variables") + } +} + +// TruncateAll truncates all application tables for test isolation. +// Faster than recreating the DB for each test. +func TruncateAll(t *testing.T) { + t.Helper() + if DB == nil { + return + } + // Order matters due to foreign keys — truncate with CASCADE + tables := []string{ + "notes", + "channel_cursors", + "messages", + "channel_models", + "channel_members", + "channels", + "model_configs", + "model_presets", + "api_configs", + "refresh_tokens", + "users", + } + for _, table := range tables { + DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)) + } +} + +// SeedTestUser creates a test user and returns the user ID. +func SeedTestUser(t *testing.T, username, email string) string { + t.Helper() + var id string + err := DB.QueryRow(` + INSERT INTO users (username, email, password_hash, role) + VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user') + RETURNING id + `, username, email).Scan(&id) + if err != nil { + t.Fatalf("SeedTestUser: %v", err) + } + return id +} + +// SeedTestChannel creates a test channel owned by userID and returns the channel ID. +func SeedTestChannel(t *testing.T, userID, title string) string { + t.Helper() + var id string + err := DB.QueryRow(` + INSERT INTO channels (title, created_by) + VALUES ($1, $2) + RETURNING id + `, title, userID).Scan(&id) + if err != nil { + t.Fatalf("SeedTestChannel: %v", err) + } + // Add ownership + DB.Exec(`INSERT INTO channel_members (channel_id, user_id, role) VALUES ($1, $2, 'owner')`, + id, userID) + return id +} + +// ── Helpers ───────────────────────────────── + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// replaceDBName swaps the dbname in a DSN string. +func replaceDBName(dsn, newDB string) string { + // Handle key=value format + if strings.Contains(dsn, "dbname=") { + parts := strings.Fields(dsn) + for i, p := range parts { + if strings.HasPrefix(p, "dbname=") { + parts[i] = "dbname=" + newDB + return strings.Join(parts, " ") + } + } + } + // Handle URL format: postgres://user:pass@host/olddb?... + if strings.Contains(dsn, "://") { + // Find the last / before ? and replace the path + idx := strings.LastIndex(dsn, "/") + qIdx := strings.Index(dsn, "?") + if qIdx > idx { + return dsn[:idx+1] + newDB + dsn[qIdx:] + } + return dsn[:idx+1] + newDB + } + // Fallback: append dbname + return dsn + " dbname=" + newDB +} diff --git a/server/handlers/apiconfigs_test.go b/server/handlers/apiconfigs_test.go index f408810..c9e0286 100644 --- a/server/handlers/apiconfigs_test.go +++ b/server/handlers/apiconfigs_test.go @@ -12,11 +12,6 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/providers" ) -func init() { - gin.SetMode(gin.TestMode) - providers.Init() -} - func TestCreateConfigMissingFields(t *testing.T) { h := NewAPIConfigHandler() r := gin.New() diff --git a/server/handlers/auth_test.go b/server/handlers/auth_test.go index 788259a..385b501 100644 --- a/server/handlers/auth_test.go +++ b/server/handlers/auth_test.go @@ -17,10 +17,6 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/config" ) -func init() { - gin.SetMode(gin.TestMode) -} - func testConfig() *config.Config { return &config.Config{ JWTSecret: "test-secret-key-for-unit-tests", diff --git a/server/handlers/channels_test.go b/server/handlers/channels_test.go index eb4cba1..a005b4a 100644 --- a/server/handlers/channels_test.go +++ b/server/handlers/channels_test.go @@ -1,17 +1,16 @@ package handlers import ( + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" -) -func init() { - gin.SetMode(gin.TestMode) -} + "git.gobha.me/xcaliber/chat-switchboard/database" +) // ── Channel Request Validation ───────────────── @@ -60,7 +59,7 @@ func TestUpdateChannelEmptyBody(t *testing.T) { c.Request.Header.Set("Content-Type", "application/json") // Without a DB connection, UpdateChannel will fail at ownership check. - // Integration tests with a real DB would validate the "no fields" path. + // Integration tests with a real DB validate the "no fields" path. // Here we just confirm it doesn't return 400 for valid JSON. h.UpdateChannel(c) @@ -122,19 +121,151 @@ func TestCreateMessageMissingContent(t *testing.T) { } } +// ── Integration: Message CRUD with Real DB ────── + func TestCreateMessageValidRoles(t *testing.T) { - // Valid roles should pass validation (not return 400). - // They'll fail at ownership check (no DB), which is expected. - t.Skip("requires database connection for ownership check") + database.RequireTestDB(t) + database.TruncateAll(t) + + userID := database.SeedTestUser(t, "roletester", "role@test.com") + channelID := database.SeedTestChannel(t, userID, "Role Test") + + h := NewMessageHandler() + + for _, role := range []string{"user", "assistant", "system"} { + t.Run(role, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", userID) + c.Params = gin.Params{{Key: "id", Value: channelID}} + c.Request = httptest.NewRequest("POST", "/api/v1/channels/"+channelID+"/messages", + strings.NewReader(`{"role":"`+role+`","content":"hello from `+role+`"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateMessage(c) + + if w.Code != http.StatusCreated { + t.Errorf("role=%s: expected 201, got %d: %s", role, w.Code, w.Body.String()) + } + }) + } } -// ── Regenerate ─────────────────────────────── +func TestChannelCRUDIntegration(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) -func TestRegenerateRequiresDB(t *testing.T) { - // Regenerate is now a full handler (creates sibling assistant message - // via tree-aware streaming). It calls userOwnsChannel → database.DB, - // so it cannot run without a live database connection. - t.Skip("requires database connection for ownership check") + userID := database.SeedTestUser(t, "cruduser", "crud@test.com") + + h := NewChannelHandler() + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() }) + r.POST("/channels", h.CreateChannel) + r.GET("/channels", h.ListChannels) + r.GET("/channels/:id", h.GetChannel) + r.PUT("/channels/:id", h.UpdateChannel) + r.DELETE("/channels/:id", h.DeleteChannel) + + // Create + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/channels", + strings.NewReader(`{"title":"Integration Test Channel"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var created map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &created) + channelID := created["id"].(string) + + // Get + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/channels/"+channelID, nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("Get: expected 200, got %d", w.Code) + } + + // List + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/channels", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("List: expected 200, got %d", w.Code) + } + var listResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &listResp) + if listResp["total"].(float64) < 1 { + t.Error("List: expected at least 1 channel") + } + + // Update + w = httptest.NewRecorder() + req, _ = http.NewRequest("PUT", "/channels/"+channelID, + strings.NewReader(`{"title":"Updated Title"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("Update: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Delete + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", "/channels/"+channelID, nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("Delete: expected 200, got %d", w.Code) + } + + // Verify gone + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/channels/"+channelID, nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("Get after delete: expected 404, got %d", w.Code) + } +} + +func TestRegeneratePassesOwnershipCheck(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + userID := database.SeedTestUser(t, "regenuser", "regen@test.com") + channelID := database.SeedTestChannel(t, userID, "Regen Test") + + // Seed an assistant message to regenerate + var msgID string + err := database.DB.QueryRow(` + INSERT INTO messages (channel_id, role, content, participant_type, participant_id) + VALUES ($1, 'assistant', 'original response', 'model', 'test-model') + RETURNING id + `, channelID).Scan(&msgID) + if err != nil { + t.Fatalf("seed message: %v", err) + } + + h := NewMessageHandler() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", userID) + c.Params = gin.Params{ + {Key: "id", Value: channelID}, + {Key: "msgId", Value: msgID}, + } + c.Request = httptest.NewRequest("POST", + "/api/v1/channels/"+channelID+"/messages/"+msgID+"/regenerate", + strings.NewReader(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Regenerate(c) + + // Should NOT be 404 — ownership check passed. Will be 400 or 500 + // because no API config is set up, which is expected. + if w.Code == http.StatusNotFound { + t.Errorf("Expected to pass ownership check, got 404: %s", w.Body.String()) + } } // ── Pagination Helpers ────────────────────── diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 6d7ab6b..a3b5ffc 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -4,14 +4,15 @@ import ( "database/sql" "encoding/json" "fmt" - "io" "log" "net/http" + "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/tools" ) // ── Request Types ─────────────────────────── @@ -45,9 +46,11 @@ func NewCompletionHandler() *CompletionHandler { // 2. Resolve api_config (from request, chat, or user default) // 3. Load conversation history from messages // 4. Persist user message -// 5. Call provider (stream or non-stream) -// 6. Stream SSE to client / return JSON -// 7. Persist assistant message with token counts +// 5. Attach tool definitions if model supports tools +// 6. Call provider (stream or non-stream) +// 7. Tool loop: if LLM requests tool calls, execute them and re-call +// 8. Stream SSE to client / return JSON +// 9. Persist assistant message with token counts func (h *CompletionHandler) Complete(c *gin.Context) { var req completionRequest @@ -153,6 +156,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) { provReq.TopP = req.TopP } + // Attach tool definitions if model supports tool calling and tools are available + if caps.ToolCalling && tools.HasTools() { + provReq.Tools = h.buildToolDefs() + } + // Determine streaming stream := true // default if req.Stream != nil { @@ -166,7 +174,26 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } } -// ── Streaming Completion (SSE) ────────────── +// buildToolDefs converts registered tools to provider-format tool definitions. +func (h *CompletionHandler) buildToolDefs() []providers.ToolDef { + allTools := tools.AllDefinitions() + defs := make([]providers.ToolDef, len(allTools)) + for i, t := range allTools { + defs[i] = providers.ToolDef{ + Type: "function", + Function: providers.FunctionDef{ + Name: t.Name, + Description: t.Description, + Parameters: t.Parameters, + }, + } + } + return defs +} + +// ── Streaming Completion (SSE) with Tool Loop ── + +const maxToolIterations = 10 // safety limit on tool call rounds func (h *CompletionHandler) streamCompletion( c *gin.Context, @@ -175,12 +202,6 @@ func (h *CompletionHandler) streamCompletion( req providers.CompletionRequest, channelID, userID, model string, ) { - ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()}) - return - } - // Set SSE headers c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") @@ -188,45 +209,150 @@ func (h *CompletionHandler) streamCompletion( c.Header("X-Accel-Buffering", "no") // Disable nginx buffering c.Status(http.StatusOK) - - var fullContent string flusher, _ := c.Writer.(http.Flusher) - for event := range ch { - if event.Error != nil { - fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error()) - if flusher != nil { - flusher.Flush() - } - break - } - - if event.Delta != "" { - fullContent += event.Delta - // OpenAI-compatible SSE format - fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n", - event.Delta, model) - if flusher != nil { - flusher.Flush() - } - } - - if event.Done { - finishReason := event.FinishReason - if finishReason == "" { - finishReason = "stop" - } - fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n", - finishReason, model) - io.WriteString(c.Writer, "data: [DONE]\n\n") - if flusher != nil { - flusher.Flush() - } - break + flush := func() { + if flusher != nil { + flusher.Flush() } } - // Persist assistant response + // sendSSE sends a standard OpenAI-compatible SSE data line. + sendSSE := func(data string) { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + flush() + } + + // sendEvent sends a named SSE event (for tool status — non-standard). + sendEvent := func(event, data string) { + fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data) + flush() + } + + var fullContent string + + for iteration := 0; iteration < maxToolIterations; iteration++ { + ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req) + if err != nil { + sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error()))) + return + } + + var iterContent string + var toolCalls []providers.ToolCall + + for event := range ch { + if event.Error != nil { + sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error()))) + return + } + + // Stream text deltas to client + if event.Delta != "" { + iterContent += event.Delta + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`, + event.Delta, model)) + } + + if event.Done { + if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 { + toolCalls = event.ToolCalls + } else { + // Normal completion — send finish event + finishReason := event.FinishReason + if finishReason == "" { + finishReason = "stop" + } + fullContent += iterContent + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`, + finishReason, model)) + sendSSE("[DONE]") + + // Persist assistant response + if fullContent != "" { + if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil { + log.Printf("Failed to persist assistant message: %v", err) + } + } + return + } + break + } + } + + // ── Tool execution round ──────────────── + if len(toolCalls) == 0 { + // Stream ended without tool calls or finish — shouldn't happen + sendSSE("[DONE]") + if iterContent != "" { + fullContent += iterContent + if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil { + log.Printf("Failed to persist assistant message: %v", err) + } + } + return + } + + // Notify client about tool calls + toolCallsJSON, _ := json.Marshal(toolCalls) + sendEvent("tool_use", string(toolCallsJSON)) + + // Append assistant message (with tool_calls, possibly with text) to conversation + assistantMsg := providers.Message{ + Role: "assistant", + Content: iterContent, + } + for _, tc := range toolCalls { + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc) + } + req.Messages = append(req.Messages, assistantMsg) + + // Execute all tool calls + execCtx := tools.ExecutionContext{ + UserID: userID, + ChannelID: channelID, + } + for _, tc := range toolCalls { + call := tools.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + + log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID) + result := tools.ExecuteCall(c.Request.Context(), execCtx, call) + + // Notify client about tool result + resultJSON, _ := json.Marshal(map[string]interface{}{ + "tool_call_id": result.ToolCallID, + "name": result.Name, + "content": result.Content, + "is_error": result.IsError, + }) + sendEvent("tool_result", string(resultJSON)) + + // Append tool result to conversation for next iteration + req.Messages = append(req.Messages, providers.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: result.ToolCallID, + Name: result.Name, + }) + } + + fullContent += iterContent + // Loop: send updated messages back to provider + } + + // If we hit max iterations, send what we have + log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID) + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`, + escapeJSON("[Tool execution limit reached]"), model)) + sendSSE("[DONE]") + if fullContent != "" { if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil { log.Printf("Failed to persist assistant message: %v", err) @@ -234,7 +360,7 @@ func (h *CompletionHandler) streamCompletion( } } -// ── Non-Streaming Completion ──────────────── +// ── Non-Streaming Completion with Tool Loop ── func (h *CompletionHandler) syncCompletion( c *gin.Context, @@ -243,39 +369,108 @@ func (h *CompletionHandler) syncCompletion( req providers.CompletionRequest, channelID, userID, model string, ) { - resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()}) - return + var totalInput, totalOutput int + var finalContent string + + for iteration := 0; iteration < maxToolIterations; iteration++ { + resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()}) + return + } + + totalInput += resp.InputTokens + totalOutput += resp.OutputTokens + + // No tool calls — normal response + if len(resp.ToolCalls) == 0 || resp.FinishReason != "tool_calls" { + finalContent = resp.Content + + // Persist assistant response + if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil); err != nil { + log.Printf("Failed to persist assistant message: %v", err) + } + + // Return OpenAI-compatible response + c.JSON(http.StatusOK, gin.H{ + "model": resp.Model, + "choices": []gin.H{ + { + "message": gin.H{ + "role": "assistant", + "content": finalContent, + }, + "finish_reason": resp.FinishReason, + }, + }, + "usage": gin.H{ + "prompt_tokens": totalInput, + "completion_tokens": totalOutput, + "total_tokens": totalInput + totalOutput, + }, + }) + return + } + + // ── Tool execution round ──────────────── + assistantMsg := providers.Message{ + Role: "assistant", + Content: resp.Content, + } + for _, tc := range resp.ToolCalls { + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc) + } + req.Messages = append(req.Messages, assistantMsg) + + execCtx := tools.ExecutionContext{ + UserID: userID, + ChannelID: channelID, + } + for _, tc := range resp.ToolCalls { + call := tools.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID) + result := tools.ExecuteCall(c.Request.Context(), execCtx, call) + + req.Messages = append(req.Messages, providers.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: result.ToolCallID, + Name: result.Name, + }) + } } - // Persist assistant response - if _, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil { - log.Printf("Failed to persist assistant message: %v", err) - } - - // Return OpenAI-compatible response + // Max iterations reached c.JSON(http.StatusOK, gin.H{ - "model": resp.Model, + "model": model, "choices": []gin.H{ { "message": gin.H{ "role": "assistant", - "content": resp.Content, + "content": "[Tool execution limit reached]", }, - "finish_reason": resp.FinishReason, + "finish_reason": "stop", }, }, - "usage": gin.H{ - "prompt_tokens": resp.InputTokens, - "completion_tokens": resp.OutputTokens, - "total_tokens": resp.InputTokens + resp.OutputTokens, - }, }) } // ── Model Capabilities ────────────────────── +// escapeJSON escapes a string for safe embedding in a JSON string value. +func escapeJSON(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", `\n`) + s = strings.ReplaceAll(s, "\r", `\r`) + s = strings.ReplaceAll(s, "\t", `\t`) + return s +} + // getModelCapabilities looks up capabilities from model_configs DB, // then overlays with known model defaults and heuristic detection. func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities { diff --git a/server/handlers/notes.go b/server/handlers/notes.go new file mode 100644 index 0000000..14c4c6c --- /dev/null +++ b/server/handlers/notes.go @@ -0,0 +1,504 @@ +package handlers + +import ( + "database/sql" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/lib/pq" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// ── Request / Response Types ──────────────── + +type createNoteRequest struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + FolderPath string `json:"folder_path"` + Tags []string `json:"tags"` + SourceChannelID string `json:"source_channel_id"` +} + +type updateNoteRequest struct { + Title *string `json:"title"` + Content *string `json:"content"` + FolderPath *string `json:"folder_path"` + Tags []string `json:"tags"` + // Mode controls how content is applied: "replace" (default), "append", "prepend" + Mode string `json:"mode"` +} + +type noteResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + FolderPath string `json:"folder_path"` + Tags []string `json:"tags"` + SourceChannelID *string `json:"source_channel_id,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type noteListItem struct { + ID string `json:"id"` + Title string `json:"title"` + FolderPath string `json:"folder_path"` + Tags []string `json:"tags"` + Preview string `json:"preview"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type searchResult struct { + noteListItem + Rank float64 `json:"rank"` + Headline string `json:"headline"` +} + +// NoteHandler handles notes CRUD. +type NoteHandler struct{} + +// NewNoteHandler creates a new handler. +func NewNoteHandler() *NoteHandler { + return &NoteHandler{} +} + +// ── Create ────────────────────────────────── +// POST /api/v1/notes + +func (h *NoteHandler) Create(c *gin.Context) { + var req createNoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := getUserID(c) + + folder := normalizeFolderPath(req.FolderPath) + tags := req.Tags + if tags == nil { + tags = []string{} + } + + var sourceChannelID *string + if req.SourceChannelID != "" { + sourceChannelID = &req.SourceChannelID + } + + var note noteResponse + var dbTags pq.StringArray + err := database.DB.QueryRow(` + INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, title, content, folder_path, tags, source_channel_id, + created_at::text, updated_at::text + `, userID, req.Title, req.Content, folder, pq.Array(tags), sourceChannelID, + ).Scan( + ¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath, + &dbTags, ¬e.SourceChannelID, + ¬e.CreatedAt, ¬e.UpdatedAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"}) + return + } + note.Tags = []string(dbTags) + if note.Tags == nil { + note.Tags = []string{} + } + + c.JSON(http.StatusCreated, note) +} + +// ── Get ───────────────────────────────────── +// GET /api/v1/notes/:id + +func (h *NoteHandler) Get(c *gin.Context) { + userID := getUserID(c) + noteID := c.Param("id") + + var note noteResponse + var dbTags pq.StringArray + err := database.DB.QueryRow(` + SELECT id, title, content, folder_path, tags, source_channel_id, + created_at::text, updated_at::text + FROM notes WHERE id = $1 AND user_id = $2 + `, noteID, userID).Scan( + ¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath, + &dbTags, ¬e.SourceChannelID, + ¬e.CreatedAt, ¬e.UpdatedAt, + ) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get note"}) + return + } + note.Tags = []string(dbTags) + if note.Tags == nil { + note.Tags = []string{} + } + + c.JSON(http.StatusOK, note) +} + +// ── Update ────────────────────────────────── +// PUT /api/v1/notes/:id + +func (h *NoteHandler) Update(c *gin.Context) { + var req updateNoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := getUserID(c) + noteID := c.Param("id") + + // Verify ownership + var exists bool + err := database.DB.QueryRow( + `SELECT EXISTS(SELECT 1 FROM notes WHERE id = $1 AND user_id = $2)`, + noteID, userID, + ).Scan(&exists) + if err != nil || !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) + return + } + + // Build dynamic update + setClauses := []string{} + args := []interface{}{} + argIdx := 1 + + if req.Title != nil { + setClauses = append(setClauses, "title = $"+strconv.Itoa(argIdx)) + args = append(args, *req.Title) + argIdx++ + } + + if req.Content != nil { + mode := strings.ToLower(req.Mode) + switch mode { + case "append": + setClauses = append(setClauses, "content = content || $"+strconv.Itoa(argIdx)) + case "prepend": + setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)+" || content") + default: // "replace" or empty + setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)) + } + args = append(args, *req.Content) + argIdx++ + } + + if req.FolderPath != nil { + setClauses = append(setClauses, "folder_path = $"+strconv.Itoa(argIdx)) + args = append(args, normalizeFolderPath(*req.FolderPath)) + argIdx++ + } + + if req.Tags != nil { + setClauses = append(setClauses, "tags = $"+strconv.Itoa(argIdx)) + args = append(args, pq.Array(req.Tags)) + argIdx++ + } + + if len(setClauses) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + // WHERE clause + args = append(args, noteID, userID) + query := "UPDATE notes SET " + strings.Join(setClauses, ", ") + + " WHERE id = $" + strconv.Itoa(argIdx) + + " AND user_id = $" + strconv.Itoa(argIdx+1) + + " RETURNING id, title, content, folder_path, tags, source_channel_id, created_at::text, updated_at::text" + + var note noteResponse + var dbTags pq.StringArray + err = database.DB.QueryRow(query, args...).Scan( + ¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath, + &dbTags, ¬e.SourceChannelID, + ¬e.CreatedAt, ¬e.UpdatedAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"}) + return + } + note.Tags = []string(dbTags) + if note.Tags == nil { + note.Tags = []string{} + } + + c.JSON(http.StatusOK, note) +} + +// ── Delete ────────────────────────────────── +// DELETE /api/v1/notes/:id + +func (h *NoteHandler) Delete(c *gin.Context) { + userID := getUserID(c) + noteID := c.Param("id") + + result, err := database.DB.Exec( + `DELETE FROM notes WHERE id = $1 AND user_id = $2`, + noteID, userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"}) + return + } + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// ── Bulk Delete ──────────────────────────── +// POST /api/v1/notes/bulk-delete + +func (h *NoteHandler) BulkDelete(c *gin.Context) { + var req struct { + IDs []string `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"}) + return + } + if len(req.IDs) > 100 { + c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"}) + return + } + + userID := getUserID(c) + + result, err := database.DB.Exec( + `DELETE FROM notes WHERE id = ANY($1) AND user_id = $2`, + pq.Array(req.IDs), userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"}) + return + } + count, _ := result.RowsAffected() + + c.JSON(http.StatusOK, gin.H{"deleted": count}) +} +// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc + +func (h *NoteHandler) List(c *gin.Context) { + userID := getUserID(c) + folder := c.Query("folder") + tag := c.Query("tag") + sort := c.DefaultQuery("sort", "updated_desc") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + if limit <= 0 || limit > 200 { + limit = 50 + } + + query := `SELECT id, title, folder_path, tags, LEFT(content, 200), + created_at::text, updated_at::text + FROM notes WHERE user_id = $1` + args := []interface{}{userID} + argIdx := 2 + + if folder != "" { + query += " AND folder_path = $" + strconv.Itoa(argIdx) + args = append(args, normalizeFolderPath(folder)) + argIdx++ + } + if tag != "" { + query += " AND $" + strconv.Itoa(argIdx) + " = ANY(tags)" + args = append(args, tag) + argIdx++ + } + + // Sort options + switch sort { + case "created_asc": + query += " ORDER BY created_at ASC" + case "created_desc": + query += " ORDER BY created_at DESC" + case "updated_asc": + query += " ORDER BY updated_at ASC" + case "title_asc": + query += " ORDER BY title ASC" + case "title_desc": + query += " ORDER BY title DESC" + default: // "updated_desc" + query += " ORDER BY updated_at DESC" + } + + query += " LIMIT $" + strconv.Itoa(argIdx) + + " OFFSET $" + strconv.Itoa(argIdx+1) + args = append(args, limit, offset) + + rows, err := database.DB.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"}) + return + } + defer rows.Close() + + notes := make([]noteListItem, 0) + for rows.Next() { + var n noteListItem + var dbTags pq.StringArray + if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath, &dbTags, &n.Preview, + &n.CreatedAt, &n.UpdatedAt); err != nil { + continue + } + n.Tags = []string(dbTags) + if n.Tags == nil { + n.Tags = []string{} + } + notes = append(notes, n) + } + + // Get total count + var total int + countQuery := `SELECT COUNT(*) FROM notes WHERE user_id = $1` + countArgs := []interface{}{userID} + if folder != "" { + countQuery += " AND folder_path = $2" + countArgs = append(countArgs, normalizeFolderPath(folder)) + } + _ = database.DB.QueryRow(countQuery, countArgs...).Scan(&total) + + c.JSON(http.StatusOK, gin.H{ + "data": notes, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// ── Search ────────────────────────────────── +// GET /api/v1/notes/search?q=query&limit=20 + +func (h *NoteHandler) Search(c *gin.Context) { + userID := getUserID(c) + q := strings.TrimSpace(c.Query("q")) + if q == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"}) + return + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + if limit <= 0 || limit > 100 { + limit = 20 + } + + // Use plainto_tsquery for natural language (not websearch_to_tsquery which + // requires Postgres 11+ and has stricter syntax). + rows, err := database.DB.Query(` + SELECT id, title, folder_path, tags, LEFT(content, 200), + created_at::text, updated_at::text, + ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, + ts_headline('english', content, plainto_tsquery('english', $2), + 'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS headline + FROM notes + WHERE user_id = $1 + AND search_vector @@ plainto_tsquery('english', $2) + ORDER BY rank DESC + LIMIT $3 + `, userID, q, limit) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) + return + } + defer rows.Close() + + results := make([]searchResult, 0) + for rows.Next() { + var r searchResult + var dbTags pq.StringArray + if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Preview, + &r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil { + continue + } + r.Tags = []string(dbTags) + if r.Tags == nil { + r.Tags = []string{} + } + results = append(results, r) + } + + c.JSON(http.StatusOK, gin.H{ + "data": results, + "query": q, + "total": len(results), + }) +} + +// ── List Folders ──────────────────────────── +// GET /api/v1/notes/folders + +func (h *NoteHandler) ListFolders(c *gin.Context) { + userID := getUserID(c) + + rows, err := database.DB.Query(` + SELECT DISTINCT folder_path, COUNT(*) AS count + FROM notes WHERE user_id = $1 + GROUP BY folder_path + ORDER BY folder_path + `, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) + return + } + defer rows.Close() + + type folderInfo struct { + Path string `json:"path"` + Count int `json:"count"` + } + folders := make([]folderInfo, 0) + for rows.Next() { + var f folderInfo + if err := rows.Scan(&f.Path, &f.Count); err != nil { + continue + } + folders = append(folders, f) + } + + c.JSON(http.StatusOK, gin.H{"folders": folders}) +} + +// ── Helpers ───────────────────────────────── + +// normalizeFolderPath ensures consistent folder path format. +func normalizeFolderPath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if !strings.HasSuffix(p, "/") { + p = p + "/" + } + // Collapse double slashes + for strings.Contains(p, "//") { + p = strings.ReplaceAll(p, "//", "/") + } + return p +} diff --git a/server/handlers/notes_test.go b/server/handlers/notes_test.go new file mode 100644 index 0000000..1edbbab --- /dev/null +++ b/server/handlers/notes_test.go @@ -0,0 +1,329 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// ── Notes: Validation (no DB needed) ──────── + +func TestCreateNoteMissingTitle(t *testing.T) { + h := NewNoteHandler() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "test-user") + c.Request = httptest.NewRequest("POST", "/api/v1/notes", + strings.NewReader(`{"content":"body only"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Create(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing title, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateNoteMissingContent(t *testing.T) { + h := NewNoteHandler() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "test-user") + c.Request = httptest.NewRequest("POST", "/api/v1/notes", + strings.NewReader(`{"title":"title only"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Create(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing content, got %d: %s", w.Code, w.Body.String()) + } +} + +// ── Notes: Full CRUD Integration ──────────── + +func TestNoteCRUDIntegration(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + userID := database.SeedTestUser(t, "noteuser", "note@test.com") + + h := NewNoteHandler() + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() }) + r.POST("/notes", h.Create) + r.GET("/notes", h.List) + r.GET("/notes/search", h.Search) + r.GET("/notes/folders", h.ListFolders) + r.GET("/notes/:id", h.Get) + r.PUT("/notes/:id", h.Update) + r.DELETE("/notes/:id", h.Delete) + + // ── Create ── + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/notes", + strings.NewReader(`{ + "title": "Meeting Notes", + "content": "Discussed project timeline and deliverables", + "folder_path": "/work/meetings", + "tags": ["project", "planning"] + }`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var created map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &created) + noteID, ok := created["id"].(string) + if !ok || noteID == "" { + t.Fatal("Create: missing or empty id in response") + } + if created["title"] != "Meeting Notes" { + t.Errorf("Create: title mismatch: %v", created["title"]) + } + if created["folder_path"] != "/work/meetings/" { + t.Errorf("Create: folder_path should be normalized, got %v", created["folder_path"]) + } + + // ── Create second note for search/list tests ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/notes", + strings.NewReader(`{ + "title": "Recipe Ideas", + "content": "Try making sourdough bread with rosemary", + "folder_path": "/personal", + "tags": ["food", "recipes"] + }`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("Create 2nd note: expected 201, got %d", w.Code) + } + + // ── Get ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes/"+noteID, nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Get: expected 200, got %d", w.Code) + } + var got map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &got) + if got["content"] != "Discussed project timeline and deliverables" { + t.Errorf("Get: wrong content: %v", got["content"]) + } + + // ── List (all) ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("List: expected 200, got %d", w.Code) + } + var listResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &listResp) + total := listResp["total"].(float64) + if total != 2 { + t.Errorf("List: expected total=2, got %.0f", total) + } + + // ── List (filtered by folder) ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes?folder=/work/meetings", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("List by folder: expected 200, got %d", w.Code) + } + json.Unmarshal(w.Body.Bytes(), &listResp) + total = listResp["total"].(float64) + if total != 1 { + t.Errorf("List by folder: expected total=1, got %.0f", total) + } + + // ── List (filtered by tag) ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes?tag=food", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("List by tag: expected 200, got %d", w.Code) + } + json.Unmarshal(w.Body.Bytes(), &listResp) + total = listResp["total"].(float64) + if total != 1 { + t.Errorf("List by tag: expected total=1, got %.0f", total) + } + + // ── Search ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes/search?q=sourdough", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Search: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var searchResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &searchResp) + count := searchResp["count"].(float64) + if count != 1 { + t.Errorf("Search 'sourdough': expected count=1, got %.0f", count) + } + + // ── Folders ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes/folders", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Folders: expected 200, got %d", w.Code) + } + var folders []interface{} + json.Unmarshal(w.Body.Bytes(), &folders) + if len(folders) != 2 { + t.Errorf("Folders: expected 2 folders, got %d", len(folders)) + } + + // ── Update (replace) ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("PUT", "/notes/"+noteID, + strings.NewReader(`{"title":"Updated Meeting Notes","content":"New content","mode":"replace"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Update: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify update + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes/"+noteID, nil) + r.ServeHTTP(w, req) + json.Unmarshal(w.Body.Bytes(), &got) + if got["title"] != "Updated Meeting Notes" { + t.Errorf("Update title: got %v", got["title"]) + } + if got["content"] != "New content" { + t.Errorf("Update content: got %v", got["content"]) + } + + // ── Update (append) ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("PUT", "/notes/"+noteID, + strings.NewReader(`{"content":"\nAppended line","mode":"append"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Append: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes/"+noteID, nil) + r.ServeHTTP(w, req) + json.Unmarshal(w.Body.Bytes(), &got) + content := got["content"].(string) + if !strings.Contains(content, "New content") || !strings.Contains(content, "Appended line") { + t.Errorf("Append: expected both parts in content, got %q", content) + } + + // ── Delete ── + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", "/notes/"+noteID, nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Delete: expected 200, got %d", w.Code) + } + + // Verify gone + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes/"+noteID, nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("Get after delete: expected 404, got %d", w.Code) + } +} + +// ── Notes: Search empty query ─────────────── + +func TestNoteSearchEmptyQuery(t *testing.T) { + database.RequireTestDB(t) + + h := NewNoteHandler() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "test-user") + c.Request = httptest.NewRequest("GET", "/api/v1/notes/search", nil) + + h.Search(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Search with no query: expected 400, got %d", w.Code) + } +} + +// ── Notes: Cross-user isolation ───────────── + +func TestNoteIsolationBetweenUsers(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + userA := database.SeedTestUser(t, "alice", "alice@test.com") + userB := database.SeedTestUser(t, "bob", "bob@test.com") + + h := NewNoteHandler() + + // Alice creates a note + rA := gin.New() + rA.Use(func(c *gin.Context) { c.Set("user_id", userA); c.Next() }) + rA.POST("/notes", h.Create) + rA.GET("/notes", h.List) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/notes", + strings.NewReader(`{"title":"Alice Secret","content":"private stuff"}`)) + req.Header.Set("Content-Type", "application/json") + rA.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("Alice create: expected 201, got %d", w.Code) + } + + // Bob lists — should see zero + rB := gin.New() + rB.Use(func(c *gin.Context) { c.Set("user_id", userB); c.Next() }) + rB.GET("/notes", h.List) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes", nil) + rB.ServeHTTP(w, req) + + var listResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &listResp) + total := listResp["total"].(float64) + if total != 0 { + t.Errorf("Bob should see 0 notes, got %.0f", total) + } + + // Alice lists — should see one + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/notes", nil) + rA.ServeHTTP(w, req) + + json.Unmarshal(w.Body.Bytes(), &listResp) + total = listResp["total"].(float64) + if total != 1 { + t.Errorf("Alice should see 1 note, got %.0f", total) + } +} diff --git a/server/handlers/testmain_test.go b/server/handlers/testmain_test.go new file mode 100644 index 0000000..e1d9797 --- /dev/null +++ b/server/handlers/testmain_test.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "os" + "testing" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/providers" +) + +// TestMain sets up the test DB (if available) and runs all tests. +// DB-dependent tests call database.RequireTestDB(t) to skip gracefully +// when no DB is configured. +func TestMain(m *testing.M) { + gin.SetMode(gin.TestMode) + providers.Init() + + teardown := database.SetupTestDB() + code := m.Run() + teardown() + os.Exit(code) +} diff --git a/server/main.go b/server/main.go index d8dba9d..842015d 100644 --- a/server/main.go +++ b/server/main.go @@ -11,6 +11,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/handlers" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/providers" + _ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init() ) func main() { @@ -143,6 +144,17 @@ func main() { protected.PUT("/presets/:id", presets.UpdateUserPreset) protected.DELETE("/presets/:id", presets.DeleteUserPreset) + // Notes + notes := handlers.NewNoteHandler() + protected.GET("/notes", notes.List) + protected.POST("/notes", notes.Create) + protected.GET("/notes/search", notes.Search) + protected.GET("/notes/folders", notes.ListFolders) + protected.POST("/notes/bulk-delete", notes.BulkDelete) + protected.GET("/notes/:id", notes.Get) + protected.PUT("/notes/:id", notes.Update) + protected.DELETE("/notes/:id", notes.Delete) + // Public global settings (non-admin users can read safe subset) adm := handlers.NewAdminHandler() protected.GET("/settings/public", adm.PublicSettings) diff --git a/server/main_test.go b/server/main_test.go index 3a6de9f..a2587b8 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -58,6 +58,8 @@ func TestAllRoutesRegistered(t *testing.T) { comp := handlers.NewCompletionHandler() apiCfg := handlers.NewAPIConfigHandler() settings := handlers.NewSettingsHandler() + presets := handlers.NewPresetHandler() + notes := handlers.NewNoteHandler() adm := handlers.NewAdminHandler() r := gin.New() @@ -83,7 +85,13 @@ func TestAllRoutesRegistered(t *testing.T) { // Messages protected.GET("/channels/:id/messages", msgs.ListMessages) protected.POST("/channels/:id/messages", msgs.CreateMessage) - protected.POST("/channels/:id/regenerate", msgs.Regenerate) + + // Message tree (forking) + protected.GET("/channels/:id/path", msgs.GetActivePath) + protected.PUT("/channels/:id/cursor", msgs.UpdateCursor) + protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage) + protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate) + protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) // Completion Engine protected.POST("/chat/completions", comp.Complete) @@ -104,6 +112,22 @@ func TestAllRoutesRegistered(t *testing.T) { protected.POST("/profile/password", settings.ChangePassword) protected.GET("/settings", settings.GetSettings) protected.PUT("/settings", settings.UpdateSettings) + + // Model Presets + protected.GET("/presets", presets.ListUserPresets) + protected.POST("/presets", presets.CreateUserPreset) + protected.PUT("/presets/:id", presets.UpdateUserPreset) + protected.DELETE("/presets/:id", presets.DeleteUserPreset) + + // Notes + protected.GET("/notes", notes.List) + protected.POST("/notes", notes.Create) + protected.GET("/notes/search", notes.Search) + protected.GET("/notes/folders", notes.ListFolders) + protected.POST("/notes/bulk-delete", notes.BulkDelete) + protected.GET("/notes/:id", notes.Get) + protected.PUT("/notes/:id", notes.Update) + protected.DELETE("/notes/:id", notes.Delete) } // Admin routes @@ -149,7 +173,12 @@ func TestAllRoutesRegistered(t *testing.T) { // Messages "GET /api/v1/channels/:id/messages", "POST /api/v1/channels/:id/messages", - "POST /api/v1/channels/:id/regenerate", + // Message tree (forking) + "GET /api/v1/channels/:id/path", + "PUT /api/v1/channels/:id/cursor", + "POST /api/v1/channels/:id/messages/:msgId/edit", + "POST /api/v1/channels/:id/messages/:msgId/regenerate", + "GET /api/v1/channels/:id/messages/:msgId/siblings", // Completion Engine "POST /api/v1/chat/completions", // API Configs @@ -168,6 +197,20 @@ func TestAllRoutesRegistered(t *testing.T) { "POST /api/v1/profile/password", "GET /api/v1/settings", "PUT /api/v1/settings", + // Presets + "GET /api/v1/presets", + "POST /api/v1/presets", + "PUT /api/v1/presets/:id", + "DELETE /api/v1/presets/:id", + // Notes + "GET /api/v1/notes", + "POST /api/v1/notes", + "GET /api/v1/notes/search", + "GET /api/v1/notes/folders", + "POST /api/v1/notes/bulk-delete", + "GET /api/v1/notes/:id", + "PUT /api/v1/notes/:id", + "DELETE /api/v1/notes/:id", // Admin "GET /api/v1/admin/users", "POST /api/v1/admin/users", diff --git a/server/providers/anthropic.go b/server/providers/anthropic.go index 07be61c..c230cee 100644 --- a/server/providers/anthropic.go +++ b/server/providers/anthropic.go @@ -35,21 +35,37 @@ func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConf return nil, fmt.Errorf("decode response: %w", err) } - // Extract text from content blocks - var content string - for _, block := range resp.Content { - if block.Type == "text" { - content += block.Text - } - } - - return &CompletionResponse{ - Content: content, + result := &CompletionResponse{ Model: resp.Model, FinishReason: resp.StopReason, InputTokens: resp.Usage.InputTokens, OutputTokens: resp.Usage.OutputTokens, - }, nil + } + + // Extract text and tool_use blocks + for _, block := range resp.Content { + switch block.Type { + case "text": + result.Content += block.Text + case "tool_use": + argsJSON, _ := json.Marshal(block.Input) + result.ToolCalls = append(result.ToolCalls, ToolCall{ + ID: block.ID, + Type: "function", + Function: FunctionCall{ + Name: block.Name, + Arguments: string(argsJSON), + }, + }) + } + } + + // Normalize stop reason + if resp.StopReason == "tool_use" { + result.FinishReason = "tool_calls" + } + + return result, nil } // ── Stream Completion ─────────────────────── @@ -68,6 +84,9 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo defer close(ch) defer body.Close() + var toolCalls []ToolCall + var currentToolIdx int = -1 + scanner := bufio.NewScanner(body) for scanner.Scan() { line := scanner.Text() @@ -84,18 +103,42 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo } switch event.Type { + case "content_block_start": + if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" { + currentToolIdx++ + toolCalls = append(toolCalls, ToolCall{ + ID: event.ContentBlock.ID, + Type: "function", + Function: FunctionCall{ + Name: event.ContentBlock.Name, + Arguments: "", + }, + }) + } + case "content_block_delta": if event.Delta.Type == "text_delta" { ch <- StreamEvent{Delta: event.Delta.Text} + } else if event.Delta.Type == "input_json_delta" && currentToolIdx >= 0 { + toolCalls[currentToolIdx].Function.Arguments += event.Delta.PartialJSON } + case "message_delta": - ch <- StreamEvent{ + stopReason := event.Delta.StopReason + ev := StreamEvent{ Done: true, - FinishReason: event.Delta.StopReason, + FinishReason: stopReason, } + if stopReason == "tool_use" { + ev.FinishReason = "tool_calls" + ev.ToolCalls = toolCalls + } + ch <- ev + case "message_stop": ch <- StreamEvent{Done: true} return + case "error": ch <- StreamEvent{ Error: fmt.Errorf("anthropic stream error: %s", data), @@ -115,8 +158,6 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo // ── List Models ───────────────────────────── func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) { - // Anthropic doesn't have a list models endpoint. - // Return known models with authoritative capabilities from our table. modelIDs := []struct{ id, name string }{ {"claude-opus-4-20250514", "Claude Opus 4"}, {"claude-sonnet-4-20250514", "Claude Sonnet 4"}, @@ -147,7 +188,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r } endpoint = strings.TrimSuffix(endpoint, "/") + "/v1/messages" - // Separate system message from conversation + // Separate system message and convert to Anthropic format var system string messages := make([]anthropicMessage, 0, len(req.Messages)) for _, m := range req.Messages { @@ -155,13 +196,55 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r system = m.Content continue } - messages = append(messages, anthropicMessage{ - Role: m.Role, - Content: m.Content, - }) + + antMsg := anthropicMessage{Role: m.Role} + + if m.Role == "assistant" && len(m.ToolCalls) > 0 { + // Assistant with tool calls → mixed content blocks + if m.Content != "" { + antMsg.Content = []anthropicContentBlock{ + {Type: "text", Text: m.Content}, + } + } else { + antMsg.Content = []anthropicContentBlock{} + } + for _, tc := range m.ToolCalls { + var input json.RawMessage + if tc.Function.Arguments != "" { + input = json.RawMessage(tc.Function.Arguments) + } else { + input = json.RawMessage("{}") + } + antMsg.Content = append(antMsg.Content, anthropicContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: input, + }) + } + } else if m.Role == "tool" { + // Tool results → user message with tool_result block + antMsg.Role = "user" + antMsg.Content = []anthropicContentBlock{ + { + Type: "tool_result", + ToolUseID: m.ToolCallID, + Content: m.Content, + }, + } + } else { + // Regular text message + antMsg.Content = []anthropicContentBlock{ + {Type: "text", Text: m.Content}, + } + } + + messages = append(messages, antMsg) } - // Build Anthropic-format request + // Merge consecutive user messages (Anthropic requires alternating roles) + messages = mergeConsecutiveUserMessages(messages) + antReq := anthropicRequest{ Model: req.Model, Messages: messages, @@ -172,7 +255,6 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r antReq.System = system } if antReq.MaxTokens == 0 { - // Anthropic requires max_tokens. Resolve from known model table. antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{}) } if req.Temperature != nil { @@ -182,6 +264,15 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r antReq.TopP = req.TopP } + // Add tools in Anthropic format + for _, t := range req.Tools { + antReq.Tools = append(antReq.Tools, anthropicToolDef{ + Name: t.Function.Name, + Description: t.Function.Description, + InputSchema: t.Function.Parameters, + }) + } + body, err := json.Marshal(antReq) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) @@ -209,11 +300,47 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r return resp.Body, nil } +// mergeConsecutiveUserMessages combines consecutive user-role messages. +// Anthropic requires strictly alternating user/assistant roles. +func mergeConsecutiveUserMessages(msgs []anthropicMessage) []anthropicMessage { + if len(msgs) <= 1 { + return msgs + } + merged := make([]anthropicMessage, 0, len(msgs)) + for _, m := range msgs { + if len(merged) > 0 && merged[len(merged)-1].Role == "user" && m.Role == "user" { + merged[len(merged)-1].Content = append(merged[len(merged)-1].Content, m.Content...) + } else { + merged = append(merged, m) + } + } + return merged +} + // ── Anthropic Wire Types ──────────────────── +type anthropicContentBlock struct { + Type string `json:"type"` + // text + Text string `json:"text,omitempty"` + // tool_use + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + // tool_result + ToolUseID string `json:"tool_use_id,omitempty"` + Content string `json:"content,omitempty"` +} + type anthropicMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content []anthropicContentBlock `json:"content"` +} + +type anthropicToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` } type anthropicRequest struct { @@ -224,14 +351,18 @@ type anthropicRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` Stream bool `json:"stream"` + Tools []anthropicToolDef `json:"tools,omitempty"` } type anthropicResponse struct { Model string `json:"model"` StopReason string `json:"stop_reason"` Content []struct { - Type string `json:"type"` - Text string `json:"text"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` } `json:"content"` Usage struct { InputTokens int `json:"input_tokens"` @@ -242,8 +373,14 @@ type anthropicResponse struct { type anthropicStreamEvent struct { Type string `json:"type"` Delta struct { - Type string `json:"type"` - Text string `json:"text"` - StopReason string `json:"stop_reason"` + Type string `json:"type"` + Text string `json:"text"` + StopReason string `json:"stop_reason"` + PartialJSON string `json:"partial_json"` } `json:"delta"` + ContentBlock *struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + } `json:"content_block,omitempty"` } diff --git a/server/providers/openai.go b/server/providers/openai.go index 398e035..fcc4428 100644 --- a/server/providers/openai.go +++ b/server/providers/openai.go @@ -36,13 +36,32 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, return nil, fmt.Errorf("no choices in response") } - return &CompletionResponse{ + result := &CompletionResponse{ Content: resp.Choices[0].Message.Content, Model: resp.Model, FinishReason: resp.Choices[0].FinishReason, InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens, - }, nil + } + + // Extract tool calls if present + if len(resp.Choices[0].Message.ToolCalls) > 0 { + for _, tc := range resp.Choices[0].Message.ToolCalls { + result.ToolCalls = append(result.ToolCalls, ToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + if result.FinishReason == "" { + result.FinishReason = "tool_calls" + } + } + + return result, nil } // ── Stream Completion ─────────────────────── @@ -61,6 +80,9 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi defer close(ch) defer body.Close() + // Accumulate tool calls across stream chunks + toolCallMap := map[int]*ToolCall{} // index → accumulated call + scanner := bufio.NewScanner(body) for scanner.Scan() { line := scanner.Text() @@ -84,13 +106,45 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi continue } - ev := StreamEvent{ - Delta: chunk.Choices[0].Delta.Content, - Model: chunk.Model, - FinishReason: chunk.Choices[0].FinishReason, + choice := chunk.Choices[0] + + // Accumulate tool call deltas + for _, tc := range choice.Delta.ToolCalls { + idx := 0 + if tc.Index != nil { + idx = *tc.Index + } + + if existing, ok := toolCallMap[idx]; ok { + // Append arguments fragment + existing.Function.Arguments += tc.Function.Arguments + } else { + // First chunk for this tool — has ID and name + toolCallMap[idx] = &ToolCall{ + ID: tc.ID, + Type: "function", + Function: FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + } + } } + + ev := StreamEvent{ + Delta: choice.Delta.Content, + Model: chunk.Model, + FinishReason: choice.FinishReason, + } + if ev.FinishReason != "" { ev.Done = true + // Attach accumulated tool calls on final event + if ev.FinishReason == "tool_calls" && len(toolCallMap) > 0 { + for _, tc := range toolCallMap { + ev.ToolCalls = append(ev.ToolCalls, *tc) + } + } } ch <- ev @@ -178,11 +232,46 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req oaiReq.TopP = req.TopP } + // Convert tools + for _, t := range req.Tools { + oaiReq.Tools = append(oaiReq.Tools, openaiToolDef{ + Type: t.Type, + Function: openaiToolDefFunction{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + }, + }) + } + + // Convert messages — handle all roles including tool for _, m := range req.Messages { - oaiReq.Messages = append(oaiReq.Messages, openaiMessage{ + oaiMsg := openaiMessage{ Role: m.Role, Content: m.Content, - }) + } + + // Assistant messages with tool calls + if len(m.ToolCalls) > 0 { + for _, tc := range m.ToolCalls { + oaiMsg.ToolCalls = append(oaiMsg.ToolCalls, openaiToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: openaiToolCallFunction{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + } + + // Tool result messages + if m.Role == "tool" { + oaiMsg.ToolCallID = m.ToolCallID + oaiMsg.Name = m.Name + } + + oaiReq.Messages = append(oaiReq.Messages, oaiMsg) } body, err := json.Marshal(oaiReq) @@ -220,8 +309,34 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req // ── OpenAI Wire Types ─────────────────────── type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools + ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result + Name string `json:"name,omitempty"` // tool name for role=tool +} + +type openaiToolCallFunction struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type openaiToolCall struct { + Index *int `json:"index,omitempty"` // present in streaming deltas + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` // "function" + Function openaiToolCallFunction `json:"function"` +} + +type openaiToolDefFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` +} + +type openaiToolDef struct { + Type string `json:"type"` // "function" + Function openaiToolDefFunction `json:"function"` } type openaiChatRequest struct { @@ -231,6 +346,7 @@ type openaiChatRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` Stream bool `json:"stream"` + Tools []openaiToolDef `json:"tools,omitempty"` } type openaiChatResponse struct { @@ -249,7 +365,8 @@ type openaiStreamChunk struct { Model string `json:"model"` Choices []struct { Delta struct { - Content string `json:"content"` + Content string `json:"content"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` } `json:"delta"` FinishReason string `json:"finish_reason"` } `json:"choices"` diff --git a/server/providers/provider.go b/server/providers/provider.go index 4169770..e6f4c8a 100644 --- a/server/providers/provider.go +++ b/server/providers/provider.go @@ -2,6 +2,7 @@ package providers import ( "context" + "encoding/json" ) // ── Provider Interface ────────────────────── @@ -37,8 +38,39 @@ type ProviderConfig struct { // Message represents a chat message in the conversation. type Message struct { - Role string `json:"role"` // user, assistant, system + Role string `json:"role"` // user, assistant, system, tool Content string `json:"content"` + + // Tool calling fields (assistant → tool_calls, tool → tool_call_id) + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools + ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages + Name string `json:"name,omitempty"` // tool name for role="tool" +} + +// ToolCall represents a function call requested by the LLM. +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` // "function" + Function FunctionCall `json:"function"` +} + +// FunctionCall is the function name and JSON arguments. +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// ToolDef describes a tool available to the LLM. +type ToolDef struct { + Type string `json:"type"` // "function" + Function FunctionDef `json:"function"` +} + +// FunctionDef is the schema for a tool function. +type FunctionDef struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` } // CompletionRequest is the normalized request sent to any provider. @@ -49,15 +81,17 @@ type CompletionRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` Stream bool `json:"stream,omitempty"` + Tools []ToolDef `json:"tools,omitempty"` // available tools for function calling } // CompletionResponse is the normalized non-streaming response. type CompletionResponse struct { - Content string `json:"content"` - Model string `json:"model"` - FinishReason string `json:"finish_reason"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + Content string `json:"content"` + Model string `json:"model"` + FinishReason string `json:"finish_reason"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when finish_reason is "tool_calls" } // StreamEvent is a single chunk from a streaming response. @@ -69,8 +103,13 @@ type StreamEvent struct { Done bool `json:"done,omitempty"` // FinishReason is set on the final event. + // "stop" = normal, "tool_calls" = LLM wants to call tools. FinishReason string `json:"finish_reason,omitempty"` + // ToolCalls accumulates tool call data during streaming. + // Fully populated on the final event when FinishReason is "tool_calls". + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + // Model echoes back the model used. Model string `json:"model,omitempty"` diff --git a/server/tools/notes.go b/server/tools/notes.go new file mode 100644 index 0000000..acb218c --- /dev/null +++ b/server/tools/notes.go @@ -0,0 +1,372 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/lib/pq" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// ── Init ──────────────────────────────────── + +func init() { + Register(&NoteCreateTool{}) + Register(&NoteSearchTool{}) + Register(&NoteUpdateTool{}) + Register(&NoteListTool{}) +} + +// ═══════════════════════════════════════════ +// note_create +// ═══════════════════════════════════════════ + +type NoteCreateTool struct{} + +func (t *NoteCreateTool) Definition() ToolDef { + return ToolDef{ + Name: "note_create", + Description: "Create a new note for the user. Use this to save information, insights, summaries, or anything the user wants to remember. Notes are persistent and searchable.", + Parameters: JSONSchema(map[string]interface{}{ + "title": Prop("string", "Title of the note"), + "content": Prop("string", "Markdown content of the note"), + "folder": Prop("string", "Folder path, e.g. '/projects/switchboard/'. Defaults to '/'"), + "tags": PropArray("Tags for categorization, e.g. ['research', 'golang']"), + }, []string{"title", "content"}), + } +} + +func (t *NoteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Title string `json:"title"` + Content string `json:"content"` + Folder string `json:"folder"` + Tags []string `json:"tags"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + + if args.Title == "" { + return "", fmt.Errorf("title is required") + } + + folder := normalizePath(args.Folder) + tags := args.Tags + if tags == nil { + tags = []string{} + } + + // source_channel_id links the note to the conversation that created it + var sourceChannelID *string + if execCtx.ChannelID != "" { + sourceChannelID = &execCtx.ChannelID + } + + var id, createdAt string + err := database.DB.QueryRow(` + INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at::text + `, execCtx.UserID, args.Title, args.Content, folder, pq.Array(tags), sourceChannelID, + ).Scan(&id, &createdAt) + + if err != nil { + return "", fmt.Errorf("failed to create note: %w", err) + } + + result, _ := json.Marshal(map[string]interface{}{ + "id": id, + "title": args.Title, + "folder": folder, + "tags": tags, + "created_at": createdAt, + "message": fmt.Sprintf("Note '%s' created successfully.", args.Title), + }) + return string(result), nil +} + +// ═══════════════════════════════════════════ +// note_search +// ═══════════════════════════════════════════ + +type NoteSearchTool struct{} + +func (t *NoteSearchTool) Definition() ToolDef { + return ToolDef{ + Name: "note_search", + Description: "Search the user's notes by keyword. Returns matching notes ranked by relevance with highlighted excerpts. Use this to find previously saved information.", + Parameters: JSONSchema(map[string]interface{}{ + "query": Prop("string", "Search query — natural language keywords"), + "limit": Prop("integer", "Maximum results to return (default 10, max 50)"), + }, []string{"query"}), + } +} + +func (t *NoteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + + if args.Query == "" { + return "", fmt.Errorf("query is required") + } + if args.Limit <= 0 || args.Limit > 50 { + args.Limit = 10 + } + + rows, err := database.DB.Query(` + SELECT id, title, folder_path, tags, LEFT(content, 500), + ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, + ts_headline('english', content, plainto_tsquery('english', $2), + 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline + FROM notes + WHERE user_id = $1 + AND search_vector @@ plainto_tsquery('english', $2) + ORDER BY rank DESC + LIMIT $3 + `, execCtx.UserID, args.Query, args.Limit) + + if err != nil { + return "", fmt.Errorf("search failed: %w", err) + } + defer rows.Close() + + type result struct { + ID string `json:"id"` + Title string `json:"title"` + Folder string `json:"folder"` + Tags []string `json:"tags"` + Excerpt string `json:"excerpt"` + Headline string `json:"headline"` + Rank float64 `json:"rank"` + } + + results := make([]result, 0) + for rows.Next() { + var r result + var dbTags pq.StringArray + if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil { + continue + } + r.Tags = []string(dbTags) + if r.Tags == nil { + r.Tags = []string{} + } + results = append(results, r) + } + + out, _ := json.Marshal(map[string]interface{}{ + "query": args.Query, + "count": len(results), + "results": results, + }) + return string(out), nil +} + +// ═══════════════════════════════════════════ +// note_update +// ═══════════════════════════════════════════ + +type NoteUpdateTool struct{} + +func (t *NoteUpdateTool) Definition() ToolDef { + return ToolDef{ + Name: "note_update", + Description: "Update an existing note. Can replace content entirely, append to it, or prepend. Use note_search first to find the note ID.", + Parameters: JSONSchema(map[string]interface{}{ + "note_id": Prop("string", "UUID of the note to update (from note_search results)"), + "title": Prop("string", "New title (omit to keep current)"), + "content": Prop("string", "New content or text to append/prepend"), + "mode": PropEnum("How to apply content", "replace", "append", "prepend"), + "tags": PropArray("New tags (replaces existing tags)"), + }, []string{"note_id"}), + } +} + +func (t *NoteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + NoteID string `json:"note_id"` + Title *string `json:"title"` + Content *string `json:"content"` + Mode string `json:"mode"` + Tags []string `json:"tags"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + + if args.NoteID == "" { + return "", fmt.Errorf("note_id is required") + } + + // Build dynamic update + setClauses := []string{} + queryArgs := []interface{}{} + argIdx := 1 + + if args.Title != nil { + setClauses = append(setClauses, fmt.Sprintf("title = $%d", argIdx)) + queryArgs = append(queryArgs, *args.Title) + argIdx++ + } + + if args.Content != nil { + mode := strings.ToLower(args.Mode) + switch mode { + case "append": + setClauses = append(setClauses, fmt.Sprintf("content = content || $%d", argIdx)) + case "prepend": + setClauses = append(setClauses, fmt.Sprintf("content = $%d || content", argIdx)) + default: + setClauses = append(setClauses, fmt.Sprintf("content = $%d", argIdx)) + } + queryArgs = append(queryArgs, *args.Content) + argIdx++ + } + + if args.Tags != nil { + setClauses = append(setClauses, fmt.Sprintf("tags = $%d", argIdx)) + queryArgs = append(queryArgs, pq.Array(args.Tags)) + argIdx++ + } + + if len(setClauses) == 0 { + return "", fmt.Errorf("no fields to update — provide at least title, content, or tags") + } + + queryArgs = append(queryArgs, args.NoteID, execCtx.UserID) + query := "UPDATE notes SET " + strings.Join(setClauses, ", ") + + fmt.Sprintf(" WHERE id = $%d AND user_id = $%d", argIdx, argIdx+1) + + " RETURNING id, title, updated_at::text" + + var id, title, updatedAt string + err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &updatedAt) + if err != nil { + return "", fmt.Errorf("note not found or update failed") + } + + result, _ := json.Marshal(map[string]interface{}{ + "id": id, + "title": title, + "updated_at": updatedAt, + "message": fmt.Sprintf("Note '%s' updated successfully.", title), + }) + return string(result), nil +} + +// ═══════════════════════════════════════════ +// note_list +// ═══════════════════════════════════════════ + +type NoteListTool struct{} + +func (t *NoteListTool) Definition() ToolDef { + return ToolDef{ + Name: "note_list", + Description: "List the user's notes, optionally filtered by folder or tag. Shows titles and metadata without full content. Use note_search for keyword searching.", + Parameters: JSONSchema(map[string]interface{}{ + "folder": Prop("string", "Filter by folder path, e.g. '/projects/' (omit for all)"), + "tag": Prop("string", "Filter by tag (omit for all)"), + "limit": Prop("integer", "Maximum results (default 20, max 100)"), + }, nil), + } +} + +func (t *NoteListTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Folder string `json:"folder"` + Tag string `json:"tag"` + Limit int `json:"limit"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + + if args.Limit <= 0 || args.Limit > 100 { + args.Limit = 20 + } + + query := `SELECT id, title, folder_path, tags, LEFT(content, 100), + created_at::text, updated_at::text + FROM notes WHERE user_id = $1` + queryArgs := []interface{}{execCtx.UserID} + argIdx := 2 + + if args.Folder != "" { + query += fmt.Sprintf(" AND folder_path = $%d", argIdx) + queryArgs = append(queryArgs, normalizePath(args.Folder)) + argIdx++ + } + if args.Tag != "" { + query += fmt.Sprintf(" AND $%d = ANY(tags)", argIdx) + queryArgs = append(queryArgs, args.Tag) + argIdx++ + } + + query += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", argIdx) + queryArgs = append(queryArgs, args.Limit) + + rows, err := database.DB.Query(query, queryArgs...) + if err != nil { + return "", fmt.Errorf("failed to list notes: %w", err) + } + defer rows.Close() + + type item struct { + ID string `json:"id"` + Title string `json:"title"` + Folder string `json:"folder"` + Tags []string `json:"tags"` + Preview string `json:"preview"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + + items := make([]item, 0) + for rows.Next() { + var n item + var dbTags pq.StringArray + if err := rows.Scan(&n.ID, &n.Title, &n.Folder, &dbTags, &n.Preview, + &n.CreatedAt, &n.UpdatedAt); err != nil { + continue + } + n.Tags = []string(dbTags) + if n.Tags == nil { + n.Tags = []string{} + } + items = append(items, n) + } + + out, _ := json.Marshal(map[string]interface{}{ + "count": len(items), + "notes": items, + }) + return string(out), nil +} + +// ── Path Helper ───────────────────────────── + +func normalizePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if !strings.HasSuffix(p, "/") { + p = p + "/" + } + for strings.Contains(p, "//") { + p = strings.ReplaceAll(p, "//", "/") + } + return p +} diff --git a/server/tools/notes_test.go b/server/tools/notes_test.go new file mode 100644 index 0000000..4811e1e --- /dev/null +++ b/server/tools/notes_test.go @@ -0,0 +1,203 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "testing" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +func TestMain(m *testing.M) { + teardown := database.SetupTestDB() + code := m.Run() + teardown() + os.Exit(code) +} + +// ── Note Tool Execution (requires DB) ─────── + +func TestNoteCreateExecute(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + userID := database.SeedTestUser(t, "tooluser", "tool@test.com") + channelID := database.SeedTestChannel(t, userID, "Tool Test") + + ctx := context.Background() + execCtx := ExecutionContext{UserID: userID, ChannelID: channelID} + + tool := Get("note_create") + if tool == nil { + t.Fatal("note_create not registered") + } + + args := `{"title":"Test Note","content":"Created via tool","folder":"/tools","tags":["test","ci"]}` + result, err := tool.Execute(ctx, execCtx, args) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + var resp map[string]interface{} + if err := json.Unmarshal([]byte(result), &resp); err != nil { + t.Fatalf("Invalid JSON result: %v\nRaw: %s", err, result) + } + + if resp["id"] == nil || resp["id"] == "" { + t.Error("Expected id in result") + } + if resp["title"] != "Test Note" { + t.Errorf("Expected title='Test Note', got %v", resp["title"]) + } +} + +func TestNoteListExecute(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + userID := database.SeedTestUser(t, "listuser", "list@test.com") + channelID := database.SeedTestChannel(t, userID, "List Test") + + ctx := context.Background() + execCtx := ExecutionContext{UserID: userID, ChannelID: channelID} + + // Create two notes first + createTool := Get("note_create") + createTool.Execute(ctx, execCtx, `{"title":"Note A","content":"Alpha","folder":"/a"}`) + createTool.Execute(ctx, execCtx, `{"title":"Note B","content":"Beta","folder":"/b"}`) + + // List all + listTool := Get("note_list") + result, err := listTool.Execute(ctx, execCtx, `{}`) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + var resp map[string]interface{} + json.Unmarshal([]byte(result), &resp) + + count := resp["count"].(float64) + if count != 2 { + t.Errorf("Expected count=2, got %.0f", count) + } + + // List filtered by folder + result, err = listTool.Execute(ctx, execCtx, `{"folder":"/a"}`) + if err != nil { + t.Fatalf("Execute with folder: %v", err) + } + json.Unmarshal([]byte(result), &resp) + count = resp["count"].(float64) + if count != 1 { + t.Errorf("Expected count=1 for folder /a, got %.0f", count) + } +} + +func TestNoteSearchExecute(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + userID := database.SeedTestUser(t, "searchuser", "search@test.com") + channelID := database.SeedTestChannel(t, userID, "Search Test") + + ctx := context.Background() + execCtx := ExecutionContext{UserID: userID, ChannelID: channelID} + + createTool := Get("note_create") + createTool.Execute(ctx, execCtx, `{"title":"Kubernetes Guide","content":"How to deploy pods and services"}`) + createTool.Execute(ctx, execCtx, `{"title":"Cooking Tips","content":"Season your cast iron pan properly"}`) + + searchTool := Get("note_search") + result, err := searchTool.Execute(ctx, execCtx, `{"query":"kubernetes pods"}`) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + var resp map[string]interface{} + json.Unmarshal([]byte(result), &resp) + count := resp["count"].(float64) + if count != 1 { + t.Errorf("Expected 1 search result for 'kubernetes pods', got %.0f", count) + } +} + +func TestNoteUpdateExecute(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + userID := database.SeedTestUser(t, "updateuser", "update@test.com") + channelID := database.SeedTestChannel(t, userID, "Update Test") + + ctx := context.Background() + execCtx := ExecutionContext{UserID: userID, ChannelID: channelID} + + // Create a note + createTool := Get("note_create") + result, _ := createTool.Execute(ctx, execCtx, `{"title":"Original","content":"Original content"}`) + + var created map[string]interface{} + json.Unmarshal([]byte(result), &created) + noteID := created["id"].(string) + + // Update title + updateTool := Get("note_update") + result, err := updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","title":"Renamed"}`) + if err != nil { + t.Fatalf("Update title: %v", err) + } + + var updated map[string]interface{} + json.Unmarshal([]byte(result), &updated) + if updated["title"] != "Renamed" { + t.Errorf("Expected title='Renamed', got %v", updated["title"]) + } + + // Append content + result, err = updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","content":"\nNew line","mode":"append"}`) + if err != nil { + t.Fatalf("Append: %v", err) + } + + // Verify via direct DB read + var row string + database.DB.QueryRow("SELECT content FROM notes WHERE id = $1", noteID).Scan(&row) + if row != "Original content\nNew line" { + t.Errorf("Append: expected concatenated content, got %q", row) + } +} + +func TestNoteCreateMissingRequiredField(t *testing.T) { + database.RequireTestDB(t) + + ctx := context.Background() + execCtx := ExecutionContext{UserID: "test", ChannelID: "test"} + + tool := Get("note_create") + // Missing content + result, err := tool.Execute(ctx, execCtx, `{"title":"No Content"}`) + if err == nil { + // Tools return errors in content, not as Go errors + var resp map[string]interface{} + json.Unmarshal([]byte(result), &resp) + if resp["error"] == nil { + t.Error("Expected error for missing content") + } + } +} + +func TestNoteUpdateNonexistent(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + userID := database.SeedTestUser(t, "ghostuser", "ghost@test.com") + + ctx := context.Background() + execCtx := ExecutionContext{UserID: userID, ChannelID: "test"} + + tool := Get("note_update") + _, err := tool.Execute(ctx, execCtx, `{"note_id":"00000000-0000-0000-0000-000000000000","title":"Nope"}`) + if err == nil { + t.Log("Update of nonexistent note should return error or empty result") + } +} diff --git a/server/tools/registry.go b/server/tools/registry.go new file mode 100644 index 0000000..285f25f --- /dev/null +++ b/server/tools/registry.go @@ -0,0 +1,92 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "log" +) + +// ── Registry ──────────────────────────────── + +var registry = map[string]Tool{} + +// Register adds a tool to the global registry. Called at init time. +func Register(t Tool) { + def := t.Definition() + if _, exists := registry[def.Name]; exists { + panic("tools.Register: duplicate tool name: " + def.Name) + } + registry[def.Name] = t + log.Printf("🔧 Registered tool: %s", def.Name) +} + +// Get returns a tool by name, or nil if not found. +func Get(name string) Tool { + return registry[name] +} + +// AllDefinitions returns tool definitions for all registered tools. +// Used to populate the `tools` field in LLM requests. +func AllDefinitions() []ToolDef { + defs := make([]ToolDef, 0, len(registry)) + for _, t := range registry { + defs = append(defs, t.Definition()) + } + return defs +} + +// ── Execution ─────────────────────────────── + +// ExecuteCall runs a single tool call and returns a ToolResult. +// Never returns an error — failures are encoded in the result content +// so the LLM can see what went wrong and adjust. +func ExecuteCall(ctx context.Context, execCtx ExecutionContext, call ToolCall) ToolResult { + tool := Get(call.Name) + if tool == nil { + return ToolResult{ + ToolCallID: call.ID, + Name: call.Name, + Content: jsonError(fmt.Sprintf("unknown tool: %s", call.Name)), + IsError: true, + } + } + + result, err := tool.Execute(ctx, execCtx, call.Arguments) + if err != nil { + log.Printf("Tool %s error: %v", call.Name, err) + return ToolResult{ + ToolCallID: call.ID, + Name: call.Name, + Content: jsonError(err.Error()), + IsError: true, + } + } + + return ToolResult{ + ToolCallID: call.ID, + Name: call.Name, + Content: result, + } +} + +// ExecuteAll runs multiple tool calls and returns all results. +func ExecuteAll(ctx context.Context, execCtx ExecutionContext, calls []ToolCall) []ToolResult { + results := make([]ToolResult, len(calls)) + for i, call := range calls { + results[i] = ExecuteCall(ctx, execCtx, call) + } + return results +} + +// HasTools returns true if any tools are registered. +func HasTools() bool { + return len(registry) > 0 +} + +// ── Helpers ───────────────────────────────── + +func jsonError(msg string) string { + b, _ := json.Marshal(map[string]string{"error": msg}) + return string(b) +} diff --git a/server/tools/tools_test.go b/server/tools/tools_test.go new file mode 100644 index 0000000..4edfc15 --- /dev/null +++ b/server/tools/tools_test.go @@ -0,0 +1,194 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" +) + +// ── Registry Tests ────────────────────────── + +func TestRegistryHasTools(t *testing.T) { + // init() in notes.go registers 4 tools + if !HasTools() { + t.Fatal("Expected HasTools() == true after init registration") + } +} + +func TestRegistryAllDefinitions(t *testing.T) { + defs := AllDefinitions() + if len(defs) < 4 { + t.Errorf("Expected at least 4 tool definitions, got %d", len(defs)) + } + + expected := map[string]bool{ + "note_create": false, + "note_search": false, + "note_update": false, + "note_list": false, + } + for _, d := range defs { + if _, ok := expected[d.Name]; ok { + expected[d.Name] = true + } + // Every tool must have a description and parameters + if d.Description == "" { + t.Errorf("Tool %q has empty description", d.Name) + } + if len(d.Parameters) == 0 { + t.Errorf("Tool %q has empty parameters", d.Name) + } + } + for name, found := range expected { + if !found { + t.Errorf("Expected tool %q not found in registry", name) + } + } +} + +func TestRegistryGetKnown(t *testing.T) { + tool := Get("note_create") + if tool == nil { + t.Fatal("Get(note_create) returned nil") + } + def := tool.Definition() + if def.Name != "note_create" { + t.Errorf("Expected name 'note_create', got %q", def.Name) + } +} + +func TestRegistryGetUnknown(t *testing.T) { + tool := Get("nonexistent_tool") + if tool != nil { + t.Error("Get(nonexistent_tool) should return nil") + } +} + +func TestExecuteCallUnknownTool(t *testing.T) { + ctx := context.Background() + execCtx := ExecutionContext{UserID: "test", ChannelID: "test"} + call := ToolCall{ID: "call_1", Name: "nonexistent", Arguments: "{}"} + + result := ExecuteCall(ctx, execCtx, call) + + if !result.IsError { + t.Error("Expected is_error=true for unknown tool") + } + if result.ToolCallID != "call_1" { + t.Errorf("Expected tool_call_id='call_1', got %q", result.ToolCallID) + } +} + +func TestExecuteAll(t *testing.T) { + ctx := context.Background() + execCtx := ExecutionContext{UserID: "test", ChannelID: "test"} + calls := []ToolCall{ + {ID: "call_1", Name: "nonexistent_a", Arguments: "{}"}, + {ID: "call_2", Name: "nonexistent_b", Arguments: "{}"}, + } + + results := ExecuteAll(ctx, execCtx, calls) + + if len(results) != 2 { + t.Fatalf("Expected 2 results, got %d", len(results)) + } + for i, r := range results { + if !r.IsError { + t.Errorf("Result %d: expected is_error=true", i) + } + } +} + +// ── Tool Definition Schema Tests ──────────── + +func TestToolDefinitionsHaveValidJSON(t *testing.T) { + for _, def := range AllDefinitions() { + var schema map[string]interface{} + if err := json.Unmarshal(def.Parameters, &schema); err != nil { + t.Errorf("Tool %q has invalid JSON schema: %v", def.Name, err) + } + // Should be a JSON Schema object with "type" and "properties" + if schema["type"] != "object" { + t.Errorf("Tool %q schema type should be 'object', got %v", def.Name, schema["type"]) + } + if _, ok := schema["properties"]; !ok { + t.Errorf("Tool %q schema missing 'properties'", def.Name) + } + } +} + +// ── JSON Schema Helper Tests ──────────────── + +func TestPropString(t *testing.T) { + p := Prop("string", "a description") + + if p["type"] != "string" { + t.Errorf("Expected type=string, got %v", p["type"]) + } + if p["description"] != "a description" { + t.Errorf("Expected description, got %v", p["description"]) + } +} + +func TestPropEnum(t *testing.T) { + p := PropEnum("content mode", "replace", "append", "prepend") + + if p["type"] != "string" { + t.Error("Expected type=string") + } + enums := p["enum"].([]string) + if len(enums) != 3 { + t.Errorf("Expected 3 enum values, got %d", len(enums)) + } +} + +func TestPropArray(t *testing.T) { + p := PropArray("list of tags") + + if p["type"] != "array" { + t.Error("Expected type=array") + } + items := p["items"].(map[string]string) + if items["type"] != "string" { + t.Error("Expected items.type=string") + } +} + +func TestJSONSchema(t *testing.T) { + props := map[string]interface{}{ + "title": Prop("string", "note title"), + "content": Prop("string", "note body"), + } + schema := JSONSchema(props, []string{"title"}) + + var m map[string]interface{} + if err := json.Unmarshal(schema, &m); err != nil { + t.Fatalf("Invalid JSON: %v", err) + } + + if m["type"] != "object" { + t.Error("Expected type=object") + } + required := m["required"].([]interface{}) + if len(required) != 1 || required[0] != "title" { + t.Errorf("Expected required=[title], got %v", required) + } + mProps := m["properties"].(map[string]interface{}) + if _, ok := mProps["title"]; !ok { + t.Error("Missing property 'title'") + } + if _, ok := mProps["content"]; !ok { + t.Error("Missing property 'content'") + } +} + +func TestMustJSON(t *testing.T) { + result := MustJSON(map[string]string{"key": "value"}) + var m map[string]string + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("MustJSON produced invalid JSON: %v", err) + } + if m["key"] != "value" { + t.Errorf("Expected value, got %q", m["key"]) + } +} diff --git a/server/tools/types.go b/server/tools/types.go new file mode 100644 index 0000000..576acaf --- /dev/null +++ b/server/tools/types.go @@ -0,0 +1,105 @@ +package tools + +import ( + "context" + "encoding/json" +) + +// ── Tool Definition (sent to LLM) ────────── + +// ToolDef describes a tool the LLM can call. Serialized to OpenAI-compatible +// format by providers. The Parameters field uses JSON Schema. +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` // JSON Schema object +} + +// ── Tool Call (from LLM response) ─────────── + +// ToolCall represents a single tool invocation requested by the LLM. +type ToolCall struct { + ID string `json:"id"` // provider-assigned call ID + Name string `json:"name"` // tool function name + Arguments string `json:"arguments"` // JSON string of arguments +} + +// ── Tool Result (fed back to LLM) ─────────── + +// ToolResult is the output of executing a tool, sent back to the LLM +// as a message with role "tool". +type ToolResult struct { + ToolCallID string `json:"tool_call_id"` + Name string `json:"name"` + Content string `json:"content"` // JSON-encoded result + IsError bool `json:"is_error,omitempty"` // true if execution failed +} + +// ── Tool Interface ────────────────────────── + +// ExecutionContext provides tool implementations with the info they need. +type ExecutionContext struct { + UserID string + ChannelID string +} + +// Tool is the interface every built-in tool implements. +type Tool interface { + // Definition returns the tool's schema for the LLM. + Definition() ToolDef + + // Execute runs the tool with the given JSON arguments. + // Returns a JSON string result or an error. + Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) +} + +// ── Helpers ───────────────────────────────── + +// MustJSON marshals v to a json.RawMessage, panicking on error. +// Used for static parameter schemas at init time. +func MustJSON(v interface{}) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic("tools.MustJSON: " + err.Error()) + } + return json.RawMessage(b) +} + +// JSONSchema builds a JSON Schema object type with the given properties. +// Convenience for defining tool parameters. +func JSONSchema(properties map[string]interface{}, required []string) json.RawMessage { + schema := map[string]interface{}{ + "type": "object", + "properties": properties, + } + if len(required) > 0 { + schema["required"] = required + } + return MustJSON(schema) +} + +// Prop builds a JSON Schema property definition. +func Prop(typ, description string) map[string]interface{} { + return map[string]interface{}{ + "type": typ, + "description": description, + } +} + +// PropEnum builds a JSON Schema string property with enum values. +func PropEnum(description string, values ...string) map[string]interface{} { + return map[string]interface{}{ + "type": "string", + "description": description, + "enum": values, + } +} + +// PropArray builds a JSON Schema array property with string items. +func PropArray(description string) map[string]interface{} { + return map[string]interface{}{ + "type": "array", + "description": description, + "items": map[string]string{"type": "string"}, + } +} diff --git a/src/css/styles.css b/src/css/styles.css index 1b76a04..26d3754 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -570,6 +570,36 @@ a:hover { text-decoration: underline; } max-height: 300px; overflow-y: auto; line-height: 1.6; } +/* Tool activity indicators */ +.msg-tools { + display: flex; flex-direction: column; gap: 4px; + margin-bottom: 0.5rem; +} +.tool-activity { + display: flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--text-3); + background: var(--bg-raised); border: 1px solid var(--border); + padding: 4px 10px; border-radius: var(--radius); + animation: tool-fade-in 0.2s ease; +} +@keyframes tool-fade-in { from { opacity: 0; transform: translateY(-4px); } } +.tool-icon { font-size: 13px; } +.tool-name { font-family: var(--mono); font-size: 11px; color: var(--text-2); } +.tool-status { font-size: 11px; margin-left: auto; } +.tool-running { color: var(--warning); } +.tool-running::after { + content: ''; display: inline-block; width: 4px; height: 4px; + border-radius: 50%; background: var(--warning); margin-left: 4px; + animation: tool-pulse 1s infinite; +} +@keyframes tool-pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } } +.tool-done { color: var(--success); } +.tool-error { color: var(--danger); } +.tool-hint { + font-size: 11px; color: var(--text-3); margin-left: 6px; + max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + /* Empty state */ .empty-state { display: flex; flex-direction: column; align-items: center; @@ -1064,6 +1094,175 @@ button { font-family: var(--font); cursor: pointer; } .debug-pre { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; } .debug-footer { display: flex; align-items: center; } +/* ── Sidebar Notes Button ───────────────── */ + +.sidebar-notes-btn { + padding: 4px 8px; border-top: 1px solid var(--border); + flex-shrink: 0; +} +.sb-notes { + display: flex; align-items: center; gap: 8px; width: 100%; + padding: 8px 12px; background: none; border: none; + border-radius: var(--radius); color: var(--text-2); + cursor: pointer; font-size: 13px; font-family: var(--font); + transition: background var(--transition), color var(--transition); +} +.sb-notes:hover { background: var(--bg-hover); color: var(--text); } +.sidebar.collapsed .sidebar-notes-btn .sb-label { display: none; } +.sidebar.collapsed .sb-notes { justify-content: center; padding: 8px; } + +/* ── Notes Modal ────────────────────────── */ + +.notes-header-actions { display: flex; align-items: center; gap: 8px; } + +.notes-toolbar { + display: flex; gap: 8px; padding: 10px 20px; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); +} +.notes-search-wrap { + display: flex; align-items: center; gap: 6px; flex: 1; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 0 10px; +} +.notes-search-wrap svg { color: var(--text-3); flex-shrink: 0; } +.notes-search-wrap input { + flex: 1; background: none; border: none; color: var(--text); + font-size: 13px; font-family: var(--font); padding: 6px 0; + outline: none; +} +.notes-filter-select { + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); color: var(--text); font-size: 12px; + padding: 4px 8px; font-family: var(--font); cursor: pointer; + max-width: 160px; +} + +.notes-body { padding: 0 !important; } + +/* Selection bar */ +.notes-selection-bar { + display: flex; align-items: center; gap: 10px; + padding: 6px 20px; + background: var(--accent-dim); border-bottom: 1px solid var(--border); + font-size: 12px; +} +.notes-select-all { + display: flex; align-items: center; gap: 6px; + color: var(--text-2); cursor: pointer; font-size: 12px; +} +.notes-select-all input { cursor: pointer; } + +.notes-list { padding: 8px; } +.notes-empty, .notes-loading { + text-align: center; color: var(--text-3); padding: 2rem; + font-size: 13px; +} + +.note-item { + display: flex; align-items: flex-start; gap: 8px; + padding: 10px 12px; border-radius: var(--radius); + cursor: pointer; border: 1px solid transparent; + transition: background var(--transition), border-color var(--transition); +} +.note-item:hover { + background: var(--bg-hover); border-color: var(--border); +} +.note-item.selected { + background: var(--accent-dim); border-color: var(--accent); +} +.note-select-col { flex-shrink: 0; padding-top: 2px; } +.note-checkbox { cursor: pointer; accent-color: var(--accent); } +.note-content-col { flex: 1; min-width: 0; } +.note-item-header { + display: flex; align-items: baseline; justify-content: space-between; + gap: 8px; margin-bottom: 2px; +} +.note-item-title { + font-size: 13px; font-weight: 600; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.note-item-time { font-size: 11px; color: var(--text-3); flex-shrink: 0; } +.note-preview { + font-size: 12px; color: var(--text-3); line-height: 1.4; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-height: 1.4em; +} +.note-headline { + font-size: 12px; color: var(--text-3); line-height: 1.4; +} +.note-headline mark { + background: var(--accent-dim); color: var(--accent); + padding: 0 2px; border-radius: 2px; +} +.note-item-meta { + display: flex; align-items: center; gap: 4px; + margin-top: 4px; flex-wrap: wrap; +} +.note-folder { + font-size: 11px; color: var(--text-3); font-family: var(--mono); + background: var(--bg-raised); padding: 1px 6px; border-radius: 3px; +} +.note-tag { + font-size: 10px; color: var(--purple); background: var(--purple-dim); + padding: 1px 6px; border-radius: 3px; +} + +/* Notes editor */ +.notes-editor { padding: 12px; } +.notes-editor-toolbar { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 8px; +} + +/* Note read mode */ +.note-read-title { + font-size: 18px; font-weight: 600; color: var(--text); + margin: 0 0 6px; line-height: 1.3; +} +.note-read-meta { + display: flex; align-items: center; gap: 4px; flex-wrap: wrap; + margin-bottom: 12px; +} +.note-read-content { + font-size: var(--msg-font, 14px); line-height: 1.7; + max-height: 50vh; overflow-y: auto; + padding: 12px; background: var(--bg-surface); + border: 1px solid var(--border); border-radius: var(--radius); +} +.notes-back-btn { + background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 12px; font-family: var(--font); + padding: 4px 0; margin-bottom: 8px; + transition: color var(--transition); +} +.notes-back-btn:hover { color: var(--text); } +.notes-title-input { + font-size: 16px !important; font-weight: 600; + border: 1px solid var(--border) !important; + padding: 8px 10px !important; +} +.notes-folder-input, .notes-tags-input { + font-size: 12px !important; font-family: var(--mono); +} +.notes-content-input { + font-size: 13px !important; line-height: 1.6 !important; + min-height: 200px; resize: vertical; + font-family: var(--mono); border: 1px solid var(--border) !important; +} +.notes-editor input, .notes-editor textarea { + width: 100%; background: var(--bg-surface); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 10px; font-family: var(--font); outline: none; + transition: border-color var(--transition); +} +.notes-editor input:focus, .notes-editor textarea:focus { + border-color: var(--accent); +} +.notes-editor-actions { + display: flex; gap: 8px; margin-top: 12px; +} + /* ── Responsive ──────────────────────────── */ .mobile-menu-btn { diff --git a/src/index.html b/src/index.html index 669372c..6e586e1 100644 --- a/src/index.html +++ b/src/index.html @@ -56,6 +56,14 @@ + + + + + +