diff --git a/cmd/gateway.go b/cmd/gateway.go index bfed853e..2543f84e 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -124,7 +124,26 @@ func runGateway() { registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets, dbGatewayAddr, cfg.Gateway.Token, pgStores.MCP, cfg) } - setupMemoryEmbeddings(cfg, pgStores, providerRegistry) + // Warn if deprecated session scope settings are configured + if cfg.Sessions.Scope != "" && cfg.Sessions.Scope != "per-sender" { + slog.Warn("sessions.scope config is deprecated and ignored — fixed to per-sender", "configured", cfg.Sessions.Scope) + } + if cfg.Sessions.DmScope != "" && cfg.Sessions.DmScope != "per-channel-peer" { + slog.Warn("sessions.dm_scope config is deprecated and ignored — fixed to per-channel-peer", "configured", cfg.Sessions.DmScope) + } + + seedSystemConfigs(pgStores.SystemConfigs, pgStores.Tenants, cfg) + // Read back system_configs from DB and overlay onto in-memory config. + // This ensures runtime components read DB values via cfg.* without needing direct DB access. + if pgStores.SystemConfigs != nil { + if sysConfigs, err := pgStores.SystemConfigs.List( + store.WithTenantID(context.Background(), store.MasterTenantID), + ); err == nil && len(sysConfigs) > 0 { + cfg.ApplySystemConfigs(sysConfigs) + slog.Info("system_configs applied to in-memory config", "keys", len(sysConfigs)) + } + } + setupMemoryEmbeddings(pgStores, providerRegistry) loadBootstrapFiles(pgStores, workspace, agentCfg) @@ -375,6 +394,26 @@ func runGateway() { server.SetActivityHandler(httpapi.NewActivityHandler(pgStores.Activity)) } + // System configs API + if pgStores.SystemConfigs != nil { + server.SetSystemConfigsHandler(httpapi.NewSystemConfigsHandler(pgStores.SystemConfigs, msgBus)) + + // Refresh in-memory config when system_configs change via HTTP API + msgBus.Subscribe(bus.TopicSystemConfigChanged, func(evt bus.Event) { + // Use tenant context from the request that triggered the change + ctx := context.Background() + if reqCtx, ok := evt.Payload.(context.Context); ok { + ctx = reqCtx + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + if sysConfigs, err := pgStores.SystemConfigs.List(ctx); err == nil && len(sysConfigs) > 0 { + cfg.ApplySystemConfigs(sysConfigs) + slog.Debug("system_configs refreshed to in-memory config", "keys", len(sysConfigs)) + } + }) + } + // Usage analytics API if pgStores.Snapshots != nil { server.SetUsageHandler(httpapi.NewUsageHandler(pgStores.Snapshots, pgStores.DB)) @@ -435,7 +474,7 @@ func runGateway() { // Register all RPC methods server.SetLogTee(logTee) - pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions) + pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants) // Wire post-turn processor for team task dispatch (WS chat.send + HTTP API paths). if postTurn != nil { diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index 37dce7d7..2feec5be 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -3,102 +3,156 @@ package cmd import ( "context" "log/slog" - "strings" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/memory" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sandbox" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/tts" ) -// resolveEmbeddingProvider auto-selects an embedding provider based on config and available API keys. +// resolveEmbeddingProvider selects an embedding provider from DB only. // Resolution order: -// 1. Explicit provider name from memCfg → hardcoded match → registry lookup (DB providers) -// 2. Auto-detect: openai → openrouter → gemini (config-file keys) +// 1. system_configs "embedding.provider" + "embedding.model" → find provider by name in DB +// 2. Auto-detect: first DB provider with settings.embedding.enabled = true // -// The optional providerReg allows resolving DB-stored provider names (e.g. "openai-embedding") -// that don't match the hardcoded provider names. -func resolveEmbeddingProvider(cfg *config.Config, memCfg *config.MemoryConfig, providerReg *providers.Registry) memory.EmbeddingProvider { - // Explicit provider in config - if memCfg != nil && memCfg.EmbeddingProvider != "" { - if p := createEmbeddingProvider(memCfg.EmbeddingProvider, cfg, memCfg, providerReg); p != nil { - return p +// Config file (agents.defaults.memory) is NOT used — all embedding config lives in DB. +func resolveEmbeddingProvider( + providerStore store.ProviderStore, + providerReg *providers.Registry, + sysConfigs store.SystemConfigStore, +) memory.EmbeddingProvider { + crossCtx := store.WithCrossTenant(context.Background()) // for provider store (list all) + masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID) // for system_configs (tenant-scoped) + + // 1. System config: embedding.provider (set via UI / API) + if sysConfigs != nil { + if name, err := sysConfigs.Get(masterCtx, "embedding.provider"); err == nil && name != "" { + sysModel := "" + if m, mErr := sysConfigs.Get(masterCtx, "embedding.model"); mErr == nil { + sysModel = m + } + var mcfg *config.MemoryConfig + if sysModel != "" { + mcfg = &config.MemoryConfig{EmbeddingModel: sysModel} + } + p := resolveEmbeddingFromDB(crossCtx, providerStore, name, mcfg, providerReg) + if p != nil { + slog.Info("embedding provider from system_configs", "name", name, "model", p.Model()) + return p + } + slog.Warn("system_configs embedding.provider not found in DB", "name", name) } - // Explicit name set but no match — don't auto-detect, log and return nil - slog.Warn("embedding provider not found", "name", memCfg.EmbeddingProvider) + } + + // 2. Auto-detect: scan DB providers for first with settings.embedding.enabled + allProviders, err := providerStore.ListProviders(crossCtx) + if err != nil { + slog.Warn("failed to list providers for embedding auto-detect", "error", err) return nil } - // Auto-select: openai → openrouter → gemini - for _, name := range []string{"openai", "openrouter", "gemini"} { - if p := createEmbeddingProvider(name, cfg, memCfg, nil); p != nil { - return p + for _, dbp := range allProviders { + if !dbp.Enabled || store.NoEmbeddingTypes[dbp.ProviderType] { + continue + } + es := store.ParseEmbeddingSettings(dbp.Settings) + if es == nil || !es.Enabled { + continue + } + + ep := buildEmbeddingProvider(&dbp, es, nil, providerReg) + if ep != nil { + slog.Info("embedding provider auto-detected", "name", dbp.Name, "model", ep.Model()) + return ep } } + + slog.Warn("no embedding provider configured — enable embedding in a provider's settings") return nil } -func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.MemoryConfig, providerReg *providers.Registry) memory.EmbeddingProvider { +// resolveEmbeddingFromDB finds a specific provider by name and builds an embedding provider from it. +func resolveEmbeddingFromDB( + ctx context.Context, + providerStore store.ProviderStore, + name string, + memCfg *config.MemoryConfig, + providerReg *providers.Registry, +) memory.EmbeddingProvider { + dbp, err := providerStore.GetProviderByName(ctx, name) + if err != nil { + return nil + } + if !dbp.Enabled { + slog.Warn("embedding provider disabled", "name", name) + return nil + } + if store.NoEmbeddingTypes[dbp.ProviderType] { + slog.Warn("embedding provider type does not support embeddings", "name", name, "type", dbp.ProviderType) + return nil + } + es := store.ParseEmbeddingSettings(dbp.Settings) + return buildEmbeddingProvider(dbp, es, memCfg, providerReg) +} + +// buildEmbeddingProvider creates a memory.EmbeddingProvider from a DB provider record. +func buildEmbeddingProvider( + dbp *store.LLMProviderData, + es *store.EmbeddingSettings, + memCfg *config.MemoryConfig, + providerReg *providers.Registry, +) memory.EmbeddingProvider { + // Resolve model: embedding settings → memCfg override → default model := "text-embedding-3-small" - apiBase := "" - if memCfg != nil { - if memCfg.EmbeddingModel != "" { - model = memCfg.EmbeddingModel - } - if memCfg.EmbeddingAPIBase != "" { - apiBase = memCfg.EmbeddingAPIBase - } + if es != nil && es.Model != "" { + model = es.Model + } + if memCfg != nil && memCfg.EmbeddingModel != "" { + model = memCfg.EmbeddingModel } - switch name { - case "openai": - if cfg.Providers.OpenAI.APIKey == "" { - return nil - } - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - return memory.NewOpenAIEmbeddingProvider("openai", cfg.Providers.OpenAI.APIKey, apiBase, model) - case "openrouter": - if cfg.Providers.OpenRouter.APIKey == "" { - return nil - } - // OpenRouter requires provider prefix: "openai/text-embedding-3-small" - orModel := model - if !strings.Contains(orModel, "/") { - orModel = "openai/" + orModel - } - return memory.NewOpenAIEmbeddingProvider("openrouter", cfg.Providers.OpenRouter.APIKey, "https://openrouter.ai/api/v1", orModel) - case "gemini": - if cfg.Providers.Gemini.APIKey == "" { - return nil - } - geminiModel := "gemini-embedding-001" - if memCfg != nil && memCfg.EmbeddingModel != "" { - geminiModel = memCfg.EmbeddingModel - } - return memory.NewOpenAIEmbeddingProvider("gemini", cfg.Providers.Gemini.APIKey, "https://generativelanguage.googleapis.com/v1beta/openai", geminiModel). - WithDimensions(1536) + // Resolve API base: embedding settings → provider api_base → registry + apiBase := dbp.APIBase + if es != nil && es.APIBase != "" { + apiBase = es.APIBase + } + if memCfg != nil && memCfg.EmbeddingAPIBase != "" { + apiBase = memCfg.EmbeddingAPIBase } - // Fallback: resolve from provider registry (DB-stored providers like "openai-embedding"). - // Any OpenAI-compatible provider in the registry can serve embeddings. + // Gemini requires dimension truncation to match pgvector(1536) index. + needsDimTruncate := dbp.ProviderType == store.ProviderGeminiNative + + // Try registry first for the actual API key / base (handles runtime-registered providers) if providerReg != nil { - if regProv, err := providerReg.Get(context.Background(), name); err == nil { + if regProv, regErr := providerReg.Get(context.Background(), dbp.Name); regErr == nil { if op, ok := regProv.(*providers.OpenAIProvider); ok { - embBase := op.APIBase() - if apiBase != "" { - embBase = apiBase // memCfg override takes precedence + if apiBase == "" { + apiBase = op.APIBase() } - slog.Info("embedding provider resolved from registry", "name", name, "base", embBase, "model", model) - return memory.NewOpenAIEmbeddingProvider(name, op.APIKey(), embBase, model) + ep := memory.NewOpenAIEmbeddingProvider(dbp.Name, op.APIKey(), apiBase, model) + if needsDimTruncate { + ep.WithDimensions(1536) + } + return ep } - slog.Warn("embedding provider in registry is not OpenAI-compatible", "name", name) + slog.Debug("embedding provider in registry is not OpenAI-compatible, using DB record", "name", dbp.Name) } } + + // Fallback: build directly from DB record + if dbp.APIKey != "" { + ep := memory.NewOpenAIEmbeddingProvider(dbp.Name, dbp.APIKey, apiBase, model) + if needsDimTruncate { + ep.WithDimensions(1536) + } + return ep + } + return nil } diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 7ebd65f7..d51dbdaf 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -68,7 +68,7 @@ func handleSubagentAnnounce( sessionKey := msg.Metadata["origin_session_key"] if sessionKey == "" { // Fallback: rebuild session key from origin metadata (works for Telegram, Discord, etc.) - sessionKey = sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey) + sessionKey = sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID) sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, parentAgent, origChannel, msg.ChatID, origPeerKind) } @@ -528,7 +528,7 @@ func handleTeammateMessage( leadSessionKey := inMeta["origin_session_key"] if leadSessionKey == "" { // Fallback: rebuild session key from origin metadata (works for Telegram, Discord, etc.) - leadSessionKey = sessions.BuildScopedSessionKey(leadAgent, origCh, sessions.PeerKind(origPeerKind), origChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey) + leadSessionKey = sessions.BuildScopedSessionKey(leadAgent, origCh, sessions.PeerKind(origPeerKind), origChatID) leadSessionKey = overrideSessionKeyFromLocalKey(leadSessionKey, origLocalKey, leadAgent, origCh, origChatID, origPeerKind) } @@ -600,7 +600,7 @@ func handleResetCommand( if peerKind == "" { peerKind = string(sessions.PeerDirect) } - sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey) + sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID) if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) { var topicID int fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID) @@ -639,7 +639,7 @@ func handleStopCommand( if peerKind == "" { peerKind = string(sessions.PeerDirect) } - sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey) + sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID) if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) { var topicID int fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID) diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index 2f1c0422..ea95ae05 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -63,7 +63,7 @@ func processNormalMessage( if peerKind == "" { peerKind = string(sessions.PeerDirect) // default to DM } - sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey) + sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID) // Forum topic: override session key to isolate per-topic history. // TS ref: buildTelegramGroupPeerId() in src/telegram/bot/helpers.ts diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 2e04c859..1c285aa5 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -57,6 +57,9 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if stores != nil && stores.Providers != nil { providersH = httpapi.NewProvidersHandler(stores.Providers, stores.ConfigSecrets, providerReg, gatewayAddr) providersH.SetMessageBus(msgBus) + if stores.SystemConfigs != nil { + providersH.SetSystemConfigStore(stores.SystemConfigs) + } if stores.MCP != nil { providersH.SetMCPServerLookup(buildMCPServerLookup(stores.MCP)) } diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index d4544961..4a74cf46 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "log/slog" "github.com/nextlevelbuilder/goclaw/internal/agent" @@ -12,7 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tools" ) -func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) { +func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) { router := server.Router() // Phase 1: Core methods @@ -20,7 +21,19 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore chatMethods.Register(router) methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router) methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router) - methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus).Register(router) + configMethods := methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus) + if sysConfigStore != nil { + configMethods.SetSystemConfigSync(func(ctx context.Context, c *config.Config) { + // Only sync config for the current tenant (from request context) + seedConfigForContext(ctx, sysConfigStore, c, false) // onlyMissing=false → upsert + // Trigger readback via bus event with fresh context (request ctx may be canceled) + if msgBus != nil { + freshCtx := store.WithTenantID(context.Background(), store.TenantIDFromContext(ctx)) + msgBus.Broadcast(bus.Event{Name: bus.TopicSystemConfigChanged, Payload: freshCtx}) + } + }) + } + configMethods.Register(router) // Phase 2: Skills (uses SkillStore interface — PG or File) methods.NewSkillsMethods(skillStore).Register(router) diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index cb418ac6..b0dc71c7 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -342,25 +342,13 @@ func setupStoresAndTracing( } // setupMemoryEmbeddings wires embedding provider to PGMemoryStore and triggers backfill. -// Per-agent DB config takes priority over config file defaults. +// Resolves embedding provider from DB providers with settings.embedding.enabled. func setupMemoryEmbeddings( - cfg *config.Config, pgStores *store.Stores, providerRegistry *providers.Registry, ) { - // Wire embedding provider to PGMemoryStore so IndexDocument generates vectors. - // Per-agent DB config takes priority over config file defaults. if pgStores.Memory != nil { - memCfg := cfg.Agents.Defaults.Memory - if pgStores.Agents != nil { - if defaultAgent, agErr := pgStores.Agents.GetByKey(store.WithCrossTenant(context.Background()), "default"); agErr == nil { - if agentMemCfg := defaultAgent.ParseMemoryConfig(); agentMemCfg != nil { - memCfg = agentMemCfg - slog.Debug("using per-agent memory config from DB", "agent", defaultAgent.AgentKey) - } - } - } - if embProvider := resolveEmbeddingProvider(cfg, memCfg, providerRegistry); embProvider != nil { + if embProvider := resolveEmbeddingProvider(pgStores.Providers, providerRegistry, pgStores.SystemConfigs); embProvider != nil { pgStores.Memory.SetEmbeddingProvider(embProvider) slog.Info("memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model()) @@ -391,12 +379,30 @@ func setupMemoryEmbeddings( } }() } + + // Wire embedding provider into KG store for entity semantic search. + if pgKG, ok := pgStores.KnowledgeGraph.(*pg.PGKnowledgeGraphStore); ok { + pgKG.SetEmbeddingProvider(embProvider) + go func() { + if count, err := pgKG.BackfillKGEmbeddings(context.Background()); err != nil { + slog.Warn("KG embeddings backfill failed", "error", err) + } else if count > 0 { + slog.Info("KG embeddings backfill complete", "entities_updated", count) + } + }() + } } else { slog.Warn("memory embeddings disabled (no API key), chunks stored without vectors") } } } +// seedSystemConfigs ensures system_configs has all expected keys for all tenants. +// Inserts missing keys from config.json without overwriting existing values. +func seedSystemConfigs(sc store.SystemConfigStore, ts store.TenantStore, cfg *config.Config) { + syncSystemConfigs(sc, ts, cfg, true) // onlyMissing=true +} + // loadBootstrapFiles loads bootstrap files for the default agent's system prompt from DB. // Seeds if empty; falls back to filesystem as last resort. func loadBootstrapFiles( @@ -547,8 +553,7 @@ func setupSkillsSystem( skillSearchTool.SetSkillAccessStore(sas) } if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok { - memCfg := cfg.Agents.Defaults.Memory - if embProvider := resolveEmbeddingProvider(cfg, memCfg, providerRegistry); embProvider != nil { + if embProvider := resolveEmbeddingProvider(pgStores.Providers, providerRegistry, pgStores.SystemConfigs); embProvider != nil { pgSkills.SetEmbeddingProvider(embProvider) skillSearchTool.SetEmbeddingSearcher(pgSkills, embProvider) slog.Info("skill embeddings enabled", "provider", embProvider.Name()) diff --git a/cmd/gateway_system_config_sync.go b/cmd/gateway_system_config_sync.go new file mode 100644 index 00000000..01a8ffad --- /dev/null +++ b/cmd/gateway_system_config_sync.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "context" + "fmt" + "log/slog" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// syncSystemConfigs upserts non-secret config values into the system_configs table. +// Seeds per-tenant entries for all known tenants using real tenant IDs. +// When onlyMissing is true, existing keys are preserved (used at startup). +// When onlyMissing is false, all keys are upserted (used after config.apply/patch). +func syncSystemConfigs(sc store.SystemConfigStore, ts store.TenantStore, cfg *config.Config, onlyMissing bool) { + if sc == nil { + return + } + + crossCtx := store.WithCrossTenant(context.Background()) + + // Enumerate tenants and seed each one + if ts != nil { + tenants, err := ts.ListTenants(crossCtx) + if err != nil { + slog.Warn("failed to list tenants for system config seed", "error", err) + // Fall back to master tenant only + masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID) + seedConfigForContext(masterCtx, sc, cfg, onlyMissing) + return + } + + if len(tenants) == 0 { + // No tenants yet (fresh install before onboard) → seed master tenant + masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID) + seedConfigForContext(masterCtx, sc, cfg, onlyMissing) + return + } + + for _, t := range tenants { + tenantCtx := store.WithTenantID(context.Background(), t.ID) + seedConfigForContext(tenantCtx, sc, cfg, onlyMissing) + } + } else { + // No tenant store → seed master tenant + masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID) + seedConfigForContext(masterCtx, sc, cfg, onlyMissing) + } +} + +// seedConfigForContext writes config keys for a specific tenant context. +func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *config.Config, onlyMissing bool) { + var existing map[string]string + if onlyMissing { + var err error + existing, err = sc.List(ctx) + if err != nil { + slog.Warn("failed to list system_configs for seed", "error", err) + return + } + } + + set := func(key, val string) { + if val == "" { + return + } + if onlyMissing { + if _, ok := existing[key]; ok { + return + } + } + if err := sc.Set(ctx, key, val); err != nil { + slog.Warn("failed to sync system config", "key", key, "error", err) + } + } + setInt := func(key string, val int) { + if val != 0 { + set(key, fmt.Sprintf("%d", val)) + } + } + setBool := func(key string, val *bool) { + if val != nil { + set(key, fmt.Sprintf("%t", *val)) + } + } + + // Embedding + if m := cfg.Agents.Defaults.Memory; m != nil { + set("embedding.provider", m.EmbeddingProvider) + set("embedding.model", m.EmbeddingModel) + } + + // Agent defaults + set("agent.default_provider", cfg.Agents.Defaults.Provider) + set("agent.default_model", cfg.Agents.Defaults.Model) + setInt("agent.context_window", cfg.Agents.Defaults.ContextWindow) + setInt("agent.max_tool_iterations", cfg.Agents.Defaults.MaxToolIterations) + + // Gateway behavior (host/port are infra — env/file only, not DB) + setInt("gateway.rate_limit_rpm", cfg.Gateway.RateLimitRPM) + setInt("gateway.max_message_chars", cfg.Gateway.MaxMessageChars) + set("gateway.injection_action", cfg.Gateway.InjectionAction) + setInt("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs) + setBool("gateway.block_reply", cfg.Gateway.BlockReply) + setBool("gateway.tool_status", cfg.Gateway.ToolStatus) + setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec) + + // Tools + set("tools.profile", cfg.Tools.Profile) + setInt("tools.rate_limit_per_hour", cfg.Tools.RateLimitPerHour) + setBool("tools.scrub_credentials", cfg.Tools.ScrubCredentials) + + // TTS + set("tts.provider", cfg.Tts.Provider) + set("tts.auto", cfg.Tts.Auto) + set("tts.mode", cfg.Tts.Mode) + setInt("tts.max_length", cfg.Tts.MaxLength) + + // Cron + setInt("cron.max_retries", cfg.Cron.MaxRetries) + set("cron.default_timezone", cfg.Cron.DefaultTimezone) + + // Pending message compaction + if pc := cfg.Channels.PendingCompaction; pc != nil { + setInt("compaction.threshold", pc.Threshold) + setInt("compaction.keep_recent", pc.KeepRecent) + setInt("compaction.max_tokens", pc.MaxTokens) + set("compaction.provider", pc.Provider) + set("compaction.model", pc.Model) + } +} diff --git a/internal/bus/types.go b/internal/bus/types.go index 095df11c..f73e556d 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -95,6 +95,7 @@ const ( TopicTeamTaskAudit = "team-task-audit" TopicChannelStreaming = "channel-streaming" TopicConfigChanged = "config:changed" + TopicSystemConfigChanged = "system_config:changed" TopicPairingRevoked = "pairing:revoked" TopicAgentStatusChanged = "agent:status_changed" ) diff --git a/internal/channels/history.go b/internal/channels/history.go index 0e503435..1ea5d96b 100644 --- a/internal/channels/history.go +++ b/internal/channels/history.go @@ -24,7 +24,7 @@ import ( const maxHistoryKeys = 1000 // DefaultGroupHistoryLimit is the default pending message limit per group. -const DefaultGroupHistoryLimit = 100 +const DefaultGroupHistoryLimit = 200 const ( flushInterval = 3 * time.Second // periodic flush interval diff --git a/internal/channels/history_compaction.go b/internal/channels/history_compaction.go index 04388635..6480a6ad 100644 --- a/internal/channels/history_compaction.go +++ b/internal/channels/history_compaction.go @@ -15,8 +15,8 @@ import ( // CompactionConfig configures LLM-based history compaction. type CompactionConfig struct { - Threshold int // trigger compaction when entries exceed this (default 100) - KeepRecent int // keep this many recent raw messages (default 15) + Threshold int // trigger compaction when entries exceed this (default 200) + KeepRecent int // keep this many recent raw messages (default 40) MaxTokens int // max output tokens for summarization (default 4096) Provider providers.Provider // LLM provider for summarization Model string // model to use for summarization @@ -57,7 +57,7 @@ func (ph *PendingHistory) MaybeCompact(historyKey string, currentCount int, cfg // Returns the number of entries remaining after compaction. func CompactGroup(ctx context.Context, s store.PendingMessageStore, channelName, historyKey string, provider providers.Provider, model string, keepRecent, maxTokens int) (int, error) { if keepRecent <= 0 { - keepRecent = 15 + keepRecent = 40 } if maxTokens <= 0 { maxTokens = 4096 diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 206f4812..c889c636 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -4,8 +4,8 @@ package config // When a group accumulates more than Threshold pending messages, older messages are // summarized by an LLM and replaced with a compact summary, keeping KeepRecent raw messages. type PendingCompactionConfig struct { - Threshold int `json:"threshold,omitempty"` // trigger compaction when entries exceed this (default 50) - KeepRecent int `json:"keep_recent,omitempty"` // keep this many recent raw messages after compaction (default 15) + Threshold int `json:"threshold,omitempty"` // trigger compaction when entries exceed this (default 200) + KeepRecent int `json:"keep_recent,omitempty"` // keep this many recent raw messages after compaction (default 40) MaxTokens int `json:"max_tokens,omitempty"` // max output tokens for LLM summarization (default 4096) Provider string `json:"provider,omitempty"` // LLM provider name (e.g. "openai"); empty = use agent's provider Model string `json:"model,omitempty"` // model for summarization; empty = use agent's model diff --git a/internal/config/config_system.go b/internal/config/config_system.go new file mode 100644 index 00000000..17048e04 --- /dev/null +++ b/internal/config/config_system.go @@ -0,0 +1,80 @@ +package config + +import ( + "strconv" +) + +// ApplySystemConfigs overlays system_configs DB values onto the in-memory config. +// Called after startup seed and after config.apply/patch to keep cfg.* in sync with DB. +// Follows the same pattern as ApplyDBSecrets — non-empty DB values override config.json values. +// Keys must match those in cmd/gateway_system_config_sync.go seedConfigForContext(). +func (c *Config) ApplySystemConfigs(configs map[string]string) { + str := func(key string, dst *string) { + if v, ok := configs[key]; ok && v != "" { + *dst = v + } + } + integer := func(key string, dst *int) { + if v, ok := configs[key]; ok && v != "" { + if n, err := strconv.Atoi(v); err == nil { + *dst = n + } + } + } + boolean := func(key string, dst **bool) { + if v, ok := configs[key]; ok && v != "" { + b := v == "true" || v == "1" + *dst = &b + } + } + + // Embedding + if c.Agents.Defaults.Memory == nil { + c.Agents.Defaults.Memory = &MemoryConfig{} + } + str("embedding.provider", &c.Agents.Defaults.Memory.EmbeddingProvider) + str("embedding.model", &c.Agents.Defaults.Memory.EmbeddingModel) + + // Agent defaults + str("agent.default_provider", &c.Agents.Defaults.Provider) + str("agent.default_model", &c.Agents.Defaults.Model) + integer("agent.context_window", &c.Agents.Defaults.ContextWindow) + integer("agent.max_tool_iterations", &c.Agents.Defaults.MaxToolIterations) + + // Gateway behavior + integer("gateway.rate_limit_rpm", &c.Gateway.RateLimitRPM) + integer("gateway.max_message_chars", &c.Gateway.MaxMessageChars) + str("gateway.injection_action", &c.Gateway.InjectionAction) + integer("gateway.inbound_debounce_ms", &c.Gateway.InboundDebounceMs) + boolean("gateway.block_reply", &c.Gateway.BlockReply) + boolean("gateway.tool_status", &c.Gateway.ToolStatus) + integer("gateway.task_recovery_interval_sec", &c.Gateway.TaskRecoveryIntervalSec) + + // Tools + str("tools.profile", &c.Tools.Profile) + integer("tools.rate_limit_per_hour", &c.Tools.RateLimitPerHour) + boolean("tools.scrub_credentials", &c.Tools.ScrubCredentials) + + // TTS + str("tts.provider", &c.Tts.Provider) + str("tts.auto", &c.Tts.Auto) + str("tts.mode", &c.Tts.Mode) + integer("tts.max_length", &c.Tts.MaxLength) + + // Cron + integer("cron.max_retries", &c.Cron.MaxRetries) + str("cron.default_timezone", &c.Cron.DefaultTimezone) + + // Pending message compaction + if _, ok := configs["compaction.threshold"]; ok { + if c.Channels.PendingCompaction == nil { + c.Channels.PendingCompaction = &PendingCompactionConfig{} + } + pc := c.Channels.PendingCompaction + integer("compaction.threshold", &pc.Threshold) + integer("compaction.keep_recent", &pc.KeepRecent) + integer("compaction.max_tokens", &pc.MaxTokens) + str("compaction.provider", &pc.Provider) + str("compaction.model", &pc.Model) + } +} diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index 1a2dbf02..a9c3674b 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -21,13 +21,20 @@ type ConfigMethods struct { cfg *config.Config cfgPath string secretsStore store.ConfigSecretsStore - eventBus bus.EventPublisher // nil-safe; broadcasts config change events + syncFn func(ctx context.Context, cfg *config.Config) // nil-safe; syncs non-secret settings to system_configs + eventBus bus.EventPublisher // nil-safe; broadcasts config change events } func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods { return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, secretsStore: secretsStore, eventBus: eventBus} } +// SetSystemConfigSync sets a callback to sync config to system_configs after save. +// The callback receives the final resolved config (with secrets + env applied). +func (m *ConfigMethods) SetSystemConfigSync(fn func(ctx context.Context, cfg *config.Config)) { + m.syncFn = fn +} + func (m *ConfigMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodConfigGet, m.requireCrossTenant(m.handleGet)) router.Register(protocol.MethodConfigApply, m.requireCrossTenant(m.handleApply)) @@ -106,6 +113,7 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client, } } m.cfg.ApplyEnvOverrides() + m.syncToSystemConfigs(ctx) m.broadcastChanged() emitAudit(m.eventBus, client, "config.applied", "config", "gateway") @@ -179,6 +187,7 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client, } } m.cfg.ApplyEnvOverrides() + m.syncToSystemConfigs(ctx) m.broadcastChanged() emitAudit(m.eventBus, client, "config.patched", "config", "gateway") @@ -191,6 +200,13 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client, })) } +// syncToSystemConfigs syncs the resolved config to system_configs table for the given tenant. +func (m *ConfigMethods) syncToSystemConfigs(ctx context.Context) { + if m.syncFn != nil { + m.syncFn(ctx, m.cfg) + } +} + // broadcastChanged notifies subscribers that config has been updated. func (m *ConfigMethods) broadcastChanged() { if m.eventBus != nil { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 5e0c57b4..be1d7a89 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -60,6 +60,7 @@ type Server struct { mediaUploadHandler *httpapi.MediaUploadHandler // media upload endpoint mediaServeHandler *httpapi.MediaServeHandler // media serve endpoint activityHandler *httpapi.ActivityHandler // activity audit log API + systemConfigsHandler *httpapi.SystemConfigsHandler // system configs API usageHandler *httpapi.UsageHandler // usage analytics API apiKeysHandler *httpapi.APIKeysHandler // API key management apiKeyStore store.APIKeyStore // for API key auth lookup @@ -291,6 +292,9 @@ func (s *Server) BuildMux() *http.ServeMux { if s.activityHandler != nil { s.activityHandler.RegisterRoutes(mux) } + if s.systemConfigsHandler != nil { + s.systemConfigsHandler.RegisterRoutes(mux) + } if s.usageHandler != nil { s.usageHandler.RegisterRoutes(mux) @@ -566,6 +570,9 @@ func (s *Server) SetKnowledgeGraphHandler(h *httpapi.KnowledgeGraphHandler) { s. // SetActivityHandler sets the activity audit log handler. func (s *Server) SetActivityHandler(h *httpapi.ActivityHandler) { s.activityHandler = h } +// SetSystemConfigsHandler sets the system configs handler. +func (s *Server) SetSystemConfigsHandler(h *httpapi.SystemConfigsHandler) { s.systemConfigsHandler = h } + // SetUsageHandler sets the usage analytics handler. func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.usageHandler = h } diff --git a/internal/http/provider_verify_embedding.go b/internal/http/provider_verify_embedding.go new file mode 100644 index 00000000..c09bebc0 --- /dev/null +++ b/internal/http/provider_verify_embedding.go @@ -0,0 +1,134 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/memory" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// handleVerifyEmbedding tests a provider's embedding capability with a minimal API call. +// +// POST /v1/providers/{id}/verify-embedding +// Body: {"model": "text-embedding-3-small"} (optional, falls back to settings.embedding.model) +// Response: {"valid": true, "dimensions": 1536} or {"valid": false, "error": "..."} +func (h *ProvidersHandler) handleVerifyEmbedding(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")}) + return + } + + var req struct { + Model string `json:"model"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil && err.Error() != "EOF" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + p, err := h.store.GetProvider(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())}) + return + } + + // Provider types that cannot serve embeddings + if store.NoEmbeddingTypes[p.ProviderType] { + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "provider type does not support embeddings"}) + return + } + + // Resolve model: request body → settings.embedding.model → default + model := req.Model + if model == "" { + if es := store.ParseEmbeddingSettings(p.Settings); es != nil && es.Model != "" { + model = es.Model + } + } + if model == "" { + model = "text-embedding-3-small" + } + + // Resolve API base: settings.embedding.api_base → provider api_base → resolved base + apiBase := h.resolveAPIBase(p) + if es := store.ParseEmbeddingSettings(p.Settings); es != nil && es.APIBase != "" { + apiBase = es.APIBase + } + + ep := memory.NewOpenAIEmbeddingProvider(p.Name, p.APIKey, apiBase, model) + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + vectors, embErr := ep.Embed(ctx, []string{"test"}) + if embErr != nil { + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": friendlyVerifyError(embErr)}) + return + } + + dims := 0 + if len(vectors) > 0 && len(vectors[0]) > 0 { + dims = len(vectors[0]) + } + writeJSON(w, http.StatusOK, map[string]any{"valid": true, "dimensions": dims}) +} + +// handleEmbeddingStatus returns the current embedding system configuration. +// +// GET /v1/embedding/status +// Response: {"configured": true, "provider": "openai", "model": "text-embedding-3-small"} +// or: {"configured": false} +func (h *ProvidersHandler) handleEmbeddingStatus(w http.ResponseWriter, r *http.Request) { + // Primary: check system_configs for embedding.provider/model + if h.sysConfigStore != nil { + provName, _ := h.sysConfigStore.Get(r.Context(), "embedding.provider") + model, _ := h.sysConfigStore.Get(r.Context(), "embedding.model") + if provName != "" { + if model == "" { + model = "text-embedding-3-small" + } + writeJSON(w, http.StatusOK, map[string]any{ + "configured": true, + "provider": provName, + "provider_name": provName, + "model": model, + }) + return + } + } + + // Fallback: check provider-level embedding settings + providerList, err := h.store.ListProviders(r.Context()) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"configured": false}) + return + } + for _, p := range providerList { + if !p.Enabled || store.NoEmbeddingTypes[p.ProviderType] { + continue + } + es := store.ParseEmbeddingSettings(p.Settings) + if es != nil && es.Enabled { + model := es.Model + if model == "" { + model = "text-embedding-3-small" + } + writeJSON(w, http.StatusOK, map[string]any{ + "configured": true, + "provider": p.DisplayName, + "provider_name": p.Name, + "model": model, + }) + return + } + } + + writeJSON(w, http.StatusOK, map[string]any{"configured": false}) +} diff --git a/internal/http/providers.go b/internal/http/providers.go index 16b74235..03e6dd19 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -26,6 +26,7 @@ type ProvidersHandler struct { apiBaseFallback func(providerType string) string // optional: config/env fallback for api_base cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates msgBus *bus.MessageBus + sysConfigStore store.SystemConfigStore } // NewProvidersHandler creates a handler for provider management endpoints. @@ -39,6 +40,11 @@ func (h *ProvidersHandler) SetMessageBus(msgBus *bus.MessageBus) { h.msgBus = msgBus } +// SetSystemConfigStore sets the system config store for embedding status checks. +func (h *ProvidersHandler) SetSystemConfigStore(s store.SystemConfigStore) { + h.sysConfigStore = s +} + // SetMCPServerLookup sets the per-agent MCP server lookup for Claude CLI providers. // Must be called before serving requests (not thread-safe). func (h *ProvidersHandler) SetMCPServerLookup(lookup providers.MCPServerLookup) { @@ -88,6 +94,10 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) { // Provider + model verification (pre-flight check) mux.HandleFunc("POST /v1/providers/{id}/verify", h.auth(h.handleVerifyProvider)) + mux.HandleFunc("POST /v1/providers/{id}/verify-embedding", h.auth(h.handleVerifyEmbedding)) + + // Embedding system status + mux.HandleFunc("GET /v1/embedding/status", h.auth(h.handleEmbeddingStatus)) // Claude CLI auth status (global — not per-provider) mux.HandleFunc("GET /v1/providers/claude-cli/auth-status", h.auth(h.handleClaudeCLIAuthStatus)) diff --git a/internal/http/system_configs.go b/internal/http/system_configs.go new file mode 100644 index 00000000..da4a0b9d --- /dev/null +++ b/internal/http/system_configs.go @@ -0,0 +1,106 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "regexp" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SystemConfigsHandler handles system config CRUD endpoints. +type SystemConfigsHandler struct { + store store.SystemConfigStore + msgBus *bus.MessageBus +} + +func NewSystemConfigsHandler(s store.SystemConfigStore, msgBus *bus.MessageBus) *SystemConfigsHandler { + return &SystemConfigsHandler{store: s, msgBus: msgBus} +} + +// validKeyRe allows alphanumeric, dots, underscores, hyphens (1-100 chars). +var validKeyRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,100}$`) + +// RegisterRoutes registers system config endpoints on the given mux. +func (h *SystemConfigsHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/system-configs", requireAuth("", h.handleList)) + mux.HandleFunc("GET /v1/system-configs/{key}", requireAuth("", h.handleGet)) + mux.HandleFunc("PUT /v1/system-configs/{key}", requireAuth("admin", h.handleSet)) + mux.HandleFunc("DELETE /v1/system-configs/{key}", requireAuth("admin", h.handleDelete)) +} + +func (h *SystemConfigsHandler) handleList(w http.ResponseWriter, r *http.Request) { + configs, err := h.store.List(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, configs) +} + +func (h *SystemConfigsHandler) handleGet(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + key := r.PathValue("key") + val, err := h.store.Get(r.Context(), key) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "config", key)}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"key": key, "value": val}) +} + +func (h *SystemConfigsHandler) handleSet(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + key := r.PathValue("key") + + if !validKeyRe.MatchString(key) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid key format (alphanumeric, dots, hyphens, 1-100 chars)"}) + return + } + + var req struct { + Value string `json:"value"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + if err := h.store.Set(r.Context(), key, req.Value); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + // Broadcast change with tenant context so in-memory config refreshes for the right tenant. + // Use fresh context with tenant ID — request context may be canceled before subscriber runs. + if h.msgBus != nil { + freshCtx := store.WithTenantID(context.Background(), store.TenantIDFromContext(r.Context())) + h.msgBus.Broadcast(bus.Event{ + Name: bus.TopicSystemConfigChanged, + Payload: freshCtx, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"key": key, "value": req.Value}) +} + +func (h *SystemConfigsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + if err := h.store.Delete(r.Context(), key); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + if h.msgBus != nil { + freshCtx := store.WithTenantID(context.Background(), store.TenantIDFromContext(r.Context())) + h.msgBus.Broadcast(bus.Event{ + Name: bus.TopicSystemConfigChanged, + Payload: freshCtx, + }) + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/sessions/key.go b/internal/sessions/key.go index 0b19952f..3c5d3b1c 100644 --- a/internal/sessions/key.go +++ b/internal/sessions/key.go @@ -107,41 +107,11 @@ func BuildAgentMainSessionKey(agentID, mainKey string) string { return fmt.Sprintf("agent:%s:%s", agentID, mainKey) } -// BuildScopedSessionKey builds session key based on scope config. -// Matching TS src/routing/session-key.ts buildAgentPeerSessionKey(). -// -// scope: -// - "global" → "global" -// - "per-sender" → depends on dmScope (default) -// -// dmScope (for DMs only — groups always use full key): -// - "main" → agent:{agentId}:{mainKey} -// - "per-peer" → agent:{agentId}:direct:{peerId} -// - "per-channel-peer" → agent:{agentId}:{channel}:direct:{peerId} (default) -// - "per-account-channel-peer" → agent:{agentId}:{channel}:{accountId}:direct:{peerId} -func BuildScopedSessionKey(agentID, channel string, kind PeerKind, chatID, scope, dmScope, mainKey string) string { - // Global scope: one session for everything - if scope == "global" { - return "global" - } - - // Groups always use full key (matching TS) - if kind == PeerGroup { - return BuildSessionKey(agentID, channel, kind, chatID) - } - - // DM scope modes - switch dmScope { - case "main": - return BuildAgentMainSessionKey(agentID, mainKey) - case "per-peer": - return fmt.Sprintf("agent:%s:direct:%s", agentID, chatID) - case "per-account-channel-peer": - // accountId not yet wired — falls through to per-channel-peer behavior - return BuildSessionKey(agentID, channel, kind, chatID) - default: // "per-channel-peer" or empty - return BuildSessionKey(agentID, channel, kind, chatID) - } +// BuildScopedSessionKey builds a session key using fixed scoping: +// - Groups: per-sender (full key with channel + group ID) +// - DMs: per-channel-peer (channel + peer user ID) +func BuildScopedSessionKey(agentID, channel string, kind PeerKind, chatID string) string { + return BuildSessionKey(agentID, channel, kind, chatID) } // ParseSessionKey extracts the agentID and rest from a canonical session key. diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go index 560e75b3..6cab78b6 100644 --- a/internal/store/pg/agents_context.go +++ b/internal/store/pg/agents_context.go @@ -165,7 +165,7 @@ func (s *PGAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, // --- User Instances --- func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) { - tClause, tArgs, err := tenantClauseN(ctx, 2) + tClause, tArgs, err := tenantClauseNAlias(ctx, 2, "p") if err != nil { return nil, err } diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index 563e4c54..8b9b693f 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -49,5 +49,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Tenants: NewPGTenantStore(db), BuiltinToolTenantCfgs: NewPGBuiltinToolTenantConfigStore(db), SkillTenantCfgs: NewPGSkillTenantConfigStore(db), + SystemConfigs: NewPGSystemConfigStore(db), }, nil } diff --git a/internal/store/pg/helpers.go b/internal/store/pg/helpers.go index ff748d21..9a5c16e0 100644 --- a/internal/store/pg/helpers.go +++ b/internal/store/pg/helpers.go @@ -226,6 +226,26 @@ func tenantClauseN(ctx context.Context, paramN int) (clause string, args []any, return fmt.Sprintf(" AND tenant_id = $%d", paramN), []any{tid}, nil } +// tenantClauseNAlias is like tenantClauseN but qualifies tenant_id with a table alias +// to avoid ambiguity in JOIN queries (e.g. " AND d.tenant_id = $3"). +// SECURITY: alias is interpolated into SQL — callers MUST pass hardcoded string literals only. +func tenantClauseNAlias(ctx context.Context, paramN int, alias string) (clause string, args []any, err error) { + // Guard: only allow simple alphanumeric aliases (a-z, 0-9, underscore) to prevent SQL injection. + for _, c := range alias { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + return "", nil, fmt.Errorf("invalid table alias: %q", alias) + } + } + if store.IsCrossTenant(ctx) { + return "", nil, nil + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return "", nil, fmt.Errorf("tenant_id required") + } + return fmt.Sprintf(" AND %s.tenant_id = $%d", alias, paramN), []any{tid}, nil +} + // tenantIDForInsert returns the tenant UUID for INSERT operations. // Falls back to MasterTenantID when no tenant in context. func tenantIDForInsert(ctx context.Context) uuid.UUID { diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go index 2484da13..0ab6dcea 100644 --- a/internal/store/pg/mcp_servers_access.go +++ b/internal/store/pg/mcp_servers_access.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strings" "time" "github.com/google/uuid" @@ -169,7 +168,7 @@ func (s *PGMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUI // --- Resolution --- func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) { - tClause, tArgs, err := tenantClauseN(ctx, 3) + tClause, tArgs, err := tenantClauseNAlias(ctx, 3, "ms") if err != nil { return nil, err } @@ -181,8 +180,7 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = $1 AND mag.enabled = true LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = $2 WHERE ms.enabled = true - AND (mug.id IS NULL OR mug.enabled = true)`+ - strings.Replace(tClause, "tenant_id", "ms.tenant_id", 1), + AND (mug.id IS NULL OR mug.enabled = true)`+tClause, append([]any{agentID, userID}, tArgs...)...) if err != nil { return nil, err diff --git a/internal/store/pg/memory_admin.go b/internal/store/pg/memory_admin.go index 45764dc2..d0d8bf7f 100644 --- a/internal/store/pg/memory_admin.go +++ b/internal/store/pg/memory_admin.go @@ -95,7 +95,7 @@ func (s *PGMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID, var q string var args []any if userID == "" { - tc, tcArgs, err := tenantClauseN(ctx, 3) + tc, tcArgs, err := tenantClauseNAlias(ctx, 3, "d") if err != nil { return nil, err } @@ -108,7 +108,7 @@ func (s *PGMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID, GROUP BY d.id` args = append([]any{aid, path}, tcArgs...) } else { - tc, tcArgs, err := tenantClauseN(ctx, 4) + tc, tcArgs, err := tenantClauseNAlias(ctx, 4, "d") if err != nil { return nil, err } @@ -148,7 +148,7 @@ func (s *PGMemoryStore) ListChunks(ctx context.Context, agentID, userID, path st var q string var args []any if userID == "" { - tc, tcArgs, err := tenantClauseN(ctx, 3) + tc, tcArgs, err := tenantClauseNAlias(ctx, 3, "d") if err != nil { return nil, err } @@ -161,7 +161,7 @@ func (s *PGMemoryStore) ListChunks(ctx context.Context, agentID, userID, path st ORDER BY c.start_line` args = append([]any{aid, path}, tcArgs...) } else { - tc, tcArgs, err := tenantClauseN(ctx, 4) + tc, tcArgs, err := tenantClauseNAlias(ctx, 4, "d") if err != nil { return nil, err } diff --git a/internal/store/pg/system_configs.go b/internal/store/pg/system_configs.go new file mode 100644 index 00000000..40c1a719 --- /dev/null +++ b/internal/store/pg/system_configs.go @@ -0,0 +1,110 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGSystemConfigStore implements store.SystemConfigStore backed by Postgres. +// Strict tenant isolation — all operations require tenant_id in context (no fallback). +type PGSystemConfigStore struct { + db *sql.DB +} + +func NewPGSystemConfigStore(db *sql.DB) *PGSystemConfigStore { + return &PGSystemConfigStore{db: db} +} + +// resolveTenantID returns the tenant ID from context. +// Returns uuid.Nil if no tenant in context — callers must handle this explicitly. +func resolveTenantID(ctx context.Context) uuid.UUID { + return store.TenantIDFromContext(ctx) +} + +func (s *PGSystemConfigStore) Get(ctx context.Context, key string) (string, error) { + tid := resolveTenantID(ctx) + if tid == uuid.Nil { + return "", fmt.Errorf("system config get: tenant_id required") + } + + var val string + err := s.db.QueryRowContext(ctx, + "SELECT value FROM system_configs WHERE key = $1 AND tenant_id = $2", + key, tid, + ).Scan(&val) + if err == nil { + return val, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("system config get: %w", err) + } + + return "", fmt.Errorf("system config not found: %s", key) +} + +func (s *PGSystemConfigStore) Set(ctx context.Context, key, value string) error { + tid := resolveTenantID(ctx) + if tid == uuid.Nil { + slog.Warn("system_config.set: no tenant in context", "key", key) + return fmt.Errorf("system config set: tenant_id required") + } + return s.upsert(ctx, key, value, tid) +} + +func (s *PGSystemConfigStore) upsert(ctx context.Context, key, value string, tenantID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO system_configs (key, value, tenant_id, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (key, tenant_id) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`, + key, value, tenantID, time.Now(), + ) + return err +} + +func (s *PGSystemConfigStore) Delete(ctx context.Context, key string) error { + tid := resolveTenantID(ctx) + if tid == uuid.Nil { + return fmt.Errorf("system config delete: tenant_id required") + } + _, err := s.db.ExecContext(ctx, + "DELETE FROM system_configs WHERE key = $1 AND tenant_id = $2", + key, tid, + ) + return err +} + +func (s *PGSystemConfigStore) List(ctx context.Context) (map[string]string, error) { + tid := resolveTenantID(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("system config list: tenant_id required") + } + + rows, err := s.db.QueryContext(ctx, + "SELECT key, value FROM system_configs WHERE tenant_id = $1 ORDER BY key", + tid, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("system config scan: %w", err) + } + result[k] = v + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("system config list: %w", err) + } + return result, nil +} diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index eae7047c..c86c2283 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -72,6 +72,36 @@ type LLMProviderData struct { Settings json.RawMessage `json:"settings,omitempty"` } +// EmbeddingSettings holds embedding-specific configuration stored in provider settings JSONB. +type EmbeddingSettings struct { + Enabled bool `json:"enabled"` + Model string `json:"model,omitempty"` // e.g. "text-embedding-3-small" + APIBase string `json:"api_base,omitempty"` // override if embedding endpoint differs from chat +} + +// ParseEmbeddingSettings extracts embedding config from a provider's settings JSONB. +// Returns nil if not configured. +func ParseEmbeddingSettings(settings json.RawMessage) *EmbeddingSettings { + if len(settings) == 0 { + return nil + } + var s struct { + Embedding *EmbeddingSettings `json:"embedding"` + } + if json.Unmarshal(settings, &s) != nil || s.Embedding == nil { + return nil + } + return s.Embedding +} + +// NoEmbeddingTypes lists provider types that cannot serve embeddings. +var NoEmbeddingTypes = map[string]bool{ + ProviderACP: true, + ProviderClaudeCLI: true, + ProviderChatGPTOAuth: true, + ProviderSuno: true, +} + // ProviderStore manages LLM providers. type ProviderStore interface { CreateProvider(ctx context.Context, p *LLMProviderData) error diff --git a/internal/store/stores.go b/internal/store/stores.go index 93de55c7..d7e016e9 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -31,4 +31,5 @@ type Stores struct { Tenants TenantStore BuiltinToolTenantCfgs BuiltinToolTenantConfigStore SkillTenantCfgs SkillTenantConfigStore + SystemConfigs SystemConfigStore } diff --git a/internal/store/system_config_store.go b/internal/store/system_config_store.go new file mode 100644 index 00000000..06e3f966 --- /dev/null +++ b/internal/store/system_config_store.go @@ -0,0 +1,20 @@ +package store + +import "context" + +// SystemConfigStore manages per-tenant configuration settings. +// Non-secret, plain-text key-value pairs. Use ConfigSecretsStore for secrets. +// +// Resolution: tenant-specific → MasterTenantID fallback → not found. +// All entries are stored per tenant_id. No sentinel UUID. +type SystemConfigStore interface { + // Get returns the config value. Checks tenant-specific first, falls back to MasterTenantID. + Get(ctx context.Context, key string) (string, error) + // Set stores a config value for the current tenant (from context). + // Falls back to MasterTenantID if no tenant in context. + Set(ctx context.Context, key, value string) error + // Delete removes a config value for the current tenant. + Delete(ctx context.Context, key string) error + // List returns all configs visible to the current tenant (master merged with tenant overrides). + List(ctx context.Context) (map[string]string, error) +} diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 32a73865..16665209 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 28 +const RequiredSchemaVersion uint = 29 diff --git a/migrations/000029_system_configs.down.sql b/migrations/000029_system_configs.down.sql new file mode 100644 index 00000000..06a8c1da --- /dev/null +++ b/migrations/000029_system_configs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS system_configs; diff --git a/migrations/000029_system_configs.up.sql b/migrations/000029_system_configs.up.sql new file mode 100644 index 00000000..bbe4aa61 --- /dev/null +++ b/migrations/000029_system_configs.up.sql @@ -0,0 +1,12 @@ +-- system_configs: centralized key-value store for per-tenant system settings. +-- Each tenant has its own config entries. Falls back to master tenant at app layer. +-- Plain TEXT value (not encrypted). Use config_secrets for secrets. +CREATE TABLE IF NOT EXISTS system_configs ( + key VARCHAR(100) NOT NULL, + value TEXT NOT NULL, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (key, tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_system_configs_tenant ON system_configs(tenant_id); diff --git a/ui/web/src/components/layout/system-settings-modal.tsx b/ui/web/src/components/layout/system-settings-modal.tsx new file mode 100644 index 00000000..a0efa54a --- /dev/null +++ b/ui/web/src/components/layout/system-settings-modal.tsx @@ -0,0 +1,409 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { + Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, + Brain, Eye, MessageSquareText, Archive, Clock, Hash, CheckCircle2, XCircle, +} from "lucide-react"; +import { Link } from "react-router"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { FeatureSwitchGroup } from "@/components/shared/feature-switch-group"; +import type { FeatureSwitchItem } from "@/components/shared/feature-switch-group"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; +import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify"; +import { useProviders } from "@/pages/providers/hooks/use-providers"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; + +// Curated 1536-dimension embedding models per provider type. +// Verified 1536d embedding models per provider type. +// Only providers with confirmed 1536-dimension output are listed. +const EMBEDDING_MODELS: Record = { + // OpenAI — native 1536d + openai_compat: [ + { id: "text-embedding-3-small", name: "text-embedding-3-small (1536d)" }, + { id: "text-embedding-ada-002", name: "text-embedding-ada-002 (1536d)" }, + ], + // OpenRouter — proxied OpenAI models + openrouter: [ + { id: "openai/text-embedding-3-small", name: "openai/text-embedding-3-small (1536d)" }, + { id: "openai/text-embedding-ada-002", name: "openai/text-embedding-ada-002 (1536d)" }, + ], + // Mistral — mistral-embed native 1024d, codestral-embed native 1536d (MRL) + mistral: [ + { id: "codestral-embed", name: "codestral-embed (1536d)" }, + ], +}; +// Fallback for unlisted provider types — no curated models +const DEFAULT_EMBEDDING_MODELS: { id: string; name: string }[] = []; + +interface SystemSettingsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface InitState { + embProvider: string; + embModel: string; + toolStatus: boolean; + blockReply: boolean; + intentClassify: boolean; + compProvider: string; + compModel: string; + compThreshold: string; + compKeepRecent: string; + compMaxTokens: string; +} + +const DEFAULTS: InitState = { + embProvider: "", embModel: "", + toolStatus: true, blockReply: false, intentClassify: true, + compProvider: "", compModel: "", + compThreshold: "", compKeepRecent: "", compMaxTokens: "", +}; + +function parseBool(v: string | undefined, fallback: boolean): boolean { + if (v === undefined) return fallback; + return v !== "false" && v !== "0"; +} + +export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalProps) { + const { t } = useTranslation("system-settings"); + const http = useHttp(); + const { providers } = useProviders(); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [init, setInit] = useState(DEFAULTS); + + // Embedding + const [embProvider, setEmbProvider] = useState(""); + const [embModel, setEmbModel] = useState(""); + const { verifyEmbedding, embVerifying, embResult, resetEmb } = useProviderVerify(); + + // UX Behavior + const [toolStatus, setToolStatus] = useState(true); + const [blockReply, setBlockReply] = useState(false); + const [intentClassify, setIntentClassify] = useState(true); + + // Pending Compaction + const [compProvider, setCompProvider] = useState(""); + const [compModel, setCompModel] = useState(""); + const [compThreshold, setCompThreshold] = useState(""); + const [compKeepRecent, setCompKeepRecent] = useState(""); + const [compMaxTokens, setCompMaxTokens] = useState(""); + + const applyConfigs = useCallback((configs: Record) => { + const s: InitState = { + embProvider: configs["embedding.provider"] ?? "", + embModel: configs["embedding.model"] ?? "", + toolStatus: parseBool(configs["gateway.tool_status"], true), + blockReply: parseBool(configs["gateway.block_reply"], false), + intentClassify: parseBool(configs["gateway.intent_classify"], true), + compProvider: configs["compaction.provider"] ?? "", + compModel: configs["compaction.model"] ?? "", + compThreshold: configs["compaction.threshold"] ?? "", + compKeepRecent: configs["compaction.keep_recent"] ?? "", + compMaxTokens: configs["compaction.max_tokens"] ?? "", + }; + setInit(s); + setEmbProvider(s.embProvider); + setEmbModel(s.embModel); + setToolStatus(s.toolStatus); + setBlockReply(s.blockReply); + setIntentClassify(s.intentClassify); + setCompProvider(s.compProvider); + setCompModel(s.compModel); + setCompThreshold(s.compThreshold); + setCompKeepRecent(s.compKeepRecent); + setCompMaxTokens(s.compMaxTokens); + resetEmb(); + }, [resetEmb]); + + useEffect(() => { + if (!open) return; + setLoading(true); + http + .get>("/v1/system-configs") + .then(applyConfigs) + .catch((err) => toast.error(err instanceof Error ? err.message : t("loadFailed"))) + .finally(() => setLoading(false)); + }, [open, http, applyConfigs, t]); + + useEffect(() => { resetEmb(); }, [embProvider, embModel, resetEmb]); + + const embChanged = embProvider !== init.embProvider || embModel !== init.embModel; + const embVerified = embResult?.valid === true; + const saveDisabled = saving || (embChanged && !embVerified); + + const selectedEmbProviderData = providers.find((p) => p.name === embProvider); + const embExtraModels = selectedEmbProviderData + ? (EMBEDDING_MODELS[selectedEmbProviderData.provider_type] ?? DEFAULT_EMBEDDING_MODELS) + : DEFAULT_EMBEDDING_MODELS; + + const handleVerifyEmb = () => { + if (!selectedEmbProviderData) return; + verifyEmbedding(selectedEmbProviderData.id, embModel.trim() || undefined); + }; + + const handleSave = async () => { + setSaving(true); + try { + const updates: Record = {}; + if (embProvider !== init.embProvider) updates["embedding.provider"] = embProvider; + if (embModel !== init.embModel) updates["embedding.model"] = embModel; + if (toolStatus !== init.toolStatus) updates["gateway.tool_status"] = String(toolStatus); + if (blockReply !== init.blockReply) updates["gateway.block_reply"] = String(blockReply); + if (intentClassify !== init.intentClassify) updates["gateway.intent_classify"] = String(intentClassify); + if (compProvider !== init.compProvider) updates["compaction.provider"] = compProvider; + if (compModel !== init.compModel) updates["compaction.model"] = compModel; + if (compThreshold !== init.compThreshold) updates["compaction.threshold"] = compThreshold; + if (compKeepRecent !== init.compKeepRecent) updates["compaction.keep_recent"] = compKeepRecent; + if (compMaxTokens !== init.compMaxTokens) updates["compaction.max_tokens"] = compMaxTokens; + + for (const [key, value] of Object.entries(updates)) { + await http.put(`/v1/system-configs/${key}`, { value }); + } + toast.success(t("saved")); + onOpenChange(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : t("saveFailed")); + } finally { + setSaving(false); + } + }; + + // UX Behavior switch items — reuses FeatureSwitchGroup pattern from config page + const uxItems: FeatureSwitchItem[] = [ + { + icon: Eye, + iconClass: "text-blue-500", + label: t("ux.toolStatus"), + hint: t("ux.toolStatusHint"), + checked: toolStatus, + onCheckedChange: setToolStatus, + infoWhenOn: t("ux.toolStatusInfo"), + infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300", + }, + { + icon: MessageSquareText, + iconClass: "text-emerald-500", + label: t("ux.blockReply"), + hint: t("ux.blockReplyHint"), + checked: blockReply, + onCheckedChange: setBlockReply, + infoWhenOn: t("ux.blockReplyInfo"), + infoClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300", + }, + { + icon: Brain, + iconClass: "text-orange-500", + label: t("ux.intentClassify"), + hint: t("ux.intentClassifyHint"), + checked: intentClassify, + onCheckedChange: setIntentClassify, + infoWhenOn: t("ux.intentClassifyInfo"), + infoClass: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300", + }, + ]; + + return ( + + + + + + {t("title")} + + + + {loading ? ( +
+ +
+ ) : ( +
+ {/* ── Embedding ── */} + + + + + {t("embedding.title")} + + {t("embedding.description")} + + +
+ +
+

{t("embedding.importance")}

+

{t("embedding.supportedProviders")}

+
+
+ + { setEmbProvider(v); setEmbModel(""); }} + model={embModel} + onModelChange={setEmbModel} + allowEmpty + showVerify={false} + extraModels={embExtraModels} + modelFilter="embed" + providerLabel={t("embedding.provider")} + modelLabel={t("embedding.model")} + providerTip={t("embedding.providerTip")} + modelTip={t("embedding.modelTip")} + providerPlaceholder={t("embedding.providerPlaceholder")} + modelPlaceholder={t("embedding.modelPlaceholder")} + /> + +
+ + {embResult && ( + + {embResult.valid ? ( + <> + + {t("embedding.dimensions", { count: embResult.dimensions })} + + ) : ( + <> + + {embResult.error || t("embedding.verifyFailed")} + + )} + + )} +
+
+
+ + {/* ── UX Behavior — uses FeatureSwitchGroup (same as config page) ── */} + + + {/* ── Pending Compaction ── */} + + + {t("compaction.title")} + {t("compaction.description")} + + +
+ +
+ +
+
+ +
+ +

{t("compaction.thresholdHint")}

+
+
+ setCompThreshold(e.target.value)} + placeholder="200" min={1} + className="w-24 shrink-0 text-base md:text-sm" + /> +
+ +
+
+ +
+ +

{t("compaction.keepRecentHint")}

+
+
+ setCompKeepRecent(e.target.value)} + placeholder="40" min={1} + className="w-24 shrink-0 text-base md:text-sm" + /> +
+ +
+
+ +
+ +

{t("compaction.maxTokensHint")}

+
+
+ setCompMaxTokens(e.target.value)} + placeholder="4096" min={256} + className="w-24 shrink-0 text-base md:text-sm" + /> +
+ +
+ + {t("compaction.info")} +
+
+
+
+ )} + + {/* Footer */} +
+ {embChanged && !embVerified && ( +

+ + {t("embedding.verifyRequired")} +

+ )} +
+ onOpenChange(false)} + className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" + > + + {t("moreConfig")} + +
+ + +
+
+
+
+
+ ); +} diff --git a/ui/web/src/components/layout/topbar.tsx b/ui/web/src/components/layout/topbar.tsx index 33b2dd2f..5db9a5c5 100644 --- a/ui/web/src/components/layout/topbar.tsx +++ b/ui/web/src/components/layout/topbar.tsx @@ -1,10 +1,11 @@ -import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut, Globe, Clock, Building2, ChevronDown, Check, User, KeyRound, Info } from "lucide-react"; +import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut, Globe, Clock, Building2, ChevronDown, Check, User, KeyRound, Info, Settings2 } from "lucide-react"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { useUiStore } from "@/stores/use-ui-store"; import { useAuthStore } from "@/stores/use-auth-store"; import { useTenants } from "@/hooks/use-tenants"; import { useIsMobile } from "@/hooks/use-media-query"; +import { useEmbeddingStatus } from "@/hooks/use-embedding-status"; import { ROUTES, SUPPORTED_LANGUAGES, LANGUAGE_LABELS, TIMEZONE_OPTIONS, LOCAL_STORAGE_KEYS, type Language } from "@/lib/constants"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -12,6 +13,7 @@ import { Popover } from "radix-ui"; import { useState } from "react"; import { ConfirmDialog } from "@/components/shared/confirm-dialog"; import { AboutDialog } from "./about-dialog"; +import { SystemSettingsModal } from "./system-settings-modal"; export function Topbar() { const { t } = useTranslation("topbar"); @@ -26,6 +28,10 @@ export function Topbar() { const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen); const isMobile = useIsMobile(); const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); + const { status: embStatus } = useEmbeddingStatus(); + const [settingsOpen, setSettingsOpen] = useState(false); + const role = useAuthStore((s) => s.role); + const isAdmin = role === "admin"; const handleSidebarToggle = isMobile ? () => setMobileSidebarOpen(true) @@ -80,6 +86,21 @@ export function Topbar() { + {isAdmin && ( + + )} + + {embResult && ( + + {embResult.valid ? ( + <> + + {embResult.dimensions} dimensions + + ) : ( + <> + + {embResult.error || t("embedding.verifyFailed")} + + )} + + )} + + + )} + + )} + {/* Status */}

{t("detail.statusSection")}

diff --git a/ui/web/src/types/provider.ts b/ui/web/src/types/provider.ts index 6c394300..9b3b8310 100644 --- a/ui/web/src/types/provider.ts +++ b/ui/web/src/types/provider.ts @@ -25,3 +25,15 @@ export interface ModelInfo { id: string; name?: string; } + +export interface EmbeddingSettings { + enabled: boolean; + model?: string; + api_base?: string; +} + +/** Extract embedding settings from provider.settings */ +export function getEmbeddingSettings(settings?: Record): EmbeddingSettings | null { + if (!settings?.embedding) return null; + return settings.embedding as EmbeddingSettings; +}