Changeset 0.23.0 (#153)
This commit is contained in:
@@ -79,7 +79,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
Capabilities: caps,
|
||||
Pricing: entry.Pricing,
|
||||
Scope: models.ScopeGlobal,
|
||||
Hidden: hiddenMap[entry.ModelID],
|
||||
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
Pricing: entry.Pricing,
|
||||
Scope: models.ScopeTeam,
|
||||
OwnerID: prov.OwnerID,
|
||||
Hidden: hiddenMap[entry.ModelID],
|
||||
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
||||
})
|
||||
countTeam++
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
Pricing: entry.Pricing,
|
||||
Scope: models.ScopePersonal,
|
||||
OwnerID: &userID,
|
||||
Hidden: hiddenMap[entry.ModelID],
|
||||
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
||||
})
|
||||
countPersonal++
|
||||
}
|
||||
@@ -211,6 +211,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
Capabilities: caps,
|
||||
IsPersona: true,
|
||||
PersonaID: p.ID,
|
||||
PersonaHandle: p.Handle,
|
||||
PersonaScope: p.Scope,
|
||||
PersonaAvatar: p.Avatar,
|
||||
PersonaTeamName: teamName(p, stores, ctx),
|
||||
@@ -223,7 +224,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
ToolGrants: toolGrants,
|
||||
Scope: p.Scope,
|
||||
OwnerID: p.OwnerID,
|
||||
Hidden: hiddenMap[p.ID],
|
||||
Hidden: false, // ICD §11.2: persona visibility via grants, not model prefs
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,12 @@ CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_private BOOLEAN DEFAULT false,
|
||||
proxy_mode TEXT NOT NULL DEFAULT 'system'
|
||||
CHECK (proxy_mode IN ('system', 'direct', 'custom')),
|
||||
proxy_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT chk_proxy_url CHECK (proxy_mode != 'custom' OR proxy_url IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
||||
@@ -49,6 +53,8 @@ COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team us
|
||||
COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)';
|
||||
COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
|
||||
COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)';
|
||||
COMMENT ON COLUMN provider_configs.proxy_mode IS 'system=env proxy, direct=no proxy, custom=explicit URL';
|
||||
COMMENT ON COLUMN provider_configs.proxy_url IS 'Explicit proxy URL when proxy_mode=custom (http/https/socks5)';
|
||||
|
||||
|
||||
-- =========================================
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
handle VARCHAR(50),
|
||||
description TEXT DEFAULT '',
|
||||
icon VARCHAR(10) DEFAULT '',
|
||||
avatar TEXT DEFAULT '',
|
||||
@@ -48,17 +49,54 @@ CREATE TABLE IF NOT EXISTS personas (
|
||||
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = true;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_personas_handle ON personas(handle) WHERE handle IS NOT NULL;
|
||||
DROP TRIGGER IF EXISTS personas_updated_at ON personas;
|
||||
CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN personas.scope IS 'global=all users, team=team members, personal=creator only';
|
||||
COMMENT ON COLUMN personas.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal';
|
||||
COMMENT ON COLUMN personas.handle IS 'Unique @mention handle, e.g. veronica-sharpe. Auto-generated from name.';
|
||||
COMMENT ON COLUMN personas.is_shared IS 'Personal personas shared with others (read-only)';
|
||||
COMMENT ON COLUMN personas.memory_enabled IS 'Whether memory tools and extraction are active for this persona';
|
||||
COMMENT ON COLUMN personas.memory_extraction_prompt IS 'Custom extraction prompt; NULL = use system default';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- PERSONA GROUPS (v0.23.0)
|
||||
-- =========================================
|
||||
-- Saved roster templates. A group is a named set of personas
|
||||
-- that can be stamped onto any new conversation.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
scope VARCHAR(10) NOT NULL DEFAULT 'personal'
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_groups_owner ON persona_groups(owner_id);
|
||||
DROP TRIGGER IF EXISTS persona_groups_updated_at ON persona_groups;
|
||||
CREATE TRIGGER persona_groups_updated_at BEFORE UPDATE ON persona_groups
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_group_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES persona_groups(id) ON DELETE CASCADE,
|
||||
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
is_leader BOOLEAN NOT NULL DEFAULT false,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
UNIQUE(group_id, persona_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_group_members_group ON persona_group_members(group_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- PERSONA GRANTS (what a persona can do)
|
||||
-- =========================================
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 005 Channels & Conversations
|
||||
-- ==========================================
|
||||
-- Channels, messages, members, models, cursors, folders, user prefs.
|
||||
-- Channels, messages, participants, models, cursors, folders, user prefs.
|
||||
-- ICD §3 (Channels & Conversations)
|
||||
-- Note: project_id and workspace_id FKs added by 010_projects.sql
|
||||
-- ==========================================
|
||||
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
type VARCHAR(20) DEFAULT 'direct'
|
||||
CHECK (type IN ('direct', 'group', 'channel')),
|
||||
CHECK (type IN ('direct', 'group', 'workflow')),
|
||||
model VARCHAR(100),
|
||||
system_prompt TEXT,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
@@ -73,7 +73,7 @@ DO $$ BEGIN
|
||||
END $$;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
|
||||
COMMENT ON COLUMN channels.type IS 'direct=single-user chat, group=multi-user/multi-model, workflow=staged channel';
|
||||
|
||||
|
||||
-- =========================================
|
||||
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type VARCHAR(10) DEFAULT 'user',
|
||||
participant_id VARCHAR(255),
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -110,35 +111,53 @@ COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CHANNEL MEMBERS & MODELS
|
||||
-- CHANNEL PARTICIPANTS & MODELS
|
||||
-- =========================================
|
||||
-- ICD §3.7: Polymorphic participant system.
|
||||
-- participant_type determines what participant_id references:
|
||||
-- user → users.id
|
||||
-- persona → personas.id
|
||||
-- session → opaque session token
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
CREATE TABLE IF NOT EXISTS channel_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) DEFAULT 'member',
|
||||
participant_type VARCHAR(10) NOT NULL DEFAULT 'user'
|
||||
CHECK (participant_type IN ('user', 'persona', 'session')),
|
||||
participant_id UUID NOT NULL,
|
||||
role VARCHAR(10) NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'member', 'observer')),
|
||||
display_name VARCHAR(200),
|
||||
avatar_url TEXT,
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_id)
|
||||
UNIQUE(channel_id, participant_type, participant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_models (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
model_id VARCHAR(255) NOT NULL,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
||||
display_name VARCHAR(100),
|
||||
system_prompt TEXT,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, model_id)
|
||||
added_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Raw model entries (no persona): unique per channel+model+provider
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_raw
|
||||
ON channel_models(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL;
|
||||
-- Persona entries: one per persona per channel (same model allowed for different personas)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_persona
|
||||
ON channel_models(channel_id, persona_id) WHERE persona_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
|
||||
@@ -167,13 +186,14 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
hidden BOOLEAN DEFAULT false,
|
||||
preferred_temperature FLOAT,
|
||||
preferred_max_tokens INT,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, model_id)
|
||||
UNIQUE(user_id, model_id, provider_config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
|
||||
@@ -16,8 +16,11 @@ CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
settings TEXT DEFAULT '{}',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_private INTEGER DEFAULT 0,
|
||||
proxy_mode TEXT NOT NULL DEFAULT 'system' CHECK (proxy_mode IN ('system', 'direct', 'custom')),
|
||||
proxy_url TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
CHECK (proxy_mode != 'custom' OR proxy_url IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
handle TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '',
|
||||
avatar TEXT DEFAULT '',
|
||||
@@ -27,6 +28,31 @@ CREATE TABLE IF NOT EXISTS personas (
|
||||
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_personas_handle ON personas(handle) WHERE handle IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT 'personal' CHECK (scope IN ('global', 'team', 'personal')),
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_groups_owner ON persona_groups(owner_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_group_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_id TEXT NOT NULL REFERENCES persona_groups(id) ON DELETE CASCADE,
|
||||
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
is_leader INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(group_id, persona_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_group_members_group ON persona_group_members(group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'channel')),
|
||||
type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'workflow')),
|
||||
model TEXT,
|
||||
system_prompt TEXT,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type TEXT DEFAULT 'user',
|
||||
participant_id TEXT,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -64,32 +65,44 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT DEFAULT 'member',
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
last_read_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, user_id)
|
||||
CREATE TABLE IF NOT EXISTS channel_participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
participant_type TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (participant_type IN ('user', 'persona', 'session')),
|
||||
participant_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'member', 'observer')),
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
last_read_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, participant_type, participant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_models (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||
display_name TEXT,
|
||||
system_prompt TEXT,
|
||||
settings TEXT DEFAULT '{}',
|
||||
is_default INTEGER DEFAULT 0,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, model_id)
|
||||
added_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Raw model entries (no persona): unique per channel+model+provider
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_raw
|
||||
ON channel_models(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL;
|
||||
-- Persona entries: one per persona per channel
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_persona
|
||||
ON channel_models(channel_id, persona_id) WHERE persona_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
@@ -108,13 +121,14 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
preferred_temperature REAL,
|
||||
preferred_max_tokens INTEGER,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, model_id)
|
||||
UNIQUE(user_id, model_id, provider_config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
|
||||
@@ -264,7 +264,7 @@ func TruncateAll(t *testing.T) {
|
||||
"channel_cursors",
|
||||
"messages",
|
||||
"channel_models",
|
||||
"channel_members",
|
||||
"channel_participants",
|
||||
"channel_knowledge_bases",
|
||||
"persona_knowledge_bases",
|
||||
"project_notes",
|
||||
@@ -278,6 +278,8 @@ func TruncateAll(t *testing.T) {
|
||||
"user_model_settings",
|
||||
"model_catalog",
|
||||
"persona_grants",
|
||||
"persona_group_members",
|
||||
"persona_groups",
|
||||
"personas",
|
||||
"provider_configs",
|
||||
"team_members",
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
type createChannelRequest struct {
|
||||
Title string `json:"title" binding:"required,max=500"`
|
||||
Type string `json:"type,omitempty"` // direct (default), group, channel
|
||||
Type string `json:"type,omitempty"` // direct (default), group, workflow
|
||||
Description string `json:"description,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
@@ -334,17 +334,17 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
ch.Tags = tags
|
||||
ch.MessageCount = 0
|
||||
|
||||
// Auto-create channel_member for the creator
|
||||
// Auto-create channel_participant for the creator
|
||||
if database.IsSQLite() {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_members (id, channel_id, user_id, role)
|
||||
VALUES (?, ?, ?, 'owner')
|
||||
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
|
||||
VALUES (?, ?, 'user', ?, 'owner')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, store.NewID(), ch.ID, userID)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_members (channel_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
|
||||
VALUES ($1, 'user', $2, 'owner')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, userID)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/health"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/mentions"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/routing"
|
||||
@@ -201,9 +200,13 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
// Verify channel ownership
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
// Verify participation: check channel_participants first, fall back to
|
||||
// legacy channels.user_id ownership for direct channels.
|
||||
isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
|
||||
if !isParticipant {
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
|
||||
@@ -332,7 +335,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Load conversation history
|
||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID)
|
||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID, model)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
@@ -365,7 +368,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
// Persist user message (text-only for storage — multimodal parts are ephemeral)
|
||||
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil)
|
||||
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist user message: %v", err)
|
||||
}
|
||||
@@ -383,17 +386,65 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// Resolve effective workspace: channel.workspace_id > project.workspace_id
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||
|
||||
// ── Multi-model @mention routing (v0.20.0) ──────────
|
||||
// Check if the message @mentions specific channel models.
|
||||
// If multiple models are targeted, fan out sequentially.
|
||||
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
||||
if len(roster) > 1 {
|
||||
parsed := mentions.Parse(req.Content, roster)
|
||||
targets := mentions.ResolvedModels(parsed)
|
||||
if len(targets) > 1 {
|
||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req)
|
||||
// ── @mention routing (v0.23.0) ──────────────
|
||||
// Resolve @model-id or @persona-handle directly against the enabled
|
||||
// model catalog and personas table. Works in ANY chat — no roster needed.
|
||||
if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" {
|
||||
req.Model = mentionModel
|
||||
if mentionConfig != "" {
|
||||
req.ProviderConfigID = mentionConfig
|
||||
}
|
||||
if mentionPersona != nil {
|
||||
personaID = mentionPersona.ID
|
||||
personaThinkingBudget = mentionPersona.ThinkingBudget
|
||||
if mentionPersona.SystemPrompt != "" {
|
||||
if len(messages) > 0 && messages[0].Role == "system" {
|
||||
messages[0].Content = mentionPersona.SystemPrompt
|
||||
} else {
|
||||
messages = append([]providers.Message{{Role: "system", Content: mentionPersona.SystemPrompt}}, messages...)
|
||||
}
|
||||
}
|
||||
// Context boundary: tell this persona to ignore other personas' styles
|
||||
boundary := fmt.Sprintf(
|
||||
"[You are now %s. Previous assistant messages may be from different AI personas with different styles — ignore their tone, character, and mannerisms. Respond only as yourself per your system prompt.]",
|
||||
mentionPersona.Name,
|
||||
)
|
||||
if len(messages) >= 2 {
|
||||
last := messages[len(messages)-1]
|
||||
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
|
||||
messages = append(messages, last)
|
||||
}
|
||||
}
|
||||
// Re-resolve provider config for the @mentioned target
|
||||
providerCfg, providerID, model, configID, providerScope, err = h.resolveConfig(userID, channelID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
caps = h.getModelCapabilities(c, model, configID)
|
||||
if personaThinkingBudget != nil && *personaThinkingBudget > 0 {
|
||||
if providerCfg.Settings == nil {
|
||||
providerCfg.Settings = make(map[string]interface{})
|
||||
}
|
||||
providerCfg.Settings["extended_thinking"] = true
|
||||
providerCfg.Settings["thinking_budget"] = *personaThinkingBudget
|
||||
}
|
||||
provider, err = providers.Get(providerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// No @mention: inject a boundary if conversation has persona responses
|
||||
// to prevent the default model from mimicking persona styles.
|
||||
if h.conversationHasPersonaMessages(c.Request.Context(), channelID) {
|
||||
boundary := "[Previous assistant messages in this conversation may include responses from AI personas with specific characters and styles. You are the default assistant — respond in your own natural style, not theirs.]"
|
||||
if len(messages) >= 2 {
|
||||
last := messages[len(messages)-1]
|
||||
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
|
||||
messages = append(messages, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build provider request
|
||||
@@ -540,7 +591,11 @@ func (h *CompletionHandler) multiModelStream(
|
||||
|
||||
// Persist assistant message with model attribution
|
||||
if result.Content != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
|
||||
pID := ""
|
||||
if target.PersonaID != nil {
|
||||
pID = *target.PersonaID
|
||||
}
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, pID); err != nil {
|
||||
log.Printf("Failed to persist multi-model assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -716,9 +771,20 @@ func (h *CompletionHandler) streamCompletion(
|
||||
|
||||
// Persist assistant response
|
||||
if result.Content != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// AI-to-AI chaining: if the response @mentions another persona participant,
|
||||
// trigger a follow-up completion asynchronously via WebSocket delivery.
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
h.chainIfMentioned(channelID, userID, personaID, result.Content, 0)
|
||||
}()
|
||||
}
|
||||
|
||||
// Log usage
|
||||
@@ -765,10 +831,20 @@ func (h *CompletionHandler) syncCompletion(
|
||||
finalContent = resp.Content
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity)); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity), configID, personaID); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// AI-to-AI chaining
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
|
||||
}()
|
||||
|
||||
// Log usage
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
|
||||
@@ -992,6 +1068,199 @@ func isImageContentType(ct string) bool {
|
||||
return strings.HasPrefix(ct, "image/")
|
||||
}
|
||||
|
||||
// ── @mention Resolution (v0.23.0) ───────────
|
||||
|
||||
// conversationHasPersonaMessages checks if any assistant message in the
|
||||
// channel was generated by a persona (participant_type = 'persona').
|
||||
// Used to decide whether to inject a context boundary for the default model.
|
||||
func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context, channelID string) bool {
|
||||
var id string
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM messages
|
||||
WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona'
|
||||
LIMIT 1
|
||||
`), channelID).Scan(&id)
|
||||
return err == nil && id != ""
|
||||
}
|
||||
|
||||
// buildParticipantHint builds a system message listing available @mentionable
|
||||
// personas so the LLM knows who it can direct responses to.
|
||||
// Only includes personas accessible to this user. Excludes the current persona.
|
||||
func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPersonaID string) string {
|
||||
ctx := context.Background()
|
||||
|
||||
personas, err := h.stores.Personas.ListForUser(ctx, userID)
|
||||
if err != nil || len(personas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, p := range personas {
|
||||
if !p.IsActive || p.Handle == "" || p.ID == currentPersonaID {
|
||||
continue
|
||||
}
|
||||
desc := p.Description
|
||||
if len(desc) > 80 {
|
||||
desc = desc[:80] + "..."
|
||||
}
|
||||
if desc != "" {
|
||||
lines = append(lines, fmt.Sprintf("- @%s (%s) — %s", p.Handle, p.Name, desc))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- @%s (%s)", p.Handle, p.Name))
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
hint := "Available conversation participants you can @mention to direct a response:\n"
|
||||
for _, l := range lines {
|
||||
hint += l + "\n"
|
||||
}
|
||||
hint += "To ask another participant to respond, include their @handle in your message."
|
||||
return hint
|
||||
}
|
||||
|
||||
// Resolves @tokens in message content directly against the model catalog
|
||||
// and personas table. Works in any chat — no channel roster needed.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Persona handle (exact match, then prefix)
|
||||
// 2. Model ID in enabled catalog (exact match, then prefix)
|
||||
//
|
||||
// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found.
|
||||
|
||||
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) {
|
||||
// Extract first @token
|
||||
token := extractFirstMention(content)
|
||||
if token == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// Normalize: lowercase, hyphens
|
||||
normalized := strings.ToLower(strings.TrimSpace(token))
|
||||
|
||||
// 1. Try persona handle (exact)
|
||||
var personaID, personaHandle string
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id, handle FROM personas
|
||||
WHERE LOWER(handle) = $1 AND is_active = true
|
||||
`), normalized).Scan(&personaID, &personaHandle)
|
||||
if err == nil && personaID != "" {
|
||||
p := ResolvePersona(h.stores, personaID, userID)
|
||||
if p != nil {
|
||||
cfgID := ""
|
||||
if p.ProviderConfigID != nil {
|
||||
cfgID = *p.ProviderConfigID
|
||||
}
|
||||
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID)
|
||||
return p.BaseModelID, cfgID, p
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try persona handle (prefix — unambiguous only)
|
||||
if personaID == "" {
|
||||
var count int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
|
||||
`), normalized+"%").Scan(&count)
|
||||
if count == 1 {
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
|
||||
`), normalized+"%").Scan(&personaID)
|
||||
if personaID != "" {
|
||||
p := ResolvePersona(h.stores, personaID, userID)
|
||||
if p != nil {
|
||||
cfgID := ""
|
||||
if p.ProviderConfigID != nil {
|
||||
cfgID = *p.ProviderConfigID
|
||||
}
|
||||
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID)
|
||||
return p.BaseModelID, cfgID, p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try model_id in enabled catalog (exact)
|
||||
var modelID, provConfigID string
|
||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT mc.model_id, mc.provider_config_id
|
||||
FROM model_catalog mc
|
||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||
WHERE LOWER(mc.model_id) = $1
|
||||
AND mc.visibility = 'enabled'
|
||||
AND pc.is_active = true
|
||||
ORDER BY
|
||||
CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
||||
LIMIT 1
|
||||
`), normalized).Scan(&modelID, &provConfigID)
|
||||
if err == nil && modelID != "" {
|
||||
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID)
|
||||
return modelID, provConfigID, nil
|
||||
}
|
||||
|
||||
// 4. Try model_id prefix (unambiguous)
|
||||
var prefixCount int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(DISTINCT mc.model_id)
|
||||
FROM model_catalog mc
|
||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||
WHERE LOWER(mc.model_id) LIKE $1
|
||||
AND mc.visibility = 'enabled'
|
||||
AND pc.is_active = true
|
||||
`), normalized+"%").Scan(&prefixCount)
|
||||
if prefixCount == 1 {
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT mc.model_id, mc.provider_config_id
|
||||
FROM model_catalog mc
|
||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||
WHERE LOWER(mc.model_id) LIKE $1
|
||||
AND mc.visibility = 'enabled'
|
||||
AND pc.is_active = true
|
||||
ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
||||
LIMIT 1
|
||||
`), normalized+"%").Scan(&modelID, &provConfigID)
|
||||
if modelID != "" {
|
||||
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID)
|
||||
return modelID, provConfigID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// extractFirstMention finds the first @token in content.
|
||||
// Returns the token without the @ prefix, or empty string.
|
||||
func extractFirstMention(content string) string {
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] != '@' {
|
||||
continue
|
||||
}
|
||||
// @ must be at start or preceded by whitespace
|
||||
if i > 0 && content[i-1] != ' ' && content[i-1] != '\n' && content[i-1] != '\t' {
|
||||
continue
|
||||
}
|
||||
// Extract token
|
||||
start := i + 1
|
||||
end := start
|
||||
for end < len(content) && content[end] != ' ' && content[end] != '\n' && content[end] != '\t' {
|
||||
end++
|
||||
}
|
||||
if end > start {
|
||||
// Strip trailing punctuation
|
||||
for end > start && (content[end-1] == ',' || content[end-1] == '.' || content[end-1] == '!' || content[end-1] == '?' || content[end-1] == ':' || content[end-1] == ';') {
|
||||
end--
|
||||
}
|
||||
if end > start {
|
||||
return content[start:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Config Resolution ───────────────────────
|
||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||
|
||||
@@ -1037,6 +1306,8 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var customHeadersJSON, providerSettingsJSON []byte
|
||||
var proxyMode string
|
||||
var proxyURL *string
|
||||
// $2/userID appears twice in the query; Postgres reuses positional params,
|
||||
// SQLite needs each ? bound separately.
|
||||
configArgs := []interface{}{configID, userID}
|
||||
@@ -1045,14 +1316,14 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
}
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
|
||||
model_default, headers, settings
|
||||
model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
||||
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
|
||||
@@ -1100,11 +1371,18 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
|
||||
}
|
||||
|
||||
proxyURLStr := ""
|
||||
if proxyURL != nil {
|
||||
proxyURLStr = *proxyURL
|
||||
}
|
||||
|
||||
return providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
Settings: providerSettings,
|
||||
ProxyMode: proxyMode,
|
||||
ProxyURL: proxyURLStr,
|
||||
}, providerID, model, configID, providerScope, nil
|
||||
}
|
||||
|
||||
@@ -1116,7 +1394,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
//
|
||||
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
||||
// messages before it are replaced by the summary content as a system message.
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID string) ([]providers.Message, error) {
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID, currentModel string) ([]providers.Message, error) {
|
||||
messages := make([]providers.Message, 0)
|
||||
|
||||
// ── Admin system prompt (always injected first, no opt out) ──
|
||||
@@ -1183,6 +1461,16 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
||||
})
|
||||
}
|
||||
|
||||
// ── Participant roster (v0.23.0) ──
|
||||
// Inject the list of available @mentionable personas so the LLM
|
||||
// knows who it can direct responses to. This enables LLM→LLM chaining.
|
||||
if participantHint := h.buildParticipantHint(channelID, userID, personaID); participantHint != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: participantHint,
|
||||
})
|
||||
}
|
||||
|
||||
// Walk the active path (root → leaf) instead of loading all messages
|
||||
path, err := getActivePath(channelID, userID)
|
||||
if err != nil {
|
||||
@@ -1207,6 +1495,29 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
||||
startIdx = summaryIdx + 1
|
||||
}
|
||||
|
||||
// ── Build persona name cache for history rewrite (v0.23.0) ──
|
||||
// Collect unique persona IDs from the path so we can attribute
|
||||
// foreign persona messages by name instead of raw UUID.
|
||||
personaNameCache := make(map[string]string)
|
||||
hasForeignPersona := false
|
||||
{
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range path {
|
||||
if m.ParticipantType == "persona" && m.ParticipantID != "" && m.ParticipantID != personaID {
|
||||
if !seen[m.ParticipantID] {
|
||||
seen[m.ParticipantID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for pid := range seen {
|
||||
var name string
|
||||
database.DB.QueryRow(database.Q(`SELECT name FROM personas WHERE id = $1`), pid).Scan(&name)
|
||||
if name != "" {
|
||||
personaNameCache[pid] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range path[startIdx:] {
|
||||
if m.Role == "system" {
|
||||
continue // system prompts handled above
|
||||
@@ -1215,10 +1526,59 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
||||
if isSummaryMessage(&m) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, providers.Message{
|
||||
|
||||
// ── Foreign speaker attribution (v0.23.0) ──
|
||||
// ALL assistant messages get a Name field identifying who generated them.
|
||||
// Providers that support "name" (OpenAI, OpenRouter, Venice) use it
|
||||
// to distinguish speakers. Anthropic rewrites named foreign messages
|
||||
// to user role with attribution.
|
||||
msg := providers.Message{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
if m.Role == "assistant" {
|
||||
isForeign := false
|
||||
|
||||
if m.ParticipantType == "persona" && m.ParticipantID != "" {
|
||||
if m.ParticipantID != personaID {
|
||||
// Foreign persona — tag with name
|
||||
isForeign = true
|
||||
name := personaNameCache[m.ParticipantID]
|
||||
if name != "" {
|
||||
msg.Name = name
|
||||
}
|
||||
}
|
||||
} else if m.Model != nil && *m.Model != "" {
|
||||
// Raw model — foreign if model differs or we're a persona
|
||||
if *m.Model != currentModel || personaID != "" {
|
||||
isForeign = true
|
||||
msg.Name = *m.Model
|
||||
}
|
||||
}
|
||||
|
||||
if isForeign {
|
||||
hasForeignPersona = true
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
// If foreign persona messages exist, add a system hint explaining
|
||||
// the multi-participant context (helps all providers)
|
||||
if hasForeignPersona {
|
||||
hint := "This is a multi-participant AI conversation. Messages from other AI participants are marked with their name. You are one of several models — respond only as yourself, ignoring other participants' styles and characters."
|
||||
// Insert after the last system message, before conversation history
|
||||
insertIdx := 0
|
||||
for i, m := range messages {
|
||||
if m.Role == "system" {
|
||||
insertIdx = i + 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
messages = append(messages[:insertIdx+1], messages[insertIdx:]...)
|
||||
messages[insertIdx] = providers.Message{Role: "system", Content: hint}
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
@@ -1242,7 +1602,7 @@ func toolActivityJSON(activity []map[string]interface{}) json.RawMessage {
|
||||
return b
|
||||
}
|
||||
|
||||
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage) (string, error) {
|
||||
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage, providerConfigID string, personaID string) (string, error) {
|
||||
var tokensUsed *int
|
||||
if inputTokens > 0 || outputTokens > 0 {
|
||||
total := inputTokens + outputTokens
|
||||
@@ -1260,12 +1620,23 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
// Compute sibling_index
|
||||
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||
|
||||
// Determine participant
|
||||
// Determine participant: persona for assistant messages with personaID, otherwise user/model
|
||||
participantType := "user"
|
||||
participantID := userID
|
||||
if role == "assistant" {
|
||||
participantType = "model"
|
||||
participantID = model
|
||||
if personaID != "" {
|
||||
participantType = "persona"
|
||||
participantID = personaID
|
||||
} else {
|
||||
participantType = "model"
|
||||
participantID = model
|
||||
}
|
||||
}
|
||||
|
||||
// provider_config_id: nil if empty
|
||||
var provCfgVal interface{}
|
||||
if providerConfigID != "" {
|
||||
provCfgVal = providerConfigID
|
||||
}
|
||||
|
||||
// Prepare tool_calls JSONB (nil → NULL)
|
||||
@@ -1280,21 +1651,21 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
newID = store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)
|
||||
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)
|
||||
`, newID, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx)
|
||||
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10)
|
||||
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx,
|
||||
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx,
|
||||
).Scan(&newID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
223
server/handlers/completion_chain.go
Normal file
223
server/handlers/completion_chain.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
)
|
||||
|
||||
// maxChainDepth limits AI-to-AI chaining to prevent runaway loops.
|
||||
const maxChainDepth = 5
|
||||
|
||||
// chainIfMentioned checks if an assistant response @mentions another
|
||||
// persona and triggers a follow-up completion. Uses resolveMention()
|
||||
// directly — no roster or participant list needed. Works in any chat.
|
||||
//
|
||||
// Runs asynchronously (goroutine) after the SSE stream closes.
|
||||
// The follow-up response is delivered to the client via WebSocket events.
|
||||
func (h *CompletionHandler) chainIfMentioned(
|
||||
channelID, userID, currentPersonaID, responseContent string,
|
||||
depth int,
|
||||
) {
|
||||
if depth >= maxChainDepth {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC in chain (depth=%d): %v", depth, r)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Extract the @token for logging
|
||||
token := extractFirstMention(responseContent)
|
||||
if token == "" {
|
||||
log.Printf("[chain] No @mention found in response (len=%d, persona=%s)", len(responseContent), currentPersonaID[:min(8, len(currentPersonaID))])
|
||||
return
|
||||
}
|
||||
log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth)
|
||||
|
||||
// Use the same resolveMention() that handles user @mentions
|
||||
mentionModel, mentionConfig, mentionPersona := h.resolveMention(ctx, userID, responseContent)
|
||||
if mentionModel == "" {
|
||||
log.Printf("[chain] @%s did not resolve to any model", token)
|
||||
return
|
||||
}
|
||||
if mentionPersona == nil {
|
||||
log.Printf("[chain] @%s resolved to raw model %s (no chain for raw models)", token, mentionModel)
|
||||
return // no persona @mention found, or it's a raw model (no chain for those)
|
||||
}
|
||||
|
||||
// Don't chain to self
|
||||
if mentionPersona.ID == currentPersonaID {
|
||||
log.Printf("[chain] @%s is self-mention, skipping", token)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[chain] %s @mentioned %s (depth=%d)", currentPersonaID[:min(8, len(currentPersonaID))], mentionPersona.Name, depth+1)
|
||||
|
||||
// Emit typing indicator
|
||||
if h.hub != nil {
|
||||
h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, true)
|
||||
defer h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, false)
|
||||
}
|
||||
|
||||
// Brief pause for UX
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Resolve provider config
|
||||
configID := mentionConfig
|
||||
chainReq := completionRequest{
|
||||
ChannelID: channelID,
|
||||
Model: mentionModel,
|
||||
ProviderConfigID: configID,
|
||||
}
|
||||
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
|
||||
if err != nil {
|
||||
log.Printf("[chain] Failed to resolve config for %s: %v", mentionPersona.Name, err)
|
||||
return
|
||||
}
|
||||
configID = resolvedConfigID
|
||||
|
||||
provider, err := providers.Get(providerID)
|
||||
if err != nil {
|
||||
log.Printf("[chain] Provider %s unavailable: %v", providerID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Load conversation with persona's system prompt
|
||||
messages, err := h.loadConversation(channelID, userID, mentionPersona.SystemPrompt, mentionPersona.ID, model)
|
||||
if err != nil {
|
||||
log.Printf("[chain] Failed to load conversation: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Context boundary
|
||||
boundary := fmt.Sprintf(
|
||||
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style and mannerisms. Respond only as yourself per your system prompt.]",
|
||||
mentionPersona.Name,
|
||||
)
|
||||
messages = append(messages, providers.Message{Role: "system", Content: boundary})
|
||||
|
||||
caps := capspkg.ResolveIntrinsic(model, nil, nil)
|
||||
|
||||
provReq := providers.CompletionRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
|
||||
}
|
||||
if mentionPersona.Temperature != nil {
|
||||
provReq.Temperature = mentionPersona.Temperature
|
||||
}
|
||||
if mentionPersona.ThinkingBudget != nil && *mentionPersona.ThinkingBudget > 0 {
|
||||
if providerCfg.Settings == nil {
|
||||
providerCfg.Settings = make(map[string]interface{})
|
||||
}
|
||||
providerCfg.Settings["extended_thinking"] = true
|
||||
providerCfg.Settings["thinking_budget"] = *mentionPersona.ThinkingBudget
|
||||
}
|
||||
|
||||
// Apply provider-specific hooks
|
||||
if hooks := providers.GetHooks(providerID); hooks != nil {
|
||||
hooks.PreRequest(providerCfg, &provReq)
|
||||
}
|
||||
|
||||
// Synchronous completion — result delivered via WebSocket
|
||||
callStart := time.Now()
|
||||
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
|
||||
latencyMs := int(time.Since(callStart).Milliseconds())
|
||||
if err != nil {
|
||||
log.Printf("[chain] Completion failed for %s: %v", mentionPersona.Name, err)
|
||||
if h.health != nil {
|
||||
h.health.RecordError(configID, latencyMs, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if h.health != nil {
|
||||
h.health.RecordSuccess(configID, latencyMs)
|
||||
}
|
||||
|
||||
if resp.Content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Persist
|
||||
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
|
||||
resp.InputTokens, resp.OutputTokens, nil, nil, configID, mentionPersona.ID)
|
||||
if err != nil {
|
||||
log.Printf("[chain] Failed to persist chained message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Deliver via WebSocket
|
||||
if h.hub != nil {
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": channelID,
|
||||
"role": "assistant",
|
||||
"content": resp.Content,
|
||||
"model": model,
|
||||
"participant_type": "persona",
|
||||
"participant_id": mentionPersona.ID,
|
||||
"display_name": mentionPersona.Name,
|
||||
"avatar": mentionPersona.Avatar,
|
||||
"tokens_used": resp.InputTokens + resp.OutputTokens,
|
||||
"chain_depth": depth + 1,
|
||||
})
|
||||
h.hub.SendToUser(userID, events.Event{
|
||||
Label: "message.created",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// Log usage
|
||||
if h.stores.Usage != nil {
|
||||
entry := &models.UsageEntry{
|
||||
ChannelID: &channelID,
|
||||
UserID: userID,
|
||||
ProviderConfigID: &configID,
|
||||
ProviderScope: "global",
|
||||
ModelID: model,
|
||||
PromptTokens: resp.InputTokens,
|
||||
CompletionTokens: resp.OutputTokens,
|
||||
}
|
||||
_ = h.stores.Usage.Log(ctx, entry)
|
||||
}
|
||||
|
||||
log.Printf("[chain] ✅ %s responded (depth=%d, %d tokens) in channel %s",
|
||||
mentionPersona.Name, depth+1, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
|
||||
|
||||
// Recursive: check if this response @mentions yet another persona
|
||||
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
|
||||
}
|
||||
|
||||
// emitTyping sends a typing indicator via WebSocket.
|
||||
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
|
||||
eventType := "typing.start"
|
||||
if !start {
|
||||
eventType = "typing.stop"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"channel_id": channelID,
|
||||
"participant_id": participantID,
|
||||
"participant_type": "persona",
|
||||
"display_name": displayName,
|
||||
})
|
||||
h.hub.SendToUser(userID, events.Event{
|
||||
Label: eventType,
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
@@ -37,6 +37,7 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
ModelID string `json:"model_id" binding:"required"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
Hidden *bool `json:"hidden,omitempty"`
|
||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
||||
@@ -48,13 +49,14 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
||||
}
|
||||
|
||||
patch := models.UserModelSettingPatch{
|
||||
ProviderConfigID: req.ProviderConfigID,
|
||||
Hidden: req.Hidden,
|
||||
PreferredTemperature: req.PreferredTemperature,
|
||||
PreferredMaxTokens: req.PreferredMaxTokens,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
|
||||
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, patch); err != nil {
|
||||
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, req.ProviderConfigID, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
|
||||
return
|
||||
}
|
||||
@@ -62,23 +64,23 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
|
||||
}
|
||||
|
||||
// BulkSetPreferences sets hidden state for multiple models at once.
|
||||
// BulkSetPreferences sets hidden state for multiple model+provider pairs at once.
|
||||
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req struct {
|
||||
ModelIDs []string `json:"model_ids" binding:"required"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Entries []models.HiddenEntry `json:"entries" binding:"required"`
|
||||
Hidden bool `json:"hidden"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.ModelIDs, req.Hidden); err != nil {
|
||||
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.Entries, req.Hidden); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.ModelIDs)})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.Entries)})
|
||||
}
|
||||
|
||||
@@ -27,9 +27,15 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
|
||||
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if cfg.ProxyURL != nil {
|
||||
proxyURL = *cfg.ProxyURL
|
||||
}
|
||||
provCfg := providers.ProviderConfig{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: apiKeyPlain,
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: apiKeyPlain,
|
||||
ProxyMode: cfg.ProxyMode,
|
||||
ProxyURL: proxyURL,
|
||||
}
|
||||
|
||||
if cfg.Headers != nil {
|
||||
|
||||
340
server/handlers/participants.go
Normal file
340
server/handlers/participants.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Channel Participants Handler ──────────────
|
||||
// ICD §3.7: Manages polymorphic participants per channel.
|
||||
// Routes:
|
||||
// GET /channels/:id/participants — list
|
||||
// POST /channels/:id/participants — add
|
||||
// PATCH /channels/:id/participants/:participantId — update role
|
||||
// DELETE /channels/:id/participants/:participantId — remove
|
||||
// GET /channels/:id/presence — who's online
|
||||
|
||||
type ParticipantHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewParticipantHandler(stores store.Stores) *ParticipantHandler {
|
||||
return &ParticipantHandler{stores: stores}
|
||||
}
|
||||
|
||||
// ── List ─────────────────────────────────────
|
||||
|
||||
func (h *ParticipantHandler) List(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
userID := getUserID(c)
|
||||
|
||||
// Caller must be a participant (any role)
|
||||
if !h.requireParticipant(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
participants, err := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list participants"})
|
||||
return
|
||||
}
|
||||
if participants == nil {
|
||||
participants = []models.ChannelParticipant{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"participants": participants})
|
||||
}
|
||||
|
||||
// ── Add ──────────────────────────────────────
|
||||
|
||||
type addParticipantRequest struct {
|
||||
ParticipantType string `json:"participant_type" binding:"required"`
|
||||
ParticipantID string `json:"participant_id" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (h *ParticipantHandler) Add(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
userID := getUserID(c)
|
||||
|
||||
// Only owners can add participants
|
||||
if !h.requireOwner(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req addParticipantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate participant_type
|
||||
switch req.ParticipantType {
|
||||
case "user", "persona", "session":
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "participant_type must be user, persona, or session"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default role
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = "member"
|
||||
}
|
||||
switch role {
|
||||
case "owner", "member", "observer":
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build participant
|
||||
p := &models.ChannelParticipant{
|
||||
ChannelID: channelID,
|
||||
ParticipantType: req.ParticipantType,
|
||||
ParticipantID: req.ParticipantID,
|
||||
Role: role,
|
||||
}
|
||||
|
||||
// If adding a persona, resolve display_name and avatar from persona record,
|
||||
// and auto-add persona's model to channel_models roster.
|
||||
if req.ParticipantType == "persona" {
|
||||
persona, err := h.stores.Personas.GetByID(c.Request.Context(), req.ParticipantID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up persona"})
|
||||
return
|
||||
}
|
||||
|
||||
dn := persona.Name
|
||||
p.DisplayName = &dn
|
||||
if persona.Avatar != "" {
|
||||
p.AvatarURL = &persona.Avatar
|
||||
}
|
||||
|
||||
// Auto-add persona's model to channel_models roster
|
||||
cm := &models.ChannelModel{
|
||||
ChannelID: channelID,
|
||||
ModelID: persona.BaseModelID,
|
||||
ProviderConfigID: derefStr(persona.ProviderConfigID),
|
||||
PersonaID: &req.ParticipantID,
|
||||
Handle: persona.Handle,
|
||||
DisplayName: persona.Name,
|
||||
SystemPrompt: persona.SystemPrompt,
|
||||
IsDefault: false,
|
||||
}
|
||||
_ = h.stores.Channels.SetModel(c.Request.Context(), cm)
|
||||
|
||||
} else if req.ParticipantType == "user" {
|
||||
// Resolve user display name
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), req.ParticipantID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up user"})
|
||||
return
|
||||
}
|
||||
dn := user.DisplayName
|
||||
if dn == "" {
|
||||
dn = user.Username
|
||||
}
|
||||
p.DisplayName = &dn
|
||||
if user.AvatarURL != "" {
|
||||
p.AvatarURL = &user.AvatarURL
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Channels.AddParticipant(c.Request.Context(), p); err != nil {
|
||||
// Check for duplicate
|
||||
if isDuplicateErr(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "participant already in channel"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated participant list
|
||||
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
|
||||
c.JSON(http.StatusCreated, gin.H{"participants": participants})
|
||||
}
|
||||
|
||||
// ── Update Role ─────────────────────────────
|
||||
|
||||
type updateParticipantRequest struct {
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ParticipantHandler) Update(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
participantRecordID := c.Param("participantId")
|
||||
userID := getUserID(c)
|
||||
|
||||
if !h.requireOwner(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateParticipantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Role {
|
||||
case "owner", "member", "observer":
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify participant belongs to this channel
|
||||
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
|
||||
return
|
||||
}
|
||||
if p.ChannelID != channelID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Channels.UpdateParticipantRole(c.Request.Context(), participantRecordID, req.Role); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update participant"})
|
||||
return
|
||||
}
|
||||
|
||||
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
|
||||
c.JSON(http.StatusOK, gin.H{"participants": participants})
|
||||
}
|
||||
|
||||
// ── Remove ──────────────────────────────────
|
||||
|
||||
func (h *ParticipantHandler) Remove(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
participantRecordID := c.Param("participantId")
|
||||
userID := getUserID(c)
|
||||
|
||||
if !h.requireOwner(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify participant belongs to this channel
|
||||
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
|
||||
return
|
||||
}
|
||||
if p.ChannelID != channelID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
|
||||
return
|
||||
}
|
||||
|
||||
// Cannot remove the last owner
|
||||
if p.Role == "owner" {
|
||||
owners, _ := h.stores.Channels.CountParticipantsByRole(c.Request.Context(), channelID, "owner")
|
||||
if owners <= 1 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last owner"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If removing a persona, also remove its auto-created model roster entry
|
||||
if p.ParticipantType == "persona" {
|
||||
_ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID)
|
||||
}
|
||||
|
||||
if err := h.stores.Channels.RemoveParticipant(c.Request.Context(), participantRecordID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"})
|
||||
return
|
||||
}
|
||||
|
||||
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
|
||||
c.JSON(http.StatusOK, gin.H{"participants": participants})
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
// requireParticipant checks the user is any participant in the channel.
|
||||
func (h *ParticipantHandler) requireParticipant(c *gin.Context, channelID, userID string) bool {
|
||||
ok, err := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check participation"})
|
||||
return false
|
||||
}
|
||||
if !ok {
|
||||
// Fall back to legacy channels.user_id ownership for direct channels
|
||||
if userOwnsChannel(c, channelID, userID) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requireOwner checks the user is an owner participant in the channel.
|
||||
func (h *ParticipantHandler) requireOwner(c *gin.Context, channelID, userID string) bool {
|
||||
role, err := h.stores.Channels.GetParticipantRole(c.Request.Context(), channelID, "user", userID)
|
||||
if err != nil {
|
||||
// Fall back to legacy channels.user_id ownership for direct channels
|
||||
if userOwnsChannel(c, channelID, userID) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if role != "owner" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "owner role required"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func derefStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// isDuplicateErr detects unique constraint violations across both Postgres and SQLite.
|
||||
func isDuplicateErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
// Postgres: "duplicate key value violates unique constraint"
|
||||
// SQLite: "UNIQUE constraint failed"
|
||||
return contains(msg, "duplicate key") || contains(msg, "UNIQUE constraint")
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchStr(s, substr)
|
||||
}
|
||||
|
||||
func searchStr(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -223,6 +223,7 @@ func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
|
||||
|
||||
type personaRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Handle string `json:"handle,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
BaseModelID string `json:"base_model_id,omitempty"`
|
||||
@@ -238,6 +239,7 @@ type personaRequest struct {
|
||||
func (r *personaRequest) toPersona() *models.Persona {
|
||||
p := &models.Persona{
|
||||
Name: r.Name,
|
||||
Handle: r.Handle,
|
||||
Description: r.Description,
|
||||
Icon: r.Icon,
|
||||
BaseModelID: r.BaseModelID,
|
||||
|
||||
@@ -380,6 +380,13 @@ func main() {
|
||||
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
|
||||
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
|
||||
|
||||
// Channel participants (v0.23.0 — ICD §3.7)
|
||||
partH := handlers.NewParticipantHandler(stores)
|
||||
protected.GET("/channels/:id/participants", partH.List)
|
||||
protected.POST("/channels/:id/participants", partH.Add)
|
||||
protected.PATCH("/channels/:id/participants/:participantId", partH.Update)
|
||||
protected.DELETE("/channels/:id/participants/:participantId", partH.Remove)
|
||||
|
||||
// Messages
|
||||
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
|
||||
@@ -10,47 +10,45 @@ import (
|
||||
|
||||
// Mention represents a parsed @mention token in message content.
|
||||
type Mention struct {
|
||||
Raw string // "@claude-3-opus" as written (includes @)
|
||||
Name string // "claude-3-opus" (normalized, no @)
|
||||
Raw string // "@veronica-sharpe" as written (includes @)
|
||||
Name string // "veronica-sharpe" (normalized, no @)
|
||||
Start int // byte offset in message content
|
||||
End int // byte offset end (exclusive)
|
||||
Resolved *models.ChannelModel // nil if unresolved
|
||||
IsAll bool // true for @all special token
|
||||
}
|
||||
|
||||
// Parse extracts @mentions from message content and resolves them
|
||||
// against the channel's model roster.
|
||||
//
|
||||
// Rules:
|
||||
// - @ followed by one or more non-whitespace characters
|
||||
// - Greedy: @claude-3-opus-20240229 matches the full token
|
||||
// - Resolution: case-insensitive match against display_name with
|
||||
// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus")
|
||||
// - Longest-match-first when display names overlap
|
||||
// - Unresolved mentions are included with Resolved = nil
|
||||
// - @ must be at start of content or preceded by whitespace
|
||||
// Resolution priority:
|
||||
// 1. Exact handle match (e.g. @veronica-sharpe → handle "veronica-sharpe")
|
||||
// 2. Prefix handle match (e.g. @veronica → only one handle starts with "veronica")
|
||||
// 3. Fallback: normalized display_name match (backward compat)
|
||||
// 4. @all is a special token that resolves to all roster entries
|
||||
//
|
||||
// @ must be at start of content or preceded by whitespace.
|
||||
func Parse(content string, roster []models.ChannelModel) []Mention {
|
||||
if len(roster) == 0 || !strings.Contains(content, "@") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build lookup: normalized display_name → *ChannelModel
|
||||
// Sort roster by display_name length descending for longest-match-first
|
||||
// Build handle lookup: handle → *ChannelModel
|
||||
// Also build display name lookup for backward compat
|
||||
type entry struct {
|
||||
normalized string
|
||||
handle string // normalized handle
|
||||
normalized string // normalized display_name
|
||||
model models.ChannelModel
|
||||
}
|
||||
entries := make([]entry, 0, len(roster))
|
||||
for _, cm := range roster {
|
||||
if cm.DisplayName == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry{
|
||||
normalized: normalize(cm.DisplayName),
|
||||
model: cm,
|
||||
})
|
||||
h := normalize(cm.Handle)
|
||||
dn := normalize(cm.DisplayName)
|
||||
entries = append(entries, entry{handle: h, normalized: dn, model: cm})
|
||||
}
|
||||
// Sort by handle length descending for longest-match-first
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return len(entries[i].normalized) > len(entries[j].normalized)
|
||||
return len(entries[i].handle) > len(entries[j].handle)
|
||||
})
|
||||
|
||||
// Extract @tokens
|
||||
@@ -80,29 +78,86 @@ func Parse(content string, roster []models.ChannelModel) []Mention {
|
||||
continue // bare @ with no token
|
||||
}
|
||||
|
||||
// Strip trailing punctuation (commas, periods, etc.)
|
||||
// Strip trailing punctuation
|
||||
tokenEnd := i
|
||||
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
|
||||
tokenEnd--
|
||||
}
|
||||
if tokenEnd == tokenStart {
|
||||
continue // all punctuation
|
||||
continue
|
||||
}
|
||||
|
||||
raw := content[start:tokenEnd]
|
||||
name := content[tokenStart:tokenEnd]
|
||||
normalizedName := normalize(name)
|
||||
|
||||
// Try to resolve against roster (longest match first)
|
||||
// Special: @all
|
||||
if normalizedName == "all" {
|
||||
mentions = append(mentions, Mention{
|
||||
Raw: raw,
|
||||
Name: name,
|
||||
Start: start,
|
||||
End: tokenEnd,
|
||||
IsAll: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Exact handle match
|
||||
var resolved *models.ChannelModel
|
||||
for idx := range entries {
|
||||
if normalizedName == entries[idx].normalized {
|
||||
if entries[idx].handle != "" && normalizedName == entries[idx].handle {
|
||||
cm := entries[idx].model
|
||||
resolved = &cm
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Prefix handle match (unambiguous only)
|
||||
if resolved == nil {
|
||||
var prefixMatches []int
|
||||
for idx := range entries {
|
||||
if entries[idx].handle != "" && strings.HasPrefix(entries[idx].handle, normalizedName) {
|
||||
prefixMatches = append(prefixMatches, idx)
|
||||
}
|
||||
}
|
||||
if len(prefixMatches) == 1 {
|
||||
cm := entries[prefixMatches[0]].model
|
||||
resolved = &cm
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: display_name match (backward compat)
|
||||
if resolved == nil {
|
||||
for idx := range entries {
|
||||
if entries[idx].normalized != "" && normalizedName == entries[idx].normalized {
|
||||
cm := entries[idx].model
|
||||
resolved = &cm
|
||||
break
|
||||
}
|
||||
}
|
||||
// Display name prefix
|
||||
if resolved == nil {
|
||||
var prefixMatches []int
|
||||
for idx := range entries {
|
||||
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName+" ") {
|
||||
prefixMatches = append(prefixMatches, idx)
|
||||
}
|
||||
}
|
||||
if len(prefixMatches) == 0 {
|
||||
for idx := range entries {
|
||||
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName) {
|
||||
prefixMatches = append(prefixMatches, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(prefixMatches) == 1 {
|
||||
cm := entries[prefixMatches[0]].model
|
||||
resolved = &cm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mentions = append(mentions, Mention{
|
||||
Raw: raw,
|
||||
Name: name,
|
||||
@@ -133,6 +188,16 @@ func ResolvedModels(mentions []Mention) []models.ChannelModel {
|
||||
return result
|
||||
}
|
||||
|
||||
// HasAll returns true if any mention is @all.
|
||||
func HasAll(mentions []Mention) bool {
|
||||
for _, m := range mentions {
|
||||
if m.IsAll {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isMentionTrailingPunct returns true for punctuation that commonly
|
||||
// follows an @mention but isn't part of the name.
|
||||
func isMentionTrailingPunct(b byte) bool {
|
||||
|
||||
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -128,6 +129,8 @@ type ProviderConfig struct {
|
||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
IsPrivate bool `json:"is_private" db:"is_private"`
|
||||
ProxyMode string `json:"proxy_mode" db:"proxy_mode"`
|
||||
ProxyURL *string `json:"proxy_url,omitempty" db:"proxy_url"`
|
||||
}
|
||||
|
||||
// HasKey returns true if an encrypted API key is stored.
|
||||
@@ -146,6 +149,8 @@ type ProviderConfigPatch struct {
|
||||
Settings JSONMap `json:"settings,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
IsPrivate *bool `json:"is_private,omitempty"`
|
||||
ProxyMode *string `json:"proxy_mode,omitempty"`
|
||||
ProxyURL *string `json:"proxy_url,omitempty"`
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -194,6 +199,7 @@ type ModelPricing struct {
|
||||
type Persona struct {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Handle string `json:"handle,omitempty" db:"handle"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
Icon string `json:"icon,omitempty" db:"icon"`
|
||||
Avatar string `json:"avatar,omitempty" db:"avatar"`
|
||||
@@ -227,6 +233,7 @@ type Persona struct {
|
||||
|
||||
type PersonaPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Handle *string `json:"handle,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
@@ -278,6 +285,7 @@ type UserModelSetting struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
ModelID string `json:"model_id" db:"model_id"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||
Hidden bool `json:"hidden" db:"hidden"`
|
||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"`
|
||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"`
|
||||
@@ -285,12 +293,24 @@ type UserModelSetting struct {
|
||||
}
|
||||
|
||||
type UserModelSettingPatch struct {
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
Hidden *bool `json:"hidden,omitempty"`
|
||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
// HiddenEntry identifies a model+provider pair for bulk visibility operations.
|
||||
type HiddenEntry struct {
|
||||
ModelID string `json:"model_id"`
|
||||
ProviderConfigID string `json:"provider_config_id"`
|
||||
}
|
||||
|
||||
// CompositeModelKey builds the composite key used for per-provider model preferences.
|
||||
func CompositeModelKey(providerConfigID, modelID string) string {
|
||||
return providerConfigID + ":" + modelID
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// CHANNELS
|
||||
// =========================================
|
||||
@@ -319,41 +339,47 @@ type Channel struct {
|
||||
|
||||
type Message struct {
|
||||
BaseModel
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
Content string `json:"content" db:"content"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"`
|
||||
ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"`
|
||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
|
||||
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
|
||||
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
|
||||
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
Content string `json:"content" db:"content"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"`
|
||||
ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"`
|
||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
|
||||
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
|
||||
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
|
||||
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// CHANNEL MEMBERS, MODELS, CURSORS
|
||||
// CHANNEL PARTICIPANTS, MODELS, CURSORS
|
||||
// =========================================
|
||||
|
||||
type ChannelMember struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
|
||||
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
|
||||
type ChannelParticipant struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
ParticipantType string `json:"participant_type" db:"participant_type"`
|
||||
ParticipantID string `json:"participant_id" db:"participant_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
DisplayName *string `json:"display_name,omitempty" db:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty" db:"avatar_url"`
|
||||
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
|
||||
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
|
||||
}
|
||||
|
||||
type ChannelModel struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
ModelID string `json:"model_id" db:"model_id"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||
IsDefault bool `json:"is_default" db:"is_default"`
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
ModelID string `json:"model_id" db:"model_id"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
||||
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
||||
Handle string `json:"handle,omitempty" db:"handle"`
|
||||
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||
IsDefault bool `json:"is_default" db:"is_default"`
|
||||
}
|
||||
|
||||
type ChannelCursor struct {
|
||||
@@ -364,6 +390,56 @@ type ChannelCursor struct {
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// PERSONA GROUPS (v0.23.0)
|
||||
// =========================================
|
||||
|
||||
type PersonaGroup struct {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||
Scope string `json:"scope" db:"scope"`
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
Members []PersonaGroupMember `json:"members,omitempty" db:"-"`
|
||||
}
|
||||
|
||||
type PersonaGroupMember struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
GroupID string `json:"group_id" db:"group_id"`
|
||||
PersonaID string `json:"persona_id" db:"persona_id"`
|
||||
IsLeader bool `json:"is_leader" db:"is_leader"`
|
||||
SortOrder int `json:"sort_order" db:"sort_order"`
|
||||
// Joined from personas table for API responses
|
||||
PersonaName string `json:"persona_name,omitempty" db:"-"`
|
||||
PersonaHandle string `json:"persona_handle,omitempty" db:"-"`
|
||||
PersonaAvatar string `json:"persona_avatar,omitempty" db:"-"`
|
||||
}
|
||||
|
||||
// HandleFromName generates a URL-safe @mention handle from a display name.
|
||||
// "Veronica Sharpe" → "veronica-sharpe"
|
||||
func HandleFromName(name string) string {
|
||||
h := strings.ToLower(strings.TrimSpace(name))
|
||||
h = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
return r
|
||||
}
|
||||
if r == ' ' || r == '_' {
|
||||
return '-'
|
||||
}
|
||||
return -1
|
||||
}, h)
|
||||
// Collapse multiple hyphens
|
||||
for strings.Contains(h, "--") {
|
||||
h = strings.ReplaceAll(h, "--", "-")
|
||||
}
|
||||
h = strings.Trim(h, "-")
|
||||
if len(h) > 50 {
|
||||
h = h[:50]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// ORGANIZATION
|
||||
// =========================================
|
||||
@@ -635,6 +711,7 @@ type UserModel struct {
|
||||
// Persona fields — always emitted so frontend can branch on is_persona.
|
||||
IsPersona bool `json:"is_persona"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
PersonaHandle string `json:"persona_handle,omitempty"`
|
||||
PersonaScope string `json:"persona_scope,omitempty"`
|
||||
PersonaAvatar string `json:"persona_avatar,omitempty"`
|
||||
PersonaTeamName string `json:"persona_team_name,omitempty"`
|
||||
|
||||
@@ -106,10 +106,9 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>New Project</span>
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<button class="split-dropdown-item" onclick="closeNewChatMenu(); newGroupChat();">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Group Chat</span>
|
||||
<span class="dd-hint">soon</span>
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
|
||||
@@ -235,6 +235,20 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
|
||||
antMsg := anthropicMessage{Role: m.Role}
|
||||
|
||||
// ── Foreign persona rewrite (v0.23.0) ──
|
||||
// Anthropic doesn't support the "name" field on messages.
|
||||
// Rewrite named assistant messages (from other personas) to user
|
||||
// role with attribution so Anthropic maintains alternation and
|
||||
// the model understands it's a multi-participant conversation.
|
||||
if m.Role == "assistant" && m.Name != "" {
|
||||
antMsg.Role = "user"
|
||||
antMsg.Content = []anthropicContentBlock{
|
||||
{Type: "text", Text: fmt.Sprintf("[%s responded:]\n%s", m.Name, m.Content)},
|
||||
}
|
||||
messages = append(messages, antMsg)
|
||||
continue
|
||||
}
|
||||
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
// Assistant with tool calls → mixed content blocks
|
||||
if m.Content != "" {
|
||||
@@ -372,7 +386,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider request: %w", err)
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list models: %w", err)
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func (p *OpenAIProvider) Embed(ctx context.Context, cfg ProviderConfig, req Embe
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedding request: %w", err)
|
||||
}
|
||||
@@ -424,6 +424,12 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
||||
oaiMsg.Name = m.Name
|
||||
}
|
||||
|
||||
// Named messages: pass through for speaker attribution (v0.23.0)
|
||||
// OpenAI supports "name" on any role to distinguish participants
|
||||
if m.Name != "" && m.Role != "tool" {
|
||||
oaiMsg.Name = m.Name
|
||||
}
|
||||
|
||||
oaiReq.Messages = append(oaiReq.Messages, oaiMsg)
|
||||
}
|
||||
|
||||
@@ -453,7 +459,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider request: %w", err)
|
||||
}
|
||||
@@ -474,7 +480,7 @@ type openaiMessage struct {
|
||||
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
|
||||
Name string `json:"name,omitempty"` // tool name for role=tool
|
||||
Name string `json:"name,omitempty"` // speaker name (tool or persona attribution)
|
||||
}
|
||||
|
||||
// openaiContentPart represents a single element in a multimodal content array.
|
||||
|
||||
@@ -56,7 +56,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openrouter list models: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
@@ -43,6 +47,41 @@ type ProviderConfig struct {
|
||||
APIKey string
|
||||
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
|
||||
Settings map[string]interface{} // Provider-specific settings from config JSONB
|
||||
ProxyMode string // "system" (default), "direct", "custom"
|
||||
ProxyURL string // Only used when ProxyMode == "custom"
|
||||
}
|
||||
|
||||
// Client returns an *http.Client configured for this provider's proxy settings.
|
||||
// system = http.ProxyFromEnvironment (Go default), direct = no proxy,
|
||||
// custom = explicit proxy URL (http/https/socks5).
|
||||
func (c ProviderConfig) Client() *http.Client {
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
switch c.ProxyMode {
|
||||
case "direct":
|
||||
transport.Proxy = nil
|
||||
case "custom":
|
||||
if c.ProxyURL != "" {
|
||||
u, err := url.Parse(c.ProxyURL)
|
||||
if err != nil {
|
||||
// Fall back to system rather than silently going direct —
|
||||
// a bad custom URL in a mandatory-proxy environment is a
|
||||
// security/audit hole if it silently bypasses.
|
||||
log.Printf("[proxy] invalid custom proxy URL %q: %v — falling back to system proxy", c.ProxyURL, err)
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
} else {
|
||||
transport.Proxy = http.ProxyURL(u)
|
||||
}
|
||||
} else {
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
default: // "system" or empty
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
// ── Request / Response Types ────────────────
|
||||
@@ -60,7 +99,7 @@ type Message struct {
|
||||
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
|
||||
Name string `json:"name,omitempty"` // tool name for role="tool"
|
||||
Name string `json:"name,omitempty"` // speaker name (tool result, or persona attribution v0.23.0)
|
||||
}
|
||||
|
||||
// ContentPart is a single element in a multimodal message.
|
||||
|
||||
@@ -52,7 +52,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
||||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
resp, err := cfg.Client().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("venice list models: %w", err)
|
||||
}
|
||||
|
||||
@@ -313,10 +313,16 @@ func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (pr
|
||||
}
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if cfg.ProxyURL != nil {
|
||||
proxyURL = *cfg.ProxyURL
|
||||
}
|
||||
return prov, providers.ProviderConfig{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: apiKey,
|
||||
CustomHeaders: headers,
|
||||
ProxyMode: cfg.ProxyMode,
|
||||
ProxyURL: proxyURL,
|
||||
}, cfg.Scope, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -153,8 +153,8 @@ type PolicyStore interface {
|
||||
type UserModelSettingsStore interface {
|
||||
GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error)
|
||||
GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error)
|
||||
Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error
|
||||
BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error
|
||||
Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error
|
||||
BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -229,6 +229,19 @@ type ChannelStore interface {
|
||||
// Ownership check
|
||||
UserOwns(ctx context.Context, channelID, userID string) (bool, error)
|
||||
|
||||
// Channel participants (ICD §3.7)
|
||||
AddParticipant(ctx context.Context, p *models.ChannelParticipant) error
|
||||
ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error)
|
||||
GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error)
|
||||
UpdateParticipantRole(ctx context.Context, id, role string) error
|
||||
RemoveParticipant(ctx context.Context, id string) error
|
||||
IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error)
|
||||
GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error)
|
||||
CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error)
|
||||
|
||||
// Remove model roster entries auto-created by a persona participant
|
||||
DeleteModelByPersona(ctx context.Context, channelID, personaID string) error
|
||||
|
||||
// Workspace resolution: channel workspace_id > project workspace_id
|
||||
ResolveWorkspaceID(ctx context.Context, channelID string) (string, error)
|
||||
}
|
||||
|
||||
@@ -183,20 +183,40 @@ func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||
// For persona entries, use the persona-aware path
|
||||
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
||||
return s.SetPersonaModel(ctx, cm)
|
||||
}
|
||||
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (channel_id, model_id) DO UPDATE SET
|
||||
provider_config_id = $3, display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
|
||||
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
|
||||
display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
|
||||
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPersonaModel inserts or updates a persona's channel model roster entry.
|
||||
// Uses the idx_channel_models_persona partial index (one per persona per channel).
|
||||
func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET
|
||||
model_id = $2, provider_config_id = $3, display_name = $5, system_prompt = $6, settings = $7, is_default = $8`,
|
||||
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE channel_id = $1`, channelID)
|
||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''),
|
||||
COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||
COALESCE(cm.system_prompt, ''), cm.is_default
|
||||
FROM channel_models cm
|
||||
LEFT JOIN personas p ON p.id = cm.persona_id
|
||||
WHERE cm.channel_id = $1`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -205,10 +225,14 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
||||
var result []models.ChannelModel
|
||||
for rows.Next() {
|
||||
var cm models.ChannelModel
|
||||
var personaID string
|
||||
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
||||
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
||||
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if personaID != "" {
|
||||
cm.PersonaID = &personaID
|
||||
}
|
||||
result = append(result, cm)
|
||||
}
|
||||
return result, rows.Err()
|
||||
@@ -216,15 +240,22 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
||||
|
||||
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
|
||||
var cm models.ChannelModel
|
||||
var personaID string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE id = $1`, id).Scan(
|
||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''),
|
||||
COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||
COALESCE(cm.system_prompt, ''), cm.is_default
|
||||
FROM channel_models cm
|
||||
LEFT JOIN personas p ON p.id = cm.persona_id
|
||||
WHERE cm.id = $1`, id).Scan(
|
||||
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
||||
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if personaID != "" {
|
||||
cm.PersonaID = &personaID
|
||||
}
|
||||
return &cm, nil
|
||||
}
|
||||
|
||||
@@ -287,3 +318,99 @@ func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string)
|
||||
}
|
||||
return NullableString(wsID), nil
|
||||
}
|
||||
|
||||
// ── Channel Participants (ICD §3.7) ──────────
|
||||
|
||||
func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role, display_name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, participant_type, participant_id, role,
|
||||
display_name, avatar_url, joined_at
|
||||
FROM channel_participants WHERE channel_id = $1 ORDER BY joined_at`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.ChannelParticipant
|
||||
for rows.Next() {
|
||||
var p models.ChannelParticipant
|
||||
if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
||||
&p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) {
|
||||
var p models.ChannelParticipant
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, participant_type, participant_id, role,
|
||||
display_name, avatar_url, joined_at
|
||||
FROM channel_participants WHERE id = $1`, id).Scan(
|
||||
&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
||||
&p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = $1 WHERE id = $2`, role, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error {
|
||||
res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) {
|
||||
var exists bool
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3)`,
|
||||
channelID, pType, pID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) {
|
||||
var role string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT role FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3`,
|
||||
channelID, pType, pID).Scan(&role)
|
||||
return role, err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM channel_participants WHERE channel_id = $1 AND role = $2`,
|
||||
channelID, role).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM channel_models WHERE channel_id = $1 AND persona_id = $2`,
|
||||
channelID, personaID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,20 +13,24 @@ type PersonaStore struct{}
|
||||
|
||||
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
||||
|
||||
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
|
||||
const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||
created_at, updated_at`
|
||||
|
||||
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
||||
// Auto-generate handle from name if not set
|
||||
if p.Handle == "" {
|
||||
p.Handle = models.HandleFromName(p.Name)
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO personas (name, description, icon, avatar, base_model_id, provider_config_id,
|
||||
INSERT INTO personas (name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||
scope, owner_id, created_by, is_active, is_shared,
|
||||
memory_enabled, memory_extraction_prompt)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||
p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||
models.NullString(p.ProviderConfigID),
|
||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
||||
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
||||
@@ -53,6 +57,9 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
|
||||
if patch.Name != nil {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.Handle != nil {
|
||||
b.Set("handle", *patch.Handle)
|
||||
}
|
||||
if patch.Description != nil {
|
||||
b.Set("description", *patch.Description)
|
||||
}
|
||||
@@ -281,10 +288,11 @@ func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID stri
|
||||
func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||
var p models.Persona
|
||||
var providerConfigID, ownerID sql.NullString
|
||||
var handle sql.NullString
|
||||
var temp, topP sql.NullFloat64
|
||||
var maxTokens, thinkingBudget sql.NullInt64
|
||||
err := row.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
|
||||
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
||||
&p.BaseModelID, &providerConfigID,
|
||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
@@ -294,6 +302,9 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if handle.Valid {
|
||||
p.Handle = handle.String
|
||||
}
|
||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.Temperature = NullableFloat64Ptr(temp)
|
||||
@@ -308,10 +319,11 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
for rows.Next() {
|
||||
var p models.Persona
|
||||
var providerConfigID, ownerID sql.NullString
|
||||
var handle sql.NullString
|
||||
var temp, topP sql.NullFloat64
|
||||
var maxTokens, thinkingBudget sql.NullInt64
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
|
||||
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
||||
&p.BaseModelID, &providerConfigID,
|
||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
@@ -321,6 +333,9 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if handle.Valid {
|
||||
p.Handle = handle.String
|
||||
}
|
||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.Temperature = NullableFloat64Ptr(temp)
|
||||
|
||||
@@ -14,7 +14,7 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
|
||||
|
||||
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, model_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens,
|
||||
SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens,
|
||||
COALESCE(sort_order, 0), created_at, updated_at
|
||||
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
|
||||
if err != nil {
|
||||
@@ -27,7 +27,7 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
||||
var s models.UserModelSetting
|
||||
var prefTemp, prefMaxTokens interface{}
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
|
||||
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
|
||||
&prefTemp, &prefMaxTokens,
|
||||
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
|
||||
)
|
||||
@@ -46,10 +46,10 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetHiddenModelIDs returns a map of model_id → true for all hidden models.
|
||||
// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models.
|
||||
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
"SELECT model_id FROM user_model_settings WHERE user_id = $1 AND hidden = true",
|
||||
"SELECT model_id, COALESCE(provider_config_id::text, '') FROM user_model_settings WHERE user_id = $1 AND hidden = true",
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -58,17 +58,17 @@ func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID s
|
||||
|
||||
result := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var modelID string
|
||||
if err := rows.Scan(&modelID); err != nil {
|
||||
var modelID, provCfgID string
|
||||
if err := rows.Scan(&modelID, &provCfgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[modelID] = true
|
||||
result[models.CompositeModelKey(provCfgID, modelID)] = true
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Set upserts a single user model setting.
|
||||
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error {
|
||||
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
|
||||
b := NewUpdate("user_model_settings")
|
||||
if patch.Hidden != nil {
|
||||
b.Set("hidden", *patch.Hidden)
|
||||
@@ -88,17 +88,16 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
||||
}
|
||||
|
||||
// Use upsert: insert if not exists, update if exists
|
||||
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||
VALUES ($1, $2, COALESCE($3, false), $4, $5, COALESCE($6, 0))
|
||||
ON CONFLICT (user_id, model_id)
|
||||
INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||
VALUES ($1, $2, $3, COALESCE($4, false), $5, $6, COALESCE($7, 0))
|
||||
ON CONFLICT (user_id, model_id, provider_config_id)
|
||||
DO UPDATE SET
|
||||
hidden = COALESCE($3, user_model_settings.hidden),
|
||||
preferred_temperature = COALESCE($4, user_model_settings.preferred_temperature),
|
||||
preferred_max_tokens = COALESCE($5, user_model_settings.preferred_max_tokens),
|
||||
sort_order = COALESCE($6, user_model_settings.sort_order)`,
|
||||
userID, modelID,
|
||||
hidden = COALESCE($4, user_model_settings.hidden),
|
||||
preferred_temperature = COALESCE($5, user_model_settings.preferred_temperature),
|
||||
preferred_max_tokens = COALESCE($6, user_model_settings.preferred_max_tokens),
|
||||
sort_order = COALESCE($7, user_model_settings.sort_order)`,
|
||||
userID, modelID, providerConfigID,
|
||||
patchBoolOrNil(patch.Hidden),
|
||||
patchFloat64OrNil(patch.PreferredTemperature),
|
||||
patchIntOrNil(patch.PreferredMaxTokens),
|
||||
@@ -107,30 +106,20 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
||||
return err
|
||||
}
|
||||
|
||||
// BulkSetHidden sets the hidden state for multiple models at once.
|
||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
|
||||
if len(modelIDs) == 0 {
|
||||
// BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
|
||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build parameterized IN clause
|
||||
placeholders := make([]string, len(modelIDs))
|
||||
args := make([]interface{}, 0, len(modelIDs)+2)
|
||||
args = append(args, userID, hidden)
|
||||
for i, id := range modelIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+3)
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
// Upsert each: some might not have rows yet
|
||||
for _, modelID := range modelIDs {
|
||||
for _, entry := range entries {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (user_id, model_id, hidden)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = $3`,
|
||||
userID, modelID, hidden)
|
||||
INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = $4`,
|
||||
userID, entry.ModelID, entry.ProviderConfigID, hidden)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set hidden for %s: %w", modelID, err)
|
||||
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -188,20 +188,39 @@ func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||
// For persona entries, use the persona-aware path
|
||||
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
||||
return s.SetPersonaModel(ctx, cm)
|
||||
}
|
||||
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (channel_id, model_id) DO UPDATE SET
|
||||
provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
||||
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
|
||||
display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
||||
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPersonaModel inserts or updates a persona's channel model roster entry.
|
||||
func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET
|
||||
model_id = excluded.model_id, provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
||||
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE channel_id = ?`, channelID)
|
||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
|
||||
COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||
COALESCE(cm.system_prompt, ''), cm.is_default
|
||||
FROM channel_models cm
|
||||
LEFT JOIN personas p ON p.id = cm.persona_id
|
||||
WHERE cm.channel_id = ?`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -210,10 +229,14 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
||||
var result []models.ChannelModel
|
||||
for rows.Next() {
|
||||
var cm models.ChannelModel
|
||||
var personaID string
|
||||
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
||||
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
||||
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if personaID != "" {
|
||||
cm.PersonaID = &personaID
|
||||
}
|
||||
result = append(result, cm)
|
||||
}
|
||||
return result, rows.Err()
|
||||
@@ -221,15 +244,22 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
|
||||
|
||||
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
|
||||
var cm models.ChannelModel
|
||||
var personaID string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE id = ?`, id).Scan(
|
||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
|
||||
COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
||||
COALESCE(cm.system_prompt, ''), cm.is_default
|
||||
FROM channel_models cm
|
||||
LEFT JOIN personas p ON p.id = cm.persona_id
|
||||
WHERE cm.id = ?`, id).Scan(
|
||||
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
||||
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if personaID != "" {
|
||||
cm.PersonaID = &personaID
|
||||
}
|
||||
return &cm, nil
|
||||
}
|
||||
|
||||
@@ -289,3 +319,103 @@ func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string)
|
||||
}
|
||||
return NullableString(wsID), nil
|
||||
}
|
||||
|
||||
// ── Channel Participants (ICD §3.7) ──────────
|
||||
|
||||
func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role, display_name, avatar_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
store.NewID(), p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, participant_type, participant_id, role,
|
||||
display_name, avatar_url, joined_at
|
||||
FROM channel_participants WHERE channel_id = ? ORDER BY joined_at`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.ChannelParticipant
|
||||
for rows.Next() {
|
||||
var p models.ChannelParticipant
|
||||
var joinedStr string
|
||||
if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
||||
&p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr)
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) {
|
||||
var p models.ChannelParticipant
|
||||
var joinedStr string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, participant_type, participant_id, role,
|
||||
display_name, avatar_url, joined_at
|
||||
FROM channel_participants WHERE id = ?`, id).Scan(
|
||||
&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
||||
&p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = ? WHERE id = ?`, role, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error {
|
||||
res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM channel_participants
|
||||
WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`,
|
||||
channelID, pType, pID).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) {
|
||||
var role string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT role FROM channel_participants
|
||||
WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`,
|
||||
channelID, pType, pID).Scan(&role)
|
||||
return role, err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM channel_participants WHERE channel_id = ? AND role = ?`,
|
||||
channelID, role).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM channel_models WHERE channel_id = ? AND persona_id = ?`,
|
||||
channelID, personaID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type PersonaStore struct{}
|
||||
|
||||
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
||||
|
||||
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
|
||||
const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||
created_at, updated_at`
|
||||
@@ -25,13 +25,17 @@ func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
||||
now := time.Now().UTC()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
// Auto-generate handle from name if not set
|
||||
if p.Handle == "" {
|
||||
p.Handle = models.HandleFromName(p.Name)
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO personas (id, name, description, icon, avatar, base_model_id, provider_config_id,
|
||||
INSERT INTO personas (id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
||||
created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
||||
models.NullString(p.ProviderConfigID),
|
||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
||||
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
||||
@@ -60,6 +64,9 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
|
||||
if patch.Name != nil {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.Handle != nil {
|
||||
b.Set("handle", *patch.Handle)
|
||||
}
|
||||
if patch.Description != nil {
|
||||
b.Set("description", *patch.Description)
|
||||
}
|
||||
@@ -287,11 +294,11 @@ func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID stri
|
||||
|
||||
func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||
var p models.Persona
|
||||
var providerConfigID, ownerID sql.NullString
|
||||
var providerConfigID, ownerID, handle sql.NullString
|
||||
var temp, topP sql.NullFloat64
|
||||
var maxTokens, thinkingBudget sql.NullInt64
|
||||
err := row.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
|
||||
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
||||
&p.BaseModelID, &providerConfigID,
|
||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
@@ -301,6 +308,9 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if handle.Valid {
|
||||
p.Handle = handle.String
|
||||
}
|
||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.Temperature = NullableFloat64Ptr(temp)
|
||||
@@ -314,11 +324,11 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
var result []models.Persona
|
||||
for rows.Next() {
|
||||
var p models.Persona
|
||||
var providerConfigID, ownerID sql.NullString
|
||||
var providerConfigID, ownerID, handle sql.NullString
|
||||
var temp, topP sql.NullFloat64
|
||||
var maxTokens, thinkingBudget sql.NullInt64
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
|
||||
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
||||
&p.BaseModelID, &providerConfigID,
|
||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
||||
@@ -328,6 +338,9 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if handle.Valid {
|
||||
p.Handle = handle.String
|
||||
}
|
||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.Temperature = NullableFloat64Ptr(temp)
|
||||
|
||||
@@ -15,7 +15,7 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
|
||||
|
||||
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, model_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens,
|
||||
SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens,
|
||||
COALESCE(sort_order, 0), created_at, updated_at
|
||||
FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID)
|
||||
if err != nil {
|
||||
@@ -28,7 +28,7 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
||||
var s models.UserModelSetting
|
||||
var prefTemp, prefMaxTokens interface{}
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
|
||||
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
|
||||
&prefTemp, &prefMaxTokens,
|
||||
&s.SortOrder, st(&s.CreatedAt), st(&s.UpdatedAt),
|
||||
)
|
||||
@@ -47,10 +47,10 @@ func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string)
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetHiddenModelIDs returns a map of model_id → true for all hidden models.
|
||||
// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models.
|
||||
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
"SELECT model_id FROM user_model_settings WHERE user_id = ? AND hidden = 1",
|
||||
"SELECT model_id, COALESCE(provider_config_id, '') FROM user_model_settings WHERE user_id = ? AND hidden = 1",
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -59,17 +59,17 @@ func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID s
|
||||
|
||||
result := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var modelID string
|
||||
if err := rows.Scan(&modelID); err != nil {
|
||||
var modelID, provCfgID string
|
||||
if err := rows.Scan(&modelID, &provCfgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[modelID] = true
|
||||
result[models.CompositeModelKey(provCfgID, modelID)] = true
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Set upserts a single user model setting.
|
||||
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error {
|
||||
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
|
||||
b := NewUpdate("user_model_settings")
|
||||
if patch.Hidden != nil {
|
||||
b.Set("hidden", *patch.Hidden)
|
||||
@@ -89,17 +89,16 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
||||
}
|
||||
|
||||
// Use upsert: insert if not exists, update if exists
|
||||
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||
VALUES (?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0))
|
||||
ON CONFLICT (user_id, model_id)
|
||||
INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0))
|
||||
ON CONFLICT (user_id, model_id, provider_config_id)
|
||||
DO UPDATE SET
|
||||
hidden = COALESCE(excluded.hidden, user_model_settings.hidden),
|
||||
preferred_temperature = COALESCE(excluded.preferred_temperature, user_model_settings.preferred_temperature),
|
||||
preferred_max_tokens = COALESCE(excluded.preferred_max_tokens, user_model_settings.preferred_max_tokens),
|
||||
sort_order = COALESCE(excluded.sort_order, user_model_settings.sort_order)`,
|
||||
store.NewID(), userID, modelID,
|
||||
store.NewID(), userID, modelID, providerConfigID,
|
||||
patchBoolOrNil(patch.Hidden),
|
||||
patchFloat64OrNil(patch.PreferredTemperature),
|
||||
patchIntOrNil(patch.PreferredMaxTokens),
|
||||
@@ -108,21 +107,20 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
|
||||
return err
|
||||
}
|
||||
|
||||
// BulkSetHidden sets the hidden state for multiple models at once.
|
||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
|
||||
if len(modelIDs) == 0 {
|
||||
// BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
|
||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upsert each model individually
|
||||
for _, modelID := range modelIDs {
|
||||
for _, entry := range entries {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (id, user_id, model_id, hidden)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = excluded.hidden`,
|
||||
store.NewID(), userID, modelID, hidden)
|
||||
INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = excluded.hidden`,
|
||||
store.NewID(), userID, entry.ModelID, entry.ProviderConfigID, hidden)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set hidden for %s: %w", modelID, err)
|
||||
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user