diff --git a/VERSION b/VERSION index a3493b1..610e287 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.10 \ No newline at end of file +0.23.1 diff --git a/capabilities/resolver.go b/capabilities/resolver.go deleted file mode 100644 index 2526867..0000000 --- a/capabilities/resolver.go +++ /dev/null @@ -1,287 +0,0 @@ -package capabilities - -import ( - "context" - "log" - - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" -) - -// ModelsForUser returns all models and Personas visible to a user. -// -// Visibility is controlled by the three-state visibility field on each -// catalog entry (enabled / team / disabled), applied per provider scope: -// -// Tier | enabled | team | disabled -// ----------+----------------+----------------------+--------- -// Global | All users | Team admin → personas | Hidden -// Team | Team members | Team admin → personas | Hidden -// Personal | Owner direct | N/A | Hidden -// -// Sources aggregated: -// 1. Global catalog: enabled models → all authenticated users -// 2. Team catalog: enabled models → team members -// 3. Personal BYOK: enabled models → owner (if allow_user_byok) -// 4. Personas: global + team-scoped + personal + shared -// 5. User hidden preferences applied last -func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]models.UserModel, error) { - result := make([]models.UserModel, 0) // never nil — serializes as [] not null - - // Load policies once - policies, err := stores.Policies.GetAll(ctx) - if err != nil { - return nil, err - } - allowBYOK := policies["allow_user_byok"] == "true" - - // Load user's hidden preferences - hiddenMap, err := stores.UserSettings.GetHiddenModelIDs(ctx, userID) - if err != nil { - log.Printf("warn: failed to load user model settings: %v", err) - hiddenMap = make(map[string]bool) - } - - // Get user's team IDs - teamIDs, err := stores.Teams.GetUserTeamIDs(ctx, userID) - if err != nil { - log.Printf("warn: failed to load user teams: %v", err) - } - - // ── 1. Global enabled catalog models → all users ──── - globalModels, err := stores.Catalog.ListVisible(ctx) - if err != nil { - return nil, err - } - - // Build provider name lookup for global providers - globalProviders, _ := stores.Providers.ListGlobal(ctx) - providerMap := make(map[string]models.ProviderConfig) - for _, p := range globalProviders { - providerMap[p.ID] = p - } - - countGlobal := len(globalModels) - for _, entry := range globalModels { - caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil) - prov := providerMap[entry.ProviderConfigID] - - result = append(result, models.UserModel{ - ID: entry.ModelID, - DisplayName: displayName(entry.DisplayName, entry.ModelID), - ModelID: entry.ModelID, - ModelType: entry.ModelType, - Source: "catalog", - ProviderConfigID: entry.ProviderConfigID, - ConfigID: entry.ProviderConfigID, - ProviderName: prov.Name, - ProviderType: prov.Provider, - Capabilities: caps, - Pricing: entry.Pricing, - Scope: models.ScopeGlobal, - Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)], - }) - } - - // ── 2. Team enabled catalog models → team members ──── - countTeam := 0 - for _, teamID := range teamIDs { - teamProviders, err := stores.Providers.ListForTeam(ctx, teamID) - if err != nil { - log.Printf("warn: failed to load team %s providers: %v", teamID, err) - continue - } - for _, prov := range teamProviders { - if !prov.IsActive { - continue - } - entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID) - if err != nil { - continue - } - for _, entry := range entries { - caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil) - result = append(result, models.UserModel{ - ID: entry.ModelID, - DisplayName: displayName(entry.DisplayName, entry.ModelID), - ModelID: entry.ModelID, - ModelType: entry.ModelType, - Source: "catalog", - ProviderConfigID: prov.ID, - ConfigID: prov.ID, - ProviderName: prov.Name, - ProviderType: prov.Provider, - Capabilities: caps, - Pricing: entry.Pricing, - Scope: models.ScopeTeam, - OwnerID: prov.OwnerID, - Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)], - }) - countTeam++ - } - } - } - - // ── 3. Personal BYOK enabled models → owner ──── - countPersonal := 0 - if allowBYOK { - personalProviders, err := stores.Providers.ListForUser(ctx, userID) - if err != nil { - log.Printf("warn: failed to load personal providers: %v", err) - } else { - for _, prov := range personalProviders { - if !prov.IsActive { - continue - } - entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID) - if err != nil { - continue - } - for _, entry := range entries { - caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil) - result = append(result, models.UserModel{ - ID: entry.ModelID, - DisplayName: displayName(entry.DisplayName, entry.ModelID), - ModelID: entry.ModelID, - ModelType: entry.ModelType, - Source: "catalog", - ProviderConfigID: prov.ID, - ConfigID: prov.ID, - ProviderName: prov.Name, - ProviderType: prov.Provider, - Capabilities: caps, - Pricing: entry.Pricing, - Scope: models.ScopePersonal, - OwnerID: &userID, - Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)], - }) - countPersonal++ - } - } - } - } - - // ── 4. Personas (always resolved) ──────────────── - personas, err := stores.Personas.ListForUser(ctx, userID) - if err != nil { - log.Printf("warn: failed to load personas: %v", err) - } else { - for _, p := range personas { - // Resolve base model capabilities - var catalogCaps *models.ModelCapabilities - if p.ProviderConfigID != nil { - if entry, err := stores.Catalog.GetByModelID(ctx, *p.ProviderConfigID, p.BaseModelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - // Fallback: look up any provider's catalog entry for this model - // (covers auto-resolve personas where provider_config_id is NULL) - if catalogCaps == nil { - if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - caps := ResolveIntrinsic(p.BaseModelID, catalogCaps, nil) - - // Load tool grants - toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID) - - if len(toolGrants) > 0 { - caps.ToolCalling = true - } - - // Look up provider info - var provName, provType string - if p.ProviderConfigID != nil { - if prov, err := stores.Providers.GetByID(ctx, *p.ProviderConfigID); err == nil { - provName = prov.Name - provType = prov.Provider - } - } - - result = append(result, models.UserModel{ - ID: p.ID, - DisplayName: p.Name, - ModelID: p.BaseModelID, - Source: "persona", - ProviderConfigID: deref(p.ProviderConfigID), - ConfigID: deref(p.ProviderConfigID), - ProviderName: provName, - ProviderType: provType, - Capabilities: caps, - IsPersona: true, - PersonaID: p.ID, - PersonaScope: p.Scope, - PersonaAvatar: p.Avatar, - PersonaTeamName: teamName(p, stores, ctx), - Description: p.Description, - Icon: p.Icon, - Avatar: p.Avatar, - SystemPrompt: p.SystemPrompt, - Temperature: p.Temperature, - MaxTokens: p.MaxTokens, - ToolGrants: toolGrants, - Scope: p.Scope, - OwnerID: p.OwnerID, - Hidden: false, // ICD §11.2: persona visibility via grants, not model prefs - }) - } - } - - countPersonas := len(result) - countGlobal - countTeam - countPersonal - log.Printf("📋 ModelsForUser(%s): %d global, %d team, %d personal, %d personas → %d total", - userID, countGlobal, countTeam, countPersonal, countPersonas, len(result)) - - return result, nil -} - -// ResolveForPersona returns effective capabilities for a specific Persona. -// Used at completion time to determine what tools/features are available. -func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models.Persona) (models.ModelCapabilities, []string, error) { - var catalogCaps *models.ModelCapabilities - if persona.ProviderConfigID != nil { - if entry, err := stores.Catalog.GetByModelID(ctx, *persona.ProviderConfigID, persona.BaseModelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - // Fallback: any provider's catalog entry (auto-resolve personas) - if catalogCaps == nil { - if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps, nil) - - toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID) - if err != nil { - return caps, nil, err - } - - return caps, toolGrants, nil -} - -func displayName(name, modelID string) string { - if name != "" { - return name - } - return modelID -} - -func deref(s *string) string { - if s == nil { - return "" - } - return *s -} - -// teamName resolves the team_name for a persona's owner (if team-scoped). -func teamName(p models.Persona, stores store.Stores, ctx context.Context) string { - if p.Scope != models.ScopeTeam || p.OwnerID == nil { - return "" - } - team, err := stores.Teams.GetByID(ctx, *p.OwnerID) - if err != nil { - return "" - } - return team.Name -} diff --git a/database/migrations/005_channels.sql b/database/migrations/005_channels.sql deleted file mode 100644 index e19e3b6..0000000 --- a/database/migrations/005_channels.sql +++ /dev/null @@ -1,196 +0,0 @@ --- ========================================== --- Chat Switchboard — 005 Channels & Conversations --- ========================================== --- Channels, messages, participants, models, cursors, folders, user prefs. --- ICD §3 (Channels & Conversations) --- Note: project_id and workspace_id FKs added by 010_projects.sql --- ========================================== - --- ========================================= --- FOLDERS --- ========================================= - -CREATE TABLE IF NOT EXISTS folders ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name VARCHAR(200) NOT NULL, - parent_id UUID REFERENCES folders(id) ON DELETE CASCADE, - sort_order INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, name, parent_id) -); - -CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id); -CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id); -DROP TRIGGER IF EXISTS folders_updated_at ON folders; -CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - - --- ========================================= --- CHANNELS --- ========================================= - -CREATE TABLE IF NOT EXISTS channels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - title VARCHAR(500) NOT NULL, - description TEXT, - type VARCHAR(20) DEFAULT 'direct' - CHECK (type IN ('direct', 'group', 'workflow')), - model VARCHAR(100), - system_prompt TEXT, - provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, - is_archived BOOLEAN DEFAULT false, - is_pinned BOOLEAN DEFAULT false, - folder_id UUID, - folder TEXT, - team_id UUID REFERENCES teams(id) ON DELETE SET NULL, - settings JSONB DEFAULT '{}'::jsonb, - tags TEXT[], - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id); -CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC); -CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type); -CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_channels_tags ON channels USING GIN(tags); -DROP TRIGGER IF EXISTS channels_updated_at ON channels; -CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - --- Deferred FK: channels.folder_id → folders.id -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder' - ) THEN - ALTER TABLE channels ADD CONSTRAINT fk_channels_folder - FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL; - END IF; -END $$; -CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL; - -COMMENT ON COLUMN channels.type IS 'direct=single-user chat, group=multi-user/multi-model, workflow=staged channel'; - - --- ========================================= --- MESSAGES --- ========================================= - -CREATE TABLE IF NOT EXISTS messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - role VARCHAR(20) NOT NULL - CHECK (role IN ('user', 'assistant', 'system', 'tool')), - content TEXT NOT NULL, - model VARCHAR(100), - tokens_used INTEGER, - tool_calls JSONB, - metadata JSONB DEFAULT '{}'::jsonb, - parent_id UUID REFERENCES messages(id) ON DELETE SET NULL, - sibling_index INTEGER DEFAULT 0, - participant_type VARCHAR(10) DEFAULT 'user', - participant_id VARCHAR(255), - provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, - deleted_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); -CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id); -CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at) - WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index) - WHERE deleted_at IS NULL; - -COMMENT ON COLUMN messages.parent_id IS 'Tree parent for conversation forking'; -COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)'; - - --- ========================================= --- CHANNEL PARTICIPANTS & MODELS --- ========================================= --- ICD §3.7: Polymorphic participant system. --- participant_type determines what participant_id references: --- user → users.id --- persona → personas.id --- session → opaque session token --- ========================================= - -CREATE TABLE IF NOT EXISTS channel_participants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - participant_type VARCHAR(10) NOT NULL DEFAULT 'user' - CHECK (participant_type IN ('user', 'persona', 'session')), - participant_id UUID NOT NULL, - role VARCHAR(10) NOT NULL DEFAULT 'member' - CHECK (role IN ('owner', 'member', 'observer')), - display_name VARCHAR(200), - avatar_url TEXT, - joined_at TIMESTAMPTZ DEFAULT NOW(), - last_read_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(channel_id, participant_type, participant_id) -); - -CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id); -CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id); - -CREATE TABLE IF NOT EXISTS channel_models ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - model_id VARCHAR(255) NOT NULL, - provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, - persona_id UUID REFERENCES personas(id) ON DELETE SET NULL, - display_name VARCHAR(100), - system_prompt TEXT, - settings JSONB DEFAULT '{}'::jsonb, - is_default BOOLEAN DEFAULT false, - added_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(channel_id, model_id, provider_config_id) -); - -CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id); - - --- ========================================= --- CHANNEL CURSORS (forking navigation) --- ========================================= - -CREATE TABLE IF NOT EXISTS channel_cursors ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL, - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(channel_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id); -CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id); - - --- ========================================= --- USER MODEL SETTINGS --- ========================================= - -CREATE TABLE IF NOT EXISTS user_model_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - model_id TEXT NOT NULL, - provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE, - hidden BOOLEAN DEFAULT false, - preferred_temperature FLOAT, - preferred_max_tokens INT, - sort_order INT DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, model_id, provider_config_id) -); - -CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id); -DROP TRIGGER IF EXISTS user_model_settings_updated_at ON user_model_settings; -CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settings - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); diff --git a/database/migrations/sqlite/005_channels.sql b/database/migrations/sqlite/005_channels.sql deleted file mode 100644 index a622817..0000000 --- a/database/migrations/sqlite/005_channels.sql +++ /dev/null @@ -1,128 +0,0 @@ --- Chat Switchboard — 005 Channels & Conversations (SQLite) - -CREATE TABLE IF NOT EXISTS folders ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE, - sort_order INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), - UNIQUE(user_id, name, parent_id) -); - -CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id); -CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id); - -CREATE TABLE IF NOT EXISTS channels ( - id TEXT PRIMARY KEY, - user_id TEXT REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT, - type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'workflow')), - model TEXT, - system_prompt TEXT, - provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL, - is_archived INTEGER DEFAULT 0, - is_pinned INTEGER DEFAULT 0, - folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL, - folder TEXT, - team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, - settings TEXT DEFAULT '{}', - tags TEXT DEFAULT '[]', - project_id TEXT, - workspace_id TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id); -CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at); -CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type); -CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id); -CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id); -CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id); -CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id); - -CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system', 'tool')), - content TEXT NOT NULL, - model TEXT, - tokens_used INTEGER, - tool_calls TEXT, - metadata TEXT DEFAULT '{}', - parent_id TEXT REFERENCES messages(id) ON DELETE SET NULL, - sibling_index INTEGER DEFAULT 0, - participant_type TEXT DEFAULT 'user', - participant_id TEXT, - provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL, - deleted_at TEXT, - created_at TEXT DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); -CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id); - -CREATE TABLE IF NOT EXISTS channel_participants ( - id TEXT PRIMARY KEY, - channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - participant_type TEXT NOT NULL DEFAULT 'user' - CHECK (participant_type IN ('user', 'persona', 'session')), - participant_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'member' - CHECK (role IN ('owner', 'member', 'observer')), - display_name TEXT, - avatar_url TEXT, - joined_at TEXT DEFAULT (datetime('now')), - last_read_at TEXT DEFAULT (datetime('now')), - UNIQUE(channel_id, participant_type, participant_id) -); - -CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id); -CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id); - -CREATE TABLE IF NOT EXISTS channel_models ( - id TEXT PRIMARY KEY, - channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - model_id TEXT NOT NULL, - provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL, - persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL, - display_name TEXT, - system_prompt TEXT, - settings TEXT DEFAULT '{}', - is_default INTEGER DEFAULT 0, - added_at TEXT DEFAULT (datetime('now')), - UNIQUE(channel_id, model_id, provider_config_id) -); - -CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id); - -CREATE TABLE IF NOT EXISTS channel_cursors ( - id TEXT PRIMARY KEY, - channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - active_leaf_id TEXT REFERENCES messages(id) ON DELETE SET NULL, - updated_at TEXT DEFAULT (datetime('now')), - UNIQUE(channel_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id); -CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id); - -CREATE TABLE IF NOT EXISTS user_model_settings ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - model_id TEXT NOT NULL, - provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE, - hidden INTEGER DEFAULT 0, - preferred_temperature REAL, - preferred_max_tokens INTEGER, - sort_order INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), - UNIQUE(user_id, model_id, provider_config_id) -); - -CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id); diff --git a/database/testhelper.go b/database/testhelper.go deleted file mode 100644 index 3805998..0000000 --- a/database/testhelper.go +++ /dev/null @@ -1,513 +0,0 @@ -package database - -import ( - "database/sql" - "fmt" - "log" - "os" - "strings" - "testing" - - "github.com/google/uuid" - _ "github.com/lib/pq" - _ "modernc.org/sqlite" -) - -// 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 the appropriate database backend (Postgres or -// SQLite based on DB_DRIVER env), runs 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) -// } -// -// For Postgres: requires PGHOST+PGUSER or TEST_DATABASE_URL. -// For SQLite: set DB_DRIVER=sqlite (uses temp file, no external deps). -// -// Returns a cleanup function. -func SetupTestDB() func() { - driver := os.Getenv("DB_DRIVER") - if driver == "sqlite" { - return setupSQLiteTestDB() - } - return setupPostgresTestDB() -} - -// ── SQLite test setup ─────────────────────── - -func setupSQLiteTestDB() func() { - CurrentDialect = DialectSQLite - - // Use a temp file so multiple connections share the same DB. - tmpFile, err := os.CreateTemp("", "switchboard-test-*.db") - if err != nil { - log.Printf("⚠ Cannot create temp SQLite DB: %v — skipping DB tests", err) - return func() {} - } - dbPath := tmpFile.Name() - tmpFile.Close() - - DB, err = sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") - if err != nil { - log.Printf("⚠ Cannot open SQLite DB: %v — skipping DB tests", err) - os.Remove(dbPath) - return func() {} - } - if err := DB.Ping(); err != nil { - DB.Close() - DB = nil - log.Printf("⚠ Cannot ping SQLite DB: %v — skipping DB tests", err) - os.Remove(dbPath) - return func() {} - } - - // SQLite: single writer - DB.SetMaxOpenConns(1) - - TestDB = DB - - // Run SQLite migrations - if err := Migrate(); err != nil { - DB.Close() - DB = nil - TestDB = nil - os.Remove(dbPath) - log.Fatalf("❌ SQLite migration failed on test DB: %v", err) - } - - log.Printf("✓ SQLite test database ready: %s", dbPath) - - return func() { - if DB != nil { - DB.Close() - DB = nil - TestDB = nil - } - os.Remove(dbPath) - log.Printf("✓ Removed SQLite test database: %s", dbPath) - } -} - -// ── Postgres test setup (original) ────────── - -func setupPostgresTestDB() func() { - CurrentDialect = DialectPostgres - - 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) - } - - 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() {} - } - - createdByUs := false - var dbExists bool - err = adminDB.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", testDBName).Scan(&dbExists) - if err != nil { - if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil { - 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) - } - } else { - log.Printf("✓ Test database %s already exists (keeping extensions)", testDBName) - } - - 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) - } - - 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 - - for _, ext := range []string{"uuid-ossp", "pgcrypto", "vector"} { - 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 $$; - `) - - 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 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() - } -} - -// ── Dialect helpers ───────────────────────── - -// PH returns a placeholder for the Nth parameter (1-indexed). -// Postgres: $1, $2, ... SQLite: ?, ?, ... -// Exported for use by test files in other packages (e.g. handlers). -func PH(n int) string { - if IsSQLite() { - return "?" - } - return fmt.Sprintf("$%d", n) -} - -// placeholders returns "?, ?, ?" or "$1, $2, $3" for n params. -func placeholders(n int) string { - parts := make([]string, n) - for i := range parts { - parts[i] = PH(i + 1) - } - return strings.Join(parts, ", ") -} - -// nowSQL returns the SQL expression for "current timestamp". -func nowSQL() string { - if IsSQLite() { - return "datetime('now')" - } - return "NOW()" -} - -// ── Shared test helpers ───────────────────── - -// 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 (or DB_DRIVER=sqlite)") - } -} - -// TruncateAll truncates all application tables for test isolation. -func TruncateAll(t *testing.T) { - t.Helper() - if DB == nil { - return - } - - tables := []string{ - "resource_grants", - "group_members", - "groups", - "notifications", - "usage_log", - "model_pricing", - "notes", - "audit_log", - "channel_cursors", - "messages", - "channel_models", - "channel_participants", - "channel_knowledge_bases", - "persona_knowledge_bases", - "project_notes", - "project_knowledge_bases", - "project_channels", - "kb_chunks", - "kb_documents", - "knowledge_bases", - "channels", - "projects", - "user_model_settings", - "model_catalog", - "persona_grants", - "personas", - "provider_configs", - "team_members", - "teams", - "refresh_tokens", - "users", - "platform_policies", - "global_config", - "global_settings", - "folders", - "attachments", - "extension_user_settings", - "extensions", - } - - if IsSQLite() { - DB.Exec("PRAGMA foreign_keys = OFF") - for _, table := range tables { - DB.Exec(fmt.Sprintf("DELETE FROM %s", table)) - } - DB.Exec("PRAGMA foreign_keys = ON") - } else { - for _, table := range tables { - DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)) - } - } - - // Re-seed default config rows. - if IsSQLite() { - DB.Exec(` - INSERT INTO global_settings (key, value) VALUES - ('registration', '{"enabled": true}'), - ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'), - ('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'), - ('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}') - ON CONFLICT (key) DO NOTHING - `) - DB.Exec(` - INSERT INTO platform_policies (key, value) VALUES - ('allow_user_byok', 'false'), - ('allow_user_personas', 'false'), - ('allow_raw_model_access', 'true'), - ('allow_registration', 'true'), - ('default_user_active', 'false'), - ('allow_team_providers', 'true') - ON CONFLICT (key) DO NOTHING - `) - } else { - DB.Exec(` - INSERT INTO global_settings (key, value) VALUES - ('registration', '{"enabled": true}'::jsonb), - ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb), - ('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'::jsonb), - ('model_roles', '{ - "utility": { "primary": null, "fallback": null }, - "embedding": { "primary": null, "fallback": null }, - "generation": { "primary": null, "fallback": null } - }'::jsonb) - ON CONFLICT (key) DO NOTHING - `) - DB.Exec(` - INSERT INTO platform_policies (key, value) VALUES - ('allow_user_byok', 'false'), - ('allow_user_personas', 'false'), - ('allow_raw_model_access', 'true'), - ('allow_registration', 'true'), - ('default_user_active', 'false'), - ('allow_team_providers', 'true') - ON CONFLICT (key) DO NOTHING - `) - } -} - -// SeedTestUser creates a test user and returns the user ID. -func SeedTestUser(t *testing.T, username, email string) string { - t.Helper() - if IsSQLite() { - id := uuid.New().String() - _, err := DB.Exec(` - INSERT INTO users (id, username, email, password_hash, role) - VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user') - `, id, username, email) - if err != nil { - t.Fatalf("SeedTestUser: %v", err) - } - return id - } - 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() - if IsSQLite() { - id := uuid.New().String() - _, err := DB.Exec(`INSERT INTO channels (id, user_id, title) VALUES (?, ?, ?)`, id, userID, title) - if err != nil { - t.Fatalf("SeedTestChannel: %v", err) - } - return id - } - var id string - err := DB.QueryRow(`INSERT INTO channels (user_id, title) VALUES ($1, $2) RETURNING id`, userID, title).Scan(&id) - if err != nil { - t.Fatalf("SeedTestChannel: %v", err) - } - return id -} - -// SeedTestTeam creates a test team and returns the team ID. -func SeedTestTeam(t *testing.T, name, createdBy string) string { - t.Helper() - if IsSQLite() { - id := uuid.New().String() - _, err := DB.Exec(`INSERT INTO teams (id, name, created_by) VALUES (?, ?, ?)`, id, name, createdBy) - if err != nil { - t.Fatalf("SeedTestTeam: %v", err) - } - return id - } - var id string - err := DB.QueryRow(`INSERT INTO teams (name, created_by) VALUES ($1, $2) RETURNING id`, name, createdBy).Scan(&id) - if err != nil { - t.Fatalf("SeedTestTeam: %v", err) - } - return id -} - -// SeedTestTeamMember adds a user to a team. -func SeedTestTeamMember(t *testing.T, teamID, userID, role string) { - t.Helper() - if IsSQLite() { - _, err := DB.Exec(`INSERT INTO team_members (id, team_id, user_id, role) VALUES (?, ?, ?, ?)`, - uuid.New().String(), teamID, userID, role) - if err != nil { - t.Fatalf("SeedTestTeamMember: %v", err) - } - return - } - q := fmt.Sprintf(` - INSERT INTO team_members (team_id, user_id, role) - VALUES (%s, %s, %s) - `, PH(1), PH(2), PH(3)) - _, err := DB.Exec(q, teamID, userID, role) - if err != nil { - t.Fatalf("SeedTestTeamMember: %v", err) - } -} - -// SeedTestGroup creates a test group and returns the group ID. -func SeedTestGroup(t *testing.T, name, scope string, teamID *string, createdBy string) string { - t.Helper() - var teamArg interface{} - if teamID != nil { - teamArg = *teamID - } - if IsSQLite() { - id := uuid.New().String() - _, err := DB.Exec(`INSERT INTO groups (id, name, scope, team_id, created_by) VALUES (?, ?, ?, ?, ?)`, - id, name, scope, teamArg, createdBy) - if err != nil { - t.Fatalf("SeedTestGroup: %v", err) - } - return id - } - var id string - err := DB.QueryRow(`INSERT INTO groups (name, scope, team_id, created_by) VALUES ($1, $2, $3, $4) RETURNING id`, - name, scope, teamArg, createdBy).Scan(&id) - if err != nil { - t.Fatalf("SeedTestGroup: %v", err) - } - return id -} - -// SeedGroupMember adds a user to a group. -func SeedGroupMember(t *testing.T, groupID, userID, addedBy string) { - t.Helper() - if IsSQLite() { - _, err := DB.Exec(`INSERT INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`, - uuid.New().String(), groupID, userID, addedBy) - if err != nil { - t.Fatalf("SeedGroupMember: %v", err) - } - return - } - q := fmt.Sprintf(` - INSERT INTO group_members (group_id, user_id, added_by) - VALUES (%s, %s, %s) - `, PH(1), PH(2), PH(3)) - _, err := DB.Exec(q, groupID, userID, addedBy) - if err != nil { - t.Fatalf("SeedGroupMember: %v", err) - } -} - -// ── Internal helpers ──────────────────────── - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func replaceDBName(dsn, newDB string) string { - 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, " ") - } - } - } - if strings.Contains(dsn, "://") { - idx := strings.LastIndex(dsn, "/") - qIdx := strings.Index(dsn, "?") - if qIdx > idx { - return dsn[:idx+1] + newDB + dsn[qIdx:] - } - return dsn[:idx+1] + newDB - } - return dsn + " dbname=" + newDB -} diff --git a/docker-compose.yml b/docker-compose.yml index b5c10ac..70fb8ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,18 @@ -version: '3.8' +# docker-compose.yml — Local development (SQLite) +# +# Single container: Go backend + nginx frontend. +# No Postgres, no sidecar, no PVC — just a mounted data directory. +# +# Usage: +# docker compose up --build +# open http://localhost:3000 +# +# Data persists in ./data/ between restarts. +# To reset: rm -rf ./data/ +# +# For Postgres / multi-replica / k8s deployment see k8s/ and server/Dockerfile + Dockerfile.frontend. services: - # PostgreSQL database - postgres: - image: postgres:16-alpine - container_name: chat-switchboard-db - environment: - POSTGRES_USER: switchboard - POSTGRES_PASSWORD: ${DB_PASSWORD:-switchboard_dev} - POSTGRES_DB: chat_switchboard - volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U switchboard -d chat_switchboard"] - interval: 5s - timeout: 5s - retries: 5 - - # Chat Switchboard (unified: Go backend + nginx frontend) switchboard: build: context: . @@ -28,36 +21,15 @@ services: environment: PORT: "8080" BASE_PATH: "" - POSTGRES_HOST: postgres - POSTGRES_PORT: "5432" - POSTGRES_USER: switchboard - POSTGRES_PASSWORD: ${DB_PASSWORD:-switchboard_dev} - POSTGRES_DB: chat_switchboard - POSTGRES_SSLMODE: disable - JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + DB_DRIVER: sqlite + DATABASE_URL: /data/switchboard.db + JWT_SECRET: ${JWT_SECRET:-change-me-for-production} SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} - # File storage — mount ./data/storage on host + STORAGE_BACKEND: pvc STORAGE_PATH: /data/storage volumes: - - switchboard_storage:/data/storage + - ./data:/data ports: - "3000:80" - depends_on: - postgres: - condition: service_healthy - - # Adminer for database management (optional, dev only) - adminer: - image: adminer:latest - container_name: chat-switchboard-adminer - ports: - - "8081:8080" - depends_on: - - postgres - profiles: - - dev - -volumes: - postgres_data: - switchboard_storage: + restart: unless-stopped diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 73d975c..9e07a01 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1449,6 +1449,7 @@ based on need. - Plugin/extension marketplace - Virtual scroll for long conversations - ~~SQLite backend option (single-user / dev)~~ → v0.17.1 +- **Helm chart.** The k8s/ raw manifests with `${VAR}` substitution have served well but are friction for external adopters and make values management manual. A Helm chart wraps the same backend + frontend deployments with a `values.yaml` (replicas, image tags, ingress host, storage class, secret refs, resource limits, feature flags). Target: `helm install switchboard ./chart` for a fresh cluster, `helm upgrade` for rolling deploys. Subcharts for optional Postgres (for dev/test — prod uses external). Candidate for v0.27+ or a parallel track once the core feature set stabilizes. **Pane Architecture (Workspace Container)** diff --git a/handlers/channels.go b/handlers/channels.go deleted file mode 100644 index a7510b5..0000000 --- a/handlers/channels.go +++ /dev/null @@ -1,552 +0,0 @@ -package handlers - -import ( - "database/sql" - "encoding/json" - "math" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "github.com/lib/pq" - - "git.gobha.me/xcaliber/chat-switchboard/database" - "git.gobha.me/xcaliber/chat-switchboard/store" -) - -// ── Request / Response types ──────────────── - -type createChannelRequest struct { - Title string `json:"title" binding:"required,max=500"` - Type string `json:"type,omitempty"` // direct (default), group, workflow - Description string `json:"description,omitempty"` - Model string `json:"model,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - Folder string `json:"folder,omitempty"` - Tags []string `json:"tags,omitempty"` -} - -type updateChannelRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Model *string `json:"model,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` - IsPinned *bool `json:"is_pinned,omitempty"` - Folder *string `json:"folder,omitempty"` - Tags []string `json:"tags,omitempty"` - Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings - WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5) -} - -type channelResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Title string `json:"title"` - Type string `json:"type"` - Description *string `json:"description"` - Model *string `json:"model"` - ProviderConfigID *string `json:"provider_config_id"` - SystemPrompt *string `json:"system_prompt"` - IsArchived bool `json:"is_archived"` - IsPinned bool `json:"is_pinned"` - Folder *string `json:"folder"` - ProjectID *string `json:"project_id,omitempty"` - WorkspaceID *string `json:"workspace_id,omitempty"` - Tags []string `json:"tags"` - Settings json.RawMessage `json:"settings,omitempty"` - MessageCount int `json:"message_count"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type paginatedResponse struct { - Data interface{} `json:"data"` - Page int `json:"page"` - PerPage int `json:"per_page"` - Total int `json:"total"` - TotalPages int `json:"total_pages"` -} - -// ChannelHandler holds dependencies for channel endpoints. -type ChannelHandler struct{} - -// NewChannelHandler creates a new channel handler. -func NewChannelHandler() *ChannelHandler { - return &ChannelHandler{} -} - -// channelDeleteHook is called after a channel is successfully deleted. -// Set at startup by SetChannelDeleteHook to clean up storage files. -var channelDeleteHook func(channelID string) - -// SetChannelDeleteHook registers a callback invoked after channel deletion. -// Used to clean up channel files on the storage backend. -func SetChannelDeleteHook(fn func(channelID string)) { - channelDeleteHook = fn -} - -// ── Tag Helpers ───────────────────────────── - -// writeTagsArg returns a value suitable for inserting/updating the tags column. -// Postgres: pq.Array SQLite: JSON string -func writeTagsArg(tags []string) interface{} { - if database.IsSQLite() { - b, _ := json.Marshal(tags) - return string(b) - } - return pq.Array(tags) -} - -// scanJSON, scanTags, SafeJSON → safe_json.go - -// ── Helpers ───────────────────────────────── - -// getUserID extracts the authenticated user's ID from context. -func getUserID(c *gin.Context) string { - uid, _ := c.Get("user_id") - s, _ := uid.(string) - return s -} - -// parsePagination reads page and per_page from query params with defaults. -func parsePagination(c *gin.Context) (page, perPage, offset int) { - page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50")) - - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 50 - } - if perPage > 100 { - perPage = 100 - } - offset = (page - 1) * perPage - return -} - -// ── List Channels ─────────────────────────── - -func (h *ChannelHandler) ListChannels(c *gin.Context) { - userID := getUserID(c) - page, perPage, offset := parsePagination(c) - - // Optional filters - archived := c.DefaultQuery("archived", "false") - folder := c.Query("folder") - channelType := c.DefaultQuery("type", "") // empty = all types - search := strings.TrimSpace(c.Query("search")) - projectFilter := c.Query("project_id") // "uuid" or "none" - - // Count total - countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2` - countArgs := []interface{}{userID, archived == "true"} - argN := 3 - - if channelType != "" { - countQuery += ` AND type = $` + strconv.Itoa(argN) - countArgs = append(countArgs, channelType) - argN++ - } - if folder != "" { - countQuery += ` AND folder = $` + strconv.Itoa(argN) - countArgs = append(countArgs, folder) - argN++ - } - if search != "" { - countQuery += ` AND title ILIKE $` + strconv.Itoa(argN) - countArgs = append(countArgs, "%"+search+"%") - argN++ - } - if projectFilter == "none" { - countQuery += ` AND project_id IS NULL` - } else if projectFilter != "" { - countQuery += ` AND project_id = $` + strconv.Itoa(argN) - countArgs = append(countArgs, projectFilter) - argN++ - } - - var total int - if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"}) - return - } - - // Fetch channels with message count - query := ` - SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id, - c.tags, c.settings, - COALESCE(mc.cnt, 0) AS message_count, - c.created_at, c.updated_at - FROM channels c - LEFT JOIN ( - SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id - ) mc ON mc.channel_id = c.id - WHERE c.user_id = $1 AND c.is_archived = $2` - - args := []interface{}{userID, archived == "true"} - argN = 3 - - if channelType != "" { - query += ` AND c.type = $` + strconv.Itoa(argN) - args = append(args, channelType) - argN++ - } - if folder != "" { - query += ` AND c.folder = $` + strconv.Itoa(argN) - args = append(args, folder) - argN++ - } - if search != "" { - query += ` AND c.title ILIKE $` + strconv.Itoa(argN) - args = append(args, "%"+search+"%") - argN++ - } - if projectFilter == "none" { - query += ` AND c.project_id IS NULL` - } else if projectFilter != "" { - query += ` AND c.project_id = $` + strconv.Itoa(argN) - args = append(args, projectFilter) - argN++ - } - - query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1) - args = append(args, perPage, offset) - - rows, err := database.DB.Query(database.Q(query), args...) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) - return - } - defer rows.Close() - - channels := make([]channelResponse, 0) - for rows.Next() { - var ch channelResponse - var tags []string - err := rows.Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, - scanTags(&tags), scanJSON(&ch.Settings), - &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"}) - return - } - if tags == nil { - tags = []string{} - } - ch.Tags = tags - channels = append(channels, ch) - } - - SafeJSON(c, http.StatusOK, paginatedResponse{ - Data: channels, - Page: page, - PerPage: perPage, - Total: total, - TotalPages: int(math.Ceil(float64(total) / float64(perPage))), - }) -} - -// ── Create Channel ────────────────────────── - -func (h *ChannelHandler) CreateChannel(c *gin.Context) { - userID := getUserID(c) - - var req createChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Tags == nil { - req.Tags = []string{} - } - - // Default type is "direct" (1:1 AI chat) - channelType := req.Type - if channelType == "" { - channelType = "direct" - } - - // INSERT and retrieve the new row - var ch channelResponse - var tags []string - - if database.IsSQLite() { - id := store.NewID() - _, err := database.DB.Exec(` - INSERT INTO channels (id, user_id, title, type, description, model, - system_prompt, provider_config_id, folder, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - id, userID, req.Title, channelType, req.Description, req.Model, - req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags), - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) - return - } - // Read back the row - err = database.DB.QueryRow(` - SELECT id, user_id, title, type, description, model, provider_config_id, - system_prompt, is_archived, is_pinned, folder, project_id, workspace_id, - tags, settings, - created_at, updated_at - FROM channels WHERE id = ?`, id).Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, - scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel: " + err.Error()}) - return - } - } else { - err := database.DB.QueryRow(` - INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt, - is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at - `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID, - req.Folder, pq.Array(req.Tags), - ).Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, - pq.Array(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) - return - } - } - - if tags == nil { - tags = []string{} - } - ch.Tags = tags - ch.MessageCount = 0 - - // Auto-create channel_participant for the creator - if database.IsSQLite() { - _, _ = database.DB.Exec(` - INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) - VALUES (?, ?, 'user', ?, 'owner') - ON CONFLICT DO NOTHING - `, store.NewID(), ch.ID, userID) - } else { - _, _ = database.DB.Exec(` - INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) - VALUES ($1, 'user', $2, 'owner') - ON CONFLICT DO NOTHING - `, ch.ID, userID) - } - - // Auto-create channel_model if model specified - if req.Model != "" { - if database.IsSQLite() { - _, _ = database.DB.Exec(` - INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default) - VALUES (?, ?, ?, ?, 1) - ON CONFLICT DO NOTHING - `, store.NewID(), ch.ID, req.Model, req.ProviderConfigID) - } else { - _, _ = database.DB.Exec(` - INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default) - VALUES ($1, $2, $3, true) - ON CONFLICT DO NOTHING - `, ch.ID, req.Model, req.ProviderConfigID) - } - } - - SafeJSON(c, http.StatusCreated, ch) -} - -// ── Get Channel ───────────────────────────── - -func (h *ChannelHandler) GetChannel(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - - var ch channelResponse - var tags []string - err := database.DB.QueryRow(database.Q(` - SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id, - c.tags, c.settings, - COALESCE(mc.cnt, 0) AS message_count, - c.created_at, c.updated_at - FROM channels c - LEFT JOIN ( - SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id - ) mc ON mc.channel_id = c.id - WHERE c.id = $1 AND c.user_id = $2 - `), channelID, userID).Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, - scanTags(&tags), scanJSON(&ch.Settings), - &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, - ) - - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"}) - return - } - - if tags == nil { - tags = []string{} - } - ch.Tags = tags - - SafeJSON(c, http.StatusOK, ch) -} - -// ── Update Channel ────────────────────────── - -func (h *ChannelHandler) UpdateChannel(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - - if database.DB == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"}) - return - } - - var req updateChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Verify ownership - var ownerID string - err := database.DB.QueryRow(database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - if ownerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return - } - - // Build dynamic UPDATE with ? placeholders (converted for Postgres) - setClauses := []string{} - args := []interface{}{} - - addClause := func(col string, val interface{}) { - setClauses = append(setClauses, col+" = ?") - args = append(args, val) - } - - if req.Title != nil { - addClause("title", *req.Title) - } - if req.Description != nil { - addClause("description", *req.Description) - } - if req.Model != nil { - addClause("model", *req.Model) - } - if req.SystemPrompt != nil { - addClause("system_prompt", *req.SystemPrompt) - } - if req.ProviderConfigID != nil { - addClause("provider_config_id", *req.ProviderConfigID) - } - if req.IsArchived != nil { - addClause("is_archived", *req.IsArchived) - } - if req.IsPinned != nil { - addClause("is_pinned", *req.IsPinned) - } - if req.Folder != nil { - addClause("folder", *req.Folder) - } - if req.Tags != nil { - addClause("tags", writeTagsArg(req.Tags)) - } - if req.Settings != nil { - // Validate settings is well-formed JSON before writing - if !json.Valid([]byte(*req.Settings)) { - c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"}) - return - } - // JSONB merge: new settings keys overwrite existing, unmentioned keys preserved - if database.IsSQLite() { - setClauses = append(setClauses, "settings = json_patch(settings, ?)") - } else { - setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || ?::jsonb") - } - args = append(args, []byte(*req.Settings)) - } - if req.WorkspaceID != nil { - if *req.WorkspaceID == "" { - addClause("workspace_id", nil) // unbind - } else { - addClause("workspace_id", *req.WorkspaceID) - } - } - - if len(setClauses) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) - return - } - - query := "UPDATE channels SET " + strings.Join(setClauses, ", ") - query += " WHERE id = ? AND user_id = ?" - args = append(args, channelID, userID) - - if !database.IsSQLite() { - query = convertPlaceholders(query) - } - - _, err = database.DB.Exec(query, args...) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"}) - return - } - - // Return updated channel - h.GetChannel(c) -} - -// ── Delete Channel ────────────────────────── - -func (h *ChannelHandler) DeleteChannel(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - - result, err := database.DB.Exec( - database.Q(`DELETE FROM channels WHERE id = $1 AND user_id = $2`), - channelID, userID, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) - return - } - - rows, _ := result.RowsAffected() - if rows == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - - // Clean up storage files (CASCADE already removed PG file rows) - if channelDeleteHook != nil { - go channelDeleteHook(channelID) - } - - c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) -} diff --git a/handlers/model_prefs.go b/handlers/model_prefs.go deleted file mode 100644 index 91493a6..0000000 --- a/handlers/model_prefs.go +++ /dev/null @@ -1,86 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" -) - -// ModelPrefsHandler handles user model preference endpoints. -type ModelPrefsHandler struct { - stores store.Stores -} - -func NewModelPrefsHandler(s store.Stores) *ModelPrefsHandler { - return &ModelPrefsHandler{stores: s} -} - -// GetPreferences returns the user's model preferences. -func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) { - userID := getUserID(c) - - prefs, err := h.stores.UserSettings.GetForUser(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get preferences"}) - return - } - - c.JSON(http.StatusOK, gin.H{"preferences": prefs}) -} - -// SetPreference upserts a single model preference. -func (h *ModelPrefsHandler) SetPreference(c *gin.Context) { - userID := getUserID(c) - - var req struct { - ModelID string `json:"model_id" binding:"required"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - Hidden *bool `json:"hidden,omitempty"` - PreferredTemperature *float64 `json:"preferred_temperature,omitempty"` - PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - patch := models.UserModelSettingPatch{ - ProviderConfigID: req.ProviderConfigID, - Hidden: req.Hidden, - PreferredTemperature: req.PreferredTemperature, - PreferredMaxTokens: req.PreferredMaxTokens, - SortOrder: req.SortOrder, - } - - if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, req.ProviderConfigID, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "preference updated"}) -} - -// BulkSetPreferences sets hidden state for multiple model+provider pairs at once. -func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) { - userID := getUserID(c) - - var req struct { - Entries []models.HiddenEntry `json:"entries" binding:"required"` - Hidden bool `json:"hidden"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.Entries, req.Hidden); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.Entries)}) -} diff --git a/models/models.go b/models/models.go deleted file mode 100644 index 8fc2162..0000000 --- a/models/models.go +++ /dev/null @@ -1,1029 +0,0 @@ -package models - -import ( - "database/sql" - "encoding/json" - "time" -) - -// ── Base ──────────────────────────────────── - -type BaseModel struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// ── Scope Constants ───────────────────────── - -const ( - ScopeGlobal = "global" - ScopeTeam = "team" - ScopePersonal = "personal" -) - -// ── Visibility Constants ──────────────────── - -const ( - VisibilityVisible = "visible" - VisibilityHidden = "hidden" -) - -// ── Role Constants ────────────────────────── - -const ( - UserRoleUser = "user" - UserRoleAdmin = "admin" - - TeamRoleAdmin = "admin" - TeamRoleMember = "member" -) - -// ── Grant Type Constants ──────────────────── - -const ( - GrantTypeTool = "tool" - GrantTypeKnowledgeBase = "knowledge_base" - GrantTypeAPIEndpoint = "api_endpoint" -) - -// ── Channel Type Constants ────────────────── - -const ( - ChannelTypeDirect = "direct" - ChannelTypeGroup = "group" - ChannelTypeChannel = "channel" -) - -// ── Message Role Constants ────────────────── - -const ( - MessageRoleUser = "user" - MessageRoleAssistant = "assistant" - MessageRoleSystem = "system" - MessageRoleTool = "tool" -) - -// ========================================= -// USERS -// ========================================= - -type User struct { - BaseModel - Username string `json:"username" db:"username"` - Email string `json:"email" db:"email"` - PasswordHash string `json:"-" db:"password_hash"` - DisplayName string `json:"display_name,omitempty" db:"display_name"` - AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"` - Role string `json:"role" db:"role"` - IsActive bool `json:"is_active" db:"is_active"` - Settings JSONMap `json:"settings,omitempty" db:"settings"` - LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` -} - -// ========================================= -// TEAMS -// ========================================= - -type Team struct { - BaseModel - Name string `json:"name" db:"name"` - Description string `json:"description,omitempty" db:"description"` - CreatedBy string `json:"created_by" db:"created_by"` - IsActive bool `json:"is_active" db:"is_active"` - Settings JSONMap `json:"settings,omitempty" db:"settings"` - MemberCount int `json:"member_count,omitempty"` // computed -} - -type TeamMember struct { - ID string `json:"id" db:"id"` - TeamID string `json:"team_id" db:"team_id"` - UserID string `json:"user_id" db:"user_id"` - Role string `json:"role" db:"role"` - JoinedAt string `json:"joined_at" db:"joined_at"` - // Joined fields from users table - Email string `json:"email,omitempty"` - DisplayName string `json:"display_name,omitempty"` - Username string `json:"username,omitempty"` - UserRole string `json:"user_role,omitempty"` -} - -// ========================================= -// PROVIDER CONFIGS (replaces APIConfig) -// ========================================= - -type ProviderConfig struct { - BaseModel - Scope string `json:"scope" db:"scope"` - OwnerID *string `json:"owner_id,omitempty" db:"owner_id"` - Name string `json:"name" db:"name"` - Provider string `json:"provider" db:"provider"` - Endpoint string `json:"endpoint" db:"endpoint"` - APIKeyEnc []byte `json:"-" db:"api_key_enc"` - KeyNonce []byte `json:"-" db:"key_nonce"` - KeyScope string `json:"-" db:"key_scope"` - ModelDefault string `json:"model_default,omitempty" db:"model_default"` - Config JSONMap `json:"config,omitempty" db:"config"` - Headers JSONMap `json:"headers,omitempty" db:"headers"` - Settings JSONMap `json:"settings,omitempty" db:"settings"` - IsActive bool `json:"is_active" db:"is_active"` - IsPrivate bool `json:"is_private" db:"is_private"` -} - -// HasKey returns true if an encrypted API key is stored. -func (p *ProviderConfig) HasKey() bool { - return len(p.APIKeyEnc) > 0 -} - -type ProviderConfigPatch struct { - Name *string `json:"name,omitempty"` - Endpoint *string `json:"endpoint,omitempty"` - APIKeyEnc []byte `json:"-"` - KeyNonce []byte `json:"-"` - ModelDefault *string `json:"model_default,omitempty"` - Config JSONMap `json:"config,omitempty"` - Headers JSONMap `json:"headers,omitempty"` - Settings JSONMap `json:"settings,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - IsPrivate *bool `json:"is_private,omitempty"` -} - -// ========================================= -// MODEL CATALOG (replaces model_configs) -// ========================================= - -type CatalogEntry struct { - BaseModel - ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"` - ModelID string `json:"model_id" db:"model_id"` - DisplayName string `json:"display_name,omitempty" db:"display_name"` - ModelType string `json:"model_type" db:"model_type"` // "chat", "embedding", "image", etc. - Capabilities ModelCapabilities `json:"capabilities" db:"capabilities"` - Pricing *ModelPricing `json:"pricing,omitempty" db:"pricing"` - Visibility string `json:"visibility" db:"visibility"` - LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"` -} - -type ModelCapabilities struct { - Streaming bool `json:"streaming"` - ToolCalling bool `json:"tool_calling"` - Vision bool `json:"vision"` - Thinking bool `json:"thinking"` - Reasoning bool `json:"reasoning"` - CodeOptimized bool `json:"code_optimized"` - WebSearch bool `json:"web_search"` - MaxContext int `json:"max_context"` - MaxOutputTokens int `json:"max_output_tokens"` -} - -func (c ModelCapabilities) HasProviderData() bool { - return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning || - c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0 -} - -type ModelPricing struct { - InputPerM float64 `json:"input_per_m,omitempty"` - OutputPerM float64 `json:"output_per_m,omitempty"` - Currency string `json:"currency,omitempty"` -} - -// ========================================= -// PERSONAS (replaces ModelPreset) -// ========================================= - -type Persona struct { - BaseModel - Name string `json:"name" db:"name"` - Description string `json:"description,omitempty" db:"description"` - Icon string `json:"icon,omitempty" db:"icon"` - Avatar string `json:"avatar,omitempty" db:"avatar"` - - BaseModelID string `json:"base_model_id" db:"base_model_id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - - SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` - Temperature *float64 `json:"temperature,omitempty" db:"temperature"` - MaxTokens *int `json:"max_tokens,omitempty" db:"max_tokens"` - ThinkingBudget *int `json:"thinking_budget,omitempty" db:"thinking_budget"` - TopP *float64 `json:"top_p,omitempty" db:"top_p"` - - Scope string `json:"scope" db:"scope"` - OwnerID *string `json:"owner_id,omitempty" db:"owner_id"` - CreatedBy string `json:"created_by" db:"created_by"` - - IsActive bool `json:"is_active" db:"is_active"` - IsShared bool `json:"is_shared" db:"is_shared"` - - // Loaded from persona_grants, not stored in personas table - Grants []Grant `json:"grants,omitempty" db:"-"` - - // Loaded from persona_knowledge_bases, not stored in personas table - KBIDs []string `json:"kb_ids,omitempty" db:"-"` - - // Memory configuration (v0.18.0) - MemoryEnabled bool `json:"memory_enabled" db:"memory_enabled"` - MemoryExtractionPrompt *string `json:"memory_extraction_prompt,omitempty" db:"memory_extraction_prompt"` -} - -type PersonaPatch struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Icon *string `json:"icon,omitempty"` - Avatar *string `json:"avatar,omitempty"` - BaseModelID *string `json:"base_model_id,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - ThinkingBudget *int `json:"thinking_budget,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - IsShared *bool `json:"is_shared,omitempty"` - MemoryEnabled *bool `json:"memory_enabled,omitempty"` - MemoryExtractionPrompt *string `json:"memory_extraction_prompt,omitempty"` -} - -// ========================================= -// GRANTS -// ========================================= - -type Grant struct { - ID string `json:"id" db:"id"` - PersonaID string `json:"persona_id" db:"persona_id"` - GrantType string `json:"grant_type" db:"grant_type"` - GrantRef string `json:"grant_ref" db:"grant_ref"` - Config JSONMap `json:"config,omitempty" db:"config"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// ========================================= -// PLATFORM POLICIES -// ========================================= - -var PolicyDefaults = map[string]string{ - "allow_user_byok": "false", - "allow_user_personas": "false", - "allow_raw_model_access": "false", - "allow_registration": "true", - "default_user_active": "false", - "allow_team_providers": "true", - "default_model": "", -} - -// ========================================= -// USER MODEL SETTINGS -// ========================================= - -type UserModelSetting struct { - BaseModel - UserID string `json:"user_id" db:"user_id"` - ModelID string `json:"model_id" db:"model_id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - Hidden bool `json:"hidden" db:"hidden"` - PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"` - PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"` - SortOrder int `json:"sort_order" db:"sort_order"` -} - -type UserModelSettingPatch struct { - ProviderConfigID *string `json:"provider_config_id,omitempty"` - Hidden *bool `json:"hidden,omitempty"` - PreferredTemperature *float64 `json:"preferred_temperature,omitempty"` - PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` -} - -// HiddenEntry identifies a model+provider pair for bulk visibility operations. -type HiddenEntry struct { - ModelID string `json:"model_id"` - ProviderConfigID string `json:"provider_config_id"` -} - -// CompositeModelKey builds the composite key used for per-provider model preferences. -func CompositeModelKey(providerConfigID, modelID string) string { - return providerConfigID + ":" + modelID -} - -// ========================================= -// CHANNELS -// ========================================= - -type Channel struct { - BaseModel - UserID string `json:"user_id" db:"user_id"` - Title string `json:"title" db:"title"` - Description string `json:"description,omitempty" db:"description"` - Type string `json:"type" db:"type"` - Model string `json:"model,omitempty" db:"model"` - SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - IsArchived bool `json:"is_archived" db:"is_archived"` - IsPinned bool `json:"is_pinned" db:"is_pinned"` - FolderID *string `json:"folder_id,omitempty" db:"folder_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - ProjectID *string `json:"project_id,omitempty" db:"project_id"` - WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"` - Settings JSONMap `json:"settings,omitempty" db:"settings"` -} - -// ========================================= -// MESSAGES -// ========================================= - -type Message struct { - BaseModel - ChannelID string `json:"channel_id" db:"channel_id"` - Role string `json:"role" db:"role"` - Content string `json:"content" db:"content"` - Model string `json:"model,omitempty" db:"model"` - TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"` - ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"` - Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` - ParentID *string `json:"parent_id,omitempty" db:"parent_id"` - SiblingIndex int `json:"sibling_index" db:"sibling_index"` - ParticipantType string `json:"participant_type,omitempty" db:"participant_type"` - ParticipantID string `json:"participant_id,omitempty" db:"participant_id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` -} - -// ========================================= -// CHANNEL PARTICIPANTS, MODELS, CURSORS -// ========================================= - -type ChannelParticipant struct { - ID string `json:"id" db:"id"` - ChannelID string `json:"channel_id" db:"channel_id"` - ParticipantType string `json:"participant_type" db:"participant_type"` - ParticipantID string `json:"participant_id" db:"participant_id"` - Role string `json:"role" db:"role"` - DisplayName *string `json:"display_name,omitempty" db:"display_name"` - AvatarURL *string `json:"avatar_url,omitempty" db:"avatar_url"` - JoinedAt time.Time `json:"joined_at" db:"joined_at"` - LastReadAt time.Time `json:"last_read_at" db:"last_read_at"` -} - -type ChannelModel struct { - ID string `json:"id" db:"id"` - ChannelID string `json:"channel_id" db:"channel_id"` - ModelID string `json:"model_id" db:"model_id"` - ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"` - PersonaID *string `json:"persona_id,omitempty" db:"persona_id"` - DisplayName string `json:"display_name,omitempty" db:"display_name"` - SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` - IsDefault bool `json:"is_default" db:"is_default"` -} - -type ChannelCursor struct { - ID string `json:"id" db:"id"` - ChannelID string `json:"channel_id" db:"channel_id"` - UserID string `json:"user_id" db:"user_id"` - ActiveLeafID *string `json:"active_leaf_id,omitempty" db:"active_leaf_id"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// ========================================= -// ORGANIZATION -// ========================================= - -type Folder struct { - BaseModel - UserID string `json:"user_id" db:"user_id"` - Name string `json:"name" db:"name"` - ParentID *string `json:"parent_id,omitempty" db:"parent_id"` - SortOrder int `json:"sort_order" db:"sort_order"` -} - -type Project struct { - BaseModel - Name string `json:"name" db:"name"` - Description string `json:"description,omitempty" db:"description"` - Color *string `json:"color,omitempty" db:"color"` - Icon *string `json:"icon,omitempty" db:"icon"` - Scope string `json:"scope" db:"scope"` // personal, team, global - OwnerID string `json:"owner_id" db:"owner_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - IsArchived bool `json:"is_archived" db:"is_archived"` - WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"` - Settings JSONMap `json:"settings,omitempty" db:"settings"` - - // Computed fields (not DB columns) - ChannelCount int `json:"channel_count,omitempty"` - KBCount int `json:"kb_count,omitempty"` - NoteCount int `json:"note_count,omitempty"` -} - -// ProjectPatch holds optional fields for updating a project. -type ProjectPatch struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Color *string `json:"color,omitempty"` - Icon *string `json:"icon,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` - WorkspaceID *string `json:"workspace_id,omitempty"` - Settings JSONMap `json:"settings,omitempty"` -} - -// ProjectChannel represents a channel's membership in a project. -type ProjectChannel struct { - ProjectID string `json:"project_id" db:"project_id"` - ChannelID string `json:"channel_id" db:"channel_id"` - Position int `json:"position" db:"position"` - Folder string `json:"folder,omitempty" db:"folder"` - AddedAt string `json:"added_at" db:"added_at"` -} - -// ProjectKB represents a KB's association with a project. -type ProjectKB struct { - ProjectID string `json:"project_id" db:"project_id"` - KBID string `json:"kb_id" db:"kb_id"` - AutoSearch bool `json:"auto_search" db:"auto_search"` - AddedAt string `json:"added_at" db:"added_at"` - Name string `json:"name,omitempty" db:"name"` // enriched via JOIN -} - -// ProjectNote represents a note's association with a project. -type ProjectNote struct { - ProjectID string `json:"project_id" db:"project_id"` - NoteID string `json:"note_id" db:"note_id"` - AddedAt string `json:"added_at" db:"added_at"` - Title string `json:"title,omitempty" db:"title"` // enriched via JOIN -} - -// ========================================= -// NOTES -// ========================================= - -type Note struct { - BaseModel - UserID string `json:"user_id" db:"user_id"` - Title string `json:"title" db:"title"` - Content string `json:"content" db:"content"` - FolderPath string `json:"folder_path" db:"folder_path"` - Tags []string `json:"tags,omitempty" db:"tags"` - Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` - SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"` - SourceMessageID *string `json:"source_message_id,omitempty" db:"source_message_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` -} - -// NoteLink represents a directed link extracted from [[wikilink]] syntax. -type NoteLink struct { - TargetNoteID *string `json:"target_note_id,omitempty"` - TargetTitle string `json:"target_title"` - DisplayText string `json:"display_text,omitempty"` - IsTransclusion bool `json:"is_transclusion"` -} - -// NoteLinkResult represents a backlink — a note that links to a given note. -type NoteLinkResult struct { - SourceNoteID string `json:"id"` - Title string `json:"title"` - FolderPath string `json:"folder_path"` - UpdatedAt time.Time `json:"updated_at"` - DisplayText string `json:"display_text,omitempty"` -} - -// NoteGraphNode is a lightweight note representation for graph display. -type NoteGraphNode struct { - ID string `json:"id"` - Title string `json:"title"` - FolderPath string `json:"folder_path"` - Tags []string `json:"tags"` - UpdatedAt string `json:"updated_at"` - LinkCount int `json:"link_count"` -} - -// NoteGraphEdge is a resolved link between two notes. -type NoteGraphEdge struct { - Source string `json:"source"` - Target string `json:"target"` - Title string `json:"title"` - IsTransclusion bool `json:"is_transclusion"` -} - -// NoteGraphDangling is an unresolved [[link]] reference. -type NoteGraphDangling struct { - Source string `json:"source"` - Title string `json:"title"` -} - -// NoteGraph is the full graph topology for a user's notes. -type NoteGraph struct { - Nodes []NoteGraphNode `json:"nodes"` - Edges []NoteGraphEdge `json:"edges"` - Unresolved []NoteGraphDangling `json:"unresolved"` -} - -// ========================================= -// ATTACHMENTS -// ========================================= - -// ========================================= -// FILES -// ========================================= - -// FileOrigin constants -const ( - FileOriginUserUpload = "user_upload" - FileOriginToolOutput = "tool_output" - FileOriginSystem = "system" -) - -// FileDisplayHint constants -const ( - FileHintInline = "inline" - FileHintDownload = "download" - FileHintThumbnail = "thumbnail" -) - -type File struct { - ID string `json:"id" db:"id"` - ChannelID string `json:"channel_id" db:"channel_id"` - MessageID *string `json:"message_id,omitempty" db:"message_id"` - UserID string `json:"user_id" db:"user_id"` - ProjectID *string `json:"project_id,omitempty" db:"project_id"` - Origin string `json:"origin" db:"origin"` // user_upload, tool_output, system - Filename string `json:"filename" db:"filename"` - ContentType string `json:"content_type" db:"content_type"` - SizeBytes int64 `json:"size_bytes" db:"size_bytes"` - StorageKey string `json:"-" db:"storage_key"` // never expose filesystem path - DisplayHint string `json:"display_hint" db:"display_hint"` // inline, download, thumbnail - ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"` - Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - - - -// ========================================= -// AUDIT LOG -// ========================================= - -type AuditEntry struct { - ID string `json:"id" db:"id"` - ActorID *string `json:"actor_id,omitempty" db:"actor_id"` - Action string `json:"action" db:"action"` - ResourceType string `json:"resource_type" db:"resource_type"` - ResourceID string `json:"resource_id,omitempty" db:"resource_id"` - Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` - IPAddress string `json:"ip_address,omitempty" db:"ip_address"` - UserAgent string `json:"user_agent,omitempty" db:"user_agent"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// ========================================= -// USAGE TRACKING -// ========================================= - -type UsageEntry struct { - ID string `json:"id" db:"id"` - ChannelID *string `json:"channel_id,omitempty" db:"channel_id"` - UserID string `json:"user_id" db:"user_id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - ProviderScope string `json:"provider_scope" db:"provider_scope"` - ModelID string `json:"model_id" db:"model_id"` - Role *string `json:"role,omitempty" db:"role"` // null=chat, "utility", "embedding" - PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens" db:"completion_tokens"` - CacheCreationTokens int `json:"cache_creation_tokens" db:"cache_creation_tokens"` - CacheReadTokens int `json:"cache_read_tokens" db:"cache_read_tokens"` - CostInput *float64 `json:"cost_input,omitempty" db:"cost_input"` - CostOutput *float64 `json:"cost_output,omitempty" db:"cost_output"` - RoutingDecision JSONMap `json:"routing_decision,omitempty" db:"routing_decision"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -type UsageAggregate struct { - GroupKey string `json:"group_key"` - Label string `json:"label"` - Requests int `json:"requests"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalCost float64 `json:"total_cost"` -} - -type UsageTotals struct { - Requests int `json:"requests"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalCost float64 `json:"total_cost"` - Period string `json:"period"` -} - -// ========================================= -// MODEL PRICING -// ========================================= - -type PricingEntry struct { - ID string `json:"id" db:"id"` - ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"` - ModelID string `json:"model_id" db:"model_id"` - InputPerM *float64 `json:"input_per_m" db:"input_per_m"` - OutputPerM *float64 `json:"output_per_m" db:"output_per_m"` - CacheCreatePerM *float64 `json:"cache_create_per_m" db:"cache_create_per_m"` - CacheReadPerM *float64 `json:"cache_read_per_m" db:"cache_read_per_m"` - Currency string `json:"currency" db:"currency"` - Source string `json:"source" db:"source"` // "catalog" | "manual" - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - UpdatedBy *string `json:"updated_by,omitempty" db:"updated_by"` -} - -// ========================================= -// VIEW MODELS (computed, not stored) -// ========================================= - -// UserModel is the view model returned by the capability resolver. -// Combines catalog entries + Personas for the frontend. -type UserModel struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - ModelID string `json:"model_id"` - ModelType string `json:"model_type"` // "chat", "embedding", "image", etc. - Source string `json:"source"` // "catalog", "persona", "live" - - ProviderConfigID string `json:"provider_config_id"` - ConfigID string `json:"config_id"` // Alias of ProviderConfigID for frontend compat - ProviderName string `json:"provider_name"` - ProviderType string `json:"provider_type"` - - Capabilities ModelCapabilities `json:"capabilities"` - - // Persona fields — always emitted so frontend can branch on is_persona. - IsPersona bool `json:"is_persona"` - PersonaID string `json:"persona_id,omitempty"` - PersonaScope string `json:"persona_scope,omitempty"` - PersonaAvatar string `json:"persona_avatar,omitempty"` - PersonaTeamName string `json:"persona_team_name,omitempty"` - - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - Avatar string `json:"avatar,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - ToolGrants []string `json:"tool_grants,omitempty"` - - Pricing *ModelPricing `json:"pricing,omitempty"` - - Scope string `json:"scope"` - OwnerID *string `json:"owner_id,omitempty"` - TeamName string `json:"team_name,omitempty"` - - Hidden bool `json:"hidden"` - SortOrder int `json:"sort_order"` - - ProviderStatus ProviderStatus `json:"provider_status,omitempty"` // health status from provider health (v0.22.3) -} - -// ========================================= -// JSON HELPERS -// ========================================= - -// JSONMap scans from/to JSONB columns. -type JSONMap map[string]interface{} - -func (m *JSONMap) Scan(src interface{}) error { - if src == nil { - *m = nil - return nil - } - var source []byte - switch v := src.(type) { - case []byte: - source = v - case string: - source = []byte(v) - default: - return nil - } - result := make(JSONMap) - if err := json.Unmarshal(source, &result); err != nil { - return err - } - *m = result - return nil -} - -// ── Extensions ────────────────────────────── - -// Extension tier constants -const ( - ExtTierBrowser = "browser" - ExtTierStarlark = "starlark" - ExtTierSidecar = "sidecar" -) - -// Extension represents an installed extension in the registry. -type Extension struct { - ID string `json:"id" db:"id"` - ExtID string `json:"ext_id" db:"ext_id"` // manifest id - Name string `json:"name" db:"name"` - Version string `json:"version" db:"version"` - Tier string `json:"tier" db:"tier"` - Description string `json:"description" db:"description"` - Author string `json:"author" db:"author"` - Manifest json.RawMessage `json:"manifest" db:"manifest"` - IsSystem bool `json:"is_system" db:"is_system"` - IsEnabled bool `json:"is_enabled" db:"is_enabled"` - Scope string `json:"scope" db:"scope"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// ExtensionUserSettings stores per-user overrides for an extension. -type ExtensionUserSettings struct { - ExtensionID string `json:"extension_id" db:"extension_id"` - UserID string `json:"user_id" db:"user_id"` - Settings json.RawMessage `json:"settings" db:"settings"` - IsEnabled bool `json:"is_enabled" db:"is_enabled"` -} - -// UserExtension combines extension info with user-specific settings for API responses. -type UserExtension struct { - Extension - UserEnabled *bool `json:"user_enabled,omitempty"` - UserSettings *json.RawMessage `json:"user_settings,omitempty"` -} - -func NullString(s *string) sql.NullString { - if s == nil { - return sql.NullString{} - } - return sql.NullString{String: *s, Valid: true} -} - -func NullFloat(f *float64) sql.NullFloat64 { - if f == nil { - return sql.NullFloat64{} - } - return sql.NullFloat64{Float64: *f, Valid: true} -} - -func NullInt(i *int) sql.NullInt64 { - if i == nil { - return sql.NullInt64{} - } - return sql.NullInt64{Int64: int64(*i), Valid: true} -} - -func StringPtr(s string) *string { return &s } -func Float64Ptr(f float64) *float64 { return &f } -func IntPtr(i int) *int { return &i } -func BoolPtr(b bool) *bool { return &b } - -// ========================================= -// GROUPS (v0.16.0) -// ========================================= - -// Group scope constants (reuses ScopeGlobal, ScopeTeam from above) - -// ResourceType constants for resource_grants -const ( - ResourceTypePersona = "persona" - ResourceTypeKnowledgeBase = "knowledge_base" -) - -// GrantScope constants for resource_grants -const ( - GrantScopeTeamOnly = "team_only" - GrantScopeGlobal = "global" - GrantScopeGroups = "groups" -) - -// Group is an access-control list. Decouples resource visibility from teams. -type Group struct { - BaseModel - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Scope string `json:"scope" db:"scope"` // global, team - TeamID *string `json:"team_id,omitempty" db:"team_id"` - CreatedBy string `json:"created_by" db:"created_by"` - MemberCount int `json:"member_count,omitempty"` // computed, not a DB column -} - -// GroupMember links a user to a group. -type GroupMember struct { - ID string `json:"id" db:"id"` - GroupID string `json:"group_id" db:"group_id"` - UserID string `json:"user_id" db:"user_id"` - AddedBy string `json:"added_by" db:"added_by"` - AddedAt time.Time `json:"added_at" db:"added_at"` - // Joined fields from users table (for list responses) - Username string `json:"username,omitempty"` - Email string `json:"email,omitempty"` - DisplayName string `json:"display_name,omitempty"` -} - -// ResourceGrant controls who can USE a resource (personas, KBs) via groups. -// One row per resource. Not to be confused with persona_grants (what a Persona can DO). -type ResourceGrant struct { - BaseModel - ResourceType string `json:"resource_type" db:"resource_type"` - ResourceID string `json:"resource_id" db:"resource_id"` - GrantScope string `json:"grant_scope" db:"grant_scope"` - GrantedGroups []string `json:"granted_groups" db:"granted_groups"` // UUID array - CreatedBy string `json:"created_by" db:"created_by"` -} - -// ── Knowledge Bases ──────────────────────────── - -// KnowledgeBase is a named collection of documents with vector embeddings. -type KnowledgeBase struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Scope string `json:"scope" db:"scope"` // global, team, personal - OwnerID *string `json:"owner_id,omitempty" db:"owner_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - EmbeddingConfig JSONMap `json:"embedding_config" db:"embedding_config"` - DocumentCount int `json:"document_count" db:"document_count"` - ChunkCount int `json:"chunk_count" db:"chunk_count"` - TotalBytes int64 `json:"total_bytes" db:"total_bytes"` - Discoverable bool `json:"discoverable" db:"discoverable"` - Status string `json:"status" db:"status"` // active, processing, error - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// PersonaKB binds a knowledge base to a persona. -type PersonaKB struct { - PersonaID string `json:"persona_id" db:"persona_id"` - KBID string `json:"kb_id" db:"kb_id"` - AutoSearch bool `json:"auto_search" db:"auto_search"` - AddedAt time.Time `json:"added_at" db:"added_at"` - - // Joined fields (not in persona_knowledge_bases table) - KBName string `json:"kb_name,omitempty" db:"kb_name"` - DocumentCount int `json:"document_count,omitempty" db:"document_count"` - ChunkCount int `json:"chunk_count,omitempty" db:"chunk_count"` -} - -// KBDocument is a single uploaded file within a knowledge base. -type KBDocument struct { - ID string `json:"id" db:"id"` - KBID string `json:"kb_id" db:"kb_id"` - Filename string `json:"filename" db:"filename"` - ContentType string `json:"content_type" db:"content_type"` - SizeBytes int64 `json:"size_bytes" db:"size_bytes"` - StorageKey string `json:"storage_key" db:"storage_key"` - ExtractedText *string `json:"-" db:"extracted_text"` - ChunkCount int `json:"chunk_count" db:"chunk_count"` - Status string `json:"status" db:"status"` // pending, chunking, embedding, ready, error - Error *string `json:"error,omitempty" db:"error"` - UploadedBy string `json:"uploaded_by" db:"uploaded_by"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// KBChunk is a text segment from a document with its embedding vector. -type KBChunk struct { - ID string `json:"id" db:"id"` - KBID string `json:"kb_id" db:"kb_id"` - DocumentID string `json:"document_id" db:"document_id"` - ChunkIndex int `json:"chunk_index" db:"chunk_index"` - Content string `json:"content" db:"content"` - TokenCount int `json:"token_count" db:"token_count"` - Embedding []float64 `json:"-" db:"embedding"` // never serialized to API - Metadata JSONMap `json:"metadata" db:"metadata"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// KBSearchResult is a single result from similarity search. -type KBSearchResult struct { - Content string `json:"content"` - Filename string `json:"source"` - KBName string `json:"kb"` - Similarity float64 `json:"similarity"` - Metadata JSONMap `json:"metadata,omitempty"` -} - -// ChannelKB represents a knowledge base linked to a channel. -type ChannelKB struct { - KBID string `json:"kb_id" db:"kb_id"` - KBName string `json:"kb_name" db:"name"` - Enabled bool `json:"enabled" db:"enabled"` - DocumentCount int `json:"document_count" db:"document_count"` -} - -// ========================================= -// PROVIDER HEALTH (v0.22.0) -// ========================================= - -// ProviderStatus represents the derived health state. -type ProviderStatus string - -const ( - StatusHealthy ProviderStatus = "healthy" - StatusDegraded ProviderStatus = "degraded" - StatusDown ProviderStatus = "down" - StatusUnknown ProviderStatus = "unknown" -) - -// ProviderHealthWindow is one hourly bucket of health metrics. -type ProviderHealthWindow struct { - ID string `json:"id" db:"id"` - ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"` - WindowStart time.Time `json:"window_start" db:"window_start"` - RequestCount int `json:"request_count" db:"request_count"` - ErrorCount int `json:"error_count" db:"error_count"` - TimeoutCount int `json:"timeout_count" db:"timeout_count"` - RateLimitCount int `json:"rate_limit_count" db:"rate_limit_count"` // v0.22.4 - TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"` - MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"` - LastError *string `json:"last_error,omitempty" db:"last_error"` - LastErrorAt *string `json:"last_error_at,omitempty" db:"last_error_at"` -} - -// AvgLatencyMs returns the average latency, or 0 if no requests. -func (w *ProviderHealthWindow) AvgLatencyMs() int { - if w.RequestCount == 0 { - return 0 - } - return int(w.TotalLatencyMs / int64(w.RequestCount)) -} - -// ErrorRate returns the error fraction, or 0 if no requests. -func (w *ProviderHealthWindow) ErrorRate() float64 { - if w.RequestCount == 0 { - return 0 - } - return float64(w.ErrorCount) / float64(w.RequestCount) -} - -// ProviderHealthSummary is the API response for a provider's current health. -type ProviderHealthSummary struct { - ProviderConfigID string `json:"provider_config_id"` - ProviderName string `json:"provider_name,omitempty"` - Status ProviderStatus `json:"status"` - RequestCount int `json:"request_count"` // last hour - ErrorRate float64 `json:"error_rate"` // last hour - ErrorCount int `json:"error_count"` // last hour - RateLimitCount int `json:"rate_limit_count"` // last hour (v0.22.4) - TimeoutCount int `json:"timeout_count"` // last hour - AvgLatencyMs int `json:"avg_latency_ms"` // last hour - MaxLatencyMs int `json:"max_latency_ms"` // last hour - LastError *string `json:"last_error,omitempty"` - LastErrorAt *string `json:"last_error_at,omitempty"` -} - -// ToolHealthWindow tracks health of built-in tools (web_search, url_fetch, etc.) -// in hourly buckets, analogous to ProviderHealthWindow. -type ToolHealthWindow struct { - ID string `json:"id" db:"id"` - ToolName string `json:"tool_name" db:"tool_name"` - WindowStart time.Time `json:"window_start" db:"window_start"` - RequestCount int `json:"request_count" db:"request_count"` - ErrorCount int `json:"error_count" db:"error_count"` - TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"` - MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"` - LastError *string `json:"last_error,omitempty" db:"last_error"` - LastErrorAt *string `json:"last_error_at,omitempty" db:"last_error_at"` -} - -// ToolHealthSummary is the API response for a tool's health. -type ToolHealthSummary struct { - ToolName string `json:"tool_name"` - Status string `json:"status"` // healthy, degraded, down, unknown - RequestCount int `json:"request_count"` - ErrorRate float64 `json:"error_rate"` - AvgLatencyMs int `json:"avg_latency_ms"` -} - -// ========================================= -// CAPABILITY OVERRIDES (v0.22.0) -// ========================================= - -// CapabilityOverride is an admin correction for a model's capabilities. -type CapabilityOverride struct { - ID string `json:"id" db:"id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - ModelID string `json:"model_id" db:"model_id"` - Field string `json:"field" db:"field"` - Value string `json:"value" db:"value"` - SetBy *string `json:"set_by,omitempty" db:"set_by"` - CreatedAt string `json:"created_at" db:"created_at"` -} - -// ========================================= -// ROUTING POLICIES (v0.22.2) -// ========================================= - -// RoutingPolicy is one routing rule that controls how requests are -// dispatched to provider configs. Stored in the routing_policies table. -type RoutingPolicy struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Scope string `json:"scope" db:"scope"` // "global" or "team" - TeamID *string `json:"team_id,omitempty" db:"team_id"` - Priority int `json:"priority" db:"priority"` // lower = first - Type string `json:"policy_type" db:"policy_type"` // provider_prefer, team_route, cost_limit, model_alias - Config JSONMap `json:"config" db:"config"` // type-specific JSONB - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - diff --git a/server/Dockerfile b/server/Dockerfile index 9952534..f279cbf 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -19,7 +19,12 @@ RUN go mod download # Copy source and tidy (resolves any go.sum gaps) COPY server/ . RUN go mod tidy -RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${APP_VERSION}" -o /bin/switchboard . + +# Copy VERSION from repo root — used by ldflags and copied to runtime image. +# If APP_VERSION build-arg was supplied by CI, it takes precedence via ldflags. +# If not, $(cat VERSION) reads the file directly so the binary always has a real version. +COPY VERSION ./ +RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION)" -o /bin/switchboard . # Runtime stage FROM debian:bookworm-slim @@ -31,6 +36,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /bin/switchboard /usr/local/bin/switchboard COPY --from=builder /app/database/migrations /app/database/migrations +# VERSION file at /VERSION — version.go init() reads it as fallback for local/dev runs +COPY --from=builder /app/VERSION /VERSION + # Builtin extensions (seeded into DB on startup) COPY extensions/builtin/ /app/extensions/builtin/ diff --git a/server/database/database.go b/server/database/database.go index 37bc1a6..ea79b7e 100644 --- a/server/database/database.go +++ b/server/database/database.go @@ -17,6 +17,14 @@ import ( // DB is the application-wide database connection pool. var DB *sql.DB +// rawDSN holds the Postgres connection string for LISTEN/NOTIFY consumers. +// Empty when running SQLite. +var rawDSN string + +// DSN returns the raw Postgres connection string, or "" for SQLite. +// Used by the event broadcast layer to open a dedicated listener connection. +func DSN() string { return rawDSN } + // Connect opens a connection pool to the configured database. // Driver is selected by DB_DRIVER env var: "postgres" (default) or "sqlite". // For SQLite the DATABASE_URL is treated as a file path; ":memory:" is supported. @@ -45,6 +53,7 @@ func connectPostgres(dsn string) error { // Log the database name for operational clarity (never log credentials). dbName = extractDBName(dsn) + rawDSN = dsn log.Printf("Connecting to Postgres database %q ...", dbName) var err error diff --git a/server/database/migrations/016_v023_multiuser.sql b/server/database/migrations/016_v023_multiuser.sql new file mode 100644 index 0000000..ea662e5 --- /dev/null +++ b/server/database/migrations/016_v023_multiuser.sql @@ -0,0 +1,46 @@ +-- ========================================== +-- Chat Switchboard — 006 Multi-User: DMs + Channels +-- ========================================== +-- Extends channels with 'dm' and 'channel' types, ai_mode, and topic. +-- Adds presence tracking table. +-- ICD §3 (Channels & Conversations) — v0.23.1 +-- ========================================== + +-- ── Channel type extension ─────────────────────────────────────────── +-- Add 'dm' (human-to-human, AI silent unless @mentioned) +-- Add 'channel' (named persistent space, configurable ai_mode) +-- Existing 'direct', 'group', 'workflow' are unchanged. +ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check; +ALTER TABLE channels ADD CONSTRAINT channels_type_check + CHECK (type IN ('direct', 'dm', 'group', 'channel', 'workflow')); + +-- ── ai_mode ────────────────────────────────────────────────────────── +-- auto : AI responds to every message (existing behavior for 'direct') +-- mention_only : AI silent unless @mentioned (default for 'dm') +-- off : AI disabled entirely +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS ai_mode VARCHAR(20) NOT NULL DEFAULT 'auto' + CHECK (ai_mode IN ('auto', 'mention_only', 'off')); + +-- Back-fill: DM channels should be mention_only +-- (No existing rows should have type='dm' yet, but be safe) +UPDATE channels SET ai_mode = 'mention_only' WHERE type = 'dm' AND ai_mode = 'auto'; + +-- ── topic ───────────────────────────────────────────────────────────── +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS topic TEXT; + +-- ── Presence (in-memory heartbeat, DB stores last_seen for persistence) ── +-- Not queried at high frequency — updated by heartbeat (30s), read on load. +CREATE TABLE IF NOT EXISTS user_presence ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status VARCHAR(20) NOT NULL DEFAULT 'online' + CHECK (status IN ('online', 'away', 'offline')) +); + +CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC); + +COMMENT ON TABLE user_presence IS 'Heartbeat-driven presence. Rows upserted every 30s by active clients.'; +COMMENT ON COLUMN channels.ai_mode IS 'auto=respond always, mention_only=@mention required, off=AI disabled'; +COMMENT ON COLUMN channels.topic IS 'Short channel description shown in header'; diff --git a/server/database/migrations/sqlite/016_v023_multiuser.sql b/server/database/migrations/sqlite/016_v023_multiuser.sql new file mode 100644 index 0000000..3ad9636 --- /dev/null +++ b/server/database/migrations/sqlite/016_v023_multiuser.sql @@ -0,0 +1,23 @@ +-- ========================================== +-- Chat Switchboard — 006 Multi-User: DMs + Channels (SQLite) +-- ========================================== +-- SQLite does not support ALTER CONSTRAINT or DROP CONSTRAINT. +-- The type check is enforced by application logic for SQLite. +-- ========================================== + +-- ── ai_mode ────────────────────────────────────────────────────────── +-- SQLite ignores CHECK constraints on existing rows; we add the column +-- and rely on app-layer validation for the enum values. +ALTER TABLE channels ADD COLUMN ai_mode TEXT NOT NULL DEFAULT 'auto'; + +-- ── topic ───────────────────────────────────────────────────────────── +ALTER TABLE channels ADD COLUMN topic TEXT; + +-- ── Presence ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS user_presence ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_seen DATETIME NOT NULL DEFAULT (datetime('now')), + status TEXT NOT NULL DEFAULT 'online' +); + +CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC); diff --git a/server/events/bus.go b/server/events/bus.go index 444bf47..d4180b6 100644 --- a/server/events/bus.go +++ b/server/events/bus.go @@ -10,9 +10,10 @@ import ( // Handlers subscribe to patterns (exact or trailing wildcard). // Publishing fans out to all matching subscribers. type Bus struct { - mu sync.RWMutex - subs map[string][]*subscription - seq uint64 // subscription ID counter + mu sync.RWMutex + subs map[string][]*subscription + seq uint64 // subscription ID counter + broadcastHook func(Event) // called after Publish for cross-pod fan-out; nil-safe } type subscription struct { @@ -28,6 +29,15 @@ func NewBus() *Bus { } } +// SetBroadcastHook registers a function called after every Publish. +// Used by the Postgres LISTEN/NOTIFY adapter to fan out across pods. +// The hook is NOT called by publishLocal (avoids re-broadcast loops). +func (b *Bus) SetBroadcastHook(fn func(Event)) { + b.mu.Lock() + b.broadcastHook = fn + b.mu.Unlock() +} + // Subscribe registers a handler for a label pattern. // Returns an unsubscribe function. // @@ -60,7 +70,22 @@ func (b *Bus) Subscribe(pattern string, handler Handler) func() { // Publish dispatches an event to all matching subscribers. // Handlers are called synchronously in subscription order. // Use PublishAsync for non-blocking dispatch. +// After local dispatch, calls the broadcastHook if set (Postgres fan-out). func (b *Bus) Publish(event Event) { + b.publishLocal(event) + + b.mu.RLock() + hook := b.broadcastHook + b.mu.RUnlock() + if hook != nil { + hook(event) + } +} + +// publishLocal dispatches to local subscribers only, without triggering +// the broadcastHook. Used by the Postgres listener to re-publish remote +// events without causing an infinite re-broadcast loop. +func (b *Bus) publishLocal(event Event) { b.mu.RLock() var matched []Handler for pattern, subs := range b.subs { @@ -79,6 +104,7 @@ func (b *Bus) Publish(event Event) { // PublishAsync dispatches an event to all matching subscribers // in separate goroutines. Useful for I/O-heavy handlers. +// Also calls the broadcastHook asynchronously if set. func (b *Bus) PublishAsync(event Event) { b.mu.RLock() var matched []Handler @@ -89,11 +115,15 @@ func (b *Bus) PublishAsync(event Event) { } } } + hook := b.broadcastHook b.mu.RUnlock() for _, h := range matched { go h(event) } + if hook != nil { + go hook(event) + } } // match checks if a concrete label matches a subscription pattern. diff --git a/server/events/pg_broadcast.go b/server/events/pg_broadcast.go new file mode 100644 index 0000000..83ab059 --- /dev/null +++ b/server/events/pg_broadcast.go @@ -0,0 +1,130 @@ +package events + +// pg_broadcast.go — Postgres LISTEN/NOTIFY adapter for multi-pod fan-out. +// +// Problem: the in-process Bus is per-pod. With N backend replicas, an event +// published on pod-0 (e.g. a chat message completion) is never seen by clients +// connected to pod-1. +// +// Solution: for every event that should reach clients (DirToClient | DirBoth), +// publish via pg_notify so every pod's listener goroutine receives it and +// re-publishes into its local Bus → Hub → WebSocket clients. +// +// Key invariant: re-published (remote) events use publishLocal, which skips the +// broadcastHook, preventing infinite re-broadcast loops. +// +// No-op when running SQLite (single process, in-process Bus is sufficient). + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/lib/pq" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +const pgNotifyChannel = "switchboard_events" + +// maxNotifyBytes is the practical PG NOTIFY payload limit (8192 byte hard limit, +// leave headroom for encoding overhead). +const maxNotifyBytes = 7900 + +// StartPGBroadcast wires the Bus to Postgres LISTEN/NOTIFY. +// Call once after bus and database are both initialized. +// No-op for SQLite deployments. +func StartPGBroadcast(bus *Bus) { + if !database.IsPostgres() { + log.Println("[pg_broadcast] SQLite mode — skipping cross-pod broadcast") + return + } + + // Start the listener goroutine (auto-reconnects on failure). + go broadcastListenLoop(bus) + + // Hook into the Bus: after every local Publish, fan out to other pods. + bus.SetBroadcastHook(func(e Event) { + // Only events that clients care about need to cross pod boundaries. + dir := RouteFor(e.Label) + if dir == DirLocal { + return + } + + // tool.result.* arrives from a client WS on a specific pod — that pod + // handles WaitFor() locally. No need (or benefit) to broadcast. + if dir == DirFromClient { + return + } + + data, err := json.Marshal(e) + if err != nil { + return + } + if len(data) > maxNotifyBytes { + // Event payload too large for NOTIFY — this should never happen + // in practice (we notify IDs/metadata, not message bodies). + log.Printf("[pg_broadcast] event %q payload %d bytes exceeds limit — skipping broadcast", e.Label, len(data)) + return + } + + if _, err := database.DB.Exec("SELECT pg_notify($1, $2)", pgNotifyChannel, string(data)); err != nil { + log.Printf("[pg_broadcast] pg_notify failed: %v", err) + } + }) + + log.Println("[pg_broadcast] cross-pod broadcast enabled via Postgres LISTEN/NOTIFY") +} + +// broadcastListenLoop runs forever, reconnecting on error. +func broadcastListenLoop(bus *Bus) { + dsn := database.DSN() + for { + if err := runBroadcastListener(bus, dsn); err != nil { + log.Printf("[pg_broadcast] listener error: %v — reconnecting in 5s", err) + } + time.Sleep(5 * time.Second) + } +} + +// runBroadcastListener opens a dedicated pq listener connection and feeds +// received notifications into the local Bus via publishLocal. +// Returns an error when the connection is lost (caller retries). +func runBroadcastListener(bus *Bus, dsn string) error { + reportErr := func(ev pq.ListenerEventType, err error) { + if err != nil { + log.Printf("[pg_broadcast] pq listener event=%d err=%v", ev, err) + } + } + + l := pq.NewListener(dsn, 5*time.Second, time.Minute, reportErr) + if err := l.Listen(pgNotifyChannel); err != nil { + return fmt.Errorf("LISTEN %s: %w", pgNotifyChannel, err) + } + defer l.Close() + + log.Printf("[pg_broadcast] listener ready on channel %q", pgNotifyChannel) + + for { + select { + case n, ok := <-l.Notify: + if !ok { + return fmt.Errorf("notify channel closed") + } + if n == nil { + // Keepalive ping from pq — ignore. + continue + } + + var e Event + if err := json.Unmarshal([]byte(n.Extra), &e); err != nil { + log.Printf("[pg_broadcast] malformed notification: %v", err) + continue + } + + // publishLocal bypasses the broadcastHook — no re-broadcast loop. + bus.publishLocal(e) + } + } +} diff --git a/server/handlers/channels.go b/server/handlers/channels.go index a7510b5..b210eb5 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -140,6 +140,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { archived := c.DefaultQuery("archived", "false") folder := c.Query("folder") channelType := c.DefaultQuery("type", "") // empty = all types + channelTypes := c.QueryArray("types") // v0.23.1: multi-value, e.g. ?types=dm&types=channel + // Comma-joined convenience: ?types=dm,channel + if len(channelTypes) == 0 && c.Query("types") != "" { + channelTypes = strings.Split(c.Query("types"), ",") + } search := strings.TrimSpace(c.Query("search")) projectFilter := c.Query("project_id") // "uuid" or "none" @@ -148,7 +153,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { countArgs := []interface{}{userID, archived == "true"} argN := 3 - if channelType != "" { + if len(channelTypes) > 0 { + placeholders := make([]string, len(channelTypes)) + for i, t := range channelTypes { + placeholders[i] = "$" + strconv.Itoa(argN) + countArgs = append(countArgs, strings.TrimSpace(t)) + argN++ + } + countQuery += " AND type IN (" + strings.Join(placeholders, ",") + ")" + } else if channelType != "" { countQuery += ` AND type = $` + strconv.Itoa(argN) countArgs = append(countArgs, channelType) argN++ @@ -193,7 +206,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { args := []interface{}{userID, archived == "true"} argN = 3 - if channelType != "" { + if len(channelTypes) > 0 { + placeholders := make([]string, len(channelTypes)) + for i, t := range channelTypes { + placeholders[i] = "$" + strconv.Itoa(argN) + args = append(args, strings.TrimSpace(t)) + argN++ + } + query += " AND c.type IN (" + strings.Join(placeholders, ",") + ")" + } else if channelType != "" { query += ` AND c.type = $` + strconv.Itoa(argN) args = append(args, channelType) argN++ diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 065b345..e5e6159 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -209,6 +209,27 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } } + // ── ai_mode guard (v0.23.1) ──────────────────────────────────────────────── + // Check channel ai_mode before doing any work. For DM channels with + // mention_only mode and no @mention in the content, persist the message + // but skip the completion entirely. + { + var aiMode string + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1 + `), channelID).Scan(&aiMode) + + if aiMode == "off" { + c.JSON(http.StatusForbidden, gin.H{"error": "AI responses are disabled for this channel"}) + return + } + if aiMode == "mention_only" && extractFirstMention(req.Content) == "" { + // Deliver message without triggering completion + c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true}) + return + } + } + // ── Persona unwrap: persona overrides defaults, explicit request fields win ── var personaSystemPrompt string var personaID string // tracks active persona for KB scoping @@ -386,10 +407,26 @@ func (h *CompletionHandler) Complete(c *gin.Context) { // Resolve effective workspace: channel.workspace_id > project.workspace_id workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) - // ── @mention routing (v0.23.0) ────────────── - // Resolve @model-id or @persona-handle directly against the enabled - // model catalog and personas table. Works in ANY chat — no roster needed. - if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" { + // ── @mention routing (v0.23.0 / v0.23.1) ───────────────────────────────── + // Resolve @persona-handle, @model-id, or @username. + // - User mention → skip completion, notify recipient (v0.23.1) + // - AI mention → override model/persona for this turn (v0.23.0) + mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content) + + if mentionedUserID != "" { + // Human @mention — message already persisted; notify, skip AI. + if hub, ok := c.Get("events_hub"); ok { + if h2, ok2 := hub.(interface { + NotifyUserMention(toUser, channel, fromUser string) + }); ok2 { + h2.NotifyUserMention(mentionedUserID, channelID, userID) + } + } + c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID}) + return + } + + if mentionModel != "" { req.Model = mentionModel if mentionConfig != "" { req.ProviderConfigID = mentionConfig @@ -1127,15 +1164,20 @@ func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPerso // // Resolution order: // 1. Persona handle (exact match, then prefix) -// 2. Model ID in enabled catalog (exact match, then prefix) +// 2. User handle/username (exact, then prefix) — v0.23.1 +// When a user is @mentioned in a DM/channel, skip completion and deliver +// a notification instead. Returns non-empty mentionedUserID in that case. +// 3. Model ID in enabled catalog (exact match, then prefix) // -// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found. +// Returns (modelID, providerConfigID, *Persona, mentionedUserID). +// Exactly one of (modelID, mentionedUserID) will be non-empty on a successful +// match, or all empty if no @mention found/resolved. -func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) { +func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona, string) { // Extract first @token token := extractFirstMention(content) if token == "" { - return "", "", nil + return "", "", nil, "" } // Normalize: lowercase, hyphens @@ -1155,7 +1197,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content cfgID = *p.ProviderConfigID } log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID) - return p.BaseModelID, cfgID, p + return p.BaseModelID, cfgID, p, "" } } @@ -1177,13 +1219,42 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content cfgID = *p.ProviderConfigID } log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID) - return p.BaseModelID, cfgID, p + return p.BaseModelID, cfgID, p, "" } } } } - // 3. Try model_id in enabled catalog (exact) + // 3. Try username (exact match) — v0.23.1 + // Only resolves to a user if the caller is not the same user (no self-mention) + var mentionedUserID string + err = database.DB.QueryRowContext(ctx, database.Q(` + SELECT id FROM users + WHERE LOWER(username) = $1 AND id != $2 AND is_approved = true + LIMIT 1 + `), normalized, userID).Scan(&mentionedUserID) + if err == nil && mentionedUserID != "" { + log.Printf("[mention] @%s → user %s", token, mentionedUserID) + return "", "", nil, mentionedUserID + } + + // 4. Try username (prefix — unambiguous only) — v0.23.1 + var userCount int + database.DB.QueryRowContext(ctx, database.Q(` + SELECT COUNT(*) FROM users + WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true + `), normalized+"%", userID).Scan(&userCount) + if userCount == 1 { + database.DB.QueryRowContext(ctx, database.Q(` + SELECT id FROM users + WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true + LIMIT 1 + `), normalized+"%", userID).Scan(&mentionedUserID) + if mentionedUserID != "" { + log.Printf("[mention] @%s → user prefix %s", token, mentionedUserID) + return "", "", nil, mentionedUserID + } + } var modelID, provConfigID string err = database.DB.QueryRowContext(ctx, database.Q(` SELECT mc.model_id, mc.provider_config_id @@ -1198,10 +1269,10 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content `), normalized).Scan(&modelID, &provConfigID) if err == nil && modelID != "" { log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID) - return modelID, provConfigID, nil + return modelID, provConfigID, nil, "" } - // 4. Try model_id prefix (unambiguous) + // 5. Try model_id prefix (unambiguous) var prefixCount int database.DB.QueryRowContext(ctx, database.Q(` SELECT COUNT(DISTINCT mc.model_id) @@ -1224,11 +1295,11 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content `), normalized+"%").Scan(&modelID, &provConfigID) if modelID != "" { log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID) - return modelID, provConfigID, nil + return modelID, provConfigID, nil, "" } } - return "", "", nil + return "", "", nil, "" } // extractFirstMention finds the first @token in content. diff --git a/server/handlers/completion_chain.go b/server/handlers/completion_chain.go index 3a1638a..83852eb 100644 --- a/server/handlers/completion_chain.go +++ b/server/handlers/completion_chain.go @@ -49,7 +49,7 @@ func (h *CompletionHandler) chainIfMentioned( log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth) // Use the same resolveMention() that handles user @mentions - mentionModel, mentionConfig, mentionPersona := h.resolveMention(ctx, userID, responseContent) + mentionModel, mentionConfig, mentionPersona, _ := h.resolveMention(ctx, userID, responseContent) if mentionModel == "" { log.Printf("[chain] @%s did not resolve to any model", token) return diff --git a/server/handlers/folders.go b/server/handlers/folders.go new file mode 100644 index 0000000..9c965ad --- /dev/null +++ b/server/handlers/folders.go @@ -0,0 +1,146 @@ +package handlers + +// folders.go — Chat folder CRUD (v0.23.1) +// +// Folders are user-scoped groupings for personal (direct) chats. +// Routes: +// GET /api/v1/folders +// POST /api/v1/folders +// PUT /api/v1/folders/:id +// DELETE /api/v1/folders/:id + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +type FolderHandler struct{} + +func NewFolderHandler() *FolderHandler { return &FolderHandler{} } + +type folderRow struct { + ID string `json:"id"` + Name string `json:"name"` + ParentID *string `json:"parent_id,omitempty"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (h *FolderHandler) List(c *gin.Context) { + userID := getUserID(c) + rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` + SELECT id, name, parent_id, sort_order, created_at, updated_at + FROM folders + WHERE user_id = $1 + ORDER BY sort_order, name + `), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) + return + } + defer rows.Close() + + var folders []folderRow + for rows.Next() { + var f folderRow + if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt); err != nil { + continue + } + folders = append(folders, f) + } + if folders == nil { + folders = []folderRow{} + } + c.JSON(http.StatusOK, gin.H{"folders": folders}) +} + +func (h *FolderHandler) Create(c *gin.Context) { + userID := getUserID(c) + var req struct { + Name string `json:"name" binding:"required"` + SortOrder int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + var f folderRow + err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` + INSERT INTO folders (user_id, name, sort_order) + VALUES ($1, $2, $3) + RETURNING id, name, parent_id, sort_order, created_at, updated_at + `), userID, req.Name, req.SortOrder).Scan( + &f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"}) + return + } + c.JSON(http.StatusCreated, gin.H{"folder": f}) +} + +func (h *FolderHandler) Update(c *gin.Context) { + userID := getUserID(c) + folderID := c.Param("id") + var req struct { + Name string `json:"name"` + SortOrder *int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Name != "" { + req.Name = strings.TrimSpace(req.Name) + } + + res, err := database.DB.ExecContext(c.Request.Context(), database.Q(` + UPDATE folders + SET name = COALESCE(NULLIF($3, ''), name), + sort_order = COALESCE($4, sort_order) + WHERE id = $1 AND user_id = $2 + `), folderID, userID, req.Name, req.SortOrder) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"}) + return + } + if n, _ := res.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *FolderHandler) Delete(c *gin.Context) { + userID := getUserID(c) + folderID := c.Param("id") + + // Unassign chats before deleting folder + _, _ = database.DB.ExecContext(c.Request.Context(), database.Q(` + UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2 + `), folderID, userID) + + res, err := database.DB.ExecContext(c.Request.Context(), database.Q(` + DELETE FROM folders WHERE id = $1 AND user_id = $2 + `), folderID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"}) + return + } + if n, _ := res.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/server/handlers/presence.go b/server/handlers/presence.go new file mode 100644 index 0000000..04fa959 --- /dev/null +++ b/server/handlers/presence.go @@ -0,0 +1,69 @@ +package handlers + +// presence.go — Heartbeat upsert and status query (v0.23.1) +// +// Clients POST /api/v1/presence/heartbeat every 30s while active. +// GET /api/v1/presence?users=id1,id2 returns current status. +// Online = last_seen within 90s. + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +const presenceOnlineThreshold = 90 * time.Second + +// PresenceHeartbeat upserts the calling user's last_seen timestamp. +func PresenceHeartbeat(c *gin.Context) { + userID := getUserID(c) + _, err := database.DB.ExecContext(c.Request.Context(), database.Q(` + INSERT INTO user_presence (user_id, last_seen, status) + VALUES ($1, NOW(), 'online') + ON CONFLICT (user_id) DO UPDATE + SET last_seen = NOW(), status = 'online' + `), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "presence update failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// PresenceQuery returns online/offline status for a list of user IDs. +// Query param: ?users=uuid1,uuid2,... +func PresenceQuery(c *gin.Context) { + raw := c.Query("users") + if raw == "" { + c.JSON(http.StatusOK, gin.H{"presence": map[string]string{}}) + return + } + ids := strings.Split(raw, ",") + if len(ids) > 100 { + ids = ids[:100] + } + + threshold := time.Now().Add(-presenceOnlineThreshold) + + result := make(map[string]string, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + var lastSeen time.Time + err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT last_seen FROM user_presence WHERE user_id = $1 + `), id).Scan(&lastSeen) + if err != nil || lastSeen.Before(threshold) { + result[id] = "offline" + } else { + result[id] = "online" + } + } + c.JSON(http.StatusOK, gin.H{"presence": result}) +} diff --git a/server/main.go b/server/main.go index 721ed2c..4c7e05d 100644 --- a/server/main.go +++ b/server/main.go @@ -198,6 +198,10 @@ func main() { // ── EventBus (created early — needed by role resolver) ── bus := events.NewBus() + // Wire Postgres LISTEN/NOTIFY for cross-pod event fan-out (multi-replica). + // No-op when running SQLite — in-process Bus is sufficient for single-pod. + events.StartPGBroadcast(bus) + // ── Workspace FS (v0.21.0) ────────────── // Provides file operations for workspace storage primitive. // Nil-safe: handler checks wfs != nil before operating. @@ -373,6 +377,17 @@ func main() { protected.PUT("/channels/:id", channels.UpdateChannel) protected.DELETE("/channels/:id", channels.DeleteChannel) + // Chat Folders (v0.23.1) + folders := handlers.NewFolderHandler() + protected.GET("/folders", folders.List) + protected.POST("/folders", folders.Create) + protected.PUT("/folders/:id", folders.Update) + protected.DELETE("/folders/:id", folders.Delete) + + // Presence (v0.23.1) + protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat) + protected.GET("/presence", handlers.PresenceQuery) + // Channel models (v0.20.0 — multi-model @mention routing) chModelH := handlers.NewChannelModelHandler(stores) protected.GET("/channels/:id/models", chModelH.List) diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html index 6bda5b7..cfa95f3 100644 --- a/server/pages/templates/surfaces/chat.html +++ b/server/pages/templates/surfaces/chat.html @@ -88,35 +88,11 @@ window.addEventListener('unhandledrejection', function(e) { - {{/* New Chat (split button) */}} -
- - -
- - - - -
-
+ {{/* New Chat button */}} + {{/* Search (outside sidebar-top, below brand section) */}} @@ -132,22 +108,72 @@ window.addEventListener('unhandledrejection', function(e) { {{/* Sidebar Tabs (Chats / Files) */}} + {{/* Sidebar Tabs (kept hidden — only Files tab used by EditorMode) */}} - {{/* Chat list */}} - `; + + document.body.appendChild(overlay); + const nameEl = overlay.querySelector('#sbDialogName'); + const okBtn = overlay.querySelector('#sbDialogOK'); + const cancelEl = overlay.querySelector('#sbDialogCancel'); + setTimeout(() => { nameEl.focus(); nameEl.select(); }, 30); + + function done(result) { + overlay.remove(); + resolve(result); + } + + okBtn.addEventListener('click', () => { + const name = nameEl.value.trim(); + if (!name) { nameEl.focus(); return; } + const color = withColor ? overlay.querySelector('#sbDialogColor').value : null; + done({ name, color }); + }); + cancelEl.addEventListener('click', () => done(null)); + overlay.addEventListener('click', e => { if (e.target === overlay) done(null); }); + nameEl.addEventListener('keydown', e => { + if (e.key === 'Enter') okBtn.click(); + if (e.key === 'Escape') done(null); + }); + }); +} // ── Project CRUD ──────────────────────────── async function createProject() { - const name = prompt('Project name:'); - if (!name || !name.trim()) return; + const result = await _showCreationDialog({ + title: 'New Project', + placeholder: 'Project name', + withColor: true, + }); + if (!result) return; try { - const p = await API.createProject({ name: name.trim() }); + const p = await API.createProject({ name: result.name, color: result.color }); App.projects.push({ id: p.id, name: p.name, description: p.description || '', - color: p.color || null, + color: p.color || result.color || null, icon: p.icon || null, channelCount: 0, kbCount: 0, noteCount: 0, isArchived: false, @@ -332,7 +384,7 @@ function showProjectMenu(e, projectId) { menu.className = 'project-ctx-menu'; menu.innerHTML = ` +
+ `; + + menu.style.left = Math.min(e.clientX, window.innerWidth - 160) + 'px'; + menu.style.top = Math.min(e.clientY, window.innerHeight - 100) + 'px'; + document.body.appendChild(menu); + _ctxMenu = menu; + setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); +} + +async function renameFolder(folderId) { + const folder = (App.folders || []).find(f => f.id === folderId); + if (!folder) return; + const result = await _showCreationDialog({ + title: 'Rename Folder', + placeholder: 'Folder name', + withColor: false, + defaultValue: folder.name, + }); + if (!result || !result.name.trim() || result.name.trim() === folder.name) return; + try { + await API.updateFolder(folderId, { name: result.name.trim() }); + folder.name = result.name.trim(); + if (typeof UI !== 'undefined') UI.renderChatList(); + UI.toast('Folder renamed', 'success'); + } catch (e) { + UI.toast('Failed to rename folder', 'error'); + console.error('[renameFolder]', e); + } +} + +async function deleteFolder(folderId) { + const folder = (App.folders || []).find(f => f.id === folderId); + if (!folder) return; + // Move any chats in this folder back to unfiled before deleting + (App.chats || []).forEach(c => { if (c.folderId === folderId) c.folderId = null; }); + try { + await API.deleteFolder(folderId); + App.folders = App.folders.filter(f => f.id !== folderId); + if (typeof UI !== 'undefined') UI.renderChatList(); + UI.toast('Folder deleted', 'success'); + } catch (e) { + UI.toast('Failed to delete folder', 'error'); + console.error('[deleteFolder]', e); + } +} + +// ── Presence heartbeat ──────────────────────────────────────────────── + +(function startPresenceHeartbeat() { + const INTERVAL = 30000; // 30s + async function beat() { + if (!API.isAuthed) return; + try { + await API.presenceHeartbeat(); + } catch (_) { /* non-critical */ } + } + setInterval(beat, INTERVAL); +})(); diff --git a/src/js/ui-core.js b/src/js/ui-core.js index e51972b..5412ad6 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -282,167 +282,268 @@ const UI = { document.getElementById('userFlyout').classList.remove('open'); }, - // ── Chat List ──────────────────────────── + // ── Sidebar Section Collapse ────────────── + // State persisted in localStorage per section key. - renderChatList() { - const el = document.getElementById('chatHistory'); - const searchInput = document.getElementById('chatSearchInput'); - const searchWrap = document.getElementById('sidebarSearch'); - const query = (searchInput?.value || '').trim().toLowerCase(); + _sidebarSections: null, - // Toggle clear button visibility - if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0); - - // Filter chats by search query - let chats = App.chats; - if (query) { - chats = chats.filter(c => - (c.title || '').toLowerCase().includes(query) - ); + _getSidebarSections() { + if (!this._sidebarSections) { + try { + this._sidebarSections = JSON.parse(localStorage.getItem('cs-sidebar-sections') || '{}'); + } catch (_) { this._sidebarSections = {}; } } + return this._sidebarSections; + }, + + toggleSidebarSection(key) { + const sections = this._getSidebarSections(); + sections[key] = !sections[key]; // true = collapsed + try { localStorage.setItem('cs-sidebar-sections', JSON.stringify(sections)); } catch (_) {} + this._applySidebarSection(key, sections[key]); + }, + + _applySidebarSection(key, collapsed) { + const body = document.getElementById('sbBody' + key[0].toUpperCase() + key.slice(1)); + const arrow = document.getElementById('sbArrow' + key[0].toUpperCase() + key.slice(1)); + if (body) body.style.display = collapsed ? 'none' : ''; + if (arrow) arrow.textContent = collapsed ? '▸' : '▾'; + }, + + restoreSidebarSections() { + const sections = this._getSidebarSections(); + ['projects', 'channels', 'chats'].forEach(k => { + this._applySidebarSection(k, !!sections[k]); + }); + }, + + // ── Projects Section ────────────────────── + + renderProjectsSection() { + const el = document.getElementById('sbBodyProjects'); + if (!el) return; const projects = App.projects || []; + const showArchived = App.showArchivedProjects || false; + const visible = showArchived ? projects : projects.filter(p => !p.isArchived); + const hasArchived = projects.some(p => p.isArchived); + const sidebar = document.getElementById('sidebar'); + const collapsed = sidebar?.classList.contains('collapsed'); - if (chats.length === 0 && projects.length === 0 && query) { - el.innerHTML = ``; - return; - } - if (chats.length === 0 && projects.length === 0) { - el.innerHTML = ''; + if (visible.length === 0 && !hasArchived) { + el.innerHTML = collapsed ? '' : + '
No projects yet
'; return; } let html = ''; + visible.forEach(proj => { + const isCollapsed = App.collapsedProjects?.[proj.id]; + const isActive = App.activeProjectId === proj.id; + const dot = proj.color + ? `` + : ''; + const arrow = isCollapsed ? '▸' : '▾'; + const pinIcon = isActive ? '📍' : ''; + const chats = (App.chats || []).filter(c => c.projectId === proj.id); - // ── Chat item renderer ────────────────── - const renderChatItem = (c) => { - const time = _relativeTime(c.updatedAt); - const typeIcon = c.type === 'group' ? '👥 ' - : c.type === 'workflow' ? ' ' : ''; - return ` -
+
+ ${collapsed ? dot : `${arrow}${dot}${esc(proj.name)}${pinIcon}`} + ${!collapsed ? `${chats.length} + ` : ''} +
`; + + if (!isCollapsed && !collapsed) { + if (chats.length === 0) { + html += '
Drop chats here
'; + } else { + chats.forEach(c => { html += this._chatItemHTML(c); }); + } + } + html += '
'; + }); + + if (hasArchived && !collapsed) { + html += ``; + } + + el.innerHTML = html; + }, + + // ── Channels Section ────────────────────── + + renderChannelsSection() { + const el = document.getElementById('sbBodyChannels'); + if (!el) return; + + const channels = App.channels || []; + const sidebar = document.getElementById('sidebar'); + const collapsed = sidebar?.classList.contains('collapsed'); + + if (channels.length === 0) { + el.innerHTML = collapsed ? '' : + '
No channels yet
'; + return; + } + + let html = ''; + channels.forEach(ch => { + const isActive = App.currentChannelId === ch.id; + const isDM = ch.type === 'dm'; + const isChannel = ch.type === 'channel'; + const online = App.presence?.[ch.dmPartnerId] === 'online'; + + // Icon: @ for DM, # for channel + const icon = isDM + ? `` + : ``; + + const onlineDot = isDM && online + ? '' + : ''; + const unreadBadge = ch.unread > 0 + ? `${ch.unread > 99 ? '99+' : ch.unread}` + : ''; + + html += `
+ ${icon} + ${!collapsed ? `${esc(ch.name)}${onlineDot}${unreadBadge}` : unreadBadge} +
`; + }); + + el.innerHTML = html; + }, + + // ── Chats Section (renderChatList) ──────── + + renderChatList() { + // Target the new section body; fall back to legacy id during transition. + const el = document.getElementById('sbBodyChats') || document.getElementById('chatHistory'); + if (!el) return; + + const searchInput = document.getElementById('chatSearchInput'); + const searchWrap = document.getElementById('sidebarSearch'); + const query = (searchInput?.value || '').trim().toLowerCase(); + if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0); + + // Chats section shows only direct/group chats not in a project. + // type='channel' and type='dm' belong to the Channels section (App.channels). + // Project chats rendered by renderProjectsSection. + let chats = (App.chats || []).filter(c => + !c.projectId && + c.type !== 'channel' && + c.type !== 'dm' + ); + if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query)); + + const folders = App.folders || []; + + if (chats.length === 0 && folders.length === 0) { + el.innerHTML = query + ? `
No chats matching "${esc(query)}"
` + : '
No chats yet
'; + // Still re-render projects in case search is cross-cutting + this.renderProjectsSection(); + return; + } + + // Group by folder, then by time for unfiled + const folderMap = {}; + folders.forEach(f => { folderMap[f.id] = []; }); + + const unfiled = []; + chats.forEach(c => { + if (c.folderId && folderMap[c.folderId]) { + folderMap[c.folderId].push(c); + } else { + unfiled.push(c); + } + }); + + let html = ''; + + // Folder groups — always render, even when empty + folders.forEach(f => { + const items = folderMap[f.id] || []; + const isOpen = !App.collapsedFolders?.[f.id]; + html += `
+
+ + + + ${esc(f.name)} + ${isOpen ? '▾' : '▸'} + +
+ ${isOpen + ? (items.length > 0 + ? items.map(c => this._chatItemHTML(c, true)).join('') + : '
Drop chats here
') + : ''} +
`; + }); + + // Unfiled chats grouped by time + const now = Date.now(); + const DAY = 86400000; + const groups = { today: [], yesterday: [], week: [], older: [] }; + unfiled.forEach(c => { + const age = now - new Date(c.updatedAt).getTime(); + if (age < DAY) groups.today.push(c); + else if (age < 2*DAY) groups.yesterday.push(c); + else if (age < 7*DAY) groups.week.push(c); + else groups.older.push(c); + }); + + const renderTimeGroup = (label, items) => { + if (!items.length) return; + html += `
${label}
`; + items.forEach(c => { html += this._chatItemHTML(c); }); + }; + + renderTimeGroup('Today', groups.today); + renderTimeGroup('Yesterday', groups.yesterday); + renderTimeGroup('Previous 7 days', groups.week); + renderTimeGroup('Older', groups.older); + + el.innerHTML = html; + + // Always keep projects section in sync + this.renderProjectsSection(); + }, + + toggleFolder(folderId) { + if (!App.collapsedFolders) App.collapsedFolders = {}; + App.collapsedFolders[folderId] = !App.collapsedFolders[folderId]; + this.renderChatList(); + }, + + _chatItemHTML(c, indented = false) { + const time = _relativeTime(c.updatedAt); + const active = c.id === App.currentChatId ? ' active' : ''; + const indent = indented ? ' indented' : ''; + const typeIcon = c.type === 'group' + ? '👥 ' + : c.type === 'workflow' ? ' ' : ''; + return `
- ${typeIcon}${esc(c.title)} - ${time} - -
`; - }; - - // ── Project groups ────────────────────── - const showArchived = App.showArchivedProjects || false; - const visibleProjects = showArchived ? projects : projects.filter(p => !p.isArchived); - const hasArchived = projects.some(p => p.isArchived); - - if (visibleProjects.length > 0) { - visibleProjects.forEach(proj => { - const projChats = typeof _getReorderedProjectChats === 'function' - ? _getReorderedProjectChats(proj.id, chats) - : chats.filter(c => c.projectId === proj.id); - if (projChats.length === 0 && query) return; // hide empty projects during search - const isCollapsed = App.collapsedProjects[proj.id]; - const colorDot = proj.color - ? `` - : ''; - const arrow = isCollapsed ? '▸' : '▾'; - - const isActive = App.activeProjectId === proj.id; - - const groupClasses = 'project-group' + (isActive ? ' active' : '') + (proj.isArchived ? ' archived' : ''); - - html += `
-
- ${arrow} - ${colorDot} - ${esc(proj.name)} - ${isActive ? '📌' : ''} - ${projChats.length} - -
`; - - if (!isCollapsed) { - if (projChats.length === 0) { - html += '
Drop chats here
'; - } else { - projChats.forEach(c => { html += renderChatItem(c); }); - } - } - html += '
'; - }); - - // "Show archived" toggle - if (hasArchived) { - html += ``; - } - } - - // ── Recent (unassigned) chats ─────────── - // Include chats from archived-and-hidden projects so they don't vanish - const hiddenProjectIds = new Set( - projects.filter(p => p.isArchived && !showArchived).map(p => p.id) - ); - const recentChats = chats.filter(c => !c.projectId || hiddenProjectIds.has(c.projectId)); - - if (recentChats.length > 0 || visibleProjects.length > 0) { - // Only show "Recent" label if there are also projects - if (visibleProjects.length > 0) { - html += `
-
Recent
`; - if (recentChats.length === 0) { - html += '
Drop chats here to unassign
'; - } - } - } - - // Group recent by time - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today - 86400000); - const weekAgo = new Date(today - 7 * 86400000); - - const groups = { today: [], yesterday: [], week: [], older: [] }; - - recentChats.forEach(c => { - const d = new Date(c.updatedAt); - if (d >= today) groups.today.push(c); - else if (d >= yesterday) groups.yesterday.push(c); - else if (d >= weekAgo) groups.week.push(c); - else groups.older.push(c); - }); - - const renderGroup = (label, items) => { - if (items.length === 0) return; - if (visibleProjects.length > 0) { - html += `
${label}
`; - } else { - html += `
${label}
`; - } - items.forEach(c => { html += renderChatItem(c); }); - }; - - renderGroup('Today', groups.today); - renderGroup('Yesterday', groups.yesterday); - renderGroup('Previous 7 days', groups.week); - renderGroup('Older', groups.older); - - if (visibleProjects.length > 0 && recentChats.length > 0) { - html += '
'; // close recent-section - } else if (visibleProjects.length > 0 && recentChats.length === 0) { - html += ''; - } - - el.innerHTML = html; + ${typeIcon}${esc(c.title)} + ${time} + + `; }, // ── Messages ───────────────────────────── diff --git a/src/js/ui-primitives.js b/src/js/ui-primitives.js index c3630e8..5dc4975 100644 --- a/src/js/ui-primitives.js +++ b/src/js/ui-primitives.js @@ -792,6 +792,74 @@ function showConfirm(message, opts = {}) { }); } +// §8b PROMPT DIALOG +// ══════════════════════════════════════════════════════════════════════════════ + +/** + * Custom prompt dialog replacing native prompt(). + * Returns a Promise — null means cancelled or empty. + * + * @param {Object} opts + * @param {string} [opts.title='Enter value'] + * @param {string} [opts.label] - Optional helper text below title + * @param {string} [opts.value=''] - Pre-filled value + * @param {string} [opts.placeholder=''] + * @param {string} [opts.ok='OK'] + * @param {string} [opts.cancel='Cancel'] + * @param {string} [opts.inputType='text'] + * @returns {Promise} + */ +function showPrompt(opts = {}) { + return new Promise(resolve => { + const title = opts.title || 'Enter value'; + const label = opts.label || ''; + const defVal = opts.value || ''; + const ph = opts.placeholder || ''; + const okText = opts.ok || 'OK'; + const caText = opts.cancel || 'Cancel'; + const iType = opts.inputType || 'text'; + + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + overlay.innerHTML = ` +
+
${esc(title)}
+ ${label ? `
${esc(label)}
` : ''} +
+ +
+ +
`; + + const input = overlay.querySelector('.prompt-input'); + + function close(result) { + overlay.remove(); + document.removeEventListener('keydown', onKey); + resolve(result); + } + + function onKey(e) { + if (e.key === 'Escape') { close(null); } + if (e.key === 'Enter') { const v = input.value.trim(); close(v || null); } + } + + overlay.querySelector('[data-action="ok"]').addEventListener('click', () => { + close(input.value.trim() || null); + }); + overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(null)); + overlay.addEventListener('click', e => { if (e.target === overlay) close(null); }); + document.addEventListener('keydown', onKey); + + document.body.appendChild(overlay); + requestAnimationFrame(() => { input.focus(); if (defVal) input.select(); }); + }); +} + // ── Modal open/close ──────────────────────── // Generic modal show/hide used by settings, admin, debug, team-admin modals. // Moved here from app.js so all surfaces (not just chat) can use modals. diff --git a/store/interfaces.go b/store/interfaces.go deleted file mode 100644 index cf8fa60..0000000 --- a/store/interfaces.go +++ /dev/null @@ -1,640 +0,0 @@ -package store - -import ( - "context" - "time" - - "git.gobha.me/xcaliber/chat-switchboard/models" -) - -// ========================================= -// STORES — Data Access Layer -// ========================================= -// Every database operation goes through these interfaces. -// Handlers never touch SQL directly. This makes the DB -// portable (Postgres today, SQLite/MySQL later) and -// handlers testable with in-memory implementations. -// ========================================= - -// Stores bundles all store interfaces for dependency injection. -type Stores struct { - Providers ProviderStore - Catalog CatalogStore - Personas PersonaStore - Policies PolicyStore - UserSettings UserModelSettingsStore - Users UserStore - Teams TeamStore - Channels ChannelStore - Messages MessageStore - Audit AuditStore - Notes NoteStore - NoteLinks NoteLinkStore - GlobalConfig GlobalConfigStore - Usage UsageStore - Pricing PricingStore - Extensions ExtensionStore - Files FileStore - KnowledgeBases KnowledgeBaseStore - Groups GroupStore - ResourceGrants ResourceGrantStore - Memories MemoryStore - Projects ProjectStore - Notifications NotificationStore - NotifPrefs NotificationPreferenceStore - Workspaces WorkspaceStore - GitCredentials GitCredentialStore - CapOverrides CapabilityOverrideStore - RoutingPolicies RoutingPolicyStore -} - -// ========================================= -// PROVIDER STORE -// ========================================= - -type ProviderStore interface { - Create(ctx context.Context, cfg *models.ProviderConfig) error - GetByID(ctx context.Context, id string) (*models.ProviderConfig, error) - Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error - Delete(ctx context.Context, id string) error - - // Scoped queries - ListGlobal(ctx context.Context) ([]models.ProviderConfig, error) - ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) - ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) // personal scope - ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) // all user can access - - // Access check - UserCanAccess(ctx context.Context, userID, configID string) (bool, error) -} - -// ========================================= -// CATALOG STORE -// ========================================= - -type CatalogStore interface { - // Sync from provider API - UpsertFromSync(ctx context.Context, providerConfigID string, entries []CatalogSyncEntry) (added, updated int, err error) - - // Queries - GetByID(ctx context.Context, id string) (*models.CatalogEntry, error) - GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error) - GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) // any provider, most recently synced - ListVisible(ctx context.Context) ([]models.CatalogEntry, error) - ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) - ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) - ListAll(ctx context.Context) ([]models.CatalogEntry, error) // everything (internal use) - ListAllGlobal(ctx context.Context) ([]models.CatalogEntry, error) // admin view — global-scope providers only - - // Visibility management - SetVisibility(ctx context.Context, id string, visibility string) error - BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error - BulkSetVisibilityAll(ctx context.Context, visibility string) error - - // Delete - Delete(ctx context.Context, id string) error - DeleteForProvider(ctx context.Context, providerConfigID string) error -} - -// CatalogSyncEntry is the input format from provider FetchModels. -type CatalogSyncEntry struct { - ModelID string - DisplayName string - ModelType string // "chat", "embedding", "image" — from provider API - Capabilities models.ModelCapabilities - Pricing *models.ModelPricing -} - -// ========================================= -// PERSONA STORE -// ========================================= - -type PersonaStore interface { - Create(ctx context.Context, p *models.Persona) error - GetByID(ctx context.Context, id string) (*models.Persona, error) - Update(ctx context.Context, id string, patch models.PersonaPatch) error - Delete(ctx context.Context, id string) error - - // Scoped queries - ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user - ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) - ListGlobal(ctx context.Context) ([]models.Persona, error) - ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) // user's own - - // Grants - SetGrants(ctx context.Context, personaID string, grants []models.Grant) error - GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) - GetToolGrants(ctx context.Context, personaID string) ([]string, error) - - // Access check - UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) - - // Knowledge base bindings - SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error - GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) - GetKBIDs(ctx context.Context, personaID string) ([]string, error) -} - -// ========================================= -// POLICY STORE -// ========================================= - -type PolicyStore interface { - Get(ctx context.Context, key string) (string, error) - GetBool(ctx context.Context, key string) (bool, error) - Set(ctx context.Context, key, value string, updatedBy string) error - GetAll(ctx context.Context) (map[string]string, error) -} - -// ========================================= -// USER MODEL SETTINGS STORE -// ========================================= - -type UserModelSettingsStore interface { - GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) - GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) - Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error - BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error -} - -// ========================================= -// USER STORE -// ========================================= - -type UserStore interface { - Create(ctx context.Context, u *models.User) error - GetByID(ctx context.Context, id string) (*models.User, error) - GetByUsername(ctx context.Context, username string) (*models.User, error) - GetByEmail(ctx context.Context, email string) (*models.User, error) - GetByLogin(ctx context.Context, login string) (*models.User, error) // username or email - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error - List(ctx context.Context, opts ListOptions) ([]models.User, int, error) - UpdateLastLogin(ctx context.Context, id string) error - SetActive(ctx context.Context, id string, active bool) error - - // Refresh tokens - CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error - GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error) - RevokeRefreshToken(ctx context.Context, tokenHash string) error - RevokeAllRefreshTokens(ctx context.Context, userID string) error - CleanExpiredTokens(ctx context.Context) error -} - -// ========================================= -// TEAM STORE -// ========================================= - -type TeamStore interface { - Create(ctx context.Context, t *models.Team) error - GetByID(ctx context.Context, id string) (*models.Team, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error - List(ctx context.Context) ([]models.Team, error) - - // Members - AddMember(ctx context.Context, teamID, userID, role string) error - RemoveMember(ctx context.Context, teamID, userID string) error - UpdateMemberRole(ctx context.Context, teamID, userID, role string) error - ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) - GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error) - GetUserTeamIDs(ctx context.Context, userID string) ([]string, error) - IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error) - IsMember(ctx context.Context, teamID, userID string) (bool, error) -} - -// ========================================= -// CHANNEL STORE -// ========================================= - -type ChannelStore interface { - Create(ctx context.Context, ch *models.Channel) error - GetByID(ctx context.Context, id string) (*models.Channel, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error - ListForUser(ctx context.Context, userID string, opts ListOptions) ([]models.Channel, int, error) - Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Channel, int, error) - - // Cursor management (conversation forking) - GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error) - SetCursor(ctx context.Context, channelID, userID, leafID string) error - - // Channel models - SetModel(ctx context.Context, cm *models.ChannelModel) error - GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) - GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) - UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error - DeleteModel(ctx context.Context, id string) error - - // Ownership check - UserOwns(ctx context.Context, channelID, userID string) (bool, error) - - // Workspace resolution: channel workspace_id > project workspace_id - ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) -} - -// ========================================= -// MESSAGE STORE -// ========================================= - -type MessageStore interface { - Create(ctx context.Context, m *models.Message) error - GetByID(ctx context.Context, id string) (*models.Message, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error // soft delete - ListForChannel(ctx context.Context, channelID string, opts ListOptions) ([]models.Message, error) - - // Tree operations - GetChildren(ctx context.Context, parentID string) ([]models.Message, error) - GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) - GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) - GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) - - // Count - CountForChannel(ctx context.Context, channelID string) (int, error) -} - -// ========================================= -// AUDIT STORE -// ========================================= - -type AuditStore interface { - Log(ctx context.Context, entry *models.AuditEntry) error - List(ctx context.Context, opts AuditListOptions) ([]models.AuditEntry, int, error) -} - -type AuditListOptions struct { - ListOptions - ActorID string - Action string - ResourceType string - ResourceID string - Since *time.Time - Until *time.Time -} - -// ========================================= -// NOTE STORE -// ========================================= - -type NoteStore interface { - Create(ctx context.Context, n *models.Note) error - GetByID(ctx context.Context, id string) (*models.Note, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error - ListForUser(ctx context.Context, userID string, opts NoteListOptions) ([]models.Note, int, error) - Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error) - SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) - BulkDelete(ctx context.Context, ids []string, userID string) (int, error) -} - -type NoteListOptions struct { - ListOptions - FolderPath string - Tag string - TeamID string -} - -// ========================================= -// NOTE LINK STORE -// ========================================= - -// NoteLinkStore manages wikilink edges between notes. -type NoteLinkStore interface { - // ReplaceLinks deletes existing links for sourceNoteID and inserts new ones. - ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error - - // ResolveByTitle sets target_note_id on dangling links matching the title for a user. - ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error - - // Backlinks returns notes that link to the given note. - Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) - - // Graph returns all nodes and edges for a user's note graph. - Graph(ctx context.Context, userID string) (*models.NoteGraph, error) -} - -// ========================================= -// GLOBAL CONFIG STORE -// ========================================= - -type GlobalConfigStore interface { - Get(ctx context.Context, key string) (models.JSONMap, error) - Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error - GetAll(ctx context.Context) (map[string]models.JSONMap, error) -} - -// ========================================= -// USAGE STORE -// ========================================= - -type UsageStore interface { - Log(ctx context.Context, entry *models.UsageEntry) error - CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) - QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) - QueryByUserPersonal(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) - QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) - QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) - QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error) - GetTotals(ctx context.Context, opts UsageQueryOptions) (*models.UsageTotals, error) - GetTeamProviderTotals(ctx context.Context, teamID string, opts UsageQueryOptions) (*models.UsageTotals, error) - GetPersonalTotals(ctx context.Context, userID string, opts UsageQueryOptions) (*models.UsageTotals, error) -} - -type UsageQueryOptions struct { - Since *time.Time - Until *time.Time - GroupBy string // "day", "model", "user", "provider" - ExcludeBYOK bool // true for admin views — filters provider_scope != 'personal' - Limit int -} - -// ========================================= -// PRICING STORE -// ========================================= - -type PricingStore interface { - GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error) - Upsert(ctx context.Context, entry *models.PricingEntry) error - UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error - List(ctx context.Context) ([]models.PricingEntry, error) - Delete(ctx context.Context, providerConfigID, modelID string) error -} - -// ========================================= -// EXTENSION STORE -// ========================================= - -type ExtensionStore interface { - // Admin CRUD - Create(ctx context.Context, ext *models.Extension) error - GetByID(ctx context.Context, id string) (*models.Extension, error) - GetByExtID(ctx context.Context, extID string) (*models.Extension, error) - Update(ctx context.Context, id string, ext *models.Extension) error - Delete(ctx context.Context, id string) error - - // Listing - ListAll(ctx context.Context) ([]models.Extension, error) - ListEnabled(ctx context.Context) ([]models.Extension, error) - ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) - - // User settings - GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) - SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error - DeleteUserSettings(ctx context.Context, extID, userID string) error -} - -// ========================================= -// FILE STORE -// ========================================= - -type FileStore interface { - Create(ctx context.Context, f *models.File) error - GetByID(ctx context.Context, id string) (*models.File, error) - GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error) - GetByMessage(ctx context.Context, messageID string) ([]models.File, error) - GetByProject(ctx context.Context, projectID string) ([]models.File, error) - GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) // paginated, returns total - SetMessageID(ctx context.Context, fileID, messageID string) error - UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error - SetExtractedText(ctx context.Context, id string, text string) error - Delete(ctx context.Context, id string) (*models.File, error) // returns deleted row for storage cleanup - DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys - UserUsageBytes(ctx context.Context, userID string) (int64, error) - ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) -} - - - -// ========================================= -// KNOWLEDGE BASE STORE -// ========================================= - -type KnowledgeBaseStore interface { - // KB CRUD - Create(ctx context.Context, kb *models.KnowledgeBase) error - GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error - - // Scoped listing - ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) - ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error) - ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) - ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) - - // Documents - CreateDocument(ctx context.Context, doc *models.KBDocument) error - GetDocument(ctx context.Context, id string) (*models.KBDocument, error) - ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error) - UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error - UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error - UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error - DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) // returns for storage cleanup - - // Chunks - InsertChunks(ctx context.Context, chunks []models.KBChunk) error - DeleteChunksForDocument(ctx context.Context, documentID string) error - SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64, - threshold float64, limit int) ([]models.KBSearchResult, error) - - // Channel links - SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error - GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error) - GetActiveKBIDs(ctx context.Context, channelID string, userID string, - teamIDs []string) ([]string, error) // enabled + user has access - GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, - teamIDs []string, personaID string) ([]string, error) // includes persona-bound KBs - ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) - - // Stats — recount document_count/chunk_count/total_bytes from child tables - UpdateStats(ctx context.Context, kbID string) error - - // Discoverable management - SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error -} - -// ========================================= -// GROUP STORE (v0.16.0) -// ========================================= - -type GroupStore interface { - Create(ctx context.Context, g *models.Group) error - GetByID(ctx context.Context, id string) (*models.Group, error) - Update(ctx context.Context, id string, name, description *string) error - Delete(ctx context.Context, id string) error - - // Scoped listing - ListAll(ctx context.Context) ([]models.Group, error) // admin - ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) // team admin - ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I belong to - - // Members - AddMember(ctx context.Context, groupID, userID, addedBy string) error - RemoveMember(ctx context.Context, groupID, userID string) error - ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) - IsMember(ctx context.Context, groupID, userID string) (bool, error) - - // For access resolution: returns group IDs a user belongs to - GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) -} - -// ========================================= -// RESOURCE GRANT STORE (v0.16.0) -// ========================================= - -type ResourceGrantStore interface { - Set(ctx context.Context, grant *models.ResourceGrant) error - Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) - Delete(ctx context.Context, resourceType, resourceID string) error - - // Access check: does userID have group-based access to this resource? - UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) -} - -// ========================================= -// NOTIFICATION STORE (v0.20.0) -// ========================================= - -type NotificationStore interface { - Create(ctx context.Context, n *models.Notification) error - ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) - MarkRead(ctx context.Context, id, userID string) error - MarkAllRead(ctx context.Context, userID string) error - Delete(ctx context.Context, id, userID string) error - UnreadCount(ctx context.Context, userID string) (int, error) - DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) -} - -// ========================================= -// NOTIFICATION PREFERENCES STORE (v0.20.0) -// ========================================= - -type NotificationPreferenceStore interface { - // Get returns the preference for a specific user + type. Returns nil if not set. - Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error) - // ListForUser returns all preferences set by a user. - ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error) - // Upsert creates or updates a preference row. - Upsert(ctx context.Context, pref *models.NotificationPreference) error - // Delete removes a preference (falls back to default). - Delete(ctx context.Context, userID, notifType string) error -} - -// ========================================= -// WORKSPACE STORE (v0.21.0) -// ========================================= - -type WorkspaceStore interface { - // CRUD - Create(ctx context.Context, w *models.Workspace) error - GetByID(ctx context.Context, id string) (*models.Workspace, error) - Update(ctx context.Context, id string, patch models.WorkspacePatch) error - Delete(ctx context.Context, id string) error - - // Ownership lookup - GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) - ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) - - // File index - UpsertFile(ctx context.Context, f *models.WorkspaceFile) error - DeleteFile(ctx context.Context, workspaceID, path string) error - DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error - GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) - ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) - DeleteAllFiles(ctx context.Context, workspaceID string) error - - // Stats - GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) - - // Chunks (v0.21.2 — workspace indexing) - InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error - DeleteChunksByFile(ctx context.Context, fileID string) error - SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) - UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error - - // Git (v0.21.4) - SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error -} - -// ========================================= -// GIT CREDENTIAL STORE (v0.21.4) -// ========================================= - -type GitCredentialStore interface { - Create(ctx context.Context, cred *models.GitCredential) error - GetByID(ctx context.Context, id string) (*models.GitCredential, error) - ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) - Delete(ctx context.Context, id, userID string) error -} - -// ========================================= -// CAPABILITY OVERRIDES (v0.22.0) -// ========================================= - -type CapabilityOverrideStore interface { - // Set creates or updates an override for (provider, model, field). - Set(ctx context.Context, o *models.CapabilityOverride) error - - // Delete removes a specific override. - Delete(ctx context.Context, id string) error - - // ListForModel returns all overrides for a model ID (across all providers + global). - ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error) - - // ListForProviderModel returns overrides for a specific provider+model combination. - ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error) - - // ListAll returns every override (admin view). - ListAll(ctx context.Context) ([]models.CapabilityOverride, error) - - // DeleteForProvider removes all overrides for a provider (cascade cleanup). - DeleteForProvider(ctx context.Context, providerConfigID string) error -} - -// ========================================= -// ROUTING POLICY STORE (v0.22.2) -// ========================================= - -type RoutingPolicyStore interface { - // Create adds a new routing policy. - Create(ctx context.Context, p *models.RoutingPolicy) error - - // Update modifies an existing routing policy. - Update(ctx context.Context, p *models.RoutingPolicy) error - - // Delete removes a routing policy by ID. - Delete(ctx context.Context, id string) error - - // GetByID returns a single policy. - GetByID(ctx context.Context, id string) (*models.RoutingPolicy, error) - - // ListActive returns all active policies, ordered by priority ASC. - ListActive(ctx context.Context) ([]models.RoutingPolicy, error) - - // ListAll returns all policies (including inactive), for admin listing. - ListAll(ctx context.Context) ([]models.RoutingPolicy, error) - - // ListForTeam returns active policies applicable to a team (team-scoped + global). - ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error) -} - -// ========================================= -// SHARED TYPES -// ========================================= - -// ListOptions provides standard pagination/sort for list queries. -type ListOptions struct { - Limit int - Offset int - Sort string // column name - Order string // "asc" or "desc" -} - -// DefaultListOptions returns sensible defaults. -func DefaultListOptions() ListOptions { - return ListOptions{ - Limit: 50, - Order: "desc", - } -} diff --git a/store/postgres/user_settings.go b/store/postgres/user_settings.go deleted file mode 100644 index e2a8c28..0000000 --- a/store/postgres/user_settings.go +++ /dev/null @@ -1,152 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "strings" - - "git.gobha.me/xcaliber/chat-switchboard/models" -) - -type UserModelSettingsStore struct{} - -func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSettingsStore{} } - -func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens, - COALESCE(sort_order, 0), created_at, updated_at - FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.UserModelSetting - for rows.Next() { - var s models.UserModelSetting - var prefTemp, prefMaxTokens interface{} - err := rows.Scan( - &s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden, - &prefTemp, &prefMaxTokens, - &s.SortOrder, &s.CreatedAt, &s.UpdatedAt, - ) - if err != nil { - return nil, err - } - if f, ok := prefTemp.(float64); ok { - s.PreferredTemperature = &f - } - if n, ok := prefMaxTokens.(int64); ok { - v := int(n) - s.PreferredMaxTokens = &v - } - result = append(result, s) - } - return result, rows.Err() -} - -// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models. -func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) { - rows, err := DB.QueryContext(ctx, - "SELECT model_id, COALESCE(provider_config_id::text, '') FROM user_model_settings WHERE user_id = $1 AND hidden = true", - userID) - if err != nil { - return nil, err - } - defer rows.Close() - - result := make(map[string]bool) - for rows.Next() { - var modelID, provCfgID string - if err := rows.Scan(&modelID, &provCfgID); err != nil { - return nil, err - } - result[models.CompositeModelKey(provCfgID, modelID)] = true - } - return result, rows.Err() -} - -// Set upserts a single user model setting. -func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error { - b := NewUpdate("user_model_settings") - if patch.Hidden != nil { - b.Set("hidden", *patch.Hidden) - } - if patch.PreferredTemperature != nil { - b.Set("preferred_temperature", models.NullFloat(patch.PreferredTemperature)) - } - if patch.PreferredMaxTokens != nil { - b.Set("preferred_max_tokens", models.NullInt(patch.PreferredMaxTokens)) - } - if patch.SortOrder != nil { - b.Set("sort_order", *patch.SortOrder) - } - - if !b.HasSets() { - return nil - } - - // Use upsert: insert if not exists, update if exists - _, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order) - VALUES ($1, $2, $3, COALESCE($4, false), $5, $6, COALESCE($7, 0)) - ON CONFLICT (user_id, model_id, provider_config_id) - DO UPDATE SET - hidden = COALESCE($4, user_model_settings.hidden), - preferred_temperature = COALESCE($5, user_model_settings.preferred_temperature), - preferred_max_tokens = COALESCE($6, user_model_settings.preferred_max_tokens), - sort_order = COALESCE($7, user_model_settings.sort_order)`, - userID, modelID, providerConfigID, - patchBoolOrNil(patch.Hidden), - patchFloat64OrNil(patch.PreferredTemperature), - patchIntOrNil(patch.PreferredMaxTokens), - patchIntOrNil(patch.SortOrder), - ) - return err -} - -// BulkSetHidden sets the hidden state for multiple model+provider pairs at once. -func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error { - if len(entries) == 0 { - return nil - } - - for _, entry := range entries { - _, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden) - VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = $4`, - userID, entry.ModelID, entry.ProviderConfigID, hidden) - if err != nil { - return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err) - } - } - return nil -} - -// ── Helpers ───────────────────────────────── - -func patchBoolOrNil(b *bool) interface{} { - if b == nil { - return nil - } - return *b -} - -func patchFloat64OrNil(f *float64) interface{} { - if f == nil { - return nil - } - return *f -} - -func patchIntOrNil(i *int) interface{} { - if i == nil { - return nil - } - return *i -} - -// unused but keeping for reference - will be used in ListOptions-based queries -var _ = strings.Join diff --git a/store/sqlite/user_settings.go b/store/sqlite/user_settings.go deleted file mode 100644 index baf6d0b..0000000 --- a/store/sqlite/user_settings.go +++ /dev/null @@ -1,153 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - "strings" - - "git.gobha.me/xcaliber/chat-switchboard/models" - "git.gobha.me/xcaliber/chat-switchboard/store" -) - -type UserModelSettingsStore struct{} - -func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSettingsStore{} } - -func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens, - COALESCE(sort_order, 0), created_at, updated_at - FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.UserModelSetting - for rows.Next() { - var s models.UserModelSetting - var prefTemp, prefMaxTokens interface{} - err := rows.Scan( - &s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden, - &prefTemp, &prefMaxTokens, - &s.SortOrder, st(&s.CreatedAt), st(&s.UpdatedAt), - ) - if err != nil { - return nil, err - } - if f, ok := prefTemp.(float64); ok { - s.PreferredTemperature = &f - } - if n, ok := prefMaxTokens.(int64); ok { - v := int(n) - s.PreferredMaxTokens = &v - } - result = append(result, s) - } - return result, rows.Err() -} - -// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models. -func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) { - rows, err := DB.QueryContext(ctx, - "SELECT model_id, COALESCE(provider_config_id, '') FROM user_model_settings WHERE user_id = ? AND hidden = 1", - userID) - if err != nil { - return nil, err - } - defer rows.Close() - - result := make(map[string]bool) - for rows.Next() { - var modelID, provCfgID string - if err := rows.Scan(&modelID, &provCfgID); err != nil { - return nil, err - } - result[models.CompositeModelKey(provCfgID, modelID)] = true - } - return result, rows.Err() -} - -// Set upserts a single user model setting. -func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error { - b := NewUpdate("user_model_settings") - if patch.Hidden != nil { - b.Set("hidden", *patch.Hidden) - } - if patch.PreferredTemperature != nil { - b.Set("preferred_temperature", models.NullFloat(patch.PreferredTemperature)) - } - if patch.PreferredMaxTokens != nil { - b.Set("preferred_max_tokens", models.NullInt(patch.PreferredMaxTokens)) - } - if patch.SortOrder != nil { - b.Set("sort_order", *patch.SortOrder) - } - - if !b.HasSets() { - return nil - } - - // Use upsert: insert if not exists, update if exists - _, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order) - VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0)) - ON CONFLICT (user_id, model_id, provider_config_id) - DO UPDATE SET - hidden = COALESCE(excluded.hidden, user_model_settings.hidden), - preferred_temperature = COALESCE(excluded.preferred_temperature, user_model_settings.preferred_temperature), - preferred_max_tokens = COALESCE(excluded.preferred_max_tokens, user_model_settings.preferred_max_tokens), - sort_order = COALESCE(excluded.sort_order, user_model_settings.sort_order)`, - store.NewID(), userID, modelID, providerConfigID, - patchBoolOrNil(patch.Hidden), - patchFloat64OrNil(patch.PreferredTemperature), - patchIntOrNil(patch.PreferredMaxTokens), - patchIntOrNil(patch.SortOrder), - ) - return err -} - -// BulkSetHidden sets the hidden state for multiple model+provider pairs at once. -func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error { - if len(entries) == 0 { - return nil - } - - for _, entry := range entries { - _, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = excluded.hidden`, - store.NewID(), userID, entry.ModelID, entry.ProviderConfigID, hidden) - if err != nil { - return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err) - } - } - return nil -} - -// ── Helpers ───────────────────────────────── - -func patchBoolOrNil(b *bool) interface{} { - if b == nil { - return nil - } - return *b -} - -func patchFloat64OrNil(f *float64) interface{} { - if f == nil { - return nil - } - return *f -} - -func patchIntOrNil(i *int) interface{} { - if i == nil { - return nil - } - return *i -} - -// unused but keeping for reference - will be used in ListOptions-based queries -var _ = strings.Join