step 3: gut store interfaces and models
- interfaces.go: 1449 → 437 lines (13 kernel interfaces from 33) - models.go: 1137 → 397 lines (17 kernel types from 64) - Deleted 28 store impl files per dialect (PG + SQLite) - Deleted 3 model files (git, memory, workspace) - Deleted 3 store iface files (project, memory, tree_types) - Rewrote stores.go constructors for both PG and SQLite - Stores struct: 20 fields (from 40+, includes separate iface stores) 66 files changed, ~19K lines removed. Build will break — expected, fixed in steps 4-5.
This commit is contained in:
@@ -23,52 +23,6 @@ const (
|
|||||||
ScopePersonal = "personal"
|
ScopePersonal = "personal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Visibility Constants ────────────────────
|
|
||||||
|
|
||||||
const (
|
|
||||||
VisibilityVisible = "visible"
|
|
||||||
VisibilityHidden = "hidden"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Role Constants ──────────────────────────
|
|
||||||
|
|
||||||
const (
|
|
||||||
UserRoleUser = "user"
|
|
||||||
UserRoleAdmin = "admin"
|
|
||||||
|
|
||||||
TeamRoleAdmin = "admin"
|
|
||||||
TeamRoleMember = "member"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Grant Type Constants ────────────────────
|
|
||||||
|
|
||||||
const (
|
|
||||||
GrantTypeTool = "tool"
|
|
||||||
GrantTypeKnowledgeBase = "knowledge_base"
|
|
||||||
GrantTypeAPIEndpoint = "api_endpoint"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Channel Type Constants ──────────────────
|
|
||||||
|
|
||||||
const (
|
|
||||||
ChannelTypeDirect = "direct"
|
|
||||||
ChannelTypeGroup = "group"
|
|
||||||
ChannelTypeChannel = "channel"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Message Role Constants ──────────────────
|
|
||||||
|
|
||||||
const (
|
|
||||||
MessageRoleUser = "user"
|
|
||||||
MessageRoleAssistant = "assistant"
|
|
||||||
MessageRoleSystem = "system"
|
|
||||||
MessageRoleTool = "tool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// USERS
|
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
Username string `json:"username" db:"username"`
|
Username string `json:"username" db:"username"`
|
||||||
@@ -113,146 +67,21 @@ type TeamMember struct {
|
|||||||
UserRole string `json:"user_role,omitempty"`
|
UserRole string `json:"user_role,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// PROVIDER CONFIGS (replaces APIConfig)
|
// PROVIDER CONFIGS (replaces APIConfig)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type ProviderConfig struct {
|
|
||||||
BaseModel
|
|
||||||
Scope string `json:"scope" db:"scope"`
|
|
||||||
OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
Provider string `json:"provider" db:"provider"`
|
|
||||||
Endpoint string `json:"endpoint" db:"endpoint"`
|
|
||||||
APIKeyEnc []byte `json:"-" db:"api_key_enc"`
|
|
||||||
KeyNonce []byte `json:"-" db:"key_nonce"`
|
|
||||||
KeyScope string `json:"-" db:"key_scope"`
|
|
||||||
ModelDefault string `json:"model_default,omitempty" db:"model_default"`
|
|
||||||
Config JSONMap `json:"config,omitempty" db:"config"`
|
|
||||||
Headers JSONMap `json:"headers,omitempty" db:"headers"`
|
|
||||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
|
||||||
IsActive bool `json:"is_active" db:"is_active"`
|
|
||||||
IsPrivate bool `json:"is_private" db:"is_private"`
|
|
||||||
ProxyMode string `json:"proxy_mode" db:"proxy_mode"`
|
|
||||||
ProxyURL *string `json:"proxy_url,omitempty" db:"proxy_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasKey returns true if an encrypted API key is stored.
|
// HasKey returns true if an encrypted API key is stored.
|
||||||
func (p *ProviderConfig) HasKey() bool {
|
func (p *ProviderConfig) HasKey() bool {
|
||||||
return len(p.APIKeyEnc) > 0
|
return len(p.APIKeyEnc) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderConfigPatch struct {
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
Endpoint *string `json:"endpoint,omitempty"`
|
|
||||||
APIKeyEnc []byte `json:"-"`
|
|
||||||
KeyNonce []byte `json:"-"`
|
|
||||||
ModelDefault *string `json:"model_default,omitempty"`
|
|
||||||
Config JSONMap `json:"config,omitempty"`
|
|
||||||
Headers JSONMap `json:"headers,omitempty"`
|
|
||||||
Settings JSONMap `json:"settings,omitempty"`
|
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
|
||||||
IsPrivate *bool `json:"is_private,omitempty"`
|
|
||||||
ProxyMode *string `json:"proxy_mode,omitempty"`
|
|
||||||
ProxyURL *string `json:"proxy_url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// MODEL CATALOG (replaces model_configs)
|
// MODEL CATALOG (replaces model_configs)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type CatalogEntry struct {
|
|
||||||
BaseModel
|
|
||||||
ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"`
|
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
|
||||||
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
|
||||||
ModelType string `json:"model_type" db:"model_type"` // "chat", "embedding", "image", etc.
|
|
||||||
Capabilities ModelCapabilities `json:"capabilities" db:"capabilities"`
|
|
||||||
Pricing *ModelPricing `json:"pricing,omitempty" db:"pricing"`
|
|
||||||
Visibility string `json:"visibility" db:"visibility"`
|
|
||||||
LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelCapabilities struct {
|
|
||||||
Streaming bool `json:"streaming"`
|
|
||||||
ToolCalling bool `json:"tool_calling"`
|
|
||||||
Vision bool `json:"vision"`
|
|
||||||
Thinking bool `json:"thinking"`
|
|
||||||
Reasoning bool `json:"reasoning"`
|
|
||||||
CodeOptimized bool `json:"code_optimized"`
|
|
||||||
WebSearch bool `json:"web_search"`
|
|
||||||
MaxContext int `json:"max_context"`
|
|
||||||
MaxOutputTokens int `json:"max_output_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c ModelCapabilities) HasProviderData() bool {
|
func (c ModelCapabilities) HasProviderData() bool {
|
||||||
return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning ||
|
return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning ||
|
||||||
c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0
|
c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelPricing struct {
|
|
||||||
InputPerM float64 `json:"input_per_m,omitempty"`
|
|
||||||
OutputPerM float64 `json:"output_per_m,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// PERSONAS (replaces ModelPreset)
|
// PERSONAS (replaces ModelPreset)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type Persona struct {
|
|
||||||
BaseModel
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
Handle string `json:"handle,omitempty" db:"handle"`
|
|
||||||
Description string `json:"description,omitempty" db:"description"`
|
|
||||||
Icon string `json:"icon,omitempty" db:"icon"`
|
|
||||||
Avatar string `json:"avatar,omitempty" db:"avatar"`
|
|
||||||
|
|
||||||
BaseModelID string `json:"base_model_id" db:"base_model_id"`
|
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
|
||||||
|
|
||||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
|
||||||
Temperature *float64 `json:"temperature,omitempty" db:"temperature"`
|
|
||||||
MaxTokens *int `json:"max_tokens,omitempty" db:"max_tokens"`
|
|
||||||
ThinkingBudget *int `json:"thinking_budget,omitempty" db:"thinking_budget"`
|
|
||||||
TopP *float64 `json:"top_p,omitempty" db:"top_p"`
|
|
||||||
|
|
||||||
Scope string `json:"scope" db:"scope"`
|
|
||||||
OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
|
|
||||||
CreatedBy string `json:"created_by" db:"created_by"`
|
|
||||||
|
|
||||||
IsActive bool `json:"is_active" db:"is_active"`
|
|
||||||
IsShared bool `json:"is_shared" db:"is_shared"`
|
|
||||||
|
|
||||||
// Loaded from persona_grants, not stored in personas table
|
|
||||||
Grants []Grant `json:"grants,omitempty" db:"-"`
|
|
||||||
|
|
||||||
// Loaded from persona_knowledge_bases, not stored in personas table
|
|
||||||
KBIDs []string `json:"kb_ids,omitempty" db:"-"`
|
|
||||||
|
|
||||||
// Memory configuration (v0.18.0)
|
|
||||||
MemoryEnabled bool `json:"memory_enabled" db:"memory_enabled"`
|
|
||||||
MemoryExtractionPrompt *string `json:"memory_extraction_prompt,omitempty" db:"memory_extraction_prompt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PersonaPatch struct {
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
Handle *string `json:"handle,omitempty"`
|
|
||||||
Description *string `json:"description,omitempty"`
|
|
||||||
Icon *string `json:"icon,omitempty"`
|
|
||||||
Avatar *string `json:"avatar,omitempty"`
|
|
||||||
BaseModelID *string `json:"base_model_id,omitempty"`
|
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
|
||||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
|
||||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
|
||||||
ThinkingBudget *int `json:"thinking_budget,omitempty"`
|
|
||||||
TopP *float64 `json:"top_p,omitempty"`
|
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
|
||||||
IsShared *bool `json:"is_shared,omitempty"`
|
|
||||||
MemoryEnabled *bool `json:"memory_enabled,omitempty"`
|
|
||||||
MemoryExtractionPrompt *string `json:"memory_extraction_prompt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// GRANTS
|
// GRANTS
|
||||||
@@ -267,9 +96,7 @@ type Grant struct {
|
|||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// PLATFORM POLICIES
|
// PLATFORM POLICIES
|
||||||
// =========================================
|
|
||||||
|
|
||||||
var PolicyDefaults = map[string]string{
|
var PolicyDefaults = map[string]string{
|
||||||
"allow_user_byok": "false",
|
"allow_user_byok": "false",
|
||||||
@@ -281,161 +108,25 @@ var PolicyDefaults = map[string]string{
|
|||||||
"default_model": "",
|
"default_model": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// USER MODEL SETTINGS
|
// USER MODEL SETTINGS
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type UserModelSetting struct {
|
|
||||||
BaseModel
|
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
|
||||||
Hidden bool `json:"hidden" db:"hidden"`
|
|
||||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"`
|
|
||||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"`
|
|
||||||
SortOrder int `json:"sort_order" db:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserModelSettingPatch struct {
|
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
|
||||||
Hidden *bool `json:"hidden,omitempty"`
|
|
||||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
|
||||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
|
||||||
SortOrder *int `json:"sort_order,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HiddenEntry identifies a model+provider pair for bulk visibility operations.
|
// HiddenEntry identifies a model+provider pair for bulk visibility operations.
|
||||||
type HiddenEntry struct {
|
|
||||||
ModelID string `json:"model_id"`
|
|
||||||
ProviderConfigID string `json:"provider_config_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompositeModelKey builds the composite key used for per-provider model preferences.
|
// CompositeModelKey builds the composite key used for per-provider model preferences.
|
||||||
func CompositeModelKey(providerConfigID, modelID string) string {
|
func CompositeModelKey(providerConfigID, modelID string) string {
|
||||||
return providerConfigID + ":" + modelID
|
return providerConfigID + ":" + modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// CHANNELS
|
// CHANNELS
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type Channel struct {
|
|
||||||
BaseModel
|
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
|
||||||
Title string `json:"title" db:"title"`
|
|
||||||
Description string `json:"description,omitempty" db:"description"`
|
|
||||||
Type string `json:"type" db:"type"`
|
|
||||||
Model string `json:"model,omitempty" db:"model"`
|
|
||||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
|
||||||
IsArchived bool `json:"is_archived" db:"is_archived"`
|
|
||||||
IsPinned bool `json:"is_pinned" db:"is_pinned"`
|
|
||||||
PurgeAfter *time.Time `json:"purge_after,omitempty" db:"purge_after"`
|
|
||||||
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
|
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
|
||||||
ProjectID *string `json:"project_id,omitempty" db:"project_id"`
|
|
||||||
WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"`
|
|
||||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
|
||||||
AllowAnonymous bool `json:"allow_anonymous" db:"allow_anonymous"` // v0.24.3: session participants can join
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// SESSION PARTICIPANTS (v0.24.3)
|
// SESSION PARTICIPANTS (v0.24.3)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// SessionParticipant is an ephemeral identity for anonymous workflow
|
// SessionParticipant is an ephemeral identity for anonymous workflow
|
||||||
// channel visitors. Scoped to a single channel, no users row required.
|
// channel visitors. Scoped to a single channel, no users row required.
|
||||||
type SessionParticipant struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
SessionToken string `json:"session_token" db:"session_token"`
|
|
||||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
|
||||||
DisplayName string `json:"display_name" db:"display_name"`
|
|
||||||
Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// MESSAGES
|
// MESSAGES
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
BaseModel
|
|
||||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
|
||||||
Role string `json:"role" db:"role"`
|
|
||||||
Content string `json:"content" db:"content"`
|
|
||||||
Model string `json:"model,omitempty" db:"model"`
|
|
||||||
TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"`
|
|
||||||
ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"`
|
|
||||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
|
||||||
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
|
|
||||||
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
|
|
||||||
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
|
|
||||||
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
|
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// CHANNEL PARTICIPANTS, MODELS, CURSORS
|
// CHANNEL PARTICIPANTS, MODELS, CURSORS
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type ChannelParticipant struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
|
||||||
ParticipantType string `json:"participant_type" db:"participant_type"`
|
|
||||||
ParticipantID string `json:"participant_id" db:"participant_id"`
|
|
||||||
Role string `json:"role" db:"role"`
|
|
||||||
DisplayName *string `json:"display_name,omitempty" db:"display_name"`
|
|
||||||
AvatarURL *string `json:"avatar_url,omitempty" db:"avatar_url"`
|
|
||||||
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
|
|
||||||
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChannelModel struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
|
||||||
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
|
||||||
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
|
||||||
Handle string `json:"handle,omitempty" db:"handle"`
|
|
||||||
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
|
||||||
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
|
|
||||||
IsDefault bool `json:"is_default" db:"is_default"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChannelCursor struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
|
||||||
ActiveLeafID *string `json:"active_leaf_id,omitempty" db:"active_leaf_id"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// PERSONA GROUPS (v0.23.0)
|
// PERSONA GROUPS (v0.23.0)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type PersonaGroup struct {
|
|
||||||
BaseModel
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
Description string `json:"description,omitempty" db:"description"`
|
|
||||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
|
||||||
Scope string `json:"scope" db:"scope"`
|
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
|
||||||
Members []PersonaGroupMember `json:"members" db:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PersonaGroupMember struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
GroupID string `json:"group_id" db:"group_id"`
|
|
||||||
PersonaID string `json:"persona_id" db:"persona_id"`
|
|
||||||
IsLeader bool `json:"is_leader" db:"is_leader"`
|
|
||||||
SortOrder int `json:"sort_order" db:"sort_order"`
|
|
||||||
// Joined from personas table for API responses
|
|
||||||
PersonaName string `json:"persona_name,omitempty" db:"-"`
|
|
||||||
PersonaHandle string `json:"persona_handle,omitempty" db:"-"`
|
|
||||||
PersonaAvatar string `json:"persona_avatar,omitempty" db:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleFromName generates a URL-safe @mention handle from a display name.
|
// HandleFromName generates a URL-safe @mention handle from a display name.
|
||||||
// "Veronica Sharpe" → "veronica-sharpe"
|
// "Veronica Sharpe" → "veronica-sharpe"
|
||||||
@@ -461,190 +152,26 @@ func HandleFromName(name string) string {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// ORGANIZATION
|
// ORGANIZATION
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type Folder struct {
|
|
||||||
BaseModel
|
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
|
|
||||||
SortOrder int `json:"sort_order" db:"sort_order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Project struct {
|
|
||||||
BaseModel
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
Description string `json:"description,omitempty" db:"description"`
|
|
||||||
Color *string `json:"color,omitempty" db:"color"`
|
|
||||||
Icon *string `json:"icon,omitempty" db:"icon"`
|
|
||||||
Scope string `json:"scope" db:"scope"` // personal, team, global
|
|
||||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
|
||||||
IsArchived bool `json:"is_archived" db:"is_archived"`
|
|
||||||
WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"`
|
|
||||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
|
||||||
|
|
||||||
// Computed fields (not DB columns)
|
|
||||||
ChannelCount int `json:"channel_count,omitempty"`
|
|
||||||
KBCount int `json:"kb_count,omitempty"`
|
|
||||||
NoteCount int `json:"note_count,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectPatch holds optional fields for updating a project.
|
// ProjectPatch holds optional fields for updating a project.
|
||||||
type ProjectPatch struct {
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
Description *string `json:"description,omitempty"`
|
|
||||||
Color *string `json:"color,omitempty"`
|
|
||||||
Icon *string `json:"icon,omitempty"`
|
|
||||||
IsArchived *bool `json:"is_archived,omitempty"`
|
|
||||||
WorkspaceID *string `json:"workspace_id,omitempty"`
|
|
||||||
Settings JSONMap `json:"settings,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectChannel represents a channel's membership in a project.
|
// ProjectChannel represents a channel's membership in a project.
|
||||||
type ProjectChannel struct {
|
|
||||||
ProjectID string `json:"project_id" db:"project_id"`
|
|
||||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
|
||||||
Position int `json:"position" db:"position"`
|
|
||||||
Folder string `json:"folder,omitempty" db:"folder"`
|
|
||||||
AddedAt string `json:"added_at" db:"added_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectKB represents a KB's association with a project.
|
// ProjectKB represents a KB's association with a project.
|
||||||
type ProjectKB struct {
|
|
||||||
ProjectID string `json:"project_id" db:"project_id"`
|
|
||||||
KBID string `json:"kb_id" db:"kb_id"`
|
|
||||||
AutoSearch bool `json:"auto_search" db:"auto_search"`
|
|
||||||
AddedAt string `json:"added_at" db:"added_at"`
|
|
||||||
Name string `json:"name,omitempty" db:"name"` // enriched via JOIN
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectNote represents a note's association with a project.
|
// ProjectNote represents a note's association with a project.
|
||||||
type ProjectNote struct {
|
|
||||||
ProjectID string `json:"project_id" db:"project_id"`
|
|
||||||
NoteID string `json:"note_id" db:"note_id"`
|
|
||||||
AddedAt string `json:"added_at" db:"added_at"`
|
|
||||||
Title string `json:"title,omitempty" db:"title"` // enriched via JOIN
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// NOTES
|
// NOTES
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type Note struct {
|
|
||||||
BaseModel
|
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
|
||||||
Title string `json:"title" db:"title"`
|
|
||||||
Content string `json:"content" db:"content"`
|
|
||||||
FolderPath string `json:"folder_path" db:"folder_path"`
|
|
||||||
Tags []string `json:"tags,omitempty" db:"tags"`
|
|
||||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
|
||||||
SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"`
|
|
||||||
SourceMessageID *string `json:"source_message_id,omitempty" db:"source_message_id"`
|
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteLink represents a directed link extracted from [[wikilink]] syntax.
|
// NoteLink represents a directed link extracted from [[wikilink]] syntax.
|
||||||
type NoteLink struct {
|
|
||||||
TargetNoteID *string `json:"target_note_id,omitempty"`
|
|
||||||
TargetTitle string `json:"target_title"`
|
|
||||||
DisplayText string `json:"display_text,omitempty"`
|
|
||||||
IsTransclusion bool `json:"is_transclusion"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportNoteLink is a note_link with source_note_id included (for export).
|
// ExportNoteLink is a note_link with source_note_id included (for export).
|
||||||
type ExportNoteLink struct {
|
|
||||||
SourceNoteID string `json:"source_note_id" db:"source_note_id"`
|
|
||||||
TargetNoteID *string `json:"target_note_id,omitempty" db:"target_note_id"`
|
|
||||||
TargetTitle string `json:"target_title" db:"target_title"`
|
|
||||||
DisplayText string `json:"display_text,omitempty" db:"display_text"`
|
|
||||||
IsTransclusion bool `json:"is_transclusion" db:"is_transclusion"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteLinkResult represents a backlink — a note that links to a given note.
|
// NoteLinkResult represents a backlink — a note that links to a given note.
|
||||||
type NoteLinkResult struct {
|
|
||||||
SourceNoteID string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
FolderPath string `json:"folder_path"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DisplayText string `json:"display_text,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteGraphNode is a lightweight note representation for graph display.
|
// NoteGraphNode is a lightweight note representation for graph display.
|
||||||
type NoteGraphNode struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
FolderPath string `json:"folder_path"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
LinkCount int `json:"link_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteGraphEdge is a resolved link between two notes.
|
// NoteGraphEdge is a resolved link between two notes.
|
||||||
type NoteGraphEdge struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
IsTransclusion bool `json:"is_transclusion"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteGraphDangling is an unresolved [[link]] reference.
|
// NoteGraphDangling is an unresolved [[link]] reference.
|
||||||
type NoteGraphDangling struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteGraph is the full graph topology for a user's notes.
|
// NoteGraph is the full graph topology for a user's notes.
|
||||||
type NoteGraph struct {
|
|
||||||
Nodes []NoteGraphNode `json:"nodes"`
|
|
||||||
Edges []NoteGraphEdge `json:"edges"`
|
|
||||||
Unresolved []NoteGraphDangling `json:"unresolved"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// ATTACHMENTS
|
// ATTACHMENTS
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// FILES
|
// FILES
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// FileOrigin constants
|
// 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
|
// AUDIT LOG
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -662,111 +189,14 @@ type AuditEntry struct {
|
|||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// USAGE TRACKING
|
// USAGE TRACKING
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type UsageEntry struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
ChannelID *string `json:"channel_id,omitempty" db:"channel_id"`
|
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
|
||||||
ProviderScope string `json:"provider_scope" db:"provider_scope"`
|
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
|
||||||
Role *string `json:"role,omitempty" db:"role"` // null=chat, "utility", "embedding"
|
|
||||||
PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens" db:"completion_tokens"`
|
|
||||||
CacheCreationTokens int `json:"cache_creation_tokens" db:"cache_creation_tokens"`
|
|
||||||
CacheReadTokens int `json:"cache_read_tokens" db:"cache_read_tokens"`
|
|
||||||
CostInput *float64 `json:"cost_input,omitempty" db:"cost_input"`
|
|
||||||
CostOutput *float64 `json:"cost_output,omitempty" db:"cost_output"`
|
|
||||||
RoutingDecision JSONMap `json:"routing_decision,omitempty" db:"routing_decision"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageAggregate struct {
|
|
||||||
GroupKey string `json:"group_key"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Requests int `json:"requests"`
|
|
||||||
InputTokens int `json:"input_tokens"`
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
TotalCost float64 `json:"total_cost"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageTotals struct {
|
|
||||||
Requests int `json:"requests"`
|
|
||||||
InputTokens int `json:"input_tokens"`
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
TotalCost float64 `json:"total_cost"`
|
|
||||||
Period string `json:"period"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// MODEL PRICING
|
// MODEL PRICING
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type PricingEntry struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"`
|
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
|
||||||
InputPerM *float64 `json:"input_per_m" db:"input_per_m"`
|
|
||||||
OutputPerM *float64 `json:"output_per_m" db:"output_per_m"`
|
|
||||||
CacheCreatePerM *float64 `json:"cache_create_per_m" db:"cache_create_per_m"`
|
|
||||||
CacheReadPerM *float64 `json:"cache_read_per_m" db:"cache_read_per_m"`
|
|
||||||
Currency string `json:"currency" db:"currency"`
|
|
||||||
Source string `json:"source" db:"source"` // "catalog" | "manual"
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
UpdatedBy *string `json:"updated_by,omitempty" db:"updated_by"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// VIEW MODELS (computed, not stored)
|
// VIEW MODELS (computed, not stored)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// UserModel is the view model returned by the capability resolver.
|
// UserModel is the view model returned by the capability resolver.
|
||||||
// Combines catalog entries + Personas for the frontend.
|
// Combines catalog entries + Personas for the frontend.
|
||||||
type UserModel struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
ModelID string `json:"model_id"`
|
|
||||||
ModelType string `json:"model_type"` // "chat", "embedding", "image", etc.
|
|
||||||
Source string `json:"source"` // "catalog", "persona", "live"
|
|
||||||
|
|
||||||
ProviderConfigID string `json:"provider_config_id"`
|
|
||||||
ConfigID string `json:"config_id"` // Alias of ProviderConfigID for frontend compat
|
|
||||||
ProviderName string `json:"provider_name"`
|
|
||||||
ProviderType string `json:"provider_type"`
|
|
||||||
|
|
||||||
Capabilities ModelCapabilities `json:"capabilities"`
|
|
||||||
|
|
||||||
// Persona fields — always emitted so frontend can branch on is_persona.
|
|
||||||
IsPersona bool `json:"is_persona"`
|
|
||||||
PersonaID string `json:"persona_id,omitempty"`
|
|
||||||
PersonaHandle string `json:"persona_handle,omitempty"`
|
|
||||||
PersonaScope string `json:"persona_scope,omitempty"`
|
|
||||||
PersonaAvatar string `json:"persona_avatar,omitempty"`
|
|
||||||
PersonaTeamName string `json:"persona_team_name,omitempty"`
|
|
||||||
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Icon string `json:"icon,omitempty"`
|
|
||||||
Avatar string `json:"avatar,omitempty"`
|
|
||||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
|
||||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
|
||||||
ToolGrants []string `json:"tool_grants,omitempty"`
|
|
||||||
|
|
||||||
Pricing *ModelPricing `json:"pricing,omitempty"`
|
|
||||||
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
OwnerID *string `json:"owner_id,omitempty"`
|
|
||||||
TeamName string `json:"team_name,omitempty"`
|
|
||||||
|
|
||||||
Hidden bool `json:"hidden"`
|
|
||||||
SortOrder int `json:"sort_order"`
|
|
||||||
|
|
||||||
ProviderStatus ProviderStatus `json:"provider_status,omitempty"` // health status from provider health (v0.22.3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// JSON HELPERS
|
// JSON HELPERS
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -796,16 +226,7 @@ func (m *JSONMap) Scan(src interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extensions ──────────────────────────────
|
|
||||||
|
|
||||||
// Extension tier constants
|
// Extension tier constants
|
||||||
const (
|
|
||||||
ExtTierBrowser = "browser"
|
|
||||||
ExtTierStarlark = "starlark"
|
|
||||||
ExtTierSidecar = "sidecar"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Extension represents an installed extension in the registry.
|
|
||||||
type Extension struct {
|
type Extension struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
ExtID string `json:"ext_id" db:"ext_id"` // manifest id
|
ExtID string `json:"ext_id" db:"ext_id"` // manifest id
|
||||||
@@ -937,114 +358,15 @@ type ResourceGrant struct {
|
|||||||
CreatedBy string `json:"created_by" db:"created_by"`
|
CreatedBy string `json:"created_by" db:"created_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Knowledge Bases ────────────────────────────
|
|
||||||
|
|
||||||
// KnowledgeBase is a named collection of documents with vector embeddings.
|
// KnowledgeBase is a named collection of documents with vector embeddings.
|
||||||
type KnowledgeBase struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
Description string `json:"description" db:"description"`
|
|
||||||
Scope string `json:"scope" db:"scope"` // global, team, personal
|
|
||||||
OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
|
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
|
||||||
EmbeddingConfig JSONMap `json:"embedding_config" db:"embedding_config"`
|
|
||||||
DocumentCount int `json:"document_count" db:"document_count"`
|
|
||||||
ChunkCount int `json:"chunk_count" db:"chunk_count"`
|
|
||||||
TotalBytes int64 `json:"total_bytes" db:"total_bytes"`
|
|
||||||
Discoverable bool `json:"discoverable" db:"discoverable"`
|
|
||||||
Status string `json:"status" db:"status"` // active, processing, error
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PersonaKB binds a knowledge base to a persona.
|
// PersonaKB binds a knowledge base to a persona.
|
||||||
type PersonaKB struct {
|
|
||||||
PersonaID string `json:"persona_id" db:"persona_id"`
|
|
||||||
KBID string `json:"kb_id" db:"kb_id"`
|
|
||||||
AutoSearch bool `json:"auto_search" db:"auto_search"`
|
|
||||||
AddedAt time.Time `json:"added_at" db:"added_at"`
|
|
||||||
|
|
||||||
// Joined fields (not in persona_knowledge_bases table)
|
|
||||||
KBName string `json:"kb_name,omitempty" db:"kb_name"`
|
|
||||||
DocumentCount int `json:"document_count,omitempty" db:"document_count"`
|
|
||||||
ChunkCount int `json:"chunk_count,omitempty" db:"chunk_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// KBDocument is a single uploaded file within a knowledge base.
|
// KBDocument is a single uploaded file within a knowledge base.
|
||||||
type KBDocument struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
KBID string `json:"kb_id" db:"kb_id"`
|
|
||||||
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:"storage_key" db:"storage_key"`
|
|
||||||
ExtractedText *string `json:"-" db:"extracted_text"`
|
|
||||||
ChunkCount int `json:"chunk_count" db:"chunk_count"`
|
|
||||||
Status string `json:"status" db:"status"` // pending, chunking, embedding, ready, error
|
|
||||||
Error *string `json:"error,omitempty" db:"error"`
|
|
||||||
UploadedBy string `json:"uploaded_by" db:"uploaded_by"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// KBChunk is a text segment from a document with its embedding vector.
|
// KBChunk is a text segment from a document with its embedding vector.
|
||||||
type KBChunk struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
KBID string `json:"kb_id" db:"kb_id"`
|
|
||||||
DocumentID string `json:"document_id" db:"document_id"`
|
|
||||||
ChunkIndex int `json:"chunk_index" db:"chunk_index"`
|
|
||||||
Content string `json:"content" db:"content"`
|
|
||||||
TokenCount int `json:"token_count" db:"token_count"`
|
|
||||||
Embedding []float64 `json:"-" db:"embedding"` // never serialized to API
|
|
||||||
Metadata JSONMap `json:"metadata" db:"metadata"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// KBSearchResult is a single result from similarity search.
|
// KBSearchResult is a single result from similarity search.
|
||||||
type KBSearchResult struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
Filename string `json:"source"`
|
|
||||||
KBName string `json:"kb"`
|
|
||||||
Similarity float64 `json:"similarity"`
|
|
||||||
Metadata JSONMap `json:"metadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelKB represents a knowledge base linked to a channel.
|
// ChannelKB represents a knowledge base linked to a channel.
|
||||||
type ChannelKB struct {
|
|
||||||
KBID string `json:"kb_id" db:"kb_id"`
|
|
||||||
KBName string `json:"kb_name" db:"name"`
|
|
||||||
Enabled bool `json:"enabled" db:"enabled"`
|
|
||||||
DocumentCount int `json:"document_count" db:"document_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// PROVIDER HEALTH (v0.22.0)
|
// PROVIDER HEALTH (v0.22.0)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// ProviderStatus represents the derived health state.
|
// ProviderStatus represents the derived health state.
|
||||||
type ProviderStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusHealthy ProviderStatus = "healthy"
|
|
||||||
StatusDegraded ProviderStatus = "degraded"
|
|
||||||
StatusDown ProviderStatus = "down"
|
|
||||||
StatusUnknown ProviderStatus = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProviderHealthWindow is one hourly bucket of health metrics.
|
|
||||||
type ProviderHealthWindow struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"`
|
|
||||||
WindowStart time.Time `json:"window_start" db:"window_start"`
|
|
||||||
RequestCount int `json:"request_count" db:"request_count"`
|
|
||||||
ErrorCount int `json:"error_count" db:"error_count"`
|
|
||||||
TimeoutCount int `json:"timeout_count" db:"timeout_count"`
|
|
||||||
RateLimitCount int `json:"rate_limit_count" db:"rate_limit_count"` // v0.22.4
|
|
||||||
TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"`
|
|
||||||
MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"`
|
|
||||||
LastError *string `json:"last_error,omitempty" db:"last_error"`
|
|
||||||
LastErrorAt *string `json:"last_error_at,omitempty" db:"last_error_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AvgLatencyMs returns the average latency, or 0 if no requests.
|
// AvgLatencyMs returns the average latency, or 0 if no requests.
|
||||||
func (w *ProviderHealthWindow) AvgLatencyMs() int {
|
func (w *ProviderHealthWindow) AvgLatencyMs() int {
|
||||||
@@ -1063,75 +385,13 @@ func (w *ProviderHealthWindow) ErrorRate() float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProviderHealthSummary is the API response for a provider's current health.
|
// ProviderHealthSummary is the API response for a provider's current health.
|
||||||
type ProviderHealthSummary struct {
|
|
||||||
ProviderConfigID string `json:"provider_config_id"`
|
|
||||||
ProviderName string `json:"provider_name,omitempty"`
|
|
||||||
Status ProviderStatus `json:"status"`
|
|
||||||
RequestCount int `json:"request_count"` // last hour
|
|
||||||
ErrorRate float64 `json:"error_rate"` // last hour
|
|
||||||
ErrorCount int `json:"error_count"` // last hour
|
|
||||||
RateLimitCount int `json:"rate_limit_count"` // last hour (v0.22.4)
|
|
||||||
TimeoutCount int `json:"timeout_count"` // last hour
|
|
||||||
AvgLatencyMs int `json:"avg_latency_ms"` // last hour
|
|
||||||
MaxLatencyMs int `json:"max_latency_ms"` // last hour
|
|
||||||
LastError *string `json:"last_error,omitempty"`
|
|
||||||
LastErrorAt *string `json:"last_error_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolHealthWindow tracks health of built-in tools (web_search, url_fetch, etc.)
|
// ToolHealthWindow tracks health of built-in tools (web_search, url_fetch, etc.)
|
||||||
// in hourly buckets, analogous to ProviderHealthWindow.
|
// in hourly buckets, analogous to ProviderHealthWindow.
|
||||||
type ToolHealthWindow struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
ToolName string `json:"tool_name" db:"tool_name"`
|
|
||||||
WindowStart time.Time `json:"window_start" db:"window_start"`
|
|
||||||
RequestCount int `json:"request_count" db:"request_count"`
|
|
||||||
ErrorCount int `json:"error_count" db:"error_count"`
|
|
||||||
TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"`
|
|
||||||
MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"`
|
|
||||||
LastError *string `json:"last_error,omitempty" db:"last_error"`
|
|
||||||
LastErrorAt *string `json:"last_error_at,omitempty" db:"last_error_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolHealthSummary is the API response for a tool's health.
|
// ToolHealthSummary is the API response for a tool's health.
|
||||||
type ToolHealthSummary struct {
|
|
||||||
ToolName string `json:"tool_name"`
|
|
||||||
Status string `json:"status"` // healthy, degraded, down, unknown
|
|
||||||
RequestCount int `json:"request_count"`
|
|
||||||
ErrorRate float64 `json:"error_rate"`
|
|
||||||
AvgLatencyMs int `json:"avg_latency_ms"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// CAPABILITY OVERRIDES (v0.22.0)
|
// CAPABILITY OVERRIDES (v0.22.0)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// CapabilityOverride is an admin correction for a model's capabilities.
|
// CapabilityOverride is an admin correction for a model's capabilities.
|
||||||
type CapabilityOverride struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
|
|
||||||
ModelID string `json:"model_id" db:"model_id"`
|
|
||||||
Field string `json:"field" db:"field"`
|
|
||||||
Value string `json:"value" db:"value"`
|
|
||||||
SetBy *string `json:"set_by,omitempty" db:"set_by"`
|
|
||||||
CreatedAt string `json:"created_at" db:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// ROUTING POLICIES (v0.22.2)
|
// ROUTING POLICIES (v0.22.2)
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// RoutingPolicy is one routing rule that controls how requests are
|
// RoutingPolicy is one routing rule that controls how requests are
|
||||||
// dispatched to provider configs. Stored in the routing_policies table.
|
// dispatched to provider configs. Stored in the routing_policies table.
|
||||||
type RoutingPolicy struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
Scope string `json:"scope" db:"scope"` // "global" or "team"
|
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
|
||||||
Priority int `json:"priority" db:"priority"` // lower = first
|
|
||||||
Type string `json:"policy_type" db:"policy_type"` // provider_prefer, team_route, cost_limit, model_alias
|
|
||||||
Config JSONMap `json:"config" db:"config"` // type-specific JSONB
|
|
||||||
IsActive bool `json:"is_active" db:"is_active"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// GitCredential stores encrypted git authentication credentials.
|
|
||||||
// The encrypted_data field contains JSON: { "token": "..." } for PAT,
|
|
||||||
// { "username": "...", "password": "..." } for basic auth, or
|
|
||||||
// { "private_key": "...", "passphrase": "..." } for SSH keys.
|
|
||||||
type GitCredential struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key
|
|
||||||
EncryptedData []byte `json:"-" db:"encrypted_data"` // never expose
|
|
||||||
Nonce []byte `json:"-" db:"nonce"` // never expose
|
|
||||||
PublicKey string `json:"public_key,omitempty" db:"public_key"`
|
|
||||||
Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"`
|
|
||||||
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitCredentialSummary is the safe-to-expose version (no encrypted data).
|
|
||||||
type GitCredentialSummary struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
AuthType string `json:"auth_type"`
|
|
||||||
PublicKey string `json:"public_key,omitempty"`
|
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
|
||||||
PersonaID *string `json:"persona_id,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary returns a safe version without encrypted fields.
|
|
||||||
func (c *GitCredential) Summary() GitCredentialSummary {
|
|
||||||
return GitCredentialSummary{
|
|
||||||
ID: c.ID,
|
|
||||||
Name: c.Name,
|
|
||||||
AuthType: c.AuthType,
|
|
||||||
PublicKey: c.PublicKey,
|
|
||||||
Fingerprint: c.Fingerprint,
|
|
||||||
PersonaID: c.PersonaID,
|
|
||||||
CreatedAt: c.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitStatus represents the output of git status.
|
|
||||||
type GitStatus struct {
|
|
||||||
Branch string `json:"branch"`
|
|
||||||
Clean bool `json:"clean"`
|
|
||||||
Ahead int `json:"ahead"`
|
|
||||||
Behind int `json:"behind"`
|
|
||||||
Staged []GitFileStatus `json:"staged,omitempty"`
|
|
||||||
Modified []GitFileStatus `json:"modified,omitempty"`
|
|
||||||
Untracked []string `json:"untracked,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitFileStatus represents a single file in git status output.
|
|
||||||
type GitFileStatus struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Status string `json:"status"` // M, A, D, R, C, U
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitLogEntry represents a single commit in git log.
|
|
||||||
type GitLogEntry struct {
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
ShortHash string `json:"short_hash"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
Date time.Time `json:"date"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// ── models_memory.go ────────────────────────
|
|
||||||
// Memory model for v0.18.0 — add these types to models/models.go
|
|
||||||
// (or keep as a separate file in the models package)
|
|
||||||
|
|
||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// ── Memory Scope Constants ──────────────────
|
|
||||||
|
|
||||||
const (
|
|
||||||
MemoryScopeUser = "user" // personal facts, persists across all conversations
|
|
||||||
MemoryScopePersona = "persona" // shared across all users of a Persona
|
|
||||||
MemoryScopePersonaUser = "persona_user" // per-user within a Persona context
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Memory Status Constants ─────────────────
|
|
||||||
|
|
||||||
const (
|
|
||||||
MemoryStatusActive = "active"
|
|
||||||
MemoryStatusPendingReview = "pending_review"
|
|
||||||
MemoryStatusArchived = "archived"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Memory represents a single fact or preference persisted across conversations.
|
|
||||||
type Memory struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
Scope string `json:"scope" db:"scope"`
|
|
||||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
|
||||||
UserID *string `json:"user_id,omitempty" db:"user_id"`
|
|
||||||
Key string `json:"key" db:"key"`
|
|
||||||
Value string `json:"value" db:"value"`
|
|
||||||
SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"`
|
|
||||||
Confidence float64 `json:"confidence" db:"confidence"`
|
|
||||||
Status string `json:"status" db:"status"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MemoryFilter specifies which memories to retrieve.
|
|
||||||
type MemoryFilter struct {
|
|
||||||
Scope string // required: user, persona, persona_user
|
|
||||||
OwnerID string // required: user_id or persona_id
|
|
||||||
UserID *string // only for persona_user scope
|
|
||||||
Status string // default: "active"
|
|
||||||
Query string // optional: keyword filter on key+value
|
|
||||||
Limit int // max results (default 50)
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// WORKSPACES (v0.21.0)
|
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// Workspace owner type constants.
|
|
||||||
const (
|
|
||||||
WorkspaceOwnerUser = "user"
|
|
||||||
WorkspaceOwnerProject = "project"
|
|
||||||
WorkspaceOwnerChannel = "channel"
|
|
||||||
WorkspaceOwnerTeam = "team"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Workspace status constants.
|
|
||||||
const (
|
|
||||||
WorkspaceStatusActive = "active"
|
|
||||||
WorkspaceStatusArchived = "archived"
|
|
||||||
WorkspaceStatusDeleting = "deleting"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Workspace represents a file workspace bound to an owner (user, project, channel, or team).
|
|
||||||
type Workspace struct {
|
|
||||||
BaseModel
|
|
||||||
OwnerType string `json:"owner_type" db:"owner_type"`
|
|
||||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
|
||||||
Name string `json:"name" db:"name"`
|
|
||||||
RootPath string `json:"-" db:"root_path"` // never expose filesystem path
|
|
||||||
MaxBytes *int64 `json:"max_bytes,omitempty" db:"max_bytes"`
|
|
||||||
Status string `json:"status" db:"status"`
|
|
||||||
|
|
||||||
IndexingEnabled bool `json:"indexing_enabled" db:"indexing_enabled"`
|
|
||||||
|
|
||||||
// Git integration (v0.21.4)
|
|
||||||
GitRemoteURL *string `json:"git_remote_url,omitempty" db:"git_remote_url"`
|
|
||||||
GitBranch *string `json:"git_branch,omitempty" db:"git_branch"`
|
|
||||||
GitCredentialID *string `json:"git_credential_id,omitempty" db:"git_credential_id"`
|
|
||||||
GitLastSync *time.Time `json:"git_last_sync,omitempty" db:"git_last_sync"`
|
|
||||||
|
|
||||||
// Computed fields (not DB columns)
|
|
||||||
FileCount int `json:"file_count,omitempty"`
|
|
||||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorkspacePatch holds optional fields for updating a workspace.
|
|
||||||
type WorkspacePatch struct {
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
|
||||||
Status *string `json:"status,omitempty"`
|
|
||||||
IndexingEnabled *bool `json:"indexing_enabled,omitempty"`
|
|
||||||
|
|
||||||
// Git integration (v0.21.4)
|
|
||||||
GitRemoteURL *string `json:"git_remote_url,omitempty"`
|
|
||||||
GitBranch *string `json:"git_branch,omitempty"`
|
|
||||||
GitCredentialID *string `json:"git_credential_id,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorkspaceFile represents metadata for a single file or directory in a workspace.
|
|
||||||
type WorkspaceFile struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
|
|
||||||
Path string `json:"path" db:"path"`
|
|
||||||
IsDirectory bool `json:"is_directory" db:"is_directory"`
|
|
||||||
ContentType string `json:"content_type,omitempty" db:"content_type"`
|
|
||||||
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
|
|
||||||
SHA256 string `json:"sha256,omitempty" db:"sha256"`
|
|
||||||
IndexStatus string `json:"index_status,omitempty" db:"index_status"`
|
|
||||||
ChunkCount int `json:"chunk_count,omitempty" db:"chunk_count"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorkspaceStats holds aggregate storage metrics for a workspace.
|
|
||||||
type WorkspaceStats struct {
|
|
||||||
WorkspaceID string `json:"workspace_id"`
|
|
||||||
FileCount int `json:"file_count"`
|
|
||||||
DirCount int `json:"dir_count"`
|
|
||||||
TotalBytes int64 `json:"total_bytes"`
|
|
||||||
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// WORKSPACE CHUNKS (v0.21.2)
|
|
||||||
// =========================================
|
|
||||||
|
|
||||||
// Workspace file index status constants.
|
|
||||||
const (
|
|
||||||
IndexStatusPending = "pending"
|
|
||||||
IndexStatusIndexing = "indexing"
|
|
||||||
IndexStatusReady = "ready"
|
|
||||||
IndexStatusError = "error"
|
|
||||||
IndexStatusSkipped = "skipped"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WorkspaceChunk is a text segment from a workspace file with its embedding vector.
|
|
||||||
type WorkspaceChunk struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
|
|
||||||
FileID string `json:"file_id" db:"file_id"`
|
|
||||||
ChunkIndex int `json:"chunk_index" db:"chunk_index"`
|
|
||||||
Content string `json:"content" db:"content"`
|
|
||||||
TokenCount int `json:"token_count" db:"token_count"`
|
|
||||||
Embedding []float64 `json:"-" db:"embedding"` // never serialized to API
|
|
||||||
Metadata JSONMap `json:"metadata" db:"metadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorkspaceChunkResult is a single result from workspace similarity search.
|
|
||||||
type WorkspaceChunkResult struct {
|
|
||||||
FilePath string `json:"file_path"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
LineHint int `json:"line_hint,omitempty"`
|
|
||||||
Metadata JSONMap `json:"metadata,omitempty"`
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CapOverrideStore implements store.CapabilityOverrideStore for Postgres.
|
|
||||||
type CapOverrideStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCapOverrideStore(db *sql.DB) *CapOverrideStore {
|
|
||||||
return &CapOverrideStore{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) Set(ctx context.Context, o *models.CapabilityOverride) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO capability_overrides (provider_config_id, model_id, field, value, set_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT (provider_config_id, model_id, field) DO UPDATE SET
|
|
||||||
value = EXCLUDED.value,
|
|
||||||
set_by = EXCLUDED.set_by,
|
|
||||||
created_at = now()
|
|
||||||
`, o.ProviderConfigID, o.ModelID, o.Field, o.Value, o.SetBy)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `DELETE FROM capability_overrides WHERE id = $1`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
|
|
||||||
FROM capability_overrides
|
|
||||||
WHERE model_id = $1
|
|
||||||
ORDER BY provider_config_id NULLS LAST, field
|
|
||||||
`, modelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
|
|
||||||
FROM capability_overrides
|
|
||||||
WHERE model_id = $1 AND (provider_config_id = $2 OR provider_config_id IS NULL)
|
|
||||||
ORDER BY provider_config_id NULLS LAST, field
|
|
||||||
`, modelID, providerConfigID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) ListAll(ctx context.Context) ([]models.CapabilityOverride, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
|
|
||||||
FROM capability_overrides
|
|
||||||
ORDER BY model_id, provider_config_id NULLS LAST, field
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM capability_overrides WHERE provider_config_id = $1
|
|
||||||
`, providerConfigID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) query(ctx context.Context, q string, args ...interface{}) ([]models.CapabilityOverride, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.CapabilityOverride
|
|
||||||
for rows.Next() {
|
|
||||||
var o models.CapabilityOverride
|
|
||||||
if err := rows.Scan(&o.ID, &o.ProviderConfigID, &o.ModelID, &o.Field, &o.Value, &o.SetBy, &o.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, o)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CatalogStore struct{}
|
|
||||||
|
|
||||||
func NewCatalogStore() *CatalogStore { return &CatalogStore{} }
|
|
||||||
|
|
||||||
const catalogCols = `id, provider_config_id, model_id, display_name, model_type,
|
|
||||||
capabilities, pricing, visibility, last_synced_at, created_at, updated_at`
|
|
||||||
|
|
||||||
// catalogColsMC is catalogCols with mc. prefix for use in JOINs
|
|
||||||
// where id/created_at/updated_at are ambiguous.
|
|
||||||
const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_name, mc.model_type,
|
|
||||||
mc.capabilities, mc.pricing, mc.visibility, mc.last_synced_at, mc.created_at, mc.updated_at`
|
|
||||||
|
|
||||||
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
|
|
||||||
// New models default to 'disabled' visibility (secure by default).
|
|
||||||
//
|
|
||||||
// Runs in a single transaction: one SELECT to identify existing models,
|
|
||||||
// then N upserts via a prepared INSERT ON CONFLICT statement, one COMMIT.
|
|
||||||
// Previously this was N×2 auto-committed queries (SELECT + INSERT/UPDATE per model),
|
|
||||||
// which caused 300+ round-trips and WAL flushes on a typical Venice sync.
|
|
||||||
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return 0, 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("begin tx: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback() // no-op after commit
|
|
||||||
|
|
||||||
// Pre-fetch existing model_ids for this provider to track added vs updated.
|
|
||||||
existing := make(map[string]bool)
|
|
||||||
rows, err := tx.QueryContext(ctx,
|
|
||||||
"SELECT model_id FROM model_catalog WHERE provider_config_id = $1",
|
|
||||||
providerConfigID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("list existing: %w", err)
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var mid string
|
|
||||||
if err := rows.Scan(&mid); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return 0, 0, fmt.Errorf("scan existing: %w", err)
|
|
||||||
}
|
|
||||||
existing[mid] = true
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("list existing: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the upsert statement once, execute N times.
|
|
||||||
// INSERT creates new rows with visibility='disabled' (secure by default).
|
|
||||||
// ON CONFLICT updates metadata but preserves visibility (admin controls that).
|
|
||||||
stmt, err := tx.PrepareContext(ctx, `
|
|
||||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name,
|
|
||||||
model_type, capabilities, pricing, visibility, last_synced_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'disabled', $7)
|
|
||||||
ON CONFLICT (provider_config_id, model_id) DO UPDATE SET
|
|
||||||
display_name = EXCLUDED.display_name,
|
|
||||||
model_type = EXCLUDED.model_type,
|
|
||||||
capabilities = EXCLUDED.capabilities,
|
|
||||||
pricing = EXCLUDED.pricing,
|
|
||||||
last_synced_at = EXCLUDED.last_synced_at`)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("prepare upsert: %w", err)
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
for _, e := range entries {
|
|
||||||
capsJSON := ToJSON(e.Capabilities)
|
|
||||||
pricingJSON := ToJSON(e.Pricing)
|
|
||||||
|
|
||||||
modelType := e.ModelType
|
|
||||||
if modelType == "" {
|
|
||||||
modelType = "chat"
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := stmt.ExecContext(ctx,
|
|
||||||
providerConfigID, e.ModelID, e.DisplayName,
|
|
||||||
modelType, capsJSON, pricingJSON, now,
|
|
||||||
); err != nil {
|
|
||||||
return added, updated, fmt.Errorf("upsert %s: %w", e.ModelID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing[e.ModelID] {
|
|
||||||
updated++
|
|
||||||
} else {
|
|
||||||
added++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return added, updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) GetByID(ctx context.Context, id string) (*models.CatalogEntry, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE id = $1", catalogCols), id)
|
|
||||||
return scanCatalogEntry(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 AND model_id = $2", catalogCols),
|
|
||||||
providerConfigID, modelID)
|
|
||||||
return scanCatalogEntry(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByModelIDAny returns the most recently synced catalog entry for a model_id
|
|
||||||
// across any provider. Used to resolve capabilities for personas with auto-resolve
|
|
||||||
// (no specific provider_config_id).
|
|
||||||
func (s *CatalogStore) GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE model_id = $1 ORDER BY last_synced_at DESC NULLS LAST LIMIT 1", catalogCols),
|
|
||||||
modelID)
|
|
||||||
return scanCatalogEntry(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListVisible(ctx context.Context) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf(`SELECT %s FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE mc.visibility = 'enabled' AND pc.scope = 'global' AND pc.is_active = true
|
|
||||||
ORDER BY mc.model_id`, catalogColsMC))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 ORDER BY model_id", catalogCols),
|
|
||||||
providerConfigID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 AND visibility = 'enabled' ORDER BY model_id", catalogCols),
|
|
||||||
providerConfigID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListAll(ctx context.Context) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog ORDER BY model_id", catalogCols))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAllGlobal returns all catalog entries whose provider_config has scope='global'.
|
|
||||||
// Used by admin panel — admin should not see user BYOK or team-level models.
|
|
||||||
func (s *CatalogStore) ListAllGlobal(ctx context.Context) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf(`SELECT %s FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE pc.scope = 'global'
|
|
||||||
ORDER BY mc.model_id`, catalogColsMC))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) SetVisibility(ctx context.Context, id string, visibility string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
"UPDATE model_catalog SET visibility = $1 WHERE id = $2", visibility, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
"UPDATE model_catalog SET visibility = $1 WHERE provider_config_id = $2",
|
|
||||||
visibility, providerConfigID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) BulkSetVisibilityAll(ctx context.Context, visibility string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE model_catalog SET visibility = $1
|
|
||||||
WHERE provider_config_id IN (
|
|
||||||
SELECT id FROM provider_configs WHERE scope = 'global'
|
|
||||||
)`, visibility)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM model_catalog WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
"DELETE FROM model_catalog WHERE provider_config_id = $1", providerConfigID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scanners ────────────────────────────────
|
|
||||||
|
|
||||||
func scanCatalogEntry(row *sql.Row) (*models.CatalogEntry, error) {
|
|
||||||
var e models.CatalogEntry
|
|
||||||
var capsJSON, pricingJSON []byte
|
|
||||||
var displayName sql.NullString
|
|
||||||
var lastSynced sql.NullTime
|
|
||||||
err := row.Scan(
|
|
||||||
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType,
|
|
||||||
&capsJSON, &pricingJSON, &e.Visibility, &lastSynced,
|
|
||||||
&e.CreatedAt, &e.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
e.DisplayName = NullableString(displayName)
|
|
||||||
json.Unmarshal(capsJSON, &e.Capabilities)
|
|
||||||
if len(pricingJSON) > 0 {
|
|
||||||
e.Pricing = &models.ModelPricing{}
|
|
||||||
json.Unmarshal(pricingJSON, e.Pricing)
|
|
||||||
}
|
|
||||||
if lastSynced.Valid {
|
|
||||||
e.LastSyncedAt = &lastSynced.Time
|
|
||||||
}
|
|
||||||
return &e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) {
|
|
||||||
result := make([]models.CatalogEntry, 0) // never nil — serializes as [] not null
|
|
||||||
for rows.Next() {
|
|
||||||
var e models.CatalogEntry
|
|
||||||
var capsJSON, pricingJSON []byte
|
|
||||||
var displayName sql.NullString
|
|
||||||
var lastSynced sql.NullTime
|
|
||||||
err := rows.Scan(
|
|
||||||
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType,
|
|
||||||
&capsJSON, &pricingJSON, &e.Visibility, &lastSynced,
|
|
||||||
&e.CreatedAt, &e.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
e.DisplayName = NullableString(displayName)
|
|
||||||
json.Unmarshal(capsJSON, &e.Capabilities)
|
|
||||||
if len(pricingJSON) > 0 {
|
|
||||||
e.Pricing = &models.ModelPricing{}
|
|
||||||
json.Unmarshal(pricingJSON, e.Pricing)
|
|
||||||
}
|
|
||||||
if lastSynced.Valid {
|
|
||||||
e.LastSyncedAt = &lastSynced.Time
|
|
||||||
}
|
|
||||||
result = append(result, e)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *CatalogStore) GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) {
|
|
||||||
var capsJSON []byte
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT capabilities FROM model_catalog
|
|
||||||
WHERE model_id = $1 AND provider_config_id = $2
|
|
||||||
`, modelID, configID).Scan(&capsJSON)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return capsJSON, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) {
|
|
||||||
var capsJSON []byte
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT capabilities FROM model_catalog
|
|
||||||
WHERE model_id = $1 ORDER BY last_synced_at DESC LIMIT 1
|
|
||||||
`, modelID).Scan(&capsJSON)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return capsJSON, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListTeamAvailable(ctx context.Context) ([]store.TeamAvailableModel, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
|
|
||||||
ac.provider, ac.name AS provider_name
|
|
||||||
FROM model_catalog mc
|
|
||||||
JOIN provider_configs ac ON mc.provider_config_id = ac.id
|
|
||||||
WHERE mc.visibility IN ('enabled', 'team')
|
|
||||||
AND ac.is_active = true AND ac.scope = 'global'
|
|
||||||
ORDER BY ac.name, mc.model_id
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.TeamAvailableModel, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var m store.TeamAvailableModel
|
|
||||||
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility,
|
|
||||||
&m.Provider, &m.ProviderName); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, m)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Mention resolution (v0.29.0) ────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *CatalogStore) FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) {
|
|
||||||
var foundModelID, configID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT mc.model_id, mc.provider_config_id
|
|
||||||
FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE LOWER(mc.model_id) = LOWER($1)
|
|
||||||
AND mc.visibility = 'enabled'
|
|
||||||
AND pc.is_active = true
|
|
||||||
ORDER BY
|
|
||||||
CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
|
||||||
LIMIT 1
|
|
||||||
`, modelID).Scan(&foundModelID, &configID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", "", nil
|
|
||||||
}
|
|
||||||
return foundModelID, configID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(DISTINCT mc.model_id)
|
|
||||||
FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE LOWER(mc.model_id) LIKE LOWER($1)
|
|
||||||
AND mc.visibility = 'enabled'
|
|
||||||
AND pc.is_active = true
|
|
||||||
`, prefix+"%").Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", 0, err
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
return "", "", count, nil
|
|
||||||
}
|
|
||||||
var modelID, configID string
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT mc.model_id, mc.provider_config_id
|
|
||||||
FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE LOWER(mc.model_id) LIKE LOWER($1)
|
|
||||||
AND mc.visibility = 'enabled'
|
|
||||||
AND pc.is_active = true
|
|
||||||
ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
|
||||||
LIMIT 1
|
|
||||||
`, prefix+"%").Scan(&modelID, &configID)
|
|
||||||
return modelID, configID, 1, err
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Single-field helpers (v0.29.0) ──────────────────────────────────────
|
|
||||||
// Moved from handlers/completion.go and handlers/messages.go raw SQL.
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetAIMode(ctx context.Context, channelID string) (string, error) {
|
|
||||||
var aiMode string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1
|
|
||||||
`, channelID).Scan(&aiMode)
|
|
||||||
if err != nil {
|
|
||||||
return "auto", err
|
|
||||||
}
|
|
||||||
return aiMode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) {
|
|
||||||
var channelType string
|
|
||||||
var teamID *string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
|
|
||||||
`, channelID).Scan(&channelType, &teamID)
|
|
||||||
return channelType, teamID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetSystemPrompt(ctx context.Context, channelID string) (*string, error) {
|
|
||||||
var prompt *string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT system_prompt FROM channels WHERE id = $1
|
|
||||||
`, channelID).Scan(&prompt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return prompt, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetDefaultModel(ctx context.Context, channelID string) (*string, error) {
|
|
||||||
var model *string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT model FROM channels WHERE id = $1
|
|
||||||
`, channelID).Scan(&model)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return model, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) TouchUpdatedAt(ctx context.Context, channelID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT participant_id FROM channel_participants
|
|
||||||
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
|
|
||||||
`, channelID, excludeUserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
if ids == nil {
|
|
||||||
ids = []string{}
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT participant_id FROM channel_participants
|
|
||||||
WHERE channel_id = $1 AND participant_type = 'persona'
|
|
||||||
ORDER BY created_at
|
|
||||||
`, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
if ids == nil {
|
|
||||||
ids = []string{}
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) {
|
|
||||||
// Try group leader first
|
|
||||||
var leaderID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT cp.participant_id
|
|
||||||
FROM channel_participants cp
|
|
||||||
JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id
|
|
||||||
WHERE cp.channel_id = $1
|
|
||||||
AND cp.participant_type = 'persona'
|
|
||||||
AND pgm.is_leader = true
|
|
||||||
LIMIT 1
|
|
||||||
`, channelID).Scan(&leaderID)
|
|
||||||
if err == nil && leaderID != "" {
|
|
||||||
return leaderID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: first persona participant
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT participant_id FROM channel_participants
|
|
||||||
WHERE channel_id = $1 AND participant_type = 'persona'
|
|
||||||
ORDER BY created_at LIMIT 1
|
|
||||||
`, channelID).Scan(&leaderID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return leaderID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) {
|
|
||||||
var workflowID *string
|
|
||||||
var currentStage int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT workflow_id, COALESCE(current_stage, 0)
|
|
||||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
|
||||||
`, channelID).Scan(&workflowID, ¤tStage)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, 0, nil
|
|
||||||
}
|
|
||||||
return workflowID, currentStage, err
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,260 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *FileStore) UpdateStorageKey(ctx context.Context, id, key string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE files SET storage_key = $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
key, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FolderStore struct{}
|
|
||||||
|
|
||||||
func NewFolderStore() *FolderStore { return &FolderStore{} }
|
|
||||||
|
|
||||||
func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, name, parent_id, sort_order, created_at, updated_at
|
|
||||||
FROM folders WHERE user_id = $1
|
|
||||||
ORDER BY sort_order, name
|
|
||||||
`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Folder
|
|
||||||
for rows.Next() {
|
|
||||||
var f models.Folder
|
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder,
|
|
||||||
&f.CreatedAt, &f.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f.UserID = userID
|
|
||||||
result = append(result, f)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = []models.Folder{}
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO folders (user_id, name, parent_id, sort_order)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, name, parent_id, sort_order, created_at, updated_at
|
|
||||||
`, f.UserID, f.Name, f.ParentID, f.SortOrder).Scan(
|
|
||||||
&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) {
|
|
||||||
if parentID != nil {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE folders
|
|
||||||
SET name = COALESCE(NULLIF($3, ''), name),
|
|
||||||
sort_order = COALESCE($4, sort_order),
|
|
||||||
parent_id = $5
|
|
||||||
WHERE id = $1 AND user_id = $2
|
|
||||||
`, folderID, userID, name, sortOrder, *parentID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.RowsAffected()
|
|
||||||
}
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE folders
|
|
||||||
SET name = COALESCE(NULLIF($3, ''), name),
|
|
||||||
sort_order = COALESCE($4, sort_order)
|
|
||||||
WHERE id = $1 AND user_id = $2
|
|
||||||
`, folderID, userID, name, sortOrder)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FolderStore) Delete(ctx context.Context, folderID, userID string) (int64, error) {
|
|
||||||
res, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM folders WHERE id = $1 AND user_id = $2`, folderID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FolderStore) UnassignChannels(ctx context.Context, folderID, userID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2`, folderID, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GitCredentialStore implements store.GitCredentialStore for PostgreSQL.
|
|
||||||
type GitCredentialStore struct{}
|
|
||||||
|
|
||||||
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
|
|
||||||
if cred.ID != "" {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
RETURNING created_at`,
|
|
||||||
cred.ID, cred.UserID, cred.Name, cred.AuthType,
|
|
||||||
cred.EncryptedData, cred.Nonce,
|
|
||||||
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
|
|
||||||
).Scan(&cred.CreatedAt)
|
|
||||||
}
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
RETURNING id, created_at`,
|
|
||||||
cred.UserID, cred.Name, cred.AuthType,
|
|
||||||
cred.EncryptedData, cred.Nonce,
|
|
||||||
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
|
|
||||||
).Scan(&cred.ID, &cred.CreatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
|
|
||||||
var c models.GitCredential
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
|
|
||||||
FROM git_credentials WHERE id = $1`, id,
|
|
||||||
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
|
||||||
&c.EncryptedData, &c.Nonce,
|
|
||||||
&c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
|
|
||||||
FROM git_credentials WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var creds []models.GitCredential
|
|
||||||
for rows.Next() {
|
|
||||||
var c models.GitCredential
|
|
||||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
|
||||||
&c.EncryptedData, &c.Nonce,
|
|
||||||
&c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
creds = append(creds, c)
|
|
||||||
}
|
|
||||||
return creds, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GitCredentialStore) Delete(ctx context.Context, id, userID string) error {
|
|
||||||
res, err := DB.ExecContext(ctx, `DELETE FROM git_credentials WHERE id = $1 AND user_id = $2`, id, userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
|
|
||||||
"github.com/lib/pq"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── KnowledgeBaseStore ──────────────────────────
|
|
||||||
|
|
||||||
type KnowledgeBaseStore struct{}
|
|
||||||
|
|
||||||
func NewKnowledgeBaseStore() *KnowledgeBaseStore { return &KnowledgeBaseStore{} }
|
|
||||||
|
|
||||||
// ── KB CRUD ──────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) Create(ctx context.Context, kb *models.KnowledgeBase) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO knowledge_bases (name, description, scope, owner_id, team_id, embedding_config, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id, document_count, chunk_count, total_bytes, created_at, updated_at`,
|
|
||||||
kb.Name, kb.Description, kb.Scope,
|
|
||||||
models.NullString(kb.OwnerID), models.NullString(kb.TeamID),
|
|
||||||
ToJSON(kb.EmbeddingConfig), kb.Status,
|
|
||||||
).Scan(&kb.ID, &kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes, &kb.CreatedAt, &kb.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) {
|
|
||||||
kb, err := scanKB(DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
|
||||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
|
||||||
FROM knowledge_bases WHERE id = $1`, id))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return kb, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
b := NewUpdate("knowledge_bases")
|
|
||||||
for k, v := range fields {
|
|
||||||
if k == "embedding_config" {
|
|
||||||
b.SetJSON(k, v)
|
|
||||||
} else {
|
|
||||||
b.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.Set("updated_at", "now()")
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) Delete(ctx context.Context, id string) error {
|
|
||||||
// CASCADE deletes kb_documents, kb_chunks, channel_knowledge_bases.
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM knowledge_bases WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scoped Listing ──────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
|
|
||||||
// User can see: global + their teams + personal + group-granted.
|
|
||||||
q := `
|
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
|
||||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
|
||||||
FROM knowledge_bases
|
|
||||||
WHERE scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = $1)`
|
|
||||||
|
|
||||||
args := []interface{}{userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
q += fmt.Sprintf(` OR (scope = 'team' AND team_id = ANY($%d))`, len(args)+1)
|
|
||||||
args = append(args, pq.Array(teamIDs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group-granted KBs
|
|
||||||
q += fmt.Sprintf(`
|
|
||||||
OR id IN (
|
|
||||||
SELECT rg.resource_id FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'knowledge_base'
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
WHERE gm.user_id = $%d
|
|
||||||
AND gm.group_id = ANY(rg.granted_groups)
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)`, len(args)+1)
|
|
||||||
args = append(args, userID)
|
|
||||||
|
|
||||||
q += ` ORDER BY name`
|
|
||||||
return queryKBs(ctx, q, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) {
|
|
||||||
return queryKBs(ctx, `
|
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
|
||||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
|
||||||
FROM knowledge_bases WHERE scope = 'personal' AND owner_id = $1 ORDER BY name`, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Documents ────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) CreateDocument(ctx context.Context, doc *models.KBDocument) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO kb_documents (kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id, chunk_count, created_at, updated_at`,
|
|
||||||
doc.KBID, doc.Filename, doc.ContentType, doc.SizeBytes,
|
|
||||||
doc.StorageKey, doc.Status, doc.UploadedBy,
|
|
||||||
).Scan(&doc.ID, &doc.ChunkCount, &doc.CreatedAt, &doc.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) GetDocument(ctx context.Context, id string) (*models.KBDocument, error) {
|
|
||||||
var doc models.KBDocument
|
|
||||||
var extractedText, errMsg sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
|
|
||||||
extracted_text, chunk_count, status, error, uploaded_by, created_at, updated_at
|
|
||||||
FROM kb_documents WHERE id = $1`, id).Scan(
|
|
||||||
&doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType, &doc.SizeBytes,
|
|
||||||
&doc.StorageKey, &extractedText, &doc.ChunkCount, &doc.Status,
|
|
||||||
&errMsg, &doc.UploadedBy, &doc.CreatedAt, &doc.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
doc.ExtractedText = NullableStringPtr(extractedText)
|
|
||||||
doc.Error = NullableStringPtr(errMsg)
|
|
||||||
return &doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
|
|
||||||
chunk_count, status, error, uploaded_by, created_at, updated_at
|
|
||||||
FROM kb_documents WHERE kb_id = $1 ORDER BY created_at`, kbID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.KBDocument
|
|
||||||
for rows.Next() {
|
|
||||||
var doc models.KBDocument
|
|
||||||
var errMsg sql.NullString
|
|
||||||
err := rows.Scan(&doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType,
|
|
||||||
&doc.SizeBytes, &doc.StorageKey, &doc.ChunkCount, &doc.Status,
|
|
||||||
&errMsg, &doc.UploadedBy, &doc.CreatedAt, &doc.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
doc.Error = NullableStringPtr(errMsg)
|
|
||||||
result = append(result, doc)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE kb_documents SET status = $2, error = $3, updated_at = now()
|
|
||||||
WHERE id = $1`, id, status, models.NullString(errMsg))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE kb_documents SET extracted_text = $2, chunk_count = $3, updated_at = now()
|
|
||||||
WHERE id = $1`, id, text, chunkCount)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE kb_documents SET storage_key = $2 WHERE id = $1`, id, storageKey)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) {
|
|
||||||
var doc models.KBDocument
|
|
||||||
var errMsg sql.NullString
|
|
||||||
// Return the row before deleting so caller can clean up storage.
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
DELETE FROM kb_documents WHERE id = $1
|
|
||||||
RETURNING id, kb_id, filename, storage_key, status, error`,
|
|
||||||
id).Scan(&doc.ID, &doc.KBID, &doc.Filename, &doc.StorageKey, &doc.Status, &errMsg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
doc.Error = NullableStringPtr(errMsg)
|
|
||||||
return &doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chunks ───────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) InsertChunks(ctx context.Context, chunks []models.KBChunk) error {
|
|
||||||
if len(chunks) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch insert using a single multi-row INSERT.
|
|
||||||
// Embedding vectors are inserted as text representations that pgvector parses.
|
|
||||||
const cols = 7 // kb_id, document_id, chunk_index, content, token_count, embedding, metadata
|
|
||||||
valueParts := make([]string, 0, len(chunks))
|
|
||||||
args := make([]interface{}, 0, len(chunks)*cols)
|
|
||||||
|
|
||||||
for i, c := range chunks {
|
|
||||||
base := i * cols
|
|
||||||
valueParts = append(valueParts, fmt.Sprintf(
|
|
||||||
"($%d, $%d, $%d, $%d, $%d, $%d::vector, $%d)",
|
|
||||||
base+1, base+2, base+3, base+4, base+5, base+6, base+7,
|
|
||||||
))
|
|
||||||
args = append(args, c.KBID, c.DocumentID, c.ChunkIndex,
|
|
||||||
c.Content, c.TokenCount, vectorToString(c.Embedding), ToJSON(c.Metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`INSERT INTO kb_chunks (kb_id, document_id, chunk_index, content, token_count, embedding, metadata)
|
|
||||||
VALUES %s`, strings.Join(valueParts, ", "))
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, q, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) DeleteChunksForDocument(ctx context.Context, documentID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM kb_chunks WHERE document_id = $1", documentID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64, threshold float64, limit int) ([]models.KBSearchResult, error) {
|
|
||||||
if len(kbIDs) == 0 || len(queryVec) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT c.content, c.metadata, d.filename, kb.name,
|
|
||||||
1 - (c.embedding <=> $1::vector) AS similarity
|
|
||||||
FROM kb_chunks c
|
|
||||||
JOIN kb_documents d ON c.document_id = d.id
|
|
||||||
JOIN knowledge_bases kb ON c.kb_id = kb.id
|
|
||||||
WHERE c.kb_id = ANY($2)
|
|
||||||
AND 1 - (c.embedding <=> $1::vector) > $3
|
|
||||||
ORDER BY c.embedding <=> $1::vector
|
|
||||||
LIMIT $4`,
|
|
||||||
vectorToString(queryVec), pq.Array(kbIDs), threshold, limit,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.KBSearchResult
|
|
||||||
for rows.Next() {
|
|
||||||
var r models.KBSearchResult
|
|
||||||
var metadataJSON []byte
|
|
||||||
err := rows.Scan(&r.Content, &metadataJSON, &r.Filename, &r.KBName, &r.Similarity)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ScanJSON(metadataJSON, &r.Metadata)
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Channel Links ─────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// Clear existing links.
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM channel_knowledge_bases WHERE channel_id = $1", channelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new links.
|
|
||||||
for _, kbID := range kbIDs {
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_knowledge_bases (channel_id, kb_id, enabled) VALUES ($1, $2, true)`,
|
|
||||||
channelID, kbID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT ckb.kb_id, kb.name, ckb.enabled, kb.document_count
|
|
||||||
FROM channel_knowledge_bases ckb
|
|
||||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
|
||||||
WHERE ckb.channel_id = $1
|
|
||||||
ORDER BY kb.name`, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ChannelKB
|
|
||||||
for rows.Next() {
|
|
||||||
var ckb models.ChannelKB
|
|
||||||
if err := rows.Scan(&ckb.KBID, &ckb.KBName, &ckb.Enabled, &ckb.DocumentCount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, ckb)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) GetActiveKBIDs(ctx context.Context, channelID string, userID string, teamIDs []string) ([]string, error) {
|
|
||||||
// Return KB IDs that are: enabled on this channel AND user has access to.
|
|
||||||
q := `
|
|
||||||
SELECT ckb.kb_id
|
|
||||||
FROM channel_knowledge_bases ckb
|
|
||||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
|
||||||
WHERE ckb.channel_id = $1 AND ckb.enabled = true
|
|
||||||
AND (
|
|
||||||
kb.scope = 'global'
|
|
||||||
OR (kb.scope = 'personal' AND kb.owner_id = $2)`
|
|
||||||
|
|
||||||
args := []interface{}{channelID, userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id = ANY($%d))`, len(args)+1)
|
|
||||||
args = append(args, pq.Array(teamIDs))
|
|
||||||
}
|
|
||||||
|
|
||||||
q += `)`
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats ────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) UpdateStats(ctx context.Context, kbID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE knowledge_bases SET
|
|
||||||
document_count = (SELECT COUNT(*) FROM kb_documents WHERE kb_id = $1 AND status != 'error'),
|
|
||||||
chunk_count = (SELECT COUNT(*) FROM kb_chunks WHERE kb_id = $1),
|
|
||||||
total_bytes = COALESCE((SELECT SUM(size_bytes) FROM kb_documents WHERE kb_id = $1), 0),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1`, kbID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Discoverable Management (v0.17.0) ────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE knowledge_bases SET discoverable = $2, updated_at = now()
|
|
||||||
WHERE id = $1`, kbID, discoverable)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetActiveKBIDsWithPersona returns KB IDs accessible for a channel, including
|
|
||||||
// KBs bound to the active persona. Persona-bound KBs are included even if
|
|
||||||
// they are not discoverable and not channel-linked.
|
|
||||||
func (s *KnowledgeBaseStore) GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, teamIDs []string, personaID string) ([]string, error) {
|
|
||||||
// Start with channel-linked KBs (existing behavior)
|
|
||||||
q := `
|
|
||||||
SELECT ckb.kb_id
|
|
||||||
FROM channel_knowledge_bases ckb
|
|
||||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
|
||||||
WHERE ckb.channel_id = $1 AND ckb.enabled = true
|
|
||||||
AND (
|
|
||||||
kb.scope = 'global'
|
|
||||||
OR (kb.scope = 'personal' AND kb.owner_id = $2)`
|
|
||||||
|
|
||||||
args := []interface{}{channelID, userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id = ANY($%d))`, len(args)+1)
|
|
||||||
args = append(args, pq.Array(teamIDs))
|
|
||||||
}
|
|
||||||
|
|
||||||
q += `)`
|
|
||||||
|
|
||||||
// UNION with persona-bound KBs (bypass discoverable check)
|
|
||||||
if personaID != "" {
|
|
||||||
q += fmt.Sprintf(`
|
|
||||||
UNION
|
|
||||||
SELECT pkb.kb_id
|
|
||||||
FROM persona_knowledge_bases pkb
|
|
||||||
JOIN knowledge_bases kb ON pkb.kb_id = kb.id
|
|
||||||
WHERE pkb.persona_id = $%d
|
|
||||||
AND kb.chunk_count > 0`, len(args)+1)
|
|
||||||
args = append(args, personaID)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !seen[id] {
|
|
||||||
seen[id] = true
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListDiscoverable returns KBs the user can see AND that are discoverable.
|
|
||||||
// Used for the channel KB toggle popup when kb_direct_access is enabled.
|
|
||||||
func (s *KnowledgeBaseStore) ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
|
|
||||||
q := `
|
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
|
||||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
|
||||||
FROM knowledge_bases
|
|
||||||
WHERE discoverable = true
|
|
||||||
AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = $1)`
|
|
||||||
|
|
||||||
args := []interface{}{userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
q += fmt.Sprintf(` OR (scope = 'team' AND team_id = ANY($%d))`, len(args)+1)
|
|
||||||
args = append(args, pq.Array(teamIDs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group-granted KBs
|
|
||||||
q += fmt.Sprintf(`
|
|
||||||
OR id IN (
|
|
||||||
SELECT rg.resource_id FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'knowledge_base'
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
WHERE gm.user_id = $%d
|
|
||||||
AND gm.group_id = ANY(rg.granted_groups)
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)`, len(args)+1)
|
|
||||||
args = append(args, userID)
|
|
||||||
|
|
||||||
q += `) ORDER BY name`
|
|
||||||
return queryKBs(ctx, q, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scan Helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
|
|
||||||
var kb models.KnowledgeBase
|
|
||||||
var ownerID, teamID sql.NullString
|
|
||||||
var embCfgJSON []byte
|
|
||||||
|
|
||||||
err := row.Scan(
|
|
||||||
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
|
||||||
&ownerID, &teamID, &embCfgJSON,
|
|
||||||
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
|
||||||
&kb.Status, &kb.Discoverable, &kb.CreatedAt, &kb.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
kb.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
kb.TeamID = NullableStringPtr(teamID)
|
|
||||||
ScanJSON(embCfgJSON, &kb.EmbeddingConfig)
|
|
||||||
return &kb, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryKBs(ctx context.Context, query string, args ...interface{}) ([]models.KnowledgeBase, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.KnowledgeBase
|
|
||||||
for rows.Next() {
|
|
||||||
var kb models.KnowledgeBase
|
|
||||||
var ownerID, teamID sql.NullString
|
|
||||||
var embCfgJSON []byte
|
|
||||||
err := rows.Scan(
|
|
||||||
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
|
||||||
&ownerID, &teamID, &embCfgJSON,
|
|
||||||
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
|
||||||
&kb.Status, &kb.Discoverable, &kb.CreatedAt, &kb.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
kb.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
kb.TeamID = NullableStringPtr(teamID)
|
|
||||||
ScanJSON(embCfgJSON, &kb.EmbeddingConfig)
|
|
||||||
result = append(result, kb)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// vectorToString converts a float64 slice to pgvector text format: [0.1,0.2,0.3]
|
|
||||||
func vectorToString(v []float64) string {
|
|
||||||
if len(v) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
parts := make([]string, len(v))
|
|
||||||
for i, f := range v {
|
|
||||||
parts[i] = fmt.Sprintf("%g", f)
|
|
||||||
}
|
|
||||||
return "[" + strings.Join(parts, ",") + "]"
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MemoryStore struct{}
|
|
||||||
|
|
||||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{} }
|
|
||||||
|
|
||||||
func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error {
|
|
||||||
if m.ID == "" {
|
|
||||||
m.ID = store.NewID()
|
|
||||||
}
|
|
||||||
if m.Status == "" {
|
|
||||||
m.Status = models.MemoryStatusActive
|
|
||||||
}
|
|
||||||
if m.Confidence == 0 {
|
|
||||||
m.Confidence = 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO memories (id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
ON CONFLICT (scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key)
|
|
||||||
DO UPDATE SET
|
|
||||||
value = EXCLUDED.value,
|
|
||||||
confidence = EXCLUDED.confidence,
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
source_channel_id = EXCLUDED.source_channel_id,
|
|
||||||
updated_at = now()
|
|
||||||
`, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value,
|
|
||||||
m.SourceChannelID, m.Confidence, m.Status)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Update(ctx context.Context, m *models.Memory) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE memories
|
|
||||||
SET key = $1, value = $2, confidence = $3, status = $4,
|
|
||||||
source_channel_id = $5, updated_at = now()
|
|
||||||
WHERE id = $6
|
|
||||||
`, m.Key, m.Value, m.Confidence, m.Status, m.SourceChannelID, m.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) {
|
|
||||||
m := &models.Memory{}
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
|
|
||||||
&m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, fmt.Errorf("memory not found: %s", id)
|
|
||||||
}
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) {
|
|
||||||
clauses := []string{"scope = $1", "owner_id = $2"}
|
|
||||||
args := []interface{}{f.Scope, f.OwnerID}
|
|
||||||
idx := 3
|
|
||||||
|
|
||||||
if f.UserID != nil {
|
|
||||||
clauses = append(clauses, fmt.Sprintf("user_id = $%d", idx))
|
|
||||||
args = append(args, *f.UserID)
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
|
|
||||||
status := f.Status
|
|
||||||
if status == "" {
|
|
||||||
status = models.MemoryStatusActive
|
|
||||||
}
|
|
||||||
clauses = append(clauses, fmt.Sprintf("status = $%d", idx))
|
|
||||||
args = append(args, status)
|
|
||||||
idx++
|
|
||||||
|
|
||||||
if f.Query != "" {
|
|
||||||
clauses = append(clauses, fmt.Sprintf(
|
|
||||||
"to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx))
|
|
||||||
args = append(args, f.Query)
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := f.Limit
|
|
||||||
if limit <= 0 || limit > 200 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE %s
|
|
||||||
ORDER BY confidence DESC, updated_at DESC
|
|
||||||
LIMIT $%d
|
|
||||||
`, strings.Join(clauses, " AND "), idx)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return scanMemories(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = $1`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Archive(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE memories SET status = 'archived', updated_at = now() WHERE id = $1
|
|
||||||
`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) {
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a UNION query across applicable scopes.
|
|
||||||
// Always include user-scope memories for this user.
|
|
||||||
// If a persona is active, also include persona and persona+user memories.
|
|
||||||
|
|
||||||
parts := []string{}
|
|
||||||
args := []interface{}{}
|
|
||||||
idx := 1
|
|
||||||
|
|
||||||
// 1. User-scope memories
|
|
||||||
userPart := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'user' AND owner_id = $%d AND status = 'active'
|
|
||||||
`, idx)
|
|
||||||
args = append(args, userID)
|
|
||||||
idx++
|
|
||||||
|
|
||||||
if query != "" {
|
|
||||||
userPart += fmt.Sprintf(
|
|
||||||
" AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx)
|
|
||||||
args = append(args, query)
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
parts = append(parts, userPart)
|
|
||||||
|
|
||||||
// 2. Persona-scope memories (if persona active)
|
|
||||||
if personaID != nil && *personaID != "" {
|
|
||||||
personaPart := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'persona' AND owner_id = $%d AND status = 'active'
|
|
||||||
`, idx)
|
|
||||||
args = append(args, *personaID)
|
|
||||||
idx++
|
|
||||||
if query != "" {
|
|
||||||
personaPart += fmt.Sprintf(
|
|
||||||
" AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx)
|
|
||||||
args = append(args, query)
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
parts = append(parts, personaPart)
|
|
||||||
|
|
||||||
// 3. Persona+user memories
|
|
||||||
puPart := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'persona_user' AND owner_id = $%d AND user_id = $%d AND status = 'active'
|
|
||||||
`, idx, idx+1)
|
|
||||||
args = append(args, *personaID, userID)
|
|
||||||
idx += 2
|
|
||||||
if query != "" {
|
|
||||||
puPart += fmt.Sprintf(
|
|
||||||
" AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx)
|
|
||||||
args = append(args, query)
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
parts = append(parts, puPart)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scope priority: persona_user > persona > user (CASE ordering)
|
|
||||||
fullQuery := fmt.Sprintf(`
|
|
||||||
SELECT * FROM (%s) AS combined
|
|
||||||
ORDER BY
|
|
||||||
CASE scope
|
|
||||||
WHEN 'persona_user' THEN 0
|
|
||||||
WHEN 'persona' THEN 1
|
|
||||||
WHEN 'user' THEN 2
|
|
||||||
END,
|
|
||||||
confidence DESC,
|
|
||||||
updated_at DESC
|
|
||||||
LIMIT $%d
|
|
||||||
`, strings.Join(parts, " UNION ALL "), idx)
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, fullQuery, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return scanMemories(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM memories
|
|
||||||
WHERE scope = $1 AND owner_id = $2 AND status = 'active'
|
|
||||||
`, scope, ownerID).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error) {
|
|
||||||
if limit <= 0 || limit > 500 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE status = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $2
|
|
||||||
`, status, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMemories(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func scanMemories(rows *sql.Rows) ([]models.Memory, error) {
|
|
||||||
var memories []models.Memory
|
|
||||||
for rows.Next() {
|
|
||||||
var m models.Memory
|
|
||||||
if err := rows.Scan(
|
|
||||||
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
|
|
||||||
&m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
memories = append(memories, m)
|
|
||||||
}
|
|
||||||
if memories == nil {
|
|
||||||
memories = []models.Memory{}
|
|
||||||
}
|
|
||||||
return memories, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure compile-time interface satisfaction
|
|
||||||
var _ store.MemoryStore = (*MemoryStore)(nil)
|
|
||||||
|
|
||||||
// ── CS5b additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MemoryStore) SetEmbedding(ctx context.Context, id, embedding string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE memories SET embedding = $1::vector WHERE id = $2`, embedding, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) {
|
|
||||||
var lastID string
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT last_message_id FROM memory_extraction_log WHERE channel_id = $1 AND user_id = $2`,
|
|
||||||
channelID, userID).Scan(&lastID)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil // no entry yet
|
|
||||||
}
|
|
||||||
return lastID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO memory_extraction_log (channel_id, user_id, last_message_id, memory_count)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT(channel_id, user_id) DO UPDATE SET
|
|
||||||
last_message_id = EXCLUDED.last_message_id,
|
|
||||||
extracted_at = now(),
|
|
||||||
memory_count = memory_extraction_log.memory_count + EXCLUDED.memory_count
|
|
||||||
`, channelID, userID, lastMessageID, count)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── v0.30.1 — Memory Compaction ────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE memories
|
|
||||||
SET confidence = confidence * $1,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE status = 'active'
|
|
||||||
AND updated_at < $2::timestamptz
|
|
||||||
AND confidence > 0.1
|
|
||||||
`, decayFactor, olderThan)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return int(n), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE memories
|
|
||||||
SET status = 'archived',
|
|
||||||
updated_at = now()
|
|
||||||
WHERE status = 'active'
|
|
||||||
AND confidence < $1
|
|
||||||
`, belowConfidence)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return int(n), nil
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RecallHybrid returns active memories using vector similarity + keyword search.
|
|
||||||
// If queryVec is nil/empty, falls back to keyword-only Recall.
|
|
||||||
func (s *MemoryStore) RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) {
|
|
||||||
if len(queryVec) == 0 {
|
|
||||||
return s.Recall(ctx, userID, personaID, query, limit)
|
|
||||||
}
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run semantic recall
|
|
||||||
semantic, err := s.recallSemantic(ctx, userID, personaID, queryVec, limit)
|
|
||||||
if err != nil {
|
|
||||||
// Fall back to keyword
|
|
||||||
return s.Recall(ctx, userID, personaID, query, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run keyword recall if query provided
|
|
||||||
var keyword []models.Memory
|
|
||||||
if query != "" {
|
|
||||||
keyword, _ = s.Recall(ctx, userID, personaID, query, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeResults(semantic, keyword, limit), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// recallSemantic uses pgvector cosine distance to find similar memories.
|
|
||||||
func (s *MemoryStore) recallSemantic(ctx context.Context, userID string, personaID *string, queryVec []float64, limit int) ([]models.Memory, error) {
|
|
||||||
vecStr := vectorToString(queryVec)
|
|
||||||
|
|
||||||
parts := []string{}
|
|
||||||
args := []interface{}{}
|
|
||||||
idx := 1
|
|
||||||
|
|
||||||
// User-scope
|
|
||||||
userPart := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at,
|
|
||||||
(embedding <=> $%d::vector) AS distance
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'user' AND owner_id = $%d AND status = 'active'
|
|
||||||
AND embedding IS NOT NULL
|
|
||||||
`, idx, idx+1)
|
|
||||||
args = append(args, vecStr, userID)
|
|
||||||
idx += 2
|
|
||||||
parts = append(parts, userPart)
|
|
||||||
|
|
||||||
if personaID != nil && *personaID != "" {
|
|
||||||
// Persona-scope
|
|
||||||
personaPart := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at,
|
|
||||||
(embedding <=> $%d::vector) AS distance
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'persona' AND owner_id = $%d AND status = 'active'
|
|
||||||
AND embedding IS NOT NULL
|
|
||||||
`, idx, idx+1)
|
|
||||||
args = append(args, vecStr, *personaID)
|
|
||||||
idx += 2
|
|
||||||
parts = append(parts, personaPart)
|
|
||||||
|
|
||||||
// Persona+user scope
|
|
||||||
puPart := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at,
|
|
||||||
(embedding <=> $%d::vector) AS distance
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'persona_user' AND owner_id = $%d AND user_id = $%d AND status = 'active'
|
|
||||||
AND embedding IS NOT NULL
|
|
||||||
`, idx, idx+1, idx+2)
|
|
||||||
args = append(args, vecStr, *personaID, userID)
|
|
||||||
idx += 3
|
|
||||||
parts = append(parts, puPart)
|
|
||||||
}
|
|
||||||
|
|
||||||
args = append(args, limit)
|
|
||||||
fullQuery := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM (%s) AS combined
|
|
||||||
WHERE distance < 0.7
|
|
||||||
ORDER BY distance ASC, confidence DESC
|
|
||||||
LIMIT $%d
|
|
||||||
`, strings.Join(parts, " UNION ALL "), idx)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, fullQuery, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return scanMemories(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeResults deduplicates and merges semantic + keyword results.
|
|
||||||
func mergeResults(semantic, keyword []models.Memory, limit int) []models.Memory {
|
|
||||||
seen := make(map[string]bool, len(semantic))
|
|
||||||
result := make([]models.Memory, 0, limit)
|
|
||||||
|
|
||||||
// Semantic results first (higher relevance)
|
|
||||||
for _, m := range semantic {
|
|
||||||
if len(result) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
seen[m.ID] = true
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then keyword results not already included
|
|
||||||
for _, m := range keyword {
|
|
||||||
if len(result) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !seen[m.ID] {
|
|
||||||
seen[m.ID] = true
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MessageStore struct{}
|
|
||||||
|
|
||||||
func NewMessageStore() *MessageStore { return &MessageStore{} }
|
|
||||||
|
|
||||||
func (s *MessageStore) Create(ctx context.Context, m *models.Message) error {
|
|
||||||
toolCallsJSON := ToJSON(m.ToolCalls)
|
|
||||||
metadataJSON := ToJSON(m.Metadata)
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO messages (channel_id, role, content, model, tokens_used, tool_calls,
|
|
||||||
metadata, parent_id, sibling_index, participant_type, participant_id)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
||||||
RETURNING id, created_at`,
|
|
||||||
m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed,
|
|
||||||
toolCallsJSON, metadataJSON,
|
|
||||||
models.NullString(m.ParentID), m.SiblingIndex,
|
|
||||||
m.ParticipantType, m.ParticipantID,
|
|
||||||
).Scan(&m.ID, &m.CreatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetByID(ctx context.Context, id string) (*models.Message, error) {
|
|
||||||
var m models.Message
|
|
||||||
var parentID sql.NullString
|
|
||||||
var toolCallsJSON, metadataJSON []byte
|
|
||||||
var deletedAt sql.NullTime
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM messages WHERE id = $1`, id).Scan(
|
|
||||||
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
|
||||||
&toolCallsJSON, &metadataJSON,
|
|
||||||
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
||||||
&deletedAt, &m.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m.ParentID = NullableStringPtr(parentID)
|
|
||||||
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
|
|
||||||
json.Unmarshal(metadataJSON, &m.Metadata)
|
|
||||||
if deletedAt.Valid {
|
|
||||||
m.DeletedAt = &deletedAt.Time
|
|
||||||
}
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
b := NewUpdate("messages")
|
|
||||||
for k, v := range fields {
|
|
||||||
if k == "tool_calls" || k == "metadata" {
|
|
||||||
b.SetJSON(k, v)
|
|
||||||
} else {
|
|
||||||
b.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) Delete(ctx context.Context, id string) error {
|
|
||||||
now := time.Now()
|
|
||||||
_, err := DB.ExecContext(ctx, "UPDATE messages SET deleted_at = $1 WHERE id = $2", now, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) ListForChannel(ctx context.Context, channelID string, opts store.ListOptions) ([]models.Message, error) {
|
|
||||||
b := NewSelect(
|
|
||||||
"id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at",
|
|
||||||
"messages",
|
|
||||||
).Where("channel_id = ?", channelID).WhereRaw("deleted_at IS NULL")
|
|
||||||
if opts.Sort == "" {
|
|
||||||
b.OrderBy("created_at", "ASC")
|
|
||||||
}
|
|
||||||
b.Paginate(opts)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMessages(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetChildren(ctx context.Context, parentID string) ([]models.Message, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM messages WHERE parent_id = $1 AND deleted_at IS NULL
|
|
||||||
ORDER BY sibling_index`, parentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMessages(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE parent_id = (SELECT parent_id FROM messages WHERE id = $1)
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
ORDER BY sibling_index`, messageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMessages(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
WITH RECURSIVE path AS (
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM messages WHERE id = $1
|
|
||||||
UNION ALL
|
|
||||||
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
|
|
||||||
m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at
|
|
||||||
FROM messages m JOIN path p ON m.id = p.parent_id
|
|
||||||
)
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM path ORDER BY created_at ASC`, messageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMessages(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) {
|
|
||||||
var maxIdx sql.NullInt64
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
"SELECT MAX(sibling_index) FROM messages WHERE parent_id = $1 AND deleted_at IS NULL",
|
|
||||||
parentID).Scan(&maxIdx)
|
|
||||||
if err != nil || !maxIdx.Valid {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return int(maxIdx.Int64) + 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) CountForChannel(ctx context.Context, channelID string) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL",
|
|
||||||
channelID).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanMessages(rows *sql.Rows) ([]models.Message, error) {
|
|
||||||
var result []models.Message
|
|
||||||
for rows.Next() {
|
|
||||||
var m models.Message
|
|
||||||
var parentID sql.NullString
|
|
||||||
var toolCallsJSON, metadataJSON []byte
|
|
||||||
var deletedAt sql.NullTime
|
|
||||||
err := rows.Scan(
|
|
||||||
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
|
||||||
&toolCallsJSON, &metadataJSON,
|
|
||||||
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
||||||
&deletedAt, &m.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m.ParentID = NullableStringPtr(parentID)
|
|
||||||
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
|
|
||||||
json.Unmarshal(metadataJSON, &m.Metadata)
|
|
||||||
if deletedAt.Valid {
|
|
||||||
m.DeletedAt = &deletedAt.Time
|
|
||||||
}
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MessageStore) CountAll(ctx context.Context) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) {
|
|
||||||
roleClause := ""
|
|
||||||
queryArgs := []interface{}{channelID, query, limit}
|
|
||||||
if roleFilter == "user" || roleFilter == "assistant" {
|
|
||||||
roleClause = "AND m.role = $4"
|
|
||||||
queryArgs = append(queryArgs, roleFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT m.id, m.role,
|
|
||||||
ts_headline('english', m.content, plainto_tsquery('english', $2),
|
|
||||||
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline,
|
|
||||||
ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank,
|
|
||||||
m.created_at
|
|
||||||
FROM messages m
|
|
||||||
WHERE m.channel_id = $1
|
|
||||||
AND m.deleted_at IS NULL
|
|
||||||
AND m.role IN ('user', 'assistant')
|
|
||||||
AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2)
|
|
||||||
%s
|
|
||||||
ORDER BY rank DESC, m.created_at DESC
|
|
||||||
LIMIT $3
|
|
||||||
`, roleClause), queryArgs...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.ChannelSearchResult, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var r store.ChannelSearchResult
|
|
||||||
if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &r.Rank, &r.Timestamp); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) {
|
|
||||||
var total int
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`,
|
|
||||||
channelID).Scan(&total)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
|
|
||||||
m.sibling_index, m.participant_type, m.participant_id,
|
|
||||||
CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username)
|
|
||||||
WHEN m.participant_type = 'persona' THEN p.name
|
|
||||||
ELSE NULL END AS sender_name,
|
|
||||||
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
|
|
||||||
WHEN m.participant_type = 'persona' THEN p.avatar
|
|
||||||
ELSE NULL END AS sender_avatar,
|
|
||||||
m.created_at
|
|
||||||
FROM messages m
|
|
||||||
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
|
|
||||||
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
|
|
||||||
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
|
|
||||||
ORDER BY m.created_at ASC
|
|
||||||
LIMIT $2 OFFSET $3
|
|
||||||
`, channelID, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.MessageWithSender, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var m store.MessageWithSender
|
|
||||||
if err := rows.Scan(
|
|
||||||
&m.ID, &m.ChannelID, &m.Role, &m.Content,
|
|
||||||
&m.Model, &m.TokensUsed, &m.ParentID,
|
|
||||||
&m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
||||||
&m.SenderName, &m.SenderAvatar,
|
|
||||||
&m.CreatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, total, err
|
|
||||||
}
|
|
||||||
results = append(results, m)
|
|
||||||
}
|
|
||||||
return results, total, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) {
|
|
||||||
var parentID sql.NullString
|
|
||||||
var role string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT parent_id, role FROM messages
|
|
||||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
|
||||||
`, messageID, channelID).Scan(&parentID, &role)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return NullableStringPtr(parentID), role, nil
|
|
||||||
}
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Tree Operations (v0.29.0) ───────────────────────────────────────────
|
|
||||||
// Moved from treepath package. All message tree traversal goes through
|
|
||||||
// the store interface now.
|
|
||||||
|
|
||||||
func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) {
|
|
||||||
var leafID *string
|
|
||||||
|
|
||||||
// Try cursor first
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT active_leaf_id FROM channel_cursors
|
|
||||||
WHERE channel_id = $1 AND user_id = $2
|
|
||||||
`, channelID, userID).Scan(&leafID)
|
|
||||||
|
|
||||||
if err == nil && leafID != nil {
|
|
||||||
// Verify the leaf still exists and isn't deleted
|
|
||||||
var exists bool
|
|
||||||
DB.QueryRowContext(ctx, `
|
|
||||||
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL)
|
|
||||||
`, *leafID).Scan(&exists)
|
|
||||||
if exists {
|
|
||||||
return leafID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: latest live message in channel
|
|
||||||
var fallbackID string
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM messages
|
|
||||||
WHERE channel_id = $1 AND deleted_at IS NULL
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
`, channelID).Scan(&fallbackID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil // empty channel
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &fallbackID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
WITH RECURSIVE path AS (
|
|
||||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
participant_type, participant_id, sibling_index, created_at,
|
|
||||||
0 AS depth
|
|
||||||
FROM messages
|
|
||||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
|
|
||||||
m.participant_type, m.participant_id, m.sibling_index, m.created_at,
|
|
||||||
p.depth + 1
|
|
||||||
FROM messages m
|
|
||||||
JOIN path p ON m.id = p.parent_id
|
|
||||||
WHERE m.deleted_at IS NULL
|
|
||||||
)
|
|
||||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
participant_type, participant_id, sibling_index, created_at
|
|
||||||
FROM path
|
|
||||||
ORDER BY depth DESC
|
|
||||||
`, leafID, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GetPathToLeaf: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var path []store.PathMessage
|
|
||||||
for rows.Next() {
|
|
||||||
var m store.PathMessage
|
|
||||||
var participantType, participantID sql.NullString
|
|
||||||
var toolCallsJSON, metadataJSON []byte
|
|
||||||
if err := rows.Scan(
|
|
||||||
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
|
||||||
&toolCallsJSON, &metadataJSON,
|
|
||||||
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("GetPathToLeaf scan: %w", err)
|
|
||||||
}
|
|
||||||
if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" {
|
|
||||||
raw := json.RawMessage(toolCallsJSON)
|
|
||||||
m.ToolCalls = &raw
|
|
||||||
}
|
|
||||||
if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" {
|
|
||||||
raw := json.RawMessage(metadataJSON)
|
|
||||||
m.Metadata = &raw
|
|
||||||
}
|
|
||||||
if participantType.Valid {
|
|
||||||
m.ParticipantType = participantType.String
|
|
||||||
}
|
|
||||||
if participantID.Valid {
|
|
||||||
m.ParticipantID = participantID.String
|
|
||||||
}
|
|
||||||
path = append(path, m)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrich with sibling counts
|
|
||||||
for i := range path {
|
|
||||||
count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID)
|
|
||||||
path[i].SiblingCount = count
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve sender info
|
|
||||||
_ = s.ResolveSenderInfo(ctx, path)
|
|
||||||
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) {
|
|
||||||
leafID, err := s.GetActiveLeaf(ctx, channelID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if leafID == nil {
|
|
||||||
return []store.PathMessage{}, nil
|
|
||||||
}
|
|
||||||
return s.GetPathToLeaf(ctx, channelID, *leafID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) {
|
|
||||||
// Get parent_id and channel_id of the target message
|
|
||||||
var parentID *string
|
|
||||||
var channelID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT parent_id, channel_id FROM messages
|
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
|
||||||
`, messageID).Scan(&parentID, &channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("message not found: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows *sql.Rows
|
|
||||||
if parentID == nil {
|
|
||||||
rows, err = DB.QueryContext(ctx, `
|
|
||||||
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
|
||||||
ORDER BY sibling_index, created_at
|
|
||||||
`, channelID)
|
|
||||||
} else {
|
|
||||||
rows, err = DB.QueryContext(ctx, `
|
|
||||||
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
|
||||||
ORDER BY sibling_index, created_at
|
|
||||||
`, *parentID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var siblings []store.SiblingInfo
|
|
||||||
currentIdx := 0
|
|
||||||
for i := 0; rows.Next(); i++ {
|
|
||||||
var si store.SiblingInfo
|
|
||||||
if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
if si.ID == messageID {
|
|
||||||
currentIdx = i
|
|
||||||
}
|
|
||||||
siblings = append(siblings, si)
|
|
||||||
}
|
|
||||||
|
|
||||||
return siblings, currentIdx, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) {
|
|
||||||
var count int
|
|
||||||
var err error
|
|
||||||
if parentID == nil {
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM messages
|
|
||||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
|
||||||
`, channelID).Scan(&count)
|
|
||||||
} else {
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM messages
|
|
||||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
|
||||||
`, *parentID).Scan(&count)
|
|
||||||
}
|
|
||||||
if err != nil || count == 0 {
|
|
||||||
return 1, nil // minimum 1 (the message itself)
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) {
|
|
||||||
var leafID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
WITH RECURSIVE descendants AS (
|
|
||||||
SELECT id, 0 AS depth
|
|
||||||
FROM messages
|
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT child.id, d.depth + 1
|
|
||||||
FROM messages child
|
|
||||||
JOIN descendants d ON child.parent_id = d.id
|
|
||||||
WHERE child.deleted_at IS NULL
|
|
||||||
AND child.sibling_index = (
|
|
||||||
SELECT MIN(sibling_index) FROM messages
|
|
||||||
WHERE parent_id = d.id AND deleted_at IS NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
SELECT id FROM descendants
|
|
||||||
ORDER BY depth DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, messageID).Scan(&leafID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return messageID, nil // fallback to message itself
|
|
||||||
}
|
|
||||||
return leafID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) {
|
|
||||||
var maxIdx sql.NullInt64
|
|
||||||
var err error
|
|
||||||
if parentID == nil {
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT MAX(sibling_index) FROM messages
|
|
||||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
|
||||||
`, channelID).Scan(&maxIdx)
|
|
||||||
} else {
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT MAX(sibling_index) FROM messages
|
|
||||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
|
||||||
`, *parentID).Scan(&maxIdx)
|
|
||||||
}
|
|
||||||
if err != nil || !maxIdx.Valid {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return int(maxIdx.Int64) + 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) {
|
|
||||||
var id string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM messages
|
|
||||||
WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona'
|
|
||||||
LIMIT 1
|
|
||||||
`, channelID).Scan(&id)
|
|
||||||
return err == nil && id != "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error {
|
|
||||||
// Insert message — PG generates ID via gen_random_uuid()
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
|
||||||
tool_calls, parent_id, participant_type, participant_id,
|
|
||||||
provider_config_id, sibling_index)
|
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11)
|
|
||||||
RETURNING id, created_at`,
|
|
||||||
m.ChannelID, m.Role, m.Content, safeModel(m.Model), m.TokensUsed,
|
|
||||||
ToJSON(m.ToolCalls), models.NullString(m.ParentID),
|
|
||||||
m.ParticipantType, m.ParticipantID,
|
|
||||||
safeString(m.ProviderConfigID), m.SiblingIndex,
|
|
||||||
).Scan(&m.ID, &m.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateWithCursor insert: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cursor
|
|
||||||
if cursorUserID != "" {
|
|
||||||
_, _ = DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW()
|
|
||||||
`, m.ChannelID, cursorUserID, m.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch channel
|
|
||||||
_, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, m.ChannelID)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error {
|
|
||||||
// Collect unique participant IDs by type
|
|
||||||
userIDs := map[string]bool{}
|
|
||||||
personaIDs := map[string]bool{}
|
|
||||||
for _, m := range path {
|
|
||||||
if m.ParticipantID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch m.ParticipantType {
|
|
||||||
case "user":
|
|
||||||
userIDs[m.ParticipantID] = true
|
|
||||||
case "persona":
|
|
||||||
personaIDs[m.ParticipantID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve users
|
|
||||||
userNames := map[string]string{}
|
|
||||||
userAvatars := map[string]string{}
|
|
||||||
for uid := range userIDs {
|
|
||||||
var name, avatar sql.NullString
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1
|
|
||||||
`, uid).Scan(&name, &avatar)
|
|
||||||
if name.Valid {
|
|
||||||
userNames[uid] = name.String
|
|
||||||
}
|
|
||||||
if avatar.Valid {
|
|
||||||
userAvatars[uid] = avatar.String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve personas
|
|
||||||
personaNames := map[string]string{}
|
|
||||||
personaAvatars := map[string]string{}
|
|
||||||
for pid := range personaIDs {
|
|
||||||
var name, avatar sql.NullString
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT name, avatar FROM personas WHERE id = $1
|
|
||||||
`, pid).Scan(&name, &avatar)
|
|
||||||
if name.Valid {
|
|
||||||
personaNames[pid] = name.String
|
|
||||||
}
|
|
||||||
if avatar.Valid {
|
|
||||||
personaAvatars[pid] = avatar.String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply to path
|
|
||||||
for i := range path {
|
|
||||||
pid := path[i].ParticipantID
|
|
||||||
switch path[i].ParticipantType {
|
|
||||||
case "user":
|
|
||||||
if n, ok := userNames[pid]; ok {
|
|
||||||
path[i].SenderName = &n
|
|
||||||
}
|
|
||||||
if a, ok := userAvatars[pid]; ok && a != "" {
|
|
||||||
path[i].SenderAvatar = &a
|
|
||||||
}
|
|
||||||
case "persona":
|
|
||||||
if n, ok := personaNames[pid]; ok {
|
|
||||||
path[i].SenderName = &n
|
|
||||||
}
|
|
||||||
if a, ok := personaAvatars[pid]; ok && a != "" {
|
|
||||||
path[i].SenderAvatar = &a
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func safeModel(m string) interface{} {
|
|
||||||
if m == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeString(s *string) interface{} {
|
|
||||||
if s == nil || *s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *s
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
|
|
||||||
"github.com/lib/pq"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NoteStore struct{}
|
|
||||||
|
|
||||||
func NewNoteStore() *NoteStore { return &NoteStore{} }
|
|
||||||
|
|
||||||
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
||||||
RETURNING id, created_at, updated_at`,
|
|
||||||
n.UserID, n.Title, n.Content, n.FolderPath,
|
|
||||||
pq.Array(n.Tags), ToJSON(n.Metadata),
|
|
||||||
models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID),
|
|
||||||
models.NullString(n.TeamID),
|
|
||||||
).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
|
|
||||||
var n models.Note
|
|
||||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
|
||||||
var metadataJSON []byte
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, user_id, title, content, folder_path, tags, metadata,
|
|
||||||
source_channel_id, source_message_id, team_id, created_at, updated_at
|
|
||||||
FROM notes WHERE id = $1`, id).Scan(
|
|
||||||
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
|
||||||
pq.Array(&n.Tags), &metadataJSON,
|
|
||||||
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
|
||||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
|
||||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
|
||||||
return &n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
b := NewUpdate("notes")
|
|
||||||
for k, v := range fields {
|
|
||||||
if k == "metadata" {
|
|
||||||
b.SetJSON(k, v)
|
|
||||||
} else if k == "tags" {
|
|
||||||
if tags, ok := v.([]string); ok {
|
|
||||||
b.Set(k, pq.Array(tags))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM notes WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
|
|
||||||
b := NewSelect(
|
|
||||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
|
|
||||||
"notes",
|
|
||||||
).Where("user_id = ?", userID)
|
|
||||||
|
|
||||||
if opts.FolderPath != "" {
|
|
||||||
b.Where("folder_path = ?", opts.FolderPath)
|
|
||||||
}
|
|
||||||
if opts.Tag != "" {
|
|
||||||
b.Where("? = ANY(tags)", opts.Tag)
|
|
||||||
}
|
|
||||||
if opts.TeamID != "" {
|
|
||||||
b.Where("team_id = ?", opts.TeamID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count
|
|
||||||
countQ, countArgs := b.CountBuild()
|
|
||||||
var total int
|
|
||||||
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
|
|
||||||
|
|
||||||
if opts.Sort == "" {
|
|
||||||
b.OrderBy("updated_at", "DESC")
|
|
||||||
}
|
|
||||||
b.Paginate(opts.ListOptions)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Note
|
|
||||||
for rows.Next() {
|
|
||||||
var n models.Note
|
|
||||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
|
||||||
var metadataJSON []byte
|
|
||||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
|
||||||
pq.Array(&n.Tags), &metadataJSON,
|
|
||||||
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
|
||||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
|
||||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
|
||||||
result = append(result, n)
|
|
||||||
}
|
|
||||||
return result, total, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) {
|
|
||||||
tsQuery := strings.Join(strings.Fields(query), " & ")
|
|
||||||
|
|
||||||
b := NewSelect(
|
|
||||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
|
|
||||||
"notes",
|
|
||||||
).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery)
|
|
||||||
b.OrderBy("ts_rank(search_vector, to_tsquery('english', '"+tsQuery+"'))", "DESC")
|
|
||||||
b.Paginate(opts)
|
|
||||||
|
|
||||||
var total int
|
|
||||||
DB.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM notes WHERE user_id = $1 AND search_vector @@ to_tsquery('english', $2)",
|
|
||||||
userID, tsQuery).Scan(&total)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Note
|
|
||||||
for rows.Next() {
|
|
||||||
var n models.Note
|
|
||||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
|
||||||
var metadataJSON []byte
|
|
||||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
|
||||||
pq.Array(&n.Tags), &metadataJSON,
|
|
||||||
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
|
||||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
|
||||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
|
||||||
result = append(result, n)
|
|
||||||
}
|
|
||||||
return result, total, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
placeholders := make([]string, len(ids))
|
|
||||||
args := make([]interface{}, 0, len(ids)+1)
|
|
||||||
args = append(args, userID)
|
|
||||||
for i, id := range ids {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+2)
|
|
||||||
args = append(args, id)
|
|
||||||
}
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
fmt.Sprintf("DELETE FROM notes WHERE user_id = $1 AND id IN (%s)",
|
|
||||||
strings.Join(placeholders, ",")),
|
|
||||||
args...)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
return int(n), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) {
|
|
||||||
if limit <= 0 || limit > 50 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, title, folder_path
|
|
||||||
FROM notes
|
|
||||||
WHERE user_id = $1 AND title ILIKE '%' || $2 || '%'
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT $3`, userID, query, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.Note
|
|
||||||
for rows.Next() {
|
|
||||||
var n models.Note
|
|
||||||
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, n)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE notes SET embedding = $1::vector WHERE id = $2`,
|
|
||||||
vecStr, noteID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, title, folder_path, tags, LEFT(content, 500),
|
|
||||||
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
|
|
||||||
ts_headline('english', content, plainto_tsquery('english', $2),
|
|
||||||
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline
|
|
||||||
FROM notes
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND search_vector @@ plainto_tsquery('english', $2)
|
|
||||||
ORDER BY rank DESC
|
|
||||||
LIMIT $3
|
|
||||||
`, userID, query, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.NoteSearchResult, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var r store.NoteSearchResult
|
|
||||||
var dbTags pq.StringArray
|
|
||||||
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r.Tags = []string(dbTags)
|
|
||||||
if r.Tags == nil {
|
|
||||||
r.Tags = []string{}
|
|
||||||
}
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, title, folder_path, tags, LEFT(content, 500),
|
|
||||||
1 - (embedding <=> $2::vector) AS similarity
|
|
||||||
FROM notes
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND embedding IS NOT NULL
|
|
||||||
AND 1 - (embedding <=> $2::vector) > 0.3
|
|
||||||
ORDER BY embedding <=> $2::vector
|
|
||||||
LIMIT $3
|
|
||||||
`, userID, vecStr, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.NoteSearchResult, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var r store.NoteSearchResult
|
|
||||||
var dbTags pq.StringArray
|
|
||||||
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r.Tags = []string(dbTags)
|
|
||||||
if r.Tags == nil {
|
|
||||||
r.Tags = []string{}
|
|
||||||
}
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT DISTINCT folder_path, COUNT(*) AS count
|
|
||||||
FROM notes WHERE user_id = $1
|
|
||||||
GROUP BY folder_path
|
|
||||||
ORDER BY folder_path
|
|
||||||
`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.FolderInfo, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var f store.FolderInfo
|
|
||||||
if err := rows.Scan(&f.Path, &f.Count); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, f)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
|
|
||||||
"github.com/lib/pq"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NoteLinkStore struct{}
|
|
||||||
|
|
||||||
func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} }
|
|
||||||
|
|
||||||
func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// Delete existing links for this source note
|
|
||||||
if _, err := tx.ExecContext(ctx,
|
|
||||||
"DELETE FROM note_links WHERE source_note_id = $1", sourceNoteID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new links
|
|
||||||
if len(links) > 0 {
|
|
||||||
stmt, err := tx.PrepareContext(ctx, `
|
|
||||||
INSERT INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT (source_note_id, target_title) DO NOTHING`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
for _, l := range links {
|
|
||||||
var targetID interface{}
|
|
||||||
if l.TargetNoteID != nil {
|
|
||||||
targetID = *l.TargetNoteID
|
|
||||||
}
|
|
||||||
if _, err := stmt.ExecContext(ctx,
|
|
||||||
sourceNoteID, targetID, l.TargetTitle, l.DisplayText, l.IsTransclusion,
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE note_links
|
|
||||||
SET target_note_id = $1
|
|
||||||
WHERE target_note_id IS NULL
|
|
||||||
AND LOWER(target_title) = LOWER($2)
|
|
||||||
AND source_note_id IN (SELECT id FROM notes WHERE user_id = $3)`,
|
|
||||||
targetNoteID, title, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(nl.display_text, '')
|
|
||||||
FROM note_links nl
|
|
||||||
JOIN notes n ON n.id = nl.source_note_id
|
|
||||||
WHERE nl.target_note_id = $1
|
|
||||||
ORDER BY n.updated_at DESC`, noteID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.NoteLinkResult
|
|
||||||
for rows.Next() {
|
|
||||||
var r models.NoteLinkResult
|
|
||||||
if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath,
|
|
||||||
&r.UpdatedAt, &r.DisplayText); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) {
|
|
||||||
graph := &models.NoteGraph{
|
|
||||||
Nodes: make([]models.NoteGraphNode, 0),
|
|
||||||
Edges: make([]models.NoteGraphEdge, 0),
|
|
||||||
Unresolved: make([]models.NoteGraphDangling, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nodes: all user's notes with link counts
|
|
||||||
nodeRows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at::text,
|
|
||||||
COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT source_note_id, COUNT(*) AS cnt FROM note_links
|
|
||||||
WHERE target_note_id IS NOT NULL GROUP BY source_note_id
|
|
||||||
) outbound ON outbound.source_note_id = n.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT target_note_id, COUNT(*) AS cnt FROM note_links
|
|
||||||
WHERE target_note_id IS NOT NULL GROUP BY target_note_id
|
|
||||||
) inbound ON inbound.target_note_id = n.id
|
|
||||||
WHERE n.user_id = $1
|
|
||||||
ORDER BY n.updated_at DESC`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer nodeRows.Close()
|
|
||||||
|
|
||||||
for nodeRows.Next() {
|
|
||||||
var node models.NoteGraphNode
|
|
||||||
var tags []string
|
|
||||||
if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath,
|
|
||||||
pq.Array(&tags), &node.UpdatedAt, &node.LinkCount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if tags == nil {
|
|
||||||
tags = []string{}
|
|
||||||
}
|
|
||||||
node.Tags = tags
|
|
||||||
graph.Nodes = append(graph.Nodes, node)
|
|
||||||
}
|
|
||||||
if err := nodeRows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolved edges
|
|
||||||
edgeRows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT nl.source_note_id, nl.target_note_id, nl.target_title, nl.is_transclusion
|
|
||||||
FROM note_links nl
|
|
||||||
JOIN notes n ON n.id = nl.source_note_id
|
|
||||||
WHERE n.user_id = $1
|
|
||||||
AND nl.target_note_id IS NOT NULL`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer edgeRows.Close()
|
|
||||||
|
|
||||||
for edgeRows.Next() {
|
|
||||||
var edge models.NoteGraphEdge
|
|
||||||
if err := edgeRows.Scan(&edge.Source, &edge.Target,
|
|
||||||
&edge.Title, &edge.IsTransclusion); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
graph.Edges = append(graph.Edges, edge)
|
|
||||||
}
|
|
||||||
if err := edgeRows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unresolved links (dangling)
|
|
||||||
danglingRows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT nl.source_note_id, nl.target_title
|
|
||||||
FROM note_links nl
|
|
||||||
JOIN notes n ON n.id = nl.source_note_id
|
|
||||||
WHERE n.user_id = $1
|
|
||||||
AND nl.target_note_id IS NULL`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer danglingRows.Close()
|
|
||||||
|
|
||||||
for danglingRows.Next() {
|
|
||||||
var d models.NoteGraphDangling
|
|
||||||
if err := danglingRows.Scan(&d.Source, &d.Title); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
graph.Unresolved = append(graph.Unresolved, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
return graph, danglingRows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PersonaStore struct{}
|
|
||||||
|
|
||||||
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
|
||||||
|
|
||||||
const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
|
||||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
|
||||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
|
||||||
created_at, updated_at`
|
|
||||||
|
|
||||||
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
|
||||||
// Auto-generate handle from name if not set
|
|
||||||
if p.Handle == "" {
|
|
||||||
p.Handle = models.HandleFromName(p.Name)
|
|
||||||
}
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO personas (name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
|
||||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
|
||||||
scope, owner_id, created_by, is_active, is_shared,
|
|
||||||
memory_enabled, memory_extraction_prompt)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
|
||||||
RETURNING id, created_at, updated_at`,
|
|
||||||
p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
|
||||||
models.NullString(p.ProviderConfigID),
|
|
||||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
|
||||||
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
|
||||||
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
|
|
||||||
p.MemoryEnabled, p.MemoryExtractionPrompt,
|
|
||||||
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM personas WHERE id = $1", personaCols), id)
|
|
||||||
p, err := scanPersona(row)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Load grants
|
|
||||||
grants, _ := s.GetGrants(ctx, id)
|
|
||||||
p.Grants = grants
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) Update(ctx context.Context, id string, patch models.PersonaPatch) error {
|
|
||||||
b := NewUpdate("personas")
|
|
||||||
if patch.Name != nil {
|
|
||||||
b.Set("name", *patch.Name)
|
|
||||||
}
|
|
||||||
if patch.Handle != nil {
|
|
||||||
b.Set("handle", *patch.Handle)
|
|
||||||
}
|
|
||||||
if patch.Description != nil {
|
|
||||||
b.Set("description", *patch.Description)
|
|
||||||
}
|
|
||||||
if patch.Icon != nil {
|
|
||||||
b.Set("icon", *patch.Icon)
|
|
||||||
}
|
|
||||||
if patch.Avatar != nil {
|
|
||||||
b.Set("avatar", *patch.Avatar)
|
|
||||||
}
|
|
||||||
if patch.BaseModelID != nil {
|
|
||||||
b.Set("base_model_id", *patch.BaseModelID)
|
|
||||||
}
|
|
||||||
if patch.ProviderConfigID != nil {
|
|
||||||
b.Set("provider_config_id", models.NullString(patch.ProviderConfigID))
|
|
||||||
}
|
|
||||||
if patch.SystemPrompt != nil {
|
|
||||||
b.Set("system_prompt", *patch.SystemPrompt)
|
|
||||||
}
|
|
||||||
if patch.Temperature != nil {
|
|
||||||
b.Set("temperature", models.NullFloat(patch.Temperature))
|
|
||||||
}
|
|
||||||
if patch.MaxTokens != nil {
|
|
||||||
b.Set("max_tokens", models.NullInt(patch.MaxTokens))
|
|
||||||
}
|
|
||||||
if patch.ThinkingBudget != nil {
|
|
||||||
b.Set("thinking_budget", models.NullInt(patch.ThinkingBudget))
|
|
||||||
}
|
|
||||||
if patch.TopP != nil {
|
|
||||||
b.Set("top_p", models.NullFloat(patch.TopP))
|
|
||||||
}
|
|
||||||
if patch.IsActive != nil {
|
|
||||||
b.Set("is_active", *patch.IsActive)
|
|
||||||
}
|
|
||||||
if patch.IsShared != nil {
|
|
||||||
b.Set("is_shared", *patch.IsShared)
|
|
||||||
}
|
|
||||||
if patch.MemoryEnabled != nil {
|
|
||||||
b.Set("memory_enabled", *patch.MemoryEnabled)
|
|
||||||
}
|
|
||||||
if patch.MemoryExtractionPrompt != nil {
|
|
||||||
b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt)
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM personas WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListForUser returns all Personas visible to a user:
|
|
||||||
// global active + team-scoped (for user's teams) + personal + shared + group-granted.
|
|
||||||
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = true AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND created_by = $1)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = $1
|
|
||||||
))
|
|
||||||
OR (scope = 'personal' AND is_shared = true)
|
|
||||||
OR id IN (
|
|
||||||
SELECT rg.resource_id FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'persona'
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
WHERE gm.user_id = $1
|
|
||||||
AND gm.group_id = ANY(rg.granted_groups)
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) ORDER BY scope, name`, personaCols), userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanPersonas(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'team' AND owner_id = $1 AND is_active = true ORDER BY name", personaCols),
|
|
||||||
teamID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanPersonas(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'global' ORDER BY name", personaCols))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanPersonas(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Grants ──────────────────────────────────
|
|
||||||
|
|
||||||
// SetGrants replaces all grants for a Persona (delete + re-insert).
|
|
||||||
func (s *PersonaStore) SetGrants(ctx context.Context, personaID string, grants []models.Grant) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM persona_grants WHERE persona_id = $1", personaID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, g := range grants {
|
|
||||||
configJSON := ToJSON(g.Config)
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO persona_grants (persona_id, grant_type, grant_ref, config)
|
|
||||||
VALUES ($1, $2, $3, $4)`,
|
|
||||||
personaID, g.GrantType, g.GrantRef, configJSON)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("grant %s/%s: %w", g.GrantType, g.GrantRef, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
"SELECT id, persona_id, grant_type, grant_ref, config, created_at FROM persona_grants WHERE persona_id = $1 ORDER BY grant_type, grant_ref",
|
|
||||||
personaID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Grant
|
|
||||||
for rows.Next() {
|
|
||||||
var g models.Grant
|
|
||||||
var configJSON []byte
|
|
||||||
err := rows.Scan(&g.ID, &g.PersonaID, &g.GrantType, &g.GrantRef, &configJSON, &g.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
json.Unmarshal(configJSON, &g.Config)
|
|
||||||
result = append(result, g)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetToolGrants returns just the tool names for a Persona.
|
|
||||||
func (s *PersonaStore) GetToolGrants(ctx context.Context, personaID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
"SELECT grant_ref FROM persona_grants WHERE persona_id = $1 AND grant_type = 'tool' ORDER BY grant_ref",
|
|
||||||
personaID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []string
|
|
||||||
for rows.Next() {
|
|
||||||
var name string
|
|
||||||
if err := rows.Scan(&name); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, name)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserCanAccess checks if a user can see/use a specific Persona.
|
|
||||||
func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) {
|
|
||||||
var exists bool
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM personas WHERE id = $2 AND is_active = true AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND created_by = $1)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = $1
|
|
||||||
))
|
|
||||||
OR (scope = 'personal' AND is_shared = true)
|
|
||||||
OR id IN (
|
|
||||||
SELECT rg.resource_id FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'persona'
|
|
||||||
AND rg.resource_id = $2
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
WHERE gm.user_id = $1
|
|
||||||
AND gm.group_id = ANY(rg.granted_groups)
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)`, userID, personaID).Scan(&exists)
|
|
||||||
return exists, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scanners ────────────────────────────────
|
|
||||||
|
|
||||||
func scanPersona(row *sql.Row) (*models.Persona, error) {
|
|
||||||
var p models.Persona
|
|
||||||
var providerConfigID, ownerID sql.NullString
|
|
||||||
var handle sql.NullString
|
|
||||||
var temp, topP sql.NullFloat64
|
|
||||||
var maxTokens, thinkingBudget sql.NullInt64
|
|
||||||
err := row.Scan(
|
|
||||||
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
|
||||||
&p.BaseModelID, &providerConfigID,
|
|
||||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
|
||||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
|
||||||
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if handle.Valid {
|
|
||||||
p.Handle = handle.String
|
|
||||||
}
|
|
||||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
p.Temperature = NullableFloat64Ptr(temp)
|
|
||||||
p.MaxTokens = NullableIntPtr(maxTokens)
|
|
||||||
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
|
|
||||||
p.TopP = NullableFloat64Ptr(topP)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
|
||||||
var result []models.Persona
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.Persona
|
|
||||||
var providerConfigID, ownerID sql.NullString
|
|
||||||
var handle sql.NullString
|
|
||||||
var temp, topP sql.NullFloat64
|
|
||||||
var maxTokens, thinkingBudget sql.NullInt64
|
|
||||||
err := rows.Scan(
|
|
||||||
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
|
||||||
&p.BaseModelID, &providerConfigID,
|
|
||||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
|
||||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
|
||||||
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if handle.Valid {
|
|
||||||
p.Handle = handle.String
|
|
||||||
}
|
|
||||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
p.Temperature = NullableFloat64Ptr(temp)
|
|
||||||
p.MaxTokens = NullableIntPtr(maxTokens)
|
|
||||||
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
|
|
||||||
p.TopP = NullableFloat64Ptr(topP)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Persona-KB Bindings (v0.17.0) ───────────
|
|
||||||
|
|
||||||
func (s *PersonaStore) SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM persona_knowledge_bases WHERE persona_id = $1", personaID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, kbID := range kbIDs {
|
|
||||||
auto := false
|
|
||||||
if autoSearch != nil {
|
|
||||||
auto = autoSearch[kbID]
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO persona_knowledge_bases (persona_id, kb_id, auto_search)
|
|
||||||
VALUES ($1, $2, $3)`,
|
|
||||||
personaID, kbID, auto)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("persona KB %s: %w", kbID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT pkb.persona_id, pkb.kb_id, pkb.auto_search, pkb.added_at,
|
|
||||||
kb.name AS kb_name, kb.document_count, kb.chunk_count
|
|
||||||
FROM persona_knowledge_bases pkb
|
|
||||||
JOIN knowledge_bases kb ON kb.id = pkb.kb_id
|
|
||||||
WHERE pkb.persona_id = $1
|
|
||||||
ORDER BY kb.name`, personaID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.PersonaKB
|
|
||||||
for rows.Next() {
|
|
||||||
var pkb models.PersonaKB
|
|
||||||
if err := rows.Scan(&pkb.PersonaID, &pkb.KBID, &pkb.AutoSearch, &pkb.AddedAt,
|
|
||||||
&pkb.KBName, &pkb.DocumentCount, &pkb.ChunkCount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, pkb)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = make([]models.PersonaKB, 0)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetKBIDs(ctx context.Context, personaID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
"SELECT kb_id FROM persona_knowledge_bases WHERE persona_id = $1", personaID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
if ids == nil {
|
|
||||||
ids = make([]string, 0)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PersonaGroupStore struct{}
|
|
||||||
|
|
||||||
func NewPersonaGroupStore() *PersonaGroupStore { return &PersonaGroupStore{} }
|
|
||||||
|
|
||||||
const personaGroupCols = `id, name, description, owner_id, scope, team_id, created_at, updated_at`
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT `+personaGroupCols+` FROM persona_groups
|
|
||||||
WHERE owner_id = $1 ORDER BY name
|
|
||||||
`, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
groups := []models.PersonaGroup{}
|
|
||||||
for rows.Next() {
|
|
||||||
var g models.PersonaGroup
|
|
||||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
|
||||||
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
groups = append(groups, g)
|
|
||||||
}
|
|
||||||
return groups, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) {
|
|
||||||
var g models.PersonaGroup
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT `+personaGroupCols+` FROM persona_groups WHERE id = $1 AND owner_id = $2
|
|
||||||
`, id, ownerID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
|
||||||
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) Create(ctx context.Context, g *models.PersonaGroup) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO persona_groups (name, description, owner_id, scope)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at
|
|
||||||
`, g.Name, g.Description, g.OwnerID, g.Scope).Scan(
|
|
||||||
&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
|
||||||
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
for k, v := range fields {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE persona_groups SET `+k+` = $1, updated_at = NOW() WHERE id = $2`, v, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) Delete(ctx context.Context, id, ownerID string) (int64, error) {
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2`, id, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) GetOwnerID(ctx context.Context, id string) (string, error) {
|
|
||||||
var ownerID string
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT owner_id FROM persona_groups WHERE id = $1`, id).Scan(&ownerID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return ownerID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error {
|
|
||||||
if isLeader {
|
|
||||||
_, _ = DB.ExecContext(ctx,
|
|
||||||
`UPDATE persona_group_members SET is_leader = false WHERE group_id = $1`, groupID)
|
|
||||||
}
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO persona_group_members (group_id, persona_id, is_leader)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader
|
|
||||||
`, groupID, personaID, isLeader)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) RemoveMember(ctx context.Context, memberID, groupID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2`, memberID, groupID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order,
|
|
||||||
COALESCE(p.name, '') AS persona_name,
|
|
||||||
COALESCE(p.handle, '') AS persona_handle,
|
|
||||||
COALESCE(p.avatar, '') AS persona_avatar
|
|
||||||
FROM persona_group_members pgm
|
|
||||||
LEFT JOIN personas p ON p.id = pgm.persona_id
|
|
||||||
WHERE pgm.group_id = $1
|
|
||||||
ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id
|
|
||||||
`, groupID)
|
|
||||||
if err != nil {
|
|
||||||
return []models.PersonaGroupMember{}, nil
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
members := []models.PersonaGroupMember{}
|
|
||||||
for rows.Next() {
|
|
||||||
var m models.PersonaGroupMember
|
|
||||||
if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder,
|
|
||||||
&m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
members = append(members, m)
|
|
||||||
}
|
|
||||||
return members, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Mention resolution + display info (v0.29.0) ────────────────────────
|
|
||||||
|
|
||||||
func (s *PersonaStore) FindActiveByHandle(ctx context.Context, handle string) (string, error) {
|
|
||||||
var id string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM personas
|
|
||||||
WHERE LOWER(handle) = LOWER($1) AND is_active = true
|
|
||||||
LIMIT 1
|
|
||||||
`, handle).Scan(&id)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE LOWER($1) AND is_active = true
|
|
||||||
`, prefix+"%").Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
return "", count, nil
|
|
||||||
}
|
|
||||||
var id string
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM personas WHERE LOWER(handle) LIKE LOWER($1) AND is_active = true
|
|
||||||
`, prefix+"%").Scan(&id)
|
|
||||||
return id, 1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetNameByID(ctx context.Context, id string) (string, error) {
|
|
||||||
var name string
|
|
||||||
err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = $1`, id).Scan(&name)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return name, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) {
|
|
||||||
result := make(map[string]string)
|
|
||||||
for _, id := range ids {
|
|
||||||
var name string
|
|
||||||
err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = $1`, id).Scan(&name)
|
|
||||||
if err == nil && name != "" {
|
|
||||||
result[id] = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) {
|
|
||||||
result := make(map[string]store.UserDisplayInfo)
|
|
||||||
for _, id := range ids {
|
|
||||||
var name, avatar sql.NullString
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT name, avatar FROM personas WHERE id = $1
|
|
||||||
`, id).Scan(&name, &avatar)
|
|
||||||
if name.Valid {
|
|
||||||
info := store.UserDisplayInfo{Name: name.String}
|
|
||||||
if avatar.Valid {
|
|
||||||
info.Avatar = avatar.String
|
|
||||||
}
|
|
||||||
result[id] = info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PolicyStore struct{}
|
|
||||||
|
|
||||||
func NewPolicyStore() *PolicyStore { return &PolicyStore{} }
|
|
||||||
|
|
||||||
// Get returns a policy value by key. Falls back to PolicyDefaults if not in DB.
|
|
||||||
func (s *PolicyStore) Get(ctx context.Context, key string) (string, error) {
|
|
||||||
var value string
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
"SELECT value FROM platform_policies WHERE key = $1", key).Scan(&value)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
if def, ok := models.PolicyDefaults[key]; ok {
|
|
||||||
return def, nil
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return value, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBool returns a policy value as a boolean.
|
|
||||||
func (s *PolicyStore) GetBool(ctx context.Context, key string) (bool, error) {
|
|
||||||
val, err := s.Get(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return val == "true", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set upserts a policy value.
|
|
||||||
func (s *PolicyStore) Set(ctx context.Context, key, value, updatedBy string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO platform_policies (key, value, updated_by, updated_at)
|
|
||||||
VALUES ($1, $2, $3, NOW())
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW()`,
|
|
||||||
key, value, updatedBy)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAll returns all platform policies, merged with defaults.
|
|
||||||
func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) {
|
|
||||||
result := make(map[string]string)
|
|
||||||
// Start with defaults
|
|
||||||
for k, v := range models.PolicyDefaults {
|
|
||||||
result[k] = v
|
|
||||||
}
|
|
||||||
// Override with DB values
|
|
||||||
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM platform_policies")
|
|
||||||
if err != nil {
|
|
||||||
return result, err // return defaults on error
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var k, v string
|
|
||||||
if err := rows.Scan(&k, &v); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[k] = v
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PricingStore struct{}
|
|
||||||
|
|
||||||
func NewPricingStore() *PricingStore { return &PricingStore{} }
|
|
||||||
|
|
||||||
// GetForModel returns the pricing entry for a specific provider+model pair.
|
|
||||||
func (s *PricingStore) GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error) {
|
|
||||||
var p models.PricingEntry
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, provider_config_id, model_id,
|
|
||||||
input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
|
|
||||||
currency, source, updated_at, updated_by
|
|
||||||
FROM model_pricing
|
|
||||||
WHERE provider_config_id = $1 AND model_id = $2
|
|
||||||
`, providerConfigID, modelID).Scan(
|
|
||||||
&p.ID, &p.ProviderConfigID, &p.ModelID,
|
|
||||||
&p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
|
|
||||||
&p.Currency, &p.Source, &p.UpdatedAt, &p.UpdatedBy,
|
|
||||||
)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &p, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert inserts or updates a pricing entry (manual admin override).
|
|
||||||
func (s *PricingStore) Upsert(ctx context.Context, entry *models.PricingEntry) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO model_pricing (
|
|
||||||
provider_config_id, model_id,
|
|
||||||
input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
|
|
||||||
currency, source, updated_by, updated_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
|
||||||
ON CONFLICT (provider_config_id, model_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
input_per_m = EXCLUDED.input_per_m,
|
|
||||||
output_per_m = EXCLUDED.output_per_m,
|
|
||||||
cache_create_per_m = EXCLUDED.cache_create_per_m,
|
|
||||||
cache_read_per_m = EXCLUDED.cache_read_per_m,
|
|
||||||
currency = EXCLUDED.currency,
|
|
||||||
source = EXCLUDED.source,
|
|
||||||
updated_by = EXCLUDED.updated_by,
|
|
||||||
updated_at = NOW()
|
|
||||||
`,
|
|
||||||
entry.ProviderConfigID, entry.ModelID,
|
|
||||||
entry.InputPerM, entry.OutputPerM, entry.CacheCreatePerM, entry.CacheReadPerM,
|
|
||||||
entry.Currency, entry.Source, entry.UpdatedBy,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpsertFromCatalog inserts or updates pricing from a catalog sync.
|
|
||||||
// Manual overrides (source='manual') are never overwritten.
|
|
||||||
func (s *PricingStore) UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error {
|
|
||||||
if pricing == nil || (pricing.InputPerM == 0 && pricing.OutputPerM == 0) {
|
|
||||||
return nil // No pricing to store
|
|
||||||
}
|
|
||||||
|
|
||||||
currency := pricing.Currency
|
|
||||||
if currency == "" {
|
|
||||||
currency = "USD"
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO model_pricing (
|
|
||||||
provider_config_id, model_id,
|
|
||||||
input_per_m, output_per_m,
|
|
||||||
currency, source, updated_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, 'catalog', NOW())
|
|
||||||
ON CONFLICT (provider_config_id, model_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
input_per_m = EXCLUDED.input_per_m,
|
|
||||||
output_per_m = EXCLUDED.output_per_m,
|
|
||||||
currency = EXCLUDED.currency,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE model_pricing.source = 'catalog'
|
|
||||||
`,
|
|
||||||
providerConfigID, modelID,
|
|
||||||
pricing.InputPerM, pricing.OutputPerM,
|
|
||||||
currency,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns pricing entries for admin-managed providers (global + team).
|
|
||||||
// Excludes personal BYOK providers — those are not the admin's concern.
|
|
||||||
func (s *PricingStore) List(ctx context.Context) ([]models.PricingEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT mp.id, mp.provider_config_id, mp.model_id,
|
|
||||||
mp.input_per_m, mp.output_per_m, mp.cache_create_per_m, mp.cache_read_per_m,
|
|
||||||
mp.currency, mp.source, mp.updated_at, mp.updated_by
|
|
||||||
FROM model_pricing mp
|
|
||||||
JOIN provider_configs pc ON pc.id = mp.provider_config_id
|
|
||||||
WHERE pc.scope != 'personal'
|
|
||||||
ORDER BY mp.provider_config_id, mp.model_id
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.PricingEntry
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.PricingEntry
|
|
||||||
if err := rows.Scan(
|
|
||||||
&p.ID, &p.ProviderConfigID, &p.ModelID,
|
|
||||||
&p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
|
|
||||||
&p.Currency, &p.Source, &p.UpdatedAt, &p.UpdatedBy,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, p)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a pricing entry.
|
|
||||||
func (s *PricingStore) Delete(ctx context.Context, providerConfigID, modelID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM model_pricing WHERE provider_config_id = $1 AND model_id = $2
|
|
||||||
`, providerConfigID, modelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/lib/pq"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── ProjectStore ───────────────────────────
|
|
||||||
|
|
||||||
type ProjectStore struct{}
|
|
||||||
|
|
||||||
func NewProjectStore() *ProjectStore { return &ProjectStore{} }
|
|
||||||
|
|
||||||
// ── CRUD ────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO projects (name, description, color, icon, scope, owner_id, team_id, settings)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
RETURNING id, created_at, updated_at`,
|
|
||||||
p.Name, p.Description, p.Color, p.Icon, p.Scope,
|
|
||||||
p.OwnerID, models.NullString(p.TeamID), ToJSON(p.Settings),
|
|
||||||
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) {
|
|
||||||
var p models.Project
|
|
||||||
var teamID, workspaceID sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
|
|
||||||
p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings,
|
|
||||||
p.created_at, p.updated_at,
|
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id)
|
|
||||||
FROM projects p
|
|
||||||
WHERE p.id = $1`, id).Scan(
|
|
||||||
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
|
||||||
&p.OwnerID, &teamID, &p.IsArchived, &workspaceID, &p.Settings,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
|
||||||
p.WorkspaceID = NullableStringPtr(workspaceID)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) Update(ctx context.Context, id string, patch models.ProjectPatch) error {
|
|
||||||
b := NewUpdate("projects")
|
|
||||||
if patch.Name != nil {
|
|
||||||
b.Set("name", *patch.Name)
|
|
||||||
}
|
|
||||||
if patch.Description != nil {
|
|
||||||
b.Set("description", *patch.Description)
|
|
||||||
}
|
|
||||||
if patch.Color != nil {
|
|
||||||
b.Set("color", *patch.Color)
|
|
||||||
}
|
|
||||||
if patch.Icon != nil {
|
|
||||||
b.Set("icon", *patch.Icon)
|
|
||||||
}
|
|
||||||
if patch.IsArchived != nil {
|
|
||||||
b.Set("is_archived", *patch.IsArchived)
|
|
||||||
}
|
|
||||||
if patch.WorkspaceID != nil {
|
|
||||||
b.Set("workspace_id", models.NullString(patch.WorkspaceID))
|
|
||||||
}
|
|
||||||
if len(patch.Settings) > 0 {
|
|
||||||
// Merge: read existing settings, overlay with patch values, write back.
|
|
||||||
var existing models.JSONMap
|
|
||||||
_ = DB.QueryRowContext(ctx, "SELECT settings FROM projects WHERE id = $1", id).Scan(&existing)
|
|
||||||
if existing == nil {
|
|
||||||
existing = make(models.JSONMap)
|
|
||||||
}
|
|
||||||
for k, v := range patch.Settings {
|
|
||||||
existing[k] = v
|
|
||||||
}
|
|
||||||
b.Set("settings", ToJSON(existing))
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
res, err := b.Exec(DB)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) Delete(ctx context.Context, id string) error {
|
|
||||||
// CASCADE handles junction tables.
|
|
||||||
// Channels get project_id set to NULL via ON DELETE SET NULL.
|
|
||||||
res, err := DB.ExecContext(ctx, "DELETE FROM projects WHERE id = $1", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Listing ─────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) {
|
|
||||||
// User sees: their personal projects + team projects for their teams + global projects
|
|
||||||
q := `
|
|
||||||
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
|
|
||||||
p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings,
|
|
||||||
p.created_at, p.updated_at,
|
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id)
|
|
||||||
FROM projects p
|
|
||||||
WHERE (
|
|
||||||
(p.scope = 'personal' AND p.owner_id = $1)
|
|
||||||
OR p.scope = 'global'`
|
|
||||||
|
|
||||||
args := []interface{}{userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
q += fmt.Sprintf(`
|
|
||||||
OR (p.scope = 'team' AND p.team_id = ANY($%d))`, len(args)+1)
|
|
||||||
args = append(args, pq.Array(teamIDs))
|
|
||||||
}
|
|
||||||
q += `)`
|
|
||||||
|
|
||||||
if !includeArchived {
|
|
||||||
q += ` AND p.is_archived = false`
|
|
||||||
}
|
|
||||||
q += ` ORDER BY p.name`
|
|
||||||
|
|
||||||
return queryProjects(ctx, q, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Channel Association ─────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) AddChannel(ctx context.Context, projectID, channelID string, position int) error {
|
|
||||||
// Atomic move: remove from old project (if any), add to new.
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// Remove existing association (handles the "move" case)
|
|
||||||
tx.ExecContext(ctx, `DELETE FROM project_channels WHERE channel_id = $1`, channelID)
|
|
||||||
|
|
||||||
// Insert new association
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO project_channels (project_id, channel_id, position)
|
|
||||||
VALUES ($1, $2, $3)`,
|
|
||||||
projectID, channelID, position)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update denormalized column
|
|
||||||
_, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = $1 WHERE id = $2`,
|
|
||||||
projectID, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) RemoveChannel(ctx context.Context, projectID, channelID string) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
res, err := tx.ExecContext(ctx, `
|
|
||||||
DELETE FROM project_channels WHERE project_id = $1 AND channel_id = $2`,
|
|
||||||
projectID, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear denormalized column
|
|
||||||
_, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE id = $1`, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT project_id, channel_id, position, COALESCE(folder, ''), added_at
|
|
||||||
FROM project_channels
|
|
||||||
WHERE project_id = $1
|
|
||||||
ORDER BY position, added_at`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ProjectChannel
|
|
||||||
for rows.Next() {
|
|
||||||
var pc models.ProjectChannel
|
|
||||||
if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, &pc.Folder, &pc.AddedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, pc)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
for i, chID := range channelIDs {
|
|
||||||
_, err := tx.ExecContext(ctx, `
|
|
||||||
UPDATE project_channels SET position = $1
|
|
||||||
WHERE project_id = $2 AND channel_id = $3`,
|
|
||||||
i, projectID, chID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── KB Association ──────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (project_id, kb_id) DO UPDATE SET auto_search = EXCLUDED.auto_search`,
|
|
||||||
projectID, kbID, autoSearch)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) error {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM project_knowledge_bases WHERE project_id = $1 AND kb_id = $2`,
|
|
||||||
projectID, kbID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at,
|
|
||||||
COALESCE(kb.name, '') AS name
|
|
||||||
FROM project_knowledge_bases pk
|
|
||||||
LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id
|
|
||||||
WHERE pk.project_id = $1
|
|
||||||
ORDER BY pk.added_at`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ProjectKB
|
|
||||||
for rows.Next() {
|
|
||||||
var pkb models.ProjectKB
|
|
||||||
if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &pkb.AutoSearch, &pkb.AddedAt, &pkb.Name); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, pkb)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) GetKBIDs(ctx context.Context, projectID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT kb_id FROM project_knowledge_bases
|
|
||||||
WHERE project_id = $1`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Note Association ────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) AddNote(ctx context.Context, projectID, noteID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO project_notes (project_id, note_id)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT DO NOTHING`,
|
|
||||||
projectID, noteID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) error {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM project_notes WHERE project_id = $1 AND note_id = $2`,
|
|
||||||
projectID, noteID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT pn.project_id, pn.note_id, pn.added_at,
|
|
||||||
COALESCE(n.title, '') AS title
|
|
||||||
FROM project_notes pn
|
|
||||||
LEFT JOIN notes n ON n.id = pn.note_id
|
|
||||||
WHERE pn.project_id = $1
|
|
||||||
ORDER BY pn.added_at`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ProjectNote
|
|
||||||
for rows.Next() {
|
|
||||||
var pn models.ProjectNote
|
|
||||||
if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt, &pn.Title); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, pn)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Access Check ────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) {
|
|
||||||
q := `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM projects p
|
|
||||||
WHERE p.id = $1 AND (
|
|
||||||
(p.scope = 'personal' AND p.owner_id = $2)
|
|
||||||
OR p.scope = 'global'`
|
|
||||||
|
|
||||||
args := []interface{}{projectID, userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
q += fmt.Sprintf(`
|
|
||||||
OR (p.scope = 'team' AND p.team_id = ANY($%d))`, len(args)+1)
|
|
||||||
args = append(args, pq.Array(teamIDs))
|
|
||||||
}
|
|
||||||
q += `))`
|
|
||||||
|
|
||||||
var ok bool
|
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(&ok)
|
|
||||||
return ok, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) {
|
|
||||||
var projectID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT project_id FROM project_channels WHERE channel_id = $1`,
|
|
||||||
channelID).Scan(&projectID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil // no project — not an error
|
|
||||||
}
|
|
||||||
return projectID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT p.id, p.name, p.description, p.scope,
|
|
||||||
p.owner_id, p.team_id, p.is_archived,
|
|
||||||
p.created_at, p.updated_at,
|
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id),
|
|
||||||
COALESCE(u.username, '')
|
|
||||||
FROM projects p
|
|
||||||
LEFT JOIN users u ON u.id = p.owner_id
|
|
||||||
WHERE ($1 OR p.is_archived = false)
|
|
||||||
ORDER BY p.updated_at DESC`, includeArchived)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.AdminProject, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var p store.AdminProject
|
|
||||||
var teamID sql.NullString
|
|
||||||
if err := rows.Scan(
|
|
||||||
&p.ID, &p.Name, &p.Description, &p.Scope,
|
|
||||||
&p.OwnerID, &teamID, &p.IsArchived,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
||||||
&p.OwnerName,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
|
||||||
results = append(results, p)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Query Helper ────────────────────────────
|
|
||||||
|
|
||||||
func queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("queryProjects: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Project
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.Project
|
|
||||||
var teamID, workspaceID sql.NullString
|
|
||||||
if err := rows.Scan(
|
|
||||||
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
|
||||||
&p.OwnerID, &teamID, &p.IsArchived, &workspaceID, &p.Settings,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
|
||||||
p.WorkspaceID = NullableStringPtr(workspaceID)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProviderStore struct{}
|
|
||||||
|
|
||||||
func NewProviderStore() *ProviderStore { return &ProviderStore{} }
|
|
||||||
|
|
||||||
const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc,
|
|
||||||
key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private, created_at, updated_at`
|
|
||||||
|
|
||||||
func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc,
|
|
||||||
key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
||||||
RETURNING id, created_at, updated_at`,
|
|
||||||
cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint,
|
|
||||||
cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, cfg.ModelDefault,
|
|
||||||
ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings),
|
|
||||||
cfg.IsActive, cfg.IsPrivate,
|
|
||||||
).Scan(&cfg.ID, &cfg.CreatedAt, &cfg.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) GetByID(ctx context.Context, id string) (*models.ProviderConfig, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE id = $1", providerCols), id)
|
|
||||||
return scanProvider(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error {
|
|
||||||
b := NewUpdate("provider_configs")
|
|
||||||
if patch.Name != nil {
|
|
||||||
b.Set("name", *patch.Name)
|
|
||||||
}
|
|
||||||
if patch.Endpoint != nil {
|
|
||||||
b.Set("endpoint", *patch.Endpoint)
|
|
||||||
}
|
|
||||||
if patch.APIKeyEnc != nil {
|
|
||||||
b.Set("api_key_enc", patch.APIKeyEnc)
|
|
||||||
b.Set("key_nonce", patch.KeyNonce)
|
|
||||||
}
|
|
||||||
if patch.ModelDefault != nil {
|
|
||||||
b.Set("model_default", *patch.ModelDefault)
|
|
||||||
}
|
|
||||||
if patch.Config != nil {
|
|
||||||
b.SetJSON("config", patch.Config)
|
|
||||||
}
|
|
||||||
if patch.Headers != nil {
|
|
||||||
b.SetJSON("headers", patch.Headers)
|
|
||||||
}
|
|
||||||
if patch.Settings != nil {
|
|
||||||
b.SetJSON("settings", patch.Settings)
|
|
||||||
}
|
|
||||||
if patch.IsActive != nil {
|
|
||||||
b.Set("is_active", *patch.IsActive)
|
|
||||||
}
|
|
||||||
if patch.IsPrivate != nil {
|
|
||||||
b.Set("is_private", *patch.IsPrivate)
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM provider_configs WHERE id = $1", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) ListGlobal(ctx context.Context) ([]models.ProviderConfig, error) {
|
|
||||||
return s.listByScope(ctx, models.ScopeGlobal, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) {
|
|
||||||
return s.listByScope(ctx, models.ScopeTeam, teamID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
|
|
||||||
return s.listByScope(ctx, models.ScopePersonal, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAccessible returns all provider configs a user can access:
|
|
||||||
// global + team (for teams they belong to) + personal.
|
|
||||||
func (s *ProviderStore) ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT %s FROM provider_configs
|
|
||||||
WHERE is_active = true AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = $1)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = $1
|
|
||||||
))
|
|
||||||
)
|
|
||||||
ORDER BY scope, name`, providerCols), userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanProviders(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserCanAccess checks if a user can use a specific provider config.
|
|
||||||
func (s *ProviderStore) UserCanAccess(ctx context.Context, userID, configID string) (bool, error) {
|
|
||||||
var exists bool
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM provider_configs
|
|
||||||
WHERE id = $2 AND is_active = true AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = $1)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = $1
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)`, userID, configID).Scan(&exists)
|
|
||||||
return exists, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal helpers ────────────────────────
|
|
||||||
|
|
||||||
func (s *ProviderStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ProviderConfig, error) {
|
|
||||||
var rows *sql.Rows
|
|
||||||
var err error
|
|
||||||
if scope == models.ScopeGlobal {
|
|
||||||
rows, err = DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND is_active = true ORDER BY name", providerCols),
|
|
||||||
scope)
|
|
||||||
} else {
|
|
||||||
rows, err = DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND owner_id = $2 AND is_active = true ORDER BY name", providerCols),
|
|
||||||
scope, ownerID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanProviders(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanProvider(row *sql.Row) (*models.ProviderConfig, error) {
|
|
||||||
var p models.ProviderConfig
|
|
||||||
var ownerID, modelDefault, keyScope sql.NullString
|
|
||||||
var configJSON, headersJSON, settingsJSON []byte
|
|
||||||
err := row.Scan(
|
|
||||||
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
|
|
||||||
&p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
|
|
||||||
&configJSON, &headersJSON, &settingsJSON,
|
|
||||||
&p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
p.ModelDefault = modelDefault.String
|
|
||||||
p.KeyScope = keyScope.String
|
|
||||||
json.Unmarshal(configJSON, &p.Config)
|
|
||||||
json.Unmarshal(headersJSON, &p.Headers)
|
|
||||||
json.Unmarshal(settingsJSON, &p.Settings)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
|
|
||||||
var result []models.ProviderConfig
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.ProviderConfig
|
|
||||||
var ownerID, modelDefault, keyScope sql.NullString
|
|
||||||
var configJSON, headersJSON, settingsJSON []byte
|
|
||||||
err := rows.Scan(
|
|
||||||
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
|
|
||||||
&p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
|
|
||||||
&configJSON, &headersJSON, &settingsJSON,
|
|
||||||
&p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
p.ModelDefault = modelDefault.String
|
|
||||||
p.KeyScope = keyScope.String
|
|
||||||
json.Unmarshal(configJSON, &p.Config)
|
|
||||||
json.Unmarshal(headersJSON, &p.Headers)
|
|
||||||
json.Unmarshal(settingsJSON, &p.Settings)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS4 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProviderStore) DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) {
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1`, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProviderStore) ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = 'team' AND owner_id = $1 ORDER BY name", providerCols),
|
|
||||||
teamID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanProviders(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) {
|
|
||||||
res, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`,
|
|
||||||
id, teamID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProviderStore) FindFirstForUser(ctx context.Context, userID string) (string, error) {
|
|
||||||
var configID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM provider_configs
|
|
||||||
WHERE is_active = true AND (
|
|
||||||
(scope = 'personal' AND owner_id = $1)
|
|
||||||
OR scope = 'global'
|
|
||||||
)
|
|
||||||
ORDER BY scope ASC, created_at ASC
|
|
||||||
LIMIT 1
|
|
||||||
`, userID).Scan(&configID)
|
|
||||||
return configID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) {
|
|
||||||
row := DB.QueryRowContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT %s FROM provider_configs
|
|
||||||
WHERE id = $1 AND is_active = true
|
|
||||||
AND (scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = $2)
|
|
||||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
|
||||||
`, providerCols), configID, userID)
|
|
||||||
return scanProvider(row)
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RoutingPolicyStore implements store.RoutingPolicyStore for Postgres.
|
|
||||||
type RoutingPolicyStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRoutingPolicyStore(db *sql.DB) *RoutingPolicyStore {
|
|
||||||
return &RoutingPolicyStore{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) Create(ctx context.Context, p *models.RoutingPolicy) error {
|
|
||||||
configJSON, err := json.Marshal(p.Config)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.db.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO routing_policies (name, scope, team_id, priority, policy_type, config, is_active)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`, p.Name, p.Scope, p.TeamID, p.Priority, p.Type, configJSON, p.IsActive,
|
|
||||||
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) Update(ctx context.Context, p *models.RoutingPolicy) error {
|
|
||||||
configJSON, err := json.Marshal(p.Config)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.db.ExecContext(ctx, `
|
|
||||||
UPDATE routing_policies
|
|
||||||
SET name = $2, scope = $3, team_id = $4, priority = $5,
|
|
||||||
policy_type = $6, config = $7, is_active = $8
|
|
||||||
WHERE id = $1
|
|
||||||
`, p.ID, p.Name, p.Scope, p.TeamID, p.Priority, p.Type, configJSON, p.IsActive)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `DELETE FROM routing_policies WHERE id = $1`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) GetByID(ctx context.Context, id string) (*models.RoutingPolicy, error) {
|
|
||||||
var p models.RoutingPolicy
|
|
||||||
var configJSON []byte
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
|
||||||
FROM routing_policies WHERE id = $1
|
|
||||||
`, id).Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &p.IsActive, &p.CreatedAt, &p.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
json.Unmarshal(configJSON, &p.Config)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) ListActive(ctx context.Context) ([]models.RoutingPolicy, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
|
||||||
FROM routing_policies
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY priority ASC, created_at ASC
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) ListAll(ctx context.Context) ([]models.RoutingPolicy, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
|
||||||
FROM routing_policies
|
|
||||||
ORDER BY priority ASC, created_at ASC
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
|
||||||
FROM routing_policies
|
|
||||||
WHERE is_active = true AND (scope = 'global' OR (scope = 'team' AND team_id = $1))
|
|
||||||
ORDER BY priority ASC, created_at ASC
|
|
||||||
`, teamID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) query(ctx context.Context, q string, args ...interface{}) ([]models.RoutingPolicy, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.RoutingPolicy
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.RoutingPolicy
|
|
||||||
var configJSON []byte
|
|
||||||
if err := rows.Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &p.IsActive, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
json.Unmarshal(configJSON, &p.Config)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -11,47 +11,25 @@ import (
|
|||||||
func NewStores(db *sql.DB) store.Stores {
|
func NewStores(db *sql.DB) store.Stores {
|
||||||
SetDB(db)
|
SetDB(db)
|
||||||
return store.Stores{
|
return store.Stores{
|
||||||
Providers: NewProviderStore(),
|
|
||||||
Catalog: NewCatalogStore(),
|
|
||||||
Personas: NewPersonaStore(),
|
|
||||||
Policies: NewPolicyStore(),
|
|
||||||
UserSettings: NewUserModelSettingsStore(),
|
|
||||||
Users: NewUserStore(),
|
Users: NewUserStore(),
|
||||||
Teams: NewTeamStore(),
|
Teams: NewTeamStore(),
|
||||||
Channels: NewChannelStore(),
|
|
||||||
Messages: NewMessageStore(),
|
|
||||||
Audit: NewAuditStore(),
|
Audit: NewAuditStore(),
|
||||||
Notes: NewNoteStore(),
|
|
||||||
NoteLinks: NewNoteLinkStore(),
|
|
||||||
GlobalConfig: NewGlobalConfigStore(),
|
GlobalConfig: NewGlobalConfigStore(),
|
||||||
Usage: NewUsageStore(),
|
|
||||||
Pricing: NewPricingStore(),
|
|
||||||
Files: NewFileStore(),
|
|
||||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
|
||||||
Groups: NewGroupStore(),
|
Groups: NewGroupStore(),
|
||||||
ResourceGrants: NewResourceGrantStore(),
|
ResourceGrants: NewResourceGrantStore(),
|
||||||
Memories: NewMemoryStore(),
|
|
||||||
Projects: NewProjectStore(),
|
|
||||||
Notifications: NewNotificationStore(),
|
Notifications: NewNotificationStore(),
|
||||||
NotifPrefs: NewNotificationPreferenceStore(),
|
NotifPrefs: NewNotificationPreferenceStore(),
|
||||||
Workspaces: NewWorkspaceStore(),
|
|
||||||
GitCredentials: &GitCredentialStore{},
|
|
||||||
CapOverrides: NewCapOverrideStore(db),
|
|
||||||
RoutingPolicies: NewRoutingPolicyStore(db),
|
|
||||||
Sessions: NewSessionStore(),
|
Sessions: NewSessionStore(),
|
||||||
|
Presence: NewPresenceStore(),
|
||||||
|
Health: NewHealthStore(db),
|
||||||
|
Connections: NewConnectionStore(),
|
||||||
|
Dependencies: NewDependencyStore(),
|
||||||
Packages: NewPackageStore(),
|
Packages: NewPackageStore(),
|
||||||
Workflows: NewWorkflowStore(),
|
Workflows: NewWorkflowStore(),
|
||||||
Tasks: NewTaskStore(),
|
Tasks: NewTaskStore(),
|
||||||
Presence: NewPresenceStore(),
|
|
||||||
PersonaGroups: NewPersonaGroupStore(),
|
|
||||||
Folders: NewFolderStore(),
|
|
||||||
Health: NewHealthStore(db),
|
|
||||||
ExtPermissions: NewExtensionPermissionStore(db),
|
ExtPermissions: NewExtensionPermissionStore(db),
|
||||||
ExtData: NewExtDataStore(db),
|
ExtData: NewExtDataStore(db),
|
||||||
Tickets: NewTicketStore(),
|
Tickets: NewTicketStore(),
|
||||||
RateLimits: NewRateLimitStore(),
|
RateLimits: NewRateLimitStore(),
|
||||||
Export: NewExportStore(),
|
|
||||||
Connections: NewConnectionStore(),
|
|
||||||
Dependencies: NewDependencyStore(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UsageStore struct{}
|
|
||||||
|
|
||||||
func NewUsageStore() *UsageStore { return &UsageStore{} }
|
|
||||||
|
|
||||||
// Log inserts a usage entry.
|
|
||||||
func (s *UsageStore) Log(ctx context.Context, entry *models.UsageEntry) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO usage_log (
|
|
||||||
channel_id, user_id, provider_config_id, provider_scope,
|
|
||||||
model_id, role,
|
|
||||||
prompt_tokens, completion_tokens,
|
|
||||||
cache_creation_tokens, cache_read_tokens,
|
|
||||||
cost_input, cost_output
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
||||||
`,
|
|
||||||
entry.ChannelID, entry.UserID, entry.ProviderConfigID, entry.ProviderScope,
|
|
||||||
entry.ModelID, entry.Role,
|
|
||||||
entry.PromptTokens, entry.CompletionTokens,
|
|
||||||
entry.CacheCreationTokens, entry.CacheReadTokens,
|
|
||||||
entry.CostInput, entry.CostOutput,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByUser returns aggregated usage for a specific user.
|
|
||||||
func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where := []string{"user_id = $1"}
|
|
||||||
args := []interface{}{userID}
|
|
||||||
return s.query(ctx, where, args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByUserPersonal returns aggregated usage for a user's own (BYOK) providers only.
|
|
||||||
func (s *UsageStore) QueryByUserPersonal(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where := []string{
|
|
||||||
"user_id = $1",
|
|
||||||
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)",
|
|
||||||
}
|
|
||||||
args := []interface{}{userID}
|
|
||||||
return s.query(ctx, where, args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByTeam returns aggregated usage for all members of a team (admin view).
|
|
||||||
func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"}
|
|
||||||
args := []interface{}{teamID}
|
|
||||||
return s.query(ctx, where, args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByTeamProviders returns usage against providers owned by this team.
|
|
||||||
// Used by team admins to see how their team's API keys are being consumed.
|
|
||||||
func (s *UsageStore) QueryByTeamProviders(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = $1)"}
|
|
||||||
args := []interface{}{teamID}
|
|
||||||
return s.query(ctx, where, args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByModel returns aggregated usage grouped by model.
|
|
||||||
func (s *UsageStore) QueryByModel(ctx context.Context, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
return s.query(ctx, nil, nil, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTotals returns aggregate totals (admin view — excludes BYOK by default).
|
|
||||||
func (s *UsageStore) GetTotals(ctx context.Context, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
|
||||||
where, args := s.buildFilters(nil, nil, opts)
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
COUNT(*),
|
|
||||||
COALESCE(SUM(prompt_tokens), 0),
|
|
||||||
COALESCE(SUM(completion_tokens), 0),
|
|
||||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
|
||||||
FROM usage_log
|
|
||||||
WHERE %s
|
|
||||||
`, strings.Join(where, " AND "))
|
|
||||||
|
|
||||||
var t models.UsageTotals
|
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
|
||||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
|
||||||
)
|
|
||||||
return &t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTeamProviderTotals returns aggregate totals for providers owned by a team.
|
|
||||||
func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
|
||||||
baseWhere := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = $1)"}
|
|
||||||
baseArgs := []interface{}{teamID}
|
|
||||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
COUNT(*),
|
|
||||||
COALESCE(SUM(prompt_tokens), 0),
|
|
||||||
COALESCE(SUM(completion_tokens), 0),
|
|
||||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
|
||||||
FROM usage_log
|
|
||||||
WHERE %s
|
|
||||||
`, strings.Join(where, " AND "))
|
|
||||||
|
|
||||||
var t models.UsageTotals
|
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
|
||||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
|
||||||
)
|
|
||||||
return &t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPersonalTotals returns aggregate totals for a user's own provider usage.
|
|
||||||
// Only includes usage against personal (BYOK) providers — global provider
|
|
||||||
// costs are the org's responsibility, not the user's.
|
|
||||||
func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
|
||||||
baseWhere := []string{
|
|
||||||
"user_id = $1",
|
|
||||||
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)",
|
|
||||||
}
|
|
||||||
baseArgs := []interface{}{userID}
|
|
||||||
opts.ExcludeBYOK = false
|
|
||||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
COUNT(*),
|
|
||||||
COALESCE(SUM(prompt_tokens), 0),
|
|
||||||
COALESCE(SUM(completion_tokens), 0),
|
|
||||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
|
||||||
FROM usage_log
|
|
||||||
WHERE %s
|
|
||||||
`, strings.Join(where, " AND "))
|
|
||||||
|
|
||||||
var t models.UsageTotals
|
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
|
||||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
|
||||||
)
|
|
||||||
return &t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountRecentByRole counts how many usage entries a user has for a given
|
|
||||||
// role within the specified duration. Used for rate limiting utility calls.
|
|
||||||
func (s *UsageStore) CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM usage_log
|
|
||||||
WHERE user_id = $1 AND role = $2 AND created_at >= $3
|
|
||||||
`, userID, role, since).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal ───────────────────────────────
|
|
||||||
|
|
||||||
func (s *UsageStore) buildFilters(baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]string, []interface{}) {
|
|
||||||
where := append([]string{}, baseWhere...)
|
|
||||||
args := append([]interface{}{}, baseArgs...)
|
|
||||||
idx := len(args) + 1
|
|
||||||
|
|
||||||
if opts.ExcludeBYOK {
|
|
||||||
where = append(where, fmt.Sprintf("provider_scope != $%d", idx))
|
|
||||||
args = append(args, "personal")
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
if opts.Since != nil {
|
|
||||||
where = append(where, fmt.Sprintf("created_at >= $%d", idx))
|
|
||||||
args = append(args, *opts.Since)
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
if opts.Until != nil {
|
|
||||||
where = append(where, fmt.Sprintf("created_at < $%d", idx))
|
|
||||||
args = append(args, *opts.Until)
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(where) == 0 {
|
|
||||||
where = append(where, "1=1")
|
|
||||||
}
|
|
||||||
return where, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UsageStore) query(ctx context.Context, baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
|
||||||
|
|
||||||
// Determine GROUP BY expression and label
|
|
||||||
groupExpr := "model_id"
|
|
||||||
labelExpr := "model_id"
|
|
||||||
switch opts.GroupBy {
|
|
||||||
case "day":
|
|
||||||
groupExpr = "DATE(created_at)"
|
|
||||||
labelExpr = "DATE(created_at)::text"
|
|
||||||
case "user":
|
|
||||||
groupExpr = "user_id"
|
|
||||||
labelExpr = "user_id::text"
|
|
||||||
case "provider":
|
|
||||||
groupExpr = "provider_config_id"
|
|
||||||
labelExpr = "provider_config_id::text"
|
|
||||||
case "model":
|
|
||||||
groupExpr = "model_id"
|
|
||||||
labelExpr = "model_id"
|
|
||||||
default:
|
|
||||||
groupExpr = "model_id"
|
|
||||||
labelExpr = "model_id"
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := opts.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
%s AS group_key,
|
|
||||||
%s AS label,
|
|
||||||
COUNT(*) AS requests,
|
|
||||||
COALESCE(SUM(prompt_tokens), 0) AS input_tokens,
|
|
||||||
COALESCE(SUM(completion_tokens), 0) AS output_tokens,
|
|
||||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0) AS total_cost
|
|
||||||
FROM usage_log
|
|
||||||
WHERE %s
|
|
||||||
GROUP BY %s
|
|
||||||
ORDER BY total_cost DESC
|
|
||||||
LIMIT %d
|
|
||||||
`, groupExpr, labelExpr, strings.Join(where, " AND "), groupExpr, limit)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.UsageAggregate
|
|
||||||
for rows.Next() {
|
|
||||||
var a models.UsageAggregate
|
|
||||||
if err := rows.Scan(&a.GroupKey, &a.Label, &a.Requests, &a.InputTokens, &a.OutputTokens, &a.TotalCost); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, a)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Mention resolution + display info (v0.29.0) ────────────────────────
|
|
||||||
|
|
||||||
func (s *UserStore) FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) {
|
|
||||||
var id string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM users
|
|
||||||
WHERE LOWER(handle) = LOWER($1) AND id != $2 AND is_active = true
|
|
||||||
LIMIT 1
|
|
||||||
`, handle, excludeUserID).Scan(&id)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserStore) FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM users
|
|
||||||
WHERE LOWER(handle) LIKE LOWER($1) AND id != $2 AND is_active = true
|
|
||||||
`, prefix+"%", excludeUserID).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
return "", count, nil
|
|
||||||
}
|
|
||||||
var id string
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM users
|
|
||||||
WHERE LOWER(handle) LIKE LOWER($1) AND id != $2 AND is_active = true
|
|
||||||
LIMIT 1
|
|
||||||
`, prefix+"%", excludeUserID).Scan(&id)
|
|
||||||
return id, 1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) {
|
|
||||||
result := make(map[string]store.UserDisplayInfo)
|
|
||||||
for _, id := range ids {
|
|
||||||
var name, avatar sql.NullString
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1
|
|
||||||
`, id).Scan(&name, &avatar)
|
|
||||||
if name.Valid {
|
|
||||||
info := store.UserDisplayInfo{Name: name.String}
|
|
||||||
if avatar.Valid {
|
|
||||||
info.Avatar = avatar.String
|
|
||||||
}
|
|
||||||
result[id] = info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserModelSettingsStore struct{}
|
|
||||||
|
|
||||||
func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSettingsStore{} }
|
|
||||||
|
|
||||||
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens,
|
|
||||||
COALESCE(sort_order, 0), created_at, updated_at
|
|
||||||
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.UserModelSetting
|
|
||||||
for rows.Next() {
|
|
||||||
var s models.UserModelSetting
|
|
||||||
var prefTemp, prefMaxTokens interface{}
|
|
||||||
err := rows.Scan(
|
|
||||||
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
|
|
||||||
&prefTemp, &prefMaxTokens,
|
|
||||||
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if f, ok := prefTemp.(float64); ok {
|
|
||||||
s.PreferredTemperature = &f
|
|
||||||
}
|
|
||||||
if n, ok := prefMaxTokens.(int64); ok {
|
|
||||||
v := int(n)
|
|
||||||
s.PreferredMaxTokens = &v
|
|
||||||
}
|
|
||||||
result = append(result, s)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models.
|
|
||||||
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
"SELECT model_id, COALESCE(provider_config_id::text, '') FROM user_model_settings WHERE user_id = $1 AND hidden = true",
|
|
||||||
userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
result := make(map[string]bool)
|
|
||||||
for rows.Next() {
|
|
||||||
var modelID, provCfgID string
|
|
||||||
if err := rows.Scan(&modelID, &provCfgID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result[models.CompositeModelKey(provCfgID, modelID)] = true
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set upserts a single user model setting.
|
|
||||||
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
|
|
||||||
b := NewUpdate("user_model_settings")
|
|
||||||
if patch.Hidden != nil {
|
|
||||||
b.Set("hidden", *patch.Hidden)
|
|
||||||
}
|
|
||||||
if patch.PreferredTemperature != nil {
|
|
||||||
b.Set("preferred_temperature", models.NullFloat(patch.PreferredTemperature))
|
|
||||||
}
|
|
||||||
if patch.PreferredMaxTokens != nil {
|
|
||||||
b.Set("preferred_max_tokens", models.NullInt(patch.PreferredMaxTokens))
|
|
||||||
}
|
|
||||||
if patch.SortOrder != nil {
|
|
||||||
b.Set("sort_order", *patch.SortOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use upsert: insert if not exists, update if exists
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
|
||||||
VALUES ($1, $2, $3, COALESCE($4, false), $5, $6, COALESCE($7, 0))
|
|
||||||
ON CONFLICT (user_id, model_id, provider_config_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
hidden = COALESCE($4, user_model_settings.hidden),
|
|
||||||
preferred_temperature = COALESCE($5, user_model_settings.preferred_temperature),
|
|
||||||
preferred_max_tokens = COALESCE($6, user_model_settings.preferred_max_tokens),
|
|
||||||
sort_order = COALESCE($7, user_model_settings.sort_order)`,
|
|
||||||
userID, modelID, providerConfigID,
|
|
||||||
patchBoolOrNil(patch.Hidden),
|
|
||||||
patchFloat64OrNil(patch.PreferredTemperature),
|
|
||||||
patchIntOrNil(patch.PreferredMaxTokens),
|
|
||||||
patchIntOrNil(patch.SortOrder),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
|
|
||||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = $4`,
|
|
||||||
userID, entry.ModelID, entry.ProviderConfigID, hidden)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func patchBoolOrNil(b *bool) interface{} {
|
|
||||||
if b == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *b
|
|
||||||
}
|
|
||||||
|
|
||||||
func patchFloat64OrNil(f *float64) interface{} {
|
|
||||||
if f == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *f
|
|
||||||
}
|
|
||||||
|
|
||||||
func patchIntOrNil(i *int) interface{} {
|
|
||||||
if i == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *i
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WorkspaceStore struct{}
|
|
||||||
|
|
||||||
func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
|
|
||||||
|
|
||||||
// ── columns ────────────────────────────────
|
|
||||||
|
|
||||||
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, git_remote_url, git_branch, git_credential_id, git_last_sync, created_at, updated_at`
|
|
||||||
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at`
|
|
||||||
|
|
||||||
// ── scanners ───────────────────────────────
|
|
||||||
|
|
||||||
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
|
||||||
var w models.Workspace
|
|
||||||
var maxBytes sql.NullInt64
|
|
||||||
var gitRemote, gitBranch, gitCredID sql.NullString
|
|
||||||
var gitSync sql.NullTime
|
|
||||||
err := row.Scan(
|
|
||||||
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
|
||||||
&w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled,
|
|
||||||
&gitRemote, &gitBranch, &gitCredID, &gitSync,
|
|
||||||
&w.CreatedAt, &w.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if maxBytes.Valid {
|
|
||||||
w.MaxBytes = &maxBytes.Int64
|
|
||||||
}
|
|
||||||
if gitRemote.Valid {
|
|
||||||
w.GitRemoteURL = &gitRemote.String
|
|
||||||
}
|
|
||||||
if gitBranch.Valid {
|
|
||||||
w.GitBranch = &gitBranch.String
|
|
||||||
}
|
|
||||||
if gitCredID.Valid {
|
|
||||||
w.GitCredentialID = &gitCredID.String
|
|
||||||
}
|
|
||||||
if gitSync.Valid {
|
|
||||||
w.GitLastSync = &gitSync.Time
|
|
||||||
}
|
|
||||||
return &w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) {
|
|
||||||
var f models.WorkspaceFile
|
|
||||||
var contentType sql.NullString
|
|
||||||
var sha256 sql.NullString
|
|
||||||
var indexStatus sql.NullString
|
|
||||||
err := row.Scan(
|
|
||||||
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
|
|
||||||
&contentType, &f.SizeBytes, &sha256,
|
|
||||||
&indexStatus, &f.ChunkCount,
|
|
||||||
&f.CreatedAt, &f.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if contentType.Valid {
|
|
||||||
f.ContentType = contentType.String
|
|
||||||
}
|
|
||||||
if sha256.Valid {
|
|
||||||
f.SHA256 = sha256.String
|
|
||||||
}
|
|
||||||
if indexStatus.Valid {
|
|
||||||
f.IndexStatus = indexStatus.String
|
|
||||||
}
|
|
||||||
return &f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Workspace CRUD ─────────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error {
|
|
||||||
// Accept pre-generated ID or let Postgres generate one
|
|
||||||
if w.ID != "" {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING created_at, updated_at`,
|
|
||||||
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
|
||||||
w.MaxBytes, w.Status,
|
|
||||||
).Scan(&w.CreatedAt, &w.UpdatedAt)
|
|
||||||
}
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO workspaces (owner_type, owner_id, name, root_path, max_bytes, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id, created_at, updated_at`,
|
|
||||||
w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
|
||||||
w.MaxBytes, w.Status,
|
|
||||||
).Scan(&w.ID, &w.CreatedAt, &w.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT `+workspaceCols+` FROM workspaces WHERE id = $1`, id)
|
|
||||||
return scanWorkspace(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
|
|
||||||
b := NewUpdate("workspaces")
|
|
||||||
if patch.Name != nil {
|
|
||||||
b.Set("name", *patch.Name)
|
|
||||||
}
|
|
||||||
if patch.MaxBytes != nil {
|
|
||||||
b.Set("max_bytes", *patch.MaxBytes)
|
|
||||||
}
|
|
||||||
if patch.Status != nil {
|
|
||||||
b.Set("status", *patch.Status)
|
|
||||||
}
|
|
||||||
if patch.IndexingEnabled != nil {
|
|
||||||
b.Set("indexing_enabled", *patch.IndexingEnabled)
|
|
||||||
}
|
|
||||||
if patch.GitRemoteURL != nil {
|
|
||||||
b.Set("git_remote_url", *patch.GitRemoteURL)
|
|
||||||
}
|
|
||||||
if patch.GitBranch != nil {
|
|
||||||
b.Set("git_branch", *patch.GitBranch)
|
|
||||||
}
|
|
||||||
if patch.GitCredentialID != nil {
|
|
||||||
b.Set("git_credential_id", *patch.GitCredentialID)
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Set("updated_at", time.Now().UTC())
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = $1`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ownership lookup ───────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT `+workspaceCols+` FROM workspaces
|
|
||||||
WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting'
|
|
||||||
ORDER BY created_at DESC LIMIT 1`,
|
|
||||||
ownerType, ownerID)
|
|
||||||
return scanWorkspace(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
`SELECT `+workspaceCols+` FROM workspaces
|
|
||||||
WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting'
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
ownerType, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []models.Workspace
|
|
||||||
for rows.Next() {
|
|
||||||
w, err := scanWorkspace(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, *w)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File index ─────────────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO workspace_files (workspace_id, path, is_directory, content_type, size_bytes, sha256)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (workspace_id, path) DO UPDATE SET
|
|
||||||
is_directory = EXCLUDED.is_directory,
|
|
||||||
content_type = EXCLUDED.content_type,
|
|
||||||
size_bytes = EXCLUDED.size_bytes,
|
|
||||||
sha256 = EXCLUDED.sha256,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING id, created_at, updated_at`,
|
|
||||||
f.WorkspaceID, f.Path, f.IsDirectory,
|
|
||||||
models.NullString(&f.ContentType), f.SizeBytes,
|
|
||||||
models.NullString(&f.SHA256),
|
|
||||||
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM workspace_files WHERE workspace_id = $1 AND path = $2`,
|
|
||||||
workspaceID, path)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2`,
|
|
||||||
workspaceID, prefix+"%")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT `+workspaceFileCols+` FROM workspace_files
|
|
||||||
WHERE workspace_id = $1 AND path = $2`,
|
|
||||||
workspaceID, path)
|
|
||||||
return scanWorkspaceFile(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
|
||||||
var query string
|
|
||||||
var args []interface{}
|
|
||||||
|
|
||||||
if recursive {
|
|
||||||
// All files under prefix (recursive)
|
|
||||||
if prefix == "" {
|
|
||||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
|
||||||
WHERE workspace_id = $1 ORDER BY path`
|
|
||||||
args = []interface{}{workspaceID}
|
|
||||||
} else {
|
|
||||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
|
||||||
WHERE workspace_id = $1 AND path LIKE $2 ORDER BY path`
|
|
||||||
args = []interface{}{workspaceID, prefix + "%"}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Direct children only: match prefix but no additional slashes
|
|
||||||
if prefix == "" {
|
|
||||||
// Root level: no slash in path
|
|
||||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
|
||||||
WHERE workspace_id = $1 AND path NOT LIKE '%/%' ORDER BY path`
|
|
||||||
args = []interface{}{workspaceID}
|
|
||||||
} else {
|
|
||||||
// Children of prefix: starts with prefix, no additional slash after prefix
|
|
||||||
query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files
|
|
||||||
WHERE workspace_id = $1 AND path LIKE $2
|
|
||||||
AND substring(path FROM %d) NOT LIKE '%%/%%'
|
|
||||||
ORDER BY path`, len(prefix)+1)
|
|
||||||
args = []interface{}{workspaceID, prefix + "%"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []models.WorkspaceFile
|
|
||||||
for rows.Next() {
|
|
||||||
f, err := scanWorkspaceFile(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, *f)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM workspace_files WHERE workspace_id = $1`, workspaceID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats ──────────────────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
|
|
||||||
stats := &models.WorkspaceStats{WorkspaceID: workspaceID}
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0),
|
|
||||||
COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0),
|
|
||||||
COALESCE(SUM(size_bytes), 0)
|
|
||||||
FROM workspace_files WHERE workspace_id = $1`,
|
|
||||||
workspaceID,
|
|
||||||
).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch quota
|
|
||||||
var maxBytes sql.NullInt64
|
|
||||||
err = DB.QueryRowContext(ctx,
|
|
||||||
`SELECT max_bytes FROM workspaces WHERE id = $1`, workspaceID,
|
|
||||||
).Scan(&maxBytes)
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if maxBytes.Valid {
|
|
||||||
stats.MaxBytes = &maxBytes.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chunks (v0.21.2) ─────────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error {
|
|
||||||
if len(chunks) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const cols = 8 // workspace_id, file_id, chunk_index, content, token_count, embedding, metadata, created_at
|
|
||||||
valueParts := make([]string, 0, len(chunks))
|
|
||||||
args := make([]interface{}, 0, len(chunks)*cols)
|
|
||||||
|
|
||||||
for i, c := range chunks {
|
|
||||||
base := i * cols
|
|
||||||
valueParts = append(valueParts, fmt.Sprintf(
|
|
||||||
"($%d, $%d, $%d, $%d, $%d, $%d::vector, $%d, NOW())",
|
|
||||||
base+1, base+2, base+3, base+4, base+5, base+6, base+7,
|
|
||||||
))
|
|
||||||
args = append(args, c.WorkspaceID, c.FileID, c.ChunkIndex,
|
|
||||||
c.Content, c.TokenCount, vectorToString(c.Embedding), ToJSON(c.Metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`INSERT INTO workspace_chunks
|
|
||||||
(workspace_id, file_id, chunk_index, content, token_count, embedding, metadata, created_at)
|
|
||||||
VALUES %s`, strings.Join(valueParts, ", "))
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, q, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE file_id = $1`, fileID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) {
|
|
||||||
if len(queryVec) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT f.path, c.content,
|
|
||||||
1 - (c.embedding <=> $1::vector) AS similarity,
|
|
||||||
c.metadata
|
|
||||||
FROM workspace_chunks c
|
|
||||||
JOIN workspace_files f ON c.file_id = f.id
|
|
||||||
WHERE c.workspace_id = $2
|
|
||||||
AND 1 - (c.embedding <=> $1::vector) > $3
|
|
||||||
ORDER BY c.embedding <=> $1::vector
|
|
||||||
LIMIT $4`,
|
|
||||||
vectorToString(queryVec), workspaceID, threshold, limit,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.WorkspaceChunkResult
|
|
||||||
for rows.Next() {
|
|
||||||
var r models.WorkspaceChunkResult
|
|
||||||
var metadataJSON []byte
|
|
||||||
if err := rows.Scan(&r.FilePath, &r.Content, &r.Score, &metadataJSON); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ScanJSON(metadataJSON, &r.Metadata)
|
|
||||||
if r.Metadata != nil {
|
|
||||||
if lh, ok := r.Metadata["line_start"]; ok {
|
|
||||||
if n, ok := lh.(float64); ok {
|
|
||||||
r.LineHint = int(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE workspace_files SET index_status = $1, chunk_count = $2, updated_at = NOW() WHERE id = $3`,
|
|
||||||
status, chunkCount, fileID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE workspaces SET git_last_sync = $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
t, workspaceID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// PROJECT STORE (v0.19.0)
|
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type ProjectStore interface {
|
|
||||||
// CRUD
|
|
||||||
Create(ctx context.Context, p *models.Project) error
|
|
||||||
GetByID(ctx context.Context, id string) (*models.Project, error)
|
|
||||||
Update(ctx context.Context, id string, patch models.ProjectPatch) error
|
|
||||||
Delete(ctx context.Context, id string) error
|
|
||||||
|
|
||||||
// Listing
|
|
||||||
ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error)
|
|
||||||
|
|
||||||
// Channel association (atomic move: removes old project if any)
|
|
||||||
AddChannel(ctx context.Context, projectID, channelID string, position int) error
|
|
||||||
RemoveChannel(ctx context.Context, projectID, channelID string) error
|
|
||||||
ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error)
|
|
||||||
ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error
|
|
||||||
|
|
||||||
// KB association
|
|
||||||
AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error
|
|
||||||
RemoveKB(ctx context.Context, projectID, kbID string) error
|
|
||||||
ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error)
|
|
||||||
|
|
||||||
// Note association
|
|
||||||
AddNote(ctx context.Context, projectID, noteID string) error
|
|
||||||
RemoveNote(ctx context.Context, projectID, noteID string) error
|
|
||||||
ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error)
|
|
||||||
|
|
||||||
// Access check
|
|
||||||
UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error)
|
|
||||||
|
|
||||||
// Get project ID for a channel (used during note auto-association)
|
|
||||||
GetProjectIDForChannel(ctx context.Context, channelID string) (string, error)
|
|
||||||
|
|
||||||
// Get KB IDs bound to a project (used for virtual injection at completion)
|
|
||||||
GetKBIDs(ctx context.Context, projectID string) ([]string, error)
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ──
|
|
||||||
|
|
||||||
// AdminList returns all projects with counts and owner names (admin only).
|
|
||||||
AdminList(ctx context.Context, includeArchived bool) ([]AdminProject, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminProject is returned by ProjectStore.AdminList.
|
|
||||||
type AdminProject struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Scope string
|
|
||||||
OwnerID string
|
|
||||||
TeamID *string
|
|
||||||
IsArchived bool
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
ChannelCount int
|
|
||||||
KBCount int
|
|
||||||
NoteCount int
|
|
||||||
OwnerName string
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CapOverrideStore struct{}
|
|
||||||
|
|
||||||
func NewCapOverrideStore() *CapOverrideStore { return &CapOverrideStore{} }
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) Set(ctx context.Context, o *models.CapabilityOverride) error {
|
|
||||||
id := store.NewID()
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO capability_overrides (id, provider_config_id, model_id, field, value, set_by)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (provider_config_id, model_id, field) DO UPDATE SET
|
|
||||||
value = excluded.value,
|
|
||||||
set_by = excluded.set_by,
|
|
||||||
created_at = datetime('now')
|
|
||||||
`, id, o.ProviderConfigID, o.ModelID, o.Field, o.Value, o.SetBy)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM capability_overrides WHERE id = ?`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
|
|
||||||
FROM capability_overrides
|
|
||||||
WHERE model_id = ?
|
|
||||||
ORDER BY provider_config_id, field
|
|
||||||
`, modelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
|
|
||||||
FROM capability_overrides
|
|
||||||
WHERE model_id = ? AND (provider_config_id = ? OR provider_config_id IS NULL)
|
|
||||||
ORDER BY provider_config_id, field
|
|
||||||
`, modelID, providerConfigID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) ListAll(ctx context.Context) ([]models.CapabilityOverride, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
|
|
||||||
FROM capability_overrides
|
|
||||||
ORDER BY model_id, provider_config_id, field
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM capability_overrides WHERE provider_config_id = ?
|
|
||||||
`, providerConfigID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CapOverrideStore) query(ctx context.Context, q string, args ...interface{}) ([]models.CapabilityOverride, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.CapabilityOverride
|
|
||||||
for rows.Next() {
|
|
||||||
var o models.CapabilityOverride
|
|
||||||
if err := rows.Scan(&o.ID, &o.ProviderConfigID, &o.ModelID, &o.Field, &o.Value, &o.SetBy, &o.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, o)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure interface compliance (compile-time check)
|
|
||||||
var _ store.CapabilityOverrideStore = (*CapOverrideStore)(nil)
|
|
||||||
|
|
||||||
// also check the health store interface at this package level
|
|
||||||
// (health.Store is in the health package, but we verify via the sql.Rows pattern)
|
|
||||||
func init() {
|
|
||||||
// compile-time interface checks happen via the var _ lines above
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CatalogStore struct{}
|
|
||||||
|
|
||||||
func NewCatalogStore() *CatalogStore { return &CatalogStore{} }
|
|
||||||
|
|
||||||
const catalogCols = `id, provider_config_id, model_id, display_name, model_type,
|
|
||||||
capabilities, pricing, visibility, last_synced_at, created_at, updated_at`
|
|
||||||
|
|
||||||
// catalogColsMC is catalogCols with mc. prefix for use in JOINs
|
|
||||||
// where id/created_at/updated_at are ambiguous.
|
|
||||||
const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_name, mc.model_type,
|
|
||||||
mc.capabilities, mc.pricing, mc.visibility, mc.last_synced_at, mc.created_at, mc.updated_at`
|
|
||||||
|
|
||||||
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
|
|
||||||
// New models default to 'disabled' visibility (secure by default).
|
|
||||||
//
|
|
||||||
// Runs in a single transaction: one SELECT to identify existing models,
|
|
||||||
// then N upserts via a prepared INSERT ON CONFLICT statement, one COMMIT.
|
|
||||||
// SQLite with WAL benefits enormously — one journal cycle instead of N.
|
|
||||||
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return 0, 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("begin tx: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// Pre-fetch existing model_ids for this provider to track added vs updated.
|
|
||||||
existing := make(map[string]bool)
|
|
||||||
rows, err := tx.QueryContext(ctx,
|
|
||||||
"SELECT model_id FROM model_catalog WHERE provider_config_id = ?",
|
|
||||||
providerConfigID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("list existing: %w", err)
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var mid string
|
|
||||||
if err := rows.Scan(&mid); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return 0, 0, fmt.Errorf("scan existing: %w", err)
|
|
||||||
}
|
|
||||||
existing[mid] = true
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("list existing: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the upsert statement once, execute N times.
|
|
||||||
// SQLite needs an explicit id value (TEXT PK, no auto-generation).
|
|
||||||
// On conflict the id is preserved — only metadata columns update.
|
|
||||||
stmt, err := tx.PrepareContext(ctx, `
|
|
||||||
INSERT INTO model_catalog (id, provider_config_id, model_id, display_name,
|
|
||||||
model_type, capabilities, pricing, visibility, last_synced_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'disabled', ?)
|
|
||||||
ON CONFLICT (provider_config_id, model_id) DO UPDATE SET
|
|
||||||
display_name = excluded.display_name,
|
|
||||||
model_type = excluded.model_type,
|
|
||||||
capabilities = excluded.capabilities,
|
|
||||||
pricing = excluded.pricing,
|
|
||||||
last_synced_at = excluded.last_synced_at`)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("prepare upsert: %w", err)
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
|
||||||
for _, e := range entries {
|
|
||||||
capsJSON := ToJSON(e.Capabilities)
|
|
||||||
pricingJSON := ToJSON(e.Pricing)
|
|
||||||
|
|
||||||
modelType := e.ModelType
|
|
||||||
if modelType == "" {
|
|
||||||
modelType = "chat"
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := stmt.ExecContext(ctx,
|
|
||||||
store.NewID(), providerConfigID, e.ModelID, e.DisplayName,
|
|
||||||
modelType, capsJSON, pricingJSON, now,
|
|
||||||
); err != nil {
|
|
||||||
return added, updated, fmt.Errorf("upsert %s: %w", e.ModelID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing[e.ModelID] {
|
|
||||||
updated++
|
|
||||||
} else {
|
|
||||||
added++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return added, updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) GetByID(ctx context.Context, id string) (*models.CatalogEntry, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE id = ?", catalogCols), id)
|
|
||||||
return scanCatalogEntry(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = ? AND model_id = ?", catalogCols),
|
|
||||||
providerConfigID, modelID)
|
|
||||||
return scanCatalogEntry(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByModelIDAny returns the most recently synced catalog entry for a model_id
|
|
||||||
// across any provider. Used to resolve capabilities for personas with auto-resolve
|
|
||||||
// (no specific provider_config_id).
|
|
||||||
func (s *CatalogStore) GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE model_id = ? ORDER BY last_synced_at DESC NULLS LAST LIMIT 1", catalogCols),
|
|
||||||
modelID)
|
|
||||||
return scanCatalogEntry(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListVisible(ctx context.Context) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf(`SELECT %s FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE mc.visibility = 'enabled' AND pc.scope = 'global' AND pc.is_active = 1
|
|
||||||
ORDER BY mc.model_id`, catalogColsMC))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = ? ORDER BY model_id", catalogCols),
|
|
||||||
providerConfigID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = ? AND visibility = 'enabled' ORDER BY model_id", catalogCols),
|
|
||||||
providerConfigID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListAll(ctx context.Context) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM model_catalog ORDER BY model_id", catalogCols))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAllGlobal returns all catalog entries whose provider_config has scope='global'.
|
|
||||||
// Used by admin panel — admin should not see user BYOK or team-level models.
|
|
||||||
func (s *CatalogStore) ListAllGlobal(ctx context.Context) ([]models.CatalogEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf(`SELECT %s FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE pc.scope = 'global'
|
|
||||||
ORDER BY mc.model_id`, catalogColsMC))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanCatalogEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) SetVisibility(ctx context.Context, id string, visibility string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
"UPDATE model_catalog SET visibility = ? WHERE id = ?", visibility, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
"UPDATE model_catalog SET visibility = ? WHERE provider_config_id = ?",
|
|
||||||
visibility, providerConfigID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) BulkSetVisibilityAll(ctx context.Context, visibility string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE model_catalog SET visibility = ?
|
|
||||||
WHERE provider_config_id IN (
|
|
||||||
SELECT id FROM provider_configs WHERE scope = 'global'
|
|
||||||
)`, visibility)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM model_catalog WHERE id = ?", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
"DELETE FROM model_catalog WHERE provider_config_id = ?", providerConfigID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scanners ────────────────────────────────
|
|
||||||
|
|
||||||
func scanCatalogEntry(row *sql.Row) (*models.CatalogEntry, error) {
|
|
||||||
var e models.CatalogEntry
|
|
||||||
var capsJSON, pricingJSON []byte
|
|
||||||
var displayName sql.NullString
|
|
||||||
var lastSynced *time.Time
|
|
||||||
err := row.Scan(
|
|
||||||
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType,
|
|
||||||
&capsJSON, &pricingJSON, &e.Visibility, stN(&lastSynced),
|
|
||||||
st(&e.CreatedAt), st(&e.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
e.DisplayName = NullableString(displayName)
|
|
||||||
json.Unmarshal(capsJSON, &e.Capabilities)
|
|
||||||
if len(pricingJSON) > 0 {
|
|
||||||
e.Pricing = &models.ModelPricing{}
|
|
||||||
json.Unmarshal(pricingJSON, e.Pricing)
|
|
||||||
}
|
|
||||||
e.LastSyncedAt = lastSynced
|
|
||||||
return &e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) {
|
|
||||||
result := make([]models.CatalogEntry, 0) // never nil — serializes as [] not null
|
|
||||||
for rows.Next() {
|
|
||||||
var e models.CatalogEntry
|
|
||||||
var capsJSON, pricingJSON []byte
|
|
||||||
var displayName sql.NullString
|
|
||||||
var lastSynced *time.Time
|
|
||||||
err := rows.Scan(
|
|
||||||
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType,
|
|
||||||
&capsJSON, &pricingJSON, &e.Visibility, stN(&lastSynced),
|
|
||||||
st(&e.CreatedAt), st(&e.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
e.DisplayName = NullableString(displayName)
|
|
||||||
json.Unmarshal(capsJSON, &e.Capabilities)
|
|
||||||
if len(pricingJSON) > 0 {
|
|
||||||
e.Pricing = &models.ModelPricing{}
|
|
||||||
json.Unmarshal(pricingJSON, e.Pricing)
|
|
||||||
}
|
|
||||||
e.LastSyncedAt = lastSynced
|
|
||||||
result = append(result, e)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *CatalogStore) GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) {
|
|
||||||
var capsJSON []byte
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT capabilities FROM model_catalog
|
|
||||||
WHERE model_id = ? AND provider_config_id = ?
|
|
||||||
`, modelID, configID).Scan(&capsJSON)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return capsJSON, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) {
|
|
||||||
var capsJSON []byte
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT capabilities FROM model_catalog
|
|
||||||
WHERE model_id = ? ORDER BY last_synced_at DESC LIMIT 1
|
|
||||||
`, modelID).Scan(&capsJSON)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return capsJSON, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *CatalogStore) ListTeamAvailable(ctx context.Context) ([]store.TeamAvailableModel, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
|
|
||||||
ac.provider, ac.name AS provider_name
|
|
||||||
FROM model_catalog mc
|
|
||||||
JOIN provider_configs ac ON mc.provider_config_id = ac.id
|
|
||||||
WHERE mc.visibility IN ('enabled', 'team')
|
|
||||||
AND ac.is_active = 1 AND ac.scope = 'global'
|
|
||||||
ORDER BY ac.name, mc.model_id
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.TeamAvailableModel, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var m store.TeamAvailableModel
|
|
||||||
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility,
|
|
||||||
&m.Provider, &m.ProviderName); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, m)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Mention resolution (v0.29.0) ────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *CatalogStore) FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) {
|
|
||||||
var foundModelID, configID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT mc.model_id, mc.provider_config_id
|
|
||||||
FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE LOWER(mc.model_id) = LOWER(?)
|
|
||||||
AND mc.visibility = 'enabled'
|
|
||||||
AND pc.is_active = 1
|
|
||||||
ORDER BY
|
|
||||||
CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
|
||||||
LIMIT 1
|
|
||||||
`, modelID).Scan(&foundModelID, &configID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", "", nil
|
|
||||||
}
|
|
||||||
return foundModelID, configID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CatalogStore) FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(DISTINCT mc.model_id)
|
|
||||||
FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE LOWER(mc.model_id) LIKE LOWER(?)
|
|
||||||
AND mc.visibility = 'enabled'
|
|
||||||
AND pc.is_active = 1
|
|
||||||
`, prefix+"%").Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", 0, err
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
return "", "", count, nil
|
|
||||||
}
|
|
||||||
var foundModelID, configID string
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT mc.model_id, mc.provider_config_id
|
|
||||||
FROM model_catalog mc
|
|
||||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
|
||||||
WHERE LOWER(mc.model_id) LIKE LOWER(?)
|
|
||||||
AND mc.visibility = 'enabled'
|
|
||||||
AND pc.is_active = 1
|
|
||||||
ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
|
||||||
LIMIT 1
|
|
||||||
`, prefix+"%").Scan(&foundModelID, &configID)
|
|
||||||
return foundModelID, configID, 1, err
|
|
||||||
}
|
|
||||||
@@ -1,972 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ChannelStore struct{}
|
|
||||||
|
|
||||||
func NewChannelStore() *ChannelStore { return &ChannelStore{} }
|
|
||||||
|
|
||||||
func (s *ChannelStore) Create(ctx context.Context, ch *models.Channel) error {
|
|
||||||
ch.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
ch.CreatedAt = now
|
|
||||||
ch.UpdatedAt = now
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO channels (id, user_id, title, description, type, model, system_prompt,
|
|
||||||
provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
||||||
ch.ID, ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt,
|
|
||||||
models.NullString(ch.ProviderConfigID), ch.IsArchived, ch.IsPinned,
|
|
||||||
models.NullString(ch.FolderID), models.NullString(ch.TeamID), ToJSON(ch.Settings),
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetByID(ctx context.Context, id string) (*models.Channel, error) {
|
|
||||||
var ch models.Channel
|
|
||||||
var providerConfigID, folderID, teamID sql.NullString
|
|
||||||
var desc sql.NullString
|
|
||||||
var settingsJSON []byte
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, user_id, title, description, type, model, system_prompt,
|
|
||||||
provider_config_id, is_archived, is_pinned, folder_id, team_id, settings,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM channels WHERE id = ?`, id).Scan(
|
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt,
|
|
||||||
&providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON,
|
|
||||||
st(&ch.CreatedAt), st(&ch.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ch.Description = NullableString(desc)
|
|
||||||
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
|
|
||||||
ch.FolderID = NullableStringPtr(folderID)
|
|
||||||
ch.TeamID = NullableStringPtr(teamID)
|
|
||||||
json.Unmarshal(settingsJSON, &ch.Settings)
|
|
||||||
return &ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
b := NewUpdate("channels")
|
|
||||||
for k, v := range fields {
|
|
||||||
if k == "settings" || k == "tags" {
|
|
||||||
b.SetJSON(k, v)
|
|
||||||
} else {
|
|
||||||
b.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM channels WHERE id = ?", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Channel, int, error) {
|
|
||||||
// Count
|
|
||||||
var total int
|
|
||||||
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels WHERE user_id = ?", userID).Scan(&total)
|
|
||||||
|
|
||||||
b := NewSelect(
|
|
||||||
"id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at",
|
|
||||||
"channels",
|
|
||||||
).Where("user_id = ?", userID)
|
|
||||||
|
|
||||||
if opts.Sort == "" {
|
|
||||||
b.OrderBy("updated_at", "DESC")
|
|
||||||
}
|
|
||||||
b.Paginate(opts)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Channel
|
|
||||||
for rows.Next() {
|
|
||||||
var ch models.Channel
|
|
||||||
var providerConfigID, folderID, teamID, desc sql.NullString
|
|
||||||
var settingsJSON []byte
|
|
||||||
err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model,
|
|
||||||
&ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned,
|
|
||||||
&folderID, &teamID, &settingsJSON, st(&ch.CreatedAt), st(&ch.UpdatedAt))
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
ch.Description = NullableString(desc)
|
|
||||||
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
|
|
||||||
ch.FolderID = NullableStringPtr(folderID)
|
|
||||||
ch.TeamID = NullableStringPtr(teamID)
|
|
||||||
json.Unmarshal(settingsJSON, &ch.Settings)
|
|
||||||
result = append(result, ch)
|
|
||||||
}
|
|
||||||
return result, total, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Channel, int, error) {
|
|
||||||
// Simple title search for now — will add full-text when search feature lands
|
|
||||||
b := NewSelect(
|
|
||||||
"id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at",
|
|
||||||
"channels",
|
|
||||||
).Where("user_id = ?", userID).Where("title LIKE ?", "%"+query+"%")
|
|
||||||
b.OrderBy("updated_at", "DESC")
|
|
||||||
b.Paginate(opts)
|
|
||||||
|
|
||||||
var total int
|
|
||||||
DB.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM channels WHERE user_id = ? AND title LIKE ?",
|
|
||||||
userID, "%"+query+"%").Scan(&total)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Channel
|
|
||||||
for rows.Next() {
|
|
||||||
var ch models.Channel
|
|
||||||
var providerConfigID, folderID, teamID, desc sql.NullString
|
|
||||||
var settingsJSON []byte
|
|
||||||
err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model,
|
|
||||||
&ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned,
|
|
||||||
&folderID, &teamID, &settingsJSON, st(&ch.CreatedAt), st(&ch.UpdatedAt))
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
ch.Description = NullableString(desc)
|
|
||||||
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
|
|
||||||
ch.FolderID = NullableStringPtr(folderID)
|
|
||||||
ch.TeamID = NullableStringPtr(teamID)
|
|
||||||
json.Unmarshal(settingsJSON, &ch.Settings)
|
|
||||||
result = append(result, ch)
|
|
||||||
}
|
|
||||||
return result, total, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error) {
|
|
||||||
var c models.ChannelCursor
|
|
||||||
var leafID sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, channel_id, user_id, active_leaf_id, updated_at
|
|
||||||
FROM channel_cursors WHERE channel_id = ? AND user_id = ?`,
|
|
||||||
channelID, userID).Scan(&c.ID, &c.ChannelID, &c.UserID, &leafID, st(&c.UpdatedAt))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.ActiveLeafID = NullableStringPtr(leafID)
|
|
||||||
return &c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now')`,
|
|
||||||
store.NewID(), channelID, userID, leafID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
|
|
||||||
// For persona entries, use the persona-aware path
|
|
||||||
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
|
||||||
return s.SetPersonaModel(ctx, cm)
|
|
||||||
}
|
|
||||||
// Nullable provider_config_id: empty string → SQL NULL (UUID FK)
|
|
||||||
var provCfgID interface{} = cm.ProviderConfigID
|
|
||||||
if cm.ProviderConfigID == "" {
|
|
||||||
provCfgID = nil
|
|
||||||
}
|
|
||||||
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
|
|
||||||
display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
|
||||||
store.NewID(), cm.ChannelID, cm.ModelID, provCfgID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPersonaModel inserts or updates a persona's channel model roster entry.
|
|
||||||
func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET
|
|
||||||
model_id = excluded.model_id, provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
|
||||||
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
|
|
||||||
COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
|
||||||
COALESCE(cm.system_prompt, ''), cm.is_default
|
|
||||||
FROM channel_models cm
|
|
||||||
LEFT JOIN personas p ON p.id = cm.persona_id
|
|
||||||
WHERE cm.channel_id = ?`, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ChannelModel
|
|
||||||
for rows.Next() {
|
|
||||||
var cm models.ChannelModel
|
|
||||||
var personaID string
|
|
||||||
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
|
||||||
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if personaID != "" {
|
|
||||||
cm.PersonaID = &personaID
|
|
||||||
}
|
|
||||||
result = append(result, cm)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
|
|
||||||
var cm models.ChannelModel
|
|
||||||
var personaID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''),
|
|
||||||
COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''),
|
|
||||||
COALESCE(cm.system_prompt, ''), cm.is_default
|
|
||||||
FROM channel_models cm
|
|
||||||
LEFT JOIN personas p ON p.id = cm.persona_id
|
|
||||||
WHERE cm.id = ?`, id).Scan(
|
|
||||||
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
|
|
||||||
&personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if personaID != "" {
|
|
||||||
cm.PersonaID = &personaID
|
|
||||||
}
|
|
||||||
return &cm, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
if len(fields) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sets := make([]string, 0, len(fields))
|
|
||||||
args := make([]interface{}, 0, len(fields)+1)
|
|
||||||
for col, val := range fields {
|
|
||||||
sets = append(sets, col+" = ?")
|
|
||||||
args = append(args, val)
|
|
||||||
}
|
|
||||||
args = append(args, id)
|
|
||||||
query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = ?"
|
|
||||||
_, err := DB.ExecContext(ctx, query, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) DeleteModel(ctx context.Context, id string) error {
|
|
||||||
res, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) {
|
|
||||||
var exists bool
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM channels WHERE id = ? AND user_id = ?)",
|
|
||||||
channelID, userID).Scan(&exists)
|
|
||||||
return exists, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveWorkspaceID returns the effective workspace for a channel.
|
|
||||||
// Resolution: channel.workspace_id > project.workspace_id.
|
|
||||||
// Returns empty string if no workspace is bound.
|
|
||||||
func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) {
|
|
||||||
var wsID sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(c.workspace_id, p.workspace_id)
|
|
||||||
FROM channels c
|
|
||||||
LEFT JOIN project_channels pc ON pc.channel_id = c.id
|
|
||||||
LEFT JOIN projects p ON p.id = pc.project_id
|
|
||||||
WHERE c.id = ?
|
|
||||||
LIMIT 1`, channelID).Scan(&wsID)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return NullableString(wsID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Channel Participants (ICD §3.7) ──────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role, display_name, avatar_url)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
store.NewID(), p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT cp.id, cp.channel_id, cp.participant_type, cp.participant_id, cp.role,
|
|
||||||
COALESCE(NULLIF(cp.display_name,''), NULLIF(u.display_name,''), u.username) AS display_name,
|
|
||||||
COALESCE(NULLIF(cp.avatar_url,''), u.avatar_url) AS avatar_url,
|
|
||||||
cp.joined_at
|
|
||||||
FROM channel_participants cp
|
|
||||||
LEFT JOIN users u ON cp.participant_type = 'user' AND cp.participant_id = u.id
|
|
||||||
WHERE cp.channel_id = ? ORDER BY cp.joined_at`, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ChannelParticipant
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.ChannelParticipant
|
|
||||||
var joinedStr string
|
|
||||||
if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
|
||||||
&p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) {
|
|
||||||
var p models.ChannelParticipant
|
|
||||||
var joinedStr string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, channel_id, participant_type, participant_id, role,
|
|
||||||
display_name, avatar_url, joined_at
|
|
||||||
FROM channel_participants WHERE id = ?`, id).Scan(
|
|
||||||
&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID,
|
|
||||||
&p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = ? WHERE id = ?`, role, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error {
|
|
||||||
res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM channel_participants
|
|
||||||
WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`,
|
|
||||||
channelID, pType, pID).Scan(&count)
|
|
||||||
return count > 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) {
|
|
||||||
var role string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT role FROM channel_participants
|
|
||||||
WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`,
|
|
||||||
channelID, pType, pID).Scan(&role)
|
|
||||||
return role, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM channel_participants WHERE channel_id = ? AND role = ?`,
|
|
||||||
channelID, role).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM channel_models WHERE channel_id = ? AND persona_id = ?`,
|
|
||||||
channelID, personaID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin: Archived Channel Management ──────
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListArchived(ctx context.Context, opts store.ListOptions) ([]store.ArchivedChannel, int, error) {
|
|
||||||
// Count
|
|
||||||
var total int
|
|
||||||
DB.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM channels WHERE is_archived = 1`).Scan(&total)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT c.id, c.title, c.type, COALESCE(u.username, '') AS owner_name,
|
|
||||||
c.updated_at,
|
|
||||||
(SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count
|
|
||||||
FROM channels c
|
|
||||||
LEFT JOIN users u ON u.id = c.user_id
|
|
||||||
WHERE c.is_archived = 1
|
|
||||||
ORDER BY c.updated_at DESC
|
|
||||||
LIMIT ? OFFSET ?`, opts.Limit, opts.Offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var channels []store.ArchivedChannel
|
|
||||||
for rows.Next() {
|
|
||||||
var ch store.ArchivedChannel
|
|
||||||
if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil {
|
|
||||||
channels = append(channels, ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if channels == nil {
|
|
||||||
channels = []store.ArchivedChannel{}
|
|
||||||
}
|
|
||||||
return channels, total, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) Purge(ctx context.Context, id string) error {
|
|
||||||
// Verify the channel is actually archived
|
|
||||||
var isArchived int
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT is_archived FROM channels WHERE id = ?`, id).Scan(&isArchived)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("channel not found")
|
|
||||||
}
|
|
||||||
if isArchived == 0 {
|
|
||||||
return fmt.Errorf("channel must be archived before purging")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hard delete (CASCADE removes messages, participants, models)
|
|
||||||
_, err = DB.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) {
|
|
||||||
var channelID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT cp1.channel_id FROM channel_participants cp1
|
|
||||||
JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id
|
|
||||||
JOIN channels c ON c.id = cp1.channel_id
|
|
||||||
WHERE c.type = 'dm'
|
|
||||||
AND cp1.participant_type = 'user' AND cp1.participant_id = ?
|
|
||||||
AND cp2.participant_type = 'user' AND cp2.participant_id = ?
|
|
||||||
LIMIT 1
|
|
||||||
`, userID1, userID2).Scan(&channelID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return channelID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM messages m
|
|
||||||
JOIN channel_participants cp ON cp.channel_id = m.channel_id
|
|
||||||
WHERE cp.channel_id = ?
|
|
||||||
AND cp.participant_type = 'user' AND cp.participant_id = ?
|
|
||||||
AND m.created_at > cp.last_read_at
|
|
||||||
`, channelID, userID).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) {
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM channels WHERE id = ? AND user_id = ?`,
|
|
||||||
channelID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE channels SET is_archived = 1, purge_after = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ?
|
|
||||||
`, purgeAfter.UTC().Format("2006-01-02T15:04:05Z"), channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListPurgeable(ctx context.Context) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id FROM channels WHERE purge_after IS NOT NULL AND purge_after <= datetime('now')
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if rows.Scan(&id) == nil {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE channel_participants
|
|
||||||
SET last_read_at = datetime('now')
|
|
||||||
WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ?
|
|
||||||
`, channelID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestMsgID *string
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM messages WHERE channel_id = ? ORDER BY created_at DESC LIMIT 1
|
|
||||||
`, channelID).Scan(&latestMsgID)
|
|
||||||
if latestMsgID != nil {
|
|
||||||
_, _ = DB.ExecContext(ctx, `
|
|
||||||
UPDATE channel_participants
|
|
||||||
SET last_read_message_id = ?
|
|
||||||
WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ?
|
|
||||||
`, *latestMsgID, channelID, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM channel_participants
|
|
||||||
WHERE channel_id = ? AND participant_type = ?
|
|
||||||
`, channelID, pType).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
|
|
||||||
now := time.Now().UTC().Format(timeFmt)
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE channels
|
|
||||||
SET workflow_id = ?, workflow_version = ?, current_stage = 0,
|
|
||||||
stage_data = ?, workflow_status = ?, last_activity_at = ?,
|
|
||||||
stage_entered_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`, workflowID, version, stageData, status, now, now, channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string) (*store.WorkflowChannelStatus, error) {
|
|
||||||
var ws store.WorkflowChannelStatus
|
|
||||||
var stageData []byte
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT workflow_id, workflow_version, current_stage,
|
|
||||||
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
|
|
||||||
last_activity_at, stage_entered_at
|
|
||||||
FROM channels WHERE id = ? AND type = 'workflow'
|
|
||||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
|
||||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ws.StageData = stageData
|
|
||||||
return &ws, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
|
|
||||||
now := time.Now().UTC().Format(timeFmt)
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE channels
|
|
||||||
SET current_stage = ?, stage_data = ?, last_activity_at = ?,
|
|
||||||
stage_entered_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`, nextStage, stageData, now, now, channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE channels
|
|
||||||
SET current_stage = ?, workflow_status = 'completed',
|
|
||||||
stage_data = ?, last_activity_at = ?, ai_mode = 'off'
|
|
||||||
WHERE id = ?
|
|
||||||
`, finalStage, stageData, time.Now().UTC().Format(timeFmt), channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE channels
|
|
||||||
SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = ?
|
|
||||||
WHERE id = ? AND type = 'workflow'
|
|
||||||
`, time.Now().UTC().Format(timeFmt), channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
|
||||||
now := time.Now().UTC().Format(timeFmt)
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE channels SET current_stage = ?, last_activity_at = ?,
|
|
||||||
stage_entered_at = ? WHERE id = ?
|
|
||||||
`, stage, now, now, channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) {
|
|
||||||
var data json.RawMessage
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = ?
|
|
||||||
`, channelID).Scan(&data)
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Background job helpers (v0.29.0-cs4) ────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) {
|
|
||||||
result, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE channels
|
|
||||||
SET workflow_status = 'stale'
|
|
||||||
WHERE type = 'workflow'
|
|
||||||
AND workflow_status = 'active'
|
|
||||||
AND last_activity_at < ?
|
|
||||||
`, cutoff.Format(timeFmt))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) EnforceWorkflowRetention(ctx context.Context) (int64, error) {
|
|
||||||
// SQLite doesn't support JSON operators for retention policy evaluation.
|
|
||||||
// This is a no-op on SQLite; retention enforcement is PG-only.
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) {
|
|
||||||
var chType string
|
|
||||||
var allowAnon bool
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT type, allow_anonymous FROM channels WHERE id = ?`, channelID).Scan(&chType, &allowAnon)
|
|
||||||
return chType, allowAnon, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5b additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) {
|
|
||||||
// Auto-compaction scanner is PG-only (uses settings::text cast).
|
|
||||||
return []models.Channel{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetProviderConfigID(ctx context.Context, channelID string) (*string, error) {
|
|
||||||
var configID sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT provider_config_id FROM channels WHERE id = ?`, channelID).Scan(&configID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return NullableStringPtr(configID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) {
|
|
||||||
var ok bool
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM channels WHERE id = ? AND user_id = ?
|
|
||||||
UNION ALL
|
|
||||||
SELECT 1 FROM channel_participants
|
|
||||||
WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ?
|
|
||||||
LIMIT 1
|
|
||||||
)`, channelID, userID, channelID, userID).Scan(&ok)
|
|
||||||
return ok, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS7b additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
const channelListCols = `c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
|
|
||||||
c.description, c.model, c.provider_config_id,
|
|
||||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id,
|
|
||||||
c.tags, c.settings,
|
|
||||||
COALESCE(mc.cnt, 0) AS message_count,
|
|
||||||
c.created_at, c.updated_at`
|
|
||||||
|
|
||||||
const channelListFrom = `channels c
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT channel_id, COUNT(*) AS cnt FROM messages WHERE deleted_at IS NULL GROUP BY channel_id
|
|
||||||
) mc ON mc.channel_id = c.id`
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListFiltered(ctx context.Context, userID string, f store.ChannelListFilter) ([]store.ChannelListItem, int, error) {
|
|
||||||
b := NewSelect(channelListCols, channelListFrom)
|
|
||||||
b.Where("(c.user_id = ? OR c.id IN (SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?))", userID, userID)
|
|
||||||
archivedVal := 0
|
|
||||||
if f.Archived {
|
|
||||||
archivedVal = 1
|
|
||||||
}
|
|
||||||
b.Where("c.is_archived = ?", archivedVal)
|
|
||||||
|
|
||||||
if len(f.Types) == 1 {
|
|
||||||
b.Where("c.type = ?", f.Types[0])
|
|
||||||
} else if len(f.Types) > 1 {
|
|
||||||
placeholders := make([]string, len(f.Types))
|
|
||||||
for i, t := range f.Types {
|
|
||||||
placeholders[i] = "?"
|
|
||||||
b.args = append(b.args, t)
|
|
||||||
}
|
|
||||||
b.WhereRaw("c.type IN (" + strings.Join(placeholders, ",") + ")")
|
|
||||||
}
|
|
||||||
if f.Folder != "" {
|
|
||||||
b.Where("c.folder = ?", f.Folder)
|
|
||||||
}
|
|
||||||
if f.FolderID != "" {
|
|
||||||
b.Where("c.folder_id = ?", f.FolderID)
|
|
||||||
}
|
|
||||||
if f.Search != "" {
|
|
||||||
b.Where("c.title LIKE ?", "%"+f.Search+"%")
|
|
||||||
}
|
|
||||||
if f.ProjectID == "none" {
|
|
||||||
b.WhereRaw("c.project_id IS NULL")
|
|
||||||
} else if f.ProjectID != "" {
|
|
||||||
b.Where("c.project_id = ?", f.ProjectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count
|
|
||||||
countQ, countArgs := b.CountBuild()
|
|
||||||
var total int
|
|
||||||
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
|
|
||||||
|
|
||||||
b.OrderBy("c.is_pinned DESC, c.updated_at", "DESC")
|
|
||||||
b.Paginate(f.ListOptions)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
items, err := scanChannelListItems(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute unread counts
|
|
||||||
for i := range items {
|
|
||||||
DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM messages m
|
|
||||||
JOIN channel_participants cp ON cp.channel_id = m.channel_id
|
|
||||||
WHERE cp.channel_id = ?
|
|
||||||
AND cp.participant_type = 'user' AND cp.participant_id = ?
|
|
||||||
AND m.created_at > cp.last_read_at
|
|
||||||
AND m.deleted_at IS NULL
|
|
||||||
`, items[i].ID, userID).Scan(&items[i].UnreadCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetForUser(ctx context.Context, channelID, userID string) (*store.ChannelListItem, error) {
|
|
||||||
row := DB.QueryRowContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT %s FROM %s
|
|
||||||
WHERE c.id = ? AND (c.user_id = ? OR c.id IN (
|
|
||||||
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?
|
|
||||||
))
|
|
||||||
`, channelListCols, channelListFrom), channelID, userID, userID)
|
|
||||||
|
|
||||||
var item store.ChannelListItem
|
|
||||||
var tags, settings string
|
|
||||||
err := row.Scan(
|
|
||||||
&item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic,
|
|
||||||
&item.Description, &item.Model, &item.ProviderConfigID,
|
|
||||||
&item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID,
|
|
||||||
&tags, &settings,
|
|
||||||
&item.MessageCount, st(&item.CreatedAtTime), st(&item.UpdatedAtTime),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
item.Tags = ScanArray(tags)
|
|
||||||
if item.Tags == nil {
|
|
||||||
item.Tags = []string{}
|
|
||||||
}
|
|
||||||
item.Settings = safeJSONString(settings)
|
|
||||||
item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z")
|
|
||||||
item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z")
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string,
|
|
||||||
ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error {
|
|
||||||
ch.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
ch.CreatedAt = now
|
|
||||||
ch.UpdatedAt = now
|
|
||||||
|
|
||||||
if tags == nil {
|
|
||||||
tags = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
tagsJSON := ArrayToJSON(tags)
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO channels (id, user_id, title, type, description, model, system_prompt,
|
|
||||||
provider_config_id, folder, folder_id, tags, ai_mode, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
ch.ID, ch.UserID, ch.Title, ch.Type, ch.Description, ch.Model, ch.SystemPrompt,
|
|
||||||
models.NullString(ch.ProviderConfigID), folder, models.NullString(ch.FolderID),
|
|
||||||
tagsJSON, aiMode, now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateFull insert channel: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add owner participant
|
|
||||||
_, _ = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
|
|
||||||
VALUES (?, ?, 'user', ?, 'owner')
|
|
||||||
ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, ownerUserID)
|
|
||||||
|
|
||||||
// Add DM partner participants
|
|
||||||
for _, pid := range dmPartnerIDs {
|
|
||||||
if pid == ownerUserID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, _ = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
|
|
||||||
VALUES (?, ?, 'user', ?, 'member')
|
|
||||||
ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-create channel_model if model specified
|
|
||||||
if defaultModel != "" {
|
|
||||||
_, _ = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default)
|
|
||||||
VALUES (?, ?, ?, ?, 1)
|
|
||||||
ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, defaultModel, models.NullString(&defaultConfigID))
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE channels SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`,
|
|
||||||
string(settingsJSON), channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Monitoring (v0.35.0) ────────────────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, user_id, title, COALESCE(description, ''), type,
|
|
||||||
team_id, created_at
|
|
||||||
FROM channels WHERE type = ?
|
|
||||||
ORDER BY created_at DESC`, channelType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var result []models.Channel
|
|
||||||
for rows.Next() {
|
|
||||||
var ch models.Channel
|
|
||||||
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
|
||||||
&ch.Type, &ch.TeamID, st(&ch.CreatedAt)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, ch)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS7b helpers ────────────────────────────
|
|
||||||
|
|
||||||
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {
|
|
||||||
var result []store.ChannelListItem
|
|
||||||
for rows.Next() {
|
|
||||||
var item store.ChannelListItem
|
|
||||||
var tags, settings string
|
|
||||||
err := rows.Scan(
|
|
||||||
&item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic,
|
|
||||||
&item.Description, &item.Model, &item.ProviderConfigID,
|
|
||||||
&item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID,
|
|
||||||
&tags, &settings,
|
|
||||||
&item.MessageCount, st(&item.CreatedAtTime), st(&item.UpdatedAtTime),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
item.Tags = ScanArray(tags)
|
|
||||||
if item.Tags == nil {
|
|
||||||
item.Tags = []string{}
|
|
||||||
}
|
|
||||||
item.Settings = safeJSONString(settings)
|
|
||||||
item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z")
|
|
||||||
item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z")
|
|
||||||
result = append(result, item)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeJSONString(s string) json.RawMessage {
|
|
||||||
if s == "" || s == "null" {
|
|
||||||
return json.RawMessage("{}")
|
|
||||||
}
|
|
||||||
return json.RawMessage(s)
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Single-field helpers (v0.29.0) ──────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetAIMode(ctx context.Context, channelID string) (string, error) {
|
|
||||||
var aiMode string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = ?
|
|
||||||
`, channelID).Scan(&aiMode)
|
|
||||||
if err != nil {
|
|
||||||
return "auto", err
|
|
||||||
}
|
|
||||||
return aiMode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) {
|
|
||||||
var channelType string
|
|
||||||
var teamID *string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = ?
|
|
||||||
`, channelID).Scan(&channelType, &teamID)
|
|
||||||
return channelType, teamID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetSystemPrompt(ctx context.Context, channelID string) (*string, error) {
|
|
||||||
var prompt *string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT system_prompt FROM channels WHERE id = ?
|
|
||||||
`, channelID).Scan(&prompt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return prompt, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetDefaultModel(ctx context.Context, channelID string) (*string, error) {
|
|
||||||
var model *string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT model FROM channels WHERE id = ?
|
|
||||||
`, channelID).Scan(&model)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return model, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) TouchUpdatedAt(ctx context.Context, channelID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `UPDATE channels SET updated_at = datetime('now') WHERE id = ?`, channelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT participant_id FROM channel_participants
|
|
||||||
WHERE channel_id = ? AND participant_type = 'user' AND participant_id != ?
|
|
||||||
`, channelID, excludeUserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
if ids == nil {
|
|
||||||
ids = []string{}
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT participant_id FROM channel_participants
|
|
||||||
WHERE channel_id = ? AND participant_type = 'persona'
|
|
||||||
ORDER BY created_at
|
|
||||||
`, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
if ids == nil {
|
|
||||||
ids = []string{}
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) {
|
|
||||||
var leaderID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT cp.participant_id
|
|
||||||
FROM channel_participants cp
|
|
||||||
JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id
|
|
||||||
WHERE cp.channel_id = ?
|
|
||||||
AND cp.participant_type = 'persona'
|
|
||||||
AND pgm.is_leader = 1
|
|
||||||
LIMIT 1
|
|
||||||
`, channelID).Scan(&leaderID)
|
|
||||||
if err == nil && leaderID != "" {
|
|
||||||
return leaderID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT participant_id FROM channel_participants
|
|
||||||
WHERE channel_id = ? AND participant_type = 'persona'
|
|
||||||
ORDER BY created_at LIMIT 1
|
|
||||||
`, channelID).Scan(&leaderID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return leaderID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChannelStore) GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) {
|
|
||||||
var workflowID *string
|
|
||||||
var currentStage int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT workflow_id, COALESCE(current_stage, 0)
|
|
||||||
FROM channels WHERE id = ? AND type = 'workflow'
|
|
||||||
`, channelID).Scan(&workflowID, ¤tStage)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, 0, nil
|
|
||||||
}
|
|
||||||
return workflowID, currentStage, err
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,265 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *FileStore) UpdateStorageKey(ctx context.Context, id, key string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE files SET storage_key = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
||||||
key, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FolderStore struct{}
|
|
||||||
|
|
||||||
func NewFolderStore() *FolderStore { return &FolderStore{} }
|
|
||||||
|
|
||||||
func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, name, parent_id, sort_order, created_at, updated_at
|
|
||||||
FROM folders WHERE user_id = ?
|
|
||||||
ORDER BY sort_order, name
|
|
||||||
`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Folder
|
|
||||||
for rows.Next() {
|
|
||||||
var f models.Folder
|
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder,
|
|
||||||
st(&f.CreatedAt), st(&f.UpdatedAt)); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f.UserID = userID
|
|
||||||
result = append(result, f)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = []models.Folder{}
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
|
|
||||||
f.ID = store.NewID()
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO folders (id, user_id, name, parent_id, sort_order)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`, f.ID, f.UserID, f.Name, f.ParentID, f.SortOrder)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
SELECT name, parent_id, sort_order, created_at, updated_at
|
|
||||||
FROM folders WHERE id = ?
|
|
||||||
`, f.ID).Scan(&f.Name, &f.ParentID, &f.SortOrder, st(&f.CreatedAt), st(&f.UpdatedAt))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) {
|
|
||||||
if parentID != nil {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE folders
|
|
||||||
SET name = COALESCE(NULLIF(?, ''), name),
|
|
||||||
sort_order = COALESCE(?, sort_order),
|
|
||||||
parent_id = ?
|
|
||||||
WHERE id = ? AND user_id = ?
|
|
||||||
`, name, sortOrder, *parentID, folderID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.RowsAffected()
|
|
||||||
}
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE folders
|
|
||||||
SET name = COALESCE(NULLIF(?, ''), name),
|
|
||||||
sort_order = COALESCE(?, sort_order)
|
|
||||||
WHERE id = ? AND user_id = ?
|
|
||||||
`, name, sortOrder, folderID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FolderStore) Delete(ctx context.Context, folderID, userID string) (int64, error) {
|
|
||||||
res, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM folders WHERE id = ? AND user_id = ?`, folderID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FolderStore) UnassignChannels(ctx context.Context, folderID, userID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE channels SET folder_id = NULL WHERE folder_id = ? AND user_id = ?`, folderID, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GitCredentialStore implements store.GitCredentialStore for SQLite.
|
|
||||||
type GitCredentialStore struct{}
|
|
||||||
|
|
||||||
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
|
|
||||||
if cred.ID == "" {
|
|
||||||
cred.ID = uuid.NewString()
|
|
||||||
}
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
|
||||||
cred.ID, cred.UserID, cred.Name, cred.AuthType,
|
|
||||||
base64.StdEncoding.EncodeToString(cred.EncryptedData),
|
|
||||||
base64.StdEncoding.EncodeToString(cred.Nonce),
|
|
||||||
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Read back created_at
|
|
||||||
return DB.QueryRowContext(ctx,
|
|
||||||
`SELECT created_at FROM git_credentials WHERE id = ?`, cred.ID,
|
|
||||||
).Scan(st(&cred.CreatedAt))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
|
|
||||||
var c models.GitCredential
|
|
||||||
var encData, nonce string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
|
|
||||||
FROM git_credentials WHERE id = ?`, id,
|
|
||||||
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
|
||||||
&encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)
|
|
||||||
c.Nonce, _ = base64.StdEncoding.DecodeString(nonce)
|
|
||||||
return &c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
|
|
||||||
FROM git_credentials WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var creds []models.GitCredential
|
|
||||||
for rows.Next() {
|
|
||||||
var c models.GitCredential
|
|
||||||
var encData, nonce string
|
|
||||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
|
||||||
&encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)
|
|
||||||
c.Nonce, _ = base64.StdEncoding.DecodeString(nonce)
|
|
||||||
creds = append(creds, c)
|
|
||||||
}
|
|
||||||
return creds, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GitCredentialStore) Delete(ctx context.Context, id, userID string) error {
|
|
||||||
res, err := DB.ExecContext(ctx, `DELETE FROM git_credentials WHERE id = ? AND user_id = ?`, id, userID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── KnowledgeBaseStore ──────────────────────────
|
|
||||||
|
|
||||||
type KnowledgeBaseStore struct{}
|
|
||||||
|
|
||||||
func NewKnowledgeBaseStore() *KnowledgeBaseStore { return &KnowledgeBaseStore{} }
|
|
||||||
|
|
||||||
// ── KB CRUD ──────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) Create(ctx context.Context, kb *models.KnowledgeBase) error {
|
|
||||||
kb.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
kb.CreatedAt = now
|
|
||||||
kb.UpdatedAt = now
|
|
||||||
// document_count, chunk_count, total_bytes default to 0 (Go zero values)
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO knowledge_bases (id, name, description, scope, owner_id, team_id, embedding_config, status, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
kb.ID, kb.Name, kb.Description, kb.Scope,
|
|
||||||
models.NullString(kb.OwnerID), models.NullString(kb.TeamID),
|
|
||||||
ToJSON(kb.EmbeddingConfig), kb.Status,
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) {
|
|
||||||
kb, err := scanKB(DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
|
||||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
|
||||||
FROM knowledge_bases WHERE id = ?`, id))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return kb, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
b := NewUpdate("knowledge_bases")
|
|
||||||
for k, v := range fields {
|
|
||||||
if k == "embedding_config" {
|
|
||||||
b.SetJSON(k, v)
|
|
||||||
} else {
|
|
||||||
b.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.SetExpr("updated_at", nowExpr)
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM knowledge_bases WHERE id = ?", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scoped Listing ──────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
|
|
||||||
q := `
|
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
|
||||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
|
||||||
FROM knowledge_bases
|
|
||||||
WHERE scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = ?)`
|
|
||||||
|
|
||||||
args := []interface{}{userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
placeholders := makeQPlaceholders(len(teamIDs))
|
|
||||||
q += fmt.Sprintf(` OR (scope = 'team' AND team_id IN (%s))`, placeholders)
|
|
||||||
for _, tid := range teamIDs {
|
|
||||||
args = append(args, tid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group-granted KBs
|
|
||||||
q += `
|
|
||||||
OR id IN (
|
|
||||||
SELECT rg.resource_id FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'knowledge_base'
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
|
||||||
WHERE gm.user_id = ?
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)`
|
|
||||||
args = append(args, userID)
|
|
||||||
|
|
||||||
q += ` ORDER BY name`
|
|
||||||
return queryKBs(ctx, q, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) {
|
|
||||||
return queryKBs(ctx, `
|
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
|
||||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
|
||||||
FROM knowledge_bases WHERE scope = 'personal' AND owner_id = ? ORDER BY name`, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Documents ────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) CreateDocument(ctx context.Context, doc *models.KBDocument) error {
|
|
||||||
doc.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
doc.CreatedAt = now
|
|
||||||
doc.UpdatedAt = now
|
|
||||||
// chunk_count defaults to 0 (Go zero value)
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
doc.ID, doc.KBID, doc.Filename, doc.ContentType, doc.SizeBytes,
|
|
||||||
doc.StorageKey, doc.Status, doc.UploadedBy,
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) GetDocument(ctx context.Context, id string) (*models.KBDocument, error) {
|
|
||||||
var doc models.KBDocument
|
|
||||||
var extractedText, errMsg sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
|
|
||||||
extracted_text, chunk_count, status, error, uploaded_by, created_at, updated_at
|
|
||||||
FROM kb_documents WHERE id = ?`, id).Scan(
|
|
||||||
&doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType, &doc.SizeBytes,
|
|
||||||
&doc.StorageKey, &extractedText, &doc.ChunkCount, &doc.Status,
|
|
||||||
&errMsg, &doc.UploadedBy, st(&doc.CreatedAt), st(&doc.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
doc.ExtractedText = NullableStringPtr(extractedText)
|
|
||||||
doc.Error = NullableStringPtr(errMsg)
|
|
||||||
return &doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
|
|
||||||
chunk_count, status, error, uploaded_by, created_at, updated_at
|
|
||||||
FROM kb_documents WHERE kb_id = ? ORDER BY created_at`, kbID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.KBDocument
|
|
||||||
for rows.Next() {
|
|
||||||
var doc models.KBDocument
|
|
||||||
var errMsg sql.NullString
|
|
||||||
err := rows.Scan(&doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType,
|
|
||||||
&doc.SizeBytes, &doc.StorageKey, &doc.ChunkCount, &doc.Status,
|
|
||||||
&errMsg, &doc.UploadedBy, st(&doc.CreatedAt), st(&doc.UpdatedAt))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
doc.Error = NullableStringPtr(errMsg)
|
|
||||||
result = append(result, doc)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE kb_documents SET status = ?, error = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ?`, status, models.NullString(errMsg), id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE kb_documents SET extracted_text = ?, chunk_count = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ?`, text, chunkCount, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE kb_documents SET storage_key = ? WHERE id = ?`, storageKey, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) {
|
|
||||||
var doc models.KBDocument
|
|
||||||
var errMsg sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
DELETE FROM kb_documents WHERE id = ?
|
|
||||||
RETURNING id, kb_id, filename, storage_key, status, error`,
|
|
||||||
id).Scan(&doc.ID, &doc.KBID, &doc.Filename, &doc.StorageKey, &doc.Status, &errMsg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
doc.Error = NullableStringPtr(errMsg)
|
|
||||||
return &doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chunks ───────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) InsertChunks(ctx context.Context, chunks []models.KBChunk) error {
|
|
||||||
if len(chunks) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch insert using multi-row INSERT.
|
|
||||||
// Embeddings stored as JSON text (e.g. "[0.1,0.2,...]") for app-level cosine similarity.
|
|
||||||
valueParts := make([]string, 0, len(chunks))
|
|
||||||
args := make([]interface{}, 0, len(chunks)*8)
|
|
||||||
|
|
||||||
for _, c := range chunks {
|
|
||||||
c.ID = store.NewID()
|
|
||||||
valueParts = append(valueParts, "(?, ?, ?, ?, ?, ?, ?, ?)")
|
|
||||||
var embJSON interface{}
|
|
||||||
if len(c.Embedding) > 0 {
|
|
||||||
embJSON = ToJSON(c.Embedding)
|
|
||||||
}
|
|
||||||
args = append(args, c.ID, c.KBID, c.DocumentID, c.ChunkIndex,
|
|
||||||
c.Content, c.TokenCount, embJSON, ToJSON(c.Metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`INSERT INTO kb_chunks (id, kb_id, document_id, chunk_index, content, token_count, embedding, metadata)
|
|
||||||
VALUES %s`, strings.Join(valueParts, ", "))
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, q, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) DeleteChunksForDocument(ctx context.Context, documentID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM kb_chunks WHERE document_id = ?", documentID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SimilaritySearch performs cosine similarity search in Go.
|
|
||||||
// Loads candidate chunks from the specified KBs, decodes their JSON-stored
|
|
||||||
// embeddings, computes cosine similarity against queryVec, and returns the
|
|
||||||
// top results above threshold. For SQLite-scale deployments (single-user,
|
|
||||||
// edge, dev) this is perfectly adequate without requiring CGO/sqlite-vec.
|
|
||||||
func (s *KnowledgeBaseStore) SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64, threshold float64, limit int) ([]models.KBSearchResult, error) {
|
|
||||||
if len(kbIDs) == 0 || len(queryVec) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load candidate chunks with embeddings
|
|
||||||
placeholders := makeQPlaceholders(len(kbIDs))
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT c.content, c.embedding, c.metadata, d.filename, kb.name
|
|
||||||
FROM kb_chunks c
|
|
||||||
JOIN kb_documents d ON c.document_id = d.id
|
|
||||||
JOIN knowledge_bases kb ON c.kb_id = kb.id
|
|
||||||
WHERE c.kb_id IN (%s)
|
|
||||||
AND c.embedding IS NOT NULL`, placeholders)
|
|
||||||
|
|
||||||
args := make([]interface{}, len(kbIDs))
|
|
||||||
for i, id := range kbIDs {
|
|
||||||
args[i] = id
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type candidate struct {
|
|
||||||
result models.KBSearchResult
|
|
||||||
similarity float64
|
|
||||||
}
|
|
||||||
var candidates []candidate
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var content, embJSON, filename, kbName string
|
|
||||||
var metaJSON sql.NullString
|
|
||||||
if err := rows.Scan(&content, &embJSON, &metaJSON, &filename, &kbName); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode embedding from JSON
|
|
||||||
var embedding []float64
|
|
||||||
if err := json.Unmarshal([]byte(embJSON), &embedding); err != nil {
|
|
||||||
continue // skip chunks with malformed embeddings
|
|
||||||
}
|
|
||||||
|
|
||||||
sim := cosineSimilarity(queryVec, embedding)
|
|
||||||
if sim > threshold {
|
|
||||||
r := models.KBSearchResult{
|
|
||||||
Content: content,
|
|
||||||
Filename: filename,
|
|
||||||
KBName: kbName,
|
|
||||||
Similarity: sim,
|
|
||||||
}
|
|
||||||
if metaJSON.Valid {
|
|
||||||
ScanJSON(metaJSON.String, &r.Metadata)
|
|
||||||
}
|
|
||||||
candidates = append(candidates, candidate{result: r, similarity: sim})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by similarity descending
|
|
||||||
sort.Slice(candidates, func(i, j int) bool {
|
|
||||||
return candidates[i].similarity > candidates[j].similarity
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cap at limit
|
|
||||||
if len(candidates) > limit {
|
|
||||||
candidates = candidates[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]models.KBSearchResult, len(candidates))
|
|
||||||
for i, c := range candidates {
|
|
||||||
results[i] = c.result
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cosineSimilarity computes cosine similarity between two vectors.
|
|
||||||
// Returns 0 if either vector is zero-length or all-zeros.
|
|
||||||
func cosineSimilarity(a, b []float64) float64 {
|
|
||||||
if len(a) != len(b) || len(a) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var dot, normA, normB float64
|
|
||||||
for i := range a {
|
|
||||||
dot += a[i] * b[i]
|
|
||||||
normA += a[i] * a[i]
|
|
||||||
normB += b[i] * b[i]
|
|
||||||
}
|
|
||||||
if normA == 0 || normB == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Channel Links ─────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM channel_knowledge_bases WHERE channel_id = ?", channelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, kbID := range kbIDs {
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_knowledge_bases (channel_id, kb_id, enabled) VALUES (?, ?, 1)`,
|
|
||||||
channelID, kbID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT ckb.kb_id, kb.name, ckb.enabled, kb.document_count
|
|
||||||
FROM channel_knowledge_bases ckb
|
|
||||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
|
||||||
WHERE ckb.channel_id = ?
|
|
||||||
ORDER BY kb.name`, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ChannelKB
|
|
||||||
for rows.Next() {
|
|
||||||
var ckb models.ChannelKB
|
|
||||||
if err := rows.Scan(&ckb.KBID, &ckb.KBName, &ckb.Enabled, &ckb.DocumentCount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, ckb)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) GetActiveKBIDs(ctx context.Context, channelID string, userID string, teamIDs []string) ([]string, error) {
|
|
||||||
q := `
|
|
||||||
SELECT ckb.kb_id
|
|
||||||
FROM channel_knowledge_bases ckb
|
|
||||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
|
||||||
WHERE ckb.channel_id = ? AND ckb.enabled = 1
|
|
||||||
AND (
|
|
||||||
kb.scope = 'global'
|
|
||||||
OR (kb.scope = 'personal' AND kb.owner_id = ?)`
|
|
||||||
|
|
||||||
args := []interface{}{channelID, userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
placeholders := makeQPlaceholders(len(teamIDs))
|
|
||||||
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id IN (%s))`, placeholders)
|
|
||||||
for _, tid := range teamIDs {
|
|
||||||
args = append(args, tid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
q += `)`
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats ────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) UpdateStats(ctx context.Context, kbID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE knowledge_bases SET
|
|
||||||
document_count = (SELECT COUNT(*) FROM kb_documents WHERE kb_id = ? AND status != 'error'),
|
|
||||||
chunk_count = (SELECT COUNT(*) FROM kb_chunks WHERE kb_id = ?),
|
|
||||||
total_bytes = COALESCE((SELECT SUM(size_bytes) FROM kb_documents WHERE kb_id = ?), 0),
|
|
||||||
updated_at = datetime('now')
|
|
||||||
WHERE id = ?`, kbID, kbID, kbID, kbID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Discoverable Management (v0.17.0) ────────────
|
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE knowledge_bases SET discoverable = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ?`, discoverable, kbID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetActiveKBIDsWithPersona returns KB IDs accessible for a channel, including
|
|
||||||
// KBs bound to the active persona.
|
|
||||||
func (s *KnowledgeBaseStore) GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, teamIDs []string, personaID string) ([]string, error) {
|
|
||||||
q := `
|
|
||||||
SELECT ckb.kb_id
|
|
||||||
FROM channel_knowledge_bases ckb
|
|
||||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
|
||||||
WHERE ckb.channel_id = ? AND ckb.enabled = 1
|
|
||||||
AND (
|
|
||||||
kb.scope = 'global'
|
|
||||||
OR (kb.scope = 'personal' AND kb.owner_id = ?)`
|
|
||||||
|
|
||||||
args := []interface{}{channelID, userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
placeholders := makeQPlaceholders(len(teamIDs))
|
|
||||||
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id IN (%s))`, placeholders)
|
|
||||||
for _, tid := range teamIDs {
|
|
||||||
args = append(args, tid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
q += `)`
|
|
||||||
|
|
||||||
// UNION with persona-bound KBs (bypass discoverable check)
|
|
||||||
if personaID != "" {
|
|
||||||
q += `
|
|
||||||
UNION
|
|
||||||
SELECT pkb.kb_id
|
|
||||||
FROM persona_knowledge_bases pkb
|
|
||||||
JOIN knowledge_bases kb ON pkb.kb_id = kb.id
|
|
||||||
WHERE pkb.persona_id = ?
|
|
||||||
AND kb.chunk_count > 0`
|
|
||||||
args = append(args, personaID)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !seen[id] {
|
|
||||||
seen[id] = true
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListDiscoverable returns KBs the user can see AND that are discoverable.
|
|
||||||
func (s *KnowledgeBaseStore) ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
|
|
||||||
q := `
|
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
|
||||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
|
||||||
FROM knowledge_bases
|
|
||||||
WHERE discoverable = 1
|
|
||||||
AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = ?)`
|
|
||||||
|
|
||||||
args := []interface{}{userID}
|
|
||||||
|
|
||||||
if len(teamIDs) > 0 {
|
|
||||||
placeholders := makeQPlaceholders(len(teamIDs))
|
|
||||||
q += fmt.Sprintf(` OR (scope = 'team' AND team_id IN (%s))`, placeholders)
|
|
||||||
for _, tid := range teamIDs {
|
|
||||||
args = append(args, tid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group-granted KBs
|
|
||||||
q += `
|
|
||||||
OR id IN (
|
|
||||||
SELECT rg.resource_id FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'knowledge_base'
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
|
||||||
WHERE gm.user_id = ?
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)`
|
|
||||||
args = append(args, userID)
|
|
||||||
|
|
||||||
q += `) ORDER BY name`
|
|
||||||
return queryKBs(ctx, q, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scan Helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
|
|
||||||
var kb models.KnowledgeBase
|
|
||||||
var ownerID, teamID sql.NullString
|
|
||||||
var embCfgJSON string
|
|
||||||
|
|
||||||
err := row.Scan(
|
|
||||||
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
|
||||||
&ownerID, &teamID, &embCfgJSON,
|
|
||||||
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
|
||||||
&kb.Status, &kb.Discoverable, st(&kb.CreatedAt), st(&kb.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
kb.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
kb.TeamID = NullableStringPtr(teamID)
|
|
||||||
ScanJSON(embCfgJSON, &kb.EmbeddingConfig)
|
|
||||||
return &kb, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryKBs(ctx context.Context, query string, args ...interface{}) ([]models.KnowledgeBase, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.KnowledgeBase
|
|
||||||
for rows.Next() {
|
|
||||||
var kb models.KnowledgeBase
|
|
||||||
var ownerID, teamID sql.NullString
|
|
||||||
var embCfgJSON string
|
|
||||||
err := rows.Scan(
|
|
||||||
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
|
||||||
&ownerID, &teamID, &embCfgJSON,
|
|
||||||
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
|
||||||
&kb.Status, &kb.Discoverable, st(&kb.CreatedAt), st(&kb.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
kb.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
kb.TeamID = NullableStringPtr(teamID)
|
|
||||||
ScanJSON(embCfgJSON, &kb.EmbeddingConfig)
|
|
||||||
result = append(result, kb)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeQPlaceholders returns "?,?,?" for n items.
|
|
||||||
func makeQPlaceholders(n int) string {
|
|
||||||
if n <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimRight(strings.Repeat("?,", n), ",")
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MemoryStore struct{}
|
|
||||||
|
|
||||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{} }
|
|
||||||
|
|
||||||
func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error {
|
|
||||||
if m.ID == "" {
|
|
||||||
m.ID = store.NewID()
|
|
||||||
}
|
|
||||||
if m.Status == "" {
|
|
||||||
m.Status = models.MemoryStatusActive
|
|
||||||
}
|
|
||||||
if m.Confidence == 0 {
|
|
||||||
m.Confidence = 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO memories (id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key)
|
|
||||||
DO UPDATE SET
|
|
||||||
value = excluded.value,
|
|
||||||
confidence = excluded.confidence,
|
|
||||||
status = excluded.status,
|
|
||||||
source_channel_id = excluded.source_channel_id,
|
|
||||||
updated_at = datetime('now')
|
|
||||||
`, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value,
|
|
||||||
m.SourceChannelID, m.Confidence, m.Status)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Update(ctx context.Context, m *models.Memory) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE memories
|
|
||||||
SET key = ?, value = ?, confidence = ?, status = ?,
|
|
||||||
source_channel_id = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ?
|
|
||||||
`, m.Key, m.Value, m.Confidence, m.Status, m.SourceChannelID, m.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) {
|
|
||||||
m := &models.Memory{}
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories WHERE id = ?
|
|
||||||
`, id).Scan(
|
|
||||||
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
|
|
||||||
&m.SourceChannelID, &m.Confidence, &m.Status,
|
|
||||||
st(&m.CreatedAt), st(&m.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, fmt.Errorf("memory not found: %s", id)
|
|
||||||
}
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) {
|
|
||||||
clauses := []string{"scope = ?", "owner_id = ?"}
|
|
||||||
args := []interface{}{f.Scope, f.OwnerID}
|
|
||||||
|
|
||||||
if f.UserID != nil {
|
|
||||||
clauses = append(clauses, "user_id = ?")
|
|
||||||
args = append(args, *f.UserID)
|
|
||||||
}
|
|
||||||
|
|
||||||
status := f.Status
|
|
||||||
if status == "" {
|
|
||||||
status = models.MemoryStatusActive
|
|
||||||
}
|
|
||||||
clauses = append(clauses, "status = ?")
|
|
||||||
args = append(args, status)
|
|
||||||
|
|
||||||
if f.Query != "" {
|
|
||||||
// SQLite: LIKE-based keyword search (no tsvector)
|
|
||||||
clauses = append(clauses, "(key || ' ' || value) LIKE ?")
|
|
||||||
args = append(args, "%"+f.Query+"%")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := f.Limit
|
|
||||||
if limit <= 0 || limit > 200 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE %s
|
|
||||||
ORDER BY confidence DESC, updated_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`, strings.Join(clauses, " AND "))
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return s.scanMemories(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = ?`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Archive(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE memories SET status = 'archived', updated_at = datetime('now') WHERE id = ?
|
|
||||||
`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) {
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build UNION query across applicable scopes
|
|
||||||
parts := []string{}
|
|
||||||
args := []interface{}{}
|
|
||||||
|
|
||||||
// 1. User-scope memories
|
|
||||||
userPart := `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'user' AND owner_id = ? AND status = 'active'
|
|
||||||
`
|
|
||||||
args = append(args, userID)
|
|
||||||
|
|
||||||
if query != "" {
|
|
||||||
userPart += " AND (key || ' ' || value) LIKE ?"
|
|
||||||
args = append(args, "%"+query+"%")
|
|
||||||
}
|
|
||||||
parts = append(parts, userPart)
|
|
||||||
|
|
||||||
// 2. Persona-scope memories
|
|
||||||
if personaID != nil && *personaID != "" {
|
|
||||||
personaPart := `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'persona' AND owner_id = ? AND status = 'active'
|
|
||||||
`
|
|
||||||
args = append(args, *personaID)
|
|
||||||
if query != "" {
|
|
||||||
personaPart += " AND (key || ' ' || value) LIKE ?"
|
|
||||||
args = append(args, "%"+query+"%")
|
|
||||||
}
|
|
||||||
parts = append(parts, personaPart)
|
|
||||||
|
|
||||||
// 3. Persona+user memories
|
|
||||||
puPart := `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'persona_user' AND owner_id = ? AND user_id = ? AND status = 'active'
|
|
||||||
`
|
|
||||||
args = append(args, *personaID, userID)
|
|
||||||
if query != "" {
|
|
||||||
puPart += " AND (key || ' ' || value) LIKE ?"
|
|
||||||
args = append(args, "%"+query+"%")
|
|
||||||
}
|
|
||||||
parts = append(parts, puPart)
|
|
||||||
}
|
|
||||||
|
|
||||||
fullQuery := fmt.Sprintf(`
|
|
||||||
SELECT * FROM (%s)
|
|
||||||
ORDER BY
|
|
||||||
CASE scope
|
|
||||||
WHEN 'persona_user' THEN 0
|
|
||||||
WHEN 'persona' THEN 1
|
|
||||||
WHEN 'user' THEN 2
|
|
||||||
END,
|
|
||||||
confidence DESC,
|
|
||||||
updated_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`, strings.Join(parts, " UNION ALL "))
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, fullQuery, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return s.scanMemories(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM memories
|
|
||||||
WHERE scope = ? AND owner_id = ? AND status = 'active'
|
|
||||||
`, scope, ownerID).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error) {
|
|
||||||
if limit <= 0 || limit > 500 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE status = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`, status, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return s.scanMemories(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) {
|
|
||||||
var memories []models.Memory
|
|
||||||
for rows.Next() {
|
|
||||||
var m models.Memory
|
|
||||||
if err := rows.Scan(
|
|
||||||
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
|
|
||||||
&m.SourceChannelID, &m.Confidence, &m.Status,
|
|
||||||
st(&m.CreatedAt), st(&m.UpdatedAt),
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
memories = append(memories, m)
|
|
||||||
}
|
|
||||||
if memories == nil {
|
|
||||||
memories = []models.Memory{}
|
|
||||||
}
|
|
||||||
return memories, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure compile-time interface satisfaction
|
|
||||||
var _ store.MemoryStore = (*MemoryStore)(nil)
|
|
||||||
|
|
||||||
// ── CS5b additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MemoryStore) SetEmbedding(ctx context.Context, id, embedding string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE memories SET embedding = ? WHERE id = ?`, embedding, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) {
|
|
||||||
var lastID string
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT last_message_id FROM memory_extraction_log WHERE channel_id = ? AND user_id = ?`,
|
|
||||||
channelID, userID).Scan(&lastID)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return lastID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO memory_extraction_log (id, channel_id, user_id, last_message_id, memory_count)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(channel_id, user_id) DO UPDATE SET
|
|
||||||
last_message_id = excluded.last_message_id,
|
|
||||||
extracted_at = datetime('now'),
|
|
||||||
memory_count = memory_extraction_log.memory_count + excluded.memory_count
|
|
||||||
`, store.NewID(), channelID, userID, lastMessageID, count)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── v0.30.1 — Memory Compaction ────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE memories
|
|
||||||
SET confidence = confidence * ?,
|
|
||||||
updated_at = datetime('now')
|
|
||||||
WHERE status = 'active'
|
|
||||||
AND updated_at < ?
|
|
||||||
AND confidence > 0.1
|
|
||||||
`, decayFactor, olderThan)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return int(n), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE memories
|
|
||||||
SET status = 'archived',
|
|
||||||
updated_at = datetime('now')
|
|
||||||
WHERE status = 'active'
|
|
||||||
AND confidence < ?
|
|
||||||
`, belowConfidence)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
return int(n), nil
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RecallHybrid returns active memories using vector similarity + keyword search.
|
|
||||||
// SQLite has no pgvector, so we load embeddings and compute cosine similarity in Go.
|
|
||||||
func (s *MemoryStore) RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) {
|
|
||||||
if len(queryVec) == 0 {
|
|
||||||
return s.Recall(ctx, userID, personaID, query, limit)
|
|
||||||
}
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all active memories with embeddings for applicable scopes
|
|
||||||
semantic, err := s.recallSemantic(ctx, userID, personaID, queryVec, limit)
|
|
||||||
if err != nil || len(semantic) == 0 {
|
|
||||||
return s.Recall(ctx, userID, personaID, query, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyword []models.Memory
|
|
||||||
if query != "" {
|
|
||||||
keyword, _ = s.Recall(ctx, userID, personaID, query, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeResultsSQLite(semantic, keyword, limit), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type scoredMemory struct {
|
|
||||||
Memory models.Memory
|
|
||||||
Similarity float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MemoryStore) recallSemantic(ctx context.Context, userID string, personaID *string, queryVec []float64, limit int) ([]models.Memory, error) {
|
|
||||||
// Build query to load memories with embeddings
|
|
||||||
parts := []string{}
|
|
||||||
args := []interface{}{}
|
|
||||||
|
|
||||||
userPart := `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at, embedding
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'user' AND owner_id = ? AND status = 'active'
|
|
||||||
AND embedding IS NOT NULL
|
|
||||||
`
|
|
||||||
args = append(args, userID)
|
|
||||||
parts = append(parts, userPart)
|
|
||||||
|
|
||||||
if personaID != nil && *personaID != "" {
|
|
||||||
personaPart := `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at, embedding
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'persona' AND owner_id = ? AND status = 'active'
|
|
||||||
AND embedding IS NOT NULL
|
|
||||||
`
|
|
||||||
args = append(args, *personaID)
|
|
||||||
parts = append(parts, personaPart)
|
|
||||||
|
|
||||||
puPart := `
|
|
||||||
SELECT id, scope, owner_id, user_id, key, value,
|
|
||||||
source_channel_id, confidence, status, created_at, updated_at, embedding
|
|
||||||
FROM memories
|
|
||||||
WHERE scope = 'persona_user' AND owner_id = ? AND user_id = ? AND status = 'active'
|
|
||||||
AND embedding IS NOT NULL
|
|
||||||
`
|
|
||||||
args = append(args, *personaID, userID)
|
|
||||||
parts = append(parts, puPart)
|
|
||||||
}
|
|
||||||
|
|
||||||
fullQuery := strings.Join(parts, " UNION ALL ")
|
|
||||||
rows, err := DB.QueryContext(ctx, fullQuery, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
// Score each memory by cosine similarity
|
|
||||||
var scored []scoredMemory
|
|
||||||
for rows.Next() {
|
|
||||||
var m models.Memory
|
|
||||||
var embeddingJSON string
|
|
||||||
if err := rows.Scan(
|
|
||||||
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
|
|
||||||
&m.SourceChannelID, &m.Confidence, &m.Status,
|
|
||||||
st(&m.CreatedAt), st(&m.UpdatedAt), &embeddingJSON,
|
|
||||||
); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var vec []float64
|
|
||||||
if err := json.Unmarshal([]byte(embeddingJSON), &vec); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// cosineSimilarity is defined in knowledge_bases.go (same package)
|
|
||||||
sim := cosineSimilarity(queryVec, vec)
|
|
||||||
if sim > 0.3 {
|
|
||||||
scored = append(scored, scoredMemory{Memory: m, Similarity: sim})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by similarity descending (simple insertion sort for small N)
|
|
||||||
for i := 0; i < len(scored); i++ {
|
|
||||||
for j := i + 1; j < len(scored); j++ {
|
|
||||||
if scored[j].Similarity > scored[i].Similarity {
|
|
||||||
scored[i], scored[j] = scored[j], scored[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take top N
|
|
||||||
result := make([]models.Memory, 0, limit)
|
|
||||||
for i := 0; i < len(scored) && i < limit; i++ {
|
|
||||||
result = append(result, scored[i].Memory)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeResultsSQLite(semantic, keyword []models.Memory, limit int) []models.Memory {
|
|
||||||
seen := make(map[string]bool, len(semantic))
|
|
||||||
result := make([]models.Memory, 0, limit)
|
|
||||||
|
|
||||||
for _, m := range semantic {
|
|
||||||
if len(result) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
seen[m.ID] = true
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
for _, m := range keyword {
|
|
||||||
if len(result) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !seen[m.ID] {
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MessageStore struct{}
|
|
||||||
|
|
||||||
func NewMessageStore() *MessageStore { return &MessageStore{} }
|
|
||||||
|
|
||||||
func (s *MessageStore) Create(ctx context.Context, m *models.Message) error {
|
|
||||||
toolCallsJSON := ToJSON(m.ToolCalls)
|
|
||||||
metadataJSON := ToJSON(m.Metadata)
|
|
||||||
m.ID = store.NewID()
|
|
||||||
m.CreatedAt = time.Now().UTC()
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used, tool_calls,
|
|
||||||
metadata, parent_id, sibling_index, participant_type, participant_id, created_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
||||||
m.ID, m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed,
|
|
||||||
toolCallsJSON, metadataJSON,
|
|
||||||
models.NullString(m.ParentID), m.SiblingIndex,
|
|
||||||
m.ParticipantType, m.ParticipantID,
|
|
||||||
m.CreatedAt.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetByID(ctx context.Context, id string) (*models.Message, error) {
|
|
||||||
var m models.Message
|
|
||||||
var parentID sql.NullString
|
|
||||||
var toolCallsJSON, metadataJSON []byte
|
|
||||||
var deletedAt *time.Time
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM messages WHERE id = ?`, id).Scan(
|
|
||||||
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
|
||||||
&toolCallsJSON, &metadataJSON,
|
|
||||||
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
||||||
stN(&deletedAt), st(&m.CreatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m.ParentID = NullableStringPtr(parentID)
|
|
||||||
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
|
|
||||||
json.Unmarshal(metadataJSON, &m.Metadata)
|
|
||||||
m.DeletedAt = deletedAt
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
b := NewUpdate("messages")
|
|
||||||
for k, v := range fields {
|
|
||||||
if k == "tool_calls" || k == "metadata" {
|
|
||||||
b.SetJSON(k, v)
|
|
||||||
} else {
|
|
||||||
b.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) Delete(ctx context.Context, id string) error {
|
|
||||||
now := time.Now()
|
|
||||||
_, err := DB.ExecContext(ctx, "UPDATE messages SET deleted_at = ? WHERE id = ?", now, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) ListForChannel(ctx context.Context, channelID string, opts store.ListOptions) ([]models.Message, error) {
|
|
||||||
b := NewSelect(
|
|
||||||
"id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at",
|
|
||||||
"messages",
|
|
||||||
).Where("channel_id = ?", channelID).WhereRaw("deleted_at IS NULL")
|
|
||||||
if opts.Sort == "" {
|
|
||||||
b.OrderBy("created_at", "ASC")
|
|
||||||
}
|
|
||||||
b.Paginate(opts)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMessages(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetChildren(ctx context.Context, parentID string) ([]models.Message, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM messages WHERE parent_id = ? AND deleted_at IS NULL
|
|
||||||
ORDER BY sibling_index`, parentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMessages(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE parent_id = (SELECT parent_id FROM messages WHERE id = ?)
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
ORDER BY sibling_index`, messageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMessages(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
WITH RECURSIVE path AS (
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM messages WHERE id = ?
|
|
||||||
UNION ALL
|
|
||||||
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
|
|
||||||
m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at
|
|
||||||
FROM messages m JOIN path p ON m.id = p.parent_id
|
|
||||||
)
|
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
||||||
FROM path ORDER BY created_at ASC`, messageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanMessages(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) {
|
|
||||||
var maxIdx sql.NullInt64
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
"SELECT MAX(sibling_index) FROM messages WHERE parent_id = ? AND deleted_at IS NULL",
|
|
||||||
parentID).Scan(&maxIdx)
|
|
||||||
if err != nil || !maxIdx.Valid {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return int(maxIdx.Int64) + 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) CountForChannel(ctx context.Context, channelID string) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM messages WHERE channel_id = ? AND deleted_at IS NULL",
|
|
||||||
channelID).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanMessages(rows *sql.Rows) ([]models.Message, error) {
|
|
||||||
var result []models.Message
|
|
||||||
for rows.Next() {
|
|
||||||
var m models.Message
|
|
||||||
var parentID sql.NullString
|
|
||||||
var toolCallsJSON, metadataJSON []byte
|
|
||||||
var deletedAt *time.Time
|
|
||||||
err := rows.Scan(
|
|
||||||
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
|
||||||
&toolCallsJSON, &metadataJSON,
|
|
||||||
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
||||||
stN(&deletedAt), st(&m.CreatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m.ParentID = NullableStringPtr(parentID)
|
|
||||||
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
|
|
||||||
json.Unmarshal(metadataJSON, &m.Metadata)
|
|
||||||
m.DeletedAt = deletedAt
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MessageStore) CountAll(ctx context.Context) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) {
|
|
||||||
words := strings.Fields(query)
|
|
||||||
if len(words) == 0 {
|
|
||||||
return []store.ChannelSearchResult{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
q := `SELECT m.id, m.role, SUBSTR(m.content, 1, 300), m.created_at
|
|
||||||
FROM messages m
|
|
||||||
WHERE m.channel_id = ?
|
|
||||||
AND m.deleted_at IS NULL
|
|
||||||
AND m.role IN ('user', 'assistant')`
|
|
||||||
args := []interface{}{channelID}
|
|
||||||
|
|
||||||
if roleFilter == "user" || roleFilter == "assistant" {
|
|
||||||
q += " AND m.role = ?"
|
|
||||||
args = append(args, roleFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, w := range words {
|
|
||||||
q += " AND m.content LIKE ?"
|
|
||||||
args = append(args, "%"+w+"%")
|
|
||||||
}
|
|
||||||
|
|
||||||
q += " ORDER BY m.created_at DESC LIMIT ?"
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.ChannelSearchResult, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var r store.ChannelSearchResult
|
|
||||||
if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, st(&r.Timestamp)); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r.Rank = 1.0 // no ranking on SQLite
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) {
|
|
||||||
var total int
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM messages WHERE channel_id = ? AND deleted_at IS NULL`,
|
|
||||||
channelID).Scan(&total)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
|
|
||||||
m.sibling_index, m.participant_type, m.participant_id,
|
|
||||||
CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username)
|
|
||||||
WHEN m.participant_type = 'persona' THEN p.name
|
|
||||||
ELSE NULL END AS sender_name,
|
|
||||||
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
|
|
||||||
WHEN m.participant_type = 'persona' THEN p.avatar
|
|
||||||
ELSE NULL END AS sender_avatar,
|
|
||||||
m.created_at
|
|
||||||
FROM messages m
|
|
||||||
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id
|
|
||||||
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id
|
|
||||||
WHERE m.channel_id = ? AND m.deleted_at IS NULL
|
|
||||||
ORDER BY m.created_at ASC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`, channelID, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.MessageWithSender, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var m store.MessageWithSender
|
|
||||||
if err := rows.Scan(
|
|
||||||
&m.ID, &m.ChannelID, &m.Role, &m.Content,
|
|
||||||
&m.Model, &m.TokensUsed, &m.ParentID,
|
|
||||||
&m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
||||||
&m.SenderName, &m.SenderAvatar,
|
|
||||||
&m.CreatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, total, err
|
|
||||||
}
|
|
||||||
results = append(results, m)
|
|
||||||
}
|
|
||||||
return results, total, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) {
|
|
||||||
var parentID sql.NullString
|
|
||||||
var role string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT parent_id, role FROM messages
|
|
||||||
WHERE id = ? AND channel_id = ? AND deleted_at IS NULL
|
|
||||||
`, messageID, channelID).Scan(&parentID, &role)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return NullableStringPtr(parentID), role, nil
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Tree Operations (v0.29.0) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) {
|
|
||||||
var leafID *string
|
|
||||||
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT active_leaf_id FROM channel_cursors
|
|
||||||
WHERE channel_id = ? AND user_id = ?
|
|
||||||
`, channelID, userID).Scan(&leafID)
|
|
||||||
|
|
||||||
if err == nil && leafID != nil {
|
|
||||||
var exists bool
|
|
||||||
DB.QueryRowContext(ctx, `
|
|
||||||
SELECT EXISTS(SELECT 1 FROM messages WHERE id = ? AND deleted_at IS NULL)
|
|
||||||
`, *leafID).Scan(&exists)
|
|
||||||
if exists {
|
|
||||||
return leafID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallbackID string
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM messages
|
|
||||||
WHERE channel_id = ? AND deleted_at IS NULL
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
`, channelID).Scan(&fallbackID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &fallbackID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
WITH RECURSIVE path AS (
|
|
||||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
participant_type, participant_id, sibling_index, created_at,
|
|
||||||
0 AS depth
|
|
||||||
FROM messages
|
|
||||||
WHERE id = ? AND channel_id = ? AND deleted_at IS NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
|
|
||||||
m.participant_type, m.participant_id, m.sibling_index, m.created_at,
|
|
||||||
p.depth + 1
|
|
||||||
FROM messages m
|
|
||||||
JOIN path p ON m.id = p.parent_id
|
|
||||||
WHERE m.deleted_at IS NULL
|
|
||||||
)
|
|
||||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
||||||
participant_type, participant_id, sibling_index, created_at
|
|
||||||
FROM path
|
|
||||||
ORDER BY depth DESC
|
|
||||||
`, leafID, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GetPathToLeaf: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var path []store.PathMessage
|
|
||||||
for rows.Next() {
|
|
||||||
var m store.PathMessage
|
|
||||||
var participantType, participantID sql.NullString
|
|
||||||
var toolCallsJSON, metadataJSON []byte
|
|
||||||
if err := rows.Scan(
|
|
||||||
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
|
||||||
&toolCallsJSON, &metadataJSON,
|
|
||||||
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("GetPathToLeaf scan: %w", err)
|
|
||||||
}
|
|
||||||
if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" {
|
|
||||||
raw := json.RawMessage(toolCallsJSON)
|
|
||||||
m.ToolCalls = &raw
|
|
||||||
}
|
|
||||||
if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" {
|
|
||||||
raw := json.RawMessage(metadataJSON)
|
|
||||||
m.Metadata = &raw
|
|
||||||
}
|
|
||||||
if participantType.Valid {
|
|
||||||
m.ParticipantType = participantType.String
|
|
||||||
}
|
|
||||||
if participantID.Valid {
|
|
||||||
m.ParticipantID = participantID.String
|
|
||||||
}
|
|
||||||
path = append(path, m)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range path {
|
|
||||||
count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID)
|
|
||||||
path[i].SiblingCount = count
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = s.ResolveSenderInfo(ctx, path)
|
|
||||||
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) {
|
|
||||||
leafID, err := s.GetActiveLeaf(ctx, channelID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if leafID == nil {
|
|
||||||
return []store.PathMessage{}, nil
|
|
||||||
}
|
|
||||||
return s.GetPathToLeaf(ctx, channelID, *leafID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) {
|
|
||||||
var parentID *string
|
|
||||||
var channelID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT parent_id, channel_id FROM messages
|
|
||||||
WHERE id = ? AND deleted_at IS NULL
|
|
||||||
`, messageID).Scan(&parentID, &channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("message not found: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows *sql.Rows
|
|
||||||
if parentID == nil {
|
|
||||||
rows, err = DB.QueryContext(ctx, `
|
|
||||||
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL
|
|
||||||
ORDER BY sibling_index, created_at
|
|
||||||
`, channelID)
|
|
||||||
} else {
|
|
||||||
rows, err = DB.QueryContext(ctx, `
|
|
||||||
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE parent_id = ? AND deleted_at IS NULL
|
|
||||||
ORDER BY sibling_index, created_at
|
|
||||||
`, *parentID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var siblings []store.SiblingInfo
|
|
||||||
currentIdx := 0
|
|
||||||
for i := 0; rows.Next(); i++ {
|
|
||||||
var si store.SiblingInfo
|
|
||||||
if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
if si.ID == messageID {
|
|
||||||
currentIdx = i
|
|
||||||
}
|
|
||||||
siblings = append(siblings, si)
|
|
||||||
}
|
|
||||||
|
|
||||||
return siblings, currentIdx, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) {
|
|
||||||
var count int
|
|
||||||
var err error
|
|
||||||
if parentID == nil {
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM messages
|
|
||||||
WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL
|
|
||||||
`, channelID).Scan(&count)
|
|
||||||
} else {
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM messages
|
|
||||||
WHERE parent_id = ? AND deleted_at IS NULL
|
|
||||||
`, *parentID).Scan(&count)
|
|
||||||
}
|
|
||||||
if err != nil || count == 0 {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) {
|
|
||||||
var leafID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
WITH RECURSIVE descendants AS (
|
|
||||||
SELECT id, 0 AS depth
|
|
||||||
FROM messages
|
|
||||||
WHERE id = ? AND deleted_at IS NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT child.id, d.depth + 1
|
|
||||||
FROM messages child
|
|
||||||
JOIN descendants d ON child.parent_id = d.id
|
|
||||||
WHERE child.deleted_at IS NULL
|
|
||||||
AND child.sibling_index = (
|
|
||||||
SELECT MIN(sibling_index) FROM messages
|
|
||||||
WHERE parent_id = d.id AND deleted_at IS NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
SELECT id FROM descendants
|
|
||||||
ORDER BY depth DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, messageID).Scan(&leafID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return messageID, nil
|
|
||||||
}
|
|
||||||
return leafID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) {
|
|
||||||
var maxIdx sql.NullInt64
|
|
||||||
var err error
|
|
||||||
if parentID == nil {
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT MAX(sibling_index) FROM messages
|
|
||||||
WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL
|
|
||||||
`, channelID).Scan(&maxIdx)
|
|
||||||
} else {
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT MAX(sibling_index) FROM messages
|
|
||||||
WHERE parent_id = ? AND deleted_at IS NULL
|
|
||||||
`, *parentID).Scan(&maxIdx)
|
|
||||||
}
|
|
||||||
if err != nil || !maxIdx.Valid {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return int(maxIdx.Int64) + 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) {
|
|
||||||
var id string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM messages
|
|
||||||
WHERE channel_id = ? AND role = 'assistant' AND participant_type = 'persona'
|
|
||||||
LIMIT 1
|
|
||||||
`, channelID).Scan(&id)
|
|
||||||
return err == nil && id != "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error {
|
|
||||||
m.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
m.CreatedAt = now
|
|
||||||
|
|
||||||
// tool_calls: serialize for SQLite
|
|
||||||
var tcVal interface{}
|
|
||||||
if m.ToolCalls != nil {
|
|
||||||
tcVal = ToJSON(m.ToolCalls)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
|
||||||
tool_calls, parent_id, participant_type, participant_id,
|
|
||||||
provider_config_id, sibling_index, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)
|
|
||||||
`, m.ID, m.ChannelID, m.Role, m.Content, safeModelStr(m.Model), m.TokensUsed,
|
|
||||||
tcVal, models.NullString(m.ParentID),
|
|
||||||
m.ParticipantType, m.ParticipantID,
|
|
||||||
safeStringPtr(m.ProviderConfigID), m.SiblingIndex,
|
|
||||||
now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateWithCursor insert: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cursor
|
|
||||||
if cursorUserID != "" {
|
|
||||||
_, _ = DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
|
||||||
active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now')
|
|
||||||
`, store.NewID(), m.ChannelID, cursorUserID, m.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch channel
|
|
||||||
_, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = datetime('now') WHERE id = ?`, m.ChannelID)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error {
|
|
||||||
userIDs := map[string]bool{}
|
|
||||||
personaIDs := map[string]bool{}
|
|
||||||
for _, m := range path {
|
|
||||||
if m.ParticipantID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch m.ParticipantType {
|
|
||||||
case "user":
|
|
||||||
userIDs[m.ParticipantID] = true
|
|
||||||
case "persona":
|
|
||||||
personaIDs[m.ParticipantID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
userNames := map[string]string{}
|
|
||||||
userAvatars := map[string]string{}
|
|
||||||
for uid := range userIDs {
|
|
||||||
var name, avatar sql.NullString
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = ?
|
|
||||||
`, uid).Scan(&name, &avatar)
|
|
||||||
if name.Valid {
|
|
||||||
userNames[uid] = name.String
|
|
||||||
}
|
|
||||||
if avatar.Valid {
|
|
||||||
userAvatars[uid] = avatar.String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
personaNames := map[string]string{}
|
|
||||||
personaAvatars := map[string]string{}
|
|
||||||
for pid := range personaIDs {
|
|
||||||
var name, avatar sql.NullString
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT name, avatar FROM personas WHERE id = ?
|
|
||||||
`, pid).Scan(&name, &avatar)
|
|
||||||
if name.Valid {
|
|
||||||
personaNames[pid] = name.String
|
|
||||||
}
|
|
||||||
if avatar.Valid {
|
|
||||||
personaAvatars[pid] = avatar.String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range path {
|
|
||||||
pid := path[i].ParticipantID
|
|
||||||
switch path[i].ParticipantType {
|
|
||||||
case "user":
|
|
||||||
if n, ok := userNames[pid]; ok {
|
|
||||||
path[i].SenderName = &n
|
|
||||||
}
|
|
||||||
if a, ok := userAvatars[pid]; ok && a != "" {
|
|
||||||
path[i].SenderAvatar = &a
|
|
||||||
}
|
|
||||||
case "persona":
|
|
||||||
if n, ok := personaNames[pid]; ok {
|
|
||||||
path[i].SenderName = &n
|
|
||||||
}
|
|
||||||
if a, ok := personaAvatars[pid]; ok && a != "" {
|
|
||||||
path[i].SenderAvatar = &a
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func safeModelStr(m string) string {
|
|
||||||
if m == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeStringPtr(s *string) string {
|
|
||||||
if s == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *s
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NoteStore struct{}
|
|
||||||
|
|
||||||
func NewNoteStore() *NoteStore { return &NoteStore{} }
|
|
||||||
|
|
||||||
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
|
||||||
n.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
n.CreatedAt = now
|
|
||||||
n.UpdatedAt = now
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO notes (id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
||||||
n.ID, n.UserID, n.Title, n.Content, n.FolderPath,
|
|
||||||
ArrayToJSON(n.Tags), ToJSON(n.Metadata),
|
|
||||||
models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID),
|
|
||||||
models.NullString(n.TeamID),
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
|
|
||||||
var n models.Note
|
|
||||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
|
||||||
var tagsJSON, metadataJSON string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, user_id, title, content, folder_path, tags, metadata,
|
|
||||||
source_channel_id, source_message_id, team_id, created_at, updated_at
|
|
||||||
FROM notes WHERE id = ?`, id).Scan(
|
|
||||||
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
|
||||||
&tagsJSON, &metadataJSON,
|
|
||||||
&sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
n.Tags = ScanArray(tagsJSON)
|
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
|
||||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
|
||||||
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
|
||||||
return &n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
b := NewUpdate("notes")
|
|
||||||
for k, v := range fields {
|
|
||||||
if k == "metadata" {
|
|
||||||
b.SetJSON(k, v)
|
|
||||||
} else if k == "tags" {
|
|
||||||
if tags, ok := v.([]string); ok {
|
|
||||||
b.Set(k, ArrayToJSON(tags))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM notes WHERE id = ?", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
|
|
||||||
b := NewSelect(
|
|
||||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
|
|
||||||
"notes",
|
|
||||||
).Where("user_id = ?", userID)
|
|
||||||
|
|
||||||
if opts.FolderPath != "" {
|
|
||||||
b.Where("folder_path = ?", opts.FolderPath)
|
|
||||||
}
|
|
||||||
if opts.Tag != "" {
|
|
||||||
// SQLite: check JSON array membership with json_each.
|
|
||||||
b.Where("EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)", opts.Tag)
|
|
||||||
}
|
|
||||||
if opts.TeamID != "" {
|
|
||||||
b.Where("team_id = ?", opts.TeamID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count
|
|
||||||
countQ, countArgs := b.CountBuild()
|
|
||||||
var total int
|
|
||||||
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
|
|
||||||
|
|
||||||
if opts.Sort == "" {
|
|
||||||
b.OrderBy("updated_at", "DESC")
|
|
||||||
}
|
|
||||||
b.Paginate(opts.ListOptions)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return s.scanNotes(rows, total)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search uses LIKE against title and content (SQLite fallback for tsvector).
|
|
||||||
func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) {
|
|
||||||
// Split query into words, build LIKE clauses for each.
|
|
||||||
words := strings.Fields(query)
|
|
||||||
if len(words) == 0 {
|
|
||||||
return nil, 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
b := NewSelect(
|
|
||||||
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
|
|
||||||
"notes",
|
|
||||||
).Where("user_id = ?", userID)
|
|
||||||
|
|
||||||
for _, w := range words {
|
|
||||||
pattern := "%" + w + "%"
|
|
||||||
b.Where("(title LIKE ? OR content LIKE ?)", pattern, pattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count
|
|
||||||
countQ, countArgs := b.CountBuild()
|
|
||||||
var total int
|
|
||||||
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
|
|
||||||
|
|
||||||
b.OrderBy("updated_at", "DESC")
|
|
||||||
b.Paginate(opts)
|
|
||||||
|
|
||||||
q, args := b.Build()
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return s.scanNotes(rows, total)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) {
|
|
||||||
if limit <= 0 || limit > 50 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, title, folder_path
|
|
||||||
FROM notes
|
|
||||||
WHERE user_id = ? AND title LIKE '%' || ? || '%' COLLATE NOCASE
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT ?`, userID, query, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.Note
|
|
||||||
for rows.Next() {
|
|
||||||
var n models.Note
|
|
||||||
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, n)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
placeholders := make([]string, len(ids))
|
|
||||||
args := make([]interface{}, 0, len(ids)+1)
|
|
||||||
args = append(args, userID)
|
|
||||||
for i, id := range ids {
|
|
||||||
placeholders[i] = "?"
|
|
||||||
args = append(args, id)
|
|
||||||
}
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
fmt.Sprintf("DELETE FROM notes WHERE user_id = ? AND id IN (%s)",
|
|
||||||
strings.Join(placeholders, ",")),
|
|
||||||
args...)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
return int(n), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, error) {
|
|
||||||
var result []models.Note
|
|
||||||
for rows.Next() {
|
|
||||||
var n models.Note
|
|
||||||
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
|
||||||
var tagsJSON, metadataJSON string
|
|
||||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
|
||||||
&tagsJSON, &metadataJSON,
|
|
||||||
&sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
n.Tags = ScanArray(tagsJSON)
|
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
|
||||||
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
|
||||||
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
|
||||||
result = append(result, n)
|
|
||||||
}
|
|
||||||
return result, total, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
// SetEmbedding is a no-op on SQLite (pgvector is PG-only).
|
|
||||||
func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchKeyword performs LIKE-based keyword search on SQLite.
|
|
||||||
func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) {
|
|
||||||
words := strings.Fields(query)
|
|
||||||
if len(words) == 0 {
|
|
||||||
return []store.NoteSearchResult{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
q := `SELECT id, title, folder_path, tags, SUBSTR(content, 1, 500)
|
|
||||||
FROM notes WHERE user_id = ?`
|
|
||||||
args := []interface{}{userID}
|
|
||||||
|
|
||||||
for _, w := range words {
|
|
||||||
pattern := "%" + w + "%"
|
|
||||||
q += " AND (title LIKE ? OR content LIKE ?)"
|
|
||||||
args = append(args, pattern, pattern)
|
|
||||||
}
|
|
||||||
q += " ORDER BY updated_at DESC LIMIT ?"
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.NoteSearchResult, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var r store.NoteSearchResult
|
|
||||||
var tagsJSON string
|
|
||||||
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &tagsJSON, &r.Excerpt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r.Tags = ScanArray(tagsJSON)
|
|
||||||
if r.Tags == nil {
|
|
||||||
r.Tags = []string{}
|
|
||||||
}
|
|
||||||
r.Rank = 1.0 // no ranking on SQLite
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchSemantic returns empty results on SQLite (vector search requires PG).
|
|
||||||
func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) {
|
|
||||||
return []store.NoteSearchResult{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT DISTINCT folder_path, COUNT(*) AS count
|
|
||||||
FROM notes WHERE user_id = ?
|
|
||||||
GROUP BY folder_path
|
|
||||||
ORDER BY folder_path
|
|
||||||
`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.FolderInfo, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var f store.FolderInfo
|
|
||||||
if err := rows.Scan(&f.Path, &f.Count); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, f)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NoteLinkStore struct{}
|
|
||||||
|
|
||||||
func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} }
|
|
||||||
|
|
||||||
func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// Delete existing links for this source note
|
|
||||||
if _, err := tx.ExecContext(ctx,
|
|
||||||
"DELETE FROM note_links WHERE source_note_id = ?", sourceNoteID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new links
|
|
||||||
if len(links) > 0 {
|
|
||||||
stmt, err := tx.PrepareContext(ctx, `
|
|
||||||
INSERT OR IGNORE INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
for _, l := range links {
|
|
||||||
var targetID interface{}
|
|
||||||
if l.TargetNoteID != nil {
|
|
||||||
targetID = *l.TargetNoteID
|
|
||||||
}
|
|
||||||
isTransclusion := 0
|
|
||||||
if l.IsTransclusion {
|
|
||||||
isTransclusion = 1
|
|
||||||
}
|
|
||||||
if _, err := stmt.ExecContext(ctx,
|
|
||||||
sourceNoteID, targetID, l.TargetTitle, l.DisplayText, isTransclusion,
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE note_links
|
|
||||||
SET target_note_id = ?
|
|
||||||
WHERE target_note_id IS NULL
|
|
||||||
AND LOWER(target_title) = LOWER(?)
|
|
||||||
AND source_note_id IN (SELECT id FROM notes WHERE user_id = ?)`,
|
|
||||||
targetNoteID, title, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(nl.display_text, '')
|
|
||||||
FROM note_links nl
|
|
||||||
JOIN notes n ON n.id = nl.source_note_id
|
|
||||||
WHERE nl.target_note_id = ?
|
|
||||||
ORDER BY n.updated_at DESC`, noteID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.NoteLinkResult
|
|
||||||
for rows.Next() {
|
|
||||||
var r models.NoteLinkResult
|
|
||||||
if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath,
|
|
||||||
st(&r.UpdatedAt), &r.DisplayText); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) {
|
|
||||||
graph := &models.NoteGraph{
|
|
||||||
Nodes: make([]models.NoteGraphNode, 0),
|
|
||||||
Edges: make([]models.NoteGraphEdge, 0),
|
|
||||||
Unresolved: make([]models.NoteGraphDangling, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nodes: all user's notes with link counts
|
|
||||||
nodeRows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at,
|
|
||||||
COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT source_note_id, COUNT(*) AS cnt FROM note_links
|
|
||||||
WHERE target_note_id IS NOT NULL GROUP BY source_note_id
|
|
||||||
) outbound ON outbound.source_note_id = n.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT target_note_id, COUNT(*) AS cnt FROM note_links
|
|
||||||
WHERE target_note_id IS NOT NULL GROUP BY target_note_id
|
|
||||||
) inbound ON inbound.target_note_id = n.id
|
|
||||||
WHERE n.user_id = ?
|
|
||||||
ORDER BY n.updated_at DESC`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer nodeRows.Close()
|
|
||||||
|
|
||||||
for nodeRows.Next() {
|
|
||||||
var node models.NoteGraphNode
|
|
||||||
var tagsJSON string
|
|
||||||
if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath,
|
|
||||||
&tagsJSON, &node.UpdatedAt, &node.LinkCount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
node.Tags = ScanArray(tagsJSON)
|
|
||||||
if node.Tags == nil {
|
|
||||||
node.Tags = []string{}
|
|
||||||
}
|
|
||||||
graph.Nodes = append(graph.Nodes, node)
|
|
||||||
}
|
|
||||||
if err := nodeRows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolved edges
|
|
||||||
edgeRows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT nl.source_note_id, nl.target_note_id, nl.target_title, nl.is_transclusion
|
|
||||||
FROM note_links nl
|
|
||||||
JOIN notes n ON n.id = nl.source_note_id
|
|
||||||
WHERE n.user_id = ?
|
|
||||||
AND nl.target_note_id IS NOT NULL`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer edgeRows.Close()
|
|
||||||
|
|
||||||
for edgeRows.Next() {
|
|
||||||
var edge models.NoteGraphEdge
|
|
||||||
var isTransclusion int
|
|
||||||
if err := edgeRows.Scan(&edge.Source, &edge.Target,
|
|
||||||
&edge.Title, &isTransclusion); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
edge.IsTransclusion = isTransclusion != 0
|
|
||||||
graph.Edges = append(graph.Edges, edge)
|
|
||||||
}
|
|
||||||
if err := edgeRows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unresolved links (dangling)
|
|
||||||
danglingRows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT nl.source_note_id, nl.target_title
|
|
||||||
FROM note_links nl
|
|
||||||
JOIN notes n ON n.id = nl.source_note_id
|
|
||||||
WHERE n.user_id = ?
|
|
||||||
AND nl.target_note_id IS NULL`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer danglingRows.Close()
|
|
||||||
|
|
||||||
for danglingRows.Next() {
|
|
||||||
var d models.NoteGraphDangling
|
|
||||||
if err := danglingRows.Scan(&d.Source, &d.Title); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
graph.Unresolved = append(graph.Unresolved, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
return graph, danglingRows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PersonaStore struct{}
|
|
||||||
|
|
||||||
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
|
|
||||||
|
|
||||||
const personaCols = `id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
|
||||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
|
||||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
|
||||||
created_at, updated_at`
|
|
||||||
|
|
||||||
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
|
|
||||||
p.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
p.CreatedAt = now
|
|
||||||
p.UpdatedAt = now
|
|
||||||
// Auto-generate handle from name if not set
|
|
||||||
if p.Handle == "" {
|
|
||||||
p.Handle = models.HandleFromName(p.Name)
|
|
||||||
}
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO personas (id, name, handle, description, icon, avatar, base_model_id, provider_config_id,
|
|
||||||
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
|
||||||
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
|
|
||||||
created_at, updated_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
||||||
p.ID, p.Name, p.Handle, p.Description, p.Icon, p.Avatar, p.BaseModelID,
|
|
||||||
models.NullString(p.ProviderConfigID),
|
|
||||||
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
|
|
||||||
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
|
|
||||||
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
|
|
||||||
p.MemoryEnabled, p.MemoryExtractionPrompt,
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM personas WHERE id = ?", personaCols), id)
|
|
||||||
p, err := scanPersona(row)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Load grants
|
|
||||||
grants, _ := s.GetGrants(ctx, id)
|
|
||||||
p.Grants = grants
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) Update(ctx context.Context, id string, patch models.PersonaPatch) error {
|
|
||||||
b := NewUpdate("personas")
|
|
||||||
if patch.Name != nil {
|
|
||||||
b.Set("name", *patch.Name)
|
|
||||||
}
|
|
||||||
if patch.Handle != nil {
|
|
||||||
b.Set("handle", *patch.Handle)
|
|
||||||
}
|
|
||||||
if patch.Description != nil {
|
|
||||||
b.Set("description", *patch.Description)
|
|
||||||
}
|
|
||||||
if patch.Icon != nil {
|
|
||||||
b.Set("icon", *patch.Icon)
|
|
||||||
}
|
|
||||||
if patch.Avatar != nil {
|
|
||||||
b.Set("avatar", *patch.Avatar)
|
|
||||||
}
|
|
||||||
if patch.BaseModelID != nil {
|
|
||||||
b.Set("base_model_id", *patch.BaseModelID)
|
|
||||||
}
|
|
||||||
if patch.ProviderConfigID != nil {
|
|
||||||
b.Set("provider_config_id", models.NullString(patch.ProviderConfigID))
|
|
||||||
}
|
|
||||||
if patch.SystemPrompt != nil {
|
|
||||||
b.Set("system_prompt", *patch.SystemPrompt)
|
|
||||||
}
|
|
||||||
if patch.Temperature != nil {
|
|
||||||
b.Set("temperature", models.NullFloat(patch.Temperature))
|
|
||||||
}
|
|
||||||
if patch.MaxTokens != nil {
|
|
||||||
b.Set("max_tokens", models.NullInt(patch.MaxTokens))
|
|
||||||
}
|
|
||||||
if patch.ThinkingBudget != nil {
|
|
||||||
b.Set("thinking_budget", models.NullInt(patch.ThinkingBudget))
|
|
||||||
}
|
|
||||||
if patch.TopP != nil {
|
|
||||||
b.Set("top_p", models.NullFloat(patch.TopP))
|
|
||||||
}
|
|
||||||
if patch.IsActive != nil {
|
|
||||||
b.Set("is_active", *patch.IsActive)
|
|
||||||
}
|
|
||||||
if patch.IsShared != nil {
|
|
||||||
b.Set("is_shared", *patch.IsShared)
|
|
||||||
}
|
|
||||||
if patch.MemoryEnabled != nil {
|
|
||||||
b.Set("memory_enabled", *patch.MemoryEnabled)
|
|
||||||
}
|
|
||||||
if patch.MemoryExtractionPrompt != nil {
|
|
||||||
b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt)
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM personas WHERE id = ?", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListForUser returns all Personas visible to a user:
|
|
||||||
// global active + team-scoped (for user's teams) + personal + shared + group-granted.
|
|
||||||
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = 1 AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND created_by = ?)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = ?
|
|
||||||
))
|
|
||||||
OR (scope = 'personal' AND is_shared = 1)
|
|
||||||
OR id IN (
|
|
||||||
SELECT rg.resource_id FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'persona'
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
|
||||||
WHERE gm.user_id = ?
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) ORDER BY scope, name`, personaCols), userID, userID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanPersonas(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'team' AND owner_id = ? AND is_active = 1 ORDER BY name", personaCols),
|
|
||||||
teamID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanPersonas(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'global' ORDER BY name", personaCols))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanPersonas(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Grants ──────────────────────────────────
|
|
||||||
|
|
||||||
// SetGrants replaces all grants for a Persona (delete + re-insert).
|
|
||||||
func (s *PersonaStore) SetGrants(ctx context.Context, personaID string, grants []models.Grant) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM persona_grants WHERE persona_id = ?", personaID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, g := range grants {
|
|
||||||
configJSON := ToJSON(g.Config)
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO persona_grants (id, persona_id, grant_type, grant_ref, config)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
store.NewID(), personaID, g.GrantType, g.GrantRef, configJSON)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("grant %s/%s: %w", g.GrantType, g.GrantRef, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
"SELECT id, persona_id, grant_type, grant_ref, config, created_at FROM persona_grants WHERE persona_id = ? ORDER BY grant_type, grant_ref",
|
|
||||||
personaID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Grant
|
|
||||||
for rows.Next() {
|
|
||||||
var g models.Grant
|
|
||||||
var configJSON []byte
|
|
||||||
err := rows.Scan(&g.ID, &g.PersonaID, &g.GrantType, &g.GrantRef, &configJSON, st(&g.CreatedAt))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
json.Unmarshal(configJSON, &g.Config)
|
|
||||||
result = append(result, g)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetToolGrants returns just the tool names for a Persona.
|
|
||||||
func (s *PersonaStore) GetToolGrants(ctx context.Context, personaID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
"SELECT grant_ref FROM persona_grants WHERE persona_id = ? AND grant_type = 'tool' ORDER BY grant_ref",
|
|
||||||
personaID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []string
|
|
||||||
for rows.Next() {
|
|
||||||
var name string
|
|
||||||
if err := rows.Scan(&name); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, name)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserCanAccess checks if a user can see/use a specific Persona.
|
|
||||||
func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) {
|
|
||||||
var exists bool
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM personas WHERE id = ? AND is_active = 1 AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND created_by = ?)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = ?
|
|
||||||
))
|
|
||||||
OR (scope = 'personal' AND is_shared = 1)
|
|
||||||
OR id IN (
|
|
||||||
SELECT rg.resource_id FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'persona'
|
|
||||||
AND rg.resource_id = ?
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
|
||||||
WHERE gm.user_id = ?
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)`, personaID, userID, userID, personaID, userID).Scan(&exists)
|
|
||||||
return exists, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scanners ────────────────────────────────
|
|
||||||
|
|
||||||
func scanPersona(row *sql.Row) (*models.Persona, error) {
|
|
||||||
var p models.Persona
|
|
||||||
var providerConfigID, ownerID, handle sql.NullString
|
|
||||||
var temp, topP sql.NullFloat64
|
|
||||||
var maxTokens, thinkingBudget sql.NullInt64
|
|
||||||
err := row.Scan(
|
|
||||||
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
|
||||||
&p.BaseModelID, &providerConfigID,
|
|
||||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
|
||||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
|
||||||
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
|
|
||||||
st(&p.CreatedAt), st(&p.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if handle.Valid {
|
|
||||||
p.Handle = handle.String
|
|
||||||
}
|
|
||||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
p.Temperature = NullableFloat64Ptr(temp)
|
|
||||||
p.MaxTokens = NullableIntPtr(maxTokens)
|
|
||||||
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
|
|
||||||
p.TopP = NullableFloat64Ptr(topP)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
|
||||||
var result []models.Persona
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.Persona
|
|
||||||
var providerConfigID, ownerID, handle sql.NullString
|
|
||||||
var temp, topP sql.NullFloat64
|
|
||||||
var maxTokens, thinkingBudget sql.NullInt64
|
|
||||||
err := rows.Scan(
|
|
||||||
&p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar,
|
|
||||||
&p.BaseModelID, &providerConfigID,
|
|
||||||
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
|
|
||||||
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
|
|
||||||
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
|
|
||||||
st(&p.CreatedAt), st(&p.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if handle.Valid {
|
|
||||||
p.Handle = handle.String
|
|
||||||
}
|
|
||||||
p.ProviderConfigID = NullableStringPtr(providerConfigID)
|
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
p.Temperature = NullableFloat64Ptr(temp)
|
|
||||||
p.MaxTokens = NullableIntPtr(maxTokens)
|
|
||||||
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
|
|
||||||
p.TopP = NullableFloat64Ptr(topP)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Persona-KB Bindings (v0.17.0) ───────────
|
|
||||||
|
|
||||||
func (s *PersonaStore) SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, "DELETE FROM persona_knowledge_bases WHERE persona_id = ?", personaID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, kbID := range kbIDs {
|
|
||||||
auto := false
|
|
||||||
if autoSearch != nil {
|
|
||||||
auto = autoSearch[kbID]
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO persona_knowledge_bases (persona_id, kb_id, auto_search)
|
|
||||||
VALUES (?, ?, ?)`,
|
|
||||||
personaID, kbID, auto)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("persona KB %s: %w", kbID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT pkb.persona_id, pkb.kb_id, pkb.auto_search, pkb.added_at,
|
|
||||||
kb.name AS kb_name, kb.document_count, kb.chunk_count
|
|
||||||
FROM persona_knowledge_bases pkb
|
|
||||||
JOIN knowledge_bases kb ON kb.id = pkb.kb_id
|
|
||||||
WHERE pkb.persona_id = ?
|
|
||||||
ORDER BY kb.name`, personaID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.PersonaKB
|
|
||||||
for rows.Next() {
|
|
||||||
var pkb models.PersonaKB
|
|
||||||
if err := rows.Scan(&pkb.PersonaID, &pkb.KBID, &pkb.AutoSearch, st(&pkb.AddedAt),
|
|
||||||
&pkb.KBName, &pkb.DocumentCount, &pkb.ChunkCount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, pkb)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = make([]models.PersonaKB, 0)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetKBIDs(ctx context.Context, personaID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
"SELECT kb_id FROM persona_knowledge_bases WHERE persona_id = ?", personaID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
if ids == nil {
|
|
||||||
ids = make([]string, 0)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PersonaGroupStore struct{}
|
|
||||||
|
|
||||||
func NewPersonaGroupStore() *PersonaGroupStore { return &PersonaGroupStore{} }
|
|
||||||
|
|
||||||
const personaGroupCols = `id, name, description, owner_id, scope, team_id, created_at, updated_at`
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT `+personaGroupCols+` FROM persona_groups
|
|
||||||
WHERE owner_id = ? ORDER BY name
|
|
||||||
`, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
groups := []models.PersonaGroup{}
|
|
||||||
for rows.Next() {
|
|
||||||
var g models.PersonaGroup
|
|
||||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
|
||||||
&g.Scope, &g.TeamID, st(&g.CreatedAt), st(&g.UpdatedAt)); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
groups = append(groups, g)
|
|
||||||
}
|
|
||||||
return groups, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) {
|
|
||||||
var g models.PersonaGroup
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT `+personaGroupCols+` FROM persona_groups WHERE id = ? AND owner_id = ?
|
|
||||||
`, id, ownerID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
|
||||||
&g.Scope, &g.TeamID, st(&g.CreatedAt), st(&g.UpdatedAt))
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) Create(ctx context.Context, g *models.PersonaGroup) error {
|
|
||||||
g.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
g.CreatedAt = now
|
|
||||||
g.UpdatedAt = now
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO persona_groups (id, name, description, owner_id, scope, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`, g.ID, g.Name, g.Description, g.OwnerID, g.Scope,
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
||||||
for k, v := range fields {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE persona_groups SET `+k+` = ?, updated_at = datetime('now') WHERE id = ?`, v, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) Delete(ctx context.Context, id, ownerID string) (int64, error) {
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM persona_groups WHERE id = ? AND owner_id = ?`, id, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) GetOwnerID(ctx context.Context, id string) (string, error) {
|
|
||||||
var ownerID string
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT owner_id FROM persona_groups WHERE id = ?`, id).Scan(&ownerID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return ownerID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error {
|
|
||||||
if isLeader {
|
|
||||||
_, _ = DB.ExecContext(ctx,
|
|
||||||
`UPDATE persona_group_members SET is_leader = 0 WHERE group_id = ?`, groupID)
|
|
||||||
}
|
|
||||||
id := store.NewID()
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO persona_group_members (id, group_id, persona_id, is_leader)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader
|
|
||||||
`, id, groupID, personaID, isLeader)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) RemoveMember(ctx context.Context, memberID, groupID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM persona_group_members WHERE id = ? AND group_id = ?`, memberID, groupID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaGroupStore) ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order,
|
|
||||||
COALESCE(p.name, '') AS persona_name,
|
|
||||||
COALESCE(p.handle, '') AS persona_handle,
|
|
||||||
COALESCE(p.avatar, '') AS persona_avatar
|
|
||||||
FROM persona_group_members pgm
|
|
||||||
LEFT JOIN personas p ON p.id = pgm.persona_id
|
|
||||||
WHERE pgm.group_id = ?
|
|
||||||
ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id
|
|
||||||
`, groupID)
|
|
||||||
if err != nil {
|
|
||||||
return []models.PersonaGroupMember{}, nil
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
members := []models.PersonaGroupMember{}
|
|
||||||
for rows.Next() {
|
|
||||||
var m models.PersonaGroupMember
|
|
||||||
if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder,
|
|
||||||
&m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
members = append(members, m)
|
|
||||||
}
|
|
||||||
return members, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Mention resolution + display info (v0.29.0) ────────────────────────
|
|
||||||
|
|
||||||
func (s *PersonaStore) FindActiveByHandle(ctx context.Context, handle string) (string, error) {
|
|
||||||
var id string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM personas
|
|
||||||
WHERE LOWER(handle) = LOWER(?) AND is_active = 1
|
|
||||||
LIMIT 1
|
|
||||||
`, handle).Scan(&id)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE LOWER(?) AND is_active = 1
|
|
||||||
`, prefix+"%").Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
return "", count, nil
|
|
||||||
}
|
|
||||||
var id string
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM personas WHERE LOWER(handle) LIKE LOWER(?) AND is_active = 1
|
|
||||||
`, prefix+"%").Scan(&id)
|
|
||||||
return id, 1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetNameByID(ctx context.Context, id string) (string, error) {
|
|
||||||
var name string
|
|
||||||
err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = ?`, id).Scan(&name)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return name, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) {
|
|
||||||
result := make(map[string]string)
|
|
||||||
for _, id := range ids {
|
|
||||||
var name string
|
|
||||||
err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = ?`, id).Scan(&name)
|
|
||||||
if err == nil && name != "" {
|
|
||||||
result[id] = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PersonaStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) {
|
|
||||||
result := make(map[string]store.UserDisplayInfo)
|
|
||||||
for _, id := range ids {
|
|
||||||
var name, avatar sql.NullString
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT name, avatar FROM personas WHERE id = ?
|
|
||||||
`, id).Scan(&name, &avatar)
|
|
||||||
if name.Valid {
|
|
||||||
info := store.UserDisplayInfo{Name: name.String}
|
|
||||||
if avatar.Valid {
|
|
||||||
info.Avatar = avatar.String
|
|
||||||
}
|
|
||||||
result[id] = info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PolicyStore struct{}
|
|
||||||
|
|
||||||
func NewPolicyStore() *PolicyStore { return &PolicyStore{} }
|
|
||||||
|
|
||||||
func (s *PolicyStore) Get(ctx context.Context, key string) (string, error) {
|
|
||||||
var value string
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
"SELECT value FROM platform_policies WHERE key = ?", key).Scan(&value)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
if def, ok := models.PolicyDefaults[key]; ok {
|
|
||||||
return def, nil
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return value, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PolicyStore) GetBool(ctx context.Context, key string) (bool, error) {
|
|
||||||
val, err := s.Get(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return val == "true", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PolicyStore) Set(ctx context.Context, key, value, updatedBy string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO platform_policies (key, value, updated_by, updated_at)
|
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = datetime('now')`,
|
|
||||||
key, value, updatedBy, value, updatedBy)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) {
|
|
||||||
result := make(map[string]string)
|
|
||||||
for k, v := range models.PolicyDefaults {
|
|
||||||
result[k] = v
|
|
||||||
}
|
|
||||||
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM platform_policies")
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var k, v string
|
|
||||||
if err := rows.Scan(&k, &v); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[k] = v
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PricingStore struct{}
|
|
||||||
|
|
||||||
func NewPricingStore() *PricingStore { return &PricingStore{} }
|
|
||||||
|
|
||||||
// GetForModel returns the pricing entry for a specific provider+model pair.
|
|
||||||
func (s *PricingStore) GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error) {
|
|
||||||
var p models.PricingEntry
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, provider_config_id, model_id,
|
|
||||||
input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
|
|
||||||
currency, source, updated_at, updated_by
|
|
||||||
FROM model_pricing
|
|
||||||
WHERE provider_config_id = ? AND model_id = ?
|
|
||||||
`, providerConfigID, modelID).Scan(
|
|
||||||
&p.ID, &p.ProviderConfigID, &p.ModelID,
|
|
||||||
&p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
|
|
||||||
&p.Currency, &p.Source, st(&p.UpdatedAt), &p.UpdatedBy,
|
|
||||||
)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &p, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert inserts or updates a pricing entry (manual admin override).
|
|
||||||
func (s *PricingStore) Upsert(ctx context.Context, entry *models.PricingEntry) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO model_pricing (
|
|
||||||
id, provider_config_id, model_id,
|
|
||||||
input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
|
|
||||||
currency, source, updated_by, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
||||||
ON CONFLICT (provider_config_id, model_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
input_per_m = EXCLUDED.input_per_m,
|
|
||||||
output_per_m = EXCLUDED.output_per_m,
|
|
||||||
cache_create_per_m = EXCLUDED.cache_create_per_m,
|
|
||||||
cache_read_per_m = EXCLUDED.cache_read_per_m,
|
|
||||||
currency = EXCLUDED.currency,
|
|
||||||
source = EXCLUDED.source,
|
|
||||||
updated_by = EXCLUDED.updated_by,
|
|
||||||
updated_at = datetime('now')
|
|
||||||
`,
|
|
||||||
store.NewID(), entry.ProviderConfigID, entry.ModelID,
|
|
||||||
entry.InputPerM, entry.OutputPerM, entry.CacheCreatePerM, entry.CacheReadPerM,
|
|
||||||
entry.Currency, entry.Source, entry.UpdatedBy,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpsertFromCatalog inserts or updates pricing from a catalog sync.
|
|
||||||
// Manual overrides (source='manual') are never overwritten.
|
|
||||||
func (s *PricingStore) UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error {
|
|
||||||
if pricing == nil || (pricing.InputPerM == 0 && pricing.OutputPerM == 0) {
|
|
||||||
return nil // No pricing to store
|
|
||||||
}
|
|
||||||
|
|
||||||
currency := pricing.Currency
|
|
||||||
if currency == "" {
|
|
||||||
currency = "USD"
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO model_pricing (
|
|
||||||
id, provider_config_id, model_id,
|
|
||||||
input_per_m, output_per_m,
|
|
||||||
currency, source, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, 'catalog', datetime('now'))
|
|
||||||
ON CONFLICT (provider_config_id, model_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
input_per_m = EXCLUDED.input_per_m,
|
|
||||||
output_per_m = EXCLUDED.output_per_m,
|
|
||||||
currency = EXCLUDED.currency,
|
|
||||||
updated_at = datetime('now')
|
|
||||||
WHERE model_pricing.source = 'catalog'
|
|
||||||
`,
|
|
||||||
store.NewID(), providerConfigID, modelID,
|
|
||||||
pricing.InputPerM, pricing.OutputPerM,
|
|
||||||
currency,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns pricing entries for admin-managed providers (global + team).
|
|
||||||
// Excludes personal BYOK providers — those are not the admin's concern.
|
|
||||||
func (s *PricingStore) List(ctx context.Context) ([]models.PricingEntry, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT mp.id, mp.provider_config_id, mp.model_id,
|
|
||||||
mp.input_per_m, mp.output_per_m, mp.cache_create_per_m, mp.cache_read_per_m,
|
|
||||||
mp.currency, mp.source, mp.updated_at, mp.updated_by
|
|
||||||
FROM model_pricing mp
|
|
||||||
JOIN provider_configs pc ON pc.id = mp.provider_config_id
|
|
||||||
WHERE pc.scope != 'personal'
|
|
||||||
ORDER BY mp.provider_config_id, mp.model_id
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.PricingEntry
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.PricingEntry
|
|
||||||
if err := rows.Scan(
|
|
||||||
&p.ID, &p.ProviderConfigID, &p.ModelID,
|
|
||||||
&p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
|
|
||||||
&p.Currency, &p.Source, st(&p.UpdatedAt), &p.UpdatedBy,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, p)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a pricing entry.
|
|
||||||
func (s *PricingStore) Delete(ctx context.Context, providerConfigID, modelID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM model_pricing WHERE provider_config_id = ? AND model_id = ?
|
|
||||||
`, providerConfigID, modelID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── ProjectStore ───────────────────────────
|
|
||||||
|
|
||||||
type ProjectStore struct{}
|
|
||||||
|
|
||||||
func NewProjectStore() *ProjectStore { return &ProjectStore{} }
|
|
||||||
|
|
||||||
// ── CRUD ────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error {
|
|
||||||
p.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
p.CreatedAt = now
|
|
||||||
p.UpdatedAt = now
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO projects (id, name, description, color, icon, scope, owner_id, team_id,
|
|
||||||
is_archived, settings, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
p.ID, p.Name, p.Description, p.Color, p.Icon, p.Scope,
|
|
||||||
p.OwnerID, models.NullString(p.TeamID),
|
|
||||||
boolToInt(p.IsArchived), ToJSON(p.Settings),
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) {
|
|
||||||
var p models.Project
|
|
||||||
var teamID, workspaceID sql.NullString
|
|
||||||
var archived int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
|
|
||||||
p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings,
|
|
||||||
p.created_at, p.updated_at,
|
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id)
|
|
||||||
FROM projects p
|
|
||||||
WHERE p.id = ?`, id).Scan(
|
|
||||||
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
|
||||||
&p.OwnerID, &teamID, &archived, &workspaceID, &p.Settings,
|
|
||||||
st(&p.CreatedAt), st(&p.UpdatedAt),
|
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.IsArchived = archived != 0
|
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
|
||||||
p.WorkspaceID = NullableStringPtr(workspaceID)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) Update(ctx context.Context, id string, patch models.ProjectPatch) error {
|
|
||||||
sets := []string{}
|
|
||||||
args := []interface{}{}
|
|
||||||
|
|
||||||
add := func(col string, val interface{}) {
|
|
||||||
sets = append(sets, col+" = ?")
|
|
||||||
args = append(args, val)
|
|
||||||
}
|
|
||||||
|
|
||||||
if patch.Name != nil {
|
|
||||||
add("name", *patch.Name)
|
|
||||||
}
|
|
||||||
if patch.Description != nil {
|
|
||||||
add("description", *patch.Description)
|
|
||||||
}
|
|
||||||
if patch.Color != nil {
|
|
||||||
add("color", *patch.Color)
|
|
||||||
}
|
|
||||||
if patch.Icon != nil {
|
|
||||||
add("icon", *patch.Icon)
|
|
||||||
}
|
|
||||||
if patch.IsArchived != nil {
|
|
||||||
add("is_archived", boolToInt(*patch.IsArchived))
|
|
||||||
}
|
|
||||||
if patch.WorkspaceID != nil {
|
|
||||||
add("workspace_id", models.NullString(patch.WorkspaceID))
|
|
||||||
}
|
|
||||||
if len(patch.Settings) > 0 {
|
|
||||||
// Merge: read existing settings, overlay with patch values, write back.
|
|
||||||
var existing models.JSONMap
|
|
||||||
_ = DB.QueryRowContext(ctx, "SELECT settings FROM projects WHERE id = ?", id).Scan(&existing)
|
|
||||||
if existing == nil {
|
|
||||||
existing = make(models.JSONMap)
|
|
||||||
}
|
|
||||||
for k, v := range patch.Settings {
|
|
||||||
existing[k] = v
|
|
||||||
}
|
|
||||||
add("settings", ToJSON(existing))
|
|
||||||
}
|
|
||||||
if len(sets) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
query := "UPDATE projects SET "
|
|
||||||
for i, s := range sets {
|
|
||||||
if i > 0 {
|
|
||||||
query += ", "
|
|
||||||
}
|
|
||||||
query += s
|
|
||||||
}
|
|
||||||
query += " WHERE id = ?"
|
|
||||||
args = append(args, id)
|
|
||||||
|
|
||||||
res, err := DB.ExecContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) Delete(ctx context.Context, id string) error {
|
|
||||||
// Clear denormalized project_id on channels first (SQLite FK cascade
|
|
||||||
// handles junction tables, but ON DELETE SET NULL requires PRAGMA
|
|
||||||
// foreign_keys = ON which may not cascade reliably on all builds).
|
|
||||||
DB.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE project_id = ?`, id)
|
|
||||||
|
|
||||||
res, err := DB.ExecContext(ctx, "DELETE FROM projects WHERE id = ?", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Listing ─────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) {
|
|
||||||
q := `
|
|
||||||
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
|
|
||||||
p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings,
|
|
||||||
p.created_at, p.updated_at,
|
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id)
|
|
||||||
FROM projects p
|
|
||||||
WHERE (
|
|
||||||
(p.scope = 'personal' AND p.owner_id = ?)
|
|
||||||
OR p.scope = 'global'`
|
|
||||||
|
|
||||||
args := []interface{}{userID}
|
|
||||||
|
|
||||||
for _, tid := range teamIDs {
|
|
||||||
q += ` OR (p.scope = 'team' AND p.team_id = ?)`
|
|
||||||
args = append(args, tid)
|
|
||||||
}
|
|
||||||
q += `)`
|
|
||||||
|
|
||||||
if !includeArchived {
|
|
||||||
q += ` AND p.is_archived = 0`
|
|
||||||
}
|
|
||||||
q += ` ORDER BY p.name`
|
|
||||||
|
|
||||||
return s.queryProjects(ctx, q, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Channel Association ─────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) AddChannel(ctx context.Context, projectID, channelID string, position int) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// Remove existing (atomic move)
|
|
||||||
tx.ExecContext(ctx, `DELETE FROM project_channels WHERE channel_id = ?`, channelID)
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO project_channels (project_id, channel_id, position)
|
|
||||||
VALUES (?, ?, ?)`,
|
|
||||||
projectID, channelID, position)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = ? WHERE id = ?`,
|
|
||||||
projectID, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) RemoveChannel(ctx context.Context, projectID, channelID string) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
res, err := tx.ExecContext(ctx, `
|
|
||||||
DELETE FROM project_channels WHERE project_id = ? AND channel_id = ?`,
|
|
||||||
projectID, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE id = ?`, channelID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT project_id, channel_id, position, COALESCE(folder, ''), added_at
|
|
||||||
FROM project_channels
|
|
||||||
WHERE project_id = ?
|
|
||||||
ORDER BY position, added_at`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ProjectChannel
|
|
||||||
for rows.Next() {
|
|
||||||
var pc models.ProjectChannel
|
|
||||||
if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, &pc.Folder, &pc.AddedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, pc)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error {
|
|
||||||
tx, err := DB.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
for i, chID := range channelIDs {
|
|
||||||
_, err := tx.ExecContext(ctx, `
|
|
||||||
UPDATE project_channels SET position = ?
|
|
||||||
WHERE project_id = ? AND channel_id = ?`,
|
|
||||||
i, projectID, chID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── KB Association ──────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT (project_id, kb_id) DO UPDATE SET auto_search = excluded.auto_search`,
|
|
||||||
projectID, kbID, boolToInt(autoSearch))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) error {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM project_knowledge_bases WHERE project_id = ? AND kb_id = ?`,
|
|
||||||
projectID, kbID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at,
|
|
||||||
COALESCE(kb.name, '') AS name
|
|
||||||
FROM project_knowledge_bases pk
|
|
||||||
LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id
|
|
||||||
WHERE pk.project_id = ?
|
|
||||||
ORDER BY pk.added_at`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ProjectKB
|
|
||||||
for rows.Next() {
|
|
||||||
var pkb models.ProjectKB
|
|
||||||
var autoSearch int
|
|
||||||
if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &autoSearch, &pkb.AddedAt, &pkb.Name); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pkb.AutoSearch = autoSearch != 0
|
|
||||||
result = append(result, pkb)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) GetKBIDs(ctx context.Context, projectID string) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT kb_id FROM project_knowledge_bases WHERE project_id = ?`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Note Association ────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) AddNote(ctx context.Context, projectID, noteID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO project_notes (project_id, note_id)
|
|
||||||
VALUES (?, ?)
|
|
||||||
ON CONFLICT DO NOTHING`,
|
|
||||||
projectID, noteID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) error {
|
|
||||||
res, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM project_notes WHERE project_id = ? AND note_id = ?`,
|
|
||||||
projectID, noteID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT pn.project_id, pn.note_id, pn.added_at,
|
|
||||||
COALESCE(n.title, '') AS title
|
|
||||||
FROM project_notes pn
|
|
||||||
LEFT JOIN notes n ON n.id = pn.note_id
|
|
||||||
WHERE pn.project_id = ?
|
|
||||||
ORDER BY pn.added_at`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.ProjectNote
|
|
||||||
for rows.Next() {
|
|
||||||
var pn models.ProjectNote
|
|
||||||
if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt, &pn.Title); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, pn)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Access Check ────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) {
|
|
||||||
q := `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM projects p
|
|
||||||
WHERE p.id = ? AND (
|
|
||||||
(p.scope = 'personal' AND p.owner_id = ?)
|
|
||||||
OR p.scope = 'global'`
|
|
||||||
|
|
||||||
args := []interface{}{projectID, userID}
|
|
||||||
|
|
||||||
for _, tid := range teamIDs {
|
|
||||||
q += ` OR (p.scope = 'team' AND p.team_id = ?)`
|
|
||||||
args = append(args, tid)
|
|
||||||
}
|
|
||||||
q += `))`
|
|
||||||
|
|
||||||
var ok bool
|
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(&ok)
|
|
||||||
return ok, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) {
|
|
||||||
var projectID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT project_id FROM project_channels WHERE channel_id = ?`,
|
|
||||||
channelID).Scan(&projectID)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return projectID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) {
|
|
||||||
archivedFilter := "AND p.is_archived = 0"
|
|
||||||
if includeArchived {
|
|
||||||
archivedFilter = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT p.id, p.name, p.description, p.scope,
|
|
||||||
p.owner_id, p.team_id, p.is_archived,
|
|
||||||
p.created_at, p.updated_at,
|
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id),
|
|
||||||
COALESCE(u.username, '')
|
|
||||||
FROM projects p
|
|
||||||
LEFT JOIN users u ON u.id = p.owner_id
|
|
||||||
WHERE 1=1 %s
|
|
||||||
ORDER BY p.updated_at DESC`, archivedFilter))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := make([]store.AdminProject, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var p store.AdminProject
|
|
||||||
var teamID sql.NullString
|
|
||||||
var archived int
|
|
||||||
if err := rows.Scan(
|
|
||||||
&p.ID, &p.Name, &p.Description, &p.Scope,
|
|
||||||
&p.OwnerID, &teamID, &archived,
|
|
||||||
st(&p.CreatedAt), st(&p.UpdatedAt),
|
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
||||||
&p.OwnerName,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.IsArchived = archived != 0
|
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
|
||||||
results = append(results, p)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func boolToInt(b bool) int {
|
|
||||||
if b {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectStore) queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("queryProjects: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Project
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.Project
|
|
||||||
var teamID, workspaceID sql.NullString
|
|
||||||
var archived int
|
|
||||||
if err := rows.Scan(
|
|
||||||
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
|
||||||
&p.OwnerID, &teamID, &archived, &workspaceID, &p.Settings,
|
|
||||||
st(&p.CreatedAt), st(&p.UpdatedAt),
|
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.IsArchived = archived != 0
|
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
|
||||||
p.WorkspaceID = NullableStringPtr(workspaceID)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProviderStore struct{}
|
|
||||||
|
|
||||||
func NewProviderStore() *ProviderStore { return &ProviderStore{} }
|
|
||||||
|
|
||||||
const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc,
|
|
||||||
key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private, created_at, updated_at`
|
|
||||||
|
|
||||||
func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error {
|
|
||||||
cfg.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
cfg.CreatedAt = now
|
|
||||||
cfg.UpdatedAt = now
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO provider_configs (id, scope, owner_id, name, provider, endpoint, api_key_enc,
|
|
||||||
key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private,
|
|
||||||
created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
cfg.ID, cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint,
|
|
||||||
cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, cfg.ModelDefault,
|
|
||||||
ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings),
|
|
||||||
cfg.IsActive, cfg.IsPrivate,
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) GetByID(ctx context.Context, id string) (*models.ProviderConfig, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE id = ?", providerCols), id)
|
|
||||||
return scanProvider(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error {
|
|
||||||
b := NewUpdate("provider_configs")
|
|
||||||
if patch.Name != nil {
|
|
||||||
b.Set("name", *patch.Name)
|
|
||||||
}
|
|
||||||
if patch.Endpoint != nil {
|
|
||||||
b.Set("endpoint", *patch.Endpoint)
|
|
||||||
}
|
|
||||||
if patch.APIKeyEnc != nil {
|
|
||||||
b.Set("api_key_enc", patch.APIKeyEnc)
|
|
||||||
b.Set("key_nonce", patch.KeyNonce)
|
|
||||||
}
|
|
||||||
if patch.ModelDefault != nil {
|
|
||||||
b.Set("model_default", *patch.ModelDefault)
|
|
||||||
}
|
|
||||||
if patch.Config != nil {
|
|
||||||
b.SetJSON("config", patch.Config)
|
|
||||||
}
|
|
||||||
if patch.Headers != nil {
|
|
||||||
b.SetJSON("headers", patch.Headers)
|
|
||||||
}
|
|
||||||
if patch.Settings != nil {
|
|
||||||
b.SetJSON("settings", patch.Settings)
|
|
||||||
}
|
|
||||||
if patch.IsActive != nil {
|
|
||||||
b.Set("is_active", *patch.IsActive)
|
|
||||||
}
|
|
||||||
if patch.IsPrivate != nil {
|
|
||||||
b.Set("is_private", *patch.IsPrivate)
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, "DELETE FROM provider_configs WHERE id = ?", id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) ListGlobal(ctx context.Context) ([]models.ProviderConfig, error) {
|
|
||||||
return s.listByScope(ctx, models.ScopeGlobal, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) {
|
|
||||||
return s.listByScope(ctx, models.ScopeTeam, teamID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
|
|
||||||
return s.listByScope(ctx, models.ScopePersonal, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAccessible returns all provider configs a user can access:
|
|
||||||
// global + team (for teams they belong to) + personal.
|
|
||||||
func (s *ProviderStore) ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT %s FROM provider_configs
|
|
||||||
WHERE is_active = 1 AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = ?)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = ?
|
|
||||||
))
|
|
||||||
)
|
|
||||||
ORDER BY scope, name`, providerCols), userID, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanProviders(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserCanAccess checks if a user can use a specific provider config.
|
|
||||||
func (s *ProviderStore) UserCanAccess(ctx context.Context, userID, configID string) (bool, error) {
|
|
||||||
var exists bool
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM provider_configs
|
|
||||||
WHERE id = ? AND is_active = 1 AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = ?)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = ?
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)`, configID, userID, userID).Scan(&exists)
|
|
||||||
return exists, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal helpers ────────────────────────
|
|
||||||
|
|
||||||
func (s *ProviderStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ProviderConfig, error) {
|
|
||||||
var rows *sql.Rows
|
|
||||||
var err error
|
|
||||||
if scope == models.ScopeGlobal {
|
|
||||||
rows, err = DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = ? AND is_active = 1 ORDER BY name", providerCols),
|
|
||||||
scope)
|
|
||||||
} else {
|
|
||||||
rows, err = DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = ? AND owner_id = ? AND is_active = 1 ORDER BY name", providerCols),
|
|
||||||
scope, ownerID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanProviders(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanProvider(row *sql.Row) (*models.ProviderConfig, error) {
|
|
||||||
var p models.ProviderConfig
|
|
||||||
var ownerID, modelDefault, keyScope sql.NullString
|
|
||||||
var configJSON, headersJSON, settingsJSON []byte
|
|
||||||
err := row.Scan(
|
|
||||||
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
|
|
||||||
&p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
|
|
||||||
&configJSON, &headersJSON, &settingsJSON,
|
|
||||||
&p.IsActive, &p.IsPrivate, st(&p.CreatedAt), st(&p.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
p.ModelDefault = modelDefault.String
|
|
||||||
p.KeyScope = keyScope.String
|
|
||||||
json.Unmarshal(configJSON, &p.Config)
|
|
||||||
json.Unmarshal(headersJSON, &p.Headers)
|
|
||||||
json.Unmarshal(settingsJSON, &p.Settings)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
|
|
||||||
var result []models.ProviderConfig
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.ProviderConfig
|
|
||||||
var ownerID, modelDefault, keyScope sql.NullString
|
|
||||||
var configJSON, headersJSON, settingsJSON []byte
|
|
||||||
err := rows.Scan(
|
|
||||||
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
|
|
||||||
&p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
|
|
||||||
&configJSON, &headersJSON, &settingsJSON,
|
|
||||||
&p.IsActive, &p.IsPrivate, st(&p.CreatedAt), st(&p.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.OwnerID = NullableStringPtr(ownerID)
|
|
||||||
p.ModelDefault = modelDefault.String
|
|
||||||
p.KeyScope = keyScope.String
|
|
||||||
json.Unmarshal(configJSON, &p.Config)
|
|
||||||
json.Unmarshal(headersJSON, &p.Headers)
|
|
||||||
json.Unmarshal(settingsJSON, &p.Settings)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS4 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProviderStore) DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) {
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = ?`, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProviderStore) ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = 'team' AND owner_id = ? ORDER BY name", providerCols),
|
|
||||||
teamID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
return scanProviders(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) {
|
|
||||||
res, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM provider_configs WHERE id = ? AND scope = 'team' AND owner_id = ?`,
|
|
||||||
id, teamID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *ProviderStore) FindFirstForUser(ctx context.Context, userID string) (string, error) {
|
|
||||||
var configID string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM provider_configs
|
|
||||||
WHERE is_active = 1 AND (
|
|
||||||
(scope = 'personal' AND owner_id = ?)
|
|
||||||
OR scope = 'global'
|
|
||||||
)
|
|
||||||
ORDER BY scope ASC, created_at ASC
|
|
||||||
LIMIT 1
|
|
||||||
`, userID).Scan(&configID)
|
|
||||||
return configID, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProviderStore) LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) {
|
|
||||||
row := DB.QueryRowContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT %s FROM provider_configs
|
|
||||||
WHERE id = ? AND is_active = 1
|
|
||||||
AND (scope = 'global'
|
|
||||||
OR (scope = 'personal' AND owner_id = ?)
|
|
||||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))
|
|
||||||
`, providerCols), configID, userID, userID)
|
|
||||||
return scanProvider(row)
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RoutingPolicyStore implements store.RoutingPolicyStore for SQLite.
|
|
||||||
type RoutingPolicyStore struct{}
|
|
||||||
|
|
||||||
func NewRoutingPolicyStore() *RoutingPolicyStore { return &RoutingPolicyStore{} }
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) Create(ctx context.Context, p *models.RoutingPolicy) error {
|
|
||||||
p.ID = store.NewID()
|
|
||||||
configJSON, err := json.Marshal(p.Config)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
p.CreatedAt = now
|
|
||||||
p.UpdatedAt = now
|
|
||||||
_, err = DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO routing_policies (id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`, p.ID, p.Name, p.Scope, p.TeamID, p.Priority, p.Type, string(configJSON), boolToInt(p.IsActive), p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) Update(ctx context.Context, p *models.RoutingPolicy) error {
|
|
||||||
configJSON, err := json.Marshal(p.Config)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = DB.ExecContext(ctx, `
|
|
||||||
UPDATE routing_policies
|
|
||||||
SET name = ?, scope = ?, team_id = ?, priority = ?,
|
|
||||||
policy_type = ?, config = ?, is_active = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ?
|
|
||||||
`, p.Name, p.Scope, p.TeamID, p.Priority, p.Type, string(configJSON), boolToInt(p.IsActive), p.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM routing_policies WHERE id = ?`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) GetByID(ctx context.Context, id string) (*models.RoutingPolicy, error) {
|
|
||||||
var p models.RoutingPolicy
|
|
||||||
var configJSON string
|
|
||||||
var isActive int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
|
||||||
FROM routing_policies WHERE id = ?
|
|
||||||
`, id).Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, st(&p.CreatedAt), st(&p.UpdatedAt))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.IsActive = isActive != 0
|
|
||||||
json.Unmarshal([]byte(configJSON), &p.Config)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) ListActive(ctx context.Context) ([]models.RoutingPolicy, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
|
||||||
FROM routing_policies
|
|
||||||
WHERE is_active = 1
|
|
||||||
ORDER BY priority ASC, created_at ASC
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) ListAll(ctx context.Context) ([]models.RoutingPolicy, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
|
||||||
FROM routing_policies
|
|
||||||
ORDER BY priority ASC, created_at ASC
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error) {
|
|
||||||
return s.query(ctx, `
|
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
|
||||||
FROM routing_policies
|
|
||||||
WHERE is_active = 1 AND (scope = 'global' OR (scope = 'team' AND team_id = ?))
|
|
||||||
ORDER BY priority ASC, created_at ASC
|
|
||||||
`, teamID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RoutingPolicyStore) query(ctx context.Context, q string, args ...interface{}) ([]models.RoutingPolicy, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.RoutingPolicy
|
|
||||||
for rows.Next() {
|
|
||||||
var p models.RoutingPolicy
|
|
||||||
var configJSON string
|
|
||||||
var isActive int
|
|
||||||
if err := rows.Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, st(&p.CreatedAt), st(&p.UpdatedAt)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.IsActive = isActive != 0
|
|
||||||
json.Unmarshal([]byte(configJSON), &p.Config)
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ store.RoutingPolicyStore = (*RoutingPolicyStore)(nil)
|
|
||||||
@@ -11,47 +11,25 @@ import (
|
|||||||
func NewStores(db *sql.DB) store.Stores {
|
func NewStores(db *sql.DB) store.Stores {
|
||||||
SetDB(db)
|
SetDB(db)
|
||||||
return store.Stores{
|
return store.Stores{
|
||||||
Providers: NewProviderStore(),
|
|
||||||
Catalog: NewCatalogStore(),
|
|
||||||
Personas: NewPersonaStore(),
|
|
||||||
Policies: NewPolicyStore(),
|
|
||||||
UserSettings: NewUserModelSettingsStore(),
|
|
||||||
Users: NewUserStore(),
|
Users: NewUserStore(),
|
||||||
Teams: NewTeamStore(),
|
Teams: NewTeamStore(),
|
||||||
Channels: NewChannelStore(),
|
|
||||||
Messages: NewMessageStore(),
|
|
||||||
Audit: NewAuditStore(),
|
Audit: NewAuditStore(),
|
||||||
Notes: NewNoteStore(),
|
|
||||||
NoteLinks: NewNoteLinkStore(),
|
|
||||||
GlobalConfig: NewGlobalConfigStore(),
|
GlobalConfig: NewGlobalConfigStore(),
|
||||||
Usage: NewUsageStore(),
|
|
||||||
Pricing: NewPricingStore(),
|
|
||||||
Files: NewFileStore(),
|
|
||||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
|
||||||
Groups: NewGroupStore(),
|
Groups: NewGroupStore(),
|
||||||
ResourceGrants: NewResourceGrantStore(),
|
ResourceGrants: NewResourceGrantStore(),
|
||||||
Memories: NewMemoryStore(),
|
|
||||||
Projects: NewProjectStore(),
|
|
||||||
Notifications: NewNotificationStore(),
|
Notifications: NewNotificationStore(),
|
||||||
NotifPrefs: NewNotificationPreferenceStore(),
|
NotifPrefs: NewNotificationPreferenceStore(),
|
||||||
Workspaces: NewWorkspaceStore(),
|
|
||||||
GitCredentials: &GitCredentialStore{},
|
|
||||||
CapOverrides: NewCapOverrideStore(),
|
|
||||||
RoutingPolicies: NewRoutingPolicyStore(),
|
|
||||||
Sessions: NewSessionStore(),
|
Sessions: NewSessionStore(),
|
||||||
|
Presence: NewPresenceStore(),
|
||||||
|
Health: NewHealthStore(),
|
||||||
|
Connections: NewConnectionStore(),
|
||||||
|
Dependencies: NewDependencyStore(),
|
||||||
Packages: NewPackageStore(),
|
Packages: NewPackageStore(),
|
||||||
Workflows: NewWorkflowStore(),
|
Workflows: NewWorkflowStore(),
|
||||||
Tasks: NewTaskStore(),
|
Tasks: NewTaskStore(),
|
||||||
Presence: NewPresenceStore(),
|
|
||||||
PersonaGroups: NewPersonaGroupStore(),
|
|
||||||
Folders: NewFolderStore(),
|
|
||||||
Health: NewHealthStore(),
|
|
||||||
ExtPermissions: NewExtensionPermissionStore(),
|
ExtPermissions: NewExtensionPermissionStore(),
|
||||||
ExtData: NewExtDataStore(),
|
ExtData: NewExtDataStore(),
|
||||||
Tickets: NewTicketStore(),
|
Tickets: NewTicketStore(),
|
||||||
RateLimits: NewRateLimitStore(),
|
RateLimits: NewRateLimitStore(),
|
||||||
Export: NewExportStore(),
|
|
||||||
Connections: NewConnectionStore(),
|
|
||||||
Dependencies: NewDependencyStore(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UsageStore struct{}
|
|
||||||
|
|
||||||
func NewUsageStore() *UsageStore { return &UsageStore{} }
|
|
||||||
|
|
||||||
// Log inserts a usage entry.
|
|
||||||
func (s *UsageStore) Log(ctx context.Context, entry *models.UsageEntry) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO usage_log (
|
|
||||||
id, channel_id, user_id, provider_config_id, provider_scope,
|
|
||||||
model_id, role,
|
|
||||||
prompt_tokens, completion_tokens,
|
|
||||||
cache_creation_tokens, cache_read_tokens,
|
|
||||||
cost_input, cost_output
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`,
|
|
||||||
store.NewID(), entry.ChannelID, entry.UserID, entry.ProviderConfigID, entry.ProviderScope,
|
|
||||||
entry.ModelID, entry.Role,
|
|
||||||
entry.PromptTokens, entry.CompletionTokens,
|
|
||||||
entry.CacheCreationTokens, entry.CacheReadTokens,
|
|
||||||
entry.CostInput, entry.CostOutput,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByUser returns aggregated usage for a specific user.
|
|
||||||
func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where := []string{"user_id = ?"}
|
|
||||||
args := []interface{}{userID}
|
|
||||||
return s.query(ctx, where, args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByUserPersonal returns aggregated usage for a user's own (BYOK) providers only.
|
|
||||||
func (s *UsageStore) QueryByUserPersonal(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where := []string{
|
|
||||||
"user_id = ?",
|
|
||||||
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = ?)",
|
|
||||||
}
|
|
||||||
args := []interface{}{userID, userID}
|
|
||||||
return s.query(ctx, where, args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByTeam returns aggregated usage for all members of a team (admin view).
|
|
||||||
func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = ?)"}
|
|
||||||
args := []interface{}{teamID}
|
|
||||||
return s.query(ctx, where, args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByTeamProviders returns usage against providers owned by this team.
|
|
||||||
// Used by team admins to see how their team's API keys are being consumed.
|
|
||||||
func (s *UsageStore) QueryByTeamProviders(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = ?)"}
|
|
||||||
args := []interface{}{teamID}
|
|
||||||
return s.query(ctx, where, args, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryByModel returns aggregated usage grouped by model.
|
|
||||||
func (s *UsageStore) QueryByModel(ctx context.Context, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
return s.query(ctx, nil, nil, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTotals returns aggregate totals (admin view — excludes BYOK by default).
|
|
||||||
func (s *UsageStore) GetTotals(ctx context.Context, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
|
||||||
where, args := s.buildFilters(nil, nil, opts)
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
COUNT(*),
|
|
||||||
COALESCE(SUM(prompt_tokens), 0),
|
|
||||||
COALESCE(SUM(completion_tokens), 0),
|
|
||||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
|
||||||
FROM usage_log
|
|
||||||
WHERE %s
|
|
||||||
`, strings.Join(where, " AND "))
|
|
||||||
|
|
||||||
var t models.UsageTotals
|
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
|
||||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
|
||||||
)
|
|
||||||
return &t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTeamProviderTotals returns aggregate totals for providers owned by a team.
|
|
||||||
func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
|
||||||
baseWhere := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = ?)"}
|
|
||||||
baseArgs := []interface{}{teamID}
|
|
||||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
COUNT(*),
|
|
||||||
COALESCE(SUM(prompt_tokens), 0),
|
|
||||||
COALESCE(SUM(completion_tokens), 0),
|
|
||||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
|
||||||
FROM usage_log
|
|
||||||
WHERE %s
|
|
||||||
`, strings.Join(where, " AND "))
|
|
||||||
|
|
||||||
var t models.UsageTotals
|
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
|
||||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
|
||||||
)
|
|
||||||
return &t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPersonalTotals returns aggregate totals for a user's own provider usage.
|
|
||||||
// Only includes usage against personal (BYOK) providers — global provider
|
|
||||||
// costs are the org's responsibility, not the user's.
|
|
||||||
func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
|
||||||
baseWhere := []string{
|
|
||||||
"user_id = ?",
|
|
||||||
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = ?)",
|
|
||||||
}
|
|
||||||
baseArgs := []interface{}{userID, userID}
|
|
||||||
opts.ExcludeBYOK = false
|
|
||||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
COUNT(*),
|
|
||||||
COALESCE(SUM(prompt_tokens), 0),
|
|
||||||
COALESCE(SUM(completion_tokens), 0),
|
|
||||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
|
||||||
FROM usage_log
|
|
||||||
WHERE %s
|
|
||||||
`, strings.Join(where, " AND "))
|
|
||||||
|
|
||||||
var t models.UsageTotals
|
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
|
||||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
|
||||||
)
|
|
||||||
return &t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountRecentByRole counts how many usage entries a user has for a given
|
|
||||||
// role within the specified duration. Used for rate limiting utility calls.
|
|
||||||
func (s *UsageStore) CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM usage_log
|
|
||||||
WHERE user_id = ? AND role = ? AND created_at >= ?
|
|
||||||
`, userID, role, since).Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal ───────────────────────────────
|
|
||||||
|
|
||||||
func (s *UsageStore) buildFilters(baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]string, []interface{}) {
|
|
||||||
where := append([]string{}, baseWhere...)
|
|
||||||
args := append([]interface{}{}, baseArgs...)
|
|
||||||
|
|
||||||
if opts.ExcludeBYOK {
|
|
||||||
where = append(where, "provider_scope != ?")
|
|
||||||
args = append(args, "personal")
|
|
||||||
}
|
|
||||||
if opts.Since != nil {
|
|
||||||
where = append(where, "created_at >= ?")
|
|
||||||
args = append(args, *opts.Since)
|
|
||||||
}
|
|
||||||
if opts.Until != nil {
|
|
||||||
where = append(where, "created_at < ?")
|
|
||||||
args = append(args, *opts.Until)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(where) == 0 {
|
|
||||||
where = append(where, "1=1")
|
|
||||||
}
|
|
||||||
return where, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UsageStore) query(ctx context.Context, baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
|
||||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
|
||||||
|
|
||||||
// Determine GROUP BY expression and label
|
|
||||||
groupExpr := "model_id"
|
|
||||||
labelExpr := "model_id"
|
|
||||||
switch opts.GroupBy {
|
|
||||||
case "day":
|
|
||||||
groupExpr = "DATE(created_at)"
|
|
||||||
labelExpr = "DATE(created_at)"
|
|
||||||
case "user":
|
|
||||||
groupExpr = "user_id"
|
|
||||||
labelExpr = "user_id"
|
|
||||||
case "provider":
|
|
||||||
groupExpr = "provider_config_id"
|
|
||||||
labelExpr = "provider_config_id"
|
|
||||||
case "model":
|
|
||||||
groupExpr = "model_id"
|
|
||||||
labelExpr = "model_id"
|
|
||||||
default:
|
|
||||||
groupExpr = "model_id"
|
|
||||||
labelExpr = "model_id"
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := opts.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
%s AS group_key,
|
|
||||||
%s AS label,
|
|
||||||
COUNT(*) AS requests,
|
|
||||||
COALESCE(SUM(prompt_tokens), 0) AS input_tokens,
|
|
||||||
COALESCE(SUM(completion_tokens), 0) AS output_tokens,
|
|
||||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0) AS total_cost
|
|
||||||
FROM usage_log
|
|
||||||
WHERE %s
|
|
||||||
GROUP BY %s
|
|
||||||
ORDER BY total_cost DESC
|
|
||||||
LIMIT %d
|
|
||||||
`, groupExpr, labelExpr, strings.Join(where, " AND "), groupExpr, limit)
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []models.UsageAggregate
|
|
||||||
for rows.Next() {
|
|
||||||
var a models.UsageAggregate
|
|
||||||
if err := rows.Scan(&a.GroupKey, &a.Label, &a.Requests, &a.InputTokens, &a.OutputTokens, &a.TotalCost); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, a)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Mention resolution + display info (v0.29.0) ────────────────────────
|
|
||||||
|
|
||||||
func (s *UserStore) FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) {
|
|
||||||
var id string
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM users
|
|
||||||
WHERE LOWER(handle) = LOWER(?) AND id != ? AND is_active = 1
|
|
||||||
LIMIT 1
|
|
||||||
`, handle, excludeUserID).Scan(&id)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserStore) FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) {
|
|
||||||
var count int
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*) FROM users
|
|
||||||
WHERE LOWER(handle) LIKE LOWER(?) AND id != ? AND is_active = 1
|
|
||||||
`, prefix+"%", excludeUserID).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
return "", count, nil
|
|
||||||
}
|
|
||||||
var id string
|
|
||||||
err = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM users
|
|
||||||
WHERE LOWER(handle) LIKE LOWER(?) AND id != ? AND is_active = 1
|
|
||||||
LIMIT 1
|
|
||||||
`, prefix+"%", excludeUserID).Scan(&id)
|
|
||||||
return id, 1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) {
|
|
||||||
result := make(map[string]store.UserDisplayInfo)
|
|
||||||
for _, id := range ids {
|
|
||||||
var name, avatar sql.NullString
|
|
||||||
_ = DB.QueryRowContext(ctx, `
|
|
||||||
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = ?
|
|
||||||
`, id).Scan(&name, &avatar)
|
|
||||||
if name.Valid {
|
|
||||||
info := store.UserDisplayInfo{Name: name.String}
|
|
||||||
if avatar.Valid {
|
|
||||||
info.Avatar = avatar.String
|
|
||||||
}
|
|
||||||
result[id] = info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserModelSettingsStore struct{}
|
|
||||||
|
|
||||||
func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSettingsStore{} }
|
|
||||||
|
|
||||||
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT id, user_id, model_id, provider_config_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens,
|
|
||||||
COALESCE(sort_order, 0), created_at, updated_at
|
|
||||||
FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.UserModelSetting
|
|
||||||
for rows.Next() {
|
|
||||||
var s models.UserModelSetting
|
|
||||||
var prefTemp, prefMaxTokens interface{}
|
|
||||||
err := rows.Scan(
|
|
||||||
&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, &s.Hidden,
|
|
||||||
&prefTemp, &prefMaxTokens,
|
|
||||||
&s.SortOrder, st(&s.CreatedAt), st(&s.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if f, ok := prefTemp.(float64); ok {
|
|
||||||
s.PreferredTemperature = &f
|
|
||||||
}
|
|
||||||
if n, ok := prefMaxTokens.(int64); ok {
|
|
||||||
v := int(n)
|
|
||||||
s.PreferredMaxTokens = &v
|
|
||||||
}
|
|
||||||
result = append(result, s)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHiddenModelIDs returns a map of provider_config_id:model_id → true for all hidden models.
|
|
||||||
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
"SELECT model_id, COALESCE(provider_config_id, '') FROM user_model_settings WHERE user_id = ? AND hidden = 1",
|
|
||||||
userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
result := make(map[string]bool)
|
|
||||||
for rows.Next() {
|
|
||||||
var modelID, provCfgID string
|
|
||||||
if err := rows.Scan(&modelID, &provCfgID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result[models.CompositeModelKey(provCfgID, modelID)] = true
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set upserts a single user model setting.
|
|
||||||
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error {
|
|
||||||
b := NewUpdate("user_model_settings")
|
|
||||||
if patch.Hidden != nil {
|
|
||||||
b.Set("hidden", *patch.Hidden)
|
|
||||||
}
|
|
||||||
if patch.PreferredTemperature != nil {
|
|
||||||
b.Set("preferred_temperature", models.NullFloat(patch.PreferredTemperature))
|
|
||||||
}
|
|
||||||
if patch.PreferredMaxTokens != nil {
|
|
||||||
b.Set("preferred_max_tokens", models.NullInt(patch.PreferredMaxTokens))
|
|
||||||
}
|
|
||||||
if patch.SortOrder != nil {
|
|
||||||
b.Set("sort_order", *patch.SortOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use upsert: insert if not exists, update if exists
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
|
||||||
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0))
|
|
||||||
ON CONFLICT (user_id, model_id, provider_config_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
hidden = COALESCE(excluded.hidden, user_model_settings.hidden),
|
|
||||||
preferred_temperature = COALESCE(excluded.preferred_temperature, user_model_settings.preferred_temperature),
|
|
||||||
preferred_max_tokens = COALESCE(excluded.preferred_max_tokens, user_model_settings.preferred_max_tokens),
|
|
||||||
sort_order = COALESCE(excluded.sort_order, user_model_settings.sort_order)`,
|
|
||||||
store.NewID(), userID, modelID, providerConfigID,
|
|
||||||
patchBoolOrNil(patch.Hidden),
|
|
||||||
patchFloat64OrNil(patch.PreferredTemperature),
|
|
||||||
patchIntOrNil(patch.PreferredMaxTokens),
|
|
||||||
patchIntOrNil(patch.SortOrder),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// BulkSetHidden sets the hidden state for multiple model+provider pairs at once.
|
|
||||||
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error {
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = excluded.hidden`,
|
|
||||||
store.NewID(), userID, entry.ModelID, entry.ProviderConfigID, hidden)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.ModelID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func patchBoolOrNil(b *bool) interface{} {
|
|
||||||
if b == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *b
|
|
||||||
}
|
|
||||||
|
|
||||||
func patchFloat64OrNil(f *float64) interface{} {
|
|
||||||
if f == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *f
|
|
||||||
}
|
|
||||||
|
|
||||||
func patchIntOrNil(i *int) interface{} {
|
|
||||||
if i == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *i
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
"switchboard-core/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WorkspaceStore struct{}
|
|
||||||
|
|
||||||
func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
|
|
||||||
|
|
||||||
// ── columns ────────────────────────────────
|
|
||||||
|
|
||||||
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, git_remote_url, git_branch, git_credential_id, git_last_sync, created_at, updated_at`
|
|
||||||
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at`
|
|
||||||
|
|
||||||
// ── scanners ───────────────────────────────
|
|
||||||
|
|
||||||
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
|
||||||
var w models.Workspace
|
|
||||||
var maxBytes sql.NullInt64
|
|
||||||
var gitRemote, gitBranch, gitCredID sql.NullString
|
|
||||||
err := row.Scan(
|
|
||||||
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
|
||||||
&w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled,
|
|
||||||
&gitRemote, &gitBranch, &gitCredID, stN(&w.GitLastSync),
|
|
||||||
st(&w.CreatedAt), st(&w.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if maxBytes.Valid {
|
|
||||||
w.MaxBytes = &maxBytes.Int64
|
|
||||||
}
|
|
||||||
if gitRemote.Valid {
|
|
||||||
w.GitRemoteURL = &gitRemote.String
|
|
||||||
}
|
|
||||||
if gitBranch.Valid {
|
|
||||||
w.GitBranch = &gitBranch.String
|
|
||||||
}
|
|
||||||
if gitCredID.Valid {
|
|
||||||
w.GitCredentialID = &gitCredID.String
|
|
||||||
}
|
|
||||||
return &w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) {
|
|
||||||
var f models.WorkspaceFile
|
|
||||||
var contentType sql.NullString
|
|
||||||
var sha256 sql.NullString
|
|
||||||
var indexStatus sql.NullString
|
|
||||||
err := row.Scan(
|
|
||||||
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
|
|
||||||
&contentType, &f.SizeBytes, &sha256,
|
|
||||||
&indexStatus, &f.ChunkCount,
|
|
||||||
st(&f.CreatedAt), st(&f.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if contentType.Valid {
|
|
||||||
f.ContentType = contentType.String
|
|
||||||
}
|
|
||||||
if sha256.Valid {
|
|
||||||
f.SHA256 = sha256.String
|
|
||||||
}
|
|
||||||
if indexStatus.Valid {
|
|
||||||
f.IndexStatus = indexStatus.String
|
|
||||||
}
|
|
||||||
return &f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Workspace CRUD ─────────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error {
|
|
||||||
if w.ID == "" {
|
|
||||||
w.ID = store.NewID()
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
w.CreatedAt = now
|
|
||||||
w.UpdatedAt = now
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
|
||||||
w.MaxBytes, w.Status,
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT `+workspaceCols+` FROM workspaces WHERE id = ?`, id)
|
|
||||||
return scanWorkspace(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
|
|
||||||
b := NewUpdate("workspaces")
|
|
||||||
if patch.Name != nil {
|
|
||||||
b.Set("name", *patch.Name)
|
|
||||||
}
|
|
||||||
if patch.MaxBytes != nil {
|
|
||||||
b.Set("max_bytes", *patch.MaxBytes)
|
|
||||||
}
|
|
||||||
if patch.Status != nil {
|
|
||||||
b.Set("status", *patch.Status)
|
|
||||||
}
|
|
||||||
if patch.IndexingEnabled != nil {
|
|
||||||
b.Set("indexing_enabled", *patch.IndexingEnabled)
|
|
||||||
}
|
|
||||||
if patch.GitRemoteURL != nil {
|
|
||||||
b.Set("git_remote_url", *patch.GitRemoteURL)
|
|
||||||
}
|
|
||||||
if patch.GitBranch != nil {
|
|
||||||
b.Set("git_branch", *patch.GitBranch)
|
|
||||||
}
|
|
||||||
if patch.GitCredentialID != nil {
|
|
||||||
b.Set("git_credential_id", *patch.GitCredentialID)
|
|
||||||
}
|
|
||||||
if !b.HasSets() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.SetExpr("updated_at", nowExpr)
|
|
||||||
b.Where("id", id)
|
|
||||||
_, err := b.Exec(DB)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ownership lookup ───────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT `+workspaceCols+` FROM workspaces
|
|
||||||
WHERE owner_type = ? AND owner_id = ? AND status != 'deleting'
|
|
||||||
ORDER BY created_at DESC LIMIT 1`,
|
|
||||||
ownerType, ownerID)
|
|
||||||
return scanWorkspace(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
`SELECT `+workspaceCols+` FROM workspaces
|
|
||||||
WHERE owner_type = ? AND owner_id = ? AND status != 'deleting'
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
ownerType, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []models.Workspace
|
|
||||||
for rows.Next() {
|
|
||||||
w, err := scanWorkspace(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, *w)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File index ─────────────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
|
|
||||||
if f.ID == "" {
|
|
||||||
f.ID = store.NewID()
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
f.CreatedAt = now
|
|
||||||
f.UpdatedAt = now
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (workspace_id, path) DO UPDATE SET
|
|
||||||
is_directory = excluded.is_directory,
|
|
||||||
content_type = excluded.content_type,
|
|
||||||
size_bytes = excluded.size_bytes,
|
|
||||||
sha256 = excluded.sha256,
|
|
||||||
updated_at = datetime('now')`,
|
|
||||||
f.ID, f.WorkspaceID, f.Path, f.IsDirectory,
|
|
||||||
models.NullString(&f.ContentType), f.SizeBytes,
|
|
||||||
models.NullString(&f.SHA256),
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM workspace_files WHERE workspace_id = ? AND path = ?`,
|
|
||||||
workspaceID, path)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM workspace_files WHERE workspace_id = ? AND path LIKE ?`,
|
|
||||||
workspaceID, prefix+"%")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
|
|
||||||
row := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT `+workspaceFileCols+` FROM workspace_files
|
|
||||||
WHERE workspace_id = ? AND path = ?`,
|
|
||||||
workspaceID, path)
|
|
||||||
return scanWorkspaceFile(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
|
||||||
var query string
|
|
||||||
var args []interface{}
|
|
||||||
|
|
||||||
if recursive {
|
|
||||||
if prefix == "" {
|
|
||||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
|
||||||
WHERE workspace_id = ? ORDER BY path`
|
|
||||||
args = []interface{}{workspaceID}
|
|
||||||
} else {
|
|
||||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
|
||||||
WHERE workspace_id = ? AND path LIKE ? ORDER BY path`
|
|
||||||
args = []interface{}{workspaceID, prefix + "%"}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if prefix == "" {
|
|
||||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
|
||||||
WHERE workspace_id = ? AND path NOT LIKE '%/%' ORDER BY path`
|
|
||||||
args = []interface{}{workspaceID}
|
|
||||||
} else {
|
|
||||||
// Direct children: starts with prefix, no slash after prefix
|
|
||||||
query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files
|
|
||||||
WHERE workspace_id = ? AND path LIKE ?
|
|
||||||
AND substr(path, %d) NOT LIKE '%%/%%'
|
|
||||||
ORDER BY path`, len(prefix)+1)
|
|
||||||
args = []interface{}{workspaceID, prefix + "%"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []models.WorkspaceFile
|
|
||||||
for rows.Next() {
|
|
||||||
f, err := scanWorkspaceFile(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, *f)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM workspace_files WHERE workspace_id = ?`, workspaceID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats ──────────────────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
|
|
||||||
stats := &models.WorkspaceStats{WorkspaceID: workspaceID}
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0),
|
|
||||||
COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0),
|
|
||||||
COALESCE(SUM(size_bytes), 0)
|
|
||||||
FROM workspace_files WHERE workspace_id = ?`,
|
|
||||||
workspaceID,
|
|
||||||
).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxBytes sql.NullInt64
|
|
||||||
err = DB.QueryRowContext(ctx,
|
|
||||||
`SELECT max_bytes FROM workspaces WHERE id = ?`, workspaceID,
|
|
||||||
).Scan(&maxBytes)
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if maxBytes.Valid {
|
|
||||||
stats.MaxBytes = &maxBytes.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chunks (v0.21.2) ─────────────────────────
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error {
|
|
||||||
if len(chunks) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
valueParts := make([]string, 0, len(chunks))
|
|
||||||
args := make([]interface{}, 0, len(chunks)*8)
|
|
||||||
|
|
||||||
for _, c := range chunks {
|
|
||||||
c.ID = store.NewID()
|
|
||||||
valueParts = append(valueParts, "(?, ?, ?, ?, ?, ?, ?, ?)")
|
|
||||||
var embJSON interface{}
|
|
||||||
if len(c.Embedding) > 0 {
|
|
||||||
embJSON = ToJSON(c.Embedding)
|
|
||||||
}
|
|
||||||
args = append(args, c.ID, c.WorkspaceID, c.FileID, c.ChunkIndex,
|
|
||||||
c.Content, c.TokenCount, embJSON, ToJSON(c.Metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`INSERT INTO workspace_chunks
|
|
||||||
(id, workspace_id, file_id, chunk_index, content, token_count, embedding, metadata)
|
|
||||||
VALUES %s`, strings.Join(valueParts, ", "))
|
|
||||||
|
|
||||||
_, err := DB.ExecContext(ctx, q, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE file_id = ?`, fileID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SimilaritySearch performs app-level cosine similarity for SQLite.
|
|
||||||
// Loads all chunks for the workspace, computes cosine distance in Go,
|
|
||||||
// and returns the top results above threshold.
|
|
||||||
func (s *WorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) {
|
|
||||||
if len(queryVec) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT f.path, c.content, c.embedding, c.metadata
|
|
||||||
FROM workspace_chunks c
|
|
||||||
JOIN workspace_files f ON c.file_id = f.id
|
|
||||||
WHERE c.workspace_id = ?
|
|
||||||
AND c.embedding IS NOT NULL`,
|
|
||||||
workspaceID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type candidate struct {
|
|
||||||
result models.WorkspaceChunkResult
|
|
||||||
similarity float64
|
|
||||||
}
|
|
||||||
var candidates []candidate
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var filePath, content, embJSON string
|
|
||||||
var metaJSON sql.NullString
|
|
||||||
if err := rows.Scan(&filePath, &content, &embJSON, &metaJSON); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var embedding []float64
|
|
||||||
if err := json.Unmarshal([]byte(embJSON), &embedding); err != nil {
|
|
||||||
continue // skip malformed
|
|
||||||
}
|
|
||||||
|
|
||||||
sim := wsCosineSimilarity(queryVec, embedding)
|
|
||||||
if sim > threshold {
|
|
||||||
r := models.WorkspaceChunkResult{
|
|
||||||
FilePath: filePath,
|
|
||||||
Content: content,
|
|
||||||
Score: sim,
|
|
||||||
}
|
|
||||||
if metaJSON.Valid {
|
|
||||||
ScanJSON(metaJSON.String, &r.Metadata)
|
|
||||||
}
|
|
||||||
if r.Metadata != nil {
|
|
||||||
if lh, ok := r.Metadata["line_start"]; ok {
|
|
||||||
if n, ok := lh.(float64); ok {
|
|
||||||
r.LineHint = int(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidates = append(candidates, candidate{result: r, similarity: sim})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(candidates, func(i, j int) bool {
|
|
||||||
return candidates[i].similarity > candidates[j].similarity
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(candidates) > limit {
|
|
||||||
candidates = candidates[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]models.WorkspaceChunkResult, len(candidates))
|
|
||||||
for i, c := range candidates {
|
|
||||||
results[i] = c.result
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE workspace_files SET index_status = ?, chunk_count = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
||||||
status, chunkCount, fileID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error {
|
|
||||||
_, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE workspaces SET git_last_sync = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
||||||
t.Format(timeFmt), workspaceID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// wsCosineSimilarity computes cosine similarity between two vectors.
|
|
||||||
// Named to avoid collision with the KB package-level function.
|
|
||||||
func wsCosineSimilarity(a, b []float64) float64 {
|
|
||||||
if len(a) != len(b) || len(a) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var dot, normA, normB float64
|
|
||||||
for i := range a {
|
|
||||||
dot += a[i] * b[i]
|
|
||||||
normA += a[i] * a[i]
|
|
||||||
normB += b[i] * b[i]
|
|
||||||
}
|
|
||||||
if normA == 0 || normB == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// ── store_memory.go ─────────────────────────
|
|
||||||
// MemoryStore interface for v0.18.0.
|
|
||||||
// Add this interface to store/interfaces.go and add
|
|
||||||
// Memories MemoryStore
|
|
||||||
// to the Stores struct.
|
|
||||||
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// MEMORY STORE (v0.18.0)
|
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type MemoryStore interface {
|
|
||||||
// Upsert creates or updates a memory (matched on scope+owner+user+key).
|
|
||||||
// If a memory with the same key exists, value/confidence/status are updated.
|
|
||||||
Upsert(ctx context.Context, m *models.Memory) error
|
|
||||||
|
|
||||||
// Update modifies an existing memory by ID. Use for handler edits where
|
|
||||||
// the key may change (Upsert conflicts on composite key, not on ID).
|
|
||||||
Update(ctx context.Context, m *models.Memory) error
|
|
||||||
|
|
||||||
// GetByID returns a single memory by ID.
|
|
||||||
GetByID(ctx context.Context, id string) (*models.Memory, error)
|
|
||||||
|
|
||||||
// List returns memories matching the filter criteria.
|
|
||||||
List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error)
|
|
||||||
|
|
||||||
// Delete hard-deletes a memory by ID.
|
|
||||||
Delete(ctx context.Context, id string) error
|
|
||||||
|
|
||||||
// Archive sets status to 'archived' (soft delete).
|
|
||||||
Archive(ctx context.Context, id string) error
|
|
||||||
|
|
||||||
// Recall returns active memories relevant to a query, merging scopes.
|
|
||||||
// Combines user + persona + persona_user memories for the given context.
|
|
||||||
// Results are ordered by confidence DESC, updated_at DESC.
|
|
||||||
Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error)
|
|
||||||
|
|
||||||
// CountByOwner returns the number of active memories for a scope+owner.
|
|
||||||
CountByOwner(ctx context.Context, scope, ownerID string) (int, error)
|
|
||||||
|
|
||||||
// ListByStatus returns all memories with the given status across all owners.
|
|
||||||
// Used by admin review endpoints. Results ordered by created_at DESC.
|
|
||||||
ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error)
|
|
||||||
|
|
||||||
// RecallHybrid returns active memories using vector similarity + keyword search.
|
|
||||||
// If queryVec is nil/empty, falls back to keyword-only (same as Recall).
|
|
||||||
RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error)
|
|
||||||
|
|
||||||
// ── CS5b additions (v0.29.0) ──
|
|
||||||
|
|
||||||
// SetEmbedding updates the embedding vector for a memory. Handles dialect differences.
|
|
||||||
SetEmbedding(ctx context.Context, id, embedding string) error
|
|
||||||
|
|
||||||
// GetLastExtractionMessageID returns the last processed message ID from the extraction log.
|
|
||||||
// Returns "" if no log entry exists.
|
|
||||||
GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error)
|
|
||||||
|
|
||||||
// UpsertExtractionLog creates or updates the memory extraction log entry.
|
|
||||||
UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error
|
|
||||||
|
|
||||||
// ── v0.30.1 — Memory Compaction ──
|
|
||||||
|
|
||||||
// DecayConfidence reduces confidence for active memories not updated since olderThan.
|
|
||||||
// Formula: new_confidence = confidence * decayFactor. Skips memories with confidence <= 0.1.
|
|
||||||
// Returns the number of affected rows.
|
|
||||||
DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error)
|
|
||||||
|
|
||||||
// Prune archives active memories whose confidence has dropped below the threshold.
|
|
||||||
// Returns the number of archived rows.
|
|
||||||
Prune(ctx context.Context, belowConfidence float64) (int, error)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import "encoding/json"
|
|
||||||
|
|
||||||
// ── Tree Types (v0.29.0) ────────────────────
|
|
||||||
// Moved from treepath package to store so MessageStore methods can
|
|
||||||
// return enriched path/sibling data without import cycles.
|
|
||||||
// treepath aliases these types for backward compatibility.
|
|
||||||
|
|
||||||
// PathMessage represents a single message in an active path, enriched
|
|
||||||
// with sibling metadata for the frontend branch indicator.
|
|
||||||
type PathMessage struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ParentID *string `json:"parent_id"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Model *string `json:"model"`
|
|
||||||
TokensUsed *int `json:"tokens_used,omitempty"`
|
|
||||||
ToolCalls *json.RawMessage `json:"tool_calls,omitempty"`
|
|
||||||
Metadata *json.RawMessage `json:"metadata,omitempty"`
|
|
||||||
SiblingCount int `json:"sibling_count"`
|
|
||||||
SiblingIndex int `json:"sibling_index"`
|
|
||||||
ParticipantType string `json:"participant_type,omitempty"`
|
|
||||||
ParticipantID string `json:"participant_id,omitempty"`
|
|
||||||
SenderName *string `json:"sender_name,omitempty"`
|
|
||||||
SenderAvatar *string `json:"sender_avatar,omitempty"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SiblingInfo is a lightweight entry for the siblings listing endpoint.
|
|
||||||
type SiblingInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Model *string `json:"model,omitempty"`
|
|
||||||
SiblingIndex int `json:"sibling_index"`
|
|
||||||
Preview string `json:"preview"` // first ~80 chars of content
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MentionResult is the outcome of resolving an @mention token.
|
|
||||||
// Exactly one of PersonaID, UserID, or ModelID will be non-empty.
|
|
||||||
type MentionResult struct {
|
|
||||||
ModelID string // resolved model ID (for @model mentions)
|
|
||||||
ProviderConfigID string // provider config for model-based mentions
|
|
||||||
PersonaID string // matched persona ID
|
|
||||||
PersonaName string // persona display name (for logging)
|
|
||||||
UserID string // matched user ID (for @user mentions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserDisplayInfo holds name + avatar for sender resolution in paths.
|
|
||||||
type UserDisplayInfo struct {
|
|
||||||
Name string
|
|
||||||
Avatar string
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user