Changeset 0.7.2 (#40)
This commit is contained in:
@@ -86,9 +86,54 @@ jobs:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
- name: Bootstrap CI test database
|
||||
env:
|
||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||
PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }}
|
||||
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
||||
APP_USER: ${{ secrets.POSTGRES_USER }}
|
||||
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
DB_NAME: chat_switchboard_ci
|
||||
run: |
|
||||
echo "━━━ CI Test Database Setup ━━━"
|
||||
# Create DB for Go integration tests (admin creds)
|
||||
DB_EXISTS=$(psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}';" postgres || echo "0")
|
||||
if [[ "${DB_EXISTS}" != "1" ]]; then
|
||||
psql -c "CREATE DATABASE ${DB_NAME} OWNER ${APP_USER};" postgres
|
||||
echo "✓ Created ${DB_NAME}"
|
||||
else
|
||||
echo "✓ ${DB_NAME} already exists"
|
||||
fi
|
||||
# Extensions
|
||||
psql -d "${DB_NAME}" -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";'
|
||||
psql -d "${DB_NAME}" -c 'CREATE EXTENSION IF NOT EXISTS "pgcrypto";'
|
||||
# Grant
|
||||
psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${APP_USER};"
|
||||
psql -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${APP_USER};"
|
||||
psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER};"
|
||||
psql -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER};"
|
||||
echo "✓ CI test database ready"
|
||||
|
||||
- name: Run tests
|
||||
working-directory: server
|
||||
run: go test -v -race ./...
|
||||
env:
|
||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||
PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
run: go test -v -race -count=1 ./...
|
||||
|
||||
- name: Drop CI test database
|
||||
if: always()
|
||||
env:
|
||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||
PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }}
|
||||
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
||||
run: |
|
||||
psql -c "DROP DATABASE IF EXISTS chat_switchboard_ci;" postgres
|
||||
echo "✓ Dropped CI test database"
|
||||
|
||||
- name: Build check
|
||||
working-directory: server
|
||||
|
||||
102
TOOLS_IMPL.md
Normal file
102
TOOLS_IMPL.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Notes & Tool Execution Framework — Implementation
|
||||
|
||||
**Version:** 0.7.2 (Phase 2 per ARCHITECTURE.md §10)
|
||||
**Depends on:** channels (006), messages with tree (013) — both deployed
|
||||
|
||||
---
|
||||
|
||||
## Status: Complete
|
||||
|
||||
### Migration
|
||||
- [x] `014_notes.sql` — notes table, tsvector full-text search, folder/tag indexes, auto-update trigger
|
||||
|
||||
### Notes API (handlers/notes.go)
|
||||
- [x] `POST /notes` — create note with title, content, folder, tags, source_channel_id
|
||||
- [x] `GET /notes` — list with folder/tag filter, pagination
|
||||
- [x] `GET /notes/:id` — get single note
|
||||
- [x] `PUT /notes/:id` — update with replace/append/prepend modes
|
||||
- [x] `DELETE /notes/:id` — delete
|
||||
- [x] `GET /notes/search?q=` — full-text search with ts_rank + ts_headline
|
||||
- [x] `GET /notes/folders` — list distinct folder paths with counts
|
||||
|
||||
### Tool Type System (tools/types.go)
|
||||
- [x] `ToolDef` — name, description, JSON Schema parameters (sent to LLM)
|
||||
- [x] `ToolCall` — ID, name, arguments (from LLM response)
|
||||
- [x] `ToolResult` — tool_call_id, name, content, is_error (fed back)
|
||||
- [x] `Tool` interface — `Definition()` + `Execute(ctx, execCtx, argsJSON)`
|
||||
- [x] `ExecutionContext` — userID, channelID for scoped execution
|
||||
- [x] JSON Schema helpers: `Prop`, `PropEnum`, `PropArray`, `JSONSchema`
|
||||
|
||||
### Tool Registry (tools/registry.go)
|
||||
- [x] `Register(Tool)` — global registry, called from init()
|
||||
- [x] `Get(name)` — lookup by name
|
||||
- [x] `AllDefinitions()` — all registered tool schemas for LLM requests
|
||||
- [x] `ExecuteCall()` — runs a single tool, returns ToolResult (never errors)
|
||||
- [x] `ExecuteAll()` — batch execution
|
||||
- [x] `HasTools()` — check if any tools registered
|
||||
|
||||
### Note Tools (tools/notes.go)
|
||||
- [x] `note_create` — create with title, content, folder, tags; links source_channel_id
|
||||
- [x] `note_search` — full-text search with relevance ranking and excerpts
|
||||
- [x] `note_update` — update by note_id with replace/append/prepend modes
|
||||
- [x] `note_list` — list by folder/tag filter
|
||||
- All registered via init()
|
||||
|
||||
### Provider Tool Calling Support
|
||||
- [x] `providers.Message` — extended with ToolCalls, ToolCallID, Name fields
|
||||
- [x] `providers.CompletionRequest` — Tools field for function definitions
|
||||
- [x] `providers.CompletionResponse` — ToolCalls field for function calls
|
||||
- [x] `providers.StreamEvent` — ToolCalls accumulated on final event
|
||||
- [x] OpenAI provider — full tool_calls in request/response/stream
|
||||
- [x] Anthropic provider — tool_use/tool_result content blocks, input_json_delta streaming, consecutive user message merging
|
||||
- [x] OpenRouter — inherits from OpenAI (delegation)
|
||||
- [x] Venice — inherits from OpenAI (delegation)
|
||||
|
||||
### Completion Handler Tool Loop (handlers/completion.go)
|
||||
- [x] `buildToolDefs()` — converts registered tools to provider format
|
||||
- [x] Tools attached when `caps.ToolCalling && tools.HasTools()`
|
||||
- [x] Streaming: tool loop with max 10 iterations
|
||||
- Stream text deltas normally
|
||||
- On `finish_reason: "tool_calls"`: execute tools, send SSE events
|
||||
- Custom SSE events: `event: tool_use` and `event: tool_result`
|
||||
- Re-call provider with tool results appended to messages
|
||||
- Final text response streamed normally
|
||||
- [x] Non-streaming: same loop, tools executed synchronously
|
||||
- [x] Safety: `maxToolIterations = 10` prevents infinite loops
|
||||
|
||||
---
|
||||
|
||||
## SSE Events for Tool Execution
|
||||
|
||||
The streaming completion sends custom named SSE events during tool execution.
|
||||
Standard data events continue to carry OpenAI-compatible content deltas.
|
||||
|
||||
| Event | Payload | When |
|
||||
|-------|---------|------|
|
||||
| `tool_use` | `[{"id":"...","type":"function","function":{"name":"note_create","arguments":"..."}}]` | LLM requests tool calls |
|
||||
| `tool_result` | `{"tool_call_id":"...","name":"note_create","content":"...","is_error":false}` | Tool execution complete |
|
||||
|
||||
The frontend can listen for these to show tool execution status.
|
||||
Existing frontends that only handle `data:` events are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Separate packages** — `tools/` is independent of `handlers/`. Tools don't know about HTTP. The completion handler bridges them.
|
||||
2. **init() registration** — Tools self-register. Adding a new tool = create file with init(), done. No central wiring.
|
||||
3. **JSON Schema parameters** — Tool definitions use standard JSON Schema, matching OpenAI's function calling spec.
|
||||
4. **Non-streaming tool loop** — After tool execution, re-calls use streaming. Each iteration streams text deltas.
|
||||
5. **Anthropic content blocks** — Full translation: `tool_use` blocks, `tool_result` as user messages, consecutive user message merging for alternating-role requirement.
|
||||
6. **Folder paths** — Normalized to `/path/` format. Root is `/`. Tree structure without a folders table.
|
||||
7. **Search** — PostgreSQL tsvector with weighted ranks (title=A, content=B). No external search infrastructure needed.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Work
|
||||
|
||||
- [x] Notes panel/modal in UI
|
||||
- [x] Notes CRUD forms (create, edit, list, search)
|
||||
- [x] Tool execution indicators during streaming
|
||||
- [x] Parse `event: tool_use` / `event: tool_result` SSE events
|
||||
- [x] Display tool activity in message bubbles
|
||||
@@ -190,6 +190,25 @@ check_column "model_presets" "scope"
|
||||
check_column "model_presets" "created_by"
|
||||
check_column "model_presets" "is_active"
|
||||
|
||||
# ── Migration 013: Message Forking ───────────
|
||||
echo ""
|
||||
echo "Migration 013: Message Forking"
|
||||
check_column "messages" "deleted_at"
|
||||
check_column "messages" "sibling_index"
|
||||
|
||||
# ── Migration 014: Notes ─────────────────────
|
||||
echo ""
|
||||
echo "Migration 014: Notes"
|
||||
check_table "notes"
|
||||
check_column "notes" "user_id"
|
||||
check_column "notes" "title"
|
||||
check_column "notes" "content"
|
||||
check_column "notes" "folder_path"
|
||||
check_column "notes" "tags"
|
||||
check_column "notes" "metadata"
|
||||
check_column "notes" "source_channel_id"
|
||||
check_column "notes" "search_vector"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ADD NEW MIGRATION CHECKS ABOVE THIS LINE
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
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,43 +209,66 @@ func (h *CompletionHandler) streamCompletion(
|
||||
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
flush := func() {
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// sendSSE sends a standard OpenAI-compatible SSE data line.
|
||||
sendSSE := func(data string) {
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
|
||||
flush()
|
||||
}
|
||||
|
||||
// sendEvent sends a named SSE event (for tool status — non-standard).
|
||||
sendEvent := func(event, data string) {
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||||
flush()
|
||||
}
|
||||
|
||||
var fullContent string
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
var iterContent string
|
||||
var toolCalls []providers.ToolCall
|
||||
|
||||
for event := range ch {
|
||||
if event.Error != nil {
|
||||
fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error())
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
break
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
// Stream text deltas to client
|
||||
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()
|
||||
}
|
||||
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"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
fullContent += iterContent
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
|
||||
finishReason, model))
|
||||
sendSSE("[DONE]")
|
||||
|
||||
// Persist assistant response
|
||||
if fullContent != "" {
|
||||
@@ -232,9 +276,91 @@ func (h *CompletionHandler) streamCompletion(
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Non-Streaming Completion ────────────────
|
||||
// ── Tool execution round ────────────────
|
||||
if len(toolCalls) == 0 {
|
||||
// Stream ended without tool calls or finish — shouldn't happen
|
||||
sendSSE("[DONE]")
|
||||
if iterContent != "" {
|
||||
fullContent += iterContent
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Notify client about tool calls
|
||||
toolCallsJSON, _ := json.Marshal(toolCalls)
|
||||
sendEvent("tool_use", string(toolCallsJSON))
|
||||
|
||||
// Append assistant message (with tool_calls, possibly with text) to conversation
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: iterContent,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
// Execute all tool calls
|
||||
execCtx := tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
|
||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
||||
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
|
||||
// Notify client about tool result
|
||||
resultJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"tool_call_id": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"content": result.Content,
|
||||
"is_error": result.IsError,
|
||||
})
|
||||
sendEvent("tool_result", string(resultJSON))
|
||||
|
||||
// Append tool result to conversation for next iteration
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Name: result.Name,
|
||||
})
|
||||
}
|
||||
|
||||
fullContent += iterContent
|
||||
// Loop: send updated messages back to provider
|
||||
}
|
||||
|
||||
// If we hit max iterations, send what we have
|
||||
log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID)
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
|
||||
escapeJSON("[Tool execution limit reached]"), model))
|
||||
sendSSE("[DONE]")
|
||||
|
||||
if fullContent != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Non-Streaming Completion with Tool Loop ──
|
||||
|
||||
func (h *CompletionHandler) syncCompletion(
|
||||
c *gin.Context,
|
||||
@@ -243,14 +369,25 @@ func (h *CompletionHandler) syncCompletion(
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model string,
|
||||
) {
|
||||
var totalInput, totalOutput int
|
||||
var finalContent string
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
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", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
@@ -261,21 +398,79 @@ func (h *CompletionHandler) syncCompletion(
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": resp.Content,
|
||||
"content": finalContent,
|
||||
},
|
||||
"finish_reason": resp.FinishReason,
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": resp.InputTokens,
|
||||
"completion_tokens": resp.OutputTokens,
|
||||
"total_tokens": resp.InputTokens + resp.OutputTokens,
|
||||
"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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Max iterations reached
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": "[Tool execution limit reached]",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Model Capabilities ──────────────────────
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Merge consecutive user messages (Anthropic requires alternating roles)
|
||||
messages = mergeConsecutiveUserMessages(messages)
|
||||
|
||||
// Build Anthropic-format request
|
||||
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"`
|
||||
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,6 +351,7 @@ 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 {
|
||||
@@ -231,7 +359,10 @@ type anthropicResponse struct {
|
||||
StopReason string `json:"stop_reason"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
@@ -245,5 +376,11 @@ type anthropicStreamEvent struct {
|
||||
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,12 +232,47 @@ 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)
|
||||
if err != nil {
|
||||
@@ -221,7 +310,33 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
||||
|
||||
type openaiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
|
||||
Name string `json:"name,omitempty"` // tool name for role=tool
|
||||
}
|
||||
|
||||
type openaiToolCallFunction struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type openaiToolCall struct {
|
||||
Index *int `json:"index,omitempty"` // present in streaming deltas
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"` // "function"
|
||||
Function openaiToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
type openaiToolDefFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
}
|
||||
|
||||
type openaiToolDef struct {
|
||||
Type string `json:"type"` // "function"
|
||||
Function openaiToolDefFunction `json:"function"`
|
||||
}
|
||||
|
||||
type openaiChatRequest struct {
|
||||
@@ -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 {
|
||||
@@ -250,6 +366,7 @@ type openaiStreamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
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,6 +81,7 @@ 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.
|
||||
@@ -58,6 +91,7 @@ type CompletionResponse struct {
|
||||
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"},
|
||||
}
|
||||
}
|
||||
@@ -570,6 +570,36 @@ a:hover { text-decoration: underline; }
|
||||
max-height: 300px; overflow-y: auto; line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Tool activity indicators */
|
||||
.msg-tools {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tool-activity {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: var(--text-3);
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
padding: 4px 10px; border-radius: var(--radius);
|
||||
animation: tool-fade-in 0.2s ease;
|
||||
}
|
||||
@keyframes tool-fade-in { from { opacity: 0; transform: translateY(-4px); } }
|
||||
.tool-icon { font-size: 13px; }
|
||||
.tool-name { font-family: var(--mono); font-size: 11px; color: var(--text-2); }
|
||||
.tool-status { font-size: 11px; margin-left: auto; }
|
||||
.tool-running { color: var(--warning); }
|
||||
.tool-running::after {
|
||||
content: ''; display: inline-block; width: 4px; height: 4px;
|
||||
border-radius: 50%; background: var(--warning); margin-left: 4px;
|
||||
animation: tool-pulse 1s infinite;
|
||||
}
|
||||
@keyframes tool-pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
.tool-done { color: var(--success); }
|
||||
.tool-error { color: var(--danger); }
|
||||
.tool-hint {
|
||||
font-size: 11px; color: var(--text-3); margin-left: 6px;
|
||||
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
@@ -1064,6 +1094,175 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.debug-pre { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; }
|
||||
.debug-footer { display: flex; align-items: center; }
|
||||
|
||||
/* ── Sidebar Notes Button ───────────────── */
|
||||
|
||||
.sidebar-notes-btn {
|
||||
padding: 4px 8px; border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-notes {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
padding: 8px 12px; background: none; border: none;
|
||||
border-radius: var(--radius); color: var(--text-2);
|
||||
cursor: pointer; font-size: 13px; font-family: var(--font);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.sb-notes:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.sidebar.collapsed .sidebar-notes-btn .sb-label { display: none; }
|
||||
.sidebar.collapsed .sb-notes { justify-content: center; padding: 8px; }
|
||||
|
||||
/* ── Notes Modal ────────────────────────── */
|
||||
|
||||
.notes-header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.notes-toolbar {
|
||||
display: flex; gap: 8px; padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.notes-search-wrap {
|
||||
display: flex; align-items: center; gap: 6px; flex: 1;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 0 10px;
|
||||
}
|
||||
.notes-search-wrap svg { color: var(--text-3); flex-shrink: 0; }
|
||||
.notes-search-wrap input {
|
||||
flex: 1; background: none; border: none; color: var(--text);
|
||||
font-size: 13px; font-family: var(--font); padding: 6px 0;
|
||||
outline: none;
|
||||
}
|
||||
.notes-filter-select {
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text); font-size: 12px;
|
||||
padding: 4px 8px; font-family: var(--font); cursor: pointer;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.notes-body { padding: 0 !important; }
|
||||
|
||||
/* Selection bar */
|
||||
.notes-selection-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 20px;
|
||||
background: var(--accent-dim); border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.notes-select-all {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
color: var(--text-2); cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.notes-select-all input { cursor: pointer; }
|
||||
|
||||
.notes-list { padding: 8px; }
|
||||
.notes-empty, .notes-loading {
|
||||
text-align: center; color: var(--text-3); padding: 2rem;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
padding: 10px 12px; border-radius: var(--radius);
|
||||
cursor: pointer; border: 1px solid transparent;
|
||||
transition: background var(--transition), border-color var(--transition);
|
||||
}
|
||||
.note-item:hover {
|
||||
background: var(--bg-hover); border-color: var(--border);
|
||||
}
|
||||
.note-item.selected {
|
||||
background: var(--accent-dim); border-color: var(--accent);
|
||||
}
|
||||
.note-select-col { flex-shrink: 0; padding-top: 2px; }
|
||||
.note-checkbox { cursor: pointer; accent-color: var(--accent); }
|
||||
.note-content-col { flex: 1; min-width: 0; }
|
||||
.note-item-header {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
gap: 8px; margin-bottom: 2px;
|
||||
}
|
||||
.note-item-title {
|
||||
font-size: 13px; font-weight: 600; color: var(--text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.note-item-time { font-size: 11px; color: var(--text-3); flex-shrink: 0; }
|
||||
.note-preview {
|
||||
font-size: 12px; color: var(--text-3); line-height: 1.4;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
max-height: 1.4em;
|
||||
}
|
||||
.note-headline {
|
||||
font-size: 12px; color: var(--text-3); line-height: 1.4;
|
||||
}
|
||||
.note-headline mark {
|
||||
background: var(--accent-dim); color: var(--accent);
|
||||
padding: 0 2px; border-radius: 2px;
|
||||
}
|
||||
.note-item-meta {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
margin-top: 4px; flex-wrap: wrap;
|
||||
}
|
||||
.note-folder {
|
||||
font-size: 11px; color: var(--text-3); font-family: var(--mono);
|
||||
background: var(--bg-raised); padding: 1px 6px; border-radius: 3px;
|
||||
}
|
||||
.note-tag {
|
||||
font-size: 10px; color: var(--purple); background: var(--purple-dim);
|
||||
padding: 1px 6px; border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Notes editor */
|
||||
.notes-editor { padding: 12px; }
|
||||
.notes-editor-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Note read mode */
|
||||
.note-read-title {
|
||||
font-size: 18px; font-weight: 600; color: var(--text);
|
||||
margin: 0 0 6px; line-height: 1.3;
|
||||
}
|
||||
.note-read-meta {
|
||||
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.note-read-content {
|
||||
font-size: var(--msg-font, 14px); line-height: 1.7;
|
||||
max-height: 50vh; overflow-y: auto;
|
||||
padding: 12px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
}
|
||||
.notes-back-btn {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 12px; font-family: var(--font);
|
||||
padding: 4px 0; margin-bottom: 8px;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.notes-back-btn:hover { color: var(--text); }
|
||||
.notes-title-input {
|
||||
font-size: 16px !important; font-weight: 600;
|
||||
border: 1px solid var(--border) !important;
|
||||
padding: 8px 10px !important;
|
||||
}
|
||||
.notes-folder-input, .notes-tags-input {
|
||||
font-size: 12px !important; font-family: var(--mono);
|
||||
}
|
||||
.notes-content-input {
|
||||
font-size: 13px !important; line-height: 1.6 !important;
|
||||
min-height: 200px; resize: vertical;
|
||||
font-family: var(--mono); border: 1px solid var(--border) !important;
|
||||
}
|
||||
.notes-editor input, .notes-editor textarea {
|
||||
width: 100%; background: var(--bg-surface); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 6px 10px; font-family: var(--font); outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.notes-editor input:focus, .notes-editor textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.notes-editor-actions {
|
||||
display: flex; gap: 8px; margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
.mobile-menu-btn {
|
||||
|
||||
@@ -56,6 +56,14 @@
|
||||
|
||||
<div class="sidebar-chats" id="chatHistory"></div>
|
||||
|
||||
<!-- Notes shortcut -->
|
||||
<div class="sidebar-notes-btn">
|
||||
<button class="sb-notes" id="notesBtn" title="Notes">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span class="sb-label">Notes</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User area -->
|
||||
<div class="sidebar-bottom">
|
||||
<button class="user-btn" id="userMenuBtn">
|
||||
@@ -401,6 +409,86 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Notes Modal ────────────────────────── -->
|
||||
<div class="modal-overlay" id="notesModal">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h2>Notes</h2>
|
||||
<div class="notes-header-actions">
|
||||
<button class="btn-small" id="notesSelectModeBtn">Select</button>
|
||||
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
|
||||
<button class="modal-close" onclick="closeModal('notesModal')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-toolbar">
|
||||
<div class="notes-search-wrap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
|
||||
</div>
|
||||
<select id="notesFolderFilter" class="notes-filter-select">
|
||||
<option value="">All folders</option>
|
||||
</select>
|
||||
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
|
||||
<option value="updated_desc">Updated ↓</option>
|
||||
<option value="updated_asc">Updated ↑</option>
|
||||
<option value="created_desc">Created ↓</option>
|
||||
<option value="created_asc">Created ↑</option>
|
||||
<option value="title_asc">Title A–Z</option>
|
||||
<option value="title_desc">Title Z–A</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
|
||||
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
|
||||
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
|
||||
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
|
||||
</div>
|
||||
<div class="modal-body notes-body">
|
||||
<!-- List view (default) -->
|
||||
<div id="notesListView">
|
||||
<div id="notesList" class="notes-list">
|
||||
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Editor view (hidden by default) -->
|
||||
<div id="notesEditorView" style="display:none">
|
||||
<div class="notes-editor">
|
||||
<div class="notes-editor-toolbar">
|
||||
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
|
||||
<div class="notes-mode-toggle">
|
||||
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
|
||||
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Read mode: rendered markdown -->
|
||||
<div id="noteReadMode" style="display:none">
|
||||
<h3 class="note-read-title" id="noteReadTitle"></h3>
|
||||
<div class="note-read-meta" id="noteReadMeta"></div>
|
||||
<div class="note-read-content msg-text" id="noteReadContent"></div>
|
||||
<div class="notes-editor-actions">
|
||||
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
|
||||
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit mode: form fields -->
|
||||
<div id="noteEditMode">
|
||||
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
|
||||
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
||||
</div>
|
||||
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
|
||||
<div class="notes-editor-actions">
|
||||
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
|
||||
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
|
||||
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Modal (Ctrl+Shift+L) -->
|
||||
<div class="modal-overlay" id="debugModal">
|
||||
<div class="modal debug-modal">
|
||||
|
||||
@@ -278,6 +278,27 @@ const API = {
|
||||
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||||
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||||
|
||||
// ── Notes ────────────────────────────────
|
||||
listNotes(limit = 50, offset = 0, folder, tag, sort) {
|
||||
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
|
||||
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
|
||||
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
|
||||
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
|
||||
return this._get(url);
|
||||
},
|
||||
createNote(title, content, folderPath, tags) {
|
||||
const body = { title, content };
|
||||
if (folderPath) body.folder_path = folderPath;
|
||||
if (tags?.length) body.tags = tags;
|
||||
return this._post('/api/v1/notes', body);
|
||||
},
|
||||
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
|
||||
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
|
||||
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
|
||||
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
|
||||
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||||
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
|
||||
|
||||
// ── HTTP Internals ───────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
|
||||
351
src/js/app.js
351
src/js/app.js
@@ -753,6 +753,56 @@ function initListeners() {
|
||||
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||||
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
||||
|
||||
// Notes panel
|
||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
|
||||
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
|
||||
if (_notesSelectMode) _exitSelectMode();
|
||||
else _enterSelectMode();
|
||||
});
|
||||
document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
|
||||
document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote);
|
||||
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
|
||||
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
|
||||
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
|
||||
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
|
||||
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
|
||||
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
|
||||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
||||
else showNotesList();
|
||||
});
|
||||
document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => {
|
||||
document.getElementById('notesSearchInput').value = '';
|
||||
_exitSelectMode();
|
||||
loadNotesList(e.target.value);
|
||||
});
|
||||
document.getElementById('notesSortSelect')?.addEventListener('change', (e) => {
|
||||
_notesSort = e.target.value;
|
||||
_exitSelectMode();
|
||||
loadNotesList();
|
||||
});
|
||||
document.getElementById('notesSelectAll')?.addEventListener('change', (e) => {
|
||||
_toggleSelectAll(e.target.checked);
|
||||
});
|
||||
document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected);
|
||||
document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode);
|
||||
|
||||
// Notes search with debounce
|
||||
let _notesSearchTimer;
|
||||
document.getElementById('notesSearchInput')?.addEventListener('input', (e) => {
|
||||
clearTimeout(_notesSearchTimer);
|
||||
const q = e.target.value.trim();
|
||||
_notesSearchTimer = setTimeout(() => {
|
||||
_exitSelectMode();
|
||||
if (q.length >= 2) {
|
||||
document.getElementById('notesFolderFilter').value = '';
|
||||
loadNotesList(null, q);
|
||||
} else if (q.length === 0) {
|
||||
loadNotesList();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Chat actions
|
||||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||
@@ -1212,6 +1262,307 @@ async function deleteAdminPreset(id, name) {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Notes Panel ─────────────────────────────
|
||||
|
||||
var _editingNoteId = null;
|
||||
var _notesSelectMode = false;
|
||||
var _selectedNoteIds = new Set();
|
||||
var _notesSort = 'updated_desc';
|
||||
|
||||
async function openNotes() {
|
||||
openModal('notesModal');
|
||||
_exitSelectMode();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
}
|
||||
|
||||
async function loadNotesList(folder, searchQuery) {
|
||||
const list = document.getElementById('notesList');
|
||||
list.innerHTML = '<div class="notes-loading">Loading…</div>';
|
||||
|
||||
try {
|
||||
let data;
|
||||
if (searchQuery) {
|
||||
data = await API.searchNotes(searchQuery);
|
||||
const results = data.data || [];
|
||||
if (results.length === 0) {
|
||||
list.innerHTML = '<div class="notes-empty">No results found</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
|
||||
} else {
|
||||
const folderVal = folder || document.getElementById('notesFolderFilter')?.value || '';
|
||||
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
|
||||
const notes = data.data || [];
|
||||
if (notes.length === 0) {
|
||||
list.innerHTML = `<div class="notes-empty">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="notes-empty">Failed to load: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _noteListItem(note, isSearch) {
|
||||
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
|
||||
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
|
||||
const preview = isSearch && note.headline
|
||||
? `<div class="note-headline">${_highlightHeadline(note.headline)}</div>`
|
||||
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
|
||||
const time = _relativeTime(note.updated_at);
|
||||
const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
|
||||
|
||||
return `
|
||||
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
|
||||
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
|
||||
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
|
||||
onclick="event.stopPropagation(); _toggleNoteSelect('${note.id}', this.checked)">
|
||||
</div>
|
||||
<div class="note-content-col" onclick="${_notesSelectMode ? `_toggleNoteSelect('${note.id}')` : `openNoteEditor('${note.id}')`}">
|
||||
<div class="note-item-header">
|
||||
<span class="note-item-title">${esc(note.title)}</span>
|
||||
<span class="note-item-time">${time}</span>
|
||||
</div>
|
||||
${preview}
|
||||
<div class="note-item-meta">${folder}${tags}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _highlightHeadline(headline) {
|
||||
const escaped = esc(headline);
|
||||
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
// ── Multi-select ────────────────
|
||||
|
||||
function _enterSelectMode() {
|
||||
_notesSelectMode = true;
|
||||
_selectedNoteIds.clear();
|
||||
document.getElementById('notesSelectionBar').style.display = '';
|
||||
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = '');
|
||||
_updateSelectedCount();
|
||||
}
|
||||
|
||||
function _exitSelectMode() {
|
||||
_notesSelectMode = false;
|
||||
_selectedNoteIds.clear();
|
||||
document.getElementById('notesSelectionBar').style.display = 'none';
|
||||
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none');
|
||||
document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected'));
|
||||
document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false);
|
||||
const sa = document.getElementById('notesSelectAll');
|
||||
if (sa) sa.checked = false;
|
||||
}
|
||||
|
||||
function _toggleNoteSelect(id, forceState) {
|
||||
if (!_notesSelectMode) {
|
||||
_enterSelectMode();
|
||||
}
|
||||
const has = _selectedNoteIds.has(id);
|
||||
const newState = forceState !== undefined ? forceState : !has;
|
||||
|
||||
if (newState) _selectedNoteIds.add(id);
|
||||
else _selectedNoteIds.delete(id);
|
||||
|
||||
const item = document.querySelector(`.note-item[data-note-id="${id}"]`);
|
||||
if (item) {
|
||||
item.classList.toggle('selected', newState);
|
||||
const cb = item.querySelector('.note-checkbox');
|
||||
if (cb) cb.checked = newState;
|
||||
}
|
||||
_updateSelectedCount();
|
||||
}
|
||||
|
||||
function _toggleSelectAll(checked) {
|
||||
document.querySelectorAll('.note-checkbox').forEach(cb => {
|
||||
const id = cb.dataset.id;
|
||||
if (checked) _selectedNoteIds.add(id);
|
||||
else _selectedNoteIds.delete(id);
|
||||
cb.checked = checked;
|
||||
cb.closest('.note-item')?.classList.toggle('selected', checked);
|
||||
});
|
||||
_updateSelectedCount();
|
||||
}
|
||||
|
||||
function _updateSelectedCount() {
|
||||
const el = document.getElementById('notesSelectedCount');
|
||||
if (el) el.textContent = _selectedNoteIds.size;
|
||||
}
|
||||
|
||||
async function _bulkDeleteSelected() {
|
||||
if (_selectedNoteIds.size === 0) return;
|
||||
const count = _selectedNoteIds.size;
|
||||
if (!confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
|
||||
|
||||
try {
|
||||
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
|
||||
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
|
||||
_exitSelectMode();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Folders / Editor / CRUD ─────
|
||||
|
||||
async function loadNoteFolders() {
|
||||
const sel = document.getElementById('notesFolderFilter');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const data = await API.listNoteFolders();
|
||||
const folders = data.folders || [];
|
||||
sel.innerHTML = '<option value="">All folders</option>';
|
||||
folders.forEach(f => {
|
||||
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
|
||||
});
|
||||
} catch (e) { /* non-critical */ }
|
||||
}
|
||||
|
||||
function showNotesList() {
|
||||
document.getElementById('notesListView').style.display = '';
|
||||
document.getElementById('notesEditorView').style.display = 'none';
|
||||
_editingNoteId = null;
|
||||
}
|
||||
|
||||
var _currentNote = null; // cached note for read mode
|
||||
|
||||
async function openNoteEditor(noteId) {
|
||||
document.getElementById('notesListView').style.display = 'none';
|
||||
document.getElementById('notesEditorView').style.display = '';
|
||||
|
||||
if (noteId) {
|
||||
_editingNoteId = noteId;
|
||||
try {
|
||||
_currentNote = await API.getNote(noteId);
|
||||
_populateEditFields(_currentNote);
|
||||
_showNoteReadMode();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to load note: ' + e.message, 'error');
|
||||
showNotesList();
|
||||
}
|
||||
} else {
|
||||
// New note — straight to edit mode
|
||||
_editingNoteId = null;
|
||||
_currentNote = null;
|
||||
_clearEditFields();
|
||||
_showNoteEditMode();
|
||||
document.getElementById('noteEditorTitle').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function _populateEditFields(note) {
|
||||
document.getElementById('noteEditorTitle').value = note.title || '';
|
||||
document.getElementById('noteEditorFolder').value = note.folder_path || '';
|
||||
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
|
||||
document.getElementById('noteEditorContent').value = note.content || '';
|
||||
}
|
||||
|
||||
function _clearEditFields() {
|
||||
document.getElementById('noteEditorTitle').value = '';
|
||||
document.getElementById('noteEditorFolder').value = '';
|
||||
document.getElementById('noteEditorTags').value = '';
|
||||
document.getElementById('noteEditorContent').value = '';
|
||||
}
|
||||
|
||||
function _showNoteReadMode() {
|
||||
if (!_currentNote) return;
|
||||
const n = _currentNote;
|
||||
|
||||
// Title
|
||||
document.getElementById('noteReadTitle').textContent = n.title || '';
|
||||
|
||||
// Meta: folder + tags
|
||||
const parts = [];
|
||||
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
|
||||
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||
|
||||
// Rendered markdown content — reuse the chat formatter
|
||||
document.getElementById('noteReadContent').innerHTML = formatMessage(n.content || '');
|
||||
|
||||
// Show/hide delete
|
||||
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
|
||||
|
||||
// Toggle visibility
|
||||
document.getElementById('noteReadMode').style.display = '';
|
||||
document.getElementById('noteEditMode').style.display = 'none';
|
||||
document.getElementById('noteEditBtn').style.display = '';
|
||||
document.getElementById('notePreviewBtn').style.display = 'none';
|
||||
}
|
||||
|
||||
function _showNoteEditMode() {
|
||||
document.getElementById('noteReadMode').style.display = 'none';
|
||||
document.getElementById('noteEditMode').style.display = '';
|
||||
document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none';
|
||||
document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none';
|
||||
document.getElementById('noteEditBtn').style.display = 'none';
|
||||
document.getElementById('notePreviewBtn').style.display = '';
|
||||
}
|
||||
|
||||
function _showNotePreview() {
|
||||
// Live preview from textarea without saving
|
||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||
const content = document.getElementById('noteEditorContent').value;
|
||||
const folderVal = document.getElementById('noteEditorFolder').value.trim();
|
||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
|
||||
document.getElementById('noteReadTitle').textContent = title || 'Untitled';
|
||||
const parts = [];
|
||||
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
|
||||
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
||||
|
||||
document.getElementById('noteReadMode').style.display = '';
|
||||
document.getElementById('noteEditMode').style.display = 'none';
|
||||
document.getElementById('noteEditBtn').style.display = '';
|
||||
document.getElementById('notePreviewBtn').style.display = 'none';
|
||||
// Keep delete hidden in preview-from-edit
|
||||
document.getElementById('noteDeleteBtn2').style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||
const content = document.getElementById('noteEditorContent').value;
|
||||
const folder = document.getElementById('noteEditorFolder').value.trim();
|
||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
|
||||
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
||||
if (!content) { UI.toast('Content is required', 'warning'); return; }
|
||||
|
||||
try {
|
||||
if (_editingNoteId) {
|
||||
const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
|
||||
_currentNote = updated;
|
||||
UI.toast('Note updated', 'success');
|
||||
_showNoteReadMode();
|
||||
} else {
|
||||
const created = await API.createNote(title, content, folder, tags);
|
||||
_editingNoteId = created.id;
|
||||
_currentNote = created;
|
||||
UI.toast('Note created', 'success');
|
||||
_showNoteReadMode();
|
||||
}
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteNote() {
|
||||
if (!_editingNoteId) return;
|
||||
if (!confirm('Delete this note?')) return;
|
||||
try {
|
||||
await API.deleteNote(_editingNoteId);
|
||||
UI.toast('Note deleted', 'success');
|
||||
showNotesList();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Banners ──────────────────────────────────
|
||||
|
||||
async function initBanners() {
|
||||
|
||||
68
src/js/ui.js
68
src/js/ui.js
@@ -215,6 +215,7 @@ const UI = {
|
||||
<div class="msg-avatar">🤖</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
|
||||
<div class="msg-tools" id="streamTools" style="display:none"></div>
|
||||
<div class="msg-text" id="streamContent"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -224,6 +225,7 @@ const UI = {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let content = '';
|
||||
let currentEvent = ''; // SSE event type
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -234,18 +236,41 @@ const UI = {
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
// SSE event type line
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
if (data === '[DONE]') { currentEvent = ''; continue; }
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
if (currentEvent === 'tool_use') {
|
||||
// parsed = array of tool calls
|
||||
this._renderToolUse(parsed);
|
||||
currentEvent = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentEvent === 'tool_result') {
|
||||
// parsed = { tool_call_id, name, content, is_error }
|
||||
this._renderToolResult(parsed);
|
||||
currentEvent = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal content delta
|
||||
const delta = parsed.choices?.[0]?.delta?.content || '';
|
||||
if (delta) {
|
||||
content += delta;
|
||||
document.getElementById('streamContent').innerHTML = formatMessage(content);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
currentEvent = '';
|
||||
} catch (e) { /* partial JSON */ }
|
||||
}
|
||||
}
|
||||
@@ -253,6 +278,47 @@ const UI = {
|
||||
return content;
|
||||
},
|
||||
|
||||
_renderToolUse(toolCalls) {
|
||||
const el = document.getElementById('streamTools');
|
||||
if (!el) return;
|
||||
el.style.display = '';
|
||||
for (const tc of toolCalls) {
|
||||
const name = tc.function?.name || tc.name || 'unknown';
|
||||
const toolDiv = document.createElement('div');
|
||||
toolDiv.className = 'tool-activity';
|
||||
toolDiv.id = `tool_${tc.id}`;
|
||||
toolDiv.innerHTML = `
|
||||
<span class="tool-icon">🔧</span>
|
||||
<span class="tool-name">${esc(name)}</span>
|
||||
<span class="tool-status tool-running">running…</span>`;
|
||||
el.appendChild(toolDiv);
|
||||
}
|
||||
this._scrollToBottom();
|
||||
},
|
||||
|
||||
_renderToolResult(result) {
|
||||
const el = document.getElementById(`tool_${result.tool_call_id}`);
|
||||
if (el) {
|
||||
const statusEl = el.querySelector('.tool-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = result.is_error ? 'error' : 'done';
|
||||
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
|
||||
}
|
||||
// Parse tool result content for a brief summary
|
||||
try {
|
||||
const parsed = JSON.parse(result.content);
|
||||
const summary = parsed.title || parsed.id || parsed.count != null ? `${parsed.count} results` : '';
|
||||
if (summary) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'tool-hint';
|
||||
hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary);
|
||||
el.appendChild(hint);
|
||||
}
|
||||
} catch (e) { /* not JSON or no useful summary */ }
|
||||
}
|
||||
this._scrollToBottom();
|
||||
},
|
||||
|
||||
// ── Model Selector (Custom Dropdown) ────
|
||||
|
||||
_modelValue: '',
|
||||
|
||||
Reference in New Issue
Block a user