Changeset 0.23.1 (#154)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,25 +1,18 @@
|
||||
version: '3.8'
|
||||
# docker-compose.yml — Local development (SQLite)
|
||||
#
|
||||
# Single container: Go backend + nginx frontend.
|
||||
# No Postgres, no sidecar, no PVC — just a mounted data directory.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose up --build
|
||||
# open http://localhost:3000
|
||||
#
|
||||
# Data persists in ./data/ between restarts.
|
||||
# To reset: rm -rf ./data/
|
||||
#
|
||||
# For Postgres / multi-replica / k8s deployment see k8s/ and server/Dockerfile + Dockerfile.frontend.
|
||||
|
||||
services:
|
||||
# PostgreSQL database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: chat-switchboard-db
|
||||
environment:
|
||||
POSTGRES_USER: switchboard
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-switchboard_dev}
|
||||
POSTGRES_DB: chat_switchboard
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U switchboard -d chat_switchboard"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Chat Switchboard (unified: Go backend + nginx frontend)
|
||||
switchboard:
|
||||
build:
|
||||
context: .
|
||||
@@ -28,36 +21,15 @@ services:
|
||||
environment:
|
||||
PORT: "8080"
|
||||
BASE_PATH: ""
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_USER: switchboard
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-switchboard_dev}
|
||||
POSTGRES_DB: chat_switchboard
|
||||
POSTGRES_SSLMODE: disable
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
DB_DRIVER: sqlite
|
||||
DATABASE_URL: /data/switchboard.db
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-for-production}
|
||||
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
# File storage — mount ./data/storage on host
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
volumes:
|
||||
- switchboard_storage:/data/storage
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# Adminer for database management (optional, dev only)
|
||||
adminer:
|
||||
image: adminer:latest
|
||||
container_name: chat-switchboard-adminer
|
||||
ports:
|
||||
- "8081:8080"
|
||||
depends_on:
|
||||
- postgres
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
switchboard_storage:
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1449,6 +1449,7 @@ based on need.
|
||||
- Plugin/extension marketplace
|
||||
- Virtual scroll for long conversations
|
||||
- ~~SQLite backend option (single-user / dev)~~ → v0.17.1
|
||||
- **Helm chart.** The k8s/ raw manifests with `${VAR}` substitution have served well but are friction for external adopters and make values management manual. A Helm chart wraps the same backend + frontend deployments with a `values.yaml` (replicas, image tags, ingress host, storage class, secret refs, resource limits, feature flags). Target: `helm install switchboard ./chart` for a fresh cluster, `helm upgrade` for rolling deploys. Subcharts for optional Postgres (for dev/test — prod uses external). Candidate for v0.27+ or a parallel track once the core feature set stabilizes.
|
||||
|
||||
**Pane Architecture (Workspace Container)**
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
1029
models/models.go
1029
models/models.go
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,12 @@ RUN go mod download
|
||||
# Copy source and tidy (resolves any go.sum gaps)
|
||||
COPY server/ .
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${APP_VERSION}" -o /bin/switchboard .
|
||||
|
||||
# Copy VERSION from repo root — used by ldflags and copied to runtime image.
|
||||
# If APP_VERSION build-arg was supplied by CI, it takes precedence via ldflags.
|
||||
# If not, $(cat VERSION) reads the file directly so the binary always has a real version.
|
||||
COPY VERSION ./
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION)" -o /bin/switchboard .
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
@@ -31,6 +36,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
|
||||
COPY --from=builder /app/database/migrations /app/database/migrations
|
||||
|
||||
# VERSION file at /VERSION — version.go init() reads it as fallback for local/dev runs
|
||||
COPY --from=builder /app/VERSION /VERSION
|
||||
|
||||
# Builtin extensions (seeded into DB on startup)
|
||||
COPY extensions/builtin/ /app/extensions/builtin/
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ import (
|
||||
// DB is the application-wide database connection pool.
|
||||
var DB *sql.DB
|
||||
|
||||
// rawDSN holds the Postgres connection string for LISTEN/NOTIFY consumers.
|
||||
// Empty when running SQLite.
|
||||
var rawDSN string
|
||||
|
||||
// DSN returns the raw Postgres connection string, or "" for SQLite.
|
||||
// Used by the event broadcast layer to open a dedicated listener connection.
|
||||
func DSN() string { return rawDSN }
|
||||
|
||||
// Connect opens a connection pool to the configured database.
|
||||
// Driver is selected by DB_DRIVER env var: "postgres" (default) or "sqlite".
|
||||
// For SQLite the DATABASE_URL is treated as a file path; ":memory:" is supported.
|
||||
@@ -45,6 +53,7 @@ func connectPostgres(dsn string) error {
|
||||
|
||||
// Log the database name for operational clarity (never log credentials).
|
||||
dbName = extractDBName(dsn)
|
||||
rawDSN = dsn
|
||||
log.Printf("Connecting to Postgres database %q ...", dbName)
|
||||
|
||||
var err error
|
||||
|
||||
46
server/database/migrations/016_v023_multiuser.sql
Normal file
46
server/database/migrations/016_v023_multiuser.sql
Normal 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';
|
||||
23
server/database/migrations/sqlite/016_v023_multiuser.sql
Normal file
23
server/database/migrations/sqlite/016_v023_multiuser.sql
Normal 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);
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
// Handlers subscribe to patterns (exact or trailing wildcard).
|
||||
// Publishing fans out to all matching subscribers.
|
||||
type Bus struct {
|
||||
mu sync.RWMutex
|
||||
subs map[string][]*subscription
|
||||
seq uint64 // subscription ID counter
|
||||
mu sync.RWMutex
|
||||
subs map[string][]*subscription
|
||||
seq uint64 // subscription ID counter
|
||||
broadcastHook func(Event) // called after Publish for cross-pod fan-out; nil-safe
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
@@ -28,6 +29,15 @@ func NewBus() *Bus {
|
||||
}
|
||||
}
|
||||
|
||||
// SetBroadcastHook registers a function called after every Publish.
|
||||
// Used by the Postgres LISTEN/NOTIFY adapter to fan out across pods.
|
||||
// The hook is NOT called by publishLocal (avoids re-broadcast loops).
|
||||
func (b *Bus) SetBroadcastHook(fn func(Event)) {
|
||||
b.mu.Lock()
|
||||
b.broadcastHook = fn
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// Subscribe registers a handler for a label pattern.
|
||||
// Returns an unsubscribe function.
|
||||
//
|
||||
@@ -60,7 +70,22 @@ func (b *Bus) Subscribe(pattern string, handler Handler) func() {
|
||||
// Publish dispatches an event to all matching subscribers.
|
||||
// Handlers are called synchronously in subscription order.
|
||||
// Use PublishAsync for non-blocking dispatch.
|
||||
// After local dispatch, calls the broadcastHook if set (Postgres fan-out).
|
||||
func (b *Bus) Publish(event Event) {
|
||||
b.publishLocal(event)
|
||||
|
||||
b.mu.RLock()
|
||||
hook := b.broadcastHook
|
||||
b.mu.RUnlock()
|
||||
if hook != nil {
|
||||
hook(event)
|
||||
}
|
||||
}
|
||||
|
||||
// publishLocal dispatches to local subscribers only, without triggering
|
||||
// the broadcastHook. Used by the Postgres listener to re-publish remote
|
||||
// events without causing an infinite re-broadcast loop.
|
||||
func (b *Bus) publishLocal(event Event) {
|
||||
b.mu.RLock()
|
||||
var matched []Handler
|
||||
for pattern, subs := range b.subs {
|
||||
@@ -79,6 +104,7 @@ func (b *Bus) Publish(event Event) {
|
||||
|
||||
// PublishAsync dispatches an event to all matching subscribers
|
||||
// in separate goroutines. Useful for I/O-heavy handlers.
|
||||
// Also calls the broadcastHook asynchronously if set.
|
||||
func (b *Bus) PublishAsync(event Event) {
|
||||
b.mu.RLock()
|
||||
var matched []Handler
|
||||
@@ -89,11 +115,15 @@ func (b *Bus) PublishAsync(event Event) {
|
||||
}
|
||||
}
|
||||
}
|
||||
hook := b.broadcastHook
|
||||
b.mu.RUnlock()
|
||||
|
||||
for _, h := range matched {
|
||||
go h(event)
|
||||
}
|
||||
if hook != nil {
|
||||
go hook(event)
|
||||
}
|
||||
}
|
||||
|
||||
// match checks if a concrete label matches a subscription pattern.
|
||||
|
||||
130
server/events/pg_broadcast.go
Normal file
130
server/events/pg_broadcast.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
archived := c.DefaultQuery("archived", "false")
|
||||
folder := c.Query("folder")
|
||||
channelType := c.DefaultQuery("type", "") // empty = all types
|
||||
channelTypes := c.QueryArray("types") // v0.23.1: multi-value, e.g. ?types=dm&types=channel
|
||||
// Comma-joined convenience: ?types=dm,channel
|
||||
if len(channelTypes) == 0 && c.Query("types") != "" {
|
||||
channelTypes = strings.Split(c.Query("types"), ",")
|
||||
}
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
projectFilter := c.Query("project_id") // "uuid" or "none"
|
||||
|
||||
@@ -148,7 +153,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
countArgs := []interface{}{userID, archived == "true"}
|
||||
argN := 3
|
||||
|
||||
if channelType != "" {
|
||||
if len(channelTypes) > 0 {
|
||||
placeholders := make([]string, len(channelTypes))
|
||||
for i, t := range channelTypes {
|
||||
placeholders[i] = "$" + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, strings.TrimSpace(t))
|
||||
argN++
|
||||
}
|
||||
countQuery += " AND type IN (" + strings.Join(placeholders, ",") + ")"
|
||||
} else if channelType != "" {
|
||||
countQuery += ` AND type = $` + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, channelType)
|
||||
argN++
|
||||
@@ -193,7 +206,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
args := []interface{}{userID, archived == "true"}
|
||||
argN = 3
|
||||
|
||||
if channelType != "" {
|
||||
if len(channelTypes) > 0 {
|
||||
placeholders := make([]string, len(channelTypes))
|
||||
for i, t := range channelTypes {
|
||||
placeholders[i] = "$" + strconv.Itoa(argN)
|
||||
args = append(args, strings.TrimSpace(t))
|
||||
argN++
|
||||
}
|
||||
query += " AND c.type IN (" + strings.Join(placeholders, ",") + ")"
|
||||
} else if channelType != "" {
|
||||
query += ` AND c.type = $` + strconv.Itoa(argN)
|
||||
args = append(args, channelType)
|
||||
argN++
|
||||
|
||||
@@ -209,6 +209,27 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ai_mode guard (v0.23.1) ────────────────────────────────────────────────
|
||||
// Check channel ai_mode before doing any work. For DM channels with
|
||||
// mention_only mode and no @mention in the content, persist the message
|
||||
// but skip the completion entirely.
|
||||
{
|
||||
var aiMode string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&aiMode)
|
||||
|
||||
if aiMode == "off" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "AI responses are disabled for this channel"})
|
||||
return
|
||||
}
|
||||
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
|
||||
// Deliver message without triggering completion
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
|
||||
var personaSystemPrompt string
|
||||
var personaID string // tracks active persona for KB scoping
|
||||
@@ -386,10 +407,26 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// Resolve effective workspace: channel.workspace_id > project.workspace_id
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||
|
||||
// ── @mention routing (v0.23.0) ──────────────
|
||||
// Resolve @model-id or @persona-handle directly against the enabled
|
||||
// model catalog and personas table. Works in ANY chat — no roster needed.
|
||||
if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" {
|
||||
// ── @mention routing (v0.23.0 / v0.23.1) ─────────────────────────────────
|
||||
// Resolve @persona-handle, @model-id, or @username.
|
||||
// - User mention → skip completion, notify recipient (v0.23.1)
|
||||
// - AI mention → override model/persona for this turn (v0.23.0)
|
||||
mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content)
|
||||
|
||||
if mentionedUserID != "" {
|
||||
// Human @mention — message already persisted; notify, skip AI.
|
||||
if hub, ok := c.Get("events_hub"); ok {
|
||||
if h2, ok2 := hub.(interface {
|
||||
NotifyUserMention(toUser, channel, fromUser string)
|
||||
}); ok2 {
|
||||
h2.NotifyUserMention(mentionedUserID, channelID, userID)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
|
||||
return
|
||||
}
|
||||
|
||||
if mentionModel != "" {
|
||||
req.Model = mentionModel
|
||||
if mentionConfig != "" {
|
||||
req.ProviderConfigID = mentionConfig
|
||||
@@ -1127,15 +1164,20 @@ func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPerso
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Persona handle (exact match, then prefix)
|
||||
// 2. Model ID in enabled catalog (exact match, then prefix)
|
||||
// 2. User handle/username (exact, then prefix) — v0.23.1
|
||||
// When a user is @mentioned in a DM/channel, skip completion and deliver
|
||||
// a notification instead. Returns non-empty mentionedUserID in that case.
|
||||
// 3. Model ID in enabled catalog (exact match, then prefix)
|
||||
//
|
||||
// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found.
|
||||
// Returns (modelID, providerConfigID, *Persona, mentionedUserID).
|
||||
// Exactly one of (modelID, mentionedUserID) will be non-empty on a successful
|
||||
// match, or all empty if no @mention found/resolved.
|
||||
|
||||
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) {
|
||||
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona, string) {
|
||||
// Extract first @token
|
||||
token := extractFirstMention(content)
|
||||
if token == "" {
|
||||
return "", "", nil
|
||||
return "", "", nil, ""
|
||||
}
|
||||
|
||||
// Normalize: lowercase, hyphens
|
||||
@@ -1155,7 +1197,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
cfgID = *p.ProviderConfigID
|
||||
}
|
||||
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID)
|
||||
return p.BaseModelID, cfgID, p
|
||||
return p.BaseModelID, cfgID, p, ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1177,13 +1219,42 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
cfgID = *p.ProviderConfigID
|
||||
}
|
||||
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID)
|
||||
return p.BaseModelID, cfgID, p
|
||||
return p.BaseModelID, cfgID, p, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try model_id in enabled catalog (exact)
|
||||
// 3. Try username (exact match) — v0.23.1
|
||||
// Only resolves to a user if the caller is not the same user (no self-mention)
|
||||
var mentionedUserID string
|
||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(username) = $1 AND id != $2 AND is_approved = true
|
||||
LIMIT 1
|
||||
`), normalized, userID).Scan(&mentionedUserID)
|
||||
if err == nil && mentionedUserID != "" {
|
||||
log.Printf("[mention] @%s → user %s", token, mentionedUserID)
|
||||
return "", "", nil, mentionedUserID
|
||||
}
|
||||
|
||||
// 4. Try username (prefix — unambiguous only) — v0.23.1
|
||||
var userCount int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(*) FROM users
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
|
||||
`), normalized+"%", userID).Scan(&userCount)
|
||||
if userCount == 1 {
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
|
||||
LIMIT 1
|
||||
`), normalized+"%", userID).Scan(&mentionedUserID)
|
||||
if mentionedUserID != "" {
|
||||
log.Printf("[mention] @%s → user prefix %s", token, mentionedUserID)
|
||||
return "", "", nil, mentionedUserID
|
||||
}
|
||||
}
|
||||
var modelID, provConfigID string
|
||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT mc.model_id, mc.provider_config_id
|
||||
@@ -1198,10 +1269,10 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
`), normalized).Scan(&modelID, &provConfigID)
|
||||
if err == nil && modelID != "" {
|
||||
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID)
|
||||
return modelID, provConfigID, nil
|
||||
return modelID, provConfigID, nil, ""
|
||||
}
|
||||
|
||||
// 4. Try model_id prefix (unambiguous)
|
||||
// 5. Try model_id prefix (unambiguous)
|
||||
var prefixCount int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(DISTINCT mc.model_id)
|
||||
@@ -1224,11 +1295,11 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
`), normalized+"%").Scan(&modelID, &provConfigID)
|
||||
if modelID != "" {
|
||||
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID)
|
||||
return modelID, provConfigID, nil
|
||||
return modelID, provConfigID, nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
return "", "", nil, ""
|
||||
}
|
||||
|
||||
// extractFirstMention finds the first @token in content.
|
||||
|
||||
@@ -49,7 +49,7 @@ func (h *CompletionHandler) chainIfMentioned(
|
||||
log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth)
|
||||
|
||||
// Use the same resolveMention() that handles user @mentions
|
||||
mentionModel, mentionConfig, mentionPersona := h.resolveMention(ctx, userID, responseContent)
|
||||
mentionModel, mentionConfig, mentionPersona, _ := h.resolveMention(ctx, userID, responseContent)
|
||||
if mentionModel == "" {
|
||||
log.Printf("[chain] @%s did not resolve to any model", token)
|
||||
return
|
||||
|
||||
146
server/handlers/folders.go
Normal file
146
server/handlers/folders.go
Normal 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})
|
||||
}
|
||||
69
server/handlers/presence.go
Normal file
69
server/handlers/presence.go
Normal 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})
|
||||
}
|
||||
@@ -198,6 +198,10 @@ func main() {
|
||||
// ── EventBus (created early — needed by role resolver) ──
|
||||
bus := events.NewBus()
|
||||
|
||||
// Wire Postgres LISTEN/NOTIFY for cross-pod event fan-out (multi-replica).
|
||||
// No-op when running SQLite — in-process Bus is sufficient for single-pod.
|
||||
events.StartPGBroadcast(bus)
|
||||
|
||||
// ── Workspace FS (v0.21.0) ──────────────
|
||||
// Provides file operations for workspace storage primitive.
|
||||
// Nil-safe: handler checks wfs != nil before operating.
|
||||
@@ -373,6 +377,17 @@ func main() {
|
||||
protected.PUT("/channels/:id", channels.UpdateChannel)
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
|
||||
// Chat Folders (v0.23.1)
|
||||
folders := handlers.NewFolderHandler()
|
||||
protected.GET("/folders", folders.List)
|
||||
protected.POST("/folders", folders.Create)
|
||||
protected.PUT("/folders/:id", folders.Update)
|
||||
protected.DELETE("/folders/:id", folders.Delete)
|
||||
|
||||
// Presence (v0.23.1)
|
||||
protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat)
|
||||
protected.GET("/presence", handlers.PresenceQuery)
|
||||
|
||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||
chModelH := handlers.NewChannelModelHandler(stores)
|
||||
protected.GET("/channels/:id/models", chModelH.List)
|
||||
|
||||
@@ -88,35 +88,11 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
<span id="brandWordmark" class="brand-text" style="display:none;"></span>
|
||||
</div>
|
||||
|
||||
{{/* New Chat (split button) */}}
|
||||
<div class="split-btn">
|
||||
<button id="newChatBtn" class="split-btn-main sb-btn" title="New chat">
|
||||
<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>
|
||||
</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>
|
||||
{{/* New Chat button */}}
|
||||
<button id="newChatBtn" class="sb-btn" onclick="newChat()" 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>
|
||||
<span class="sb-label">New Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{/* Search (outside sidebar-top, below brand section) */}}
|
||||
@@ -132,22 +108,72 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
</button>
|
||||
|
||||
{{/* Sidebar Tabs (Chats / Files) */}}
|
||||
{{/* Sidebar Tabs (kept hidden — only Files tab used by EditorMode) */}}
|
||||
<div id="sidebarTabs" class="sidebar-tabs" style="display:none;">
|
||||
<button class="sidebar-tab active" data-tab="chats">Chats</button>
|
||||
<button id="sidebarFilesTab" class="sidebar-tab" data-tab="files" style="display:none;">Files</button>
|
||||
</div>
|
||||
|
||||
{{/* Chat list */}}
|
||||
<div id="chatHistory" class="sidebar-chats sidebar-tab-panel" data-tab-panel="chats">
|
||||
{{/* Populated by UI.renderChatList() */}}
|
||||
{{/* Three-section nav: Projects / Channels / 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() */}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Legacy chatHistory id kept for JS compatibility during transition */}}
|
||||
<div id="chatHistory" style="display:none;"></div>
|
||||
|
||||
{{/* Files panel (editor mode) */}}
|
||||
<div class="sidebar-tab-panel" data-tab-panel="files" style="display:none;"></div>
|
||||
|
||||
{{/* User / bottom */}}
|
||||
<div class="sidebar-bottom">
|
||||
{{/* Notes shortcut */}}
|
||||
<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>
|
||||
<span class="sb-label">Notes</span>
|
||||
|
||||
@@ -458,3 +458,39 @@
|
||||
.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; }
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
@@ -522,3 +522,241 @@
|
||||
.admin-content-body { padding: 16px; }
|
||||
.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; }
|
||||
|
||||
@@ -240,6 +240,20 @@ const API = {
|
||||
},
|
||||
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) ────────────────
|
||||
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
|
||||
listWorkspaceFiles(wsId, path = '', recursive = false) {
|
||||
|
||||
@@ -21,6 +21,12 @@ const App = {
|
||||
collapsedProjects: {},
|
||||
activeProjectId: null,
|
||||
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) {
|
||||
if (!id) return null;
|
||||
|
||||
@@ -70,6 +70,10 @@ async function startApp() {
|
||||
UI.restoreSidebar();
|
||||
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)
|
||||
// are registered when messages are first rendered.
|
||||
try {
|
||||
@@ -107,6 +111,8 @@ async function startApp() {
|
||||
console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse');
|
||||
}
|
||||
UI.renderChatList();
|
||||
UI.renderChannelsSection();
|
||||
UI.restoreSidebarSections();
|
||||
UI.updateModelSelector();
|
||||
|
||||
// Restore last-active chat from sessionStorage
|
||||
|
||||
@@ -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() {
|
||||
clearStaged(); // Discard any staged files
|
||||
App.currentChatId = null;
|
||||
@@ -945,34 +939,7 @@ async function switchSibling(messageId, direction) {
|
||||
function _initChatListeners() {
|
||||
// Sidebar
|
||||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('newChatBtn').addEventListener('click', (e) => {
|
||||
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
|
||||
}
|
||||
});
|
||||
document.getElementById('newChatBtn').addEventListener('click', () => newChat());
|
||||
|
||||
// User flyout
|
||||
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||||
|
||||
@@ -681,7 +681,9 @@ const EditorMode = {
|
||||
});
|
||||
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) {
|
||||
@@ -768,7 +770,7 @@ const EditorMode = {
|
||||
// ── New File ─────────────────────────────
|
||||
|
||||
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;
|
||||
// Normalize: strip leading ./ and / — backend expects relative paths
|
||||
const path = name.trim().replace(/^\.?\/+/, '');
|
||||
|
||||
@@ -26,7 +26,7 @@ const Notifications = {
|
||||
|
||||
async _fetchUnreadCount() {
|
||||
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._renderBadge();
|
||||
} catch (e) {
|
||||
@@ -40,7 +40,7 @@ const Notifications = {
|
||||
const unreadOnly = opts.unreadOnly || false;
|
||||
try {
|
||||
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;
|
||||
} catch (e) {
|
||||
return { data: [], total: 0 };
|
||||
@@ -186,7 +186,7 @@ const Notifications = {
|
||||
|
||||
async _markRead(id) {
|
||||
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);
|
||||
const item = this._items.find(n => n.id === id);
|
||||
if (item) item.is_read = true;
|
||||
@@ -196,7 +196,7 @@ const Notifications = {
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
await API._post('/notifications/mark-all-read');
|
||||
await API._post('/api/v1/notifications/mark-all-read');
|
||||
this._unreadCount = 0;
|
||||
this._items.forEach(n => n.is_read = true);
|
||||
this._renderBadge();
|
||||
@@ -312,7 +312,7 @@ const Notifications = {
|
||||
|
||||
async _deleteItem(id) {
|
||||
try {
|
||||
await API._del(`/notifications/${id}`);
|
||||
await API._del(`/api/v1/notifications/${id}`);
|
||||
// Check if it was unread before removal
|
||||
const item = this._panelItems.find(n => n.id === id);
|
||||
if (item && !item.is_read) {
|
||||
|
||||
@@ -166,7 +166,7 @@ const Pages = {
|
||||
},
|
||||
|
||||
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;
|
||||
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
|
||||
if (ok) window.location.reload();
|
||||
|
||||
@@ -50,18 +50,70 @@ function toggleProjectCollapse(projectId) {
|
||||
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, '"')}">
|
||||
${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 ────────────────────────────
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Project name:');
|
||||
if (!name || !name.trim()) return;
|
||||
const result = await _showCreationDialog({
|
||||
title: 'New Project',
|
||||
placeholder: 'Project name',
|
||||
withColor: true,
|
||||
});
|
||||
if (!result) return;
|
||||
try {
|
||||
const p = await API.createProject({ name: name.trim() });
|
||||
const p = await API.createProject({ name: result.name, color: result.color });
|
||||
App.projects.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
color: p.color || null,
|
||||
color: p.color || result.color || null,
|
||||
icon: p.icon || null,
|
||||
channelCount: 0, kbCount: 0, noteCount: 0,
|
||||
isArchived: false,
|
||||
@@ -332,7 +384,7 @@ function showProjectMenu(e, projectId) {
|
||||
menu.className = 'project-ctx-menu';
|
||||
menu.innerHTML = `
|
||||
<button class="ctx-item" onclick="toggleActiveProject('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">${isActive ? '📌' : '📌'}</span> ${activeLabel}
|
||||
<span class="ctx-icon">${isActive ? '📍' : '📌'}</span> ${activeLabel}
|
||||
</button>
|
||||
<button class="ctx-item" onclick="openProjectPanel('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">⚙</span> Project settings
|
||||
@@ -1027,3 +1079,187 @@ async function _moveChatInProject(projectId, chatId, direction) {
|
||||
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);
|
||||
})();
|
||||
|
||||
@@ -282,167 +282,268 @@ const UI = {
|
||||
document.getElementById('userFlyout').classList.remove('open');
|
||||
},
|
||||
|
||||
// ── Chat List ────────────────────────────
|
||||
// ── Sidebar Section Collapse ──────────────
|
||||
// State persisted in localStorage per section key.
|
||||
|
||||
renderChatList() {
|
||||
const el = document.getElementById('chatHistory');
|
||||
const searchInput = document.getElementById('chatSearchInput');
|
||||
const searchWrap = document.getElementById('sidebarSearch');
|
||||
const query = (searchInput?.value || '').trim().toLowerCase();
|
||||
_sidebarSections: null,
|
||||
|
||||
// Toggle clear button visibility
|
||||
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
|
||||
|
||||
// Filter chats by search query
|
||||
let chats = App.chats;
|
||||
if (query) {
|
||||
chats = chats.filter(c =>
|
||||
(c.title || '').toLowerCase().includes(query)
|
||||
);
|
||||
_getSidebarSections() {
|
||||
if (!this._sidebarSections) {
|
||||
try {
|
||||
this._sidebarSections = JSON.parse(localStorage.getItem('cs-sidebar-sections') || '{}');
|
||||
} catch (_) { this._sidebarSections = {}; }
|
||||
}
|
||||
return this._sidebarSections;
|
||||
},
|
||||
|
||||
toggleSidebarSection(key) {
|
||||
const sections = this._getSidebarSections();
|
||||
sections[key] = !sections[key]; // true = collapsed
|
||||
try { localStorage.setItem('cs-sidebar-sections', JSON.stringify(sections)); } catch (_) {}
|
||||
this._applySidebarSection(key, sections[key]);
|
||||
},
|
||||
|
||||
_applySidebarSection(key, collapsed) {
|
||||
const body = document.getElementById('sbBody' + key[0].toUpperCase() + key.slice(1));
|
||||
const arrow = document.getElementById('sbArrow' + key[0].toUpperCase() + key.slice(1));
|
||||
if (body) body.style.display = collapsed ? 'none' : '';
|
||||
if (arrow) arrow.textContent = collapsed ? '▸' : '▾';
|
||||
},
|
||||
|
||||
restoreSidebarSections() {
|
||||
const sections = this._getSidebarSections();
|
||||
['projects', 'channels', 'chats'].forEach(k => {
|
||||
this._applySidebarSection(k, !!sections[k]);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Projects Section ──────────────────────
|
||||
|
||||
renderProjectsSection() {
|
||||
const el = document.getElementById('sbBodyProjects');
|
||||
if (!el) return;
|
||||
|
||||
const projects = App.projects || [];
|
||||
const showArchived = App.showArchivedProjects || false;
|
||||
const visible = showArchived ? projects : projects.filter(p => !p.isArchived);
|
||||
const hasArchived = projects.some(p => p.isArchived);
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const collapsed = sidebar?.classList.contains('collapsed');
|
||||
|
||||
if (chats.length === 0 && projects.length === 0 && query) {
|
||||
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
|
||||
return;
|
||||
}
|
||||
if (chats.length === 0 && projects.length === 0) {
|
||||
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
||||
if (visible.length === 0 && !hasArchived) {
|
||||
el.innerHTML = collapsed ? '' :
|
||||
'<div class="sb-section-empty">No projects yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
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 ──────────────────
|
||||
const renderChatItem = (c) => {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
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> ' : '';
|
||||
return `
|
||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||
html += `<div class="sb-proj-group${isActive ? ' active' : ''}${proj.isArchived ? ' archived' : ''}"
|
||||
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 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> ' : '';
|
||||
return `<div class="chat-item${active}${indent}"
|
||||
onclick="selectChat('${c.id}')"
|
||||
oncontextmenu="showChatContextMenu(event,'${c.id}')"
|
||||
draggable="true"
|
||||
ondragstart="onChatDragStart(event,'${c.id}')"
|
||||
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-time">${time}</span>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</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;
|
||||
<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>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────
|
||||
// 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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user