Changeset 0.6.0 (#36)
This commit is contained in:
@@ -12,6 +12,12 @@ type Config struct {
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
Environment string
|
||||
BasePath string // URL path prefix (e.g. "/dev", "/test", or "" for root)
|
||||
|
||||
// Admin bootstrap (optional — set via K8s secret)
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
AdminEmail string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
@@ -21,13 +27,34 @@ func Load() *Config {
|
||||
_ = godotenv.Load()
|
||||
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
BasePath: sanitizeBasePath(getEnv("BASE_PATH", "")),
|
||||
AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""),
|
||||
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
|
||||
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeBasePath ensures the path starts with / and doesn't end with /.
|
||||
// Empty string means root (no prefix).
|
||||
func sanitizeBasePath(p string) string {
|
||||
if p == "" || p == "/" {
|
||||
return ""
|
||||
}
|
||||
// Ensure leading slash
|
||||
if p[0] != '/' {
|
||||
p = "/" + p
|
||||
}
|
||||
// Strip trailing slash
|
||||
for len(p) > 1 && p[len(p)-1] == '/' {
|
||||
p = p[:len(p)-1]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
||||
114
server/database/migrations/006_channels_unify.sql
Normal file
114
server/database/migrations/006_channels_unify.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
-- ==========================================
|
||||
-- Migration 006: Unify Chats → Channels
|
||||
-- ==========================================
|
||||
-- "Everything is a channel." Merges the separate chats/channels
|
||||
-- schema into a single unified channel model.
|
||||
--
|
||||
-- The old channels/channel_members/channel_messages tables (from 001)
|
||||
-- were never populated — safe to drop and rebuild on top of chats.
|
||||
-- ==========================================
|
||||
|
||||
-- ── 1. Drop unused legacy channel tables ────
|
||||
-- These were placeholders from 001; no data, no handlers.
|
||||
-- Order matters: drop dependents first.
|
||||
|
||||
-- Remove FK references in tool_usage_log before dropping
|
||||
ALTER TABLE tool_usage_log DROP CONSTRAINT IF EXISTS tool_usage_log_channel_id_fkey;
|
||||
ALTER TABLE tool_usage_log DROP COLUMN IF EXISTS channel_id;
|
||||
|
||||
DROP TABLE IF EXISTS channel_messages CASCADE;
|
||||
DROP TABLE IF EXISTS channel_members CASCADE;
|
||||
DROP TABLE IF EXISTS channels CASCADE;
|
||||
|
||||
-- Drop the old trigger (will re-create after rename)
|
||||
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
|
||||
|
||||
-- ── 2. Rename chats → channels ──────────────
|
||||
|
||||
ALTER TABLE chats RENAME TO channels;
|
||||
ALTER TABLE chat_messages RENAME TO messages;
|
||||
|
||||
-- Rename columns to match new schema
|
||||
ALTER TABLE messages RENAME COLUMN chat_id TO channel_id;
|
||||
|
||||
-- Rename indexes
|
||||
ALTER INDEX IF EXISTS idx_chats_user RENAME TO idx_channels_user;
|
||||
ALTER INDEX IF EXISTS idx_chats_updated RENAME TO idx_channels_updated;
|
||||
ALTER INDEX IF EXISTS idx_chats_tags RENAME TO idx_channels_tags;
|
||||
ALTER INDEX IF EXISTS idx_chat_messages_chat RENAME TO idx_messages_channel;
|
||||
|
||||
-- Rename constraints (PK and FK auto-renamed with table on some PG versions,
|
||||
-- but let's be explicit for the FK)
|
||||
-- Note: PG auto-renames PKs but not FKs or check constraints
|
||||
|
||||
-- Rename the updated_at trigger
|
||||
DROP TRIGGER IF EXISTS chats_updated_at ON channels;
|
||||
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- ── 3. Add channel type + description ───────
|
||||
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'direct';
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
|
||||
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
|
||||
|
||||
-- Backfill: all existing rows are 1:1 AI chats
|
||||
UPDATE channels SET type = 'direct' WHERE type IS NULL;
|
||||
|
||||
-- Index on type for filtered queries
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
|
||||
|
||||
-- ── 4. Add message tree (parent_id) ─────────
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
|
||||
-- Backfill linear parent chains on existing messages.
|
||||
-- Each message's parent is the previous message in the same channel (by created_at).
|
||||
WITH ordered AS (
|
||||
SELECT id, channel_id, created_at,
|
||||
LAG(id) OVER (PARTITION BY channel_id ORDER BY created_at) AS prev_id
|
||||
FROM messages
|
||||
)
|
||||
UPDATE messages m
|
||||
SET parent_id = o.prev_id
|
||||
FROM ordered o
|
||||
WHERE m.id = o.id AND o.prev_id IS NOT NULL AND m.parent_id IS NULL;
|
||||
|
||||
-- ── 5. Add participant columns on messages ──
|
||||
-- Decouples messages from user-only: AI models are participants too.
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_type VARCHAR(10) DEFAULT 'user';
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_id VARCHAR(255);
|
||||
|
||||
COMMENT ON COLUMN messages.participant_type IS 'user or model';
|
||||
COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string';
|
||||
|
||||
-- Backfill: user messages get the channel owner's user_id;
|
||||
-- assistant messages get the model name as participant_id.
|
||||
UPDATE messages m
|
||||
SET participant_type = CASE WHEN m.role = 'assistant' THEN 'model' ELSE 'user' END,
|
||||
participant_id = CASE
|
||||
WHEN m.role = 'assistant' THEN COALESCE(m.model, 'unknown')
|
||||
ELSE (SELECT c.user_id::text FROM channels c WHERE c.id = m.channel_id)
|
||||
END
|
||||
WHERE m.participant_id IS NULL;
|
||||
|
||||
-- ── 6. Update model_routing_log FK ──────────
|
||||
|
||||
-- The FK column was named chat_id — rename it
|
||||
ALTER TABLE model_routing_log RENAME COLUMN chat_id TO channel_id;
|
||||
ALTER INDEX IF EXISTS idx_routing_log_chat RENAME TO idx_routing_log_channel;
|
||||
|
||||
-- message_id FK still valid (messages table was renamed, FK follows)
|
||||
|
||||
-- ── 7. Update tool_usage_log ────────────────
|
||||
-- We already dropped the old channel_id column above.
|
||||
-- Re-add it pointing to the unified channels table.
|
||||
-- Also rename the old chat_id column.
|
||||
|
||||
ALTER TABLE tool_usage_log RENAME COLUMN chat_id TO channel_id;
|
||||
-- The FK auto-follows the table rename, but let's be safe:
|
||||
-- (chat_id FK pointed to chats(id), which is now channels(id) — PG handles this)
|
||||
56
server/database/migrations/007_channel_members_models.sql
Normal file
56
server/database/migrations/007_channel_members_models.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- ==========================================
|
||||
-- Migration 007: Channel Members & Models
|
||||
-- ==========================================
|
||||
-- Membership and model assignment tables for
|
||||
-- multi-user and multi-model channels.
|
||||
-- ==========================================
|
||||
|
||||
-- ── Channel Members ─────────────────────────
|
||||
-- Who is in this channel (human participants).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
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', -- owner, admin, member
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_members_channel ON channel_members(channel_id);
|
||||
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
|
||||
|
||||
-- Backfill: existing channels are 1:1, so the channel owner is the sole member.
|
||||
INSERT INTO channel_members (channel_id, user_id, role)
|
||||
SELECT id, user_id, 'owner'
|
||||
FROM channels
|
||||
WHERE user_id IS NOT NULL
|
||||
ON CONFLICT (channel_id, user_id) DO NOTHING;
|
||||
|
||||
-- ── Channel Models ──────────────────────────
|
||||
-- Which AI models are assigned to this channel.
|
||||
-- For direct chats this is one model; for group/channel
|
||||
-- there can be multiple with @mention routing.
|
||||
|
||||
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, -- e.g. 'claude-sonnet-4'
|
||||
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
|
||||
display_name VARCHAR(100), -- optional alias in this channel
|
||||
system_prompt TEXT, -- per-model system prompt override
|
||||
settings JSONB DEFAULT '{}'::jsonb, -- temperature, max_tokens overrides
|
||||
is_default BOOLEAN DEFAULT false, -- auto-complete (no @mention needed)
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, model_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
-- Backfill: existing channels have a model in the channels.model column.
|
||||
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
|
||||
SELECT id, model, api_config_id, true
|
||||
FROM channels
|
||||
WHERE model IS NOT NULL AND model != ''
|
||||
ON CONFLICT (channel_id, model_id) DO NOTHING;
|
||||
29
server/database/migrations/008_channel_cursors.sql
Normal file
29
server/database/migrations/008_channel_cursors.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- ==========================================
|
||||
-- Migration 008: Channel Cursors
|
||||
-- ==========================================
|
||||
-- Tracks each user's active branch position
|
||||
-- per channel. Essential for conversation forking.
|
||||
-- ==========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_cursors_channel ON channel_cursors(channel_id);
|
||||
CREATE INDEX idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
-- Backfill: set cursor to the last message in each channel for the owner.
|
||||
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
|
||||
SELECT c.id, c.user_id, (
|
||||
SELECT m.id FROM messages m
|
||||
WHERE m.channel_id = c.id
|
||||
ORDER BY m.created_at DESC LIMIT 1
|
||||
)
|
||||
FROM channels c
|
||||
WHERE c.user_id IS NOT NULL
|
||||
ON CONFLICT (channel_id, user_id) DO NOTHING;
|
||||
57
server/database/migrations/009_folders_projects.sql
Normal file
57
server/database/migrations/009_folders_projects.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- ==========================================
|
||||
-- Migration 009: Folders & Projects
|
||||
-- ==========================================
|
||||
-- Organizational structures for channels.
|
||||
-- Folders are simple containers; projects are
|
||||
-- tagged collections that can span folders.
|
||||
-- ==========================================
|
||||
|
||||
-- ── Folders ─────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, name, parent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_user ON folders(user_id);
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||
|
||||
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Add folder_id to channels (replaces the old text folder column)
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES folders(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id);
|
||||
|
||||
-- ── Projects ────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7), -- hex color for UI badge
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_projects_user ON projects(user_id);
|
||||
|
||||
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Junction: channels can belong to multiple projects
|
||||
CREATE TABLE IF NOT EXISTS project_channels (
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_channels_channel ON project_channels(channel_id);
|
||||
32
server/database/migrations/010_banners.sql
Normal file
32
server/database/migrations/010_banners.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- ==========================================
|
||||
-- Migration 010: Environment Banners
|
||||
-- ==========================================
|
||||
-- Environment banners for deployment context
|
||||
-- (dev, staging, production, etc). Stored in
|
||||
-- global_settings with a dedicated key.
|
||||
-- ==========================================
|
||||
|
||||
-- Seed default banner config (disabled).
|
||||
-- Schema: { enabled, text, position, bg, fg }
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('banner', '{
|
||||
"enabled": false,
|
||||
"text": "",
|
||||
"position": "both",
|
||||
"bg": "#007a33",
|
||||
"fg": "#ffffff"
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Banner presets for quick selection in admin UI.
|
||||
-- Generic environment labels — admins can set custom text.
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('banner_presets', '{
|
||||
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
|
||||
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
|
||||
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
|
||||
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
|
||||
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
|
||||
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
18
server/database/migrations/011_banner_presets_fix.sql
Normal file
18
server/database/migrations/011_banner_presets_fix.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- ==========================================
|
||||
-- Migration 011: Replace banner presets
|
||||
-- ==========================================
|
||||
-- Replaces legacy presets with
|
||||
-- generic environment labels. Existing databases
|
||||
-- that ran 010 have the old presets; this overwrites.
|
||||
-- ==========================================
|
||||
|
||||
UPDATE global_settings
|
||||
SET value = '{
|
||||
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
|
||||
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
|
||||
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
|
||||
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
|
||||
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
|
||||
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
|
||||
}'::jsonb
|
||||
WHERE key = 'banner_presets';
|
||||
40
server/go.sum
Normal file
40
server/go.sum
Normal file
@@ -0,0 +1,40 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
@@ -278,6 +278,44 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
||||
}
|
||||
|
||||
// ── Public Settings (for any authenticated user) ──
|
||||
|
||||
var publicSettingKeys = map[string]bool{
|
||||
"banner": true,
|
||||
"user_providers_enabled": true,
|
||||
"registration_enabled": true,
|
||||
"registration_default_state": true,
|
||||
"banner_presets": true,
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT key, value::text FROM global_settings ORDER BY key
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"settings": []interface{}{}})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
settings := make([]globalSettingResponse, 0)
|
||||
for rows.Next() {
|
||||
var key, valueRaw string
|
||||
if err := rows.Scan(&key, &valueRaw); err != nil {
|
||||
continue
|
||||
}
|
||||
if !publicSettingKeys[key] {
|
||||
continue
|
||||
}
|
||||
s := globalSettingResponse{Key: key}
|
||||
s.Value = make(map[string]interface{})
|
||||
_ = json.Unmarshal([]byte(valueRaw), &s.Value)
|
||||
settings = append(settings, s)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||
}
|
||||
|
||||
// ── List Global Settings ────────────────────
|
||||
|
||||
func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
|
||||
@@ -369,8 +407,8 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
|
||||
queries := map[string]string{
|
||||
"total_users": "SELECT COUNT(*) FROM users",
|
||||
"active_users": "SELECT COUNT(*) FROM users WHERE is_active = true",
|
||||
"total_chats": "SELECT COUNT(*) FROM chats",
|
||||
"total_messages": "SELECT COUNT(*) FROM chat_messages",
|
||||
"total_channels": "SELECT COUNT(*) FROM channels",
|
||||
"total_messages": "SELECT COUNT(*) FROM messages",
|
||||
"api_configs": "SELECT COUNT(*) FROM api_configs",
|
||||
}
|
||||
|
||||
@@ -438,8 +476,8 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||
|
||||
var id string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL)
|
||||
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id, is_global)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL, true)
|
||||
RETURNING id
|
||||
`, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id)
|
||||
if err != nil {
|
||||
@@ -666,6 +704,28 @@ func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
|
||||
}
|
||||
|
||||
// BulkUpdateModels enables or disables all models at once
|
||||
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
|
||||
var req struct {
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW()`,
|
||||
req.IsEnabled,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update models"})
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "models updated", "count": rows})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
|
||||
modelID := c.Param("id")
|
||||
result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID)
|
||||
|
||||
@@ -89,8 +89,8 @@ func TestCompletionHandlerMissingFields(t *testing.T) {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing chat_id", `{"content":"hello"}`},
|
||||
{"missing content", `{"chat_id":"abc"}`},
|
||||
{"missing channel_id", `{"content":"hello"}`},
|
||||
{"missing content", `{"channel_id":"abc"}`},
|
||||
{"empty body", `{}`},
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -91,8 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
_ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
|
||||
isFirstUser := userCount == 0
|
||||
|
||||
// If not first user, check if registration is enabled
|
||||
if !isFirstUser {
|
||||
// First-user-becomes-admin only when no env admin is configured
|
||||
envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != ""
|
||||
promoteFirst := isFirstUser && !envAdminSet
|
||||
|
||||
// If not first user (or env admin handles bootstrap), check registration
|
||||
if !promoteFirst {
|
||||
if !IsRegistrationEnabled() {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
|
||||
return
|
||||
@@ -106,19 +112,25 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// First user gets admin role
|
||||
// Determine role and active state
|
||||
role := "user"
|
||||
if isFirstUser {
|
||||
isActive := true
|
||||
if promoteFirst {
|
||||
role = "admin"
|
||||
} else {
|
||||
// Apply registration default state
|
||||
if GetRegistrationDefaultState() == "pending" {
|
||||
isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
// Insert user
|
||||
var user userResponse
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO users (username, email, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO users (username, email, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, email, display_name, role
|
||||
`, req.Username, req.Email, string(hash), role).Scan(
|
||||
`, req.Username, req.Email, string(hash), role, isActive).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -134,6 +146,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// If account is pending, don't generate tokens
|
||||
if !isActive {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Account created and pending admin approval",
|
||||
"pending": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
resp, err := h.generateTokenPair(user)
|
||||
if err != nil {
|
||||
@@ -152,8 +173,8 @@ func IsRegistrationEnabled() bool {
|
||||
}
|
||||
var enabled bool
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT COALESCE((value->>'enabled')::boolean, true)
|
||||
FROM global_settings WHERE key = 'registration'
|
||||
SELECT COALESCE((value->>'value')::boolean, true)
|
||||
FROM global_settings WHERE key = 'registration_enabled'
|
||||
`).Scan(&enabled)
|
||||
if err != nil {
|
||||
return true // Default to open if setting missing
|
||||
@@ -161,6 +182,75 @@ func IsRegistrationEnabled() bool {
|
||||
return enabled
|
||||
}
|
||||
|
||||
// GetRegistrationDefaultState returns "active" or "pending".
|
||||
func GetRegistrationDefaultState() string {
|
||||
if database.DB == nil {
|
||||
return "active"
|
||||
}
|
||||
var state string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT COALESCE(value->>'value', 'active')
|
||||
FROM global_settings WHERE key = 'registration_default_state'
|
||||
`).Scan(&state)
|
||||
if err != nil || state == "" {
|
||||
return "active"
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// BootstrapAdmin creates or updates the admin user from environment variables.
|
||||
// This runs on every startup, so changing the K8s secret + restarting resets the password.
|
||||
// Handles both username and email conflicts (e.g. admin username changed between deploys).
|
||||
func BootstrapAdmin(cfg *config.Config) {
|
||||
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||||
return
|
||||
}
|
||||
if database.DB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
email := cfg.AdminEmail
|
||||
if email == "" {
|
||||
email = cfg.AdminUsername + "@localhost"
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to hash admin password: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Try upsert by username (common case: same username, new password)
|
||||
_, err = database.DB.Exec(`
|
||||
INSERT INTO users (username, email, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, 'admin', true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
email = EXCLUDED.email,
|
||||
role = 'admin',
|
||||
is_active = true
|
||||
`, cfg.AdminUsername, email, string(hash))
|
||||
|
||||
if err != nil && strings.Contains(err.Error(), "duplicate key") {
|
||||
// Email conflict — admin username was changed in config but email
|
||||
// already belongs to old admin row. Update that row instead.
|
||||
_, err = database.DB.Exec(`
|
||||
UPDATE users SET
|
||||
username = $1,
|
||||
password_hash = $3,
|
||||
role = 'admin',
|
||||
is_active = true
|
||||
WHERE email = $2
|
||||
`, cfg.AdminUsername, email, string(hash))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠ Admin bootstrap failed: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Login ───────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
@@ -195,7 +285,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !isActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
|
||||
// ── Request / Response types ────────────────
|
||||
|
||||
type createChatRequest struct {
|
||||
type createChannelRequest struct {
|
||||
Title string `json:"title" binding:"required,max=500"`
|
||||
Type string `json:"type,omitempty"` // direct (default), group, channel
|
||||
Description string `json:"description,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"api_config_id,omitempty"`
|
||||
@@ -23,8 +25,9 @@ type createChatRequest struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type updateChatRequest struct {
|
||||
type updateChannelRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"api_config_id,omitempty"`
|
||||
@@ -34,10 +37,12 @@ type updateChatRequest struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
type channelResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Description *string `json:"description"`
|
||||
Model *string `json:"model"`
|
||||
APIConfigID *string `json:"api_config_id"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
@@ -58,12 +63,12 @@ type paginatedResponse struct {
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// ChatHandler holds dependencies for chat endpoints.
|
||||
type ChatHandler struct{}
|
||||
// ChannelHandler holds dependencies for channel endpoints.
|
||||
type ChannelHandler struct{}
|
||||
|
||||
// NewChatHandler creates a new chat handler.
|
||||
func NewChatHandler() *ChatHandler {
|
||||
return &ChatHandler{}
|
||||
// NewChannelHandler creates a new channel handler.
|
||||
func NewChannelHandler() *ChannelHandler {
|
||||
return &ChannelHandler{}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
@@ -93,46 +98,59 @@ func parsePagination(c *gin.Context) (page, perPage, offset int) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── List Chats ──────────────────────────────
|
||||
// ── List Channels ───────────────────────────
|
||||
|
||||
func (h *ChatHandler) ListChats(c *gin.Context) {
|
||||
func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
// Optional filters
|
||||
archived := c.DefaultQuery("archived", "false")
|
||||
folder := c.Query("folder")
|
||||
channelType := c.DefaultQuery("type", "") // empty = all types
|
||||
|
||||
// Count total
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM chats WHERE user_id = $1 AND is_archived = $2`
|
||||
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
|
||||
countArgs := []interface{}{userID, archived == "true"}
|
||||
argN := 3
|
||||
|
||||
if channelType != "" {
|
||||
countQuery += ` AND type = $` + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, channelType)
|
||||
argN++
|
||||
}
|
||||
if folder != "" {
|
||||
countQuery += ` AND folder = $3`
|
||||
countQuery += ` AND folder = $` + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, folder)
|
||||
argN++
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count chats"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch chats with message count
|
||||
// Fetch channels with message count
|
||||
query := `
|
||||
SELECT c.id, c.user_id, c.title, c.model, c.api_config_id,
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
c.created_at, c.updated_at
|
||||
FROM chats c
|
||||
FROM channels c
|
||||
LEFT JOIN (
|
||||
SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id
|
||||
) mc ON mc.chat_id = c.id
|
||||
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
||||
) mc ON mc.channel_id = c.id
|
||||
WHERE c.user_id = $1 AND c.is_archived = $2`
|
||||
|
||||
args := []interface{}{userID, archived == "true"}
|
||||
argN := 3
|
||||
argN = 3
|
||||
|
||||
if channelType != "" {
|
||||
query += ` AND c.type = $` + strconv.Itoa(argN)
|
||||
args = append(args, channelType)
|
||||
argN++
|
||||
}
|
||||
if folder != "" {
|
||||
query += ` AND c.folder = $` + strconv.Itoa(argN)
|
||||
args = append(args, folder)
|
||||
@@ -145,34 +163,34 @@ func (h *ChatHandler) ListChats(c *gin.Context) {
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list chats"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
chats := make([]chatResponse, 0)
|
||||
channels := make([]channelResponse, 0)
|
||||
for rows.Next() {
|
||||
var chat chatResponse
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := rows.Scan(
|
||||
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID,
|
||||
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags),
|
||||
&chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt,
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan chat"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"})
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
chat.Tags = tags
|
||||
chats = append(chats, chat)
|
||||
ch.Tags = tags
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, paginatedResponse{
|
||||
Data: chats,
|
||||
Data: channels,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Total: total,
|
||||
@@ -180,12 +198,12 @@ func (h *ChatHandler) ListChats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Create Chat ─────────────────────────────
|
||||
// ── Create Channel ──────────────────────────
|
||||
|
||||
func (h *ChatHandler) CreateChat(c *gin.Context) {
|
||||
func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req createChatRequest
|
||||
var req createChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -195,88 +213,110 @@ func (h *ChatHandler) CreateChat(c *gin.Context) {
|
||||
req.Tags = []string{}
|
||||
}
|
||||
|
||||
var chat chatResponse
|
||||
// Default type is "direct" (1:1 AI chat)
|
||||
channelType := req.Type
|
||||
if channelType == "" {
|
||||
channelType = "direct"
|
||||
}
|
||||
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO chats (user_id, title, model, system_prompt, api_config_id, folder, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, user_id, title, model, api_config_id, system_prompt,
|
||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, api_config_id, folder, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, title, type, description, model, api_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, tags, created_at, updated_at
|
||||
`, userID, req.Title, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
).Scan(
|
||||
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID,
|
||||
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder,
|
||||
pq.Array(&tags), &chat.CreatedAt, &chat.UpdatedAt,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create chat"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||
return
|
||||
}
|
||||
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
chat.Tags = tags
|
||||
chat.MessageCount = 0
|
||||
ch.Tags = tags
|
||||
ch.MessageCount = 0
|
||||
|
||||
c.JSON(http.StatusCreated, chat)
|
||||
// Auto-create channel_member for the creator
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_members (channel_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, userID)
|
||||
|
||||
// Auto-create channel_model if model specified
|
||||
if req.Model != "" {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, req.Model, req.APIConfigID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, ch)
|
||||
}
|
||||
|
||||
// ── Get Chat ────────────────────────────────
|
||||
// ── Get Channel ─────────────────────────────
|
||||
|
||||
func (h *ChatHandler) GetChat(c *gin.Context) {
|
||||
func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
chatID := c.Param("id")
|
||||
channelID := c.Param("id")
|
||||
|
||||
var chat chatResponse
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT c.id, c.user_id, c.title, c.model, c.api_config_id,
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
c.created_at, c.updated_at
|
||||
FROM chats c
|
||||
FROM channels c
|
||||
LEFT JOIN (
|
||||
SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id
|
||||
) mc ON mc.chat_id = c.id
|
||||
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
||||
) mc ON mc.channel_id = c.id
|
||||
WHERE c.id = $1 AND c.user_id = $2
|
||||
`, chatID, userID).Scan(
|
||||
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID,
|
||||
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder,
|
||||
`, channelID, userID).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags),
|
||||
&chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt,
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get chat"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"})
|
||||
return
|
||||
}
|
||||
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
chat.Tags = tags
|
||||
ch.Tags = tags
|
||||
|
||||
c.JSON(http.StatusOK, chat)
|
||||
c.JSON(http.StatusOK, ch)
|
||||
}
|
||||
|
||||
// ── Update Chat ─────────────────────────────
|
||||
// ── Update Channel ──────────────────────────
|
||||
|
||||
func (h *ChatHandler) UpdateChat(c *gin.Context) {
|
||||
func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
chatID := c.Param("id")
|
||||
|
||||
if database.DB == nil {
|
||||
channelID := c.Param("id")
|
||||
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var req updateChatRequest
|
||||
var req updateChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -284,13 +324,13 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
|
||||
|
||||
// Verify ownership
|
||||
var ownerID string
|
||||
err := database.DB.QueryRow(`SELECT user_id FROM chats WHERE id = $1`, chatID).Scan(&ownerID)
|
||||
err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -308,6 +348,9 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
|
||||
if req.Title != nil {
|
||||
addClause("title", *req.Title)
|
||||
}
|
||||
if req.Description != nil {
|
||||
addClause("description", *req.Description)
|
||||
}
|
||||
if req.Model != nil {
|
||||
addClause("model", *req.Model)
|
||||
}
|
||||
@@ -335,7 +378,7 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
query := "UPDATE chats SET "
|
||||
query := "UPDATE channels SET "
|
||||
for i, clause := range setClauses {
|
||||
if i > 0 {
|
||||
query += ", "
|
||||
@@ -343,38 +386,38 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
|
||||
query += clause
|
||||
}
|
||||
query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1)
|
||||
args = append(args, chatID, userID)
|
||||
args = append(args, channelID, userID)
|
||||
|
||||
_, err = database.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update chat"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated chat
|
||||
h.GetChat(c)
|
||||
// Return updated channel
|
||||
h.GetChannel(c)
|
||||
}
|
||||
|
||||
// ── Delete Chat ─────────────────────────────
|
||||
// ── Delete Channel ──────────────────────────
|
||||
|
||||
func (h *ChatHandler) DeleteChat(c *gin.Context) {
|
||||
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
chatID := c.Param("id")
|
||||
channelID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM chats WHERE id = $1 AND user_id = $2`,
|
||||
chatID, userID,
|
||||
`DELETE FROM channels WHERE id = $1 AND user_id = $2`,
|
||||
channelID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete chat"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "chat deleted"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
||||
}
|
||||
@@ -13,56 +13,56 @@ func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// ── Chat Request Validation ─────────────────
|
||||
// ── Channel Request Validation ─────────────────
|
||||
|
||||
func TestCreateChatMissingTitle(t *testing.T) {
|
||||
h := NewChatHandler()
|
||||
func TestCreateChannelMissingTitle(t *testing.T) {
|
||||
h := NewChannelHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/chats",
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
|
||||
strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateChat(c)
|
||||
h.CreateChannel(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChatTitleTooLong(t *testing.T) {
|
||||
h := NewChatHandler()
|
||||
func TestCreateChannelTitleTooLong(t *testing.T) {
|
||||
h := NewChannelHandler()
|
||||
|
||||
longTitle := strings.Repeat("x", 501)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/chats",
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
|
||||
strings.NewReader(`{"title":"`+longTitle+`"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateChat(c)
|
||||
h.CreateChannel(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateChatEmptyBody(t *testing.T) {
|
||||
h := NewChatHandler()
|
||||
func TestUpdateChannelEmptyBody(t *testing.T) {
|
||||
h := NewChannelHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "test-user-id")
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-chat-id"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/api/v1/chats/test-chat-id",
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-channel-id"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/api/v1/channels/test-channel-id",
|
||||
strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Without a DB connection, UpdateChat will fail at ownership check.
|
||||
// Without a DB connection, UpdateChannel will fail at ownership check.
|
||||
// Integration tests with a real DB would validate the "no fields" path.
|
||||
// Here we just confirm it doesn't return 400 for valid JSON.
|
||||
h.UpdateChat(c)
|
||||
h.UpdateChannel(c)
|
||||
|
||||
if w.Code == http.StatusBadRequest {
|
||||
t.Error("Empty JSON body should not be a parse error")
|
||||
@@ -76,8 +76,8 @@ func TestCreateMessageMissingRole(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-chat"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages",
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
|
||||
strings.NewReader(`{"content":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -93,8 +93,8 @@ func TestCreateMessageInvalidRole(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-chat"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages",
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
|
||||
strings.NewReader(`{"role":"invalid","content":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -110,8 +110,8 @@ func TestCreateMessageMissingContent(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-chat"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages",
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
|
||||
strings.NewReader(`{"role":"user"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestRegenerateReturns501(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/chats/x/regenerate", nil)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/x/regenerate", nil)
|
||||
|
||||
h.Regenerate(c)
|
||||
|
||||
@@ -149,7 +149,7 @@ func TestRegenerateReturns501(t *testing.T) {
|
||||
func TestParsePaginationDefaults(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/chats", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/channels", nil)
|
||||
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
@@ -167,7 +167,7 @@ func TestParsePaginationDefaults(t *testing.T) {
|
||||
func TestParsePaginationCustom(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/chats?page=3&per_page=10", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/channels?page=3&per_page=10", nil)
|
||||
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
@@ -185,7 +185,7 @@ func TestParsePaginationCustom(t *testing.T) {
|
||||
func TestParsePaginationClampMax(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/chats?per_page=500", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/channels?per_page=500", nil)
|
||||
|
||||
_, perPage, _ := parsePagination(c)
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
// ── Request Types ───────────────────────────
|
||||
|
||||
type completionRequest struct {
|
||||
ChatID string `json:"chat_id" binding:"required"`
|
||||
ChannelID string `json:"channel_id"` // preferred; validated manually below
|
||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
||||
Content string `json:"content" binding:"required"`
|
||||
Model string `json:"model,omitempty"`
|
||||
APIConfigID string `json:"api_config_id,omitempty"`
|
||||
@@ -41,7 +42,7 @@ func NewCompletionHandler() *CompletionHandler {
|
||||
// Flow:
|
||||
// 1. Validate request, verify chat ownership
|
||||
// 2. Resolve api_config (from request, chat, or user default)
|
||||
// 3. Load conversation history from chat_messages
|
||||
// 3. Load conversation history from messages
|
||||
// 4. Persist user message
|
||||
// 5. Call provider (stream or non-stream)
|
||||
// 6. Stream SSE to client / return JSON
|
||||
@@ -54,15 +55,25 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Support chat_id as alias during frontend transition
|
||||
channelID := req.ChannelID
|
||||
if channelID == "" {
|
||||
channelID = req.ChatID
|
||||
}
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
// Verify chat ownership
|
||||
if !userOwnsChat(c, req.ChatID, userID) {
|
||||
// Verify channel ownership
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve provider config
|
||||
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, req)
|
||||
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, channelID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -75,7 +86,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Load conversation history
|
||||
messages, err := h.loadConversation(req.ChatID)
|
||||
messages, err := h.loadConversation(channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
@@ -88,7 +99,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
})
|
||||
|
||||
// Persist user message
|
||||
if err := h.persistMessage(req.ChatID, "user", req.Content, "", 0, 0); err != nil {
|
||||
if err := h.persistMessage(channelID, "user", req.Content, "", 0, 0); err != nil {
|
||||
log.Printf("Failed to persist user message: %v", err)
|
||||
}
|
||||
|
||||
@@ -122,9 +133,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
if stream {
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, req.ChatID, model)
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, model)
|
||||
} else {
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, req.ChatID, model)
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +146,7 @@ func (h *CompletionHandler) streamCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
chatID, model string,
|
||||
channelID, model string,
|
||||
) {
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
@@ -190,7 +201,7 @@ func (h *CompletionHandler) streamCompletion(
|
||||
|
||||
// Persist assistant response
|
||||
if fullContent != "" {
|
||||
if err := h.persistMessage(chatID, "assistant", fullContent, model, 0, 0); err != nil {
|
||||
if err := h.persistMessage(channelID, "assistant", fullContent, model, 0, 0); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -203,7 +214,7 @@ func (h *CompletionHandler) syncCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
chatID, model string,
|
||||
channelID, model string,
|
||||
) {
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
@@ -212,7 +223,7 @@ func (h *CompletionHandler) syncCompletion(
|
||||
}
|
||||
|
||||
// Persist assistant response
|
||||
if err := h.persistMessage(chatID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil {
|
||||
if err := h.persistMessage(channelID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
@@ -271,7 +282,7 @@ func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) prov
|
||||
// ── Config Resolution ───────────────────────
|
||||
// Priority: request.api_config_id → chat.api_config_id → user's first active config
|
||||
|
||||
func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
|
||||
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
|
||||
var configID string
|
||||
|
||||
// 1. Explicit config from request
|
||||
@@ -279,14 +290,14 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
|
||||
configID = req.APIConfigID
|
||||
}
|
||||
|
||||
// 2. Config from chat
|
||||
// 2. Config from channel
|
||||
if configID == "" {
|
||||
var chatConfigID *string
|
||||
var channelConfigID *string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT api_config_id FROM chats WHERE id = $1`, req.ChatID,
|
||||
).Scan(&chatConfigID)
|
||||
if err == nil && chatConfigID != nil {
|
||||
configID = *chatConfigID
|
||||
`SELECT api_config_id FROM channels WHERE id = $1`, channelID,
|
||||
).Scan(&channelConfigID)
|
||||
if err == nil && channelConfigID != nil {
|
||||
configID = *channelConfigID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +305,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
|
||||
if configID == "" {
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id FROM api_configs
|
||||
WHERE (user_id = $1 OR is_global = true) AND is_active = true
|
||||
WHERE (user_id = $1 OR is_global = true OR user_id IS NULL) AND is_active = true
|
||||
ORDER BY user_id NULLS LAST, created_at ASC
|
||||
LIMIT 1
|
||||
`, userID).Scan(&configID)
|
||||
@@ -310,7 +321,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings
|
||||
FROM api_configs
|
||||
WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true
|
||||
WHERE id = $1 AND (user_id = $2 OR is_global = true OR user_id IS NULL) AND is_active = true
|
||||
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -356,11 +367,11 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
|
||||
|
||||
// ── Conversation Loader ─────────────────────
|
||||
|
||||
func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message, error) {
|
||||
// Load system prompt from chat
|
||||
func (h *CompletionHandler) loadConversation(channelID string) ([]providers.Message, error) {
|
||||
// Load system prompt from channel
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(
|
||||
`SELECT system_prompt FROM chats WHERE id = $1`, chatID,
|
||||
`SELECT system_prompt FROM channels WHERE id = $1`, channelID,
|
||||
).Scan(&systemPrompt)
|
||||
|
||||
messages := make([]providers.Message, 0)
|
||||
@@ -374,10 +385,10 @@ func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message
|
||||
|
||||
// Load message history (oldest first)
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT role, content FROM chat_messages
|
||||
WHERE chat_id = $1
|
||||
SELECT role, content FROM messages
|
||||
WHERE channel_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, chatID)
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -396,22 +407,42 @@ func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message
|
||||
|
||||
// ── Message Persistence ─────────────────────
|
||||
|
||||
func (h *CompletionHandler) persistMessage(chatID, role, content, model string, inputTokens, outputTokens int) error {
|
||||
func (h *CompletionHandler) persistMessage(channelID, role, content, model string, inputTokens, outputTokens int) error {
|
||||
var tokensUsed *int
|
||||
if inputTokens > 0 || outputTokens > 0 {
|
||||
total := inputTokens + outputTokens
|
||||
tokensUsed = &total
|
||||
}
|
||||
|
||||
// Find current leaf for parent_id
|
||||
var parentID *string
|
||||
_ = database.DB.QueryRow(
|
||||
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||
channelID,
|
||||
).Scan(&parentID)
|
||||
|
||||
participantType := "user"
|
||||
participantID := channelID // will be overridden below
|
||||
if role == "assistant" {
|
||||
participantType = "model"
|
||||
participantID = model
|
||||
} else {
|
||||
// Get channel owner as participant_id for user messages
|
||||
var ownerID string
|
||||
if err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID); err == nil {
|
||||
participantID = ownerID
|
||||
}
|
||||
}
|
||||
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO chat_messages (chat_id, role, content, model, tokens_used)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||
`, chatID, role, content, model, tokensUsed)
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used, parent_id, participant_type, participant_id)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8)
|
||||
`, channelID, role, content, model, tokensUsed, parentID, participantType, participantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Touch chat updated_at
|
||||
_, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID)
|
||||
// Touch channel updated_at
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,13 +19,14 @@ type createMessageRequest struct {
|
||||
}
|
||||
|
||||
type messageResponse struct {
|
||||
ID string `json:"id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Model *string `json:"model"`
|
||||
TokensUsed *int `json:"tokens_used"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Model *string `json:"model"`
|
||||
TokensUsed *int `json:"tokens_used"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// MessageHandler holds dependencies for message endpoints.
|
||||
@@ -42,18 +43,18 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
userID := getUserID(c)
|
||||
chatID := c.Param("id")
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
if !userOwnsChat(c, chatID, userID) {
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Count total messages
|
||||
var total int
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT COUNT(*) FROM chat_messages WHERE chat_id = $1`,
|
||||
chatID,
|
||||
`SELECT COUNT(*) FROM messages WHERE channel_id = $1`,
|
||||
channelID,
|
||||
).Scan(&total)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"})
|
||||
@@ -62,12 +63,12 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
|
||||
// Fetch messages (oldest first for conversation display)
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, chat_id, role, content, model, tokens_used, created_at
|
||||
FROM chat_messages
|
||||
WHERE chat_id = $1
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id, created_at
|
||||
FROM messages
|
||||
WHERE channel_id = $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, chatID, perPage, offset)
|
||||
`, channelID, perPage, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
|
||||
return
|
||||
@@ -78,8 +79,8 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
for rows.Next() {
|
||||
var msg messageResponse
|
||||
err := rows.Scan(
|
||||
&msg.ID, &msg.ChatID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.CreatedAt,
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
|
||||
@@ -107,29 +108,49 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
chatID := c.Param("id")
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
if !userOwnsChat(c, chatID, userID) {
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the current leaf message to set as parent
|
||||
var parentID *string
|
||||
_ = database.DB.QueryRow(
|
||||
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||
channelID,
|
||||
).Scan(&parentID)
|
||||
|
||||
var msg messageResponse
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO chat_messages (chat_id, role, content, model)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, chat_id, role, content, model, tokens_used, created_at
|
||||
`, chatID, req.Role, req.Content, req.Model).Scan(
|
||||
&msg.ID, &msg.ChatID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.CreatedAt,
|
||||
INSERT INTO messages (channel_id, role, content, model, parent_id, participant_type, participant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id, created_at
|
||||
`, channelID, req.Role, req.Content, req.Model, parentID,
|
||||
func() string {
|
||||
if req.Role == "assistant" {
|
||||
return "model"
|
||||
}
|
||||
return "user"
|
||||
}(),
|
||||
func() string {
|
||||
if req.Role == "assistant" && req.Model != "" {
|
||||
return req.Model
|
||||
}
|
||||
return userID
|
||||
}(),
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||
return
|
||||
}
|
||||
|
||||
// Touch the chat's updated_at so it sorts to top of list
|
||||
_, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID)
|
||||
// Touch the channel's updated_at so it sorts to top of list
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
|
||||
c.JSON(http.StatusCreated, msg)
|
||||
}
|
||||
@@ -152,22 +173,22 @@ func (h *MessageHandler) Stream(c *gin.Context) {
|
||||
|
||||
// ── Ownership Check ─────────────────────────
|
||||
|
||||
func userOwnsChat(c *gin.Context, chatID, userID string) bool {
|
||||
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
|
||||
var ownerID string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT user_id FROM chats WHERE id = $1`, chatID,
|
||||
`SELECT user_id FROM channels WHERE id = $1`, channelID,
|
||||
).Scan(&ownerID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify chat ownership"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
|
||||
return false
|
||||
}
|
||||
if ownerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -27,18 +27,24 @@ func main() {
|
||||
if err := database.Migrate(); err != nil {
|
||||
log.Fatalf("❌ Schema migration failed: %v", err)
|
||||
}
|
||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||
handlers.BootstrapAdmin(cfg)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// ── Base path group ──────────────────────
|
||||
// All routes live under cfg.BasePath (e.g. "/dev", "/test", or "")
|
||||
base := r.Group(cfg.BasePath)
|
||||
|
||||
// ── EventBus + WebSocket Hub ─────────────
|
||||
bus := events.NewBus()
|
||||
hub := events.NewHub(bus)
|
||||
|
||||
// Health check (k8s probes hit this directly)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
base.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"version": Version,
|
||||
@@ -48,13 +54,13 @@ func main() {
|
||||
})
|
||||
|
||||
// WebSocket endpoint — auth via ?token= query param
|
||||
r.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
|
||||
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
|
||||
|
||||
// ── Auth routes (rate limited) ──────────────
|
||||
auth := handlers.NewAuthHandler(cfg)
|
||||
authLimiter := middleware.NewRateLimiter(1, 5)
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
api := base.Group("/api/v1")
|
||||
{
|
||||
// Health (routable through ingress)
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
@@ -85,23 +91,23 @@ func main() {
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
{
|
||||
// Chats
|
||||
chats := handlers.NewChatHandler()
|
||||
protected.GET("/chats", chats.ListChats)
|
||||
protected.POST("/chats", chats.CreateChat)
|
||||
protected.GET("/chats/:id", chats.GetChat)
|
||||
protected.PUT("/chats/:id", chats.UpdateChat)
|
||||
protected.DELETE("/chats/:id", chats.DeleteChat)
|
||||
// Channels (unified: replaces /chats)
|
||||
channels := handlers.NewChannelHandler()
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
protected.PUT("/channels/:id", channels.UpdateChannel)
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
|
||||
// Messages
|
||||
msgs := handlers.NewMessageHandler()
|
||||
protected.GET("/chats/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/chats/:id/messages", msgs.CreateMessage)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||
|
||||
// ── Chat Engine (#8) ────────────────
|
||||
comp := handlers.NewCompletionHandler()
|
||||
protected.POST("/chat/completions", comp.Complete)
|
||||
protected.POST("/chats/:id/regenerate", msgs.Regenerate) // TODO: wire to completion handler
|
||||
protected.POST("/channels/:id/regenerate", msgs.Regenerate) // TODO: wire to completion handler
|
||||
|
||||
// API Configs
|
||||
apiCfg := handlers.NewAPIConfigHandler()
|
||||
@@ -123,6 +129,10 @@ func main() {
|
||||
protected.POST("/profile/password", settings.ChangePassword)
|
||||
protected.GET("/settings", settings.GetSettings)
|
||||
protected.PUT("/settings", settings.UpdateSettings)
|
||||
|
||||
// Public global settings (non-admin users can read safe subset)
|
||||
adm := handlers.NewAdminHandler()
|
||||
protected.GET("/settings/public", adm.PublicSettings)
|
||||
}
|
||||
|
||||
// ── Admin routes ────────────────────────
|
||||
@@ -156,15 +166,21 @@ func main() {
|
||||
// Model Configs
|
||||
admin.GET("/models", adm.ListModelConfigs)
|
||||
admin.POST("/models/fetch", adm.FetchModels)
|
||||
admin.PUT("/models/bulk", adm.BulkUpdateModels)
|
||||
admin.PUT("/models/:id", adm.UpdateModelConfig)
|
||||
admin.DELETE("/models/:id", adm.DeleteModelConfig)
|
||||
}
|
||||
}
|
||||
|
||||
bp := cfg.BasePath
|
||||
if bp == "" {
|
||||
bp = "/"
|
||||
}
|
||||
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
|
||||
log.Printf(" Base path: %s", bp)
|
||||
log.Printf(" Schema: %s", database.SchemaVersion())
|
||||
log.Printf(" Providers: %v", providers.List())
|
||||
log.Printf(" EventBus: ready, WebSocket on /ws")
|
||||
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestCORSHeaders(t *testing.T) {
|
||||
func TestAllRoutesRegistered(t *testing.T) {
|
||||
cfg := &config.Config{JWTSecret: "test"}
|
||||
auth := handlers.NewAuthHandler(cfg)
|
||||
chats := handlers.NewChatHandler()
|
||||
channels := handlers.NewChannelHandler()
|
||||
msgs := handlers.NewMessageHandler()
|
||||
comp := handlers.NewCompletionHandler()
|
||||
apiCfg := handlers.NewAPIConfigHandler()
|
||||
@@ -73,19 +73,19 @@ func TestAllRoutesRegistered(t *testing.T) {
|
||||
|
||||
protected := api.Group("")
|
||||
{
|
||||
// Chats
|
||||
protected.GET("/chats", chats.ListChats)
|
||||
protected.POST("/chats", chats.CreateChat)
|
||||
protected.GET("/chats/:id", chats.GetChat)
|
||||
protected.PUT("/chats/:id", chats.UpdateChat)
|
||||
protected.DELETE("/chats/:id", chats.DeleteChat)
|
||||
// Channels
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
protected.PUT("/channels/:id", channels.UpdateChannel)
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
|
||||
// Messages
|
||||
protected.GET("/chats/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/chats/:id/messages", msgs.CreateMessage)
|
||||
protected.POST("/chats/:id/regenerate", msgs.Regenerate)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||
protected.POST("/channels/:id/regenerate", msgs.Regenerate)
|
||||
|
||||
// Chat Engine
|
||||
// Completion Engine
|
||||
protected.POST("/chat/completions", comp.Complete)
|
||||
|
||||
// API Configs
|
||||
@@ -140,17 +140,17 @@ func TestAllRoutesRegistered(t *testing.T) {
|
||||
"POST /api/v1/auth/login",
|
||||
"POST /api/v1/auth/refresh",
|
||||
"POST /api/v1/auth/logout",
|
||||
// Chats
|
||||
"GET /api/v1/chats",
|
||||
"POST /api/v1/chats",
|
||||
"GET /api/v1/chats/:id",
|
||||
"PUT /api/v1/chats/:id",
|
||||
"DELETE /api/v1/chats/:id",
|
||||
// Channels
|
||||
"GET /api/v1/channels",
|
||||
"POST /api/v1/channels",
|
||||
"GET /api/v1/channels/:id",
|
||||
"PUT /api/v1/channels/:id",
|
||||
"DELETE /api/v1/channels/:id",
|
||||
// Messages
|
||||
"GET /api/v1/chats/:id/messages",
|
||||
"POST /api/v1/chats/:id/messages",
|
||||
"POST /api/v1/chats/:id/regenerate",
|
||||
// Chat Engine
|
||||
"GET /api/v1/channels/:id/messages",
|
||||
"POST /api/v1/channels/:id/messages",
|
||||
"POST /api/v1/channels/:id/regenerate",
|
||||
// Completion Engine
|
||||
"POST /api/v1/chat/completions",
|
||||
// API Configs
|
||||
"GET /api/v1/api-configs",
|
||||
|
||||
@@ -14,12 +14,12 @@ type BaseModel struct {
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
BaseModel
|
||||
Email string `json:"email" db:"email"`
|
||||
PasswordHash string `json:"-" db:"password_hash"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Role string `json:"role" db:"role"`
|
||||
Email string `json:"email" db:"email"`
|
||||
PasswordHash string `json:"-" db:"password_hash"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Role string `json:"role" db:"role"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
}
|
||||
|
||||
// UserRole constants
|
||||
@@ -28,121 +28,147 @@ const (
|
||||
UserRoleAdmin = "admin"
|
||||
)
|
||||
|
||||
// Chat represents a chat conversation
|
||||
type Chat struct {
|
||||
// ── Channel Types ───────────────────────────
|
||||
|
||||
const (
|
||||
ChannelTypeDirect = "direct" // 1:1 AI chat (legacy "chat")
|
||||
ChannelTypeGroup = "group" // multi-model conversation
|
||||
ChannelTypeChannel = "channel" // named, persistent, membered
|
||||
)
|
||||
|
||||
// Channel represents a conversation channel (unified: chats + channels).
|
||||
type Channel struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Model string `json:"model" db:"model"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||
IsArchived bool `json:"is_archived" db:"is_archived"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
Type string `json:"type" db:"type"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||
APIConfigID *string `json:"api_config_id,omitempty" db:"api_config_id"`
|
||||
IsArchived bool `json:"is_archived" db:"is_archived"`
|
||||
IsPinned bool `json:"is_pinned" db:"is_pinned"`
|
||||
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
|
||||
}
|
||||
|
||||
// Message represents a chat message
|
||||
// Message represents a message in a channel
|
||||
type Message struct {
|
||||
BaseModel
|
||||
ChatID string `json:"chat_id" db:"chat_id"`
|
||||
Role string `json:"role" db:"role"` // "user", "assistant", "system"
|
||||
Content string `json:"content" db:"content"`
|
||||
Tokens int `json:"tokens,omitempty" db:"tokens"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
Content string `json:"content" db:"content"`
|
||||
Tokens int `json:"tokens,omitempty" db:"tokens"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
|
||||
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
|
||||
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
|
||||
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
|
||||
}
|
||||
|
||||
// MessageRole constants
|
||||
const (
|
||||
MessageRoleUser = "user"
|
||||
MessageRoleAssistant = "assistant"
|
||||
MessageRoleSystem = "system"
|
||||
)
|
||||
|
||||
// APIConfig represents a configured API provider
|
||||
type APIConfig struct {
|
||||
// ── Channel Members & Models ────────────────
|
||||
|
||||
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 ChannelModel struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
ModelID string `json:"model_id" db:"model_id"`
|
||||
APIConfigID string `json:"api_config_id,omitempty" db:"api_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"`
|
||||
}
|
||||
|
||||
type ChannelCursor struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
ActiveLeafID *string `json:"active_leaf_id,omitempty" db:"active_leaf_id"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// ── Organization ────────────────────────────
|
||||
|
||||
type Folder struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Provider string `json:"provider" db:"provider"` // "openai", "anthropic", "google", etc.
|
||||
APIKey string `json:"-" db:"api_key"`
|
||||
BaseURL string `json:"base_url,omitempty" db:"base_url"`
|
||||
Model string `json:"model" db:"model"`
|
||||
IsDefault bool `json:"is_default" db:"is_default"`
|
||||
}
|
||||
|
||||
// Channel represents a chat channel
|
||||
type Channel struct {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
IsPrivate bool `json:"is_private" db:"is_private"`
|
||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||
Color string `json:"color,omitempty" db:"color"`
|
||||
}
|
||||
|
||||
// ChannelMember represents a member of a channel
|
||||
type ChannelMember struct {
|
||||
// ── API Config ──────────────────────────────
|
||||
|
||||
type APIConfig struct {
|
||||
BaseModel
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Role string `json:"role" db:"role"` // "member", "moderator", "admin"
|
||||
Name string `json:"name" db:"name"`
|
||||
Provider string `json:"provider" db:"provider"`
|
||||
APIKey string `json:"-" db:"api_key"`
|
||||
BaseURL string `json:"base_url,omitempty" db:"base_url"`
|
||||
Model string `json:"model" db:"model"`
|
||||
IsDefault bool `json:"is_default" db:"is_default"`
|
||||
}
|
||||
|
||||
// ChannelMessage represents a message in a channel
|
||||
type ChannelMessage struct {
|
||||
BaseModel
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Content string `json:"content" db:"content"`
|
||||
ParentID string `json:"parent_id,omitempty" db:"parent_id"`
|
||||
}
|
||||
// ── Notes (future Phase 2) ──────────────────
|
||||
|
||||
// Note represents a user note
|
||||
type Note struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Content string `json:"content" db:"content"`
|
||||
Tags []string `json:"tags,omitempty" db:"tags"`
|
||||
FolderID string `json:"folder_id,omitempty" db:"folder_id"`
|
||||
IsShared bool `json:"is_shared" db:"is_shared"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Content string `json:"content" db:"content"`
|
||||
Tags []string `json:"tags,omitempty" db:"tags"`
|
||||
FolderID string `json:"folder_id,omitempty" db:"folder_id"`
|
||||
IsShared bool `json:"is_shared" db:"is_shared"`
|
||||
}
|
||||
|
||||
// KnowledgeBase represents a knowledge base collection
|
||||
type KnowledgeBase struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Source string `json:"source" db:"source"` // "url", "file", "text"
|
||||
Settings string `json:"settings,omitempty" db:"settings"` // JSON settings
|
||||
}
|
||||
|
||||
// Workflow represents a workflow definition
|
||||
type Workflow struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Steps string `json:"steps" db:"steps"` // JSON array of steps
|
||||
Source string `json:"source" db:"source"`
|
||||
Settings string `json:"settings,omitempty" db:"settings"`
|
||||
IsPublic bool `json:"is_public" db:"is_public"`
|
||||
}
|
||||
|
||||
// Settings represents user settings
|
||||
// ── Settings ────────────────────────────────
|
||||
|
||||
type Settings struct {
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Theme string `json:"theme" db:"theme"`
|
||||
Language string `json:"language" db:"language"`
|
||||
Model string `json:"model" db:"model"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||
MaxTokens int `json:"max_tokens" db:"max_tokens"`
|
||||
Temperature float64 `json:"temperature" db:"temperature"`
|
||||
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Theme string `json:"theme" db:"theme"`
|
||||
Language string `json:"language" db:"language"`
|
||||
Model string `json:"model" db:"model"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
||||
MaxTokens int `json:"max_tokens" db:"max_tokens"`
|
||||
Temperature float64 `json:"temperature" db:"temperature"`
|
||||
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
|
||||
}
|
||||
|
||||
// APIToken represents an API token for external access
|
||||
type APIToken struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Token string `json:"-" db:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Token string `json:"-" db:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
}
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user