Changeset 0.23.1 (#154)

This commit is contained in:
2026-03-06 13:26:25 +00:00
parent 2fc620e1ac
commit 4c6555cb06
38 changed files with 1536 additions and 4031 deletions

View File

@@ -1 +1 @@
0.22.10 0.23.1

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: 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: switchboard:
build: build:
context: . context: .
@@ -28,36 +21,15 @@ services:
environment: environment:
PORT: "8080" PORT: "8080"
BASE_PATH: "" BASE_PATH: ""
POSTGRES_HOST: postgres DB_DRIVER: sqlite
POSTGRES_PORT: "5432" DATABASE_URL: /data/switchboard.db
POSTGRES_USER: switchboard JWT_SECRET: ${JWT_SECRET:-change-me-for-production}
POSTGRES_PASSWORD: ${DB_PASSWORD:-switchboard_dev}
POSTGRES_DB: chat_switchboard
POSTGRES_SSLMODE: disable
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# File storage — mount ./data/storage on host STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage STORAGE_PATH: /data/storage
volumes: volumes:
- switchboard_storage:/data/storage - ./data:/data
ports: ports:
- "3000:80" - "3000:80"
depends_on: restart: unless-stopped
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:

View File

@@ -1449,6 +1449,7 @@ based on need.
- Plugin/extension marketplace - Plugin/extension marketplace
- Virtual scroll for long conversations - Virtual scroll for long conversations
- ~~SQLite backend option (single-user / dev)~~ → v0.17.1 - ~~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)** **Pane Architecture (Workspace Container)**

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,12 @@ RUN go mod download
# Copy source and tidy (resolves any go.sum gaps) # Copy source and tidy (resolves any go.sum gaps)
COPY server/ . COPY server/ .
RUN go mod tidy 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 # Runtime stage
FROM debian:bookworm-slim 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 /bin/switchboard /usr/local/bin/switchboard
COPY --from=builder /app/database/migrations /app/database/migrations 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) # Builtin extensions (seeded into DB on startup)
COPY extensions/builtin/ /app/extensions/builtin/ COPY extensions/builtin/ /app/extensions/builtin/

View File

@@ -17,6 +17,14 @@ import (
// DB is the application-wide database connection pool. // DB is the application-wide database connection pool.
var DB *sql.DB 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. // Connect opens a connection pool to the configured database.
// Driver is selected by DB_DRIVER env var: "postgres" (default) or "sqlite". // 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. // 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). // Log the database name for operational clarity (never log credentials).
dbName = extractDBName(dsn) dbName = extractDBName(dsn)
rawDSN = dsn
log.Printf("Connecting to Postgres database %q ...", dbName) log.Printf("Connecting to Postgres database %q ...", dbName)
var err error var err error

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ type Bus struct {
mu sync.RWMutex mu sync.RWMutex
subs map[string][]*subscription subs map[string][]*subscription
seq uint64 // subscription ID counter seq uint64 // subscription ID counter
broadcastHook func(Event) // called after Publish for cross-pod fan-out; nil-safe
} }
type subscription struct { 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. // Subscribe registers a handler for a label pattern.
// Returns an unsubscribe function. // 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. // Publish dispatches an event to all matching subscribers.
// Handlers are called synchronously in subscription order. // Handlers are called synchronously in subscription order.
// Use PublishAsync for non-blocking dispatch. // Use PublishAsync for non-blocking dispatch.
// After local dispatch, calls the broadcastHook if set (Postgres fan-out).
func (b *Bus) Publish(event Event) { 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() b.mu.RLock()
var matched []Handler var matched []Handler
for pattern, subs := range b.subs { for pattern, subs := range b.subs {
@@ -79,6 +104,7 @@ func (b *Bus) Publish(event Event) {
// PublishAsync dispatches an event to all matching subscribers // PublishAsync dispatches an event to all matching subscribers
// in separate goroutines. Useful for I/O-heavy handlers. // in separate goroutines. Useful for I/O-heavy handlers.
// Also calls the broadcastHook asynchronously if set.
func (b *Bus) PublishAsync(event Event) { func (b *Bus) PublishAsync(event Event) {
b.mu.RLock() b.mu.RLock()
var matched []Handler var matched []Handler
@@ -89,11 +115,15 @@ func (b *Bus) PublishAsync(event Event) {
} }
} }
} }
hook := b.broadcastHook
b.mu.RUnlock() b.mu.RUnlock()
for _, h := range matched { for _, h := range matched {
go h(event) go h(event)
} }
if hook != nil {
go hook(event)
}
} }
// match checks if a concrete label matches a subscription pattern. // match checks if a concrete label matches a subscription pattern.

View File

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

View File

@@ -140,6 +140,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
archived := c.DefaultQuery("archived", "false") archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder") folder := c.Query("folder")
channelType := c.DefaultQuery("type", "") // empty = all types 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")) search := strings.TrimSpace(c.Query("search"))
projectFilter := c.Query("project_id") // "uuid" or "none" projectFilter := c.Query("project_id") // "uuid" or "none"
@@ -148,7 +153,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
countArgs := []interface{}{userID, archived == "true"} countArgs := []interface{}{userID, archived == "true"}
argN := 3 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) countQuery += ` AND type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType) countArgs = append(countArgs, channelType)
argN++ argN++
@@ -193,7 +206,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
args := []interface{}{userID, archived == "true"} args := []interface{}{userID, archived == "true"}
argN = 3 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) query += ` AND c.type = $` + strconv.Itoa(argN)
args = append(args, channelType) args = append(args, channelType)
argN++ argN++

View File

@@ -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 ── // ── Persona unwrap: persona overrides defaults, explicit request fields win ──
var personaSystemPrompt string var personaSystemPrompt string
var personaID string // tracks active persona for KB scoping 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 // Resolve effective workspace: channel.workspace_id > project.workspace_id
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
// ── @mention routing (v0.23.0) ────────────── // ── @mention routing (v0.23.0 / v0.23.1) ─────────────────────────────────
// Resolve @model-id or @persona-handle directly against the enabled // Resolve @persona-handle, @model-id, or @username.
// model catalog and personas table. Works in ANY chat — no roster needed. // - User mention → skip completion, notify recipient (v0.23.1)
if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" { // - 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 req.Model = mentionModel
if mentionConfig != "" { if mentionConfig != "" {
req.ProviderConfigID = mentionConfig req.ProviderConfigID = mentionConfig
@@ -1127,15 +1164,20 @@ func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPerso
// //
// Resolution order: // Resolution order:
// 1. Persona handle (exact match, then prefix) // 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 // Extract first @token
token := extractFirstMention(content) token := extractFirstMention(content)
if token == "" { if token == "" {
return "", "", nil return "", "", nil, ""
} }
// Normalize: lowercase, hyphens // Normalize: lowercase, hyphens
@@ -1155,7 +1197,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
cfgID = *p.ProviderConfigID cfgID = *p.ProviderConfigID
} }
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID) 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 cfgID = *p.ProviderConfigID
} }
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID) 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 var modelID, provConfigID string
err = database.DB.QueryRowContext(ctx, database.Q(` err = database.DB.QueryRowContext(ctx, database.Q(`
SELECT mc.model_id, mc.provider_config_id 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) `), normalized).Scan(&modelID, &provConfigID)
if err == nil && modelID != "" { if err == nil && modelID != "" {
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID) 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 var prefixCount int
database.DB.QueryRowContext(ctx, database.Q(` database.DB.QueryRowContext(ctx, database.Q(`
SELECT COUNT(DISTINCT mc.model_id) SELECT COUNT(DISTINCT mc.model_id)
@@ -1224,11 +1295,11 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
`), normalized+"%").Scan(&modelID, &provConfigID) `), normalized+"%").Scan(&modelID, &provConfigID)
if modelID != "" { if modelID != "" {
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID) 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. // extractFirstMention finds the first @token in content.

View File

@@ -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) 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 // 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 == "" { if mentionModel == "" {
log.Printf("[chain] @%s did not resolve to any model", token) log.Printf("[chain] @%s did not resolve to any model", token)
return return

146
server/handlers/folders.go Normal file
View File

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

View File

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

View File

@@ -198,6 +198,10 @@ func main() {
// ── EventBus (created early — needed by role resolver) ── // ── EventBus (created early — needed by role resolver) ──
bus := events.NewBus() 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) ────────────── // ── Workspace FS (v0.21.0) ──────────────
// Provides file operations for workspace storage primitive. // Provides file operations for workspace storage primitive.
// Nil-safe: handler checks wfs != nil before operating. // Nil-safe: handler checks wfs != nil before operating.
@@ -373,6 +377,17 @@ func main() {
protected.PUT("/channels/:id", channels.UpdateChannel) protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel) 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) // Channel models (v0.20.0 — multi-model @mention routing)
chModelH := handlers.NewChannelModelHandler(stores) chModelH := handlers.NewChannelModelHandler(stores)
protected.GET("/channels/:id/models", chModelH.List) protected.GET("/channels/:id/models", chModelH.List)

View File

@@ -88,35 +88,11 @@ window.addEventListener('unhandledrejection', function(e) {
<span id="brandWordmark" class="brand-text" style="display:none;"></span> <span id="brandWordmark" class="brand-text" style="display:none;"></span>
</div> </div>
{{/* New Chat (split button) */}} {{/* New Chat button */}}
<div class="split-btn"> <button id="newChatBtn" class="sb-btn" onclick="newChat()" title="New chat">
<button id="newChatBtn" class="split-btn-main sb-btn" title="New chat"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="sb-label">New Chat</span> <span class="sb-label">New Chat</span>
</button> </button>
<button id="newChatDropBtn" class="split-btn-drop" title="More options">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div id="newChatDropdown" class="split-dropdown">
<button class="split-dropdown-item" onclick="closeNewChatMenu(); newChat();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
<span>New Chat</span>
</button>
<button class="split-dropdown-item" onclick="closeNewChatMenu(); if(typeof createProject==='function') createProject();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span>New Project</span>
</button>
<button class="split-dropdown-item" onclick="closeNewChatMenu(); newGroupChat();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Group Chat</span>
</button>
<button class="split-dropdown-item disabled" title="Coming soon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>New Channel</span>
<span class="dd-hint">soon</span>
</button>
</div>
</div>
</div> </div>
{{/* Search (outside sidebar-top, below brand section) */}} {{/* Search (outside sidebar-top, below brand section) */}}
@@ -132,22 +108,72 @@ window.addEventListener('unhandledrejection', function(e) {
</button> </button>
{{/* Sidebar Tabs (Chats / Files) */}} {{/* Sidebar Tabs (Chats / Files) */}}
{{/* Sidebar Tabs (kept hidden — only Files tab used by EditorMode) */}}
<div id="sidebarTabs" class="sidebar-tabs" style="display:none;"> <div id="sidebarTabs" class="sidebar-tabs" style="display:none;">
<button class="sidebar-tab active" data-tab="chats">Chats</button> <button class="sidebar-tab active" data-tab="chats">Chats</button>
<button id="sidebarFilesTab" class="sidebar-tab" data-tab="files" style="display:none;">Files</button> <button id="sidebarFilesTab" class="sidebar-tab" data-tab="files" style="display:none;">Files</button>
</div> </div>
{{/* Chat list */}} {{/* Three-section nav: Projects / Channels / Chats */}}
<div id="chatHistory" class="sidebar-chats sidebar-tab-panel" data-tab-panel="chats"> <div id="sidebarNav" class="sidebar-nav sidebar-tab-panel" data-tab-panel="chats">
{{/* Projects section */}}
<div class="sb-section" id="sbSectionProjects">
<div class="sb-section-header" onclick="UI.toggleSidebarSection('projects')">
<span class="sb-section-arrow" id="sbArrowProjects"></span>
<span class="sb-section-label">Projects</span>
<button class="sb-section-add" title="New project"
onclick="event.stopPropagation();if(typeof createProject==='function')createProject();">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="sb-section-body" id="sbBodyProjects">
{{/* Populated by UI.renderProjectsSection() */}}
</div>
</div>
{{/* Channels section (DMs + named channels) */}}
<div class="sb-section" id="sbSectionChannels">
<div class="sb-section-header" onclick="UI.toggleSidebarSection('channels')">
<span class="sb-section-arrow" id="sbArrowChannels"></span>
<span class="sb-section-label">Channels</span>
<button class="sb-section-add" title="New channel"
onclick="event.stopPropagation();newChannelOrDM();">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="sb-section-body" id="sbBodyChannels">
{{/* Populated by UI.renderChannelsSection() */}}
</div>
</div>
{{/* Chats section (personal AI chats + folders) */}}
<div class="sb-section" id="sbSectionChats">
<div class="sb-section-header" onclick="UI.toggleSidebarSection('chats')">
<span class="sb-section-arrow" id="sbArrowChats"></span>
<span class="sb-section-label">Chats</span>
<div class="sb-section-add-group" onclick="event.stopPropagation();">
<button class="sb-section-add" title="New chat" onclick="newChat();">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="sb-section-add" title="New folder" onclick="newFolder();">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
</div>
</div>
<div class="sb-section-body" id="sbBodyChats">
{{/* Populated by UI.renderChatList() */}} {{/* Populated by UI.renderChatList() */}}
</div> </div>
</div>
</div>
{{/* Legacy chatHistory id kept for JS compatibility during transition */}}
<div id="chatHistory" style="display:none;"></div>
{{/* Files panel (editor mode) */}} {{/* Files panel (editor mode) */}}
<div class="sidebar-tab-panel" data-tab-panel="files" style="display:none;"></div> <div class="sidebar-tab-panel" data-tab-panel="files" style="display:none;"></div>
{{/* User / bottom */}} {{/* User / bottom */}}
<div class="sidebar-bottom"> <div class="sidebar-bottom">
{{/* Notes shortcut */}}
<button id="notesBtn" class="sb-btn sidebar-notes-btn"> <button id="notesBtn" class="sb-btn sidebar-notes-btn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span class="sb-label">Notes</span> <span class="sb-label">Notes</span>

View File

@@ -458,3 +458,39 @@
.group-persona-model { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); } .group-persona-model { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); }
.chain-typing-name { font-size: 11px; font-weight: 600; color: var(--accent); margin-right: 6px; } .chain-typing-name { font-size: 11px; font-weight: 600; color: var(--accent); margin-right: 6px; }
/* ── Creation Dialog (project / channel / folder — v0.23.1) ── */
.sb-dialog-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
background: var(--overlay);
animation: fade-in 0.12s ease;
}
.sb-dialog {
background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 20px 22px 16px;
min-width: 300px; max-width: 420px; width: 90vw;
display: flex; flex-direction: column; gap: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: confirm-in 0.15s ease;
}
.sb-dialog-title { font-size: 14px; font-weight: 600; color: var(--text); }
.sb-dialog-input {
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 10px;
font-size: 13px; color: var(--text);
font-family: inherit; outline: none; width: 100%; box-sizing: border-box;
}
.sb-dialog-input:focus { border-color: var(--accent); }
.sb-dialog-color-row { display: flex; align-items: center; gap: 10px; }
.sb-dialog-color-label { font-size: 12px; color: var(--text-2); }
.sb-dialog-color-input { width: 32px; height: 26px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 1px; background: none; }
.sb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.sb-dialog-btn {
padding: 6px 14px; border-radius: var(--radius); font-size: 12px; font-family: inherit;
cursor: pointer; border: 1px solid var(--border);
background: var(--bg-hover); color: var(--text-2);
}
.sb-dialog-btn:hover { background: var(--bg-raised); }
.sb-dialog-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.sb-dialog-btn-primary:hover { opacity: 0.88; }

View File

@@ -522,3 +522,241 @@
.admin-content-body { padding: 16px; } .admin-content-body { padding: 16px; }
.editor-tree { width: 180px; } .editor-tree { width: 180px; }
} }
/* =====================================================
v0.23.1 — Three-section sidebar (Projects/Channels/Chats)
===================================================== */
/* Scrollable nav container */
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* Section wrapper */
.sb-section { margin-bottom: 2px; }
/* Section header row */
.sb-section-header {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 10px 4px 10px;
cursor: pointer;
user-select: none;
}
.sb-section-header:hover .sb-section-add { opacity: 1; }
.sidebar.collapsed .sb-section-header { justify-content: center; padding: 6px 0; }
.sb-section-arrow {
font-size: 9px;
color: var(--text-3);
width: 10px;
flex-shrink: 0;
}
.sidebar.collapsed .sb-section-arrow { display: none; }
.sb-section-label {
font-size: 10px;
font-weight: 700;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.7px;
flex: 1;
}
.sidebar.collapsed .sb-section-label { display: none; }
.sb-section-add {
opacity: 0;
background: none;
border: none;
color: var(--text-3);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
transition: opacity 0.1s, color 0.1s;
flex-shrink: 0;
}
.sb-section-add:hover { color: var(--text); }
.sidebar.collapsed .sb-section-add { display: none; }
.sb-section-body { /* no extra padding — children handle it */ }
.sb-section-empty {
font-size: 11px;
color: var(--text-3);
padding: 4px 14px 8px;
font-style: italic;
}
.sidebar.collapsed .sb-section-empty { display: none; }
/* ── Projects section items ── */
.sb-proj-group { margin-bottom: 1px; }
.sb-proj-group.drag-over .sb-proj-header { background: var(--accent-dim); }
.sb-proj-header {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
cursor: pointer;
border-radius: 6px;
margin: 0 4px;
transition: background 0.1s;
}
.sb-proj-header:hover { background: var(--bg-hover); }
.sb-proj-group.active .sb-proj-header {
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--accent);
}
.sb-proj-group.active .sb-proj-name { color: var(--accent); font-weight: 600; }
.sb-proj-group.active .sb-proj-dot { background: var(--accent); }
.sb-proj-pin { font-size: 11px; margin-left: auto; opacity: 0.75; flex-shrink: 0; }
.sidebar.collapsed .sb-proj-header { justify-content: center; padding: 5px 0; margin: 0 6px; }
.sb-proj-arrow { font-size: 9px; color: var(--text-3); flex-shrink: 0; }
.sidebar.collapsed .sb-proj-arrow { display: none; }
.sb-proj-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-3);
flex-shrink: 0;
}
.sb-proj-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar.collapsed .sb-proj-name { display: none; }
.sb-proj-count {
font-size: 10px;
color: var(--text-3);
min-width: 14px;
text-align: right;
}
.sidebar.collapsed .sb-proj-count { display: none; }
.sb-proj-menu {
opacity: 0;
background: none;
border: none;
color: var(--text-3);
cursor: pointer;
font-size: 14px;
padding: 0 2px;
line-height: 1;
border-radius: 4px;
transition: opacity 0.1s;
}
.sb-proj-header:hover .sb-proj-menu { opacity: 1; }
.sidebar.collapsed .sb-proj-menu { display: none; }
.sb-proj-empty {
font-size: 11px;
color: var(--text-3);
padding: 4px 10px 6px 28px;
font-style: italic;
}
.sb-show-archived {
background: none;
border: none;
font-size: 11px;
color: var(--text-3);
cursor: pointer;
padding: 4px 14px 6px;
font-family: inherit;
display: block;
width: 100%;
text-align: left;
}
.sb-show-archived:hover { color: var(--text-2); }
/* ── Channels section items ── */
.sb-channel-item {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 6px;
margin: 0 4px;
cursor: pointer;
font-size: 13px;
color: var(--text-2);
position: relative;
transition: background 0.1s;
}
.sb-channel-item:hover { background: var(--bg-hover); color: var(--text); }
.sb-channel-item.active { background: var(--bg-raised); color: var(--text); }
.sidebar.collapsed .sb-channel-item { justify-content: center; padding: 5px 0; margin: 0 6px; }
.sb-ch-icon { color: var(--text-3); flex-shrink: 0; display: flex; }
.sb-ch-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar.collapsed .sb-ch-name { display: none; }
.sb-presence {
width: 7px; height: 7px; border-radius: 50%;
background: var(--text-3);
flex-shrink: 0;
}
.sb-presence.online { background: var(--success-light, #22c55e); }
.sb-unread {
font-size: 10px;
font-weight: 700;
background: var(--accent);
color: #fff;
border-radius: 10px;
padding: 1px 5px;
min-width: 16px;
text-align: center;
flex-shrink: 0;
}
/* ── Chats section folder items ── */
.sb-folder-group { margin-bottom: 1px; }
.sb-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-folder-menu {
display: none; background: none; border: none; cursor: pointer;
color: var(--text-3); font-size: 14px; padding: 0 2px; line-height: 1;
flex-shrink: 0; border-radius: 3px;
}
.sb-folder-menu:hover { color: var(--text); background: var(--bg-hover); }
.sb-folder-header:hover .sb-folder-menu { display: block; }
.sb-folder-empty { padding: 4px 14px 6px 28px; font-size: 11px; color: var(--text-3); font-style: italic; }
.sb-folder-header {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 6px;
margin: 0 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--text-3);
transition: background 0.1s;
}
.sb-folder-header:hover { background: var(--bg-hover); color: var(--text-2); }
.sb-folder-arrow { margin-left: auto; font-size: 9px; }
/* Indented chat items inside folders */
.chat-item.indented { padding-left: 28px; }

View File

@@ -240,6 +240,20 @@ const API = {
}, },
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); }, listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
// ── Folders (v0.23.1) ────────────────────
listFolders() { return this._get('/api/v1/folders'); },
createFolder(name, sortOrder = 0) { return this._post('/api/v1/folders', { name, sort_order: sortOrder }); },
updateFolder(id, data) { return this._put(`/api/v1/folders/${id}`, data); },
deleteFolder(id) { return this._del(`/api/v1/folders/${id}`); },
// ── Sidebar helpers ───────────────────────
listSidebarChannels(page = 1, perPage = 500) {
return this._get(`/api/v1/channels?page=${page}&per_page=${perPage}&types=dm,channel`);
},
// ── Presence ──────────────────────────────
presenceHeartbeat() { return this._post('/api/v1/presence/heartbeat', {}); },
// ── Workspaces (v0.21.0) ──────────────── // ── Workspaces (v0.21.0) ────────────────
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); }, getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
listWorkspaceFiles(wsId, path = '', recursive = false) { listWorkspaceFiles(wsId, path = '', recursive = false) {

View File

@@ -21,6 +21,12 @@ const App = {
collapsedProjects: {}, collapsedProjects: {},
activeProjectId: null, activeProjectId: null,
showArchivedProjects: false, showArchivedProjects: false,
// v0.23.1: Multi-user
channels: [], // DMs + named channels
currentChannelId: null,
folders: [], // chat folders (user-scoped)
collapsedFolders: {},
presence: {}, // { userId: 'online' | 'offline' }
findModel(id) { findModel(id) {
if (!id) return null; if (!id) return null;

View File

@@ -70,6 +70,10 @@ async function startApp() {
UI.restoreSidebar(); UI.restoreSidebar();
await loadSettings(); await loadSettings();
// v0.23.1: Load folders and channels before rendering sidebar
await loadFolders();
await loadChannels();
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff) // Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
// are registered when messages are first rendered. // are registered when messages are first rendered.
try { try {
@@ -107,6 +111,8 @@ async function startApp() {
console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse'); console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse');
} }
UI.renderChatList(); UI.renderChatList();
UI.renderChannelsSection();
UI.restoreSidebarSections();
UI.updateModelSelector(); UI.updateModelSelector();
// Restore last-active chat from sessionStorage // Restore last-active chat from sessionStorage

View File

@@ -377,12 +377,6 @@ function _cleanStaleChatModel(chatId) {
} }
} }
/** Close the new-chat dropdown and reset any fixed positioning from collapsed mode. */
function closeNewChatMenu() {
const dd = document.getElementById('newChatDropdown');
if (dd) { dd.classList.remove('open'); dd.style.cssText = ''; }
}
async function newChat() { async function newChat() {
clearStaged(); // Discard any staged files clearStaged(); // Discard any staged files
App.currentChatId = null; App.currentChatId = null;
@@ -945,34 +939,7 @@ async function switchSibling(messageId, direction) {
function _initChatListeners() { function _initChatListeners() {
// Sidebar // Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar); document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', (e) => { document.getElementById('newChatBtn').addEventListener('click', () => newChat());
const sidebar = document.querySelector('.sidebar');
if (sidebar && sidebar.classList.contains('collapsed')) {
// Collapsed: show the dropdown menu, positioned to pop out right
const dd = document.getElementById('newChatDropdown');
const rect = e.currentTarget.getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.left = rect.right + 4 + 'px';
dd.style.top = rect.top + 'px';
dd.style.width = '200px';
dd.classList.toggle('open');
} else {
newChat();
}
});
// Split button dropdown
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('newChatDropdown').classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.split-btn')) {
const dd = document.getElementById('newChatDropdown');
dd.classList.remove('open');
dd.style.cssText = ''; // Reset any fixed positioning from collapsed mode
}
});
// User flyout // User flyout
document.getElementById('userMenuBtn').addEventListener('click', (e) => { document.getElementById('userMenuBtn').addEventListener('click', (e) => {

View File

@@ -681,7 +681,9 @@ const EditorMode = {
}); });
return r; // true = save, false = discard return r; // true = save, false = discard
} }
return window.confirm(`Save changes to ${path.split('/').pop()}?`); return window.showConfirm
? showConfirm(`Save changes to ${path.split('/').pop()}?`, { ok: 'Save', cancel: 'Discard', danger: false })
: Promise.resolve(true);
}, },
_markModified(path) { _markModified(path) {
@@ -768,7 +770,7 @@ const EditorMode = {
// ── New File ───────────────────────────── // ── New File ─────────────────────────────
async _createNewFile() { async _createNewFile() {
const name = prompt('File name (e.g. notes.md, src/main.go):'); const name = await showPrompt({ title: 'New File', placeholder: 'e.g. notes.md, src/main.go', ok: 'Create' });
if (!name?.trim()) return; if (!name?.trim()) return;
// Normalize: strip leading ./ and / — backend expects relative paths // Normalize: strip leading ./ and / — backend expects relative paths
const path = name.trim().replace(/^\.?\/+/, ''); const path = name.trim().replace(/^\.?\/+/, '');

View File

@@ -26,7 +26,7 @@ const Notifications = {
async _fetchUnreadCount() { async _fetchUnreadCount() {
try { try {
const data = await API._get('/notifications/unread-count'); const data = await API._get('/api/v1/notifications/unread-count');
this._unreadCount = data.count || 0; this._unreadCount = data.count || 0;
this._renderBadge(); this._renderBadge();
} catch (e) { } catch (e) {
@@ -40,7 +40,7 @@ const Notifications = {
const unreadOnly = opts.unreadOnly || false; const unreadOnly = opts.unreadOnly || false;
try { try {
const data = await API._get( const data = await API._get(
`/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`); `/api/v1/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`);
return data; return data;
} catch (e) { } catch (e) {
return { data: [], total: 0 }; return { data: [], total: 0 };
@@ -186,7 +186,7 @@ const Notifications = {
async _markRead(id) { async _markRead(id) {
try { try {
await API._authed(`/notifications/${id}/read`, 'PATCH'); await API._authed(`/api/v1/notifications/${id}/read`, 'PATCH');
this._unreadCount = Math.max(0, this._unreadCount - 1); this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === id); const item = this._items.find(n => n.id === id);
if (item) item.is_read = true; if (item) item.is_read = true;
@@ -196,7 +196,7 @@ const Notifications = {
async markAllRead() { async markAllRead() {
try { try {
await API._post('/notifications/mark-all-read'); await API._post('/api/v1/notifications/mark-all-read');
this._unreadCount = 0; this._unreadCount = 0;
this._items.forEach(n => n.is_read = true); this._items.forEach(n => n.is_read = true);
this._renderBadge(); this._renderBadge();
@@ -312,7 +312,7 @@ const Notifications = {
async _deleteItem(id) { async _deleteItem(id) {
try { try {
await API._del(`/notifications/${id}`); await API._del(`/api/v1/notifications/${id}`);
// Check if it was unread before removal // Check if it was unread before removal
const item = this._panelItems.find(n => n.id === id); const item = this._panelItems.find(n => n.id === id);
if (item && !item.is_read) { if (item && !item.is_read) {

View File

@@ -166,7 +166,7 @@ const Pages = {
}, },
async editUserRole(id, username, currentRole) { async editUserRole(id, username, currentRole) {
const role = prompt(`Role for ${username}:`, currentRole); const role = await showPrompt({ title: `Set role for ${username}`, value: currentRole, ok: 'Save' });
if (!role || role === currentRole) return; if (!role || role === currentRole) return;
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role }); const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
if (ok) window.location.reload(); if (ok) window.location.reload();

View File

@@ -50,18 +50,70 @@ function toggleProjectCollapse(projectId) {
UI.renderChatList(); UI.renderChatList();
} }
// ── Creation Dialog (shared) ─────────────────
// Lightweight modal for name + optional color. Returns {name, color} or null.
function _showCreationDialog({ title, placeholder, withColor = false, defaultColor = '#3B82F6', defaultValue = '', confirmLabel = null }) {
const btnLabel = confirmLabel || (defaultValue ? 'Rename' : 'Create');
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'sb-dialog-overlay';
overlay.innerHTML = `
<div class="sb-dialog" role="dialog" aria-modal="true">
<div class="sb-dialog-title">${title}</div>
<input id="sbDialogName" class="sb-dialog-input" type="text"
placeholder="${placeholder}" autocomplete="off" maxlength="200" value="${defaultValue.replace(/"/g, '&quot;')}">
${withColor ? `
<div class="sb-dialog-color-row">
<label class="sb-dialog-color-label">Color</label>
<input id="sbDialogColor" type="color" value="${defaultColor}" class="sb-dialog-color-input">
</div>` : ''}
<div class="sb-dialog-actions">
<button id="sbDialogCancel" class="sb-dialog-btn">Cancel</button>
<button id="sbDialogOK" class="sb-dialog-btn sb-dialog-btn-primary">${btnLabel}</button>
</div>
</div>`;
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 ──────────────────────────── // ── Project CRUD ────────────────────────────
async function createProject() { async function createProject() {
const name = prompt('Project name:'); const result = await _showCreationDialog({
if (!name || !name.trim()) return; title: 'New Project',
placeholder: 'Project name',
withColor: true,
});
if (!result) return;
try { try {
const p = await API.createProject({ name: name.trim() }); const p = await API.createProject({ name: result.name, color: result.color });
App.projects.push({ App.projects.push({
id: p.id, id: p.id,
name: p.name, name: p.name,
description: p.description || '', description: p.description || '',
color: p.color || null, color: p.color || result.color || null,
icon: p.icon || null, icon: p.icon || null,
channelCount: 0, kbCount: 0, noteCount: 0, channelCount: 0, kbCount: 0, noteCount: 0,
isArchived: false, isArchived: false,
@@ -332,7 +384,7 @@ function showProjectMenu(e, projectId) {
menu.className = 'project-ctx-menu'; menu.className = 'project-ctx-menu';
menu.innerHTML = ` menu.innerHTML = `
<button class="ctx-item" onclick="toggleActiveProject('${projectId}');dismissChatContextMenu()"> <button class="ctx-item" onclick="toggleActiveProject('${projectId}');dismissChatContextMenu()">
<span class="ctx-icon">${isActive ? '📌' : '📌'}</span> ${activeLabel} <span class="ctx-icon">${isActive ? '📍' : '📌'}</span> ${activeLabel}
</button> </button>
<button class="ctx-item" onclick="openProjectPanel('${projectId}');dismissChatContextMenu()"> <button class="ctx-item" onclick="openProjectPanel('${projectId}');dismissChatContextMenu()">
<span class="ctx-icon">⚙</span> Project settings <span class="ctx-icon">⚙</span> Project settings
@@ -1027,3 +1079,187 @@ async function _moveChatInProject(projectId, chatId, direction) {
UI.toast('Failed to reorder', 'error'); UI.toast('Failed to reorder', 'error');
} }
} }
// ==========================================
// v0.23.1 — Channels + Folders
// ==========================================
// ── Folders ──────────────────────────────────────────────────────────
async function loadFolders() {
try {
const resp = await API.listFolders();
App.folders = (resp.folders || []).map(f => ({
id: f.id,
name: f.name,
sortOrder: f.sort_order || 0,
}));
App.folders.sort((a, b) => a.sortOrder - b.sortOrder);
} catch (e) {
console.warn('[folders] load failed:', e.message);
App.folders = [];
}
}
// ── Channels (DMs + named channels) ──────────────────────────────────
async function loadChannels() {
try {
const resp = await API.listSidebarChannels();
App.channels = (resp.channels || []).map(normalizeChannel);
if (typeof UI !== 'undefined') UI.renderChannelsSection();
} catch (e) {
console.warn('[channels] load failed:', e.message);
App.channels = [];
}
}
function normalizeChannel(ch) {
return {
id: ch.id,
name: ch.title || ch.name || 'Untitled',
type: ch.type, // 'dm' | 'channel'
aiMode: ch.ai_mode || 'auto',
topic: ch.topic || '',
unread: ch.unread_count || 0,
dmPartnerId: ch.dm_partner_id || null,
updatedAt: ch.updated_at,
};
}
async function selectChannel(channelId) {
App.currentChannelId = channelId;
App.currentChatId = null; // clear chat selection
if (typeof UI !== 'undefined') {
UI.renderChannelsSection();
UI.renderChatList();
}
// Load messages for this channel
try {
const resp = await API._get(`/api/v1/channels/${channelId}/messages`);
const messages = resp.messages || [];
if (typeof UI !== 'undefined') UI.renderMessages(messages);
} catch (e) {
console.warn('[channel] message load failed:', e.message);
}
try {
sessionStorage.setItem('cs-active-channel', channelId);
} catch (_) {}
}
async function newChannelOrDM() {
const result = await _showCreationDialog({
title: 'New Channel',
placeholder: 'Channel name',
withColor: false,
});
if (!result) return;
try {
const raw = await API.createChannel(result.name, '', '', 'channel');
const ch = normalizeChannel(raw);
App.channels = App.channels || [];
App.channels.push(ch);
if (typeof UI !== 'undefined') UI.renderChannelsSection();
UI.toast(`#${result.name} created`, 'success');
} catch (e) {
UI.toast('Failed to create channel', 'error');
console.error('[newChannelOrDM]', e);
}
}
async function newFolder() {
const result = await _showCreationDialog({
title: 'New Folder',
placeholder: 'Folder name',
withColor: false,
});
if (!result) return;
try {
const resp = await API.createFolder(result.name, App.folders.length);
const f = resp.folder || resp;
App.folders.push({
id: f.id,
name: f.name,
sortOrder: f.sort_order ?? App.folders.length,
});
App.folders.sort((a, b) => a.sortOrder - b.sortOrder);
if (typeof UI !== 'undefined') UI.renderChatList();
UI.toast('Folder created', 'success');
} catch (e) {
UI.toast('Failed to create folder', 'error');
console.error('[newFolder]', e);
}
}
function showFolderMenu(e, folderId) {
e.preventDefault();
e.stopPropagation();
dismissChatContextMenu();
const menu = document.createElement('div');
menu.className = 'project-ctx-menu';
menu.innerHTML = `
<button class="ctx-item" onclick="renameFolder('${folderId}');dismissChatContextMenu()">
<span class="ctx-icon">✏</span> Rename
</button>
<div class="ctx-divider"></div>
<button class="ctx-item ctx-danger" onclick="deleteFolder('${folderId}');dismissChatContextMenu()">
<span class="ctx-icon">🗑</span> Delete folder
</button>`;
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);
})();

View File

@@ -282,167 +282,268 @@ const UI = {
document.getElementById('userFlyout').classList.remove('open'); document.getElementById('userFlyout').classList.remove('open');
}, },
// ── Chat List ──────────────────────────── // ── Sidebar Section Collapse ──────────────
// State persisted in localStorage per section key.
renderChatList() { _sidebarSections: null,
const el = document.getElementById('chatHistory');
const searchInput = document.getElementById('chatSearchInput');
const searchWrap = document.getElementById('sidebarSearch');
const query = (searchInput?.value || '').trim().toLowerCase();
// Toggle clear button visibility _getSidebarSections() {
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0); if (!this._sidebarSections) {
try {
// Filter chats by search query this._sidebarSections = JSON.parse(localStorage.getItem('cs-sidebar-sections') || '{}');
let chats = App.chats; } catch (_) { this._sidebarSections = {}; }
if (query) {
chats = chats.filter(c =>
(c.title || '').toLowerCase().includes(query)
);
} }
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 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) { if (visible.length === 0 && !hasArchived) {
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`; el.innerHTML = collapsed ? '' :
return; '<div class="sb-section-empty">No projects yet</div>';
}
if (chats.length === 0 && projects.length === 0) {
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
return; return;
} }
let html = ''; let html = '';
visible.forEach(proj => {
const isCollapsed = App.collapsedProjects?.[proj.id];
const isActive = App.activeProjectId === proj.id;
const dot = proj.color
? `<span class="sb-proj-dot" style="background:${esc(proj.color)}"></span>`
: '<span class="sb-proj-dot"></span>';
const arrow = isCollapsed ? '▸' : '▾';
const pinIcon = isActive ? '<span class="sb-proj-pin" title="Pinned for new chats">📍</span>' : '';
const chats = (App.chats || []).filter(c => c.projectId === proj.id);
// ── Chat item renderer ────────────────── html += `<div class="sb-proj-group${isActive ? ' active' : ''}${proj.isArchived ? ' archived' : ''}"
const renderChatItem = (c) => { ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
ondrop="onProjectDrop(event,'${proj.id}')">
<div class="sb-proj-header" onclick="toggleProjectCollapse('${proj.id}')">
${collapsed ? dot : `<span class="sb-proj-arrow">${arrow}</span>${dot}<span class="sb-proj-name">${esc(proj.name)}</span>${pinIcon}`}
${!collapsed ? `<span class="sb-proj-count">${chats.length}</span>
<button class="sb-proj-menu" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Options">⋯</button>` : ''}
</div>`;
if (!isCollapsed && !collapsed) {
if (chats.length === 0) {
html += '<div class="sb-proj-empty">Drop chats here</div>';
} else {
chats.forEach(c => { html += this._chatItemHTML(c); });
}
}
html += '</div>';
});
if (hasArchived && !collapsed) {
html += `<button class="sb-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderProjectsSection()">
${showArchived ? '▾ Hide archived' : `▸ Show archived (${projects.filter(p => p.isArchived).length})`}
</button>`;
}
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 ? '' :
'<div class="sb-section-empty">No channels yet</div>';
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
? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>`;
const onlineDot = isDM && online
? '<span class="sb-presence online"></span>'
: '';
const unreadBadge = ch.unread > 0
? `<span class="sb-unread">${ch.unread > 99 ? '99+' : ch.unread}</span>`
: '';
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
onclick="selectChannel('${ch.id}')"
title="${esc(ch.name)}">
<span class="sb-ch-icon">${icon}</span>
${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge}` : unreadBadge}
</div>`;
});
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
? `<div class="sb-section-empty">No chats matching "${esc(query)}"</div>`
: '<div class="sb-section-empty">No chats yet</div>';
// 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 += `<div class="sb-folder-group">
<div class="sb-folder-header" onclick="UI.toggleFolder('${f.id}')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
<span class="sb-folder-name">${esc(f.name)}</span>
<span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span>
<button class="sb-folder-menu" onclick="event.stopPropagation();showFolderMenu(event,'${f.id}')" title="Folder options">⋯</button>
</div>
${isOpen
? (items.length > 0
? items.map(c => this._chatItemHTML(c, true)).join('')
: '<div class="sb-folder-empty">Drop chats here</div>')
: ''}
</div>`;
});
// 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 += `<div class="chat-group-label">${label}</div>`;
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 time = _relativeTime(c.updatedAt);
const typeIcon = c.type === 'group' ? '<span class="chat-type-icon" title="Group Chat">👥</span> ' const active = c.id === App.currentChatId ? ' active' : '';
const indent = indented ? ' indented' : '';
const typeIcon = c.type === 'group'
? '<span class="chat-type-icon" title="Group Chat">👥</span> '
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : ''; : c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
return ` return `<div class="chat-item${active}${indent}"
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
onclick="selectChat('${c.id}')" onclick="selectChat('${c.id}')"
oncontextmenu="showChatContextMenu(event,'${c.id}')" oncontextmenu="showChatContextMenu(event,'${c.id}')"
draggable="true" draggable="true"
ondragstart="onChatDragStart(event,'${c.id}')" ondragstart="onChatDragStart(event,'${c.id}')"
ondragend="onChatDragEnd(event)"> ondragend="onChatDragEnd(event)">
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${typeIcon}${esc(c.title)}</span> <span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();"
title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
<span class="chat-item-time">${time}</span> <span class="chat-item-time">${time}</span>
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button> <button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
</div>`; </div>`;
};
// ── 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
? `<span class="project-dot" style="background:${esc(proj.color)}"></span>`
: '<span class="project-dot"></span>';
const arrow = isCollapsed ? '▸' : '▾';
const isActive = App.activeProjectId === proj.id;
const groupClasses = 'project-group' + (isActive ? ' active' : '') + (proj.isArchived ? ' archived' : '');
html += `<div class="${groupClasses}"
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
ondrop="onProjectDrop(event,'${proj.id}')">
<div class="project-header" onclick="toggleProjectCollapse('${proj.id}')">
<span class="project-arrow">${arrow}</span>
${colorDot}
<span class="project-name">${esc(proj.name)}</span>
${isActive ? '<span class="project-pin" title="Active project">📌</span>' : ''}
<span class="project-count">${projChats.length}</span>
<button class="project-menu-btn" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Project options">⋯</button>
</div>`;
if (!isCollapsed) {
if (projChats.length === 0) {
html += '<div class="project-empty">Drop chats here</div>';
} else {
projChats.forEach(c => { html += renderChatItem(c); });
}
}
html += '</div>';
});
// "Show archived" toggle
if (hasArchived) {
html += `<button class="project-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderChatList()">
${showArchived ? '▾ Hide archived' : '▸ Show archived (' + projects.filter(p => p.isArchived).length + ')'}
</button>`;
}
}
// ── 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 += `<div class="recent-section"
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
ondrop="onRecentDrop(event)">
<div class="chat-group-label" style="padding-top:8px">Recent</div>`;
if (recentChats.length === 0) {
html += '<div class="project-empty">Drop chats here to unassign</div>';
}
}
}
// 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 += `<div class="chat-group-label chat-time-label">${label}</div>`;
} else {
html += `<div class="chat-group-label">${label}</div>`;
}
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 += '</div>'; // close recent-section
} else if (visibleProjects.length > 0 && recentChats.length === 0) {
html += '</div>';
}
el.innerHTML = html;
}, },
// ── Messages ───────────────────────────── // ── Messages ─────────────────────────────

View File

@@ -792,6 +792,74 @@ function showConfirm(message, opts = {}) {
}); });
} }
// §8b PROMPT DIALOG
// ══════════════════════════════════════════════════════════════════════════════
/**
* Custom prompt dialog replacing native prompt().
* Returns a Promise<string|null> — 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<string|null>}
*/
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 = `
<div class="confirm-dialog">
<div class="confirm-header">${esc(title)}</div>
${label ? `<div class="confirm-body">${esc(label)}</div>` : ''}
<div class="prompt-input-wrap">
<input class="prompt-input" type="${esc(iType)}"
value="${esc(defVal)}" placeholder="${esc(ph)}" autocomplete="off">
</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
<button class="btn-small btn-primary" data-action="ok">${esc(okText)}</button>
</div>
</div>`;
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 ──────────────────────── // ── Modal open/close ────────────────────────
// Generic modal show/hide used by settings, admin, debug, team-admin modals. // 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. // Moved here from app.js so all surfaces (not just chat) can use modals.

View File

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

View File

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

View File

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