feat: add token usage tracking, cost analytics, budget enforcement, wake API, and activity audit trail (#142)

- A1+C2: Include token usage in run.completed event payload for WS clients
- A2: Cost tracking with model pricing config, cost calculation, and cost summary API
- A3: Budget enforcement per agent with monthly budget limits (migration 000015)
- C1: External wake/trigger API (POST /v1/agents/{id}/wake) for orchestrators
- C3: Activity audit trail with structured logging and queryable API
- UI: Activity page, cost stat card on overview, budget section in agent detail
- i18n: Complete en/vi/zh translations for all new features
This commit is contained in:
Viet Tran authored and GitHub committed 2026-03-11 12:52:12 +07:00
1 parent bef428c124
commit 0926d053b0
50 files changed
+1090 -32

No files matched your search

+8
View File
@@ -603,6 +603,9 @@ func runGateway() {
if tracesH != nil {
server.SetTracesHandler(tracesH)
}
// External wake/trigger API
wakeH := httpapi.NewWakeHandler(agentRouter, cfg.Gateway.Token)
server.SetWakeHandler(wakeH)
if mcpH != nil {
server.SetMCPHandler(mcpH)
}
@@ -630,6 +633,11 @@ func runGateway() {
server.SetPendingMessagesHandler(pendingMessagesH)
}
// Activity audit log API
if pgStores.Activity != nil {
server.SetActivityHandler(httpapi.NewActivityHandler(pgStores.Activity, cfg.Gateway.Token))
}
// Memory management API (wired directly, only needs MemoryStore + token)
if pgStores != nil && pgStores.Memory != nil {
server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token))
+3
View File
@@ -28,6 +28,9 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR
summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus)
}
agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus, summoner, isOwner)
if stores.Activity != nil {
agentsH.SetActivityStore(stores.Activity)
}
}
if stores != nil && stores.Skills != nil {
+2
View File
@@ -152,6 +152,8 @@ func wireExtras(
MCPPool: mcpPool,
GroupWriterCache: groupWriterCache,
MediaStore: mediaStore,
ModelPricing: appCfg.Telemetry.ModelPricing,
TracingStore: stores.Tracing,
OnEvent: func(event agent.AgentEvent) {
msgBus.Broadcast(bus.Event{
Name: protocol.EventAgent,
+13
View File
@@ -369,6 +369,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
maxIter = req.MaxIterations
}
// Budget check: query monthly spent once before starting iterations.
if l.budgetMonthlyCents > 0 && l.tracingStore != nil && l.agentUUID != uuid.Nil {
now := time.Now().UTC()
spent, err := l.tracingStore.GetMonthlyAgentCost(ctx, l.agentUUID, now.Year(), now.Month())
if err == nil {
spentCents := int(spent * 100)
if spentCents >= l.budgetMonthlyCents {
slog.Warn("agent budget exceeded", "agent", l.id, "spent_cents", spentCents, "budget_cents", l.budgetMonthlyCents)
return nil, fmt.Errorf("monthly budget exceeded ($%.2f / $%.2f)", spent, float64(l.budgetMonthlyCents)/100)
}
}
}
for iteration < maxIter {
iteration++
+11 -1
View File
@@ -157,11 +157,21 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
return nil, err
}
completedPayload := map[string]any{"content": result.Content}
if result.Usage != nil {
completedPayload["usage"] = map[string]any{
"prompt_tokens": result.Usage.PromptTokens,
"completion_tokens": result.Usage.CompletionTokens,
"total_tokens": result.Usage.TotalTokens,
"cache_creation_tokens": result.Usage.CacheCreationTokens,
"cache_read_tokens": result.Usage.CacheReadTokens,
}
}
emitRun(AgentEvent{
Type: protocol.AgentEventRunCompleted,
AgentID: l.id,
RunID: req.RunID,
Payload: map[string]any{"content": result.Content},
Payload: completedPayload,
})
if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil {
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, 500))
+16
View File
@@ -131,6 +131,13 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time.
}
}
}
// Calculate cost if pricing config is available.
if pricing := tracing.LookupPricing(l.modelPricing, l.provider.Name(), l.model); pricing != nil {
cost := tracing.CalculateCost(pricing, resp.Usage)
if cost > 0 {
updates["total_cost"] = cost
}
}
updates["finish_reason"] = resp.FinishReason
verbose := collector.Verbose()
if verbose {
@@ -237,6 +244,15 @@ func (l *Loop) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID, start time
updates["metadata"] = b
}
}
// Calculate cost for tool's internal LLM calls.
provider := result.Provider
model := result.Model
if pricing := tracing.LookupPricing(l.modelPricing, provider, model); pricing != nil {
cost := tracing.CalculateCost(pricing, result.Usage)
if cost > 0 {
updates["total_cost"] = cost
}
}
}
collector.EmitSpanUpdate(spanID, traceID, updates)
+17
View File
@@ -109,6 +109,13 @@ type Loop struct {
// Persistent media storage for cross-turn image/document access
mediaStore *media.Store
// Model pricing config for cost tracking (nil = no cost calculation)
modelPricing map[string]*config.ModelPricing
// Budget enforcement: monthly spending limit in cents (0 = unlimited)
budgetMonthlyCents int
tracingStore store.TracingStore
}
// AgentEvent is emitted during agent execution for WS broadcasting.
@@ -199,6 +206,13 @@ type LoopConfig struct {
// Persistent media storage for cross-turn image/document access
MediaStore *media.Store
// Model pricing for cost tracking (key = "provider/model" or "model")
ModelPricing map[string]*config.ModelPricing
// Budget enforcement
BudgetMonthlyCents int
TracingStore store.TracingStore
}
func NewLoop(cfg LoopConfig) *Loop {
@@ -263,6 +277,9 @@ func NewLoop(cfg LoopConfig) *Loop {
groupWriterCache: cfg.GroupWriterCache,
teamStore: cfg.TeamStore,
mediaStore: cfg.MediaStore,
modelPricing: cfg.ModelPricing,
budgetMonthlyCents: cfg.BudgetMonthlyCents,
tracingStore: cfg.TracingStore,
}
}
+16
View File
@@ -76,6 +76,12 @@ type ResolverDeps struct {
// Persistent media storage for cross-turn image/document access
MediaStore *media.Store
// Model pricing for cost tracking
ModelPricing map[string]*config.ModelPricing
// Tracing store for budget enforcement queries
TracingStore store.TracingStore
}
// NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data.
@@ -375,6 +381,9 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
GroupWriterCache: deps.GroupWriterCache,
TeamStore: deps.TeamStore,
MediaStore: deps.MediaStore,
ModelPricing: deps.ModelPricing,
BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents),
TracingStore: deps.TracingStore,
})
slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider)
@@ -400,3 +409,10 @@ func (r *Router) InvalidateAll() {
slog.Debug("invalidated all agent caches")
}
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}
+3 -1
View File
@@ -195,6 +195,7 @@ type QuotaUsageResult struct {
RequestsToday int `json:"requestsToday"`
InputTokensToday int64 `json:"inputTokensToday"`
OutputTokensToday int64 `json:"outputTokensToday"`
CostToday float64 `json:"costToday"`
UniqueUsersToday int `json:"uniqueUsersToday"`
Entries []QuotaUsageEntry `json:"entries"`
}
@@ -276,11 +277,12 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult
COUNT(*),
COALESCE(SUM(total_input_tokens), 0),
COALESCE(SUM(total_output_tokens), 0),
COALESCE(SUM(total_cost), 0),
COUNT(DISTINCT user_id)
FROM traces
WHERE parent_trace_id IS NULL AND created_at >= $1`,
startOfDay,
).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.UniqueUsersToday)
).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.CostToday, &result.UniqueUsersToday)
if err != nil {
slog.Warn("quota.usage: failed to query today summary", "error", err)
}
+15 -6
View File
@@ -296,16 +296,25 @@ func (sc *SandboxConfig) ToSandboxConfig() sandbox.Config {
return cfg
}
// ModelPricing defines per-million-token pricing for a model.
type ModelPricing struct {
InputPerMillion float64 `json:"input_per_million"`
OutputPerMillion float64 `json:"output_per_million"`
CacheReadPerMillion float64 `json:"cache_read_per_million,omitempty"`
CacheCreatePerMillion float64 `json:"cache_create_per_million,omitempty"`
}
// TelemetryConfig configures OpenTelemetry export for traces and spans.
// When enabled, spans are exported to an OTLP-compatible backend (Jaeger, Tempo, Datadog, etc.)
// in addition to PostgreSQL storage.
type TelemetryConfig struct {
Enabled bool `json:"enabled,omitempty"` // enable OTLP export (default false)
Endpoint string `json:"endpoint,omitempty"` // OTLP endpoint (e.g. "localhost:4317", "https://otel.example.com:4318")
Protocol string `json:"protocol,omitempty"` // "grpc" (default) or "http"
Insecure bool `json:"insecure,omitempty"` // skip TLS verification (default false, set true for local dev)
ServiceName string `json:"service_name,omitempty"` // OTEL service name (default "goclaw-gateway")
Headers map[string]string `json:"headers,omitempty"` // extra headers (e.g. auth tokens for cloud backends)
Enabled bool `json:"enabled,omitempty"` // enable OTLP export (default false)
Endpoint string `json:"endpoint,omitempty"` // OTLP endpoint (e.g. "localhost:4317", "https://otel.example.com:4318")
Protocol string `json:"protocol,omitempty"` // "grpc" (default) or "http"
Insecure bool `json:"insecure,omitempty"` // skip TLS verification (default false, set true for local dev)
ServiceName string `json:"service_name,omitempty"` // OTEL service name (default "goclaw-gateway")
Headers map[string]string `json:"headers,omitempty"` // extra headers (e.g. auth tokens for cloud backends)
ModelPricing map[string]*ModelPricing `json:"model_pricing,omitempty"` // cost per model, key = "provider/model" or just "model"
}
// CronConfig configures the cron job system.
+17
View File
@@ -40,6 +40,7 @@ type Server struct {
agentsHandler *httpapi.AgentsHandler // agent CRUD API
skillsHandler *httpapi.SkillsHandler // skill management API
tracesHandler *httpapi.TracesHandler // LLM trace listing API
wakeHandler *httpapi.WakeHandler // external wake/trigger API
mcpHandler *httpapi.MCPHandler // MCP server management API
customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API
channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API
@@ -54,6 +55,7 @@ type Server struct {
storageHandler *httpapi.StorageHandler // storage file management
mediaUploadHandler *httpapi.MediaUploadHandler // media upload endpoint
mediaServeHandler *httpapi.MediaServeHandler // media serve endpoint
activityHandler *httpapi.ActivityHandler // activity audit log API
agentStore store.AgentStore // for context injection in tools_invoke
msgBus *bus.MessageBus // for MCP bridge media delivery
@@ -175,6 +177,11 @@ func (s *Server) BuildMux() *http.ServeMux {
s.tracesHandler.RegisterRoutes(mux)
}
// External wake/trigger API
if s.wakeHandler != nil {
s.wakeHandler.RegisterRoutes(mux)
}
// MCP server management API
if s.mcpHandler != nil {
s.mcpHandler.RegisterRoutes(mux)
@@ -240,6 +247,10 @@ func (s *Server) BuildMux() *http.ServeMux {
s.mediaServeHandler.RegisterRoutes(mux)
}
if s.activityHandler != nil {
s.activityHandler.RegisterRoutes(mux)
}
// OAuth endpoints (available in all modes)
if s.oauthHandler != nil {
s.oauthHandler.RegisterRoutes(mux)
@@ -410,6 +421,9 @@ func (s *Server) SetSkillsHandler(h *httpapi.SkillsHandler) { s.skillsHandler =
// SetTracesHandler sets the LLM trace listing handler.
func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler = h }
// SetWakeHandler sets the external wake/trigger handler.
func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.wakeHandler = h }
// SetMCPHandler sets the MCP server management handler.
func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h }
@@ -458,6 +472,9 @@ func (s *Server) SetMemoryHandler(h *httpapi.MemoryHandler) { s.memoryHandler =
// SetKnowledgeGraphHandler sets the knowledge graph handler.
func (s *Server) SetKnowledgeGraphHandler(h *httpapi.KnowledgeGraphHandler) { s.kgHandler = h }
// SetActivityHandler sets the activity audit log handler.
func (s *Server) SetActivityHandler(h *httpapi.ActivityHandler) { s.activityHandler = h }
// SetAgentStore sets the agent store for context injection in tools_invoke.
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
+89
View File
@@ -0,0 +1,89 @@
package http
import (
"net/http"
"strconv"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ActivityHandler handles activity audit log endpoints.
type ActivityHandler struct {
activity store.ActivityStore
token string
}
// NewActivityHandler creates a handler for activity log endpoints.
func NewActivityHandler(activity store.ActivityStore, token string) *ActivityHandler {
return &ActivityHandler{activity: activity, token: token}
}
// RegisterRoutes registers activity routes on the given mux.
func (h *ActivityHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/activity", h.authMiddleware(h.handleList))
}
func (h *ActivityHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
locale := extractLocale(r)
ctx := store.WithLocale(r.Context(), locale)
r = r.WithContext(ctx)
next(w, r)
}
}
func (h *ActivityHandler) handleList(w http.ResponseWriter, r *http.Request) {
opts := store.ActivityListOpts{
Limit: 50,
Offset: 0,
}
if v := r.URL.Query().Get("actor_type"); v != "" {
opts.ActorType = v
}
if v := r.URL.Query().Get("actor_id"); v != "" {
opts.ActorID = v
}
if v := r.URL.Query().Get("action"); v != "" {
opts.Action = v
}
if v := r.URL.Query().Get("entity_type"); v != "" {
opts.EntityType = v
}
if v := r.URL.Query().Get("entity_id"); v != "" {
opts.EntityID = v
}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
opts.Limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
opts.Offset = n
}
}
logs, err := h.activity.List(r.Context(), opts)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
total, _ := h.activity.Count(r.Context(), opts)
writeJSON(w, http.StatusOK, map[string]any{
"logs": logs,
"total": total,
"limit": opts.Limit,
"offset": opts.Offset,
})
}
+22
View File
@@ -23,8 +23,12 @@ type AgentsHandler struct {
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
activity store.ActivityStore // optional audit logging (nil = disabled)
}
// SetActivityStore sets the optional activity audit store.
func (h *AgentsHandler) SetActivityStore(a store.ActivityStore) { h.activity = a }
// NewAgentsHandler creates a handler for agent management endpoints.
// isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled).
func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler {
@@ -47,6 +51,21 @@ func (h *AgentsHandler) emitCacheInvalidate(kind, key string) {
})
}
// logActivity records an audit log entry if the activity store is set.
func (h *AgentsHandler) logActivity(r *http.Request, actorID, action, entityType, entityID string) {
if h.activity == nil {
return
}
h.activity.Log(r.Context(), &store.ActivityLog{
ActorType: "user",
ActorID: actorID,
Action: action,
EntityType: entityType,
EntityID: entityID,
IPAddress: r.RemoteAddr,
})
}
// RegisterRoutes registers all agent management routes on the given mux.
func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/agents", h.authMiddleware(h.handleList))
@@ -184,6 +203,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description)
}
h.logActivity(r, userID, "agent.created", "agent", req.ID.String())
writeJSON(w, http.StatusCreated, req)
}
@@ -279,6 +299,7 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
}
}
h.logActivity(r, userID, "agent.updated", "agent", id.String())
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
@@ -311,5 +332,6 @@ func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey)
h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String())
h.logActivity(r, userID, "agent.deleted", "agent", id.String())
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
+31
View File
@@ -3,6 +3,7 @@ package http
import (
"net/http"
"strconv"
"time"
"github.com/google/uuid"
@@ -25,6 +26,7 @@ func NewTracesHandler(tracing store.TracingStore, token string) *TracesHandler {
func (h *TracesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/traces", h.authMiddleware(h.handleList))
mux.HandleFunc("GET /v1/traces/{traceID}", h.authMiddleware(h.handleGet))
mux.HandleFunc("GET /v1/costs/summary", h.authMiddleware(h.handleCostSummary))
}
func (h *TracesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
@@ -117,3 +119,32 @@ func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
"spans": spans,
})
}
func (h *TracesHandler) handleCostSummary(w http.ResponseWriter, r *http.Request) {
opts := store.CostSummaryOpts{}
if v := r.URL.Query().Get("agent_id"); v != "" {
id, err := uuid.Parse(v)
if err == nil {
opts.AgentID = &id
}
}
if v := r.URL.Query().Get("from"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
opts.From = &t
}
}
if v := r.URL.Query().Get("to"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
opts.To = &t
}
}
rows, err := h.tracing.GetCostSummary(r.Context(), opts)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
+135
View File
@@ -0,0 +1,135 @@
package http
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// WakeHandler handles POST /v1/agents/{id}/wake — external trigger API.
// Allows orchestrators (Paperclip, n8n, etc.) to trigger agent runs via HTTP.
type WakeHandler struct {
agents *agent.Router
token string
}
// NewWakeHandler creates a handler for the wake endpoint.
func NewWakeHandler(agents *agent.Router, token string) *WakeHandler {
return &WakeHandler{agents: agents, token: token}
}
// RegisterRoutes registers wake routes on the given mux.
func (h *WakeHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/agents/{id}/wake", h.handleWake)
}
type wakeRequest struct {
Message string `json:"message"`
SessionKey string `json:"session_key,omitempty"`
UserID string `json:"user_id,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type wakeResponse struct {
Content string `json:"content"`
RunID string `json:"run_id"`
Usage *wakeUsage `json:"usage,omitempty"`
}
type wakeUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
// Auth check
if h.token != "" && !tokenMatch(extractBearerToken(r), h.token) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
agentID := r.PathValue("id")
if agentID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Limit request body size
const maxBodySize = 1 << 20 // 1MB
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
var req wakeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
if req.Message == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"})
return
}
loop, err := h.agents.Get(agentID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID)})
return
}
// Build session key
sessionKey := req.SessionKey
if sessionKey == "" {
sessionKey = sessions.SessionKey(agentID, "wake-"+uuid.NewString()[:8])
}
userID := req.UserID
if userID == "" {
userID = extractUserID(r)
}
ctx := store.WithLocale(r.Context(), locale)
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
runID := uuid.NewString()
slog.Info("wake request", "agent", agentID, "user", userID, "session", sessionKey)
result, err := loop.Run(ctx, agent.RunRequest{
SessionKey: sessionKey,
Message: req.Message,
Channel: "wake",
ChatID: "api",
RunID: runID,
UserID: userID,
Stream: false,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("agent run failed: %v", err)})
return
}
resp := wakeResponse{
Content: result.Content,
RunID: runID,
}
if result.Usage != nil {
resp.Usage = &wakeUsage{
PromptTokens: result.Usage.PromptTokens,
CompletionTokens: result.Usage.CompletionTokens,
TotalTokens: result.Usage.TotalTokens,
}
}
writeJSON(w, http.StatusOK, resp)
}
+40
View File
@@ -0,0 +1,40 @@
package store
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
// ActivityLog represents a single audit log entry.
type ActivityLog struct {
ID uuid.UUID `json:"id"`
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id"`
Action string `json:"action"`
EntityType string `json:"entity_type,omitempty"`
EntityID string `json:"entity_id,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ActivityListOpts configures activity log listing.
type ActivityListOpts struct {
ActorType string
ActorID string
Action string
EntityType string
EntityID string
Limit int
Offset int
}
// ActivityStore manages activity audit logs.
type ActivityStore interface {
Log(ctx context.Context, entry *ActivityLog) error
List(ctx context.Context, opts ActivityListOpts) ([]ActivityLog, error)
Count(ctx context.Context, opts ActivityListOpts) (int, error)
}
+3
View File
@@ -39,6 +39,9 @@ type AgentData struct {
IsDefault bool `json:"is_default"`
Status string `json:"status"`
// Budget: optional monthly spending limit in cents (nil = unlimited)
BudgetMonthlyCents *int `json:"budget_monthly_cents,omitempty"`
// Per-agent JSONB config (nullable — nil means "use global defaults")
ToolsConfig json.RawMessage `json:"tools_config,omitempty"`
SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"`
+107
View File
@@ -0,0 +1,107 @@
package pg
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGActivityStore implements store.ActivityStore backed by Postgres.
type PGActivityStore struct {
db *sql.DB
}
// NewPGActivityStore creates a new PGActivityStore.
func NewPGActivityStore(db *sql.DB) *PGActivityStore {
return &PGActivityStore{db: db}
}
func (s *PGActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
entry.ActorType, entry.ActorID, entry.Action,
entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress,
)
return err
}
func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) {
where, args := buildActivityWhere(opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
args = append(args, limit, opts.Offset)
query := fmt.Sprintf(
`SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), details, COALESCE(ip_address,''), created_at
FROM activity_logs %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`,
where, len(args)-1, len(args),
)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.ActivityLog
for rows.Next() {
var a store.ActivityLog
if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.EntityType, &a.EntityID, &a.Details, &a.IPAddress, &a.CreatedAt); err != nil {
return nil, err
}
result = append(result, a)
}
return result, rows.Err()
}
func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) {
where, args := buildActivityWhere(opts)
query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where)
var count int
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
return count, err
}
func buildActivityWhere(opts store.ActivityListOpts) (string, []any) {
var conditions []string
var args []any
idx := 1
if opts.ActorType != "" {
conditions = append(conditions, fmt.Sprintf("actor_type = $%d", idx))
args = append(args, opts.ActorType)
idx++
}
if opts.ActorID != "" {
conditions = append(conditions, fmt.Sprintf("actor_id = $%d", idx))
args = append(args, opts.ActorID)
idx++
}
if opts.Action != "" {
conditions = append(conditions, fmt.Sprintf("action = $%d", idx))
args = append(args, opts.Action)
idx++
}
if opts.EntityType != "" {
conditions = append(conditions, fmt.Sprintf("entity_type = $%d", idx))
args = append(args, opts.EntityType)
idx++
}
if opts.EntityID != "" {
conditions = append(conditions, fmt.Sprintf("entity_id = $%d", idx))
args = append(args, opts.EntityID)
idx++
}
if len(conditions) == 0 {
return "", nil
}
return "WHERE " + strings.Join(conditions, " AND "), args
}
+5 -5
View File
@@ -104,7 +104,7 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro
context_window, max_tool_iterations, workspace, restrict_to_workspace,
tools_config, sandbox_config, subagents_config, memory_config,
compaction_config, context_pruning, other_config,
agent_type, is_default, status, created_at, updated_at`
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at`
func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
if agent.ID == uuid.Nil {
@@ -118,13 +118,13 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error
context_window, max_tool_iterations, workspace, restrict_to_workspace,
tools_config, sandbox_config, subagents_config, memory_config,
compaction_config, context_pruning, other_config,
agent_type, is_default, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`,
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
agent.ID, agent.AgentKey, agent.DisplayName, sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, agent.OwnerID, agent.Provider, agent.Model,
agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace,
jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig),
jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig),
agent.AgentType, agent.IsDefault, agent.Status, now, now,
agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now,
)
if err != nil {
return err
@@ -330,7 +330,7 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model,
&d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace,
&toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg,
&d.AgentType, &d.IsDefault, &d.Status, &d.CreatedAt, &d.UpdatedAt)
&d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
return nil, err
}
+1
View File
@@ -42,5 +42,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
PendingMessages: NewPGPendingMessageStore(db),
KnowledgeGraph: NewPGKnowledgeGraphStore(db),
Contacts: NewPGContactStore(db),
Activity: NewPGActivityStore(db),
}, nil
}
+69 -7
View File
@@ -29,14 +29,14 @@ func (s *PGTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData
_, err := s.db.ExecContext(ctx,
`INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count,
total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey),
nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime),
nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel),
nilStr(trace.InputPreview), nilStr(trace.OutputPreview),
trace.TotalInputTokens, trace.TotalOutputTokens, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount,
trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount,
trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), trace.CreatedAt,
)
return err
@@ -58,12 +58,12 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor
err := s.db.QueryRowContext(ctx,
`SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count,
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, created_at
FROM traces WHERE id = $1`, traceID,
).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime,
&durationMS, &name, &channel, &inputPreview, &outputPreview,
&d.TotalInputTokens, &d.TotalOutputTokens, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
&d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
&d.Status, &errStr, &metadata, &tags, &d.CreatedAt)
if err != nil {
return nil, err
@@ -135,7 +135,7 @@ func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpt
q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count,
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, created_at
FROM traces` + where
@@ -163,7 +163,7 @@ func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpt
if err := rows.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime,
&durationMS, &name, &channel, &inputPreview, &outputPreview,
&d.TotalInputTokens, &d.TotalOutputTokens, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
&d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
&d.Status, &errStr, &metadata, &tags, &d.CreatedAt); err != nil {
continue
}
@@ -346,6 +346,7 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID
tool_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = $1 AND span_type = 'tool_call'),
total_input_tokens = COALESCE((SELECT SUM(input_tokens) FROM spans WHERE trace_id = $1 AND span_type = 'llm_call' AND input_tokens IS NOT NULL), 0),
total_output_tokens = COALESCE((SELECT SUM(output_tokens) FROM spans WHERE trace_id = $1 AND span_type = 'llm_call' AND output_tokens IS NOT NULL), 0),
total_cost = COALESCE((SELECT SUM(total_cost) FROM spans WHERE trace_id = $1 AND total_cost IS NOT NULL), 0),
metadata = (
SELECT jsonb_build_object(
'total_cache_read_tokens', COALESCE(SUM((metadata->>'cache_read_tokens')::int), 0),
@@ -356,3 +357,64 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID
WHERE id = $1`, traceID)
return err
}
func (s *PGTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) {
start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
end := start.AddDate(0, 1, 0)
var cost float64
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(total_cost), 0) FROM traces
WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL`,
agentID, start, end,
).Scan(&cost)
return cost, err
}
func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSummaryOpts) ([]store.CostSummaryRow, error) {
var conditions []string
var args []any
argIdx := 1
// Only root traces (not delegations)
conditions = append(conditions, "parent_trace_id IS NULL")
if opts.AgentID != nil {
conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx))
args = append(args, *opts.AgentID)
argIdx++
}
if opts.From != nil {
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", argIdx))
args = append(args, *opts.From)
argIdx++
}
if opts.To != nil {
conditions = append(conditions, fmt.Sprintf("created_at < $%d", argIdx))
args = append(args, *opts.To)
argIdx++
}
where := " WHERE " + strings.Join(conditions, " AND ")
q := `SELECT agent_id, COALESCE(SUM(total_cost), 0), COALESCE(SUM(total_input_tokens), 0),
COALESCE(SUM(total_output_tokens), 0), COUNT(*)
FROM traces` + where + ` GROUP BY agent_id ORDER BY SUM(total_cost) DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.CostSummaryRow
for rows.Next() {
var r store.CostSummaryRow
var agentID *uuid.UUID
if err := rows.Scan(&agentID, &r.TotalCost, &r.TotalInputTokens, &r.TotalOutputTokens, &r.TraceCount); err != nil {
continue
}
r.AgentID = agentID
result = append(result, r)
}
return result, nil
}
+1
View File
@@ -23,4 +23,5 @@ type Stores struct {
PendingMessages PendingMessageStore
KnowledgeGraph KnowledgeGraphStore
Contacts ContactStore
Activity ActivityStore
}
+22
View File
@@ -54,6 +54,7 @@ type TraceData struct {
OutputPreview string `json:"output_preview,omitempty"`
TotalInputTokens int `json:"total_input_tokens"`
TotalOutputTokens int `json:"total_output_tokens"`
TotalCost float64 `json:"total_cost"`
SpanCount int `json:"span_count"`
LLMCallCount int `json:"llm_call_count"`
ToolCallCount int `json:"tool_call_count"`
@@ -82,6 +83,7 @@ type SpanData struct {
Provider string `json:"provider,omitempty"`
InputTokens int `json:"input_tokens,omitempty"`
OutputTokens int `json:"output_tokens,omitempty"`
TotalCost *float64 `json:"total_cost,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
ModelParams json.RawMessage `json:"model_params,omitempty"`
ToolName string `json:"tool_name,omitempty"`
@@ -102,6 +104,22 @@ type TraceListOpts struct {
Offset int
}
// CostSummaryOpts configures cost aggregation queries.
type CostSummaryOpts struct {
AgentID *uuid.UUID
From *time.Time
To *time.Time
}
// CostSummaryRow is a single row of aggregated cost data.
type CostSummaryRow struct {
AgentID *uuid.UUID `json:"agent_id,omitempty"`
TotalCost float64 `json:"total_cost"`
TotalInputTokens int `json:"total_input_tokens"`
TotalOutputTokens int `json:"total_output_tokens"`
TraceCount int `json:"trace_count"`
}
// TracingStore manages LLM traces and spans.
type TracingStore interface {
CreateTrace(ctx context.Context, trace *TraceData) error
@@ -117,4 +135,8 @@ type TracingStore interface {
// Batch operations (async flush)
BatchCreateSpans(ctx context.Context, spans []SpanData) error
BatchUpdateTraceAggregates(ctx context.Context, traceID uuid.UUID) error
// Cost aggregation
GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error)
GetCostSummary(ctx context.Context, opts CostSummaryOpts) ([]CostSummaryRow, error)
}
+38
View File
@@ -0,0 +1,38 @@
package tracing
import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// CalculateCost computes the USD cost for a single LLM call based on token usage and pricing.
// Returns 0 if pricing is nil.
func CalculateCost(pricing *config.ModelPricing, usage *providers.Usage) float64 {
if pricing == nil || usage == nil {
return 0
}
cost := float64(usage.PromptTokens) * pricing.InputPerMillion / 1_000_000
cost += float64(usage.CompletionTokens) * pricing.OutputPerMillion / 1_000_000
if pricing.CacheReadPerMillion > 0 && usage.CacheReadTokens > 0 {
cost += float64(usage.CacheReadTokens) * pricing.CacheReadPerMillion / 1_000_000
}
if pricing.CacheCreatePerMillion > 0 && usage.CacheCreationTokens > 0 {
cost += float64(usage.CacheCreationTokens) * pricing.CacheCreatePerMillion / 1_000_000
}
return cost
}
// LookupPricing finds the model pricing from config.
// Tries "provider/model" first, then just "model".
func LookupPricing(pricingMap map[string]*config.ModelPricing, provider, model string) *config.ModelPricing {
if pricingMap == nil {
return nil
}
if p, ok := pricingMap[provider+"/"+model]; ok {
return p
}
if p, ok := pricingMap[model]; ok {
return p
}
return nil
}
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 14
const RequiredSchemaVersion uint = 15
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS activity_logs;
ALTER TABLE agents DROP COLUMN IF EXISTS budget_monthly_cents;
+18
View File
@@ -0,0 +1,18 @@
ALTER TABLE agents ADD COLUMN budget_monthly_cents INTEGER;
CREATE TABLE activity_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
actor_type VARCHAR(20) NOT NULL,
actor_id VARCHAR(255) NOT NULL,
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(50),
entity_id VARCHAR(255),
details JSONB,
ip_address VARCHAR(45),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_activity_logs_actor ON activity_logs (actor_type, actor_id);
CREATE INDEX idx_activity_logs_action ON activity_logs (action);
CREATE INDEX idx_activity_logs_entity ON activity_logs (entity_type, entity_id);
CREATE INDEX idx_activity_logs_created ON activity_logs (created_at DESC);
+2
View File
@@ -19,6 +19,7 @@ import {
Volume2,
Cpu,
ArrowRightLeft,
ClipboardList,
HardDrive,
Inbox,
Brain,
@@ -106,6 +107,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
<SidebarItem to={ROUTES.EVENTS} icon={Radar} label={t("nav.realtimeEvents")} collapsed={collapsed} />
<SidebarItem to={ROUTES.DELEGATIONS} icon={ArrowRightLeft} label={t("nav.delegations")} collapsed={collapsed} />
<SidebarItem to={ROUTES.USAGE} icon={BarChart3} label={t("nav.usage")} collapsed={collapsed} />
<SidebarItem to={ROUTES.ACTIVITY} icon={ClipboardList} label={t("nav.activity")} collapsed={collapsed} />
<SidebarItem to={ROUTES.LOGS} icon={Terminal} label={t("nav.logs")} collapsed={collapsed} />
</SidebarGroup>
+7 -4
View File
@@ -31,6 +31,7 @@ import enMemory from "./locales/en/memory.json";
import enStorage from "./locales/en/storage.json";
import enPendingMessages from "./locales/en/pending-messages.json";
import enContacts from "./locales/en/contacts.json";
import enActivity from "./locales/en/activity.json";
// --- VI namespaces ---
import viCommon from "./locales/vi/common.json";
@@ -62,6 +63,7 @@ import viMemory from "./locales/vi/memory.json";
import viStorage from "./locales/vi/storage.json";
import viPendingMessages from "./locales/vi/pending-messages.json";
import viContacts from "./locales/vi/contacts.json";
import viActivity from "./locales/vi/activity.json";
// --- ZH namespaces ---
import zhCommon from "./locales/zh/common.json";
@@ -93,6 +95,7 @@ import zhMemory from "./locales/zh/memory.json";
import zhStorage from "./locales/zh/storage.json";
import zhPendingMessages from "./locales/zh/pending-messages.json";
import zhContacts from "./locales/zh/contacts.json";
import zhActivity from "./locales/zh/activity.json";
const STORAGE_KEY = "goclaw:language";
@@ -110,7 +113,7 @@ const ns = [
"agents", "teams", "sessions", "skills", "cron", "config",
"channels", "providers", "traces", "events", "delegations",
"usage", "approvals", "nodes", "logs", "tools", "mcp", "tts",
"setup", "memory", "storage", "pending-messages", "contacts",
"setup", "memory", "storage", "pending-messages", "contacts", "activity",
] as const;
i18n.use(initReactI18next).init({
@@ -124,7 +127,7 @@ i18n.use(initReactI18next).init({
approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools,
mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage,
"pending-messages": enPendingMessages,
contacts: enContacts,
contacts: enContacts, activity: enActivity,
},
vi: {
common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin,
@@ -135,7 +138,7 @@ i18n.use(initReactI18next).init({
approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools,
mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage,
"pending-messages": viPendingMessages,
contacts: viContacts,
contacts: viContacts, activity: viActivity,
},
zh: {
common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin,
@@ -146,7 +149,7 @@ i18n.use(initReactI18next).init({
approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools,
mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage,
"pending-messages": zhPendingMessages,
contacts: zhContacts,
contacts: zhContacts, activity: zhActivity,
},
},
ns: [...ns],
+26
View File
@@ -0,0 +1,26 @@
{
"title": "Activity Log",
"description": "Audit trail of admin and configuration changes.",
"filters": {
"action": "Action",
"allActions": "All Actions",
"agentCreated": "Agent Created",
"agentUpdated": "Agent Updated",
"agentDeleted": "Agent Deleted",
"entityType": "Entity Type",
"allEntities": "All Entities",
"agent": "Agent"
},
"table": {
"action": "Action",
"actor": "Actor",
"entity": "Entity",
"entityId": "Entity ID",
"ip": "IP Address",
"time": "Time"
},
"empty": {
"title": "No activity logs",
"description": "Activity logs will appear here when admin actions are performed."
}
}
+3
View File
@@ -81,6 +81,9 @@
"selfEvolutionLabel": "Allow agent to evolve its communication style",
"selfEvolutionHint": "When enabled, the agent can update its SOUL.md to refine tone, vocabulary, and response style based on interactions. Identity, name, and operating instructions remain locked.",
"selfEvolutionInfo": "Agent will evolve its style over time through SOUL.md updates. Only style and tone are affected — identity and workflow rules stay fixed.",
"budget": "Monthly Budget",
"budgetLabel": "Budget limit (USD)",
"budgetHint": "Optional monthly spending cap. Agent will reject requests when exceeded. Leave empty for unlimited.",
"saveChanges": "Save Changes",
"saving": "Saving...",
"saved": "Saved",
+1
View File
@@ -4,6 +4,7 @@
"statCards": {
"requestsToday": "Requests Today",
"tokensToday": "Tokens Today",
"costToday": "Cost Today",
"agents": "Agents",
"channels": "Channels",
"running": "running",
+2 -1
View File
@@ -34,6 +34,7 @@
"config": "Config",
"approvals": "Approvals",
"nodes": "Nodes",
"tts": "TTS"
"tts": "TTS",
"activity": "Activity"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"title": "Nhật ký hoạt động",
"description": "Lịch sử kiểm toán các thay đổi quản trị và cấu hình.",
"filters": {
"action": "Hành động",
"allActions": "Tất cả",
"agentCreated": "Tạo Agent",
"agentUpdated": "Cập nhật Agent",
"agentDeleted": "Xóa Agent",
"entityType": "Loại đối tượng",
"allEntities": "Tất cả",
"agent": "Agent"
},
"table": {
"action": "Hành động",
"actor": "Người thực hiện",
"entity": "Đối tượng",
"entityId": "ID đối tượng",
"ip": "Địa chỉ IP",
"time": "Thời gian"
},
"empty": {
"title": "Chưa có nhật ký",
"description": "Nhật ký hoạt động sẽ xuất hiện khi có thao tác quản trị."
}
}
+3
View File
@@ -81,6 +81,9 @@
"selfEvolutionLabel": "Cho phép agent tiến hóa phong cách giao tiếp",
"selfEvolutionHint": "Khi được bật, agent có thể cập nhật SOUL.md để tinh chỉnh giọng điệu, từ vựng và phong cách phản hồi dựa trên tương tác. Nhận dạng, tên và hướng dẫn hoạt động vẫn bị khóa.",
"selfEvolutionInfo": "Agent sẽ tiến hóa phong cách theo thời gian qua các cập nhật SOUL.md. Chỉ phong cách và giọng điệu bị ảnh hưởng — nhận dạng và quy tắc quy trình làm việc vẫn cố định.",
"budget": "Ngân sách hàng tháng",
"budgetLabel": "Giới hạn ngân sách (USD)",
"budgetHint": "Giới hạn chi tiêu hàng tháng tùy chọn. Agent sẽ từ chối yêu cầu khi vượt quá. Để trống cho không giới hạn.",
"saveChanges": "Lưu thay đổi",
"saving": "Đang lưu...",
"saved": "Đã lưu",
+1
View File
@@ -56,6 +56,7 @@
"requestsToday": "Request hôm nay",
"running": "Đang chạy",
"tokensToday": "Token hôm nay",
"costToday": "Chi phí hôm nay",
"users": "Người dùng"
},
"systemHealth": {
+2 -1
View File
@@ -34,6 +34,7 @@
"config": "Cấu hình",
"approvals": "Phê duyệt",
"nodes": "Nodes",
"tts": "TTS"
"tts": "TTS",
"activity": "Hoạt động"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"title": "活动日志",
"description": "管理和配置变更的审计记录。",
"filters": {
"action": "操作",
"allActions": "全部",
"agentCreated": "创建 Agent",
"agentUpdated": "更新 Agent",
"agentDeleted": "删除 Agent",
"entityType": "实体类型",
"allEntities": "全部",
"agent": "Agent"
},
"table": {
"action": "操作",
"actor": "操作者",
"entity": "实体",
"entityId": "实体 ID",
"ip": "IP 地址",
"time": "时间"
},
"empty": {
"title": "暂无活动日志",
"description": "执行管理操作后,活动日志将在此显示。"
}
}
+3
View File
@@ -81,6 +81,9 @@
"selfEvolutionLabel": "允许Agent进化其沟通风格",
"selfEvolutionHint": "启用后,Agent可以更新其 SOUL.md 以根据交互改善语调、词汇和响应风格。身份、名称和操作指令保持锁定。",
"selfEvolutionInfo": "Agent将通过 SOUL.md 更新随时间进化其风格。只有风格和语调受影响 — 身份和工作流规则保持固定。",
"budget": "月度预算",
"budgetLabel": "预算限额 (USD)",
"budgetHint": "可选的月度支出上限。超出后 Agent 将拒绝请求。留空表示无限制。",
"saveChanges": "保存更改",
"saving": "保存中...",
"saved": "已保存",
+1
View File
@@ -56,6 +56,7 @@
"requestsToday": "今日请求",
"running": "运行中",
"tokensToday": "今日 Token",
"costToday": "今日费用",
"users": "用户"
},
"systemHealth": {
+2 -1
View File
@@ -34,6 +34,7 @@
"storage": "存储",
"traces": "追踪",
"tts": "TTS",
"usage": "用量"
"usage": "用量",
"activity": "活动日志"
}
}
+1
View File
@@ -34,6 +34,7 @@ export const ROUTES = {
PENDING_MESSAGES: "/pending-messages",
MEMORY: "/memory",
KNOWLEDGE_GRAPH: "/knowledge-graph",
ACTIVITY: "/activity",
SETUP: "/setup",
} as const;
+6
View File
@@ -31,6 +31,12 @@ export function formatTokens(count: number | null | undefined): string {
return count.toString();
}
export function formatCost(cost: number | null | undefined): string {
if (cost == null || cost === 0) return "$0.00";
if (cost < 0.01) return `$${cost.toFixed(4)}`;
return `$${cost.toFixed(2)}`;
}
export function formatDuration(ms: number | undefined | null): string {
if (ms == null || isNaN(ms)) return "—";
if (ms < 1000) return `${ms}ms`;
+158
View File
@@ -0,0 +1,158 @@
import { useState, useEffect } from "react";
import { ClipboardList, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { formatDate } from "@/lib/format";
import { useActivity } from "./hooks/use-activity";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
const ACTION_COLORS: Record<string, string> = {
"agent.created": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
"agent.updated": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
"agent.deleted": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
};
export function ActivityPage() {
const { t } = useTranslation("activity");
const { logs, total, loading, load } = useActivity();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && logs.length === 0);
const [actionFilter, setActionFilter] = useState("all");
const [entityFilter, setEntityFilter] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
useEffect(() => {
load({
action: actionFilter !== "all" ? actionFilter : undefined,
entity_type: entityFilter !== "all" ? entityFilter : undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
});
}, [page, pageSize, actionFilter, entityFilter]);
const handleRefresh = () => {
load({
action: actionFilter !== "all" ? actionFilter : undefined,
entity_type: entityFilter !== "all" ? entityFilter : undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
});
};
return (
<div className="p-4 sm:p-6">
<PageHeader
title={t("title")}
description={t("description")}
actions={
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("common:refresh", "Refresh")}
</Button>
}
/>
{/* Filters */}
<div className="mb-4 flex flex-wrap gap-2">
<Select value={actionFilter} onValueChange={(v) => { setActionFilter(v); setPage(1); }}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("filters.action")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allActions")}</SelectItem>
<SelectItem value="agent.created">{t("filters.agentCreated")}</SelectItem>
<SelectItem value="agent.updated">{t("filters.agentUpdated")}</SelectItem>
<SelectItem value="agent.deleted">{t("filters.agentDeleted")}</SelectItem>
</SelectContent>
</Select>
<Select value={entityFilter} onValueChange={(v) => { setEntityFilter(v); setPage(1); }}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("filters.entityType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allEntities")}</SelectItem>
<SelectItem value="agent">{t("filters.agent")}</SelectItem>
</SelectContent>
</Select>
</div>
{showSkeleton ? (
<TableSkeleton rows={8} />
) : logs.length === 0 ? (
<EmptyState
icon={ClipboardList}
title={t("empty.title")}
description={t("empty.description")}
/>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">{t("table.action")}</th>
<th className="px-3 py-2 text-left font-medium">{t("table.actor")}</th>
<th className="px-3 py-2 text-left font-medium">{t("table.entity")}</th>
<th className="px-3 py-2 text-left font-medium">{t("table.entityId")}</th>
<th className="px-3 py-2 text-left font-medium">{t("table.ip")}</th>
<th className="px-3 py-2 text-left font-medium">{t("table.time")}</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b hover:bg-muted/30">
<td className="px-3 py-2">
<Badge variant="secondary" className={ACTION_COLORS[log.action] ?? ""}>
{log.action}
</Badge>
</td>
<td className="px-3 py-2 font-mono text-xs">
{log.actor_type}:{log.actor_id}
</td>
<td className="px-3 py-2">{log.entity_type || "—"}</td>
<td className="px-3 py-2 font-mono text-xs max-w-[200px] truncate">
{log.entity_id || "—"}
</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{log.ip_address || "—"}
</td>
<td className="px-3 py-2 text-muted-foreground text-xs whitespace-nowrap">
{formatDate(log.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4">
<Pagination
page={page}
totalPages={totalPages}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
/>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { useState, useEffect, useCallback } from "react";
import { useHttp } from "@/hooks/use-ws";
export interface ActivityLog {
id: string;
actor_type: string;
actor_id: string;
action: string;
entity_type: string;
entity_id: string;
details: unknown;
ip_address: string;
created_at: string;
}
interface ActivityListResponse {
logs: ActivityLog[];
total: number;
limit: number;
offset: number;
}
interface ActivityFilters {
action?: string;
entity_type?: string;
actor_id?: string;
limit?: number;
offset?: number;
}
export function useActivity() {
const http = useHttp();
const [logs, setLogs] = useState<ActivityLog[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(
async (filters?: ActivityFilters) => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (filters?.action) params.action = filters.action;
if (filters?.entity_type) params.entity_type = filters.entity_type;
if (filters?.actor_id) params.actor_id = filters.actor_id;
if (filters?.limit) params.limit = String(filters.limit);
if (filters?.offset) params.offset = String(filters.offset);
const res = await http.get<ActivityListResponse>("/v1/activity", params);
setLogs(res.logs ?? []);
setTotal(res.total ?? 0);
} catch {
// ignore
} finally {
setLoading(false);
}
},
[http],
);
useEffect(() => {
load();
}, [load]);
return { logs, total, loading, load };
}
@@ -1,7 +1,8 @@
import { useState, useCallback } from "react";
import { Save, Check, AlertCircle, Sparkles, Info } from "lucide-react";
import { Save, Check, AlertCircle, Sparkles, Info, DollarSign } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
@@ -32,6 +33,11 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
// Workspace
const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
// Budget (stored in cents, displayed in dollars)
const [budgetDollars, setBudgetDollars] = useState(
agent.budget_monthly_cents ? String(agent.budget_monthly_cents / 100) : "",
);
// Self-evolve (predefined agents only)
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve));
@@ -51,6 +57,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
setSaved(false);
try {
const updatedOtherConfig = { ...otherCfg, self_evolve: selfEvolve };
const budgetCents = budgetDollars ? Math.round(parseFloat(budgetDollars) * 100) : null;
await onUpdate({
display_name: displayName,
frontmatter: frontmatter || null,
@@ -62,6 +69,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
status,
is_default: isDefault,
other_config: updatedOtherConfig,
budget_monthly_cents: budgetCents,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
@@ -110,6 +118,33 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
onRestrictChange={setRestrictToWorkspace}
/>
{/* Budget */}
<Separator />
<div className="space-y-3">
<div className="flex items-center gap-3">
<DollarSign className="h-4 w-4 text-emerald-500" />
<h3 className="text-sm font-medium">{t("general.budget")}</h3>
</div>
<div className="space-y-1">
<Label htmlFor="budget" className="text-sm font-normal">
{t("general.budgetLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("general.budgetHint")}
</p>
</div>
<Input
id="budget"
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={budgetDollars}
onChange={(e) => setBudgetDollars(e.target.value)}
className="max-w-[200px]"
/>
</div>
{/* Self-Evolve (predefined agents only) */}
{agent.agent_type === "predefined" && (
<>
+8 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useCallback } from "react";
import { Activity, Bot, Hash, Radio, AlertTriangle } from "lucide-react";
import { Activity, Bot, DollarSign, Hash, Radio, AlertTriangle } from "lucide-react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@/components/shared/page-header";
@@ -12,7 +12,7 @@ import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useTraces } from "@/pages/traces/hooks/use-traces";
import { Methods, Events } from "@/api/protocol";
import { ROUTES } from "@/lib/constants";
import { formatTokens } from "@/lib/format";
import { formatTokens, formatCost } from "@/lib/format";
import type {
HealthPayload,
@@ -132,7 +132,7 @@ export function OverviewPage() {
)}
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard
icon={Activity}
label={t("statCards.requestsToday")}
@@ -155,6 +155,11 @@ export function OverviewPage() {
: undefined
}
/>
<StatCard
icon={DollarSign}
label={t("statCards.costToday", "Cost Today")}
value={formatCost(quota?.costToday)}
/>
<StatCard
icon={Bot}
label={t("statCards.agents")}
+1
View File
@@ -56,6 +56,7 @@ export interface QuotaUsageResult {
requestsToday: number;
inputTokensToday: number;
outputTokensToday: number;
costToday: number;
uniqueUsersToday: number;
entries: QuotaUsageEntry[];
}
+4
View File
@@ -90,6 +90,9 @@ const KnowledgeGraphPage = lazy(() =>
const ContactsPage = lazy(() =>
import("@/pages/contacts/contacts-page").then((m) => ({ default: m.ContactsPage })),
);
const ActivityPage = lazy(() =>
import("@/pages/activity/activity-page").then((m) => ({ default: m.ActivityPage })),
);
function PageLoader() {
return (
@@ -145,6 +148,7 @@ export function AppRoutes() {
<Route path={ROUTES.EVENTS} element={<EventsPage />} />
<Route path={ROUTES.DELEGATIONS} element={<DelegationsPage />} />
<Route path={ROUTES.USAGE} element={<UsagePage />} />
<Route path={ROUTES.ACTIVITY} element={<ActivityPage />} />
<Route path={ROUTES.CHANNELS} element={<ChannelsPage key="list" />} />
<Route path={ROUTES.CHANNEL_DETAIL} element={<ChannelsPage key="detail" />} />
<Route path={ROUTES.CONTACTS} element={<ContactsPage />} />
+1
View File
@@ -102,6 +102,7 @@ export interface AgentData {
compaction_config?: CompactionConfig | null;
context_pruning?: ContextPruningConfig | null;
other_config?: Record<string, unknown> | null;
budget_monthly_cents?: number | null;
}
export interface AgentShareData {