Changeset 0.7.2 (#40)

This commit is contained in:
2026-02-21 19:03:19 +00:00
parent 494b1aa981
commit 416e5439ea
28 changed files with 3813 additions and 138 deletions

View File

@@ -86,9 +86,54 @@ jobs:
go mod download go mod download
go mod tidy 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 - name: Run tests
working-directory: server 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 - name: Build check
working-directory: server working-directory: server

102
TOOLS_IMPL.md Normal file
View File

@@ -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

View File

@@ -1 +1 @@
0.7.1 0.7.2

View File

@@ -190,6 +190,25 @@ check_column "model_presets" "scope"
check_column "model_presets" "created_by" check_column "model_presets" "created_by"
check_column "model_presets" "is_active" 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 # ADD NEW MIGRATION CHECKS ABOVE THIS LINE
# ═══════════════════════════════════════════ # ═══════════════════════════════════════════

View File

@@ -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();

View File

@@ -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
}

View File

@@ -12,11 +12,6 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
) )
func init() {
gin.SetMode(gin.TestMode)
providers.Init()
}
func TestCreateConfigMissingFields(t *testing.T) { func TestCreateConfigMissingFields(t *testing.T) {
h := NewAPIConfigHandler() h := NewAPIConfigHandler()
r := gin.New() r := gin.New()

View File

@@ -17,10 +17,6 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/config"
) )
func init() {
gin.SetMode(gin.TestMode)
}
func testConfig() *config.Config { func testConfig() *config.Config {
return &config.Config{ return &config.Config{
JWTSecret: "test-secret-key-for-unit-tests", JWTSecret: "test-secret-key-for-unit-tests",

View File

@@ -1,17 +1,16 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
)
func init() { "git.gobha.me/xcaliber/chat-switchboard/database"
gin.SetMode(gin.TestMode) )
}
// ── Channel Request Validation ───────────────── // ── Channel Request Validation ─────────────────
@@ -60,7 +59,7 @@ func TestUpdateChannelEmptyBody(t *testing.T) {
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
// Without a DB connection, UpdateChannel will fail at ownership check. // 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. // Here we just confirm it doesn't return 400 for valid JSON.
h.UpdateChannel(c) h.UpdateChannel(c)
@@ -122,19 +121,151 @@ func TestCreateMessageMissingContent(t *testing.T) {
} }
} }
// ── Integration: Message CRUD with Real DB ──────
func TestCreateMessageValidRoles(t *testing.T) { func TestCreateMessageValidRoles(t *testing.T) {
// Valid roles should pass validation (not return 400). database.RequireTestDB(t)
// They'll fail at ownership check (no DB), which is expected. database.TruncateAll(t)
t.Skip("requires database connection for ownership check")
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) { userID := database.SeedTestUser(t, "cruduser", "crud@test.com")
// Regenerate is now a full handler (creates sibling assistant message
// via tree-aware streaming). It calls userOwnsChannel → database.DB, h := NewChannelHandler()
// so it cannot run without a live database connection. r := gin.New()
t.Skip("requires database connection for ownership check") 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 ────────────────────── // ── Pagination Helpers ──────────────────────

View File

@@ -4,14 +4,15 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/tools"
) )
// ── Request Types ─────────────────────────── // ── Request Types ───────────────────────────
@@ -45,9 +46,11 @@ func NewCompletionHandler() *CompletionHandler {
// 2. Resolve api_config (from request, chat, or user default) // 2. Resolve api_config (from request, chat, or user default)
// 3. Load conversation history from messages // 3. Load conversation history from messages
// 4. Persist user message // 4. Persist user message
// 5. Call provider (stream or non-stream) // 5. Attach tool definitions if model supports tools
// 6. Stream SSE to client / return JSON // 6. Call provider (stream or non-stream)
// 7. Persist assistant message with token counts // 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) { func (h *CompletionHandler) Complete(c *gin.Context) {
var req completionRequest var req completionRequest
@@ -153,6 +156,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
provReq.TopP = req.TopP 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 // Determine streaming
stream := true // default stream := true // default
if req.Stream != nil { 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( func (h *CompletionHandler) streamCompletion(
c *gin.Context, c *gin.Context,
@@ -175,12 +202,6 @@ func (h *CompletionHandler) streamCompletion(
req providers.CompletionRequest, req providers.CompletionRequest,
channelID, userID, model string, 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 // Set SSE headers
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
@@ -188,43 +209,66 @@ func (h *CompletionHandler) streamCompletion(
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering c.Header("X-Accel-Buffering", "no") // Disable nginx buffering
c.Status(http.StatusOK) c.Status(http.StatusOK)
flusher, _ := c.Writer.(http.Flusher)
flush := func() {
if flusher != nil {
flusher.Flush()
}
}
// 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 var fullContent string
flusher, _ := c.Writer.(http.Flusher)
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 { for event := range ch {
if event.Error != nil { if event.Error != nil {
fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error()) sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
if flusher != nil { return
flusher.Flush()
}
break
} }
// Stream text deltas to client
if event.Delta != "" { if event.Delta != "" {
fullContent += event.Delta iterContent += event.Delta
// OpenAI-compatible SSE format sendSSE(fmt.Sprintf(
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n", `{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
event.Delta, model) event.Delta, model))
if flusher != nil {
flusher.Flush()
}
} }
if event.Done { if event.Done {
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
} else {
// Normal completion — send finish event
finishReason := event.FinishReason finishReason := event.FinishReason
if finishReason == "" { if finishReason == "" {
finishReason = "stop" finishReason = "stop"
} }
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n", fullContent += iterContent
finishReason, model) sendSSE(fmt.Sprintf(
io.WriteString(c.Writer, "data: [DONE]\n\n") `{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
if flusher != nil { finishReason, model))
flusher.Flush() sendSSE("[DONE]")
}
break
}
}
// Persist assistant response // Persist assistant response
if fullContent != "" { if fullContent != "" {
@@ -232,9 +276,91 @@ func (h *CompletionHandler) streamCompletion(
log.Printf("Failed to persist assistant message: %v", err) log.Printf("Failed to persist assistant message: %v", err)
} }
} }
return
}
break
}
} }
// ── Non-Streaming Completion ──────────────── // ── 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)
}
}
}
// ── Non-Streaming Completion with Tool Loop ──
func (h *CompletionHandler) syncCompletion( func (h *CompletionHandler) syncCompletion(
c *gin.Context, c *gin.Context,
@@ -243,14 +369,25 @@ func (h *CompletionHandler) syncCompletion(
req providers.CompletionRequest, req providers.CompletionRequest,
channelID, userID, model string, channelID, userID, model string,
) { ) {
var totalInput, totalOutput int
var finalContent string
for iteration := 0; iteration < maxToolIterations; iteration++ {
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req) resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
return 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 // Persist assistant response
if _, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil { if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil); err != nil {
log.Printf("Failed to persist assistant message: %v", err) log.Printf("Failed to persist assistant message: %v", err)
} }
@@ -261,21 +398,79 @@ func (h *CompletionHandler) syncCompletion(
{ {
"message": gin.H{ "message": gin.H{
"role": "assistant", "role": "assistant",
"content": resp.Content, "content": finalContent,
}, },
"finish_reason": resp.FinishReason, "finish_reason": resp.FinishReason,
}, },
}, },
"usage": gin.H{ "usage": gin.H{
"prompt_tokens": resp.InputTokens, "prompt_tokens": totalInput,
"completion_tokens": resp.OutputTokens, "completion_tokens": totalOutput,
"total_tokens": resp.InputTokens + resp.OutputTokens, "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,
})
}
}
// Max iterations reached
c.JSON(http.StatusOK, gin.H{
"model": model,
"choices": []gin.H{
{
"message": gin.H{
"role": "assistant",
"content": "[Tool execution limit reached]",
},
"finish_reason": "stop",
},
}, },
}) })
} }
// ── Model Capabilities ────────────────────── // ── 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, // getModelCapabilities looks up capabilities from model_configs DB,
// then overlays with known model defaults and heuristic detection. // then overlays with known model defaults and heuristic detection.
func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities { func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities {

504
server/handlers/notes.go Normal file
View File

@@ -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(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.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(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.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(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.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
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -11,6 +11,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/handlers" "git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
) )
func main() { func main() {
@@ -143,6 +144,17 @@ func main() {
protected.PUT("/presets/:id", presets.UpdateUserPreset) protected.PUT("/presets/:id", presets.UpdateUserPreset)
protected.DELETE("/presets/:id", presets.DeleteUserPreset) 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) // Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler() adm := handlers.NewAdminHandler()
protected.GET("/settings/public", adm.PublicSettings) protected.GET("/settings/public", adm.PublicSettings)

View File

@@ -58,6 +58,8 @@ func TestAllRoutesRegistered(t *testing.T) {
comp := handlers.NewCompletionHandler() comp := handlers.NewCompletionHandler()
apiCfg := handlers.NewAPIConfigHandler() apiCfg := handlers.NewAPIConfigHandler()
settings := handlers.NewSettingsHandler() settings := handlers.NewSettingsHandler()
presets := handlers.NewPresetHandler()
notes := handlers.NewNoteHandler()
adm := handlers.NewAdminHandler() adm := handlers.NewAdminHandler()
r := gin.New() r := gin.New()
@@ -83,7 +85,13 @@ func TestAllRoutesRegistered(t *testing.T) {
// Messages // Messages
protected.GET("/channels/:id/messages", msgs.ListMessages) protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage) 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 // Completion Engine
protected.POST("/chat/completions", comp.Complete) protected.POST("/chat/completions", comp.Complete)
@@ -104,6 +112,22 @@ func TestAllRoutesRegistered(t *testing.T) {
protected.POST("/profile/password", settings.ChangePassword) protected.POST("/profile/password", settings.ChangePassword)
protected.GET("/settings", settings.GetSettings) protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings) 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 // Admin routes
@@ -149,7 +173,12 @@ func TestAllRoutesRegistered(t *testing.T) {
// Messages // Messages
"GET /api/v1/channels/:id/messages", "GET /api/v1/channels/:id/messages",
"POST /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 // Completion Engine
"POST /api/v1/chat/completions", "POST /api/v1/chat/completions",
// API Configs // API Configs
@@ -168,6 +197,20 @@ func TestAllRoutesRegistered(t *testing.T) {
"POST /api/v1/profile/password", "POST /api/v1/profile/password",
"GET /api/v1/settings", "GET /api/v1/settings",
"PUT /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 // Admin
"GET /api/v1/admin/users", "GET /api/v1/admin/users",
"POST /api/v1/admin/users", "POST /api/v1/admin/users",

View File

@@ -35,21 +35,37 @@ func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConf
return nil, fmt.Errorf("decode response: %w", err) return nil, fmt.Errorf("decode response: %w", err)
} }
// Extract text from content blocks result := &CompletionResponse{
var content string
for _, block := range resp.Content {
if block.Type == "text" {
content += block.Text
}
}
return &CompletionResponse{
Content: content,
Model: resp.Model, Model: resp.Model,
FinishReason: resp.StopReason, FinishReason: resp.StopReason,
InputTokens: resp.Usage.InputTokens, InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens, 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 ─────────────────────── // ── Stream Completion ───────────────────────
@@ -68,6 +84,9 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
defer close(ch) defer close(ch)
defer body.Close() defer body.Close()
var toolCalls []ToolCall
var currentToolIdx int = -1
scanner := bufio.NewScanner(body) scanner := bufio.NewScanner(body)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
@@ -84,18 +103,42 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
} }
switch event.Type { 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": case "content_block_delta":
if event.Delta.Type == "text_delta" { if event.Delta.Type == "text_delta" {
ch <- StreamEvent{Delta: event.Delta.Text} 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": case "message_delta":
ch <- StreamEvent{ stopReason := event.Delta.StopReason
ev := StreamEvent{
Done: true, Done: true,
FinishReason: event.Delta.StopReason, FinishReason: stopReason,
} }
if stopReason == "tool_use" {
ev.FinishReason = "tool_calls"
ev.ToolCalls = toolCalls
}
ch <- ev
case "message_stop": case "message_stop":
ch <- StreamEvent{Done: true} ch <- StreamEvent{Done: true}
return return
case "error": case "error":
ch <- StreamEvent{ ch <- StreamEvent{
Error: fmt.Errorf("anthropic stream error: %s", data), Error: fmt.Errorf("anthropic stream error: %s", data),
@@ -115,8 +158,6 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
// ── List Models ───────────────────────────── // ── List Models ─────────────────────────────
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) { 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 }{ modelIDs := []struct{ id, name string }{
{"claude-opus-4-20250514", "Claude Opus 4"}, {"claude-opus-4-20250514", "Claude Opus 4"},
{"claude-sonnet-4-20250514", "Claude Sonnet 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" endpoint = strings.TrimSuffix(endpoint, "/") + "/v1/messages"
// Separate system message from conversation // Separate system message and convert to Anthropic format
var system string var system string
messages := make([]anthropicMessage, 0, len(req.Messages)) messages := make([]anthropicMessage, 0, len(req.Messages))
for _, m := range req.Messages { for _, m := range req.Messages {
@@ -155,13 +196,55 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
system = m.Content system = m.Content
continue continue
} }
messages = append(messages, anthropicMessage{
Role: m.Role, antMsg := anthropicMessage{Role: m.Role}
Content: m.Content,
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)
}
// Merge consecutive user messages (Anthropic requires alternating roles)
messages = mergeConsecutiveUserMessages(messages)
// Build Anthropic-format request
antReq := anthropicRequest{ antReq := anthropicRequest{
Model: req.Model, Model: req.Model,
Messages: messages, Messages: messages,
@@ -172,7 +255,6 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antReq.System = system antReq.System = system
} }
if antReq.MaxTokens == 0 { if antReq.MaxTokens == 0 {
// Anthropic requires max_tokens. Resolve from known model table.
antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{}) antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{})
} }
if req.Temperature != nil { if req.Temperature != nil {
@@ -182,6 +264,15 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antReq.TopP = req.TopP 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) body, err := json.Marshal(antReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal request: %w", err) 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 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 ──────────────────── // ── 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 { type anthropicMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content []anthropicContentBlock `json:"content"`
}
type anthropicToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
} }
type anthropicRequest struct { type anthropicRequest struct {
@@ -224,6 +351,7 @@ type anthropicRequest struct {
Temperature *float64 `json:"temperature,omitempty"` Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"` TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
Tools []anthropicToolDef `json:"tools,omitempty"`
} }
type anthropicResponse struct { type anthropicResponse struct {
@@ -231,7 +359,10 @@ type anthropicResponse struct {
StopReason string `json:"stop_reason"` StopReason string `json:"stop_reason"`
Content []struct { Content []struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text"` Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
} `json:"content"` } `json:"content"`
Usage struct { Usage struct {
InputTokens int `json:"input_tokens"` InputTokens int `json:"input_tokens"`
@@ -245,5 +376,11 @@ type anthropicStreamEvent struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text"` Text string `json:"text"`
StopReason string `json:"stop_reason"` StopReason string `json:"stop_reason"`
PartialJSON string `json:"partial_json"`
} `json:"delta"` } `json:"delta"`
ContentBlock *struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"content_block,omitempty"`
} }

View File

@@ -36,13 +36,32 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig,
return nil, fmt.Errorf("no choices in response") return nil, fmt.Errorf("no choices in response")
} }
return &CompletionResponse{ result := &CompletionResponse{
Content: resp.Choices[0].Message.Content, Content: resp.Choices[0].Message.Content,
Model: resp.Model, Model: resp.Model,
FinishReason: resp.Choices[0].FinishReason, FinishReason: resp.Choices[0].FinishReason,
InputTokens: resp.Usage.PromptTokens, InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens, 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 ─────────────────────── // ── Stream Completion ───────────────────────
@@ -61,6 +80,9 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
defer close(ch) defer close(ch)
defer body.Close() defer body.Close()
// Accumulate tool calls across stream chunks
toolCallMap := map[int]*ToolCall{} // index → accumulated call
scanner := bufio.NewScanner(body) scanner := bufio.NewScanner(body)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
@@ -84,13 +106,45 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
continue continue
} }
ev := StreamEvent{ choice := chunk.Choices[0]
Delta: chunk.Choices[0].Delta.Content,
Model: chunk.Model, // Accumulate tool call deltas
FinishReason: chunk.Choices[0].FinishReason, 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 != "" { if ev.FinishReason != "" {
ev.Done = true 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 ch <- ev
@@ -178,12 +232,47 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
oaiReq.TopP = req.TopP 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 { for _, m := range req.Messages {
oaiReq.Messages = append(oaiReq.Messages, openaiMessage{ oaiMsg := openaiMessage{
Role: m.Role, Role: m.Role,
Content: m.Content, 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) body, err := json.Marshal(oaiReq)
if err != nil { if err != nil {
@@ -221,7 +310,33 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
type openaiMessage struct { type openaiMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` 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 { type openaiChatRequest struct {
@@ -231,6 +346,7 @@ type openaiChatRequest struct {
Temperature *float64 `json:"temperature,omitempty"` Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"` TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
Tools []openaiToolDef `json:"tools,omitempty"`
} }
type openaiChatResponse struct { type openaiChatResponse struct {
@@ -250,6 +366,7 @@ type openaiStreamChunk struct {
Choices []struct { Choices []struct {
Delta struct { Delta struct {
Content string `json:"content"` Content string `json:"content"`
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
} `json:"delta"` } `json:"delta"`
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`
} `json:"choices"` } `json:"choices"`

View File

@@ -2,6 +2,7 @@ package providers
import ( import (
"context" "context"
"encoding/json"
) )
// ── Provider Interface ────────────────────── // ── Provider Interface ──────────────────────
@@ -37,8 +38,39 @@ type ProviderConfig struct {
// Message represents a chat message in the conversation. // Message represents a chat message in the conversation.
type Message struct { type Message struct {
Role string `json:"role"` // user, assistant, system Role string `json:"role"` // user, assistant, system, tool
Content string `json:"content"` 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. // CompletionRequest is the normalized request sent to any provider.
@@ -49,6 +81,7 @@ type CompletionRequest struct {
Temperature *float64 `json:"temperature,omitempty"` Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"` TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
Tools []ToolDef `json:"tools,omitempty"` // available tools for function calling
} }
// CompletionResponse is the normalized non-streaming response. // CompletionResponse is the normalized non-streaming response.
@@ -58,6 +91,7 @@ type CompletionResponse struct {
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`
InputTokens int `json:"input_tokens"` InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_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. // StreamEvent is a single chunk from a streaming response.
@@ -69,8 +103,13 @@ type StreamEvent struct {
Done bool `json:"done,omitempty"` Done bool `json:"done,omitempty"`
// FinishReason is set on the final event. // FinishReason is set on the final event.
// "stop" = normal, "tool_calls" = LLM wants to call tools.
FinishReason string `json:"finish_reason,omitempty"` 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 echoes back the model used.
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`

372
server/tools/notes.go Normal file
View File

@@ -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
}

203
server/tools/notes_test.go Normal file
View File

@@ -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")
}
}

92
server/tools/registry.go Normal file
View File

@@ -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)
}

194
server/tools/tools_test.go Normal file
View File

@@ -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"])
}
}

105
server/tools/types.go Normal file
View File

@@ -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"},
}
}

View File

@@ -570,6 +570,36 @@ a:hover { text-decoration: underline; }
max-height: 300px; overflow-y: auto; line-height: 1.6; 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 */
.empty-state { .empty-state {
display: flex; flex-direction: column; align-items: center; 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-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; } .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 ──────────────────────────── */ /* ── Responsive ──────────────────────────── */
.mobile-menu-btn { .mobile-menu-btn {

View File

@@ -56,6 +56,14 @@
<div class="sidebar-chats" id="chatHistory"></div> <div class="sidebar-chats" id="chatHistory"></div>
<!-- Notes shortcut -->
<div class="sidebar-notes-btn">
<button class="sb-notes" id="notesBtn" title="Notes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span class="sb-label">Notes</span>
</button>
</div>
<!-- User area --> <!-- User area -->
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<button class="user-btn" id="userMenuBtn"> <button class="user-btn" id="userMenuBtn">
@@ -401,6 +409,86 @@
</div> </div>
</div> </div>
<!-- ── Notes Modal ────────────────────────── -->
<div class="modal-overlay" id="notesModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2>Notes</h2>
<div class="notes-header-actions">
<button class="btn-small" id="notesSelectModeBtn">Select</button>
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
<button class="modal-close" onclick="closeModal('notesModal')"></button>
</div>
</div>
<div class="notes-toolbar">
<div class="notes-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
</div>
<select id="notesFolderFilter" class="notes-filter-select">
<option value="">All folders</option>
</select>
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
<option value="updated_desc">Updated ↓</option>
<option value="updated_asc">Updated ↑</option>
<option value="created_desc">Created ↓</option>
<option value="created_asc">Created ↑</option>
<option value="title_asc">Title AZ</option>
<option value="title_desc">Title ZA</option>
</select>
</div>
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
</div>
<div class="modal-body notes-body">
<!-- List view (default) -->
<div id="notesListView">
<div id="notesList" class="notes-list">
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
</div>
</div>
<!-- Editor view (hidden by default) -->
<div id="notesEditorView" style="display:none">
<div class="notes-editor">
<div class="notes-editor-toolbar">
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
<div class="notes-mode-toggle">
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
</div>
</div>
<!-- Read mode: rendered markdown -->
<div id="noteReadMode" style="display:none">
<h3 class="note-read-title" id="noteReadTitle"></h3>
<div class="note-read-meta" id="noteReadMeta"></div>
<div class="note-read-content msg-text" id="noteReadContent"></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
</div>
</div>
<!-- Edit mode: form fields -->
<div id="noteEditMode">
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
<div class="form-row">
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Debug Modal (Ctrl+Shift+L) --> <!-- Debug Modal (Ctrl+Shift+L) -->
<div class="modal-overlay" id="debugModal"> <div class="modal-overlay" id="debugModal">
<div class="modal debug-modal"> <div class="modal debug-modal">

View File

@@ -278,6 +278,27 @@ const API = {
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); }, updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); }, deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
// ── Notes ────────────────────────────────
listNotes(limit = 50, offset = 0, folder, tag, sort) {
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
// ── HTTP Internals ─────────────────────── // ── HTTP Internals ───────────────────────
_authHeaders() { _authHeaders() {

View File

@@ -753,6 +753,56 @@ function initListeners() {
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); }); document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); }); document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Notes panel
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
});
document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote);
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
else showNotesList();
});
document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => {
document.getElementById('notesSearchInput').value = '';
_exitSelectMode();
loadNotesList(e.target.value);
});
document.getElementById('notesSortSelect')?.addEventListener('change', (e) => {
_notesSort = e.target.value;
_exitSelectMode();
loadNotesList();
});
document.getElementById('notesSelectAll')?.addEventListener('change', (e) => {
_toggleSelectAll(e.target.checked);
});
document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected);
document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode);
// Notes search with debounce
let _notesSearchTimer;
document.getElementById('notesSearchInput')?.addEventListener('input', (e) => {
clearTimeout(_notesSearchTimer);
const q = e.target.value.trim();
_notesSearchTimer = setTimeout(() => {
_exitSelectMode();
if (q.length >= 2) {
document.getElementById('notesFolderFilter').value = '';
loadNotesList(null, q);
} else if (q.length === 0) {
loadNotesList();
}
}, 300);
});
// Chat actions // Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage); document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration); document.getElementById('stopBtn').addEventListener('click', stopGeneration);
@@ -1212,6 +1262,307 @@ async function deleteAdminPreset(id, name) {
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
} }
// ── Notes Panel ─────────────────────────────
var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
async function openNotes() {
openModal('notesModal');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
}
async function loadNotesList(folder, searchQuery) {
const list = document.getElementById('notesList');
list.innerHTML = '<div class="notes-loading">Loading…</div>';
try {
let data;
if (searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="notes-empty">No results found</div>';
return;
}
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
} else {
const folderVal = folder || document.getElementById('notesFolderFilter')?.value || '';
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="notes-empty">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
}
} catch (e) {
list.innerHTML = `<div class="notes-empty">Failed to load: ${esc(e.message)}</div>`;
}
}
function _noteListItem(note, isSearch) {
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
const preview = isSearch && note.headline
? `<div class="note-headline">${_highlightHeadline(note.headline)}</div>`
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
const time = _relativeTime(note.updated_at);
const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
return `
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
onclick="event.stopPropagation(); _toggleNoteSelect('${note.id}', this.checked)">
</div>
<div class="note-content-col" onclick="${_notesSelectMode ? `_toggleNoteSelect('${note.id}')` : `openNoteEditor('${note.id}')`}">
<div class="note-item-header">
<span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span>
</div>
${preview}
<div class="note-item-meta">${folder}${tags}</div>
</div>
</div>`;
}
function _highlightHeadline(headline) {
const escaped = esc(headline);
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
}
// ── Multi-select ────────────────
function _enterSelectMode() {
_notesSelectMode = true;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = '';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = '');
_updateSelectedCount();
}
function _exitSelectMode() {
_notesSelectMode = false;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = 'none';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none');
document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false);
const sa = document.getElementById('notesSelectAll');
if (sa) sa.checked = false;
}
function _toggleNoteSelect(id, forceState) {
if (!_notesSelectMode) {
_enterSelectMode();
}
const has = _selectedNoteIds.has(id);
const newState = forceState !== undefined ? forceState : !has;
if (newState) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
const item = document.querySelector(`.note-item[data-note-id="${id}"]`);
if (item) {
item.classList.toggle('selected', newState);
const cb = item.querySelector('.note-checkbox');
if (cb) cb.checked = newState;
}
_updateSelectedCount();
}
function _toggleSelectAll(checked) {
document.querySelectorAll('.note-checkbox').forEach(cb => {
const id = cb.dataset.id;
if (checked) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
cb.checked = checked;
cb.closest('.note-item')?.classList.toggle('selected', checked);
});
_updateSelectedCount();
}
function _updateSelectedCount() {
const el = document.getElementById('notesSelectedCount');
if (el) el.textContent = _selectedNoteIds.size;
}
async function _bulkDeleteSelected() {
if (_selectedNoteIds.size === 0) return;
const count = _selectedNoteIds.size;
if (!confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Folders / Editor / CRUD ─────
async function loadNoteFolders() {
const sel = document.getElementById('notesFolderFilter');
if (!sel) return;
try {
const data = await API.listNoteFolders();
const folders = data.folders || [];
sel.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
});
} catch (e) { /* non-critical */ }
}
function showNotesList() {
document.getElementById('notesListView').style.display = '';
document.getElementById('notesEditorView').style.display = 'none';
_editingNoteId = null;
}
var _currentNote = null; // cached note for read mode
async function openNoteEditor(noteId) {
document.getElementById('notesListView').style.display = 'none';
document.getElementById('notesEditorView').style.display = '';
if (noteId) {
_editingNoteId = noteId;
try {
_currentNote = await API.getNote(noteId);
_populateEditFields(_currentNote);
_showNoteReadMode();
} catch (e) {
UI.toast('Failed to load note: ' + e.message, 'error');
showNotesList();
}
} else {
// New note — straight to edit mode
_editingNoteId = null;
_currentNote = null;
_clearEditFields();
_showNoteEditMode();
document.getElementById('noteEditorTitle').focus();
}
}
function _populateEditFields(note) {
document.getElementById('noteEditorTitle').value = note.title || '';
document.getElementById('noteEditorFolder').value = note.folder_path || '';
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
document.getElementById('noteEditorContent').value = note.content || '';
}
function _clearEditFields() {
document.getElementById('noteEditorTitle').value = '';
document.getElementById('noteEditorFolder').value = '';
document.getElementById('noteEditorTags').value = '';
document.getElementById('noteEditorContent').value = '';
}
function _showNoteReadMode() {
if (!_currentNote) return;
const n = _currentNote;
// Title
document.getElementById('noteReadTitle').textContent = n.title || '';
// Meta: folder + tags
const parts = [];
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
// Rendered markdown content — reuse the chat formatter
document.getElementById('noteReadContent').innerHTML = formatMessage(n.content || '');
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
// Toggle visibility
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
}
function _showNoteEditMode() {
document.getElementById('noteReadMode').style.display = 'none';
document.getElementById('noteEditMode').style.display = '';
document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteEditBtn').style.display = 'none';
document.getElementById('notePreviewBtn').style.display = '';
}
function _showNotePreview() {
// Live preview from textarea without saving
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const folderVal = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
document.getElementById('noteReadTitle').textContent = title || 'Untitled';
const parts = [];
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Keep delete hidden in preview-from-edit
document.getElementById('noteDeleteBtn2').style.display = 'none';
}
async function saveNote() {
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const folder = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!content) { UI.toast('Content is required', 'warning'); return; }
try {
if (_editingNoteId) {
const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
_currentNote = updated;
UI.toast('Note updated', 'success');
_showNoteReadMode();
} else {
const created = await API.createNote(title, content, folder, tags);
_editingNoteId = created.id;
_currentNote = created;
UI.toast('Note created', 'success');
_showNoteReadMode();
}
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteNote() {
if (!_editingNoteId) return;
if (!confirm('Delete this note?')) return;
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');
showNotesList();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Banners ────────────────────────────────── // ── Banners ──────────────────────────────────
async function initBanners() { async function initBanners() {

View File

@@ -215,6 +215,7 @@ const UI = {
<div class="msg-avatar">🤖</div> <div class="msg-avatar">🤖</div>
<div class="msg-body"> <div class="msg-body">
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div> <div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
<div class="msg-tools" id="streamTools" style="display:none"></div>
<div class="msg-text" id="streamContent"></div> <div class="msg-text" id="streamContent"></div>
</div> </div>
</div>`; </div>`;
@@ -224,6 +225,7 @@ const UI = {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
let content = ''; let content = '';
let currentEvent = ''; // SSE event type
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -234,18 +236,41 @@ const UI = {
buffer = lines.pop() || ''; buffer = lines.pop() || '';
for (const line of lines) { for (const line of lines) {
// SSE event type line
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
continue;
}
if (!line.startsWith('data: ')) continue; if (!line.startsWith('data: ')) continue;
const data = line.slice(6); const data = line.slice(6);
if (data === '[DONE]') continue; if (data === '[DONE]') { currentEvent = ''; continue; }
try { try {
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
if (currentEvent === 'tool_use') {
// parsed = array of tool calls
this._renderToolUse(parsed);
currentEvent = '';
continue;
}
if (currentEvent === 'tool_result') {
// parsed = { tool_call_id, name, content, is_error }
this._renderToolResult(parsed);
currentEvent = '';
continue;
}
// Normal content delta
const delta = parsed.choices?.[0]?.delta?.content || ''; const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) { if (delta) {
content += delta; content += delta;
document.getElementById('streamContent').innerHTML = formatMessage(content); document.getElementById('streamContent').innerHTML = formatMessage(content);
this._scrollToBottom(); this._scrollToBottom();
} }
currentEvent = '';
} catch (e) { /* partial JSON */ } } catch (e) { /* partial JSON */ }
} }
} }
@@ -253,6 +278,47 @@ const UI = {
return content; return content;
}, },
_renderToolUse(toolCalls) {
const el = document.getElementById('streamTools');
if (!el) return;
el.style.display = '';
for (const tc of toolCalls) {
const name = tc.function?.name || tc.name || 'unknown';
const toolDiv = document.createElement('div');
toolDiv.className = 'tool-activity';
toolDiv.id = `tool_${tc.id}`;
toolDiv.innerHTML = `
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
<span class="tool-status tool-running">running…</span>`;
el.appendChild(toolDiv);
}
this._scrollToBottom();
},
_renderToolResult(result) {
const el = document.getElementById(`tool_${result.tool_call_id}`);
if (el) {
const statusEl = el.querySelector('.tool-status');
if (statusEl) {
statusEl.textContent = result.is_error ? 'error' : 'done';
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
}
// Parse tool result content for a brief summary
try {
const parsed = JSON.parse(result.content);
const summary = parsed.title || parsed.id || parsed.count != null ? `${parsed.count} results` : '';
if (summary) {
const hint = document.createElement('span');
hint.className = 'tool-hint';
hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary);
el.appendChild(hint);
}
} catch (e) { /* not JSON or no useful summary */ }
}
this._scrollToBottom();
},
// ── Model Selector (Custom Dropdown) ──── // ── Model Selector (Custom Dropdown) ────
_modelValue: '', _modelValue: '',