mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
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:
1 parent
0209f6f970
commit
651072a9ea
59 files changed
+1832
-185
No files matched your search
+41
-2
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -31,4 +31,5 @@ type Stores struct {
|
||||
Tenants TenantStore
|
||||
BuiltinToolTenantCfgs BuiltinToolTenantConfigStore
|
||||
SkillTenantCfgs SkillTenantConfigStore
|
||||
SystemConfigs SystemConfigStore
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -77,5 +77,10 @@
|
||||
"week": "Week"
|
||||
},
|
||||
"noLimit": "no limit"
|
||||
},
|
||||
"embedding": {
|
||||
"title": "Embedding",
|
||||
"notConfigured": "Not configured",
|
||||
"configure": "Configure in Providers →"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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?",
|
||||
|
||||
@@ -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ờ",
|
||||
|
||||
@@ -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 →"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "待处理消息压缩",
|
||||
|
||||
@@ -77,5 +77,10 @@
|
||||
"version": "版本",
|
||||
"runtimes": "运行时"
|
||||
},
|
||||
"title": "概览"
|
||||
"title": "概览",
|
||||
"embedding": {
|
||||
"title": "Embedding",
|
||||
"notConfigured": "未配置",
|
||||
"configure": "在Providers中配置 →"
|
||||
}
|
||||
}
|
||||
@@ -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": "更多配置"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user