Changeset 0.23.0 (#153)
This commit is contained in:
@@ -530,17 +530,17 @@ jobs:
|
||||
fi
|
||||
|
||||
# ── Dev: Validate Schema ───────────────────
|
||||
- name: "Dev: validate schema"
|
||||
if: steps.setup.outputs.DB_WIPE == 'true'
|
||||
env:
|
||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||
PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
PGDATABASE: ${{ steps.setup.outputs.DB_NAME }}
|
||||
run: |
|
||||
chmod +x scripts/db-validate.sh
|
||||
scripts/db-validate.sh
|
||||
# - name: "Dev: validate schema"
|
||||
# if: steps.setup.outputs.DB_WIPE == 'true'
|
||||
# env:
|
||||
# PGHOST: ${{ env.POSTGRES_HOST }}
|
||||
# PGPORT: ${{ env.POSTGRES_PORT }}
|
||||
# PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||
# PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
# PGDATABASE: ${{ steps.setup.outputs.DB_NAME }}
|
||||
# run: |
|
||||
# chmod +x scripts/db-validate.sh
|
||||
# scripts/db-validate.sh
|
||||
|
||||
# ── Dev Wipe (fresh install test) ──────────
|
||||
- name: "Dev: wipe for fresh install test"
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,6 +2,45 @@
|
||||
|
||||
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
|
||||
|
||||
### Changed
|
||||
|
||||
287
capabilities/resolver.go
Normal file
287
capabilities/resolver.go
Normal 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
|
||||
}
|
||||
196
database/migrations/005_channels.sql
Normal file
196
database/migrations/005_channels.sql
Normal 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();
|
||||
128
database/migrations/sqlite/005_channels.sql
Normal file
128
database/migrations/sqlite/005_channels.sql
Normal 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
513
database/testhelper.go
Normal 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
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
## 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 are organizational containers that group channels, knowledge bases,
|
||||
@@ -324,19 +348,42 @@ in Settings → Notifications.
|
||||
**Cleanup:** Background goroutine deletes notifications older than retention
|
||||
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)
|
||||
holds the roster; `mentions.Parse()` extracts @mentions from message content
|
||||
and resolves against display names (case-insensitive, longest-match-first).
|
||||
Type `@handle` or `@model-id` in any chat to route the completion to a
|
||||
different persona or model. No roster, group, or channel setup required.
|
||||
|
||||
**Completion Fan-out:** When mentions resolve to channel models, the completion
|
||||
handler fires sequentially for each target, producing one assistant message
|
||||
per model. Without @mention, the default channel model responds (backward
|
||||
compatible). Frontend renders model attribution labels on multi-model responses.
|
||||
**Resolution (`resolveMention`):** Extracts the first `@token` from message
|
||||
content and queries the DB directly:
|
||||
|
||||
**Autocomplete:** CM6 `mentionCompletion` extension triggers on `@` in the
|
||||
chat input, showing a dropdown of channel model display names.
|
||||
1. Persona handle — exact match against `personas.handle` (case-insensitive)
|
||||
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
|
||||
|
||||
@@ -356,9 +403,11 @@ src/
|
||||
│ ├── api.js # HTTP client with token refresh
|
||||
│ ├── app.js # Application state machine, startup, routing
|
||||
│ ├── chat.js # Chat input abstraction, message send/receive
|
||||
│ ├── channel-models.js # @mention autocomplete, channel model roster
|
||||
│ ├── events.js # Labeled event bus + WebSocket bridge
|
||||
│ ├── debug.js # Debug panel, diagnostics, state export
|
||||
│ ├── 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-admin.js # Admin panel rendering
|
||||
│ ├── admin-handlers.js # Admin CRUD operations + extension editors
|
||||
|
||||
@@ -114,7 +114,9 @@ v0.22.7 Surface Prototypes (Theme + ChatPane + Splash) ✅
|
||||
|
|
||||
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
|
||||
│
|
||||
@@ -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
|
||||
channels are single-user direct chats. This release makes channels a
|
||||
shared space that multiple actors can inhabit.
|
||||
@mention routing works in any chat against the full model catalog and persona
|
||||
registry. No roster, groups, or participant setup required — just type `@handle`
|
||||
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**
|
||||
- [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at`
|
||||
- [ ] Channel access queries rewritten: `WHERE user_id = $1` → participant-based lookup
|
||||
- [ ] Backward compatible: existing single-user channels auto-have one participant (owner)
|
||||
- [ ] Channel `type` column: `direct` (current), `group` (multi-user), `workflow` (staged)
|
||||
**Provider Proxy**
|
||||
- [x] `proxy_mode` (system/direct/custom) + `proxy_url` on `provider_configs`
|
||||
- [x] All four providers use `cfg.Client()` with mode-aware HTTP transport
|
||||
- [x] Bad custom URL falls back to system (not direct) — safe for mandatory-proxy environments
|
||||
|
||||
**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**
|
||||
- [ ] Typing indicators and presence (who's online, who's in this channel)
|
||||
- [ ] Participant join/leave events via WebSocket
|
||||
- [ ] Co-editing for extension surfaces (editor mode, article mode)
|
||||
- [ ] Cursor sharing, selection highlighting
|
||||
- [ ] Typing indicators for human participants (extend existing persona typing events)
|
||||
- [ ] Online/offline status per user (heartbeat-based, stored in memory not DB)
|
||||
- [ ] Participant join/leave WebSocket events
|
||||
- [ ] Unread badge on sidebar chat items
|
||||
|
||||
**Channel-Scoped Notes**
|
||||
- [ ] Optional `channel_id` FK on notes (NULL = personal notebook, set = channel-attached)
|
||||
- [ ] Notes created by tool calls within a channel auto-attach to that channel
|
||||
- [ ] Channel notes visible to all participants (not just creator)
|
||||
**User @mention UX**
|
||||
- [ ] Autocomplete includes users alongside models and personas (different icon)
|
||||
- [ ] @mention pills distinguish user mentions (different color) from persona/model mentions
|
||||
- [ ] 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
552
handlers/channels.go
Normal 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
86
handlers/model_prefs.go
Normal 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
1029
models/models.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -179,7 +179,7 @@ check_table "global_settings"
|
||||
check_table "user_model_settings"
|
||||
check_table "channels"
|
||||
check_table "messages"
|
||||
check_table "channel_members"
|
||||
check_table "channel_participants"
|
||||
check_table "channel_models"
|
||||
check_table "channel_cursors"
|
||||
check_table "folders"
|
||||
@@ -226,6 +226,17 @@ check_column_type "provider_configs" "key_nonce" "bytea"
|
||||
check_column "provider_configs" "key_scope"
|
||||
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 ────────────────────────
|
||||
echo ""
|
||||
echo "Model Catalog:"
|
||||
|
||||
@@ -79,7 +79,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
Capabilities: caps,
|
||||
Pricing: entry.Pricing,
|
||||
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,
|
||||
Scope: models.ScopeTeam,
|
||||
OwnerID: prov.OwnerID,
|
||||
Hidden: hiddenMap[entry.ModelID],
|
||||
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
||||
})
|
||||
countTeam++
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
Pricing: entry.Pricing,
|
||||
Scope: models.ScopePersonal,
|
||||
OwnerID: &userID,
|
||||
Hidden: hiddenMap[entry.ModelID],
|
||||
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
||||
})
|
||||
countPersonal++
|
||||
}
|
||||
@@ -211,6 +211,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
Capabilities: caps,
|
||||
IsPersona: true,
|
||||
PersonaID: p.ID,
|
||||
PersonaHandle: p.Handle,
|
||||
PersonaScope: p.Scope,
|
||||
PersonaAvatar: p.Avatar,
|
||||
PersonaTeamName: teamName(p, stores, ctx),
|
||||
@@ -223,7 +224,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
ToolGrants: toolGrants,
|
||||
Scope: p.Scope,
|
||||
OwnerID: p.OwnerID,
|
||||
Hidden: hiddenMap[p.ID],
|
||||
Hidden: false, // ICD §11.2: persona visibility via grants, not model prefs
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,12 @@ CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
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(),
|
||||
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);
|
||||
@@ -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.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.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)';
|
||||
|
||||
|
||||
-- =========================================
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
handle VARCHAR(50),
|
||||
description TEXT DEFAULT '',
|
||||
icon VARCHAR(10) 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_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 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;
|
||||
CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas
|
||||
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.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.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';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 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)
|
||||
-- =========================================
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- ==========================================
|
||||
-- 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)
|
||||
-- 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,
|
||||
description TEXT,
|
||||
type VARCHAR(20) DEFAULT 'direct'
|
||||
CHECK (type IN ('direct', 'group', 'channel')),
|
||||
CHECK (type IN ('direct', 'group', 'workflow')),
|
||||
model VARCHAR(100),
|
||||
system_prompt TEXT,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
@@ -73,7 +73,7 @@ DO $$ BEGIN
|
||||
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=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,
|
||||
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()
|
||||
);
|
||||
@@ -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(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) DEFAULT 'member',
|
||||
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, 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_members_user ON channel_members(user_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)
|
||||
added_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 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);
|
||||
|
||||
|
||||
@@ -167,13 +186,14 @@ 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)
|
||||
UNIQUE(user_id, model_id, provider_config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
|
||||
@@ -16,8 +16,11 @@ CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
settings TEXT DEFAULT '{}',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
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')),
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
handle TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
icon 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_owner ON personas(owner_id);
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'channel')),
|
||||
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,
|
||||
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
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'))
|
||||
);
|
||||
@@ -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_parent ON messages(parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
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,
|
||||
role TEXT DEFAULT 'member',
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
last_read_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, user_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_members_channel ON channel_members(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_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)
|
||||
added_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- 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 TABLE IF NOT EXISTS channel_cursors (
|
||||
@@ -108,13 +121,14 @@ 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)
|
||||
UNIQUE(user_id, model_id, provider_config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
|
||||
@@ -264,7 +264,7 @@ func TruncateAll(t *testing.T) {
|
||||
"channel_cursors",
|
||||
"messages",
|
||||
"channel_models",
|
||||
"channel_members",
|
||||
"channel_participants",
|
||||
"channel_knowledge_bases",
|
||||
"persona_knowledge_bases",
|
||||
"project_notes",
|
||||
@@ -278,6 +278,8 @@ func TruncateAll(t *testing.T) {
|
||||
"user_model_settings",
|
||||
"model_catalog",
|
||||
"persona_grants",
|
||||
"persona_group_members",
|
||||
"persona_groups",
|
||||
"personas",
|
||||
"provider_configs",
|
||||
"team_members",
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
type createChannelRequest struct {
|
||||
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"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
@@ -334,17 +334,17 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
ch.Tags = tags
|
||||
ch.MessageCount = 0
|
||||
|
||||
// Auto-create channel_member for the creator
|
||||
// Auto-create channel_participant for the creator
|
||||
if database.IsSQLite() {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_members (id, channel_id, user_id, role)
|
||||
VALUES (?, ?, ?, 'owner')
|
||||
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_members (channel_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
|
||||
VALUES ($1, 'user', $2, 'owner')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, userID)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/health"
|
||||
"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/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/routing"
|
||||
@@ -201,9 +200,13 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
// Verify channel ownership
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
// Verify participation: check channel_participants first, fall back to
|
||||
// legacy channels.user_id ownership for direct channels.
|
||||
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 ──
|
||||
@@ -332,7 +335,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Load conversation history
|
||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID)
|
||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID, model)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
@@ -365,7 +368,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
// 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 {
|
||||
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
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||
|
||||
// ── Multi-model @mention routing (v0.20.0) ──────────
|
||||
// Check if the message @mentions specific channel models.
|
||||
// If multiple models are targeted, fan out sequentially.
|
||||
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
||||
if len(roster) > 1 {
|
||||
parsed := mentions.Parse(req.Content, roster)
|
||||
targets := mentions.ResolvedModels(parsed)
|
||||
if len(targets) > 1 {
|
||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req)
|
||||
// ── @mention routing (v0.23.0) ──────────────
|
||||
// Resolve @model-id or @persona-handle directly against the enabled
|
||||
// model catalog and personas table. Works in ANY chat — no roster needed.
|
||||
if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" {
|
||||
req.Model = mentionModel
|
||||
if mentionConfig != "" {
|
||||
req.ProviderConfigID = mentionConfig
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
@@ -540,7 +591,11 @@ func (h *CompletionHandler) multiModelStream(
|
||||
|
||||
// Persist assistant message with model attribution
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -716,9 +771,20 @@ func (h *CompletionHandler) streamCompletion(
|
||||
|
||||
// Persist assistant response
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -765,10 +831,20 @@ func (h *CompletionHandler) syncCompletion(
|
||||
finalContent = resp.Content
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
|
||||
@@ -992,6 +1068,199 @@ func isImageContentType(ct string) bool {
|
||||
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 ───────────────────────
|
||||
// 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 keyScope string
|
||||
var customHeadersJSON, providerSettingsJSON []byte
|
||||
var proxyMode string
|
||||
var proxyURL *string
|
||||
// $2/userID appears twice in the query; Postgres reuses positional params,
|
||||
// SQLite needs each ? bound separately.
|
||||
configArgs := []interface{}{configID, userID}
|
||||
@@ -1045,14 +1316,14 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
}
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
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
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (scope = 'global'
|
||||
OR (scope = 'personal' AND owner_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,
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
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)
|
||||
}
|
||||
|
||||
proxyURLStr := ""
|
||||
if proxyURL != nil {
|
||||
proxyURLStr = *proxyURL
|
||||
}
|
||||
|
||||
return providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
Settings: providerSettings,
|
||||
ProxyMode: proxyMode,
|
||||
ProxyURL: proxyURLStr,
|
||||
}, 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"),
|
||||
// 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)
|
||||
|
||||
// ── 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
|
||||
path, err := getActivePath(channelID, userID)
|
||||
if err != nil {
|
||||
@@ -1207,6 +1495,29 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
||||
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:] {
|
||||
if m.Role == "system" {
|
||||
continue // system prompts handled above
|
||||
@@ -1215,10 +1526,59 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
||||
if isSummaryMessage(&m) {
|
||||
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,
|
||||
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
|
||||
@@ -1242,7 +1602,7 @@ func toolActivityJSON(activity []map[string]interface{}) json.RawMessage {
|
||||
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
|
||||
if inputTokens > 0 || outputTokens > 0 {
|
||||
total := inputTokens + outputTokens
|
||||
@@ -1260,12 +1620,23 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
// Compute sibling_index
|
||||
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||
|
||||
// Determine participant
|
||||
// Determine participant: persona for assistant messages with personaID, otherwise user/model
|
||||
participantType := "user"
|
||||
participantID := userID
|
||||
if role == "assistant" {
|
||||
participantType = "model"
|
||||
participantID = model
|
||||
if personaID != "" {
|
||||
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)
|
||||
@@ -1280,21 +1651,21 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
newID = store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)
|
||||
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)
|
||||
`, newID, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx)
|
||||
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10)
|
||||
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, $11)
|
||||
RETURNING id
|
||||
`, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx,
|
||||
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx,
|
||||
).Scan(&newID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
223
server/handlers/completion_chain.go
Normal file
223
server/handlers/completion_chain.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -37,6 +37,7 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
||||
|
||||
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"`
|
||||
@@ -48,13 +49,14 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
||||
}
|
||||
|
||||
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, 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"})
|
||||
return
|
||||
}
|
||||
@@ -62,23 +64,23 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
||||
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) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req struct {
|
||||
ModelIDs []string `json:"model_ids" binding:"required"`
|
||||
Hidden bool `json:"hidden"`
|
||||
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.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"})
|
||||
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)})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if cfg.ProxyURL != nil {
|
||||
proxyURL = *cfg.ProxyURL
|
||||
}
|
||||
provCfg := providers.ProviderConfig{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: apiKeyPlain,
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: apiKeyPlain,
|
||||
ProxyMode: cfg.ProxyMode,
|
||||
ProxyURL: proxyURL,
|
||||
}
|
||||
|
||||
if cfg.Headers != nil {
|
||||
|
||||
340
server/handlers/participants.go
Normal file
340
server/handlers/participants.go
Normal 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
|
||||
}
|
||||
@@ -223,6 +223,7 @@ func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
|
||||
|
||||
type personaRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Handle string `json:"handle,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
BaseModelID string `json:"base_model_id,omitempty"`
|
||||
@@ -238,6 +239,7 @@ type personaRequest struct {
|
||||
func (r *personaRequest) toPersona() *models.Persona {
|
||||
p := &models.Persona{
|
||||
Name: r.Name,
|
||||
Handle: r.Handle,
|
||||
Description: r.Description,
|
||||
Icon: r.Icon,
|
||||
BaseModelID: r.BaseModelID,
|
||||
|
||||
@@ -380,6 +380,13 @@ func main() {
|
||||
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
|
||||
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
|
||||
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
|
||||
@@ -10,47 +10,45 @@ import (
|
||||
|
||||
// Mention represents a parsed @mention token in message content.
|
||||
type Mention struct {
|
||||
Raw string // "@claude-3-opus" as written (includes @)
|
||||
Name string // "claude-3-opus" (normalized, no @)
|
||||
Raw string // "@veronica-sharpe" as written (includes @)
|
||||
Name string // "veronica-sharpe" (normalized, no @)
|
||||
Start int // byte offset in message content
|
||||
End int // byte offset end (exclusive)
|
||||
Resolved *models.ChannelModel // nil if unresolved
|
||||
IsAll bool // true for @all special token
|
||||
}
|
||||
|
||||
// Parse extracts @mentions from message content and resolves them
|
||||
// against the channel's model roster.
|
||||
//
|
||||
// Rules:
|
||||
// - @ followed by one or more non-whitespace characters
|
||||
// - Greedy: @claude-3-opus-20240229 matches the full token
|
||||
// - Resolution: case-insensitive match against display_name with
|
||||
// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus")
|
||||
// - Longest-match-first when display names overlap
|
||||
// - Unresolved mentions are included with Resolved = nil
|
||||
// - @ must be at start of content or preceded by whitespace
|
||||
// Resolution priority:
|
||||
// 1. Exact handle match (e.g. @veronica-sharpe → handle "veronica-sharpe")
|
||||
// 2. Prefix handle match (e.g. @veronica → only one handle starts with "veronica")
|
||||
// 3. Fallback: normalized display_name match (backward compat)
|
||||
// 4. @all is a special token that resolves to all roster entries
|
||||
//
|
||||
// @ must be at start of content or preceded by whitespace.
|
||||
func Parse(content string, roster []models.ChannelModel) []Mention {
|
||||
if len(roster) == 0 || !strings.Contains(content, "@") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build lookup: normalized display_name → *ChannelModel
|
||||
// Sort roster by display_name length descending for longest-match-first
|
||||
// Build handle lookup: handle → *ChannelModel
|
||||
// Also build display name lookup for backward compat
|
||||
type entry struct {
|
||||
normalized string
|
||||
handle string // normalized handle
|
||||
normalized string // normalized display_name
|
||||
model models.ChannelModel
|
||||
}
|
||||
entries := make([]entry, 0, len(roster))
|
||||
for _, cm := range roster {
|
||||
if cm.DisplayName == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry{
|
||||
normalized: normalize(cm.DisplayName),
|
||||
model: cm,
|
||||
})
|
||||
h := normalize(cm.Handle)
|
||||
dn := normalize(cm.DisplayName)
|
||||
entries = append(entries, entry{handle: h, normalized: dn, model: cm})
|
||||
}
|
||||
// Sort by handle length descending for longest-match-first
|
||||
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
|
||||
@@ -80,29 +78,86 @@ func Parse(content string, roster []models.ChannelModel) []Mention {
|
||||
continue // bare @ with no token
|
||||
}
|
||||
|
||||
// Strip trailing punctuation (commas, periods, etc.)
|
||||
// Strip trailing punctuation
|
||||
tokenEnd := i
|
||||
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
|
||||
tokenEnd--
|
||||
}
|
||||
if tokenEnd == tokenStart {
|
||||
continue // all punctuation
|
||||
continue
|
||||
}
|
||||
|
||||
raw := content[start:tokenEnd]
|
||||
name := content[tokenStart:tokenEnd]
|
||||
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
|
||||
for idx := range entries {
|
||||
if normalizedName == entries[idx].normalized {
|
||||
if entries[idx].handle != "" && normalizedName == entries[idx].handle {
|
||||
cm := entries[idx].model
|
||||
resolved = &cm
|
||||
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{
|
||||
Raw: raw,
|
||||
Name: name,
|
||||
@@ -133,6 +188,16 @@ func ResolvedModels(mentions []Mention) []models.ChannelModel {
|
||||
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
|
||||
// follows an @mention but isn't part of the name.
|
||||
func isMentionTrailingPunct(b byte) bool {
|
||||
|
||||
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -128,6 +129,8 @@ type ProviderConfig struct {
|
||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
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.
|
||||
@@ -146,6 +149,8 @@ type ProviderConfigPatch struct {
|
||||
Settings JSONMap `json:"settings,omitempty"`
|
||||
IsActive *bool `json:"is_active,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 {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Handle string `json:"handle,omitempty" db:"handle"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
Icon string `json:"icon,omitempty" db:"icon"`
|
||||
Avatar string `json:"avatar,omitempty" db:"avatar"`
|
||||
@@ -227,6 +233,7 @@ type Persona struct {
|
||||
|
||||
type PersonaPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Handle *string `json:"handle,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
@@ -278,6 +285,7 @@ type UserModelSetting struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
ModelID string `json:"model_id" db:"model_id"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||
Hidden bool `json:"hidden" db:"hidden"`
|
||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"`
|
||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"`
|
||||
@@ -285,12 +293,24 @@ type UserModelSetting struct {
|
||||
}
|
||||
|
||||
type UserModelSettingPatch struct {
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
Hidden *bool `json:"hidden,omitempty"`
|
||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
// HiddenEntry identifies a model+provider pair for bulk visibility operations.
|
||||
type HiddenEntry struct {
|
||||
ModelID string `json:"model_id"`
|
||||
ProviderConfigID string `json:"provider_config_id"`
|
||||
}
|
||||
|
||||
// CompositeModelKey builds the composite key used for per-provider model preferences.
|
||||
func CompositeModelKey(providerConfigID, modelID string) string {
|
||||
return providerConfigID + ":" + modelID
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// CHANNELS
|
||||
// =========================================
|
||||
@@ -319,41 +339,47 @@ type Channel struct {
|
||||
|
||||
type Message struct {
|
||||
BaseModel
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
Content string `json:"content" db:"content"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"`
|
||||
ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"`
|
||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
|
||||
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
|
||||
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
|
||||
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
Content string `json:"content" db:"content"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"`
|
||||
ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"`
|
||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
|
||||
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
|
||||
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
|
||||
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// CHANNEL MEMBERS, MODELS, CURSORS
|
||||
// CHANNEL PARTICIPANTS, MODELS, CURSORS
|
||||
// =========================================
|
||||
|
||||
type ChannelMember struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
|
||||
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
|
||||
type ChannelParticipant struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
ParticipantType string `json:"participant_type" db:"participant_type"`
|
||||
ParticipantID string `json:"participant_id" db:"participant_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
DisplayName *string `json:"display_name,omitempty" db:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty" db:"avatar_url"`
|
||||
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
|
||||
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
|
||||
}
|
||||
|
||||
type ChannelModel struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
ModelID string `json:"model_id" db:"model_id"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||
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"`
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
ModelID string `json:"model_id" db:"model_id"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
||||
Handle string `json:"handle,omitempty" db:"handle"`
|
||||
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 {
|
||||
@@ -364,6 +390,56 @@ type ChannelCursor struct {
|
||||
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
|
||||
// =========================================
|
||||
@@ -635,6 +711,7 @@ type UserModel struct {
|
||||
// Persona fields — always emitted so frontend can branch on is_persona.
|
||||
IsPersona bool `json:"is_persona"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
PersonaHandle string `json:"persona_handle,omitempty"`
|
||||
PersonaScope string `json:"persona_scope,omitempty"`
|
||||
PersonaAvatar string `json:"persona_avatar,omitempty"`
|
||||
PersonaTeamName string `json:"persona_team_name,omitempty"`
|
||||
|
||||
@@ -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>
|
||||
<span>New Project</span>
|
||||
</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>
|
||||
<span>Group Chat</span>
|
||||
<span class="dd-hint">soon</span>
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
|
||||
@@ -235,6 +235,20 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
|
||||
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 {
|
||||
// Assistant with tool calls → mixed content blocks
|
||||
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 {
|
||||
return nil, fmt.Errorf("provider request: %w", err)
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -453,7 +459,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider request: %w", err)
|
||||
}
|
||||
@@ -474,7 +480,7 @@ type openaiMessage struct {
|
||||
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
|
||||
Name string `json:"name,omitempty"` // tool name for role=tool
|
||||
Name string `json:"name,omitempty"` // speaker name (tool or persona attribution)
|
||||
}
|
||||
|
||||
// openaiContentPart represents a single element in a multimodal content array.
|
||||
|
||||
@@ -56,7 +56,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openrouter list models: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
@@ -43,6 +47,41 @@ type ProviderConfig struct {
|
||||
APIKey string
|
||||
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
|
||||
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 ────────────────
|
||||
@@ -60,7 +99,7 @@ type Message struct {
|
||||
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
|
||||
Name string `json:"name,omitempty"` // tool name for role="tool"
|
||||
Name string `json:"name,omitempty"` // speaker name (tool result, or persona attribution v0.23.0)
|
||||
}
|
||||
|
||||
// ContentPart is a single element in a multimodal message.
|
||||
|
||||
@@ -52,7 +52,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
||||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("venice list models: %w", err)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: apiKey,
|
||||
CustomHeaders: headers,
|
||||
ProxyMode: cfg.ProxyMode,
|
||||
ProxyURL: proxyURL,
|
||||
}, cfg.Scope, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -153,8 +153,8 @@ type PolicyStore interface {
|
||||
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, patch models.UserModelSettingPatch) error
|
||||
BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden 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
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -229,6 +229,19 @@ type ChannelStore interface {
|
||||
// Ownership check
|
||||
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
|
||||
ResolveWorkspaceID(ctx context.Context, channelID string) (string, error)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 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, `
|
||||
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)
|
||||
ON CONFLICT (channel_id, model_id) DO UPDATE SET
|
||||
provider_config_id = $3, display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
|
||||
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
|
||||
display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
|
||||
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||
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) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE channel_id = $1`, channelID)
|
||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''),
|
||||
COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -205,10 +225,14 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
||||
var result []models.ChannelModel
|
||||
for rows.Next() {
|
||||
var cm models.ChannelModel
|
||||
var personaID string
|
||||
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
|
||||
}
|
||||
if personaID != "" {
|
||||
cm.PersonaID = &personaID
|
||||
}
|
||||
result = append(result, cm)
|
||||
}
|
||||
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) {
|
||||
var cm models.ChannelModel
|
||||
var personaID string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE id = $1`, id).Scan(
|
||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''),
|
||||
COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||
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.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if personaID != "" {
|
||||
cm.PersonaID = &personaID
|
||||
}
|
||||
return &cm, nil
|
||||
}
|
||||
|
||||
@@ -287,3 +318,99 @@ func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,20 +13,24 @@ type PersonaStore struct{}
|
||||
|
||||
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,
|
||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||
created_at, updated_at`
|
||||
|
||||
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, `
|
||||
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,
|
||||
scope, owner_id, created_by, is_active, is_shared,
|
||||
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`,
|
||||
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),
|
||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
||||
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 {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.Handle != nil {
|
||||
b.Set("handle", *patch.Handle)
|
||||
}
|
||||
if patch.Description != nil {
|
||||
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) {
|
||||
var p models.Persona
|
||||
var providerConfigID, ownerID sql.NullString
|
||||
var handle sql.NullString
|
||||
var temp, topP sql.NullFloat64
|
||||
var maxTokens, thinkingBudget sql.NullInt64
|
||||
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.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
@@ -294,6 +302,9 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if handle.Valid {
|
||||
p.Handle = handle.String
|
||||
}
|
||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.Temperature = NullableFloat64Ptr(temp)
|
||||
@@ -308,10 +319,11 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
for rows.Next() {
|
||||
var p models.Persona
|
||||
var providerConfigID, ownerID sql.NullString
|
||||
var handle sql.NullString
|
||||
var temp, topP sql.NullFloat64
|
||||
var maxTokens, thinkingBudget sql.NullInt64
|
||||
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.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
@@ -321,6 +333,9 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if handle.Valid {
|
||||
p.Handle = handle.String
|
||||
}
|
||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.Temperature = NullableFloat64Ptr(temp)
|
||||
|
||||
@@ -14,7 +14,7 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
|
||||
|
||||
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
||||
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
|
||||
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
|
||||
if err != nil {
|
||||
@@ -27,7 +27,7 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
||||
var s models.UserModelSetting
|
||||
var prefTemp, prefMaxTokens interface{}
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
|
||||
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
|
||||
&prefTemp, &prefMaxTokens,
|
||||
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
@@ -46,10 +46,10 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -58,17 +58,17 @@ func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID s
|
||||
|
||||
result := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var modelID string
|
||||
if err := rows.Scan(&modelID); err != nil {
|
||||
var modelID, provCfgID string
|
||||
if err := rows.Scan(&modelID, &provCfgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[modelID] = true
|
||||
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, 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")
|
||||
if patch.Hidden != nil {
|
||||
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
|
||||
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||
VALUES ($1, $2, COALESCE($3, false), $4, $5, COALESCE($6, 0))
|
||||
ON CONFLICT (user_id, model_id)
|
||||
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($3, user_model_settings.hidden),
|
||||
preferred_temperature = COALESCE($4, user_model_settings.preferred_temperature),
|
||||
preferred_max_tokens = COALESCE($5, user_model_settings.preferred_max_tokens),
|
||||
sort_order = COALESCE($6, user_model_settings.sort_order)`,
|
||||
userID, modelID,
|
||||
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),
|
||||
@@ -107,30 +106,20 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
||||
return err
|
||||
}
|
||||
|
||||
// BulkSetHidden sets the hidden state for multiple models at once.
|
||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
|
||||
if len(modelIDs) == 0 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Build parameterized IN clause
|
||||
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 {
|
||||
for _, entry := range entries {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (user_id, model_id, hidden)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = $3`,
|
||||
userID, modelID, hidden)
|
||||
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: %w", modelID, err)
|
||||
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -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 {
|
||||
// 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, `
|
||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (channel_id, model_id) 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`,
|
||||
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
|
||||
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)
|
||||
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) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE channel_id = ?`, channelID)
|
||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
|
||||
COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -210,10 +229,14 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
||||
var result []models.ChannelModel
|
||||
for rows.Next() {
|
||||
var cm models.ChannelModel
|
||||
var personaID string
|
||||
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
|
||||
}
|
||||
if personaID != "" {
|
||||
cm.PersonaID = &personaID
|
||||
}
|
||||
result = append(result, cm)
|
||||
}
|
||||
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) {
|
||||
var cm models.ChannelModel
|
||||
var personaID string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE id = ?`, id).Scan(
|
||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
|
||||
COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||
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.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if personaID != "" {
|
||||
cm.PersonaID = &personaID
|
||||
}
|
||||
return &cm, nil
|
||||
}
|
||||
|
||||
@@ -289,3 +319,103 @@ func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type PersonaStore struct{}
|
||||
|
||||
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,
|
||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||
created_at, updated_at`
|
||||
@@ -25,13 +25,17 @@ func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
||||
now := time.Now().UTC()
|
||||
p.CreatedAt = 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, `
|
||||
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,
|
||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||
created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||
models.NullString(p.ProviderConfigID),
|
||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
||||
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 {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.Handle != nil {
|
||||
b.Set("handle", *patch.Handle)
|
||||
}
|
||||
if patch.Description != nil {
|
||||
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) {
|
||||
var p models.Persona
|
||||
var providerConfigID, ownerID sql.NullString
|
||||
var providerConfigID, ownerID, handle sql.NullString
|
||||
var temp, topP sql.NullFloat64
|
||||
var maxTokens, thinkingBudget sql.NullInt64
|
||||
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.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
@@ -301,6 +308,9 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if handle.Valid {
|
||||
p.Handle = handle.String
|
||||
}
|
||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.Temperature = NullableFloat64Ptr(temp)
|
||||
@@ -314,11 +324,11 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
var result []models.Persona
|
||||
for rows.Next() {
|
||||
var p models.Persona
|
||||
var providerConfigID, ownerID sql.NullString
|
||||
var providerConfigID, ownerID, handle sql.NullString
|
||||
var temp, topP sql.NullFloat64
|
||||
var maxTokens, thinkingBudget sql.NullInt64
|
||||
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.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
@@ -328,6 +338,9 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if handle.Valid {
|
||||
p.Handle = handle.String
|
||||
}
|
||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.Temperature = NullableFloat64Ptr(temp)
|
||||
|
||||
@@ -15,7 +15,7 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
|
||||
|
||||
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
||||
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
|
||||
FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID)
|
||||
if err != nil {
|
||||
@@ -28,7 +28,7 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
||||
var s models.UserModelSetting
|
||||
var prefTemp, prefMaxTokens interface{}
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
|
||||
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
|
||||
&prefTemp, &prefMaxTokens,
|
||||
&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()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -59,17 +59,17 @@ func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID s
|
||||
|
||||
result := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var modelID string
|
||||
if err := rows.Scan(&modelID); err != nil {
|
||||
var modelID, provCfgID string
|
||||
if err := rows.Scan(&modelID, &provCfgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[modelID] = true
|
||||
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, 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")
|
||||
if patch.Hidden != nil {
|
||||
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
|
||||
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||
VALUES (?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0))
|
||||
ON CONFLICT (user_id, model_id)
|
||||
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,
|
||||
store.NewID(), userID, modelID, providerConfigID,
|
||||
patchBoolOrNil(patch.Hidden),
|
||||
patchFloat64OrNil(patch.PreferredTemperature),
|
||||
patchIntOrNil(patch.PreferredMaxTokens),
|
||||
@@ -108,21 +107,20 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
||||
return err
|
||||
}
|
||||
|
||||
// BulkSetHidden sets the hidden state for multiple models at once.
|
||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
|
||||
if len(modelIDs) == 0 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Upsert each model individually
|
||||
for _, modelID := range modelIDs {
|
||||
for _, entry := range entries {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (id, user_id, model_id, hidden)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = excluded.hidden`,
|
||||
store.NewID(), userID, modelID, hidden)
|
||||
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: %w", modelID, err)
|
||||
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
gap: 2px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary, #f0f0f0);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
.ch-model-pill.ch-model-default {
|
||||
background: var(--accent-bg, #e8f0fe);
|
||||
border-color: var(--accent-color, #4a8af4);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ch-model-pill-name {
|
||||
cursor: pointer;
|
||||
@@ -45,7 +45,7 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--text-3);
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
@@ -58,18 +58,18 @@
|
||||
|
||||
.ch-model-add-btn {
|
||||
background: none;
|
||||
border: 1px dashed var(--border-color, #ccc);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.ch-model-add-btn:hover {
|
||||
color: var(--text-primary, #333);
|
||||
border-color: var(--text-primary, #333);
|
||||
color: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Add Model Dialog ────────────────────── */
|
||||
@@ -84,7 +84,7 @@
|
||||
z-index: 1100;
|
||||
}
|
||||
.ch-model-add-inner {
|
||||
background: var(--bg-primary, #fff);
|
||||
background: var(--bg-surface);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
min-width: 320px;
|
||||
@@ -99,7 +99,7 @@
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ch-model-add-inner select,
|
||||
.ch-model-add-inner input[type="text"] {
|
||||
@@ -107,11 +107,11 @@
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ch-model-add-actions {
|
||||
@@ -126,40 +126,72 @@
|
||||
.mention-ac {
|
||||
position: fixed;
|
||||
z-index: 1050;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
max-height: 200px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
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.mention-ac-active {
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.mention-ac-name {
|
||||
font-weight: 500;
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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) ─ */
|
||||
|
||||
.msg-model-label {
|
||||
@@ -168,8 +200,8 @@
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f0f0f0);
|
||||
color: var(--text-secondary, #666);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-3);
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -181,7 +213,7 @@
|
||||
gap: 8px;
|
||||
padding: 12px 0 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.model-stream-divider::before,
|
||||
@@ -189,7 +221,7 @@
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color, #eee);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ── Mobile ──────────────────────────────── */
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
padding: 10px 14px;
|
||||
flex: initial;
|
||||
max-width: 85%;
|
||||
}
|
||||
.message.user .msg-head { flex-direction: row-reverse; }
|
||||
.message.user .msg-role { color: var(--accent); }
|
||||
@@ -594,3 +596,11 @@
|
||||
}
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -445,3 +445,16 @@
|
||||
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
|
||||
@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; }
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ function processModelsResponse(data, hiddenModels = new Set()) {
|
||||
return (data.models || []).map(m => {
|
||||
const isPersona = !!m.is_persona;
|
||||
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
|
||||
? (m.persona_id || m.id)
|
||||
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
|
||||
@@ -143,12 +143,13 @@ function processModelsResponse(data, hiddenModels = new Set()) {
|
||||
configId: cfgId || null,
|
||||
isPersona,
|
||||
personaId: m.persona_id || null,
|
||||
personaHandle: m.persona_handle || null,
|
||||
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
|
||||
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
|
||||
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
|
||||
source: m.source || (isPersona ? 'persona' : 'global'),
|
||||
teamName: m.persona_team_name || null,
|
||||
hidden: !isPersona && hiddenModels.has(baseModelId),
|
||||
hidden: !isPersona && hiddenModels.has((cfgId || '') + ':' + baseModelId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ describe('processModelsResponse — personas', () => {
|
||||
persona_id: 'preset-uuid',
|
||||
persona_scope: 'global',
|
||||
persona_avatar: '/avatars/code.png',
|
||||
persona_handle: 'code-helper',
|
||||
persona_team_name: null,
|
||||
config_id: 'cfg-uuid',
|
||||
provider_name: 'OpenAI',
|
||||
@@ -147,6 +148,7 @@ describe('processModelsResponse — personas', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].personaScope, 'global');
|
||||
assert.equal(models[0].personaAvatar, '/avatars/code.png');
|
||||
assert.equal(models[0].personaHandle, 'code-helper');
|
||||
assert.equal(models[0].personaId, 'preset-uuid');
|
||||
});
|
||||
|
||||
@@ -208,8 +210,8 @@ describe('processModelsResponse — hidden models', () => {
|
||||
],
|
||||
};
|
||||
|
||||
it('marks models as hidden from user prefs', () => {
|
||||
const hidden = new Set(['gpt-4o']);
|
||||
it('marks models as hidden from user prefs (composite key)', () => {
|
||||
const hidden = new Set(['c1:gpt-4o']);
|
||||
const models = processModelsResponse(apiResponse, hidden);
|
||||
assert.equal(models[0].hidden, true, 'gpt-4o should 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' },
|
||||
],
|
||||
};
|
||||
const hidden = new Set(['gpt-4o']);
|
||||
const hidden = new Set([':gpt-4o']);
|
||||
const models = processModelsResponse(resp, hidden);
|
||||
assert.equal(models[0].hidden, false,
|
||||
'personas must NOT be hidden by base model hidden pref');
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('Edge: provider_config_id only (no config_id alias)', () => {
|
||||
|
||||
describe('Edge: Hidden model preferences', () => {
|
||||
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 mini = models.find(m => m.baseModelId === 'gpt-4o-mini');
|
||||
const main = models.find(m => m.baseModelId === 'gpt-4o');
|
||||
@@ -324,7 +324,7 @@ describe('Edge: Hidden model preferences', () => {
|
||||
|
||||
it('personas are never hidden', () => {
|
||||
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);
|
||||
assert.equal(models[0].hidden, false, 'personas must never be hidden');
|
||||
});
|
||||
|
||||
@@ -154,11 +154,12 @@ async function bulkSetVisibility(visibility) {
|
||||
|
||||
// ── User Model Preferences ──────────────────
|
||||
|
||||
async function toggleUserModelVisibility(modelId, currentlyHidden) {
|
||||
async function toggleUserModelVisibility(modelId, providerConfigId, currentlyHidden) {
|
||||
const compositeKey = (providerConfigId || '') + ':' + modelId;
|
||||
try {
|
||||
await API.setModelPreference(modelId, !currentlyHidden);
|
||||
if (currentlyHidden) App.hiddenModels.delete(modelId);
|
||||
else App.hiddenModels.add(modelId);
|
||||
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
|
||||
if (currentlyHidden) App.hiddenModels.delete(compositeKey);
|
||||
else App.hiddenModels.add(compositeKey);
|
||||
await UI.loadUserModels();
|
||||
await fetchModels(); // refresh model selector
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
@@ -170,12 +171,18 @@ async function bulkSetUserModelVisibility(show) {
|
||||
const shouldHide = !show;
|
||||
// Only update models not already in the desired state
|
||||
const toUpdate = models
|
||||
.map(m => m.baseModelId || m.id)
|
||||
.filter(mid => App.hiddenModels.has(mid) !== shouldHide);
|
||||
.filter(m => {
|
||||
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; }
|
||||
try {
|
||||
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 fetchModels();
|
||||
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
|
||||
|
||||
@@ -194,6 +194,12 @@ const API = {
|
||||
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}`); },
|
||||
|
||||
// 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)
|
||||
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
|
||||
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'); },
|
||||
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
|
||||
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
|
||||
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
|
||||
setModelPreference(modelId, providerConfigId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: providerConfigId, hidden }); },
|
||||
bulkSetModelPreferences(entries, hidden) { return this._post('/api/v1/models/preferences/bulk', { entries, hidden }); },
|
||||
|
||||
// User personas
|
||||
listUserPersonas() { return this._get('/api/v1/personas'); },
|
||||
|
||||
@@ -54,7 +54,9 @@ async function fetchModels() {
|
||||
try {
|
||||
const prefData = await API.getModelPreferences();
|
||||
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) {
|
||||
if (!App.hiddenModels) App.hiddenModels = new Set();
|
||||
@@ -63,25 +65,27 @@ async function fetchModels() {
|
||||
App.models = (data.models || []).map(m => {
|
||||
const isPersona = !!m.is_persona;
|
||||
const baseModelId = m.model_id || m.id;
|
||||
const cfgId = m.config_id || m.provider_config_id || '';
|
||||
const id = isPersona
|
||||
? (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 {
|
||||
id,
|
||||
baseModelId,
|
||||
name: m.display_name || baseModelId,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: m.config_id || m.provider_config_id || null,
|
||||
configId: cfgId || null,
|
||||
model_type: m.model_type || 'chat',
|
||||
capabilities: resolveCapabilities(m.capabilities, baseModelId),
|
||||
isPersona,
|
||||
personaId: m.persona_id || null,
|
||||
personaHandle: m.persona_handle || null,
|
||||
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
|
||||
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
|
||||
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
|
||||
source: m.source || (isPersona ? 'persona' : 'global'),
|
||||
teamName: m.persona_team_name || null,
|
||||
hidden: !isPersona && App.hiddenModels.has(baseModelId),
|
||||
hidden: !isPersona && App.hiddenModels.has((cfgId || '') + ':' + baseModelId),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -187,31 +187,44 @@ const ChannelModels = {
|
||||
* Shows a dropdown of channel models when user types @.
|
||||
*/
|
||||
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 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 atIdx = before.lastIndexOf('@');
|
||||
if (atIdx < 0) { this.hideAutocomplete(); return; }
|
||||
|
||||
// @ must be at start or preceded by whitespace
|
||||
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
|
||||
this.hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const partial = before.slice(atIdx + 1).toLowerCase();
|
||||
const matches = this._roster.filter(m =>
|
||||
m.display_name.toLowerCase().startsWith(partial) ||
|
||||
m.display_name.toLowerCase().replace(/-/g, ' ').startsWith(partial.replace(/-/g, ' '))
|
||||
);
|
||||
const partial = before.slice(atIdx + 1).toLowerCase().replace(/-/g, ' ');
|
||||
if (partial.length === 0) {
|
||||
// Just typed @ — show everything
|
||||
}
|
||||
|
||||
// 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; }
|
||||
|
||||
this.showAutocomplete(inputEl, atIdx, cursorPos, matches);
|
||||
// Cap at 10 to keep dropdown manageable
|
||||
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
|
||||
},
|
||||
|
||||
showAutocomplete(inputEl, atIdx, cursorPos, matches) {
|
||||
@@ -221,19 +234,36 @@ const ChannelModels = {
|
||||
wrap.className = 'mention-ac';
|
||||
wrap.id = 'mentionAc';
|
||||
|
||||
// Position relative to input
|
||||
const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
|
||||
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
|
||||
wrap.style.left = inputRect.left + 'px';
|
||||
wrap.style.minWidth = '200px';
|
||||
wrap.style.minWidth = '280px';
|
||||
|
||||
matches.forEach((m, i) => {
|
||||
const item = document.createElement('div');
|
||||
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', () => {
|
||||
this._insertMention(inputEl, atIdx, cursorPos, m.display_name);
|
||||
this._insertMention(inputEl, atIdx, cursorPos, mentionToken);
|
||||
});
|
||||
wrap.appendChild(item);
|
||||
});
|
||||
@@ -278,9 +308,7 @@ const ChannelModels = {
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const active = items[activeIdx];
|
||||
if (active) {
|
||||
active.click();
|
||||
}
|
||||
if (active) active.click();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
@@ -326,6 +354,31 @@ const ChannelModels = {
|
||||
if (cm?.display_name) return cm.display_name;
|
||||
const m = App.models?.find(x => x.id === modelId || x.baseModelId === 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
178
src/js/chat.js
178
src/js/chat.js
@@ -185,7 +185,7 @@ async function toggleChannelAutoCompact(enabled) {
|
||||
|
||||
async function loadChats() {
|
||||
try {
|
||||
const resp = await API.listChannels(1, 100, 'direct');
|
||||
const resp = await API.listChannels(1, 100);
|
||||
App.chats = (resp.data || []).map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
@@ -404,6 +404,123 @@ async function newChat() {
|
||||
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) {
|
||||
if (!await showConfirm('Delete this chat?')) return;
|
||||
try {
|
||||
@@ -908,6 +1025,65 @@ function _initChatListeners() {
|
||||
// Multi-model channel roster (v0.20.0)
|
||||
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
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', (e) => {
|
||||
|
||||
@@ -630,7 +630,7 @@ function _showSaveToNoteModal(opts) {
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'saveToNoteModal';
|
||||
modal.className = 'modal-overlay';
|
||||
modal.className = 'modal-overlay active';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content save-to-note-modal">
|
||||
<h3>Save to Note</h3>
|
||||
|
||||
@@ -17,7 +17,17 @@ function avatarHTML(role, avatarDataURI) {
|
||||
|
||||
// Look up the current model/persona avatar for assistant messages.
|
||||
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;
|
||||
if (modelId) {
|
||||
const m = App.models.find(x => x.id === modelId || x.personaId === modelId);
|
||||
@@ -37,6 +47,7 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
|
||||
containerEl.innerHTML = `
|
||||
<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 ? `
|
||||
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
|
||||
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
|
||||
@@ -95,6 +106,22 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
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
|
||||
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
|
||||
if (options.onSubmit) options.onSubmit(form.getValues());
|
||||
@@ -104,6 +131,7 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
getValues() {
|
||||
const v = {
|
||||
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
|
||||
handle: document.getElementById(`${pfx}_handle`)?.value.trim() || '',
|
||||
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
|
||||
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
|
||||
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
|
||||
@@ -129,6 +157,7 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
setValues(p) {
|
||||
const el = id => document.getElementById(`${pfx}_${id}`);
|
||||
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('prompt')) el('prompt').value = p.system_prompt || '';
|
||||
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 || '';
|
||||
},
|
||||
clearForm() {
|
||||
['name','desc','prompt','temp','maxTokens'].forEach(f => {
|
||||
['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
|
||||
const el = document.getElementById(`${pfx}_${f}`);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
@@ -288,6 +317,8 @@ const UI = {
|
||||
// ── Chat item renderer ──────────────────
|
||||
const renderChatItem = (c) => {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
const typeIcon = c.type === 'group' ? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||||
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
|
||||
return `
|
||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||
onclick="selectChat('${c.id}')"
|
||||
@@ -295,7 +326,7 @@ const UI = {
|
||||
draggable="true"
|
||||
ondragstart="onChatDragStart(event,'${c.id}')"
|
||||
ondragend="onChatDragEnd(event)">
|
||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${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>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</div>`;
|
||||
@@ -487,9 +518,11 @@ const UI = {
|
||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
let assistantLabel = 'Assistant';
|
||||
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) {
|
||||
assistantLabel = msg.modelName;
|
||||
} else if (typeof ChannelModels !== 'undefined' && msg.model) {
|
||||
assistantLabel = ChannelModels.resolveDisplayName(msg.model);
|
||||
} else if (msg.model) {
|
||||
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
|
||||
assistantLabel = m?.name || msg.model;
|
||||
@@ -598,7 +631,15 @@ const UI = {
|
||||
|
||||
const label = modelName || 'Assistant';
|
||||
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
|
||||
// separate message bubbles per model. Without them, single-bubble.
|
||||
@@ -911,8 +952,9 @@ const UI = {
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
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>` : '';
|
||||
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', () => {
|
||||
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
||||
UI._closeModelDropdown();
|
||||
|
||||
@@ -44,9 +44,71 @@ function formatMessage(content) {
|
||||
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
|
||||
}
|
||||
|
||||
// @mention highlighting (v0.23.0): style @Name references as pills
|
||||
html = _highlightMentions(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) {
|
||||
// ── Unwrap outer ```markdown wrappers ────────────────────────
|
||||
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
|
||||
|
||||
@@ -766,7 +766,9 @@ Object.assign(UI, {
|
||||
try {
|
||||
const prefData = await API.getModelPreferences();
|
||||
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) {
|
||||
if (!App.hiddenModels) App.hiddenModels = new Set();
|
||||
@@ -781,11 +783,13 @@ Object.assign(UI, {
|
||||
}
|
||||
el.innerHTML = models.map(m => {
|
||||
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 badges = renderCapBadges(caps, { compact: true });
|
||||
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>` : '';
|
||||
const hidden = App.hiddenModels.has(mid);
|
||||
const hidden = App.hiddenModels.has(compositeKey);
|
||||
const toggleCls = hidden ? '' : 'enabled';
|
||||
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
|
||||
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-provider">${esc(m.provider_name || m.provider || '')}</span>
|
||||
${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>`;
|
||||
}).join('');
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
|
||||
640
store/interfaces.go
Normal file
640
store/interfaces.go
Normal 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",
|
||||
}
|
||||
}
|
||||
152
store/postgres/user_settings.go
Normal file
152
store/postgres/user_settings.go
Normal 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
|
||||
153
store/sqlite/user_settings.go
Normal file
153
store/sqlite/user_settings.go
Normal 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
|
||||
Reference in New Issue
Block a user