feat(i18n): add full i18n support for backend and web UI

- Add i18next + react-i18next with namespace-split locale files (27 namespaces x 3 languages)
- Add language switcher in topbar (EN/VI/ZH) with localStorage persistence
- Replace hardcoded strings in 160+ React components with t() translations
- Add Go message catalog (internal/i18n) with T(locale, key, args...) function
- Replace 81 hardcoded error strings in gateway methods and HTTP handlers
- Add locale context propagation: WS connect param + HTTP Accept-Language header
- Keep technical terms in English: Agent, Session, Channel, Provider, Skill, Team, MCP, Cron
- Update CLAUDE.md and review-pr skill with i18n compliance checks
This commit is contained in:
viettranx committed 2026-03-09 22:22:42 +07:00
1 parent 3187196bd8
commit 344e2ac7d1
312 files changed
+10361 -2441

No files matched your search

+4 -1
View File
@@ -33,6 +33,7 @@ internal/
├── crypto/ AES-256-GCM encryption for API keys
├── sandbox/ Docker-based code sandbox
├── tts/ Text-to-Speech (OpenAI, ElevenLabs, Edge, MiniMax)
├── i18n/ Message catalog: T(locale, key, args...) + per-locale catalogs (en/vi/zh)
pkg/protocol/ Wire types (frames, methods, errors, events)
pkg/browser/ Browser automation (Rod + CDP)
migrations/ PostgreSQL migration files
@@ -46,11 +47,12 @@ ui/web/ React SPA (pnpm, Vite, Tailwind, Radix UI)
- **Context files:** `agent_context_files` (agent-level) + `user_context_files` (per-user), routed via `ContextFileInterceptor`
- **Providers:** Anthropic (native HTTP+SSE) and OpenAI-compat (generic). Both use `RetryDo()` for retries. Loads from `llm_providers` table with encrypted API keys
- **Agent loop:** `RunRequest` → think→act→observe → `RunResult`. Events: `run.started`, `run.completed`, `chunk`, `tool.call`, `tool.result`. Auto-summarization at >75% context
- **Context propagation:** `store.WithAgentType(ctx)`, `store.WithUserID(ctx)`, `store.WithAgentID(ctx)`
- **Context propagation:** `store.WithAgentType(ctx)`, `store.WithUserID(ctx)`, `store.WithAgentID(ctx)`, `store.WithLocale(ctx)`
- **WebSocket protocol (v3):** Frame types `req`/`res`/`event`. First request must be `connect`
- **Config:** JSON5 at `GOCLAW_CONFIG` env. Secrets in `.env.local` or env vars, never in config.json
- **Security:** Rate limiting, input guard (detection-only), CORS, shell deny patterns, SSRF protection, path traversal prevention, AES-256-GCM encryption. All security logs: `slog.Warn("security.*")`
- **Telegram formatting:** LLM output → `SanitizeAssistantContent()` → `markdownToTelegramHTML()` → `chunkHTML()` → `sendHTML()`. Tables rendered as ASCII in `<pre>` tags
- **i18n:** Web UI uses `i18next` with namespace-split locale files in `ui/web/src/i18n/locales/{lang}/`. Backend uses `internal/i18n` message catalog with `i18n.T(locale, key, args...)`. Locale propagated via `store.WithLocale(ctx)` — WS `connect` param `locale`, HTTP `Accept-Language` header. Supported: en (default), vi, zh. New user-facing strings: add key to `internal/i18n/keys.go`, add translations to all 3 catalog files. New UI strings: add key to all 3 locale dirs. Bootstrap templates (SOUL.md, etc.) stay English-only (LLM consumption).
## Running
@@ -78,3 +80,4 @@ Go conventions to follow:
- Use `append(dst, src...)` instead of loop-based append
- Always handle errors; don't ignore return values
- **Migrations:** When adding a new SQL migration file in `migrations/`, bump `RequiredSchemaVersion` in `internal/upgrade/version.go` to match the new migration number
- **i18n strings:** When adding user-facing error messages, add key to `internal/i18n/keys.go` and translations to `catalog_en.go`, `catalog_vi.go`, `catalog_zh.go`. For UI strings, add to all locale JSON files in `ui/web/src/i18n/locales/{en,vi,zh}/`
+2
View File
@@ -26,6 +26,8 @@ type Client struct {
connectedAt time.Time // when the client connected
remoteAddr string // peer IP (extracted from proxy headers or RemoteAddr)
locale string // user's preferred locale (e.g. "en", "vi", "zh")
// Browser pairing state
pairingCode string // 8-char code if pending approval
pairingPending bool // true while waiting for admin approval
+32 -32
View File
@@ -9,6 +9,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -39,20 +40,21 @@ type linksListParams struct {
Direction string `json:"direction"` // "from" (default), "to", "all"
}
func (m *AgentLinksMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentLinksMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.linkStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgLinksNotConfigured)))
return
}
var params linksListParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.AgentID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
return
}
@@ -62,7 +64,6 @@ func (m *AgentLinksMethods) handleList(_ context.Context, client *gateway.Client
return
}
ctx := context.Background()
var links []store.AgentLinkData
switch params.Direction {
@@ -104,20 +105,21 @@ type linksCreateParams struct {
Settings json.RawMessage `json:"settings"`
}
func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentLinksMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.linkStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgLinksNotConfigured)))
return
}
var params linksCreateParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.SourceAgent == "" || params.TargetAgent == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sourceAgent and targetAgent are required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "sourceAgent and targetAgent")))
return
}
@@ -126,7 +128,7 @@ func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Clie
direction = store.LinkDirectionOutbound
}
if direction != store.LinkDirectionOutbound && direction != store.LinkDirectionInbound && direction != store.LinkDirectionBidirectional {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "direction must be outbound, inbound, or bidirectional"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidDirection)))
return
}
@@ -142,13 +144,13 @@ func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Clie
}
if sourceAgent.ID == targetAgent.ID {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "source and target must be different agents"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSourceTargetSame)))
return
}
// Delegation targets must be predefined agents (open agents have no agent-level context files)
if targetAgent.AgentType == store.AgentTypeOpen {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delegate to open agents — only predefined agents can be delegation targets"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotDelegateOpen)))
return
}
@@ -168,9 +170,8 @@ func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Clie
CreatedBy: client.UserID(),
}
ctx := context.Background()
if err := m.linkStore.CreateLink(ctx, link); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to create link: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "link", err.Error())))
return
}
@@ -217,33 +218,34 @@ type linksUpdateParams struct {
Status string `json:"status"`
}
func (m *AgentLinksMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentLinksMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.linkStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgLinksNotConfigured)))
return
}
var params linksUpdateParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.LinkID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "linkId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "linkId")))
return
}
linkID, err := uuid.Parse(params.LinkID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid linkId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "linkId")))
return
}
updates := map[string]any{}
if params.Direction != "" {
if params.Direction != store.LinkDirectionOutbound && params.Direction != store.LinkDirectionInbound && params.Direction != store.LinkDirectionBidirectional {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "direction must be outbound, inbound, or bidirectional"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidDirection)))
return
}
updates["direction"] = params.Direction
@@ -259,20 +261,19 @@ func (m *AgentLinksMethods) handleUpdate(_ context.Context, client *gateway.Clie
}
if params.Status != "" {
if params.Status != store.LinkStatusActive && params.Status != store.LinkStatusDisabled {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "status must be active or disabled"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidLinkStatus)))
return
}
updates["status"] = params.Status
}
if len(updates) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "no updates provided"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgNoUpdatesProvided)))
return
}
ctx := context.Background()
if err := m.linkStore.UpdateLink(ctx, linkID, updates); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update link: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "link", err.Error())))
return
}
@@ -311,36 +312,35 @@ type linksDeleteParams struct {
LinkID string `json:"linkId"`
}
func (m *AgentLinksMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentLinksMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.linkStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgLinksNotConfigured)))
return
}
var params linksDeleteParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.LinkID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "linkId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "linkId")))
return
}
linkID, err := uuid.Parse(params.LinkID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid linkId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "linkId")))
return
}
ctx := context.Background()
// Fetch link before deleting to get agent IDs for cache invalidation
link, _ := m.linkStore.GetLink(ctx, linkID)
if err := m.linkStore.DeleteLink(ctx, linkID); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete link: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "link", err.Error())))
return
}
+4 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -103,9 +104,10 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien
func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
if m.agentStore != nil {
locale := store.LocaleFromContext(ctx)
userID := client.UserID()
if userID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "user context required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserCtxRequired)))
return
}
@@ -118,7 +120,7 @@ func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client,
}
if err != nil {
slog.Warn("agents.list: store query failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to list agents"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "agents")))
return
}
+9 -7
View File
@@ -11,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -18,7 +19,8 @@ import (
// --- agents.create ---
// Matching TS src/gateway/server-methods/agents.ts:216-287
func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
Workspace string `json:"workspace"`
@@ -40,7 +42,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
@@ -51,7 +53,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
agentID := config.NormalizeAgentID(params.Name)
if agentID == "default" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot create agent with reserved id 'default'"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "cannot create agent with reserved id 'default'")))
return
}
@@ -69,7 +71,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
// Check if agent already exists in DB
if existing, _ := m.agentStore.GetByKey(ctx, agentID); existing != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgAlreadyExists, "agent", agentID)))
return
}
@@ -98,7 +100,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
OtherConfig: params.OtherConfig,
}
if err := m.agentStore.Create(ctx, agentData); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to create agent: %v", err)))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "agent", fmt.Sprintf("%v", err))))
return
}
@@ -120,7 +122,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
} else {
// --- Fallback: config.json + filesystem ---
if _, ok := m.cfg.Agents.List[agentID]; ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgAlreadyExists, "agent", agentID)))
return
}
@@ -140,7 +142,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
m.cfg.Agents.List[agentID] = spec
if err := config.Save(m.cfgPath, m.cfg); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
return
}
+10 -7
View File
@@ -8,13 +8,16 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// --- agents.delete ---
// Matching TS src/gateway/server-methods/agents.ts:347-398
func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
AgentID string `json:"agentId"`
DeleteFiles bool `json:"deleteFiles"`
@@ -25,11 +28,11 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client,
}
if params.AgentID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
return
}
if params.AgentID == "default" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delete the default agent"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotDeleteDefault)))
return
}
@@ -40,12 +43,12 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client,
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
if err := m.agentStore.Delete(ctx, ag.ID); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to delete agent: %v", err)))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "agent", fmt.Sprintf("%v", err))))
return
}
@@ -60,7 +63,7 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client,
// --- Fallback: config.json ---
spec, ok := m.cfg.Agents.List[params.AgentID]
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
@@ -79,7 +82,7 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client,
m.agents.Remove(params.AgentID)
if err := config.Save(m.cfgPath, m.cfg); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
return
}
+18 -14
View File
@@ -9,6 +9,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -23,7 +24,8 @@ var allowedAgentFiles = []string{
// --- agents.files.list ---
// Matching TS src/gateway/server-methods/agents.ts:399-422
func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleFilesList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params agentParams
if req.Params != nil {
json.Unmarshal(req.Params, &params)
@@ -37,13 +39,13 @@ func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Clien
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
dbFiles, err := m.agentStore.GetAgentContextFiles(ctx, ag.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to list files: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "files")))
return
}
@@ -110,7 +112,8 @@ func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Clien
// --- agents.files.get ---
// Matching TS src/gateway/server-methods/agents.ts:423-473
func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleFilesGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
@@ -122,11 +125,11 @@ func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client
params.AgentID = "default"
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if !isAllowedFile(params.Name) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "file not allowed: "+params.Name))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "file not allowed: "+params.Name)))
return
}
@@ -135,13 +138,13 @@ func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
dbFiles, err := m.agentStore.GetAgentContextFiles(ctx, ag.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to get files: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "files")))
return
}
@@ -207,7 +210,8 @@ func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client
// --- agents.files.set ---
// Matching TS src/gateway/server-methods/agents.ts:474-515
func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleFilesSet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
@@ -220,11 +224,11 @@ func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client
params.AgentID = "default"
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if !isAllowedFile(params.Name) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "file not allowed: "+params.Name))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "file not allowed: "+params.Name)))
return
}
@@ -233,12 +237,12 @@ func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
if err := m.agentStore.SetAgentContextFile(ctx, ag.ID, params.Name, params.Content); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to write file: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "file", err.Error())))
return
}
@@ -267,7 +271,7 @@ func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client
p := filepath.Join(ws, params.Name)
if err := os.WriteFile(p, []byte(params.Content), 0644); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to write file: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "file", err.Error())))
return
}
+9 -6
View File
@@ -10,13 +10,16 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// --- agents.update ---
// Matching TS src/gateway/server-methods/agents.ts:288-346
func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
@@ -37,7 +40,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
}
if params.AgentID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
return
}
@@ -46,7 +49,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
@@ -87,7 +90,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
if len(updates) > 0 {
if err := m.agentStore.Update(ctx, ag.ID, updates); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to update agent: %v", err)))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "agent", fmt.Sprintf("%v", err))))
return
}
}
@@ -110,7 +113,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
spec, ok := m.cfg.Agents.List[params.AgentID]
if !ok {
if params.AgentID != "default" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
}
@@ -147,7 +150,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
}
if err := config.Save(m.cfgPath, m.cfg); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
return
}
}
+20 -14
View File
@@ -9,6 +9,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -44,10 +45,11 @@ func (m *ChannelInstancesMethods) emitCacheInvalidate() {
}
func (m *ChannelInstancesMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
instances, err := m.store.ListAll(ctx)
if err != nil {
slog.Error("channels.instances.list", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to list channel instances"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel instances")))
return
}
@@ -63,6 +65,7 @@ func (m *ChannelInstancesMethods) handleList(ctx context.Context, client *gatewa
}
func (m *ChannelInstancesMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
ID string `json:"id"`
}
@@ -72,13 +75,13 @@ func (m *ChannelInstancesMethods) handleGet(ctx context.Context, client *gateway
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid instance ID"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
return
}
inst, err := m.store.Get(ctx, id)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "instance not found"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
return
}
@@ -86,6 +89,7 @@ func (m *ChannelInstancesMethods) handleGet(ctx context.Context, client *gateway
}
func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
@@ -100,18 +104,18 @@ func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gate
}
if params.Name == "" || params.ChannelType == "" || params.AgentID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name, channel_type, and agent_id are required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name, channel_type, and agent_id")))
return
}
if !isValidChannelType(params.ChannelType) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid channel_type"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidChannelType)))
return
}
agentID, err := uuid.Parse(params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agent_id"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent_id")))
return
}
@@ -132,7 +136,7 @@ func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gate
if err := m.store.Create(ctx, inst); err != nil {
slog.Error("channels.instances.create", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to create instance: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "instance", err.Error())))
return
}
@@ -141,6 +145,7 @@ func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gate
}
func (m *ChannelInstancesMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
ID string `json:"id"`
Updates json.RawMessage `json:"updates"`
@@ -151,19 +156,19 @@ func (m *ChannelInstancesMethods) handleUpdate(ctx context.Context, client *gate
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid instance ID"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
return
}
var updates map[string]interface{}
if err := json.Unmarshal(params.Updates, &updates); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid updates"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidUpdates)))
return
}
if err := m.store.Update(ctx, id, updates); err != nil {
slog.Error("channels.instances.update", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update instance: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "instance", err.Error())))
return
}
@@ -172,6 +177,7 @@ func (m *ChannelInstancesMethods) handleUpdate(ctx context.Context, client *gate
}
func (m *ChannelInstancesMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
ID string `json:"id"`
}
@@ -181,24 +187,24 @@ func (m *ChannelInstancesMethods) handleDelete(ctx context.Context, client *gate
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid instance ID"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
return
}
// Look up instance to check if it's a default (seeded) instance.
inst, err := m.store.Get(ctx, id)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "instance not found"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInstanceNotFound)))
return
}
if store.IsDefaultChannelInstance(inst.Name) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delete default channel instance"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotDeleteDefaultInst)))
return
}
if err := m.store.Delete(ctx, id); err != nil {
slog.Error("channels.instances.delete", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete instance: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "instance", err.Error())))
return
}
+5 -2
View File
@@ -5,6 +5,8 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -39,7 +41,8 @@ func (m *ChannelsMethods) handleStatus(_ context.Context, client *gateway.Client
}))
}
func (m *ChannelsMethods) handleToggle(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *ChannelsMethods) handleToggle(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
// Channel toggling requires restarting the channel, which is a Phase 3 feature.
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "channels.toggle not yet implemented"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotImplemented, "channels.toggle")))
}
+16 -11
View File
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -44,6 +45,7 @@ type chatSendParams struct {
}
func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
// Rate limit check per user/client
if m.rateLimiter != nil && m.rateLimiter.Enabled() {
key := client.UserID()
@@ -51,14 +53,14 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
key = client.ID()
}
if !m.rateLimiter.Allow(key) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "rate limit exceeded — please wait before sending more messages"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRateLimitExceeded)))
return
}
}
var params chatSendParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
@@ -83,7 +85,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
userID := client.UserID()
if userID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "user_id is required in managed mode — provide it in the connect handshake"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDRequired)))
return
}
@@ -171,9 +173,10 @@ type chatHistoryParams struct {
}
func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params chatHistoryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
@@ -195,23 +198,24 @@ func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client,
// handleInject injects a message into a session transcript without running the agent.
// Matching TS chat.inject (src/gateway/server-methods/chat.ts:686-746).
func (m *ChatMethods) handleInject(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *ChatMethods) handleInject(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
SessionKey string `json:"sessionKey"`
Message string `json:"message"`
Label string `json:"label"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.SessionKey == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sessionKey is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "sessionKey")))
return
}
if params.Message == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "message is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgMsgRequired)))
return
}
@@ -249,18 +253,19 @@ func (m *ChatMethods) handleInject(_ context.Context, client *gateway.Client, re
// Response:
//
// { ok: true, aborted: bool, runIds: []string }
func (m *ChatMethods) handleAbort(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *ChatMethods) handleAbort(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
RunID string `json:"runId"`
SessionKey string `json:"sessionKey"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.SessionKey == "" && params.RunID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sessionKey or runId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "sessionKey or runId")))
return
}
+13 -10
View File
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -45,6 +46,7 @@ func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req
// handleApply replaces the entire config with the provided JSON5 raw content.
// Matching TS config.apply (src/gateway/server-methods/config.ts:435-486).
func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Raw string `json:"raw"`
BaseHash string `json:"baseHash"`
@@ -54,20 +56,20 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client,
}
if params.Raw == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "raw config is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRawConfigRequired)))
return
}
// Optimistic concurrency: validate hash if provided
if params.BaseHash != "" && params.BaseHash != m.cfg.Hash() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "config has changed (hash mismatch)"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgConfigHashMismatch)))
return
}
// Parse the new config
newCfg := config.Default()
if err := json5.Unmarshal([]byte(params.Raw), newCfg); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid config: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
return
}
@@ -77,7 +79,7 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client,
// Save to disk
if err := config.Save(m.cfgPath, newCfg); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
return
}
@@ -103,6 +105,7 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client,
// handlePatch merges a partial config update into the current config.
// Matching TS config.patch (src/gateway/server-methods/config.ts:321-434).
func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Raw string `json:"raw"`
BaseHash string `json:"baseHash"`
@@ -112,33 +115,33 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client,
}
if params.Raw == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "raw patch is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRawPatchRequired)))
return
}
// Optimistic concurrency
if params.BaseHash != "" && params.BaseHash != m.cfg.Hash() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "config has changed (hash mismatch)"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgConfigHashMismatch)))
return
}
// Merge strategy: serialize current -> deserialize patch on top -> save
currentJSON, err := json.Marshal(m.cfg)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to serialize current config"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "failed to serialize current config")))
return
}
// Start from current config as base
merged := config.Default()
if err := json.Unmarshal(currentJSON, merged); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to clone config"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "failed to clone config")))
return
}
// Apply patch on top
if err := json5.Unmarshal([]byte(params.Raw), merged); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid patch: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
return
}
@@ -148,7 +151,7 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client,
// Save to disk
if err := config.Save(m.cfgPath, merged); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
return
}
+27 -21
View File
@@ -7,6 +7,7 @@ import (
"regexp"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -49,30 +50,31 @@ func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req
}))
}
func (m *CronMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *CronMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
Name string `json:"name"`
Schedule store.CronSchedule `json:"schedule"`
Message string `json:"message"`
Deliver bool `json:"deliver"`
Channel string `json:"channel"`
To string `json:"to"`
AgentID string `json:"agentId"`
Message string `json:"message"`
Deliver bool `json:"deliver"`
Channel string `json:"channel"`
To string `json:"to"`
AgentID string `json:"agentId"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if !cronSlugRe.MatchString(params.Name) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name must be a valid slug (lowercase letters, numbers, hyphens only)"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "name")))
return
}
if params.Message == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "message is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgMsgRequired)))
return
}
@@ -87,7 +89,8 @@ func (m *CronMethods) handleCreate(_ context.Context, client *gateway.Client, re
}))
}
func (m *CronMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *CronMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
JobID string `json:"jobId"`
}
@@ -96,7 +99,7 @@ func (m *CronMethods) handleDelete(_ context.Context, client *gateway.Client, re
}
if params.JobID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "jobId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "jobId")))
return
}
@@ -110,7 +113,8 @@ func (m *CronMethods) handleDelete(_ context.Context, client *gateway.Client, re
}))
}
func (m *CronMethods) handleToggle(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *CronMethods) handleToggle(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
JobID string `json:"jobId"`
Enabled bool `json:"enabled"`
@@ -120,7 +124,7 @@ func (m *CronMethods) handleToggle(_ context.Context, client *gateway.Client, re
}
if params.JobID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "jobId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "jobId")))
return
}
@@ -139,10 +143,11 @@ func (m *CronMethods) handleStatus(_ context.Context, client *gateway.Client, re
client.SendResponse(protocol.NewOKResponse(req.ID, m.service.Status()))
}
func (m *CronMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
JobID string `json:"jobId"`
ID string `json:"id"` // alias (matching TS)
JobID string `json:"jobId"`
ID string `json:"id"` // alias (matching TS)
Patch store.CronJobPatch `json:"patch"`
}
if req.Params != nil {
@@ -154,7 +159,7 @@ func (m *CronMethods) handleUpdate(_ context.Context, client *gateway.Client, re
jobID = params.ID
}
if jobID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "jobId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "jobId")))
return
}
@@ -169,7 +174,8 @@ func (m *CronMethods) handleUpdate(_ context.Context, client *gateway.Client, re
}))
}
func (m *CronMethods) handleRun(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
JobID string `json:"jobId"`
ID string `json:"id"`
@@ -184,7 +190,7 @@ func (m *CronMethods) handleRun(_ context.Context, client *gateway.Client, req *
jobID = params.ID
}
if jobID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "jobId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "jobId")))
return
}
@@ -193,7 +199,7 @@ func (m *CronMethods) handleRun(_ context.Context, client *gateway.Client, req *
// Validate job exists before responding
_, ok := m.service.GetJob(jobID)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "job not found"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgJobNotFound)))
return
}
+9 -8
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -25,9 +26,10 @@ func (m *DelegationsMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodDelegationsGet, m.handleGet)
}
func (m *DelegationsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *DelegationsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "delegations not available"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable)))
return
}
@@ -67,7 +69,6 @@ func (m *DelegationsMethods) handleList(_ context.Context, client *gateway.Clien
}
}
ctx := context.Background()
records, total, err := m.teamStore.ListDelegationHistory(ctx, opts)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -92,9 +93,10 @@ func (m *DelegationsMethods) handleList(_ context.Context, client *gateway.Clien
}))
}
func (m *DelegationsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *DelegationsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "delegations not available"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable)))
return
}
@@ -105,17 +107,16 @@ func (m *DelegationsMethods) handleGet(_ context.Context, client *gateway.Client
_ = json.Unmarshal(req.Params, &params)
}
if params.ID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "id is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id")))
return
}
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid id"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "id")))
return
}
ctx := context.Background()
record, err := m.teamStore.GetDelegationHistory(ctx, id)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+12 -8
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -55,22 +57,23 @@ func (m *ExecApprovalMethods) handleList(_ context.Context, client *gateway.Clie
}))
}
func (m *ExecApprovalMethods) handleApprove(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *ExecApprovalMethods) handleApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.manager == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "exec approval is not enabled"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgExecApprovalDisabled)))
return
}
var params struct {
ID string `json:"id"`
Always bool `json:"always"` // true = allow-always, false = allow-once
ID string `json:"id"`
Always bool `json:"always"` // true = allow-always, false = allow-once
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.ID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "id is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id")))
return
}
@@ -90,9 +93,10 @@ func (m *ExecApprovalMethods) handleApprove(_ context.Context, client *gateway.C
}))
}
func (m *ExecApprovalMethods) handleDeny(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *ExecApprovalMethods) handleDeny(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.manager == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "exec approval is not enabled"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgExecApprovalDisabled)))
return
}
@@ -104,7 +108,7 @@ func (m *ExecApprovalMethods) handleDeny(_ context.Context, client *gateway.Clie
}
if params.ID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "id is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id")))
return
}
+5 -2
View File
@@ -6,6 +6,8 @@ import (
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -22,7 +24,8 @@ func (m *LogsMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodLogsTail, m.handleTail)
}
func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *LogsMethods) handleTail(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Action string `json:"action"`
Level string `json:"level"` // "debug", "info", "warn", "error" (default: "info")
@@ -48,7 +51,7 @@ func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req
client.SendResponse(protocol.NewErrorResponse(
req.ID,
protocol.ErrInvalidRequest,
"action must be 'start' or 'stop'",
i18n.T(locale, i18n.MsgInvalidLogAction),
))
}
}
+15 -9
View File
@@ -6,6 +6,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -46,7 +47,8 @@ func (m *PairingMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodBrowserPairingStatus, m.handleBrowserPairingStatus)
}
func (m *PairingMethods) handleRequest(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *PairingMethods) handleRequest(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
SenderID string `json:"senderId"`
Channel string `json:"channel"`
@@ -58,7 +60,7 @@ func (m *PairingMethods) handleRequest(_ context.Context, client *gateway.Client
}
if params.SenderID == "" || params.Channel == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "senderId and channel are required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
return
}
@@ -78,6 +80,7 @@ func (m *PairingMethods) handleRequest(_ context.Context, client *gateway.Client
}
func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Code string `json:"code"`
ApprovedBy string `json:"approvedBy"`
@@ -87,7 +90,7 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie
}
if params.Code == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "code is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCodeRequired)))
return
}
if params.ApprovedBy == "" {
@@ -115,7 +118,8 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie
}))
}
func (m *PairingMethods) handleDeny(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Code string `json:"code"`
}
@@ -124,7 +128,7 @@ func (m *PairingMethods) handleDeny(_ context.Context, client *gateway.Client, r
}
if params.Code == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "code is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCodeRequired)))
return
}
@@ -152,7 +156,8 @@ func (m *PairingMethods) handleList(_ context.Context, client *gateway.Client, r
}))
}
func (m *PairingMethods) handleRevoke(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
SenderID string `json:"senderId"`
Channel string `json:"channel"`
@@ -162,7 +167,7 @@ func (m *PairingMethods) handleRevoke(_ context.Context, client *gateway.Client,
}
if params.SenderID == "" || params.Channel == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "senderId and channel are required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
return
}
@@ -193,7 +198,8 @@ func (m *PairingMethods) handleRevoke(_ context.Context, client *gateway.Client,
// handleBrowserPairingStatus lets a pending browser client check if its pairing code has been approved.
// Called by unauthenticated clients during the browser pairing flow.
func (m *PairingMethods) handleBrowserPairingStatus(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
SenderID string `json:"sender_id"`
}
@@ -202,7 +208,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(_ context.Context, client *g
}
if params.SenderID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sender_id is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderIDRequired)))
return
}
+7 -4
View File
@@ -6,6 +6,8 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -23,7 +25,8 @@ func (m *SendMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodSend, m.handleSend)
}
func (m *SendMethods) handleSend(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *SendMethods) handleSend(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Channel string `json:"channel"`
To string `json:"to"`
@@ -34,15 +37,15 @@ func (m *SendMethods) handleSend(_ context.Context, client *gateway.Client, req
}
if params.Channel == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "channel is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "channel")))
return
}
if params.To == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "to is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "to")))
return
}
if params.Message == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "message is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgMsgRequired)))
return
}
+14 -9
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -59,10 +60,11 @@ type sessionKeyParams struct {
Key string `json:"key"`
}
func (m *SessionsMethods) handlePreview(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *SessionsMethods) handlePreview(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params sessionKeyParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
@@ -78,7 +80,8 @@ func (m *SessionsMethods) handlePreview(_ context.Context, client *gateway.Clien
// handlePatch updates session metadata fields.
// Matching TS sessions.patch (src/gateway/server-methods/sessions.ts:237-287).
func (m *SessionsMethods) handlePatch(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *SessionsMethods) handlePatch(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Key string `json:"key"`
Label *string `json:"label,omitempty"`
@@ -86,12 +89,12 @@ func (m *SessionsMethods) handlePatch(_ context.Context, client *gateway.Client,
Metadata map[string]string `json:"metadata,omitempty"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.Key == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "key is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "key")))
return
}
@@ -119,10 +122,11 @@ func (m *SessionsMethods) handlePatch(_ context.Context, client *gateway.Client,
}))
}
func (m *SessionsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *SessionsMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params sessionKeyParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
@@ -136,10 +140,11 @@ func (m *SessionsMethods) handleDelete(_ context.Context, client *gateway.Client
}))
}
func (m *SessionsMethods) handleReset(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *SessionsMethods) handleReset(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params sessionKeyParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
+13 -10
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -55,7 +56,8 @@ func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, re
}))
}
func (m *SkillsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
}
@@ -63,13 +65,13 @@ func (m *SkillsMethods) handleGet(_ context.Context, client *gateway.Client, req
json.Unmarshal(req.Params, &params)
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
info, ok := m.store.GetSkill(params.Name)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "skill not found: "+params.Name))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
return
}
@@ -100,7 +102,8 @@ type skillUpdater interface {
UpdateSkill(id uuid.UUID, updates map[string]interface{}) error
}
func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
ID string `json:"id"`
@@ -110,14 +113,14 @@ func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client,
json.Unmarshal(req.Params, &params)
}
if params.Name == "" && params.ID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name or id is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name or id")))
return
}
// Check if the store supports updates (PGSkillStore does, FileSkillStore doesn't)
updater, ok := m.store.(skillUpdater)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "skills.update not supported for file-based skills"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgSkillsUpdateNotSupported)))
return
}
@@ -126,7 +129,7 @@ func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client,
if params.ID != "" {
parsed, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid skill ID"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "skill")))
return
}
skillID = parsed
@@ -135,20 +138,20 @@ func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client,
// For PGSkillStore, the name is the slug
info, exists := m.store.GetSkill(params.Name)
if !exists {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "skill not found: "+params.Name))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
return
}
// Try to parse Path as UUID (PGSkillStore stores DB ID in Path field for managed skills)
parsed, err := uuid.Parse(info.Path)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot resolve skill ID for file-based skill"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotResolveSkillID)))
return
}
skillID = parsed
}
if params.Updates == nil || len(params.Updates) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "updates is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "updates")))
return
}
+12 -12
View File
@@ -8,6 +8,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -50,13 +51,13 @@ func (m *TeamsMethods) Register(router *gateway.MethodRouter) {
// --- List ---
func (m *TeamsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
ctx := context.Background()
teams, err := m.teamStore.ListTeams(ctx)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -79,24 +80,25 @@ type teamsCreateParams struct {
Settings json.RawMessage `json:"settings"`
}
func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsCreateParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if params.Lead == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "lead")))
return
}
@@ -118,8 +120,6 @@ func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, r
memberAgents = append(memberAgents, ag)
}
ctx := context.Background()
// Create team
team := &store.TeamData{
Name: params.Name,
@@ -130,13 +130,13 @@ func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, r
CreatedBy: client.UserID(),
}
if err := m.teamStore.CreateTeam(ctx, team); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to create team: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "team", err.Error())))
return
}
// Add lead as member with lead role
if err := m.teamStore.AddMember(ctx, team.ID, leadAgent.ID, store.TeamRoleLead); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add lead as member: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "team lead membership", err.Error())))
return
}
+35 -36
View File
@@ -8,6 +8,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -18,30 +19,30 @@ type teamsGetParams struct {
TeamID string `json:"teamId"`
}
func (m *TeamsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsGetParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.TeamID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
return
}
teamID, err := uuid.Parse(params.TeamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
return
}
ctx := context.Background()
team, err := m.teamStore.GetTeam(ctx, teamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -66,37 +67,36 @@ type teamsDeleteParams struct {
TeamID string `json:"teamId"`
}
func (m *TeamsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsDeleteParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.TeamID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
return
}
teamID, err := uuid.Parse(params.TeamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
return
}
ctx := context.Background()
// Fetch team and members before deleting for event + cache invalidation
team, _ := m.teamStore.GetTeam(ctx, teamID)
members, _ := m.teamStore.ListMembers(ctx, teamID)
if err := m.teamStore.DeleteTeam(ctx, teamID); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete team: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "team", err.Error())))
return
}
@@ -127,30 +127,30 @@ type teamsTaskListParams struct {
TeamID string `json:"teamId"`
}
func (m *TeamsMethods) handleTaskList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleTaskList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsTaskListParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.TeamID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
return
}
teamID, err := uuid.Parse(params.TeamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
return
}
ctx := context.Background()
tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", store.TeamTaskFilterAll, "")
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -170,35 +170,34 @@ type teamsUpdateParams struct {
Settings map[string]interface{} `json:"settings"`
}
func (m *TeamsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsUpdateParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.TeamID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
return
}
teamID, err := uuid.Parse(params.TeamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
return
}
ctx := context.Background()
// Validate team exists
team, err := m.teamStore.GetTeam(ctx, teamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgNotFound, "team", err.Error())))
return
}
@@ -213,14 +212,14 @@ func (m *TeamsMethods) handleUpdate(_ context.Context, client *gateway.Client, r
raw, _ := json.Marshal(params.Settings)
var access teamAccessSettings
if err := json.Unmarshal(raw, &access); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid settings: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
return
}
cleaned, _ := json.Marshal(access)
updates := map[string]any{"settings": json.RawMessage(cleaned)}
if err := m.teamStore.UpdateTeam(ctx, teamID, updates); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update team: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "team", err.Error())))
return
}
@@ -251,30 +250,30 @@ type teamsKnownUsersParams struct {
TeamID string `json:"teamId"`
}
func (m *TeamsMethods) handleKnownUsers(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleKnownUsers(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsKnownUsersParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.TeamID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
return
}
teamID, err := uuid.Parse(params.TeamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
return
}
ctx := context.Background()
users, err := m.teamStore.KnownUserIDs(ctx, teamID, 100)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+20 -21
View File
@@ -9,6 +9,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -20,34 +21,33 @@ type teamsAddMemberParams struct {
Agent string `json:"agent"` // agent key or UUID
}
func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleAddMember(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsAddMemberParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.TeamID == "" || params.Agent == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agent are required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId and agent")))
return
}
teamID, err := uuid.Parse(params.TeamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
return
}
ctx := context.Background()
// Validate team exists
team, err := m.teamStore.GetTeam(ctx, teamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgNotFound, "team", err.Error())))
return
}
@@ -60,13 +60,13 @@ func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client
// Prevent adding lead again
if ag.ID == team.LeadAgentID {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent is already the team lead"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgAgentIsTeamLead)))
return
}
// Add member
if err := m.teamStore.AddMember(ctx, teamID, ag.ID, store.TeamRoleMember); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add member: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "member", err.Error())))
return
}
@@ -106,43 +106,42 @@ type teamsRemoveMemberParams struct {
AgentID string `json:"agentId"` // agent UUID
}
func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *TeamsMethods) handleRemoveMember(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsRemoveMemberParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.TeamID == "" || params.AgentID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agentId are required"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId and agentId")))
return
}
teamID, err := uuid.Parse(params.TeamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
return
}
agentID, err := uuid.Parse(params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agentId")))
return
}
ctx := context.Background()
// Validate team exists and prevent removing the lead
team, err := m.teamStore.GetTeam(ctx, teamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgNotFound, "team", err.Error())))
return
}
if agentID == team.LeadAgentID {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot remove the team lead"))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotRemoveTeamLead)))
return
}
@@ -151,7 +150,7 @@ func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Cli
// Remove member
if err := m.teamStore.RemoveMember(ctx, teamID, agentID); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to remove member: "+err.Error()))
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "member", err.Error())))
return
}
+13 -2
View File
@@ -6,7 +6,9 @@ import (
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -38,10 +40,11 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
handler, ok := r.handlers[req.Method]
if !ok {
slog.Warn("unknown method", "method", req.Method, "client", client.id)
locale := i18n.Normalize(client.locale)
client.SendResponse(protocol.NewErrorResponse(
req.ID,
protocol.ErrInvalidRequest,
"unknown method: "+req.Method,
i18n.T(locale, i18n.MsgUnknownMethod, req.Method),
))
return
}
@@ -51,16 +54,20 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
if pe := r.server.policyEngine; pe != nil {
if !pe.CanAccess(client.role, req.Method) {
slog.Warn("permission denied", "method", req.Method, "role", client.role, "client", client.id)
locale := i18n.Normalize(client.locale)
client.SendResponse(protocol.NewErrorResponse(
req.ID,
protocol.ErrUnauthorized,
"permission denied: insufficient role for "+req.Method,
i18n.T(locale, i18n.MsgPermissionDenied, req.Method),
))
return
}
}
}
// Inject locale into context for i18n support
ctx = store.WithLocale(ctx, i18n.Normalize(client.locale))
slog.Debug("handling method", "method", req.Method, "client", client.id, "req_id", req.ID)
handler(ctx, client, req)
}
@@ -81,11 +88,15 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
Token string `json:"token"`
UserID string `json:"user_id"`
SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect
Locale string `json:"locale"` // user's preferred locale (en, vi, zh)
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
// Set locale on client (persists across all requests for this connection)
client.locale = i18n.Normalize(params.Locale)
configToken := r.server.cfg.Gateway.Token
// Path 1: Valid token → admin
+29 -21
View File
@@ -11,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -68,16 +69,18 @@ func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
// Inject user_id into context
// Inject user_id and locale into context
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx := store.WithUserID(r.Context(), userID)
r = r.WithContext(ctx)
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
}
@@ -85,7 +88,8 @@ func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
if userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "X-GoClaw-User-Id header required"})
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
return
}
@@ -106,25 +110,26 @@ func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
if userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "X-GoClaw-User-Id header required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
return
}
var req store.AgentData
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
if !isValidSlug(req.AgentKey) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_key must be a valid slug (lowercase letters, numbers, hyphens only)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")})
return
}
// Check for duplicate agent_key before creating
if existing, _ := h.agents.GetByKey(r.Context(), req.AgentKey); existing != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": "An agent with key \"" + req.AgentKey + "\" already exists"})
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
return
}
@@ -161,7 +166,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
if err := h.agents.Create(r.Context(), &req); err != nil {
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") {
writeJSON(w, http.StatusConflict, map[string]string{"error": "An agent with this key already exists"})
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
} else {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -184,6 +189,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
isOwner := h.isOwnerUser(userID)
id, err := uuid.Parse(r.PathValue("id"))
@@ -191,12 +197,12 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
// Try by agent_key
ag, err2 := h.agents.GetByKey(r.Context(), r.PathValue("id"))
if err2 != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", r.PathValue("id"))})
return
}
if userID != "" && !isOwner {
if ok, _, _ := h.agents.CanAccess(r.Context(), ag.ID, userID); !ok {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "no access to this agent"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")})
return
}
}
@@ -206,13 +212,13 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && !isOwner {
if ok, _, _ := h.agents.CanAccess(r.Context(), id, userID); !ok {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "no access to this agent"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")})
return
}
}
@@ -222,26 +228,27 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Only owner can update
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can update agent"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "update agent")})
return
}
var updates map[string]any
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
@@ -277,20 +284,21 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Only owner can delete
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can delete agent"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "delete agent")})
return
}
+26 -21
View File
@@ -8,25 +8,27 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleListInstances returns all user instances for a predefined agent.
func (h *AgentsHandler) handleListInstances(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view instances"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "view instances")})
return
}
@@ -42,24 +44,25 @@ func (h *AgentsHandler) handleListInstances(w http.ResponseWriter, r *http.Reque
// handleGetInstanceFiles returns user context files for a specific instance.
func (h *AgentsHandler) handleGetInstanceFiles(w http.ResponseWriter, r *http.Request) {
callerID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
instanceUserID := r.PathValue("userID")
if instanceUserID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userID")})
return
}
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view instance files"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "view instance files")})
return
}
@@ -75,44 +78,45 @@ func (h *AgentsHandler) handleGetInstanceFiles(w http.ResponseWriter, r *http.Re
// handleSetInstanceFile updates a user context file for a specific instance.
func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Request) {
callerID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
instanceUserID := r.PathValue("userID")
fileName := r.PathValue("fileName")
if instanceUserID == "" || fileName == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID or fileName"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userID and fileName")})
return
}
// Only USER.md can be edited via this endpoint — other files are managed by the agent itself.
if fileName != "USER.md" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only USER.md can be edited via this endpoint"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "only USER.md can be edited via this endpoint")})
return
}
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can edit instance files"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "edit instance files")})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read body"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
var payload struct {
Content string `json:"content"`
}
if err := json.Unmarshal(body, &payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
@@ -130,41 +134,42 @@ func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Req
// handleUpdateInstanceMetadata updates metadata for a user instance.
func (h *AgentsHandler) handleUpdateInstanceMetadata(w http.ResponseWriter, r *http.Request) {
callerID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
instanceUserID := r.PathValue("userID")
if instanceUserID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userID")})
return
}
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can edit instance metadata"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "edit instance metadata")})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read body"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
var payload struct {
Metadata map[string]string `json:"metadata"`
}
if err := json.Unmarshal(body, &payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if len(payload.Metadata) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "metadata is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "metadata")})
return
}
+30 -24
View File
@@ -6,25 +6,27 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (h *AgentsHandler) handleListShares(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Only owner can list shares
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view shares"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "view shares")})
return
}
@@ -39,20 +41,21 @@ func (h *AgentsHandler) handleListShares(w http.ResponseWriter, r *http.Request)
func (h *AgentsHandler) handleShare(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Only owner can share
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can share agent"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "share agent")})
return
}
@@ -61,11 +64,11 @@ func (h *AgentsHandler) handleShare(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
if req.UserID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")})
return
}
if err := store.ValidateUserID(req.UserID); err != nil {
@@ -86,20 +89,21 @@ func (h *AgentsHandler) handleShare(w http.ResponseWriter, r *http.Request) {
func (h *AgentsHandler) handleRevokeShare(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Only owner can revoke shares
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can revoke shares"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "revoke shares")})
return
}
@@ -118,28 +122,29 @@ func (h *AgentsHandler) handleRevokeShare(w http.ResponseWriter, r *http.Request
func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Only owner can regenerate
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can regenerate agent"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "regenerate agent")})
return
}
if ag.Status == store.AgentStatusSummoning {
writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"})
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadySummoning)})
return
}
if h.summoner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "summoning not available"})
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgSummoningUnavailable)})
return
}
@@ -147,11 +152,11 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request)
Prompt string `json:"prompt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
if req.Prompt == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "prompt")})
return
}
@@ -170,33 +175,34 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request)
// Used when initial summoning failed (e.g. wrong model) and user wants to retry.
func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can resummon agent"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "resummon agent")})
return
}
if ag.Status == store.AgentStatusSummoning {
writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"})
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadySummoning)})
return
}
if h.summoner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "summoning not available"})
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgSummoningUnavailable)})
return
}
description := extractDescription(ag.OtherConfig)
if description == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent has no description to resummon from"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgNoDescription)})
return
}
+19
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -66,3 +67,21 @@ func extractAgentID(r *http.Request, model string) string {
return "default"
}
// extractLocale parses the Accept-Language header and returns a supported locale.
// Falls back to "en" if no supported language is found.
func extractLocale(r *http.Request) string {
accept := r.Header.Get("Accept-Language")
if accept == "" {
return i18n.DefaultLocale
}
// Simple parser: take the first language tag before comma or semicolon
for _, part := range strings.Split(accept, ",") {
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
locale := i18n.Normalize(tag)
if locale != i18n.DefaultLocale || strings.HasPrefix(tag, "en") {
return locale
}
}
return i18n.DefaultLocale
}
+12 -7
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -34,7 +35,8 @@ func (h *BuiltinToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
@@ -56,22 +58,24 @@ func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
result, err := h.store.List(r.Context())
if err != nil {
slog.Error("builtin_tools.list", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list tools"})
locale := extractLocale(r)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"tools": result})
}
func (h *BuiltinToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
name := r.PathValue("name")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
return
}
def, err := h.store.Get(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "tool not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tool", name)})
return
}
@@ -79,15 +83,16 @@ func (h *BuiltinToolsHandler) handleGet(w http.ResponseWriter, r *http.Request)
}
func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
name := r.PathValue("name")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
return
}
var updates map[string]interface{}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
@@ -101,7 +106,7 @@ func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Reques
}
if len(allowed) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no valid fields to update (only enabled and settings are editable)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidUpdates)})
return
}
+50 -37
View File
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -47,15 +48,17 @@ func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx := store.WithUserID(r.Context(), userID)
r = r.WithContext(ctx)
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
}
@@ -93,7 +96,8 @@ func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Requ
instances, err := h.store.ListPaged(r.Context(), opts)
if err != nil {
slog.Error("channel_instances.list", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list instances"})
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "instances")})
return
}
@@ -113,6 +117,7 @@ func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Requ
}
func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
var body struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
@@ -123,23 +128,23 @@ func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Re
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.Name == "" || body.ChannelType == "" || body.AgentID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name, channel_type, and agent_id are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name, channel_type, and agent_id")})
return
}
if !isValidChannelType(body.ChannelType) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid channel_type"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidChannelType)})
return
}
agentID, err := uuid.Parse(body.AgentID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
@@ -172,15 +177,16 @@ func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Re
}
func (h *ChannelInstancesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid instance ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
return
}
inst, err := h.store.Get(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "instance not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
return
}
@@ -188,15 +194,16 @@ func (h *ChannelInstancesHandler) handleGet(w http.ResponseWriter, r *http.Reque
}
func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid instance ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
return
}
var updates map[string]interface{}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
@@ -211,20 +218,21 @@ func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Re
}
func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid instance ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
return
}
// Look up instance to check if it's a default (seeded) instance.
inst, err := h.store.Get(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "instance not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
return
}
if store.IsDefaultChannelInstance(inst.Name) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete default channel instance"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteDefaultInst)})
return
}
@@ -241,18 +249,18 @@ func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Re
// maskInstanceHTTP returns a map with credentials masked for HTTP responses.
func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} {
result := map[string]interface{}{
"id": inst.ID,
"name": inst.Name,
"display_name": inst.DisplayName,
"channel_type": inst.ChannelType,
"agent_id": inst.AgentID,
"config": inst.Config,
"enabled": inst.Enabled,
"is_default": store.IsDefaultChannelInstance(inst.Name),
"has_credentials": len(inst.Credentials) > 0,
"created_by": inst.CreatedBy,
"created_at": inst.CreatedAt,
"updated_at": inst.UpdatedAt,
"id": inst.ID,
"name": inst.Name,
"display_name": inst.DisplayName,
"channel_type": inst.ChannelType,
"agent_id": inst.AgentID,
"config": inst.Config,
"enabled": inst.Enabled,
"is_default": store.IsDefaultChannelInstance(inst.Name),
"has_credentials": len(inst.Credentials) > 0,
"created_by": inst.CreatedBy,
"created_at": inst.CreatedAt,
"updated_at": inst.UpdatedAt,
}
if len(inst.Credentials) > 0 {
@@ -277,14 +285,15 @@ func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} {
// resolveAgentID looks up the channel instance and returns its agent_id.
func (h *ChannelInstancesHandler) resolveAgentID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
locale := store.LocaleFromContext(r.Context())
instID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid instance ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
return uuid.Nil, false
}
inst, err := h.store.Get(r.Context(), instID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "instance not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
return uuid.Nil, false
}
return inst.AgentID, true
@@ -298,7 +307,8 @@ func (h *ChannelInstancesHandler) handleWriterGroups(w http.ResponseWriter, r *h
groups, err := h.agentStore.ListGroupFileWriterGroups(r.Context(), agentID)
if err != nil {
slog.Error("channel_instances.writer_groups", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list writer groups"})
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writer groups")})
return
}
if groups == nil {
@@ -312,15 +322,16 @@ func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *ht
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
groupID := r.URL.Query().Get("group_id")
if groupID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group_id query parameter is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id")})
return
}
writers, err := h.agentStore.ListGroupFileWriters(r.Context(), agentID, groupID)
if err != nil {
slog.Error("channel_instances.list_writers", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list writers"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writers")})
return
}
if writers == nil {
@@ -334,6 +345,7 @@ func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
var body struct {
GroupID string `json:"group_id"`
UserID string `json:"user_id"`
@@ -341,16 +353,16 @@ func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http
Username string `json:"username"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.GroupID == "" || body.UserID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group_id and user_id are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and user_id")})
return
}
if err := h.agentStore.AddGroupFileWriter(r.Context(), agentID, body.GroupID, body.UserID, body.DisplayName, body.Username); err != nil {
slog.Error("channel_instances.add_writer", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to add writer"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "writer", err.Error())})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
@@ -361,15 +373,16 @@ func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *h
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
userID := r.PathValue("userId")
groupID := r.URL.Query().Get("group_id")
if groupID == "" || userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group_id query parameter and userId path parameter are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and userId")})
return
}
if err := h.agentStore.RemoveGroupFileWriter(r.Context(), agentID, groupID, userID); err != nil {
slog.Error("channel_instances.remove_writer", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to remove writer"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "writer", err.Error())})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
+17 -12
View File
@@ -10,6 +10,7 @@ import (
"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"
)
@@ -74,14 +75,16 @@ type chatUsage struct {
}
func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
http.Error(w, i18n.T(locale, i18n.MsgMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
// Auth check (timing-safe comparison)
if !tokenMatch(extractBearerToken(r), h.token) {
http.Error(w, `{"error":{"message":"Invalid authentication","type":"invalid_request_error"}}`, http.StatusUnauthorized)
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"invalid_request_error"}}`, i18n.T(locale, i18n.MsgInvalidAuth)), http.StatusUnauthorized)
return
}
@@ -93,7 +96,7 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
}
if !h.rateLimiter(key) {
w.Header().Set("Retry-After", "60")
http.Error(w, `{"error":{"message":"Rate limit exceeded","type":"rate_limit_error"}}`, http.StatusTooManyRequests)
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"rate_limit_error"}}`, i18n.T(locale, i18n.MsgRateLimitExceeded)), http.StatusTooManyRequests)
return
}
}
@@ -104,25 +107,25 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
var req chatCompletionsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf(`{"error":{"message":"Invalid JSON: %s"}}`, err), http.StatusBadRequest)
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())), http.StatusBadRequest)
return
}
if len(req.Messages) == 0 {
http.Error(w, `{"error":{"message":"messages is required"}}`, http.StatusBadRequest)
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgMsgsRequired)), http.StatusBadRequest)
return
}
agentID := extractAgentID(r, req.Model)
userID := extractUserID(r)
if h.isManaged && userID == "" {
http.Error(w, `{"error":{"message":"X-GoClaw-User-Id header is required in managed mode"}}`, http.StatusBadRequest)
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgUserIDHeader)), http.StatusBadRequest)
return
}
loop, err := h.agents.Get(agentID)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":{"message":"Agent not found: %s"}}`, agentID), http.StatusNotFound)
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgNotFound, "agent", agentID)), http.StatusNotFound)
return
}
@@ -135,12 +138,12 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
}
}
if lastMessage == "" {
http.Error(w, `{"error":{"message":"No user message found"}}`, http.StatusBadRequest)
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgNoUserMessage)), http.StatusBadRequest)
return
}
// Inject user_id into context for downstream stores/tools
ctx := r.Context()
// Inject user_id and locale into context for downstream stores/tools
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
@@ -174,7 +177,8 @@ func (h *ChatCompletionsHandler) handleNonStream(w http.ResponseWriter, r *http.
})
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":{"message":"Agent error: %s"}}`, err), http.StatusInternalServerError)
locale := store.LocaleFromContext(r.Context())
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgInternalError, err.Error())), http.StatusInternalServerError)
return
}
@@ -205,7 +209,8 @@ func (h *ChatCompletionsHandler) handleNonStream(w http.ResponseWriter, r *http.
func (h *ChatCompletionsHandler) handleStream(w http.ResponseWriter, r *http.Request, loop agent.Agent, runID, sessionKey, message, model, userID string) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
locale := store.LocaleFromContext(r.Context())
http.Error(w, i18n.T(locale, i18n.MsgStreamingNotSupported), http.StatusInternalServerError)
return
}
+23 -15
View File
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -40,15 +41,17 @@ func (h *CustomToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx := store.WithUserID(r.Context(), userID)
r = r.WithContext(ctx)
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
}
@@ -64,6 +67,7 @@ func (h *CustomToolsHandler) emitCacheInvalidate(key string) {
}
func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
opts := store.CustomToolListOpts{
Limit: 50,
Offset: 0,
@@ -72,7 +76,7 @@ func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
if v := r.URL.Query().Get("agent_id"); v != "" {
id, err := uuid.Parse(v)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
opts.AgentID = &id
@@ -94,7 +98,7 @@ func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
result, err := h.store.ListPaged(r.Context(), opts)
if err != nil {
slog.Error("custom_tools.list", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list tools"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")})
return
}
@@ -109,25 +113,26 @@ func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
}
func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
var def store.CustomToolDef
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&def); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if def.Name == "" || def.Command == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and command are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name and command")})
return
}
if !isValidSlug(def.Name) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
return
}
// Check name collision with built-in/MCP tools
if h.toolsReg != nil {
if _, exists := h.toolsReg.Get(def.Name); exists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "tool name conflicts with existing built-in or MCP tool"})
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "tool name", def.Name)})
return
}
}
@@ -152,15 +157,16 @@ func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request
}
func (h *CustomToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid tool ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
return
}
def, err := h.store.Get(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "tool not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tool", id.String())})
return
}
@@ -168,21 +174,22 @@ func (h *CustomToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
}
func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid tool ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
return
}
var updates map[string]interface{}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if name, ok := updates["name"]; ok {
if s, _ := name.(string); !isValidSlug(s) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
return
}
}
@@ -198,9 +205,10 @@ func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request
}
func (h *CustomToolsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid tool ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
return
}
+8 -2
View File
@@ -6,6 +6,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -28,10 +29,14 @@ func (h *DelegationsHandler) authMiddleware(next http.HandlerFunc) http.HandlerF
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
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)
}
}
@@ -87,10 +92,11 @@ func (h *DelegationsHandler) handleList(w http.ResponseWriter, r *http.Request)
}
func (h *DelegationsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "delegation")})
return
}
+7 -3
View File
@@ -7,6 +7,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
)
// FilesHandler serves files over HTTP with Bearer token auth.
@@ -34,7 +36,8 @@ func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
provided = r.URL.Query().Get("token")
}
if provided != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
@@ -43,16 +46,17 @@ func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
}
func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
urlPath := r.PathValue("path")
if urlPath == "" {
http.Error(w, "path is required", http.StatusBadRequest)
http.Error(w, i18n.T(locale, i18n.MsgRequired, "path"), http.StatusBadRequest)
return
}
// Prevent path traversal
if strings.Contains(urlPath, "..") {
slog.Warn("security.files_traversal", "path", urlPath)
http.Error(w, "invalid path", http.StatusBadRequest)
http.Error(w, i18n.T(locale, i18n.MsgInvalidPath), http.StatusBadRequest)
return
}
+14 -9
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -53,13 +54,14 @@ func (h *KnowledgeGraphHandler) handleListEntities(w http.ResponseWriter, r *htt
}
func (h *KnowledgeGraphHandler) handleGetEntity(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
entityID := r.PathValue("entityID")
userID := r.URL.Query().Get("user_id")
entity, err := h.store.GetEntity(r.Context(), agentID, userID, entityID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "entity not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "entity", entityID)})
return
}
@@ -78,17 +80,18 @@ func (h *KnowledgeGraphHandler) handleGetEntity(w http.ResponseWriter, r *http.R
}
func (h *KnowledgeGraphHandler) handleUpsertEntity(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
var entity store.Entity
if err := json.NewDecoder(r.Body).Decode(&entity); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
entity.AgentID = agentID
if entity.ExternalID == "" || entity.Name == "" || entity.EntityType == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "external_id, name, and entity_type are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgEntityFieldsRequired)})
return
}
if entity.Confidence <= 0 {
@@ -117,6 +120,7 @@ func (h *KnowledgeGraphHandler) handleDeleteEntity(w http.ResponseWriter, r *htt
}
func (h *KnowledgeGraphHandler) handleTraverse(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
var body struct {
@@ -125,11 +129,11 @@ func (h *KnowledgeGraphHandler) handleTraverse(w http.ResponseWriter, r *http.Re
MaxDepth int `json:"max_depth"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.EntityID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "entity_id is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgEntityIDRequired)})
return
}
if body.MaxDepth <= 0 {
@@ -152,6 +156,7 @@ func (h *KnowledgeGraphHandler) handleTraverse(w http.ResponseWriter, r *http.Re
}
func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
var body struct {
@@ -162,21 +167,21 @@ func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Req
MinConf float64 `json:"min_confidence"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.Text == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTextRequired)})
return
}
if body.Provider == "" || body.Model == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgProviderModelRequired)})
return
}
extractor := h.NewExtractor(body.Provider, body.Model, body.MinConf)
if extractor == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider or model"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidProviderOrModel)})
return
}
+21 -14
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -75,15 +76,17 @@ func (h *MCPHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx := store.WithUserID(r.Context(), userID)
r = r.WithContext(ctx)
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
}
@@ -94,25 +97,27 @@ func (h *MCPHandler) handleListServers(w http.ResponseWriter, r *http.Request) {
servers, err := h.store.ListServers(r.Context())
if err != nil {
slog.Error("mcp.list_servers", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list servers"})
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "servers")})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"servers": servers})
}
func (h *MCPHandler) handleCreateServer(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
var srv store.MCPServerData
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&srv); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if srv.Name == "" || srv.Transport == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and transport are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name and transport")})
return
}
if !isValidSlug(srv.Name) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
return
}
@@ -132,15 +137,16 @@ func (h *MCPHandler) handleCreateServer(w http.ResponseWriter, r *http.Request)
}
func (h *MCPHandler) handleGetServer(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
srv, err := h.store.GetServer(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "server", id.String())})
return
}
@@ -148,21 +154,22 @@ func (h *MCPHandler) handleGetServer(w http.ResponseWriter, r *http.Request) {
}
func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
var updates map[string]interface{}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if name, ok := updates["name"]; ok {
if s, _ := name.(string); !isValidSlug(s) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
return
}
}
@@ -178,9 +185,10 @@ func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request)
}
func (h *MCPHandler) handleDeleteServer(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
@@ -193,4 +201,3 @@ func (h *MCPHandler) handleDeleteServer(w http.ResponseWriter, r *http.Request)
h.emitCacheInvalidate()
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
+19 -12
View File
@@ -7,13 +7,15 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (h *MCPHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
@@ -23,13 +25,13 @@ func (h *MCPHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
ToolDeny json.RawMessage `json:"tool_deny,omitempty"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
agentID, err := uuid.Parse(req.AgentID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
@@ -53,15 +55,16 @@ func (h *MCPHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
}
func (h *MCPHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
agentID, err := uuid.Parse(r.PathValue("agentID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
@@ -76,9 +79,10 @@ func (h *MCPHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request) {
}
func (h *MCPHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
agentID, err := uuid.Parse(r.PathValue("agentID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
@@ -93,16 +97,17 @@ func (h *MCPHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Reques
}
func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
grants, err := h.store.ListServerGrants(r.Context(), serverID)
if err != nil {
slog.Error("mcp.list_server_grants", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list grants"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "grants")})
return
}
@@ -110,9 +115,10 @@ func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Reque
}
func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
@@ -122,12 +128,12 @@ func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
ToolDeny json.RawMessage `json:"tool_deny,omitempty"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.UserID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")})
return
}
if err := store.ValidateUserID(req.UserID); err != nil {
@@ -155,9 +161,10 @@ func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
}
func (h *MCPHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
+8 -5
View File
@@ -7,22 +7,24 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (h *MCPHandler) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
var req store.MCPAccessRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.ServerID == uuid.Nil || req.Scope == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "server_id and scope are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "server_id and scope")})
return
}
if req.Scope != "agent" && req.Scope != "user" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be 'agent' or 'user'"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "scope must be 'agent' or 'user'")})
return
}
@@ -50,9 +52,10 @@ func (h *MCPHandler) handleListPendingRequests(w http.ResponseWriter, r *http.Re
}
func (h *MCPHandler) handleReviewRequest(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "request")})
return
}
@@ -61,7 +64,7 @@ func (h *MCPHandler) handleReviewRequest(w http.ResponseWriter, r *http.Request)
Note string `json:"note,omitempty"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
+8 -4
View File
@@ -7,11 +7,14 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
)
// handleTestConnection tests an MCP server connection without saving it.
func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
var req struct {
Transport string `json:"transport"`
Command string `json:"command"`
@@ -21,11 +24,11 @@ func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request
Env map[string]string `json:"env"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.Transport == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "transport is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "transport")})
return
}
@@ -46,15 +49,16 @@ func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request
// handleListServerTools lists tools for a specific MCP server.
func (h *MCPHandler) handleListServerTools(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
srv, err := h.store.GetServer(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "server", id.String())})
return
}
+5 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/media"
)
@@ -34,7 +35,8 @@ func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc {
provided = r.URL.Query().Get("token")
}
if provided != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
@@ -43,9 +45,10 @@ func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc {
}
func (h *MediaServeHandler) handleServe(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id := r.PathValue("id")
if id == "" || strings.Contains(id, "..") || strings.Contains(id, "/") {
http.Error(w, "invalid media id", http.StatusBadRequest)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "invalid media id")})
return
}
+9 -6
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
)
const (
@@ -37,7 +38,8 @@ func (h *MediaUploadHandler) auth(next http.HandlerFunc) http.HandlerFunc {
if h.token != "" {
provided := extractBearerToken(r)
if provided != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
@@ -46,16 +48,17 @@ func (h *MediaUploadHandler) auth(next http.HandlerFunc) http.HandlerFunc {
}
func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file too large or invalid multipart form"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgFileTooLarge)})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'file' field"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMissingFileField)})
return
}
defer file.Close()
@@ -63,7 +66,7 @@ func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request
// Sanitize filename: strip path, prevent traversal.
origName := filepath.Base(header.Filename)
if origName == "." || origName == "/" || strings.Contains(origName, "..") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)})
return
}
@@ -78,14 +81,14 @@ func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request
out, err := os.Create(tmpPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create temp file"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create temp file")})
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
os.Remove(tmpPath)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save file"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
+3 -1
View File
@@ -3,6 +3,7 @@ package http
import (
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -34,7 +35,8 @@ func (h *MemoryHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
+13 -7
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -44,6 +45,7 @@ func (h *MemoryHandler) handleListDocuments(w http.ResponseWriter, r *http.Reque
}
func (h *MemoryHandler) handleGetDocument(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
path := r.PathValue("path")
userID := r.URL.Query().Get("user_id")
@@ -51,13 +53,14 @@ func (h *MemoryHandler) handleGetDocument(w http.ResponseWriter, r *http.Request
detail, err := h.store.GetDocumentDetail(r.Context(), agentID, userID, path)
if err != nil {
slog.Warn("memory.get_document failed", "error", err, "path", path)
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "document", path)})
return
}
writeJSON(w, http.StatusOK, detail)
}
func (h *MemoryHandler) handlePutDocument(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
path := r.PathValue("path")
@@ -66,7 +69,7 @@ func (h *MemoryHandler) handlePutDocument(w http.ResponseWriter, r *http.Request
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
@@ -92,12 +95,13 @@ func (h *MemoryHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Requ
}
func (h *MemoryHandler) handleListChunks(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
path := r.URL.Query().Get("path")
userID := r.URL.Query().Get("user_id")
if path == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path query parameter required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
return
}
@@ -114,6 +118,7 @@ func (h *MemoryHandler) handleListChunks(w http.ResponseWriter, r *http.Request)
}
func (h *MemoryHandler) handleIndexDocument(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
var body struct {
@@ -121,11 +126,11 @@ func (h *MemoryHandler) handleIndexDocument(w http.ResponseWriter, r *http.Reque
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.Path == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
return
}
@@ -154,6 +159,7 @@ func (h *MemoryHandler) handleIndexAll(w http.ResponseWriter, r *http.Request) {
}
func (h *MemoryHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
var body struct {
@@ -163,11 +169,11 @@ func (h *MemoryHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
MinScore float64 `json:"min_score"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.Query == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "query is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "query")})
return
}
+9 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/oauth"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -47,7 +48,8 @@ func (h *OAuthHandler) RegisterRoutes(mux *http.ServeMux) {
func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !tokenMatch(extractBearerToken(r), h.token) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
next(w, r)
@@ -80,6 +82,7 @@ func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
}
func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
h.mu.Lock()
defer h.mu.Unlock()
@@ -102,7 +105,7 @@ func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Error("oauth.start", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to start OAuth flow (is port 1455 available?)",
"error": i18n.T(locale, i18n.MsgInternalError, "failed to start OAuth flow (is port 1455 available?)"),
})
return
}
@@ -142,11 +145,12 @@ func (h *OAuthHandler) waitForCallback(pending *oauth.PendingLogin) {
}
func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
var body struct {
RedirectURL string `json:"redirect_url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RedirectURL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "redirect_url is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "redirect_url")})
return
}
@@ -155,7 +159,7 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque
h.mu.Unlock()
if pending == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no pending OAuth flow"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgNoPendingOAuth)})
return
}
@@ -177,7 +181,7 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque
providerID, err := h.saveAndRegister(r.Context(), tokenResp)
if err != nil {
slog.Error("oauth.save_token", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save token"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToSaveToken)})
return
}
+13 -5
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -28,10 +29,14 @@ func (h *PendingMessagesHandler) authMiddleware(next http.HandlerFunc) http.Hand
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
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)
}
}
@@ -58,10 +63,11 @@ func (h *PendingMessagesHandler) handleListGroups(w http.ResponseWriter, r *http
// GET /v1/pending-messages/messages?channel=X&key=Y — list messages for a group
func (h *PendingMessagesHandler) handleListMessages(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
channel := r.URL.Query().Get("channel")
key := r.URL.Query().Get("key")
if channel == "" || key == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel and key are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgChannelKeyReq)})
return
}
@@ -75,10 +81,11 @@ func (h *PendingMessagesHandler) handleListMessages(w http.ResponseWriter, r *ht
// DELETE /v1/pending-messages?channel=X&key=Y — clear a group
func (h *PendingMessagesHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
channel := r.URL.Query().Get("channel")
key := r.URL.Query().Get("key")
if channel == "" || key == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel and key are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgChannelKeyReq)})
return
}
@@ -96,13 +103,14 @@ type compactRequest struct {
// POST /v1/pending-messages/compact — MVP: clear group and return success
func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
var req compactRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.ChannelName == "" || req.HistoryKey == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel_name and history_key are required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "channel_name and history_key")})
return
}
+5 -3
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -25,15 +26,16 @@ type ModelInfo struct {
//
// GET /v1/providers/{id}/models
func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
@@ -44,7 +46,7 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
}
if p.APIKey == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider has no API key configured"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "API key")})
return
}
+6 -4
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
@@ -17,9 +18,10 @@ import (
// Body: {"model": "anthropic/claude-sonnet-4"}
// Response: {"valid": true} or {"valid": false, "error": "..."}
func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
@@ -27,18 +29,18 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
Model string `json:"model"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.Model == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "model")})
return
}
// Look up provider record from DB to get the provider name
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
+21 -14
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/oauth"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -58,7 +59,8 @@ func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
@@ -128,7 +130,8 @@ func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Re
providers, err := h.store.ListProviders(r.Context())
if err != nil {
slog.Error("providers.list", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list providers"})
locale := extractLocale(r)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "providers")})
return
}
@@ -140,22 +143,23 @@ func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Re
}
func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
var p store.LLMProviderData
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if p.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
return
}
if !isValidSlug(p.Name) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
return
}
if !store.ValidProviderTypes[p.ProviderType] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported provider_type"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "unsupported provider_type")})
return
}
@@ -169,7 +173,7 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R
for _, ep := range existing {
if ep.ProviderType == store.ProviderClaudeCLI {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "A Claude CLI provider already exists. Only one is allowed per instance.",
"error": i18n.T(locale, i18n.MsgAlreadyExists, "Claude CLI provider", "only one is allowed per instance"),
})
return
}
@@ -190,15 +194,16 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R
}
func (h *ProvidersHandler) handleGetProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
@@ -207,22 +212,23 @@ func (h *ProvidersHandler) handleGetProvider(w http.ResponseWriter, r *http.Requ
}
func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
var updates map[string]interface{}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
// Validate name if being updated
if name, ok := updates["name"]; ok {
if s, _ := name.(string); !isValidSlug(s) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
return
}
}
@@ -233,7 +239,7 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
// silently deleting it would hide the error from the end user.
if pt, ok := updates["provider_type"]; ok {
if s, _ := pt.(string); !store.ValidProviderTypes[s] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported provider_type"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "unsupported provider_type")})
return
}
}
@@ -282,9 +288,10 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
}
func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
+13 -8
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -62,15 +63,17 @@ func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
userID := extractUserID(r)
ctx := store.WithLocale(r.Context(), extractLocale(r))
if userID != "" {
ctx := store.WithUserID(r.Context(), userID)
r = r.WithContext(ctx)
ctx = store.WithUserID(ctx, userID)
}
r = r.WithContext(ctx)
next(w, r)
}
}
@@ -81,26 +84,28 @@ func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) {
}
func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id := r.PathValue("id")
skill, ok := h.skills.GetSkill(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id)})
return
}
writeJSON(w, http.StatusOK, skill)
}
func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
// Prevent changing sensitive fields
@@ -118,10 +123,11 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
}
func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
@@ -133,4 +139,3 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
h.skills.BumpVersion()
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
+17 -11
View File
@@ -12,21 +12,23 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
agentIDStr := r.PathValue("agentID")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
skills, err := h.skills.ListWithGrantStatus(r.Context(), agentID)
if err != nil {
slog.Error("failed to list skills with grant status", "agent_id", agentID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list skills"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "skills")})
return
}
@@ -34,11 +36,12 @@ func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Req
}
func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
userID := store.UserIDFromContext(r.Context())
idStr := r.PathValue("id")
skillID, err := uuid.Parse(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
@@ -47,13 +50,13 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
Version int `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
agentID, err := uuid.Parse(req.AgentID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
@@ -72,17 +75,18 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
}
func (h *SkillsHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
idStr := r.PathValue("id")
skillID, err := uuid.Parse(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
agentIDStr := r.PathValue("agentID")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
@@ -97,11 +101,12 @@ func (h *SkillsHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request
}
func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
userID := store.UserIDFromContext(r.Context())
idStr := r.PathValue("id")
skillID, err := uuid.Parse(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
@@ -109,11 +114,11 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request)
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.UserID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")})
return
}
if err := store.ValidateUserID(req.UserID); err != nil {
@@ -132,10 +137,11 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request)
}
func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
idStr := r.PathValue("id")
skillID, err := uuid.Parse(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
+13 -11
View File
@@ -11,15 +11,17 @@ import (
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// handleUpload processes a ZIP file upload containing a skill (must have SKILL.md at root).
func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
userID := store.UserIDFromContext(r.Context())
if userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "X-GoClaw-User-Id header required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
return
}
@@ -27,7 +29,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file is required: " + err.Error()})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "file is required: "+err.Error())})
return
}
defer file.Close()
@@ -35,7 +37,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
// Save to temp file for zip processing
tmp, err := os.CreateTemp("", "skill-upload-*.zip")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create temp file"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create temp file")})
return
}
defer os.Remove(tmp.Name())
@@ -44,7 +46,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
hasher := sha256.New()
size, err := io.Copy(io.MultiWriter(tmp, hasher), file)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save upload"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save upload")})
return
}
fileHash := fmt.Sprintf("%x", hasher.Sum(nil))
@@ -52,7 +54,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
// Open as zip
zr, err := zip.OpenReader(tmp.Name())
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid ZIP file"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "invalid ZIP file")})
return
}
defer zr.Close()
@@ -77,27 +79,27 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
}
}
if skillMD == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ZIP must contain SKILL.md at root (or inside a single top-level directory)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "ZIP must contain SKILL.md at root (or inside a single top-level directory)")})
return
}
// Read and parse SKILL.md frontmatter
skillContent, err := readZipFile(skillMD)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read SKILL.md"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "failed to read SKILL.md")})
return
}
name, description, slug, frontmatter := parseSkillFrontmatter(skillContent)
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "SKILL.md must have a name in frontmatter"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name in SKILL.md frontmatter")})
return
}
if slug == "" {
slug = slugify(name)
}
if !slugRegexp.MatchString(slug) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "slug must be a valid slug (lowercase letters, numbers, hyphens only)"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "slug")})
return
}
@@ -107,7 +109,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
// Extract to filesystem: baseDir/slug/version/
destDir := filepath.Join(h.baseDir, slug, fmt.Sprintf("%d", version))
if err := os.MkdirAll(destDir, 0755); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create skill directory"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create skill directory")})
return
}
@@ -167,7 +169,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
id, err := h.skills.CreateSkillManaged(r.Context(), skill)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save skill: " + err.Error()})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "skill", err.Error())})
return
}
+22 -16
View File
@@ -10,19 +10,23 @@ import (
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleListVersions returns all available version numbers for a skill.
func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
return
}
@@ -60,15 +64,16 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques
// handleListFiles returns all files in a skill version directory.
func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
return
}
@@ -76,7 +81,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
if v := r.URL.Query().Get("version"); v != "" {
parsed, err := strconv.Atoi(v)
if err != nil || parsed < 1 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVersion)})
return
}
version = parsed
@@ -84,7 +89,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version))
if _, err := os.Stat(versionDir); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "version not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
return
}
@@ -137,26 +142,27 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
// handleReadFile reads a single file from a skill version directory.
func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return
}
relPath := r.PathValue("path")
if relPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
return
}
if strings.Contains(relPath, "..") {
slog.Warn("security.skill_files_traversal", "path", relPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
return
}
@@ -164,7 +170,7 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
if v := r.URL.Query().Get("version"); v != "" {
parsed, err := strconv.Atoi(v)
if err != nil || parsed < 1 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVersion)})
return
}
version = parsed
@@ -176,31 +182,31 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
// Verify resolved path is within the version directory
if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) {
slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Use Lstat to detect symlinks — reject them to prevent directory escape
info, err := os.Lstat(absPath)
if err != nil || info.IsDir() {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
return
}
if info.Mode()&os.ModeSymlink != 0 {
slog.Warn("security.skill_files_symlink", "path", absPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Skip system artifacts
if isSystemArtifact(relPath) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
return
}
data, err := os.ReadFile(absPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToReadFile)})
return
}
+23 -17
View File
@@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
)
// StorageHandler provides HTTP endpoints for browsing and managing
@@ -33,7 +35,8 @@ func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc {
if h.token != "" {
provided := extractBearerToken(r)
if !tokenMatch(provided, h.token) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
locale := extractLocale(r)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
@@ -65,10 +68,11 @@ func isProtectedPath(rel string) bool {
// handleList lists all files and directories under ~/.goclaw/.
// Optional query param ?path= scopes the listing to a subtree.
func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
subPath := r.URL.Query().Get("path")
if strings.Contains(subPath, "..") {
slog.Warn("security.storage_traversal", "path", subPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
@@ -77,7 +81,7 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath))
if !strings.HasPrefix(rootDir, h.baseDir) {
slog.Warn("security.storage_escape", "resolved", rootDir, "root", h.baseDir)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
}
@@ -87,8 +91,8 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
TotalSize int64 `json:"totalSize"` // recursive size for directories
Protected bool `json:"protected"` // true if deletion is blocked
TotalSize int64 `json:"totalSize"` // recursive size for directories
Protected bool `json:"protected"` // true if deletion is blocked
}
// Compute directory sizes via a two-pass approach:
@@ -180,38 +184,39 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
// handleRead reads a single file's content by relative path.
func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
relPath := r.PathValue("path")
if relPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
return
}
if strings.Contains(relPath, "..") {
slog.Warn("security.storage_traversal", "path", relPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
absPath := filepath.Join(h.baseDir, filepath.Clean(relPath))
if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) {
slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
info, err := os.Lstat(absPath)
if err != nil || info.IsDir() {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
return
}
if info.Mode()&os.ModeSymlink != 0 {
slog.Warn("security.storage_symlink", "path", absPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
data, err := os.ReadFile(absPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToReadFile)})
return
}
@@ -225,33 +230,34 @@ func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) {
// handleDelete removes a file or directory (recursively).
// Rejects deletion of the root dir and any path inside excluded directories.
func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
relPath := r.PathValue("path")
if relPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
return
}
if strings.Contains(relPath, "..") {
slog.Warn("security.storage_traversal", "path", relPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
if isProtectedPath(relPath) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete skills directories"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteSkillsDir)})
return
}
absPath := filepath.Join(h.baseDir, filepath.Clean(relPath))
if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) {
slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Verify path exists
info, err := os.Lstat(absPath)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "path", relPath)})
return
}
@@ -266,7 +272,7 @@ func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Error("storage.delete_failed", "path", absPath, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete"})
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDeleteFile)})
return
}
+8 -6
View File
@@ -2,10 +2,10 @@ package http
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -39,26 +39,28 @@ type toolsInvokeRequest struct {
}
func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": i18n.T(locale, i18n.MsgMethodNotAllowed)})
return
}
if h.token != "" {
if extractBearerToken(r) != h.token {
http.Error(w, `{"error":{"code":"UNAUTHORIZED","message":"Invalid token"}}`, http.StatusUnauthorized)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
}
var req toolsInvokeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf(`{"error":{"code":"BAD_REQUEST","message":"%s"}}`, err), http.StatusBadRequest)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.Tool == "" {
http.Error(w, `{"error":{"code":"BAD_REQUEST","message":"tool is required"}}`, http.StatusBadRequest)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "tool")})
return
}
@@ -68,7 +70,7 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Just check if tool exists and return its schema
tool, ok := h.registry.Get(req.Tool)
if !ok {
writeToolError(w, http.StatusNotFound, "NOT_FOUND", fmt.Sprintf("Tool '%s' not found", req.Tool))
writeToolError(w, http.StatusNotFound, "NOT_FOUND", i18n.T(locale, i18n.MsgNotFound, "tool", req.Tool))
return
}
+9 -3
View File
@@ -6,6 +6,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -30,10 +31,14 @@ func (h *TracesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.token != "" {
if extractBearerToken(r) != h.token {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
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)
}
}
@@ -87,16 +92,17 @@ func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) {
}
func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
traceIDStr := r.PathValue("traceID")
traceID, err := uuid.Parse(traceIDStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid trace ID"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "trace")})
return
}
trace, err := h.tracing.GetTrace(r.Context(), traceID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "trace not found"})
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceIDStr)})
return
}
+132
View File
@@ -0,0 +1,132 @@
package i18n
func init() {
register(LocaleEN, map[string]string{
// Common validation
MsgRequired: "%s is required",
MsgInvalidID: "invalid %s ID",
MsgNotFound: "%s not found: %s",
MsgAlreadyExists: "%s already exists: %s",
MsgInvalidRequest: "invalid request: %s",
MsgInvalidJSON: "invalid JSON",
MsgUnauthorized: "unauthorized",
MsgPermissionDenied: "permission denied: insufficient role for %s",
MsgInternalError: "internal error: %s",
MsgInvalidSlug: "%s must be a valid slug (lowercase letters, numbers, hyphens only)",
MsgFailedToList: "failed to list %s",
MsgFailedToCreate: "failed to create %s: %s",
MsgFailedToUpdate: "failed to update %s: %s",
MsgFailedToDelete: "failed to delete %s: %s",
MsgFailedToSave: "failed to save %s: %s",
MsgInvalidUpdates: "invalid updates",
// Agent
MsgAgentNotFound: "agent not found: %s",
MsgCannotDeleteDefault: "cannot delete the default agent",
MsgUserCtxRequired: "user context required",
// Chat
MsgRateLimitExceeded: "rate limit exceeded — please wait",
MsgNoUserMessage: "no user message found",
MsgUserIDRequired: "user_id is required",
MsgMsgRequired: "message is required",
// Channel instances
MsgInvalidChannelType: "invalid channel_type",
MsgInstanceNotFound: "instance not found",
// Cron
MsgJobNotFound: "job not found",
MsgInvalidCronExpr: "invalid cron expression: %s",
// Config
MsgConfigHashMismatch: "config has changed (hash mismatch)",
// Exec approval
MsgExecApprovalDisabled: "exec approval is not enabled",
// Pairing
MsgSenderChannelRequired: "senderId and channel are required",
MsgCodeRequired: "code is required",
MsgSenderIDRequired: "sender_id is required",
// HTTP API
MsgInvalidAuth: "invalid authentication",
MsgMsgsRequired: "messages is required",
MsgUserIDHeader: "X-GoClaw-User-Id header is required in managed mode",
MsgFileTooLarge: "file too large or invalid multipart form",
MsgMissingFileField: "missing 'file' field",
MsgInvalidFilename: "invalid filename",
MsgChannelKeyReq: "channel and key are required",
MsgMethodNotAllowed: "method not allowed",
MsgStreamingNotSupported: "streaming not supported",
MsgOwnerOnly: "only owner can %s",
MsgNoAccess: "no access to this %s",
MsgAlreadySummoning: "agent is already being summoned",
MsgSummoningUnavailable: "summoning not available",
MsgNoDescription: "agent has no description to resummon from",
MsgInvalidPath: "invalid path",
// Scheduler
MsgQueueFull: "session queue is full",
MsgShuttingDown: "gateway is shutting down, please retry shortly",
// Provider
MsgProviderReqFailed: "%s: request failed: %s",
// Unknown method
MsgUnknownMethod: "unknown method: %s",
// Not implemented
MsgNotImplemented: "%s not yet implemented",
// Agent links
MsgLinksNotConfigured: "agent links not configured",
MsgInvalidDirection: "direction must be outbound, inbound, or bidirectional",
MsgSourceTargetSame: "source and target must be different agents",
MsgCannotDelegateOpen: "cannot delegate to open agents — only predefined agents can be delegation targets",
MsgNoUpdatesProvided: "no updates provided",
MsgInvalidLinkStatus: "status must be active or disabled",
// Teams
MsgTeamsNotConfigured: "teams not configured",
MsgAgentIsTeamLead: "agent is already the team lead",
MsgCannotRemoveTeamLead: "cannot remove the team lead",
// Delegations
MsgDelegationsUnavailable: "delegations not available",
// Channels
MsgCannotDeleteDefaultInst: "cannot delete default channel instance",
// Skills
MsgSkillsUpdateNotSupported: "skills.update not supported for file-based skills",
MsgCannotResolveSkillID: "cannot resolve skill ID for file-based skill",
// Logs
MsgInvalidLogAction: "action must be 'start' or 'stop'",
// Config
MsgRawConfigRequired: "raw config is required",
MsgRawPatchRequired: "raw patch is required",
// Storage / File
MsgCannotDeleteSkillsDir: "cannot delete skills directories",
MsgFailedToReadFile: "failed to read file",
MsgFileNotFound: "file not found",
MsgInvalidVersion: "invalid version",
MsgVersionNotFound: "version not found",
MsgFailedToDeleteFile: "failed to delete",
// OAuth
MsgNoPendingOAuth: "no pending OAuth flow",
MsgFailedToSaveToken: "failed to save token",
// Knowledge Graph
MsgEntityIDRequired: "entity_id is required",
MsgEntityFieldsRequired: "external_id, name, and entity_type are required",
MsgTextRequired: "text is required",
MsgProviderModelRequired: "provider and model are required",
MsgInvalidProviderOrModel: "invalid provider or model",
})
}
+132
View File
@@ -0,0 +1,132 @@
package i18n
func init() {
register(LocaleVI, map[string]string{
// Common validation
MsgRequired: "%s là bắt buộc",
MsgInvalidID: "ID %s không hợp lệ",
MsgNotFound: "không tìm thấy %s: %s",
MsgAlreadyExists: "%s đã tồn tại: %s",
MsgInvalidRequest: "yêu cầu không hợp lệ: %s",
MsgInvalidJSON: "JSON không hợp lệ",
MsgUnauthorized: "chưa xác thực",
MsgPermissionDenied: "từ chối quyền truy cập: không đủ quyền cho %s",
MsgInternalError: "lỗi nội bộ: %s",
MsgInvalidSlug: "%s phải là slug hợp lệ (chữ thường, số, dấu gạch ngang)",
MsgFailedToList: "không thể liệt kê %s",
MsgFailedToCreate: "không thể tạo %s: %s",
MsgFailedToUpdate: "không thể cập nhật %s: %s",
MsgFailedToDelete: "không thể xóa %s: %s",
MsgFailedToSave: "không thể lưu %s: %s",
MsgInvalidUpdates: "cập nhật không hợp lệ",
// Agent
MsgAgentNotFound: "không tìm thấy agent: %s",
MsgCannotDeleteDefault: "không thể xóa agent mặc định",
MsgUserCtxRequired: "yêu cầu ngữ cảnh người dùng",
// Chat
MsgRateLimitExceeded: "vượt quá giới hạn tốc độ — vui lòng đợi",
MsgNoUserMessage: "không tìm thấy tin nhắn người dùng",
MsgUserIDRequired: "user_id là bắt buộc",
MsgMsgRequired: "tin nhắn là bắt buộc",
// Channel instances
MsgInvalidChannelType: "loại channel không hợp lệ",
MsgInstanceNotFound: "không tìm thấy phiên bản",
// Cron
MsgJobNotFound: "không tìm thấy tác vụ",
MsgInvalidCronExpr: "biểu thức cron không hợp lệ: %s",
// Config
MsgConfigHashMismatch: "cấu hình đã thay đổi (hash không khớp)",
// Exec approval
MsgExecApprovalDisabled: "phê duyệt thực thi chưa được bật",
// Pairing
MsgSenderChannelRequired: "senderId và channel là bắt buộc",
MsgCodeRequired: "mã là bắt buộc",
MsgSenderIDRequired: "sender_id là bắt buộc",
// HTTP API
MsgInvalidAuth: "xác thực không hợp lệ",
MsgMsgsRequired: "messages là bắt buộc",
MsgUserIDHeader: "header X-GoClaw-User-Id là bắt buộc ở chế độ managed",
MsgFileTooLarge: "tệp quá lớn hoặc form multipart không hợp lệ",
MsgMissingFileField: "thiếu trường 'file'",
MsgInvalidFilename: "tên tệp không hợp lệ",
MsgChannelKeyReq: "channel và key là bắt buộc",
MsgMethodNotAllowed: "phương thức không được phép",
MsgStreamingNotSupported: "streaming không được hỗ trợ",
MsgOwnerOnly: "chỉ chủ sở hữu mới có thể %s",
MsgNoAccess: "không có quyền truy cập %s này",
MsgAlreadySummoning: "agent đang được triệu hồi",
MsgSummoningUnavailable: "triệu hồi không khả dụng",
MsgNoDescription: "agent không có mô tả để triệu hồi lại",
MsgInvalidPath: "đường dẫn không hợp lệ",
// Scheduler
MsgQueueFull: "hàng đợi session đã đầy",
MsgShuttingDown: "cổng đang tắt, vui lòng thử lại sau",
// Provider
MsgProviderReqFailed: "%s: yêu cầu thất bại: %s",
// Unknown method
MsgUnknownMethod: "phương thức không xác định: %s",
// Not implemented
MsgNotImplemented: "%s chưa được triển khai",
// Agent links
MsgLinksNotConfigured: "liên kết agent chưa được cấu hình",
MsgInvalidDirection: "hướng phải là outbound, inbound hoặc bidirectional",
MsgSourceTargetSame: "nguồn và đích phải là các agent khác nhau",
MsgCannotDelegateOpen: "không thể ủy quyền cho agent mở — chỉ agent định sẵn mới có thể là đích ủy quyền",
MsgNoUpdatesProvided: "không có cập nhật nào được cung cấp",
MsgInvalidLinkStatus: "trạng thái phải là active hoặc disabled",
// Teams
MsgTeamsNotConfigured: "nhóm chưa được cấu hình",
MsgAgentIsTeamLead: "agent đã là trưởng nhóm",
MsgCannotRemoveTeamLead: "không thể xóa trưởng nhóm",
// Delegations
MsgDelegationsUnavailable: "ủy quyền không khả dụng",
// Channels
MsgCannotDeleteDefaultInst: "không thể xóa phiên bản channel mặc định",
// Skills
MsgSkillsUpdateNotSupported: "skills.update không được hỗ trợ với skill dựa trên tệp",
MsgCannotResolveSkillID: "không thể xác định ID skill dựa trên tệp",
// Logs
MsgInvalidLogAction: "action phải là 'start' hoặc 'stop'",
// Config
MsgRawConfigRequired: "cấu hình raw là bắt buộc",
MsgRawPatchRequired: "patch raw là bắt buộc",
// Storage / File
MsgCannotDeleteSkillsDir: "không thể xóa thư mục skill",
MsgFailedToReadFile: "không thể đọc tệp",
MsgFileNotFound: "không tìm thấy tệp",
MsgInvalidVersion: "phiên bản không hợp lệ",
MsgVersionNotFound: "không tìm thấy phiên bản",
MsgFailedToDeleteFile: "không thể xóa",
// OAuth
MsgNoPendingOAuth: "không có luồng OAuth đang chờ",
MsgFailedToSaveToken: "không thể lưu token",
// Knowledge Graph
MsgEntityIDRequired: "entity_id là bắt buộc",
MsgEntityFieldsRequired: "external_id, name và entity_type là bắt buộc",
MsgTextRequired: "text là bắt buộc",
MsgProviderModelRequired: "provider và model là bắt buộc",
MsgInvalidProviderOrModel: "provider hoặc model không hợp lệ",
})
}
+132
View File
@@ -0,0 +1,132 @@
package i18n
func init() {
register(LocaleZH, map[string]string{
// Common validation
MsgRequired: "%s 是必填项",
MsgInvalidID: "无效的 %s ID",
MsgNotFound: "未找到 %s:%s",
MsgAlreadyExists: "%s 已存在:%s",
MsgInvalidRequest: "无效请求:%s",
MsgInvalidJSON: "无效的 JSON",
MsgUnauthorized: "未授权",
MsgPermissionDenied: "权限不足:无法访问 %s",
MsgInternalError: "内部错误:%s",
MsgInvalidSlug: "%s 必须是有效的 slug(小写字母、数字、连字符)",
MsgFailedToList: "获取 %s 列表失败",
MsgFailedToCreate: "创建 %s 失败:%s",
MsgFailedToUpdate: "更新 %s 失败:%s",
MsgFailedToDelete: "删除 %s 失败:%s",
MsgFailedToSave: "保存 %s 失败:%s",
MsgInvalidUpdates: "更新内容无效",
// Agent
MsgAgentNotFound: "未找到Agent:%s",
MsgCannotDeleteDefault: "无法删除默认Agent",
MsgUserCtxRequired: "需要用户上下文",
// Chat
MsgRateLimitExceeded: "请求频率超限 — 请稍候",
MsgNoUserMessage: "未找到用户消息",
MsgUserIDRequired: "user_id 是必填项",
MsgMsgRequired: "消息是必填项",
// Channel instances
MsgInvalidChannelType: "Channel类型无效",
MsgInstanceNotFound: "未找到实例",
// Cron
MsgJobNotFound: "未找到任务",
MsgInvalidCronExpr: "无效的 cron 表达式:%s",
// Config
MsgConfigHashMismatch: "配置已更改(hash 不匹配)",
// Exec approval
MsgExecApprovalDisabled: "执行审批未启用",
// Pairing
MsgSenderChannelRequired: "senderId 和 channel 是必填项",
MsgCodeRequired: "代码是必填项",
MsgSenderIDRequired: "sender_id 是必填项",
// HTTP API
MsgInvalidAuth: "身份验证无效",
MsgMsgsRequired: "messages 是必填项",
MsgUserIDHeader: "托管模式下需要 X-GoClaw-User-Id 请求头",
MsgFileTooLarge: "文件过大或 multipart 表单无效",
MsgMissingFileField: "缺少 'file' 字段",
MsgInvalidFilename: "文件名无效",
MsgChannelKeyReq: "channel 和 key 是必填项",
MsgMethodNotAllowed: "不允许的请求方法",
MsgStreamingNotSupported: "不支持流式传输",
MsgOwnerOnly: "只有所有者才能%s",
MsgNoAccess: "无权访问此%s",
MsgAlreadySummoning: "Agent正在被召唤中",
MsgSummoningUnavailable: "召唤功能不可用",
MsgNoDescription: "Agent没有可供重新召唤的描述",
MsgInvalidPath: "路径无效",
// Scheduler
MsgQueueFull: "Session队列已满",
MsgShuttingDown: "网关正在关闭,请稍后重试",
// Provider
MsgProviderReqFailed: "%s:请求失败:%s",
// Unknown method
MsgUnknownMethod: "未知方法:%s",
// Not implemented
MsgNotImplemented: "%s 尚未实现",
// Agent links
MsgLinksNotConfigured: "Agent链接未配置",
MsgInvalidDirection: "方向必须是 outbound、inbound 或 bidirectional",
MsgSourceTargetSame: "源和目标必须是不同的Agent",
MsgCannotDelegateOpen: "无法委派给开放型Agent — 只有预定义Agent才能作为委派目标",
MsgNoUpdatesProvided: "未提供更新内容",
MsgInvalidLinkStatus: "状态必须是 active 或 disabled",
// Teams
MsgTeamsNotConfigured: "团队未配置",
MsgAgentIsTeamLead: "该Agent已是团队负责人",
MsgCannotRemoveTeamLead: "无法移除团队负责人",
// Delegations
MsgDelegationsUnavailable: "委派功能不可用",
// Channels
MsgCannotDeleteDefaultInst: "无法删除默认Channel实例",
// Skills
MsgSkillsUpdateNotSupported: "基于文件的Skill不支持 skills.update",
MsgCannotResolveSkillID: "无法解析基于文件的Skill ID",
// Logs
MsgInvalidLogAction: "action 必须是 'start' 或 'stop'",
// Config
MsgRawConfigRequired: "raw 配置是必填项",
MsgRawPatchRequired: "raw 补丁是必填项",
// Storage / File
MsgCannotDeleteSkillsDir: "无法删除Skill目录",
MsgFailedToReadFile: "读取文件失败",
MsgFileNotFound: "文件未找到",
MsgInvalidVersion: "版本无效",
MsgVersionNotFound: "未找到该版本",
MsgFailedToDeleteFile: "删除失败",
// OAuth
MsgNoPendingOAuth: "没有待处理的 OAuth 流程",
MsgFailedToSaveToken: "保存令牌失败",
// Knowledge Graph
MsgEntityIDRequired: "entity_id 是必填项",
MsgEntityFieldsRequired: "external_id、name 和 entity_type 是必填项",
MsgTextRequired: "text 是必填项",
MsgProviderModelRequired: "provider 和 model 是必填项",
MsgInvalidProviderOrModel: "provider 或 model 无效",
})
}
+80
View File
@@ -0,0 +1,80 @@
// Package i18n provides a lightweight message catalog for localizing
// user-facing error messages. Messages use fmt.Sprintf-style templates.
package i18n
import "fmt"
// Supported locales.
const (
LocaleEN = "en"
LocaleVI = "vi"
LocaleZH = "zh"
DefaultLocale = LocaleEN
)
// catalogs maps locale → key → message template.
var catalogs = map[string]map[string]string{}
// register adds a set of messages for a locale. Called from catalog_*.go init().
func register(locale string, msgs map[string]string) {
if catalogs[locale] == nil {
catalogs[locale] = make(map[string]string, len(msgs))
}
for k, v := range msgs {
catalogs[locale][k] = v
}
}
// T returns a localized message for the given key.
// If the key is not found in the requested locale, it falls back to English.
// If the key is not found in English either, the key itself is returned.
// Optional args are applied via fmt.Sprintf if the template contains verbs.
func T(locale, key string, args ...any) string {
msg := lookup(locale, key)
if len(args) > 0 {
return fmt.Sprintf(msg, args...)
}
return msg
}
func lookup(locale, key string) string {
if cat, ok := catalogs[locale]; ok {
if msg, ok := cat[key]; ok {
return msg
}
}
// Fallback to English
if locale != LocaleEN {
if cat, ok := catalogs[LocaleEN]; ok {
if msg, ok := cat[key]; ok {
return msg
}
}
}
return key
}
// IsSupported returns true if the locale is a known language.
func IsSupported(locale string) bool {
switch locale {
case LocaleEN, LocaleVI, LocaleZH:
return true
}
return false
}
// Normalize returns a supported locale or the default.
func Normalize(locale string) string {
if IsSupported(locale) {
return locale
}
// Handle common prefixes: "en-US" → "en", "vi-VN" → "vi", "zh-CN" → "zh"
if len(locale) >= 2 {
prefix := locale[:2]
if IsSupported(prefix) {
return prefix
}
}
return DefaultLocale
}
+132
View File
@@ -0,0 +1,132 @@
package i18n
// Message keys for gateway/HTTP error messages.
// Grouped by domain for easier maintenance.
const (
// --- Common validation ---
MsgRequired = "error.required" // "%s is required"
MsgInvalidID = "error.invalid_id" // "invalid %s ID"
MsgNotFound = "error.not_found" // "%s not found: %s"
MsgAlreadyExists = "error.already_exists" // "%s already exists: %s"
MsgInvalidRequest = "error.invalid_request" // "invalid request: %s"
MsgInvalidJSON = "error.invalid_json" // "invalid JSON"
MsgUnauthorized = "error.unauthorized" // "unauthorized"
MsgPermissionDenied = "error.permission_denied" // "permission denied: insufficient role for %s"
MsgInternalError = "error.internal" // "internal error: %s"
MsgInvalidSlug = "error.invalid_slug" // "%s must be a valid slug (lowercase letters, numbers, hyphens only)"
MsgFailedToList = "error.failed_to_list" // "failed to list %s"
MsgFailedToCreate = "error.failed_to_create" // "failed to create %s: %s"
MsgFailedToUpdate = "error.failed_to_update" // "failed to update %s: %s"
MsgFailedToDelete = "error.failed_to_delete" // "failed to delete %s: %s"
MsgFailedToSave = "error.failed_to_save" // "failed to save %s: %s"
MsgInvalidUpdates = "error.invalid_updates" // "invalid updates"
// --- Agent ---
MsgAgentNotFound = "error.agent_not_found" // "agent not found: %s"
MsgCannotDeleteDefault = "error.cannot_delete_default" // "cannot delete the default agent"
MsgUserCtxRequired = "error.user_ctx_required" // "user context required"
// --- Chat ---
MsgRateLimitExceeded = "error.rate_limit" // "rate limit exceeded — please wait"
MsgNoUserMessage = "error.no_user_message" // "no user message found"
MsgUserIDRequired = "error.user_id_required" // "user_id is required"
MsgMsgRequired = "error.message_required" // "message is required"
// --- Channel instances ---
MsgInvalidChannelType = "error.invalid_channel_type" // "invalid channel_type"
MsgInstanceNotFound = "error.instance_not_found" // "instance not found"
// --- Cron ---
MsgJobNotFound = "error.job_not_found" // "job not found"
MsgInvalidCronExpr = "error.invalid_cron_expr" // "invalid cron expression: %s"
// --- Config ---
MsgConfigHashMismatch = "error.config_hash_mismatch" // "config has changed (hash mismatch)"
// --- Exec approval ---
MsgExecApprovalDisabled = "error.exec_approval_disabled" // "exec approval is not enabled"
// --- Pairing ---
MsgSenderChannelRequired = "error.sender_channel_required" // "senderId and channel are required"
MsgCodeRequired = "error.code_required" // "code is required"
MsgSenderIDRequired = "error.sender_id_required" // "sender_id is required"
// --- HTTP API ---
MsgInvalidAuth = "error.invalid_auth" // "invalid authentication"
MsgMsgsRequired = "error.messages_required" // "messages is required"
MsgUserIDHeader = "error.user_id_header" // "X-GoClaw-User-Id header is required"
MsgFileTooLarge = "error.file_too_large" // "file too large or invalid multipart form"
MsgMissingFileField = "error.missing_file_field" // "missing 'file' field"
MsgInvalidFilename = "error.invalid_filename" // "invalid filename"
MsgChannelKeyReq = "error.channel_key_required" // "channel and key are required"
MsgMethodNotAllowed = "error.method_not_allowed" // "method not allowed"
MsgStreamingNotSupported = "error.streaming_not_supported" // "streaming not supported"
MsgOwnerOnly = "error.owner_only" // "only owner can %s"
MsgNoAccess = "error.no_access" // "no access to this %s"
MsgAlreadySummoning = "error.already_summoning" // "agent is already being summoned"
MsgSummoningUnavailable = "error.summoning_unavailable" // "summoning not available"
MsgNoDescription = "error.no_description" // "agent has no description to resummon from"
MsgInvalidPath = "error.invalid_path" // "invalid path"
// --- Scheduler ---
MsgQueueFull = "error.queue_full" // "session queue is full"
MsgShuttingDown = "error.shutting_down" // "gateway is shutting down, please retry shortly"
// --- Provider ---
MsgProviderReqFailed = "error.provider_request_failed" // "%s: request failed: %s"
// --- Unknown method ---
MsgUnknownMethod = "error.unknown_method" // "unknown method: %s"
// --- Not implemented ---
MsgNotImplemented = "error.not_implemented" // "%s not yet implemented"
// --- Agent links ---
MsgLinksNotConfigured = "error.links_not_configured" // "agent links not configured"
MsgInvalidDirection = "error.invalid_direction" // "direction must be outbound, inbound, or bidirectional"
MsgSourceTargetSame = "error.source_target_same" // "source and target must be different agents"
MsgCannotDelegateOpen = "error.cannot_delegate_open" // "cannot delegate to open agents — only predefined agents can be delegation targets"
MsgNoUpdatesProvided = "error.no_updates_provided" // "no updates provided"
MsgInvalidLinkStatus = "error.invalid_link_status" // "status must be active or disabled"
// --- Teams ---
MsgTeamsNotConfigured = "error.teams_not_configured" // "teams not configured"
MsgAgentIsTeamLead = "error.agent_is_team_lead" // "agent is already the team lead"
MsgCannotRemoveTeamLead = "error.cannot_remove_team_lead" // "cannot remove the team lead"
// --- Delegations ---
MsgDelegationsUnavailable = "error.delegations_not_available" // "delegations not available"
// --- Channels ---
MsgCannotDeleteDefaultInst = "error.cannot_delete_default_inst" // "cannot delete default channel instance"
// --- Skills ---
MsgSkillsUpdateNotSupported = "error.skills_update_not_supported" // "skills.update not supported for file-based skills"
MsgCannotResolveSkillID = "error.cannot_resolve_skill_id" // "cannot resolve skill ID for file-based skill"
// --- Logs ---
MsgInvalidLogAction = "error.invalid_log_action" // "action must be 'start' or 'stop'"
// --- Config ---
MsgRawConfigRequired = "error.raw_config_required" // "raw config is required"
MsgRawPatchRequired = "error.raw_patch_required" // "raw patch is required"
// --- Storage / File ---
MsgCannotDeleteSkillsDir = "error.cannot_delete_skills_dir" // "cannot delete skills directories"
MsgFailedToReadFile = "error.failed_to_read_file" // "failed to read file"
MsgFileNotFound = "error.file_not_found" // "file not found"
MsgInvalidVersion = "error.invalid_version" // "invalid version"
MsgVersionNotFound = "error.version_not_found" // "version not found"
MsgFailedToDeleteFile = "error.failed_to_delete_file" // "failed to delete"
// --- OAuth ---
MsgNoPendingOAuth = "error.no_pending_oauth" // "no pending OAuth flow"
MsgFailedToSaveToken = "error.failed_to_save_token" // "failed to save token"
// --- Knowledge Graph ---
MsgEntityIDRequired = "error.entity_id_required" // "entity_id is required"
MsgEntityFieldsRequired = "error.entity_fields_required" // "external_id, name, and entity_type are required"
MsgTextRequired = "error.text_required" // "text is required"
MsgProviderModelRequired = "error.provider_model_required" // "provider and model are required"
MsgInvalidProviderOrModel = "error.invalid_provider_or_model" // "invalid provider or model"
)
+15
View File
@@ -21,6 +21,8 @@ const (
SenderIDKey contextKey = "goclaw_sender_id"
// SelfEvolveKey indicates whether a predefined agent can update its SOUL.md.
SelfEvolveKey contextKey = "goclaw_self_evolve"
// LocaleKey is the context key for the user's preferred locale (e.g. "en", "vi", "zh").
LocaleKey contextKey = "goclaw_locale"
)
// WithUserID returns a new context with the given user ID.
@@ -87,3 +89,16 @@ func SelfEvolveFromContext(ctx context.Context) bool {
}
return false
}
// WithLocale returns a new context with the given locale.
func WithLocale(ctx context.Context, locale string) context.Context {
return context.WithValue(ctx, LocaleKey, locale)
}
// LocaleFromContext extracts the locale from context. Returns "en" if not set.
func LocaleFromContext(ctx context.Context) string {
if v, ok := ctx.Value(LocaleKey).(string); ok && v != "" {
return v
}
return "en"
}
+2
View File
@@ -22,10 +22,12 @@
"d3-force": "^3.0.0",
"framer-motion": "^12.34.3",
"highlight.js": "^11.11.1",
"i18next": "^25.8.15",
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^16.5.6",
"react-markdown": "^10.1.0",
"react-router": "^7.1.0",
"rehype-highlight": "^7.0.2",
+66
View File
@@ -44,6 +44,9 @@ importers:
highlight.js:
specifier: ^11.11.1
version: 11.11.1
i18next:
specifier: ^25.8.15
version: 25.8.15(typescript@5.7.3)
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.4)
@@ -56,6 +59,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.4(react@19.2.4)
react-i18next:
specifier: ^16.5.6
version: 16.5.6(i18next@25.8.15(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
@@ -179,6 +185,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -1654,9 +1664,20 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
i18next@25.8.15:
resolution: {integrity: sha512-DW+vfwYtt+8lqpknhPGBfPL2P4SvtUv+KGDZAjfPrbnKAJkzVWGNRYw/rNkzxSzCBvnpiUnF5AgX7kL8hCg6uw==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -1972,6 +1993,22 @@ packages:
peerDependencies:
react: ^19.2.4
react-i18next@16.5.6:
resolution: {integrity: sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw==}
peerDependencies:
i18next: '>= 25.6.2'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@@ -2204,6 +2241,10 @@ packages:
yaml:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
@@ -2346,6 +2387,8 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/runtime@7.28.6': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -3752,8 +3795,18 @@ snapshots:
highlight.js@11.11.1: {}
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
html-url-attributes@3.0.1: {}
i18next@25.8.15(typescript@5.7.3):
dependencies:
'@babel/runtime': 7.28.6
optionalDependencies:
typescript: 5.7.3
inline-style-parser@0.2.7: {}
is-alphabetical@2.0.1: {}
@@ -4299,6 +4352,17 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-i18next@16.5.6(i18next@25.8.15(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3):
dependencies:
'@babel/runtime': 7.28.6
html-parse-stringify: 3.0.1
i18next: 25.8.15(typescript@5.7.3)
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
react-dom: 19.2.4(react@19.2.4)
typescript: 5.7.3
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
@@ -4562,6 +4626,8 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.31.1
void-elements@3.1.0: {}
ws@8.19.0: {}
yallist@3.1.1: {}
+1
View File
@@ -197,6 +197,7 @@ export class WsClient {
token: this.getToken(),
user_id: this.getUserId(),
sender_id: this.getSenderID(),
locale: localStorage.getItem("goclaw:language") || "en",
protocolVersion: PROTOCOL_VERSION,
});
if (this.connectGeneration !== generation) return;
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Bot, ChevronDown } from "lucide-react";
import { useHttp } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
@@ -10,6 +11,7 @@ interface AgentSelectorProps {
}
export function AgentSelector({ value, onChange }: AgentSelectorProps) {
const { t } = useTranslation("common");
const http = useHttp();
const connected = useAuthStore((s) => s.connected);
const [agents, setAgents] = useState<AgentData[]>([]);
@@ -37,7 +39,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
>
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-left">
{selected?.display_name ?? selected?.agent_key ?? (value || "Select agent")}
{selected?.display_name ?? selected?.agent_key ?? (value || t("selectAgent"))}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
@@ -48,7 +50,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-lg border bg-popover p-1 shadow-md">
{agents.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
No agents available
{t("noAgentsAvailable")}
</div>
)}
{agents.map((agent) => (
@@ -68,7 +70,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
{agent.display_name || agent.agent_key}
</span>
{agent.is_default && (
<span className="text-xs text-muted-foreground">default</span>
<span className="text-xs text-muted-foreground">{t("default")}</span>
)}
</button>
))}
+5 -3
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useCallback, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { Send, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -10,6 +11,7 @@ interface ChatInputProps {
}
export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputProps) {
const { t } = useTranslation("common");
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -49,7 +51,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputPro
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Send a message... (Shift+Enter for new line)"
placeholder={t("sendMessage")}
disabled={disabled}
rows={1}
className="flex-1 resize-none rounded-lg border bg-background px-4 py-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
@@ -59,7 +61,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputPro
variant="destructive"
size="icon-lg"
onClick={onAbort}
title="Stop generation"
title={t("stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
@@ -68,7 +70,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputPro
size="icon-lg"
onClick={handleSend}
disabled={!value.trim() || disabled}
title="Send message"
title={t("sendMessageTitle")}
>
<Send className="h-4 w-4" />
</Button>
+8 -13
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Image,
Video,
@@ -126,33 +127,26 @@ const mediaIcons: Record<string, typeof Image> = {
animation: Video,
};
const mediaLabels: Record<string, string> = {
image: "Image",
video: "Video",
audio: "Audio",
voice: "Voice message",
document: "Document",
animation: "Animation",
};
function MediaBadge({ mediaType }: { mediaType: string }) {
const { t } = useTranslation("chat");
const Icon = mediaIcons[mediaType] ?? FileText;
const label = mediaLabels[mediaType] ?? mediaType;
const label = t(`media.${mediaType}`, { defaultValue: mediaType });
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-blue-200 bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300">
<Icon className="h-3.5 w-3.5" />
{label} attached
{label} {t("media.attached")}
</span>
);
}
function ForwardBadge({ from, date }: { from: string; date: string }) {
const { t } = useTranslation("chat");
return (
<div className="flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300">
<Forward className="h-3.5 w-3.5" />
<span>
Forwarded from <span className="font-medium">{from}</span>
{t("forwardedFrom")} <span className="font-medium">{from}</span>
{date && <span className="text-amber-600 dark:text-amber-400"> &middot; {date}</span>}
</span>
</div>
@@ -160,11 +154,12 @@ function ForwardBadge({ from, date }: { from: string; date: string }) {
}
function ReplyQuote({ sender, body }: { sender: string; body: string }) {
const { t } = useTranslation("chat");
return (
<div className="rounded-md border-l-2 border-muted-foreground/40 bg-muted/50 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Reply className="h-3 w-3" />
Replying to {sender}
{t("replyingTo")} {sender}
</div>
<div className="mt-1 text-xs text-muted-foreground/80 line-clamp-3">{body}</div>
</div>
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { MessageSquare } from "lucide-react";
import { formatRelativeTime } from "@/lib/format";
import { parseSessionKey } from "@/lib/session-key";
@@ -16,6 +17,7 @@ export function SessionSwitcher({
onSelect,
loading,
}: SessionSwitcherProps) {
const { t } = useTranslation("common");
if (sessions.length === 0 && loading) {
return (
<div className="space-y-2 p-2">
@@ -29,7 +31,7 @@ export function SessionSwitcher({
if (sessions.length === 0) {
return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No sessions yet. Start a chat!
{t("noSessions")}
</div>
);
}
@@ -56,7 +58,7 @@ export function SessionSwitcher({
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{label}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{session.messageCount} messages</span>
<span>{session.messageCount} {t("messages")}</span>
<span>{formatRelativeTime(session.updated)}</span>
</div>
</div>
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, Brain } from "lucide-react";
interface ThinkingBlockProps {
@@ -7,6 +8,7 @@ interface ThinkingBlockProps {
}
export function ThinkingBlock({ text, isStreaming = false }: ThinkingBlockProps) {
const { t } = useTranslation("common");
const [expanded, setExpanded] = useState(isStreaming);
return (
@@ -18,7 +20,7 @@ export function ThinkingBlock({ text, isStreaming = false }: ThinkingBlockProps)
>
<Brain className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium">
Thinking{isStreaming ? "..." : ""}
{isStreaming ? t("thinkingStreaming") : t("thinking")}
</span>
{expanded ? (
<ChevronDown className="ml-auto h-3.5 w-3.5" />
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Wrench, Check, AlertTriangle, Loader2, ChevronDown, ChevronRight, Zap } from "lucide-react";
import type { ToolStreamEntry } from "@/types/chat";
@@ -18,6 +19,7 @@ interface ToolCallCardProps {
}
export function ToolCallCard({ entry }: ToolCallCardProps) {
const { t } = useTranslation("common");
const hasDetails = entry.arguments || entry.result;
const hasError = entry.phase === "error" && !!entry.errorContent;
const canExpand = hasDetails || hasError;
@@ -57,7 +59,7 @@ export function ToolCallCard({ entry }: ToolCallCardProps) {
)}
{entry.arguments && Object.keys(entry.arguments).length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">Arguments</div>
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">{t("toolArguments")}</div>
<pre className="whitespace-pre-wrap text-xs font-mono bg-background rounded p-2 max-h-48 overflow-y-auto">
{JSON.stringify(entry.arguments, null, 2)}
</pre>
@@ -65,7 +67,7 @@ export function ToolCallCard({ entry }: ToolCallCardProps) {
)}
{entry.result && (
<div>
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">Result</div>
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">{t("toolResult")}</div>
<pre className="whitespace-pre-wrap text-xs font-mono bg-background rounded p-2 max-h-48 overflow-y-auto">
{entry.result}
</pre>
@@ -103,9 +105,10 @@ function ToolIcon({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSkill
}
function PhaseLabel({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSkill?: boolean }) {
const { t } = useTranslation("common");
const labels: Record<string, string> = isSkill
? { calling: "Activating...", completed: "Activated", error: "Failed" }
: { calling: "Running...", completed: "Done", error: "Failed" };
? { calling: t("skillActivating"), completed: t("skillActivated"), error: t("toolFailed") }
: { calling: t("toolRunning"), completed: t("toolDone"), error: t("toolFailed") };
const colors: Record<string, string> = {
calling: "text-blue-500",
completed: "text-green-500",
+3 -1
View File
@@ -1,5 +1,6 @@
import { Outlet } from "react-router";
import { WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar } from "./sidebar";
import { Topbar } from "./topbar";
import { useUiStore } from "@/stores/use-ui-store";
@@ -8,6 +9,7 @@ import { useIsMobile } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
export function AppLayout() {
const { t } = useTranslation("common");
const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed);
const mobileSidebarOpen = useUiStore((s) => s.mobileSidebarOpen);
const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen);
@@ -43,7 +45,7 @@ export function AppLayout() {
{!connected && (
<div className="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<WifiOff className="h-4 w-4 shrink-0" />
<span>Disconnected from gateway. Attempting to reconnect...</span>
<span>{t("disconnectedGateway")}</span>
</div>
)}
<main className="flex-1 overflow-y-auto">
@@ -1,7 +1,9 @@
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/use-auth-store";
import { cn } from "@/lib/utils";
export function ConnectionStatus() {
const { t } = useTranslation("common");
const connected = useAuthStore((s) => s.connected);
return (
@@ -12,7 +14,7 @@ export function ConnectionStatus() {
connected ? "bg-green-500" : "bg-red-500",
)}
/>
<span>{connected ? "Connected" : "Disconnected"}</span>
<span>{connected ? t("connected") : t("disconnected")}</span>
</div>
);
}
+30 -28
View File
@@ -24,6 +24,7 @@ import {
Inbox,
Brain,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { SidebarGroup } from "./sidebar-group";
import { SidebarItem } from "./sidebar-item";
import { ConnectionStatus } from "./connection-status";
@@ -37,6 +38,7 @@ interface SidebarProps {
}
export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
const { t } = useTranslation("sidebar");
const { pendingCount } = usePendingPairingsCount();
return (
@@ -66,40 +68,40 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
{/* Nav items */}
<nav className="flex-1 space-y-4 overflow-y-auto px-2 py-4">
<SidebarGroup label="Core" collapsed={collapsed}>
<SidebarItem to={ROUTES.OVERVIEW} icon={LayoutDashboard} label="Overview" collapsed={collapsed} />
<SidebarItem to={ROUTES.CHAT} icon={MessageSquare} label="Chat" collapsed={collapsed} />
<SidebarGroup label={t("groups.core")} collapsed={collapsed}>
<SidebarItem to={ROUTES.OVERVIEW} icon={LayoutDashboard} label={t("nav.overview")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CHAT} icon={MessageSquare} label={t("nav.chat")} collapsed={collapsed} />
</SidebarGroup>
<SidebarGroup label="Management" collapsed={collapsed}>
<SidebarItem to={ROUTES.AGENTS} icon={Bot} label="Agents" collapsed={collapsed} />
<SidebarItem to={ROUTES.TEAMS} icon={Users} label="Agent Teams" collapsed={collapsed} />
<SidebarItem to={ROUTES.SESSIONS} icon={History} label="Sessions" collapsed={collapsed} />
<SidebarItem to={ROUTES.PENDING_MESSAGES} icon={Inbox} label="Pending Messages" collapsed={collapsed} />
<SidebarItem to={ROUTES.CHANNELS} icon={Radio} label="Channels" collapsed={collapsed} />
<SidebarItem to={ROUTES.SKILLS} icon={Zap} label="Skills" collapsed={collapsed} />
<SidebarItem to={ROUTES.CRON} icon={Clock} label="Cron" collapsed={collapsed} />
<SidebarItem to={ROUTES.CUSTOM_TOOLS} icon={Wrench} label="Custom Tools" collapsed={collapsed} />
<SidebarItem to={ROUTES.BUILTIN_TOOLS} icon={Package} label="Built-in Tools" collapsed={collapsed} />
<SidebarItem to={ROUTES.MCP} icon={Plug} label="MCP Servers" collapsed={collapsed} />
<SidebarItem to={ROUTES.MEMORY} icon={Brain} label="Memory" collapsed={collapsed} />
<SidebarGroup label={t("groups.management")} collapsed={collapsed}>
<SidebarItem to={ROUTES.AGENTS} icon={Bot} label={t("nav.agents")} collapsed={collapsed} />
<SidebarItem to={ROUTES.TEAMS} icon={Users} label={t("nav.agentTeams")} collapsed={collapsed} />
<SidebarItem to={ROUTES.SESSIONS} icon={History} label={t("nav.sessions")} collapsed={collapsed} />
<SidebarItem to={ROUTES.PENDING_MESSAGES} icon={Inbox} label={t("nav.pendingMessages")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CHANNELS} icon={Radio} label={t("nav.channels")} collapsed={collapsed} />
<SidebarItem to={ROUTES.SKILLS} icon={Zap} label={t("nav.skills")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CRON} icon={Clock} label={t("nav.cron")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CUSTOM_TOOLS} icon={Wrench} label={t("nav.customTools")} collapsed={collapsed} />
<SidebarItem to={ROUTES.BUILTIN_TOOLS} icon={Package} label={t("nav.builtinTools")} collapsed={collapsed} />
<SidebarItem to={ROUTES.MCP} icon={Plug} label={t("nav.mcpServers")} collapsed={collapsed} />
<SidebarItem to={ROUTES.MEMORY} icon={Brain} label={t("nav.memory")} collapsed={collapsed} />
</SidebarGroup>
<SidebarGroup label="Monitoring" collapsed={collapsed}>
<SidebarItem to={ROUTES.TRACES} icon={Activity} label="Traces" collapsed={collapsed} />
<SidebarItem to={ROUTES.EVENTS} icon={Radar} label="Realtime Events" collapsed={collapsed} />
<SidebarItem to={ROUTES.DELEGATIONS} icon={ArrowRightLeft} label="Delegations" collapsed={collapsed} />
<SidebarItem to={ROUTES.USAGE} icon={BarChart3} label="Usage" collapsed={collapsed} />
<SidebarItem to={ROUTES.LOGS} icon={Terminal} label="Logs" collapsed={collapsed} />
<SidebarItem to={ROUTES.STORAGE} icon={HardDrive} label="Storage" collapsed={collapsed} />
<SidebarGroup label={t("groups.monitoring")} collapsed={collapsed}>
<SidebarItem to={ROUTES.TRACES} icon={Activity} label={t("nav.traces")} collapsed={collapsed} />
<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.LOGS} icon={Terminal} label={t("nav.logs")} collapsed={collapsed} />
<SidebarItem to={ROUTES.STORAGE} icon={HardDrive} label={t("nav.storage")} collapsed={collapsed} />
</SidebarGroup>
<SidebarGroup label="System" collapsed={collapsed}>
<SidebarItem to={ROUTES.PROVIDERS} icon={Cpu} label="Providers" collapsed={collapsed} />
<SidebarItem to={ROUTES.CONFIG} icon={Settings} label="Config" collapsed={collapsed} />
<SidebarItem to={ROUTES.APPROVALS} icon={ShieldCheck} label="Approvals" collapsed={collapsed} />
<SidebarItem to={ROUTES.NODES} icon={Link} label="Nodes" collapsed={collapsed} badge={pendingCount} />
<SidebarItem to={ROUTES.TTS} icon={Volume2} label="TTS" collapsed={collapsed} />
<SidebarGroup label={t("groups.system")} collapsed={collapsed}>
<SidebarItem to={ROUTES.PROVIDERS} icon={Cpu} label={t("nav.providers")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CONFIG} icon={Settings} label={t("nav.config")} collapsed={collapsed} />
<SidebarItem to={ROUTES.APPROVALS} icon={ShieldCheck} label={t("nav.approvals")} collapsed={collapsed} />
<SidebarItem to={ROUTES.NODES} icon={Link} label={t("nav.nodes")} collapsed={collapsed} badge={pendingCount} />
<SidebarItem to={ROUTES.TTS} icon={Volume2} label={t("nav.tts")} collapsed={collapsed} />
</SidebarGroup>
</nav>
+25 -6
View File
@@ -1,14 +1,18 @@
import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut, Bell } from "lucide-react";
import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut, Bell, Globe } from "lucide-react";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useUiStore } from "@/stores/use-ui-store";
import { useAuthStore } from "@/stores/use-auth-store";
import { useIsMobile } from "@/hooks/use-media-query";
import { usePendingPairingsCount } from "@/hooks/use-pending-pairings-count";
import { ROUTES } from "@/lib/constants";
import { ROUTES, SUPPORTED_LANGUAGES, LANGUAGE_LABELS, type Language } from "@/lib/constants";
export function Topbar() {
const { t } = useTranslation("topbar");
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
const language = useUiStore((s) => s.language);
const setLanguage = useUiStore((s) => s.setLanguage);
const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed);
const toggleSidebar = useUiStore((s) => s.toggleSidebar);
const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen);
@@ -24,13 +28,19 @@ export function Topbar() {
? () => setMobileSidebarOpen(true)
: toggleSidebar;
const cycleLanguage = () => {
const idx = SUPPORTED_LANGUAGES.indexOf(language);
const next = SUPPORTED_LANGUAGES[(idx + 1) % SUPPORTED_LANGUAGES.length] as Language;
setLanguage(next);
};
return (
<header className="flex h-14 items-center justify-between border-b bg-background px-4">
<div className="flex items-center gap-2">
<button
onClick={handleSidebarToggle}
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title={isMobile ? "Open menu" : sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
title={isMobile ? t("openMenu") : sidebarCollapsed ? t("expandSidebar") : t("collapseSidebar")}
>
{isMobile ? (
<Menu className="h-4 w-4" />
@@ -50,7 +60,7 @@ export function Topbar() {
<button
onClick={() => navigate(ROUTES.NODES)}
className="relative cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title={pendingCount > 0 ? `${pendingCount} pending pairing request${pendingCount > 1 ? "s" : ""}` : "Pairing requests"}
title={pendingCount > 0 ? t("pendingPairing", { count: pendingCount }) : t("pairingRequests")}
>
<Bell className="h-4 w-4" />
{pendingCount > 0 && (
@@ -58,10 +68,19 @@ export function Topbar() {
)}
</button>
<button
onClick={cycleLanguage}
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center gap-1"
title={t("language")}
>
<Globe className="h-4 w-4" />
<span className="text-xs">{LANGUAGE_LABELS[language]}</span>
</button>
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title="Toggle theme"
title={t("toggleTheme")}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
@@ -69,7 +88,7 @@ export function Topbar() {
<button
onClick={logout}
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title="Logout"
title={t("logout")}
>
<LogOut className="h-4 w-4" />
</button>
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
@@ -27,10 +28,11 @@ export function ConfirmDeleteDialog({
title,
description,
confirmValue,
confirmLabel = "Delete",
confirmLabel,
onConfirm,
loading,
}: ConfirmDeleteDialogProps) {
const { t } = useTranslation("common");
const [inputValue, setInputValue] = useState("");
useEffect(() => {
@@ -48,7 +50,7 @@ export function ConfirmDeleteDialog({
</DialogHeader>
<div className="py-2">
<p className="mb-2 text-sm text-muted-foreground">
Type <span className="font-semibold text-foreground">{confirmValue}</span> to confirm.
{t("typeToConfirmPrefix")} <span className="font-semibold text-foreground">{confirmValue}</span> {t("typeToConfirmSuffix")}
</p>
<Input
value={inputValue}
@@ -59,14 +61,14 @@ export function ConfirmDeleteDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
{t("cancel")}
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={!isMatch || loading}
>
{loading ? "..." : confirmLabel}
{loading ? "..." : (confirmLabel ?? t("delete"))}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
@@ -24,11 +25,12 @@ export function ConfirmDialog({
onOpenChange,
title,
description,
confirmLabel = "Confirm",
confirmLabel,
variant = "default",
onConfirm,
loading,
}: ConfirmDialogProps) {
const { t } = useTranslation("common");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -38,10 +40,10 @@ export function ConfirmDialog({
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
{t("cancel")}
</Button>
<Button variant={variant} onClick={onConfirm} disabled={loading}>
{loading ? "..." : confirmLabel}
{loading ? "..." : (confirmLabel ?? t("confirm"))}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { formatSize, type TreeNode } from "@/lib/file-helpers";
import { FileTreePanel } from "@/components/shared/file-tree";
import { FileContentPanel } from "@/components/shared/file-viewers";
@@ -33,6 +34,7 @@ export function FileBrowser({
showSize?: boolean;
}) {
const isMobile = useIsMobile();
const { t } = useTranslation("common");
const containerRef = useRef<HTMLDivElement>(null);
const [treeWidth, setTreeWidth] = useState(220);
const [mobileShowTree, setMobileShowTree] = useState(true);
@@ -82,7 +84,7 @@ export function FileBrowser({
onClick={() => setMobileShowTree(true)}
className="text-primary hover:underline cursor-pointer shrink-0"
>
&larr; Files
&larr; {t("filesBack")}
</button>
{fileContent && (
<>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Folder,
FolderOpen,
@@ -119,6 +120,7 @@ export function FileTreePanel({
onDelete?: (path: string, isDir: boolean) => void;
showSize?: boolean;
}) {
const { t } = useTranslation("common");
if (filesLoading) {
return (
<div className="flex items-center justify-center py-8">
@@ -127,7 +129,7 @@ export function FileTreePanel({
);
}
if (tree.length === 0) {
return <p className="px-3 py-4 text-sm text-muted-foreground">No files found.</p>;
return <p className="px-3 py-4 text-sm text-muted-foreground">{t("noFiles")}</p>;
}
return (
<>
@@ -1,4 +1,5 @@
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Check, Copy, Loader2 } from "lucide-react";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { useClipboard } from "@/hooks/use-clipboard";
@@ -162,6 +163,7 @@ export function FileContentPanel({
fileContent: { content: string; path: string; size: number } | null;
contentLoading: boolean;
}) {
const { t } = useTranslation("common");
if (contentLoading) {
return (
<div className="flex items-center justify-center py-8">
@@ -174,7 +176,7 @@ export function FileContentPanel({
}
return (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Select a file to view its content
{t("selectFileToView")}
</div>
);
}
+5 -3
View File
@@ -1,4 +1,5 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -29,16 +30,17 @@ export function Pagination({
onPageSizeChange,
pageSizes = PAGE_SIZES,
}: PaginationProps) {
const { t } = useTranslation("common");
if (total === 0) return null;
return (
<div className="flex flex-col items-center gap-2 border-t px-4 py-3 sm:flex-row sm:justify-between">
<div className="text-sm text-muted-foreground">
{total} {total === 1 ? "item" : "items"}
{total} {t("items")}
</div>
<div className="flex items-center gap-2 sm:gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Rows</span>
<span className="text-sm text-muted-foreground">{t("rows")}</span>
<Select
value={String(pageSize)}
onValueChange={(v) => onPageSizeChange(Number(v))}
@@ -56,7 +58,7 @@ export function Pagination({
</Select>
</div>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{t("pageOf", { page, totalPages })}
</span>
<div className="flex items-center gap-1">
<Button
@@ -1,4 +1,5 @@
import { useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
@@ -40,17 +41,18 @@ export function ProviderModelSelect({
onProviderChange,
model,
onModelChange,
providerTip = "LLM provider name. Must match a configured provider.",
modelTip = "Model ID to use.",
providerLabel = "Provider",
modelLabel = "Model",
providerPlaceholder = "Select provider",
modelPlaceholder = "Enter or select model",
providerTip,
modelTip,
providerLabel,
modelLabel,
providerPlaceholder,
modelPlaceholder,
showVerify,
savedProvider,
savedModel,
onSaveBlockedChange,
}: ProviderModelSelectProps) {
const { t } = useTranslation("common");
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
@@ -94,11 +96,11 @@ export function ProviderModelSelect({
return (
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<InfoLabel tip={providerTip}>{providerLabel}</InfoLabel>
<InfoLabel tip={providerTip ?? t("providerTip")}>{providerLabel ?? t("provider")}</InfoLabel>
{enabledProviders.length > 0 ? (
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder={providerPlaceholder} />
<SelectValue placeholder={providerPlaceholder ?? t("selectProvider")} />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
@@ -112,19 +114,19 @@ export function ProviderModelSelect({
<Input
value={provider}
onChange={(e) => handleProviderChange(e.target.value)}
placeholder="No providers configured"
placeholder={t("noProvidersConfigured")}
/>
)}
</div>
<div className="grid gap-1.5">
<InfoLabel tip={modelTip}>{modelLabel}</InfoLabel>
<InfoLabel tip={modelTip ?? t("modelTip")}>{modelLabel ?? t("model")}</InfoLabel>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={onModelChange}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : modelPlaceholder}
placeholder={modelsLoading ? t("loadingModels") : (modelPlaceholder ?? t("enterOrSelectModel"))}
/>
</div>
{shouldShowVerify && (
@@ -136,13 +138,13 @@ export function ProviderModelSelect({
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
{verifying ? "..." : t("check")}
</Button>
)}
</div>
{shouldShowVerify && verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
{verifyResult.valid ? t("modelVerified") : verifyResult.error || t("verificationFailed")}
</p>
)}
</div>
@@ -1,4 +1,5 @@
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { useDebounce } from "@/hooks/use-debounce";
import { useState, useEffect } from "react";
@@ -14,10 +15,11 @@ interface SearchInputProps {
export function SearchInput({
value,
onChange,
placeholder = "Search...",
placeholder,
className,
delay = 300,
}: SearchInputProps) {
const { t } = useTranslation("common");
const [local, setLocal] = useState(value);
const debounced = useDebounce(local, delay);
@@ -35,7 +37,7 @@ export function SearchInput({
<Input
value={local}
onChange={(e) => setLocal(e.target.value)}
placeholder={placeholder}
placeholder={placeholder ?? t("searchPlaceholder")}
className="pl-9"
/>
</div>
+4 -2
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -12,9 +13,10 @@ interface TagInputProps {
export function TagInput({
value,
onChange,
placeholder = "Type and press Enter...",
placeholder,
className,
}: TagInputProps) {
const { t } = useTranslation("common");
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@@ -70,7 +72,7 @@ export function TagInput({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { if (input.trim()) addTag(input); }}
placeholder={value.length === 0 ? placeholder : ""}
placeholder={value.length === 0 ? (placeholder ?? t("typeAndPressEnter")) : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
/>
</div>
@@ -1,4 +1,5 @@
import { useMemo, useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { X, ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useBuiltinTools } from "@/pages/builtin-tools/hooks/use-builtin-tools";
@@ -20,9 +21,10 @@ interface ToolOption {
export function ToolNameSelect({
value,
onChange,
placeholder = "Select or type tool names...",
placeholder,
className,
}: ToolNameSelectProps) {
const { t } = useTranslation("common");
const { tools: builtinTools } = useBuiltinTools();
const { tools: customTools } = useCustomTools();
const [open, setOpen] = useState(false);
@@ -127,7 +129,7 @@ export function ToolNameSelect({
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
placeholder={value.length === 0 ? (placeholder ?? t("selectOrTypeTools")) : ""}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
/>
<ChevronDownIcon
@@ -140,7 +142,7 @@ export function ToolNameSelect({
{grouped.builtin.length > 0 && (
<>
<div className="text-muted-foreground px-2 py-1 text-[10px] font-semibold uppercase tracking-wider">
Built-in
{t("builtinTools")}
</div>
{grouped.builtin.map((t) => (
<button
@@ -159,7 +161,7 @@ export function ToolNameSelect({
{grouped.custom.length > 0 && (
<>
<div className="text-muted-foreground mt-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider">
Custom
{t("customTools")}
</div>
{grouped.custom.map((t) => (
<button
+161
View File
@@ -0,0 +1,161 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
// --- EN namespaces ---
import enCommon from "./locales/en/common.json";
import enSidebar from "./locales/en/sidebar.json";
import enTopbar from "./locales/en/topbar.json";
import enLogin from "./locales/en/login.json";
import enOverview from "./locales/en/overview.json";
import enChat from "./locales/en/chat.json";
import enAgents from "./locales/en/agents.json";
import enTeams from "./locales/en/teams.json";
import enSessions from "./locales/en/sessions.json";
import enSkills from "./locales/en/skills.json";
import enCron from "./locales/en/cron.json";
import enConfig from "./locales/en/config.json";
import enChannels from "./locales/en/channels.json";
import enProviders from "./locales/en/providers.json";
import enTraces from "./locales/en/traces.json";
import enEvents from "./locales/en/events.json";
import enDelegations from "./locales/en/delegations.json";
import enUsage from "./locales/en/usage.json";
import enApprovals from "./locales/en/approvals.json";
import enNodes from "./locales/en/nodes.json";
import enLogs from "./locales/en/logs.json";
import enTools from "./locales/en/tools.json";
import enMcp from "./locales/en/mcp.json";
import enTts from "./locales/en/tts.json";
import enSetup from "./locales/en/setup.json";
import enMemory from "./locales/en/memory.json";
import enStorage from "./locales/en/storage.json";
import enPendingMessages from "./locales/en/pending-messages.json";
// --- VI namespaces ---
import viCommon from "./locales/vi/common.json";
import viSidebar from "./locales/vi/sidebar.json";
import viTopbar from "./locales/vi/topbar.json";
import viLogin from "./locales/vi/login.json";
import viOverview from "./locales/vi/overview.json";
import viChat from "./locales/vi/chat.json";
import viAgents from "./locales/vi/agents.json";
import viTeams from "./locales/vi/teams.json";
import viSessions from "./locales/vi/sessions.json";
import viSkills from "./locales/vi/skills.json";
import viCron from "./locales/vi/cron.json";
import viConfig from "./locales/vi/config.json";
import viChannels from "./locales/vi/channels.json";
import viProviders from "./locales/vi/providers.json";
import viTraces from "./locales/vi/traces.json";
import viEvents from "./locales/vi/events.json";
import viDelegations from "./locales/vi/delegations.json";
import viUsage from "./locales/vi/usage.json";
import viApprovals from "./locales/vi/approvals.json";
import viNodes from "./locales/vi/nodes.json";
import viLogs from "./locales/vi/logs.json";
import viTools from "./locales/vi/tools.json";
import viMcp from "./locales/vi/mcp.json";
import viTts from "./locales/vi/tts.json";
import viSetup from "./locales/vi/setup.json";
import viMemory from "./locales/vi/memory.json";
import viStorage from "./locales/vi/storage.json";
import viPendingMessages from "./locales/vi/pending-messages.json";
// --- ZH namespaces ---
import zhCommon from "./locales/zh/common.json";
import zhSidebar from "./locales/zh/sidebar.json";
import zhTopbar from "./locales/zh/topbar.json";
import zhLogin from "./locales/zh/login.json";
import zhOverview from "./locales/zh/overview.json";
import zhChat from "./locales/zh/chat.json";
import zhAgents from "./locales/zh/agents.json";
import zhTeams from "./locales/zh/teams.json";
import zhSessions from "./locales/zh/sessions.json";
import zhSkills from "./locales/zh/skills.json";
import zhCron from "./locales/zh/cron.json";
import zhConfig from "./locales/zh/config.json";
import zhChannels from "./locales/zh/channels.json";
import zhProviders from "./locales/zh/providers.json";
import zhTraces from "./locales/zh/traces.json";
import zhEvents from "./locales/zh/events.json";
import zhDelegations from "./locales/zh/delegations.json";
import zhUsage from "./locales/zh/usage.json";
import zhApprovals from "./locales/zh/approvals.json";
import zhNodes from "./locales/zh/nodes.json";
import zhLogs from "./locales/zh/logs.json";
import zhTools from "./locales/zh/tools.json";
import zhMcp from "./locales/zh/mcp.json";
import zhTts from "./locales/zh/tts.json";
import zhSetup from "./locales/zh/setup.json";
import zhMemory from "./locales/zh/memory.json";
import zhStorage from "./locales/zh/storage.json";
import zhPendingMessages from "./locales/zh/pending-messages.json";
const STORAGE_KEY = "goclaw:language";
function getInitialLanguage(): string {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "en" || stored === "vi" || stored === "zh") return stored;
const lang = navigator.language.toLowerCase();
if (lang.startsWith("vi")) return "vi";
if (lang.startsWith("zh")) return "zh";
return "en";
}
const ns = [
"common", "sidebar", "topbar", "login", "overview", "chat",
"agents", "teams", "sessions", "skills", "cron", "config",
"channels", "providers", "traces", "events", "delegations",
"usage", "approvals", "nodes", "logs", "tools", "mcp", "tts",
"setup", "memory", "storage", "pending-messages",
] as const;
i18n.use(initReactI18next).init({
resources: {
en: {
common: enCommon, sidebar: enSidebar, topbar: enTopbar, login: enLogin,
overview: enOverview, chat: enChat, agents: enAgents, teams: enTeams,
sessions: enSessions, skills: enSkills, cron: enCron, config: enConfig,
channels: enChannels, providers: enProviders, traces: enTraces,
events: enEvents, delegations: enDelegations, usage: enUsage,
approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools,
mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage,
"pending-messages": enPendingMessages,
},
vi: {
common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin,
overview: viOverview, chat: viChat, agents: viAgents, teams: viTeams,
sessions: viSessions, skills: viSkills, cron: viCron, config: viConfig,
channels: viChannels, providers: viProviders, traces: viTraces,
events: viEvents, delegations: viDelegations, usage: viUsage,
approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools,
mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage,
"pending-messages": viPendingMessages,
},
zh: {
common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin,
overview: zhOverview, chat: zhChat, agents: zhAgents, teams: zhTeams,
sessions: zhSessions, skills: zhSkills, cron: zhCron, config: zhConfig,
channels: zhChannels, providers: zhProviders, traces: zhTraces,
events: zhEvents, delegations: zhDelegations, usage: zhUsage,
approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools,
mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage,
"pending-messages": zhPendingMessages,
},
},
ns: [...ns],
defaultNS: "common",
lng: getInitialLanguage(),
fallbackLng: "en",
interpolation: { escapeValue: false },
missingKeyHandler: import.meta.env.DEV
? (_lngs, _ns, key) => console.warn(`[i18n] missing: ${key}`)
: undefined,
});
i18n.on("languageChanged", (lng) => {
localStorage.setItem(STORAGE_KEY, lng);
document.documentElement.lang = lng;
});
export default i18n;
+342
View File
@@ -0,0 +1,342 @@
{
"title": "Agents",
"description": "Manage your AI agents",
"createAgent": "Create Agent",
"searchPlaceholder": "Search agents...",
"emptyTitle": "No agents yet",
"emptyDescription": "Create your first agent to get started.",
"noMatchTitle": "No matching agents",
"noMatchDescription": "Try a different search term.",
"card": {
"unnamedAgent": "Unnamed Agent",
"summoning": "Summoning...",
"summonFailed": "Failed",
"resummon": "Resummon",
"delete": "Delete",
"predefinedTooltip": "Shared context files (SOUL.md, IDENTITY.md, AGENTS.md) with per-user USER.md instances.",
"openTooltip": "Each user gets their own full set of context files.",
"evolving": "Evolving",
"static": "Static",
"evolvingTooltip": "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed.",
"staticTooltip": "Agent style is static. Enable Self-Evolution in settings to allow style adaptation."
},
"delete": {
"title": "Delete Agent",
"description": "Permanently delete this agent, all its context files, sessions, and configuration. This action cannot be undone.",
"detailDescription": "Are you sure you want to delete \"{{name}}\"? All context files, sessions, and configuration will be permanently removed.",
"confirmLabel": "Delete",
"defaultCannotDelete": "The default agent cannot be deleted."
},
"create": {
"title": "Create Agent",
"displayName": "Display Name *",
"displayNamePlaceholder": "My Agent",
"agentKey": "Agent Key *",
"agentKeyPlaceholder": "e.g. my-agent",
"agentKeyHint": "Lowercase, numbers, hyphens",
"provider": "Provider *",
"selectProvider": "Select provider",
"model": "Model *",
"enterOrSelectModel": "Enter or select model",
"loadingModels": "Loading models...",
"check": "Check",
"checking": "Checking...",
"modelVerified": "Model verified",
"verificationFailed": "Verification failed",
"noModelsHint": "This provider doesn't list models — type the model ID manually.",
"agentType": "Agent Type",
"open": "Open",
"openSubLabel": "Per-user context",
"predefined": "Predefined",
"predefinedSubLabel": "Agent-level config",
"describeAgent": "Describe Your Agent",
"descriptionPlaceholder": "Describe your agent's personality, purpose, and behavior...",
"descriptionHint": "AI will automatically generate your agent's context files from this description. Leave empty to start with templates.",
"selfEvolution": "Self-Evolution",
"selfEvolutionHint": "Allow agent to evolve its style and tone over time via SOUL.md",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating...",
"checkAndCreate": "Check & Create",
"failedToCreate": "Failed to create agent"
},
"detail": {
"tabs": {
"general": "General",
"config": "Config",
"files": "Files",
"shares": "Shares",
"links": "Links",
"skills": "Skills",
"instances": "Instances"
},
"summonFailed": "Summon Failed",
"evolving": "Evolving",
"static": "Static",
"evolvingTooltipDetail": "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed.",
"staticTooltipDetail": "Agent style is static. Enable Self-Evolution in General tab to allow style adaptation."
},
"general": {
"selfEvolution": "Self-Evolution",
"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.",
"saveChanges": "Save Changes",
"saving": "Saving...",
"saved": "Saved",
"failedToSave": "Failed to save"
},
"identity": {
"title": "Identity",
"agentKey": "Agent Key",
"agentKeyHint": "Unique identifier, cannot be changed.",
"displayName": "Display Name",
"displayNamePlaceholder": "e.g. My Assistant",
"displayNameHint": "Friendly name shown in the UI. Leave empty to use the agent key.",
"expertiseSummary": "Expertise Summary",
"expertiseSummaryPlaceholder": "e.g. Chiêm tinh, bói toán, tử vi, thần số học",
"expertiseSummaryHint": "Short description of this agent's expertise. Used for delegation discovery.",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"summonFailed": "Summon Failed",
"defaultAgent": "Default Agent"
},
"llmConfig": {
"title": "LLM Configuration",
"contextWindow": "Context Window",
"contextWindowHint": "Token limit for the model context.",
"maxToolIterations": "Max Tool Iterations",
"maxToolIterationsHint": "Max tool calls per request."
},
"workspace": {
"title": "Workspace",
"workspacePath": "Workspace Path",
"workspacePathHint": "Automatically assigned when the agent is created. Per-user subdirectories are created at runtime.",
"noWorkspace": "No workspace configured",
"restrictToWorkspace": "Restrict to Workspace",
"restrictToWorkspaceHint": "Confine file access strictly within the workspace path."
},
"config": {
"saveConfig": "Save Config",
"saving": "Saving...",
"saved": "Saved",
"failedToSave": "Failed to save",
"usingGlobalDefaults": "Using global defaults from config.json"
},
"files": {
"readOnly": "Read-only",
"readOnlyDesc": "Only the agent owner can edit predefined context files.",
"resummon": "Resummon",
"editWithAi": "Edit with AI",
"resummonTitle": "Resummon Agent",
"resummonDesc": "This will regenerate SOUL.md, IDENTITY.md, and optionally USER_PREDEFINED.md from scratch using the original description. Any manual edits to these files will be overwritten.",
"cancel": "Cancel",
"resummonConfirm": "Resummon",
"summoning": "Summoning...",
"selectFileToEdit": "Select a file to edit",
"selectFileToView": "Select a file to view",
"saving": "Saving...",
"save": "Save",
"loading": "Loading...",
"openAgentTitle": "Open Agent - Per-User Context Files",
"openAgentDesc1": "This is an open agent. Context files (AGENTS.md, SOUL.md, TOOLS.md, etc.) are personalized for each user. They are automatically created from templates when a user first chats with this agent.",
"openAgentDesc2pre": "Agent-level files shown here are empty because open agents store all context per-user in the ",
"openAgentDesc2post": " table.",
"contextFiles": "Context Files",
"contextFile": "Context file",
"perUser": "per-user",
"emptyFile": "empty",
"estTokens": "Est {{tokens}} tokens"
},
"fileEditor": {
"editWithAi": "Edit with AI",
"editAiDescription": "Describe what you want to change. AI will read the current files and update them accordingly.",
"editAiPlaceholder": "e.g. Make the agent more formal, add Vietnamese language support, change the name to Luna...",
"sending": "Sending...",
"regenerate": "Regenerate",
"cancel": "Cancel"
},
"shares": {
"grantAccess": "Grant Access",
"userId": "User ID",
"userIdPlaceholder": "Enter user ID...",
"role": "Role",
"share": "Share",
"loadingShares": "Loading shares...",
"noShares": "No shares yet",
"noSharesDesc": "Share this agent with other users by entering their User ID above.",
"user": "User",
"revokeTitle": "Revoke Share",
"revokeDesc": "Revoke access for user \"{{userId}}\"? They will no longer be able to use this agent.",
"revoke": "Revoke"
},
"links": {
"teamsHint": "For multi-agent collaboration with task tracking and quality control, consider using Agent Teams instead. Agent Links are best for simple 1-to-1 delegation between two agents.",
"createLink": "Create Link",
"targetAgent": "Target Agent",
"selectAgent": "Select or search agent...",
"direction": "Direction",
"description": "Description",
"descriptionPlaceholder": "Optional description of the link...",
"maxConcurrent": "Max Concurrent",
"advancedSettings": "Advanced Settings",
"allowedUsers": "Allowed Users",
"allowedUsersPlaceholder": "user1, user2, ...",
"allowedUsersHint": "If set, only these users can trigger this delegation. Comma-separated.",
"deniedUsers": "Denied Users",
"deniedUsersPlaceholder": "user3, user4, ...",
"deniedUsersHint": "These users are blocked from triggering this delegation. Comma-separated.",
"creating": "Creating...",
"noLinks": "No agent links yet",
"noLinksDesc": "Create links to allow agents to delegate tasks to each other.",
"loadingLinks": "Loading links...",
"columns": {
"target": "Target",
"direction": "Direction",
"status": "Status",
"limit": "Limit"
},
"deleteTitle": "Delete Link",
"deleteDesc": "Remove the delegation link to \"{{name}}\"? Agents will no longer be able to delegate to each other through this link.",
"editTitle": "Edit Link",
"editDescription": "Update the delegation link settings.",
"optionalDescription": "Optional description...",
"allowedUsersHintShort": "If set, only these users can trigger this delegation.",
"deniedUsersHintShort": "These users are blocked from triggering this delegation."
},
"skills": {
"noSkillsAvailable": "No skills available",
"noSkillsDesc": "Upload skills in the Skills page to grant them to agents.",
"skillsGranted": "{{granted}} of {{total}} skills granted",
"filterSkills": "Filter skills...",
"noSkillsMatch": "No skills match your search."
},
"instances": {
"loadingInstances": "Loading instances...",
"noInstances": "No user instances yet.",
"noInstancesDesc": "Instances are created when users interact with this agent.",
"instanceCount": "{{count}} instance",
"instanceCount_plural": "{{count}} instances",
"selectInstance": "Select an instance to view and edit its USER.md",
"loading": "Loading...",
"saving": "Saving...",
"save": "Save",
"saved": "Saved"
},
"summoning": {
"title": "Summoning Your Agent...",
"completed": "Summoning Complete!",
"failed": "Summoning Failed",
"agentReady": "{{name}} is ready!",
"weavingSoul": "Weaving the soul of",
"wait": "This usually takes few minutes. Please wait...",
"continue": "Continue",
"retry": "Retry",
"retrying": "Retrying...",
"done": "done"
},
"configSections": {
"subagents": {
"title": "Subagents",
"description": "Controls sub-agent spawning limits and behavior",
"maxConcurrent": "Max Concurrent",
"maxSpawnDepth": "Max Spawn Depth",
"maxChildrenPerAgent": "Max Children Per Agent",
"archiveAfter": "Archive After (minutes)",
"modelOverride": "Model Override",
"inheritFromAgent": "(inherit from agent)"
},
"toolPolicy": {
"title": "Tool Policy",
"description": "Control which tools this agent can use",
"profile": "Profile",
"allow": "Allow",
"deny": "Deny",
"alsoAllow": "Also Allow",
"selectToolsAllow": "Select tools to allow...",
"selectToolsDeny": "Select tools to deny...",
"selectToolsAlsoAllow": "Select additional tools..."
},
"compaction": {
"title": "Compaction",
"description": "Context window compaction and memory flush settings",
"reserveTokensFloor": "Reserve Tokens Floor",
"maxHistoryShare": "Max History Share (0-1)",
"minMessages": "Min Messages",
"keepLastMessages": "Keep Last Messages",
"memoryFlush": "Memory Flush",
"softThreshold": "Soft Threshold (tokens)"
},
"contextPruning": {
"title": "Context Pruning",
"description": "Trim old tool results to save context window",
"mode": "Mode",
"keepLastAssistants": "Keep Last Assistants",
"softTrimRatio": "Soft Trim Ratio (0-1)",
"hardClearRatio": "Hard Clear Ratio (0-1)",
"minPrunableToolChars": "Min Prunable Tool Chars",
"softTrim": "Soft Trim",
"maxChars": "Max Chars",
"headChars": "Head Chars",
"tailChars": "Tail Chars",
"hardClear": "Hard Clear",
"enabled": "Enabled",
"placeholderText": "Placeholder Text"
},
"sandbox": {
"title": "Sandbox",
"description": "Docker sandbox for code execution isolation",
"mode": "Mode",
"workspaceAccess": "Workspace Access",
"image": "Image",
"scope": "Scope",
"timeout": "Timeout (sec)",
"memoryMb": "Memory (MB)",
"cpus": "CPUs",
"networkEnabled": "Network Enabled"
},
"memory": {
"title": "Memory",
"description": "Semantic memory search and embedding configuration",
"embeddingProvider": "Embedding Provider",
"embeddingModel": "Embedding Model",
"maxResults": "Max Results",
"maxChunkLen": "Max Chunk Length",
"vectorWeight": "Vector Weight",
"textWeight": "Text Weight"
},
"thinking": {
"title": "Extended Thinking",
"description": "Allow the model to reason before responding. Higher levels use more tokens but produce better results on complex tasks.",
"thinkingLevel": "Thinking Level",
"off": "Off",
"offDesc": "No extended thinking",
"low": "Low",
"lowDesc": "~4K token budget",
"medium": "Medium",
"mediumDesc": "~10-16K token budget",
"high": "High",
"highDesc": "~32K token budget"
},
"qualityGates": {
"title": "Quality Gates",
"description": "Validate delegation output before returning to the caller",
"noGates": "No quality gates configured. Click \"Add Gate\" to create one.",
"addGate": "Add Gate",
"gateNumber": "Gate #{{number}}",
"type": "Type",
"reviewerAgentKey": "Reviewer Agent Key",
"command": "Command",
"blockOnFailure": "Block on Failure",
"maxRetries": "Max Retries",
"timeout": "Timeout (seconds)"
},
"otherConfig": {
"title": "Other Config",
"description": "Additional JSON config for advanced settings not covered by other sections",
"formatJson": "Format JSON",
"invalidJson": "Invalid JSON syntax"
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"title": "Approvals",
"description": "Pending execution approvals",
"pending": "{{count}} pending",
"emptyTitle": "No pending approvals",
"emptyDescription": "All execution requests have been resolved. New requests will appear here in real-time.",
"allowOnce": "Allow Once",
"allowAlways": "Allow Always",
"deny": "Deny",
"confirmAllowOnce": {
"title": "Allow Once",
"description": "Allow execution of \"{{command}}\" from agent \"{{agentId}}\"?"
},
"confirmAllowAlways": {
"title": "Allow Always",
"description": "Permanently allow \"{{command}}\" for agent \"{{agentId}}\"? This command will be auto-approved in the future."
},
"confirmDeny": {
"title": "Deny Execution",
"description": "Deny execution request \"{{command}}\" from agent \"{{agentId}}\"?",
"confirmLabel": "Deny"
}
}
+213
View File
@@ -0,0 +1,213 @@
{
"title": "Channels",
"description": "Manage channel instances",
"statusDescription": "Communication channel status",
"addChannel": "Add Channel",
"refresh": "Refresh",
"searchPlaceholder": "Search channels...",
"emptyTitle": "No channels",
"emptyDescription": "Add your first channel instance to get started.",
"emptyStatusDescription": "No communication channels are configured.",
"noMatchTitle": "No matching channels",
"noMatchDescription": "Try a different search term.",
"columns": {
"name": "Name",
"type": "Type",
"agent": "Agent",
"status": "Status",
"enabled": "Enabled",
"actions": "Actions"
},
"status": {
"running": "Running",
"stopped": "Stopped"
},
"enabled": "Enabled",
"disabled": "Disabled",
"actions": {
"reauthenticate": "Re-authenticate",
"authenticate": "Authenticate to start channel"
},
"delete": {
"title": "Delete Channel Instance",
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"confirmLabel": "Delete"
},
"form": {
"createTitle": "Create Channel Instance",
"editTitle": "Edit Channel Instance",
"key": "Key *",
"keyPlaceholder": "my-telegram-bot",
"keyHint": "Unique slug used as channel identifier",
"displayName": "Display Name",
"displayNamePlaceholder": "Sales Bot",
"channelType": "Channel Type *",
"agent": "Agent *",
"selectAgent": "Select agent",
"credentials": "Credentials",
"credentialsHint": "(leave blank to keep current)",
"credentialsEncrypted": "Encrypted server-side. Never returned in API responses.",
"configuration": "Configuration",
"enabled": "Enabled",
"cancel": "Cancel",
"create": "Create",
"update": "Update",
"saving": "Saving...",
"step": "Step {{current}} of {{total}}",
"authenticate": "Authenticate — {{label}}",
"configure": "Configure — {{label}}",
"skip": "Skip",
"done": "Done",
"errors": {
"keyRequired": "Key is required",
"keySlug": "Key must be a valid slug (lowercase letters, numbers, hyphens only)",
"agentRequired": "Agent is required",
"requiredFields": "Required: {{fields}}",
"failedSave": "Failed to save",
"failedSaveConfig": "Failed to save configuration"
},
"authStatus": {
"authenticated": "Authenticated",
"notAuthenticated": "Not authenticated",
"useQrHint": "— Use the QR login button from the channels table"
}
},
"detail": {
"agent": "Agent: {{name}}",
"tabs": {
"general": "General",
"config": "Config",
"credentials": "Credentials",
"groups": "Groups",
"writers": "Writers"
},
"general": {
"name": "Name",
"nameHint": "Unique slug (read-only)",
"channelType": "Channel Type",
"displayName": "Display Name",
"displayNamePlaceholder": "Friendly name",
"agent": "Agent",
"selectAgent": "Select agent",
"enabled": "Enabled",
"saved": "Saved",
"saveChanges": "Save Changes",
"saving": "Saving..."
},
"credentials": {
"hint": "Leave fields blank to keep current values. Credentials are encrypted server-side and never returned in API responses.",
"noSchema": "No credentials schema for this channel type.",
"noCredentials": "No credentials to update",
"failedSave": "Failed to save",
"saved": "Saved",
"updateCredentials": "Update Credentials",
"saving": "Saving..."
},
"config": {
"noSchema": "No configuration schema for this channel type.",
"saved": "Saved",
"saveConfig": "Save Config",
"saving": "Saving..."
},
"groups": {
"saved": "Saved",
"saveGroups": "Save Groups",
"saving": "Saving..."
},
"writers": {
"description": "Manage which users are allowed to write/edit files in group chats.",
"groups": "Groups",
"noWriterGroups": "No writer groups",
"noWriterGroupsHint": "Use the form below to add writers to a group.",
"loadingWriters": "Loading writers...",
"noWriters": "No writers in this group.",
"writersCount": "{{count}} writer",
"writersCountPlural": "{{count}} writers",
"columns": {
"userId": "User ID",
"name": "Name",
"username": "Username"
},
"addForm": {
"title": "Add Writer to New Group",
"hint": "Add a writer to a group that doesn't appear in the list above.",
"groupId": "Group ID *",
"groupIdPlaceholder": "e.g. group:telegram:-100123456",
"userId": "User ID *",
"userIdPlaceholder": "e.g. 12345678",
"displayName": "Display Name",
"username": "Username",
"optional": "Optional",
"usernameWithout": "Optional (without @)",
"adding": "Adding...",
"addWriter": "Add Writer",
"add": "Add",
"errors": {
"groupUserRequired": "Group ID and User ID are required",
"failedAdd": "Failed to add writer"
}
}
}
},
"groupOverrides": {
"title": "Group & Topic Overrides",
"hint": "Override channel defaults per group chat or forum topic. Use \"*\" as group ID for wildcard defaults.",
"groupLabel": "Group: {{id}}",
"groupWildcard": "* (wildcard)",
"addGroupPlaceholder": "Chat ID (e.g. -100123456) or *",
"addGroup": "Add Group",
"topicOverrides": "Topic Overrides",
"topicLabel": "Topic: {{id}}",
"addTopicPlaceholder": "Topic thread ID",
"addTopic": "Add Topic",
"fields": {
"groupPolicy": "Group Policy",
"requireMention": "Require @mention",
"enabled": "Enabled",
"allowedUsers": "Allowed Users",
"allowedUsersPlaceholder": "One user ID per line",
"skillsFilter": "Skills Filter",
"skillsPlaceholder": "One skill name per line (empty = inherit)",
"toolAllow": "Tool Allow",
"toolAllowPlaceholder": "Select allowed tools (empty = inherit all)...",
"toolAllowHint": "Restrict which tools are available in this group. Supports group names like \"group:web\", \"group:fs\". Empty = inherit all tools.",
"systemPrompt": "System Prompt",
"systemPromptPlaceholder": "Additional system prompt for this group/topic",
"inherit": "Inherit (channel default)",
"yes": "Yes",
"no": "No"
}
},
"toast": {
"created": "Channel created",
"createdDesc": "{{name}} has been added",
"updated": "Channel updated",
"deleted": "Channel deleted",
"failedCreate": "Failed to create channel",
"failedUpdate": "Failed to update channel",
"failedDelete": "Failed to delete channel"
},
"zalo": {
"allowedUsers": "Allowed Users",
"loadContacts": "Load Contacts",
"loading": "Loading...",
"searchContacts": "Search contacts...",
"friends": "Friends",
"groups": "Groups",
"membersCount": "{{count}} members",
"noContacts": "No contacts found",
"noContactsMatch": "No contacts match \"{{search}}\"",
"addManualPlaceholder": "Add ID manually",
"add": "Add",
"zaloIdsHint": "Zalo user IDs or group IDs",
"completeQrLogin": "Complete QR login to load contacts",
"loginQr": "Login with QR — {{name}}",
"loginSuccessful": "Login successful! Channel starting...",
"generatingQr": "Generating QR code...",
"scanHint": "Scan with your Zalo app (expires in ~100s)",
"loginSuccessLoading": "Login successful! Loading contacts...",
"retry": "Retry",
"close": "Close",
"skip": "Skip"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"newChat": "New Chat",
"openSessions": "Open sessions",
"readOnly": "Read-only — this session belongs to another user",
"empty": {
"title": "Start a conversation",
"description": "Send a message to begin chatting with the agent."
},
"media": {
"image": "Image",
"video": "Video",
"audio": "Audio",
"voice": "Voice message",
"document": "Document",
"animation": "Animation",
"attached": "attached"
},
"forwardedFrom": "Forwarded from",
"replyingTo": "Replying to"
}
+104
View File
@@ -0,0 +1,104 @@
{
"refresh": "Refresh",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"save": "Save",
"edit": "Edit",
"create": "Create",
"add": "Add",
"view": "View",
"filter": "Filter",
"search": "Search",
"retry": "Retry",
"manage": "Manage",
"upload": "Upload",
"close": "Close",
"back": "Back",
"update": "Update",
"enabled": "Enabled",
"disabled": "Disabled",
"yes": "Yes",
"no": "No",
"loading": "Loading...",
"saving": "Saving...",
"creating": "Creating...",
"connecting": "Connecting...",
"connected": "Connected",
"disconnected": "Disconnected",
"running": "Running",
"stopped": "Stopped",
"pending": "Pending",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled",
"unknown": "Unknown",
"active": "active",
"items": "items",
"rows": "Rows",
"pageOf": "Page {{page}} of {{totalPages}}",
"messages": "messages",
"cached": "cached",
"live": "Live",
"noDescription": "No description",
"unsavedChanges": "Unsaved changes",
"tryDifferentSearch": "Try a different search term.",
"notSet": "Not set",
"default": "default",
"optional": "optional",
"required": "required",
"actions": "Actions",
"status": "Status",
"name": "Name",
"description": "Description",
"type": "Type",
"none": "none",
"all": "All",
"global": "Global",
"agent": "Agent",
"saved": "Saved",
"saveChanges": "Save Changes",
"saveConfig": "Save Config",
"checkAndCreate": "Check & Create",
"checking": "Checking...",
"format": "Format JSON",
"invalidJson": "Invalid JSON syntax",
"usingGlobalDefaults": "Using global defaults from config.json",
"disconnectedGateway": "Disconnected from gateway. Attempting to reconnect...",
"noFiles": "No files found.",
"selectFileToView": "Select a file to view its content",
"typeToConfirmPrefix": "Type",
"typeToConfirmSuffix": "to confirm.",
"searchPlaceholder": "Search...",
"noSessions": "No sessions yet. Start a chat!",
"noAgentsAvailable": "No agents available",
"selectAgent": "Select agent",
"sendMessage": "Send a message... (Shift+Enter for new line)",
"stopGeneration": "Stop generation",
"sendMessageTitle": "Send message",
"thinking": "Thinking",
"thinkingStreaming": "Thinking...",
"toolArguments": "Arguments",
"toolResult": "Result",
"skillActivating": "Activating...",
"skillActivated": "Activated",
"toolRunning": "Running...",
"toolDone": "Done",
"toolFailed": "Failed",
"filesBack": "Files",
"noProvidersConfigured": "No providers configured",
"loadingModels": "Loading models...",
"modelVerified": "Model verified",
"verificationFailed": "Verification failed",
"provider": "Provider",
"model": "Model",
"selectProvider": "Select provider",
"enterOrSelectModel": "Enter or select model",
"providerTip": "LLM provider name. Must match a configured provider.",
"modelTip": "Model ID to use.",
"check": "Check",
"builtinTools": "Built-in",
"customTools": "Custom",
"selectOrTypeTools": "Select or type tool names...",
"typeAndPressEnter": "Type and press Enter..."
}
+102
View File
@@ -0,0 +1,102 @@
{
"title": "Cron",
"description": "Schedule recurring agent tasks",
"newJob": "New Job",
"emptyTitle": "No cron jobs",
"emptyDescription": "Create a cron job to schedule recurring agent tasks.",
"columns": {
"enabled": "Enabled",
"name": "Name",
"schedule": "Schedule",
"message": "Message",
"agent": "Agent",
"actions": "Actions"
},
"runNow": "Run now",
"running": "Running...",
"runHistory": "Run history",
"defaultAgent": "default",
"create": {
"title": "Create Cron Job",
"name": "Name",
"namePlaceholder": "my-daily-task",
"nameHint": "Lowercase letters, numbers, and hyphens only",
"agentId": "Agent ID (optional)",
"agentIdPlaceholder": "default",
"scheduleType": "Schedule Type",
"every": "Every",
"cron": "Cron",
"once": "Once",
"intervalSeconds": "Interval (seconds)",
"cronExpression": "Cron Expression",
"cronHint": "Standard 5-field cron: min hour day month weekday",
"onceDesc": "The job will run once, approximately 1 minute from now.",
"message": "Message",
"messagePlaceholder": "What should the agent do?",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating..."
},
"enable": {
"title": "Enable Cron Job",
"description": "Enable \"{{name}}\"? It will start running on schedule.",
"confirmLabel": "Enable"
},
"disable": {
"title": "Disable Cron Job",
"description": "Disable \"{{name}}\"? It will stop running until re-enabled.",
"confirmLabel": "Disable"
},
"delete": {
"title": "Delete Cron Job",
"description": "Delete \"{{name}}\"? This cannot be undone.",
"confirmLabel": "Delete"
},
"runLog": {
"title": "Run Log: {{name}}",
"noHistory": "No run history yet.",
"loading": "Loading..."
},
"detail": {
"runNow": "Run Now",
"running": "Running...",
"disable": "Disable",
"enable": "Enable",
"delete": "Delete",
"enabled": "enabled",
"disabled": "disabled",
"runningStatus": "running...",
"payload": "Payload",
"empty": "(empty)",
"deliverDirect": "Deliver: direct",
"channel": "Channel: {{name}}",
"to": "To: {{name}}",
"lastError": "Last Error",
"runHistory": "Run History",
"refresh": "Refresh",
"noHistory": "No run history yet.",
"inOut": "{{input}} in / {{output}} out",
"infoRows": {
"schedule": "Schedule",
"timezone": "Timezone",
"nextRun": "Next Run",
"lastRun": "Last Run",
"created": "Created",
"updated": "Updated",
"autoDelete": "Auto-delete",
"autoDeleteValue": "Yes (one-time)"
}
},
"toast": {
"created": "Cron job created",
"createdDesc": "{{name}} has been added",
"enabled": "Cron job enabled",
"disabled": "Cron job disabled",
"deleted": "Cron job deleted",
"triggered": "Cron job triggered",
"failedCreate": "Failed to create cron job",
"failedToggle": "Failed to toggle cron job",
"failedDelete": "Failed to delete cron job",
"failedRun": "Failed to run cron job"
}
}
@@ -0,0 +1,41 @@
{
"title": "Delegations",
"description": "Agent delegation history and results",
"sourceFilterPlaceholder": "Source agent...",
"targetFilterPlaceholder": "Target agent...",
"filter": "Filter",
"emptyTitle": "No delegations",
"emptyDescription": "No delegation records found. Delegations are recorded when agents delegate tasks to other agents.",
"columns": {
"sourceTarget": "Source / Target",
"task": "Task",
"status": "Status",
"mode": "Mode",
"duration": "Duration",
"time": "Time"
},
"statusFilter": {
"all": "All",
"pending": "Pending",
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled"
},
"detail": {
"title": "Delegation Detail",
"notFound": "Delegation not found.",
"source": "Source:",
"target": "Target:",
"status": "Status:",
"mode": "Mode:",
"duration": "Duration:",
"iterations": "Iterations:",
"created": "Created:",
"completed": "Completed:",
"trace": "Trace:",
"task": "Task",
"result": "Result",
"error": "Error"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"title": "Realtime Events",
"description": "Live WebSocket events across all teams and agents",
"live": "Live",
"paused": "Paused",
"resume": "Resume",
"pause": "Pause",
"clear": "Clear",
"scrollToBottom": "Scroll to bottom",
"eventCount": "{{count}} event",
"eventCountPlural": "{{count}} events",
"categories": {
"all": "All",
"delegation": "Delegation",
"task": "Task",
"message": "Message",
"agent": "Agent",
"teamCrud": "Team CRUD",
"agentLink": "Agent Link"
},
"filters": {
"allTeams": "All teams",
"allUsers": "All users",
"allChats": "All chats"
},
"global": "Global",
"emptyTitle": "No events yet",
"emptyPaused": "Event capture is paused. Resume to see new events.",
"emptyWaiting": "Waiting for real-time events... Events will appear as agents work.",
"detail": {
"copy": "Copy",
"copied": "Copied"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"subtitle": "Sign in to the gateway dashboard",
"tabs": {
"token": "Token",
"pairing": "Pairing"
},
"token": {
"userId": "User ID",
"userIdPlaceholder": "your-user-id",
"gatewayToken": "Gateway Token",
"tokenPlaceholder": "Bearer token",
"connect": "Connect",
"connecting": "Connecting...",
"errorInvalidCredentials": "Invalid credentials. Check your token and user ID.",
"errorServer": "Server returned {{status}}. Please try again.",
"errorCannotConnect": "Cannot connect to gateway. Check if the server is running."
},
"pairing": {
"userId": "User ID",
"userIdPlaceholder": "your-user-id",
"noTokenNeeded": "No token needed. A pairing code will be generated for an admin to approve.",
"requestAccess": "Request Access",
"connecting": "Connecting...",
"errorConnectionLost": "Connection lost. Please try again.",
"errorCannotConnect": "Could not connect to gateway.",
"askAdmin": "Ask an admin to approve this code:",
"orRun": "Or run:",
"waitingForApproval": "Waiting for approval...",
"cancel": "Cancel",
"approved": "Access approved! Redirecting..."
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"title": "Logs",
"description": "Live log tailing",
"descriptionLive": "Live tailing at {{level}} level",
"live": "Live",
"start": "Start",
"stop": "Stop",
"clear": "Clear",
"filterPlaceholder": "Filter logs...",
"logLevelTitle": "Log level",
"emptyWaiting": "Waiting for logs...",
"emptyFilter": "No logs match filter.",
"emptyStart": "Click \"Start\" to begin streaming logs.",
"count": "{{filtered}}/{{total}}"
}
+99
View File
@@ -0,0 +1,99 @@
{
"title": "MCP Servers",
"description": "Manage Model Context Protocol server connections",
"addServer": "Add Server",
"searchPlaceholder": "Search servers...",
"emptyTitle": "No MCP servers",
"emptyDescription": "Add your first MCP server to get started.",
"noMatchTitle": "No matching servers",
"noMatchDescription": "Try a different search term.",
"columns": {
"name": "Name",
"transport": "Transport",
"tools": "Tools",
"enabled": "Enabled",
"createdBy": "Created By",
"actions": "Actions"
},
"viewTools": "View tools",
"manageGrants": "Manage grants",
"delete": {
"title": "Delete MCP Server",
"description": "Are you sure you want to delete \"{{name}}\"? This will also remove all associated grants.",
"confirmLabel": "Delete"
},
"form": {
"createTitle": "Add MCP Server",
"editTitle": "Edit MCP Server",
"name": "Name *",
"nameHint": "Lowercase letters, numbers, and hyphens only",
"displayName": "Display Name",
"transport": "Transport *",
"command": "Command *",
"args": "Args (comma-separated)",
"argsPlaceholder": "--flag1, --flag2",
"url": "URL *",
"headers": "Headers",
"headerKeyPlaceholder": "Header name",
"headerValuePlaceholder": "Header value",
"addHeader": "Add Header",
"env": "Environment Variables",
"envKeyPlaceholder": "Variable name",
"envValuePlaceholder": "Value",
"addVariable": "Add Variable",
"toolPrefix": "Tool Prefix",
"toolPrefixHint": "Auto-derived from name if empty.",
"timeout": "Timeout (seconds)",
"enabled": "Enabled",
"testConnection": "Test Connection",
"testing": "Testing...",
"toolsFound": "{{count}} tools found",
"cancel": "Cancel",
"create": "Create",
"update": "Update",
"saving": "Saving...",
"errors": {
"nameRequired": "Name and transport are required",
"nameSlug": "Name must be a valid slug (lowercase letters, numbers, hyphens only)",
"commandRequired": "Command is required for stdio transport",
"urlRequired": "URL is required for SSE/HTTP transport",
"connectionFailed": "Connection failed"
}
},
"grants": {
"title": "Agent Grants - {{name}}",
"currentGrants": "Current Grants",
"allToolsAllowed": "All tools allowed",
"addGrant": "Add Agent Grant",
"editGrant": "Edit Grant",
"selectAgent": "Select agent...",
"toolAllowList": "Tool allow list (optional)",
"toolDenyList": "Tool deny list (optional)",
"allowPlaceholder": "Select tools to allow...",
"denyPlaceholder": "Select tools to deny...",
"grant": "Grant",
"update": "Update",
"cancel": "Cancel",
"agentRequired": "Agent is required",
"failedGrant": "Failed to grant",
"failedRevoke": "Failed to revoke"
},
"tools": {
"title": "Tools — {{name}}",
"prefix": "prefix:",
"discovering": "Discovering tools...",
"noToolsTitle": "No tools discovered",
"noToolsDescription": "The server may be offline or has no tools registered.",
"failedLoad": "Failed to load tools",
"filterPlaceholder": "Filter tools...",
"noMatch": "No matching tools"
},
"toast": {
"created": "MCP server created",
"updated": "MCP server updated",
"deleted": "MCP server deleted",
"failedCreate": "Failed to create MCP server",
"failedUpdate": "Failed to update MCP server",
"failedDelete": "Failed to delete MCP server"
}
}
+166
View File
@@ -0,0 +1,166 @@
{
"title": "Memory",
"description": "Browse and manage agent memory documents, chunks, and embeddings",
"search": "Search",
"create": "Create",
"indexAll": "Index All",
"indexing": "Indexing...",
"emptyTitle": "No memory documents",
"emptyAgentDescription": "This agent has no memory documents yet.",
"emptyGlobalDescription": "No memory documents found across any agent.",
"filters": {
"agent": "Agent",
"allAgents": "All agents",
"scope": "Scope",
"allScope": "All (global + personal)"
},
"tabs": {
"documents": "Documents",
"knowledgeGraph": "Knowledge Graph"
},
"columns": {
"path": "Path",
"agent": "Agent",
"scope": "Scope",
"hash": "Hash",
"updated": "Updated",
"actions": "Actions"
},
"scopeLabel": {
"personal": "Personal",
"global": "Global"
},
"delete": {
"title": "Delete Memory Document",
"description": "Are you sure you want to delete \"{{path}}\"? This will also delete all associated chunks and embeddings.",
"confirmLabel": "Delete"
},
"createDialog": {
"title": "Create Memory Document",
"path": "Path *",
"pathPlaceholder": "e.g. notes/project-overview.md",
"content": "Content *",
"contentPlaceholder": "Document content...",
"agentId": "Agent *",
"agentIdPlaceholder": "Select agent...",
"scope": "Scope",
"existingScope": "Existing scope",
"customScope": "Custom",
"selectGroupUser": "Select group/user...",
"scopeHint": "Global = shared across all users. Personal = scoped to a specific user or group chat.",
"autoIndex": "Auto-index after creation",
"agentRequired": "Please select an agent",
"pathRequired": "Path is required",
"contentRequired": "Content is required",
"cancel": "Cancel",
"create": "Create",
"creating": "Creating..."
},
"documentDialog": {
"title": "Memory Document",
"path": "Path:",
"agent": "Agent:",
"scope": "Scope:",
"hash": "Hash:",
"updated": "Updated:",
"content": "Content",
"chunks": "Chunks ({{count}})",
"chunkIndex": "Chunk {{index}}",
"noChunks": "No chunks",
"embedded": "Embedded",
"noEmbedding": "No embedding"
},
"searchDialog": {
"title": "Search Memory",
"query": "Search Query",
"queryPlaceholder": "Enter search query...",
"agentId": "Agent ID",
"agentIdPlaceholder": "Filter by agent...",
"limit": "Limit",
"search": "Search",
"searching": "Searching...",
"results": "Results",
"noResults": "No results found",
"similarity": "similarity",
"path": "Path:"
},
"kg": {
"emptyTitle": "No entities",
"emptyDescription": "No knowledge graph entities for this agent yet.",
"emptySearchDescription": "No entities match your search.",
"stats": {
"entities": "Entities: {{count}}",
"relations": "Relations: {{count}}"
},
"search": {
"placeholder": "Search entities...",
"button": "Search",
"clear": "Clear"
},
"refresh": "Refresh",
"extract": "Extract",
"columns": {
"name": "Name",
"type": "Type",
"description": "Description",
"confidence": "Confidence",
"actions": "Actions",
"observations": "Observations",
"relations": "Relations"
},
"deleteEntity": {
"title": "Delete Entity",
"description": "Delete \"{{name}}\"? This will also delete all associated relations.",
"confirmLabel": "Delete"
},
"entity": {
"externalId": "External ID:",
"confidence": "Confidence:",
"description": "Description:",
"source": "Source:",
"properties": "Properties:",
"relations": "Relations",
"traverse": "Traverse (2-hop)",
"traversing": "Traversing...",
"loading": "Loading...",
"noRelations": "No relations found.",
"traversalResults": "Traversal Results ({{count}})",
"direction": {
"outgoing": "→ outgoing",
"incoming": "← incoming"
},
"columns": {
"direction": "Direction",
"relation": "Relation",
"target": "Target",
"confidence": "Confidence"
}
},
"extractDialog": {
"title": "Extract Entities from Text",
"providerLabel": "Extraction Provider",
"modelLabel": "Extraction Model",
"textLabel": "Text to extract from",
"textPlaceholder": "Paste conversation text, notes, or any content to extract entities and relations from...",
"extract": "Extract",
"extracting": "Extracting...",
"cancel": "Cancel",
"agentId": "Agent ID",
"agentIdPlaceholder": "Leave blank for global"
},
"graphView": {
"loading": "Loading graph...",
"empty": "No entities to visualize",
"nodes": "{{count}} nodes",
"edges": "{{count}} edges"
}
},
"toast": {
"created": "Memory document created",
"deleted": "Memory document deleted",
"indexed": "All documents indexed",
"failedCreate": "Failed to create document",
"failedDelete": "Failed to delete document",
"failedIndex": "Failed to index documents"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"title": "Nodes",
"description": "Manage paired devices and pending pairing requests",
"emptyTitle": "No devices",
"emptyDescription": "No paired devices or pending pairing requests.",
"pendingRequests": "Pending Requests ({{count}})",
"pairedDevices": "Paired Devices ({{count}})",
"sender": "Sender:",
"chat": "Chat:",
"columns": {
"channel": "Channel",
"senderId": "Sender ID",
"paired": "Paired",
"by": "By",
"actions": "Actions"
},
"approve": "Approve",
"deny": "Deny",
"revoke": "Revoke",
"confirmApprove": {
"title": "Approve Pairing",
"description": "Approve pairing request from {{channel}}:{{senderId}} (code: {{code}})? This device will be able to interact with agents.",
"confirmLabel": "Approve"
},
"confirmDeny": {
"title": "Deny Pairing",
"description": "Deny pairing request from {{channel}}:{{senderId}} (code: {{code}})? The request will be removed.",
"confirmLabel": "Deny"
},
"confirmRevoke": {
"title": "Revoke Device",
"description": "Revoke pairing for {{channel}}:{{senderId}}? The device will need to re-pair.",
"confirmLabel": "Revoke"
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"title": "Dashboard",
"description": "Gateway overview and quota usage",
"statCards": {
"requestsToday": "Requests Today",
"tokensToday": "Tokens Today",
"agents": "Agents",
"channels": "Channels",
"running": "running",
"online": "online",
"users": "{{count}} users",
"inOut": "{{input}} in / {{output}} out"
},
"providers": {
"noProvidersTitle": "No LLM providers configured",
"noEnabledTitle": "No LLM providers enabled",
"noProvidersDesc": "You need to add at least one LLM provider before agents can work. ",
"noEnabledDesc": "All providers are currently disabled. Enable at least one to start using agents. ",
"goToSettings": "Go to Provider Settings"
},
"systemHealth": {
"title": "System Health",
"uptime": "Uptime",
"database": "Database",
"providers": "Providers",
"tools": "Tools",
"sessions": "Sessions",
"clients": "Clients",
"channels": "Channels",
"active": "{{count}} active",
"none": "none"
},
"connectedClients": {
"title": "Connected Clients",
"noClients": "No clients connected",
"columns": {
"ip": "IP",
"user": "User",
"role": "Role",
"connected": "Connected"
},
"you": "you"
},
"cronJobs": {
"title": "Cron Jobs",
"manage": "Manage",
"noJobs": "No cron jobs configured",
"disabled": "disabled"
},
"recentRequests": {
"title": "Recent Requests",
"viewAll": "View All",
"noRequests": "No recent requests",
"columns": {
"time": "Time",
"name": "Name",
"user": "User",
"channel": "Channel",
"tokens": "Tokens",
"duration": "Duration",
"status": "Status"
}
},
"quotaUsage": {
"title": "Quota Usage",
"columns": {
"userGroup": "User / Group",
"hour": "Hour",
"day": "Day",
"week": "Week"
},
"noLimit": "no limit"
}
}
@@ -0,0 +1,31 @@
{
"title": "Pending Messages",
"description": "Buffered channel messages awaiting agent processing",
"emptyTitle": "No pending messages",
"emptyDescription": "No buffered message groups found. Messages appear here when channels buffer incoming messages before agent processing.",
"columns": {
"channel": "Channel",
"group": "Group",
"messages": "Messages",
"status": "Status",
"lastActivity": "Last Activity",
"actions": "Actions"
},
"status": {
"compacted": "Compacted",
"raw": "Raw"
},
"compact": "Compact",
"clear": "Clear",
"confirmClear": {
"title": "Clear messages?",
"description": "This will permanently delete all messages in {{channel}} / {{key}}. This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Clear"
},
"dialog": {
"title": "Messages — {{name}}",
"noMessages": "No messages found.",
"summary": "Summary"
}
}
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff. Show more