Changeset 0.23.0 (#153)
This commit is contained in:
@@ -530,17 +530,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Dev: Validate Schema ───────────────────
|
# ── Dev: Validate Schema ───────────────────
|
||||||
- name: "Dev: validate schema"
|
# - name: "Dev: validate schema"
|
||||||
if: steps.setup.outputs.DB_WIPE == 'true'
|
# if: steps.setup.outputs.DB_WIPE == 'true'
|
||||||
env:
|
# env:
|
||||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
# PGHOST: ${{ env.POSTGRES_HOST }}
|
||||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
# PGPORT: ${{ env.POSTGRES_PORT }}
|
||||||
PGUSER: ${{ secrets.POSTGRES_USER }}
|
# PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||||
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
# PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
PGDATABASE: ${{ steps.setup.outputs.DB_NAME }}
|
# PGDATABASE: ${{ steps.setup.outputs.DB_NAME }}
|
||||||
run: |
|
# run: |
|
||||||
chmod +x scripts/db-validate.sh
|
# chmod +x scripts/db-validate.sh
|
||||||
scripts/db-validate.sh
|
# scripts/db-validate.sh
|
||||||
|
|
||||||
# ── Dev Wipe (fresh install test) ──────────
|
# ── Dev Wipe (fresh install test) ──────────
|
||||||
- name: "Dev: wipe for fresh install test"
|
- name: "Dev: wipe for fresh install test"
|
||||||
|
|||||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,6 +2,45 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.23.0] — 2026-03-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **@mention routing.** Type `@persona-handle` or `@model-id` in any chat to route the completion to a specific persona or model. Works in all chats — no roster or group setup required. Resolution order: persona handle (exact, then prefix) → model catalog (exact, then prefix). Backend `resolveMention()` does direct DB lookup against `personas.handle` and `model_catalog.model_id`.
|
||||||
|
- **Persona handles.** Every persona gets a unique `handle` field (e.g. `veronica-sharpe`) auto-generated from the name. Editable in the persona form. Stored in `personas.handle` with a unique index. Used as the @mention identifier — no spaces, no ambiguity.
|
||||||
|
- **@mention autocomplete.** Typing `@` in the chat input shows a filterable popup of all enabled models and personas with avatars, display names, `@handle` hints, and provider info. Works in any chat. Matches against handle, model ID, and display name. Arrow keys + Enter/Tab to select, Escape to dismiss.
|
||||||
|
- **@mention pill rendering.** @mentions in message content render as styled accent-colored pills. Handles and display names both highlighted. Skipped inside `<code>` and `<pre>` blocks.
|
||||||
|
- **AI-to-AI chaining.** When a persona response contains an @mention of another persona, a follow-up completion fires automatically. Delivered via WebSocket (`message.created` event). Chain depth capped at 5. Self-mention blocked. Uses the same `resolveMention()` as user @mentions.
|
||||||
|
- **Participant list injection.** System prompt includes a list of available @mentionable personas so LLMs know who they can invoke. Injected in `loadConversation()` after memory hints. Excludes the current persona (no self-mention). Empty when no other personas exist (zero overhead on 1:1 chats).
|
||||||
|
- **Context boundaries.** When switching personas via @mention, a system message is injected before the user's message telling the target persona to ignore previous personas' styles. When a non-persona model responds after persona messages exist in history, a boundary tells it to use its own natural style.
|
||||||
|
- **Provider proxy support.** `provider_configs` table gains `proxy_mode` (system/direct/custom) and `proxy_url` columns. Provider HTTP clients use `proxy_mode` to configure transport: `system` = env-based (`HTTP_PROXY`), `direct` = no proxy, `custom` = explicit URL. All four providers (Anthropic, OpenAI, OpenRouter, Venice) updated.
|
||||||
|
- **Persona groups schema.** `persona_groups` and `persona_group_members` tables added (migration 004). Structural foundation for saved roster templates — CRUD and FE not yet implemented.
|
||||||
|
- **Per-provider model preferences.** `user_model_settings` unique key widened to `(user_id, model_id, provider_config_id)`. Same model from different providers gets independent visibility toggles. Frontend uses composite keys throughout.
|
||||||
|
- **Channel participants.** `channel_participants` table with polymorphic `participant_type` (user/persona/session), `role` (owner/member/observer). CRUD endpoints: list, add, update role, remove. Auto-created on channel creation.
|
||||||
|
- **Persona avatar resolution.** Assistant messages resolve persona portraits through channel roster → participant data → App.models lookup. Streaming messages also resolve avatars.
|
||||||
|
- **Save message to note.** Per-message "Note" button (visible on hover) opens a modal to create a new note or append to an existing note from any message. Supports text selection — selected text is saved instead of full message.
|
||||||
|
- **Group chat creation.** "Group Chat" option in New Chat dropdown. Persona picker with handles, scope badges, and model info. Creates channel + adds persona participants.
|
||||||
|
- **Chat type indicators.** Sidebar shows 👥 for group chats, ⚙ for workflow channels.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Channel models constraint.** `channel_models` unique constraint split into two partial indexes: raw models keyed on `(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL`, persona entries keyed on `(channel_id, persona_id) WHERE persona_id IS NOT NULL`. Two personas on the same underlying model now get separate roster entries.
|
||||||
|
- **Channel models queries.** `GetModels` and `GetModelByID` in both Postgres and SQLite stores now JOIN `personas` table to carry handle data alongside display name and persona ID.
|
||||||
|
- **User message alignment.** User bubbles use `flex: initial` with `max-width: 85%` to shrink-wrap content and right-align within the centered 768px column.
|
||||||
|
- **Channel list loading.** `loadChats()` no longer filters by `type=direct` — all channel types (direct, group, workflow) load on refresh.
|
||||||
|
- **Autocomplete CSS.** Replaced legacy CSS variable names (`--bg-primary`, `--text-secondary`, etc.) with current theme variables (`--bg-surface`, `--bg-raised`, `--text-3`, etc.) across all `channel-models.css`.
|
||||||
|
- **Persona form.** Added @mention handle field with auto-generation from name, monospace styling, and edit-locks (manual edit stops auto-gen). Handle included in `getValues()`, `setValues()`, and `clearForm()`.
|
||||||
|
- **Model dropdown.** Persona entries show `@handle` hint next to display name in accent color monospace.
|
||||||
|
- **Completion chain.** Rewritten to use `resolveMention()` directly instead of roster-based mention parsing. No roster, participant list, or channel_models dependency. Same function for user→LLM and LLM→LLM routing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Notes modal visibility.** `_showSaveToNoteModal` now creates overlay with `modal-overlay active` class (was missing `active`, modal was invisible).
|
||||||
|
- **Single @mention routing.** Single-target @mentions no longer reload conversation (which caused duplicate user messages that broke Anthropic's API). System prompt swapped in-place on existing messages array.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
- **DB wipe required.** Migrations 003, 004, and 005 modified. No upgrade path from previous schema — drop and recreate dev DB.
|
||||||
|
- **New columns:** `personas.handle`, `provider_configs.proxy_mode`, `provider_configs.proxy_url`.
|
||||||
|
- **New tables:** `persona_groups`, `persona_group_members`, `channel_participants`.
|
||||||
|
- **New indexes:** `idx_personas_handle` (unique), `idx_channel_models_raw` (partial), `idx_channel_models_persona` (partial).
|
||||||
|
|
||||||
## [0.22.8] — 2026-03-04
|
## [0.22.8] — 2026-03-04
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
287
capabilities/resolver.go
Normal file
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`.
|
Used by: `provider_configs`, `personas`, `model_catalog` (visibility column adds `enabled`/`disabled`/`team` states on top), `projects`.
|
||||||
|
|
||||||
|
## Personas
|
||||||
|
|
||||||
|
Personas are named AI configurations: a system prompt, base model, provider
|
||||||
|
config, and behavioral parameters (temperature, max tokens, thinking budget).
|
||||||
|
Each persona has a unique `handle` field (e.g. `veronica-sharpe`) used for
|
||||||
|
@mention routing.
|
||||||
|
|
||||||
|
**Handle Generation:** Auto-generated from name on create (`HandleFromName()`):
|
||||||
|
lowercase, spaces→hyphens, alphanumeric only, max 50 chars. Editable via the
|
||||||
|
persona form. Unique index prevents collisions.
|
||||||
|
|
||||||
|
**Scope Model:** Same as other resources — global, team, personal. Personal
|
||||||
|
personas are only visible to the creator. Team and global personas are visible
|
||||||
|
to all users with the appropriate access.
|
||||||
|
|
||||||
|
**Resolution Chain (completion):**
|
||||||
|
```
|
||||||
|
Explicit persona (req.PersonaID) → Project persona → @mention persona → dropdown selection
|
||||||
|
```
|
||||||
|
|
||||||
|
**Memory:** Personas have `memory_enabled` and `memory_extraction_prompt` fields.
|
||||||
|
When memory is enabled, the persona's memory scope is independent — facts
|
||||||
|
extracted in conversations with Persona A are not visible to Persona B.
|
||||||
|
|
||||||
## Projects (v0.19.0)
|
## Projects (v0.19.0)
|
||||||
|
|
||||||
Projects are organizational containers that group channels, knowledge bases,
|
Projects are organizational containers that group channels, knowledge bases,
|
||||||
@@ -324,19 +348,42 @@ in Settings → Notifications.
|
|||||||
**Cleanup:** Background goroutine deletes notifications older than retention
|
**Cleanup:** Background goroutine deletes notifications older than retention
|
||||||
period (default 90 days, configurable via admin settings).
|
period (default 90 days, configurable via admin settings).
|
||||||
|
|
||||||
## @mention Routing + Multi-model (v0.20.0)
|
## @mention Routing (v0.23.0)
|
||||||
|
|
||||||
Channels support multiple AI models. The `channel_models` table (schema 001)
|
Type `@handle` or `@model-id` in any chat to route the completion to a
|
||||||
holds the roster; `mentions.Parse()` extracts @mentions from message content
|
different persona or model. No roster, group, or channel setup required.
|
||||||
and resolves against display names (case-insensitive, longest-match-first).
|
|
||||||
|
|
||||||
**Completion Fan-out:** When mentions resolve to channel models, the completion
|
**Resolution (`resolveMention`):** Extracts the first `@token` from message
|
||||||
handler fires sequentially for each target, producing one assistant message
|
content and queries the DB directly:
|
||||||
per model. Without @mention, the default channel model responds (backward
|
|
||||||
compatible). Frontend renders model attribution labels on multi-model responses.
|
|
||||||
|
|
||||||
**Autocomplete:** CM6 `mentionCompletion` extension triggers on `@` in the
|
1. Persona handle — exact match against `personas.handle` (case-insensitive)
|
||||||
chat input, showing a dropdown of channel model display names.
|
2. Persona handle — prefix match (unambiguous only)
|
||||||
|
3. Model ID — exact match against `model_catalog.model_id` (enabled + active provider)
|
||||||
|
4. Model ID — prefix match (unambiguous only)
|
||||||
|
|
||||||
|
Persona resolution returns the persona's model, provider config, and system
|
||||||
|
prompt. Model resolution returns the raw model ID and config — no persona
|
||||||
|
character, no system prompt override.
|
||||||
|
|
||||||
|
**Context Boundaries:** When @mentioning a persona, a system message is
|
||||||
|
injected before the user's message telling the target to ignore previous
|
||||||
|
personas' styles. When no @mention is used but persona responses exist in
|
||||||
|
history, a boundary tells the default model to respond in its own style.
|
||||||
|
|
||||||
|
**Participant Hint:** `buildParticipantHint()` injects a system message
|
||||||
|
listing all @mentionable personas (handle + name + description). This tells
|
||||||
|
the LLM who it can invoke, enabling AI-to-AI chaining.
|
||||||
|
|
||||||
|
**AI-to-AI Chaining:** After each completion, `chainIfMentioned()` runs
|
||||||
|
`resolveMention()` on the assistant's response. If it @mentions another
|
||||||
|
persona, a follow-up completion fires asynchronously. Result delivered via
|
||||||
|
WebSocket (`message.created`). Self-mention blocked. Depth capped at 5.
|
||||||
|
Same resolution function for user→LLM and LLM→LLM routing.
|
||||||
|
|
||||||
|
**Autocomplete (Frontend):** `ChannelModels.onInput()` triggers on `@` in
|
||||||
|
the chat input, matching against all `App.models` (enabled, non-hidden) by
|
||||||
|
persona handle, model ID, and display name. Popup shows avatar, name,
|
||||||
|
`@handle` hint, and provider. Selection inserts `@handle` into the input.
|
||||||
|
|
||||||
## Frontend Architecture
|
## Frontend Architecture
|
||||||
|
|
||||||
@@ -356,9 +403,11 @@ src/
|
|||||||
│ ├── api.js # HTTP client with token refresh
|
│ ├── api.js # HTTP client with token refresh
|
||||||
│ ├── app.js # Application state machine, startup, routing
|
│ ├── app.js # Application state machine, startup, routing
|
||||||
│ ├── chat.js # Chat input abstraction, message send/receive
|
│ ├── chat.js # Chat input abstraction, message send/receive
|
||||||
|
│ ├── channel-models.js # @mention autocomplete, channel model roster
|
||||||
│ ├── events.js # Labeled event bus + WebSocket bridge
|
│ ├── events.js # Labeled event bus + WebSocket bridge
|
||||||
│ ├── debug.js # Debug panel, diagnostics, state export
|
│ ├── debug.js # Debug panel, diagnostics, state export
|
||||||
│ ├── ui-core.js # DOM rendering, sidebar, modals, panels
|
│ ├── ui-core.js # DOM rendering, sidebar, modals, panels
|
||||||
|
│ ├── ui-format.js # Markdown rendering, @mention pills, code blocks
|
||||||
│ ├── ui-settings.js # Settings tabs, theme, appearance, teams
|
│ ├── ui-settings.js # Settings tabs, theme, appearance, teams
|
||||||
│ ├── ui-admin.js # Admin panel rendering
|
│ ├── ui-admin.js # Admin panel rendering
|
||||||
│ ├── admin-handlers.js # Admin CRUD operations + extension editors
|
│ ├── admin-handlers.js # Admin CRUD operations + extension editors
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ v0.22.7 Surface Prototypes (Theme + ChatPane + Splash) ✅
|
|||||||
|
|
|
|
||||||
v0.22.8 Surface Integration (Wire into existing JS)
|
v0.22.8 Surface Integration (Wire into existing JS)
|
||||||
|
|
|
|
||||||
v0.23.0 Multi-Participant Channels + Presence
|
v0.23.0 @mention Routing + Persona Handles + Proxy ✅
|
||||||
|
│
|
||||||
|
v0.23.1 Multi-User: DMs + Group Chat
|
||||||
│
|
│
|
||||||
v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions
|
v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions
|
||||||
│
|
│
|
||||||
@@ -1198,32 +1200,83 @@ Depends on: surface prototypes (v0.22.7).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.23.0 — Multi-Participant Channels + Presence
|
## ✅ v0.23.0 — @mention Routing + Persona Handles + Proxy
|
||||||
|
|
||||||
The channel foundation for workflows and real-time collaboration. Today
|
@mention routing works in any chat against the full model catalog and persona
|
||||||
channels are single-user direct chats. This release makes channels a
|
registry. No roster, groups, or participant setup required — just type `@handle`
|
||||||
shared space that multiple actors can inhabit.
|
or `@model-id` and send.
|
||||||
|
|
||||||
Depends on: extension surfaces (v0.21.3), WebSocket infrastructure (already exists).
|
**@mention Resolution**
|
||||||
|
- [x] `resolveMention()`: direct DB lookup — persona handle (exact/prefix) → model catalog (exact/prefix)
|
||||||
|
- [x] Autocomplete popup on `@` in any chat — all enabled models + personas
|
||||||
|
- [x] Handle field on personas: auto-generated from name, unique, editable
|
||||||
|
- [x] @mention pill rendering in message content (accent-colored, code-aware)
|
||||||
|
- [x] Context boundaries: persona→plain and persona→persona style isolation
|
||||||
|
- [x] Participant list injection: system prompt tells LLM who it can @mention
|
||||||
|
|
||||||
_(Shifted from v0.20.0 — no content changes)_
|
**AI-to-AI Chaining**
|
||||||
|
- [x] `chainIfMentioned()`: uses `resolveMention()` on assistant response content
|
||||||
|
- [x] Self-mention blocked, depth capped at 5
|
||||||
|
- [x] WebSocket delivery (`message.created` + typing indicators)
|
||||||
|
- [x] Same resolution path for user→LLM and LLM→LLM routing
|
||||||
|
|
||||||
**Channel Participants**
|
**Provider Proxy**
|
||||||
- [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at`
|
- [x] `proxy_mode` (system/direct/custom) + `proxy_url` on `provider_configs`
|
||||||
- [ ] Channel access queries rewritten: `WHERE user_id = $1` → participant-based lookup
|
- [x] All four providers use `cfg.Client()` with mode-aware HTTP transport
|
||||||
- [ ] Backward compatible: existing single-user channels auto-have one participant (owner)
|
- [x] Bad custom URL falls back to system (not direct) — safe for mandatory-proxy environments
|
||||||
- [ ] Channel `type` column: `direct` (current), `group` (multi-user), `workflow` (staged)
|
|
||||||
|
**Channel Infrastructure**
|
||||||
|
- [x] `channel_participants` table with polymorphic types and roles
|
||||||
|
- [x] `channel_models` partial indexes: persona entries keyed per-persona, raw models per-provider
|
||||||
|
- [x] Per-provider model preferences (composite keys in `user_model_settings`)
|
||||||
|
- [x] `persona_groups` + `persona_group_members` tables (schema ready, CRUD not yet wired)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.23.1 — Multi-User: DMs + Group Chat
|
||||||
|
|
||||||
|
Real human-to-human messaging. Builds on v0.23.0's participant infrastructure
|
||||||
|
(`channel_participants`, @mention routing, WebSocket events). The @mention
|
||||||
|
system already resolves personas — this release extends it to resolve users.
|
||||||
|
|
||||||
|
Depends on: @mention routing (v0.23.0), WebSocket infrastructure (exists).
|
||||||
|
|
||||||
|
**User-to-User DMs**
|
||||||
|
- [ ] `@username` resolution in `resolveMention()`: extend DB lookup to `users` table
|
||||||
|
- [ ] When `resolveMention()` returns a user (not persona): skip completion, deliver as notification
|
||||||
|
- [ ] DM channel type: two user participants, no AI unless explicitly @mentioned
|
||||||
|
- [ ] DM creation flow: user picker, creates channel with both users as participants
|
||||||
|
- [ ] Unread count per channel per user (read cursor in `channel_participants`)
|
||||||
|
- [ ] Real-time message delivery via WebSocket to recipient's active session
|
||||||
|
|
||||||
|
**Group Chat (Human + AI)**
|
||||||
|
- [ ] Persona group CRUD: create/edit/delete saved roster templates (`persona_groups`)
|
||||||
|
- [ ] "New Chat with Group" flow: pick a template, stamp onto fresh channel
|
||||||
|
- [ ] Group leader (default responder): `is_leader` flag on `persona_group_members`
|
||||||
|
- [ ] No @mention in group = leader responds. @mention overrides.
|
||||||
|
- [ ] Group editing: add/remove personas, change leader — affects future chats only
|
||||||
|
- [ ] `@all` routing: every persona participant responds (existing `multiModelStream` path)
|
||||||
|
|
||||||
**Presence + Real-time**
|
**Presence + Real-time**
|
||||||
- [ ] Typing indicators and presence (who's online, who's in this channel)
|
- [ ] Typing indicators for human participants (extend existing persona typing events)
|
||||||
- [ ] Participant join/leave events via WebSocket
|
- [ ] Online/offline status per user (heartbeat-based, stored in memory not DB)
|
||||||
- [ ] Co-editing for extension surfaces (editor mode, article mode)
|
- [ ] Participant join/leave WebSocket events
|
||||||
- [ ] Cursor sharing, selection highlighting
|
- [ ] Unread badge on sidebar chat items
|
||||||
|
|
||||||
**Channel-Scoped Notes**
|
**User @mention UX**
|
||||||
- [ ] Optional `channel_id` FK on notes (NULL = personal notebook, set = channel-attached)
|
- [ ] Autocomplete includes users alongside models and personas (different icon)
|
||||||
- [ ] Notes created by tool calls within a channel auto-attach to that channel
|
- [ ] @mention pills distinguish user mentions (different color) from persona/model mentions
|
||||||
- [ ] Channel notes visible to all participants (not just creator)
|
- [ ] Notification center: @mention notifications with channel deep-link
|
||||||
|
|
||||||
|
**Message Attribution**
|
||||||
|
- [ ] Messages from human users show their avatar + display name (not "You" for other participants)
|
||||||
|
- [ ] Messages from personas retain current avatar + handle rendering
|
||||||
|
- [ ] Thread view: flat chronological for now, threading deferred
|
||||||
|
|
||||||
|
**Migration**
|
||||||
|
- [ ] `users.handle` column (unique, auto-generated from username or display_name)
|
||||||
|
- [ ] Read cursor columns on `channel_participants`: `last_read_message_id`, `last_read_at`
|
||||||
|
- [ ] Notification table: `user_notifications` (type, channel_id, message_id, actor_id, read_at)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
552
handlers/channels.go
Normal file
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 "user_model_settings"
|
||||||
check_table "channels"
|
check_table "channels"
|
||||||
check_table "messages"
|
check_table "messages"
|
||||||
check_table "channel_members"
|
check_table "channel_participants"
|
||||||
check_table "channel_models"
|
check_table "channel_models"
|
||||||
check_table "channel_cursors"
|
check_table "channel_cursors"
|
||||||
check_table "folders"
|
check_table "folders"
|
||||||
@@ -226,6 +226,17 @@ check_column_type "provider_configs" "key_nonce" "bytea"
|
|||||||
check_column "provider_configs" "key_scope"
|
check_column "provider_configs" "key_scope"
|
||||||
check_column_absent "provider_configs" "api_key_plain"
|
check_column_absent "provider_configs" "api_key_plain"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Provider Proxy (v0.23.0):"
|
||||||
|
check_column "provider_configs" "proxy_mode"
|
||||||
|
check_column "provider_configs" "proxy_url"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Personas (v0.23.0):"
|
||||||
|
check_column "personas" "handle"
|
||||||
|
check_table "persona_groups"
|
||||||
|
check_table "persona_group_members"
|
||||||
|
|
||||||
# ── Model Catalog ────────────────────────
|
# ── Model Catalog ────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "Model Catalog:"
|
echo "Model Catalog:"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
|||||||
Capabilities: caps,
|
Capabilities: caps,
|
||||||
Pricing: entry.Pricing,
|
Pricing: entry.Pricing,
|
||||||
Scope: models.ScopeGlobal,
|
Scope: models.ScopeGlobal,
|
||||||
Hidden: hiddenMap[entry.ModelID],
|
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
|||||||
Pricing: entry.Pricing,
|
Pricing: entry.Pricing,
|
||||||
Scope: models.ScopeTeam,
|
Scope: models.ScopeTeam,
|
||||||
OwnerID: prov.OwnerID,
|
OwnerID: prov.OwnerID,
|
||||||
Hidden: hiddenMap[entry.ModelID],
|
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
||||||
})
|
})
|
||||||
countTeam++
|
countTeam++
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
|||||||
Pricing: entry.Pricing,
|
Pricing: entry.Pricing,
|
||||||
Scope: models.ScopePersonal,
|
Scope: models.ScopePersonal,
|
||||||
OwnerID: &userID,
|
OwnerID: &userID,
|
||||||
Hidden: hiddenMap[entry.ModelID],
|
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
||||||
})
|
})
|
||||||
countPersonal++
|
countPersonal++
|
||||||
}
|
}
|
||||||
@@ -211,6 +211,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
|||||||
Capabilities: caps,
|
Capabilities: caps,
|
||||||
IsPersona: true,
|
IsPersona: true,
|
||||||
PersonaID: p.ID,
|
PersonaID: p.ID,
|
||||||
|
PersonaHandle: p.Handle,
|
||||||
PersonaScope: p.Scope,
|
PersonaScope: p.Scope,
|
||||||
PersonaAvatar: p.Avatar,
|
PersonaAvatar: p.Avatar,
|
||||||
PersonaTeamName: teamName(p, stores, ctx),
|
PersonaTeamName: teamName(p, stores, ctx),
|
||||||
@@ -223,7 +224,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
|||||||
ToolGrants: toolGrants,
|
ToolGrants: toolGrants,
|
||||||
Scope: p.Scope,
|
Scope: p.Scope,
|
||||||
OwnerID: p.OwnerID,
|
OwnerID: p.OwnerID,
|
||||||
Hidden: hiddenMap[p.ID],
|
Hidden: false, // ICD §11.2: persona visibility via grants, not model prefs
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ CREATE TABLE IF NOT EXISTS provider_configs (
|
|||||||
settings JSONB DEFAULT '{}'::jsonb,
|
settings JSONB DEFAULT '{}'::jsonb,
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
is_private BOOLEAN DEFAULT false,
|
is_private BOOLEAN DEFAULT false,
|
||||||
|
proxy_mode TEXT NOT NULL DEFAULT 'system'
|
||||||
|
CHECK (proxy_mode IN ('system', 'direct', 'custom')),
|
||||||
|
proxy_url TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_proxy_url CHECK (proxy_mode != 'custom' OR proxy_url IS NOT NULL)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
||||||
@@ -49,6 +53,8 @@ COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team us
|
|||||||
COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)';
|
COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)';
|
||||||
COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
|
COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
|
||||||
COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)';
|
COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)';
|
||||||
|
COMMENT ON COLUMN provider_configs.proxy_mode IS 'system=env proxy, direct=no proxy, custom=explicit URL';
|
||||||
|
COMMENT ON COLUMN provider_configs.proxy_url IS 'Explicit proxy URL when proxy_mode=custom (http/https/socks5)';
|
||||||
|
|
||||||
|
|
||||||
-- =========================================
|
-- =========================================
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
CREATE TABLE IF NOT EXISTS personas (
|
CREATE TABLE IF NOT EXISTS personas (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
|
handle VARCHAR(50),
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
icon VARCHAR(10) DEFAULT '',
|
icon VARCHAR(10) DEFAULT '',
|
||||||
avatar TEXT DEFAULT '',
|
avatar TEXT DEFAULT '',
|
||||||
@@ -48,17 +49,54 @@ CREATE TABLE IF NOT EXISTS personas (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
|
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
|
||||||
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL;
|
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = true;
|
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = true;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_personas_handle ON personas(handle) WHERE handle IS NOT NULL;
|
||||||
DROP TRIGGER IF EXISTS personas_updated_at ON personas;
|
DROP TRIGGER IF EXISTS personas_updated_at ON personas;
|
||||||
CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas
|
CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
COMMENT ON COLUMN personas.scope IS 'global=all users, team=team members, personal=creator only';
|
COMMENT ON COLUMN personas.scope IS 'global=all users, team=team members, personal=creator only';
|
||||||
COMMENT ON COLUMN personas.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal';
|
COMMENT ON COLUMN personas.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal';
|
||||||
|
COMMENT ON COLUMN personas.handle IS 'Unique @mention handle, e.g. veronica-sharpe. Auto-generated from name.';
|
||||||
COMMENT ON COLUMN personas.is_shared IS 'Personal personas shared with others (read-only)';
|
COMMENT ON COLUMN personas.is_shared IS 'Personal personas shared with others (read-only)';
|
||||||
COMMENT ON COLUMN personas.memory_enabled IS 'Whether memory tools and extraction are active for this persona';
|
COMMENT ON COLUMN personas.memory_enabled IS 'Whether memory tools and extraction are active for this persona';
|
||||||
COMMENT ON COLUMN personas.memory_extraction_prompt IS 'Custom extraction prompt; NULL = use system default';
|
COMMENT ON COLUMN personas.memory_extraction_prompt IS 'Custom extraction prompt; NULL = use system default';
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- PERSONA GROUPS (v0.23.0)
|
||||||
|
-- =========================================
|
||||||
|
-- Saved roster templates. A group is a named set of personas
|
||||||
|
-- that can be stamped onto any new conversation.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS persona_groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scope VARCHAR(10) NOT NULL DEFAULT 'personal'
|
||||||
|
CHECK (scope IN ('global', 'team', 'personal')),
|
||||||
|
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persona_groups_owner ON persona_groups(owner_id);
|
||||||
|
DROP TRIGGER IF EXISTS persona_groups_updated_at ON persona_groups;
|
||||||
|
CREATE TRIGGER persona_groups_updated_at BEFORE UPDATE ON persona_groups
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS persona_group_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID NOT NULL REFERENCES persona_groups(id) ON DELETE CASCADE,
|
||||||
|
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||||
|
is_leader BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(group_id, persona_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persona_group_members_group ON persona_group_members(group_id);
|
||||||
|
|
||||||
|
|
||||||
-- =========================================
|
-- =========================================
|
||||||
-- PERSONA GRANTS (what a persona can do)
|
-- PERSONA GRANTS (what a persona can do)
|
||||||
-- =========================================
|
-- =========================================
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Chat Switchboard — 005 Channels & Conversations
|
-- Chat Switchboard — 005 Channels & Conversations
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Channels, messages, members, models, cursors, folders, user prefs.
|
-- Channels, messages, participants, models, cursors, folders, user prefs.
|
||||||
-- ICD §3 (Channels & Conversations)
|
-- ICD §3 (Channels & Conversations)
|
||||||
-- Note: project_id and workspace_id FKs added by 010_projects.sql
|
-- Note: project_id and workspace_id FKs added by 010_projects.sql
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
title VARCHAR(500) NOT NULL,
|
title VARCHAR(500) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
type VARCHAR(20) DEFAULT 'direct'
|
type VARCHAR(20) DEFAULT 'direct'
|
||||||
CHECK (type IN ('direct', 'group', 'channel')),
|
CHECK (type IN ('direct', 'group', 'workflow')),
|
||||||
model VARCHAR(100),
|
model VARCHAR(100),
|
||||||
system_prompt TEXT,
|
system_prompt TEXT,
|
||||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||||
@@ -73,7 +73,7 @@ DO $$ BEGIN
|
|||||||
END $$;
|
END $$;
|
||||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
||||||
|
|
||||||
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
|
COMMENT ON COLUMN channels.type IS 'direct=single-user chat, group=multi-user/multi-model, workflow=staged channel';
|
||||||
|
|
||||||
|
|
||||||
-- =========================================
|
-- =========================================
|
||||||
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
sibling_index INTEGER DEFAULT 0,
|
sibling_index INTEGER DEFAULT 0,
|
||||||
participant_type VARCHAR(10) DEFAULT 'user',
|
participant_type VARCHAR(10) DEFAULT 'user',
|
||||||
participant_id VARCHAR(255),
|
participant_id VARCHAR(255),
|
||||||
|
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -110,35 +111,53 @@ COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)
|
|||||||
|
|
||||||
|
|
||||||
-- =========================================
|
-- =========================================
|
||||||
-- CHANNEL MEMBERS & MODELS
|
-- CHANNEL PARTICIPANTS & MODELS
|
||||||
|
-- =========================================
|
||||||
|
-- ICD §3.7: Polymorphic participant system.
|
||||||
|
-- participant_type determines what participant_id references:
|
||||||
|
-- user → users.id
|
||||||
|
-- persona → personas.id
|
||||||
|
-- session → opaque session token
|
||||||
-- =========================================
|
-- =========================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_members (
|
CREATE TABLE IF NOT EXISTS channel_participants (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
participant_type VARCHAR(10) NOT NULL DEFAULT 'user'
|
||||||
role VARCHAR(20) DEFAULT 'member',
|
CHECK (participant_type IN ('user', 'persona', 'session')),
|
||||||
|
participant_id UUID NOT NULL,
|
||||||
|
role VARCHAR(10) NOT NULL DEFAULT 'member'
|
||||||
|
CHECK (role IN ('owner', 'member', 'observer')),
|
||||||
|
display_name VARCHAR(200),
|
||||||
|
avatar_url TEXT,
|
||||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(channel_id, user_id)
|
UNIQUE(channel_id, participant_type, participant_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_models (
|
CREATE TABLE IF NOT EXISTS channel_models (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
model_id VARCHAR(255) NOT NULL,
|
model_id VARCHAR(255) NOT NULL,
|
||||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||||
|
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
||||||
display_name VARCHAR(100),
|
display_name VARCHAR(100),
|
||||||
system_prompt TEXT,
|
system_prompt TEXT,
|
||||||
settings JSONB DEFAULT '{}'::jsonb,
|
settings JSONB DEFAULT '{}'::jsonb,
|
||||||
is_default BOOLEAN DEFAULT false,
|
is_default BOOLEAN DEFAULT false,
|
||||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
added_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
UNIQUE(channel_id, model_id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Raw model entries (no persona): unique per channel+model+provider
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_raw
|
||||||
|
ON channel_models(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL;
|
||||||
|
-- Persona entries: one per persona per channel (same model allowed for different personas)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_persona
|
||||||
|
ON channel_models(channel_id, persona_id) WHERE persona_id IS NOT NULL;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||||
|
|
||||||
|
|
||||||
@@ -167,13 +186,14 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
model_id TEXT NOT NULL,
|
model_id TEXT NOT NULL,
|
||||||
|
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||||
hidden BOOLEAN DEFAULT false,
|
hidden BOOLEAN DEFAULT false,
|
||||||
preferred_temperature FLOAT,
|
preferred_temperature FLOAT,
|
||||||
preferred_max_tokens INT,
|
preferred_max_tokens INT,
|
||||||
sort_order INT DEFAULT 0,
|
sort_order INT DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, model_id)
|
UNIQUE(user_id, model_id, provider_config_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ CREATE TABLE IF NOT EXISTS provider_configs (
|
|||||||
settings TEXT DEFAULT '{}',
|
settings TEXT DEFAULT '{}',
|
||||||
is_active INTEGER DEFAULT 1,
|
is_active INTEGER DEFAULT 1,
|
||||||
is_private INTEGER DEFAULT 0,
|
is_private INTEGER DEFAULT 0,
|
||||||
|
proxy_mode TEXT NOT NULL DEFAULT 'system' CHECK (proxy_mode IN ('system', 'direct', 'custom')),
|
||||||
|
proxy_url TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
CHECK (proxy_mode != 'custom' OR proxy_url IS NOT NULL)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
CREATE TABLE IF NOT EXISTS personas (
|
CREATE TABLE IF NOT EXISTS personas (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
handle TEXT,
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
icon TEXT DEFAULT '',
|
icon TEXT DEFAULT '',
|
||||||
avatar TEXT DEFAULT '',
|
avatar TEXT DEFAULT '',
|
||||||
@@ -27,6 +28,31 @@ CREATE TABLE IF NOT EXISTS personas (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
|
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
|
||||||
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id);
|
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active);
|
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_personas_handle ON personas(handle) WHERE handle IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS persona_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'personal' CHECK (scope IN ('global', 'team', 'personal')),
|
||||||
|
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persona_groups_owner ON persona_groups(owner_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS persona_group_members (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
group_id TEXT NOT NULL REFERENCES persona_groups(id) ON DELETE CASCADE,
|
||||||
|
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||||
|
is_leader INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(group_id, persona_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persona_group_members_group ON persona_group_members(group_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS persona_grants (
|
CREATE TABLE IF NOT EXISTS persona_grants (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'channel')),
|
type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'workflow')),
|
||||||
model TEXT,
|
model TEXT,
|
||||||
system_prompt TEXT,
|
system_prompt TEXT,
|
||||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||||
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
sibling_index INTEGER DEFAULT 0,
|
sibling_index INTEGER DEFAULT 0,
|
||||||
participant_type TEXT DEFAULT 'user',
|
participant_type TEXT DEFAULT 'user',
|
||||||
participant_id TEXT,
|
participant_id TEXT,
|
||||||
|
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
@@ -64,32 +65,44 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_members (
|
CREATE TABLE IF NOT EXISTS channel_participants (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
participant_type TEXT NOT NULL DEFAULT 'user'
|
||||||
role TEXT DEFAULT 'member',
|
CHECK (participant_type IN ('user', 'persona', 'session')),
|
||||||
|
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')),
|
joined_at TEXT DEFAULT (datetime('now')),
|
||||||
last_read_at TEXT DEFAULT (datetime('now')),
|
last_read_at TEXT DEFAULT (datetime('now')),
|
||||||
UNIQUE(channel_id, user_id)
|
UNIQUE(channel_id, participant_type, participant_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_models (
|
CREATE TABLE IF NOT EXISTS channel_models (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
model_id TEXT NOT NULL,
|
model_id TEXT NOT NULL,
|
||||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||||
|
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
system_prompt TEXT,
|
system_prompt TEXT,
|
||||||
settings TEXT DEFAULT '{}',
|
settings TEXT DEFAULT '{}',
|
||||||
is_default INTEGER DEFAULT 0,
|
is_default INTEGER DEFAULT 0,
|
||||||
added_at TEXT DEFAULT (datetime('now')),
|
added_at TEXT DEFAULT (datetime('now'))
|
||||||
UNIQUE(channel_id, model_id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Raw model entries (no persona): unique per channel+model+provider
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_raw
|
||||||
|
ON channel_models(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL;
|
||||||
|
-- Persona entries: one per persona per channel
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_persona
|
||||||
|
ON channel_models(channel_id, persona_id) WHERE persona_id IS NOT NULL;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||||
@@ -108,13 +121,14 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
model_id TEXT NOT NULL,
|
model_id TEXT NOT NULL,
|
||||||
|
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||||
hidden INTEGER DEFAULT 0,
|
hidden INTEGER DEFAULT 0,
|
||||||
preferred_temperature REAL,
|
preferred_temperature REAL,
|
||||||
preferred_max_tokens INTEGER,
|
preferred_max_tokens INTEGER,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
UNIQUE(user_id, model_id)
|
UNIQUE(user_id, model_id, provider_config_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ func TruncateAll(t *testing.T) {
|
|||||||
"channel_cursors",
|
"channel_cursors",
|
||||||
"messages",
|
"messages",
|
||||||
"channel_models",
|
"channel_models",
|
||||||
"channel_members",
|
"channel_participants",
|
||||||
"channel_knowledge_bases",
|
"channel_knowledge_bases",
|
||||||
"persona_knowledge_bases",
|
"persona_knowledge_bases",
|
||||||
"project_notes",
|
"project_notes",
|
||||||
@@ -278,6 +278,8 @@ func TruncateAll(t *testing.T) {
|
|||||||
"user_model_settings",
|
"user_model_settings",
|
||||||
"model_catalog",
|
"model_catalog",
|
||||||
"persona_grants",
|
"persona_grants",
|
||||||
|
"persona_group_members",
|
||||||
|
"persona_groups",
|
||||||
"personas",
|
"personas",
|
||||||
"provider_configs",
|
"provider_configs",
|
||||||
"team_members",
|
"team_members",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
|
|
||||||
type createChannelRequest struct {
|
type createChannelRequest struct {
|
||||||
Title string `json:"title" binding:"required,max=500"`
|
Title string `json:"title" binding:"required,max=500"`
|
||||||
Type string `json:"type,omitempty"` // direct (default), group, channel
|
Type string `json:"type,omitempty"` // direct (default), group, workflow
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||||
@@ -334,17 +334,17 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
ch.Tags = tags
|
ch.Tags = tags
|
||||||
ch.MessageCount = 0
|
ch.MessageCount = 0
|
||||||
|
|
||||||
// Auto-create channel_member for the creator
|
// Auto-create channel_participant for the creator
|
||||||
if database.IsSQLite() {
|
if database.IsSQLite() {
|
||||||
_, _ = database.DB.Exec(`
|
_, _ = database.DB.Exec(`
|
||||||
INSERT INTO channel_members (id, channel_id, user_id, role)
|
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
|
||||||
VALUES (?, ?, ?, 'owner')
|
VALUES (?, ?, 'user', ?, 'owner')
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`, store.NewID(), ch.ID, userID)
|
`, store.NewID(), ch.ID, userID)
|
||||||
} else {
|
} else {
|
||||||
_, _ = database.DB.Exec(`
|
_, _ = database.DB.Exec(`
|
||||||
INSERT INTO channel_members (channel_id, user_id, role)
|
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
|
||||||
VALUES ($1, $2, 'owner')
|
VALUES ($1, 'user', $2, 'owner')
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`, ch.ID, userID)
|
`, ch.ID, userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/health"
|
"git.gobha.me/xcaliber/chat-switchboard/health"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/mentions"
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/routing"
|
"git.gobha.me/xcaliber/chat-switchboard/routing"
|
||||||
@@ -201,10 +200,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
|
|
||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
|
|
||||||
// Verify channel ownership
|
// Verify participation: check channel_participants first, fall back to
|
||||||
|
// 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) {
|
if !userOwnsChannel(c, channelID, userID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
|
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
|
||||||
var personaSystemPrompt string
|
var personaSystemPrompt string
|
||||||
@@ -332,7 +335,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load conversation history
|
// Load conversation history
|
||||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID)
|
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID, model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||||
return
|
return
|
||||||
@@ -365,7 +368,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
messages = append(messages, userMsg)
|
messages = append(messages, userMsg)
|
||||||
|
|
||||||
// Persist user message (text-only for storage — multimodal parts are ephemeral)
|
// Persist user message (text-only for storage — multimodal parts are ephemeral)
|
||||||
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil)
|
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to persist user message: %v", err)
|
log.Printf("Failed to persist user message: %v", err)
|
||||||
}
|
}
|
||||||
@@ -383,17 +386,65 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
// Resolve effective workspace: channel.workspace_id > project.workspace_id
|
// Resolve effective workspace: channel.workspace_id > project.workspace_id
|
||||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||||
|
|
||||||
// ── Multi-model @mention routing (v0.20.0) ──────────
|
// ── @mention routing (v0.23.0) ──────────────
|
||||||
// Check if the message @mentions specific channel models.
|
// Resolve @model-id or @persona-handle directly against the enabled
|
||||||
// If multiple models are targeted, fan out sequentially.
|
// model catalog and personas table. Works in ANY chat — no roster needed.
|
||||||
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" {
|
||||||
if len(roster) > 1 {
|
req.Model = mentionModel
|
||||||
parsed := mentions.Parse(req.Content, roster)
|
if mentionConfig != "" {
|
||||||
targets := mentions.ResolvedModels(parsed)
|
req.ProviderConfigID = mentionConfig
|
||||||
if len(targets) > 1 {
|
}
|
||||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req)
|
if mentionPersona != nil {
|
||||||
|
personaID = mentionPersona.ID
|
||||||
|
personaThinkingBudget = mentionPersona.ThinkingBudget
|
||||||
|
if mentionPersona.SystemPrompt != "" {
|
||||||
|
if len(messages) > 0 && messages[0].Role == "system" {
|
||||||
|
messages[0].Content = mentionPersona.SystemPrompt
|
||||||
|
} else {
|
||||||
|
messages = append([]providers.Message{{Role: "system", Content: mentionPersona.SystemPrompt}}, messages...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Context boundary: tell this persona to ignore other personas' styles
|
||||||
|
boundary := fmt.Sprintf(
|
||||||
|
"[You are now %s. Previous assistant messages may be from different AI personas with different styles — ignore their tone, character, and mannerisms. Respond only as yourself per your system prompt.]",
|
||||||
|
mentionPersona.Name,
|
||||||
|
)
|
||||||
|
if len(messages) >= 2 {
|
||||||
|
last := messages[len(messages)-1]
|
||||||
|
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
|
||||||
|
messages = append(messages, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-resolve provider config for the @mentioned target
|
||||||
|
providerCfg, providerID, model, configID, providerScope, err = h.resolveConfig(userID, channelID, req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
caps = h.getModelCapabilities(c, model, configID)
|
||||||
|
if personaThinkingBudget != nil && *personaThinkingBudget > 0 {
|
||||||
|
if providerCfg.Settings == nil {
|
||||||
|
providerCfg.Settings = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
providerCfg.Settings["extended_thinking"] = true
|
||||||
|
providerCfg.Settings["thinking_budget"] = *personaThinkingBudget
|
||||||
|
}
|
||||||
|
provider, err = providers.Get(providerID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No @mention: inject a boundary if conversation has persona responses
|
||||||
|
// to prevent the default model from mimicking persona styles.
|
||||||
|
if h.conversationHasPersonaMessages(c.Request.Context(), channelID) {
|
||||||
|
boundary := "[Previous assistant messages in this conversation may include responses from AI personas with specific characters and styles. You are the default assistant — respond in your own natural style, not theirs.]"
|
||||||
|
if len(messages) >= 2 {
|
||||||
|
last := messages[len(messages)-1]
|
||||||
|
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
|
||||||
|
messages = append(messages, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build provider request
|
// Build provider request
|
||||||
@@ -540,7 +591,11 @@ func (h *CompletionHandler) multiModelStream(
|
|||||||
|
|
||||||
// Persist assistant message with model attribution
|
// Persist assistant message with model attribution
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
|
pID := ""
|
||||||
|
if target.PersonaID != nil {
|
||||||
|
pID = *target.PersonaID
|
||||||
|
}
|
||||||
|
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, pID); err != nil {
|
||||||
log.Printf("Failed to persist multi-model assistant message: %v", err)
|
log.Printf("Failed to persist multi-model assistant message: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,9 +771,20 @@ func (h *CompletionHandler) streamCompletion(
|
|||||||
|
|
||||||
// Persist assistant response
|
// Persist assistant response
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
|
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
|
||||||
log.Printf("Failed to persist assistant message: %v", err)
|
log.Printf("Failed to persist assistant message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI-to-AI chaining: if the response @mentions another persona participant,
|
||||||
|
// trigger a follow-up completion asynchronously via WebSocket delivery.
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[chain] PANIC recovered: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
h.chainIfMentioned(channelID, userID, personaID, result.Content, 0)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log usage
|
// Log usage
|
||||||
@@ -765,10 +831,20 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
finalContent = resp.Content
|
finalContent = resp.Content
|
||||||
|
|
||||||
// Persist assistant response
|
// Persist assistant response
|
||||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity)); err != nil {
|
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity), configID, personaID); err != nil {
|
||||||
log.Printf("Failed to persist assistant message: %v", err)
|
log.Printf("Failed to persist assistant message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI-to-AI chaining
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[chain] PANIC recovered: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
|
||||||
|
}()
|
||||||
|
|
||||||
// Log usage
|
// Log usage
|
||||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||||
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
|
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
|
||||||
@@ -992,6 +1068,199 @@ func isImageContentType(ct string) bool {
|
|||||||
return strings.HasPrefix(ct, "image/")
|
return strings.HasPrefix(ct, "image/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── @mention Resolution (v0.23.0) ───────────
|
||||||
|
|
||||||
|
// conversationHasPersonaMessages checks if any assistant message in the
|
||||||
|
// channel was generated by a persona (participant_type = 'persona').
|
||||||
|
// Used to decide whether to inject a context boundary for the default model.
|
||||||
|
func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context, channelID string) bool {
|
||||||
|
var id string
|
||||||
|
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
|
SELECT id FROM messages
|
||||||
|
WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona'
|
||||||
|
LIMIT 1
|
||||||
|
`), channelID).Scan(&id)
|
||||||
|
return err == nil && id != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildParticipantHint builds a system message listing available @mentionable
|
||||||
|
// personas so the LLM knows who it can direct responses to.
|
||||||
|
// Only includes personas accessible to this user. Excludes the current persona.
|
||||||
|
func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPersonaID string) string {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
personas, err := h.stores.Personas.ListForUser(ctx, userID)
|
||||||
|
if err != nil || len(personas) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for _, p := range personas {
|
||||||
|
if !p.IsActive || p.Handle == "" || p.ID == currentPersonaID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
desc := p.Description
|
||||||
|
if len(desc) > 80 {
|
||||||
|
desc = desc[:80] + "..."
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("- @%s (%s) — %s", p.Handle, p.Name, desc))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, fmt.Sprintf("- @%s (%s)", p.Handle, p.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
hint := "Available conversation participants you can @mention to direct a response:\n"
|
||||||
|
for _, l := range lines {
|
||||||
|
hint += l + "\n"
|
||||||
|
}
|
||||||
|
hint += "To ask another participant to respond, include their @handle in your message."
|
||||||
|
return hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves @tokens in message content directly against the model catalog
|
||||||
|
// and personas table. Works in any chat — no channel roster needed.
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
// 1. Persona handle (exact match, then prefix)
|
||||||
|
// 2. Model ID in enabled catalog (exact match, then prefix)
|
||||||
|
//
|
||||||
|
// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found.
|
||||||
|
|
||||||
|
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) {
|
||||||
|
// Extract first @token
|
||||||
|
token := extractFirstMention(content)
|
||||||
|
if token == "" {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize: lowercase, hyphens
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(token))
|
||||||
|
|
||||||
|
// 1. Try persona handle (exact)
|
||||||
|
var personaID, personaHandle string
|
||||||
|
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
|
SELECT id, handle FROM personas
|
||||||
|
WHERE LOWER(handle) = $1 AND is_active = true
|
||||||
|
`), normalized).Scan(&personaID, &personaHandle)
|
||||||
|
if err == nil && personaID != "" {
|
||||||
|
p := ResolvePersona(h.stores, personaID, userID)
|
||||||
|
if p != nil {
|
||||||
|
cfgID := ""
|
||||||
|
if p.ProviderConfigID != nil {
|
||||||
|
cfgID = *p.ProviderConfigID
|
||||||
|
}
|
||||||
|
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID)
|
||||||
|
return p.BaseModelID, cfgID, p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try persona handle (prefix — unambiguous only)
|
||||||
|
if personaID == "" {
|
||||||
|
var count int
|
||||||
|
database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
|
SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
|
||||||
|
`), normalized+"%").Scan(&count)
|
||||||
|
if count == 1 {
|
||||||
|
database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
|
SELECT id FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
|
||||||
|
`), normalized+"%").Scan(&personaID)
|
||||||
|
if personaID != "" {
|
||||||
|
p := ResolvePersona(h.stores, personaID, userID)
|
||||||
|
if p != nil {
|
||||||
|
cfgID := ""
|
||||||
|
if p.ProviderConfigID != nil {
|
||||||
|
cfgID = *p.ProviderConfigID
|
||||||
|
}
|
||||||
|
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID)
|
||||||
|
return p.BaseModelID, cfgID, p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try model_id in enabled catalog (exact)
|
||||||
|
var modelID, provConfigID string
|
||||||
|
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
|
SELECT mc.model_id, mc.provider_config_id
|
||||||
|
FROM model_catalog mc
|
||||||
|
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||||
|
WHERE LOWER(mc.model_id) = $1
|
||||||
|
AND mc.visibility = 'enabled'
|
||||||
|
AND pc.is_active = true
|
||||||
|
ORDER BY
|
||||||
|
CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
||||||
|
LIMIT 1
|
||||||
|
`), normalized).Scan(&modelID, &provConfigID)
|
||||||
|
if err == nil && modelID != "" {
|
||||||
|
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID)
|
||||||
|
return modelID, provConfigID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try model_id prefix (unambiguous)
|
||||||
|
var prefixCount int
|
||||||
|
database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
|
SELECT COUNT(DISTINCT mc.model_id)
|
||||||
|
FROM model_catalog mc
|
||||||
|
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||||
|
WHERE LOWER(mc.model_id) LIKE $1
|
||||||
|
AND mc.visibility = 'enabled'
|
||||||
|
AND pc.is_active = true
|
||||||
|
`), normalized+"%").Scan(&prefixCount)
|
||||||
|
if prefixCount == 1 {
|
||||||
|
database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
|
SELECT mc.model_id, mc.provider_config_id
|
||||||
|
FROM model_catalog mc
|
||||||
|
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||||
|
WHERE LOWER(mc.model_id) LIKE $1
|
||||||
|
AND mc.visibility = 'enabled'
|
||||||
|
AND pc.is_active = true
|
||||||
|
ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
||||||
|
LIMIT 1
|
||||||
|
`), normalized+"%").Scan(&modelID, &provConfigID)
|
||||||
|
if modelID != "" {
|
||||||
|
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID)
|
||||||
|
return modelID, provConfigID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFirstMention finds the first @token in content.
|
||||||
|
// Returns the token without the @ prefix, or empty string.
|
||||||
|
func extractFirstMention(content string) string {
|
||||||
|
for i := 0; i < len(content); i++ {
|
||||||
|
if content[i] != '@' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// @ must be at start or preceded by whitespace
|
||||||
|
if i > 0 && content[i-1] != ' ' && content[i-1] != '\n' && content[i-1] != '\t' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Extract token
|
||||||
|
start := i + 1
|
||||||
|
end := start
|
||||||
|
for end < len(content) && content[end] != ' ' && content[end] != '\n' && content[end] != '\t' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if end > start {
|
||||||
|
// Strip trailing punctuation
|
||||||
|
for end > start && (content[end-1] == ',' || content[end-1] == '.' || content[end-1] == '!' || content[end-1] == '?' || content[end-1] == ':' || content[end-1] == ';') {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
if end > start {
|
||||||
|
return content[start:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ── Config Resolution ───────────────────────
|
// ── Config Resolution ───────────────────────
|
||||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||||
|
|
||||||
@@ -1037,6 +1306,8 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
|||||||
var apiKeyEnc, keyNonce []byte
|
var apiKeyEnc, keyNonce []byte
|
||||||
var keyScope string
|
var keyScope string
|
||||||
var customHeadersJSON, providerSettingsJSON []byte
|
var customHeadersJSON, providerSettingsJSON []byte
|
||||||
|
var proxyMode string
|
||||||
|
var proxyURL *string
|
||||||
// $2/userID appears twice in the query; Postgres reuses positional params,
|
// $2/userID appears twice in the query; Postgres reuses positional params,
|
||||||
// SQLite needs each ? bound separately.
|
// SQLite needs each ? bound separately.
|
||||||
configArgs := []interface{}{configID, userID}
|
configArgs := []interface{}{configID, userID}
|
||||||
@@ -1045,14 +1316,14 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
|||||||
}
|
}
|
||||||
err := database.DB.QueryRow(database.Q(`
|
err := database.DB.QueryRow(database.Q(`
|
||||||
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
|
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
|
||||||
model_default, headers, settings
|
model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
|
||||||
FROM provider_configs
|
FROM provider_configs
|
||||||
WHERE id = $1 AND is_active = true
|
WHERE id = $1 AND is_active = true
|
||||||
AND (scope = 'global'
|
AND (scope = 'global'
|
||||||
OR (scope = 'personal' AND owner_id = $2)
|
OR (scope = 'personal' AND owner_id = $2)
|
||||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
||||||
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
||||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
|
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
|
||||||
@@ -1100,11 +1371,18 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
|||||||
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
|
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyURLStr := ""
|
||||||
|
if proxyURL != nil {
|
||||||
|
proxyURLStr = *proxyURL
|
||||||
|
}
|
||||||
|
|
||||||
return providers.ProviderConfig{
|
return providers.ProviderConfig{
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
APIKey: key,
|
APIKey: key,
|
||||||
CustomHeaders: customHeaders,
|
CustomHeaders: customHeaders,
|
||||||
Settings: providerSettings,
|
Settings: providerSettings,
|
||||||
|
ProxyMode: proxyMode,
|
||||||
|
ProxyURL: proxyURLStr,
|
||||||
}, providerID, model, configID, providerScope, nil
|
}, providerID, model, configID, providerScope, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,7 +1394,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
|||||||
//
|
//
|
||||||
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
||||||
// messages before it are replaced by the summary content as a system message.
|
// messages before it are replaced by the summary content as a system message.
|
||||||
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID string) ([]providers.Message, error) {
|
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID, currentModel string) ([]providers.Message, error) {
|
||||||
messages := make([]providers.Message, 0)
|
messages := make([]providers.Message, 0)
|
||||||
|
|
||||||
// ── Admin system prompt (always injected first, no opt out) ──
|
// ── Admin system prompt (always injected first, no opt out) ──
|
||||||
@@ -1183,6 +1461,16 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Participant roster (v0.23.0) ──
|
||||||
|
// Inject the list of available @mentionable personas so the LLM
|
||||||
|
// knows who it can direct responses to. This enables LLM→LLM chaining.
|
||||||
|
if participantHint := h.buildParticipantHint(channelID, userID, personaID); participantHint != "" {
|
||||||
|
messages = append(messages, providers.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: participantHint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Walk the active path (root → leaf) instead of loading all messages
|
// Walk the active path (root → leaf) instead of loading all messages
|
||||||
path, err := getActivePath(channelID, userID)
|
path, err := getActivePath(channelID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1207,6 +1495,29 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
|||||||
startIdx = summaryIdx + 1
|
startIdx = summaryIdx + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Build persona name cache for history rewrite (v0.23.0) ──
|
||||||
|
// Collect unique persona IDs from the path so we can attribute
|
||||||
|
// foreign persona messages by name instead of raw UUID.
|
||||||
|
personaNameCache := make(map[string]string)
|
||||||
|
hasForeignPersona := false
|
||||||
|
{
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, m := range path {
|
||||||
|
if m.ParticipantType == "persona" && m.ParticipantID != "" && m.ParticipantID != personaID {
|
||||||
|
if !seen[m.ParticipantID] {
|
||||||
|
seen[m.ParticipantID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pid := range seen {
|
||||||
|
var name string
|
||||||
|
database.DB.QueryRow(database.Q(`SELECT name FROM personas WHERE id = $1`), pid).Scan(&name)
|
||||||
|
if name != "" {
|
||||||
|
personaNameCache[pid] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, m := range path[startIdx:] {
|
for _, m := range path[startIdx:] {
|
||||||
if m.Role == "system" {
|
if m.Role == "system" {
|
||||||
continue // system prompts handled above
|
continue // system prompts handled above
|
||||||
@@ -1215,10 +1526,59 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
|||||||
if isSummaryMessage(&m) {
|
if isSummaryMessage(&m) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
messages = append(messages, providers.Message{
|
|
||||||
|
// ── Foreign speaker attribution (v0.23.0) ──
|
||||||
|
// ALL assistant messages get a Name field identifying who generated them.
|
||||||
|
// Providers that support "name" (OpenAI, OpenRouter, Venice) use it
|
||||||
|
// to distinguish speakers. Anthropic rewrites named foreign messages
|
||||||
|
// to user role with attribution.
|
||||||
|
msg := providers.Message{
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: m.Content,
|
Content: m.Content,
|
||||||
})
|
}
|
||||||
|
if m.Role == "assistant" {
|
||||||
|
isForeign := false
|
||||||
|
|
||||||
|
if m.ParticipantType == "persona" && m.ParticipantID != "" {
|
||||||
|
if m.ParticipantID != personaID {
|
||||||
|
// Foreign persona — tag with name
|
||||||
|
isForeign = true
|
||||||
|
name := personaNameCache[m.ParticipantID]
|
||||||
|
if name != "" {
|
||||||
|
msg.Name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if m.Model != nil && *m.Model != "" {
|
||||||
|
// Raw model — foreign if model differs or we're a persona
|
||||||
|
if *m.Model != currentModel || personaID != "" {
|
||||||
|
isForeign = true
|
||||||
|
msg.Name = *m.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isForeign {
|
||||||
|
hasForeignPersona = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If foreign persona messages exist, add a system hint explaining
|
||||||
|
// the multi-participant context (helps all providers)
|
||||||
|
if hasForeignPersona {
|
||||||
|
hint := "This is a multi-participant AI conversation. Messages from other AI participants are marked with their name. You are one of several models — respond only as yourself, ignoring other participants' styles and characters."
|
||||||
|
// Insert after the last system message, before conversation history
|
||||||
|
insertIdx := 0
|
||||||
|
for i, m := range messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
insertIdx = i + 1
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages = append(messages[:insertIdx+1], messages[insertIdx:]...)
|
||||||
|
messages[insertIdx] = providers.Message{Role: "system", Content: hint}
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages, nil
|
return messages, nil
|
||||||
@@ -1242,7 +1602,7 @@ func toolActivityJSON(activity []map[string]interface{}) json.RawMessage {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage) (string, error) {
|
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage, providerConfigID string, personaID string) (string, error) {
|
||||||
var tokensUsed *int
|
var tokensUsed *int
|
||||||
if inputTokens > 0 || outputTokens > 0 {
|
if inputTokens > 0 || outputTokens > 0 {
|
||||||
total := inputTokens + outputTokens
|
total := inputTokens + outputTokens
|
||||||
@@ -1260,13 +1620,24 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
|||||||
// Compute sibling_index
|
// Compute sibling_index
|
||||||
siblingIdx := nextSiblingIndex(channelID, parentID)
|
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||||
|
|
||||||
// Determine participant
|
// Determine participant: persona for assistant messages with personaID, otherwise user/model
|
||||||
participantType := "user"
|
participantType := "user"
|
||||||
participantID := userID
|
participantID := userID
|
||||||
if role == "assistant" {
|
if role == "assistant" {
|
||||||
|
if personaID != "" {
|
||||||
|
participantType = "persona"
|
||||||
|
participantID = personaID
|
||||||
|
} else {
|
||||||
participantType = "model"
|
participantType = "model"
|
||||||
participantID = model
|
participantID = model
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// provider_config_id: nil if empty
|
||||||
|
var provCfgVal interface{}
|
||||||
|
if providerConfigID != "" {
|
||||||
|
provCfgVal = providerConfigID
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare tool_calls JSONB (nil → NULL)
|
// Prepare tool_calls JSONB (nil → NULL)
|
||||||
var tcVal interface{}
|
var tcVal interface{}
|
||||||
@@ -1280,21 +1651,21 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
|||||||
newID = store.NewID()
|
newID = store.NewID()
|
||||||
_, err := database.DB.Exec(`
|
_, err := database.DB.Exec(`
|
||||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
||||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
|
||||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, newID, channelID, role, content, model, tokensUsed,
|
`, newID, channelID, role, content, model, tokensUsed,
|
||||||
tcVal, parentID, participantType, participantID, siblingIdx)
|
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, channelID, role, content, model, tokensUsed,
|
`, channelID, role, content, model, tokensUsed,
|
||||||
tcVal, parentID, participantType, participantID, siblingIdx,
|
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx,
|
||||||
).Scan(&newID)
|
).Scan(&newID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
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 {
|
var req struct {
|
||||||
ModelID string `json:"model_id" binding:"required"`
|
ModelID string `json:"model_id" binding:"required"`
|
||||||
|
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||||
Hidden *bool `json:"hidden,omitempty"`
|
Hidden *bool `json:"hidden,omitempty"`
|
||||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
||||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
||||||
@@ -48,13 +49,14 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
patch := models.UserModelSettingPatch{
|
patch := models.UserModelSettingPatch{
|
||||||
|
ProviderConfigID: req.ProviderConfigID,
|
||||||
Hidden: req.Hidden,
|
Hidden: req.Hidden,
|
||||||
PreferredTemperature: req.PreferredTemperature,
|
PreferredTemperature: req.PreferredTemperature,
|
||||||
PreferredMaxTokens: req.PreferredMaxTokens,
|
PreferredMaxTokens: req.PreferredMaxTokens,
|
||||||
SortOrder: req.SortOrder,
|
SortOrder: req.SortOrder,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, patch); err != nil {
|
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, req.ProviderConfigID, patch); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -62,12 +64,12 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
|
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BulkSetPreferences sets hidden state for multiple models at once.
|
// BulkSetPreferences sets hidden state for multiple model+provider pairs at once.
|
||||||
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
|
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
|
||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ModelIDs []string `json:"model_ids" binding:"required"`
|
Entries []models.HiddenEntry `json:"entries" binding:"required"`
|
||||||
Hidden bool `json:"hidden"`
|
Hidden bool `json:"hidden"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -75,10 +77,10 @@ func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.ModelIDs, req.Hidden); err != nil {
|
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.Entries, req.Hidden); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.ModelIDs)})
|
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.Entries)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,15 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
|
|||||||
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
|
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyURL := ""
|
||||||
|
if cfg.ProxyURL != nil {
|
||||||
|
proxyURL = *cfg.ProxyURL
|
||||||
|
}
|
||||||
provCfg := providers.ProviderConfig{
|
provCfg := providers.ProviderConfig{
|
||||||
Endpoint: cfg.Endpoint,
|
Endpoint: cfg.Endpoint,
|
||||||
APIKey: apiKeyPlain,
|
APIKey: apiKeyPlain,
|
||||||
|
ProxyMode: cfg.ProxyMode,
|
||||||
|
ProxyURL: proxyURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Headers != nil {
|
if cfg.Headers != nil {
|
||||||
|
|||||||
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 {
|
type personaRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
|
Handle string `json:"handle,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
BaseModelID string `json:"base_model_id,omitempty"`
|
BaseModelID string `json:"base_model_id,omitempty"`
|
||||||
@@ -238,6 +239,7 @@ type personaRequest struct {
|
|||||||
func (r *personaRequest) toPersona() *models.Persona {
|
func (r *personaRequest) toPersona() *models.Persona {
|
||||||
p := &models.Persona{
|
p := &models.Persona{
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
|
Handle: r.Handle,
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
Icon: r.Icon,
|
Icon: r.Icon,
|
||||||
BaseModelID: r.BaseModelID,
|
BaseModelID: r.BaseModelID,
|
||||||
|
|||||||
@@ -380,6 +380,13 @@ func main() {
|
|||||||
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
|
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
|
||||||
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
|
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
|
||||||
|
|
||||||
|
// Channel participants (v0.23.0 — ICD §3.7)
|
||||||
|
partH := handlers.NewParticipantHandler(stores)
|
||||||
|
protected.GET("/channels/:id/participants", partH.List)
|
||||||
|
protected.POST("/channels/:id/participants", partH.Add)
|
||||||
|
protected.PATCH("/channels/:id/participants/:participantId", partH.Update)
|
||||||
|
protected.DELETE("/channels/:id/participants/:participantId", partH.Remove)
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
||||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||||
|
|||||||
@@ -10,47 +10,45 @@ import (
|
|||||||
|
|
||||||
// Mention represents a parsed @mention token in message content.
|
// Mention represents a parsed @mention token in message content.
|
||||||
type Mention struct {
|
type Mention struct {
|
||||||
Raw string // "@claude-3-opus" as written (includes @)
|
Raw string // "@veronica-sharpe" as written (includes @)
|
||||||
Name string // "claude-3-opus" (normalized, no @)
|
Name string // "veronica-sharpe" (normalized, no @)
|
||||||
Start int // byte offset in message content
|
Start int // byte offset in message content
|
||||||
End int // byte offset end (exclusive)
|
End int // byte offset end (exclusive)
|
||||||
Resolved *models.ChannelModel // nil if unresolved
|
Resolved *models.ChannelModel // nil if unresolved
|
||||||
|
IsAll bool // true for @all special token
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse extracts @mentions from message content and resolves them
|
// Parse extracts @mentions from message content and resolves them
|
||||||
// against the channel's model roster.
|
// against the channel's model roster.
|
||||||
//
|
//
|
||||||
// Rules:
|
// Resolution priority:
|
||||||
// - @ followed by one or more non-whitespace characters
|
// 1. Exact handle match (e.g. @veronica-sharpe → handle "veronica-sharpe")
|
||||||
// - Greedy: @claude-3-opus-20240229 matches the full token
|
// 2. Prefix handle match (e.g. @veronica → only one handle starts with "veronica")
|
||||||
// - Resolution: case-insensitive match against display_name with
|
// 3. Fallback: normalized display_name match (backward compat)
|
||||||
// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus")
|
// 4. @all is a special token that resolves to all roster entries
|
||||||
// - Longest-match-first when display names overlap
|
//
|
||||||
// - Unresolved mentions are included with Resolved = nil
|
// @ must be at start of content or preceded by whitespace.
|
||||||
// - @ must be at start of content or preceded by whitespace
|
|
||||||
func Parse(content string, roster []models.ChannelModel) []Mention {
|
func Parse(content string, roster []models.ChannelModel) []Mention {
|
||||||
if len(roster) == 0 || !strings.Contains(content, "@") {
|
if len(roster) == 0 || !strings.Contains(content, "@") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build lookup: normalized display_name → *ChannelModel
|
// Build handle lookup: handle → *ChannelModel
|
||||||
// Sort roster by display_name length descending for longest-match-first
|
// Also build display name lookup for backward compat
|
||||||
type entry struct {
|
type entry struct {
|
||||||
normalized string
|
handle string // normalized handle
|
||||||
|
normalized string // normalized display_name
|
||||||
model models.ChannelModel
|
model models.ChannelModel
|
||||||
}
|
}
|
||||||
entries := make([]entry, 0, len(roster))
|
entries := make([]entry, 0, len(roster))
|
||||||
for _, cm := range roster {
|
for _, cm := range roster {
|
||||||
if cm.DisplayName == "" {
|
h := normalize(cm.Handle)
|
||||||
continue
|
dn := normalize(cm.DisplayName)
|
||||||
}
|
entries = append(entries, entry{handle: h, normalized: dn, model: cm})
|
||||||
entries = append(entries, entry{
|
|
||||||
normalized: normalize(cm.DisplayName),
|
|
||||||
model: cm,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
// Sort by handle length descending for longest-match-first
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
return len(entries[i].normalized) > len(entries[j].normalized)
|
return len(entries[i].handle) > len(entries[j].handle)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Extract @tokens
|
// Extract @tokens
|
||||||
@@ -80,29 +78,86 @@ func Parse(content string, roster []models.ChannelModel) []Mention {
|
|||||||
continue // bare @ with no token
|
continue // bare @ with no token
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip trailing punctuation (commas, periods, etc.)
|
// Strip trailing punctuation
|
||||||
tokenEnd := i
|
tokenEnd := i
|
||||||
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
|
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
|
||||||
tokenEnd--
|
tokenEnd--
|
||||||
}
|
}
|
||||||
if tokenEnd == tokenStart {
|
if tokenEnd == tokenStart {
|
||||||
continue // all punctuation
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
raw := content[start:tokenEnd]
|
raw := content[start:tokenEnd]
|
||||||
name := content[tokenStart:tokenEnd]
|
name := content[tokenStart:tokenEnd]
|
||||||
normalizedName := normalize(name)
|
normalizedName := normalize(name)
|
||||||
|
|
||||||
// Try to resolve against roster (longest match first)
|
// Special: @all
|
||||||
|
if normalizedName == "all" {
|
||||||
|
mentions = append(mentions, Mention{
|
||||||
|
Raw: raw,
|
||||||
|
Name: name,
|
||||||
|
Start: start,
|
||||||
|
End: tokenEnd,
|
||||||
|
IsAll: true,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Exact handle match
|
||||||
var resolved *models.ChannelModel
|
var resolved *models.ChannelModel
|
||||||
for idx := range entries {
|
for idx := range entries {
|
||||||
if normalizedName == entries[idx].normalized {
|
if entries[idx].handle != "" && normalizedName == entries[idx].handle {
|
||||||
cm := entries[idx].model
|
cm := entries[idx].model
|
||||||
resolved = &cm
|
resolved = &cm
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Prefix handle match (unambiguous only)
|
||||||
|
if resolved == nil {
|
||||||
|
var prefixMatches []int
|
||||||
|
for idx := range entries {
|
||||||
|
if entries[idx].handle != "" && strings.HasPrefix(entries[idx].handle, normalizedName) {
|
||||||
|
prefixMatches = append(prefixMatches, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(prefixMatches) == 1 {
|
||||||
|
cm := entries[prefixMatches[0]].model
|
||||||
|
resolved = &cm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback: display_name match (backward compat)
|
||||||
|
if resolved == nil {
|
||||||
|
for idx := range entries {
|
||||||
|
if entries[idx].normalized != "" && normalizedName == entries[idx].normalized {
|
||||||
|
cm := entries[idx].model
|
||||||
|
resolved = &cm
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Display name prefix
|
||||||
|
if resolved == nil {
|
||||||
|
var prefixMatches []int
|
||||||
|
for idx := range entries {
|
||||||
|
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName+" ") {
|
||||||
|
prefixMatches = append(prefixMatches, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(prefixMatches) == 0 {
|
||||||
|
for idx := range entries {
|
||||||
|
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName) {
|
||||||
|
prefixMatches = append(prefixMatches, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(prefixMatches) == 1 {
|
||||||
|
cm := entries[prefixMatches[0]].model
|
||||||
|
resolved = &cm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mentions = append(mentions, Mention{
|
mentions = append(mentions, Mention{
|
||||||
Raw: raw,
|
Raw: raw,
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -133,6 +188,16 @@ func ResolvedModels(mentions []Mention) []models.ChannelModel {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasAll returns true if any mention is @all.
|
||||||
|
func HasAll(mentions []Mention) bool {
|
||||||
|
for _, m := range mentions {
|
||||||
|
if m.IsAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// isMentionTrailingPunct returns true for punctuation that commonly
|
// isMentionTrailingPunct returns true for punctuation that commonly
|
||||||
// follows an @mention but isn't part of the name.
|
// follows an @mention but isn't part of the name.
|
||||||
func isMentionTrailingPunct(b byte) bool {
|
func isMentionTrailingPunct(b byte) bool {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +129,8 @@ type ProviderConfig struct {
|
|||||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||||
IsActive bool `json:"is_active" db:"is_active"`
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
IsPrivate bool `json:"is_private" db:"is_private"`
|
IsPrivate bool `json:"is_private" db:"is_private"`
|
||||||
|
ProxyMode string `json:"proxy_mode" db:"proxy_mode"`
|
||||||
|
ProxyURL *string `json:"proxy_url,omitempty" db:"proxy_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasKey returns true if an encrypted API key is stored.
|
// HasKey returns true if an encrypted API key is stored.
|
||||||
@@ -146,6 +149,8 @@ type ProviderConfigPatch struct {
|
|||||||
Settings JSONMap `json:"settings,omitempty"`
|
Settings JSONMap `json:"settings,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
IsPrivate *bool `json:"is_private,omitempty"`
|
IsPrivate *bool `json:"is_private,omitempty"`
|
||||||
|
ProxyMode *string `json:"proxy_mode,omitempty"`
|
||||||
|
ProxyURL *string `json:"proxy_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -194,6 +199,7 @@ type ModelPricing struct {
|
|||||||
type Persona struct {
|
type Persona struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
Name string `json:"name" db:"name"`
|
Name string `json:"name" db:"name"`
|
||||||
|
Handle string `json:"handle,omitempty" db:"handle"`
|
||||||
Description string `json:"description,omitempty" db:"description"`
|
Description string `json:"description,omitempty" db:"description"`
|
||||||
Icon string `json:"icon,omitempty" db:"icon"`
|
Icon string `json:"icon,omitempty" db:"icon"`
|
||||||
Avatar string `json:"avatar,omitempty" db:"avatar"`
|
Avatar string `json:"avatar,omitempty" db:"avatar"`
|
||||||
@@ -227,6 +233,7 @@ type Persona struct {
|
|||||||
|
|
||||||
type PersonaPatch struct {
|
type PersonaPatch struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
|
Handle *string `json:"handle,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Icon *string `json:"icon,omitempty"`
|
Icon *string `json:"icon,omitempty"`
|
||||||
Avatar *string `json:"avatar,omitempty"`
|
Avatar *string `json:"avatar,omitempty"`
|
||||||
@@ -278,6 +285,7 @@ type UserModelSetting struct {
|
|||||||
BaseModel
|
BaseModel
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
UserID string `json:"user_id" db:"user_id"`
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
ModelID string `json:"model_id" db:"model_id"`
|
||||||
|
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||||
Hidden bool `json:"hidden" db:"hidden"`
|
Hidden bool `json:"hidden" db:"hidden"`
|
||||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"`
|
PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"`
|
||||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"`
|
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"`
|
||||||
@@ -285,12 +293,24 @@ type UserModelSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserModelSettingPatch struct {
|
type UserModelSettingPatch struct {
|
||||||
|
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||||
Hidden *bool `json:"hidden,omitempty"`
|
Hidden *bool `json:"hidden,omitempty"`
|
||||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
||||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
||||||
SortOrder *int `json:"sort_order,omitempty"`
|
SortOrder *int `json:"sort_order,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HiddenEntry identifies a model+provider pair for bulk visibility operations.
|
||||||
|
type HiddenEntry struct {
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
ProviderConfigID string `json:"provider_config_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompositeModelKey builds the composite key used for per-provider model preferences.
|
||||||
|
func CompositeModelKey(providerConfigID, modelID string) string {
|
||||||
|
return providerConfigID + ":" + modelID
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// CHANNELS
|
// CHANNELS
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -330,18 +350,22 @@ type Message struct {
|
|||||||
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
|
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
|
||||||
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
|
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
|
||||||
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
|
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
|
||||||
|
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// CHANNEL MEMBERS, MODELS, CURSORS
|
// CHANNEL PARTICIPANTS, MODELS, CURSORS
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
type ChannelMember struct {
|
type ChannelParticipant struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
ParticipantType string `json:"participant_type" db:"participant_type"`
|
||||||
|
ParticipantID string `json:"participant_id" db:"participant_id"`
|
||||||
Role string `json:"role" db:"role"`
|
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"`
|
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
|
||||||
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
|
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
|
||||||
}
|
}
|
||||||
@@ -351,6 +375,8 @@ type ChannelModel struct {
|
|||||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
ModelID string `json:"model_id" db:"model_id"`
|
||||||
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||||
|
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"`
|
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
||||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||||
IsDefault bool `json:"is_default" db:"is_default"`
|
IsDefault bool `json:"is_default" db:"is_default"`
|
||||||
@@ -364,6 +390,56 @@ type ChannelCursor struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// PERSONA GROUPS (v0.23.0)
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type PersonaGroup struct {
|
||||||
|
BaseModel
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
Description string `json:"description,omitempty" db:"description"`
|
||||||
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||||
|
Scope string `json:"scope" db:"scope"`
|
||||||
|
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||||
|
Members []PersonaGroupMember `json:"members,omitempty" db:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersonaGroupMember struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
GroupID string `json:"group_id" db:"group_id"`
|
||||||
|
PersonaID string `json:"persona_id" db:"persona_id"`
|
||||||
|
IsLeader bool `json:"is_leader" db:"is_leader"`
|
||||||
|
SortOrder int `json:"sort_order" db:"sort_order"`
|
||||||
|
// Joined from personas table for API responses
|
||||||
|
PersonaName string `json:"persona_name,omitempty" db:"-"`
|
||||||
|
PersonaHandle string `json:"persona_handle,omitempty" db:"-"`
|
||||||
|
PersonaAvatar string `json:"persona_avatar,omitempty" db:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFromName generates a URL-safe @mention handle from a display name.
|
||||||
|
// "Veronica Sharpe" → "veronica-sharpe"
|
||||||
|
func HandleFromName(name string) string {
|
||||||
|
h := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
h = strings.Map(func(r rune) rune {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if r == ' ' || r == '_' {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, h)
|
||||||
|
// Collapse multiple hyphens
|
||||||
|
for strings.Contains(h, "--") {
|
||||||
|
h = strings.ReplaceAll(h, "--", "-")
|
||||||
|
}
|
||||||
|
h = strings.Trim(h, "-")
|
||||||
|
if len(h) > 50 {
|
||||||
|
h = h[:50]
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// ORGANIZATION
|
// ORGANIZATION
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -635,6 +711,7 @@ type UserModel struct {
|
|||||||
// Persona fields — always emitted so frontend can branch on is_persona.
|
// Persona fields — always emitted so frontend can branch on is_persona.
|
||||||
IsPersona bool `json:"is_persona"`
|
IsPersona bool `json:"is_persona"`
|
||||||
PersonaID string `json:"persona_id,omitempty"`
|
PersonaID string `json:"persona_id,omitempty"`
|
||||||
|
PersonaHandle string `json:"persona_handle,omitempty"`
|
||||||
PersonaScope string `json:"persona_scope,omitempty"`
|
PersonaScope string `json:"persona_scope,omitempty"`
|
||||||
PersonaAvatar string `json:"persona_avatar,omitempty"`
|
PersonaAvatar string `json:"persona_avatar,omitempty"`
|
||||||
PersonaTeamName string `json:"persona_team_name,omitempty"`
|
PersonaTeamName string `json:"persona_team_name,omitempty"`
|
||||||
|
|||||||
@@ -106,10 +106,9 @@ window.addEventListener('unhandledrejection', function(e) {
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
<span>New Project</span>
|
<span>New Project</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
<button class="split-dropdown-item" onclick="closeNewChatMenu(); newGroupChat();">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
<span>Group Chat</span>
|
<span>Group Chat</span>
|
||||||
<span class="dd-hint">soon</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
|||||||
@@ -235,6 +235,20 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
|||||||
|
|
||||||
antMsg := anthropicMessage{Role: m.Role}
|
antMsg := anthropicMessage{Role: m.Role}
|
||||||
|
|
||||||
|
// ── Foreign persona rewrite (v0.23.0) ──
|
||||||
|
// Anthropic doesn't support the "name" field on messages.
|
||||||
|
// Rewrite named assistant messages (from other personas) to user
|
||||||
|
// role with attribution so Anthropic maintains alternation and
|
||||||
|
// the model understands it's a multi-participant conversation.
|
||||||
|
if m.Role == "assistant" && m.Name != "" {
|
||||||
|
antMsg.Role = "user"
|
||||||
|
antMsg.Content = []anthropicContentBlock{
|
||||||
|
{Type: "text", Text: fmt.Sprintf("[%s responded:]\n%s", m.Name, m.Content)},
|
||||||
|
}
|
||||||
|
messages = append(messages, antMsg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||||
// Assistant with tool calls → mixed content blocks
|
// Assistant with tool calls → mixed content blocks
|
||||||
if m.Content != "" {
|
if m.Content != "" {
|
||||||
@@ -372,7 +386,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
resp, err := cfg.Client().Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("provider request: %w", err)
|
return nil, fmt.Errorf("provider request: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
|||||||
httpReq.Header.Set(k, v)
|
httpReq.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
resp, err := cfg.Client().Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list models: %w", err)
|
return nil, fmt.Errorf("list models: %w", err)
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ func (p *OpenAIProvider) Embed(ctx context.Context, cfg ProviderConfig, req Embe
|
|||||||
httpReq.Header.Set(k, v)
|
httpReq.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
resp, err := cfg.Client().Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("embedding request: %w", err)
|
return nil, fmt.Errorf("embedding request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -424,6 +424,12 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
|||||||
oaiMsg.Name = m.Name
|
oaiMsg.Name = m.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Named messages: pass through for speaker attribution (v0.23.0)
|
||||||
|
// OpenAI supports "name" on any role to distinguish participants
|
||||||
|
if m.Name != "" && m.Role != "tool" {
|
||||||
|
oaiMsg.Name = m.Name
|
||||||
|
}
|
||||||
|
|
||||||
oaiReq.Messages = append(oaiReq.Messages, oaiMsg)
|
oaiReq.Messages = append(oaiReq.Messages, oaiMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +459,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
|||||||
httpReq.Header.Set(k, v)
|
httpReq.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
resp, err := cfg.Client().Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("provider request: %w", err)
|
return nil, fmt.Errorf("provider request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -474,7 +480,7 @@ type openaiMessage struct {
|
|||||||
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
|
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
|
||||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
|
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
|
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
|
||||||
Name string `json:"name,omitempty"` // tool name for role=tool
|
Name string `json:"name,omitempty"` // speaker name (tool or persona attribution)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openaiContentPart represents a single element in a multimodal content array.
|
// openaiContentPart represents a single element in a multimodal content array.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
|
|||||||
httpReq.Header.Set(k, v)
|
httpReq.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
resp, err := cfg.Client().Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("openrouter list models: %w", err)
|
return nil, fmt.Errorf("openrouter list models: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
)
|
)
|
||||||
@@ -43,6 +47,41 @@ type ProviderConfig struct {
|
|||||||
APIKey string
|
APIKey string
|
||||||
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
|
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
|
||||||
Settings map[string]interface{} // Provider-specific settings from config JSONB
|
Settings map[string]interface{} // Provider-specific settings from config JSONB
|
||||||
|
ProxyMode string // "system" (default), "direct", "custom"
|
||||||
|
ProxyURL string // Only used when ProxyMode == "custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client returns an *http.Client configured for this provider's proxy settings.
|
||||||
|
// system = http.ProxyFromEnvironment (Go default), direct = no proxy,
|
||||||
|
// custom = explicit proxy URL (http/https/socks5).
|
||||||
|
func (c ProviderConfig) Client() *http.Client {
|
||||||
|
transport := &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
switch c.ProxyMode {
|
||||||
|
case "direct":
|
||||||
|
transport.Proxy = nil
|
||||||
|
case "custom":
|
||||||
|
if c.ProxyURL != "" {
|
||||||
|
u, err := url.Parse(c.ProxyURL)
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to system rather than silently going direct —
|
||||||
|
// a bad custom URL in a mandatory-proxy environment is a
|
||||||
|
// security/audit hole if it silently bypasses.
|
||||||
|
log.Printf("[proxy] invalid custom proxy URL %q: %v — falling back to system proxy", c.ProxyURL, err)
|
||||||
|
transport.Proxy = http.ProxyFromEnvironment
|
||||||
|
} else {
|
||||||
|
transport.Proxy = http.ProxyURL(u)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transport.Proxy = http.ProxyFromEnvironment
|
||||||
|
}
|
||||||
|
default: // "system" or empty
|
||||||
|
transport.Proxy = http.ProxyFromEnvironment
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: transport}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Request / Response Types ────────────────
|
// ── Request / Response Types ────────────────
|
||||||
@@ -60,7 +99,7 @@ type Message struct {
|
|||||||
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
|
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
|
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
|
||||||
Name string `json:"name,omitempty"` // tool name for role="tool"
|
Name string `json:"name,omitempty"` // speaker name (tool result, or persona attribution v0.23.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentPart is a single element in a multimodal message.
|
// ContentPart is a single element in a multimodal message.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
|||||||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
resp, err := cfg.Client().Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("venice list models: %w", err)
|
return nil, fmt.Errorf("venice list models: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,10 +313,16 @@ func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyURL := ""
|
||||||
|
if cfg.ProxyURL != nil {
|
||||||
|
proxyURL = *cfg.ProxyURL
|
||||||
|
}
|
||||||
return prov, providers.ProviderConfig{
|
return prov, providers.ProviderConfig{
|
||||||
Endpoint: cfg.Endpoint,
|
Endpoint: cfg.Endpoint,
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
CustomHeaders: headers,
|
CustomHeaders: headers,
|
||||||
|
ProxyMode: cfg.ProxyMode,
|
||||||
|
ProxyURL: proxyURL,
|
||||||
}, cfg.Scope, nil
|
}, cfg.Scope, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ type PolicyStore interface {
|
|||||||
type UserModelSettingsStore interface {
|
type UserModelSettingsStore interface {
|
||||||
GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error)
|
GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error)
|
||||||
GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error)
|
GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error)
|
||||||
Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error
|
Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error
|
||||||
BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error
|
BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -229,6 +229,19 @@ type ChannelStore interface {
|
|||||||
// Ownership check
|
// Ownership check
|
||||||
UserOwns(ctx context.Context, channelID, userID string) (bool, error)
|
UserOwns(ctx context.Context, channelID, userID string) (bool, error)
|
||||||
|
|
||||||
|
// Channel participants (ICD §3.7)
|
||||||
|
AddParticipant(ctx context.Context, p *models.ChannelParticipant) error
|
||||||
|
ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error)
|
||||||
|
GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error)
|
||||||
|
UpdateParticipantRole(ctx context.Context, id, role string) error
|
||||||
|
RemoveParticipant(ctx context.Context, id string) error
|
||||||
|
IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error)
|
||||||
|
GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error)
|
||||||
|
CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error)
|
||||||
|
|
||||||
|
// Remove model roster entries auto-created by a persona participant
|
||||||
|
DeleteModelByPersona(ctx context.Context, channelID, personaID string) error
|
||||||
|
|
||||||
// Workspace resolution: channel workspace_id > project workspace_id
|
// Workspace resolution: channel workspace_id > project workspace_id
|
||||||
ResolveWorkspaceID(ctx context.Context, channelID string) (string, error)
|
ResolveWorkspaceID(ctx context.Context, channelID string) (string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,20 +183,40 @@ func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
|
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||||
|
// For persona entries, use the persona-aware path
|
||||||
|
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
||||||
|
return s.SetPersonaModel(ctx, cm)
|
||||||
|
}
|
||||||
|
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (channel_id, model_id) DO UPDATE SET
|
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
|
||||||
provider_config_id = $3, display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
|
display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
|
||||||
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPersonaModel inserts or updates a persona's channel model roster entry.
|
||||||
|
// Uses the idx_channel_models_persona partial index (one per persona per channel).
|
||||||
|
func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO channel_models (channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET
|
||||||
|
model_id = $2, provider_config_id = $3, display_name = $5, system_prompt = $6, settings = $7, is_default = $8`,
|
||||||
|
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
|
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
|
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''),
|
||||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||||
FROM channel_models WHERE channel_id = $1`, channelID)
|
COALESCE(cm.system_prompt, ''), cm.is_default
|
||||||
|
FROM channel_models cm
|
||||||
|
LEFT JOIN personas p ON p.id = cm.persona_id
|
||||||
|
WHERE cm.channel_id = $1`, channelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -205,10 +225,14 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
|||||||
var result []models.ChannelModel
|
var result []models.ChannelModel
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cm models.ChannelModel
|
var cm models.ChannelModel
|
||||||
|
var personaID string
|
||||||
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
||||||
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if personaID != "" {
|
||||||
|
cm.PersonaID = &personaID
|
||||||
|
}
|
||||||
result = append(result, cm)
|
result = append(result, cm)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
@@ -216,15 +240,22 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
|||||||
|
|
||||||
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
|
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
|
||||||
var cm models.ChannelModel
|
var cm models.ChannelModel
|
||||||
|
var personaID string
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
|
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''),
|
||||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||||
FROM channel_models WHERE id = $1`, id).Scan(
|
COALESCE(cm.system_prompt, ''), cm.is_default
|
||||||
|
FROM channel_models cm
|
||||||
|
LEFT JOIN personas p ON p.id = cm.persona_id
|
||||||
|
WHERE cm.id = $1`, id).Scan(
|
||||||
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
||||||
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if personaID != "" {
|
||||||
|
cm.PersonaID = &personaID
|
||||||
|
}
|
||||||
return &cm, nil
|
return &cm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,3 +318,99 @@ func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string)
|
|||||||
}
|
}
|
||||||
return NullableString(wsID), nil
|
return NullableString(wsID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Channel Participants (ICD §3.7) ──────────
|
||||||
|
|
||||||
|
func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role, display_name, avatar_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT id, channel_id, participant_type, participant_id, role,
|
||||||
|
display_name, avatar_url, joined_at
|
||||||
|
FROM channel_participants WHERE channel_id = $1 ORDER BY joined_at`, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []models.ChannelParticipant
|
||||||
|
for rows.Next() {
|
||||||
|
var p models.ChannelParticipant
|
||||||
|
if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
||||||
|
&p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) {
|
||||||
|
var p models.ChannelParticipant
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, channel_id, participant_type, participant_id, role,
|
||||||
|
display_name, avatar_url, joined_at
|
||||||
|
FROM channel_participants WHERE id = $1`, id).Scan(
|
||||||
|
&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
||||||
|
&p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = $1 WHERE id = $2`, role, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error {
|
||||||
|
res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM channel_participants
|
||||||
|
WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3)`,
|
||||||
|
channelID, pType, pID).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) {
|
||||||
|
var role string
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT role FROM channel_participants
|
||||||
|
WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3`,
|
||||||
|
channelID, pType, pID).Scan(&role)
|
||||||
|
return role, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM channel_participants WHERE channel_id = $1 AND role = $2`,
|
||||||
|
channelID, role).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM channel_models WHERE channel_id = $1 AND persona_id = $2`,
|
||||||
|
channelID, personaID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,20 +13,24 @@ type PersonaStore struct{}
|
|||||||
|
|
||||||
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
||||||
|
|
||||||
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
|
const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
||||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||||
created_at, updated_at`
|
created_at, updated_at`
|
||||||
|
|
||||||
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
||||||
|
// Auto-generate handle from name if not set
|
||||||
|
if p.Handle == "" {
|
||||||
|
p.Handle = models.HandleFromName(p.Name)
|
||||||
|
}
|
||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO personas (name, description, icon, avatar, base_model_id, provider_config_id,
|
INSERT INTO personas (name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
||||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||||
scope, owner_id, created_by, is_active, is_shared,
|
scope, owner_id, created_by, is_active, is_shared,
|
||||||
memory_enabled, memory_extraction_prompt)
|
memory_enabled, memory_extraction_prompt)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
||||||
RETURNING id, created_at, updated_at`,
|
RETURNING id, created_at, updated_at`,
|
||||||
p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||||
models.NullString(p.ProviderConfigID),
|
models.NullString(p.ProviderConfigID),
|
||||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
||||||
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
||||||
@@ -53,6 +57,9 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
|
|||||||
if patch.Name != nil {
|
if patch.Name != nil {
|
||||||
b.Set("name", *patch.Name)
|
b.Set("name", *patch.Name)
|
||||||
}
|
}
|
||||||
|
if patch.Handle != nil {
|
||||||
|
b.Set("handle", *patch.Handle)
|
||||||
|
}
|
||||||
if patch.Description != nil {
|
if patch.Description != nil {
|
||||||
b.Set("description", *patch.Description)
|
b.Set("description", *patch.Description)
|
||||||
}
|
}
|
||||||
@@ -281,10 +288,11 @@ func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID stri
|
|||||||
func scanPersona(row *sql.Row) (*models.Persona, error) {
|
func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||||
var p models.Persona
|
var p models.Persona
|
||||||
var providerConfigID, ownerID sql.NullString
|
var providerConfigID, ownerID sql.NullString
|
||||||
|
var handle sql.NullString
|
||||||
var temp, topP sql.NullFloat64
|
var temp, topP sql.NullFloat64
|
||||||
var maxTokens, thinkingBudget sql.NullInt64
|
var maxTokens, thinkingBudget sql.NullInt64
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
|
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
||||||
&p.BaseModelID, &providerConfigID,
|
&p.BaseModelID, &providerConfigID,
|
||||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||||
@@ -294,6 +302,9 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if handle.Valid {
|
||||||
|
p.Handle = handle.String
|
||||||
|
}
|
||||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
p.OwnerID = NullableStringPtr(ownerID)
|
||||||
p.Temperature = NullableFloat64Ptr(temp)
|
p.Temperature = NullableFloat64Ptr(temp)
|
||||||
@@ -308,10 +319,11 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Persona
|
var p models.Persona
|
||||||
var providerConfigID, ownerID sql.NullString
|
var providerConfigID, ownerID sql.NullString
|
||||||
|
var handle sql.NullString
|
||||||
var temp, topP sql.NullFloat64
|
var temp, topP sql.NullFloat64
|
||||||
var maxTokens, thinkingBudget sql.NullInt64
|
var maxTokens, thinkingBudget sql.NullInt64
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
|
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
||||||
&p.BaseModelID, &providerConfigID,
|
&p.BaseModelID, &providerConfigID,
|
||||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||||
@@ -321,6 +333,9 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if handle.Valid {
|
||||||
|
p.Handle = handle.String
|
||||||
|
}
|
||||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
p.OwnerID = NullableStringPtr(ownerID)
|
||||||
p.Temperature = NullableFloat64Ptr(temp)
|
p.Temperature = NullableFloat64Ptr(temp)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
|
|||||||
|
|
||||||
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, user_id, model_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens,
|
SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens,
|
||||||
COALESCE(sort_order, 0), created_at, updated_at
|
COALESCE(sort_order, 0), created_at, updated_at
|
||||||
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
|
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,7 +27,7 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
|||||||
var s models.UserModelSetting
|
var s models.UserModelSetting
|
||||||
var prefTemp, prefMaxTokens interface{}
|
var prefTemp, prefMaxTokens interface{}
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
|
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
|
||||||
&prefTemp, &prefMaxTokens,
|
&prefTemp, &prefMaxTokens,
|
||||||
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
|
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -46,10 +46,10 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHiddenModelIDs returns a map of model_id → true for all hidden models.
|
// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models.
|
||||||
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
||||||
rows, err := DB.QueryContext(ctx,
|
rows, err := DB.QueryContext(ctx,
|
||||||
"SELECT model_id FROM user_model_settings WHERE user_id = $1 AND hidden = true",
|
"SELECT model_id, COALESCE(provider_config_id::text, '') FROM user_model_settings WHERE user_id = $1 AND hidden = true",
|
||||||
userID)
|
userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -58,17 +58,17 @@ func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID s
|
|||||||
|
|
||||||
result := make(map[string]bool)
|
result := make(map[string]bool)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var modelID string
|
var modelID, provCfgID string
|
||||||
if err := rows.Scan(&modelID); err != nil {
|
if err := rows.Scan(&modelID, &provCfgID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result[modelID] = true
|
result[models.CompositeModelKey(provCfgID, modelID)] = true
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set upserts a single user model setting.
|
// Set upserts a single user model setting.
|
||||||
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error {
|
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
|
||||||
b := NewUpdate("user_model_settings")
|
b := NewUpdate("user_model_settings")
|
||||||
if patch.Hidden != nil {
|
if patch.Hidden != nil {
|
||||||
b.Set("hidden", *patch.Hidden)
|
b.Set("hidden", *patch.Hidden)
|
||||||
@@ -88,17 +88,16 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use upsert: insert if not exists, update if exists
|
// Use upsert: insert if not exists, update if exists
|
||||||
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO user_model_settings (user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||||
VALUES ($1, $2, COALESCE($3, false), $4, $5, COALESCE($6, 0))
|
VALUES ($1, $2, $3, COALESCE($4, false), $5, $6, COALESCE($7, 0))
|
||||||
ON CONFLICT (user_id, model_id)
|
ON CONFLICT (user_id, model_id, provider_config_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
hidden = COALESCE($3, user_model_settings.hidden),
|
hidden = COALESCE($4, user_model_settings.hidden),
|
||||||
preferred_temperature = COALESCE($4, user_model_settings.preferred_temperature),
|
preferred_temperature = COALESCE($5, user_model_settings.preferred_temperature),
|
||||||
preferred_max_tokens = COALESCE($5, user_model_settings.preferred_max_tokens),
|
preferred_max_tokens = COALESCE($6, user_model_settings.preferred_max_tokens),
|
||||||
sort_order = COALESCE($6, user_model_settings.sort_order)`,
|
sort_order = COALESCE($7, user_model_settings.sort_order)`,
|
||||||
userID, modelID,
|
userID, modelID, providerConfigID,
|
||||||
patchBoolOrNil(patch.Hidden),
|
patchBoolOrNil(patch.Hidden),
|
||||||
patchFloat64OrNil(patch.PreferredTemperature),
|
patchFloat64OrNil(patch.PreferredTemperature),
|
||||||
patchIntOrNil(patch.PreferredMaxTokens),
|
patchIntOrNil(patch.PreferredMaxTokens),
|
||||||
@@ -107,30 +106,20 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// BulkSetHidden sets the hidden state for multiple models at once.
|
// BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
|
||||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
|
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
|
||||||
if len(modelIDs) == 0 {
|
if len(entries) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build parameterized IN clause
|
for _, entry := range entries {
|
||||||
placeholders := make([]string, len(modelIDs))
|
|
||||||
args := make([]interface{}, 0, len(modelIDs)+2)
|
|
||||||
args = append(args, userID, hidden)
|
|
||||||
for i, id := range modelIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+3)
|
|
||||||
args = append(args, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert each: some might not have rows yet
|
|
||||||
for _, modelID := range modelIDs {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO user_model_settings (user_id, model_id, hidden)
|
INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = $3`,
|
ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = $4`,
|
||||||
userID, modelID, hidden)
|
userID, entry.ModelID, entry.ProviderConfigID, hidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set hidden for %s: %w", modelID, err)
|
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -188,20 +188,39 @@ func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
|
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||||
|
// For persona entries, use the persona-aware path
|
||||||
|
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
||||||
|
return s.SetPersonaModel(ctx, cm)
|
||||||
|
}
|
||||||
|
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (channel_id, model_id) DO UPDATE SET
|
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
|
||||||
provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
||||||
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPersonaModel inserts or updates a persona's channel model roster entry.
|
||||||
|
func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET
|
||||||
|
model_id = excluded.model_id, provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
||||||
|
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
|
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''),
|
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
|
||||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||||
FROM channel_models WHERE channel_id = ?`, channelID)
|
COALESCE(cm.system_prompt, ''), cm.is_default
|
||||||
|
FROM channel_models cm
|
||||||
|
LEFT JOIN personas p ON p.id = cm.persona_id
|
||||||
|
WHERE cm.channel_id = ?`, channelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -210,10 +229,14 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
|||||||
var result []models.ChannelModel
|
var result []models.ChannelModel
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cm models.ChannelModel
|
var cm models.ChannelModel
|
||||||
|
var personaID string
|
||||||
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
||||||
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if personaID != "" {
|
||||||
|
cm.PersonaID = &personaID
|
||||||
|
}
|
||||||
result = append(result, cm)
|
result = append(result, cm)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
@@ -221,15 +244,22 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
|||||||
|
|
||||||
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
|
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
|
||||||
var cm models.ChannelModel
|
var cm models.ChannelModel
|
||||||
|
var personaID string
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''),
|
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
|
||||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||||
FROM channel_models WHERE id = ?`, id).Scan(
|
COALESCE(cm.system_prompt, ''), cm.is_default
|
||||||
|
FROM channel_models cm
|
||||||
|
LEFT JOIN personas p ON p.id = cm.persona_id
|
||||||
|
WHERE cm.id = ?`, id).Scan(
|
||||||
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
||||||
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if personaID != "" {
|
||||||
|
cm.PersonaID = &personaID
|
||||||
|
}
|
||||||
return &cm, nil
|
return &cm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,3 +319,103 @@ func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string)
|
|||||||
}
|
}
|
||||||
return NullableString(wsID), nil
|
return NullableString(wsID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Channel Participants (ICD §3.7) ──────────
|
||||||
|
|
||||||
|
func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role, display_name, avatar_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
store.NewID(), p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT id, channel_id, participant_type, participant_id, role,
|
||||||
|
display_name, avatar_url, joined_at
|
||||||
|
FROM channel_participants WHERE channel_id = ? ORDER BY joined_at`, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []models.ChannelParticipant
|
||||||
|
for rows.Next() {
|
||||||
|
var p models.ChannelParticipant
|
||||||
|
var joinedStr string
|
||||||
|
if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
||||||
|
&p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr)
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) {
|
||||||
|
var p models.ChannelParticipant
|
||||||
|
var joinedStr string
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, channel_id, participant_type, participant_id, role,
|
||||||
|
display_name, avatar_url, joined_at
|
||||||
|
FROM channel_participants WHERE id = ?`, id).Scan(
|
||||||
|
&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
||||||
|
&p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr)
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = ? WHERE id = ?`, role, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error {
|
||||||
|
res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM channel_participants
|
||||||
|
WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`,
|
||||||
|
channelID, pType, pID).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) {
|
||||||
|
var role string
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT role FROM channel_participants
|
||||||
|
WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`,
|
||||||
|
channelID, pType, pID).Scan(&role)
|
||||||
|
return role, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM channel_participants WHERE channel_id = ? AND role = ?`,
|
||||||
|
channelID, role).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM channel_models WHERE channel_id = ? AND persona_id = ?`,
|
||||||
|
channelID, personaID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type PersonaStore struct{}
|
|||||||
|
|
||||||
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
||||||
|
|
||||||
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
|
const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
||||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||||
created_at, updated_at`
|
created_at, updated_at`
|
||||||
@@ -25,13 +25,17 @@ func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
p.CreatedAt = now
|
p.CreatedAt = now
|
||||||
p.UpdatedAt = now
|
p.UpdatedAt = now
|
||||||
|
// Auto-generate handle from name if not set
|
||||||
|
if p.Handle == "" {
|
||||||
|
p.Handle = models.HandleFromName(p.Name)
|
||||||
|
}
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO personas (id, name, description, icon, avatar, base_model_id, provider_config_id,
|
INSERT INTO personas (id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
||||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
p.ID, p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
p.ID, p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||||
models.NullString(p.ProviderConfigID),
|
models.NullString(p.ProviderConfigID),
|
||||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
||||||
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
||||||
@@ -60,6 +64,9 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
|
|||||||
if patch.Name != nil {
|
if patch.Name != nil {
|
||||||
b.Set("name", *patch.Name)
|
b.Set("name", *patch.Name)
|
||||||
}
|
}
|
||||||
|
if patch.Handle != nil {
|
||||||
|
b.Set("handle", *patch.Handle)
|
||||||
|
}
|
||||||
if patch.Description != nil {
|
if patch.Description != nil {
|
||||||
b.Set("description", *patch.Description)
|
b.Set("description", *patch.Description)
|
||||||
}
|
}
|
||||||
@@ -287,11 +294,11 @@ func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID stri
|
|||||||
|
|
||||||
func scanPersona(row *sql.Row) (*models.Persona, error) {
|
func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||||
var p models.Persona
|
var p models.Persona
|
||||||
var providerConfigID, ownerID sql.NullString
|
var providerConfigID, ownerID, handle sql.NullString
|
||||||
var temp, topP sql.NullFloat64
|
var temp, topP sql.NullFloat64
|
||||||
var maxTokens, thinkingBudget sql.NullInt64
|
var maxTokens, thinkingBudget sql.NullInt64
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
|
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
||||||
&p.BaseModelID, &providerConfigID,
|
&p.BaseModelID, &providerConfigID,
|
||||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||||
@@ -301,6 +308,9 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if handle.Valid {
|
||||||
|
p.Handle = handle.String
|
||||||
|
}
|
||||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
p.OwnerID = NullableStringPtr(ownerID)
|
||||||
p.Temperature = NullableFloat64Ptr(temp)
|
p.Temperature = NullableFloat64Ptr(temp)
|
||||||
@@ -314,11 +324,11 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
|||||||
var result []models.Persona
|
var result []models.Persona
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Persona
|
var p models.Persona
|
||||||
var providerConfigID, ownerID sql.NullString
|
var providerConfigID, ownerID, handle sql.NullString
|
||||||
var temp, topP sql.NullFloat64
|
var temp, topP sql.NullFloat64
|
||||||
var maxTokens, thinkingBudget sql.NullInt64
|
var maxTokens, thinkingBudget sql.NullInt64
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
|
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
||||||
&p.BaseModelID, &providerConfigID,
|
&p.BaseModelID, &providerConfigID,
|
||||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||||
@@ -328,6 +338,9 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if handle.Valid {
|
||||||
|
p.Handle = handle.String
|
||||||
|
}
|
||||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
p.OwnerID = NullableStringPtr(ownerID)
|
||||||
p.Temperature = NullableFloat64Ptr(temp)
|
p.Temperature = NullableFloat64Ptr(temp)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
|
|||||||
|
|
||||||
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT id, user_id, model_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens,
|
SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens,
|
||||||
COALESCE(sort_order, 0), created_at, updated_at
|
COALESCE(sort_order, 0), created_at, updated_at
|
||||||
FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID)
|
FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,7 +28,7 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
|||||||
var s models.UserModelSetting
|
var s models.UserModelSetting
|
||||||
var prefTemp, prefMaxTokens interface{}
|
var prefTemp, prefMaxTokens interface{}
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
|
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
|
||||||
&prefTemp, &prefMaxTokens,
|
&prefTemp, &prefMaxTokens,
|
||||||
&s.SortOrder, st(&s.CreatedAt), st(&s.UpdatedAt),
|
&s.SortOrder, st(&s.CreatedAt), st(&s.UpdatedAt),
|
||||||
)
|
)
|
||||||
@@ -47,10 +47,10 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHiddenModelIDs returns a map of model_id → true for all hidden models.
|
// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models.
|
||||||
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
||||||
rows, err := DB.QueryContext(ctx,
|
rows, err := DB.QueryContext(ctx,
|
||||||
"SELECT model_id FROM user_model_settings WHERE user_id = ? AND hidden = 1",
|
"SELECT model_id, COALESCE(provider_config_id, '') FROM user_model_settings WHERE user_id = ? AND hidden = 1",
|
||||||
userID)
|
userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -59,17 +59,17 @@ func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID s
|
|||||||
|
|
||||||
result := make(map[string]bool)
|
result := make(map[string]bool)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var modelID string
|
var modelID, provCfgID string
|
||||||
if err := rows.Scan(&modelID); err != nil {
|
if err := rows.Scan(&modelID, &provCfgID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result[modelID] = true
|
result[models.CompositeModelKey(provCfgID, modelID)] = true
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set upserts a single user model setting.
|
// Set upserts a single user model setting.
|
||||||
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error {
|
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
|
||||||
b := NewUpdate("user_model_settings")
|
b := NewUpdate("user_model_settings")
|
||||||
if patch.Hidden != nil {
|
if patch.Hidden != nil {
|
||||||
b.Set("hidden", *patch.Hidden)
|
b.Set("hidden", *patch.Hidden)
|
||||||
@@ -89,17 +89,16 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use upsert: insert if not exists, update if exists
|
// Use upsert: insert if not exists, update if exists
|
||||||
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO user_model_settings (id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||||
VALUES (?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0))
|
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0))
|
||||||
ON CONFLICT (user_id, model_id)
|
ON CONFLICT (user_id, model_id, provider_config_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
hidden = COALESCE(excluded.hidden, user_model_settings.hidden),
|
hidden = COALESCE(excluded.hidden, user_model_settings.hidden),
|
||||||
preferred_temperature = COALESCE(excluded.preferred_temperature, user_model_settings.preferred_temperature),
|
preferred_temperature = COALESCE(excluded.preferred_temperature, user_model_settings.preferred_temperature),
|
||||||
preferred_max_tokens = COALESCE(excluded.preferred_max_tokens, user_model_settings.preferred_max_tokens),
|
preferred_max_tokens = COALESCE(excluded.preferred_max_tokens, user_model_settings.preferred_max_tokens),
|
||||||
sort_order = COALESCE(excluded.sort_order, user_model_settings.sort_order)`,
|
sort_order = COALESCE(excluded.sort_order, user_model_settings.sort_order)`,
|
||||||
store.NewID(), userID, modelID,
|
store.NewID(), userID, modelID, providerConfigID,
|
||||||
patchBoolOrNil(patch.Hidden),
|
patchBoolOrNil(patch.Hidden),
|
||||||
patchFloat64OrNil(patch.PreferredTemperature),
|
patchFloat64OrNil(patch.PreferredTemperature),
|
||||||
patchIntOrNil(patch.PreferredMaxTokens),
|
patchIntOrNil(patch.PreferredMaxTokens),
|
||||||
@@ -108,21 +107,20 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// BulkSetHidden sets the hidden state for multiple models at once.
|
// BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
|
||||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
|
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
|
||||||
if len(modelIDs) == 0 {
|
if len(entries) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert each model individually
|
for _, entry := range entries {
|
||||||
for _, modelID := range modelIDs {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO user_model_settings (id, user_id, model_id, hidden)
|
INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = excluded.hidden`,
|
ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = excluded.hidden`,
|
||||||
store.NewID(), userID, modelID, hidden)
|
store.NewID(), userID, entry.ModelID, entry.ProviderConfigID, hidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set hidden for %s: %w", modelID, err)
|
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--bg-secondary, #f0f0f0);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-color, #ddd);
|
border: 1px solid var(--border);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
.ch-model-pill.ch-model-default {
|
.ch-model-pill.ch-model-default {
|
||||||
background: var(--accent-bg, #e8f0fe);
|
background: var(--accent-bg, #e8f0fe);
|
||||||
border-color: var(--accent-color, #4a8af4);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.ch-model-pill-name {
|
.ch-model-pill-name {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-3);
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -58,18 +58,18 @@
|
|||||||
|
|
||||||
.ch-model-add-btn {
|
.ch-model-add-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px dashed var(--border-color, #ccc);
|
border: 1px dashed var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-3);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
.ch-model-add-btn:hover {
|
.ch-model-add-btn:hover {
|
||||||
color: var(--text-primary, #333);
|
color: var(--text);
|
||||||
border-color: var(--text-primary, #333);
|
border-color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Add Model Dialog ────────────────────── */
|
/* ── Add Model Dialog ────────────────────── */
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
}
|
}
|
||||||
.ch-model-add-inner {
|
.ch-model-add-inner {
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.ch-model-add-inner select,
|
.ch-model-add-inner select,
|
||||||
.ch-model-add-inner input[type="text"] {
|
.ch-model-add-inner input[type="text"] {
|
||||||
@@ -107,11 +107,11 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--border-color, #ddd);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-surface);
|
||||||
color: var(--text-primary, #333);
|
color: var(--text);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.ch-model-add-actions {
|
.ch-model-add-actions {
|
||||||
@@ -126,40 +126,72 @@
|
|||||||
.mention-ac {
|
.mention-ac {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-color, #ddd);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||||
max-height: 200px;
|
max-height: 260px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.mention-ac-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
gap: 12px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.mention-ac-item:hover,
|
.mention-ac-item:hover,
|
||||||
.mention-ac-item.mention-ac-active {
|
.mention-ac-item.mention-ac-active {
|
||||||
background: var(--bg-secondary, #f5f5f5);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.mention-ac-name {
|
.mention-ac-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.mention-ac-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mention-ac-handle {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--accent, #6c9fff);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
.mention-ac-model {
|
.mention-ac-model {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-3);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 160px;
|
max-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Autocomplete avatars (v0.23.0) ──────────── */
|
||||||
|
.mention-ac-avatar {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.mention-ac-avatar-fallback {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-hover); font-size: 12px; line-height: 1;
|
||||||
|
}
|
||||||
|
.mention-ac-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── @mention pills in messages (v0.23.0) ──── */
|
||||||
|
.mention-pill {
|
||||||
|
display: inline;
|
||||||
|
color: var(--accent, #6c9fff);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Model Attribution (multi-model messages) ─ */
|
/* ── Model Attribution (multi-model messages) ─ */
|
||||||
|
|
||||||
.msg-model-label {
|
.msg-model-label {
|
||||||
@@ -168,8 +200,8 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-secondary, #f0f0f0);
|
background: var(--bg-raised);
|
||||||
color: var(--text-secondary, #666);
|
color: var(--text-3);
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
@@ -181,7 +213,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 0 4px;
|
padding: 12px 0 4px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-3);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.model-stream-divider::before,
|
.model-stream-divider::before,
|
||||||
@@ -189,7 +221,7 @@
|
|||||||
content: '';
|
content: '';
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border-color, #eee);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile ──────────────────────────────── */
|
/* ── Mobile ──────────────────────────────── */
|
||||||
|
|||||||
@@ -144,6 +144,8 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 16px 16px 4px 16px;
|
border-radius: 16px 16px 4px 16px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
flex: initial;
|
||||||
|
max-width: 85%;
|
||||||
}
|
}
|
||||||
.message.user .msg-head { flex-direction: row-reverse; }
|
.message.user .msg-head { flex-direction: row-reverse; }
|
||||||
.message.user .msg-role { color: var(--accent); }
|
.message.user .msg-role { color: var(--accent); }
|
||||||
@@ -594,3 +596,11 @@
|
|||||||
}
|
}
|
||||||
.lightbox-close:hover { background: rgba(255,255,255,0.3); }
|
.lightbox-close:hover { background: rgba(255,255,255,0.3); }
|
||||||
|
|
||||||
|
/* ── Chat type indicators (v0.23.0) ────────── */
|
||||||
|
.chat-type-icon { font-size: 11px; opacity: 0.7; }
|
||||||
|
.mono-input { font-family: var(--font-mono, 'JetBrains Mono', monospace) !important; font-size: 12px !important; }
|
||||||
|
|
||||||
|
/* Handle hints in dropdowns and pickers */
|
||||||
|
.item-handle { font-size: 10px; color: var(--accent); font-family: var(--font-mono, monospace); margin-left: 6px; opacity: 0.75; }
|
||||||
|
.group-persona-handle { font-size: 10px; color: var(--accent); font-family: var(--font-mono, monospace); margin-left: 4px; opacity: 0.75; }
|
||||||
|
|
||||||
|
|||||||
@@ -445,3 +445,16 @@
|
|||||||
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
|
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
|
||||||
@keyframes fade-in { from { opacity: 0; } }
|
@keyframes fade-in { from { opacity: 0; } }
|
||||||
|
|
||||||
|
/* ── Group Chat persona picker (v0.23.0) ────── */
|
||||||
|
.group-persona-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px; border-radius: var(--radius);
|
||||||
|
cursor: pointer; font-size: 13px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.group-persona-item:hover { background: var(--bg-hover); }
|
||||||
|
.group-persona-item input[type="checkbox"] { accent-color: var(--accent); flex-shrink: 0; }
|
||||||
|
.group-persona-name { flex: 1; color: var(--text); font-weight: 500; }
|
||||||
|
.group-persona-model { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); }
|
||||||
|
.chain-typing-name { font-size: 11px; font-weight: 600; color: var(--accent); margin-right: 6px; }
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function processModelsResponse(data, hiddenModels = new Set()) {
|
|||||||
return (data.models || []).map(m => {
|
return (data.models || []).map(m => {
|
||||||
const isPersona = !!m.is_persona;
|
const isPersona = !!m.is_persona;
|
||||||
const baseModelId = m.model_id || m.id;
|
const baseModelId = m.model_id || m.id;
|
||||||
const cfgId = m.config_id || m.provider_config_id;
|
const cfgId = m.config_id || m.provider_config_id || '';
|
||||||
const id = isPersona
|
const id = isPersona
|
||||||
? (m.persona_id || m.id)
|
? (m.persona_id || m.id)
|
||||||
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
|
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
|
||||||
@@ -143,12 +143,13 @@ function processModelsResponse(data, hiddenModels = new Set()) {
|
|||||||
configId: cfgId || null,
|
configId: cfgId || null,
|
||||||
isPersona,
|
isPersona,
|
||||||
personaId: m.persona_id || null,
|
personaId: m.persona_id || null,
|
||||||
|
personaHandle: m.persona_handle || null,
|
||||||
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
|
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
|
||||||
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
|
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
|
||||||
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
|
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
|
||||||
source: m.source || (isPersona ? 'persona' : 'global'),
|
source: m.source || (isPersona ? 'persona' : 'global'),
|
||||||
teamName: m.persona_team_name || null,
|
teamName: m.persona_team_name || null,
|
||||||
hidden: !isPersona && hiddenModels.has(baseModelId),
|
hidden: !isPersona && hiddenModels.has((cfgId || '') + ':' + baseModelId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ describe('processModelsResponse — personas', () => {
|
|||||||
persona_id: 'preset-uuid',
|
persona_id: 'preset-uuid',
|
||||||
persona_scope: 'global',
|
persona_scope: 'global',
|
||||||
persona_avatar: '/avatars/code.png',
|
persona_avatar: '/avatars/code.png',
|
||||||
|
persona_handle: 'code-helper',
|
||||||
persona_team_name: null,
|
persona_team_name: null,
|
||||||
config_id: 'cfg-uuid',
|
config_id: 'cfg-uuid',
|
||||||
provider_name: 'OpenAI',
|
provider_name: 'OpenAI',
|
||||||
@@ -147,6 +148,7 @@ describe('processModelsResponse — personas', () => {
|
|||||||
const models = processModelsResponse(apiResponse);
|
const models = processModelsResponse(apiResponse);
|
||||||
assert.equal(models[0].personaScope, 'global');
|
assert.equal(models[0].personaScope, 'global');
|
||||||
assert.equal(models[0].personaAvatar, '/avatars/code.png');
|
assert.equal(models[0].personaAvatar, '/avatars/code.png');
|
||||||
|
assert.equal(models[0].personaHandle, 'code-helper');
|
||||||
assert.equal(models[0].personaId, 'preset-uuid');
|
assert.equal(models[0].personaId, 'preset-uuid');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,8 +210,8 @@ describe('processModelsResponse — hidden models', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('marks models as hidden from user prefs', () => {
|
it('marks models as hidden from user prefs (composite key)', () => {
|
||||||
const hidden = new Set(['gpt-4o']);
|
const hidden = new Set(['c1:gpt-4o']);
|
||||||
const models = processModelsResponse(apiResponse, hidden);
|
const models = processModelsResponse(apiResponse, hidden);
|
||||||
assert.equal(models[0].hidden, true, 'gpt-4o should be hidden');
|
assert.equal(models[0].hidden, true, 'gpt-4o should be hidden');
|
||||||
assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden');
|
assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden');
|
||||||
@@ -221,7 +223,7 @@ describe('processModelsResponse — hidden models', () => {
|
|||||||
{ model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' },
|
{ model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const hidden = new Set(['gpt-4o']);
|
const hidden = new Set([':gpt-4o']);
|
||||||
const models = processModelsResponse(resp, hidden);
|
const models = processModelsResponse(resp, hidden);
|
||||||
assert.equal(models[0].hidden, false,
|
assert.equal(models[0].hidden, false,
|
||||||
'personas must NOT be hidden by base model hidden pref');
|
'personas must NOT be hidden by base model hidden pref');
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ describe('Edge: provider_config_id only (no config_id alias)', () => {
|
|||||||
|
|
||||||
describe('Edge: Hidden model preferences', () => {
|
describe('Edge: Hidden model preferences', () => {
|
||||||
it('marks hidden models correctly', () => {
|
it('marks hidden models correctly', () => {
|
||||||
const hidden = new Set(['gpt-4o-mini']);
|
const hidden = new Set(['cfg-global-openai:gpt-4o-mini']);
|
||||||
const models = processModelsResponse({ models: GLOBAL_MODELS }, hidden);
|
const models = processModelsResponse({ models: GLOBAL_MODELS }, hidden);
|
||||||
const mini = models.find(m => m.baseModelId === 'gpt-4o-mini');
|
const mini = models.find(m => m.baseModelId === 'gpt-4o-mini');
|
||||||
const main = models.find(m => m.baseModelId === 'gpt-4o');
|
const main = models.find(m => m.baseModelId === 'gpt-4o');
|
||||||
@@ -324,7 +324,7 @@ describe('Edge: Hidden model preferences', () => {
|
|||||||
|
|
||||||
it('personas are never hidden', () => {
|
it('personas are never hidden', () => {
|
||||||
const persona = personaModel('preset-1', 'gpt-4o', 'global');
|
const persona = personaModel('preset-1', 'gpt-4o', 'global');
|
||||||
const hidden = new Set(['gpt-4o']);
|
const hidden = new Set([':gpt-4o']);
|
||||||
const models = processModelsResponse({ models: [persona] }, hidden);
|
const models = processModelsResponse({ models: [persona] }, hidden);
|
||||||
assert.equal(models[0].hidden, false, 'personas must never be hidden');
|
assert.equal(models[0].hidden, false, 'personas must never be hidden');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,11 +154,12 @@ async function bulkSetVisibility(visibility) {
|
|||||||
|
|
||||||
// ── User Model Preferences ──────────────────
|
// ── User Model Preferences ──────────────────
|
||||||
|
|
||||||
async function toggleUserModelVisibility(modelId, currentlyHidden) {
|
async function toggleUserModelVisibility(modelId, providerConfigId, currentlyHidden) {
|
||||||
|
const compositeKey = (providerConfigId || '') + ':' + modelId;
|
||||||
try {
|
try {
|
||||||
await API.setModelPreference(modelId, !currentlyHidden);
|
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
|
||||||
if (currentlyHidden) App.hiddenModels.delete(modelId);
|
if (currentlyHidden) App.hiddenModels.delete(compositeKey);
|
||||||
else App.hiddenModels.add(modelId);
|
else App.hiddenModels.add(compositeKey);
|
||||||
await UI.loadUserModels();
|
await UI.loadUserModels();
|
||||||
await fetchModels(); // refresh model selector
|
await fetchModels(); // refresh model selector
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
@@ -170,12 +171,18 @@ async function bulkSetUserModelVisibility(show) {
|
|||||||
const shouldHide = !show;
|
const shouldHide = !show;
|
||||||
// Only update models not already in the desired state
|
// Only update models not already in the desired state
|
||||||
const toUpdate = models
|
const toUpdate = models
|
||||||
.map(m => m.baseModelId || m.id)
|
.filter(m => {
|
||||||
.filter(mid => App.hiddenModels.has(mid) !== shouldHide);
|
const compositeKey = (m.configId || '') + ':' + (m.baseModelId || m.id);
|
||||||
|
return App.hiddenModels.has(compositeKey) !== shouldHide;
|
||||||
|
})
|
||||||
|
.map(m => ({ model_id: m.baseModelId || m.id, provider_config_id: m.configId || '' }));
|
||||||
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
|
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
|
||||||
try {
|
try {
|
||||||
await API.bulkSetModelPreferences(toUpdate, shouldHide);
|
await API.bulkSetModelPreferences(toUpdate, shouldHide);
|
||||||
toUpdate.forEach(mid => shouldHide ? App.hiddenModels.add(mid) : App.hiddenModels.delete(mid));
|
toUpdate.forEach(e => {
|
||||||
|
const key = (e.provider_config_id || '') + ':' + e.model_id;
|
||||||
|
shouldHide ? App.hiddenModels.add(key) : App.hiddenModels.delete(key);
|
||||||
|
});
|
||||||
await UI.loadUserModels();
|
await UI.loadUserModels();
|
||||||
await fetchModels();
|
await fetchModels();
|
||||||
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
|
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
|
||||||
|
|||||||
@@ -194,6 +194,12 @@ const API = {
|
|||||||
updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); },
|
updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); },
|
||||||
deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); },
|
deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); },
|
||||||
|
|
||||||
|
// Channel participants (v0.23.0 — ICD §3.7)
|
||||||
|
listParticipants(channelId) { return this._get(`/api/v1/channels/${channelId}/participants`); },
|
||||||
|
addParticipant(channelId, data) { return this._post(`/api/v1/channels/${channelId}/participants`, data); },
|
||||||
|
updateParticipant(channelId, participantId, data) { return this._patch(`/api/v1/channels/${channelId}/participants/${participantId}`, data); },
|
||||||
|
removeParticipant(channelId, participantId) { return this._del(`/api/v1/channels/${channelId}/participants/${participantId}`); },
|
||||||
|
|
||||||
// Notification preferences (v0.20.0 Phase 3)
|
// Notification preferences (v0.20.0 Phase 3)
|
||||||
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
|
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
|
||||||
setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); },
|
setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); },
|
||||||
@@ -451,8 +457,8 @@ const API = {
|
|||||||
|
|
||||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||||
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
|
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
|
||||||
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
|
setModelPreference(modelId, providerConfigId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: providerConfigId, hidden }); },
|
||||||
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
|
bulkSetModelPreferences(entries, hidden) { return this._post('/api/v1/models/preferences/bulk', { entries, hidden }); },
|
||||||
|
|
||||||
// User personas
|
// User personas
|
||||||
listUserPersonas() { return this._get('/api/v1/personas'); },
|
listUserPersonas() { return this._get('/api/v1/personas'); },
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ async function fetchModels() {
|
|||||||
try {
|
try {
|
||||||
const prefData = await API.getModelPreferences();
|
const prefData = await API.getModelPreferences();
|
||||||
App.hiddenModels = new Set(
|
App.hiddenModels = new Set(
|
||||||
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
|
(prefData.preferences || []).filter(p => p.hidden).map(p =>
|
||||||
|
(p.provider_config_id || '') + ':' + p.model_id
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!App.hiddenModels) App.hiddenModels = new Set();
|
if (!App.hiddenModels) App.hiddenModels = new Set();
|
||||||
@@ -63,25 +65,27 @@ async function fetchModels() {
|
|||||||
App.models = (data.models || []).map(m => {
|
App.models = (data.models || []).map(m => {
|
||||||
const isPersona = !!m.is_persona;
|
const isPersona = !!m.is_persona;
|
||||||
const baseModelId = m.model_id || m.id;
|
const baseModelId = m.model_id || m.id;
|
||||||
|
const cfgId = m.config_id || m.provider_config_id || '';
|
||||||
const id = isPersona
|
const id = isPersona
|
||||||
? (m.persona_id || m.id)
|
? (m.persona_id || m.id)
|
||||||
: ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId);
|
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
baseModelId,
|
baseModelId,
|
||||||
name: m.display_name || baseModelId,
|
name: m.display_name || baseModelId,
|
||||||
provider: m.provider_name || m.provider || '',
|
provider: m.provider_name || m.provider || '',
|
||||||
configId: m.config_id || m.provider_config_id || null,
|
configId: cfgId || null,
|
||||||
model_type: m.model_type || 'chat',
|
model_type: m.model_type || 'chat',
|
||||||
capabilities: resolveCapabilities(m.capabilities, baseModelId),
|
capabilities: resolveCapabilities(m.capabilities, baseModelId),
|
||||||
isPersona,
|
isPersona,
|
||||||
personaId: m.persona_id || null,
|
personaId: m.persona_id || null,
|
||||||
|
personaHandle: m.persona_handle || null,
|
||||||
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
|
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
|
||||||
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
|
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
|
||||||
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
|
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
|
||||||
source: m.source || (isPersona ? 'persona' : 'global'),
|
source: m.source || (isPersona ? 'persona' : 'global'),
|
||||||
teamName: m.persona_team_name || null,
|
teamName: m.persona_team_name || null,
|
||||||
hidden: !isPersona && App.hiddenModels.has(baseModelId),
|
hidden: !isPersona && App.hiddenModels.has((cfgId || '') + ':' + baseModelId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -187,31 +187,44 @@ const ChannelModels = {
|
|||||||
* Shows a dropdown of channel models when user types @.
|
* Shows a dropdown of channel models when user types @.
|
||||||
*/
|
*/
|
||||||
onInput(inputEl) {
|
onInput(inputEl) {
|
||||||
if (this._roster.length < 2) return; // no autocomplete for single-model channels
|
|
||||||
|
|
||||||
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
|
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
|
||||||
const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart;
|
const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart;
|
||||||
|
|
||||||
// Find the @token being typed (search backward from cursor)
|
|
||||||
const before = text.slice(0, cursorPos);
|
const before = text.slice(0, cursorPos);
|
||||||
const atIdx = before.lastIndexOf('@');
|
const atIdx = before.lastIndexOf('@');
|
||||||
if (atIdx < 0) { this.hideAutocomplete(); return; }
|
if (atIdx < 0) { this.hideAutocomplete(); return; }
|
||||||
|
|
||||||
// @ must be at start or preceded by whitespace
|
|
||||||
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
|
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
|
||||||
this.hideAutocomplete();
|
this.hideAutocomplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const partial = before.slice(atIdx + 1).toLowerCase();
|
const partial = before.slice(atIdx + 1).toLowerCase().replace(/-/g, ' ');
|
||||||
const matches = this._roster.filter(m =>
|
if (partial.length === 0) {
|
||||||
m.display_name.toLowerCase().startsWith(partial) ||
|
// Just typed @ — show everything
|
||||||
m.display_name.toLowerCase().replace(/-/g, ' ').startsWith(partial.replace(/-/g, ' '))
|
}
|
||||||
);
|
|
||||||
|
// Build match list from ALL enabled models + personas (not just roster)
|
||||||
|
const allModels = (App.models || []).filter(m => !m.hidden);
|
||||||
|
const matches = allModels.filter(m => {
|
||||||
|
// Match against persona handle
|
||||||
|
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
|
||||||
|
// Match against model ID
|
||||||
|
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
|
||||||
|
// Match against display name
|
||||||
|
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
|
||||||
|
const firstName = name.split(' ')[0] || '';
|
||||||
|
|
||||||
|
if (partial.length === 0) return true; // show all on bare @
|
||||||
|
return handle.startsWith(partial)
|
||||||
|
|| modelId.startsWith(partial)
|
||||||
|
|| name.startsWith(partial)
|
||||||
|
|| firstName.startsWith(partial);
|
||||||
|
});
|
||||||
|
|
||||||
if (matches.length === 0) { this.hideAutocomplete(); return; }
|
if (matches.length === 0) { this.hideAutocomplete(); return; }
|
||||||
|
// Cap at 10 to keep dropdown manageable
|
||||||
this.showAutocomplete(inputEl, atIdx, cursorPos, matches);
|
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
|
||||||
},
|
},
|
||||||
|
|
||||||
showAutocomplete(inputEl, atIdx, cursorPos, matches) {
|
showAutocomplete(inputEl, atIdx, cursorPos, matches) {
|
||||||
@@ -221,19 +234,36 @@ const ChannelModels = {
|
|||||||
wrap.className = 'mention-ac';
|
wrap.className = 'mention-ac';
|
||||||
wrap.id = 'mentionAc';
|
wrap.id = 'mentionAc';
|
||||||
|
|
||||||
// Position relative to input
|
|
||||||
const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
|
const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
|
||||||
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
|
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
|
||||||
wrap.style.left = inputRect.left + 'px';
|
wrap.style.left = inputRect.left + 'px';
|
||||||
wrap.style.minWidth = '200px';
|
wrap.style.minWidth = '280px';
|
||||||
|
|
||||||
matches.forEach((m, i) => {
|
matches.forEach((m, i) => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : '');
|
item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : '');
|
||||||
item.dataset.name = m.display_name;
|
|
||||||
item.innerHTML = `<span class="mention-ac-name">${esc(m.display_name)}</span><span class="mention-ac-model">${esc(m.model_id)}</span>`;
|
// Determine the @handle to insert
|
||||||
|
const mentionToken = m.isPersona
|
||||||
|
? (m.personaHandle || m.name?.replace(/\s+/g, '-').toLowerCase() || m.id)
|
||||||
|
: (m.baseModelId || m.id);
|
||||||
|
item.dataset.handle = mentionToken;
|
||||||
|
|
||||||
|
const avatar = m.personaAvatar
|
||||||
|
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
|
||||||
|
: m.isPersona
|
||||||
|
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
|
||||||
|
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
|
||||||
|
|
||||||
|
const handleText = m.isPersona && m.personaHandle
|
||||||
|
? `@${esc(m.personaHandle)}`
|
||||||
|
: `@${esc(m.baseModelId || m.id)}`;
|
||||||
|
|
||||||
|
const providerHint = m.provider ? esc(m.provider) : '';
|
||||||
|
|
||||||
|
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
this._insertMention(inputEl, atIdx, cursorPos, m.display_name);
|
this._insertMention(inputEl, atIdx, cursorPos, mentionToken);
|
||||||
});
|
});
|
||||||
wrap.appendChild(item);
|
wrap.appendChild(item);
|
||||||
});
|
});
|
||||||
@@ -278,9 +308,7 @@ const ChannelModels = {
|
|||||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const active = items[activeIdx];
|
const active = items[activeIdx];
|
||||||
if (active) {
|
if (active) active.click();
|
||||||
active.click();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -326,6 +354,31 @@ const ChannelModels = {
|
|||||||
if (cm?.display_name) return cm.display_name;
|
if (cm?.display_name) return cm.display_name;
|
||||||
const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId);
|
const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId);
|
||||||
return m?.name || modelId;
|
return m?.name || modelId;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve persona avatar for a channel model roster entry.
|
||||||
|
* Looks up via persona_id in App.models.
|
||||||
|
*/
|
||||||
|
_resolveAvatar(rosterEntry) {
|
||||||
|
if (!rosterEntry.persona_id) return null;
|
||||||
|
const persona = App.models?.find(m => m.personaId === rosterEntry.persona_id);
|
||||||
|
return persona?.personaAvatar || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get persona info for a given model_id from the roster + App.models.
|
||||||
|
* Returns { displayName, avatar, personaId } or null.
|
||||||
|
*/
|
||||||
|
resolvePersonaInfo(modelId) {
|
||||||
|
const cm = this._roster.find(m => m.model_id === modelId);
|
||||||
|
if (!cm) return null;
|
||||||
|
const avatar = this._resolveAvatar(cm);
|
||||||
|
return {
|
||||||
|
displayName: cm.display_name || modelId,
|
||||||
|
avatar: avatar,
|
||||||
|
personaId: cm.persona_id || null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
178
src/js/chat.js
178
src/js/chat.js
@@ -185,7 +185,7 @@ async function toggleChannelAutoCompact(enabled) {
|
|||||||
|
|
||||||
async function loadChats() {
|
async function loadChats() {
|
||||||
try {
|
try {
|
||||||
const resp = await API.listChannels(1, 100, 'direct');
|
const resp = await API.listChannels(1, 100);
|
||||||
App.chats = (resp.data || []).map(c => ({
|
App.chats = (resp.data || []).map(c => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
title: c.title,
|
title: c.title,
|
||||||
@@ -404,6 +404,123 @@ async function newChat() {
|
|||||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
|
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── New Group Chat (v0.23.0) ─────────────────
|
||||||
|
// Creates a group channel and adds persona participants.
|
||||||
|
// Personas can @mention each other → AI-to-AI chaining.
|
||||||
|
|
||||||
|
async function newGroupChat() {
|
||||||
|
const personas = (App.models || []).filter(m => m.isPersona);
|
||||||
|
if (personas.length === 0) {
|
||||||
|
UI.toast('No personas available. Ask your admin to create one.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'confirm-overlay';
|
||||||
|
|
||||||
|
const personaCheckboxes = personas.map(p => {
|
||||||
|
const scope = p.personaScope || 'global';
|
||||||
|
const badge = scope === 'personal' ? ' <span class="dd-hint">personal</span>'
|
||||||
|
: scope === 'team' ? ` <span class="dd-hint">${esc(p.personaTeamName || 'team')}</span>` : '';
|
||||||
|
const handle = p.personaHandle ? ` <span class="group-persona-handle">@${esc(p.personaHandle)}</span>` : '';
|
||||||
|
return `<label class="group-persona-item">
|
||||||
|
<input type="checkbox" value="${esc(p.personaId)}" data-name="${esc(p.name)}" data-handle="${esc(p.personaHandle || '')}">
|
||||||
|
<span class="group-persona-name">${esc(p.name)}${handle}${badge}</span>
|
||||||
|
<span class="group-persona-model">${esc(p.baseModelId || '')}</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="confirm-dialog" style="max-width:480px">
|
||||||
|
<div class="confirm-header">New Group Chat</div>
|
||||||
|
<div class="confirm-body">
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<label style="font-size:12px;color:var(--text-secondary);display:block;margin-bottom:4px">Channel Name</label>
|
||||||
|
<input id="groupChatTitle" type="text" class="settings-input" placeholder="e.g. Code Review Team" style="width:100%;box-sizing:border-box" autofocus>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;color:var(--text-secondary);display:block;margin-bottom:6px">Add Personas (select at least one)</label>
|
||||||
|
<div class="group-persona-list" style="max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:4px">
|
||||||
|
${personaCheckboxes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-footer">
|
||||||
|
<button class="btn-small" data-action="cancel">Cancel</button>
|
||||||
|
<button class="btn-small btn-primary" data-action="ok">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
function close(result) {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') close(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
overlay.querySelector('[data-action="ok"]').addEventListener('click', async () => {
|
||||||
|
const title = overlay.querySelector('#groupChatTitle').value.trim() || 'Group Chat';
|
||||||
|
const checked = [...overlay.querySelectorAll('.group-persona-list input:checked')];
|
||||||
|
if (checked.length === 0) {
|
||||||
|
UI.toast('Select at least one persona', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create group channel
|
||||||
|
const resp = await API.createChannel(title, null, null, 'group');
|
||||||
|
const chat = {
|
||||||
|
id: resp.id, title: resp.title, type: 'group', model: null,
|
||||||
|
messages: [], messageCount: 0, projectId: resp.project_id || null,
|
||||||
|
workspace_id: resp.workspace_id || null, updatedAt: resp.updated_at,
|
||||||
|
settings: resp.settings || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add persona participants
|
||||||
|
for (const cb of checked) {
|
||||||
|
try {
|
||||||
|
await API.addParticipant(chat.id, {
|
||||||
|
participant_type: 'persona',
|
||||||
|
participant_id: cb.value,
|
||||||
|
role: 'member'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to add persona:', cb.dataset.name, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
App.chats.unshift(chat);
|
||||||
|
App.currentChatId = chat.id;
|
||||||
|
UI.renderChatList();
|
||||||
|
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
|
||||||
|
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
||||||
|
|
||||||
|
// Load the channel model roster (populated by participant auto-add)
|
||||||
|
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chat.id);
|
||||||
|
|
||||||
|
UI.showEmptyState();
|
||||||
|
const names = checked.map(cb => cb.dataset.name).join(', ');
|
||||||
|
UI.toast(`Group created with ${checked.length} persona${checked.length > 1 ? 's' : ''}: ${names}`, 'success');
|
||||||
|
ChatInput.focus();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to create group: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
close(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.querySelector('#groupChatTitle').focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteChat(chatId) {
|
async function deleteChat(chatId) {
|
||||||
if (!await showConfirm('Delete this chat?')) return;
|
if (!await showConfirm('Delete this chat?')) return;
|
||||||
try {
|
try {
|
||||||
@@ -908,6 +1025,65 @@ function _initChatListeners() {
|
|||||||
// Multi-model channel roster (v0.20.0)
|
// Multi-model channel roster (v0.20.0)
|
||||||
if (typeof ChannelModels !== 'undefined') ChannelModels.init();
|
if (typeof ChannelModels !== 'undefined') ChannelModels.init();
|
||||||
|
|
||||||
|
// AI-to-AI chain: handle messages arriving via WebSocket (v0.23.0)
|
||||||
|
// When a persona @mentions another persona, the follow-up completion
|
||||||
|
// runs server-side and delivers the result via WS, not SSE.
|
||||||
|
Events.on('message.created', (payload) => {
|
||||||
|
if (!payload || !payload.channel_id) return;
|
||||||
|
if (payload.channel_id !== App.currentChatId) return;
|
||||||
|
|
||||||
|
const chat = App.chats.find(c => c.id === payload.channel_id);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
// Append the chained message
|
||||||
|
chat.messages.push({
|
||||||
|
id: payload.id,
|
||||||
|
parent_id: null,
|
||||||
|
role: payload.role || 'assistant',
|
||||||
|
content: payload.content || '',
|
||||||
|
model: payload.model || '',
|
||||||
|
modelName: payload.display_name || payload.model || '',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tool_calls: null,
|
||||||
|
metadata: payload.chain_depth ? { chain_depth: payload.chain_depth } : null,
|
||||||
|
siblingCount: 1,
|
||||||
|
siblingIndex: 0,
|
||||||
|
participant_type: payload.participant_type,
|
||||||
|
participant_id: payload.participant_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove typing indicator and re-render
|
||||||
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typing indicators from persona chain activity
|
||||||
|
Events.on('typing.start', (payload) => {
|
||||||
|
if (!payload || payload.channel_id !== App.currentChatId) return;
|
||||||
|
if (payload.participant_type !== 'persona') return;
|
||||||
|
|
||||||
|
// Show typing indicator with persona name
|
||||||
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
const container = document.getElementById('chatMessages');
|
||||||
|
if (!container) return;
|
||||||
|
const typing = document.createElement('div');
|
||||||
|
typing.id = 'typingIndicator';
|
||||||
|
typing.className = 'message assistant';
|
||||||
|
const name = payload.display_name || 'AI';
|
||||||
|
typing.innerHTML = `
|
||||||
|
<div class="msg-row">
|
||||||
|
<div class="msg-avatar"><span class="avatar-emoji">⚡</span></div>
|
||||||
|
<div class="msg-body"><span class="chain-typing-name">${name}</span> <div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||||||
|
</div>`;
|
||||||
|
container.appendChild(typing);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
Events.on('typing.stop', (payload) => {
|
||||||
|
if (!payload || payload.channel_id !== App.currentChatId) return;
|
||||||
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
// Close modals on overlay click
|
// Close modals on overlay click
|
||||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||||
overlay.addEventListener('click', (e) => {
|
overlay.addEventListener('click', (e) => {
|
||||||
|
|||||||
@@ -630,7 +630,7 @@ function _showSaveToNoteModal(opts) {
|
|||||||
|
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.id = 'saveToNoteModal';
|
modal.id = 'saveToNoteModal';
|
||||||
modal.className = 'modal-overlay';
|
modal.className = 'modal-overlay active';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-content save-to-note-modal">
|
<div class="modal-content save-to-note-modal">
|
||||||
<h3>Save to Note</h3>
|
<h3>Save to Note</h3>
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ function avatarHTML(role, avatarDataURI) {
|
|||||||
|
|
||||||
// Look up the current model/persona avatar for assistant messages.
|
// Look up the current model/persona avatar for assistant messages.
|
||||||
function assistantAvatarURI(msg) {
|
function assistantAvatarURI(msg) {
|
||||||
// Try to find persona avatar from the model that generated this message
|
// 1. Check channel model roster for persona avatar (v0.23.0)
|
||||||
|
if (typeof ChannelModels !== 'undefined' && msg?.model) {
|
||||||
|
const info = ChannelModels.resolvePersonaInfo(msg.model);
|
||||||
|
if (info?.avatar) return info.avatar;
|
||||||
|
}
|
||||||
|
// 2. Check participant_id directly (for chained messages)
|
||||||
|
if (msg?.participant_type === 'persona' && msg?.participant_id) {
|
||||||
|
const m = App.models.find(x => x.personaId === msg.participant_id);
|
||||||
|
if (m?.personaAvatar) return m.personaAvatar;
|
||||||
|
}
|
||||||
|
// 3. Standard lookup by model ID or persona ID
|
||||||
const modelId = msg?.model || msg?.persona_id;
|
const modelId = msg?.model || msg?.persona_id;
|
||||||
if (modelId) {
|
if (modelId) {
|
||||||
const m = App.models.find(x => x.id === modelId || x.personaId === modelId);
|
const m = App.models.find(x => x.id === modelId || x.personaId === modelId);
|
||||||
@@ -37,6 +47,7 @@ function renderPersonaForm(containerEl, options = {}) {
|
|||||||
|
|
||||||
containerEl.innerHTML = `
|
containerEl.innerHTML = `
|
||||||
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
|
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
|
||||||
|
<div class="form-group"><label>@mention Handle</label><input type="text" id="${pfx}_handle" placeholder="code-reviewer" maxlength="50" class="mono-input"><div class="form-hint">Auto-generated from name. Used for @mentions in chat.</div></div>
|
||||||
${showAvatar ? `
|
${showAvatar ? `
|
||||||
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
|
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
|
||||||
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
|
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
|
||||||
@@ -95,6 +106,22 @@ function renderPersonaForm(containerEl, options = {}) {
|
|||||||
if (options.onCancel) options.onCancel();
|
if (options.onCancel) options.onCancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-generate handle from name
|
||||||
|
let _handleManuallyEdited = false;
|
||||||
|
document.getElementById(`${pfx}_handle`)?.addEventListener('input', () => { _handleManuallyEdited = true; });
|
||||||
|
document.getElementById(`${pfx}_name`)?.addEventListener('input', () => {
|
||||||
|
if (_handleManuallyEdited) return;
|
||||||
|
const name = document.getElementById(`${pfx}_name`)?.value || '';
|
||||||
|
const handle = name.toLowerCase().trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 50);
|
||||||
|
const hEl = document.getElementById(`${pfx}_handle`);
|
||||||
|
if (hEl) hEl.value = handle;
|
||||||
|
});
|
||||||
|
|
||||||
// Submit wiring
|
// Submit wiring
|
||||||
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
|
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
|
||||||
if (options.onSubmit) options.onSubmit(form.getValues());
|
if (options.onSubmit) options.onSubmit(form.getValues());
|
||||||
@@ -104,6 +131,7 @@ function renderPersonaForm(containerEl, options = {}) {
|
|||||||
getValues() {
|
getValues() {
|
||||||
const v = {
|
const v = {
|
||||||
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
|
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
|
||||||
|
handle: document.getElementById(`${pfx}_handle`)?.value.trim() || '',
|
||||||
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
|
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
|
||||||
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
|
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
|
||||||
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
|
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
|
||||||
@@ -129,6 +157,7 @@ function renderPersonaForm(containerEl, options = {}) {
|
|||||||
setValues(p) {
|
setValues(p) {
|
||||||
const el = id => document.getElementById(`${pfx}_${id}`);
|
const el = id => document.getElementById(`${pfx}_${id}`);
|
||||||
if (el('name')) el('name').value = p.name || '';
|
if (el('name')) el('name').value = p.name || '';
|
||||||
|
if (el('handle')) { el('handle').value = p.handle || ''; _handleManuallyEdited = !!p.handle; }
|
||||||
if (el('desc')) el('desc').value = p.description || '';
|
if (el('desc')) el('desc').value = p.description || '';
|
||||||
if (el('prompt')) el('prompt').value = p.system_prompt || '';
|
if (el('prompt')) el('prompt').value = p.system_prompt || '';
|
||||||
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
|
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
|
||||||
@@ -141,7 +170,7 @@ function renderPersonaForm(containerEl, options = {}) {
|
|||||||
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
|
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
|
||||||
},
|
},
|
||||||
clearForm() {
|
clearForm() {
|
||||||
['name','desc','prompt','temp','maxTokens'].forEach(f => {
|
['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
|
||||||
const el = document.getElementById(`${pfx}_${f}`);
|
const el = document.getElementById(`${pfx}_${f}`);
|
||||||
if (el) el.value = '';
|
if (el) el.value = '';
|
||||||
});
|
});
|
||||||
@@ -288,6 +317,8 @@ const UI = {
|
|||||||
// ── Chat item renderer ──────────────────
|
// ── Chat item renderer ──────────────────
|
||||||
const renderChatItem = (c) => {
|
const renderChatItem = (c) => {
|
||||||
const time = _relativeTime(c.updatedAt);
|
const time = _relativeTime(c.updatedAt);
|
||||||
|
const typeIcon = c.type === 'group' ? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||||||
|
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
|
||||||
return `
|
return `
|
||||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||||
onclick="selectChat('${c.id}')"
|
onclick="selectChat('${c.id}')"
|
||||||
@@ -295,7 +326,7 @@ const UI = {
|
|||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart="onChatDragStart(event,'${c.id}')"
|
ondragstart="onChatDragStart(event,'${c.id}')"
|
||||||
ondragend="onChatDragEnd(event)">
|
ondragend="onChatDragEnd(event)">
|
||||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${esc(c.title)}</span>
|
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
|
||||||
<span class="chat-item-time">${time}</span>
|
<span class="chat-item-time">${time}</span>
|
||||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -487,9 +518,11 @@ const UI = {
|
|||||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
let assistantLabel = 'Assistant';
|
let assistantLabel = 'Assistant';
|
||||||
if (!isUser) {
|
if (!isUser) {
|
||||||
// Use stored modelName, or resolve from current model list, or fall back to model id
|
// Use stored modelName, or resolve from channel roster, or model list, or fall back
|
||||||
if (msg.modelName) {
|
if (msg.modelName) {
|
||||||
assistantLabel = msg.modelName;
|
assistantLabel = msg.modelName;
|
||||||
|
} else if (typeof ChannelModels !== 'undefined' && msg.model) {
|
||||||
|
assistantLabel = ChannelModels.resolveDisplayName(msg.model);
|
||||||
} else if (msg.model) {
|
} else if (msg.model) {
|
||||||
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
|
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
|
||||||
assistantLabel = m?.name || msg.model;
|
assistantLabel = m?.name || msg.model;
|
||||||
@@ -598,7 +631,15 @@ const UI = {
|
|||||||
|
|
||||||
const label = modelName || 'Assistant';
|
const label = modelName || 'Assistant';
|
||||||
const currentModel = App.findModel(App.settings.model);
|
const currentModel = App.findModel(App.settings.model);
|
||||||
const streamAvatar = currentModel?.personaAvatar || null;
|
// Resolve avatar: channel roster persona → selected model → null
|
||||||
|
let streamAvatar = null;
|
||||||
|
if (typeof ChannelModels !== 'undefined') {
|
||||||
|
const info = ChannelModels.resolvePersonaInfo(currentModel?.baseModelId);
|
||||||
|
if (info?.avatar) streamAvatar = info.avatar;
|
||||||
|
}
|
||||||
|
if (!streamAvatar) {
|
||||||
|
streamAvatar = currentModel?.personaAvatar || null;
|
||||||
|
}
|
||||||
|
|
||||||
// Multi-model state: when model_start events arrive, we create
|
// Multi-model state: when model_start events arrive, we create
|
||||||
// separate message bubbles per model. Without them, single-bubble.
|
// separate message bubbles per model. Without them, single-bubble.
|
||||||
@@ -911,8 +952,9 @@ const UI = {
|
|||||||
div.className = 'model-dropdown-item';
|
div.className = 'model-dropdown-item';
|
||||||
div.dataset.value = m.id;
|
div.dataset.value = m.id;
|
||||||
const avatar = m.personaAvatar ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : '';
|
const avatar = m.personaAvatar ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : '';
|
||||||
|
const handleHint = m.isPersona && m.personaHandle ? `<span class="item-handle">@${esc(m.personaHandle)}</span>` : '';
|
||||||
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
|
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
|
||||||
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}${handleHint}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||||
div.addEventListener('click', () => {
|
div.addEventListener('click', () => {
|
||||||
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
||||||
UI._closeModelDropdown();
|
UI._closeModelDropdown();
|
||||||
|
|||||||
@@ -44,9 +44,71 @@ function formatMessage(content) {
|
|||||||
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
|
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @mention highlighting (v0.23.0): style @Name references as pills
|
||||||
|
html = _highlightMentions(html);
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight @mentions in rendered HTML as styled pills.
|
||||||
|
* Matches @Name patterns against the channel model roster.
|
||||||
|
* Only highlights inside text nodes (not inside code/pre/a tags).
|
||||||
|
*/
|
||||||
|
function _highlightMentions(html) {
|
||||||
|
if (typeof ChannelModels === 'undefined') return html;
|
||||||
|
const roster = ChannelModels.getRoster();
|
||||||
|
if (!roster || roster.length < 2) return html;
|
||||||
|
|
||||||
|
// Build patterns from handles (primary) and display names (fallback)
|
||||||
|
const patterns = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const m of roster) {
|
||||||
|
// Handle pattern (preferred)
|
||||||
|
if (m.handle) {
|
||||||
|
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
if (!seen.has(escaped)) {
|
||||||
|
patterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||||
|
seen.add(escaped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Display name pattern (hyphenated form)
|
||||||
|
if (m.display_name) {
|
||||||
|
const hyphenated = m.display_name.replace(/\s+/g, '-');
|
||||||
|
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
if (!seen.has(escaped.toLowerCase())) {
|
||||||
|
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
|
||||||
|
patterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||||
|
seen.add(escaped.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @all
|
||||||
|
patterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||||
|
|
||||||
|
// Don't highlight inside <code>, <pre>, <a> tags
|
||||||
|
const parts = html.split(/(<[^>]+>)/);
|
||||||
|
let inCode = false;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
if (part.startsWith('<')) {
|
||||||
|
const tag = part.toLowerCase();
|
||||||
|
if (tag.startsWith('<code') || tag.startsWith('<pre') || tag.startsWith('<a ')) inCode = true;
|
||||||
|
if (tag.startsWith('</code') || tag.startsWith('</pre') || tag.startsWith('</a')) inCode = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCode) continue;
|
||||||
|
let text = part;
|
||||||
|
for (const re of patterns) {
|
||||||
|
text = text.replace(re, '<span class="mention-pill">$1</span>');
|
||||||
|
}
|
||||||
|
parts[i] = text;
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function _formatMarked(text) {
|
function _formatMarked(text) {
|
||||||
// ── Unwrap outer ```markdown wrappers ────────────────────────
|
// ── Unwrap outer ```markdown wrappers ────────────────────────
|
||||||
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
|
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
|
||||||
|
|||||||
@@ -766,7 +766,9 @@ Object.assign(UI, {
|
|||||||
try {
|
try {
|
||||||
const prefData = await API.getModelPreferences();
|
const prefData = await API.getModelPreferences();
|
||||||
App.hiddenModels = new Set(
|
App.hiddenModels = new Set(
|
||||||
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
|
(prefData.preferences || []).filter(p => p.hidden).map(p =>
|
||||||
|
(p.provider_config_id || '') + ':' + p.model_id
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!App.hiddenModels) App.hiddenModels = new Set();
|
if (!App.hiddenModels) App.hiddenModels = new Set();
|
||||||
@@ -781,11 +783,13 @@ Object.assign(UI, {
|
|||||||
}
|
}
|
||||||
el.innerHTML = models.map(m => {
|
el.innerHTML = models.map(m => {
|
||||||
const mid = m.model_id || m.id;
|
const mid = m.model_id || m.id;
|
||||||
|
const cfgId = m.config_id || m.provider_config_id || '';
|
||||||
|
const compositeKey = (cfgId || '') + ':' + mid;
|
||||||
const caps = m.capabilities || {};
|
const caps = m.capabilities || {};
|
||||||
const badges = renderCapBadges(caps, { compact: true });
|
const badges = renderCapBadges(caps, { compact: true });
|
||||||
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
|
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
|
||||||
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
|
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
|
||||||
const hidden = App.hiddenModels.has(mid);
|
const hidden = App.hiddenModels.has(compositeKey);
|
||||||
const toggleCls = hidden ? '' : 'enabled';
|
const toggleCls = hidden ? '' : 'enabled';
|
||||||
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
|
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
|
||||||
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
|
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
|
||||||
@@ -793,7 +797,7 @@ Object.assign(UI, {
|
|||||||
<span class="model-caps-inline">${badges}</span>
|
<span class="model-caps-inline">${badges}</span>
|
||||||
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
|
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
|
||||||
${src}
|
${src}
|
||||||
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
|
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', '${esc(cfgId)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
|||||||
640
store/interfaces.go
Normal file
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