Changeset 0.23.0 (#153)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,10 +106,9 @@ window.addEventListener('unhandledrejection', function(e) {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span>New Project</span>
</button>
<button class="split-dropdown-item disabled" title="Coming soon">
<button class="split-dropdown-item" onclick="closeNewChatMenu(); newGroupChat();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Group Chat</span>
<span class="dd-hint">soon</span>
</button>
<button class="split-dropdown-item disabled" title="Coming soon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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