mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 04:15:05 +00:00
feat(quota): channel quota limiter + per-run tool budget + config UI
Part A — Channel quota limiter (managed mode): - DB-backed per-user/group request quotas with in-memory 60s TTL cache - Config merge priority: Groups > Channels > Providers > Default - Per-group quota override via channels.telegram.groups[chatID].quota - Migration 000009: index on channel_requests for quota queries - Hot-reload quota config via pub/sub (TopicConfigChanged) Part B — Per-run tool call budget: - Soft stop at configurable limit (default 25, per-agent override) - MaxToolCalls field on AgentDefaults + AgentSpec + LoopConfig - LLM gets one final call to summarize when budget exceeded Part C — Web UI + config page refactor: - QuotaSection with provider/channel dropdowns (useProviders, useChannelInstances) - Config page refactored to vertical sidebar tabs layout - Categories: General, Quota, Agents, Tools, Connections, Advanced, Raw Editor - Fixed config.patch RPC to serialize raw JSON + baseHash correctly - Config change pub/sub broadcast from handleApply/handlePatch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d62fd830e0
commit
ed32e68c68
22 files changed
+844
-96
No files matched your search
@@ -211,6 +211,7 @@ func bootstrapStandaloneAgent(cfg *config.Config, agentName string) (*agent.Loop
|
||||
Model: agentCfg.Model,
|
||||
ContextWindow: agentCfg.ContextWindow,
|
||||
MaxIterations: agentCfg.MaxToolIterations,
|
||||
MaxToolCalls: agentCfg.MaxToolCalls,
|
||||
Workspace: workspace,
|
||||
Bus: msgBus,
|
||||
Sessions: sessStore,
|
||||
|
||||
+32
-1
@@ -909,7 +909,38 @@ func runGateway() {
|
||||
if managedStores != nil {
|
||||
consumerTeamStore = managedStores.Teams
|
||||
}
|
||||
go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore)
|
||||
|
||||
// Quota checker (managed mode only): enforces per-user/group request limits.
|
||||
// Merge per-group quotas from channel configs into gateway.quota.groups.
|
||||
config.MergeChannelGroupQuotas(cfg)
|
||||
var quotaChecker *channels.QuotaChecker
|
||||
if managedStores != nil && cfg.Gateway.Quota != nil && cfg.Gateway.Quota.Enabled {
|
||||
quotaChecker = channels.NewQuotaChecker(managedStores.DB, *cfg.Gateway.Quota)
|
||||
defer quotaChecker.Stop()
|
||||
slog.Info("channel quota enabled",
|
||||
"default_hour", cfg.Gateway.Quota.Default.Hour,
|
||||
"default_day", cfg.Gateway.Quota.Default.Day,
|
||||
"default_week", cfg.Gateway.Quota.Default.Week,
|
||||
)
|
||||
}
|
||||
|
||||
// Reload quota config on config changes via pub/sub.
|
||||
if quotaChecker != nil {
|
||||
msgBus.Subscribe("quota-config-reload", func(evt bus.Event) {
|
||||
if evt.Name != bus.TopicConfigChanged {
|
||||
return
|
||||
}
|
||||
updatedCfg, ok := evt.Payload.(*config.Config)
|
||||
if !ok || updatedCfg.Gateway.Quota == nil {
|
||||
return
|
||||
}
|
||||
config.MergeChannelGroupQuotas(updatedCfg)
|
||||
quotaChecker.UpdateConfig(*updatedCfg.Gateway.Quota)
|
||||
slog.Info("quota config reloaded via pub/sub")
|
||||
})
|
||||
}
|
||||
|
||||
go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore, quotaChecker)
|
||||
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
|
||||
@@ -94,6 +94,7 @@ func createAgentLoop(agentID string, cfg *config.Config, router *agent.Router, p
|
||||
Model: agentCfg.Model,
|
||||
ContextWindow: agentCfg.ContextWindow,
|
||||
MaxIterations: agentCfg.MaxToolIterations,
|
||||
MaxToolCalls: agentCfg.MaxToolCalls,
|
||||
Workspace: workspace,
|
||||
Bus: msgBus,
|
||||
Sessions: sess,
|
||||
|
||||
+25
-2
@@ -41,7 +41,7 @@ func makeSchedulerRunFunc(agents *agent.Router, cfg *config.Config) scheduler.Ru
|
||||
// and routes them through the scheduler/agent loop, then publishes the response back.
|
||||
// Also handles subagent announcements: routes them through the parent agent's session
|
||||
// (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user.
|
||||
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore) {
|
||||
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker) {
|
||||
slog.Info("inbound message consumer started")
|
||||
|
||||
// Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts).
|
||||
@@ -66,7 +66,8 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := agents.Get(agentID); err != nil {
|
||||
agentLoop, err := agents.Get(agentID)
|
||||
if err != nil {
|
||||
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
|
||||
return
|
||||
}
|
||||
@@ -112,6 +113,28 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
|
||||
}
|
||||
|
||||
// --- Quota check (managed mode only) ---
|
||||
if quotaChecker != nil {
|
||||
qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName())
|
||||
if !qResult.Allowed {
|
||||
slog.Warn("security.quota_exceeded",
|
||||
"user_id", userID,
|
||||
"channel", msg.Channel,
|
||||
"window", qResult.Window,
|
||||
"used", qResult.Used,
|
||||
"limit", qResult.Limit,
|
||||
)
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
Content: formatQuotaExceeded(qResult),
|
||||
Metadata: msg.Metadata,
|
||||
})
|
||||
return
|
||||
}
|
||||
quotaChecker.Increment(userID)
|
||||
}
|
||||
|
||||
slog.Info("inbound: scheduling message (main lane)",
|
||||
"channel", msg.Channel,
|
||||
"chat_id", msg.ChatID,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
)
|
||||
|
||||
// Matching TS pi-embedded-helpers/errors.ts error classification.
|
||||
@@ -95,3 +98,10 @@ func containsAny(s string, substrs ...string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// formatQuotaExceeded formats a user-friendly quota exceeded message.
|
||||
func formatQuotaExceeded(result channels.QuotaResult) string {
|
||||
labels := map[string]string{"hour": "Hourly", "day": "Daily", "week": "Weekly"}
|
||||
return fmt.Sprintf("⚠️ %s request limit reached (%d/%d). Please try again later.",
|
||||
labels[result.Window], result.Used, result.Limit)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
|
||||
methods.NewChatMethods(agents, sessStore, isManaged, server.RateLimiter()).Register(router)
|
||||
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, isManaged, contextFileInterceptor).Register(router)
|
||||
methods.NewSessionsMethods(sessStore).Register(router)
|
||||
methods.NewConfigMethods(cfg, cfgPath, isManaged, configSecretsStore).Register(router)
|
||||
methods.NewConfigMethods(cfg, cfgPath, isManaged, configSecretsStore, msgBus).Register(router)
|
||||
|
||||
// Phase 2: Skills (uses SkillStore interface — PG or File)
|
||||
methods.NewSkillsMethods(skillStore).Register(router)
|
||||
|
||||
@@ -52,6 +52,7 @@ type Loop struct {
|
||||
model string
|
||||
contextWindow int
|
||||
maxIterations int
|
||||
maxToolCalls int
|
||||
workspace string
|
||||
|
||||
eventPub bus.EventPublisher // currently unused by Loop; kept for future use
|
||||
@@ -127,6 +128,7 @@ type LoopConfig struct {
|
||||
Model string
|
||||
ContextWindow int
|
||||
MaxIterations int
|
||||
MaxToolCalls int
|
||||
Workspace string
|
||||
Bus bus.EventPublisher
|
||||
Sessions store.SessionStore
|
||||
@@ -214,6 +216,7 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
model: cfg.Model,
|
||||
contextWindow: cfg.ContextWindow,
|
||||
maxIterations: cfg.MaxIterations,
|
||||
maxToolCalls: cfg.MaxToolCalls,
|
||||
workspace: cfg.Workspace,
|
||||
eventPub: cfg.Bus,
|
||||
sessions: cfg.Sessions,
|
||||
@@ -593,6 +596,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
var loopDetector toolLoopState // detects repeated no-progress tool calls
|
||||
var totalUsage providers.Usage
|
||||
iteration := 0
|
||||
totalToolCalls := 0
|
||||
var finalContent string
|
||||
var asyncToolCalls []string // track async spawn tool names for fallback
|
||||
var mediaResults []MediaResult // media files from tool MEDIA: results
|
||||
@@ -779,6 +783,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool budget check: soft stop when total tool calls exceed the per-agent limit.
|
||||
// Same pattern as maxIterations — no error thrown, LLM summarizes and returns.
|
||||
totalToolCalls += len(resp.ToolCalls)
|
||||
if l.maxToolCalls > 0 && totalToolCalls > l.maxToolCalls {
|
||||
slog.Warn("security.tool_budget_exceeded",
|
||||
"agent", l.id, "total", totalToolCalls, "limit", l.maxToolCalls)
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("[System] Tool call budget reached (%d/%d). Do NOT call any more tools. Summarize results so far and respond to the user.", totalToolCalls, l.maxToolCalls),
|
||||
})
|
||||
continue // one more LLM call for summarization, then loop exits (no tool calls)
|
||||
}
|
||||
|
||||
// Execute tool calls (parallel when multiple, sequential when single)
|
||||
if len(resp.ToolCalls) == 1 {
|
||||
// Single tool: sequential — no goroutine overhead
|
||||
@@ -1270,3 +1287,11 @@ func sanitizePathSegment(s string) string {
|
||||
func (l *Loop) InvalidateUserWorkspace(userID string) {
|
||||
l.userWorkspaces.Delete(userID)
|
||||
}
|
||||
|
||||
// ProviderName returns the name of this agent's LLM provider (e.g. "anthropic", "openai").
|
||||
func (l *Loop) ProviderName() string {
|
||||
if l.provider == nil {
|
||||
return ""
|
||||
}
|
||||
return l.provider.Name()
|
||||
}
|
||||
@@ -9,4 +9,5 @@ type Agent interface {
|
||||
Run(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
IsRunning() bool
|
||||
Model() string
|
||||
ProviderName() string
|
||||
}
|
||||
@@ -69,6 +69,7 @@ const (
|
||||
TopicCacheGroupFileWriters = "cache:group_file_writers"
|
||||
TopicCacheSkillGrants = "cache:skill_grants"
|
||||
TopicChannelStreaming = "channel-streaming"
|
||||
TopicConfigChanged = "config:changed"
|
||||
)
|
||||
|
||||
// CacheInvalidatePayload signals cache layers to evict stale entries.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
// QuotaResult is returned by QuotaChecker.Check.
|
||||
type QuotaResult struct {
|
||||
Allowed bool // whether the request is within quota
|
||||
Window string // which window was exceeded: "hour", "day", "week"
|
||||
Used int // current usage in the exceeded window
|
||||
Limit int // configured limit for the exceeded window
|
||||
}
|
||||
|
||||
// quotaCounts holds cached request counts for a user.
|
||||
type quotaCounts struct {
|
||||
hour, day, week int
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
// QuotaChecker enforces per-user/group request quotas by counting top-level
|
||||
// traces in the database. Results are cached in-memory for cacheTTL.
|
||||
// Managed mode only — nil-safe (standalone mode passes nil).
|
||||
type QuotaChecker struct {
|
||||
db *sql.DB
|
||||
config config.QuotaConfig
|
||||
cache map[string]*quotaCounts
|
||||
cacheTTL time.Duration
|
||||
mu sync.RWMutex
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewQuotaChecker creates a quota checker backed by the traces table.
|
||||
// Starts a background goroutine to evict stale cache entries.
|
||||
func NewQuotaChecker(db *sql.DB, cfg config.QuotaConfig) *QuotaChecker {
|
||||
qc := &QuotaChecker{
|
||||
db: db,
|
||||
config: cfg,
|
||||
cache: make(map[string]*quotaCounts),
|
||||
cacheTTL: 60 * time.Second,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go qc.cleanupLoop()
|
||||
return qc
|
||||
}
|
||||
|
||||
// Stop shuts down the background cleanup goroutine.
|
||||
func (qc *QuotaChecker) Stop() {
|
||||
close(qc.stopCh)
|
||||
}
|
||||
|
||||
// UpdateConfig replaces the quota configuration (e.g., after config reload).
|
||||
func (qc *QuotaChecker) UpdateConfig(cfg config.QuotaConfig) {
|
||||
qc.mu.Lock()
|
||||
defer qc.mu.Unlock()
|
||||
qc.config = cfg
|
||||
}
|
||||
|
||||
// Check verifies if a user is within their quota limits.
|
||||
// Returns QuotaResult with Allowed=true if all windows are within limits.
|
||||
func (qc *QuotaChecker) Check(ctx context.Context, userID, channel, provider string) QuotaResult {
|
||||
window := qc.resolveWindow(userID, channel, provider)
|
||||
if window.IsZero() {
|
||||
return QuotaResult{Allowed: true}
|
||||
}
|
||||
|
||||
counts := qc.getCounts(ctx, userID)
|
||||
|
||||
// Check each window — report the first exceeded
|
||||
if window.Hour > 0 && counts.hour >= window.Hour {
|
||||
return QuotaResult{Allowed: false, Window: "hour", Used: counts.hour, Limit: window.Hour}
|
||||
}
|
||||
if window.Day > 0 && counts.day >= window.Day {
|
||||
return QuotaResult{Allowed: false, Window: "day", Used: counts.day, Limit: window.Day}
|
||||
}
|
||||
if window.Week > 0 && counts.week >= window.Week {
|
||||
return QuotaResult{Allowed: false, Window: "week", Used: counts.week, Limit: window.Week}
|
||||
}
|
||||
|
||||
return QuotaResult{Allowed: true}
|
||||
}
|
||||
|
||||
// Increment optimistically bumps cached counts after a request is accepted.
|
||||
// This prevents multiple rapid requests from bypassing the quota within the
|
||||
// cache TTL window.
|
||||
func (qc *QuotaChecker) Increment(userID string) {
|
||||
qc.mu.Lock()
|
||||
defer qc.mu.Unlock()
|
||||
if c, ok := qc.cache[userID]; ok {
|
||||
c.hour++
|
||||
c.day++
|
||||
c.week++
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWindow returns the effective quota window for a user, applying
|
||||
// config merge priority: Groups > Channels > Providers > Default.
|
||||
func (qc *QuotaChecker) resolveWindow(userID, channel, provider string) config.QuotaWindow {
|
||||
qc.mu.RLock()
|
||||
cfg := qc.config
|
||||
qc.mu.RUnlock()
|
||||
|
||||
// Most specific: per-user/group override
|
||||
if w, ok := cfg.Groups[userID]; ok && !w.IsZero() {
|
||||
return w
|
||||
}
|
||||
|
||||
// Channel-level override
|
||||
if channel != "" {
|
||||
if w, ok := cfg.Channels[channel]; ok && !w.IsZero() {
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
// Provider-level override
|
||||
if provider != "" {
|
||||
if w, ok := cfg.Providers[provider]; ok && !w.IsZero() {
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
return cfg.Default
|
||||
}
|
||||
|
||||
// getCounts returns cached or fresh counts for a user.
|
||||
func (qc *QuotaChecker) getCounts(ctx context.Context, userID string) quotaCounts {
|
||||
qc.mu.RLock()
|
||||
if c, ok := qc.cache[userID]; ok && time.Since(c.fetchedAt) < qc.cacheTTL {
|
||||
counts := *c
|
||||
qc.mu.RUnlock()
|
||||
return counts
|
||||
}
|
||||
qc.mu.RUnlock()
|
||||
|
||||
// Cache miss — query DB
|
||||
counts := qc.queryDB(ctx, userID)
|
||||
|
||||
qc.mu.Lock()
|
||||
qc.cache[userID] = &counts
|
||||
qc.mu.Unlock()
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
// queryDB counts top-level traces for a user across time windows in a single query.
|
||||
// Uses idx_traces_quota partial index: (user_id, created_at DESC) WHERE parent_trace_id IS NULL.
|
||||
func (qc *QuotaChecker) queryDB(ctx context.Context, userID string) quotaCounts {
|
||||
now := time.Now().UTC()
|
||||
hourAgo := now.Add(-1 * time.Hour)
|
||||
dayAgo := now.Add(-24 * time.Hour)
|
||||
weekAgo := now.Add(-7 * 24 * time.Hour)
|
||||
|
||||
var counts quotaCounts
|
||||
counts.fetchedAt = now
|
||||
|
||||
err := qc.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE created_at >= $2) AS hour_count,
|
||||
COUNT(*) FILTER (WHERE created_at >= $3) AS day_count,
|
||||
COUNT(*) FILTER (WHERE created_at >= $4) AS week_count
|
||||
FROM traces
|
||||
WHERE user_id = $1 AND parent_trace_id IS NULL AND created_at >= $4`,
|
||||
userID, hourAgo, dayAgo, weekAgo,
|
||||
).Scan(&counts.hour, &counts.day, &counts.week)
|
||||
if err != nil {
|
||||
slog.Warn("quota: failed to query trace counts", "user_id", userID, "error", err)
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
// cleanupLoop periodically evicts stale cache entries.
|
||||
func (qc *QuotaChecker) cleanupLoop() {
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-qc.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
qc.mu.Lock()
|
||||
staleThreshold := time.Now().Add(-5 * time.Minute)
|
||||
for k, v := range qc.cache {
|
||||
if v.fetchedAt.Before(staleThreshold) {
|
||||
delete(qc.cache, k)
|
||||
}
|
||||
}
|
||||
qc.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ type AgentDefaults struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxToolIterations int `json:"max_tool_iterations"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
MaxToolCalls int `json:"max_tool_calls,omitempty"` // max total tool calls per run (0 = unlimited, default 25)
|
||||
AgentType string `json:"agent_type,omitempty"` // "open" (default) or "predefined"
|
||||
Subagents *SubagentsConfig `json:"subagents,omitempty"`
|
||||
Sandbox *SandboxConfig `json:"sandbox,omitempty"`
|
||||
@@ -376,6 +377,7 @@ type AgentSpec struct {
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxToolIterations int `json:"max_tool_iterations,omitempty"`
|
||||
ContextWindow int `json:"context_window,omitempty"`
|
||||
MaxToolCalls int `json:"max_tool_calls,omitempty"` // per-agent override
|
||||
AgentType string `json:"agent_type,omitempty"` // "open" or "predefined"
|
||||
Skills []string `json:"skills,omitempty"` // nil = all skills allowed
|
||||
Tools *ToolPolicySpec `json:"tools,omitempty"` // per-agent tool policy
|
||||
|
||||
@@ -53,6 +53,7 @@ type TelegramGroupConfig struct {
|
||||
Tools []string `json:"tools,omitempty"` // tool allow list (nil = all, supports "group:xxx")
|
||||
SystemPrompt string `json:"system_prompt,omitempty"` // extra system prompt for this group
|
||||
Topics map[string]*TelegramTopicConfig `json:"topics,omitempty"` // per-topic overrides (key: thread ID string)
|
||||
Quota *QuotaWindow `json:"quota,omitempty"` // per-group quota override
|
||||
}
|
||||
|
||||
// TelegramTopicConfig defines per-topic overrides within a Telegram group.
|
||||
@@ -178,6 +179,26 @@ func (c *Config) HasAnyProvider() bool {
|
||||
p.Bailian.APIKey != ""
|
||||
}
|
||||
|
||||
// QuotaWindow defines request limits per time window. Zero means unlimited.
|
||||
type QuotaWindow struct {
|
||||
Hour int `json:"hour,omitempty"` // max requests per hour (0 = unlimited)
|
||||
Day int `json:"day,omitempty"` // max requests per day (0 = unlimited)
|
||||
Week int `json:"week,omitempty"` // max requests per week (0 = unlimited)
|
||||
}
|
||||
|
||||
// IsZero returns true if no limits are set.
|
||||
func (w QuotaWindow) IsZero() bool { return w.Hour == 0 && w.Day == 0 && w.Week == 0 }
|
||||
|
||||
// QuotaConfig configures per-user/group request quotas (managed mode only).
|
||||
// Config merge priority: Groups > Channels > Providers > Default.
|
||||
type QuotaConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Default QuotaWindow `json:"default"`
|
||||
Providers map[string]QuotaWindow `json:"providers,omitempty"` // key = provider name (e.g. "anthropic")
|
||||
Channels map[string]QuotaWindow `json:"channels,omitempty"` // key = channel name (e.g. "telegram")
|
||||
Groups map[string]QuotaWindow `json:"groups,omitempty"` // key = userID (e.g. "group:telegram:-100123")
|
||||
}
|
||||
|
||||
// GatewayConfig controls the gateway server.
|
||||
type GatewayConfig struct {
|
||||
Host string `json:"host"`
|
||||
@@ -189,6 +210,7 @@ type GatewayConfig struct {
|
||||
RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled)
|
||||
InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off"
|
||||
InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid messages from same sender (default 1000ms, -1 = disabled)
|
||||
Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas (managed mode only)
|
||||
}
|
||||
|
||||
// ToolsConfig controls tool availability, policy, and web search.
|
||||
@@ -332,3 +354,24 @@ type TtsMiniMaxConfig struct {
|
||||
Model string `json:"model,omitempty"` // default "speech-02-hd"
|
||||
VoiceID string `json:"voice_id,omitempty"` // default "Wise_Woman"
|
||||
}
|
||||
|
||||
// MergeChannelGroupQuotas merges per-group quota overrides from channel configs
|
||||
// (e.g., channels.telegram.groups[chatID].quota) into gateway.quota.groups.
|
||||
// This allows per-group quotas to be set at the channel level and picked up
|
||||
// by the QuotaChecker at runtime.
|
||||
func MergeChannelGroupQuotas(cfg *Config) {
|
||||
if cfg.Gateway.Quota == nil {
|
||||
return
|
||||
}
|
||||
if cfg.Gateway.Quota.Groups == nil {
|
||||
cfg.Gateway.Quota.Groups = make(map[string]QuotaWindow)
|
||||
}
|
||||
|
||||
// Telegram per-group quotas
|
||||
for chatID, groupCfg := range cfg.Channels.Telegram.Groups {
|
||||
if groupCfg != nil && groupCfg.Quota != nil && !groupCfg.Quota.IsZero() {
|
||||
key := "group:telegram:" + chatID
|
||||
cfg.Gateway.Quota.Groups[key] = *groupCfg.Quota
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ func Default() *Config {
|
||||
MaxTokens: 8192,
|
||||
Temperature: 0.7,
|
||||
MaxToolIterations: 20,
|
||||
MaxToolCalls: 25,
|
||||
ContextWindow: 200000,
|
||||
Subagents: &SubagentsConfig{
|
||||
MaxConcurrent: 20,
|
||||
@@ -54,6 +55,7 @@ func Default() *Config {
|
||||
Security: "full",
|
||||
Ask: "off",
|
||||
},
|
||||
RateLimitPerHour: 150,
|
||||
},
|
||||
Sessions: SessionsConfig{
|
||||
Storage: "~/.goclaw/sessions",
|
||||
@@ -310,6 +312,9 @@ func (c *Config) ResolveAgent(agentID string) AgentDefaults {
|
||||
if spec.ContextWindow > 0 {
|
||||
d.ContextWindow = spec.ContextWindow
|
||||
}
|
||||
if spec.MaxToolCalls > 0 {
|
||||
d.MaxToolCalls = spec.MaxToolCalls
|
||||
}
|
||||
if spec.Workspace != "" {
|
||||
d.Workspace = spec.Workspace
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/titanous/json5"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -20,10 +21,11 @@ type ConfigMethods struct {
|
||||
cfgPath string
|
||||
managedMode bool
|
||||
secretsStore store.ConfigSecretsStore // nil in standalone mode
|
||||
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
|
||||
}
|
||||
|
||||
func NewConfigMethods(cfg *config.Config, cfgPath string, managedMode bool, secretsStore store.ConfigSecretsStore) *ConfigMethods {
|
||||
return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, managedMode: managedMode, secretsStore: secretsStore}
|
||||
func NewConfigMethods(cfg *config.Config, cfgPath string, managedMode bool, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods {
|
||||
return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, managedMode: managedMode, secretsStore: secretsStore, eventBus: eventBus}
|
||||
}
|
||||
|
||||
func (m *ConfigMethods) Register(router *gateway.MethodRouter) {
|
||||
@@ -94,6 +96,7 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client,
|
||||
}
|
||||
}
|
||||
m.cfg.ApplyEnvOverrides()
|
||||
m.broadcastChanged()
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
|
||||
"ok": true,
|
||||
@@ -168,6 +171,7 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client,
|
||||
}
|
||||
}
|
||||
m.cfg.ApplyEnvOverrides()
|
||||
m.broadcastChanged()
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
|
||||
"ok": true,
|
||||
@@ -178,6 +182,13 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client,
|
||||
}))
|
||||
}
|
||||
|
||||
// broadcastChanged notifies subscribers that config has been updated.
|
||||
func (m *ConfigMethods) broadcastChanged() {
|
||||
if m.eventBus != nil {
|
||||
m.eventBus.Broadcast(bus.Event{Name: bus.TopicConfigChanged, Payload: m.cfg})
|
||||
}
|
||||
}
|
||||
|
||||
// handleSchema returns the config JSON schema for UI form generation.
|
||||
// Matching TS config.schema (src/gateway/server-methods/config.ts:276-289).
|
||||
func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
|
||||
@@ -23,6 +23,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
|
||||
skillsDir = config.ExpandHome(skillsDir)
|
||||
|
||||
return &store.Stores{
|
||||
DB: db,
|
||||
Sessions: NewPGSessionStore(db),
|
||||
Memory: NewPGMemoryStore(db, memCfg),
|
||||
Cron: NewPGCronStore(db),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package store
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// Stores is the top-level container for all storage backends.
|
||||
// In standalone mode, managed-only stores (Agents, Providers, Tracing, MCP) are nil.
|
||||
type Stores struct {
|
||||
DB *sql.DB // underlying connection (nil in standalone mode)
|
||||
Sessions SessionStore
|
||||
Memory MemoryStore
|
||||
Cron CronStore
|
||||
|
||||
@@ -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 = 8
|
||||
const RequiredSchemaVersion uint = 9
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_traces_quota;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Partial index for quota checker: efficiently counts top-level traces per user in time windows.
|
||||
-- Eliminates parent_trace_id IS NULL post-filter (89% of traces are top-level).
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_traces_quota
|
||||
ON traces (user_id, created_at DESC)
|
||||
WHERE parent_trace_id IS NULL AND user_id IS NOT NULL;
|
||||
@@ -23,6 +23,7 @@ import { TtsSection } from "./sections/tts-section";
|
||||
import { CronSection } from "./sections/cron-section";
|
||||
import { TelemetrySection } from "./sections/telemetry-section";
|
||||
import { BindingsSection } from "./sections/bindings-section";
|
||||
import { QuotaSection } from "./sections/quota-section";
|
||||
|
||||
export function ConfigPage() {
|
||||
const { config, hash, configPath, loading, saving, error, refresh, applyRaw, patch } = useConfig();
|
||||
@@ -41,7 +42,7 @@ export function ConfigPage() {
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleRawSave = async () => {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await applyRaw(rawText);
|
||||
@@ -82,6 +83,8 @@ export function ConfigPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const isManaged = (config.database as any)?.mode === "managed";
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
@@ -114,17 +117,106 @@ export function ConfigPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="ui" className="mt-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="ui">UI</TabsTrigger>
|
||||
<Tabs orientation="vertical" defaultValue="general" className="mt-4 items-start">
|
||||
<TabsList variant="line" className="w-44 shrink-0 sticky top-6 rounded-lg border bg-card p-3 shadow-sm">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
{isManaged && <TabsTrigger value="quota">Quota</TabsTrigger>}
|
||||
<TabsTrigger value="agents">Agents</TabsTrigger>
|
||||
<TabsTrigger value="tools">Tools</TabsTrigger>
|
||||
<TabsTrigger value="connections">Connections</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
<div className="my-2 h-px w-full bg-border" />
|
||||
<TabsTrigger value="raw">Raw Editor</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ui" className="mt-4">
|
||||
<ConfigUI config={config} onPatch={patch} saving={saving} />
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<GatewaySection
|
||||
data={config.gateway as any}
|
||||
onSave={(v) => patch({ gateway: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw" className="mt-4">
|
||||
{isManaged && (
|
||||
<TabsContent value="quota" className="space-y-4">
|
||||
<QuotaSection
|
||||
data={config.gateway as any}
|
||||
onSave={(v) => patch({ gateway: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="agents" className="space-y-4">
|
||||
{isManaged ? (
|
||||
<ManagedRedirect
|
||||
title="LLM Providers"
|
||||
description="Managed via the Providers page in managed mode."
|
||||
to={ROUTES.PROVIDERS}
|
||||
/>
|
||||
) : (
|
||||
<ProvidersSection
|
||||
data={config.providers as any}
|
||||
onSave={(v) => patch({ providers: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
<AgentsDefaultsSection
|
||||
data={config.agents as any}
|
||||
onSave={(v) => patch({ agents: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="space-y-4">
|
||||
<ToolsSection
|
||||
data={config.tools as any}
|
||||
onSave={(v) => patch({ tools: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="connections" className="space-y-4">
|
||||
<SessionsSection
|
||||
data={config.sessions as any}
|
||||
onSave={(v) => patch({ sessions: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
{isManaged ? (
|
||||
<ManagedRedirect
|
||||
title="Channels"
|
||||
description="Managed via the Channels page in managed mode."
|
||||
to={ROUTES.CHANNELS}
|
||||
/>
|
||||
) : (
|
||||
<ChannelsSection
|
||||
data={config.channels as any}
|
||||
onSave={(v) => patch({ channels: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4">
|
||||
<TtsSection data={config.tts as any} />
|
||||
<CronSection
|
||||
data={config.cron as any}
|
||||
onSave={(v) => patch({ cron: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
<TelemetrySection
|
||||
data={config.telemetry as any}
|
||||
onSave={(v) => patch({ telemetry: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
<BindingsSection
|
||||
data={config.bindings as any}
|
||||
onSave={(v) => patch({ bindings: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw">
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={rawText}
|
||||
@@ -145,7 +237,7 @@ export function ConfigPage() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
onClick={handleRawSave}
|
||||
disabled={!dirty || saving}
|
||||
className="gap-1"
|
||||
>
|
||||
@@ -183,83 +275,3 @@ function ManagedRedirect({ title, description, to }: { title: string; descriptio
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
function ConfigUI({
|
||||
config,
|
||||
onPatch,
|
||||
saving,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
onPatch: (updates: Record<string, unknown>) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const isManaged = (config.database as any)?.mode === "managed";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GatewaySection
|
||||
data={config.gateway as any}
|
||||
onSave={(v) => onPatch({ gateway: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
{isManaged ? (
|
||||
<ManagedRedirect
|
||||
title="LLM Providers"
|
||||
description="Managed via the Providers page in managed mode."
|
||||
to={ROUTES.PROVIDERS}
|
||||
/>
|
||||
) : (
|
||||
<ProvidersSection
|
||||
data={config.providers as any}
|
||||
onSave={(v) => onPatch({ providers: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
<AgentsDefaultsSection
|
||||
data={config.agents as any}
|
||||
onSave={(v) => onPatch({ agents: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
<ToolsSection
|
||||
data={config.tools as any}
|
||||
onSave={(v) => onPatch({ tools: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
{isManaged ? (
|
||||
<ManagedRedirect
|
||||
title="Channels"
|
||||
description="Managed via the Channels page in managed mode."
|
||||
to={ROUTES.CHANNELS}
|
||||
/>
|
||||
) : (
|
||||
<ChannelsSection
|
||||
data={config.channels as any}
|
||||
onSave={(v) => onPatch({ channels: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
<SessionsSection
|
||||
data={config.sessions as any}
|
||||
onSave={(v) => onPatch({ sessions: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
<TtsSection data={config.tts as any} />
|
||||
<CronSection
|
||||
data={config.cron as any}
|
||||
onSave={(v) => onPatch({ cron: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
<TelemetrySection
|
||||
data={config.telemetry as any}
|
||||
onSave={(v) => onPatch({ telemetry: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
<BindingsSection
|
||||
data={config.bindings as any}
|
||||
onSave={(v) => onPatch({ bindings: v })}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,11 @@ export function useConfig() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await ws.call(Methods.CONFIG_PATCH, updates);
|
||||
const res = await ws.call<{ hash: string }>(Methods.CONFIG_PATCH, {
|
||||
raw: JSON.stringify(updates),
|
||||
baseHash: hashRef.current,
|
||||
});
|
||||
hashRef.current = res.hash;
|
||||
await invalidate();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to patch config");
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Save, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances";
|
||||
|
||||
interface QuotaWindow {
|
||||
hour?: number;
|
||||
day?: number;
|
||||
week?: number;
|
||||
}
|
||||
|
||||
interface QuotaData {
|
||||
enabled: boolean;
|
||||
default: QuotaWindow;
|
||||
providers?: Record<string, QuotaWindow>;
|
||||
channels?: Record<string, QuotaWindow>;
|
||||
groups?: Record<string, QuotaWindow>;
|
||||
}
|
||||
|
||||
const DEFAULT_QUOTA: QuotaData = {
|
||||
enabled: false,
|
||||
default: { hour: 40, day: 200, week: 1000 },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
data: { quota?: QuotaData } | undefined;
|
||||
onSave: (value: { quota: QuotaData }) => Promise<void>;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
function QuotaWindowInputs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: QuotaWindow;
|
||||
onChange: (v: QuotaWindow) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip="Max requests per hour (0 = unlimited)">Hour</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.hour ?? 0}
|
||||
onChange={(e) => onChange({ ...value, hour: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip="Max requests per day (0 = unlimited)">Day</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.day ?? 0}
|
||||
onChange={(e) => onChange({ ...value, day: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip="Max requests per week (0 = unlimited)">Week</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.week ?? 0}
|
||||
onChange={(e) => onChange({ ...value, week: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverridesTable({
|
||||
label,
|
||||
tip,
|
||||
entries,
|
||||
onChange,
|
||||
keyPlaceholder,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
tip: string;
|
||||
entries: Record<string, QuotaWindow>;
|
||||
onChange: (v: Record<string, QuotaWindow>) => void;
|
||||
keyPlaceholder: string;
|
||||
options?: { value: string; label: string }[];
|
||||
}) {
|
||||
const keys = Object.keys(entries);
|
||||
const usedKeys = new Set(keys);
|
||||
const availableOptions = options?.filter((o) => !usedKeys.has(o.value));
|
||||
|
||||
const addRow = (key?: string) => {
|
||||
const newKey = key ?? "";
|
||||
if (newKey in entries) return;
|
||||
onChange({ ...entries, [newKey]: { hour: 0, day: 0, week: 0 } });
|
||||
};
|
||||
|
||||
const removeRow = (key: string) => {
|
||||
const next = { ...entries };
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateKey = (oldKey: string, newKey: string) => {
|
||||
if (newKey !== oldKey && newKey in entries) return;
|
||||
const next: Record<string, QuotaWindow> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
next[k === oldKey ? newKey : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateWindow = (key: string, window: QuotaWindow) => {
|
||||
onChange({ ...entries, [key]: window });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip={tip}>{label}</InfoLabel>
|
||||
{keys.map((key, i) => (
|
||||
<div key={i} className="flex items-end gap-2">
|
||||
<div className="grid gap-1.5 min-w-[180px]">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Key</span>
|
||||
)}
|
||||
{options ? (
|
||||
<Select
|
||||
value={key}
|
||||
onValueChange={(v) => updateKey(key, v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={keyPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Current value always shown */}
|
||||
{key && (
|
||||
<SelectItem value={key}>
|
||||
{options.find((o) => o.value === key)?.label ?? key}
|
||||
</SelectItem>
|
||||
)}
|
||||
{availableOptions
|
||||
?.filter((o) => o.value !== key)
|
||||
.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={keyPlaceholder}
|
||||
value={key}
|
||||
onChange={(e) => updateKey(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Hour</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.hour ?? 0}
|
||||
onChange={(e) =>
|
||||
updateWindow(key, {
|
||||
...entries[key],
|
||||
hour: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Day</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.day ?? 0}
|
||||
onChange={(e) =>
|
||||
updateWindow(key, {
|
||||
...entries[key],
|
||||
day: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 w-20">
|
||||
{i === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Week</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entries[key]?.week ?? 0}
|
||||
onChange={(e) =>
|
||||
updateWindow(key, {
|
||||
...entries[key],
|
||||
week: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => removeRow(key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{options ? (
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(v) => addRow(v)}
|
||||
>
|
||||
<SelectTrigger className="w-auto gap-1.5" size="sm">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<SelectValue placeholder="Add override" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOptions && availableOptions.length > 0 ? (
|
||||
availableOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="__none__" disabled>
|
||||
All options added
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => addRow()} className="gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" /> Add override
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotaSection({ data, onSave, saving }: Props) {
|
||||
const [draft, setDraft] = useState<QuotaData>(
|
||||
data?.quota ?? DEFAULT_QUOTA
|
||||
);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const { providers } = useProviders();
|
||||
const { instances } = useChannelInstances();
|
||||
|
||||
const providerOptions = providers.map((p) => ({
|
||||
value: p.name,
|
||||
label: p.display_name || p.name,
|
||||
}));
|
||||
|
||||
// Deduplicate channel types
|
||||
const channelOptions = [
|
||||
...new Map(
|
||||
instances.map((c) => [c.channel_type, c.channel_type])
|
||||
).entries(),
|
||||
].map(([value]) => ({ value, label: value }));
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(data?.quota ?? DEFAULT_QUOTA);
|
||||
setDirty(false);
|
||||
}, [data]);
|
||||
|
||||
const update = (patch: Partial<QuotaData>) => {
|
||||
setDraft((prev) => ({ ...prev, ...patch }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Quota Limits</CardTitle>
|
||||
<CardDescription>
|
||||
Per-user/group request quotas. Limits are enforced per time window
|
||||
(hour/day/week). Config merge priority: Group > Channel >
|
||||
Provider > Default.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<InfoLabel tip="Enable request quota enforcement">Enabled</InfoLabel>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(v) => update({ enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Default limits applied to all users/groups unless overridden">
|
||||
Default Limits
|
||||
</InfoLabel>
|
||||
<QuotaWindowInputs
|
||||
value={draft.default}
|
||||
onChange={(v) => update({ default: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OverridesTable
|
||||
label="Provider Overrides"
|
||||
tip="Per-provider quota limits (e.g., anthropic, openai)"
|
||||
entries={draft.providers ?? {}}
|
||||
onChange={(v) => update({ providers: v })}
|
||||
keyPlaceholder="Select provider"
|
||||
options={providerOptions}
|
||||
/>
|
||||
|
||||
<OverridesTable
|
||||
label="Channel Overrides"
|
||||
tip="Per-channel quota limits (e.g., telegram, discord)"
|
||||
entries={draft.channels ?? {}}
|
||||
onChange={(v) => update({ channels: v })}
|
||||
keyPlaceholder="Select channel"
|
||||
options={channelOptions}
|
||||
/>
|
||||
|
||||
<OverridesTable
|
||||
label="Group/User Overrides"
|
||||
tip="Per-user or group quota limits (e.g., group:telegram:-100123)"
|
||||
entries={draft.groups ?? {}}
|
||||
onChange={(v) => update({ groups: v })}
|
||||
keyPlaceholder="group:telegram:-100123"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{dirty && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onSave({ quota: draft })}
|
||||
disabled={saving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />{" "}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user