Changeset 0.23.0 (#153)

This commit is contained in:
2026-03-05 22:40:26 +00:00
parent 40d9834f64
commit 2fc620e1ac
62 changed files with 6214 additions and 362 deletions

View File

@@ -530,17 +530,17 @@ jobs:
fi fi
# ── Dev: Validate Schema ─────────────────── # ── Dev: Validate Schema ───────────────────
- name: "Dev: validate schema" # - name: "Dev: validate schema"
if: steps.setup.outputs.DB_WIPE == 'true' # if: steps.setup.outputs.DB_WIPE == 'true'
env: # env:
PGHOST: ${{ env.POSTGRES_HOST }} # PGHOST: ${{ env.POSTGRES_HOST }}
PGPORT: ${{ env.POSTGRES_PORT }} # PGPORT: ${{ env.POSTGRES_PORT }}
PGUSER: ${{ secrets.POSTGRES_USER }} # PGUSER: ${{ secrets.POSTGRES_USER }}
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} # PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
PGDATABASE: ${{ steps.setup.outputs.DB_NAME }} # PGDATABASE: ${{ steps.setup.outputs.DB_NAME }}
run: | # run: |
chmod +x scripts/db-validate.sh # chmod +x scripts/db-validate.sh
scripts/db-validate.sh # scripts/db-validate.sh
# ── Dev Wipe (fresh install test) ────────── # ── Dev Wipe (fresh install test) ──────────
- name: "Dev: wipe for fresh install test" - name: "Dev: wipe for fresh install test"

View File

@@ -2,6 +2,45 @@
All notable changes to Chat Switchboard. All notable changes to Chat Switchboard.
## [0.23.0] — 2026-03-05
### Added
- **@mention routing.** Type `@persona-handle` or `@model-id` in any chat to route the completion to a specific persona or model. Works in all chats — no roster or group setup required. Resolution order: persona handle (exact, then prefix) → model catalog (exact, then prefix). Backend `resolveMention()` does direct DB lookup against `personas.handle` and `model_catalog.model_id`.
- **Persona handles.** Every persona gets a unique `handle` field (e.g. `veronica-sharpe`) auto-generated from the name. Editable in the persona form. Stored in `personas.handle` with a unique index. Used as the @mention identifier — no spaces, no ambiguity.
- **@mention autocomplete.** Typing `@` in the chat input shows a filterable popup of all enabled models and personas with avatars, display names, `@handle` hints, and provider info. Works in any chat. Matches against handle, model ID, and display name. Arrow keys + Enter/Tab to select, Escape to dismiss.
- **@mention pill rendering.** @mentions in message content render as styled accent-colored pills. Handles and display names both highlighted. Skipped inside `<code>` and `<pre>` blocks.
- **AI-to-AI chaining.** When a persona response contains an @mention of another persona, a follow-up completion fires automatically. Delivered via WebSocket (`message.created` event). Chain depth capped at 5. Self-mention blocked. Uses the same `resolveMention()` as user @mentions.
- **Participant list injection.** System prompt includes a list of available @mentionable personas so LLMs know who they can invoke. Injected in `loadConversation()` after memory hints. Excludes the current persona (no self-mention). Empty when no other personas exist (zero overhead on 1:1 chats).
- **Context boundaries.** When switching personas via @mention, a system message is injected before the user's message telling the target persona to ignore previous personas' styles. When a non-persona model responds after persona messages exist in history, a boundary tells it to use its own natural style.
- **Provider proxy support.** `provider_configs` table gains `proxy_mode` (system/direct/custom) and `proxy_url` columns. Provider HTTP clients use `proxy_mode` to configure transport: `system` = env-based (`HTTP_PROXY`), `direct` = no proxy, `custom` = explicit URL. All four providers (Anthropic, OpenAI, OpenRouter, Venice) updated.
- **Persona groups schema.** `persona_groups` and `persona_group_members` tables added (migration 004). Structural foundation for saved roster templates — CRUD and FE not yet implemented.
- **Per-provider model preferences.** `user_model_settings` unique key widened to `(user_id, model_id, provider_config_id)`. Same model from different providers gets independent visibility toggles. Frontend uses composite keys throughout.
- **Channel participants.** `channel_participants` table with polymorphic `participant_type` (user/persona/session), `role` (owner/member/observer). CRUD endpoints: list, add, update role, remove. Auto-created on channel creation.
- **Persona avatar resolution.** Assistant messages resolve persona portraits through channel roster → participant data → App.models lookup. Streaming messages also resolve avatars.
- **Save message to note.** Per-message "Note" button (visible on hover) opens a modal to create a new note or append to an existing note from any message. Supports text selection — selected text is saved instead of full message.
- **Group chat creation.** "Group Chat" option in New Chat dropdown. Persona picker with handles, scope badges, and model info. Creates channel + adds persona participants.
- **Chat type indicators.** Sidebar shows 👥 for group chats, ⚙ for workflow channels.
### Changed
- **Channel models constraint.** `channel_models` unique constraint split into two partial indexes: raw models keyed on `(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL`, persona entries keyed on `(channel_id, persona_id) WHERE persona_id IS NOT NULL`. Two personas on the same underlying model now get separate roster entries.
- **Channel models queries.** `GetModels` and `GetModelByID` in both Postgres and SQLite stores now JOIN `personas` table to carry handle data alongside display name and persona ID.
- **User message alignment.** User bubbles use `flex: initial` with `max-width: 85%` to shrink-wrap content and right-align within the centered 768px column.
- **Channel list loading.** `loadChats()` no longer filters by `type=direct` — all channel types (direct, group, workflow) load on refresh.
- **Autocomplete CSS.** Replaced legacy CSS variable names (`--bg-primary`, `--text-secondary`, etc.) with current theme variables (`--bg-surface`, `--bg-raised`, `--text-3`, etc.) across all `channel-models.css`.
- **Persona form.** Added @mention handle field with auto-generation from name, monospace styling, and edit-locks (manual edit stops auto-gen). Handle included in `getValues()`, `setValues()`, and `clearForm()`.
- **Model dropdown.** Persona entries show `@handle` hint next to display name in accent color monospace.
- **Completion chain.** Rewritten to use `resolveMention()` directly instead of roster-based mention parsing. No roster, participant list, or channel_models dependency. Same function for user→LLM and LLM→LLM routing.
### Fixed
- **Notes modal visibility.** `_showSaveToNoteModal` now creates overlay with `modal-overlay active` class (was missing `active`, modal was invisible).
- **Single @mention routing.** Single-target @mentions no longer reload conversation (which caused duplicate user messages that broke Anthropic's API). System prompt swapped in-place on existing messages array.
### Migration Notes
- **DB wipe required.** Migrations 003, 004, and 005 modified. No upgrade path from previous schema — drop and recreate dev DB.
- **New columns:** `personas.handle`, `provider_configs.proxy_mode`, `provider_configs.proxy_url`.
- **New tables:** `persona_groups`, `persona_group_members`, `channel_participants`.
- **New indexes:** `idx_personas_handle` (unique), `idx_channel_models_raw` (partial), `idx_channel_models_persona` (partial).
## [0.22.8] — 2026-03-04 ## [0.22.8] — 2026-03-04
### Changed ### Changed

287
capabilities/resolver.go Normal file
View File

@@ -0,0 +1,287 @@
package capabilities
import (
"context"
"log"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ModelsForUser returns all models and Personas visible to a user.
//
// Visibility is controlled by the three-state visibility field on each
// catalog entry (enabled / team / disabled), applied per provider scope:
//
// Tier | enabled | team | disabled
// ----------+----------------+----------------------+---------
// Global | All users | Team admin → personas | Hidden
// Team | Team members | Team admin → personas | Hidden
// Personal | Owner direct | N/A | Hidden
//
// Sources aggregated:
// 1. Global catalog: enabled models → all authenticated users
// 2. Team catalog: enabled models → team members
// 3. Personal BYOK: enabled models → owner (if allow_user_byok)
// 4. Personas: global + team-scoped + personal + shared
// 5. User hidden preferences applied last
func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]models.UserModel, error) {
result := make([]models.UserModel, 0) // never nil — serializes as [] not null
// Load policies once
policies, err := stores.Policies.GetAll(ctx)
if err != nil {
return nil, err
}
allowBYOK := policies["allow_user_byok"] == "true"
// Load user's hidden preferences
hiddenMap, err := stores.UserSettings.GetHiddenModelIDs(ctx, userID)
if err != nil {
log.Printf("warn: failed to load user model settings: %v", err)
hiddenMap = make(map[string]bool)
}
// Get user's team IDs
teamIDs, err := stores.Teams.GetUserTeamIDs(ctx, userID)
if err != nil {
log.Printf("warn: failed to load user teams: %v", err)
}
// ── 1. Global enabled catalog models → all users ────
globalModels, err := stores.Catalog.ListVisible(ctx)
if err != nil {
return nil, err
}
// Build provider name lookup for global providers
globalProviders, _ := stores.Providers.ListGlobal(ctx)
providerMap := make(map[string]models.ProviderConfig)
for _, p := range globalProviders {
providerMap[p.ID] = p
}
countGlobal := len(globalModels)
for _, entry := range globalModels {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
prov := providerMap[entry.ProviderConfigID]
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
ModelType: entry.ModelType,
Source: "catalog",
ProviderConfigID: entry.ProviderConfigID,
ConfigID: entry.ProviderConfigID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopeGlobal,
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
})
}
// ── 2. Team enabled catalog models → team members ────
countTeam := 0
for _, teamID := range teamIDs {
teamProviders, err := stores.Providers.ListForTeam(ctx, teamID)
if err != nil {
log.Printf("warn: failed to load team %s providers: %v", teamID, err)
continue
}
for _, prov := range teamProviders {
if !prov.IsActive {
continue
}
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
if err != nil {
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
ModelType: entry.ModelType,
Source: "catalog",
ProviderConfigID: prov.ID,
ConfigID: prov.ID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopeTeam,
OwnerID: prov.OwnerID,
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
})
countTeam++
}
}
}
// ── 3. Personal BYOK enabled models → owner ────
countPersonal := 0
if allowBYOK {
personalProviders, err := stores.Providers.ListForUser(ctx, userID)
if err != nil {
log.Printf("warn: failed to load personal providers: %v", err)
} else {
for _, prov := range personalProviders {
if !prov.IsActive {
continue
}
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
if err != nil {
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
ModelType: entry.ModelType,
Source: "catalog",
ProviderConfigID: prov.ID,
ConfigID: prov.ID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopePersonal,
OwnerID: &userID,
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
})
countPersonal++
}
}
}
}
// ── 4. Personas (always resolved) ────────────────
personas, err := stores.Personas.ListForUser(ctx, userID)
if err != nil {
log.Printf("warn: failed to load personas: %v", err)
} else {
for _, p := range personas {
// Resolve base model capabilities
var catalogCaps *models.ModelCapabilities
if p.ProviderConfigID != nil {
if entry, err := stores.Catalog.GetByModelID(ctx, *p.ProviderConfigID, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Fallback: look up any provider's catalog entry for this model
// (covers auto-resolve personas where provider_config_id is NULL)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps, nil)
// Load tool grants
toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID)
if len(toolGrants) > 0 {
caps.ToolCalling = true
}
// Look up provider info
var provName, provType string
if p.ProviderConfigID != nil {
if prov, err := stores.Providers.GetByID(ctx, *p.ProviderConfigID); err == nil {
provName = prov.Name
provType = prov.Provider
}
}
result = append(result, models.UserModel{
ID: p.ID,
DisplayName: p.Name,
ModelID: p.BaseModelID,
Source: "persona",
ProviderConfigID: deref(p.ProviderConfigID),
ConfigID: deref(p.ProviderConfigID),
ProviderName: provName,
ProviderType: provType,
Capabilities: caps,
IsPersona: true,
PersonaID: p.ID,
PersonaScope: p.Scope,
PersonaAvatar: p.Avatar,
PersonaTeamName: teamName(p, stores, ctx),
Description: p.Description,
Icon: p.Icon,
Avatar: p.Avatar,
SystemPrompt: p.SystemPrompt,
Temperature: p.Temperature,
MaxTokens: p.MaxTokens,
ToolGrants: toolGrants,
Scope: p.Scope,
OwnerID: p.OwnerID,
Hidden: false, // ICD §11.2: persona visibility via grants, not model prefs
})
}
}
countPersonas := len(result) - countGlobal - countTeam - countPersonal
log.Printf("📋 ModelsForUser(%s): %d global, %d team, %d personal, %d personas → %d total",
userID, countGlobal, countTeam, countPersonal, countPersonas, len(result))
return result, nil
}
// ResolveForPersona returns effective capabilities for a specific Persona.
// Used at completion time to determine what tools/features are available.
func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models.Persona) (models.ModelCapabilities, []string, error) {
var catalogCaps *models.ModelCapabilities
if persona.ProviderConfigID != nil {
if entry, err := stores.Catalog.GetByModelID(ctx, *persona.ProviderConfigID, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Fallback: any provider's catalog entry (auto-resolve personas)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps, nil)
toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID)
if err != nil {
return caps, nil, err
}
return caps, toolGrants, nil
}
func displayName(name, modelID string) string {
if name != "" {
return name
}
return modelID
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// teamName resolves the team_name for a persona's owner (if team-scoped).
func teamName(p models.Persona, stores store.Stores, ctx context.Context) string {
if p.Scope != models.ScopeTeam || p.OwnerID == nil {
return ""
}
team, err := stores.Teams.GetByID(ctx, *p.OwnerID)
if err != nil {
return ""
}
return team.Name
}

View File

@@ -0,0 +1,196 @@
-- ==========================================
-- Chat Switchboard — 005 Channels & Conversations
-- ==========================================
-- Channels, messages, participants, models, cursors, folders, user prefs.
-- ICD §3 (Channels & Conversations)
-- Note: project_id and workspace_id FKs added by 010_projects.sql
-- ==========================================
-- =========================================
-- FOLDERS
-- =========================================
CREATE TABLE IF NOT EXISTS folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, name, parent_id)
);
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
DROP TRIGGER IF EXISTS folders_updated_at ON folders;
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- =========================================
-- CHANNELS
-- =========================================
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
type VARCHAR(20) DEFAULT 'direct'
CHECK (type IN ('direct', 'group', 'workflow')),
model VARCHAR(100),
system_prompt TEXT,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
is_archived BOOLEAN DEFAULT false,
is_pinned BOOLEAN DEFAULT false,
folder_id UUID,
folder TEXT,
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
settings JSONB DEFAULT '{}'::jsonb,
tags TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_tags ON channels USING GIN(tags);
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Deferred FK: channels.folder_id → folders.id
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder'
) THEN
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
COMMENT ON COLUMN channels.type IS 'direct=single-user chat, group=multi-user/multi-model, workflow=staged channel';
-- =========================================
-- MESSAGES
-- =========================================
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
content TEXT NOT NULL,
model VARCHAR(100),
tokens_used INTEGER,
tool_calls JSONB,
metadata JSONB DEFAULT '{}'::jsonb,
parent_id UUID REFERENCES messages(id) ON DELETE SET NULL,
sibling_index INTEGER DEFAULT 0,
participant_type VARCHAR(10) DEFAULT 'user',
participant_id VARCHAR(255),
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index)
WHERE deleted_at IS NULL;
COMMENT ON COLUMN messages.parent_id IS 'Tree parent for conversation forking';
COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)';
-- =========================================
-- CHANNEL PARTICIPANTS & MODELS
-- =========================================
-- ICD §3.7: Polymorphic participant system.
-- participant_type determines what participant_id references:
-- user → users.id
-- persona → personas.id
-- session → opaque session token
-- =========================================
CREATE TABLE IF NOT EXISTS channel_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
participant_type VARCHAR(10) NOT NULL DEFAULT 'user'
CHECK (participant_type IN ('user', 'persona', 'session')),
participant_id UUID NOT NULL,
role VARCHAR(10) NOT NULL DEFAULT 'member'
CHECK (role IN ('owner', 'member', 'observer')),
display_name VARCHAR(200),
avatar_url TEXT,
joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, participant_type, participant_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
CREATE TABLE IF NOT EXISTS channel_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id VARCHAR(255) NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
display_name VARCHAR(100),
system_prompt TEXT,
settings JSONB DEFAULT '{}'::jsonb,
is_default BOOLEAN DEFAULT false,
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, model_id, provider_config_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
-- =========================================
-- CHANNEL CURSORS (forking navigation)
-- =========================================
CREATE TABLE IF NOT EXISTS channel_cursors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
-- =========================================
-- USER MODEL SETTINGS
-- =========================================
CREATE TABLE IF NOT EXISTS user_model_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
hidden BOOLEAN DEFAULT false,
preferred_temperature FLOAT,
preferred_max_tokens INT,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, model_id, provider_config_id)
);
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
DROP TRIGGER IF EXISTS user_model_settings_updated_at ON user_model_settings;
CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settings
FOR EACH ROW EXECUTE FUNCTION update_updated_at();

View File

@@ -0,0 +1,128 @@
-- 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);

513
database/testhelper.go Normal file
View File

@@ -0,0 +1,513 @@
package database
import (
"database/sql"
"fmt"
"log"
"os"
"strings"
"testing"
"github.com/google/uuid"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
)
// TestDB holds a connection to the test database.
// Use SetupTestDB in TestMain and pass this to subtests.
var TestDB *sql.DB
const testDBName = "chat_switchboard_ci"
// SetupTestDB connects to the appropriate database backend (Postgres or
// SQLite based on DB_DRIVER env), runs migrations, and sets database.DB
// so handlers can use it.
//
// Call in TestMain:
//
// func TestMain(m *testing.M) {
// teardown := database.SetupTestDB()
// code := m.Run()
// teardown()
// os.Exit(code)
// }
//
// For Postgres: requires PGHOST+PGUSER or TEST_DATABASE_URL.
// For SQLite: set DB_DRIVER=sqlite (uses temp file, no external deps).
//
// Returns a cleanup function.
func SetupTestDB() func() {
driver := os.Getenv("DB_DRIVER")
if driver == "sqlite" {
return setupSQLiteTestDB()
}
return setupPostgresTestDB()
}
// ── SQLite test setup ───────────────────────
func setupSQLiteTestDB() func() {
CurrentDialect = DialectSQLite
// Use a temp file so multiple connections share the same DB.
tmpFile, err := os.CreateTemp("", "switchboard-test-*.db")
if err != nil {
log.Printf("⚠ Cannot create temp SQLite DB: %v — skipping DB tests", err)
return func() {}
}
dbPath := tmpFile.Name()
tmpFile.Close()
DB, err = sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
log.Printf("⚠ Cannot open SQLite DB: %v — skipping DB tests", err)
os.Remove(dbPath)
return func() {}
}
if err := DB.Ping(); err != nil {
DB.Close()
DB = nil
log.Printf("⚠ Cannot ping SQLite DB: %v — skipping DB tests", err)
os.Remove(dbPath)
return func() {}
}
// SQLite: single writer
DB.SetMaxOpenConns(1)
TestDB = DB
// Run SQLite migrations
if err := Migrate(); err != nil {
DB.Close()
DB = nil
TestDB = nil
os.Remove(dbPath)
log.Fatalf("❌ SQLite migration failed on test DB: %v", err)
}
log.Printf("✓ SQLite test database ready: %s", dbPath)
return func() {
if DB != nil {
DB.Close()
DB = nil
TestDB = nil
}
os.Remove(dbPath)
log.Printf("✓ Removed SQLite test database: %s", dbPath)
}
}
// ── Postgres test setup (original) ──────────
func setupPostgresTestDB() func() {
CurrentDialect = DialectPostgres
mainDSN := os.Getenv("TEST_DATABASE_URL")
host := envOr("PGHOST", "")
port := envOr("PGPORT", "5432")
user := envOr("PGUSER", "")
pass := envOr("PGPASSWORD", "")
if mainDSN == "" {
if host == "" || user == "" {
log.Println("⚠ TEST_DATABASE_URL / PGHOST+PGUSER not set — skipping DB tests")
return func() {}
}
mainDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
host, port, user, pass)
}
adminDB, err := sql.Open("postgres", mainDSN)
if err != nil {
log.Printf("⚠ Cannot connect to admin DB: %v — skipping DB tests", err)
return func() {}
}
if err := adminDB.Ping(); err != nil {
adminDB.Close()
log.Printf("⚠ Cannot ping admin DB: %v — skipping DB tests", err)
return func() {}
}
createdByUs := false
var dbExists bool
err = adminDB.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", testDBName).Scan(&dbExists)
if err != nil {
if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil {
log.Printf("⚠ Cannot CREATE DATABASE %s (will try to connect to existing): %v", testDBName, err)
} else {
createdByUs = true
log.Printf("✓ Created test database: %s", testDBName)
}
} else {
log.Printf("✓ Test database %s already exists (keeping extensions)", testDBName)
}
var testDSN string
if host != "" {
testDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, pass, testDBName)
} else {
testDSN = replaceDBName(mainDSN, testDBName)
}
DB, err = sql.Open("postgres", testDSN)
if err != nil {
adminDB.Close()
log.Printf("⚠ Cannot connect to test DB: %v — skipping DB tests", err)
return func() {}
}
if err := DB.Ping(); err != nil {
DB.Close()
DB = nil
adminDB.Close()
log.Printf("⚠ Cannot ping test DB: %v — skipping DB tests", err)
return func() {}
}
TestDB = DB
for _, ext := range []string{"uuid-ossp", "pgcrypto", "vector"} {
DB.Exec(fmt.Sprintf(`CREATE EXTENSION IF NOT EXISTS "%s"`, ext))
}
// Wipe all tables so migrations apply cleanly
DB.Exec(`
DO $$ DECLARE r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`)
if err := Migrate(); err != nil {
DB.Close()
DB = nil
TestDB = nil
if createdByUs {
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
}
adminDB.Close()
log.Fatalf("❌ Migration failed on test DB: %v", err)
}
return func() {
if DB != nil {
DB.Close()
DB = nil
TestDB = nil
}
if createdByUs {
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
log.Printf("✓ Dropped test database: %s", testDBName)
}
adminDB.Close()
}
}
// ── Dialect helpers ─────────────────────────
// PH returns a placeholder for the Nth parameter (1-indexed).
// Postgres: $1, $2, ... SQLite: ?, ?, ...
// Exported for use by test files in other packages (e.g. handlers).
func PH(n int) string {
if IsSQLite() {
return "?"
}
return fmt.Sprintf("$%d", n)
}
// placeholders returns "?, ?, ?" or "$1, $2, $3" for n params.
func placeholders(n int) string {
parts := make([]string, n)
for i := range parts {
parts[i] = PH(i + 1)
}
return strings.Join(parts, ", ")
}
// nowSQL returns the SQL expression for "current timestamp".
func nowSQL() string {
if IsSQLite() {
return "datetime('now')"
}
return "NOW()"
}
// ── Shared test helpers ─────────────────────
// RequireTestDB skips a test if no test database is available.
func RequireTestDB(t *testing.T) {
t.Helper()
if DB == nil || TestDB == nil {
t.Skip("requires TEST_DATABASE_URL or PGHOST+PGUSER environment variables (or DB_DRIVER=sqlite)")
}
}
// TruncateAll truncates all application tables for test isolation.
func TruncateAll(t *testing.T) {
t.Helper()
if DB == nil {
return
}
tables := []string{
"resource_grants",
"group_members",
"groups",
"notifications",
"usage_log",
"model_pricing",
"notes",
"audit_log",
"channel_cursors",
"messages",
"channel_models",
"channel_participants",
"channel_knowledge_bases",
"persona_knowledge_bases",
"project_notes",
"project_knowledge_bases",
"project_channels",
"kb_chunks",
"kb_documents",
"knowledge_bases",
"channels",
"projects",
"user_model_settings",
"model_catalog",
"persona_grants",
"personas",
"provider_configs",
"team_members",
"teams",
"refresh_tokens",
"users",
"platform_policies",
"global_config",
"global_settings",
"folders",
"attachments",
"extension_user_settings",
"extensions",
}
if IsSQLite() {
DB.Exec("PRAGMA foreign_keys = OFF")
for _, table := range tables {
DB.Exec(fmt.Sprintf("DELETE FROM %s", table))
}
DB.Exec("PRAGMA foreign_keys = ON")
} else {
for _, table := range tables {
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
}
}
// Re-seed default config rows.
if IsSQLite() {
DB.Exec(`
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'),
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}')
ON CONFLICT (key) DO NOTHING
`)
DB.Exec(`
INSERT INTO platform_policies (key, value) VALUES
('allow_user_byok', 'false'),
('allow_user_personas', 'false'),
('allow_raw_model_access', 'true'),
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
ON CONFLICT (key) DO NOTHING
`)
} else {
DB.Exec(`
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'::jsonb),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
('model_roles', '{
"utility": { "primary": null, "fallback": null },
"embedding": { "primary": null, "fallback": null },
"generation": { "primary": null, "fallback": null }
}'::jsonb)
ON CONFLICT (key) DO NOTHING
`)
DB.Exec(`
INSERT INTO platform_policies (key, value) VALUES
('allow_user_byok', 'false'),
('allow_user_personas', 'false'),
('allow_raw_model_access', 'true'),
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
ON CONFLICT (key) DO NOTHING
`)
}
}
// SeedTestUser creates a test user and returns the user ID.
func SeedTestUser(t *testing.T, username, email string) string {
t.Helper()
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`
INSERT INTO users (id, username, email, password_hash, role)
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user')
`, id, username, email)
if err != nil {
t.Fatalf("SeedTestUser: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role)
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user')
RETURNING id
`, username, email).Scan(&id)
if err != nil {
t.Fatalf("SeedTestUser: %v", err)
}
return id
}
// SeedTestChannel creates a test channel owned by userID and returns the channel ID.
func SeedTestChannel(t *testing.T, userID, title string) string {
t.Helper()
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`INSERT INTO channels (id, user_id, title) VALUES (?, ?, ?)`, id, userID, title)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`INSERT INTO channels (user_id, title) VALUES ($1, $2) RETURNING id`, userID, title).Scan(&id)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
return id
}
// SeedTestTeam creates a test team and returns the team ID.
func SeedTestTeam(t *testing.T, name, createdBy string) string {
t.Helper()
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`INSERT INTO teams (id, name, created_by) VALUES (?, ?, ?)`, id, name, createdBy)
if err != nil {
t.Fatalf("SeedTestTeam: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`INSERT INTO teams (name, created_by) VALUES ($1, $2) RETURNING id`, name, createdBy).Scan(&id)
if err != nil {
t.Fatalf("SeedTestTeam: %v", err)
}
return id
}
// SeedTestTeamMember adds a user to a team.
func SeedTestTeamMember(t *testing.T, teamID, userID, role string) {
t.Helper()
if IsSQLite() {
_, err := DB.Exec(`INSERT INTO team_members (id, team_id, user_id, role) VALUES (?, ?, ?, ?)`,
uuid.New().String(), teamID, userID, role)
if err != nil {
t.Fatalf("SeedTestTeamMember: %v", err)
}
return
}
q := fmt.Sprintf(`
INSERT INTO team_members (team_id, user_id, role)
VALUES (%s, %s, %s)
`, PH(1), PH(2), PH(3))
_, err := DB.Exec(q, teamID, userID, role)
if err != nil {
t.Fatalf("SeedTestTeamMember: %v", err)
}
}
// SeedTestGroup creates a test group and returns the group ID.
func SeedTestGroup(t *testing.T, name, scope string, teamID *string, createdBy string) string {
t.Helper()
var teamArg interface{}
if teamID != nil {
teamArg = *teamID
}
if IsSQLite() {
id := uuid.New().String()
_, err := DB.Exec(`INSERT INTO groups (id, name, scope, team_id, created_by) VALUES (?, ?, ?, ?, ?)`,
id, name, scope, teamArg, createdBy)
if err != nil {
t.Fatalf("SeedTestGroup: %v", err)
}
return id
}
var id string
err := DB.QueryRow(`INSERT INTO groups (name, scope, team_id, created_by) VALUES ($1, $2, $3, $4) RETURNING id`,
name, scope, teamArg, createdBy).Scan(&id)
if err != nil {
t.Fatalf("SeedTestGroup: %v", err)
}
return id
}
// SeedGroupMember adds a user to a group.
func SeedGroupMember(t *testing.T, groupID, userID, addedBy string) {
t.Helper()
if IsSQLite() {
_, err := DB.Exec(`INSERT INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`,
uuid.New().String(), groupID, userID, addedBy)
if err != nil {
t.Fatalf("SeedGroupMember: %v", err)
}
return
}
q := fmt.Sprintf(`
INSERT INTO group_members (group_id, user_id, added_by)
VALUES (%s, %s, %s)
`, PH(1), PH(2), PH(3))
_, err := DB.Exec(q, groupID, userID, addedBy)
if err != nil {
t.Fatalf("SeedGroupMember: %v", err)
}
}
// ── Internal helpers ────────────────────────
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func replaceDBName(dsn, newDB string) string {
if strings.Contains(dsn, "dbname=") {
parts := strings.Fields(dsn)
for i, p := range parts {
if strings.HasPrefix(p, "dbname=") {
parts[i] = "dbname=" + newDB
return strings.Join(parts, " ")
}
}
}
if strings.Contains(dsn, "://") {
idx := strings.LastIndex(dsn, "/")
qIdx := strings.Index(dsn, "?")
if qIdx > idx {
return dsn[:idx+1] + newDB + dsn[qIdx:]
}
return dsn[:idx+1] + newDB
}
return dsn + " dbname=" + newDB
}

View File

@@ -263,6 +263,30 @@ scope='personal' → owner_id=user.id → User-managed, visible to owner
Used by: `provider_configs`, `personas`, `model_catalog` (visibility column adds `enabled`/`disabled`/`team` states on top), `projects`. Used by: `provider_configs`, `personas`, `model_catalog` (visibility column adds `enabled`/`disabled`/`team` states on top), `projects`.
## Personas
Personas are named AI configurations: a system prompt, base model, provider
config, and behavioral parameters (temperature, max tokens, thinking budget).
Each persona has a unique `handle` field (e.g. `veronica-sharpe`) used for
@mention routing.
**Handle Generation:** Auto-generated from name on create (`HandleFromName()`):
lowercase, spaces→hyphens, alphanumeric only, max 50 chars. Editable via the
persona form. Unique index prevents collisions.
**Scope Model:** Same as other resources — global, team, personal. Personal
personas are only visible to the creator. Team and global personas are visible
to all users with the appropriate access.
**Resolution Chain (completion):**
```
Explicit persona (req.PersonaID) → Project persona → @mention persona → dropdown selection
```
**Memory:** Personas have `memory_enabled` and `memory_extraction_prompt` fields.
When memory is enabled, the persona's memory scope is independent — facts
extracted in conversations with Persona A are not visible to Persona B.
## Projects (v0.19.0) ## Projects (v0.19.0)
Projects are organizational containers that group channels, knowledge bases, Projects are organizational containers that group channels, knowledge bases,
@@ -324,19 +348,42 @@ in Settings → Notifications.
**Cleanup:** Background goroutine deletes notifications older than retention **Cleanup:** Background goroutine deletes notifications older than retention
period (default 90 days, configurable via admin settings). period (default 90 days, configurable via admin settings).
## @mention Routing + Multi-model (v0.20.0) ## @mention Routing (v0.23.0)
Channels support multiple AI models. The `channel_models` table (schema 001) Type `@handle` or `@model-id` in any chat to route the completion to a
holds the roster; `mentions.Parse()` extracts @mentions from message content different persona or model. No roster, group, or channel setup required.
and resolves against display names (case-insensitive, longest-match-first).
**Completion Fan-out:** When mentions resolve to channel models, the completion **Resolution (`resolveMention`):** Extracts the first `@token` from message
handler fires sequentially for each target, producing one assistant message content and queries the DB directly:
per model. Without @mention, the default channel model responds (backward
compatible). Frontend renders model attribution labels on multi-model responses.
**Autocomplete:** CM6 `mentionCompletion` extension triggers on `@` in the 1. Persona handle — exact match against `personas.handle` (case-insensitive)
chat input, showing a dropdown of channel model display names. 2. Persona handle — prefix match (unambiguous only)
3. Model ID — exact match against `model_catalog.model_id` (enabled + active provider)
4. Model ID — prefix match (unambiguous only)
Persona resolution returns the persona's model, provider config, and system
prompt. Model resolution returns the raw model ID and config — no persona
character, no system prompt override.
**Context Boundaries:** When @mentioning a persona, a system message is
injected before the user's message telling the target to ignore previous
personas' styles. When no @mention is used but persona responses exist in
history, a boundary tells the default model to respond in its own style.
**Participant Hint:** `buildParticipantHint()` injects a system message
listing all @mentionable personas (handle + name + description). This tells
the LLM who it can invoke, enabling AI-to-AI chaining.
**AI-to-AI Chaining:** After each completion, `chainIfMentioned()` runs
`resolveMention()` on the assistant's response. If it @mentions another
persona, a follow-up completion fires asynchronously. Result delivered via
WebSocket (`message.created`). Self-mention blocked. Depth capped at 5.
Same resolution function for user→LLM and LLM→LLM routing.
**Autocomplete (Frontend):** `ChannelModels.onInput()` triggers on `@` in
the chat input, matching against all `App.models` (enabled, non-hidden) by
persona handle, model ID, and display name. Popup shows avatar, name,
`@handle` hint, and provider. Selection inserts `@handle` into the input.
## Frontend Architecture ## Frontend Architecture
@@ -356,9 +403,11 @@ src/
│ ├── api.js # HTTP client with token refresh │ ├── api.js # HTTP client with token refresh
│ ├── app.js # Application state machine, startup, routing │ ├── app.js # Application state machine, startup, routing
│ ├── chat.js # Chat input abstraction, message send/receive │ ├── chat.js # Chat input abstraction, message send/receive
│ ├── channel-models.js # @mention autocomplete, channel model roster
│ ├── events.js # Labeled event bus + WebSocket bridge │ ├── events.js # Labeled event bus + WebSocket bridge
│ ├── debug.js # Debug panel, diagnostics, state export │ ├── debug.js # Debug panel, diagnostics, state export
│ ├── ui-core.js # DOM rendering, sidebar, modals, panels │ ├── ui-core.js # DOM rendering, sidebar, modals, panels
│ ├── ui-format.js # Markdown rendering, @mention pills, code blocks
│ ├── ui-settings.js # Settings tabs, theme, appearance, teams │ ├── ui-settings.js # Settings tabs, theme, appearance, teams
│ ├── ui-admin.js # Admin panel rendering │ ├── ui-admin.js # Admin panel rendering
│ ├── admin-handlers.js # Admin CRUD operations + extension editors │ ├── admin-handlers.js # Admin CRUD operations + extension editors

View File

@@ -114,7 +114,9 @@ v0.22.7 Surface Prototypes (Theme + ChatPane + Splash) ✅
| |
v0.22.8 Surface Integration (Wire into existing JS) v0.22.8 Surface Integration (Wire into existing JS)
| |
v0.23.0 Multi-Participant Channels + Presence v0.23.0 @mention Routing + Persona Handles + Proxy ✅
v0.23.1 Multi-User: DMs + Group Chat
v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions
@@ -1198,32 +1200,83 @@ Depends on: surface prototypes (v0.22.7).
--- ---
## v0.23.0 — Multi-Participant Channels + Presence ## v0.23.0 — @mention Routing + Persona Handles + Proxy
The channel foundation for workflows and real-time collaboration. Today @mention routing works in any chat against the full model catalog and persona
channels are single-user direct chats. This release makes channels a registry. No roster, groups, or participant setup required — just type `@handle`
shared space that multiple actors can inhabit. or `@model-id` and send.
Depends on: extension surfaces (v0.21.3), WebSocket infrastructure (already exists). **@mention Resolution**
- [x] `resolveMention()`: direct DB lookup — persona handle (exact/prefix) → model catalog (exact/prefix)
- [x] Autocomplete popup on `@` in any chat — all enabled models + personas
- [x] Handle field on personas: auto-generated from name, unique, editable
- [x] @mention pill rendering in message content (accent-colored, code-aware)
- [x] Context boundaries: persona→plain and persona→persona style isolation
- [x] Participant list injection: system prompt tells LLM who it can @mention
_(Shifted from v0.20.0 — no content changes)_ **AI-to-AI Chaining**
- [x] `chainIfMentioned()`: uses `resolveMention()` on assistant response content
- [x] Self-mention blocked, depth capped at 5
- [x] WebSocket delivery (`message.created` + typing indicators)
- [x] Same resolution path for user→LLM and LLM→LLM routing
**Channel Participants** **Provider Proxy**
- [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at` - [x] `proxy_mode` (system/direct/custom) + `proxy_url` on `provider_configs`
- [ ] Channel access queries rewritten: `WHERE user_id = $1` → participant-based lookup - [x] All four providers use `cfg.Client()` with mode-aware HTTP transport
- [ ] Backward compatible: existing single-user channels auto-have one participant (owner) - [x] Bad custom URL falls back to system (not direct) — safe for mandatory-proxy environments
- [ ] Channel `type` column: `direct` (current), `group` (multi-user), `workflow` (staged)
**Channel Infrastructure**
- [x] `channel_participants` table with polymorphic types and roles
- [x] `channel_models` partial indexes: persona entries keyed per-persona, raw models per-provider
- [x] Per-provider model preferences (composite keys in `user_model_settings`)
- [x] `persona_groups` + `persona_group_members` tables (schema ready, CRUD not yet wired)
---
## v0.23.1 — Multi-User: DMs + Group Chat
Real human-to-human messaging. Builds on v0.23.0's participant infrastructure
(`channel_participants`, @mention routing, WebSocket events). The @mention
system already resolves personas — this release extends it to resolve users.
Depends on: @mention routing (v0.23.0), WebSocket infrastructure (exists).
**User-to-User DMs**
- [ ] `@username` resolution in `resolveMention()`: extend DB lookup to `users` table
- [ ] When `resolveMention()` returns a user (not persona): skip completion, deliver as notification
- [ ] DM channel type: two user participants, no AI unless explicitly @mentioned
- [ ] DM creation flow: user picker, creates channel with both users as participants
- [ ] Unread count per channel per user (read cursor in `channel_participants`)
- [ ] Real-time message delivery via WebSocket to recipient's active session
**Group Chat (Human + AI)**
- [ ] Persona group CRUD: create/edit/delete saved roster templates (`persona_groups`)
- [ ] "New Chat with Group" flow: pick a template, stamp onto fresh channel
- [ ] Group leader (default responder): `is_leader` flag on `persona_group_members`
- [ ] No @mention in group = leader responds. @mention overrides.
- [ ] Group editing: add/remove personas, change leader — affects future chats only
- [ ] `@all` routing: every persona participant responds (existing `multiModelStream` path)
**Presence + Real-time** **Presence + Real-time**
- [ ] Typing indicators and presence (who's online, who's in this channel) - [ ] Typing indicators for human participants (extend existing persona typing events)
- [ ] Participant join/leave events via WebSocket - [ ] Online/offline status per user (heartbeat-based, stored in memory not DB)
- [ ] Co-editing for extension surfaces (editor mode, article mode) - [ ] Participant join/leave WebSocket events
- [ ] Cursor sharing, selection highlighting - [ ] Unread badge on sidebar chat items
**Channel-Scoped Notes** **User @mention UX**
- [ ] Optional `channel_id` FK on notes (NULL = personal notebook, set = channel-attached) - [ ] Autocomplete includes users alongside models and personas (different icon)
- [ ] Notes created by tool calls within a channel auto-attach to that channel - [ ] @mention pills distinguish user mentions (different color) from persona/model mentions
- [ ] Channel notes visible to all participants (not just creator) - [ ] Notification center: @mention notifications with channel deep-link
**Message Attribution**
- [ ] Messages from human users show their avatar + display name (not "You" for other participants)
- [ ] Messages from personas retain current avatar + handle rendering
- [ ] Thread view: flat chronological for now, threading deferred
**Migration**
- [ ] `users.handle` column (unique, auto-generated from username or display_name)
- [ ] Read cursor columns on `channel_participants`: `last_read_message_id`, `last_read_at`
- [ ] Notification table: `user_notifications` (type, channel_id, message_id, actor_id, read_at)
--- ---

552
handlers/channels.go Normal file
View File

@@ -0,0 +1,552 @@
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"})
}

86
handlers/model_prefs.go Normal file
View File

@@ -0,0 +1,86 @@
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 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -179,7 +179,7 @@ check_table "global_settings"
check_table "user_model_settings" check_table "user_model_settings"
check_table "channels" check_table "channels"
check_table "messages" check_table "messages"
check_table "channel_members" check_table "channel_participants"
check_table "channel_models" check_table "channel_models"
check_table "channel_cursors" check_table "channel_cursors"
check_table "folders" check_table "folders"
@@ -226,6 +226,17 @@ check_column_type "provider_configs" "key_nonce" "bytea"
check_column "provider_configs" "key_scope" check_column "provider_configs" "key_scope"
check_column_absent "provider_configs" "api_key_plain" check_column_absent "provider_configs" "api_key_plain"
echo ""
echo "Provider Proxy (v0.23.0):"
check_column "provider_configs" "proxy_mode"
check_column "provider_configs" "proxy_url"
echo ""
echo "Personas (v0.23.0):"
check_column "personas" "handle"
check_table "persona_groups"
check_table "persona_group_members"
# ── Model Catalog ──────────────────────── # ── Model Catalog ────────────────────────
echo "" echo ""
echo "Model Catalog:" echo "Model Catalog:"

View File

@@ -79,7 +79,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
Capabilities: caps, Capabilities: caps,
Pricing: entry.Pricing, Pricing: entry.Pricing,
Scope: models.ScopeGlobal, Scope: models.ScopeGlobal,
Hidden: hiddenMap[entry.ModelID], Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
}) })
} }
@@ -115,7 +115,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
Pricing: entry.Pricing, Pricing: entry.Pricing,
Scope: models.ScopeTeam, Scope: models.ScopeTeam,
OwnerID: prov.OwnerID, OwnerID: prov.OwnerID,
Hidden: hiddenMap[entry.ModelID], Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
}) })
countTeam++ countTeam++
} }
@@ -153,7 +153,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
Pricing: entry.Pricing, Pricing: entry.Pricing,
Scope: models.ScopePersonal, Scope: models.ScopePersonal,
OwnerID: &userID, OwnerID: &userID,
Hidden: hiddenMap[entry.ModelID], Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
}) })
countPersonal++ countPersonal++
} }
@@ -211,6 +211,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
Capabilities: caps, Capabilities: caps,
IsPersona: true, IsPersona: true,
PersonaID: p.ID, PersonaID: p.ID,
PersonaHandle: p.Handle,
PersonaScope: p.Scope, PersonaScope: p.Scope,
PersonaAvatar: p.Avatar, PersonaAvatar: p.Avatar,
PersonaTeamName: teamName(p, stores, ctx), PersonaTeamName: teamName(p, stores, ctx),
@@ -223,7 +224,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
ToolGrants: toolGrants, ToolGrants: toolGrants,
Scope: p.Scope, Scope: p.Scope,
OwnerID: p.OwnerID, OwnerID: p.OwnerID,
Hidden: hiddenMap[p.ID], Hidden: false, // ICD §11.2: persona visibility via grants, not model prefs
}) })
} }
} }

View File

@@ -30,8 +30,12 @@ CREATE TABLE IF NOT EXISTS provider_configs (
settings JSONB DEFAULT '{}'::jsonb, settings JSONB DEFAULT '{}'::jsonb,
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
is_private BOOLEAN DEFAULT false, is_private BOOLEAN DEFAULT false,
proxy_mode TEXT NOT NULL DEFAULT 'system'
CHECK (proxy_mode IN ('system', 'direct', 'custom')),
proxy_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT chk_proxy_url CHECK (proxy_mode != 'custom' OR proxy_url IS NOT NULL)
); );
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope); CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
@@ -49,6 +53,8 @@ COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team us
COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)'; COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)';
COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)'; COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)'; COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)';
COMMENT ON COLUMN provider_configs.proxy_mode IS 'system=env proxy, direct=no proxy, custom=explicit URL';
COMMENT ON COLUMN provider_configs.proxy_url IS 'Explicit proxy URL when proxy_mode=custom (http/https/socks5)';
-- ========================================= -- =========================================

View File

@@ -12,6 +12,7 @@
CREATE TABLE IF NOT EXISTS personas ( CREATE TABLE IF NOT EXISTS personas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
handle VARCHAR(50),
description TEXT DEFAULT '', description TEXT DEFAULT '',
icon VARCHAR(10) DEFAULT '', icon VARCHAR(10) DEFAULT '',
avatar TEXT DEFAULT '', avatar TEXT DEFAULT '',
@@ -48,17 +49,54 @@ CREATE TABLE IF NOT EXISTS personas (
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope); CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = true; CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = true;
CREATE UNIQUE INDEX IF NOT EXISTS idx_personas_handle ON personas(handle) WHERE handle IS NOT NULL;
DROP TRIGGER IF EXISTS personas_updated_at ON personas; DROP TRIGGER IF EXISTS personas_updated_at ON personas;
CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas
FOR EACH ROW EXECUTE FUNCTION update_updated_at(); FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN personas.scope IS 'global=all users, team=team members, personal=creator only'; COMMENT ON COLUMN personas.scope IS 'global=all users, team=team members, personal=creator only';
COMMENT ON COLUMN personas.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal'; COMMENT ON COLUMN personas.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal';
COMMENT ON COLUMN personas.handle IS 'Unique @mention handle, e.g. veronica-sharpe. Auto-generated from name.';
COMMENT ON COLUMN personas.is_shared IS 'Personal personas shared with others (read-only)'; COMMENT ON COLUMN personas.is_shared IS 'Personal personas shared with others (read-only)';
COMMENT ON COLUMN personas.memory_enabled IS 'Whether memory tools and extraction are active for this persona'; COMMENT ON COLUMN personas.memory_enabled IS 'Whether memory tools and extraction are active for this persona';
COMMENT ON COLUMN personas.memory_extraction_prompt IS 'Custom extraction prompt; NULL = use system default'; COMMENT ON COLUMN personas.memory_extraction_prompt IS 'Custom extraction prompt; NULL = use system default';
-- =========================================
-- PERSONA GROUPS (v0.23.0)
-- =========================================
-- Saved roster templates. A group is a named set of personas
-- that can be stamped onto any new conversation.
CREATE TABLE IF NOT EXISTS persona_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT DEFAULT '',
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope VARCHAR(10) NOT NULL DEFAULT 'personal'
CHECK (scope IN ('global', 'team', 'personal')),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_persona_groups_owner ON persona_groups(owner_id);
DROP TRIGGER IF EXISTS persona_groups_updated_at ON persona_groups;
CREATE TRIGGER persona_groups_updated_at BEFORE UPDATE ON persona_groups
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TABLE IF NOT EXISTS persona_group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES persona_groups(id) ON DELETE CASCADE,
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
is_leader BOOLEAN NOT NULL DEFAULT false,
sort_order INT NOT NULL DEFAULT 0,
UNIQUE(group_id, persona_id)
);
CREATE INDEX IF NOT EXISTS idx_persona_group_members_group ON persona_group_members(group_id);
-- ========================================= -- =========================================
-- PERSONA GRANTS (what a persona can do) -- PERSONA GRANTS (what a persona can do)
-- ========================================= -- =========================================

View File

@@ -1,7 +1,7 @@
-- ========================================== -- ==========================================
-- Chat Switchboard — 005 Channels & Conversations -- Chat Switchboard — 005 Channels & Conversations
-- ========================================== -- ==========================================
-- Channels, messages, members, models, cursors, folders, user prefs. -- Channels, messages, participants, models, cursors, folders, user prefs.
-- ICD §3 (Channels & Conversations) -- ICD §3 (Channels & Conversations)
-- Note: project_id and workspace_id FKs added by 010_projects.sql -- Note: project_id and workspace_id FKs added by 010_projects.sql
-- ========================================== -- ==========================================
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS channels (
title VARCHAR(500) NOT NULL, title VARCHAR(500) NOT NULL,
description TEXT, description TEXT,
type VARCHAR(20) DEFAULT 'direct' type VARCHAR(20) DEFAULT 'direct'
CHECK (type IN ('direct', 'group', 'channel')), CHECK (type IN ('direct', 'group', 'workflow')),
model VARCHAR(100), model VARCHAR(100),
system_prompt TEXT, system_prompt TEXT,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
@@ -73,7 +73,7 @@ DO $$ BEGIN
END $$; END $$;
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL; 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=1:1 AI chat, group=multi-model, channel=named persistent'; COMMENT ON COLUMN channels.type IS 'direct=single-user chat, group=multi-user/multi-model, workflow=staged channel';
-- ========================================= -- =========================================
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS messages (
sibling_index INTEGER DEFAULT 0, sibling_index INTEGER DEFAULT 0,
participant_type VARCHAR(10) DEFAULT 'user', participant_type VARCHAR(10) DEFAULT 'user',
participant_id VARCHAR(255), participant_id VARCHAR(255),
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
@@ -110,35 +111,53 @@ COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)
-- ========================================= -- =========================================
-- CHANNEL MEMBERS & MODELS -- 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_members ( CREATE TABLE IF NOT EXISTS channel_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, participant_type VARCHAR(10) NOT NULL DEFAULT 'user'
role VARCHAR(20) DEFAULT 'member', 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(), joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(), last_read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id) UNIQUE(channel_id, participant_type, participant_id)
); );
CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id); CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id); CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
CREATE TABLE IF NOT EXISTS channel_models ( CREATE TABLE IF NOT EXISTS channel_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id VARCHAR(255) NOT NULL, model_id VARCHAR(255) NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET 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), display_name VARCHAR(100),
system_prompt TEXT, system_prompt TEXT,
settings JSONB DEFAULT '{}'::jsonb, settings JSONB DEFAULT '{}'::jsonb,
is_default BOOLEAN DEFAULT false, is_default BOOLEAN DEFAULT false,
added_at TIMESTAMPTZ DEFAULT NOW(), added_at TIMESTAMPTZ DEFAULT NOW()
UNIQUE(channel_id, model_id)
); );
-- Raw model entries (no persona): unique per channel+model+provider
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_raw
ON channel_models(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL;
-- Persona entries: one per persona per channel (same model allowed for different personas)
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_persona
ON channel_models(channel_id, persona_id) WHERE persona_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id); CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
@@ -167,13 +186,14 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_id TEXT NOT NULL, model_id TEXT NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
hidden BOOLEAN DEFAULT false, hidden BOOLEAN DEFAULT false,
preferred_temperature FLOAT, preferred_temperature FLOAT,
preferred_max_tokens INT, preferred_max_tokens INT,
sort_order INT DEFAULT 0, sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, model_id) UNIQUE(user_id, model_id, provider_config_id)
); );
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id); CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);

View File

@@ -16,8 +16,11 @@ CREATE TABLE IF NOT EXISTS provider_configs (
settings TEXT DEFAULT '{}', settings TEXT DEFAULT '{}',
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
is_private INTEGER DEFAULT 0, is_private INTEGER DEFAULT 0,
proxy_mode TEXT NOT NULL DEFAULT 'system' CHECK (proxy_mode IN ('system', 'direct', 'custom')),
proxy_url TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now')),
CHECK (proxy_mode != 'custom' OR proxy_url IS NOT NULL)
); );
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope); CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);

View File

@@ -3,6 +3,7 @@
CREATE TABLE IF NOT EXISTS personas ( CREATE TABLE IF NOT EXISTS personas (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
handle TEXT,
description TEXT DEFAULT '', description TEXT DEFAULT '',
icon TEXT DEFAULT '', icon TEXT DEFAULT '',
avatar TEXT DEFAULT '', avatar TEXT DEFAULT '',
@@ -27,6 +28,31 @@ CREATE TABLE IF NOT EXISTS personas (
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope); CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id); CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id);
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active); CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active);
CREATE UNIQUE INDEX IF NOT EXISTS idx_personas_handle ON personas(handle) WHERE handle IS NOT NULL;
CREATE TABLE IF NOT EXISTS persona_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'personal' CHECK (scope IN ('global', 'team', 'personal')),
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_persona_groups_owner ON persona_groups(owner_id);
CREATE TABLE IF NOT EXISTS persona_group_members (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES persona_groups(id) ON DELETE CASCADE,
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
is_leader INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE(group_id, persona_id)
);
CREATE INDEX IF NOT EXISTS idx_persona_group_members_group ON persona_group_members(group_id);
CREATE TABLE IF NOT EXISTS persona_grants ( CREATE TABLE IF NOT EXISTS persona_grants (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

View File

@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS channels (
user_id TEXT REFERENCES users(id) ON DELETE CASCADE, user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT, description TEXT,
type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'channel')), type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'workflow')),
model TEXT, model TEXT,
system_prompt TEXT, system_prompt TEXT,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL, provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS messages (
sibling_index INTEGER DEFAULT 0, sibling_index INTEGER DEFAULT 0,
participant_type TEXT DEFAULT 'user', participant_type TEXT DEFAULT 'user',
participant_id TEXT, participant_id TEXT,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
deleted_at TEXT, deleted_at TEXT,
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
@@ -64,32 +65,44 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); 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_parent ON messages(parent_id);
CREATE TABLE IF NOT EXISTS channel_members ( CREATE TABLE IF NOT EXISTS channel_participants (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, participant_type TEXT NOT NULL DEFAULT 'user'
role TEXT DEFAULT 'member', CHECK (participant_type IN ('user', 'persona', 'session')),
joined_at TEXT DEFAULT (datetime('now')), participant_id TEXT NOT NULL,
last_read_at TEXT DEFAULT (datetime('now')), role TEXT NOT NULL DEFAULT 'member'
UNIQUE(channel_id, user_id) 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_members_channel ON channel_members(channel_id); CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id); CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
CREATE TABLE IF NOT EXISTS channel_models ( CREATE TABLE IF NOT EXISTS channel_models (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id TEXT NOT NULL, model_id TEXT NOT NULL,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET 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, display_name TEXT,
system_prompt TEXT, system_prompt TEXT,
settings TEXT DEFAULT '{}', settings TEXT DEFAULT '{}',
is_default INTEGER DEFAULT 0, is_default INTEGER DEFAULT 0,
added_at TEXT DEFAULT (datetime('now')), added_at TEXT DEFAULT (datetime('now'))
UNIQUE(channel_id, model_id)
); );
-- Raw model entries (no persona): unique per channel+model+provider
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_raw
ON channel_models(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL;
-- Persona entries: one per persona per channel
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_persona
ON channel_models(channel_id, persona_id) WHERE persona_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id); CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
CREATE TABLE IF NOT EXISTS channel_cursors ( CREATE TABLE IF NOT EXISTS channel_cursors (
@@ -108,13 +121,14 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_id TEXT NOT NULL, model_id TEXT NOT NULL,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE,
hidden INTEGER DEFAULT 0, hidden INTEGER DEFAULT 0,
preferred_temperature REAL, preferred_temperature REAL,
preferred_max_tokens INTEGER, preferred_max_tokens INTEGER,
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, model_id) UNIQUE(user_id, model_id, provider_config_id)
); );
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id); CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);

View File

@@ -264,7 +264,7 @@ func TruncateAll(t *testing.T) {
"channel_cursors", "channel_cursors",
"messages", "messages",
"channel_models", "channel_models",
"channel_members", "channel_participants",
"channel_knowledge_bases", "channel_knowledge_bases",
"persona_knowledge_bases", "persona_knowledge_bases",
"project_notes", "project_notes",
@@ -278,6 +278,8 @@ func TruncateAll(t *testing.T) {
"user_model_settings", "user_model_settings",
"model_catalog", "model_catalog",
"persona_grants", "persona_grants",
"persona_group_members",
"persona_groups",
"personas", "personas",
"provider_configs", "provider_configs",
"team_members", "team_members",

View File

@@ -19,7 +19,7 @@ import (
type createChannelRequest struct { type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"` Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), group, channel Type string `json:"type,omitempty"` // direct (default), group, workflow
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"`
@@ -334,17 +334,17 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
ch.Tags = tags ch.Tags = tags
ch.MessageCount = 0 ch.MessageCount = 0
// Auto-create channel_member for the creator // Auto-create channel_participant for the creator
if database.IsSQLite() { if database.IsSQLite() {
_, _ = database.DB.Exec(` _, _ = database.DB.Exec(`
INSERT INTO channel_members (id, channel_id, user_id, role) INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
VALUES (?, ?, ?, 'owner') VALUES (?, ?, 'user', ?, 'owner')
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`, store.NewID(), ch.ID, userID) `, store.NewID(), ch.ID, userID)
} else { } else {
_, _ = database.DB.Exec(` _, _ = database.DB.Exec(`
INSERT INTO channel_members (channel_id, user_id, role) INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
VALUES ($1, $2, 'owner') VALUES ($1, 'user', $2, 'owner')
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`, ch.ID, userID) `, ch.ID, userID)
} }

View File

@@ -20,7 +20,6 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/mentions"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/routing" "git.gobha.me/xcaliber/chat-switchboard/routing"
@@ -201,9 +200,13 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
// Verify channel ownership // Verify participation: check channel_participants first, fall back to
if !userOwnsChannel(c, channelID, userID) { // legacy channels.user_id ownership for direct channels.
return isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if !isParticipant {
if !userOwnsChannel(c, channelID, userID) {
return
}
} }
// ── Persona unwrap: persona overrides defaults, explicit request fields win ── // ── Persona unwrap: persona overrides defaults, explicit request fields win ──
@@ -332,7 +335,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
} }
// Load conversation history // Load conversation history
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID) messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID, model)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
return return
@@ -365,7 +368,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
messages = append(messages, userMsg) messages = append(messages, userMsg)
// Persist user message (text-only for storage — multimodal parts are ephemeral) // Persist user message (text-only for storage — multimodal parts are ephemeral)
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil) msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
if err != nil { if err != nil {
log.Printf("Failed to persist user message: %v", err) log.Printf("Failed to persist user message: %v", err)
} }
@@ -383,17 +386,65 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// Resolve effective workspace: channel.workspace_id > project.workspace_id // Resolve effective workspace: channel.workspace_id > project.workspace_id
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
// ── Multi-model @mention routing (v0.20.0) ────────── // ── @mention routing (v0.23.0) ──────────────
// Check if the message @mentions specific channel models. // Resolve @model-id or @persona-handle directly against the enabled
// If multiple models are targeted, fan out sequentially. // model catalog and personas table. Works in ANY chat — no roster needed.
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" {
if len(roster) > 1 { req.Model = mentionModel
parsed := mentions.Parse(req.Content, roster) if mentionConfig != "" {
targets := mentions.ResolvedModels(parsed) req.ProviderConfigID = mentionConfig
if len(targets) > 1 { }
h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req) if mentionPersona != nil {
personaID = mentionPersona.ID
personaThinkingBudget = mentionPersona.ThinkingBudget
if mentionPersona.SystemPrompt != "" {
if len(messages) > 0 && messages[0].Role == "system" {
messages[0].Content = mentionPersona.SystemPrompt
} else {
messages = append([]providers.Message{{Role: "system", Content: mentionPersona.SystemPrompt}}, messages...)
}
}
// Context boundary: tell this persona to ignore other personas' styles
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas with different styles — ignore their tone, character, and mannerisms. Respond only as yourself per your system prompt.]",
mentionPersona.Name,
)
if len(messages) >= 2 {
last := messages[len(messages)-1]
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
messages = append(messages, last)
}
}
// Re-resolve provider config for the @mentioned target
providerCfg, providerID, model, configID, providerScope, err = h.resolveConfig(userID, channelID, req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
caps = h.getModelCapabilities(c, model, configID)
if personaThinkingBudget != nil && *personaThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *personaThinkingBudget
}
provider, err = providers.Get(providerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
// No @mention: inject a boundary if conversation has persona responses
// to prevent the default model from mimicking persona styles.
if h.conversationHasPersonaMessages(c.Request.Context(), channelID) {
boundary := "[Previous assistant messages in this conversation may include responses from AI personas with specific characters and styles. You are the default assistant — respond in your own natural style, not theirs.]"
if len(messages) >= 2 {
last := messages[len(messages)-1]
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
messages = append(messages, last)
}
}
} }
// Build provider request // Build provider request
@@ -540,7 +591,11 @@ func (h *CompletionHandler) multiModelStream(
// Persist assistant message with model attribution // Persist assistant message with model attribution
if result.Content != "" { if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil { pID := ""
if target.PersonaID != nil {
pID = *target.PersonaID
}
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, pID); err != nil {
log.Printf("Failed to persist multi-model assistant message: %v", err) log.Printf("Failed to persist multi-model assistant message: %v", err)
} }
} }
@@ -716,9 +771,20 @@ func (h *CompletionHandler) streamCompletion(
// Persist assistant response // Persist assistant response
if result.Content != "" { if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil { if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
log.Printf("Failed to persist assistant message: %v", err) log.Printf("Failed to persist assistant message: %v", err)
} }
// AI-to-AI chaining: if the response @mentions another persona participant,
// trigger a follow-up completion asynchronously via WebSocket delivery.
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[chain] PANIC recovered: %v", r)
}
}()
h.chainIfMentioned(channelID, userID, personaID, result.Content, 0)
}()
} }
// Log usage // Log usage
@@ -765,10 +831,20 @@ func (h *CompletionHandler) syncCompletion(
finalContent = resp.Content finalContent = resp.Content
// Persist assistant response // Persist assistant response
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity)); err != nil { if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity), configID, personaID); err != nil {
log.Printf("Failed to persist assistant message: %v", err) log.Printf("Failed to persist assistant message: %v", err)
} }
// AI-to-AI chaining
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[chain] PANIC recovered: %v", r)
}
}()
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
}()
// Log usage // Log usage
h.logUsage(c, channelID, userID, configID, providerScope, model, h.logUsage(c, channelID, userID, configID, providerScope, model,
totalInput, totalOutput, totalCacheCreation, totalCacheRead) totalInput, totalOutput, totalCacheCreation, totalCacheRead)
@@ -992,6 +1068,199 @@ func isImageContentType(ct string) bool {
return strings.HasPrefix(ct, "image/") return strings.HasPrefix(ct, "image/")
} }
// ── @mention Resolution (v0.23.0) ───────────
// conversationHasPersonaMessages checks if any assistant message in the
// channel was generated by a persona (participant_type = 'persona').
// Used to decide whether to inject a context boundary for the default model.
func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context, channelID string) bool {
var id string
err := database.DB.QueryRowContext(ctx, database.Q(`
SELECT id FROM messages
WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona'
LIMIT 1
`), channelID).Scan(&id)
return err == nil && id != ""
}
// buildParticipantHint builds a system message listing available @mentionable
// personas so the LLM knows who it can direct responses to.
// Only includes personas accessible to this user. Excludes the current persona.
func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPersonaID string) string {
ctx := context.Background()
personas, err := h.stores.Personas.ListForUser(ctx, userID)
if err != nil || len(personas) == 0 {
return ""
}
var lines []string
for _, p := range personas {
if !p.IsActive || p.Handle == "" || p.ID == currentPersonaID {
continue
}
desc := p.Description
if len(desc) > 80 {
desc = desc[:80] + "..."
}
if desc != "" {
lines = append(lines, fmt.Sprintf("- @%s (%s) — %s", p.Handle, p.Name, desc))
} else {
lines = append(lines, fmt.Sprintf("- @%s (%s)", p.Handle, p.Name))
}
}
if len(lines) == 0 {
return ""
}
hint := "Available conversation participants you can @mention to direct a response:\n"
for _, l := range lines {
hint += l + "\n"
}
hint += "To ask another participant to respond, include their @handle in your message."
return hint
}
// Resolves @tokens in message content directly against the model catalog
// and personas table. Works in any chat — no channel roster needed.
//
// Resolution order:
// 1. Persona handle (exact match, then prefix)
// 2. Model ID in enabled catalog (exact match, then prefix)
//
// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found.
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) {
// Extract first @token
token := extractFirstMention(content)
if token == "" {
return "", "", nil
}
// Normalize: lowercase, hyphens
normalized := strings.ToLower(strings.TrimSpace(token))
// 1. Try persona handle (exact)
var personaID, personaHandle string
err := database.DB.QueryRowContext(ctx, database.Q(`
SELECT id, handle FROM personas
WHERE LOWER(handle) = $1 AND is_active = true
`), normalized).Scan(&personaID, &personaHandle)
if err == nil && personaID != "" {
p := ResolvePersona(h.stores, personaID, userID)
if p != nil {
cfgID := ""
if p.ProviderConfigID != nil {
cfgID = *p.ProviderConfigID
}
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID)
return p.BaseModelID, cfgID, p
}
}
// 2. Try persona handle (prefix — unambiguous only)
if personaID == "" {
var count int
database.DB.QueryRowContext(ctx, database.Q(`
SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
`), normalized+"%").Scan(&count)
if count == 1 {
database.DB.QueryRowContext(ctx, database.Q(`
SELECT id FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
`), normalized+"%").Scan(&personaID)
if personaID != "" {
p := ResolvePersona(h.stores, personaID, userID)
if p != nil {
cfgID := ""
if p.ProviderConfigID != nil {
cfgID = *p.ProviderConfigID
}
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID)
return p.BaseModelID, cfgID, p
}
}
}
}
// 3. Try model_id in enabled catalog (exact)
var modelID, provConfigID string
err = database.DB.QueryRowContext(ctx, database.Q(`
SELECT mc.model_id, mc.provider_config_id
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) = $1
AND mc.visibility = 'enabled'
AND pc.is_active = true
ORDER BY
CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
LIMIT 1
`), normalized).Scan(&modelID, &provConfigID)
if err == nil && modelID != "" {
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID)
return modelID, provConfigID, nil
}
// 4. Try model_id prefix (unambiguous)
var prefixCount int
database.DB.QueryRowContext(ctx, database.Q(`
SELECT COUNT(DISTINCT mc.model_id)
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) LIKE $1
AND mc.visibility = 'enabled'
AND pc.is_active = true
`), normalized+"%").Scan(&prefixCount)
if prefixCount == 1 {
database.DB.QueryRowContext(ctx, database.Q(`
SELECT mc.model_id, mc.provider_config_id
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) LIKE $1
AND mc.visibility = 'enabled'
AND pc.is_active = true
ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
LIMIT 1
`), normalized+"%").Scan(&modelID, &provConfigID)
if modelID != "" {
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID)
return modelID, provConfigID, nil
}
}
return "", "", nil
}
// extractFirstMention finds the first @token in content.
// Returns the token without the @ prefix, or empty string.
func extractFirstMention(content string) string {
for i := 0; i < len(content); i++ {
if content[i] != '@' {
continue
}
// @ must be at start or preceded by whitespace
if i > 0 && content[i-1] != ' ' && content[i-1] != '\n' && content[i-1] != '\t' {
continue
}
// Extract token
start := i + 1
end := start
for end < len(content) && content[end] != ' ' && content[end] != '\n' && content[end] != '\t' {
end++
}
if end > start {
// Strip trailing punctuation
for end > start && (content[end-1] == ',' || content[end-1] == '.' || content[end-1] == '!' || content[end-1] == '?' || content[end-1] == ':' || content[end-1] == ';') {
end--
}
if end > start {
return content[start:end]
}
}
}
return ""
}
// ── Config Resolution ─────────────────────── // ── Config Resolution ───────────────────────
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config // Priority: request.provider_config_id → chat.provider_config_id → user's first active config
@@ -1037,6 +1306,8 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
var apiKeyEnc, keyNonce []byte var apiKeyEnc, keyNonce []byte
var keyScope string var keyScope string
var customHeadersJSON, providerSettingsJSON []byte var customHeadersJSON, providerSettingsJSON []byte
var proxyMode string
var proxyURL *string
// $2/userID appears twice in the query; Postgres reuses positional params, // $2/userID appears twice in the query; Postgres reuses positional params,
// SQLite needs each ? bound separately. // SQLite needs each ? bound separately.
configArgs := []interface{}{configID, userID} configArgs := []interface{}{configID, userID}
@@ -1045,14 +1316,14 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
} }
err := database.DB.QueryRow(database.Q(` err := database.DB.QueryRow(database.Q(`
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope, SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
model_default, headers, settings model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
FROM provider_configs FROM provider_configs
WHERE id = $1 AND is_active = true WHERE id = $1 AND is_active = true
AND (scope = 'global' AND (scope = 'global'
OR (scope = 'personal' AND owner_id = $2) OR (scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2))) OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope, `), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
&modelDefault, &customHeadersJSON, &providerSettingsJSON) &modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible") return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
@@ -1100,11 +1371,18 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
_ = json.Unmarshal(providerSettingsJSON, &providerSettings) _ = json.Unmarshal(providerSettingsJSON, &providerSettings)
} }
proxyURLStr := ""
if proxyURL != nil {
proxyURLStr = *proxyURL
}
return providers.ProviderConfig{ return providers.ProviderConfig{
Endpoint: endpoint, Endpoint: endpoint,
APIKey: key, APIKey: key,
CustomHeaders: customHeaders, CustomHeaders: customHeaders,
Settings: providerSettings, Settings: providerSettings,
ProxyMode: proxyMode,
ProxyURL: proxyURLStr,
}, providerID, model, configID, providerScope, nil }, providerID, model, configID, providerScope, nil
} }
@@ -1116,7 +1394,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
// //
// Summary-aware: if the path contains a summary node (metadata.type = "summary"), // Summary-aware: if the path contains a summary node (metadata.type = "summary"),
// messages before it are replaced by the summary content as a system message. // messages before it are replaced by the summary content as a system message.
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID string) ([]providers.Message, error) { func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID, currentModel string) ([]providers.Message, error) {
messages := make([]providers.Message, 0) messages := make([]providers.Message, 0)
// ── Admin system prompt (always injected first, no opt out) ── // ── Admin system prompt (always injected first, no opt out) ──
@@ -1183,6 +1461,16 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
}) })
} }
// ── Participant roster (v0.23.0) ──
// Inject the list of available @mentionable personas so the LLM
// knows who it can direct responses to. This enables LLM→LLM chaining.
if participantHint := h.buildParticipantHint(channelID, userID, personaID); participantHint != "" {
messages = append(messages, providers.Message{
Role: "system",
Content: participantHint,
})
}
// Walk the active path (root → leaf) instead of loading all messages // Walk the active path (root → leaf) instead of loading all messages
path, err := getActivePath(channelID, userID) path, err := getActivePath(channelID, userID)
if err != nil { if err != nil {
@@ -1207,6 +1495,29 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
startIdx = summaryIdx + 1 startIdx = summaryIdx + 1
} }
// ── Build persona name cache for history rewrite (v0.23.0) ──
// Collect unique persona IDs from the path so we can attribute
// foreign persona messages by name instead of raw UUID.
personaNameCache := make(map[string]string)
hasForeignPersona := false
{
seen := make(map[string]bool)
for _, m := range path {
if m.ParticipantType == "persona" && m.ParticipantID != "" && m.ParticipantID != personaID {
if !seen[m.ParticipantID] {
seen[m.ParticipantID] = true
}
}
}
for pid := range seen {
var name string
database.DB.QueryRow(database.Q(`SELECT name FROM personas WHERE id = $1`), pid).Scan(&name)
if name != "" {
personaNameCache[pid] = name
}
}
}
for _, m := range path[startIdx:] { for _, m := range path[startIdx:] {
if m.Role == "system" { if m.Role == "system" {
continue // system prompts handled above continue // system prompts handled above
@@ -1215,10 +1526,59 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
if isSummaryMessage(&m) { if isSummaryMessage(&m) {
continue continue
} }
messages = append(messages, providers.Message{
// ── Foreign speaker attribution (v0.23.0) ──
// ALL assistant messages get a Name field identifying who generated them.
// Providers that support "name" (OpenAI, OpenRouter, Venice) use it
// to distinguish speakers. Anthropic rewrites named foreign messages
// to user role with attribution.
msg := providers.Message{
Role: m.Role, Role: m.Role,
Content: m.Content, Content: m.Content,
}) }
if m.Role == "assistant" {
isForeign := false
if m.ParticipantType == "persona" && m.ParticipantID != "" {
if m.ParticipantID != personaID {
// Foreign persona — tag with name
isForeign = true
name := personaNameCache[m.ParticipantID]
if name != "" {
msg.Name = name
}
}
} else if m.Model != nil && *m.Model != "" {
// Raw model — foreign if model differs or we're a persona
if *m.Model != currentModel || personaID != "" {
isForeign = true
msg.Name = *m.Model
}
}
if isForeign {
hasForeignPersona = true
}
}
messages = append(messages, msg)
}
// If foreign persona messages exist, add a system hint explaining
// the multi-participant context (helps all providers)
if hasForeignPersona {
hint := "This is a multi-participant AI conversation. Messages from other AI participants are marked with their name. You are one of several models — respond only as yourself, ignoring other participants' styles and characters."
// Insert after the last system message, before conversation history
insertIdx := 0
for i, m := range messages {
if m.Role == "system" {
insertIdx = i + 1
} else {
break
}
}
messages = append(messages[:insertIdx+1], messages[insertIdx:]...)
messages[insertIdx] = providers.Message{Role: "system", Content: hint}
} }
return messages, nil return messages, nil
@@ -1242,7 +1602,7 @@ func toolActivityJSON(activity []map[string]interface{}) json.RawMessage {
return b return b
} }
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage) (string, error) { func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage, providerConfigID string, personaID string) (string, error) {
var tokensUsed *int var tokensUsed *int
if inputTokens > 0 || outputTokens > 0 { if inputTokens > 0 || outputTokens > 0 {
total := inputTokens + outputTokens total := inputTokens + outputTokens
@@ -1260,12 +1620,23 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
// Compute sibling_index // Compute sibling_index
siblingIdx := nextSiblingIndex(channelID, parentID) siblingIdx := nextSiblingIndex(channelID, parentID)
// Determine participant // Determine participant: persona for assistant messages with personaID, otherwise user/model
participantType := "user" participantType := "user"
participantID := userID participantID := userID
if role == "assistant" { if role == "assistant" {
participantType = "model" if personaID != "" {
participantID = model participantType = "persona"
participantID = personaID
} else {
participantType = "model"
participantID = model
}
}
// provider_config_id: nil if empty
var provCfgVal interface{}
if providerConfigID != "" {
provCfgVal = providerConfigID
} }
// Prepare tool_calls JSONB (nil → NULL) // Prepare tool_calls JSONB (nil → NULL)
@@ -1280,21 +1651,21 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
newID = store.NewID() newID = store.NewID()
_, err := database.DB.Exec(` _, err := database.DB.Exec(`
INSERT INTO messages (id, channel_id, role, content, model, tokens_used, INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
tool_calls, parent_id, participant_type, participant_id, sibling_index) tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)
`, newID, channelID, role, content, model, tokensUsed, `, newID, channelID, role, content, model, tokensUsed,
tcVal, parentID, participantType, participantID, siblingIdx) tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx)
if err != nil { if err != nil {
return "", err return "", err
} }
} else { } else {
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, model, tokens_used, INSERT INTO messages (channel_id, role, content, model, tokens_used,
tool_calls, parent_id, participant_type, participant_id, sibling_index) tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11)
RETURNING id RETURNING id
`, channelID, role, content, model, tokensUsed, `, channelID, role, content, model, tokensUsed,
tcVal, parentID, participantType, participantID, siblingIdx, tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx,
).Scan(&newID) ).Scan(&newID)
if err != nil { if err != nil {
return "", err return "", err

View File

@@ -0,0 +1,223 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
)
// maxChainDepth limits AI-to-AI chaining to prevent runaway loops.
const maxChainDepth = 5
// chainIfMentioned checks if an assistant response @mentions another
// persona and triggers a follow-up completion. Uses resolveMention()
// directly — no roster or participant list needed. Works in any chat.
//
// Runs asynchronously (goroutine) after the SSE stream closes.
// The follow-up response is delivered to the client via WebSocket events.
func (h *CompletionHandler) chainIfMentioned(
channelID, userID, currentPersonaID, responseContent string,
depth int,
) {
if depth >= maxChainDepth {
return
}
defer func() {
if r := recover(); r != nil {
log.Printf("[chain] PANIC in chain (depth=%d): %v", depth, r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// Extract the @token for logging
token := extractFirstMention(responseContent)
if token == "" {
log.Printf("[chain] No @mention found in response (len=%d, persona=%s)", len(responseContent), currentPersonaID[:min(8, len(currentPersonaID))])
return
}
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)
if mentionModel == "" {
log.Printf("[chain] @%s did not resolve to any model", token)
return
}
if mentionPersona == nil {
log.Printf("[chain] @%s resolved to raw model %s (no chain for raw models)", token, mentionModel)
return // no persona @mention found, or it's a raw model (no chain for those)
}
// Don't chain to self
if mentionPersona.ID == currentPersonaID {
log.Printf("[chain] @%s is self-mention, skipping", token)
return
}
log.Printf("[chain] %s @mentioned %s (depth=%d)", currentPersonaID[:min(8, len(currentPersonaID))], mentionPersona.Name, depth+1)
// Emit typing indicator
if h.hub != nil {
h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, true)
defer h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, false)
}
// Brief pause for UX
time.Sleep(500 * time.Millisecond)
// Resolve provider config
configID := mentionConfig
chainReq := completionRequest{
ChannelID: channelID,
Model: mentionModel,
ProviderConfigID: configID,
}
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
if err != nil {
log.Printf("[chain] Failed to resolve config for %s: %v", mentionPersona.Name, err)
return
}
configID = resolvedConfigID
provider, err := providers.Get(providerID)
if err != nil {
log.Printf("[chain] Provider %s unavailable: %v", providerID, err)
return
}
// Load conversation with persona's system prompt
messages, err := h.loadConversation(channelID, userID, mentionPersona.SystemPrompt, mentionPersona.ID, model)
if err != nil {
log.Printf("[chain] Failed to load conversation: %v", err)
return
}
// Context boundary
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style and mannerisms. Respond only as yourself per your system prompt.]",
mentionPersona.Name,
)
messages = append(messages, providers.Message{Role: "system", Content: boundary})
caps := capspkg.ResolveIntrinsic(model, nil, nil)
provReq := providers.CompletionRequest{
Model: model,
Messages: messages,
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
}
if mentionPersona.Temperature != nil {
provReq.Temperature = mentionPersona.Temperature
}
if mentionPersona.ThinkingBudget != nil && *mentionPersona.ThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *mentionPersona.ThinkingBudget
}
// Apply provider-specific hooks
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
// Synchronous completion — result delivered via WebSocket
callStart := time.Now()
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
latencyMs := int(time.Since(callStart).Milliseconds())
if err != nil {
log.Printf("[chain] Completion failed for %s: %v", mentionPersona.Name, err)
if h.health != nil {
h.health.RecordError(configID, latencyMs, err.Error())
}
return
}
if h.health != nil {
h.health.RecordSuccess(configID, latencyMs)
}
if resp.Content == "" {
return
}
// Persist
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
resp.InputTokens, resp.OutputTokens, nil, nil, configID, mentionPersona.ID)
if err != nil {
log.Printf("[chain] Failed to persist chained message: %v", err)
return
}
// Deliver via WebSocket
if h.hub != nil {
payload, _ := json.Marshal(map[string]interface{}{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": resp.Content,
"model": model,
"participant_type": "persona",
"participant_id": mentionPersona.ID,
"display_name": mentionPersona.Name,
"avatar": mentionPersona.Avatar,
"tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth + 1,
})
h.hub.SendToUser(userID, events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
// Log usage
if h.stores.Usage != nil {
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &configID,
ProviderScope: "global",
ModelID: model,
PromptTokens: resp.InputTokens,
CompletionTokens: resp.OutputTokens,
}
_ = h.stores.Usage.Log(ctx, entry)
}
log.Printf("[chain] ✅ %s responded (depth=%d, %d tokens) in channel %s",
mentionPersona.Name, depth+1, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
// Recursive: check if this response @mentions yet another persona
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
}
// emitTyping sends a typing indicator via WebSocket.
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
eventType := "typing.start"
if !start {
eventType = "typing.stop"
}
payload, _ := json.Marshal(map[string]string{
"channel_id": channelID,
"participant_id": participantID,
"participant_type": "persona",
"display_name": displayName,
})
h.hub.SendToUser(userID, events.Event{
Label: eventType,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}

View File

@@ -37,6 +37,7 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
var req struct { var req struct {
ModelID string `json:"model_id" binding:"required"` ModelID string `json:"model_id" binding:"required"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Hidden *bool `json:"hidden,omitempty"` Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"` PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"` PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
@@ -48,13 +49,14 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
} }
patch := models.UserModelSettingPatch{ patch := models.UserModelSettingPatch{
ProviderConfigID: req.ProviderConfigID,
Hidden: req.Hidden, Hidden: req.Hidden,
PreferredTemperature: req.PreferredTemperature, PreferredTemperature: req.PreferredTemperature,
PreferredMaxTokens: req.PreferredMaxTokens, PreferredMaxTokens: req.PreferredMaxTokens,
SortOrder: req.SortOrder, SortOrder: req.SortOrder,
} }
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, patch); err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
return return
} }
@@ -62,23 +64,23 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "preference updated"}) c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
} }
// BulkSetPreferences sets hidden state for multiple models at once. // BulkSetPreferences sets hidden state for multiple model+provider pairs at once.
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) { func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
var req struct { var req struct {
ModelIDs []string `json:"model_ids" binding:"required"` Entries []models.HiddenEntry `json:"entries" binding:"required"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.ModelIDs, req.Hidden); err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.ModelIDs)}) c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.Entries)})
} }

View File

@@ -27,9 +27,15 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider) return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
} }
proxyURL := ""
if cfg.ProxyURL != nil {
proxyURL = *cfg.ProxyURL
}
provCfg := providers.ProviderConfig{ provCfg := providers.ProviderConfig{
Endpoint: cfg.Endpoint, Endpoint: cfg.Endpoint,
APIKey: apiKeyPlain, APIKey: apiKeyPlain,
ProxyMode: cfg.ProxyMode,
ProxyURL: proxyURL,
} }
if cfg.Headers != nil { if cfg.Headers != nil {

View File

@@ -0,0 +1,340 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Channel Participants Handler ──────────────
// ICD §3.7: Manages polymorphic participants per channel.
// Routes:
// GET /channels/:id/participants — list
// POST /channels/:id/participants — add
// PATCH /channels/:id/participants/:participantId — update role
// DELETE /channels/:id/participants/:participantId — remove
// GET /channels/:id/presence — who's online
type ParticipantHandler struct {
stores store.Stores
}
func NewParticipantHandler(stores store.Stores) *ParticipantHandler {
return &ParticipantHandler{stores: stores}
}
// ── List ─────────────────────────────────────
func (h *ParticipantHandler) List(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// Caller must be a participant (any role)
if !h.requireParticipant(c, channelID, userID) {
return
}
participants, err := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list participants"})
return
}
if participants == nil {
participants = []models.ChannelParticipant{}
}
c.JSON(http.StatusOK, gin.H{"participants": participants})
}
// ── Add ──────────────────────────────────────
type addParticipantRequest struct {
ParticipantType string `json:"participant_type" binding:"required"`
ParticipantID string `json:"participant_id" binding:"required"`
Role string `json:"role"`
}
func (h *ParticipantHandler) Add(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// Only owners can add participants
if !h.requireOwner(c, channelID, userID) {
return
}
var req addParticipantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate participant_type
switch req.ParticipantType {
case "user", "persona", "session":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "participant_type must be user, persona, or session"})
return
}
// Default role
role := req.Role
if role == "" {
role = "member"
}
switch role {
case "owner", "member", "observer":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
return
}
// Build participant
p := &models.ChannelParticipant{
ChannelID: channelID,
ParticipantType: req.ParticipantType,
ParticipantID: req.ParticipantID,
Role: role,
}
// If adding a persona, resolve display_name and avatar from persona record,
// and auto-add persona's model to channel_models roster.
if req.ParticipantType == "persona" {
persona, err := h.stores.Personas.GetByID(c.Request.Context(), req.ParticipantID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up persona"})
return
}
dn := persona.Name
p.DisplayName = &dn
if persona.Avatar != "" {
p.AvatarURL = &persona.Avatar
}
// Auto-add persona's model to channel_models roster
cm := &models.ChannelModel{
ChannelID: channelID,
ModelID: persona.BaseModelID,
ProviderConfigID: derefStr(persona.ProviderConfigID),
PersonaID: &req.ParticipantID,
Handle: persona.Handle,
DisplayName: persona.Name,
SystemPrompt: persona.SystemPrompt,
IsDefault: false,
}
_ = h.stores.Channels.SetModel(c.Request.Context(), cm)
} else if req.ParticipantType == "user" {
// Resolve user display name
user, err := h.stores.Users.GetByID(c.Request.Context(), req.ParticipantID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up user"})
return
}
dn := user.DisplayName
if dn == "" {
dn = user.Username
}
p.DisplayName = &dn
if user.AvatarURL != "" {
p.AvatarURL = &user.AvatarURL
}
}
if err := h.stores.Channels.AddParticipant(c.Request.Context(), p); err != nil {
// Check for duplicate
if isDuplicateErr(err) {
c.JSON(http.StatusConflict, gin.H{"error": "participant already in channel"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"})
return
}
// Return updated participant list
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusCreated, gin.H{"participants": participants})
}
// ── Update Role ─────────────────────────────
type updateParticipantRequest struct {
Role string `json:"role" binding:"required"`
}
func (h *ParticipantHandler) Update(c *gin.Context) {
channelID := c.Param("id")
participantRecordID := c.Param("participantId")
userID := getUserID(c)
if !h.requireOwner(c, channelID, userID) {
return
}
var req updateParticipantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
switch req.Role {
case "owner", "member", "observer":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
return
}
// Verify participant belongs to this channel
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
return
}
if p.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
return
}
if err := h.stores.Channels.UpdateParticipantRole(c.Request.Context(), participantRecordID, req.Role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update participant"})
return
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"participants": participants})
}
// ── Remove ──────────────────────────────────
func (h *ParticipantHandler) Remove(c *gin.Context) {
channelID := c.Param("id")
participantRecordID := c.Param("participantId")
userID := getUserID(c)
if !h.requireOwner(c, channelID, userID) {
return
}
// Verify participant belongs to this channel
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
return
}
if p.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
return
}
// Cannot remove the last owner
if p.Role == "owner" {
owners, _ := h.stores.Channels.CountParticipantsByRole(c.Request.Context(), channelID, "owner")
if owners <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last owner"})
return
}
}
// If removing a persona, also remove its auto-created model roster entry
if p.ParticipantType == "persona" {
_ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID)
}
if err := h.stores.Channels.RemoveParticipant(c.Request.Context(), participantRecordID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"})
return
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"participants": participants})
}
// ── Helpers ──────────────────────────────────
// requireParticipant checks the user is any participant in the channel.
func (h *ParticipantHandler) requireParticipant(c *gin.Context, channelID, userID string) bool {
ok, err := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check participation"})
return false
}
if !ok {
// Fall back to legacy channels.user_id ownership for direct channels
if userOwnsChannel(c, channelID, userID) {
return true
}
return false
}
return true
}
// requireOwner checks the user is an owner participant in the channel.
func (h *ParticipantHandler) requireOwner(c *gin.Context, channelID, userID string) bool {
role, err := h.stores.Channels.GetParticipantRole(c.Request.Context(), channelID, "user", userID)
if err != nil {
// Fall back to legacy channels.user_id ownership for direct channels
if userOwnsChannel(c, channelID, userID) {
return true
}
return false
}
if role != "owner" {
c.JSON(http.StatusForbidden, gin.H{"error": "owner role required"})
return false
}
return true
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// isDuplicateErr detects unique constraint violations across both Postgres and SQLite.
func isDuplicateErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// Postgres: "duplicate key value violates unique constraint"
// SQLite: "UNIQUE constraint failed"
return contains(msg, "duplicate key") || contains(msg, "UNIQUE constraint")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchStr(s, substr)
}
func searchStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -223,6 +223,7 @@ func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
type personaRequest struct { type personaRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Handle string `json:"handle,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
BaseModelID string `json:"base_model_id,omitempty"` BaseModelID string `json:"base_model_id,omitempty"`
@@ -238,6 +239,7 @@ type personaRequest struct {
func (r *personaRequest) toPersona() *models.Persona { func (r *personaRequest) toPersona() *models.Persona {
p := &models.Persona{ p := &models.Persona{
Name: r.Name, Name: r.Name,
Handle: r.Handle,
Description: r.Description, Description: r.Description,
Icon: r.Icon, Icon: r.Icon,
BaseModelID: r.BaseModelID, BaseModelID: r.BaseModelID,

View File

@@ -380,6 +380,13 @@ func main() {
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update) protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete) protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
// Channel participants (v0.23.0 — ICD §3.7)
partH := handlers.NewParticipantHandler(stores)
protected.GET("/channels/:id/participants", partH.List)
protected.POST("/channels/:id/participants", partH.Add)
protected.PATCH("/channels/:id/participants/:participantId", partH.Update)
protected.DELETE("/channels/:id/participants/:participantId", partH.Remove)
// Messages // Messages
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
protected.GET("/channels/:id/messages", msgs.ListMessages) protected.GET("/channels/:id/messages", msgs.ListMessages)

View File

@@ -10,47 +10,45 @@ import (
// Mention represents a parsed @mention token in message content. // Mention represents a parsed @mention token in message content.
type Mention struct { type Mention struct {
Raw string // "@claude-3-opus" as written (includes @) Raw string // "@veronica-sharpe" as written (includes @)
Name string // "claude-3-opus" (normalized, no @) Name string // "veronica-sharpe" (normalized, no @)
Start int // byte offset in message content Start int // byte offset in message content
End int // byte offset end (exclusive) End int // byte offset end (exclusive)
Resolved *models.ChannelModel // nil if unresolved Resolved *models.ChannelModel // nil if unresolved
IsAll bool // true for @all special token
} }
// Parse extracts @mentions from message content and resolves them // Parse extracts @mentions from message content and resolves them
// against the channel's model roster. // against the channel's model roster.
// //
// Rules: // Resolution priority:
// - @ followed by one or more non-whitespace characters // 1. Exact handle match (e.g. @veronica-sharpe → handle "veronica-sharpe")
// - Greedy: @claude-3-opus-20240229 matches the full token // 2. Prefix handle match (e.g. @veronica → only one handle starts with "veronica")
// - Resolution: case-insensitive match against display_name with // 3. Fallback: normalized display_name match (backward compat)
// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus") // 4. @all is a special token that resolves to all roster entries
// - Longest-match-first when display names overlap //
// - Unresolved mentions are included with Resolved = nil // @ must be at start of content or preceded by whitespace.
// - @ must be at start of content or preceded by whitespace
func Parse(content string, roster []models.ChannelModel) []Mention { func Parse(content string, roster []models.ChannelModel) []Mention {
if len(roster) == 0 || !strings.Contains(content, "@") { if len(roster) == 0 || !strings.Contains(content, "@") {
return nil return nil
} }
// Build lookup: normalized display_name → *ChannelModel // Build handle lookup: handle → *ChannelModel
// Sort roster by display_name length descending for longest-match-first // Also build display name lookup for backward compat
type entry struct { type entry struct {
normalized string handle string // normalized handle
normalized string // normalized display_name
model models.ChannelModel model models.ChannelModel
} }
entries := make([]entry, 0, len(roster)) entries := make([]entry, 0, len(roster))
for _, cm := range roster { for _, cm := range roster {
if cm.DisplayName == "" { h := normalize(cm.Handle)
continue dn := normalize(cm.DisplayName)
} entries = append(entries, entry{handle: h, normalized: dn, model: cm})
entries = append(entries, entry{
normalized: normalize(cm.DisplayName),
model: cm,
})
} }
// Sort by handle length descending for longest-match-first
sort.Slice(entries, func(i, j int) bool { sort.Slice(entries, func(i, j int) bool {
return len(entries[i].normalized) > len(entries[j].normalized) return len(entries[i].handle) > len(entries[j].handle)
}) })
// Extract @tokens // Extract @tokens
@@ -80,29 +78,86 @@ func Parse(content string, roster []models.ChannelModel) []Mention {
continue // bare @ with no token continue // bare @ with no token
} }
// Strip trailing punctuation (commas, periods, etc.) // Strip trailing punctuation
tokenEnd := i tokenEnd := i
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) { for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
tokenEnd-- tokenEnd--
} }
if tokenEnd == tokenStart { if tokenEnd == tokenStart {
continue // all punctuation continue
} }
raw := content[start:tokenEnd] raw := content[start:tokenEnd]
name := content[tokenStart:tokenEnd] name := content[tokenStart:tokenEnd]
normalizedName := normalize(name) normalizedName := normalize(name)
// Try to resolve against roster (longest match first) // Special: @all
if normalizedName == "all" {
mentions = append(mentions, Mention{
Raw: raw,
Name: name,
Start: start,
End: tokenEnd,
IsAll: true,
})
continue
}
// 1. Exact handle match
var resolved *models.ChannelModel var resolved *models.ChannelModel
for idx := range entries { for idx := range entries {
if normalizedName == entries[idx].normalized { if entries[idx].handle != "" && normalizedName == entries[idx].handle {
cm := entries[idx].model cm := entries[idx].model
resolved = &cm resolved = &cm
break break
} }
} }
// 2. Prefix handle match (unambiguous only)
if resolved == nil {
var prefixMatches []int
for idx := range entries {
if entries[idx].handle != "" && strings.HasPrefix(entries[idx].handle, normalizedName) {
prefixMatches = append(prefixMatches, idx)
}
}
if len(prefixMatches) == 1 {
cm := entries[prefixMatches[0]].model
resolved = &cm
}
}
// 3. Fallback: display_name match (backward compat)
if resolved == nil {
for idx := range entries {
if entries[idx].normalized != "" && normalizedName == entries[idx].normalized {
cm := entries[idx].model
resolved = &cm
break
}
}
// Display name prefix
if resolved == nil {
var prefixMatches []int
for idx := range entries {
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName+" ") {
prefixMatches = append(prefixMatches, idx)
}
}
if len(prefixMatches) == 0 {
for idx := range entries {
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName) {
prefixMatches = append(prefixMatches, idx)
}
}
}
if len(prefixMatches) == 1 {
cm := entries[prefixMatches[0]].model
resolved = &cm
}
}
}
mentions = append(mentions, Mention{ mentions = append(mentions, Mention{
Raw: raw, Raw: raw,
Name: name, Name: name,
@@ -133,6 +188,16 @@ func ResolvedModels(mentions []Mention) []models.ChannelModel {
return result return result
} }
// HasAll returns true if any mention is @all.
func HasAll(mentions []Mention) bool {
for _, m := range mentions {
if m.IsAll {
return true
}
}
return false
}
// isMentionTrailingPunct returns true for punctuation that commonly // isMentionTrailingPunct returns true for punctuation that commonly
// follows an @mention but isn't part of the name. // follows an @mention but isn't part of the name.
func isMentionTrailingPunct(b byte) bool { func isMentionTrailingPunct(b byte) bool {

View File

@@ -3,6 +3,7 @@ package models
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"strings"
"time" "time"
) )
@@ -128,6 +129,8 @@ type ProviderConfig struct {
Settings JSONMap `json:"settings,omitempty" db:"settings"` Settings JSONMap `json:"settings,omitempty" db:"settings"`
IsActive bool `json:"is_active" db:"is_active"` IsActive bool `json:"is_active" db:"is_active"`
IsPrivate bool `json:"is_private" db:"is_private"` IsPrivate bool `json:"is_private" db:"is_private"`
ProxyMode string `json:"proxy_mode" db:"proxy_mode"`
ProxyURL *string `json:"proxy_url,omitempty" db:"proxy_url"`
} }
// HasKey returns true if an encrypted API key is stored. // HasKey returns true if an encrypted API key is stored.
@@ -146,6 +149,8 @@ type ProviderConfigPatch struct {
Settings JSONMap `json:"settings,omitempty"` Settings JSONMap `json:"settings,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"` IsPrivate *bool `json:"is_private,omitempty"`
ProxyMode *string `json:"proxy_mode,omitempty"`
ProxyURL *string `json:"proxy_url,omitempty"`
} }
// ========================================= // =========================================
@@ -194,6 +199,7 @@ type ModelPricing struct {
type Persona struct { type Persona struct {
BaseModel BaseModel
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Handle string `json:"handle,omitempty" db:"handle"`
Description string `json:"description,omitempty" db:"description"` Description string `json:"description,omitempty" db:"description"`
Icon string `json:"icon,omitempty" db:"icon"` Icon string `json:"icon,omitempty" db:"icon"`
Avatar string `json:"avatar,omitempty" db:"avatar"` Avatar string `json:"avatar,omitempty" db:"avatar"`
@@ -227,6 +233,7 @@ type Persona struct {
type PersonaPatch struct { type PersonaPatch struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Handle *string `json:"handle,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"` Icon *string `json:"icon,omitempty"`
Avatar *string `json:"avatar,omitempty"` Avatar *string `json:"avatar,omitempty"`
@@ -278,6 +285,7 @@ type UserModelSetting struct {
BaseModel BaseModel
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
ModelID string `json:"model_id" db:"model_id"` ModelID string `json:"model_id" db:"model_id"`
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
Hidden bool `json:"hidden" db:"hidden"` Hidden bool `json:"hidden" db:"hidden"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"` PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"` PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"`
@@ -285,12 +293,24 @@ type UserModelSetting struct {
} }
type UserModelSettingPatch struct { type UserModelSettingPatch struct {
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Hidden *bool `json:"hidden,omitempty"` Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"` PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"` PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
} }
// HiddenEntry identifies a model+provider pair for bulk visibility operations.
type HiddenEntry struct {
ModelID string `json:"model_id"`
ProviderConfigID string `json:"provider_config_id"`
}
// CompositeModelKey builds the composite key used for per-provider model preferences.
func CompositeModelKey(providerConfigID, modelID string) string {
return providerConfigID + ":" + modelID
}
// ========================================= // =========================================
// CHANNELS // CHANNELS
// ========================================= // =========================================
@@ -319,41 +339,47 @@ type Channel struct {
type Message struct { type Message struct {
BaseModel BaseModel
ChannelID string `json:"channel_id" db:"channel_id"` ChannelID string `json:"channel_id" db:"channel_id"`
Role string `json:"role" db:"role"` Role string `json:"role" db:"role"`
Content string `json:"content" db:"content"` Content string `json:"content" db:"content"`
Model string `json:"model,omitempty" db:"model"` Model string `json:"model,omitempty" db:"model"`
TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"` TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"`
ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"` ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"` ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
SiblingIndex int `json:"sibling_index" db:"sibling_index"` SiblingIndex int `json:"sibling_index" db:"sibling_index"`
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"` ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"` ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
} }
// ========================================= // =========================================
// CHANNEL MEMBERS, MODELS, CURSORS // CHANNEL PARTICIPANTS, MODELS, CURSORS
// ========================================= // =========================================
type ChannelMember struct { type ChannelParticipant struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"` ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"` ParticipantType string `json:"participant_type" db:"participant_type"`
Role string `json:"role" db:"role"` ParticipantID string `json:"participant_id" db:"participant_id"`
JoinedAt time.Time `json:"joined_at" db:"joined_at"` Role string `json:"role" db:"role"`
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"` DisplayName *string `json:"display_name,omitempty" db:"display_name"`
AvatarURL *string `json:"avatar_url,omitempty" db:"avatar_url"`
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
} }
type ChannelModel struct { type ChannelModel struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"` ChannelID string `json:"channel_id" db:"channel_id"`
ModelID string `json:"model_id" db:"model_id"` ModelID string `json:"model_id" db:"model_id"`
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"` ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
DisplayName string `json:"display_name,omitempty" db:"display_name"` PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` Handle string `json:"handle,omitempty" db:"handle"`
IsDefault bool `json:"is_default" db:"is_default"` DisplayName string `json:"display_name,omitempty" db:"display_name"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
IsDefault bool `json:"is_default" db:"is_default"`
} }
type ChannelCursor struct { type ChannelCursor struct {
@@ -364,6 +390,56 @@ type ChannelCursor struct {
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
// =========================================
// PERSONA GROUPS (v0.23.0)
// =========================================
type PersonaGroup struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
OwnerID string `json:"owner_id" db:"owner_id"`
Scope string `json:"scope" db:"scope"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
Members []PersonaGroupMember `json:"members,omitempty" db:"-"`
}
type PersonaGroupMember struct {
ID string `json:"id" db:"id"`
GroupID string `json:"group_id" db:"group_id"`
PersonaID string `json:"persona_id" db:"persona_id"`
IsLeader bool `json:"is_leader" db:"is_leader"`
SortOrder int `json:"sort_order" db:"sort_order"`
// Joined from personas table for API responses
PersonaName string `json:"persona_name,omitempty" db:"-"`
PersonaHandle string `json:"persona_handle,omitempty" db:"-"`
PersonaAvatar string `json:"persona_avatar,omitempty" db:"-"`
}
// HandleFromName generates a URL-safe @mention handle from a display name.
// "Veronica Sharpe" → "veronica-sharpe"
func HandleFromName(name string) string {
h := strings.ToLower(strings.TrimSpace(name))
h = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
if r == ' ' || r == '_' {
return '-'
}
return -1
}, h)
// Collapse multiple hyphens
for strings.Contains(h, "--") {
h = strings.ReplaceAll(h, "--", "-")
}
h = strings.Trim(h, "-")
if len(h) > 50 {
h = h[:50]
}
return h
}
// ========================================= // =========================================
// ORGANIZATION // ORGANIZATION
// ========================================= // =========================================
@@ -635,6 +711,7 @@ type UserModel struct {
// Persona fields — always emitted so frontend can branch on is_persona. // Persona fields — always emitted so frontend can branch on is_persona.
IsPersona bool `json:"is_persona"` IsPersona bool `json:"is_persona"`
PersonaID string `json:"persona_id,omitempty"` PersonaID string `json:"persona_id,omitempty"`
PersonaHandle string `json:"persona_handle,omitempty"`
PersonaScope string `json:"persona_scope,omitempty"` PersonaScope string `json:"persona_scope,omitempty"`
PersonaAvatar string `json:"persona_avatar,omitempty"` PersonaAvatar string `json:"persona_avatar,omitempty"`
PersonaTeamName string `json:"persona_team_name,omitempty"` PersonaTeamName string `json:"persona_team_name,omitempty"`

View File

@@ -106,10 +106,9 @@ window.addEventListener('unhandledrejection', function(e) {
<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> <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> <span>New Project</span>
</button> </button>
<button class="split-dropdown-item disabled" title="Coming soon"> <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> <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> <span>Group Chat</span>
<span class="dd-hint">soon</span>
</button> </button>
<button class="split-dropdown-item disabled" title="Coming soon"> <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> <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>

View File

@@ -235,6 +235,20 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antMsg := anthropicMessage{Role: m.Role} antMsg := anthropicMessage{Role: m.Role}
// ── Foreign persona rewrite (v0.23.0) ──
// Anthropic doesn't support the "name" field on messages.
// Rewrite named assistant messages (from other personas) to user
// role with attribution so Anthropic maintains alternation and
// the model understands it's a multi-participant conversation.
if m.Role == "assistant" && m.Name != "" {
antMsg.Role = "user"
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: fmt.Sprintf("[%s responded:]\n%s", m.Name, m.Content)},
}
messages = append(messages, antMsg)
continue
}
if m.Role == "assistant" && len(m.ToolCalls) > 0 { if m.Role == "assistant" && len(m.ToolCalls) > 0 {
// Assistant with tool calls → mixed content blocks // Assistant with tool calls → mixed content blocks
if m.Content != "" { if m.Content != "" {
@@ -372,7 +386,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
} }
} }
resp, err := http.DefaultClient.Do(httpReq) resp, err := cfg.Client().Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("provider request: %w", err) return nil, fmt.Errorf("provider request: %w", err)
} }

View File

@@ -242,7 +242,7 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
httpReq.Header.Set(k, v) httpReq.Header.Set(k, v)
} }
resp, err := http.DefaultClient.Do(httpReq) resp, err := cfg.Client().Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("list models: %w", err) return nil, fmt.Errorf("list models: %w", err)
} }
@@ -303,7 +303,7 @@ func (p *OpenAIProvider) Embed(ctx context.Context, cfg ProviderConfig, req Embe
httpReq.Header.Set(k, v) httpReq.Header.Set(k, v)
} }
resp, err := http.DefaultClient.Do(httpReq) resp, err := cfg.Client().Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("embedding request: %w", err) return nil, fmt.Errorf("embedding request: %w", err)
} }
@@ -424,6 +424,12 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
oaiMsg.Name = m.Name oaiMsg.Name = m.Name
} }
// Named messages: pass through for speaker attribution (v0.23.0)
// OpenAI supports "name" on any role to distinguish participants
if m.Name != "" && m.Role != "tool" {
oaiMsg.Name = m.Name
}
oaiReq.Messages = append(oaiReq.Messages, oaiMsg) oaiReq.Messages = append(oaiReq.Messages, oaiMsg)
} }
@@ -453,7 +459,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
httpReq.Header.Set(k, v) httpReq.Header.Set(k, v)
} }
resp, err := http.DefaultClient.Do(httpReq) resp, err := cfg.Client().Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("provider request: %w", err) return nil, fmt.Errorf("provider request: %w", err)
} }
@@ -474,7 +480,7 @@ type openaiMessage struct {
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
Name string `json:"name,omitempty"` // tool name for role=tool Name string `json:"name,omitempty"` // speaker name (tool or persona attribution)
} }
// openaiContentPart represents a single element in a multimodal content array. // openaiContentPart represents a single element in a multimodal content array.

View File

@@ -56,7 +56,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
httpReq.Header.Set(k, v) httpReq.Header.Set(k, v)
} }
resp, err := http.DefaultClient.Do(httpReq) resp, err := cfg.Client().Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("openrouter list models: %w", err) return nil, fmt.Errorf("openrouter list models: %w", err)
} }

View File

@@ -4,6 +4,10 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log"
"net/http"
"net/url"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
) )
@@ -43,6 +47,41 @@ type ProviderConfig struct {
APIKey string APIKey string
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer) CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
Settings map[string]interface{} // Provider-specific settings from config JSONB Settings map[string]interface{} // Provider-specific settings from config JSONB
ProxyMode string // "system" (default), "direct", "custom"
ProxyURL string // Only used when ProxyMode == "custom"
}
// Client returns an *http.Client configured for this provider's proxy settings.
// system = http.ProxyFromEnvironment (Go default), direct = no proxy,
// custom = explicit proxy URL (http/https/socks5).
func (c ProviderConfig) Client() *http.Client {
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
switch c.ProxyMode {
case "direct":
transport.Proxy = nil
case "custom":
if c.ProxyURL != "" {
u, err := url.Parse(c.ProxyURL)
if err != nil {
// Fall back to system rather than silently going direct —
// a bad custom URL in a mandatory-proxy environment is a
// security/audit hole if it silently bypasses.
log.Printf("[proxy] invalid custom proxy URL %q: %v — falling back to system proxy", c.ProxyURL, err)
transport.Proxy = http.ProxyFromEnvironment
} else {
transport.Proxy = http.ProxyURL(u)
}
} else {
transport.Proxy = http.ProxyFromEnvironment
}
default: // "system" or empty
transport.Proxy = http.ProxyFromEnvironment
}
return &http.Client{Transport: transport}
} }
// ── Request / Response Types ──────────────── // ── Request / Response Types ────────────────
@@ -60,7 +99,7 @@ type Message struct {
// Tool calling fields (assistant → tool_calls, tool → tool_call_id) // Tool calling fields (assistant → tool_calls, tool → tool_call_id)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
Name string `json:"name,omitempty"` // tool name for role="tool" Name string `json:"name,omitempty"` // speaker name (tool result, or persona attribution v0.23.0)
} }
// ContentPart is a single element in a multimodal message. // ContentPart is a single element in a multimodal message.

View File

@@ -52,7 +52,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
} }
resp, err := http.DefaultClient.Do(httpReq) resp, err := cfg.Client().Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("venice list models: %w", err) return nil, fmt.Errorf("venice list models: %w", err)
} }

View File

@@ -313,10 +313,16 @@ func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (pr
} }
} }
proxyURL := ""
if cfg.ProxyURL != nil {
proxyURL = *cfg.ProxyURL
}
return prov, providers.ProviderConfig{ return prov, providers.ProviderConfig{
Endpoint: cfg.Endpoint, Endpoint: cfg.Endpoint,
APIKey: apiKey, APIKey: apiKey,
CustomHeaders: headers, CustomHeaders: headers,
ProxyMode: cfg.ProxyMode,
ProxyURL: proxyURL,
}, cfg.Scope, nil }, cfg.Scope, nil
} }

View File

@@ -153,8 +153,8 @@ type PolicyStore interface {
type UserModelSettingsStore interface { type UserModelSettingsStore interface {
GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error)
GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error)
Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error
BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error
} }
// ========================================= // =========================================
@@ -229,6 +229,19 @@ type ChannelStore interface {
// Ownership check // Ownership check
UserOwns(ctx context.Context, channelID, userID string) (bool, error) UserOwns(ctx context.Context, channelID, userID string) (bool, error)
// Channel participants (ICD §3.7)
AddParticipant(ctx context.Context, p *models.ChannelParticipant) error
ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error)
GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error)
UpdateParticipantRole(ctx context.Context, id, role string) error
RemoveParticipant(ctx context.Context, id string) error
IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error)
GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error)
CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error)
// Remove model roster entries auto-created by a persona participant
DeleteModelByPersona(ctx context.Context, channelID, personaID string) error
// Workspace resolution: channel workspace_id > project workspace_id // Workspace resolution: channel workspace_id > project workspace_id
ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) ResolveWorkspaceID(ctx context.Context, channelID string) (string, error)
} }

View File

@@ -183,20 +183,40 @@ func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID
} }
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error { func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
// For persona entries, use the persona-aware path
if cm.PersonaID != nil && *cm.PersonaID != "" {
return s.SetPersonaModel(ctx, cm)
}
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default) INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (channel_id, model_id) DO UPDATE SET ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
provider_config_id = $3, display_name = $4, system_prompt = $5, settings = $6, is_default = $7`, display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
return err return err
} }
// SetPersonaModel inserts or updates a persona's channel model roster entry.
// Uses the idx_channel_models_persona partial index (one per persona per channel).
func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_models (channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET
model_id = $2, provider_config_id = $3, display_name = $5, system_prompt = $6, settings = $7, is_default = $8`,
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
return err
}
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) { func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''), SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
FROM channel_models WHERE channel_id = $1`, channelID) COALESCE(cm.system_prompt, ''), cm.is_default
FROM channel_models cm
LEFT JOIN personas p ON p.id = cm.persona_id
WHERE cm.channel_id = $1`, channelID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -205,10 +225,14 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
var result []models.ChannelModel var result []models.ChannelModel
for rows.Next() { for rows.Next() {
var cm models.ChannelModel var cm models.ChannelModel
var personaID string
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil { &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
return nil, err return nil, err
} }
if personaID != "" {
cm.PersonaID = &personaID
}
result = append(result, cm) result = append(result, cm)
} }
return result, rows.Err() return result, rows.Err()
@@ -216,15 +240,22 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) { func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
var cm models.ChannelModel var cm models.ChannelModel
var personaID string
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''), SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
FROM channel_models WHERE id = $1`, id).Scan( COALESCE(cm.system_prompt, ''), cm.is_default
FROM channel_models cm
LEFT JOIN personas p ON p.id = cm.persona_id
WHERE cm.id = $1`, id).Scan(
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, &cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault) &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if personaID != "" {
cm.PersonaID = &personaID
}
return &cm, nil return &cm, nil
} }
@@ -287,3 +318,99 @@ func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string)
} }
return NullableString(wsID), nil return NullableString(wsID), nil
} }
// ── Channel Participants (ICD §3.7) ──────────
func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role, display_name, avatar_url)
VALUES ($1, $2, $3, $4, $5, $6)`,
p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL)
return err
}
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, participant_type, participant_id, role,
display_name, avatar_url, joined_at
FROM channel_participants WHERE channel_id = $1 ORDER BY joined_at`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ChannelParticipant
for rows.Next() {
var p models.ChannelParticipant
if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
&p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt); err != nil {
return nil, err
}
result = append(result, p)
}
return result, rows.Err()
}
func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) {
var p models.ChannelParticipant
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, participant_type, participant_id, role,
display_name, avatar_url, joined_at
FROM channel_participants WHERE id = $1`, id).Scan(
&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
&p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt)
if err != nil {
return nil, err
}
return &p, nil
}
func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error {
_, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = $1 WHERE id = $2`, role, id)
return err
}
func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error {
res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = $1`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM channel_participants
WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3)`,
channelID, pType, pID).Scan(&exists)
return exists, err
}
func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) {
var role string
err := DB.QueryRowContext(ctx, `
SELECT role FROM channel_participants
WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3`,
channelID, pType, pID).Scan(&role)
return role, err
}
func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM channel_participants WHERE channel_id = $1 AND role = $2`,
channelID, role).Scan(&count)
return count, err
}
func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM channel_models WHERE channel_id = $1 AND persona_id = $2`,
channelID, personaID)
return err
}

View File

@@ -13,20 +13,24 @@ type PersonaStore struct{}
func NewPersonaStore() *PersonaStore { return &PersonaStore{} } func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id, const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p, system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt, scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
created_at, updated_at` created_at, updated_at`
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error { func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
// Auto-generate handle from name if not set
if p.Handle == "" {
p.Handle = models.HandleFromName(p.Name)
}
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO personas (name, description, icon, avatar, base_model_id, provider_config_id, INSERT INTO personas (name, handle, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p, system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, scope, owner_id, created_by, is_active, is_shared,
memory_enabled, memory_extraction_prompt) memory_enabled, memory_extraction_prompt)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
RETURNING id, created_at, updated_at`, RETURNING id, created_at, updated_at`,
p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID, p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
models.NullString(p.ProviderConfigID), models.NullString(p.ProviderConfigID),
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens), p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP), models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
@@ -53,6 +57,9 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
if patch.Name != nil { if patch.Name != nil {
b.Set("name", *patch.Name) b.Set("name", *patch.Name)
} }
if patch.Handle != nil {
b.Set("handle", *patch.Handle)
}
if patch.Description != nil { if patch.Description != nil {
b.Set("description", *patch.Description) b.Set("description", *patch.Description)
} }
@@ -281,10 +288,11 @@ func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID stri
func scanPersona(row *sql.Row) (*models.Persona, error) { func scanPersona(row *sql.Row) (*models.Persona, error) {
var p models.Persona var p models.Persona
var providerConfigID, ownerID sql.NullString var providerConfigID, ownerID sql.NullString
var handle sql.NullString
var temp, topP sql.NullFloat64 var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64 var maxTokens, thinkingBudget sql.NullInt64
err := row.Scan( err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar, &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID, &p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
@@ -294,6 +302,9 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if handle.Valid {
p.Handle = handle.String
}
p.ProviderConfigID = NullableStringPtr(providerConfigID) p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID) p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp) p.Temperature = NullableFloat64Ptr(temp)
@@ -308,10 +319,11 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
for rows.Next() { for rows.Next() {
var p models.Persona var p models.Persona
var providerConfigID, ownerID sql.NullString var providerConfigID, ownerID sql.NullString
var handle sql.NullString
var temp, topP sql.NullFloat64 var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64 var maxTokens, thinkingBudget sql.NullInt64
err := rows.Scan( err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar, &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID, &p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
@@ -321,6 +333,9 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if handle.Valid {
p.Handle = handle.String
}
p.ProviderConfigID = NullableStringPtr(providerConfigID) p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID) p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp) p.Temperature = NullableFloat64Ptr(temp)

View File

@@ -14,7 +14,7 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) { func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, model_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens, 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 COALESCE(sort_order, 0), created_at, updated_at
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID) FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
if err != nil { if err != nil {
@@ -27,7 +27,7 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
var s models.UserModelSetting var s models.UserModelSetting
var prefTemp, prefMaxTokens interface{} var prefTemp, prefMaxTokens interface{}
err := rows.Scan( err := rows.Scan(
&s.ID, &s.UserID, &s.ModelID, &s.Hidden, &s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
&prefTemp, &prefMaxTokens, &prefTemp, &prefMaxTokens,
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
) )
@@ -46,10 +46,10 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
return result, rows.Err() return result, rows.Err()
} }
// GetHiddenModelIDs returns a map of model_id → true for all hidden models. // 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) { func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := DB.QueryContext(ctx, rows, err := DB.QueryContext(ctx,
"SELECT model_id FROM user_model_settings WHERE user_id = $1 AND hidden = true", "SELECT model_id, COALESCE(provider_config_id::text, '') FROM user_model_settings WHERE user_id = $1 AND hidden = true",
userID) userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -58,17 +58,17 @@ func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID s
result := make(map[string]bool) result := make(map[string]bool)
for rows.Next() { for rows.Next() {
var modelID string var modelID, provCfgID string
if err := rows.Scan(&modelID); err != nil { if err := rows.Scan(&modelID, &provCfgID); err != nil {
return nil, err return nil, err
} }
result[modelID] = true result[models.CompositeModelKey(provCfgID, modelID)] = true
} }
return result, rows.Err() return result, rows.Err()
} }
// Set upserts a single user model setting. // Set upserts a single user model setting.
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error { func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
b := NewUpdate("user_model_settings") b := NewUpdate("user_model_settings")
if patch.Hidden != nil { if patch.Hidden != nil {
b.Set("hidden", *patch.Hidden) b.Set("hidden", *patch.Hidden)
@@ -88,17 +88,16 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
} }
// Use upsert: insert if not exists, update if exists // Use upsert: insert if not exists, update if exists
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order) INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
VALUES ($1, $2, COALESCE($3, false), $4, $5, COALESCE($6, 0)) VALUES ($1, $2, $3, COALESCE($4, false), $5, $6, COALESCE($7, 0))
ON CONFLICT (user_id, model_id) ON CONFLICT (user_id, model_id, provider_config_id)
DO UPDATE SET DO UPDATE SET
hidden = COALESCE($3, user_model_settings.hidden), hidden = COALESCE($4, user_model_settings.hidden),
preferred_temperature = COALESCE($4, user_model_settings.preferred_temperature), preferred_temperature = COALESCE($5, user_model_settings.preferred_temperature),
preferred_max_tokens = COALESCE($5, user_model_settings.preferred_max_tokens), preferred_max_tokens = COALESCE($6, user_model_settings.preferred_max_tokens),
sort_order = COALESCE($6, user_model_settings.sort_order)`, sort_order = COALESCE($7, user_model_settings.sort_order)`,
userID, modelID, userID, modelID, providerConfigID,
patchBoolOrNil(patch.Hidden), patchBoolOrNil(patch.Hidden),
patchFloat64OrNil(patch.PreferredTemperature), patchFloat64OrNil(patch.PreferredTemperature),
patchIntOrNil(patch.PreferredMaxTokens), patchIntOrNil(patch.PreferredMaxTokens),
@@ -107,30 +106,20 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
return err return err
} }
// BulkSetHidden sets the hidden state for multiple models at once. // BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error { func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
if len(modelIDs) == 0 { if len(entries) == 0 {
return nil return nil
} }
// Build parameterized IN clause for _, entry := range entries {
placeholders := make([]string, len(modelIDs))
args := make([]interface{}, 0, len(modelIDs)+2)
args = append(args, userID, hidden)
for i, id := range modelIDs {
placeholders[i] = fmt.Sprintf("$%d", i+3)
args = append(args, id)
}
// Upsert each: some might not have rows yet
for _, modelID := range modelIDs {
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, hidden) INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = $3`, ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = $4`,
userID, modelID, hidden) userID, entry.ModelID, entry.ProviderConfigID, hidden)
if err != nil { if err != nil {
return fmt.Errorf("set hidden for %s: %w", modelID, err) return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
} }
} }
return nil return nil

View File

@@ -188,20 +188,39 @@ func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID
} }
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error { func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
// For persona entries, use the persona-aware path
if cm.PersonaID != nil && *cm.PersonaID != "" {
return s.SetPersonaModel(ctx, cm)
}
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default) INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (channel_id, model_id) DO UPDATE SET ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
return err return err
} }
// SetPersonaModel inserts or updates a persona's channel model roster entry.
func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET
model_id = excluded.model_id, provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
return err
}
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) { func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''), SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
FROM channel_models WHERE channel_id = ?`, channelID) COALESCE(cm.system_prompt, ''), cm.is_default
FROM channel_models cm
LEFT JOIN personas p ON p.id = cm.persona_id
WHERE cm.channel_id = ?`, channelID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -210,10 +229,14 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
var result []models.ChannelModel var result []models.ChannelModel
for rows.Next() { for rows.Next() {
var cm models.ChannelModel var cm models.ChannelModel
var personaID string
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil { &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
return nil, err return nil, err
} }
if personaID != "" {
cm.PersonaID = &personaID
}
result = append(result, cm) result = append(result, cm)
} }
return result, rows.Err() return result, rows.Err()
@@ -221,15 +244,22 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) { func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
var cm models.ChannelModel var cm models.ChannelModel
var personaID string
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''), SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
FROM channel_models WHERE id = ?`, id).Scan( COALESCE(cm.system_prompt, ''), cm.is_default
FROM channel_models cm
LEFT JOIN personas p ON p.id = cm.persona_id
WHERE cm.id = ?`, id).Scan(
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, &cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault) &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if personaID != "" {
cm.PersonaID = &personaID
}
return &cm, nil return &cm, nil
} }
@@ -289,3 +319,103 @@ func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string)
} }
return NullableString(wsID), nil return NullableString(wsID), nil
} }
// ── Channel Participants (ICD §3.7) ──────────
func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role, display_name, avatar_url)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
store.NewID(), p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL)
return err
}
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, participant_type, participant_id, role,
display_name, avatar_url, joined_at
FROM channel_participants WHERE channel_id = ? ORDER BY joined_at`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ChannelParticipant
for rows.Next() {
var p models.ChannelParticipant
var joinedStr string
if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
&p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr); err != nil {
return nil, err
}
p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr)
result = append(result, p)
}
return result, rows.Err()
}
func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) {
var p models.ChannelParticipant
var joinedStr string
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, participant_type, participant_id, role,
display_name, avatar_url, joined_at
FROM channel_participants WHERE id = ?`, id).Scan(
&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
&p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr)
if err != nil {
return nil, err
}
p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr)
return &p, nil
}
func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error {
_, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = ? WHERE id = ?`, role, id)
return err
}
func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error {
res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM channel_participants
WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`,
channelID, pType, pID).Scan(&count)
return count > 0, err
}
func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) {
var role string
err := DB.QueryRowContext(ctx, `
SELECT role FROM channel_participants
WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`,
channelID, pType, pID).Scan(&role)
return role, err
}
func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM channel_participants WHERE channel_id = ? AND role = ?`,
channelID, role).Scan(&count)
return count, err
}
func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM channel_models WHERE channel_id = ? AND persona_id = ?`,
channelID, personaID)
return err
}

View File

@@ -15,7 +15,7 @@ type PersonaStore struct{}
func NewPersonaStore() *PersonaStore { return &PersonaStore{} } func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id, const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p, system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt, scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
created_at, updated_at` created_at, updated_at`
@@ -25,13 +25,17 @@ func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
now := time.Now().UTC() now := time.Now().UTC()
p.CreatedAt = now p.CreatedAt = now
p.UpdatedAt = now p.UpdatedAt = now
// Auto-generate handle from name if not set
if p.Handle == "" {
p.Handle = models.HandleFromName(p.Name)
}
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO personas (id, name, description, icon, avatar, base_model_id, provider_config_id, INSERT INTO personas (id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p, system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt, scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
created_at, updated_at) created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID, p.ID, p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
models.NullString(p.ProviderConfigID), models.NullString(p.ProviderConfigID),
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens), p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP), models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
@@ -60,6 +64,9 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
if patch.Name != nil { if patch.Name != nil {
b.Set("name", *patch.Name) b.Set("name", *patch.Name)
} }
if patch.Handle != nil {
b.Set("handle", *patch.Handle)
}
if patch.Description != nil { if patch.Description != nil {
b.Set("description", *patch.Description) b.Set("description", *patch.Description)
} }
@@ -287,11 +294,11 @@ func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID stri
func scanPersona(row *sql.Row) (*models.Persona, error) { func scanPersona(row *sql.Row) (*models.Persona, error) {
var p models.Persona var p models.Persona
var providerConfigID, ownerID sql.NullString var providerConfigID, ownerID, handle sql.NullString
var temp, topP sql.NullFloat64 var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64 var maxTokens, thinkingBudget sql.NullInt64
err := row.Scan( err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar, &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID, &p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
@@ -301,6 +308,9 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if handle.Valid {
p.Handle = handle.String
}
p.ProviderConfigID = NullableStringPtr(providerConfigID) p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID) p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp) p.Temperature = NullableFloat64Ptr(temp)
@@ -314,11 +324,11 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
var result []models.Persona var result []models.Persona
for rows.Next() { for rows.Next() {
var p models.Persona var p models.Persona
var providerConfigID, ownerID sql.NullString var providerConfigID, ownerID, handle sql.NullString
var temp, topP sql.NullFloat64 var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64 var maxTokens, thinkingBudget sql.NullInt64
err := rows.Scan( err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar, &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID, &p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
@@ -328,6 +338,9 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if handle.Valid {
p.Handle = handle.String
}
p.ProviderConfigID = NullableStringPtr(providerConfigID) p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID) p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp) p.Temperature = NullableFloat64Ptr(temp)

View File

@@ -15,7 +15,7 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) { func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, model_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens, 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 COALESCE(sort_order, 0), created_at, updated_at
FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID) FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID)
if err != nil { if err != nil {
@@ -28,7 +28,7 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
var s models.UserModelSetting var s models.UserModelSetting
var prefTemp, prefMaxTokens interface{} var prefTemp, prefMaxTokens interface{}
err := rows.Scan( err := rows.Scan(
&s.ID, &s.UserID, &s.ModelID, &s.Hidden, &s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
&prefTemp, &prefMaxTokens, &prefTemp, &prefMaxTokens,
&s.SortOrder, st(&s.CreatedAt), st(&s.UpdatedAt), &s.SortOrder, st(&s.CreatedAt), st(&s.UpdatedAt),
) )
@@ -47,10 +47,10 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
return result, rows.Err() return result, rows.Err()
} }
// GetHiddenModelIDs returns a map of model_id → true for all hidden models. // 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) { func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := DB.QueryContext(ctx, rows, err := DB.QueryContext(ctx,
"SELECT model_id FROM user_model_settings WHERE user_id = ? AND hidden = 1", "SELECT model_id, COALESCE(provider_config_id, '') FROM user_model_settings WHERE user_id = ? AND hidden = 1",
userID) userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -59,17 +59,17 @@ func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID s
result := make(map[string]bool) result := make(map[string]bool)
for rows.Next() { for rows.Next() {
var modelID string var modelID, provCfgID string
if err := rows.Scan(&modelID); err != nil { if err := rows.Scan(&modelID, &provCfgID); err != nil {
return nil, err return nil, err
} }
result[modelID] = true result[models.CompositeModelKey(provCfgID, modelID)] = true
} }
return result, rows.Err() return result, rows.Err()
} }
// Set upserts a single user model setting. // Set upserts a single user model setting.
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error { func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
b := NewUpdate("user_model_settings") b := NewUpdate("user_model_settings")
if patch.Hidden != nil { if patch.Hidden != nil {
b.Set("hidden", *patch.Hidden) b.Set("hidden", *patch.Hidden)
@@ -89,17 +89,16 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
} }
// Use upsert: insert if not exists, update if exists // Use upsert: insert if not exists, update if exists
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order) 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)) VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0))
ON CONFLICT (user_id, model_id) ON CONFLICT (user_id, model_id, provider_config_id)
DO UPDATE SET DO UPDATE SET
hidden = COALESCE(excluded.hidden, user_model_settings.hidden), hidden = COALESCE(excluded.hidden, user_model_settings.hidden),
preferred_temperature = COALESCE(excluded.preferred_temperature, user_model_settings.preferred_temperature), 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), 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)`, sort_order = COALESCE(excluded.sort_order, user_model_settings.sort_order)`,
store.NewID(), userID, modelID, store.NewID(), userID, modelID, providerConfigID,
patchBoolOrNil(patch.Hidden), patchBoolOrNil(patch.Hidden),
patchFloat64OrNil(patch.PreferredTemperature), patchFloat64OrNil(patch.PreferredTemperature),
patchIntOrNil(patch.PreferredMaxTokens), patchIntOrNil(patch.PreferredMaxTokens),
@@ -108,21 +107,20 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
return err return err
} }
// BulkSetHidden sets the hidden state for multiple models at once. // BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error { func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
if len(modelIDs) == 0 { if len(entries) == 0 {
return nil return nil
} }
// Upsert each model individually for _, entry := range entries {
for _, modelID := range modelIDs {
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (id, user_id, model_id, hidden) INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = excluded.hidden`, ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = excluded.hidden`,
store.NewID(), userID, modelID, hidden) store.NewID(), userID, entry.ModelID, entry.ProviderConfigID, hidden)
if err != nil { if err != nil {
return fmt.Errorf("set hidden for %s: %w", modelID, err) return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
} }
} }
return nil return nil

View File

@@ -22,8 +22,8 @@
gap: 2px; gap: 2px;
padding: 2px 8px; padding: 2px 8px;
border-radius: 12px; border-radius: 12px;
background: var(--bg-secondary, #f0f0f0); background: var(--bg-raised);
border: 1px solid var(--border-color, #ddd); border: 1px solid var(--border);
font-size: 0.75rem; font-size: 0.75rem;
white-space: nowrap; white-space: nowrap;
cursor: default; cursor: default;
@@ -31,7 +31,7 @@
} }
.ch-model-pill.ch-model-default { .ch-model-pill.ch-model-default {
background: var(--accent-bg, #e8f0fe); background: var(--accent-bg, #e8f0fe);
border-color: var(--accent-color, #4a8af4); border-color: var(--accent);
} }
.ch-model-pill-name { .ch-model-pill-name {
cursor: pointer; cursor: pointer;
@@ -45,7 +45,7 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
font-size: 0.65rem; font-size: 0.65rem;
color: var(--text-secondary, #888); color: var(--text-3);
padding: 0 2px; padding: 0 2px;
line-height: 1; line-height: 1;
opacity: 0.6; opacity: 0.6;
@@ -58,18 +58,18 @@
.ch-model-add-btn { .ch-model-add-btn {
background: none; background: none;
border: 1px dashed var(--border-color, #ccc); border: 1px dashed var(--border);
border-radius: 12px; border-radius: 12px;
padding: 2px 8px; padding: 2px 8px;
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
color: var(--text-secondary, #888); color: var(--text-3);
white-space: nowrap; white-space: nowrap;
transition: color 0.15s, border-color 0.15s; transition: color 0.15s, border-color 0.15s;
} }
.ch-model-add-btn:hover { .ch-model-add-btn:hover {
color: var(--text-primary, #333); color: var(--text);
border-color: var(--text-primary, #333); border-color: var(--text);
} }
/* ── Add Model Dialog ────────────────────── */ /* ── Add Model Dialog ────────────────────── */
@@ -84,7 +84,7 @@
z-index: 1100; z-index: 1100;
} }
.ch-model-add-inner { .ch-model-add-inner {
background: var(--bg-primary, #fff); background: var(--bg-surface);
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
min-width: 320px; min-width: 320px;
@@ -99,7 +99,7 @@
display: block; display: block;
margin-bottom: 12px; margin-bottom: 12px;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary, #666); color: var(--text-3);
} }
.ch-model-add-inner select, .ch-model-add-inner select,
.ch-model-add-inner input[type="text"] { .ch-model-add-inner input[type="text"] {
@@ -107,11 +107,11 @@
width: 100%; width: 100%;
margin-top: 4px; margin-top: 4px;
padding: 8px; padding: 8px;
border: 1px solid var(--border-color, #ddd); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-primary, #fff); background: var(--bg-surface);
color: var(--text-primary, #333); color: var(--text);
box-sizing: border-box; box-sizing: border-box;
} }
.ch-model-add-actions { .ch-model-add-actions {
@@ -126,40 +126,72 @@
.mention-ac { .mention-ac {
position: fixed; position: fixed;
z-index: 1050; z-index: 1050;
background: var(--bg-primary, #fff); background: var(--bg-raised);
border: 1px solid var(--border-color, #ddd); border: 1px solid var(--border-light);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15); box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-height: 200px; max-height: 260px;
overflow-y: auto; overflow-y: auto;
padding: 4px 0; padding: 4px 0;
} }
.mention-ac-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
gap: 12px;
transition: background 0.1s;
}
.mention-ac-item:hover, .mention-ac-item:hover,
.mention-ac-item.mention-ac-active { .mention-ac-item.mention-ac-active {
background: var(--bg-secondary, #f5f5f5); background: var(--bg-hover);
} }
.mention-ac-name { .mention-ac-name {
font-weight: 500; font-weight: 500;
font-size: 0.85rem; font-size: 0.85rem;
} }
.mention-ac-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.mention-ac-handle {
font-size: 0.72rem;
color: var(--accent, #6c9fff);
font-family: var(--font-mono, monospace);
opacity: 0.8;
}
.mention-ac-model { .mention-ac-model {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary, #999); color: var(--text-3);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 160px; max-width: 160px;
} }
/* ── Autocomplete avatars (v0.23.0) ──────────── */
.mention-ac-avatar {
width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0;
object-fit: cover;
}
.mention-ac-avatar-fallback {
display: inline-flex; align-items: center; justify-content: center;
background: var(--bg-hover); font-size: 12px; line-height: 1;
}
.mention-ac-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
gap: 8px;
transition: background 0.1s;
}
/* ── @mention pills in messages (v0.23.0) ──── */
.mention-pill {
display: inline;
color: var(--accent, #6c9fff);
font-weight: 600;
background: var(--accent-dim, rgba(108,159,255,0.12));
padding: 1px 5px;
border-radius: 4px;
font-size: 0.92em;
}
/* ── Model Attribution (multi-model messages) ─ */ /* ── Model Attribution (multi-model messages) ─ */
.msg-model-label { .msg-model-label {
@@ -168,8 +200,8 @@
font-weight: 600; font-weight: 600;
padding: 1px 6px; padding: 1px 6px;
border-radius: 4px; border-radius: 4px;
background: var(--bg-secondary, #f0f0f0); background: var(--bg-raised);
color: var(--text-secondary, #666); color: var(--text-3);
margin-left: 6px; margin-left: 6px;
vertical-align: middle; vertical-align: middle;
} }
@@ -181,7 +213,7 @@
gap: 8px; gap: 8px;
padding: 12px 0 4px; padding: 12px 0 4px;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-secondary, #888); color: var(--text-3);
font-weight: 500; font-weight: 500;
} }
.model-stream-divider::before, .model-stream-divider::before,
@@ -189,7 +221,7 @@
content: ''; content: '';
flex: 1; flex: 1;
height: 1px; height: 1px;
background: var(--border-color, #eee); background: var(--border);
} }
/* ── Mobile ──────────────────────────────── */ /* ── Mobile ──────────────────────────────── */

View File

@@ -144,6 +144,8 @@
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 16px 16px 4px 16px; border-radius: 16px 16px 4px 16px;
padding: 10px 14px; padding: 10px 14px;
flex: initial;
max-width: 85%;
} }
.message.user .msg-head { flex-direction: row-reverse; } .message.user .msg-head { flex-direction: row-reverse; }
.message.user .msg-role { color: var(--accent); } .message.user .msg-role { color: var(--accent); }
@@ -594,3 +596,11 @@
} }
.lightbox-close:hover { background: rgba(255,255,255,0.3); } .lightbox-close:hover { background: rgba(255,255,255,0.3); }
/* ── Chat type indicators (v0.23.0) ────────── */
.chat-type-icon { font-size: 11px; opacity: 0.7; }
.mono-input { font-family: var(--font-mono, 'JetBrains Mono', monospace) !important; font-size: 12px !important; }
/* Handle hints in dropdowns and pickers */
.item-handle { font-size: 10px; color: var(--accent); font-family: var(--font-mono, monospace); margin-left: 6px; opacity: 0.75; }
.group-persona-handle { font-size: 10px; color: var(--accent); font-family: var(--font-mono, monospace); margin-left: 4px; opacity: 0.75; }

View File

@@ -445,3 +445,16 @@
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } } @keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
@keyframes fade-in { from { opacity: 0; } } @keyframes fade-in { from { opacity: 0; } }
/* ── Group Chat persona picker (v0.23.0) ────── */
.group-persona-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: var(--radius);
cursor: pointer; font-size: 13px;
transition: background 0.1s;
}
.group-persona-item:hover { background: var(--bg-hover); }
.group-persona-item input[type="checkbox"] { accent-color: var(--accent); flex-shrink: 0; }
.group-persona-name { flex: 1; color: var(--text); font-weight: 500; }
.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; }

View File

@@ -131,7 +131,7 @@ function processModelsResponse(data, hiddenModels = new Set()) {
return (data.models || []).map(m => { return (data.models || []).map(m => {
const isPersona = !!m.is_persona; const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id; const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id; const cfgId = m.config_id || m.provider_config_id || '';
const id = isPersona const id = isPersona
? (m.persona_id || m.id) ? (m.persona_id || m.id)
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId); : (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
@@ -143,12 +143,13 @@ function processModelsResponse(data, hiddenModels = new Set()) {
configId: cfgId || null, configId: cfgId || null,
isPersona, isPersona,
personaId: m.persona_id || null, personaId: m.persona_id || null,
personaHandle: m.persona_handle || null,
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null, personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null, personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null, personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
source: m.source || (isPersona ? 'persona' : 'global'), source: m.source || (isPersona ? 'persona' : 'global'),
teamName: m.persona_team_name || null, teamName: m.persona_team_name || null,
hidden: !isPersona && hiddenModels.has(baseModelId), hidden: !isPersona && hiddenModels.has((cfgId || '') + ':' + baseModelId),
}; };
}); });
} }

View File

@@ -111,6 +111,7 @@ describe('processModelsResponse — personas', () => {
persona_id: 'preset-uuid', persona_id: 'preset-uuid',
persona_scope: 'global', persona_scope: 'global',
persona_avatar: '/avatars/code.png', persona_avatar: '/avatars/code.png',
persona_handle: 'code-helper',
persona_team_name: null, persona_team_name: null,
config_id: 'cfg-uuid', config_id: 'cfg-uuid',
provider_name: 'OpenAI', provider_name: 'OpenAI',
@@ -147,6 +148,7 @@ describe('processModelsResponse — personas', () => {
const models = processModelsResponse(apiResponse); const models = processModelsResponse(apiResponse);
assert.equal(models[0].personaScope, 'global'); assert.equal(models[0].personaScope, 'global');
assert.equal(models[0].personaAvatar, '/avatars/code.png'); assert.equal(models[0].personaAvatar, '/avatars/code.png');
assert.equal(models[0].personaHandle, 'code-helper');
assert.equal(models[0].personaId, 'preset-uuid'); assert.equal(models[0].personaId, 'preset-uuid');
}); });
@@ -208,8 +210,8 @@ describe('processModelsResponse — hidden models', () => {
], ],
}; };
it('marks models as hidden from user prefs', () => { it('marks models as hidden from user prefs (composite key)', () => {
const hidden = new Set(['gpt-4o']); const hidden = new Set(['c1:gpt-4o']);
const models = processModelsResponse(apiResponse, hidden); const models = processModelsResponse(apiResponse, hidden);
assert.equal(models[0].hidden, true, 'gpt-4o should be hidden'); assert.equal(models[0].hidden, true, 'gpt-4o should be hidden');
assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden'); assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden');
@@ -221,7 +223,7 @@ describe('processModelsResponse — hidden models', () => {
{ model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' }, { model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' },
], ],
}; };
const hidden = new Set(['gpt-4o']); const hidden = new Set([':gpt-4o']);
const models = processModelsResponse(resp, hidden); const models = processModelsResponse(resp, hidden);
assert.equal(models[0].hidden, false, assert.equal(models[0].hidden, false,
'personas must NOT be hidden by base model hidden pref'); 'personas must NOT be hidden by base model hidden pref');

View File

@@ -314,7 +314,7 @@ describe('Edge: provider_config_id only (no config_id alias)', () => {
describe('Edge: Hidden model preferences', () => { describe('Edge: Hidden model preferences', () => {
it('marks hidden models correctly', () => { it('marks hidden models correctly', () => {
const hidden = new Set(['gpt-4o-mini']); const hidden = new Set(['cfg-global-openai:gpt-4o-mini']);
const models = processModelsResponse({ models: GLOBAL_MODELS }, hidden); const models = processModelsResponse({ models: GLOBAL_MODELS }, hidden);
const mini = models.find(m => m.baseModelId === 'gpt-4o-mini'); const mini = models.find(m => m.baseModelId === 'gpt-4o-mini');
const main = models.find(m => m.baseModelId === 'gpt-4o'); const main = models.find(m => m.baseModelId === 'gpt-4o');
@@ -324,7 +324,7 @@ describe('Edge: Hidden model preferences', () => {
it('personas are never hidden', () => { it('personas are never hidden', () => {
const persona = personaModel('preset-1', 'gpt-4o', 'global'); const persona = personaModel('preset-1', 'gpt-4o', 'global');
const hidden = new Set(['gpt-4o']); const hidden = new Set([':gpt-4o']);
const models = processModelsResponse({ models: [persona] }, hidden); const models = processModelsResponse({ models: [persona] }, hidden);
assert.equal(models[0].hidden, false, 'personas must never be hidden'); assert.equal(models[0].hidden, false, 'personas must never be hidden');
}); });

View File

@@ -154,11 +154,12 @@ async function bulkSetVisibility(visibility) {
// ── User Model Preferences ────────────────── // ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, currentlyHidden) { async function toggleUserModelVisibility(modelId, providerConfigId, currentlyHidden) {
const compositeKey = (providerConfigId || '') + ':' + modelId;
try { try {
await API.setModelPreference(modelId, !currentlyHidden); await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
if (currentlyHidden) App.hiddenModels.delete(modelId); if (currentlyHidden) App.hiddenModels.delete(compositeKey);
else App.hiddenModels.add(modelId); else App.hiddenModels.add(compositeKey);
await UI.loadUserModels(); await UI.loadUserModels();
await fetchModels(); // refresh model selector await fetchModels(); // refresh model selector
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
@@ -170,12 +171,18 @@ async function bulkSetUserModelVisibility(show) {
const shouldHide = !show; const shouldHide = !show;
// Only update models not already in the desired state // Only update models not already in the desired state
const toUpdate = models const toUpdate = models
.map(m => m.baseModelId || m.id) .filter(m => {
.filter(mid => App.hiddenModels.has(mid) !== shouldHide); const compositeKey = (m.configId || '') + ':' + (m.baseModelId || m.id);
return App.hiddenModels.has(compositeKey) !== shouldHide;
})
.map(m => ({ model_id: m.baseModelId || m.id, provider_config_id: m.configId || '' }));
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; } if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
try { try {
await API.bulkSetModelPreferences(toUpdate, shouldHide); await API.bulkSetModelPreferences(toUpdate, shouldHide);
toUpdate.forEach(mid => shouldHide ? App.hiddenModels.add(mid) : App.hiddenModels.delete(mid)); toUpdate.forEach(e => {
const key = (e.provider_config_id || '') + ':' + e.model_id;
shouldHide ? App.hiddenModels.add(key) : App.hiddenModels.delete(key);
});
await UI.loadUserModels(); await UI.loadUserModels();
await fetchModels(); await fetchModels();
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`); UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);

View File

@@ -194,6 +194,12 @@ const API = {
updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); }, updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); },
deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); }, deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); },
// Channel participants (v0.23.0 — ICD §3.7)
listParticipants(channelId) { return this._get(`/api/v1/channels/${channelId}/participants`); },
addParticipant(channelId, data) { return this._post(`/api/v1/channels/${channelId}/participants`, data); },
updateParticipant(channelId, participantId, data) { return this._patch(`/api/v1/channels/${channelId}/participants/${participantId}`, data); },
removeParticipant(channelId, participantId) { return this._del(`/api/v1/channels/${channelId}/participants/${participantId}`); },
// Notification preferences (v0.20.0 Phase 3) // Notification preferences (v0.20.0 Phase 3)
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); }, listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); }, setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); },
@@ -451,8 +457,8 @@ const API = {
listEnabledModels() { return this._get('/api/v1/models/enabled'); }, listEnabledModels() { return this._get('/api/v1/models/enabled'); },
getModelPreferences() { return this._get('/api/v1/models/preferences'); }, getModelPreferences() { return this._get('/api/v1/models/preferences'); },
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); }, setModelPreference(modelId, providerConfigId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: providerConfigId, hidden }); },
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); }, bulkSetModelPreferences(entries, hidden) { return this._post('/api/v1/models/preferences/bulk', { entries, hidden }); },
// User personas // User personas
listUserPersonas() { return this._get('/api/v1/personas'); }, listUserPersonas() { return this._get('/api/v1/personas'); },

View File

@@ -54,7 +54,9 @@ async function fetchModels() {
try { try {
const prefData = await API.getModelPreferences(); const prefData = await API.getModelPreferences();
App.hiddenModels = new Set( App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id) (prefData.preferences || []).filter(p => p.hidden).map(p =>
(p.provider_config_id || '') + ':' + p.model_id
)
); );
} catch (e) { } catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set(); if (!App.hiddenModels) App.hiddenModels = new Set();
@@ -63,25 +65,27 @@ async function fetchModels() {
App.models = (data.models || []).map(m => { App.models = (data.models || []).map(m => {
const isPersona = !!m.is_persona; const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id; const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || '';
const id = isPersona const id = isPersona
? (m.persona_id || m.id) ? (m.persona_id || m.id)
: ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId); : (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
return { return {
id, id,
baseModelId, baseModelId,
name: m.display_name || baseModelId, name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '', provider: m.provider_name || m.provider || '',
configId: m.config_id || m.provider_config_id || null, configId: cfgId || null,
model_type: m.model_type || 'chat', model_type: m.model_type || 'chat',
capabilities: resolveCapabilities(m.capabilities, baseModelId), capabilities: resolveCapabilities(m.capabilities, baseModelId),
isPersona, isPersona,
personaId: m.persona_id || null, personaId: m.persona_id || null,
personaHandle: m.persona_handle || null,
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null, personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null, personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null, personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
source: m.source || (isPersona ? 'persona' : 'global'), source: m.source || (isPersona ? 'persona' : 'global'),
teamName: m.persona_team_name || null, teamName: m.persona_team_name || null,
hidden: !isPersona && App.hiddenModels.has(baseModelId), hidden: !isPersona && App.hiddenModels.has((cfgId || '') + ':' + baseModelId),
}; };
}); });

View File

@@ -187,31 +187,44 @@ const ChannelModels = {
* Shows a dropdown of channel models when user types @. * Shows a dropdown of channel models when user types @.
*/ */
onInput(inputEl) { onInput(inputEl) {
if (this._roster.length < 2) return; // no autocomplete for single-model channels
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value; const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart; const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart;
// Find the @token being typed (search backward from cursor)
const before = text.slice(0, cursorPos); const before = text.slice(0, cursorPos);
const atIdx = before.lastIndexOf('@'); const atIdx = before.lastIndexOf('@');
if (atIdx < 0) { this.hideAutocomplete(); return; } if (atIdx < 0) { this.hideAutocomplete(); return; }
// @ must be at start or preceded by whitespace
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') { if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
this.hideAutocomplete(); this.hideAutocomplete();
return; return;
} }
const partial = before.slice(atIdx + 1).toLowerCase(); const partial = before.slice(atIdx + 1).toLowerCase().replace(/-/g, ' ');
const matches = this._roster.filter(m => if (partial.length === 0) {
m.display_name.toLowerCase().startsWith(partial) || // Just typed @ — show everything
m.display_name.toLowerCase().replace(/-/g, ' ').startsWith(partial.replace(/-/g, ' ')) }
);
// Build match list from ALL enabled models + personas (not just roster)
const allModels = (App.models || []).filter(m => !m.hidden);
const matches = allModels.filter(m => {
// Match against persona handle
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
// Match against model ID
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
// Match against display name
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
const firstName = name.split(' ')[0] || '';
if (partial.length === 0) return true; // show all on bare @
return handle.startsWith(partial)
|| modelId.startsWith(partial)
|| name.startsWith(partial)
|| firstName.startsWith(partial);
});
if (matches.length === 0) { this.hideAutocomplete(); return; } if (matches.length === 0) { this.hideAutocomplete(); return; }
// Cap at 10 to keep dropdown manageable
this.showAutocomplete(inputEl, atIdx, cursorPos, matches); this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
}, },
showAutocomplete(inputEl, atIdx, cursorPos, matches) { showAutocomplete(inputEl, atIdx, cursorPos, matches) {
@@ -221,19 +234,36 @@ const ChannelModels = {
wrap.className = 'mention-ac'; wrap.className = 'mention-ac';
wrap.id = 'mentionAc'; wrap.id = 'mentionAc';
// Position relative to input
const inputRect = (inputEl.el || inputEl).getBoundingClientRect(); const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px'; wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
wrap.style.left = inputRect.left + 'px'; wrap.style.left = inputRect.left + 'px';
wrap.style.minWidth = '200px'; wrap.style.minWidth = '280px';
matches.forEach((m, i) => { matches.forEach((m, i) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : ''); item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : '');
item.dataset.name = m.display_name;
item.innerHTML = `<span class="mention-ac-name">${esc(m.display_name)}</span><span class="mention-ac-model">${esc(m.model_id)}</span>`; // Determine the @handle to insert
const mentionToken = m.isPersona
? (m.personaHandle || m.name?.replace(/\s+/g, '-').toLowerCase() || m.id)
: (m.baseModelId || m.id);
item.dataset.handle = mentionToken;
const avatar = m.personaAvatar
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
: m.isPersona
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
const handleText = m.isPersona && m.personaHandle
? `@${esc(m.personaHandle)}`
: `@${esc(m.baseModelId || m.id)}`;
const providerHint = m.provider ? esc(m.provider) : '';
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
item.addEventListener('click', () => { item.addEventListener('click', () => {
this._insertMention(inputEl, atIdx, cursorPos, m.display_name); this._insertMention(inputEl, atIdx, cursorPos, mentionToken);
}); });
wrap.appendChild(item); wrap.appendChild(item);
}); });
@@ -278,9 +308,7 @@ const ChannelModels = {
if (e.key === 'Enter' || e.key === 'Tab') { if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
const active = items[activeIdx]; const active = items[activeIdx];
if (active) { if (active) active.click();
active.click();
}
return true; return true;
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -326,6 +354,31 @@ const ChannelModels = {
if (cm?.display_name) return cm.display_name; if (cm?.display_name) return cm.display_name;
const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId); const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId);
return m?.name || modelId; return m?.name || modelId;
},
/**
* Resolve persona avatar for a channel model roster entry.
* Looks up via persona_id in App.models.
*/
_resolveAvatar(rosterEntry) {
if (!rosterEntry.persona_id) return null;
const persona = App.models?.find(m => m.personaId === rosterEntry.persona_id);
return persona?.personaAvatar || null;
},
/**
* Get persona info for a given model_id from the roster + App.models.
* Returns { displayName, avatar, personaId } or null.
*/
resolvePersonaInfo(modelId) {
const cm = this._roster.find(m => m.model_id === modelId);
if (!cm) return null;
const avatar = this._resolveAvatar(cm);
return {
displayName: cm.display_name || modelId,
avatar: avatar,
personaId: cm.persona_id || null,
};
} }
}; };

View File

@@ -185,7 +185,7 @@ async function toggleChannelAutoCompact(enabled) {
async function loadChats() { async function loadChats() {
try { try {
const resp = await API.listChannels(1, 100, 'direct'); const resp = await API.listChannels(1, 100);
App.chats = (resp.data || []).map(c => ({ App.chats = (resp.data || []).map(c => ({
id: c.id, id: c.id,
title: c.title, title: c.title,
@@ -404,6 +404,123 @@ async function newChat() {
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`); window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
} }
// ── New Group Chat (v0.23.0) ─────────────────
// Creates a group channel and adds persona participants.
// Personas can @mention each other → AI-to-AI chaining.
async function newGroupChat() {
const personas = (App.models || []).filter(m => m.isPersona);
if (personas.length === 0) {
UI.toast('No personas available. Ask your admin to create one.', 'warning');
return;
}
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
const personaCheckboxes = personas.map(p => {
const scope = p.personaScope || 'global';
const badge = scope === 'personal' ? ' <span class="dd-hint">personal</span>'
: scope === 'team' ? ` <span class="dd-hint">${esc(p.personaTeamName || 'team')}</span>` : '';
const handle = p.personaHandle ? ` <span class="group-persona-handle">@${esc(p.personaHandle)}</span>` : '';
return `<label class="group-persona-item">
<input type="checkbox" value="${esc(p.personaId)}" data-name="${esc(p.name)}" data-handle="${esc(p.personaHandle || '')}">
<span class="group-persona-name">${esc(p.name)}${handle}${badge}</span>
<span class="group-persona-model">${esc(p.baseModelId || '')}</span>
</label>`;
}).join('');
overlay.innerHTML = `
<div class="confirm-dialog" style="max-width:480px">
<div class="confirm-header">New Group Chat</div>
<div class="confirm-body">
<div style="margin-bottom:12px">
<label style="font-size:12px;color:var(--text-secondary);display:block;margin-bottom:4px">Channel Name</label>
<input id="groupChatTitle" type="text" class="settings-input" placeholder="e.g. Code Review Team" style="width:100%;box-sizing:border-box" autofocus>
</div>
<div>
<label style="font-size:12px;color:var(--text-secondary);display:block;margin-bottom:6px">Add Personas (select at least one)</label>
<div class="group-persona-list" style="max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:4px">
${personaCheckboxes}
</div>
</div>
</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">Cancel</button>
<button class="btn-small btn-primary" data-action="ok">Create</button>
</div>
</div>`;
function close(result) {
overlay.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') close(false);
}
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
overlay.querySelector('[data-action="ok"]').addEventListener('click', async () => {
const title = overlay.querySelector('#groupChatTitle').value.trim() || 'Group Chat';
const checked = [...overlay.querySelectorAll('.group-persona-list input:checked')];
if (checked.length === 0) {
UI.toast('Select at least one persona', 'warning');
return;
}
try {
// Create group channel
const resp = await API.createChannel(title, null, null, 'group');
const chat = {
id: resp.id, title: resp.title, type: 'group', model: null,
messages: [], messageCount: 0, projectId: resp.project_id || null,
workspace_id: resp.workspace_id || null, updatedAt: resp.updated_at,
settings: resp.settings || {}
};
// Add persona participants
for (const cb of checked) {
try {
await API.addParticipant(chat.id, {
participant_type: 'persona',
participant_id: cb.value,
role: 'member'
});
} catch (e) {
console.warn('Failed to add persona:', cb.dataset.name, e.message);
}
}
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
// Load the channel model roster (populated by participant auto-add)
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chat.id);
UI.showEmptyState();
const names = checked.map(cb => cb.dataset.name).join(', ');
UI.toast(`Group created with ${checked.length} persona${checked.length > 1 ? 's' : ''}: ${names}`, 'success');
ChatInput.focus();
} catch (e) {
UI.toast('Failed to create group: ' + e.message, 'error');
}
close(true);
});
document.body.appendChild(overlay);
overlay.querySelector('#groupChatTitle').focus();
});
}
async function deleteChat(chatId) { async function deleteChat(chatId) {
if (!await showConfirm('Delete this chat?')) return; if (!await showConfirm('Delete this chat?')) return;
try { try {
@@ -908,6 +1025,65 @@ function _initChatListeners() {
// Multi-model channel roster (v0.20.0) // Multi-model channel roster (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.init(); if (typeof ChannelModels !== 'undefined') ChannelModels.init();
// AI-to-AI chain: handle messages arriving via WebSocket (v0.23.0)
// When a persona @mentions another persona, the follow-up completion
// runs server-side and delivers the result via WS, not SSE.
Events.on('message.created', (payload) => {
if (!payload || !payload.channel_id) return;
if (payload.channel_id !== App.currentChatId) return;
const chat = App.chats.find(c => c.id === payload.channel_id);
if (!chat) return;
// Append the chained message
chat.messages.push({
id: payload.id,
parent_id: null,
role: payload.role || 'assistant',
content: payload.content || '',
model: payload.model || '',
modelName: payload.display_name || payload.model || '',
timestamp: new Date().toISOString(),
tool_calls: null,
metadata: payload.chain_depth ? { chain_depth: payload.chain_depth } : null,
siblingCount: 1,
siblingIndex: 0,
participant_type: payload.participant_type,
participant_id: payload.participant_id,
});
// Remove typing indicator and re-render
document.getElementById('typingIndicator')?.remove();
UI.renderMessages(chat.messages);
});
// Typing indicators from persona chain activity
Events.on('typing.start', (payload) => {
if (!payload || payload.channel_id !== App.currentChatId) return;
if (payload.participant_type !== 'persona') return;
// Show typing indicator with persona name
document.getElementById('typingIndicator')?.remove();
const container = document.getElementById('chatMessages');
if (!container) return;
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
const name = payload.display_name || 'AI';
typing.innerHTML = `
<div class="msg-row">
<div class="msg-avatar"><span class="avatar-emoji">⚡</span></div>
<div class="msg-body"><span class="chain-typing-name">${name}</span> <div class="typing-dots"><span></span><span></span><span></span></div></div>
</div>`;
container.appendChild(typing);
container.scrollTop = container.scrollHeight;
});
Events.on('typing.stop', (payload) => {
if (!payload || payload.channel_id !== App.currentChatId) return;
document.getElementById('typingIndicator')?.remove();
});
// Close modals on overlay click // Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => { document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {

View File

@@ -630,7 +630,7 @@ function _showSaveToNoteModal(opts) {
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.id = 'saveToNoteModal'; modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay'; modal.className = 'modal-overlay active';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content save-to-note-modal"> <div class="modal-content save-to-note-modal">
<h3>Save to Note</h3> <h3>Save to Note</h3>

View File

@@ -17,7 +17,17 @@ function avatarHTML(role, avatarDataURI) {
// Look up the current model/persona avatar for assistant messages. // Look up the current model/persona avatar for assistant messages.
function assistantAvatarURI(msg) { function assistantAvatarURI(msg) {
// Try to find persona avatar from the model that generated this message // 1. Check channel model roster for persona avatar (v0.23.0)
if (typeof ChannelModels !== 'undefined' && msg?.model) {
const info = ChannelModels.resolvePersonaInfo(msg.model);
if (info?.avatar) return info.avatar;
}
// 2. Check participant_id directly (for chained messages)
if (msg?.participant_type === 'persona' && msg?.participant_id) {
const m = App.models.find(x => x.personaId === msg.participant_id);
if (m?.personaAvatar) return m.personaAvatar;
}
// 3. Standard lookup by model ID or persona ID
const modelId = msg?.model || msg?.persona_id; const modelId = msg?.model || msg?.persona_id;
if (modelId) { if (modelId) {
const m = App.models.find(x => x.id === modelId || x.personaId === modelId); const m = App.models.find(x => x.id === modelId || x.personaId === modelId);
@@ -37,6 +47,7 @@ function renderPersonaForm(containerEl, options = {}) {
containerEl.innerHTML = ` containerEl.innerHTML = `
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div> <div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
<div class="form-group"><label>@mention Handle</label><input type="text" id="${pfx}_handle" placeholder="code-reviewer" maxlength="50" class="mono-input"><div class="form-hint">Auto-generated from name. Used for @mentions in chat.</div></div>
${showAvatar ? ` ${showAvatar ? `
<div class="avatar-upload-row" style="margin-bottom:0.75rem"> <div class="avatar-upload-row" style="margin-bottom:0.75rem">
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview"> <div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
@@ -95,6 +106,22 @@ function renderPersonaForm(containerEl, options = {}) {
if (options.onCancel) options.onCancel(); if (options.onCancel) options.onCancel();
}); });
// Auto-generate handle from name
let _handleManuallyEdited = false;
document.getElementById(`${pfx}_handle`)?.addEventListener('input', () => { _handleManuallyEdited = true; });
document.getElementById(`${pfx}_name`)?.addEventListener('input', () => {
if (_handleManuallyEdited) return;
const name = document.getElementById(`${pfx}_name`)?.value || '';
const handle = name.toLowerCase().trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
const hEl = document.getElementById(`${pfx}_handle`);
if (hEl) hEl.value = handle;
});
// Submit wiring // Submit wiring
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => { document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
if (options.onSubmit) options.onSubmit(form.getValues()); if (options.onSubmit) options.onSubmit(form.getValues());
@@ -104,6 +131,7 @@ function renderPersonaForm(containerEl, options = {}) {
getValues() { getValues() {
const v = { const v = {
name: document.getElementById(`${pfx}_name`)?.value.trim() || '', name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
handle: document.getElementById(`${pfx}_handle`)?.value.trim() || '',
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '', description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
base_model_id: document.getElementById(`${pfx}_model`)?.value || '', base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '', system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
@@ -129,6 +157,7 @@ function renderPersonaForm(containerEl, options = {}) {
setValues(p) { setValues(p) {
const el = id => document.getElementById(`${pfx}_${id}`); const el = id => document.getElementById(`${pfx}_${id}`);
if (el('name')) el('name').value = p.name || ''; if (el('name')) el('name').value = p.name || '';
if (el('handle')) { el('handle').value = p.handle || ''; _handleManuallyEdited = !!p.handle; }
if (el('desc')) el('desc').value = p.description || ''; if (el('desc')) el('desc').value = p.description || '';
if (el('prompt')) el('prompt').value = p.system_prompt || ''; if (el('prompt')) el('prompt').value = p.system_prompt || '';
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : ''; if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
@@ -141,7 +170,7 @@ function renderPersonaForm(containerEl, options = {}) {
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || ''; if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
}, },
clearForm() { clearForm() {
['name','desc','prompt','temp','maxTokens'].forEach(f => { ['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`); const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = ''; if (el) el.value = '';
}); });
@@ -288,6 +317,8 @@ const UI = {
// ── Chat item renderer ────────────────── // ── Chat item renderer ──────────────────
const renderChatItem = (c) => { const renderChatItem = (c) => {
const time = _relativeTime(c.updatedAt); 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 ` return `
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}" <div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
onclick="selectChat('${c.id}')" onclick="selectChat('${c.id}')"
@@ -295,7 +326,7 @@ const UI = {
draggable="true" draggable="true"
ondragstart="onChatDragStart(event,'${c.id}')" ondragstart="onChatDragStart(event,'${c.id}')"
ondragend="onChatDragEnd(event)"> ondragend="onChatDragEnd(event)">
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${esc(c.title)}</span> <span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
<span class="chat-item-time">${time}</span> <span class="chat-item-time">${time}</span>
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button> <button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
</div>`; </div>`;
@@ -487,9 +518,11 @@ const UI = {
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
let assistantLabel = 'Assistant'; let assistantLabel = 'Assistant';
if (!isUser) { if (!isUser) {
// Use stored modelName, or resolve from current model list, or fall back to model id // Use stored modelName, or resolve from channel roster, or model list, or fall back
if (msg.modelName) { if (msg.modelName) {
assistantLabel = msg.modelName; assistantLabel = msg.modelName;
} else if (typeof ChannelModels !== 'undefined' && msg.model) {
assistantLabel = ChannelModels.resolveDisplayName(msg.model);
} else if (msg.model) { } else if (msg.model) {
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model); const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
assistantLabel = m?.name || msg.model; assistantLabel = m?.name || msg.model;
@@ -598,7 +631,15 @@ const UI = {
const label = modelName || 'Assistant'; const label = modelName || 'Assistant';
const currentModel = App.findModel(App.settings.model); const currentModel = App.findModel(App.settings.model);
const streamAvatar = currentModel?.personaAvatar || null; // Resolve avatar: channel roster persona → selected model → null
let streamAvatar = null;
if (typeof ChannelModels !== 'undefined') {
const info = ChannelModels.resolvePersonaInfo(currentModel?.baseModelId);
if (info?.avatar) streamAvatar = info.avatar;
}
if (!streamAvatar) {
streamAvatar = currentModel?.personaAvatar || null;
}
// Multi-model state: when model_start events arrive, we create // Multi-model state: when model_start events arrive, we create
// separate message bubbles per model. Without them, single-bubble. // separate message bubbles per model. Without them, single-bubble.
@@ -911,8 +952,9 @@ const UI = {
div.className = 'model-dropdown-item'; div.className = 'model-dropdown-item';
div.dataset.value = m.id; div.dataset.value = m.id;
const avatar = m.personaAvatar ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : ''; const avatar = m.personaAvatar ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : '';
const handleHint = m.isPersona && m.personaHandle ? `<span class="item-handle">@${esc(m.personaHandle)}</span>` : '';
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : ''; const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`; div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}${handleHint}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
div.addEventListener('click', () => { div.addEventListener('click', () => {
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : '')); UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
UI._closeModelDropdown(); UI._closeModelDropdown();

View File

@@ -44,9 +44,71 @@ function formatMessage(content) {
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML); html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
} }
// @mention highlighting (v0.23.0): style @Name references as pills
html = _highlightMentions(html);
return html; return html;
} }
/**
* Highlight @mentions in rendered HTML as styled pills.
* Matches @Name patterns against the channel model roster.
* Only highlights inside text nodes (not inside code/pre/a tags).
*/
function _highlightMentions(html) {
if (typeof ChannelModels === 'undefined') return html;
const roster = ChannelModels.getRoster();
if (!roster || roster.length < 2) return html;
// Build patterns from handles (primary) and display names (fallback)
const patterns = [];
const seen = new Set();
for (const m of roster) {
// Handle pattern (preferred)
if (m.handle) {
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped)) {
patterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped);
}
}
// Display name pattern (hyphenated form)
if (m.display_name) {
const hyphenated = m.display_name.replace(/\s+/g, '-');
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped.toLowerCase())) {
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
patterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped.toLowerCase());
}
}
}
// @all
patterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
// Don't highlight inside <code>, <pre>, <a> tags
const parts = html.split(/(<[^>]+>)/);
let inCode = false;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith('<')) {
const tag = part.toLowerCase();
if (tag.startsWith('<code') || tag.startsWith('<pre') || tag.startsWith('<a ')) inCode = true;
if (tag.startsWith('</code') || tag.startsWith('</pre') || tag.startsWith('</a')) inCode = false;
continue;
}
if (inCode) continue;
let text = part;
for (const re of patterns) {
text = text.replace(re, '<span class="mention-pill">$1</span>');
}
parts[i] = text;
}
return parts.join('');
}
function _formatMarked(text) { function _formatMarked(text) {
// ── Unwrap outer ```markdown wrappers ──────────────────────── // ── Unwrap outer ```markdown wrappers ────────────────────────
// LLMs frequently wrap entire responses in ```markdown ... ``` which is // LLMs frequently wrap entire responses in ```markdown ... ``` which is

View File

@@ -766,7 +766,9 @@ Object.assign(UI, {
try { try {
const prefData = await API.getModelPreferences(); const prefData = await API.getModelPreferences();
App.hiddenModels = new Set( App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id) (prefData.preferences || []).filter(p => p.hidden).map(p =>
(p.provider_config_id || '') + ':' + p.model_id
)
); );
} catch (e) { } catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set(); if (!App.hiddenModels) App.hiddenModels = new Set();
@@ -781,11 +783,13 @@ Object.assign(UI, {
} }
el.innerHTML = models.map(m => { el.innerHTML = models.map(m => {
const mid = m.model_id || m.id; const mid = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || '';
const compositeKey = (cfgId || '') + ':' + mid;
const caps = m.capabilities || {}; const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true }); const badges = renderCapBadges(caps, { compact: true });
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>' const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : ''; : m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
const hidden = App.hiddenModels.has(mid); const hidden = App.hiddenModels.has(compositeKey);
const toggleCls = hidden ? '' : 'enabled'; const toggleCls = hidden ? '' : 'enabled';
const toggleLabel = hidden ? 'Hidden' : '✓ Visible'; const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}"> return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
@@ -793,7 +797,7 @@ Object.assign(UI, {
<span class="model-caps-inline">${badges}</span> <span class="model-caps-inline">${badges}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span> <span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src} ${src}
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button> <button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', '${esc(cfgId)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
</div>`; </div>`;
}).join(''); }).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }

640
store/interfaces.go Normal file
View File

@@ -0,0 +1,640 @@
package store
import (
"context"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// =========================================
// STORES — Data Access Layer
// =========================================
// Every database operation goes through these interfaces.
// Handlers never touch SQL directly. This makes the DB
// portable (Postgres today, SQLite/MySQL later) and
// handlers testable with in-memory implementations.
// =========================================
// Stores bundles all store interfaces for dependency injection.
type Stores struct {
Providers ProviderStore
Catalog CatalogStore
Personas PersonaStore
Policies PolicyStore
UserSettings UserModelSettingsStore
Users UserStore
Teams TeamStore
Channels ChannelStore
Messages MessageStore
Audit AuditStore
Notes NoteStore
NoteLinks NoteLinkStore
GlobalConfig GlobalConfigStore
Usage UsageStore
Pricing PricingStore
Extensions ExtensionStore
Files FileStore
KnowledgeBases KnowledgeBaseStore
Groups GroupStore
ResourceGrants ResourceGrantStore
Memories MemoryStore
Projects ProjectStore
Notifications NotificationStore
NotifPrefs NotificationPreferenceStore
Workspaces WorkspaceStore
GitCredentials GitCredentialStore
CapOverrides CapabilityOverrideStore
RoutingPolicies RoutingPolicyStore
}
// =========================================
// PROVIDER STORE
// =========================================
type ProviderStore interface {
Create(ctx context.Context, cfg *models.ProviderConfig) error
GetByID(ctx context.Context, id string) (*models.ProviderConfig, error)
Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error
Delete(ctx context.Context, id string) error
// Scoped queries
ListGlobal(ctx context.Context) ([]models.ProviderConfig, error)
ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error)
ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) // personal scope
ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) // all user can access
// Access check
UserCanAccess(ctx context.Context, userID, configID string) (bool, error)
}
// =========================================
// CATALOG STORE
// =========================================
type CatalogStore interface {
// Sync from provider API
UpsertFromSync(ctx context.Context, providerConfigID string, entries []CatalogSyncEntry) (added, updated int, err error)
// Queries
GetByID(ctx context.Context, id string) (*models.CatalogEntry, error)
GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error)
GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) // any provider, most recently synced
ListVisible(ctx context.Context) ([]models.CatalogEntry, error)
ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error)
ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error)
ListAll(ctx context.Context) ([]models.CatalogEntry, error) // everything (internal use)
ListAllGlobal(ctx context.Context) ([]models.CatalogEntry, error) // admin view — global-scope providers only
// Visibility management
SetVisibility(ctx context.Context, id string, visibility string) error
BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error
BulkSetVisibilityAll(ctx context.Context, visibility string) error
// Delete
Delete(ctx context.Context, id string) error
DeleteForProvider(ctx context.Context, providerConfigID string) error
}
// CatalogSyncEntry is the input format from provider FetchModels.
type CatalogSyncEntry struct {
ModelID string
DisplayName string
ModelType string // "chat", "embedding", "image" — from provider API
Capabilities models.ModelCapabilities
Pricing *models.ModelPricing
}
// =========================================
// PERSONA STORE
// =========================================
type PersonaStore interface {
Create(ctx context.Context, p *models.Persona) error
GetByID(ctx context.Context, id string) (*models.Persona, error)
Update(ctx context.Context, id string, patch models.PersonaPatch) error
Delete(ctx context.Context, id string) error
// Scoped queries
ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user
ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error)
ListGlobal(ctx context.Context) ([]models.Persona, error)
ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) // user's own
// Grants
SetGrants(ctx context.Context, personaID string, grants []models.Grant) error
GetGrants(ctx context.Context, personaID string) ([]models.Grant, error)
GetToolGrants(ctx context.Context, personaID string) ([]string, error)
// Access check
UserCanAccess(ctx context.Context, userID, personaID string) (bool, error)
// Knowledge base bindings
SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error
GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error)
GetKBIDs(ctx context.Context, personaID string) ([]string, error)
}
// =========================================
// POLICY STORE
// =========================================
type PolicyStore interface {
Get(ctx context.Context, key string) (string, error)
GetBool(ctx context.Context, key string) (bool, error)
Set(ctx context.Context, key, value string, updatedBy string) error
GetAll(ctx context.Context) (map[string]string, error)
}
// =========================================
// USER MODEL SETTINGS STORE
// =========================================
type UserModelSettingsStore interface {
GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error)
GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error)
Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error
BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error
}
// =========================================
// USER STORE
// =========================================
type UserStore interface {
Create(ctx context.Context, u *models.User) error
GetByID(ctx context.Context, id string) (*models.User, error)
GetByUsername(ctx context.Context, username string) (*models.User, error)
GetByEmail(ctx context.Context, email string) (*models.User, error)
GetByLogin(ctx context.Context, login string) (*models.User, error) // username or email
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, opts ListOptions) ([]models.User, int, error)
UpdateLastLogin(ctx context.Context, id string) error
SetActive(ctx context.Context, id string, active bool) error
// Refresh tokens
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error
}
// =========================================
// TEAM STORE
// =========================================
type TeamStore interface {
Create(ctx context.Context, t *models.Team) error
GetByID(ctx context.Context, id string) (*models.Team, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context) ([]models.Team, error)
// Members
AddMember(ctx context.Context, teamID, userID, role string) error
RemoveMember(ctx context.Context, teamID, userID string) error
UpdateMemberRole(ctx context.Context, teamID, userID, role string) error
ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error)
GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error)
GetUserTeamIDs(ctx context.Context, userID string) ([]string, error)
IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error)
IsMember(ctx context.Context, teamID, userID string) (bool, error)
}
// =========================================
// CHANNEL STORE
// =========================================
type ChannelStore interface {
Create(ctx context.Context, ch *models.Channel) error
GetByID(ctx context.Context, id string) (*models.Channel, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
ListForUser(ctx context.Context, userID string, opts ListOptions) ([]models.Channel, int, error)
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Channel, int, error)
// Cursor management (conversation forking)
GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error)
SetCursor(ctx context.Context, channelID, userID, leafID string) error
// Channel models
SetModel(ctx context.Context, cm *models.ChannelModel) error
GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error)
GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error)
UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error
DeleteModel(ctx context.Context, id string) error
// Ownership check
UserOwns(ctx context.Context, channelID, userID string) (bool, error)
// Workspace resolution: channel workspace_id > project workspace_id
ResolveWorkspaceID(ctx context.Context, channelID string) (string, error)
}
// =========================================
// MESSAGE STORE
// =========================================
type MessageStore interface {
Create(ctx context.Context, m *models.Message) error
GetByID(ctx context.Context, id string) (*models.Message, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error // soft delete
ListForChannel(ctx context.Context, channelID string, opts ListOptions) ([]models.Message, error)
// Tree operations
GetChildren(ctx context.Context, parentID string) ([]models.Message, error)
GetSiblings(ctx context.Context, messageID string) ([]models.Message, error)
GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error)
GetNextSiblingIndex(ctx context.Context, parentID string) (int, error)
// Count
CountForChannel(ctx context.Context, channelID string) (int, error)
}
// =========================================
// AUDIT STORE
// =========================================
type AuditStore interface {
Log(ctx context.Context, entry *models.AuditEntry) error
List(ctx context.Context, opts AuditListOptions) ([]models.AuditEntry, int, error)
}
type AuditListOptions struct {
ListOptions
ActorID string
Action string
ResourceType string
ResourceID string
Since *time.Time
Until *time.Time
}
// =========================================
// NOTE STORE
// =========================================
type NoteStore interface {
Create(ctx context.Context, n *models.Note) error
GetByID(ctx context.Context, id string) (*models.Note, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
ListForUser(ctx context.Context, userID string, opts NoteListOptions) ([]models.Note, int, error)
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error)
SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error)
BulkDelete(ctx context.Context, ids []string, userID string) (int, error)
}
type NoteListOptions struct {
ListOptions
FolderPath string
Tag string
TeamID string
}
// =========================================
// NOTE LINK STORE
// =========================================
// NoteLinkStore manages wikilink edges between notes.
type NoteLinkStore interface {
// ReplaceLinks deletes existing links for sourceNoteID and inserts new ones.
ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error
// ResolveByTitle sets target_note_id on dangling links matching the title for a user.
ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error
// Backlinks returns notes that link to the given note.
Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error)
// Graph returns all nodes and edges for a user's note graph.
Graph(ctx context.Context, userID string) (*models.NoteGraph, error)
}
// =========================================
// GLOBAL CONFIG STORE
// =========================================
type GlobalConfigStore interface {
Get(ctx context.Context, key string) (models.JSONMap, error)
Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error
GetAll(ctx context.Context) (map[string]models.JSONMap, error)
}
// =========================================
// USAGE STORE
// =========================================
type UsageStore interface {
Log(ctx context.Context, entry *models.UsageEntry) error
CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error)
QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
QueryByUserPersonal(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error)
GetTotals(ctx context.Context, opts UsageQueryOptions) (*models.UsageTotals, error)
GetTeamProviderTotals(ctx context.Context, teamID string, opts UsageQueryOptions) (*models.UsageTotals, error)
GetPersonalTotals(ctx context.Context, userID string, opts UsageQueryOptions) (*models.UsageTotals, error)
}
type UsageQueryOptions struct {
Since *time.Time
Until *time.Time
GroupBy string // "day", "model", "user", "provider"
ExcludeBYOK bool // true for admin views — filters provider_scope != 'personal'
Limit int
}
// =========================================
// PRICING STORE
// =========================================
type PricingStore interface {
GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error)
Upsert(ctx context.Context, entry *models.PricingEntry) error
UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error
List(ctx context.Context) ([]models.PricingEntry, error)
Delete(ctx context.Context, providerConfigID, modelID string) error
}
// =========================================
// EXTENSION STORE
// =========================================
type ExtensionStore interface {
// Admin CRUD
Create(ctx context.Context, ext *models.Extension) error
GetByID(ctx context.Context, id string) (*models.Extension, error)
GetByExtID(ctx context.Context, extID string) (*models.Extension, error)
Update(ctx context.Context, id string, ext *models.Extension) error
Delete(ctx context.Context, id string) error
// Listing
ListAll(ctx context.Context) ([]models.Extension, error)
ListEnabled(ctx context.Context) ([]models.Extension, error)
ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error)
// User settings
GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error)
SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error
DeleteUserSettings(ctx context.Context, extID, userID string) error
}
// =========================================
// FILE STORE
// =========================================
type FileStore interface {
Create(ctx context.Context, f *models.File) error
GetByID(ctx context.Context, id string) (*models.File, error)
GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error)
GetByMessage(ctx context.Context, messageID string) ([]models.File, error)
GetByProject(ctx context.Context, projectID string) ([]models.File, error)
GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) // paginated, returns total
SetMessageID(ctx context.Context, fileID, messageID string) error
UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error
SetExtractedText(ctx context.Context, id string, text string) error
Delete(ctx context.Context, id string) (*models.File, error) // returns deleted row for storage cleanup
DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys
UserUsageBytes(ctx context.Context, userID string) (int64, error)
ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error)
}
// =========================================
// KNOWLEDGE BASE STORE
// =========================================
type KnowledgeBaseStore interface {
// KB CRUD
Create(ctx context.Context, kb *models.KnowledgeBase) error
GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
// Scoped listing
ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error)
ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error)
ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error)
ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error)
// Documents
CreateDocument(ctx context.Context, doc *models.KBDocument) error
GetDocument(ctx context.Context, id string) (*models.KBDocument, error)
ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error)
UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error
UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error
UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error
DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) // returns for storage cleanup
// Chunks
InsertChunks(ctx context.Context, chunks []models.KBChunk) error
DeleteChunksForDocument(ctx context.Context, documentID string) error
SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64,
threshold float64, limit int) ([]models.KBSearchResult, error)
// Channel links
SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error
GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error)
GetActiveKBIDs(ctx context.Context, channelID string, userID string,
teamIDs []string) ([]string, error) // enabled + user has access
GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string,
teamIDs []string, personaID string) ([]string, error) // includes persona-bound KBs
ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error)
// Stats — recount document_count/chunk_count/total_bytes from child tables
UpdateStats(ctx context.Context, kbID string) error
// Discoverable management
SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error
}
// =========================================
// GROUP STORE (v0.16.0)
// =========================================
type GroupStore interface {
Create(ctx context.Context, g *models.Group) error
GetByID(ctx context.Context, id string) (*models.Group, error)
Update(ctx context.Context, id string, name, description *string) error
Delete(ctx context.Context, id string) error
// Scoped listing
ListAll(ctx context.Context) ([]models.Group, error) // admin
ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) // team admin
ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I belong to
// Members
AddMember(ctx context.Context, groupID, userID, addedBy string) error
RemoveMember(ctx context.Context, groupID, userID string) error
ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error)
IsMember(ctx context.Context, groupID, userID string) (bool, error)
// For access resolution: returns group IDs a user belongs to
GetUserGroupIDs(ctx context.Context, userID string) ([]string, error)
}
// =========================================
// RESOURCE GRANT STORE (v0.16.0)
// =========================================
type ResourceGrantStore interface {
Set(ctx context.Context, grant *models.ResourceGrant) error
Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error)
Delete(ctx context.Context, resourceType, resourceID string) error
// Access check: does userID have group-based access to this resource?
UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error)
}
// =========================================
// NOTIFICATION STORE (v0.20.0)
// =========================================
type NotificationStore interface {
Create(ctx context.Context, n *models.Notification) error
ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error)
MarkRead(ctx context.Context, id, userID string) error
MarkAllRead(ctx context.Context, userID string) error
Delete(ctx context.Context, id, userID string) error
UnreadCount(ctx context.Context, userID string) (int, error)
DeleteOlderThan(ctx context.Context, before time.Time) (int64, error)
}
// =========================================
// NOTIFICATION PREFERENCES STORE (v0.20.0)
// =========================================
type NotificationPreferenceStore interface {
// Get returns the preference for a specific user + type. Returns nil if not set.
Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error)
// ListForUser returns all preferences set by a user.
ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error)
// Upsert creates or updates a preference row.
Upsert(ctx context.Context, pref *models.NotificationPreference) error
// Delete removes a preference (falls back to default).
Delete(ctx context.Context, userID, notifType string) error
}
// =========================================
// WORKSPACE STORE (v0.21.0)
// =========================================
type WorkspaceStore interface {
// CRUD
Create(ctx context.Context, w *models.Workspace) error
GetByID(ctx context.Context, id string) (*models.Workspace, error)
Update(ctx context.Context, id string, patch models.WorkspacePatch) error
Delete(ctx context.Context, id string) error
// Ownership lookup
GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error)
ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error)
// File index
UpsertFile(ctx context.Context, f *models.WorkspaceFile) error
DeleteFile(ctx context.Context, workspaceID, path string) error
DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error
GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error)
ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error)
DeleteAllFiles(ctx context.Context, workspaceID string) error
// Stats
GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error)
// Chunks (v0.21.2 — workspace indexing)
InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error
DeleteChunksByFile(ctx context.Context, fileID string) error
SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error)
UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error
// Git (v0.21.4)
SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error
}
// =========================================
// GIT CREDENTIAL STORE (v0.21.4)
// =========================================
type GitCredentialStore interface {
Create(ctx context.Context, cred *models.GitCredential) error
GetByID(ctx context.Context, id string) (*models.GitCredential, error)
ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error)
Delete(ctx context.Context, id, userID string) error
}
// =========================================
// CAPABILITY OVERRIDES (v0.22.0)
// =========================================
type CapabilityOverrideStore interface {
// Set creates or updates an override for (provider, model, field).
Set(ctx context.Context, o *models.CapabilityOverride) error
// Delete removes a specific override.
Delete(ctx context.Context, id string) error
// ListForModel returns all overrides for a model ID (across all providers + global).
ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error)
// ListForProviderModel returns overrides for a specific provider+model combination.
ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error)
// ListAll returns every override (admin view).
ListAll(ctx context.Context) ([]models.CapabilityOverride, error)
// DeleteForProvider removes all overrides for a provider (cascade cleanup).
DeleteForProvider(ctx context.Context, providerConfigID string) error
}
// =========================================
// ROUTING POLICY STORE (v0.22.2)
// =========================================
type RoutingPolicyStore interface {
// Create adds a new routing policy.
Create(ctx context.Context, p *models.RoutingPolicy) error
// Update modifies an existing routing policy.
Update(ctx context.Context, p *models.RoutingPolicy) error
// Delete removes a routing policy by ID.
Delete(ctx context.Context, id string) error
// GetByID returns a single policy.
GetByID(ctx context.Context, id string) (*models.RoutingPolicy, error)
// ListActive returns all active policies, ordered by priority ASC.
ListActive(ctx context.Context) ([]models.RoutingPolicy, error)
// ListAll returns all policies (including inactive), for admin listing.
ListAll(ctx context.Context) ([]models.RoutingPolicy, error)
// ListForTeam returns active policies applicable to a team (team-scoped + global).
ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error)
}
// =========================================
// SHARED TYPES
// =========================================
// ListOptions provides standard pagination/sort for list queries.
type ListOptions struct {
Limit int
Offset int
Sort string // column name
Order string // "asc" or "desc"
}
// DefaultListOptions returns sensible defaults.
func DefaultListOptions() ListOptions {
return ListOptions{
Limit: 50,
Order: "desc",
}
}

View File

@@ -0,0 +1,152 @@
package postgres
import (
"context"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type UserModelSettingsStore struct{}
func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSettingsStore{} }
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens,
COALESCE(sort_order, 0), created_at, updated_at
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserModelSetting
for rows.Next() {
var s models.UserModelSetting
var prefTemp, prefMaxTokens interface{}
err := rows.Scan(
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
&prefTemp, &prefMaxTokens,
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
if f, ok := prefTemp.(float64); ok {
s.PreferredTemperature = &f
}
if n, ok := prefMaxTokens.(int64); ok {
v := int(n)
s.PreferredMaxTokens = &v
}
result = append(result, s)
}
return result, rows.Err()
}
// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models.
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := DB.QueryContext(ctx,
"SELECT model_id, COALESCE(provider_config_id::text, '') FROM user_model_settings WHERE user_id = $1 AND hidden = true",
userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var modelID, provCfgID string
if err := rows.Scan(&modelID, &provCfgID); err != nil {
return nil, err
}
result[models.CompositeModelKey(provCfgID, modelID)] = true
}
return result, rows.Err()
}
// Set upserts a single user model setting.
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
b := NewUpdate("user_model_settings")
if patch.Hidden != nil {
b.Set("hidden", *patch.Hidden)
}
if patch.PreferredTemperature != nil {
b.Set("preferred_temperature", models.NullFloat(patch.PreferredTemperature))
}
if patch.PreferredMaxTokens != nil {
b.Set("preferred_max_tokens", models.NullInt(patch.PreferredMaxTokens))
}
if patch.SortOrder != nil {
b.Set("sort_order", *patch.SortOrder)
}
if !b.HasSets() {
return nil
}
// Use upsert: insert if not exists, update if exists
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
VALUES ($1, $2, $3, COALESCE($4, false), $5, $6, COALESCE($7, 0))
ON CONFLICT (user_id, model_id, provider_config_id)
DO UPDATE SET
hidden = COALESCE($4, user_model_settings.hidden),
preferred_temperature = COALESCE($5, user_model_settings.preferred_temperature),
preferred_max_tokens = COALESCE($6, user_model_settings.preferred_max_tokens),
sort_order = COALESCE($7, user_model_settings.sort_order)`,
userID, modelID, providerConfigID,
patchBoolOrNil(patch.Hidden),
patchFloat64OrNil(patch.PreferredTemperature),
patchIntOrNil(patch.PreferredMaxTokens),
patchIntOrNil(patch.SortOrder),
)
return err
}
// BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
if len(entries) == 0 {
return nil
}
for _, entry := range entries {
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = $4`,
userID, entry.ModelID, entry.ProviderConfigID, hidden)
if err != nil {
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
}
}
return nil
}
// ── Helpers ─────────────────────────────────
func patchBoolOrNil(b *bool) interface{} {
if b == nil {
return nil
}
return *b
}
func patchFloat64OrNil(f *float64) interface{} {
if f == nil {
return nil
}
return *f
}
func patchIntOrNil(i *int) interface{} {
if i == nil {
return nil
}
return *i
}
// unused but keeping for reference - will be used in ListOptions-based queries
var _ = strings.Join

View File

@@ -0,0 +1,153 @@
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