Changeset 0.22.8 (#150)
This commit is contained in:
133
server/database/migrations/001_core.sql
Normal file
133
server/database/migrations/001_core.sql
Normal file
@@ -0,0 +1,133 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 001 Core
|
||||
-- ==========================================
|
||||
-- Users, authentication, platform policies, and global settings.
|
||||
-- Foundation tables with no external FK dependencies.
|
||||
--
|
||||
-- ICD §2 (Auth), §14 (User Profile & Settings), §16 (Platform Admin)
|
||||
-- v0.22.8: Domain-grouped migration (replaces monolith 001 + incrementals)
|
||||
-- ==========================================
|
||||
|
||||
-- ── Extensions ──────────────────────────────
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||
CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector (KB embeddings)
|
||||
|
||||
-- ── Utility: auto-update updated_at ─────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- USERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
avatar_url TEXT,
|
||||
role VARCHAR(20) DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Vault: per-user encryption key (UEK) for BYOK API keys
|
||||
encrypted_uek BYTEA,
|
||||
uek_salt BYTEA,
|
||||
uek_nonce BYTEA,
|
||||
vault_set BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Case-insensitive unique indexes (no bare UNIQUE constraint)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
||||
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key';
|
||||
COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation';
|
||||
COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping';
|
||||
COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- AUTH — REFRESH TOKENS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- PLATFORM POLICIES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_policies (
|
||||
key VARCHAR(50) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by UUID REFERENCES users(id),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Secure by default
|
||||
INSERT INTO platform_policies (key, value) VALUES
|
||||
('allow_user_byok', 'false'),
|
||||
('allow_user_personas', 'false'),
|
||||
('allow_raw_model_access', 'true'),
|
||||
('allow_registration', 'true'),
|
||||
('default_user_active', 'false'),
|
||||
('allow_team_providers', 'true'),
|
||||
('kb_direct_access', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE platform_policies IS 'Global admin switches controlling platform behavior';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- GLOBAL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'::jsonb),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
|
||||
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
"embedding": { "primary": null, "fallback": null },
|
||||
"generation": { "primary": null, "fallback": null }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -1,884 +0,0 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — v0.16.0 Consolidated Schema
|
||||
-- ==========================================
|
||||
-- Clean-slate schema. Replaces all 001–009 migrations.
|
||||
-- Drop DB and re-create before applying.
|
||||
--
|
||||
-- Changes from v0.15.1 (9-file) schema:
|
||||
-- • Folded all incremental migrations into base CREATEs
|
||||
-- • Dropped api_key_plain (vault backfill complete)
|
||||
-- • Dropped projects, project_channels (unused, v0.19.0 redesigns)
|
||||
-- • Dropped 'moderator' from users.role CHECK (dead code)
|
||||
-- • CI usernames from the start (LOWER() unique indexes)
|
||||
-- • Added: groups, group_members, resource_grants (v0.16.0)
|
||||
-- • Added: pgvector extension declaration
|
||||
--
|
||||
-- Design principles:
|
||||
-- • Explicit scope enums over nullable column tri-states
|
||||
-- • Personas as trust boundaries with extensible grants
|
||||
-- • Groups as pure access-control lists (decouple from teams)
|
||||
-- • Secure by default (models hidden until admin enables)
|
||||
-- • Only tables with active handlers — no placeholder tables
|
||||
-- ==========================================
|
||||
|
||||
-- ── Extensions ──────────────────────────────
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||
CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector (KB embeddings)
|
||||
|
||||
-- ── Upgrade-path cleanup ───────────────────
|
||||
-- These objects existed in the 001–009 migration chain but were removed
|
||||
-- in the v0.16.0 consolidation. Safe no-ops on fresh installs.
|
||||
|
||||
DROP TABLE IF EXISTS project_channels CASCADE;
|
||||
DROP TABLE IF EXISTS projects CASCADE;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'provider_configs' AND column_name = 'api_key_plain'
|
||||
) THEN
|
||||
ALTER TABLE provider_configs DROP COLUMN api_key_plain;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Utility: auto-update updated_at ─────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 1. USERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
avatar_url TEXT,
|
||||
role VARCHAR(20) DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Vault: per-user encryption key (UEK) for BYOK API keys
|
||||
encrypted_uek BYTEA,
|
||||
uek_salt BYTEA,
|
||||
uek_nonce BYTEA,
|
||||
vault_set BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Case-insensitive unique indexes (no bare UNIQUE constraint)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
||||
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key';
|
||||
COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation';
|
||||
COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping';
|
||||
COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 2. AUTH
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 3. TEAMS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = true;
|
||||
DROP TRIGGER IF EXISTS teams_updated_at ON teams;
|
||||
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN teams.settings IS 'Team policies: {"require_private_providers": false}';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('admin', 'member')),
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(team_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 4. GROUPS (v0.16.0)
|
||||
-- =========================================
|
||||
-- Pure access-control lists. Decouple resource visibility from team membership.
|
||||
-- Teams = organizational structure. Roles = vertical permissions.
|
||||
-- Groups = who can access what (cross-cutting).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- Global groups: team_id must be NULL
|
||||
-- Team groups: team_id must be set
|
||||
CONSTRAINT groups_scope_team CHECK (
|
||||
(scope = 'global' AND team_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Unique name within scope (global names globally unique,
|
||||
-- team names unique within team)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
|
||||
ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
|
||||
|
||||
DROP TRIGGER IF EXISTS groups_updated_at ON groups;
|
||||
CREATE TRIGGER groups_updated_at BEFORE UPDATE ON groups
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE groups IS 'Access-control groups. Decouple resource visibility from team membership.';
|
||||
COMMENT ON COLUMN groups.scope IS 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_by UUID NOT NULL REFERENCES users(id),
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 5. PROVIDER CONFIGS
|
||||
-- =========================================
|
||||
-- scope='global': admin-managed, visible to all users (owner_id IS NULL)
|
||||
-- scope='team': team admin-managed (owner_id = teams.id)
|
||||
-- scope='personal': user's own keys (owner_id = users.id)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scope VARCHAR(10) NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id UUID,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
|
||||
-- Encrypted API key (AES-256-GCM)
|
||||
api_key_enc BYTEA,
|
||||
key_nonce BYTEA,
|
||||
key_scope TEXT NOT NULL DEFAULT 'global',
|
||||
|
||||
model_default VARCHAR(100),
|
||||
config JSONB DEFAULT '{}'::jsonb,
|
||||
headers JSONB DEFAULT '{}'::jsonb,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_private BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active) WHERE is_active = true;
|
||||
DROP TRIGGER IF EXISTS provider_configs_updated_at ON provider_configs;
|
||||
CREATE TRIGGER provider_configs_updated_at BEFORE UPDATE ON provider_configs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN provider_configs.scope IS 'global=admin-managed, team=team-scoped, personal=user BYOK';
|
||||
COMMENT ON COLUMN provider_configs.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal scope';
|
||||
COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)';
|
||||
COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc';
|
||||
COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK';
|
||||
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)';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 6. MODEL CATALOG
|
||||
-- =========================================
|
||||
-- Intrinsic capabilities for models the system knows about.
|
||||
-- Disabled by default — admin must enable.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_catalog (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
model_type VARCHAR(20) DEFAULT 'chat',
|
||||
capabilities JSONB NOT NULL DEFAULT '{}',
|
||||
pricing JSONB,
|
||||
visibility VARCHAR(10) DEFAULT 'disabled'
|
||||
CHECK (visibility IN ('enabled', 'disabled', 'team')),
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled';
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
|
||||
DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog;
|
||||
CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN model_catalog.visibility IS 'disabled by default — admin must enable for global models';
|
||||
COMMENT ON COLUMN model_catalog.model_type IS 'chat, embedding, image — from provider API sync';
|
||||
COMMENT ON COLUMN model_catalog.capabilities IS 'Intrinsic: {"streaming","tool_calling","vision","thinking","reasoning","code_optimized","web_search","max_context","max_output_tokens"}';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 7. PERSONAS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon VARCHAR(10) DEFAULT '',
|
||||
avatar TEXT DEFAULT '',
|
||||
|
||||
-- Base model binding
|
||||
base_model_id TEXT NOT NULL,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
|
||||
-- Behavioral configuration
|
||||
system_prompt TEXT DEFAULT '',
|
||||
temperature FLOAT,
|
||||
max_tokens INT,
|
||||
thinking_budget INT,
|
||||
top_p FLOAT,
|
||||
|
||||
-- Scope & ownership
|
||||
scope VARCHAR(10) NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id UUID,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
-- State
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_shared BOOLEAN DEFAULT false,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
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;
|
||||
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.is_shared IS 'Personal Personas shared with others (read-only)';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 8. PERSONA GRANTS (what a Persona can do)
|
||||
-- =========================================
|
||||
-- Controls tools, KBs, and API endpoints that a Persona has access TO.
|
||||
-- NOT to be confused with resource_grants (who can USE a resource).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
grant_type VARCHAR(30) NOT NULL,
|
||||
grant_ref TEXT NOT NULL,
|
||||
config JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(persona_id, grant_type, grant_ref)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type);
|
||||
|
||||
COMMENT ON TABLE persona_grants IS 'What a Persona can do: tools it can call, KBs it can search, etc.';
|
||||
COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base, api_endpoint';
|
||||
COMMENT ON COLUMN persona_grants.grant_ref IS 'tool: function name. knowledge_base: UUID. api_endpoint: identifier.';
|
||||
COMMENT ON COLUMN persona_grants.config IS 'Type-specific config, e.g. {"read_only": true} for KB grants';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 9. RESOURCE GRANTS (v0.16.0 — who can USE a resource)
|
||||
-- =========================================
|
||||
-- Controls which users (via groups) can access a resource.
|
||||
-- NOT to be confused with persona_grants (what a Persona can do).
|
||||
--
|
||||
-- persona_grants: "CodeBot can use calculator" (Persona → tool)
|
||||
-- resource_grants: "Engineering can use CodeBot" (group → Persona)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
resource_type VARCHAR(30) NOT NULL
|
||||
CHECK (resource_type IN ('persona', 'knowledge_base')),
|
||||
resource_id UUID NOT NULL,
|
||||
grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only'
|
||||
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
|
||||
granted_groups UUID[] NOT NULL DEFAULT '{}',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- One grant row per resource
|
||||
UNIQUE(resource_type, resource_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
|
||||
ON resource_grants(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_groups
|
||||
ON resource_grants USING gin(granted_groups);
|
||||
|
||||
DROP TRIGGER IF EXISTS resource_grants_updated_at ON resource_grants;
|
||||
CREATE TRIGGER resource_grants_updated_at BEFORE UPDATE ON resource_grants
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE resource_grants IS 'Who can USE a resource (personas, KBs). Cross-team access via groups.';
|
||||
COMMENT ON COLUMN resource_grants.grant_scope IS 'team_only: existing team behavior. global: all users. groups: specific group list.';
|
||||
COMMENT ON COLUMN resource_grants.granted_groups IS 'UUID array of group IDs. Only meaningful when grant_scope = groups.';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 10. PLATFORM POLICIES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_policies (
|
||||
key VARCHAR(50) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by UUID REFERENCES users(id),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Secure by default
|
||||
INSERT INTO platform_policies (key, value) VALUES
|
||||
('allow_user_byok', 'false'),
|
||||
('allow_user_personas', 'false'),
|
||||
('allow_raw_model_access', 'true'),
|
||||
('allow_registration', 'true'),
|
||||
('default_user_active', 'false'),
|
||||
('allow_team_providers', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE platform_policies IS 'Global admin switches controlling platform behavior';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 11. GLOBAL SETTINGS (non-policy config)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'::jsonb),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
|
||||
('banner', '{
|
||||
"enabled": false,
|
||||
"text": "",
|
||||
"position": "both",
|
||||
"bg": "#007a33",
|
||||
"fg": "#ffffff"
|
||||
}'::jsonb),
|
||||
('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),
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
"embedding": { "primary": null, "fallback": null },
|
||||
"generation": { "primary": null, "fallback": null }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 12. USER MODEL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
DROP TRIGGER IF EXISTS user_model_settings_updated_at ON user_model_settings;
|
||||
CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 13. CHANNELS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
type VARCHAR(20) DEFAULT 'direct'
|
||||
CHECK (type IN ('direct', 'group', 'channel')),
|
||||
model VARCHAR(100),
|
||||
system_prompt TEXT,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
is_archived BOOLEAN DEFAULT false,
|
||||
is_pinned BOOLEAN DEFAULT false,
|
||||
folder_id UUID, -- FK added after folders table created
|
||||
folder TEXT, -- simple text folder name (frontend-managed)
|
||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
tags TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_tags ON channels USING GIN(tags);
|
||||
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
|
||||
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 14. MESSAGES (with tree/forking support)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL
|
||||
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT NOT NULL,
|
||||
model VARCHAR(100),
|
||||
tokens_used INTEGER,
|
||||
tool_calls JSONB,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
parent_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type VARCHAR(10) DEFAULT 'user',
|
||||
participant_id VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN messages.parent_id IS 'Tree parent for conversation forking';
|
||||
COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)';
|
||||
COMMENT ON COLUMN messages.participant_type IS 'user or model';
|
||||
COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 15. CHANNEL MEMBERS & MODELS
|
||||
-- =========================================
|
||||
|
||||
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',
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_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 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,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 16. CHANNEL CURSORS (forking navigation)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 17. FOLDERS
|
||||
-- =========================================
|
||||
-- Channel folders. Note: projects table deferred to v0.19.0.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, name, parent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
|
||||
DROP TRIGGER IF EXISTS folders_updated_at ON folders;
|
||||
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Deferred FK: channels.folder_id → folders.id
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder'
|
||||
) THEN
|
||||
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
|
||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 18. NOTES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
folder_path TEXT DEFAULT '/',
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
metadata JSONB DEFAULT '{}',
|
||||
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
search_vector TSVECTOR,
|
||||
embedding VECTOR(3072),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_search ON notes USING GIN(search_vector);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_tags ON notes USING GIN(tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notes_search_update_fn()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B');
|
||||
NEW.updated_at := NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS notes_search_update ON notes;
|
||||
CREATE TRIGGER notes_search_update
|
||||
BEFORE INSERT OR UPDATE OF title, content ON notes
|
||||
FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn();
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 19. AUDIT LOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
resource_id VARCHAR(255),
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
|
||||
COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 20. USAGE TRACKING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
provider_scope TEXT NOT NULL DEFAULT 'global',
|
||||
model_id TEXT NOT NULL,
|
||||
role TEXT, -- NULL = user chat, 'utility', 'embedding', 'generation'
|
||||
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||
completion_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INT NOT NULL DEFAULT 0,
|
||||
cost_input NUMERIC(12,6),
|
||||
cost_output NUMERIC(12,6),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 21. MODEL PRICING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
input_per_m NUMERIC(10,6),
|
||||
output_per_m NUMERIC(10,6),
|
||||
cache_create_per_m NUMERIC(10,6),
|
||||
cache_read_per_m NUMERIC(10,6),
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 22. EXTENSIONS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ext_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
|
||||
tier VARCHAR(20) NOT NULL DEFAULT 'browser',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author VARCHAR(200) NOT NULL DEFAULT '',
|
||||
manifest JSONB NOT NULL DEFAULT '{}',
|
||||
is_system BOOLEAN NOT NULL DEFAULT false,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'global',
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_user_settings (
|
||||
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (extension_id, user_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 23. ATTACHMENTS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
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),
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(127) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id) WHERE message_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE message_id IS NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 24. KNOWLEDGE BASES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
embedding_config JSONB NOT NULL DEFAULT '{}',
|
||||
document_count INT NOT NULL DEFAULT 0,
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT kb_scope_check CHECK (
|
||||
(scope = 'global' AND owner_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||
(scope = 'personal' AND owner_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
document_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INT NOT NULL DEFAULT 0,
|
||||
embedding VECTOR(3072),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id);
|
||||
|
||||
-- NOTE: IVFFlat index for similarity search created after first data load
|
||||
-- (needs rows to train). The ingest handler creates it.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_knowledge_bases (
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (channel_id, kb_id)
|
||||
);
|
||||
86
server/database/migrations/002_teams.sql
Normal file
86
server/database/migrations/002_teams.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 002 Teams & Access Control
|
||||
-- ==========================================
|
||||
-- Teams, team membership, groups (ACLs), and group membership.
|
||||
-- ICD §15 (Teams & Access Control)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- TEAMS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = true;
|
||||
DROP TRIGGER IF EXISTS teams_updated_at ON teams;
|
||||
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN teams.settings IS 'Team policies: {"require_private_providers": false}';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('admin', 'member')),
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(team_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- GROUPS (ACLs)
|
||||
-- =========================================
|
||||
-- Pure access-control lists. Decouple resource visibility from team membership.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT groups_scope_team CHECK (
|
||||
(scope = 'global' AND team_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
|
||||
ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
|
||||
|
||||
DROP TRIGGER IF EXISTS groups_updated_at ON groups;
|
||||
CREATE TRIGGER groups_updated_at BEFORE UPDATE ON groups
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE groups IS 'Access-control groups. Decouple resource visibility from team membership.';
|
||||
COMMENT ON COLUMN groups.scope IS 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_by UUID NOT NULL REFERENCES users(id),
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
@@ -1,42 +0,0 @@
|
||||
-- v0.17.0: Persona-KB Binding + Enterprise KB Mode
|
||||
--
|
||||
-- New: persona_knowledge_bases join table
|
||||
-- New: knowledge_bases.discoverable column
|
||||
-- New: kb_direct_access platform policy
|
||||
|
||||
-- =========================================
|
||||
-- 1. Persona-KB Binding
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_knowledge_bases (
|
||||
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
auto_search BOOLEAN NOT NULL DEFAULT false,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (persona_id, kb_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_persona ON persona_knowledge_bases(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_kb ON persona_knowledge_bases(kb_id);
|
||||
|
||||
COMMENT ON TABLE persona_knowledge_bases IS 'Binds KBs to Personas — the Persona becomes a gateway to its KBs';
|
||||
COMMENT ON COLUMN persona_knowledge_bases.auto_search IS 'true = auto-prepend top-K results to context; false = kb_search tool only';
|
||||
|
||||
-- =========================================
|
||||
-- 2. Enterprise KB Mode
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE knowledge_bases
|
||||
ADD COLUMN IF NOT EXISTS discoverable BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
COMMENT ON COLUMN knowledge_bases.discoverable IS 'false = hidden from user KB lists, only accessible through Persona binding';
|
||||
|
||||
-- =========================================
|
||||
-- 3. Platform Policy
|
||||
-- =========================================
|
||||
|
||||
INSERT INTO platform_policies (key, value) VALUES
|
||||
('kb_direct_access', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
COMMENT ON COLUMN platform_policies.key IS 'kb_direct_access: when false, disables channel KB popup (strict enterprise mode)';
|
||||
177
server/database/migrations/003_providers.sql
Normal file
177
server/database/migrations/003_providers.sql
Normal file
@@ -0,0 +1,177 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 003 Providers & Routing
|
||||
-- ==========================================
|
||||
-- Provider configs, model catalog, pricing, health, capability
|
||||
-- overrides, and routing policies.
|
||||
-- ICD §10 (Providers & Routing), §11 (Models & Preferences)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- PROVIDER CONFIGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scope VARCHAR(10) NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id UUID,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
|
||||
-- Encrypted API key (AES-256-GCM)
|
||||
api_key_enc BYTEA,
|
||||
key_nonce BYTEA,
|
||||
key_scope TEXT NOT NULL DEFAULT 'global',
|
||||
|
||||
model_default VARCHAR(100),
|
||||
config JSONB DEFAULT '{}'::jsonb,
|
||||
headers JSONB DEFAULT '{}'::jsonb,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_private BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active) WHERE is_active = true;
|
||||
DROP TRIGGER IF EXISTS provider_configs_updated_at ON provider_configs;
|
||||
CREATE TRIGGER provider_configs_updated_at BEFORE UPDATE ON provider_configs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN provider_configs.scope IS 'global=admin-managed, team=team-scoped, personal=user BYOK';
|
||||
COMMENT ON COLUMN provider_configs.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal scope';
|
||||
COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)';
|
||||
COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc';
|
||||
COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK';
|
||||
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)';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- MODEL CATALOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_catalog (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
model_type VARCHAR(20) DEFAULT 'chat',
|
||||
capabilities JSONB NOT NULL DEFAULT '{}',
|
||||
pricing JSONB,
|
||||
visibility VARCHAR(10) DEFAULT 'disabled'
|
||||
CHECK (visibility IN ('enabled', 'disabled', 'team')),
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled';
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
|
||||
DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog;
|
||||
CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON COLUMN model_catalog.visibility IS 'disabled by default — admin must enable for global models';
|
||||
COMMENT ON COLUMN model_catalog.model_type IS 'chat, embedding, image — from provider API sync';
|
||||
COMMENT ON COLUMN model_catalog.capabilities IS 'Intrinsic: {"streaming","tool_calling","vision","thinking","reasoning","code_optimized","web_search","max_context","max_output_tokens"}';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- MODEL PRICING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
input_per_m NUMERIC(10,6),
|
||||
output_per_m NUMERIC(10,6),
|
||||
cache_create_per_m NUMERIC(10,6),
|
||||
cache_read_per_m NUMERIC(10,6),
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES users(id),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- PROVIDER HEALTH (v0.22.0)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_health (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
window_start TIMESTAMPTZ NOT NULL,
|
||||
request_count INT NOT NULL DEFAULT 0,
|
||||
error_count INT NOT NULL DEFAULT 0,
|
||||
timeout_count INT NOT NULL DEFAULT 0,
|
||||
total_latency_ms BIGINT NOT NULL DEFAULT 0,
|
||||
max_latency_ms INT NOT NULL DEFAULT 0,
|
||||
rate_limit_count INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (provider_config_id, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_health_window
|
||||
ON provider_health (provider_config_id, window_start DESC);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CAPABILITY OVERRIDES (v0.22.0)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability_overrides (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
set_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (provider_config_id, model_id, field)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capability_overrides_model
|
||||
ON capability_overrides (model_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- ROUTING POLICIES (v0.22.2)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routing_policies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
scope VARCHAR(10) NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
policy_type VARCHAR(30) NOT NULL
|
||||
CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias')),
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_policies_active
|
||||
ON routing_policies(is_active, priority) WHERE is_active = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_policies_team
|
||||
ON routing_policies(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
DROP TRIGGER IF EXISTS routing_policies_updated_at ON routing_policies;
|
||||
CREATE TRIGGER routing_policies_updated_at BEFORE UPDATE ON routing_policies
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
@@ -1,35 +0,0 @@
|
||||
-- v0.17.3: Note Links + Wikilink Infrastructure
|
||||
--
|
||||
-- New: note_links table for [[wikilink]] graph edges
|
||||
-- New: notes.source_message_id for chat-to-note provenance
|
||||
|
||||
-- =========================================
|
||||
-- 1. Note Links
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_title TEXT NOT NULL,
|
||||
display_text TEXT,
|
||||
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (source_note_id, target_title)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
|
||||
WHERE target_note_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE note_links IS 'Directed links between notes, extracted from [[wikilink]] syntax on save';
|
||||
COMMENT ON COLUMN note_links.target_note_id IS 'NULL for unresolved links (target note does not exist yet)';
|
||||
COMMENT ON COLUMN note_links.target_title IS 'Raw [[title]] text — preserved for re-resolution after renames';
|
||||
COMMENT ON COLUMN note_links.is_transclusion IS 'true for ![[embed]] syntax, false for regular [[link]]';
|
||||
|
||||
-- =========================================
|
||||
-- 2. Source Message Provenance
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE notes ADD COLUMN IF NOT EXISTS source_message_id UUID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_source_msg ON notes(source_message_id)
|
||||
WHERE source_message_id IS NOT NULL;
|
||||
113
server/database/migrations/004_personas.sql
Normal file
113
server/database/migrations/004_personas.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 004 Personas & Grants
|
||||
-- ==========================================
|
||||
-- Persona definitions, persona capability grants, and resource access grants.
|
||||
-- ICD §4 (Personas)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- PERSONAS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon VARCHAR(10) DEFAULT '',
|
||||
avatar TEXT DEFAULT '',
|
||||
|
||||
-- Base model binding
|
||||
base_model_id TEXT NOT NULL,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
|
||||
-- Behavioral configuration
|
||||
system_prompt TEXT DEFAULT '',
|
||||
temperature FLOAT,
|
||||
max_tokens INT,
|
||||
thinking_budget INT,
|
||||
top_p FLOAT,
|
||||
|
||||
-- Memory configuration (v0.18.0)
|
||||
memory_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
memory_extraction_prompt TEXT,
|
||||
|
||||
-- Scope & ownership
|
||||
scope VARCHAR(10) NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id UUID,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
-- State
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_shared BOOLEAN DEFAULT false,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
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;
|
||||
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.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 GRANTS (what a persona can do)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
grant_type VARCHAR(30) NOT NULL,
|
||||
grant_ref TEXT NOT NULL,
|
||||
config JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(persona_id, grant_type, grant_ref)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type);
|
||||
|
||||
COMMENT ON TABLE persona_grants IS 'What a persona can do: tools it can call, KBs it can search, etc.';
|
||||
COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base, api_endpoint';
|
||||
COMMENT ON COLUMN persona_grants.grant_ref IS 'tool: function name. knowledge_base: UUID. api_endpoint: identifier.';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- RESOURCE GRANTS (who can USE a resource)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
resource_type VARCHAR(30) NOT NULL
|
||||
CHECK (resource_type IN ('persona', 'knowledge_base', 'project')),
|
||||
resource_id UUID NOT NULL,
|
||||
grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only'
|
||||
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
|
||||
granted_groups UUID[] NOT NULL DEFAULT '{}',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(resource_type, resource_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
|
||||
ON resource_grants(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_groups
|
||||
ON resource_grants USING gin(granted_groups);
|
||||
|
||||
DROP TRIGGER IF EXISTS resource_grants_updated_at ON resource_grants;
|
||||
CREATE TRIGGER resource_grants_updated_at BEFORE UPDATE ON resource_grants
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE resource_grants IS 'Who can USE a resource (personas, KBs, projects). Cross-team access via groups.';
|
||||
COMMENT ON COLUMN resource_grants.grant_scope IS 'team_only: existing team behavior. global: all users. groups: specific group list.';
|
||||
@@ -1,85 +0,0 @@
|
||||
-- v0.18.0: Memory System
|
||||
--
|
||||
-- Long-term memory across conversations, with scope-aware isolation.
|
||||
-- Three scopes: user (personal), persona (shared), persona_user (per-user-per-persona).
|
||||
--
|
||||
-- Unlike KB (static documents) or compaction (within-conversation), this
|
||||
-- captures facts and preferences that persist across channels.
|
||||
|
||||
-- =========================================
|
||||
-- 1. Memories Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scope TEXT NOT NULL CHECK (scope IN ('user', 'persona', 'persona_user')),
|
||||
|
||||
-- Owner resolution:
|
||||
-- user scope: owner_id = user_id, user_id = NULL
|
||||
-- persona scope: owner_id = persona_id, user_id = NULL
|
||||
-- persona_user scope: owner_id = persona_id, user_id = user_id
|
||||
owner_id UUID NOT NULL,
|
||||
user_id UUID,
|
||||
|
||||
key TEXT NOT NULL, -- short label ("preferred language", "team size")
|
||||
value TEXT NOT NULL, -- detail / fact content
|
||||
source_channel_id UUID, -- which conversation produced this memory
|
||||
confidence REAL NOT NULL DEFAULT 1.0, -- 0.0–1.0, used for ranking
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'pending_review', 'archived')),
|
||||
|
||||
-- Embedding for semantic recall (optional — same pattern as notes)
|
||||
embedding vector(3072),
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Primary lookup: "give me all active memories for this scope + owner"
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_scope_owner
|
||||
ON memories(scope, owner_id, status)
|
||||
WHERE status = 'active';
|
||||
|
||||
-- Persona+user lookup: "give me this persona's memories about this user"
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_persona_user
|
||||
ON memories(owner_id, user_id)
|
||||
WHERE scope = 'persona_user' AND status = 'active';
|
||||
|
||||
-- Deduplication: prevent saving the same key twice for the same scope/owner/user
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_unique_key
|
||||
ON memories(scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key);
|
||||
|
||||
-- Full-text search on key + value
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_fts
|
||||
ON memories USING gin(to_tsvector('english', key || ' ' || value));
|
||||
|
||||
-- Source channel reference (for provenance / "where did this come from")
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_source_channel
|
||||
ON memories(source_channel_id)
|
||||
WHERE source_channel_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE memories IS 'Long-term memory facts persisted across conversations, scoped to user, persona, or user+persona';
|
||||
COMMENT ON COLUMN memories.scope IS 'user = personal facts; persona = shared persona knowledge; persona_user = per-user within a persona';
|
||||
COMMENT ON COLUMN memories.owner_id IS 'user_id for user scope, persona_id for persona/persona_user scopes';
|
||||
COMMENT ON COLUMN memories.user_id IS 'Only set for persona_user scope — identifies which user within the persona';
|
||||
COMMENT ON COLUMN memories.key IS 'Short label for the memory (e.g. "preferred language", "deployment stack")';
|
||||
COMMENT ON COLUMN memories.value IS 'The actual fact/detail being remembered';
|
||||
COMMENT ON COLUMN memories.confidence IS '0.0-1.0 confidence score — higher = more certain, used for ranking and pruning';
|
||||
COMMENT ON COLUMN memories.status IS 'active = injected into context; pending_review = awaiting human approval; archived = soft-deleted';
|
||||
|
||||
-- =========================================
|
||||
-- 2. Updated-at Trigger
|
||||
-- =========================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_memories_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_memories_updated_at
|
||||
BEFORE UPDATE ON memories
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memories_updated_at();
|
||||
182
server/database/migrations/005_channels.sql
Normal file
182
server/database/migrations/005_channels.sql
Normal file
@@ -0,0 +1,182 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 005 Channels & Conversations
|
||||
-- ==========================================
|
||||
-- Channels, messages, members, models, cursors, folders, user prefs.
|
||||
-- ICD §3 (Channels & Conversations)
|
||||
-- Note: project_id and workspace_id FKs added by 010_projects.sql
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- FOLDERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, name, parent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
|
||||
DROP TRIGGER IF EXISTS folders_updated_at ON folders;
|
||||
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CHANNELS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
type VARCHAR(20) DEFAULT 'direct'
|
||||
CHECK (type IN ('direct', 'group', 'channel')),
|
||||
model VARCHAR(100),
|
||||
system_prompt TEXT,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
is_archived BOOLEAN DEFAULT false,
|
||||
is_pinned BOOLEAN DEFAULT false,
|
||||
folder_id UUID,
|
||||
folder TEXT,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
tags TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_tags ON channels USING GIN(tags);
|
||||
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
|
||||
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Deferred FK: channels.folder_id → folders.id
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder'
|
||||
) THEN
|
||||
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
|
||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- MESSAGES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL
|
||||
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT NOT NULL,
|
||||
model VARCHAR(100),
|
||||
tokens_used INTEGER,
|
||||
tool_calls JSONB,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
parent_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type VARCHAR(10) DEFAULT 'user',
|
||||
participant_id VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN messages.parent_id IS 'Tree parent for conversation forking';
|
||||
COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CHANNEL MEMBERS & MODELS
|
||||
-- =========================================
|
||||
|
||||
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',
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_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 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,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CHANNEL CURSORS (forking navigation)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- USER MODEL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
DROP TRIGGER IF EXISTS user_model_settings_updated_at ON user_model_settings;
|
||||
CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
@@ -1,46 +0,0 @@
|
||||
-- v0.18.0 Phase 2: Memory Extraction & Embeddings
|
||||
--
|
||||
-- Adds persona memory configuration, HNSW vector index for
|
||||
-- semantic recall, and extraction tracking log.
|
||||
|
||||
-- =========================================
|
||||
-- 1. Persona Memory Configuration
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE personas ADD COLUMN IF NOT EXISTS memory_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE personas ADD COLUMN IF NOT EXISTS memory_extraction_prompt TEXT;
|
||||
|
||||
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';
|
||||
|
||||
-- =========================================
|
||||
-- 2. Vector Index for Semantic Recall
|
||||
-- =========================================
|
||||
-- NOTE: HNSW indexes have a 2000-dimension limit in pgvector.
|
||||
-- Our embeddings are 3072-dim (matching KB/notes schema), so we
|
||||
-- skip the HNSW index. Memory tables are small enough (hundreds
|
||||
-- of rows per user) that filtered sequential scans with cosine
|
||||
-- distance are performant. If needed later, IVFFlat supports
|
||||
-- higher dimensions, or embeddings can be reduced to 1536-dim.
|
||||
|
||||
-- =========================================
|
||||
-- 3. Memory Extraction Log
|
||||
-- =========================================
|
||||
-- Tracks which messages have been processed by the extraction scanner
|
||||
-- to prevent duplicate work. One row per channel+user pair.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_extraction_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
last_message_id UUID NOT NULL,
|
||||
extracted_at TIMESTAMPTZ DEFAULT now(),
|
||||
memory_count INT NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mem_extract_log_channel
|
||||
ON memory_extraction_log(channel_id);
|
||||
|
||||
COMMENT ON TABLE memory_extraction_log IS 'Tracks extraction progress per channel+user to prevent re-processing';
|
||||
114
server/database/migrations/006_knowledge.sql
Normal file
114
server/database/migrations/006_knowledge.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 006 Knowledge Bases
|
||||
-- ==========================================
|
||||
-- Knowledge bases, documents, chunks, and bindings to channels/personas.
|
||||
-- ICD §5 (Knowledge Bases)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- KNOWLEDGE BASES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
embedding_config JSONB NOT NULL DEFAULT '{}',
|
||||
document_count INT NOT NULL DEFAULT 0,
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
discoverable BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT kb_scope_check CHECK (
|
||||
(scope = 'global' AND owner_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||
(scope = 'personal' AND owner_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN knowledge_bases.discoverable IS 'false = hidden from user KB lists, only accessible through persona binding';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- KB DOCUMENTS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- KB CHUNKS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
document_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INT NOT NULL DEFAULT 0,
|
||||
embedding VECTOR(3072),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CHANNEL ↔ KB BINDING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_knowledge_bases (
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (channel_id, kb_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- PERSONA ↔ KB BINDING (v0.17.0)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_knowledge_bases (
|
||||
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
auto_search BOOLEAN NOT NULL DEFAULT false,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (persona_id, kb_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_persona ON persona_knowledge_bases(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_kb ON persona_knowledge_bases(kb_id);
|
||||
|
||||
COMMENT ON TABLE persona_knowledge_bases IS 'Binds KBs to personas — the persona becomes a gateway to its KBs';
|
||||
COMMENT ON COLUMN persona_knowledge_bases.auto_search IS 'true = auto-prepend top-K results to context; false = kb_search tool only';
|
||||
75
server/database/migrations/007_notes.sql
Normal file
75
server/database/migrations/007_notes.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 007 Notes & Links
|
||||
-- ==========================================
|
||||
-- Notes with full-text search, wikilink graph edges.
|
||||
-- ICD §6 (Notes)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- NOTES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
folder_path TEXT DEFAULT '/',
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
metadata JSONB DEFAULT '{}',
|
||||
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
source_message_id UUID,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
search_vector TSVECTOR,
|
||||
embedding VECTOR(3072),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_search ON notes USING GIN(search_vector);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_tags ON notes USING GIN(tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_source_msg ON notes(source_message_id)
|
||||
WHERE source_message_id IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notes_search_update_fn()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B');
|
||||
NEW.updated_at := NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS notes_search_update ON notes;
|
||||
CREATE TRIGGER notes_search_update
|
||||
BEFORE INSERT OR UPDATE OF title, content ON notes
|
||||
FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn();
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- NOTE LINKS (v0.17.3 — wikilink graph)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_title TEXT NOT NULL,
|
||||
display_text TEXT,
|
||||
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (source_note_id, target_title)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
|
||||
WHERE target_note_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE note_links IS 'Directed links between notes, extracted from [[wikilink]] syntax on save';
|
||||
COMMENT ON COLUMN note_links.target_note_id IS 'NULL for unresolved links (target note does not exist yet)';
|
||||
COMMENT ON COLUMN note_links.target_title IS 'Raw [[title]] text — preserved for re-resolution after renames';
|
||||
COMMENT ON COLUMN note_links.is_transclusion IS 'true for ![[embed]] syntax, false for regular [[link]]';
|
||||
@@ -1,17 +0,0 @@
|
||||
-- 007_v0200_notifications.sql
|
||||
-- Notification infrastructure (v0.20.0 Phase 1)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT DEFAULT '',
|
||||
resource_type VARCHAR(50),
|
||||
resource_id UUID,
|
||||
is_read BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC);
|
||||
82
server/database/migrations/008_memory.sql
Normal file
82
server/database/migrations/008_memory.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 008 Memory
|
||||
-- ==========================================
|
||||
-- Long-term memory across conversations, extraction tracking.
|
||||
-- ICD §9 (Memory)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- MEMORIES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scope TEXT NOT NULL CHECK (scope IN ('user', 'persona', 'persona_user')),
|
||||
owner_id UUID NOT NULL,
|
||||
user_id UUID,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
source_channel_id UUID,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'pending_review', 'archived')),
|
||||
embedding vector(3072),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_scope_owner
|
||||
ON memories(scope, owner_id, status)
|
||||
WHERE status = 'active';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_persona_user
|
||||
ON memories(owner_id, user_id)
|
||||
WHERE scope = 'persona_user' AND status = 'active';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_unique_key
|
||||
ON memories(scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_fts
|
||||
ON memories USING gin(to_tsvector('english', key || ' ' || value));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_source_channel
|
||||
ON memories(source_channel_id)
|
||||
WHERE source_channel_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE memories IS 'Long-term memory facts persisted across conversations';
|
||||
COMMENT ON COLUMN memories.scope IS 'user = personal facts; persona = shared; persona_user = per-user within a persona';
|
||||
COMMENT ON COLUMN memories.owner_id IS 'user_id for user scope, persona_id for persona/persona_user scopes';
|
||||
COMMENT ON COLUMN memories.confidence IS '0.0-1.0 confidence score — higher = more certain';
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_memories_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_memories_updated_at ON memories;
|
||||
CREATE TRIGGER trg_memories_updated_at
|
||||
BEFORE UPDATE ON memories
|
||||
FOR EACH ROW EXECUTE FUNCTION update_memories_updated_at();
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- MEMORY EXTRACTION LOG (v0.18.0 Phase 2)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_extraction_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
last_message_id UUID NOT NULL,
|
||||
extracted_at TIMESTAMPTZ DEFAULT now(),
|
||||
memory_count INT NOT NULL DEFAULT 0,
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mem_extract_log_channel
|
||||
ON memory_extraction_log(channel_id);
|
||||
|
||||
COMMENT ON TABLE memory_extraction_log IS 'Tracks extraction progress per channel+user to prevent re-processing';
|
||||
@@ -1,18 +0,0 @@
|
||||
-- 008_v0200_notification_prefs.sql
|
||||
-- Notification preferences per user per type.
|
||||
-- Resolution: specific type → user '*' default → system default (in_app=true, email=false).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL, -- notification type or '*' for default
|
||||
in_app BOOLEAN NOT NULL DEFAULT true,
|
||||
email BOOLEAN NOT NULL DEFAULT false,
|
||||
UNIQUE(user_id, type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_prefs_user ON notification_preferences(user_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,74 +0,0 @@
|
||||
-- v0.21.0: Workspaces (platform storage primitive)
|
||||
--
|
||||
-- New: workspaces table (polymorphic owner: user, project, channel, team)
|
||||
-- New: workspace_files table (metadata index — filesystem is source of truth)
|
||||
|
||||
-- =========================================
|
||||
-- 1. Workspaces Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_type VARCHAR(20) NOT NULL
|
||||
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
|
||||
owner_id UUID NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
root_path TEXT NOT NULL, -- PVC-relative: workspaces/{id}
|
||||
max_bytes BIGINT, -- quota (NULL = system default)
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'archived', 'deleting')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_owner
|
||||
ON workspaces(owner_type, owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_status
|
||||
ON workspaces(status) WHERE status != 'deleting';
|
||||
|
||||
DROP TRIGGER IF EXISTS workspaces_updated_at ON workspaces;
|
||||
CREATE TRIGGER workspaces_updated_at BEFORE UPDATE ON workspaces
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE workspaces IS 'File workspaces bound to users, projects, channels, or teams';
|
||||
COMMENT ON COLUMN workspaces.owner_type IS 'Polymorphic owner: user, project, channel, team';
|
||||
COMMENT ON COLUMN workspaces.root_path IS 'PVC-relative path to workspace root directory';
|
||||
COMMENT ON COLUMN workspaces.max_bytes IS 'Storage quota in bytes (NULL = system default)';
|
||||
|
||||
-- =========================================
|
||||
-- 2. Workspace Files Table
|
||||
-- =========================================
|
||||
-- Metadata index for workspace files. The PVC filesystem is the source
|
||||
-- of truth; this table enables fast queries (list, filter by type, etc.)
|
||||
-- and will hold embedding references in v0.21.2.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL, -- relative path: src/main.go
|
||||
is_directory BOOLEAN NOT NULL DEFAULT false,
|
||||
content_type VARCHAR(100), -- MIME type
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
sha256 VARCHAR(64), -- content hash (NULL for dirs)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
|
||||
ON workspace_files(workspace_id, path);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace
|
||||
ON workspace_files(workspace_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
|
||||
ON workspace_files(workspace_id, content_type)
|
||||
WHERE content_type IS NOT NULL;
|
||||
|
||||
DROP TRIGGER IF EXISTS workspace_files_updated_at ON workspace_files;
|
||||
CREATE TRIGGER workspace_files_updated_at BEFORE UPDATE ON workspace_files
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE workspace_files IS 'Metadata index for workspace files — filesystem is source of truth';
|
||||
COMMENT ON COLUMN workspace_files.path IS 'Relative path from workspace files/ root';
|
||||
COMMENT ON COLUMN workspace_files.sha256 IS 'Content hash for change detection and dedup';
|
||||
123
server/database/migrations/009_workspaces.sql
Normal file
123
server/database/migrations/009_workspaces.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 009 Workspaces & Git
|
||||
-- ==========================================
|
||||
-- File workspaces, workspace file index, semantic chunks, git credentials.
|
||||
-- ICD §7 (Workspaces & Git)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- WORKSPACES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_type VARCHAR(20) NOT NULL
|
||||
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
|
||||
owner_id UUID NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
root_path TEXT NOT NULL,
|
||||
max_bytes BIGINT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'archived', 'deleting')),
|
||||
indexing_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
-- Git integration (v0.21.4)
|
||||
git_remote_url TEXT,
|
||||
git_branch TEXT,
|
||||
git_credential_id UUID,
|
||||
git_last_sync TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_type, owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_status ON workspaces(status) WHERE status != 'deleting';
|
||||
DROP TRIGGER IF EXISTS workspaces_updated_at ON workspaces;
|
||||
CREATE TRIGGER workspaces_updated_at BEFORE UPDATE ON workspaces
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE workspaces IS 'File workspaces bound to users, projects, channels, or teams';
|
||||
COMMENT ON COLUMN workspaces.root_path IS 'PVC-relative path to workspace root directory';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- WORKSPACE FILES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
is_directory BOOLEAN NOT NULL DEFAULT false,
|
||||
content_type VARCHAR(100),
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
sha256 VARCHAR(64),
|
||||
index_status VARCHAR(20) DEFAULT 'pending'
|
||||
CHECK (index_status IN ('pending', 'indexing', 'ready', 'error', 'skipped')),
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
|
||||
ON workspace_files(workspace_id, path);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace ON workspace_files(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
|
||||
ON workspace_files(workspace_id, content_type) WHERE content_type IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_index_status
|
||||
ON workspace_files(workspace_id, index_status)
|
||||
WHERE index_status IN ('pending', 'indexing');
|
||||
|
||||
DROP TRIGGER IF EXISTS workspace_files_updated_at ON workspace_files;
|
||||
CREATE TRIGGER workspace_files_updated_at BEFORE UPDATE ON workspace_files
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE workspace_files IS 'Metadata index for workspace files — filesystem is source of truth';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- WORKSPACE CHUNKS (v0.21.2)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
file_id UUID NOT NULL REFERENCES workspace_files(id) ON DELETE CASCADE,
|
||||
chunk_index INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INT NOT NULL DEFAULT 0,
|
||||
embedding vector(3072),
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_chunks_file ON workspace_chunks(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_chunks_workspace ON workspace_chunks(workspace_id);
|
||||
|
||||
COMMENT ON TABLE workspace_chunks IS 'Text chunks with vector embeddings for workspace file semantic search';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- GIT CREDENTIALS (v0.21.4)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS git_credentials (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
auth_type TEXT NOT NULL DEFAULT 'https_pat',
|
||||
encrypted_data BYTEA NOT NULL DEFAULT '',
|
||||
nonce BYTEA NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_git_credentials_user ON git_credentials(user_id);
|
||||
|
||||
-- Deferred FK: workspaces.git_credential_id → git_credentials.id
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_workspaces_git_credential'
|
||||
) THEN
|
||||
ALTER TABLE workspaces ADD CONSTRAINT fk_workspaces_git_credential
|
||||
FOREIGN KEY (git_credential_id) REFERENCES git_credentials(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,27 +1,26 @@
|
||||
-- v0.19.0: Projects / Workspaces
|
||||
--
|
||||
-- New: projects table
|
||||
-- New: project_channels junction table (1:1 channel→project)
|
||||
-- New: project_knowledge_bases junction table
|
||||
-- New: project_notes junction table
|
||||
-- New: channels.project_id denormalized FK
|
||||
-- Changed: resource_grants.resource_type CHECK includes 'project'
|
||||
-- Deprecated: channels.folder (column retained, no longer written)
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 010 Projects
|
||||
-- ==========================================
|
||||
-- Projects, junction tables, and cross-domain FK columns on
|
||||
-- channels/projects for workspace/project bindings.
|
||||
-- ICD §8 (Projects)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- 1. Projects Table
|
||||
-- PROJECTS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7), -- hex color e.g. '#3B82F6'
|
||||
icon VARCHAR(50), -- emoji or icon name
|
||||
color VARCHAR(7),
|
||||
icon VARCHAR(50),
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'personal'
|
||||
CHECK (scope IN ('personal', 'team', 'global')),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
is_archived BOOLEAN NOT NULL DEFAULT false,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@@ -31,6 +30,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_team ON projects(team_id) WHERE team_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_scope ON projects(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id) WHERE workspace_id IS NOT NULL;
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_updated_at ON projects;
|
||||
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
|
||||
@@ -39,31 +39,29 @@ CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
|
||||
COMMENT ON TABLE projects IS 'Organizes channels, KBs, and notes into named workspaces';
|
||||
COMMENT ON COLUMN projects.scope IS 'personal=owner only, team=team members, global=all users';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 2. Project ↔ Channel Junction
|
||||
-- PROJECT ↔ CHANNEL JUNCTION
|
||||
-- =========================================
|
||||
-- One project per channel (UNIQUE on channel_id).
|
||||
-- Source of truth for position and folder within the project.
|
||||
|
||||
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,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
folder TEXT, -- sub-folder within the project
|
||||
folder TEXT,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, channel_id),
|
||||
UNIQUE (channel_id) -- one project per channel
|
||||
UNIQUE (channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_channels_project ON project_channels(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id);
|
||||
|
||||
COMMENT ON TABLE project_channels IS 'Links channels to projects. UNIQUE(channel_id) enforces one-project-per-channel.';
|
||||
COMMENT ON COLUMN project_channels.position IS 'Sort order within the project sidebar group';
|
||||
COMMENT ON COLUMN project_channels.folder IS 'Optional sub-folder path within the project';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 3. Project ↔ Knowledge Base Junction
|
||||
-- PROJECT ↔ KB JUNCTION
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_knowledge_bases (
|
||||
@@ -77,11 +75,9 @@ CREATE TABLE IF NOT EXISTS project_knowledge_bases (
|
||||
CREATE INDEX IF NOT EXISTS idx_project_kb_project ON project_knowledge_bases(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_kb_kb ON project_knowledge_bases(kb_id);
|
||||
|
||||
COMMENT ON TABLE project_knowledge_bases IS 'Binds KBs to Projects — injected at completion time for all project channels';
|
||||
COMMENT ON COLUMN project_knowledge_bases.auto_search IS 'true = auto-prepend top-K results; false = kb_search tool only';
|
||||
|
||||
-- =========================================
|
||||
-- 4. Project ↔ Note Junction
|
||||
-- PROJECT ↔ NOTE JUNCTION
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_notes (
|
||||
@@ -94,36 +90,27 @@ CREATE TABLE IF NOT EXISTS project_notes (
|
||||
CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id);
|
||||
|
||||
COMMENT ON TABLE project_notes IS 'Links notes to projects for organizational grouping';
|
||||
|
||||
-- =========================================
|
||||
-- 5. Denormalized project_id on Channels
|
||||
-- CROSS-DOMAIN FK COLUMNS
|
||||
-- =========================================
|
||||
-- Fast sidebar query: SELECT ... FROM channels WHERE project_id = $1
|
||||
-- Source of truth is project_channels; this is updated atomically.
|
||||
-- Denormalized project_id + workspace_id on channels.
|
||||
|
||||
ALTER TABLE channels
|
||||
ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE channels
|
||||
ADD COLUMN IF NOT EXISTS workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE project_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 6. Extend resource_grants CHECK
|
||||
-- FOLDER → PROJECT MIGRATION (idempotent)
|
||||
-- =========================================
|
||||
-- Add 'project' to allowed resource_type values.
|
||||
-- Postgres ALTER TABLE ... ALTER CONSTRAINT is not supported; drop + re-add.
|
||||
|
||||
ALTER TABLE resource_grants DROP CONSTRAINT IF EXISTS resource_grants_resource_type_check;
|
||||
ALTER TABLE resource_grants ADD CONSTRAINT resource_grants_resource_type_check
|
||||
CHECK (resource_type IN ('persona', 'knowledge_base', 'project'));
|
||||
|
||||
-- =========================================
|
||||
-- 7. Best-effort folder → project migration
|
||||
-- =========================================
|
||||
-- Create a personal project for each distinct folder value, then
|
||||
-- associate the channels. Skip if no folder values exist.
|
||||
-- This is idempotent: re-running won't duplicate projects because
|
||||
-- we check for existing project_id.
|
||||
-- Best-effort migration of legacy folder values into projects.
|
||||
-- Only runs if there are channels with a folder value and no project_id.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
@@ -136,12 +123,10 @@ BEGIN
|
||||
SELECT DISTINCT user_id, folder FROM channels
|
||||
WHERE folder IS NOT NULL AND folder != '' AND project_id IS NULL
|
||||
LOOP
|
||||
-- Create project named after the folder
|
||||
INSERT INTO projects (name, scope, owner_id)
|
||||
VALUES (_folder, 'personal', _user_id)
|
||||
RETURNING id INTO _proj_id;
|
||||
|
||||
-- Associate channels
|
||||
FOR _ch_id IN
|
||||
SELECT id FROM channels
|
||||
WHERE user_id = _user_id AND folder = _folder AND project_id IS NULL
|
||||
@@ -1,16 +0,0 @@
|
||||
-- v0.21.1: Workspace bindings for channels and projects
|
||||
--
|
||||
-- Channels and projects can optionally bind to a workspace.
|
||||
-- Resolution chain: channel workspace_id > project workspace_id.
|
||||
|
||||
-- Channel workspace binding
|
||||
ALTER TABLE channels
|
||||
ADD COLUMN IF NOT EXISTS workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL;
|
||||
|
||||
-- Project workspace binding
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id) WHERE workspace_id IS NOT NULL;
|
||||
41
server/database/migrations/011_notifications.sql
Normal file
41
server/database/migrations/011_notifications.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 011 Notifications
|
||||
-- ==========================================
|
||||
-- Notification delivery and per-user preferences.
|
||||
-- ICD §12 (Notifications)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- NOTIFICATIONS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT DEFAULT '',
|
||||
resource_type VARCHAR(50),
|
||||
resource_id UUID,
|
||||
is_read BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- NOTIFICATION PREFERENCES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
in_app BOOLEAN NOT NULL DEFAULT true,
|
||||
email BOOLEAN NOT NULL DEFAULT false,
|
||||
UNIQUE(user_id, type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_prefs_user ON notification_preferences(user_id);
|
||||
@@ -1,58 +0,0 @@
|
||||
-- v0.21.2: Workspace indexing + semantic search
|
||||
--
|
||||
-- New: workspace_chunks table for text embeddings per workspace file
|
||||
-- Alter: workspace_files gains index_status and chunk_count columns
|
||||
-- Alter: workspaces gains indexing_enabled column
|
||||
|
||||
-- =========================================
|
||||
-- 1. workspace_files additions
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE workspace_files
|
||||
ADD COLUMN IF NOT EXISTS index_status VARCHAR(20) DEFAULT 'pending'
|
||||
CHECK (index_status IN ('pending', 'indexing', 'ready', 'error', 'skipped'));
|
||||
|
||||
ALTER TABLE workspace_files
|
||||
ADD COLUMN IF NOT EXISTS chunk_count INT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_index_status
|
||||
ON workspace_files(workspace_id, index_status)
|
||||
WHERE index_status IN ('pending', 'indexing');
|
||||
|
||||
-- =========================================
|
||||
-- 2. workspaces addition
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS indexing_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- =========================================
|
||||
-- 3. workspace_chunks table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
file_id UUID NOT NULL REFERENCES workspace_files(id) ON DELETE CASCADE,
|
||||
chunk_index INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INT NOT NULL DEFAULT 0,
|
||||
embedding vector(3072),
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_chunks_file
|
||||
ON workspace_chunks(file_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_chunks_workspace
|
||||
ON workspace_chunks(workspace_id);
|
||||
|
||||
-- Note: No vector index created here. pgvector IVFFlat/HNSW indexes are
|
||||
-- limited to 2000 dimensions, but our embeddings are 3072-dim (zero-padded).
|
||||
-- Sequential scan with <=> is adequate at workspace scale (thousands of
|
||||
-- chunks, not millions). Add a partial/quantized index later if needed.
|
||||
|
||||
COMMENT ON TABLE workspace_chunks IS 'Text chunks with vector embeddings for workspace file semantic search';
|
||||
COMMENT ON COLUMN workspace_chunks.embedding IS 'Vector embedding (3072-dim, zero-padded) for cosine similarity';
|
||||
COMMENT ON COLUMN workspace_chunks.metadata IS 'Extensible: line_start, heading, language, etc.';
|
||||
44
server/database/migrations/012_extensions.sql
Normal file
44
server/database/migrations/012_extensions.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 012 Extensions
|
||||
-- ==========================================
|
||||
-- Extension registry and per-user settings.
|
||||
-- ICD §13 (Extensions)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- EXTENSIONS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ext_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
|
||||
tier VARCHAR(20) NOT NULL DEFAULT 'browser',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author VARCHAR(200) NOT NULL DEFAULT '',
|
||||
manifest JSONB NOT NULL DEFAULT '{}',
|
||||
is_system BOOLEAN NOT NULL DEFAULT false,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'global',
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- EXTENSION USER SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_user_settings (
|
||||
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (extension_id, user_id)
|
||||
);
|
||||
@@ -1,21 +0,0 @@
|
||||
-- v0.21.4: Git integration — credentials table + workspace git columns.
|
||||
|
||||
-- Git credentials: encrypted storage for PAT, basic auth, and SSH keys.
|
||||
-- Uses the same AES-256-GCM vault pattern as BYOK provider configs.
|
||||
CREATE TABLE IF NOT EXISTS git_credentials (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
auth_type TEXT NOT NULL DEFAULT 'https_pat', -- https_pat, https_basic, ssh_key
|
||||
encrypted_data BYTEA NOT NULL DEFAULT '',
|
||||
nonce BYTEA NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_git_credentials_user ON git_credentials(user_id);
|
||||
|
||||
-- Workspace git tracking columns.
|
||||
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_remote_url TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_branch TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_credential_id UUID REFERENCES git_credentials(id) ON DELETE SET NULL;
|
||||
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS git_last_sync TIMESTAMPTZ;
|
||||
65
server/database/migrations/013_files.sql
Normal file
65
server/database/migrations/013_files.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 013 Files
|
||||
-- ==========================================
|
||||
-- Unified files table replacing attachments.
|
||||
-- Handles both upgrade (attachments exists) and fresh install.
|
||||
-- ==========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||
origin VARCHAR(20) NOT NULL DEFAULT 'user_upload'
|
||||
CHECK (origin IN ('user_upload', 'tool_output', 'system')),
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(127) NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
storage_key TEXT NOT NULL,
|
||||
display_hint VARCHAR(20) NOT NULL DEFAULT 'download'
|
||||
CHECK (display_hint IN ('inline', 'download', 'thumbnail')),
|
||||
extracted_text TEXT,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Ensure attachments table exists so the INSERT is always valid.
|
||||
-- Upgrade: no-op (table already has data). Fresh install: empty table.
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID, user_id UUID, message_id UUID, project_id UUID,
|
||||
filename VARCHAR(255), content_type VARCHAR(127), size_bytes BIGINT,
|
||||
storage_key TEXT, extracted_text TEXT, metadata JSONB, created_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
INSERT INTO files (
|
||||
id, channel_id, message_id, user_id, project_id,
|
||||
origin, filename, content_type, size_bytes, storage_key,
|
||||
display_hint, extracted_text, metadata, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, channel_id, message_id, user_id, project_id,
|
||||
'user_upload',
|
||||
filename, content_type, size_bytes, storage_key,
|
||||
CASE
|
||||
WHEN content_type LIKE 'image/%' THEN 'inline'
|
||||
WHEN content_type LIKE 'video/%' THEN 'inline'
|
||||
WHEN content_type LIKE 'audio/%' THEN 'inline'
|
||||
WHEN content_type = 'application/pdf' THEN 'thumbnail'
|
||||
WHEN content_type LIKE 'text/%' THEN 'inline'
|
||||
ELSE 'download'
|
||||
END,
|
||||
extracted_text, metadata, created_at, created_at
|
||||
FROM attachments
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
DROP TABLE IF EXISTS attachments;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_channel ON files(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_message ON files(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_user ON files(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_project ON files(project_id) WHERE project_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_orphan ON files(created_at) WHERE message_id IS NULL AND origin = 'user_upload';
|
||||
@@ -1,43 +0,0 @@
|
||||
-- v0.22.0: Provider health tracking + capability admin overrides.
|
||||
|
||||
-- ── Provider Health ─────────────────────────
|
||||
-- Hourly bucketed health metrics per provider config.
|
||||
-- In-memory accumulator flushes to this table every 60s.
|
||||
-- Background job prunes rows older than 7 days.
|
||||
CREATE TABLE IF NOT EXISTS provider_health (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
window_start TIMESTAMPTZ NOT NULL, -- hourly bucket floor
|
||||
request_count INT NOT NULL DEFAULT 0,
|
||||
error_count INT NOT NULL DEFAULT 0,
|
||||
timeout_count INT NOT NULL DEFAULT 0,
|
||||
total_latency_ms BIGINT NOT NULL DEFAULT 0, -- sum for avg calculation
|
||||
max_latency_ms INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (provider_config_id, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_health_window
|
||||
ON provider_health (provider_config_id, window_start DESC);
|
||||
|
||||
-- ── Capability Overrides ────────────────────
|
||||
-- Admin corrections for model capabilities. Highest priority in the
|
||||
-- three-tier resolution chain: catalog → heuristic → admin override.
|
||||
CREATE TABLE IF NOT EXISTS capability_overrides (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL, -- tool_calling, vision, thinking, reasoning, etc.
|
||||
value TEXT NOT NULL, -- "true"/"false" for bools, numeric string for ints
|
||||
set_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- Unique per (provider, model, field). NULL provider = applies to model globally.
|
||||
UNIQUE (provider_config_id, model_id, field)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capability_overrides_model
|
||||
ON capability_overrides (model_id);
|
||||
58
server/database/migrations/014_audit_usage.sql
Normal file
58
server/database/migrations/014_audit_usage.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 014 Audit & Usage
|
||||
-- ==========================================
|
||||
-- Audit trail and usage/cost tracking.
|
||||
-- ICD §16 (Platform Administration), §17 (Export & Utility)
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- AUDIT LOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
resource_id VARCHAR(255),
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
|
||||
COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- USAGE LOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
provider_scope TEXT NOT NULL DEFAULT 'global',
|
||||
model_id TEXT NOT NULL,
|
||||
role TEXT,
|
||||
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||
completion_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INT NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INT NOT NULL DEFAULT 0,
|
||||
cost_input NUMERIC(12,6),
|
||||
cost_output NUMERIC(12,6),
|
||||
routing_decision JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope);
|
||||
@@ -1,31 +0,0 @@
|
||||
-- Migration 014: v0.22.2 — Routing Policies
|
||||
-- Adds routing_policies table for rules-based provider routing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routing_policies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
scope VARCHAR(10) NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
policy_type VARCHAR(30) NOT NULL
|
||||
CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias')),
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_policies_active
|
||||
ON routing_policies(is_active, priority) WHERE is_active = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_policies_team
|
||||
ON routing_policies(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
-- Trigger for updated_at
|
||||
CREATE TRIGGER routing_policies_updated_at
|
||||
BEFORE UPDATE ON routing_policies
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Add routing_decision column to usage_log for observability.
|
||||
ALTER TABLE usage_log
|
||||
ADD COLUMN IF NOT EXISTS routing_decision JSONB;
|
||||
23
server/database/migrations/015_health.sql
Normal file
23
server/database/migrations/015_health.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 015 Tool Health
|
||||
-- ==========================================
|
||||
-- Health tracking for built-in tools (web_search, url_fetch, etc.).
|
||||
-- ==========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tool_health (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tool_name TEXT NOT NULL,
|
||||
window_start TIMESTAMPTZ NOT NULL,
|
||||
request_count INT NOT NULL DEFAULT 0,
|
||||
error_count INT NOT NULL DEFAULT 0,
|
||||
total_latency_ms BIGINT NOT NULL DEFAULT 0,
|
||||
max_latency_ms INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (tool_name, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_health_window
|
||||
ON tool_health (tool_name, window_start DESC);
|
||||
@@ -1,33 +0,0 @@
|
||||
-- v0.22.4: Rate limit tracking, tool health, project files.
|
||||
|
||||
-- ── Rate limit column on provider_health ────
|
||||
ALTER TABLE provider_health ADD COLUMN IF NOT EXISTS rate_limit_count INT NOT NULL DEFAULT 0;
|
||||
|
||||
-- ── Tool Health ─────────────────────────────
|
||||
-- Lightweight health tracking for built-in tools (web_search, url_fetch, etc.).
|
||||
-- One row per tool per hourly window, similar to provider_health.
|
||||
CREATE TABLE IF NOT EXISTS tool_health (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tool_name TEXT NOT NULL, -- web_search, url_fetch, etc.
|
||||
window_start TIMESTAMPTZ NOT NULL, -- hourly bucket floor
|
||||
request_count INT NOT NULL DEFAULT 0,
|
||||
error_count INT NOT NULL DEFAULT 0,
|
||||
total_latency_ms BIGINT NOT NULL DEFAULT 0,
|
||||
max_latency_ms INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (tool_name, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_health_window
|
||||
ON tool_health (tool_name, window_start DESC);
|
||||
|
||||
-- ── Project Files ───────────────────────────
|
||||
-- Extends attachments to support project-scoped uploads.
|
||||
-- When project_id is set, the file belongs to the project (not a message).
|
||||
ALTER TABLE attachments ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_project
|
||||
ON attachments (project_id) WHERE project_id IS NOT NULL;
|
||||
98
server/database/migrations/sqlite/001_core.sql
Normal file
98
server/database/migrations/sqlite/001_core.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 001 Core (SQLite)
|
||||
-- ==========================================
|
||||
-- Users, authentication, platform policies, global settings.
|
||||
-- v0.22.8: Domain-grouped migration
|
||||
-- ==========================================
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- =========================================
|
||||
-- USERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
encrypted_uek BLOB,
|
||||
uek_salt BLOB,
|
||||
uek_nonce BLOB,
|
||||
vault_set INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (username COLLATE NOCASE);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (email COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS users_updated_at AFTER UPDATE ON users
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id; END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- AUTH — REFRESH TOKENS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- PLATFORM POLICIES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_policies (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by TEXT REFERENCES users(id),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO platform_policies (key, value) VALUES
|
||||
('allow_user_byok', 'false'),
|
||||
('allow_user_personas', 'false'),
|
||||
('allow_raw_model_access', 'true'),
|
||||
('allow_registration', 'true'),
|
||||
('default_user_active', 'false'),
|
||||
('allow_team_providers', 'true'),
|
||||
('kb_direct_access', 'true');
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- GLOBAL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
|
||||
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'),
|
||||
('model_roles', '{"utility": {"primary": null, "fallback": null}, "embedding": {"primary": null, "fallback": null}, "generation": {"primary": null, "fallback": null}}');
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Schema patch for 001_v017_schema.sql
|
||||
-- Change the kb_chunks table to include the embedding column:
|
||||
--
|
||||
-- BEFORE:
|
||||
-- CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
-- ...
|
||||
-- token_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- -- embedding column omitted: vector search feature-gated for SQLite.
|
||||
-- -- Embedding bytes can be stored as BLOB if sqlite-vec is available.
|
||||
-- metadata TEXT NOT NULL DEFAULT '{}',
|
||||
-- ...
|
||||
--
|
||||
-- AFTER:
|
||||
-- CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
-- ...
|
||||
-- token_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- embedding TEXT, -- JSON array of float64 for app-level cosine similarity
|
||||
-- metadata TEXT NOT NULL DEFAULT '{}',
|
||||
-- ...
|
||||
--
|
||||
-- For existing SQLite databases, run:
|
||||
-- ALTER TABLE kb_chunks ADD COLUMN embedding TEXT;
|
||||
@@ -1,791 +0,0 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — v0.16.0 Consolidated Schema (SQLite)
|
||||
-- ==========================================
|
||||
-- SQLite equivalent of the Postgres schema.
|
||||
--
|
||||
-- Key differences from Postgres:
|
||||
-- • No extensions (pgcrypto, pgvector)
|
||||
-- • UUID generated application-side (Go uuid.New())
|
||||
-- • JSONB → TEXT (JSON stored as text)
|
||||
-- • TEXT[] / UUID[] → TEXT (JSON arrays as text)
|
||||
-- • TIMESTAMPTZ → TEXT (ISO 8601 strings)
|
||||
-- • BYTEA → BLOB
|
||||
-- • NUMERIC → REAL
|
||||
-- • TSVECTOR/VECTOR columns omitted (feature-gated)
|
||||
-- • No COMMENT ON statements
|
||||
-- • Triggers use SQLite syntax (WHEN guard to prevent recursion)
|
||||
-- • No partial index WHERE clauses on GIN indexes
|
||||
-- ==========================================
|
||||
|
||||
-- ── WAL mode (also set via connection pragma) ──
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- =========================================
|
||||
-- 1. USERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
|
||||
-- Vault: per-user encryption key (UEK) for BYOK API keys
|
||||
encrypted_uek BLOB,
|
||||
uek_salt BLOB,
|
||||
uek_nonce BLOB,
|
||||
vault_set INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email));
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS users_updated_at AFTER UPDATE ON users
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 2. AUTH
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 3. TEAMS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = 1;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS teams_updated_at AFTER UPDATE ON teams
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE teams SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('admin', 'member')),
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(team_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 4. GROUPS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
CONSTRAINT groups_scope_team CHECK (
|
||||
(scope = 'global' AND team_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
|
||||
ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS groups_updated_at AFTER UPDATE ON groups
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE groups SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_by TEXT NOT NULL REFERENCES users(id),
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 5. PROVIDER CONFIGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
|
||||
api_key_enc BLOB,
|
||||
key_nonce BLOB,
|
||||
key_scope TEXT NOT NULL DEFAULT 'global',
|
||||
|
||||
model_default TEXT,
|
||||
config TEXT DEFAULT '{}',
|
||||
headers TEXT DEFAULT '{}',
|
||||
settings TEXT DEFAULT '{}',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_private INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id)
|
||||
WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active)
|
||||
WHERE is_active = 1;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS provider_configs_updated_at AFTER UPDATE ON provider_configs
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE provider_configs SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 6. MODEL CATALOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_catalog (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
model_type TEXT DEFAULT 'chat',
|
||||
capabilities TEXT NOT NULL DEFAULT '{}',
|
||||
pricing TEXT,
|
||||
visibility TEXT DEFAULT 'disabled'
|
||||
CHECK (visibility IN ('enabled', 'disabled', 'team')),
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility)
|
||||
WHERE visibility = 'enabled';
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS model_catalog_updated_at AFTER UPDATE ON model_catalog
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE model_catalog SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 7. PERSONAS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '',
|
||||
avatar TEXT DEFAULT '',
|
||||
|
||||
base_model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
|
||||
system_prompt TEXT DEFAULT '',
|
||||
temperature REAL,
|
||||
max_tokens INTEGER,
|
||||
thinking_budget INTEGER,
|
||||
top_p REAL,
|
||||
|
||||
scope TEXT NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id TEXT,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_shared INTEGER DEFAULT 0,
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 = 1;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS personas_updated_at AFTER UPDATE ON personas
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE personas SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 8. PERSONA GRANTS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
grant_type TEXT NOT NULL,
|
||||
grant_ref TEXT NOT NULL,
|
||||
config TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(persona_id, grant_type, grant_ref)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 9. RESOURCE GRANTS
|
||||
-- =========================================
|
||||
-- granted_groups stored as JSON text array: '["uuid1","uuid2"]'
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_type TEXT NOT NULL
|
||||
CHECK (resource_type IN ('persona', 'knowledge_base')),
|
||||
resource_id TEXT NOT NULL,
|
||||
grant_scope TEXT NOT NULL DEFAULT 'team_only'
|
||||
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
|
||||
granted_groups TEXT NOT NULL DEFAULT '[]',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
UNIQUE(resource_type, resource_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
|
||||
ON resource_grants(resource_type, resource_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS resource_grants_updated_at AFTER UPDATE ON resource_grants
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE resource_grants SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 10. PLATFORM POLICIES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_policies (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by TEXT REFERENCES users(id),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO platform_policies (key, value) VALUES
|
||||
('allow_user_byok', 'false'),
|
||||
('allow_user_personas', 'false'),
|
||||
('allow_raw_model_access', 'true'),
|
||||
('allow_registration', 'true'),
|
||||
('default_user_active', 'false'),
|
||||
('allow_team_providers', 'true');
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 11. GLOBAL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
|
||||
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'),
|
||||
('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"}}'),
|
||||
('model_roles', '{"utility": {"primary": null, "fallback": null}, "embedding": {"primary": null, "fallback": null}, "generation": {"primary": null, "fallback": null}}');
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 12. USER MODEL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
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,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_model_settings_updated_at AFTER UPDATE ON user_model_settings
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE user_model_settings SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 13. CHANNELS
|
||||
-- =========================================
|
||||
-- tags stored as JSON text array: '["tag1","tag2"]'
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT DEFAULT 'direct'
|
||||
CHECK (type IN ('direct', 'group', 'channel')),
|
||||
model TEXT,
|
||||
system_prompt TEXT,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
is_pinned INTEGER DEFAULT 0,
|
||||
folder_id TEXT,
|
||||
folder TEXT,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
settings TEXT DEFAULT '{}',
|
||||
tags TEXT DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS channels_updated_at AFTER UPDATE ON channels
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE channels SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 14. MESSAGES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL
|
||||
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT NOT NULL,
|
||||
model TEXT,
|
||||
tokens_used INTEGER,
|
||||
tool_calls TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
parent_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type TEXT DEFAULT 'user',
|
||||
participant_id TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 15. CHANNEL MEMBERS & MODELS
|
||||
-- =========================================
|
||||
|
||||
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 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 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,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 16. CHANNEL CURSORS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 17. FOLDERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, name, parent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS folders_updated_at AFTER UPDATE ON folders
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE folders SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 18. NOTES
|
||||
-- =========================================
|
||||
-- search_vector and embedding columns omitted (Postgres-only features).
|
||||
-- Full-text search uses LIKE fallback. Semantic search feature-gated.
|
||||
-- tags stored as JSON text array.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
folder_path TEXT DEFAULT '/',
|
||||
tags TEXT DEFAULT '[]',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
source_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS notes_updated_at AFTER UPDATE ON notes
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE notes SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 19. AUDIT LOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
ip_address TEXT,
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 20. USAGE TRACKING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
provider_scope TEXT NOT NULL DEFAULT 'global',
|
||||
model_id TEXT NOT NULL,
|
||||
role TEXT,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost_input REAL,
|
||||
cost_output REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 21. MODEL PRICING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
input_per_m REAL,
|
||||
output_per_m REAL,
|
||||
cache_create_per_m REAL,
|
||||
cache_read_per_m REAL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
updated_by TEXT REFERENCES users(id),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 22. EXTENSIONS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
ext_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||
tier TEXT NOT NULL DEFAULT 'browser',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
manifest TEXT NOT NULL DEFAULT '{}',
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled)
|
||||
WHERE is_enabled = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_user_settings (
|
||||
extension_id TEXT NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings TEXT NOT NULL DEFAULT '{}',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (extension_id, user_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 23. ATTACHMENTS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id)
|
||||
WHERE message_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at)
|
||||
WHERE message_id IS NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 24. KNOWLEDGE BASES
|
||||
-- =========================================
|
||||
-- Vector columns: kb_chunks.embedding stored as JSON TEXT for app-level cosine similarity.
|
||||
-- search_vector columns omitted (Postgres-only tsvector feature).
|
||||
-- KB ingestion works (stores text chunks) but similarity search is
|
||||
-- feature-gated: requires sqlite-vec extension or returns graceful error.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
owner_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
embedding_config TEXT NOT NULL DEFAULT '{}',
|
||||
document_count INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
discoverable INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
CONSTRAINT kb_scope_check CHECK (
|
||||
(scope = 'global' AND owner_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||
(scope = 'personal' AND owner_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
uploaded_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
document_id TEXT NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
embedding TEXT, -- JSON array of float64 for app-level cosine similarity
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_knowledge_bases (
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (channel_id, kb_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 25. PERSONA-KB BINDING (v0.17.0)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_knowledge_bases (
|
||||
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
auto_search INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (persona_id, kb_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_persona ON persona_knowledge_bases(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_kb ON persona_knowledge_bases(kb_id);
|
||||
|
||||
-- v0.17.0 policy
|
||||
INSERT OR IGNORE INTO platform_policies (key, value) VALUES
|
||||
('kb_direct_access', 'true');
|
||||
59
server/database/migrations/sqlite/002_teams.sql
Normal file
59
server/database/migrations/sqlite/002_teams.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Chat Switchboard — 002 Teams & Access Control (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('admin', 'member')),
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(team_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
CHECK (
|
||||
(scope = 'global' AND team_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
|
||||
ON groups(name, COALESCE(team_id, ''));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_by TEXT NOT NULL REFERENCES users(id),
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
@@ -1,26 +0,0 @@
|
||||
-- v0.17.3: Note Links + Wikilink Infrastructure (SQLite)
|
||||
--
|
||||
-- New: note_links table for [[wikilink]] graph edges
|
||||
-- New: notes.source_message_id for chat-to-note provenance
|
||||
|
||||
-- =========================================
|
||||
-- 1. Note Links
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_title TEXT NOT NULL,
|
||||
display_text TEXT,
|
||||
is_transclusion INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (source_note_id, target_title)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id);
|
||||
|
||||
-- =========================================
|
||||
-- 2. Source Message Provenance
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE notes ADD COLUMN source_message_id TEXT;
|
||||
106
server/database/migrations/sqlite/003_providers.sql
Normal file
106
server/database/migrations/sqlite/003_providers.sql
Normal file
@@ -0,0 +1,106 @@
|
||||
-- Chat Switchboard — 003 Providers & Routing (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
api_key_enc BLOB,
|
||||
key_nonce BLOB,
|
||||
key_scope TEXT NOT NULL DEFAULT 'global',
|
||||
model_default TEXT,
|
||||
config TEXT DEFAULT '{}',
|
||||
headers TEXT DEFAULT '{}',
|
||||
settings TEXT DEFAULT '{}',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_private INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_catalog (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
model_type TEXT DEFAULT 'chat',
|
||||
capabilities TEXT NOT NULL DEFAULT '{}',
|
||||
pricing TEXT,
|
||||
visibility TEXT DEFAULT 'disabled' CHECK (visibility IN ('enabled', 'disabled', 'team')),
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
input_per_m REAL,
|
||||
output_per_m REAL,
|
||||
cache_create_per_m REAL,
|
||||
cache_read_per_m REAL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
updated_by TEXT REFERENCES users(id),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_health (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
window_start TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_count INTEGER NOT NULL DEFAULT 0,
|
||||
timeout_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
max_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
rate_limit_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_error_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (provider_config_id, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_health_window ON provider_health(provider_config_id, window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability_overrides (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
set_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (provider_config_id, model_id, field)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capability_overrides_model ON capability_overrides(model_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routing_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'global' CHECK (scope IN ('global', 'team')),
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
policy_type TEXT NOT NULL CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias')),
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_policies_active ON routing_policies(is_active, priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_policies_team ON routing_policies(team_id);
|
||||
@@ -1,61 +0,0 @@
|
||||
-- v0.18.0: Memory System (SQLite)
|
||||
--
|
||||
-- Long-term memory across conversations, with scope-aware isolation.
|
||||
-- Three scopes: user (personal), persona (shared), persona_user (per-user-per-persona).
|
||||
|
||||
-- =========================================
|
||||
-- 1. Memories Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('user', 'persona', 'persona_user')),
|
||||
|
||||
-- Owner resolution:
|
||||
-- user scope: owner_id = user_id, user_id = NULL
|
||||
-- persona scope: owner_id = persona_id, user_id = NULL
|
||||
-- persona_user scope: owner_id = persona_id, user_id = user_id
|
||||
owner_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
source_channel_id TEXT,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'pending_review', 'archived')),
|
||||
|
||||
-- Embedding stored as JSON array of float64 for app-level cosine similarity
|
||||
embedding TEXT,
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Primary lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_scope_owner
|
||||
ON memories(scope, owner_id, status);
|
||||
|
||||
-- Persona+user lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_persona_user
|
||||
ON memories(owner_id, user_id);
|
||||
|
||||
-- Deduplication: prevent saving the same key twice for the same scope/owner/user
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_unique_key
|
||||
ON memories(scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key);
|
||||
|
||||
-- Source channel reference
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_source_channel
|
||||
ON memories(source_channel_id);
|
||||
|
||||
-- =========================================
|
||||
-- 2. Updated-at Trigger
|
||||
-- =========================================
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_memories_updated_at
|
||||
AFTER UPDATE ON memories
|
||||
FOR EACH ROW
|
||||
WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE memories SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
57
server/database/migrations/sqlite/004_personas.sql
Normal file
57
server/database/migrations/sqlite/004_personas.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- Chat Switchboard — 004 Personas & Grants (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '',
|
||||
avatar TEXT DEFAULT '',
|
||||
base_model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
system_prompt TEXT DEFAULT '',
|
||||
temperature REAL,
|
||||
max_tokens INTEGER,
|
||||
thinking_budget INTEGER,
|
||||
top_p REAL,
|
||||
memory_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
memory_extraction_prompt TEXT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id TEXT,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_shared INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 TABLE IF NOT EXISTS persona_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
grant_type TEXT NOT NULL,
|
||||
grant_ref TEXT NOT NULL,
|
||||
config TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(persona_id, grant_type, grant_ref)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_type TEXT NOT NULL CHECK (resource_type IN ('persona', 'knowledge_base', 'project')),
|
||||
resource_id TEXT NOT NULL,
|
||||
grant_scope TEXT NOT NULL DEFAULT 'team_only'
|
||||
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
|
||||
granted_groups TEXT NOT NULL DEFAULT '[]',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(resource_type, resource_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource ON resource_grants(resource_type, resource_id);
|
||||
@@ -1,22 +0,0 @@
|
||||
-- v0.18.0 Phase 2: Memory Extraction & Embeddings (SQLite)
|
||||
|
||||
-- 1. Persona memory configuration
|
||||
ALTER TABLE personas ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE personas ADD COLUMN memory_extraction_prompt TEXT;
|
||||
|
||||
-- 2. No HNSW index for SQLite — app-level cosine similarity used instead.
|
||||
|
||||
-- 3. Memory extraction log
|
||||
CREATE TABLE IF NOT EXISTS memory_extraction_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
last_message_id TEXT NOT NULL,
|
||||
extracted_at TEXT DEFAULT (datetime('now')),
|
||||
memory_count INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mem_extract_log_channel
|
||||
ON memory_extraction_log(channel_id);
|
||||
120
server/database/migrations/sqlite/005_channels.sql
Normal file
120
server/database/migrations/sqlite/005_channels.sql
Normal file
@@ -0,0 +1,120 @@
|
||||
-- Chat Switchboard — 005 Channels & Conversations (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, name, parent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'channel')),
|
||||
model TEXT,
|
||||
system_prompt TEXT,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
is_pinned INTEGER DEFAULT 0,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
||||
folder TEXT,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
settings TEXT DEFAULT '{}',
|
||||
tags TEXT DEFAULT '[]',
|
||||
project_id TEXT,
|
||||
workspace_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT NOT NULL,
|
||||
model TEXT,
|
||||
tokens_used INTEGER,
|
||||
tool_calls TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
parent_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type TEXT DEFAULT 'user',
|
||||
participant_id TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_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 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 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,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
@@ -1,131 +0,0 @@
|
||||
-- v0.19.0: Projects / Workspaces (SQLite)
|
||||
|
||||
-- =========================================
|
||||
-- 1. Projects Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
scope TEXT NOT NULL DEFAULT 'personal'
|
||||
CHECK (scope IN ('personal', 'team', 'global')),
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
settings TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_team ON projects(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_scope ON projects(scope);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS projects_updated_at AFTER UPDATE ON projects
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE projects SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- =========================================
|
||||
-- 2. Project ↔ Channel Junction
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_channels (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
folder TEXT,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, channel_id),
|
||||
UNIQUE (channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_channels_project ON project_channels(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id);
|
||||
|
||||
-- =========================================
|
||||
-- 3. Project ↔ Knowledge Base Junction
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_knowledge_bases (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
auto_search INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, kb_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_kb_project ON project_knowledge_bases(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_kb_kb ON project_knowledge_bases(kb_id);
|
||||
|
||||
-- =========================================
|
||||
-- 4. Project ↔ Note Junction
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_notes (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, note_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id);
|
||||
|
||||
-- =========================================
|
||||
-- 5. Denormalized project_id on Channels
|
||||
-- =========================================
|
||||
|
||||
ALTER TABLE channels ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id);
|
||||
|
||||
-- =========================================
|
||||
-- 6. Extend resource_grants CHECK
|
||||
-- =========================================
|
||||
-- SQLite cannot ALTER CHECK constraints. The original CREATE TABLE
|
||||
-- defined CHECK(resource_type IN ('persona','knowledge_base')).
|
||||
-- SQLite does not enforce CHECK on existing rows, and new inserts
|
||||
-- with 'project' will be accepted only if we recreate the table.
|
||||
-- For pragmatism: we accept 'project' values via application-level
|
||||
-- validation and leave the SQLite CHECK as-is (it's advisory in
|
||||
-- SQLite when PRAGMA ignore_check_constraints is implicitly off
|
||||
-- for existing data, but CHECK IS enforced on INSERT).
|
||||
--
|
||||
-- Workaround: recreate the table with the new CHECK. This is safe
|
||||
-- because resource_grants typically has very few rows.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_type TEXT NOT NULL
|
||||
CHECK (resource_type IN ('persona', 'knowledge_base', 'project')),
|
||||
resource_id TEXT NOT NULL,
|
||||
grant_scope TEXT NOT NULL DEFAULT 'team_only'
|
||||
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
|
||||
granted_groups TEXT NOT NULL DEFAULT '[]',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(resource_type, resource_id)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO resource_grants_new
|
||||
SELECT id, resource_type, resource_id, grant_scope, granted_groups,
|
||||
created_by, created_at, updated_at
|
||||
FROM resource_grants;
|
||||
|
||||
DROP TABLE IF EXISTS resource_grants;
|
||||
ALTER TABLE resource_grants_new RENAME TO resource_grants;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
|
||||
ON resource_grants(resource_type, resource_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS resource_grants_updated_at AFTER UPDATE ON resource_grants
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE resource_grants SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
73
server/database/migrations/sqlite/006_knowledge.sql
Normal file
73
server/database/migrations/sqlite/006_knowledge.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- Chat Switchboard — 006 Knowledge Bases (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
owner_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
embedding_config TEXT NOT NULL DEFAULT '{}',
|
||||
document_count INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
discoverable INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
uploaded_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
document_id TEXT NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_knowledge_bases (
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (channel_id, kb_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_knowledge_bases (
|
||||
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
auto_search INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (persona_id, kb_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_persona ON persona_knowledge_bases(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_kb ON persona_knowledge_bases(kb_id);
|
||||
34
server/database/migrations/sqlite/007_notes.sql
Normal file
34
server/database/migrations/sqlite/007_notes.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- Chat Switchboard — 007 Notes & Links (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
folder_path TEXT DEFAULT '/',
|
||||
tags TEXT DEFAULT '[]',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
source_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||
source_message_id TEXT,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_source_msg ON notes(source_message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_title TEXT NOT NULL,
|
||||
display_text TEXT,
|
||||
is_transclusion INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (source_note_id, target_title)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 007_v0200_notification_prefs.sql
|
||||
-- Notification preferences per user per type (SQLite dialect).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
in_app INTEGER NOT NULL DEFAULT 1,
|
||||
email INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_prefs_user ON notification_preferences(user_id);
|
||||
34
server/database/migrations/sqlite/008_memory.sql
Normal file
34
server/database/migrations/sqlite/008_memory.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- Chat Switchboard — 008 Memory (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('user', 'persona', 'persona_user')),
|
||||
owner_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
source_channel_id TEXT,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'pending_review', 'archived')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_scope_owner ON memories(scope, owner_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_persona_user ON memories(owner_id, user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_unique_key
|
||||
ON memories(scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_source_channel ON memories(source_channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_extraction_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
last_message_id TEXT NOT NULL,
|
||||
extracted_at TEXT DEFAULT (datetime('now')),
|
||||
memory_count INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mem_extract_log_channel ON memory_extraction_log(channel_id);
|
||||
@@ -1,62 +0,0 @@
|
||||
-- v0.21.0: Workspaces (platform storage primitive) — SQLite
|
||||
|
||||
-- =========================================
|
||||
-- 1. Workspaces Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_type TEXT NOT NULL
|
||||
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL,
|
||||
max_bytes INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'archived', 'deleting')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_owner
|
||||
ON workspaces(owner_type, owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_status
|
||||
ON workspaces(status);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS workspaces_updated_at AFTER UPDATE ON workspaces
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE workspaces SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- =========================================
|
||||
-- 2. Workspace Files Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
is_directory INTEGER NOT NULL DEFAULT 0,
|
||||
content_type TEXT,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
|
||||
ON workspace_files(workspace_id, path);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace
|
||||
ON workspace_files(workspace_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
|
||||
ON workspace_files(workspace_id, content_type);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS workspace_files_updated_at AFTER UPDATE ON workspace_files
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE workspace_files SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
@@ -1,9 +0,0 @@
|
||||
-- v0.21.1: Workspace bindings for channels and projects (SQLite)
|
||||
|
||||
ALTER TABLE channels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id);
|
||||
|
||||
ALTER TABLE projects ADD COLUMN workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id);
|
||||
66
server/database/migrations/sqlite/009_workspaces.sql
Normal file
66
server/database/migrations/sqlite/009_workspaces.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- Chat Switchboard — 009 Workspaces & Git (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_type TEXT NOT NULL CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL,
|
||||
max_bytes INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleting')),
|
||||
indexing_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
git_remote_url TEXT,
|
||||
git_branch TEXT,
|
||||
git_credential_id TEXT,
|
||||
git_last_sync TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_type, owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_status ON workspaces(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
is_directory INTEGER NOT NULL DEFAULT 0,
|
||||
content_type TEXT,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT,
|
||||
index_status TEXT DEFAULT 'pending' CHECK (index_status IN ('pending', 'indexing', 'ready', 'error', 'skipped')),
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path ON workspace_files(workspace_id, path);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace ON workspace_files(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_type ON workspace_files(workspace_id, content_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_index_status ON workspace_files(workspace_id, index_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
file_id TEXT NOT NULL REFERENCES workspace_files(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_chunks_file ON workspace_chunks(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_chunks_workspace ON workspace_chunks(workspace_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS git_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
auth_type TEXT NOT NULL DEFAULT 'https_pat',
|
||||
encrypted_data BLOB NOT NULL DEFAULT '',
|
||||
nonce BLOB NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_git_credentials_user ON git_credentials(user_id);
|
||||
62
server/database/migrations/sqlite/010_projects.sql
Normal file
62
server/database/migrations/sqlite/010_projects.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- Chat Switchboard — 010 Projects (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
scope TEXT NOT NULL DEFAULT 'personal' CHECK (scope IN ('personal', 'team', 'global')),
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
settings TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_team ON projects(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_scope ON projects(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_channels (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
folder TEXT,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, channel_id),
|
||||
UNIQUE (channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_channels_project ON project_channels(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_knowledge_bases (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
auto_search INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, kb_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_kb_project ON project_knowledge_bases(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_kb_kb ON project_knowledge_bases(kb_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_notes (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, note_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id);
|
||||
|
||||
-- Cross-domain FK columns on channels (SQLite cannot ADD COLUMN with FK in all cases,
|
||||
-- so these may already exist from initial channel creation — safe to skip on error)
|
||||
-- ALTER TABLE channels ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
||||
-- ALTER TABLE channels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL;
|
||||
-- Note: SQLite ADD COLUMN IF NOT EXISTS not supported; channels table created with these columns.
|
||||
@@ -1,31 +0,0 @@
|
||||
-- v0.21.2: Workspace indexing + semantic search (SQLite)
|
||||
|
||||
-- workspace_files additions
|
||||
ALTER TABLE workspace_files ADD COLUMN index_status TEXT DEFAULT 'pending';
|
||||
ALTER TABLE workspace_files ADD COLUMN chunk_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_index_status
|
||||
ON workspace_files(workspace_id, index_status);
|
||||
|
||||
-- workspaces addition
|
||||
ALTER TABLE workspaces ADD COLUMN indexing_enabled INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- workspace_chunks table
|
||||
-- Embeddings stored as JSON text for app-level cosine similarity.
|
||||
CREATE TABLE IF NOT EXISTS workspace_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
file_id TEXT NOT NULL REFERENCES workspace_files(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
embedding TEXT, -- JSON array of floats
|
||||
metadata TEXT DEFAULT '{}', -- JSON object
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_chunks_file
|
||||
ON workspace_chunks(file_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_chunks_workspace
|
||||
ON workspace_chunks(workspace_id);
|
||||
@@ -1,5 +1,4 @@
|
||||
-- 006_v0200_notifications.sql
|
||||
-- Notification infrastructure (v0.20.0 Phase 1)
|
||||
-- Chat Switchboard — 011 Notifications (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -13,5 +12,16 @@ CREATE TABLE IF NOT EXISTS notifications (
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
in_app INTEGER NOT NULL DEFAULT 1,
|
||||
email INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_prefs_user ON notification_preferences(user_id);
|
||||
@@ -1,19 +0,0 @@
|
||||
-- v0.21.4: Git integration — credentials table + workspace git columns.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS git_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
auth_type TEXT NOT NULL DEFAULT 'https_pat',
|
||||
encrypted_data TEXT NOT NULL DEFAULT '',
|
||||
nonce TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_git_credentials_user ON git_credentials(user_id);
|
||||
|
||||
-- Workspace git tracking columns.
|
||||
ALTER TABLE workspaces ADD COLUMN git_remote_url TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN git_branch TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN git_credential_id TEXT;
|
||||
ALTER TABLE workspaces ADD COLUMN git_last_sync TEXT;
|
||||
30
server/database/migrations/sqlite/012_extensions.sql
Normal file
30
server/database/migrations/sqlite/012_extensions.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Chat Switchboard — 012 Extensions (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
ext_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||
tier TEXT NOT NULL DEFAULT 'browser',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
manifest TEXT NOT NULL DEFAULT '{}',
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_user_settings (
|
||||
extension_id TEXT NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings TEXT NOT NULL DEFAULT '{}',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (extension_id, user_id)
|
||||
);
|
||||
@@ -1,35 +0,0 @@
|
||||
-- v0.22.0: Provider health tracking + capability admin overrides.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_health (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT NOT NULL,
|
||||
window_start TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_count INTEGER NOT NULL DEFAULT 0,
|
||||
timeout_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
max_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_error_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
UNIQUE (provider_config_id, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_health_window
|
||||
ON provider_health (provider_config_id, window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability_overrides (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT,
|
||||
model_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
set_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
UNIQUE (provider_config_id, model_id, field)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capability_overrides_model
|
||||
ON capability_overrides (model_id);
|
||||
61
server/database/migrations/sqlite/013_files.sql
Normal file
61
server/database/migrations/sqlite/013_files.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Chat Switchboard — 013 Files (SQLite)
|
||||
-- Unified files table replacing attachments.
|
||||
-- Handles both upgrade (attachments exists) and fresh install.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT REFERENCES channels(id) ON DELETE CASCADE,
|
||||
message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
||||
origin TEXT NOT NULL DEFAULT 'user_upload'
|
||||
CHECK (origin IN ('user_upload', 'tool_output', 'system')),
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
storage_key TEXT NOT NULL,
|
||||
display_hint TEXT NOT NULL DEFAULT 'download'
|
||||
CHECK (display_hint IN ('inline', 'download', 'thumbnail')),
|
||||
extracted_text TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Ensure attachments table exists so the INSERT is always valid.
|
||||
-- Upgrade: no-op (table already has data). Fresh install: empty table.
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY, channel_id TEXT, user_id TEXT, message_id TEXT,
|
||||
project_id TEXT, filename TEXT, content_type TEXT, size_bytes INTEGER,
|
||||
storage_key TEXT, extracted_text TEXT, metadata TEXT, created_at TEXT
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO files (
|
||||
id, channel_id, message_id, user_id, project_id,
|
||||
origin, filename, content_type, size_bytes, storage_key,
|
||||
display_hint, extracted_text, metadata, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, channel_id, message_id, user_id, project_id,
|
||||
'user_upload',
|
||||
filename, content_type, size_bytes, storage_key,
|
||||
CASE
|
||||
WHEN content_type LIKE 'image/%' THEN 'inline'
|
||||
WHEN content_type LIKE 'video/%' THEN 'inline'
|
||||
WHEN content_type LIKE 'audio/%' THEN 'inline'
|
||||
WHEN content_type = 'application/pdf' THEN 'thumbnail'
|
||||
WHEN content_type LIKE 'text/%' THEN 'inline'
|
||||
ELSE 'download'
|
||||
END,
|
||||
extracted_text, metadata, created_at, created_at
|
||||
FROM attachments;
|
||||
|
||||
DROP TABLE IF EXISTS attachments;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_channel ON files(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_message ON files(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_user ON files(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_project ON files(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_orphan ON files(created_at)
|
||||
WHERE message_id IS NULL AND origin = 'user_upload';
|
||||
@@ -1,24 +0,0 @@
|
||||
-- Migration 013: v0.22.2 — Routing Policies (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routing_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
policy_type TEXT NOT NULL
|
||||
CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias')),
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_policies_active
|
||||
ON routing_policies(is_active, priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_policies_team
|
||||
ON routing_policies(team_id);
|
||||
|
||||
-- Add routing_decision column to usage_log
|
||||
ALTER TABLE usage_log ADD COLUMN routing_decision TEXT;
|
||||
42
server/database/migrations/sqlite/014_audit_usage.sql
Normal file
42
server/database/migrations/sqlite/014_audit_usage.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Chat Switchboard — 014 Audit & Usage (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
ip_address TEXT,
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
provider_scope TEXT NOT NULL DEFAULT 'global',
|
||||
model_id TEXT NOT NULL,
|
||||
role TEXT,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost_input REAL,
|
||||
cost_output REAL,
|
||||
routing_decision TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope);
|
||||
@@ -1,9 +1,5 @@
|
||||
-- v0.22.4: Rate limit tracking, tool health, project files (SQLite).
|
||||
-- Chat Switchboard — 015 Tool Health (SQLite)
|
||||
|
||||
-- Rate limit column (SQLite requires full rebuild or default — use default).
|
||||
ALTER TABLE provider_health ADD COLUMN rate_limit_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Tool Health
|
||||
CREATE TABLE IF NOT EXISTS tool_health (
|
||||
id TEXT PRIMARY KEY,
|
||||
tool_name TEXT NOT NULL,
|
||||
@@ -18,5 +14,4 @@ CREATE TABLE IF NOT EXISTS tool_health (
|
||||
UNIQUE (tool_name, window_start)
|
||||
);
|
||||
|
||||
-- Project Files
|
||||
ALTER TABLE attachments ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE CASCADE;
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_health_window ON tool_health(tool_name, window_start);
|
||||
@@ -311,8 +311,7 @@ func TruncateAll(t *testing.T) {
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
|
||||
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'),
|
||||
('banner_presets', '{}'),
|
||||
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'),
|
||||
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
@@ -331,8 +330,7 @@ func TruncateAll(t *testing.T) {
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'::jsonb),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
|
||||
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
|
||||
('banner_presets', '{}'::jsonb),
|
||||
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
"embedding": { "primary": null, "fallback": null },
|
||||
|
||||
@@ -25,7 +25,7 @@ const (
|
||||
// QueueItem is the status.json written to the processing directory.
|
||||
// The sidecar extractor watches this directory for pending items.
|
||||
type QueueItem struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
FileID string `json:"file_id"`
|
||||
StorageKey string `json:"storage_key"`
|
||||
ContentType string `json:"content_type"`
|
||||
Filename string `json:"filename"`
|
||||
@@ -40,7 +40,7 @@ type QueueItem struct {
|
||||
// ── Queue Manager ──────────────────────────
|
||||
|
||||
// Queue manages the filesystem-based extraction queue.
|
||||
// Processing state lives in {storagePath}/processing/{attachment_id}/status.json.
|
||||
// Processing state lives in {storagePath}/processing/{file_id}/status.json.
|
||||
// The sidecar extractor watches for pending items and updates status.
|
||||
type Queue struct {
|
||||
storagePath string
|
||||
@@ -69,19 +69,19 @@ func NewQueue(storagePath string, concurrency int) (*Queue, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enqueue adds an attachment to the extraction queue.
|
||||
// Enqueue adds a file to the extraction queue.
|
||||
// Creates {storagePath}/processing/{id}/status.json with status "pending".
|
||||
func (q *Queue) Enqueue(attachmentID, storageKey, contentType, filename string) error {
|
||||
func (q *Queue) Enqueue(fileID, storageKey, contentType, filename string) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
itemDir := filepath.Join(q.storagePath, "processing", attachmentID)
|
||||
itemDir := filepath.Join(q.storagePath, "processing", fileID)
|
||||
if err := os.MkdirAll(itemDir, 0750); err != nil {
|
||||
return fmt.Errorf("extraction: create item dir: %w", err)
|
||||
}
|
||||
|
||||
item := QueueItem{
|
||||
AttachmentID: attachmentID,
|
||||
FileID: fileID,
|
||||
StorageKey: storageKey,
|
||||
ContentType: contentType,
|
||||
Filename: filename,
|
||||
@@ -89,12 +89,12 @@ func (q *Queue) Enqueue(attachmentID, storageKey, contentType, filename string)
|
||||
QueuedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return q.writeStatus(attachmentID, &item)
|
||||
return q.writeStatus(fileID, &item)
|
||||
}
|
||||
|
||||
// GetStatus reads the current extraction status for an attachment.
|
||||
func (q *Queue) GetStatus(attachmentID string) (*QueueItem, error) {
|
||||
statusPath := filepath.Join(q.storagePath, "processing", attachmentID, "status.json")
|
||||
// GetStatus reads the current extraction status for a file.
|
||||
func (q *Queue) GetStatus(fileID string) (*QueueItem, error) {
|
||||
statusPath := filepath.Join(q.storagePath, "processing", fileID, "status.json")
|
||||
data, err := os.ReadFile(statusPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -111,40 +111,40 @@ func (q *Queue) GetStatus(attachmentID string) (*QueueItem, error) {
|
||||
}
|
||||
|
||||
// MarkComplete updates status to complete and records the output key.
|
||||
func (q *Queue) MarkComplete(attachmentID, outputKey string) error {
|
||||
func (q *Queue) MarkComplete(fileID, outputKey string) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
item, err := q.GetStatus(attachmentID)
|
||||
item, err := q.GetStatus(fileID)
|
||||
if err != nil || item == nil {
|
||||
return fmt.Errorf("extraction: item not found: %s", attachmentID)
|
||||
return fmt.Errorf("extraction: item not found: %s", fileID)
|
||||
}
|
||||
|
||||
item.Status = StatusComplete
|
||||
item.OutputKey = outputKey
|
||||
item.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return q.writeStatus(attachmentID, item)
|
||||
return q.writeStatus(fileID, item)
|
||||
}
|
||||
|
||||
// MarkFailed updates status to failed with an error message.
|
||||
func (q *Queue) MarkFailed(attachmentID, errMsg string) error {
|
||||
func (q *Queue) MarkFailed(fileID, errMsg string) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
item, err := q.GetStatus(attachmentID)
|
||||
item, err := q.GetStatus(fileID)
|
||||
if err != nil || item == nil {
|
||||
return fmt.Errorf("extraction: item not found: %s", attachmentID)
|
||||
return fmt.Errorf("extraction: item not found: %s", fileID)
|
||||
}
|
||||
|
||||
item.Status = StatusFailed
|
||||
item.Error = errMsg
|
||||
item.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return q.writeStatus(attachmentID, item)
|
||||
return q.writeStatus(fileID, item)
|
||||
}
|
||||
|
||||
// Cleanup removes the processing directory for a completed/failed item.
|
||||
func (q *Queue) Cleanup(attachmentID string) error {
|
||||
itemDir := filepath.Join(q.storagePath, "processing", attachmentID)
|
||||
func (q *Queue) Cleanup(fileID string) error {
|
||||
itemDir := filepath.Join(q.storagePath, "processing", fileID)
|
||||
return os.RemoveAll(itemDir)
|
||||
}
|
||||
|
||||
@@ -227,8 +227,8 @@ func (q *Queue) ListAll() ([]QueueItem, error) {
|
||||
|
||||
// ── Internal Helpers ───────────────────────
|
||||
|
||||
func (q *Queue) writeStatus(attachmentID string, item *QueueItem) error {
|
||||
statusPath := filepath.Join(q.storagePath, "processing", attachmentID, "status.json")
|
||||
func (q *Queue) writeStatus(fileID string, item *QueueItem) error {
|
||||
statusPath := filepath.Join(q.storagePath, "processing", fileID, "status.json")
|
||||
data, err := json.MarshalIndent(item, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,7 +20,7 @@ func tempQueue(t *testing.T) *Queue {
|
||||
func TestEnqueue_CreatesStatusFile(t *testing.T) {
|
||||
q := tempQueue(t)
|
||||
|
||||
err := q.Enqueue("att-123", "attachments/ch/att-123_doc.pdf", "application/pdf", "doc.pdf")
|
||||
err := q.Enqueue("att-123", "files/ch/att-123_doc.pdf", "application/pdf", "doc.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("Enqueue: %v", err)
|
||||
}
|
||||
@@ -35,8 +35,8 @@ func TestEnqueue_CreatesStatusFile(t *testing.T) {
|
||||
if item.Status != StatusPending {
|
||||
t.Errorf("status = %q, want %q", item.Status, StatusPending)
|
||||
}
|
||||
if item.AttachmentID != "att-123" {
|
||||
t.Errorf("attachment_id = %q, want att-123", item.AttachmentID)
|
||||
if item.FileID != "att-123" {
|
||||
t.Errorf("file_id = %q, want att-123", item.FileID)
|
||||
}
|
||||
if item.ContentType != "application/pdf" {
|
||||
t.Errorf("content_type = %q, want application/pdf", item.ContentType)
|
||||
@@ -126,8 +126,8 @@ func TestListPending(t *testing.T) {
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("expected 1 pending, got %d", len(pending))
|
||||
}
|
||||
if pending[0].AttachmentID != "att-a" {
|
||||
t.Errorf("pending[0].AttachmentID = %q, want att-a", pending[0].AttachmentID)
|
||||
if pending[0].FileID != "att-a" {
|
||||
t.Errorf("pending[0].FileID = %q, want att-a", pending[0].FileID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func NewChannelHandler() *ChannelHandler {
|
||||
var channelDeleteHook func(channelID string)
|
||||
|
||||
// SetChannelDeleteHook registers a callback invoked after channel deletion.
|
||||
// Used to clean up attachment files on the storage backend.
|
||||
// Used to clean up channel files on the storage backend.
|
||||
func SetChannelDeleteHook(fn func(channelID string)) {
|
||||
channelDeleteHook = fn
|
||||
}
|
||||
@@ -543,7 +543,7 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up storage files (CASCADE already removed PG attachment rows)
|
||||
// Clean up storage files (CASCADE already removed PG file rows)
|
||||
if channelDeleteHook != nil {
|
||||
go channelDeleteHook(channelID)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ type completionRequest struct {
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
AttachmentIDs []string `json:"attachment_ids,omitempty"` // staged attachment UUIDs to include in request
|
||||
FileIDs []string `json:"file_ids,omitempty"` // staged file UUIDs to include in request
|
||||
DisabledTools []string `json:"disabled_tools,omitempty"` // tool names to exclude from this request
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ type CompletionHandler struct {
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
hub *events.Hub // WebSocket hub for browser tool bridge
|
||||
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
|
||||
objStore storage.ObjectStore // file storage for uploaded/generated content (nil = disabled)
|
||||
embedder *knowledge.Embedder // for memory semantic recall (v0.18.0)
|
||||
health HealthRecorder // provider health tracking (v0.22.0, nil = disabled)
|
||||
healthStore HealthStatusQuerier // health status queries for routing (v0.22.2, nil = disabled)
|
||||
@@ -341,14 +341,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// Resolve capabilities early — needed for vision gating below
|
||||
caps := h.getModelCapabilities(c, model, configID)
|
||||
|
||||
// Build user message — multimodal if attachments are present
|
||||
// Build user message — multimodal if files are present
|
||||
userMsg := providers.Message{
|
||||
Role: "user",
|
||||
Content: req.Content,
|
||||
}
|
||||
|
||||
if len(req.AttachmentIDs) > 0 && h.objStore != nil {
|
||||
parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.AttachmentIDs, caps)
|
||||
if len(req.FileIDs) > 0 && h.objStore != nil {
|
||||
parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.FileIDs, caps)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -370,11 +370,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
log.Printf("Failed to persist user message: %v", err)
|
||||
}
|
||||
|
||||
// Link attachments to the persisted message
|
||||
if msgID != "" && len(req.AttachmentIDs) > 0 {
|
||||
for _, attID := range req.AttachmentIDs {
|
||||
if err := h.stores.Attachments.SetMessageID(c.Request.Context(), attID, msgID); err != nil {
|
||||
log.Printf("Failed to link attachment %s to message %s: %v", attID, msgID, err)
|
||||
// Link files to the persisted message
|
||||
if msgID != "" && len(req.FileIDs) > 0 {
|
||||
for _, fID := range req.FileIDs {
|
||||
if err := h.stores.Files.SetMessageID(c.Request.Context(), fID, msgID); err != nil {
|
||||
log.Printf("Failed to link file %s to message %s: %v", fID, msgID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -865,13 +865,13 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
|
||||
}
|
||||
|
||||
// ── Multimodal Assembly ─────────────────────
|
||||
// Builds content parts from attachments for the user message.
|
||||
// Builds content parts from files for the user message.
|
||||
//
|
||||
// Returns:
|
||||
// - parts: ContentParts array (non-nil only when images are present)
|
||||
// - augContent: enriched text content with document context (for doc-only case)
|
||||
// - validIDs: attachment IDs that were successfully processed
|
||||
// - error: if any attachment is invalid or vision is needed but missing
|
||||
// - validIDs: file IDs that were successfully processed
|
||||
// - error: if any file is invalid or vision is needed but missing
|
||||
//
|
||||
// Rules:
|
||||
// - Images → base64 data URI (requires vision capability)
|
||||
@@ -882,7 +882,7 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
|
||||
func (h *CompletionHandler) buildMultimodalParts(
|
||||
c *gin.Context,
|
||||
channelID, textContent string,
|
||||
attachmentIDs []string,
|
||||
fileIDs []string,
|
||||
caps models.ModelCapabilities,
|
||||
) ([]providers.ContentPart, string, []string, error) {
|
||||
|
||||
@@ -893,39 +893,39 @@ func (h *CompletionHandler) buildMultimodalParts(
|
||||
var validIDs []string
|
||||
hasImage := false
|
||||
|
||||
for _, attID := range attachmentIDs {
|
||||
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||
for _, fileID := range fileIDs {
|
||||
att, err := h.stores.Files.GetByID(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("attachment %s not found", attID)
|
||||
return nil, "", nil, fmt.Errorf("file %s not found", fileID)
|
||||
}
|
||||
|
||||
// Security: verify attachment belongs to this channel
|
||||
// Security: verify file belongs to this channel
|
||||
if att.ChannelID != channelID {
|
||||
return nil, "", nil, fmt.Errorf("attachment %s does not belong to this channel", attID)
|
||||
return nil, "", nil, fmt.Errorf("file %s does not belong to this channel", fileID)
|
||||
}
|
||||
|
||||
if isImageContentType(att.ContentType) {
|
||||
// Vision gating: reject images if model lacks vision
|
||||
if !caps.Vision {
|
||||
return nil, "", nil, fmt.Errorf("model does not support image input; remove image attachments or choose a vision-capable model")
|
||||
return nil, "", nil, fmt.Errorf("model does not support image input; remove image files or choose a vision-capable model")
|
||||
}
|
||||
|
||||
// Read image from storage, base64 encode, build data URI
|
||||
reader, _, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read attachment %s from storage: %v", attID, err)
|
||||
log.Printf("Failed to read file %s from storage: %v", fileID, err)
|
||||
parts = append(parts, providers.ContentPart{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("[Image: %s — failed to read from storage]", att.Filename),
|
||||
})
|
||||
validIDs = append(validIDs, attID)
|
||||
validIDs = append(validIDs, fileID)
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
log.Printf("Failed to read attachment %s bytes: %v", attID, err)
|
||||
validIDs = append(validIDs, attID)
|
||||
log.Printf("Failed to read file %s bytes: %v", fileID, err)
|
||||
validIDs = append(validIDs, fileID)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -964,7 +964,7 @@ func (h *CompletionHandler) buildMultimodalParts(
|
||||
docTexts = append(docTexts, placeholder)
|
||||
}
|
||||
|
||||
validIDs = append(validIDs, attID)
|
||||
validIDs = append(validIDs, fileID)
|
||||
}
|
||||
|
||||
// If images present → use ContentParts (multimodal array)
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
const (
|
||||
defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file)
|
||||
defaultMaxAttachmentsPerMsg = 5 // future: multi-file per message
|
||||
defaultMaxFilesPerMsg = 5 // future: multi-file per message
|
||||
defaultOrphanMaxAge = 24 * time.Hour
|
||||
)
|
||||
|
||||
@@ -51,20 +51,20 @@ var allowedMIMETypes = map[string]bool{
|
||||
|
||||
// ── Handler ────────────────────────────────
|
||||
|
||||
type AttachmentHandler struct {
|
||||
type FileHandler struct {
|
||||
stores store.Stores
|
||||
objStore storage.ObjectStore
|
||||
extQueue *extraction.Queue // nil if extraction disabled
|
||||
}
|
||||
|
||||
func NewAttachmentHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *AttachmentHandler {
|
||||
return &AttachmentHandler{stores: stores, objStore: objStore, extQueue: extQueue}
|
||||
func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *FileHandler {
|
||||
return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue}
|
||||
}
|
||||
|
||||
// ── Upload ─────────────────────────────────
|
||||
// POST /api/v1/channels/:id/attachments
|
||||
// Multipart form: file field "file", returns attachment metadata.
|
||||
func (h *AttachmentHandler) Upload(c *gin.Context) {
|
||||
// POST /api/v1/channels/:id/files
|
||||
// Multipart form: file field "file", returns file metadata.
|
||||
func (h *FileHandler) Upload(c *gin.Context) {
|
||||
if h.objStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||
return
|
||||
@@ -128,14 +128,16 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build storage key: attachments/{channel_id}/{attachment_id}_{filename}
|
||||
// Build storage key: files are stored under files/{channel_id}/{file_id}_{filename}
|
||||
// We generate the ID first via a temp UUID, then use it in the key.
|
||||
att := &models.Attachment{
|
||||
att := &models.File{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Origin: models.FileOriginUserUpload,
|
||||
Filename: sanitizeFilename(header.Filename),
|
||||
ContentType: contentType,
|
||||
SizeBytes: header.Size,
|
||||
DisplayHint: displayHintFor(contentType),
|
||||
Metadata: models.JSONMap{
|
||||
"extraction_status": "pending",
|
||||
},
|
||||
@@ -144,30 +146,30 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
|
||||
// Create PG row first to get the UUID
|
||||
// storage_key is set after we have the ID
|
||||
att.StorageKey = "placeholder"
|
||||
if err := h.stores.Attachments.Create(c.Request.Context(), att); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create attachment record"})
|
||||
if err := h.stores.Files.Create(c.Request.Context(), att); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create file record"})
|
||||
return
|
||||
}
|
||||
|
||||
// Now build the real storage key and update
|
||||
att.StorageKey = fmt.Sprintf("attachments/%s/%s_%s", channelID, att.ID, att.Filename)
|
||||
att.StorageKey = fmt.Sprintf("files/%s/%s_%s", channelID, att.ID, att.Filename)
|
||||
|
||||
// Write to object store
|
||||
if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil {
|
||||
// Rollback PG row on storage failure
|
||||
h.stores.Attachments.Delete(c.Request.Context(), att.ID)
|
||||
h.stores.Files.Delete(c.Request.Context(), att.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update storage_key in PG
|
||||
database.DB.ExecContext(c.Request.Context(),
|
||||
database.Q(`UPDATE attachments SET storage_key = $1 WHERE id = $2`),
|
||||
database.Q(`UPDATE files SET storage_key = $1 WHERE id = $2`),
|
||||
att.StorageKey, att.ID)
|
||||
|
||||
// For images, mark extraction as not needed (complete immediately)
|
||||
if strings.HasPrefix(contentType, "image/") {
|
||||
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
||||
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
||||
"extraction_status": "complete",
|
||||
})
|
||||
att.Metadata["extraction_status"] = "complete"
|
||||
@@ -178,8 +180,8 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
|
||||
if _, err := file.Seek(0, io.SeekStart); err == nil {
|
||||
if textBytes, err := io.ReadAll(file); err == nil {
|
||||
text := string(textBytes)
|
||||
h.stores.Attachments.SetExtractedText(c.Request.Context(), att.ID, text)
|
||||
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
||||
h.stores.Files.SetExtractedText(c.Request.Context(), att.ID, text)
|
||||
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
||||
"extraction_status": "complete",
|
||||
})
|
||||
att.Metadata["extraction_status"] = "complete"
|
||||
@@ -199,9 +201,9 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ── Download ───────────────────────────────
|
||||
// GET /api/v1/attachments/:id/download
|
||||
// GET /api/v1/files/:id/download
|
||||
// Streams file content with auth check via channel membership.
|
||||
func (h *AttachmentHandler) Download(c *gin.Context) {
|
||||
func (h *FileHandler) Download(c *gin.Context) {
|
||||
if h.objStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||
return
|
||||
@@ -210,9 +212,9 @@ func (h *AttachmentHandler) Download(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
attID := c.Param("id")
|
||||
|
||||
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,14 +238,14 @@ func (h *AttachmentHandler) Download(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ── Get Metadata ───────────────────────────
|
||||
// GET /api/v1/attachments/:id
|
||||
func (h *AttachmentHandler) GetMetadata(c *gin.Context) {
|
||||
// GET /api/v1/files/:id
|
||||
func (h *FileHandler) GetMetadata(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
attID := c.Param("id")
|
||||
|
||||
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -254,9 +256,9 @@ func (h *AttachmentHandler) GetMetadata(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, att)
|
||||
}
|
||||
|
||||
// ── List Channel Attachments ───────────────
|
||||
// GET /api/v1/channels/:id/attachments
|
||||
func (h *AttachmentHandler) ListByChannel(c *gin.Context) {
|
||||
// ── List Channel Files ───────────────
|
||||
// GET /api/v1/channels/:id/files
|
||||
func (h *FileHandler) ListByChannel(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
@@ -264,27 +266,27 @@ func (h *AttachmentHandler) ListByChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
attachments, err := h.stores.Attachments.GetByChannel(c.Request.Context(), channelID)
|
||||
files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list attachments"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||
return
|
||||
}
|
||||
if attachments == nil {
|
||||
attachments = []models.Attachment{}
|
||||
if files == nil {
|
||||
files = []models.File{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"attachments": attachments})
|
||||
c.JSON(http.StatusOK, gin.H{"files": files})
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────
|
||||
// DELETE /api/v1/attachments/:id
|
||||
func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
|
||||
// DELETE /api/v1/files/:id
|
||||
func (h *FileHandler) Delete(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
attID := c.Param("id")
|
||||
|
||||
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -293,9 +295,9 @@ func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Delete from PG (returns the row for storage cleanup)
|
||||
deleted, err := h.stores.Attachments.Delete(c.Request.Context(), attID)
|
||||
deleted, err := h.stores.Files.Delete(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete attachment"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -312,13 +314,13 @@ func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "attachment deleted"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "file deleted"})
|
||||
}
|
||||
|
||||
// ── Admin: Orphan Cleanup ──────────────────
|
||||
// POST /admin/storage/cleanup
|
||||
func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
|
||||
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
||||
func (h *FileHandler) CleanupOrphans(c *gin.Context) {
|
||||
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"})
|
||||
return
|
||||
@@ -328,7 +330,7 @@ func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
|
||||
var freedBytes int64
|
||||
|
||||
for _, att := range orphans {
|
||||
if _, err := h.stores.Attachments.Delete(c.Request.Context(), att.ID); err != nil {
|
||||
if _, err := h.stores.Files.Delete(c.Request.Context(), att.ID); err != nil {
|
||||
log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err)
|
||||
continue
|
||||
}
|
||||
@@ -349,8 +351,8 @@ func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
|
||||
|
||||
// ── Admin: Orphan Count ────────────────────
|
||||
// GET /admin/storage/orphans (for the admin panel card)
|
||||
func (h *AttachmentHandler) OrphanCount(c *gin.Context) {
|
||||
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
||||
func (h *FileHandler) OrphanCount(c *gin.Context) {
|
||||
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"})
|
||||
return
|
||||
@@ -369,7 +371,7 @@ func (h *AttachmentHandler) OrphanCount(c *gin.Context) {
|
||||
|
||||
// ── Admin: Extraction Queue Status ─────────
|
||||
// GET /admin/storage/extraction
|
||||
func (h *AttachmentHandler) ExtractionStatus(c *gin.Context) {
|
||||
func (h *FileHandler) ExtractionStatus(c *gin.Context) {
|
||||
if h.extQueue == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"enabled": false,
|
||||
@@ -403,12 +405,12 @@ func (h *AttachmentHandler) ExtractionStatus(c *gin.Context) {
|
||||
|
||||
// ── Channel Delete Hook ────────────────────
|
||||
// Called by ChannelHandler.DeleteChannel to clean up storage.
|
||||
func (h *AttachmentHandler) CleanupChannelStorage(channelID string) {
|
||||
func (h *FileHandler) CleanupChannelStorage(channelID string) {
|
||||
if h.objStore == nil {
|
||||
return
|
||||
}
|
||||
// CASCADE already deleted PG rows. Clean up filesystem.
|
||||
prefix := fmt.Sprintf("attachments/%s", channelID)
|
||||
prefix := fmt.Sprintf("files/%s", channelID)
|
||||
if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil {
|
||||
log.Printf("storage cleanup for channel %s failed: %v", channelID, err)
|
||||
}
|
||||
@@ -418,7 +420,7 @@ func (h *AttachmentHandler) CleanupChannelStorage(channelID string) {
|
||||
|
||||
// verifyChannelAccess checks that the requesting user owns the channel.
|
||||
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
|
||||
func (h *AttachmentHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
|
||||
func (h *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
|
||||
var ownerID string
|
||||
err := database.DB.QueryRowContext(c.Request.Context(),
|
||||
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID)
|
||||
@@ -461,7 +463,7 @@ func sanitizeFilename(name string) string {
|
||||
|
||||
// UploadToProject handles project-scoped file uploads.
|
||||
// POST /api/v1/projects/:id/files
|
||||
func (h *AttachmentHandler) UploadToProject(c *gin.Context) {
|
||||
func (h *FileHandler) UploadToProject(c *gin.Context) {
|
||||
if h.objStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||
return
|
||||
@@ -516,17 +518,19 @@ func (h *AttachmentHandler) UploadToProject(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
att := models.Attachment{
|
||||
att := models.File{
|
||||
ChannelID: "", // no channel association
|
||||
UserID: userID,
|
||||
ProjectID: &projectID,
|
||||
Origin: models.FileOriginUserUpload,
|
||||
Filename: header.Filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: header.Size,
|
||||
StorageKey: storageKey,
|
||||
DisplayHint: displayHintFor(contentType),
|
||||
}
|
||||
|
||||
if err := h.stores.Attachments.Create(c.Request.Context(), &att); err != nil {
|
||||
if err := h.stores.Files.Create(c.Request.Context(), &att); err != nil {
|
||||
log.Printf("error: project file create: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file metadata"})
|
||||
return
|
||||
@@ -543,7 +547,7 @@ func (h *AttachmentHandler) UploadToProject(c *gin.Context) {
|
||||
|
||||
// ListByProject returns all files uploaded to a project.
|
||||
// GET /api/v1/projects/:id/files
|
||||
func (h *AttachmentHandler) ListByProject(c *gin.Context) {
|
||||
func (h *FileHandler) ListByProject(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
projectID := c.Param("id")
|
||||
|
||||
@@ -562,7 +566,7 @@ func (h *AttachmentHandler) ListByProject(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.stores.Attachments.GetByProject(c.Request.Context(), projectID)
|
||||
files, err := h.stores.Files.GetByProject(c.Request.Context(), projectID)
|
||||
if err != nil {
|
||||
log.Printf("error: list project files: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||
@@ -589,3 +593,23 @@ var extToMIME = map[string]string{
|
||||
".csv": "text/csv",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
|
||||
// displayHintFor returns the appropriate display hint for a content type.
|
||||
func displayHintFor(contentType string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "image/"):
|
||||
return models.FileHintInline
|
||||
case strings.HasPrefix(contentType, "video/"):
|
||||
return models.FileHintInline
|
||||
case strings.HasPrefix(contentType, "audio/"):
|
||||
return models.FileHintInline
|
||||
case contentType == "application/pdf":
|
||||
return models.FileHintThumbnail
|
||||
case strings.HasPrefix(contentType, "text/"):
|
||||
return models.FileHintInline
|
||||
case contentType == "application/json":
|
||||
return models.FileHintInline
|
||||
default:
|
||||
return models.FileHintDownload
|
||||
}
|
||||
}
|
||||
@@ -238,13 +238,13 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
|
||||
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0
|
||||
|
||||
// Attachments (nil storage = upload returns 503, but metadata works)
|
||||
attachH := NewAttachmentHandler(stores, nil, nil)
|
||||
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
|
||||
protected.GET("/attachments/:id", attachH.GetMetadata)
|
||||
protected.GET("/attachments/:id/download", attachH.Download)
|
||||
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
|
||||
// Files (nil storage = upload returns 503, but metadata works)
|
||||
fileH := NewFileHandler(stores, nil, nil)
|
||||
protected.POST("/channels/:id/files", fileH.Upload)
|
||||
protected.GET("/channels/:id/files", fileH.ListByChannel)
|
||||
protected.GET("/files/:id", fileH.GetMetadata)
|
||||
protected.GET("/files/:id/download", fileH.Download)
|
||||
protected.DELETE("/files/:id", fileH.Delete)
|
||||
|
||||
// Completions
|
||||
completions := NewCompletionHandler(nil, stores, nil, nil, nil)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// PersonaHandler handles persona (formerly preset) endpoints.
|
||||
// PersonaHandler handles persona endpoints.
|
||||
type PersonaHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ func (ing *Ingester) extractText(ctx context.Context, doc *models.KBDocument) (s
|
||||
}
|
||||
|
||||
// Complex document types are not yet supported for inline extraction.
|
||||
// The extraction sidecar (v0.12.0) handles attachments but isn't yet
|
||||
// The extraction sidecar (v0.12.0) handles files but isn't yet
|
||||
// wired into the KB pipeline. Planned for a future phase.
|
||||
return "", fmt.Errorf("unsupported content type %q — text-based files only for now (txt, md, csv, html)", doc.ContentType)
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Register context recall tools (v0.15.1)
|
||||
tools.RegisterAttachmentRecall(stores, objStore)
|
||||
tools.RegisterFileRecall(stores, objStore)
|
||||
tools.RegisterConversationSearch(stores)
|
||||
|
||||
// Memory tools + extraction scanner (v0.18.0)
|
||||
@@ -491,13 +491,13 @@ func main() {
|
||||
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
|
||||
protected.GET("/projects/:id/notes", projectH.ListNotes)
|
||||
|
||||
// Project files (v0.22.4) — wired via attachH which is declared later,
|
||||
// Project files (v0.22.4) — wired via fileH which is declared later,
|
||||
// so we use a closure that captures the variable.
|
||||
protected.POST("/projects/:id/files", func(c *gin.Context) {
|
||||
handlers.NewAttachmentHandler(stores, objStore, extQueue).UploadToProject(c)
|
||||
handlers.NewFileHandler(stores, objStore, extQueue).UploadToProject(c)
|
||||
})
|
||||
protected.GET("/projects/:id/files", func(c *gin.Context) {
|
||||
handlers.NewAttachmentHandler(stores, objStore, extQueue).ListByProject(c)
|
||||
handlers.NewFileHandler(stores, objStore, extQueue).ListByProject(c)
|
||||
})
|
||||
|
||||
// Notifications (v0.20.0)
|
||||
@@ -553,20 +553,20 @@ func main() {
|
||||
protected.GET("/git-credentials", gitCredH.List)
|
||||
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
|
||||
|
||||
// Attachments (file upload/download)
|
||||
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
|
||||
protected.GET("/attachments/:id", attachH.GetMetadata)
|
||||
protected.GET("/attachments/:id/download", attachH.Download)
|
||||
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
|
||||
// Files (upload/download)
|
||||
fileH := handlers.NewFileHandler(stores, objStore, extQueue)
|
||||
protected.POST("/channels/:id/files", fileH.Upload)
|
||||
protected.GET("/channels/:id/files", fileH.ListByChannel)
|
||||
protected.GET("/files/:id", fileH.GetMetadata)
|
||||
protected.GET("/files/:id/download", fileH.Download)
|
||||
protected.DELETE("/files/:id", fileH.Delete)
|
||||
|
||||
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
|
||||
exportH := handlers.NewExportHandler()
|
||||
protected.POST("/export", exportH.Convert)
|
||||
|
||||
// Hook: clean up storage files when channels are deleted
|
||||
handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage)
|
||||
handlers.SetChannelDeleteHook(fileH.CleanupChannelStorage)
|
||||
|
||||
// Knowledge Bases (RAG — v0.14.0)
|
||||
kbH := handlers.NewKnowledgeBaseHandler(stores, objStore, kbIngester, kbEmbedder)
|
||||
@@ -775,10 +775,10 @@ func main() {
|
||||
admin.POST("/notifications/test-email", emailAdm.TestEmail)
|
||||
|
||||
// Storage management (orphan cleanup)
|
||||
attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||
admin.GET("/storage/orphans", attachAdm.OrphanCount)
|
||||
admin.POST("/storage/cleanup", attachAdm.CleanupOrphans)
|
||||
admin.GET("/storage/extraction", attachAdm.ExtractionStatus)
|
||||
fileAdm := handlers.NewFileHandler(stores, objStore, extQueue)
|
||||
admin.GET("/storage/orphans", fileAdm.OrphanCount)
|
||||
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
|
||||
admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
|
||||
|
||||
// Extensions (admin)
|
||||
extAdm := handlers.NewExtensionHandler(stores)
|
||||
|
||||
@@ -501,21 +501,44 @@ type NoteGraph struct {
|
||||
// ATTACHMENTS
|
||||
// =========================================
|
||||
|
||||
type Attachment struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
MessageID *string `json:"message_id,omitempty" db:"message_id"`
|
||||
ProjectID *string `json:"project_id,omitempty" db:"project_id"` // v0.22.4: project-scoped uploads
|
||||
Filename string `json:"filename" db:"filename"`
|
||||
ContentType string `json:"content_type" db:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
|
||||
StorageKey string `json:"-" db:"storage_key"` // never expose filesystem path
|
||||
ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"`
|
||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||
// =========================================
|
||||
// FILES
|
||||
// =========================================
|
||||
|
||||
// FileOrigin constants
|
||||
const (
|
||||
FileOriginUserUpload = "user_upload"
|
||||
FileOriginToolOutput = "tool_output"
|
||||
FileOriginSystem = "system"
|
||||
)
|
||||
|
||||
// FileDisplayHint constants
|
||||
const (
|
||||
FileHintInline = "inline"
|
||||
FileHintDownload = "download"
|
||||
FileHintThumbnail = "thumbnail"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
MessageID *string `json:"message_id,omitempty" db:"message_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
ProjectID *string `json:"project_id,omitempty" db:"project_id"`
|
||||
Origin string `json:"origin" db:"origin"` // user_upload, tool_output, system
|
||||
Filename string `json:"filename" db:"filename"`
|
||||
ContentType string `json:"content_type" db:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
|
||||
StorageKey string `json:"-" db:"storage_key"` // never expose filesystem path
|
||||
DisplayHint string `json:"display_hint" db:"display_hint"` // inline, download, thumbnail
|
||||
ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"`
|
||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =========================================
|
||||
// AUDIT LOG
|
||||
// =========================================
|
||||
|
||||
@@ -42,7 +42,7 @@ type Engine struct {
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// BannerConfig holds classification banner settings.
|
||||
// BannerConfig holds environment banner settings.
|
||||
type BannerConfig struct {
|
||||
Text string `json:"text"`
|
||||
Color string `json:"color"`
|
||||
@@ -243,10 +243,10 @@ func (e *Engine) loadBanner() BannerConfig {
|
||||
if v, ok := raw["text"].(string); ok {
|
||||
b.Text = v
|
||||
}
|
||||
if v, ok := raw["color"].(string); ok {
|
||||
if v, ok := raw["fg"].(string); ok {
|
||||
b.Color = v
|
||||
}
|
||||
if v, ok := raw["background"].(string); ok {
|
||||
if v, ok := raw["bg"].(string); ok {
|
||||
b.Background = v
|
||||
}
|
||||
return b
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:2;min-width:200px;">
|
||||
<label>Text</label>
|
||||
<input type="text" id="settBannerText" placeholder="UNCLASSIFIED">
|
||||
<input type="text" id="settBannerText" placeholder="DEVELOPMENT">
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;min-width:100px;">
|
||||
<label>Background</label>
|
||||
|
||||
@@ -44,6 +44,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Crash Catcher (visible without devtools) ── */}}
|
||||
<div id="crashBanner" style="display:none;position:fixed;top:0;left:0;right:0;z-index:99999;background:#b00020;color:#fff;padding:12px 20px;font-family:monospace;font-size:13px;max-height:40vh;overflow-y:auto;">
|
||||
<strong>⚠ Init Error</strong> <button onclick="this.parentElement.style.display='none'" style="float:right;background:none;border:1px solid #fff;color:#fff;cursor:pointer;padding:2px 8px;border-radius:3px">✕</button>
|
||||
<pre id="crashDetail" style="margin:8px 0 0;white-space:pre-wrap;font-size:12px;"></pre>
|
||||
</div>
|
||||
<script nonce="{{$.CSPNonce}}">
|
||||
window.addEventListener('error', function(e) {
|
||||
var b = document.getElementById('crashBanner');
|
||||
var d = document.getElementById('crashDetail');
|
||||
if (b && d) {
|
||||
b.style.display = '';
|
||||
d.textContent += (e.filename || '') + ':' + (e.lineno || '') + ' ' + (e.message || e) + '\n';
|
||||
}
|
||||
});
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
var b = document.getElementById('crashBanner');
|
||||
var d = document.getElementById('crashDetail');
|
||||
if (b && d) {
|
||||
b.style.display = '';
|
||||
d.textContent += 'Promise: ' + (e.reason?.message || e.reason || 'unknown') + '\n' + (e.reason?.stack || '') + '\n';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{{/* ── App Container ───────────────────────── */}}
|
||||
<div id="appContainer" class="app" style="display:none;height:100%;">
|
||||
|
||||
@@ -220,8 +244,8 @@
|
||||
<span id="contextWarningText"></span>
|
||||
</div>
|
||||
|
||||
{{/* Attachment strip */}}
|
||||
<div id="attachmentStrip" class="attachment-strip"></div>
|
||||
{{/* File strip */}}
|
||||
<div id="fileStrip" class="file-strip"></div>
|
||||
|
||||
{{/* Streaming tools display */}}
|
||||
<div id="streamTools" class="stream-tools" style="display:none;"></div>
|
||||
@@ -324,7 +348,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Hidden File Input (attachments) ─────── */}}
|
||||
{{/* ── Hidden File Input ─────── */}}
|
||||
<input type="file" id="fileInput" multiple style="display:none;">
|
||||
|
||||
{{/* ── Settings Modal ──────────────────────── */}}
|
||||
@@ -450,7 +474,7 @@
|
||||
<div id="settingsTeamsList"></div>
|
||||
<div id="settingsTeamMembers" style="display:none;"></div>
|
||||
<div id="settingsTeamPersonas" style="display:none;">
|
||||
<div id="adminPersonaList"></div>
|
||||
<div id="teamPersonaList"></div>
|
||||
</div>
|
||||
<div id="settingsTeamProviders" style="display:none;">
|
||||
<button id="settingsTeamAddProviderBtn" class="btn-small" style="display:none;">+ Add Provider</button>
|
||||
@@ -536,10 +560,6 @@
|
||||
<div class="form-group"><label><input type="checkbox" id="adminBannerEnabled"> Banner</label></div>
|
||||
<div id="bannerConfigFields" style="display:none;">
|
||||
<input type="text" id="adminBannerText" placeholder="Banner text">
|
||||
<div style="display:flex;gap:8px;margin-top:6px;">
|
||||
<select id="adminBannerPreset"><option value="">Custom colors</option></select>
|
||||
<select id="adminBannerPosition"><option value="both">Both</option><option value="top">Top</option><option value="bottom">Bottom</option></select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;">
|
||||
<input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" value="#007a33" style="width:80px;">
|
||||
<input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" value="#ffffff" style="width:80px;">
|
||||
@@ -716,7 +736,7 @@
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notes.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/note-graph.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/attachments.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tools-toggle.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/memory-ui.js?v={{$.Version}}"></script>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/attachments.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/files.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/tokens.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/extensions.js?v={{.Version}}"></script>
|
||||
|
||||
@@ -36,7 +36,7 @@ func NewPVC(basePath string) (*PVCStore, error) {
|
||||
}
|
||||
|
||||
// Create well-known subdirectories
|
||||
for _, sub := range []string{"attachments", "processing"} {
|
||||
for _, sub := range []string{"files", "processing"} {
|
||||
if err := os.MkdirAll(filepath.Join(basePath, sub), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("storage/pvc: cannot create %s dir: %w", sub, err)
|
||||
}
|
||||
@@ -236,9 +236,9 @@ func (s *PVCStore) Stats(ctx context.Context) (*StorageStats, error) {
|
||||
}
|
||||
stats.Healthy = true
|
||||
|
||||
// Walk attachments directory for counts
|
||||
attDir := filepath.Join(s.basePath, "attachments")
|
||||
err := filepath.Walk(attDir, func(path string, info os.FileInfo, err error) error {
|
||||
// Walk files directory for counts
|
||||
fileDir := filepath.Join(s.basePath, "files")
|
||||
err := filepath.Walk(fileDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip errors (e.g. permission denied)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestPVC_PutGetRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
data := []byte("hello, storage world")
|
||||
key := "attachments/channel-1/att-1_test.txt"
|
||||
key := "files/channel-1/att-1_test.txt"
|
||||
|
||||
// Put
|
||||
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||
@@ -56,7 +56,7 @@ func TestPVC_PutAtomicWrite(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/file.bin"
|
||||
key := "files/ch/file.bin"
|
||||
data := []byte("atomic test data")
|
||||
|
||||
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "application/octet-stream")
|
||||
@@ -65,7 +65,7 @@ func TestPVC_PutAtomicWrite(t *testing.T) {
|
||||
}
|
||||
|
||||
// File should exist at final path, no temp files left
|
||||
absPath := filepath.Join(s.basePath, "attachments", "ch")
|
||||
absPath := filepath.Join(s.basePath, "files", "ch")
|
||||
entries, _ := os.ReadDir(absPath)
|
||||
for _, e := range entries {
|
||||
if e.Name() != "file.bin" {
|
||||
@@ -79,7 +79,7 @@ func TestPVC_PutSizeMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
data := []byte("short")
|
||||
err := s.Put(ctx, "attachments/ch/f.txt", bytes.NewReader(data), 999, "text/plain")
|
||||
err := s.Put(ctx, "files/ch/f.txt", bytes.NewReader(data), 999, "text/plain")
|
||||
if err == nil {
|
||||
t.Fatal("expected size mismatch error")
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestPVC_GetNotFound(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt")
|
||||
_, _, _, err := s.Get(ctx, "files/nonexistent/file.txt")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func TestPVC_Delete(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/delete-me.txt"
|
||||
key := "files/ch/delete-me.txt"
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
|
||||
|
||||
err := s.Delete(ctx, key)
|
||||
@@ -118,7 +118,7 @@ func TestPVC_DeleteIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Delete a file that doesn't exist — should not error
|
||||
err := s.Delete(ctx, "attachments/no-such/file.txt")
|
||||
err := s.Delete(ctx, "files/no-such/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Delete nonexistent: %v", err)
|
||||
}
|
||||
@@ -129,18 +129,18 @@ func TestPVC_DeletePrefix(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create several files under a channel prefix
|
||||
prefix := "attachments/channel-abc/"
|
||||
prefix := "files/channel-abc/"
|
||||
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||
key := prefix + name
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||
}
|
||||
|
||||
// Also create a file in a different channel
|
||||
other := "attachments/channel-other/keep.txt"
|
||||
other := "files/channel-other/keep.txt"
|
||||
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
||||
|
||||
// Delete the channel prefix
|
||||
err := s.DeletePrefix(ctx, "attachments/channel-abc")
|
||||
err := s.DeletePrefix(ctx, "files/channel-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePrefix: %v", err)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func TestPVC_DeletePrefixNonexistent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Should not error
|
||||
err := s.DeletePrefix(ctx, "attachments/does-not-exist/")
|
||||
err := s.DeletePrefix(ctx, "files/does-not-exist/")
|
||||
if err != nil {
|
||||
t.Errorf("DeletePrefix nonexistent: %v", err)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func TestPVC_Exists(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/exists.txt"
|
||||
key := "files/ch/exists.txt"
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
|
||||
|
||||
exists, err := s.Exists(ctx, key)
|
||||
@@ -186,7 +186,7 @@ func TestPVC_Exists(t *testing.T) {
|
||||
t.Error("Exists returned false for existing file")
|
||||
}
|
||||
|
||||
exists, err = s.Exists(ctx, "attachments/ch/nope.txt")
|
||||
exists, err = s.Exists(ctx, "files/ch/nope.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Exists (missing): %v", err)
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func TestPVC_Stats(t *testing.T) {
|
||||
|
||||
// Add some files
|
||||
for i := 0; i < 3; i++ {
|
||||
key := filepath.Join("attachments", "ch", string(rune('a'+i))+".txt")
|
||||
key := filepath.Join("files", "ch", string(rune('a'+i))+".txt")
|
||||
data := bytes.Repeat([]byte("x"), 100*(i+1))
|
||||
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func TestPVC_PathTraversal(t *testing.T) {
|
||||
|
||||
bad := []string{
|
||||
"../etc/passwd",
|
||||
"attachments/../../etc/shadow",
|
||||
"files/../../etc/shadow",
|
||||
"/absolute/path",
|
||||
"",
|
||||
}
|
||||
@@ -270,7 +270,7 @@ func TestPVC_SubdirCreation(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
|
||||
// Verify well-known subdirs were created
|
||||
for _, sub := range []string{"attachments", "processing"} {
|
||||
for _, sub := range []string{"files", "processing"} {
|
||||
info, err := os.Stat(filepath.Join(s.basePath, sub))
|
||||
if err != nil {
|
||||
t.Errorf("subdir %q not created: %v", sub, err)
|
||||
|
||||
@@ -269,10 +269,10 @@ func (s *S3Store) Stats(ctx context.Context) (*StorageStats, error) {
|
||||
}
|
||||
stats.Healthy = true
|
||||
|
||||
// Count objects under the attachments prefix
|
||||
attPrefix := s.fullKey("attachments/")
|
||||
// Count objects under the files prefix
|
||||
filePrefix := s.fullKey("files/")
|
||||
objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
|
||||
Prefix: attPrefix,
|
||||
Prefix: filePrefix,
|
||||
Recursive: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ func TestS3_FullKey(t *testing.T) {
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"", "attachments/ch/f.txt", "attachments/ch/f.txt"},
|
||||
{"switchboard/", "attachments/ch/f.txt", "switchboard/attachments/ch/f.txt"},
|
||||
{"prefix", "attachments/ch/f.txt", "prefix/attachments/ch/f.txt"}, // trailing slash added by NewS3
|
||||
{"", "files/ch/f.txt", "files/ch/f.txt"},
|
||||
{"switchboard/", "files/ch/f.txt", "switchboard/files/ch/f.txt"},
|
||||
{"prefix", "files/ch/f.txt", "prefix/files/ch/f.txt"}, // trailing slash added by NewS3
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -68,7 +68,7 @@ func TestS3_KeyValidation(t *testing.T) {
|
||||
"",
|
||||
"../etc/passwd",
|
||||
"/absolute/path",
|
||||
"attachments/../../etc/shadow",
|
||||
"files/../../etc/shadow",
|
||||
}
|
||||
for _, key := range bad {
|
||||
if err := validateKey(key); err == nil {
|
||||
@@ -77,8 +77,8 @@ func TestS3_KeyValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
good := []string{
|
||||
"attachments/ch/f.txt",
|
||||
"attachments/channel-1/att-abc_test.pdf",
|
||||
"files/ch/f.txt",
|
||||
"files/channel-1/att-abc_test.pdf",
|
||||
"processing/abc123/status.json",
|
||||
}
|
||||
for _, key := range good {
|
||||
@@ -133,7 +133,7 @@ func testS3Store(t *testing.T) *S3Store {
|
||||
// Clean up prefix after test
|
||||
t.Cleanup(func() {
|
||||
ctx := context.Background()
|
||||
_ = s.DeletePrefix(ctx, "attachments")
|
||||
_ = s.DeletePrefix(ctx, "files")
|
||||
_ = s.DeletePrefix(ctx, "processing")
|
||||
})
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestS3_Integration_PutGetRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
data := []byte("hello, S3 storage world")
|
||||
key := "attachments/channel-1/att-1_test.txt"
|
||||
key := "files/channel-1/att-1_test.txt"
|
||||
|
||||
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||
if err != nil {
|
||||
@@ -175,7 +175,7 @@ func TestS3_Integration_GetNotFound(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt")
|
||||
_, _, _, err := s.Get(ctx, "files/nonexistent/file.txt")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func TestS3_Integration_Delete(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/delete-me.txt"
|
||||
key := "files/ch/delete-me.txt"
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
|
||||
|
||||
err := s.Delete(ctx, key)
|
||||
@@ -204,7 +204,7 @@ func TestS3_Integration_DeleteIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// S3 delete is inherently idempotent
|
||||
err := s.Delete(ctx, "attachments/no-such/file.txt")
|
||||
err := s.Delete(ctx, "files/no-such/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Delete nonexistent: %v", err)
|
||||
}
|
||||
@@ -214,16 +214,16 @@ func TestS3_Integration_DeletePrefix(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := "attachments/channel-abc/"
|
||||
prefix := "files/channel-abc/"
|
||||
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||
key := prefix + name
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||
}
|
||||
|
||||
other := "attachments/channel-other/keep.txt"
|
||||
other := "files/channel-other/keep.txt"
|
||||
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
||||
|
||||
err := s.DeletePrefix(ctx, "attachments/channel-abc")
|
||||
err := s.DeletePrefix(ctx, "files/channel-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePrefix: %v", err)
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func TestS3_Integration_Exists(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/exists.txt"
|
||||
key := "files/ch/exists.txt"
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
|
||||
|
||||
exists, err := s.Exists(ctx, key)
|
||||
@@ -256,7 +256,7 @@ func TestS3_Integration_Exists(t *testing.T) {
|
||||
t.Error("Exists returned false for existing file")
|
||||
}
|
||||
|
||||
exists, err = s.Exists(ctx, "attachments/ch/nope.txt")
|
||||
exists, err = s.Exists(ctx, "files/ch/nope.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Exists (missing): %v", err)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func TestS3_Integration_Stats(t *testing.T) {
|
||||
|
||||
// Add some files
|
||||
for i := 0; i < 3; i++ {
|
||||
key := "attachments/ch/" + string(rune('a'+i)) + ".txt"
|
||||
key := "files/ch/" + string(rune('a'+i)) + ".txt"
|
||||
data := bytes.Repeat([]byte("x"), 100*(i+1))
|
||||
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func TestS3_Integration_PutUnknownSize(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
data := []byte("unknown size upload")
|
||||
key := "attachments/ch/unknown-size.txt"
|
||||
key := "files/ch/unknown-size.txt"
|
||||
|
||||
// size=0 means unknown — S3 should handle via multipart
|
||||
err := s.Put(ctx, key, bytes.NewReader(data), 0, "text/plain")
|
||||
|
||||
@@ -24,7 +24,7 @@ var (
|
||||
//
|
||||
// Keys are slash-delimited paths relative to the storage root:
|
||||
//
|
||||
// attachments/{channel_id}/{attachment_id}_{filename}
|
||||
// files/{channel_id}/{file_id}_{filename}
|
||||
//
|
||||
// Implementations must create intermediate directories as needed.
|
||||
type ObjectStore interface {
|
||||
@@ -44,7 +44,7 @@ type ObjectStore interface {
|
||||
|
||||
// DeletePrefix removes all objects under the given prefix.
|
||||
// Used for channel deletion (bulk cleanup).
|
||||
// Example: DeletePrefix(ctx, "attachments/{channel_id}/")
|
||||
// Example: DeletePrefix(ctx, "files/{channel_id}/")
|
||||
DeletePrefix(ctx context.Context, prefix string) error
|
||||
|
||||
// Exists checks if an object exists at key without reading it.
|
||||
|
||||
@@ -34,7 +34,7 @@ type Stores struct {
|
||||
Usage UsageStore
|
||||
Pricing PricingStore
|
||||
Extensions ExtensionStore
|
||||
Attachments AttachmentStore
|
||||
Files FileStore
|
||||
KnowledgeBases KnowledgeBaseStore
|
||||
Groups GroupStore
|
||||
ResourceGrants ResourceGrantStore
|
||||
@@ -385,24 +385,27 @@ type ExtensionStore interface {
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// ATTACHMENT STORE
|
||||
// FILE STORE
|
||||
// =========================================
|
||||
|
||||
type AttachmentStore interface {
|
||||
Create(ctx context.Context, a *models.Attachment) error
|
||||
GetByID(ctx context.Context, id string) (*models.Attachment, error)
|
||||
GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error)
|
||||
GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error)
|
||||
GetByProject(ctx context.Context, projectID string) ([]models.Attachment, error) // v0.22.4
|
||||
SetMessageID(ctx context.Context, attachmentID, messageID string) error
|
||||
type FileStore interface {
|
||||
Create(ctx context.Context, f *models.File) error
|
||||
GetByID(ctx context.Context, id string) (*models.File, error)
|
||||
GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error)
|
||||
GetByMessage(ctx context.Context, messageID string) ([]models.File, error)
|
||||
GetByProject(ctx context.Context, projectID string) ([]models.File, error)
|
||||
GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) // paginated, returns total
|
||||
SetMessageID(ctx context.Context, fileID, messageID string) error
|
||||
UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error
|
||||
SetExtractedText(ctx context.Context, id string, text string) error
|
||||
Delete(ctx context.Context, id string) (*models.Attachment, error) // returns deleted row for storage cleanup
|
||||
Delete(ctx context.Context, id string) (*models.File, error) // returns deleted row for storage cleanup
|
||||
DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys
|
||||
UserUsageBytes(ctx context.Context, userID string) (int64, error)
|
||||
ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error)
|
||||
ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =========================================
|
||||
// KNOWLEDGE BASE STORE
|
||||
// =========================================
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
type AttachmentStore struct{}
|
||||
|
||||
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
|
||||
|
||||
// ── columns shared across queries ──────────
|
||||
const attachmentCols = `id, channel_id, user_id, message_id, project_id, filename, content_type,
|
||||
size_bytes, storage_key, extracted_text, metadata, created_at`
|
||||
|
||||
// scanAttachment scans a row into an Attachment struct.
|
||||
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
|
||||
var a models.Attachment
|
||||
var messageID sql.NullString
|
||||
var projectID sql.NullString
|
||||
var extractedText sql.NullString
|
||||
var metadataJSON []byte
|
||||
|
||||
err := row.Scan(
|
||||
&a.ID, &a.ChannelID, &a.UserID, &messageID, &projectID,
|
||||
&a.Filename, &a.ContentType, &a.SizeBytes,
|
||||
&a.StorageKey, &extractedText, &metadataJSON, &a.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.MessageID = NullableStringPtr(messageID)
|
||||
a.ProjectID = NullableStringPtr(projectID)
|
||||
if extractedText.Valid {
|
||||
a.ExtractedText = &extractedText.String
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &a.Metadata)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO attachments (channel_id, user_id, message_id, project_id, filename, content_type,
|
||||
size_bytes, storage_key, extracted_text, metadata)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING id, created_at`,
|
||||
a.ChannelID, a.UserID, models.NullString(a.MessageID),
|
||||
models.NullString(a.ProjectID),
|
||||
a.Filename, a.ContentType, a.SizeBytes,
|
||||
a.StorageKey, models.NullString(a.ExtractedText),
|
||||
ToJSON(a.Metadata),
|
||||
).Scan(&a.ID, &a.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByID(ctx context.Context, id string) (*models.Attachment, error) {
|
||||
row := DB.QueryRowContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE id = $1`, id)
|
||||
return scanAttachment(row)
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE channel_id = $1 ORDER BY created_at`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE message_id = $1 ORDER BY created_at`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetByProject returns all attachments for a project (v0.22.4).
|
||||
func (s *AttachmentStore) GetByProject(ctx context.Context, projectID string) ([]models.Attachment, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE project_id = $1 ORDER BY created_at`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET message_id = $1 WHERE id = $2`,
|
||||
messageID, attachmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
|
||||
// Merge into existing metadata using jsonb || operator
|
||||
metaJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET metadata = metadata || $1::jsonb WHERE id = $2`,
|
||||
metaJSON, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) SetExtractedText(ctx context.Context, id string, text string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET extracted_text = $1 WHERE id = $2`,
|
||||
text, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes an attachment and returns the deleted row (for storage cleanup).
|
||||
func (s *AttachmentStore) Delete(ctx context.Context, id string) (*models.Attachment, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`DELETE FROM attachments WHERE id = $1
|
||||
RETURNING `+attachmentCols, id)
|
||||
return scanAttachment(row)
|
||||
}
|
||||
|
||||
// DeleteByChannel removes all attachments for a channel and returns storage keys.
|
||||
func (s *AttachmentStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`DELETE FROM attachments WHERE channel_id = $1 RETURNING storage_key`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return keys, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
|
||||
var total sql.NullInt64
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM attachments WHERE user_id = $1`,
|
||||
userID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments
|
||||
WHERE message_id IS NULL AND created_at < $1
|
||||
ORDER BY created_at`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
251
server/store/postgres/file.go
Normal file
251
server/store/postgres/file.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// FileStore implements store.FileStore against the `files` table.
|
||||
//
|
||||
type FileStore struct{}
|
||||
|
||||
func NewFileStore() *FileStore { return &FileStore{} }
|
||||
|
||||
const fileCols = `id, channel_id, user_id, message_id, project_id, origin,
|
||||
filename, content_type, size_bytes, storage_key, display_hint,
|
||||
extracted_text, metadata, created_at, updated_at`
|
||||
|
||||
func scanFile(row interface{ Scan(dest ...interface{}) error }) (*models.File, error) {
|
||||
var f models.File
|
||||
var messageID sql.NullString
|
||||
var projectID sql.NullString
|
||||
var extractedText sql.NullString
|
||||
var metadataJSON []byte
|
||||
|
||||
err := row.Scan(
|
||||
&f.ID, &f.ChannelID, &f.UserID, &messageID, &projectID, &f.Origin,
|
||||
&f.Filename, &f.ContentType, &f.SizeBytes,
|
||||
&f.StorageKey, &f.DisplayHint,
|
||||
&extractedText, &metadataJSON, &f.CreatedAt, &f.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.MessageID = NullableStringPtr(messageID)
|
||||
f.ProjectID = NullableStringPtr(projectID)
|
||||
if extractedText.Valid {
|
||||
f.ExtractedText = &extractedText.String
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &f.Metadata)
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) Create(ctx context.Context, f *models.File) error {
|
||||
if f.Origin == "" {
|
||||
f.Origin = models.FileOriginUserUpload
|
||||
}
|
||||
if f.DisplayHint == "" {
|
||||
f.DisplayHint = models.FileHintDownload
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO files (channel_id, user_id, message_id, project_id, origin,
|
||||
filename, content_type, size_bytes, storage_key, display_hint,
|
||||
extracted_text, metadata)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
f.ChannelID, f.UserID, models.NullString(f.MessageID),
|
||||
models.NullString(f.ProjectID), f.Origin,
|
||||
f.Filename, f.ContentType, f.SizeBytes,
|
||||
f.StorageKey, f.DisplayHint, models.NullString(f.ExtractedText),
|
||||
ToJSON(f.Metadata),
|
||||
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByID(ctx context.Context, id string) (*models.File, error) {
|
||||
row := DB.QueryRowContext(ctx, `SELECT `+fileCols+` FROM files WHERE id = $1`, id)
|
||||
return scanFile(row)
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if origin != "" {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE channel_id = $1 AND origin = $2 ORDER BY created_at`,
|
||||
channelID, origin)
|
||||
} else {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE channel_id = $1 ORDER BY created_at`,
|
||||
channelID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByMessage(ctx context.Context, messageID string) ([]models.File, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE message_id = $1 ORDER BY created_at`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByProject(ctx context.Context, projectID string) ([]models.File, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE project_id = $1 ORDER BY created_at`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) {
|
||||
var total int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM files WHERE user_id = $1`, userID).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE user_id = $1
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3`, userID, perPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, total, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) SetMessageID(ctx context.Context, fileID, messageID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE files SET message_id = $1, updated_at = NOW() WHERE id = $2`,
|
||||
messageID, fileID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
|
||||
metaJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx,
|
||||
`UPDATE files SET metadata = metadata || $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
||||
metaJSON, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) SetExtractedText(ctx context.Context, id string, text string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE files SET extracted_text = $1, updated_at = NOW() WHERE id = $2`,
|
||||
text, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) Delete(ctx context.Context, id string) (*models.File, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`DELETE FROM files WHERE id = $1 RETURNING `+fileCols, id)
|
||||
return scanFile(row)
|
||||
}
|
||||
|
||||
func (s *FileStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`DELETE FROM files WHERE channel_id = $1 RETURNING storage_key`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return keys, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
|
||||
var total sql.NullInt64
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM files WHERE user_id = $1`,
|
||||
userID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files
|
||||
WHERE message_id IS NULL AND origin = 'user_upload' AND created_at < $1
|
||||
ORDER BY created_at`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
Extensions: NewExtensionStore(),
|
||||
Attachments: NewAttachmentStore(),
|
||||
Files: NewFileStore(),
|
||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||
Groups: NewGroupStore(),
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type AttachmentStore struct{}
|
||||
|
||||
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
|
||||
|
||||
// ── columns shared across queries ──────────
|
||||
const attachmentCols = `id, channel_id, user_id, message_id, project_id, filename, content_type,
|
||||
size_bytes, storage_key, extracted_text, metadata, created_at`
|
||||
|
||||
// scanAttachment scans a row into an Attachment struct.
|
||||
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
|
||||
var a models.Attachment
|
||||
var messageID sql.NullString
|
||||
var projectID sql.NullString
|
||||
var extractedText sql.NullString
|
||||
var metadataJSON []byte
|
||||
|
||||
err := row.Scan(
|
||||
&a.ID, &a.ChannelID, &a.UserID, &messageID, &projectID,
|
||||
&a.Filename, &a.ContentType, &a.SizeBytes,
|
||||
&a.StorageKey, &extractedText, &metadataJSON, st(&a.CreatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.MessageID = NullableStringPtr(messageID)
|
||||
a.ProjectID = NullableStringPtr(projectID)
|
||||
if extractedText.Valid {
|
||||
a.ExtractedText = &extractedText.String
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &a.Metadata)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error {
|
||||
a.ID = store.NewID()
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO attachments (id, channel_id, user_id, message_id, project_id, filename, content_type,
|
||||
size_bytes, storage_key, extracted_text, metadata, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
a.ID, a.ChannelID, a.UserID, models.NullString(a.MessageID),
|
||||
models.NullString(a.ProjectID),
|
||||
a.Filename, a.ContentType, a.SizeBytes,
|
||||
a.StorageKey, models.NullString(a.ExtractedText),
|
||||
ToJSON(a.Metadata), a.CreatedAt.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByID(ctx context.Context, id string) (*models.Attachment, error) {
|
||||
row := DB.QueryRowContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE id = ?`, id)
|
||||
return scanAttachment(row)
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE channel_id = ? ORDER BY created_at`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE message_id = ? ORDER BY created_at`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetByProject returns all attachments for a project (v0.22.4).
|
||||
func (s *AttachmentStore) GetByProject(ctx context.Context, projectID string) ([]models.Attachment, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE project_id = ? ORDER BY created_at`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET message_id = ? WHERE id = ?`,
|
||||
messageID, attachmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
|
||||
// Merge into existing metadata using jsonb || operator
|
||||
metaJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET metadata = metadata || ? WHERE id = ?`,
|
||||
metaJSON, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) SetExtractedText(ctx context.Context, id string, text string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET extracted_text = ? WHERE id = ?`,
|
||||
text, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes an attachment and returns the deleted row (for storage cleanup).
|
||||
func (s *AttachmentStore) Delete(ctx context.Context, id string) (*models.Attachment, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`DELETE FROM attachments WHERE id = ?
|
||||
RETURNING `+attachmentCols, id)
|
||||
return scanAttachment(row)
|
||||
}
|
||||
|
||||
// DeleteByChannel removes all attachments for a channel and returns storage keys.
|
||||
func (s *AttachmentStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`DELETE FROM attachments WHERE channel_id = ? RETURNING storage_key`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return keys, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
|
||||
var total sql.NullInt64
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM attachments WHERE user_id = ?`,
|
||||
userID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments
|
||||
WHERE message_id IS NULL AND created_at < ?
|
||||
ORDER BY created_at`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
256
server/store/sqlite/file.go
Normal file
256
server/store/sqlite/file.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// FileStore implements store.FileStore against the `files` table.
|
||||
//
|
||||
type FileStore struct{}
|
||||
|
||||
func NewFileStore() *FileStore { return &FileStore{} }
|
||||
|
||||
const fileCols = `id, channel_id, user_id, message_id, project_id, origin,
|
||||
filename, content_type, size_bytes, storage_key, display_hint,
|
||||
extracted_text, metadata, created_at, updated_at`
|
||||
|
||||
func scanFile(row interface{ Scan(dest ...interface{}) error }) (*models.File, error) {
|
||||
var f models.File
|
||||
var messageID sql.NullString
|
||||
var projectID sql.NullString
|
||||
var extractedText sql.NullString
|
||||
var metadataJSON []byte
|
||||
|
||||
err := row.Scan(
|
||||
&f.ID, &f.ChannelID, &f.UserID, &messageID, &projectID, &f.Origin,
|
||||
&f.Filename, &f.ContentType, &f.SizeBytes,
|
||||
&f.StorageKey, &f.DisplayHint,
|
||||
&extractedText, &metadataJSON, st(&f.CreatedAt), st(&f.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.MessageID = NullableStringPtr(messageID)
|
||||
f.ProjectID = NullableStringPtr(projectID)
|
||||
if extractedText.Valid {
|
||||
f.ExtractedText = &extractedText.String
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &f.Metadata)
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) Create(ctx context.Context, f *models.File) error {
|
||||
f.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
f.CreatedAt = now
|
||||
f.UpdatedAt = now
|
||||
if f.Origin == "" {
|
||||
f.Origin = models.FileOriginUserUpload
|
||||
}
|
||||
if f.DisplayHint == "" {
|
||||
f.DisplayHint = models.FileHintDownload
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO files (id, channel_id, user_id, message_id, project_id, origin,
|
||||
filename, content_type, size_bytes, storage_key, display_hint,
|
||||
extracted_text, metadata, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
f.ID, f.ChannelID, f.UserID, models.NullString(f.MessageID),
|
||||
models.NullString(f.ProjectID), f.Origin,
|
||||
f.Filename, f.ContentType, f.SizeBytes,
|
||||
f.StorageKey, f.DisplayHint, models.NullString(f.ExtractedText),
|
||||
ToJSON(f.Metadata), now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByID(ctx context.Context, id string) (*models.File, error) {
|
||||
row := DB.QueryRowContext(ctx, `SELECT `+fileCols+` FROM files WHERE id = ?`, id)
|
||||
return scanFile(row)
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if origin != "" {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE channel_id = ? AND origin = ? ORDER BY created_at`,
|
||||
channelID, origin)
|
||||
} else {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE channel_id = ? ORDER BY created_at`,
|
||||
channelID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByMessage(ctx context.Context, messageID string) ([]models.File, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE message_id = ? ORDER BY created_at`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByProject(ctx context.Context, projectID string) ([]models.File, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE project_id = ? ORDER BY created_at`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) {
|
||||
var total int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM files WHERE user_id = ?`, userID).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files WHERE user_id = ?
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`, userID, perPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, total, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) SetMessageID(ctx context.Context, fileID, messageID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE files SET message_id = ?, updated_at = ? WHERE id = ?`,
|
||||
messageID, time.Now().UTC().Format(timeFmt), fileID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
|
||||
metaJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx,
|
||||
`UPDATE files SET metadata = json_patch(metadata, ?), updated_at = ? WHERE id = ?`,
|
||||
string(metaJSON), time.Now().UTC().Format(timeFmt), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) SetExtractedText(ctx context.Context, id string, text string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE files SET extracted_text = ?, updated_at = ? WHERE id = ?`,
|
||||
text, time.Now().UTC().Format(timeFmt), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileStore) Delete(ctx context.Context, id string) (*models.File, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`DELETE FROM files WHERE id = ? RETURNING `+fileCols, id)
|
||||
return scanFile(row)
|
||||
}
|
||||
|
||||
func (s *FileStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`DELETE FROM files WHERE channel_id = ? RETURNING storage_key`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return keys, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (s *FileStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
|
||||
var total sql.NullInt64
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM files WHERE user_id = ?`,
|
||||
userID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+fileCols+` FROM files
|
||||
WHERE message_id IS NULL AND origin = 'user_upload' AND created_at < ?
|
||||
ORDER BY created_at`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
Extensions: NewExtensionStore(),
|
||||
Attachments: NewAttachmentStore(),
|
||||
Files: NewFileStore(),
|
||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||
Groups: NewGroupStore(),
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
|
||||
@@ -14,52 +14,52 @@ import (
|
||||
)
|
||||
|
||||
// ── Late Registration ────────────────────────
|
||||
// attachment_recall needs stores + objStore, which aren't available at
|
||||
// file_recall needs stores + objStore, which aren't available at
|
||||
// init time. Called from main.go after storage init.
|
||||
// If objStore is nil (storage not configured), the tool is not registered.
|
||||
|
||||
func RegisterAttachmentRecall(stores store.Stores, objStore storage.ObjectStore) {
|
||||
func RegisterFileRecall(stores store.Stores, objStore storage.ObjectStore) {
|
||||
if objStore == nil {
|
||||
log.Printf("ℹ attachment_recall: storage not configured, tool not registered")
|
||||
log.Printf("ℹ file_recall: storage not configured, tool not registered")
|
||||
return
|
||||
}
|
||||
Register(&attachmentRecallTool{
|
||||
Register(&fileRecallTool{
|
||||
stores: stores,
|
||||
objStore: objStore,
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// attachment_recall
|
||||
// file_recall
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images
|
||||
|
||||
type attachmentRecallTool struct {
|
||||
type fileRecallTool struct {
|
||||
stores store.Stores
|
||||
objStore storage.ObjectStore
|
||||
}
|
||||
|
||||
func (t *attachmentRecallTool) Definition() ToolDef {
|
||||
func (t *fileRecallTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "attachment_recall",
|
||||
DisplayName: "Attachments",
|
||||
Name: "file_recall",
|
||||
DisplayName: "Files",
|
||||
Category: "context",
|
||||
Description: "Re-read file attachments from this conversation. " +
|
||||
Description: "Re-read files from this conversation. " +
|
||||
"Use action='list' to see available files, then action='read' " +
|
||||
"with an attachment_id to retrieve the file content. " +
|
||||
"with a file_id to retrieve the file content. " +
|
||||
"Documents return extracted text; images return base64 data.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"action": PropEnum("Action: 'list' to see files, 'read' to get content", "list", "read"),
|
||||
"attachment_id": Prop("string", "Attachment ID to read (required for action='read')"),
|
||||
"file_id": Prop("string", "File ID to read (required for action='read')"),
|
||||
}, []string{"action"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *attachmentRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
func (t *fileRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Action string `json:"action"`
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
FileID string `json:"file_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
@@ -67,22 +67,22 @@ func (t *attachmentRecallTool) Execute(ctx context.Context, execCtx ExecutionCon
|
||||
|
||||
switch args.Action {
|
||||
case "list":
|
||||
return t.listAttachments(ctx, execCtx)
|
||||
return t.listFiles(ctx, execCtx)
|
||||
case "read":
|
||||
if args.AttachmentID == "" {
|
||||
return "", fmt.Errorf("attachment_id is required for action='read'")
|
||||
if args.FileID == "" {
|
||||
return "", fmt.Errorf("file_id is required for action='read'")
|
||||
}
|
||||
return t.readAttachment(ctx, execCtx, args.AttachmentID)
|
||||
return t.readFile(ctx, execCtx, args.FileID)
|
||||
default:
|
||||
return "", fmt.Errorf("invalid action: %q (use 'list' or 'read')", args.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// listAttachments returns metadata for all attachments in the channel.
|
||||
func (t *attachmentRecallTool) listAttachments(ctx context.Context, execCtx ExecutionContext) (string, error) {
|
||||
atts, err := t.stores.Attachments.GetByChannel(ctx, execCtx.ChannelID)
|
||||
// listFiles returns metadata for all files in the channel.
|
||||
func (t *fileRecallTool) listFiles(ctx context.Context, execCtx ExecutionContext) (string, error) {
|
||||
atts, err := t.stores.Files.GetByChannel(ctx, execCtx.ChannelID, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list attachments: %w", err)
|
||||
return "", fmt.Errorf("failed to list files: %w", err)
|
||||
}
|
||||
|
||||
type attInfo struct {
|
||||
@@ -106,32 +106,32 @@ func (t *attachmentRecallTool) listAttachments(ctx context.Context, execCtx Exec
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("📎 attachment_recall: list → %d files in channel %s", len(items), execCtx.ChannelID)
|
||||
log.Printf("📎 file_recall: list → %d files in channel %s", len(items), execCtx.ChannelID)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"attachments": items,
|
||||
"files": items,
|
||||
"count": len(items),
|
||||
"channel_id": execCtx.ChannelID,
|
||||
})
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// readAttachment returns the content of a specific attachment.
|
||||
func (t *attachmentRecallTool) readAttachment(ctx context.Context, execCtx ExecutionContext, attachmentID string) (string, error) {
|
||||
att, err := t.stores.Attachments.GetByID(ctx, attachmentID)
|
||||
// readFile returns the content of a specific file.
|
||||
func (t *fileRecallTool) readFile(ctx context.Context, execCtx ExecutionContext, fileID string) (string, error) {
|
||||
att, err := t.stores.Files.GetByID(ctx, fileID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("attachment not found: %s", attachmentID)
|
||||
return "", fmt.Errorf("file not found: %s", fileID)
|
||||
}
|
||||
|
||||
// Security: verify attachment belongs to this channel
|
||||
// Security: verify file belongs to this channel
|
||||
if att.ChannelID != execCtx.ChannelID {
|
||||
return "", fmt.Errorf("attachment %s does not belong to this conversation", attachmentID)
|
||||
return "", fmt.Errorf("file %s does not belong to this conversation", fileID)
|
||||
}
|
||||
|
||||
// Documents: return extracted text
|
||||
if !isImageType(att.ContentType) {
|
||||
if att.ExtractedText != nil && *att.ExtractedText != "" {
|
||||
log.Printf("📎 attachment_recall: read text → %s (%s, %d chars)",
|
||||
log.Printf("📎 file_recall: read text → %s (%s, %d chars)",
|
||||
att.Filename, att.ID, len(*att.ExtractedText))
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"id": att.ID,
|
||||
@@ -153,20 +153,20 @@ func (t *attachmentRecallTool) readAttachment(ctx context.Context, execCtx Execu
|
||||
|
||||
reader, size, _, err := t.objStore.Get(ctx, att.StorageKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read attachment from storage: %w", err)
|
||||
return "", fmt.Errorf("failed to read file from storage: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Read and base64-encode
|
||||
data := make([]byte, size)
|
||||
if _, err := io.ReadFull(reader, data); err != nil {
|
||||
return "", fmt.Errorf("failed to read attachment data: %w", err)
|
||||
return "", fmt.Errorf("failed to read file data: %w", err)
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
|
||||
|
||||
log.Printf("📎 attachment_recall: read image → %s (%s, %d bytes)",
|
||||
log.Printf("📎 file_recall: read image → %s (%s, %d bytes)",
|
||||
att.Filename, att.ID, size)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
Reference in New Issue
Block a user