feat(config): add system_configs DB table with per-tenant isolation and System Settings modal

- Add system_configs table (migration 029) with per-tenant key-value config
- Add SystemConfigStore interface with strict tenant isolation (no cross-tenant fallback)
- Add ApplySystemConfigs overlay: DB values → in-memory cfg at startup + after save
- Add System Settings modal (navbar gear icon) with 3 sections:
  - Embedding: verified 1536d models (OpenAI, OpenRouter, Mistral), custom verify endpoint
  - UX Behavior: tool_status, block_reply, intent_classify toggles
  - Pending Compaction: provider/model + threshold/keepRecent/maxTokens
- Fix block_reply label inversion: ON = delivers intermediate text (not suppresses)
- Fix embedding status endpoint to read from system_configs DB (not provider JSONB)
- Fix config page save to only sync current tenant (not all tenants)
- Fix bus event payload to use fresh context (prevent canceled request context)
- Remove config.json fallback for embedding provider/model resolution
- Add "More Config" link in modal footer to full config page
- Add i18n for system-settings namespace (en/vi/zh)
This commit is contained in:
viettranx committed 2026-03-24 16:01:02 +07:00
1 parent 0209f6f970
commit 651072a9ea
59 files changed
+1832 -185

No files matched your search

+41 -2
View File
@@ -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 {
+118 -64
View File
@@ -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
}
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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
+3
View File
@@ -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))
}
+15 -2
View File
@@ -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)
+21 -16
View File
@@ -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())
+132
View File
@@ -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)
}
}
+1
View File
@@ -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"
)
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+80
View File
@@ -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)
}
}
+17 -1
View File
@@ -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 {
+7
View File
@@ -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 }
+134
View File
@@ -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})
}
+10
View File
@@ -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))
+106
View File
@@ -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)
}
+5 -35
View File
@@ -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.
+1 -1
View File
@@ -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
}
+1
View File
@@ -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
}
+20
View File
@@ -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 {
+2 -4
View File
@@ -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
+4 -4
View File
@@ -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
}
+110
View File
@@ -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
}
+30
View File
@@ -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
+1
View File
@@ -31,4 +31,5 @@ type Stores struct {
Tenants TenantStore
BuiltinToolTenantCfgs BuiltinToolTenantConfigStore
SkillTenantCfgs SkillTenantConfigStore
SystemConfigs SystemConfigStore
}
+20
View File
@@ -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)
}
+1 -1
View File
@@ -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
@@ -0,0 +1 @@
DROP TABLE IF EXISTS system_configs;
+12
View File
@@ -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);
@@ -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<string, { id: string; name: string }[]> = {
// 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<InitState>(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<string, string>) => {
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<Record<string, string>>("/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<string, string> = {};
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-[95vw] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{t("title")}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex flex-1 items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6">
{/* ── Embedding ── */}
<Card className="border-blue-200 dark:border-blue-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4 text-blue-500" />
{t("embedding.title")}
</CardTitle>
<CardDescription>{t("embedding.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="space-y-1">
<p>{t("embedding.importance")}</p>
<p className="opacity-75">{t("embedding.supportedProviders")}</p>
</div>
</div>
<ProviderModelSelect
provider={embProvider}
onProviderChange={(v) => { 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")}
/>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={!selectedEmbProviderData || !embModel.trim() || embVerifying}
onClick={handleVerifyEmb}
>
{embVerifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{t("embedding.verify")}
</Button>
{embResult && (
<span className={`flex items-center gap-1 text-xs ${embResult.valid ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}>
{embResult.valid ? (
<>
<CheckCircle2 className="h-3.5 w-3.5" />
{t("embedding.dimensions", { count: embResult.dimensions })}
</>
) : (
<>
<XCircle className="h-3.5 w-3.5" />
{embResult.error || t("embedding.verifyFailed")}
</>
)}
</span>
)}
</div>
</CardContent>
</Card>
{/* ── UX Behavior — uses FeatureSwitchGroup (same as config page) ── */}
<FeatureSwitchGroup
title={t("ux.title")}
description={t("ux.description")}
items={uxItems}
/>
{/* ── Pending Compaction ── */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("compaction.title")}</CardTitle>
<CardDescription>{t("compaction.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-0 pt-0">
<div className="border-b pb-4">
<ProviderModelSelect
provider={compProvider}
onProviderChange={setCompProvider}
model={compModel}
onModelChange={setCompModel}
allowEmpty
providerPlaceholder={t("compaction.providerPlaceholder")}
/>
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Archive className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.threshold")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.thresholdHint")}</p>
</div>
</div>
<Input
type="number" value={compThreshold}
onChange={(e) => setCompThreshold(e.target.value)}
placeholder="200" min={1}
className="w-24 shrink-0 text-base md:text-sm"
/>
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-blue-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.keepRecent")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.keepRecentHint")}</p>
</div>
</div>
<Input
type="number" value={compKeepRecent}
onChange={(e) => setCompKeepRecent(e.target.value)}
placeholder="40" min={1}
className="w-24 shrink-0 text-base md:text-sm"
/>
</div>
<div className="flex items-start justify-between gap-4 border-b py-4">
<div className="flex items-start gap-3">
<Hash className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t("compaction.maxTokens")}</Label>
<p className="text-xs text-muted-foreground">{t("compaction.maxTokensHint")}</p>
</div>
</div>
<Input
type="number" value={compMaxTokens}
onChange={(e) => setCompMaxTokens(e.target.value)}
placeholder="4096" min={256}
className="w-24 shrink-0 text-base md:text-sm"
/>
</div>
<div className="flex items-start gap-2 rounded-md border border-orange-200 bg-orange-50 px-3 py-2 mt-4 text-xs text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("compaction.info")}</span>
</div>
</CardContent>
</Card>
</div>
)}
{/* Footer */}
<div className="flex flex-col gap-3 border-t pt-4 shrink-0">
{embChanged && !embVerified && (
<p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{t("embedding.verifyRequired")}
</p>
)}
<div className="flex items-center justify-between gap-2">
<Link
to="/config"
onClick={() => onOpenChange(false)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3 w-3" />
{t("moreConfig")}
</Link>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={saveDisabled}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{saving ? t("saving") : t("save")}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+23 -1
View File
@@ -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() {
</SelectContent>
</Select>
{isAdmin && (
<button
onClick={() => setSettingsOpen(true)}
className="relative cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title={t("systemSettings")}
>
<Settings2 className="h-4 w-4" />
<span
className={`absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full ${
embStatus?.configured ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
</button>
)}
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
@@ -91,6 +112,7 @@ export function Topbar() {
<UserMenu />
</div>
<SystemSettingsModal open={settingsOpen} onOpenChange={setSettingsOpen} />
</header>
);
}
@@ -36,6 +36,12 @@ interface ProviderModelSelectProps {
onSaveBlockedChange?: (blocked: boolean) => void;
/** When true, skip auto-selecting the first provider when none is set. Useful when empty means "use default". */
allowEmpty?: boolean;
/** When true, only show providers with settings.embedding.enabled. */
filterEmbedding?: boolean;
/** Filter model list by keyword (case-insensitive). E.g. "embed" to show only embedding models. */
modelFilter?: string;
/** Extra models to prepend to the dropdown (e.g. curated embedding models not returned by API). */
extraModels?: { id: string; name: string }[];
}
export function ProviderModelSelect({
@@ -54,12 +60,23 @@ export function ProviderModelSelect({
savedModel,
onSaveBlockedChange,
allowEmpty,
filterEmbedding,
modelFilter,
extraModels,
}: ProviderModelSelectProps) {
const { t } = useTranslation("common");
const { providers } = useProviders();
const enabledProviders = useMemo(
() => providers.filter((p) => p.enabled),
[providers],
() => providers.filter((p) => {
if (!p.enabled) return false;
if (filterEmbedding) {
const s = p.settings as Record<string, unknown> | undefined;
const emb = s?.embedding as { enabled?: boolean } | undefined;
return emb?.enabled === true;
}
return true;
}),
[providers, filterEmbedding],
);
// Stable ref for callback — prevents the auto-select effect from re-running
@@ -146,7 +163,23 @@ export function ProviderModelSelect({
<Combobox
value={model}
onChange={onModelChange}
options={models.map((m) => ({ value: m.id, label: m.name }))}
options={(() => {
let list = modelFilter
? models.filter((m) => {
const id = m.id.toLowerCase();
const name = (m.name ?? "").toLowerCase();
const f = modelFilter.toLowerCase();
return id.includes(f) || name.includes(f);
})
: models;
// Prepend extra models, dedup by id
if (extraModels?.length) {
const apiIds = new Set(list.map((m) => m.id));
const extras = extraModels.filter((m) => !apiIds.has(m.id));
list = [...extras, ...list];
}
return list.map((m) => ({ value: m.id, label: m.name }));
})()}
placeholder={modelsLoading ? t("loadingModels") : (modelPlaceholder ?? t("enterOrSelectModel"))}
allowCustom
customLabel={t("useCustomModel")}
+33
View File
@@ -0,0 +1,33 @@
import { useState, useEffect, useCallback } from "react";
import { useHttp } from "./use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
interface EmbeddingStatus {
configured: boolean;
provider?: string;
provider_name?: string;
model?: string;
}
export function useEmbeddingStatus() {
const http = useHttp();
const tenantId = useAuthStore((s) => s.tenantId);
const [status, setStatus] = useState<EmbeddingStatus | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const res = await http.get<EmbeddingStatus>("/v1/embedding/status");
setStatus(res);
} catch {
setStatus({ configured: false });
} finally {
setLoading(false);
}
}, [http]);
// Re-fetch when tenant changes
useEffect(() => { refresh(); }, [refresh, tenantId]);
return { status, loading, refresh };
}
+7 -1
View File
@@ -35,6 +35,7 @@ import enApiKeys from "./locales/en/api-keys.json";
import enCliCredentials from "./locales/en/cli-credentials.json";
import enPackages from "./locales/en/packages.json";
import enTenants from "./locales/en/tenants.json";
import enSystemSettings from "./locales/en/system-settings.json";
// --- VI namespaces ---
import viCommon from "./locales/vi/common.json";
@@ -70,6 +71,7 @@ import viApiKeys from "./locales/vi/api-keys.json";
import viCliCredentials from "./locales/vi/cli-credentials.json";
import viPackages from "./locales/vi/packages.json";
import viTenants from "./locales/vi/tenants.json";
import viSystemSettings from "./locales/vi/system-settings.json";
// --- ZH namespaces ---
import zhCommon from "./locales/zh/common.json";
@@ -105,6 +107,7 @@ import zhApiKeys from "./locales/zh/api-keys.json";
import zhCliCredentials from "./locales/zh/cli-credentials.json";
import zhPackages from "./locales/zh/packages.json";
import zhTenants from "./locales/zh/tenants.json";
import zhSystemSettings from "./locales/zh/system-settings.json";
const STORAGE_KEY = "goclaw:language";
@@ -123,7 +126,7 @@ const ns = [
"channels", "providers", "traces", "events",
"usage", "approvals", "nodes", "logs", "tools", "mcp", "tts",
"setup", "memory", "storage", "pending-messages", "contacts", "activity", "api-keys",
"cli-credentials", "packages", "tenants",
"cli-credentials", "packages", "tenants", "system-settings",
] as const;
i18n.use(initReactI18next).init({
@@ -141,6 +144,7 @@ i18n.use(initReactI18next).init({
"cli-credentials": enCliCredentials,
packages: enPackages,
tenants: enTenants,
"system-settings": enSystemSettings,
},
vi: {
common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin,
@@ -155,6 +159,7 @@ i18n.use(initReactI18next).init({
"cli-credentials": viCliCredentials,
packages: viPackages,
tenants: viTenants,
"system-settings": viSystemSettings,
},
zh: {
common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin,
@@ -169,6 +174,7 @@ i18n.use(initReactI18next).init({
"cli-credentials": zhCliCredentials,
packages: zhPackages,
tenants: zhTenants,
"system-settings": zhSystemSettings,
},
},
ns: [...ns],
+3 -3
View File
@@ -36,7 +36,7 @@
"gateway.allowedOriginsTip": "CORS allowed origins for the HTTP API. Use * to allow all origins.",
"gateway.allowedOriginsPlaceholder": "https://yourdomain.com",
"gateway.toolStatus": "Tool Status Messages",
"gateway.blockReply": "Block on Reply",
"gateway.blockReply": "Intermediate Replies",
"gateway.maxMessageChars": "Max Message Chars",
"gateway.maxMessageCharsTip": "Maximum allowed character count per inbound message. Messages exceeding this limit are rejected.",
"gateway.rateLimitRpm": "Rate Limit (RPM)",
@@ -120,8 +120,8 @@
"behavior.uxDescription": "High-impact toggles that change how the gateway interacts with users",
"behavior.toolStatusHint": "Show real-time tool execution status messages to users during agent runs.",
"behavior.toolStatusInfo": "Users will see live updates as the agent reads files, runs commands, and calls APIs.",
"behavior.blockReplyHint": "Wait for the agent to finish before sending intermediate status updates.",
"behavior.blockReplyInfo": "Only the final response is sent. Intermediate tool status messages are suppressed.",
"behavior.blockReplyHint": "Send partial assistant text to channels while tools are being invoked.",
"behavior.blockReplyInfo": "Intermediate text is delivered to users during tool execution, not just the final response.",
"behavior.intentClassifyHint": "Classify user intent before routing to reduce unnecessary agent invocations.",
"behavior.intentClassifyInfo": "Agent will only be invoked when the classifier detects actionable intent.",
"behavior.pendingCompactionTitle": "Pending Message Compaction",
+5
View File
@@ -77,5 +77,10 @@
"week": "Week"
},
"noLimit": "no limit"
},
"embedding": {
"title": "Embedding",
"notConfigured": "Not configured",
"configure": "Configure in Providers →"
}
}
+12 -1
View File
@@ -151,7 +151,18 @@
"apiKeySectionDesc": "Authentication credentials",
"statusSection": "Status",
"statusSectionDesc": "Provider availability",
"enabledDesc": "Provider is active and available for agents"
"enabledDesc": "Provider is active and available for agents",
"embeddingSection": "Embedding"
},
"embedding": {
"enable": "Enable Embedding",
"enableDesc": "Allow this provider to generate text embeddings for memory, skills, and knowledge graph",
"model": "Embedding Model",
"apiBase": "Embedding API Base",
"apiBasePlaceholder": "(same as provider)",
"apiBaseHint": "Override if embedding endpoint differs from the provider's main API base",
"verify": "Verify Embedding",
"verifyFailed": "Embedding verification failed"
},
"toast": {
"created": "Provider created",
@@ -0,0 +1,51 @@
{
"title": "System Settings",
"save": "Save",
"cancel": "Cancel",
"saving": "Saving...",
"saved": "Settings saved",
"loadFailed": "Failed to load settings",
"saveFailed": "Failed to save settings",
"embedding": {
"title": "Embedding",
"description": "Provider and model for vector embedding and semantic search.",
"importance": "Embedding is critical for memory recall, skill search, knowledge graph, and task matching accuracy. Changes affect the entire system.",
"provider": "Embedding Provider",
"model": "Embedding Model",
"providerTip": "LLM provider used for generating text embeddings.",
"modelTip": "Embedding model (e.g. text-embedding-3-small). Must support /embeddings endpoint.",
"providerPlaceholder": "Select provider",
"modelPlaceholder": "text-embedding-3-small",
"supportedProviders": "Verified 1536d: OpenAI (text-embedding-3-small), OpenRouter (openai/*), Mistral (codestral-embed). Other providers may work if they support /embeddings with 1536d output.",
"verify": "Verify Embedding",
"verifyRequired": "Verify the embedding model before saving.",
"verifyFailed": "Verification failed",
"dimensions": "{{count}} dimensions"
},
"ux": {
"title": "UX Behavior",
"description": "Control how the gateway displays agent activity to users.",
"toolStatus": "Show tool name during execution",
"toolStatusHint": "Display the active tool name while the agent is running.",
"toolStatusInfo": "Tool names are shown in streaming preview while the agent runs.",
"blockReply": "Show intermediate text during tool calls",
"blockReplyHint": "Stream partial assistant text while tools are being invoked.",
"blockReplyInfo": "Intermediate text is streamed to users during tool execution.",
"intentClassify": "Intent classification",
"intentClassifyHint": "Classify user intent before routing to the agent.",
"intentClassifyInfo": "Messages are classified to optimize agent routing and behavior."
},
"compaction": {
"title": "Pending Message Compaction",
"description": "Summarize long pending message history to stay within context limits.",
"providerPlaceholder": "(auto)",
"threshold": "Threshold",
"thresholdHint": "Messages before compaction triggers.",
"keepRecent": "Keep recent",
"keepRecentHint": "Messages preserved after compaction.",
"maxTokens": "Max tokens",
"maxTokensHint": "Token limit for compacted summary.",
"info": "When a group chat exceeds the threshold, the LLM automatically summarizes older messages into a single compact entry, keeping the most recent ones intact for context."
},
"moreConfig": "More Config"
}
+1
View File
@@ -2,6 +2,7 @@
"expandSidebar": "Expand sidebar",
"collapseSidebar": "Collapse sidebar",
"openMenu": "Open menu",
"systemSettings": "System Settings",
"toggleTheme": "Toggle theme",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to logout?",
+3 -3
View File
@@ -36,7 +36,7 @@
"gateway.allowedOriginsTip": "Nguồn gốc CORS được phép cho HTTP API. Dùng * để cho phép mọi nguồn gốc.",
"gateway.allowedOriginsPlaceholder": "https://yourdomain.com",
"gateway.toolStatus": "Thông báo trạng thái công cụ",
"gateway.blockReply": "Chặn khi phản hồi",
"gateway.blockReply": "Phản hồi trung gian",
"gateway.maxMessageChars": "Ký tự tối đa mỗi tin",
"gateway.maxMessageCharsTip": "Số ký tự tối đa được phép trong mỗi tin nhắn đến. Tin nhắn vượt quá giới hạn sẽ bị từ chối.",
"gateway.rateLimitRpm": "Giới hạn tốc độ (RPM)",
@@ -120,8 +120,8 @@
"behavior.uxDescription": "Các công tắc có tác động lớn thay đổi cách gateway tương tác với người dùng",
"behavior.toolStatusHint": "Hiện thông báo trạng thái thực thi công cụ theo thời gian thực cho người dùng trong quá trình chạy agent.",
"behavior.toolStatusInfo": "Người dùng sẽ thấy cập nhật trực tiếp khi agent đọc tệp, chạy lệnh và gọi API.",
"behavior.blockReplyHint": "Chờ agent hoàn thành trước khi gửi các cập nhật trạng thái trung gian.",
"behavior.blockReplyInfo": "Chỉ phản hồi cuối được gửi. Thông báo trạng thái công cụ trung gian bị ẩn.",
"behavior.blockReplyHint": "Gửi văn bản phụ trợ cho kênh trong khi công cụ đang được gọi.",
"behavior.blockReplyInfo": "Văn bản trung gian được gửi cho người dùng trong quá trình thực thi công cụ.",
"behavior.intentClassifyHint": "Phân loại ý định người dùng trước khi định tuyến để giảm các lần gọi agent không cần thiết.",
"behavior.intentClassifyInfo": "Agent chỉ được gọi khi bộ phân loại phát hiện ý định có thể thực hiện.",
"behavior.pendingCompactionTitle": "Nén tin nhắn chờ",
+6 -1
View File
@@ -77,5 +77,10 @@
"version": "Phiên bản",
"runtimes": "Môi trường runtime"
},
"title": "Bảng điều khiển"
"title": "Bảng điều khiển",
"embedding": {
"title": "Embedding",
"notConfigured": "Chưa cấu hình",
"configure": "Cấu hình trong Providers →"
}
}
+12 -1
View File
@@ -151,7 +151,18 @@
"apiKeySectionDesc": "Thông tin xác thực",
"statusSection": "Trạng thái",
"statusSectionDesc": "Tình trạng hoạt động của provider",
"enabledDesc": "Provider đang hoạt động và sẵn sàng cho các agent"
"enabledDesc": "Provider đang hoạt động và sẵn sàng cho các agent",
"embeddingSection": "Embedding"
},
"embedding": {
"enable": "Bật Embedding",
"enableDesc": "Cho phép provider này tạo embedding cho bộ nhớ, kỹ năng và đồ thị tri thức",
"model": "Model Embedding",
"apiBase": "API Base Embedding",
"apiBasePlaceholder": "(giống provider)",
"apiBaseHint": "Ghi đè nếu endpoint embedding khác với API base chính của provider",
"verify": "Kiểm tra Embedding",
"verifyFailed": "Kiểm tra embedding thất bại"
},
"toast": {
"created": "Đã tạo provider",
@@ -0,0 +1,51 @@
{
"title": "Cài đặt hệ thống",
"save": "Lưu",
"cancel": "Hủy",
"saving": "Đang lưu...",
"saved": "Đã lưu cài đặt",
"loadFailed": "Không thể tải cài đặt",
"saveFailed": "Không thể lưu cài đặt",
"embedding": {
"title": "Embedding",
"description": "Provider và model cho vector embedding và tìm kiếm ngữ nghĩa.",
"importance": "Embedding rất quan trọng cho khả năng nhớ, tìm kiếm kỹ năng, đồ thị tri thức và khớp tác vụ. Thay đổi ảnh hưởng toàn hệ thống.",
"provider": "Provider Embedding",
"model": "Model Embedding",
"providerTip": "Provider LLM dùng để tạo vector embedding.",
"modelTip": "Model embedding (vd: text-embedding-3-small). Phải hỗ trợ endpoint /embeddings.",
"providerPlaceholder": "Chọn provider",
"modelPlaceholder": "text-embedding-3-small",
"supportedProviders": "Đã xác minh 1536d: OpenAI (text-embedding-3-small), OpenRouter (openai/*), Mistral (codestral-embed). Các provider khác có thể hoạt động nếu hỗ trợ /embeddings với output 1536d.",
"verify": "Kiểm tra Embedding",
"verifyRequired": "Kiểm tra model embedding trước khi lưu.",
"verifyFailed": "Kiểm tra thất bại",
"dimensions": "{{count}} chiều"
},
"ux": {
"title": "Hành vi UX",
"description": "Điều khiển cách gateway hiển thị hoạt động của agent cho người dùng.",
"toolStatus": "Hiện tên công cụ khi thực thi",
"toolStatusHint": "Hiển thị tên công cụ đang chạy khi agent hoạt động.",
"toolStatusInfo": "Tên công cụ được hiện khi agent đang chạy.",
"blockReply": "Hiện văn bản trung gian khi gọi công cụ",
"blockReplyHint": "Gửi nội dung phụ trợ khi công cụ đang được gọi.",
"blockReplyInfo": "Văn bản trung gian được gửi cho người dùng khi thực thi công cụ.",
"intentClassify": "Phân loại ý định",
"intentClassifyHint": "Phân loại ý định người dùng trước khi chuyển tới agent.",
"intentClassifyInfo": "Tin nhắn được phân loại để tối ưu định tuyến agent."
},
"compaction": {
"title": "Nén tin nhắn chờ",
"description": "Tóm tắt lịch sử tin nhắn chờ dài để nằm trong giới hạn context.",
"providerPlaceholder": "(tự động)",
"threshold": "Ngưỡng",
"thresholdHint": "Số tin nhắn trước khi kích hoạt nén.",
"keepRecent": "Giữ lại gần đây",
"keepRecentHint": "Số tin nhắn được giữ sau khi nén.",
"maxTokens": "Token tối đa",
"maxTokensHint": "Giới hạn token cho bản tóm tắt.",
"info": "Khi nhóm chat vượt ngưỡng, LLM tự động tóm tắt các tin nhắn cũ thành một mục gọn, giữ nguyên các tin gần nhất cho ngữ cảnh."
},
"moreConfig": "Cấu hình thêm"
}
+1
View File
@@ -28,5 +28,6 @@
"updateAvailable": "Có bản cập nhật: {{version}}",
"viewRelease": "Xem chi tiết →"
},
"systemSettings": "Cài đặt hệ thống",
"toggleTheme": "Chuyển đổi giao diện"
}
+3 -3
View File
@@ -36,7 +36,7 @@
"gateway.allowedOriginsTip": "HTTP API 的 CORS 允许来源。使用 * 允许所有来源。",
"gateway.allowedOriginsPlaceholder": "https://yourdomain.com",
"gateway.toolStatus": "工具状态消息",
"gateway.blockReply": "阻塞回复",
"gateway.blockReply": "中间回复",
"gateway.maxMessageChars": "最大消息字符数",
"gateway.maxMessageCharsTip": "每条入站消息允许的最大字符数,超出限制的消息将被拒绝。",
"gateway.rateLimitRpm": "速率限制(RPM)",
@@ -120,8 +120,8 @@
"behavior.uxDescription": "影响网关与用户交互方式的高影响开关",
"behavior.toolStatusHint": "在 Agent 运行期间向用户显示实时工具执行状态消息。",
"behavior.toolStatusInfo": "当 Agent 读取文件、运行命令和调用 API 时,用户将看到实时更新。",
"behavior.blockReplyHint": "在发送中间状态更新之前等待 Agent 完成。",
"behavior.blockReplyInfo": "仅发送最终响应,中间工具状态消息被抑制。",
"behavior.blockReplyHint": "在工具调用过程中向频道发送部分助手文本。",
"behavior.blockReplyInfo": "工具执行期间向用户发送中间文本,而非仅发送最终响应。",
"behavior.intentClassifyHint": "在路由之前对用户意图进行分类,以减少不必要的 Agent 调用。",
"behavior.intentClassifyInfo": "仅当分类器检测到可执行意图时才调用 Agent。",
"behavior.pendingCompactionTitle": "待处理消息压缩",
+6 -1
View File
@@ -77,5 +77,10 @@
"version": "版本",
"runtimes": "运行时"
},
"title": "概览"
"title": "概览",
"embedding": {
"title": "Embedding",
"notConfigured": "未配置",
"configure": "在Providers中配置 →"
}
}
+12 -1
View File
@@ -151,7 +151,18 @@
"apiKeySectionDesc": "认证凭证",
"statusSection": "状态",
"statusSectionDesc": "Provider可用性",
"enabledDesc": "Provider已激活并可供Agent使用"
"enabledDesc": "Provider已激活并可供Agent使用",
"embeddingSection": "Embedding"
},
"embedding": {
"enable": "启用Embedding",
"enableDesc": "允许此Provider为记忆、技能和知识图谱生成文本嵌入",
"model": "Embedding模型",
"apiBase": "Embedding API地址",
"apiBasePlaceholder": "(与Provider相同)",
"apiBaseHint": "如果Embedding端点与Provider的主API地址不同,可在此覆盖",
"verify": "验证Embedding",
"verifyFailed": "Embedding验证失败"
},
"toast": {
"created": "Provider已创建",
@@ -0,0 +1,51 @@
{
"title": "系统设置",
"save": "保存",
"cancel": "取消",
"saving": "保存中...",
"saved": "设置已保存",
"loadFailed": "加载设置失败",
"saveFailed": "保存设置失败",
"embedding": {
"title": "Embedding",
"description": "用于向量嵌入和语义搜索的Provider和模型。",
"importance": "Embedding对记忆检索、技能搜索、知识图谱和任务匹配至关重要。更改将影响整个系统。",
"provider": "Embedding Provider",
"model": "Embedding模型",
"providerTip": "用于生成文本嵌入的LLM Provider。",
"modelTip": "Embedding模型(如text-embedding-3-small)。必须支持/embeddings端点。",
"providerPlaceholder": "选择Provider",
"modelPlaceholder": "text-embedding-3-small",
"supportedProviders": "已验证1536d:OpenAI (text-embedding-3-small)、OpenRouter (openai/*)、Mistral (codestral-embed)。其他Provider如支持/embeddings并输出1536d也可使用。",
"verify": "验证Embedding",
"verifyRequired": "保存前请先验证Embedding模型。",
"verifyFailed": "验证失败",
"dimensions": "{{count}}维"
},
"ux": {
"title": "UX行为",
"description": "控制网关如何向用户展示Agent活动。",
"toolStatus": "执行时显示工具名称",
"toolStatusHint": "Agent运行时显示当前工具名称。",
"toolStatusInfo": "Agent运行时在流式预览中显示工具名称。",
"blockReply": "工具调用时显示中间文本",
"blockReplyHint": "工具调用过程中流式传输部分助手文本。",
"blockReplyInfo": "工具执行期间向用户流式传输中间文本。",
"intentClassify": "意图分类",
"intentClassifyHint": "在路由到Agent之前分类用户意图。",
"intentClassifyInfo": "消息被分类以优化Agent路由和行为。"
},
"compaction": {
"title": "待处理消息压缩",
"description": "压缩长待处理消息历史以保持在上下文限制内。",
"providerPlaceholder": "(自动)",
"threshold": "阈值",
"thresholdHint": "触发压缩的消息数量。",
"keepRecent": "保留最近",
"keepRecentHint": "压缩后保留的消息数量。",
"maxTokens": "最大Token",
"maxTokensHint": "压缩摘要的Token限制。",
"info": "当群聊超过阈值时,LLM自动将旧消息总结为一条紧凑记录,保留最近的消息以保持上下文。"
},
"moreConfig": "更多配置"
}
+1
View File
@@ -28,5 +28,6 @@
"updateAvailable": "有新版本: {{version}}",
"viewRelease": "查看发行说明 →"
},
"systemSettings": "系统设置",
"toggleTheme": "切换主题"
}
@@ -10,6 +10,8 @@ import { SkillsSection } from "./overview-sections/skills-section";
import { EvolutionSection } from "./overview-sections/evolution-section";
import { CapabilitiesSection } from "./overview-sections/capabilities-section";
import { HeartbeatCard } from "./overview-sections/heartbeat-card";
import { MemorySection } from "./config-sections";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import type { UseAgentHeartbeatReturn } from "../hooks/use-agent-heartbeat";
interface AgentOverviewTabProps {
@@ -45,9 +47,11 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTa
typeof otherCfg.skill_nudge_interval === "number" ? otherCfg.skill_nudge_interval : 15,
);
// Capabilities
// Memory (now standalone section)
const [memEnabled, setMemEnabled] = useState(agent.memory_config != null);
const [mem, setMem] = useState<MemoryConfig>(agent.memory_config ?? {});
// Capabilities (subagents + tool policy)
const [subEnabled, setSubEnabled] = useState(agent.subagents_config != null);
const [sub, setSub] = useState<SubagentsConfig>(agent.subagents_config ?? {});
const [toolsEnabled, setToolsEnabled] = useState(agent.tools_config != null);
@@ -124,6 +128,20 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTa
onSaveBlockedChange={setLlmSaveBlocked}
/>
{/* Memory — standalone section (was inside Capabilities) */}
<section className="space-y-4">
<ConfigGroupHeader
title={t("configSections.memory.title")}
description={t("configSections.memory.description")}
/>
<MemorySection
enabled={memEnabled}
value={mem}
onToggle={(v) => { setMemEnabled(v); if (!v) setMem({}); }}
onChange={setMem}
/>
</section>
<HeartbeatCard heartbeat={heartbeat} />
<SkillsSection agentId={agent.id} />
@@ -140,10 +158,6 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTa
)}
<CapabilitiesSection
memEnabled={memEnabled}
mem={mem}
onMemToggle={setMemEnabled}
onMemChange={setMem}
subEnabled={subEnabled}
sub={sub}
onSubToggle={setSubEnabled}
@@ -36,11 +36,12 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect
onModelChange={(v) => onChange({ ...value, embedding_model: v || undefined })}
providerLabel={t(`${s}.embeddingProvider`)}
modelLabel={t(`${s}.embeddingModel`)}
providerTip="LLM provider used for generating text embeddings. Leave empty to auto-detect from the agent's main provider."
providerTip="LLM provider used for generating text embeddings. Leave empty to auto-detect. Only providers with embedding enabled are shown."
modelTip="Embedding model name (e.g. text-embedding-3-small). Must be supported by the provider."
providerPlaceholder="(auto)"
modelPlaceholder="text-embedding-3-small"
allowEmpty
filterEmbedding
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -1,14 +1,9 @@
import { useTranslation } from "react-i18next";
import type { MemoryConfig, SubagentsConfig, ToolPolicyConfig } from "@/types/agent";
import { MemorySection, SubagentsSection, ToolPolicySection } from "../config-sections";
import type { SubagentsConfig, ToolPolicyConfig } from "@/types/agent";
import { SubagentsSection, ToolPolicySection } from "../config-sections";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
interface CapabilitiesSectionProps {
memEnabled: boolean;
mem: MemoryConfig;
onMemToggle: (v: boolean) => void;
onMemChange: (v: MemoryConfig) => void;
subEnabled: boolean;
sub: SubagentsConfig;
onSubToggle: (v: boolean) => void;
@@ -21,7 +16,6 @@ interface CapabilitiesSectionProps {
}
export function CapabilitiesSection({
memEnabled, mem, onMemToggle, onMemChange,
subEnabled, sub, onSubToggle, onSubChange,
toolsEnabled, tools, onToolsToggle, onToolsChange,
}: CapabilitiesSectionProps) {
@@ -34,12 +28,6 @@ export function CapabilitiesSection({
description={t("configGroups.capabilitiesDesc")}
/>
<div className="space-y-4">
<MemorySection
enabled={memEnabled}
value={mem}
onToggle={(v) => { onMemToggle(v); if (!v) onMemChange({}); }}
onChange={onMemChange}
/>
<SubagentsSection
enabled={subEnabled}
value={sub}
@@ -1,15 +1,19 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Network } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/shared/empty-state";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useEmbeddingStatus } from "@/hooks/use-embedding-status";
import { useSessions } from "@/pages/sessions/hooks/use-sessions";
import { parseSessionKey } from "@/lib/session-key";
import { KGEntitiesTab } from "@/pages/memory/kg-entities-tab";
export function KnowledgeGraphPage() {
const { t } = useTranslation("memory");
const { t: to } = useTranslation("overview");
const { agents } = useAgents();
const { status: embStatus } = useEmbeddingStatus();
const [agentId, setAgentId] = useState("");
const [userIdFilter, setUserIdFilter] = useState("");
@@ -43,7 +47,14 @@ export function KnowledgeGraphPage() {
<div className="flex flex-wrap items-center gap-3">
<div className="mr-auto">
<h1 className="text-lg font-semibold">{t("kg.pageTitle")}</h1>
<p className="text-xs text-muted-foreground">{t("kg.pageDescription")}</p>
<p className="flex items-center gap-2 text-xs text-muted-foreground flex-wrap">
{t("kg.pageDescription")}
{embStatus && (
<Badge variant={embStatus.configured ? "outline" : "secondary"} className="text-xs font-normal">
{embStatus.configured ? `${to("embedding.title")}: ${embStatus.model}` : `${to("embedding.title")}: ${to("embedding.notConfigured")}`}
</Badge>
)}
</p>
</div>
<select
id="kg-agent"
+13 -1
View File
@@ -17,12 +17,15 @@ import { MemoryCreateDialog } from "./memory-create-dialog";
import { MemorySearchDialog } from "./memory-search-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useEmbeddingStatus } from "@/hooks/use-embedding-status";
import type { MemoryDocument } from "@/types/memory";
export function MemoryPage() {
const { t } = useTranslation("memory");
const { t: tc } = useTranslation("common");
const { t: to } = useTranslation("overview");
const { agents } = useAgents();
const { status: embStatus } = useEmbeddingStatus();
const [agentId, setAgentId] = useState("");
const [userIdFilter, setUserIdFilter] = useState("");
const [createOpen, setCreateOpen] = useState(false);
@@ -106,7 +109,16 @@ export function MemoryPage() {
<div className="p-4 sm:p-6 pb-10">
<PageHeader
title={t("title")}
description={t("description")}
description={
<span className="flex items-center gap-2 flex-wrap">
{t("description")}
{embStatus && (
<Badge variant={embStatus.configured ? "outline" : "secondary"} className="text-xs font-normal">
{embStatus.configured ? `${to("embedding.title")}: ${embStatus.model}` : `${to("embedding.title")}: ${to("embedding.notConfigured")}`}
</Badge>
)}
</span>
}
actions={
<div className="flex gap-2">
{agentId && (
@@ -4,6 +4,7 @@ import { useHttp } from "@/hooks/use-ws";
interface VerifyResult {
valid: boolean;
error?: string;
dimensions?: number;
}
export function useProviderVerify() {
@@ -38,5 +39,36 @@ export function useProviderVerify() {
const reset = useCallback(() => setResult(null), []);
return { verify, verifying, result, reset };
// Verify embedding model
const [embVerifying, setEmbVerifying] = useState(false);
const [embResult, setEmbResult] = useState<VerifyResult | null>(null);
const verifyEmbedding = useCallback(
async (providerId: string, model?: string) => {
setEmbVerifying(true);
setEmbResult(null);
try {
const res = await http.post<VerifyResult>(
`/v1/providers/${providerId}/verify-embedding`,
model ? { model } : {},
);
setEmbResult(res);
return res;
} catch (err) {
const r: VerifyResult = {
valid: false,
error: err instanceof Error ? err.message : "Verification failed",
};
setEmbResult(r);
return r;
} finally {
setEmbVerifying(false);
}
},
[http],
);
const resetEmb = useCallback(() => setEmbResult(null), []);
return { verify, verifying, result, reset, verifyEmbedding, embVerifying, embResult, resetEmb };
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Copy } from "lucide-react";
import { Copy, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import { PROVIDER_TYPES } from "@/constants/providers";
import { toast } from "@/stores/use-toast-store";
import { useProviderVerify } from "../hooks/use-provider-verify";
import { getEmbeddingSettings } from "@/types/provider";
import type { ProviderData, ProviderInput } from "@/types/provider";
interface ProviderOverviewProps {
@@ -17,6 +19,7 @@ interface ProviderOverviewProps {
}
const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]);
const NO_EMBEDDING_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth", "suno"]);
export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) {
const { t } = useTranslation("providers");
@@ -25,6 +28,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
const typeLabel = typeInfo?.label ?? provider.provider_type;
const showApiKey = !NO_API_KEY_TYPES.has(provider.provider_type);
const showEmbedding = !NO_EMBEDDING_TYPES.has(provider.provider_type);
// Identity
const [displayName, setDisplayName] = useState(provider.display_name || "");
@@ -35,6 +39,24 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
// Status
const [enabled, setEnabled] = useState(provider.enabled);
// Embedding
const initEmb = getEmbeddingSettings(provider.settings);
const [embEnabled, setEmbEnabled] = useState(initEmb?.enabled ?? false);
const [embModel, setEmbModel] = useState(initEmb?.model ?? "");
const [embApiBase, setEmbApiBase] = useState(initEmb?.api_base ?? "");
// Re-sync embedding state when provider changes (e.g. after save)
useEffect(() => {
const es = getEmbeddingSettings(provider.settings);
setEmbEnabled(es?.enabled ?? false);
setEmbModel(es?.model ?? "");
setEmbApiBase(es?.api_base ?? "");
}, [provider.settings]);
// Verify embedding
const { verifyEmbedding, embVerifying, embResult, resetEmb } = useProviderVerify();
useEffect(() => { resetEmb(); }, [embModel, resetEmb]);
// Save state
const [saving, setSaving] = useState(false);
@@ -51,6 +73,16 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
if (showApiKey && apiKey && apiKey !== "***") {
data.api_key = apiKey;
}
// Merge embedding settings into existing settings
if (showEmbedding) {
const existing = (provider.settings || {}) as Record<string, unknown>;
data.settings = {
...existing,
embedding: embEnabled
? { enabled: true, model: embModel.trim() || undefined, api_base: embApiBase.trim() || undefined }
: { enabled: false },
};
}
await onUpdate(provider.id, data);
} catch {
// toast shown by hook
@@ -64,6 +96,10 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
toast.success(tc("copy"));
};
const handleVerifyEmbedding = () => {
verifyEmbedding(provider.id, embModel.trim() || undefined);
};
return (
<div className="space-y-4">
{/* Identity */}
@@ -120,6 +156,77 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
</section>
)}
{/* Embedding */}
{showEmbedding && (
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.embeddingSection")}</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="embEnabled" className="text-sm font-medium">
{t("embedding.enable")}
</Label>
<p className="text-xs text-muted-foreground">{t("embedding.enableDesc")}</p>
</div>
<Switch id="embEnabled" checked={embEnabled} onCheckedChange={setEmbEnabled} />
</div>
{embEnabled && (
<div className="space-y-3 pt-1">
<div className="space-y-2">
<Label htmlFor="embModel">{t("embedding.model")}</Label>
<Input
id="embModel"
value={embModel}
onChange={(e) => setEmbModel(e.target.value)}
placeholder="text-embedding-3-small"
className="text-base md:text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="embApiBase">{t("embedding.apiBase")}</Label>
<Input
id="embApiBase"
value={embApiBase}
onChange={(e) => setEmbApiBase(e.target.value)}
placeholder={t("embedding.apiBasePlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("embedding.apiBaseHint")}</p>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={embVerifying}
onClick={handleVerifyEmbedding}
>
{embVerifying ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{t("embedding.verify")}
</Button>
{embResult && (
<span className={`flex items-center gap-1 text-xs ${embResult.valid ? "text-success" : "text-destructive"}`}>
{embResult.valid ? (
<>
<CheckCircle2 className="h-3.5 w-3.5" />
{embResult.dimensions} dimensions
</>
) : (
<>
<XCircle className="h-3.5 w-3.5" />
{embResult.error || t("embedding.verifyFailed")}
</>
)}
</span>
)}
</div>
</div>
)}
</section>
)}
{/* Status */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.statusSection")}</h3>
+12
View File
@@ -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<string, unknown>): EmbeddingSettings | null {
if (!settings?.embedding) return null;
return settings.embedding as EmbeddingSettings;
}