Changeset 0.7.2 (#40)
This commit is contained in:
50
server/database/migrations/014_notes.sql
Normal file
50
server/database/migrations/014_notes.sql
Normal 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();
|
||||
246
server/database/testhelper.go
Normal file
246
server/database/testhelper.go
Normal 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
|
||||
}
|
||||
@@ -12,11 +12,6 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
providers.Init()
|
||||
}
|
||||
|
||||
func TestCreateConfigMissingFields(t *testing.T) {
|
||||
h := NewAPIConfigHandler()
|
||||
r := gin.New()
|
||||
|
||||
@@ -17,10 +17,6 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
JWTSecret: "test-secret-key-for-unit-tests",
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Channel Request Validation ─────────────────
|
||||
|
||||
@@ -60,7 +59,7 @@ func TestUpdateChannelEmptyBody(t *testing.T) {
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Without a DB connection, UpdateChannel will fail at ownership check.
|
||||
// Integration tests with a real DB would validate the "no fields" path.
|
||||
// Integration tests with a real DB validate the "no fields" path.
|
||||
// Here we just confirm it doesn't return 400 for valid JSON.
|
||||
h.UpdateChannel(c)
|
||||
|
||||
@@ -122,19 +121,151 @@ func TestCreateMessageMissingContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration: Message CRUD with Real DB ──────
|
||||
|
||||
func TestCreateMessageValidRoles(t *testing.T) {
|
||||
// Valid roles should pass validation (not return 400).
|
||||
// They'll fail at ownership check (no DB), which is expected.
|
||||
t.Skip("requires database connection for ownership check")
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "roletester", "role@test.com")
|
||||
channelID := database.SeedTestChannel(t, userID, "Role Test")
|
||||
|
||||
h := NewMessageHandler()
|
||||
|
||||
for _, role := range []string{"user", "assistant", "system"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", userID)
|
||||
c.Params = gin.Params{{Key: "id", Value: channelID}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/"+channelID+"/messages",
|
||||
strings.NewReader(`{"role":"`+role+`","content":"hello from `+role+`"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateMessage(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("role=%s: expected 201, got %d: %s", role, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regenerate ───────────────────────────────
|
||||
func TestChannelCRUDIntegration(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
func TestRegenerateRequiresDB(t *testing.T) {
|
||||
// Regenerate is now a full handler (creates sibling assistant message
|
||||
// via tree-aware streaming). It calls userOwnsChannel → database.DB,
|
||||
// so it cannot run without a live database connection.
|
||||
t.Skip("requires database connection for ownership check")
|
||||
userID := database.SeedTestUser(t, "cruduser", "crud@test.com")
|
||||
|
||||
h := NewChannelHandler()
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
|
||||
r.POST("/channels", h.CreateChannel)
|
||||
r.GET("/channels", h.ListChannels)
|
||||
r.GET("/channels/:id", h.GetChannel)
|
||||
r.PUT("/channels/:id", h.UpdateChannel)
|
||||
r.DELETE("/channels/:id", h.DeleteChannel)
|
||||
|
||||
// Create
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/channels",
|
||||
strings.NewReader(`{"title":"Integration Test Channel"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &created)
|
||||
channelID := created["id"].(string)
|
||||
|
||||
// Get
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Get: expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// List
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/channels", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("List: expected 200, got %d", w.Code)
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
if listResp["total"].(float64) < 1 {
|
||||
t.Error("List: expected at least 1 channel")
|
||||
}
|
||||
|
||||
// Update
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", "/channels/"+channelID,
|
||||
strings.NewReader(`{"title":"Updated Title"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Delete
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("DELETE", "/channels/"+channelID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Delete: expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Get after delete: expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegeneratePassesOwnershipCheck(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "regenuser", "regen@test.com")
|
||||
channelID := database.SeedTestChannel(t, userID, "Regen Test")
|
||||
|
||||
// Seed an assistant message to regenerate
|
||||
var msgID string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, participant_type, participant_id)
|
||||
VALUES ($1, 'assistant', 'original response', 'model', 'test-model')
|
||||
RETURNING id
|
||||
`, channelID).Scan(&msgID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
|
||||
h := NewMessageHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", userID)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: channelID},
|
||||
{Key: "msgId", Value: msgID},
|
||||
}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/api/v1/channels/"+channelID+"/messages/"+msgID+"/regenerate",
|
||||
strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Regenerate(c)
|
||||
|
||||
// Should NOT be 404 — ownership check passed. Will be 400 or 500
|
||||
// because no API config is set up, which is expected.
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Errorf("Expected to pass ownership check, got 404: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pagination Helpers ──────────────────────
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// ── Request Types ───────────────────────────
|
||||
@@ -45,9 +46,11 @@ func NewCompletionHandler() *CompletionHandler {
|
||||
// 2. Resolve api_config (from request, chat, or user default)
|
||||
// 3. Load conversation history from messages
|
||||
// 4. Persist user message
|
||||
// 5. Call provider (stream or non-stream)
|
||||
// 6. Stream SSE to client / return JSON
|
||||
// 7. Persist assistant message with token counts
|
||||
// 5. Attach tool definitions if model supports tools
|
||||
// 6. Call provider (stream or non-stream)
|
||||
// 7. Tool loop: if LLM requests tool calls, execute them and re-call
|
||||
// 8. Stream SSE to client / return JSON
|
||||
// 9. Persist assistant message with token counts
|
||||
|
||||
func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
var req completionRequest
|
||||
@@ -153,6 +156,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
provReq.TopP = req.TopP
|
||||
}
|
||||
|
||||
// Attach tool definitions if model supports tool calling and tools are available
|
||||
if caps.ToolCalling && tools.HasTools() {
|
||||
provReq.Tools = h.buildToolDefs()
|
||||
}
|
||||
|
||||
// Determine streaming
|
||||
stream := true // default
|
||||
if req.Stream != nil {
|
||||
@@ -166,7 +174,26 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming Completion (SSE) ──────────────
|
||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
||||
func (h *CompletionHandler) buildToolDefs() []providers.ToolDef {
|
||||
allTools := tools.AllDefinitions()
|
||||
defs := make([]providers.ToolDef, len(allTools))
|
||||
for i, t := range allTools {
|
||||
defs[i] = providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ── Streaming Completion (SSE) with Tool Loop ──
|
||||
|
||||
const maxToolIterations = 10 // safety limit on tool call rounds
|
||||
|
||||
func (h *CompletionHandler) streamCompletion(
|
||||
c *gin.Context,
|
||||
@@ -175,12 +202,6 @@ func (h *CompletionHandler) streamCompletion(
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model string,
|
||||
) {
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
@@ -188,45 +209,150 @@ func (h *CompletionHandler) streamCompletion(
|
||||
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
var fullContent string
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
for event := range ch {
|
||||
if event.Error != nil {
|
||||
fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error())
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if event.Delta != "" {
|
||||
fullContent += event.Delta
|
||||
// OpenAI-compatible SSE format
|
||||
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n",
|
||||
event.Delta, model)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
if event.Done {
|
||||
finishReason := event.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n",
|
||||
finishReason, model)
|
||||
io.WriteString(c.Writer, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
break
|
||||
flush := func() {
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Persist assistant response
|
||||
// sendSSE sends a standard OpenAI-compatible SSE data line.
|
||||
sendSSE := func(data string) {
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
|
||||
flush()
|
||||
}
|
||||
|
||||
// sendEvent sends a named SSE event (for tool status — non-standard).
|
||||
sendEvent := func(event, data string) {
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||||
flush()
|
||||
}
|
||||
|
||||
var fullContent string
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
var iterContent string
|
||||
var toolCalls []providers.ToolCall
|
||||
|
||||
for event := range ch {
|
||||
if event.Error != nil {
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
// Stream text deltas to client
|
||||
if event.Delta != "" {
|
||||
iterContent += event.Delta
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
|
||||
event.Delta, model))
|
||||
}
|
||||
|
||||
if event.Done {
|
||||
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
||||
toolCalls = event.ToolCalls
|
||||
} else {
|
||||
// Normal completion — send finish event
|
||||
finishReason := event.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
fullContent += iterContent
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
|
||||
finishReason, model))
|
||||
sendSSE("[DONE]")
|
||||
|
||||
// Persist assistant response
|
||||
if fullContent != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool execution round ────────────────
|
||||
if len(toolCalls) == 0 {
|
||||
// Stream ended without tool calls or finish — shouldn't happen
|
||||
sendSSE("[DONE]")
|
||||
if iterContent != "" {
|
||||
fullContent += iterContent
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Notify client about tool calls
|
||||
toolCallsJSON, _ := json.Marshal(toolCalls)
|
||||
sendEvent("tool_use", string(toolCallsJSON))
|
||||
|
||||
// Append assistant message (with tool_calls, possibly with text) to conversation
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: iterContent,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
// Execute all tool calls
|
||||
execCtx := tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
|
||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
||||
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
|
||||
// Notify client about tool result
|
||||
resultJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"tool_call_id": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"content": result.Content,
|
||||
"is_error": result.IsError,
|
||||
})
|
||||
sendEvent("tool_result", string(resultJSON))
|
||||
|
||||
// Append tool result to conversation for next iteration
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Name: result.Name,
|
||||
})
|
||||
}
|
||||
|
||||
fullContent += iterContent
|
||||
// Loop: send updated messages back to provider
|
||||
}
|
||||
|
||||
// If we hit max iterations, send what we have
|
||||
log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID)
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
|
||||
escapeJSON("[Tool execution limit reached]"), model))
|
||||
sendSSE("[DONE]")
|
||||
|
||||
if fullContent != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
@@ -234,7 +360,7 @@ func (h *CompletionHandler) streamCompletion(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Non-Streaming Completion ────────────────
|
||||
// ── Non-Streaming Completion with Tool Loop ──
|
||||
|
||||
func (h *CompletionHandler) syncCompletion(
|
||||
c *gin.Context,
|
||||
@@ -243,39 +369,108 @@ func (h *CompletionHandler) syncCompletion(
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model string,
|
||||
) {
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
var totalInput, totalOutput int
|
||||
var finalContent string
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
totalInput += resp.InputTokens
|
||||
totalOutput += resp.OutputTokens
|
||||
|
||||
// No tool calls — normal response
|
||||
if len(resp.ToolCalls) == 0 || resp.FinishReason != "tool_calls" {
|
||||
finalContent = resp.Content
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": resp.Model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": finalContent,
|
||||
},
|
||||
"finish_reason": resp.FinishReason,
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": totalInput,
|
||||
"completion_tokens": totalOutput,
|
||||
"total_tokens": totalInput + totalOutput,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Tool execution round ────────────────
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: resp.Content,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
execCtx := tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
||||
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Name: result.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
// Max iterations reached
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": resp.Model,
|
||||
"model": model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": resp.Content,
|
||||
"content": "[Tool execution limit reached]",
|
||||
},
|
||||
"finish_reason": resp.FinishReason,
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": resp.InputTokens,
|
||||
"completion_tokens": resp.OutputTokens,
|
||||
"total_tokens": resp.InputTokens + resp.OutputTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Model Capabilities ──────────────────────
|
||||
|
||||
// escapeJSON escapes a string for safe embedding in a JSON string value.
|
||||
func escapeJSON(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
s = strings.ReplaceAll(s, "\t", `\t`)
|
||||
return s
|
||||
}
|
||||
|
||||
// getModelCapabilities looks up capabilities from model_configs DB,
|
||||
// then overlays with known model defaults and heuristic detection.
|
||||
func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities {
|
||||
|
||||
504
server/handlers/notes.go
Normal file
504
server/handlers/notes.go
Normal 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(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, note)
|
||||
}
|
||||
|
||||
// ── Get ─────────────────────────────────────
|
||||
// GET /api/v1/notes/:id
|
||||
|
||||
func (h *NoteHandler) Get(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
var note noteResponse
|
||||
var dbTags pq.StringArray
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, title, content, folder_path, tags, source_channel_id,
|
||||
created_at::text, updated_at::text
|
||||
FROM notes WHERE id = $1 AND user_id = $2
|
||||
`, noteID, userID).Scan(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get note"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, note)
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────
|
||||
// PUT /api/v1/notes/:id
|
||||
|
||||
func (h *NoteHandler) Update(c *gin.Context) {
|
||||
var req updateNoteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
var exists bool
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM notes WHERE id = $1 AND user_id = $2)`,
|
||||
noteID, userID,
|
||||
).Scan(&exists)
|
||||
if err != nil || !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.Title != nil {
|
||||
setClauses = append(setClauses, "title = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, *req.Title)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.Content != nil {
|
||||
mode := strings.ToLower(req.Mode)
|
||||
switch mode {
|
||||
case "append":
|
||||
setClauses = append(setClauses, "content = content || $"+strconv.Itoa(argIdx))
|
||||
case "prepend":
|
||||
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)+" || content")
|
||||
default: // "replace" or empty
|
||||
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx))
|
||||
}
|
||||
args = append(args, *req.Content)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.FolderPath != nil {
|
||||
setClauses = append(setClauses, "folder_path = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, normalizeFolderPath(*req.FolderPath))
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.Tags != nil {
|
||||
setClauses = append(setClauses, "tags = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, pq.Array(req.Tags))
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
// WHERE clause
|
||||
args = append(args, noteID, userID)
|
||||
query := "UPDATE notes SET " + strings.Join(setClauses, ", ") +
|
||||
" WHERE id = $" + strconv.Itoa(argIdx) +
|
||||
" AND user_id = $" + strconv.Itoa(argIdx+1) +
|
||||
" RETURNING id, title, content, folder_path, tags, source_channel_id, created_at::text, updated_at::text"
|
||||
|
||||
var note noteResponse
|
||||
var dbTags pq.StringArray
|
||||
err = database.DB.QueryRow(query, args...).Scan(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, note)
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────
|
||||
// DELETE /api/v1/notes/:id
|
||||
|
||||
func (h *NoteHandler) Delete(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM notes WHERE id = $1 AND user_id = $2`,
|
||||
noteID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// ── Bulk Delete ────────────────────────────
|
||||
// POST /api/v1/notes/bulk-delete
|
||||
|
||||
func (h *NoteHandler) BulkDelete(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM notes WHERE id = ANY($1) AND user_id = $2`,
|
||||
pq.Array(req.IDs), userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"})
|
||||
return
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": count})
|
||||
}
|
||||
// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc
|
||||
|
||||
func (h *NoteHandler) List(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
folder := c.Query("folder")
|
||||
tag := c.Query("tag")
|
||||
sort := c.DefaultQuery("sort", "updated_desc")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := `SELECT id, title, folder_path, tags, LEFT(content, 200),
|
||||
created_at::text, updated_at::text
|
||||
FROM notes WHERE user_id = $1`
|
||||
args := []interface{}{userID}
|
||||
argIdx := 2
|
||||
|
||||
if folder != "" {
|
||||
query += " AND folder_path = $" + strconv.Itoa(argIdx)
|
||||
args = append(args, normalizeFolderPath(folder))
|
||||
argIdx++
|
||||
}
|
||||
if tag != "" {
|
||||
query += " AND $" + strconv.Itoa(argIdx) + " = ANY(tags)"
|
||||
args = append(args, tag)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Sort options
|
||||
switch sort {
|
||||
case "created_asc":
|
||||
query += " ORDER BY created_at ASC"
|
||||
case "created_desc":
|
||||
query += " ORDER BY created_at DESC"
|
||||
case "updated_asc":
|
||||
query += " ORDER BY updated_at ASC"
|
||||
case "title_asc":
|
||||
query += " ORDER BY title ASC"
|
||||
case "title_desc":
|
||||
query += " ORDER BY title DESC"
|
||||
default: // "updated_desc"
|
||||
query += " ORDER BY updated_at DESC"
|
||||
}
|
||||
|
||||
query += " LIMIT $" + strconv.Itoa(argIdx) +
|
||||
" OFFSET $" + strconv.Itoa(argIdx+1)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
notes := make([]noteListItem, 0)
|
||||
for rows.Next() {
|
||||
var n noteListItem
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath, &dbTags, &n.Preview,
|
||||
&n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
n.Tags = []string(dbTags)
|
||||
if n.Tags == nil {
|
||||
n.Tags = []string{}
|
||||
}
|
||||
notes = append(notes, n)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM notes WHERE user_id = $1`
|
||||
countArgs := []interface{}{userID}
|
||||
if folder != "" {
|
||||
countQuery += " AND folder_path = $2"
|
||||
countArgs = append(countArgs, normalizeFolderPath(folder))
|
||||
}
|
||||
_ = database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": notes,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────
|
||||
// GET /api/v1/notes/search?q=query&limit=20
|
||||
|
||||
func (h *NoteHandler) Search(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Use plainto_tsquery for natural language (not websearch_to_tsquery which
|
||||
// requires Postgres 11+ and has stricter syntax).
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, title, folder_path, tags, LEFT(content, 200),
|
||||
created_at::text, updated_at::text,
|
||||
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
|
||||
ts_headline('english', content, plainto_tsquery('english', $2),
|
||||
'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS headline
|
||||
FROM notes
|
||||
WHERE user_id = $1
|
||||
AND search_vector @@ plainto_tsquery('english', $2)
|
||||
ORDER BY rank DESC
|
||||
LIMIT $3
|
||||
`, userID, q, limit)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := make([]searchResult, 0)
|
||||
for rows.Next() {
|
||||
var r searchResult
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Preview,
|
||||
&r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Tags = []string(dbTags)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": results,
|
||||
"query": q,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
// ── List Folders ────────────────────────────
|
||||
// GET /api/v1/notes/folders
|
||||
|
||||
func (h *NoteHandler) ListFolders(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT DISTINCT folder_path, COUNT(*) AS count
|
||||
FROM notes WHERE user_id = $1
|
||||
GROUP BY folder_path
|
||||
ORDER BY folder_path
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type folderInfo struct {
|
||||
Path string `json:"path"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
folders := make([]folderInfo, 0)
|
||||
for rows.Next() {
|
||||
var f folderInfo
|
||||
if err := rows.Scan(&f.Path, &f.Count); err != nil {
|
||||
continue
|
||||
}
|
||||
folders = append(folders, f)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"folders": folders})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// normalizeFolderPath ensures consistent folder path format.
|
||||
func normalizeFolderPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
if !strings.HasSuffix(p, "/") {
|
||||
p = p + "/"
|
||||
}
|
||||
// Collapse double slashes
|
||||
for strings.Contains(p, "//") {
|
||||
p = strings.ReplaceAll(p, "//", "/")
|
||||
}
|
||||
return p
|
||||
}
|
||||
329
server/handlers/notes_test.go
Normal file
329
server/handlers/notes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
24
server/handlers/testmain_test.go
Normal file
24
server/handlers/testmain_test.go
Normal 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)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -143,6 +144,17 @@ func main() {
|
||||
protected.PUT("/presets/:id", presets.UpdateUserPreset)
|
||||
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
|
||||
|
||||
// Notes
|
||||
notes := handlers.NewNoteHandler()
|
||||
protected.GET("/notes", notes.List)
|
||||
protected.POST("/notes", notes.Create)
|
||||
protected.GET("/notes/search", notes.Search)
|
||||
protected.GET("/notes/folders", notes.ListFolders)
|
||||
protected.POST("/notes/bulk-delete", notes.BulkDelete)
|
||||
protected.GET("/notes/:id", notes.Get)
|
||||
protected.PUT("/notes/:id", notes.Update)
|
||||
protected.DELETE("/notes/:id", notes.Delete)
|
||||
|
||||
// Public global settings (non-admin users can read safe subset)
|
||||
adm := handlers.NewAdminHandler()
|
||||
protected.GET("/settings/public", adm.PublicSettings)
|
||||
|
||||
@@ -58,6 +58,8 @@ func TestAllRoutesRegistered(t *testing.T) {
|
||||
comp := handlers.NewCompletionHandler()
|
||||
apiCfg := handlers.NewAPIConfigHandler()
|
||||
settings := handlers.NewSettingsHandler()
|
||||
presets := handlers.NewPresetHandler()
|
||||
notes := handlers.NewNoteHandler()
|
||||
adm := handlers.NewAdminHandler()
|
||||
|
||||
r := gin.New()
|
||||
@@ -83,7 +85,13 @@ func TestAllRoutesRegistered(t *testing.T) {
|
||||
// Messages
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||
protected.POST("/channels/:id/regenerate", msgs.Regenerate)
|
||||
|
||||
// Message tree (forking)
|
||||
protected.GET("/channels/:id/path", msgs.GetActivePath)
|
||||
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
|
||||
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
|
||||
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
|
||||
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||
|
||||
// Completion Engine
|
||||
protected.POST("/chat/completions", comp.Complete)
|
||||
@@ -104,6 +112,22 @@ func TestAllRoutesRegistered(t *testing.T) {
|
||||
protected.POST("/profile/password", settings.ChangePassword)
|
||||
protected.GET("/settings", settings.GetSettings)
|
||||
protected.PUT("/settings", settings.UpdateSettings)
|
||||
|
||||
// Model Presets
|
||||
protected.GET("/presets", presets.ListUserPresets)
|
||||
protected.POST("/presets", presets.CreateUserPreset)
|
||||
protected.PUT("/presets/:id", presets.UpdateUserPreset)
|
||||
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
|
||||
|
||||
// Notes
|
||||
protected.GET("/notes", notes.List)
|
||||
protected.POST("/notes", notes.Create)
|
||||
protected.GET("/notes/search", notes.Search)
|
||||
protected.GET("/notes/folders", notes.ListFolders)
|
||||
protected.POST("/notes/bulk-delete", notes.BulkDelete)
|
||||
protected.GET("/notes/:id", notes.Get)
|
||||
protected.PUT("/notes/:id", notes.Update)
|
||||
protected.DELETE("/notes/:id", notes.Delete)
|
||||
}
|
||||
|
||||
// Admin routes
|
||||
@@ -149,7 +173,12 @@ func TestAllRoutesRegistered(t *testing.T) {
|
||||
// Messages
|
||||
"GET /api/v1/channels/:id/messages",
|
||||
"POST /api/v1/channels/:id/messages",
|
||||
"POST /api/v1/channels/:id/regenerate",
|
||||
// Message tree (forking)
|
||||
"GET /api/v1/channels/:id/path",
|
||||
"PUT /api/v1/channels/:id/cursor",
|
||||
"POST /api/v1/channels/:id/messages/:msgId/edit",
|
||||
"POST /api/v1/channels/:id/messages/:msgId/regenerate",
|
||||
"GET /api/v1/channels/:id/messages/:msgId/siblings",
|
||||
// Completion Engine
|
||||
"POST /api/v1/chat/completions",
|
||||
// API Configs
|
||||
@@ -168,6 +197,20 @@ func TestAllRoutesRegistered(t *testing.T) {
|
||||
"POST /api/v1/profile/password",
|
||||
"GET /api/v1/settings",
|
||||
"PUT /api/v1/settings",
|
||||
// Presets
|
||||
"GET /api/v1/presets",
|
||||
"POST /api/v1/presets",
|
||||
"PUT /api/v1/presets/:id",
|
||||
"DELETE /api/v1/presets/:id",
|
||||
// Notes
|
||||
"GET /api/v1/notes",
|
||||
"POST /api/v1/notes",
|
||||
"GET /api/v1/notes/search",
|
||||
"GET /api/v1/notes/folders",
|
||||
"POST /api/v1/notes/bulk-delete",
|
||||
"GET /api/v1/notes/:id",
|
||||
"PUT /api/v1/notes/:id",
|
||||
"DELETE /api/v1/notes/:id",
|
||||
// Admin
|
||||
"GET /api/v1/admin/users",
|
||||
"POST /api/v1/admin/users",
|
||||
|
||||
@@ -35,21 +35,37 @@ func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConf
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
// Extract text from content blocks
|
||||
var content string
|
||||
for _, block := range resp.Content {
|
||||
if block.Type == "text" {
|
||||
content += block.Text
|
||||
}
|
||||
}
|
||||
|
||||
return &CompletionResponse{
|
||||
Content: content,
|
||||
result := &CompletionResponse{
|
||||
Model: resp.Model,
|
||||
FinishReason: resp.StopReason,
|
||||
InputTokens: resp.Usage.InputTokens,
|
||||
OutputTokens: resp.Usage.OutputTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Extract text and tool_use blocks
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
result.Content += block.Text
|
||||
case "tool_use":
|
||||
argsJSON, _ := json.Marshal(block.Input)
|
||||
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: FunctionCall{
|
||||
Name: block.Name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize stop reason
|
||||
if resp.StopReason == "tool_use" {
|
||||
result.FinishReason = "tool_calls"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ── Stream Completion ───────────────────────
|
||||
@@ -68,6 +84,9 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
|
||||
defer close(ch)
|
||||
defer body.Close()
|
||||
|
||||
var toolCalls []ToolCall
|
||||
var currentToolIdx int = -1
|
||||
|
||||
scanner := bufio.NewScanner(body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
@@ -84,18 +103,42 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "content_block_start":
|
||||
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
|
||||
currentToolIdx++
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: event.ContentBlock.ID,
|
||||
Type: "function",
|
||||
Function: FunctionCall{
|
||||
Name: event.ContentBlock.Name,
|
||||
Arguments: "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
if event.Delta.Type == "text_delta" {
|
||||
ch <- StreamEvent{Delta: event.Delta.Text}
|
||||
} else if event.Delta.Type == "input_json_delta" && currentToolIdx >= 0 {
|
||||
toolCalls[currentToolIdx].Function.Arguments += event.Delta.PartialJSON
|
||||
}
|
||||
|
||||
case "message_delta":
|
||||
ch <- StreamEvent{
|
||||
stopReason := event.Delta.StopReason
|
||||
ev := StreamEvent{
|
||||
Done: true,
|
||||
FinishReason: event.Delta.StopReason,
|
||||
FinishReason: stopReason,
|
||||
}
|
||||
if stopReason == "tool_use" {
|
||||
ev.FinishReason = "tool_calls"
|
||||
ev.ToolCalls = toolCalls
|
||||
}
|
||||
ch <- ev
|
||||
|
||||
case "message_stop":
|
||||
ch <- StreamEvent{Done: true}
|
||||
return
|
||||
|
||||
case "error":
|
||||
ch <- StreamEvent{
|
||||
Error: fmt.Errorf("anthropic stream error: %s", data),
|
||||
@@ -115,8 +158,6 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
|
||||
// ── List Models ─────────────────────────────
|
||||
|
||||
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) {
|
||||
// Anthropic doesn't have a list models endpoint.
|
||||
// Return known models with authoritative capabilities from our table.
|
||||
modelIDs := []struct{ id, name string }{
|
||||
{"claude-opus-4-20250514", "Claude Opus 4"},
|
||||
{"claude-sonnet-4-20250514", "Claude Sonnet 4"},
|
||||
@@ -147,7 +188,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
}
|
||||
endpoint = strings.TrimSuffix(endpoint, "/") + "/v1/messages"
|
||||
|
||||
// Separate system message from conversation
|
||||
// Separate system message and convert to Anthropic format
|
||||
var system string
|
||||
messages := make([]anthropicMessage, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
@@ -155,13 +196,55 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
system = m.Content
|
||||
continue
|
||||
}
|
||||
messages = append(messages, anthropicMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
})
|
||||
|
||||
antMsg := anthropicMessage{Role: m.Role}
|
||||
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
// Assistant with tool calls → mixed content blocks
|
||||
if m.Content != "" {
|
||||
antMsg.Content = []anthropicContentBlock{
|
||||
{Type: "text", Text: m.Content},
|
||||
}
|
||||
} else {
|
||||
antMsg.Content = []anthropicContentBlock{}
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
var input json.RawMessage
|
||||
if tc.Function.Arguments != "" {
|
||||
input = json.RawMessage(tc.Function.Arguments)
|
||||
} else {
|
||||
input = json.RawMessage("{}")
|
||||
}
|
||||
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
|
||||
Type: "tool_use",
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
} else if m.Role == "tool" {
|
||||
// Tool results → user message with tool_result block
|
||||
antMsg.Role = "user"
|
||||
antMsg.Content = []anthropicContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: m.ToolCallID,
|
||||
Content: m.Content,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Regular text message
|
||||
antMsg.Content = []anthropicContentBlock{
|
||||
{Type: "text", Text: m.Content},
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, antMsg)
|
||||
}
|
||||
|
||||
// Build Anthropic-format request
|
||||
// Merge consecutive user messages (Anthropic requires alternating roles)
|
||||
messages = mergeConsecutiveUserMessages(messages)
|
||||
|
||||
antReq := anthropicRequest{
|
||||
Model: req.Model,
|
||||
Messages: messages,
|
||||
@@ -172,7 +255,6 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
antReq.System = system
|
||||
}
|
||||
if antReq.MaxTokens == 0 {
|
||||
// Anthropic requires max_tokens. Resolve from known model table.
|
||||
antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{})
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
@@ -182,6 +264,15 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
antReq.TopP = req.TopP
|
||||
}
|
||||
|
||||
// Add tools in Anthropic format
|
||||
for _, t := range req.Tools {
|
||||
antReq.Tools = append(antReq.Tools, anthropicToolDef{
|
||||
Name: t.Function.Name,
|
||||
Description: t.Function.Description,
|
||||
InputSchema: t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
|
||||
body, err := json.Marshal(antReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
@@ -209,11 +300,47 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// mergeConsecutiveUserMessages combines consecutive user-role messages.
|
||||
// Anthropic requires strictly alternating user/assistant roles.
|
||||
func mergeConsecutiveUserMessages(msgs []anthropicMessage) []anthropicMessage {
|
||||
if len(msgs) <= 1 {
|
||||
return msgs
|
||||
}
|
||||
merged := make([]anthropicMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if len(merged) > 0 && merged[len(merged)-1].Role == "user" && m.Role == "user" {
|
||||
merged[len(merged)-1].Content = append(merged[len(merged)-1].Content, m.Content...)
|
||||
} else {
|
||||
merged = append(merged, m)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// ── Anthropic Wire Types ────────────────────
|
||||
|
||||
type anthropicContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
// text
|
||||
Text string `json:"text,omitempty"`
|
||||
// tool_use
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
// tool_result
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content []anthropicContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
type anthropicToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
}
|
||||
|
||||
type anthropicRequest struct {
|
||||
@@ -224,14 +351,18 @@ type anthropicRequest struct {
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
Tools []anthropicToolDef `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
@@ -242,8 +373,14 @@ type anthropicResponse struct {
|
||||
type anthropicStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
PartialJSON string `json:"partial_json"`
|
||||
} `json:"delta"`
|
||||
ContentBlock *struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"content_block,omitempty"`
|
||||
}
|
||||
|
||||
@@ -36,13 +36,32 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig,
|
||||
return nil, fmt.Errorf("no choices in response")
|
||||
}
|
||||
|
||||
return &CompletionResponse{
|
||||
result := &CompletionResponse{
|
||||
Content: resp.Choices[0].Message.Content,
|
||||
Model: resp.Model,
|
||||
FinishReason: resp.Choices[0].FinishReason,
|
||||
InputTokens: resp.Usage.PromptTokens,
|
||||
OutputTokens: resp.Usage.CompletionTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Extract tool calls if present
|
||||
if len(resp.Choices[0].Message.ToolCalls) > 0 {
|
||||
for _, tc := range resp.Choices[0].Message.ToolCalls {
|
||||
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: tc.Type,
|
||||
Function: FunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
if result.FinishReason == "" {
|
||||
result.FinishReason = "tool_calls"
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ── Stream Completion ───────────────────────
|
||||
@@ -61,6 +80,9 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
|
||||
defer close(ch)
|
||||
defer body.Close()
|
||||
|
||||
// Accumulate tool calls across stream chunks
|
||||
toolCallMap := map[int]*ToolCall{} // index → accumulated call
|
||||
|
||||
scanner := bufio.NewScanner(body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
@@ -84,13 +106,45 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
|
||||
continue
|
||||
}
|
||||
|
||||
ev := StreamEvent{
|
||||
Delta: chunk.Choices[0].Delta.Content,
|
||||
Model: chunk.Model,
|
||||
FinishReason: chunk.Choices[0].FinishReason,
|
||||
choice := chunk.Choices[0]
|
||||
|
||||
// Accumulate tool call deltas
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
idx := 0
|
||||
if tc.Index != nil {
|
||||
idx = *tc.Index
|
||||
}
|
||||
|
||||
if existing, ok := toolCallMap[idx]; ok {
|
||||
// Append arguments fragment
|
||||
existing.Function.Arguments += tc.Function.Arguments
|
||||
} else {
|
||||
// First chunk for this tool — has ID and name
|
||||
toolCallMap[idx] = &ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: "function",
|
||||
Function: FunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ev := StreamEvent{
|
||||
Delta: choice.Delta.Content,
|
||||
Model: chunk.Model,
|
||||
FinishReason: choice.FinishReason,
|
||||
}
|
||||
|
||||
if ev.FinishReason != "" {
|
||||
ev.Done = true
|
||||
// Attach accumulated tool calls on final event
|
||||
if ev.FinishReason == "tool_calls" && len(toolCallMap) > 0 {
|
||||
for _, tc := range toolCallMap {
|
||||
ev.ToolCalls = append(ev.ToolCalls, *tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ch <- ev
|
||||
@@ -178,11 +232,46 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
||||
oaiReq.TopP = req.TopP
|
||||
}
|
||||
|
||||
// Convert tools
|
||||
for _, t := range req.Tools {
|
||||
oaiReq.Tools = append(oaiReq.Tools, openaiToolDef{
|
||||
Type: t.Type,
|
||||
Function: openaiToolDefFunction{
|
||||
Name: t.Function.Name,
|
||||
Description: t.Function.Description,
|
||||
Parameters: t.Function.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Convert messages — handle all roles including tool
|
||||
for _, m := range req.Messages {
|
||||
oaiReq.Messages = append(oaiReq.Messages, openaiMessage{
|
||||
oaiMsg := openaiMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Assistant messages with tool calls
|
||||
if len(m.ToolCalls) > 0 {
|
||||
for _, tc := range m.ToolCalls {
|
||||
oaiMsg.ToolCalls = append(oaiMsg.ToolCalls, openaiToolCall{
|
||||
ID: tc.ID,
|
||||
Type: tc.Type,
|
||||
Function: openaiToolCallFunction{
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Tool result messages
|
||||
if m.Role == "tool" {
|
||||
oaiMsg.ToolCallID = m.ToolCallID
|
||||
oaiMsg.Name = m.Name
|
||||
}
|
||||
|
||||
oaiReq.Messages = append(oaiReq.Messages, oaiMsg)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(oaiReq)
|
||||
@@ -220,8 +309,34 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
||||
// ── OpenAI Wire Types ───────────────────────
|
||||
|
||||
type openaiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
|
||||
Name string `json:"name,omitempty"` // tool name for role=tool
|
||||
}
|
||||
|
||||
type openaiToolCallFunction struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type openaiToolCall struct {
|
||||
Index *int `json:"index,omitempty"` // present in streaming deltas
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"` // "function"
|
||||
Function openaiToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
type openaiToolDefFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
}
|
||||
|
||||
type openaiToolDef struct {
|
||||
Type string `json:"type"` // "function"
|
||||
Function openaiToolDefFunction `json:"function"`
|
||||
}
|
||||
|
||||
type openaiChatRequest struct {
|
||||
@@ -231,6 +346,7 @@ type openaiChatRequest struct {
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
Tools []openaiToolDef `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type openaiChatResponse struct {
|
||||
@@ -249,7 +365,8 @@ type openaiStreamChunk struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// ── Provider Interface ──────────────────────
|
||||
@@ -37,8 +38,39 @@ type ProviderConfig struct {
|
||||
|
||||
// Message represents a chat message in the conversation.
|
||||
type Message struct {
|
||||
Role string `json:"role"` // user, assistant, system
|
||||
Role string `json:"role"` // user, assistant, system, tool
|
||||
Content string `json:"content"`
|
||||
|
||||
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
|
||||
Name string `json:"name,omitempty"` // tool name for role="tool"
|
||||
}
|
||||
|
||||
// ToolCall represents a function call requested by the LLM.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "function"
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
// FunctionCall is the function name and JSON arguments.
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
// ToolDef describes a tool available to the LLM.
|
||||
type ToolDef struct {
|
||||
Type string `json:"type"` // "function"
|
||||
Function FunctionDef `json:"function"`
|
||||
}
|
||||
|
||||
// FunctionDef is the schema for a tool function.
|
||||
type FunctionDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
}
|
||||
|
||||
// CompletionRequest is the normalized request sent to any provider.
|
||||
@@ -49,15 +81,17 @@ type CompletionRequest struct {
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []ToolDef `json:"tools,omitempty"` // available tools for function calling
|
||||
}
|
||||
|
||||
// CompletionResponse is the normalized non-streaming response.
|
||||
type CompletionResponse struct {
|
||||
Content string `json:"content"`
|
||||
Model string `json:"model"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
Content string `json:"content"`
|
||||
Model string `json:"model"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when finish_reason is "tool_calls"
|
||||
}
|
||||
|
||||
// StreamEvent is a single chunk from a streaming response.
|
||||
@@ -69,8 +103,13 @@ type StreamEvent struct {
|
||||
Done bool `json:"done,omitempty"`
|
||||
|
||||
// FinishReason is set on the final event.
|
||||
// "stop" = normal, "tool_calls" = LLM wants to call tools.
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
|
||||
// ToolCalls accumulates tool call data during streaming.
|
||||
// Fully populated on the final event when FinishReason is "tool_calls".
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
|
||||
// Model echoes back the model used.
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
|
||||
372
server/tools/notes.go
Normal file
372
server/tools/notes.go
Normal 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
203
server/tools/notes_test.go
Normal 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
92
server/tools/registry.go
Normal 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
194
server/tools/tools_test.go
Normal 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
105
server/tools/types.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user