diff --git a/CLAUDE.md b/CLAUDE.md index 7b5a54d2..acfc1b69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `
` 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}/`
diff --git a/internal/gateway/client.go b/internal/gateway/client.go
index a22a9ab4..ce5b7457 100644
--- a/internal/gateway/client.go
+++ b/internal/gateway/client.go
@@ -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
diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go
index cb77d397..d3ab6b15 100644
--- a/internal/gateway/methods/agent_links.go
+++ b/internal/gateway/methods/agent_links.go
@@ -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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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
}
diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go
index 5992f053..25cfb8d7 100644
--- a/internal/gateway/methods/agents.go
+++ b/internal/gateway/methods/agents.go
@@ -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
}
diff --git a/internal/gateway/methods/agents_create.go b/internal/gateway/methods/agents_create.go
index c54cc416..b4049894 100644
--- a/internal/gateway/methods/agents_create.go
+++ b/internal/gateway/methods/agents_create.go
@@ -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
}
diff --git a/internal/gateway/methods/agents_delete.go b/internal/gateway/methods/agents_delete.go
index 6c63444c..98459ab8 100644
--- a/internal/gateway/methods/agents_delete.go
+++ b/internal/gateway/methods/agents_delete.go
@@ -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
}
diff --git a/internal/gateway/methods/agents_files.go b/internal/gateway/methods/agents_files.go
index b11c2bb0..f723ddb8 100644
--- a/internal/gateway/methods/agents_files.go
+++ b/internal/gateway/methods/agents_files.go
@@ -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, ¶ms)
@@ -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
}
diff --git a/internal/gateway/methods/agents_update.go b/internal/gateway/methods/agents_update.go
index 4fd59743..1416ce6f 100644
--- a/internal/gateway/methods/agents_update.go
+++ b/internal/gateway/methods/agents_update.go
@@ -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
}
}
diff --git a/internal/gateway/methods/channel_instances.go b/internal/gateway/methods/channel_instances.go
index 6b64d2aa..7cc7799b 100644
--- a/internal/gateway/methods/channel_instances.go
+++ b/internal/gateway/methods/channel_instances.go
@@ -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
}
diff --git a/internal/gateway/methods/channels.go b/internal/gateway/methods/channels.go
index a0a2d053..f40c7d8a 100644
--- a/internal/gateway/methods/channels.go
+++ b/internal/gateway/methods/channels.go
@@ -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")))
}
diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go
index d30965f8..162ad1e9 100644
--- a/internal/gateway/methods/chat.go
+++ b/internal/gateway/methods/chat.go
@@ -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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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
}
diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go
index e84240ec..0543b803 100644
--- a/internal/gateway/methods/config.go
+++ b/internal/gateway/methods/config.go
@@ -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
}
diff --git a/internal/gateway/methods/cron.go b/internal/gateway/methods/cron.go
index 5506a5aa..1968f318 100644
--- a/internal/gateway/methods/cron.go
+++ b/internal/gateway/methods/cron.go
@@ -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, ¶ms)
}
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
}
diff --git a/internal/gateway/methods/delegations.go b/internal/gateway/methods/delegations.go
index dd2aea79..14b1811f 100644
--- a/internal/gateway/methods/delegations.go
+++ b/internal/gateway/methods/delegations.go
@@ -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, ¶ms)
}
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()))
diff --git a/internal/gateway/methods/exec_approval.go b/internal/gateway/methods/exec_approval.go
index c89971b8..76ffd644 100644
--- a/internal/gateway/methods/exec_approval.go
+++ b/internal/gateway/methods/exec_approval.go
@@ -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, ¶ms)
}
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
}
diff --git a/internal/gateway/methods/logs.go b/internal/gateway/methods/logs.go
index 18d3795b..21675a35 100644
--- a/internal/gateway/methods/logs.go
+++ b/internal/gateway/methods/logs.go
@@ -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),
))
}
}
diff --git a/internal/gateway/methods/pairing.go b/internal/gateway/methods/pairing.go
index 9b1b3e44..f519832c 100644
--- a/internal/gateway/methods/pairing.go
+++ b/internal/gateway/methods/pairing.go
@@ -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
}
diff --git a/internal/gateway/methods/send.go b/internal/gateway/methods/send.go
index 0d0c190a..589cd25d 100644
--- a/internal/gateway/methods/send.go
+++ b/internal/gateway/methods/send.go
@@ -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
}
diff --git a/internal/gateway/methods/sessions.go b/internal/gateway/methods/sessions.go
index ec929841..965d72d0 100644
--- a/internal/gateway/methods/sessions.go
+++ b/internal/gateway/methods/sessions.go
@@ -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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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
}
diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go
index 313fe816..3332b003 100644
--- a/internal/gateway/methods/skills.go
+++ b/internal/gateway/methods/skills.go
@@ -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, ¶ms)
}
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, ¶ms)
}
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
}
diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go
index f6c04450..cc3af568 100644
--- a/internal/gateway/methods/teams.go
+++ b/internal/gateway/methods/teams.go
@@ -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, ¶ms); 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
}
diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go
index 9ff7a09c..5b572641 100644
--- a/internal/gateway/methods/teams_crud.go
+++ b/internal/gateway/methods/teams_crud.go
@@ -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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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()))
diff --git a/internal/gateway/methods/teams_members.go b/internal/gateway/methods/teams_members.go
index 6d153c45..179aeb78 100644
--- a/internal/gateway/methods/teams_members.go
+++ b/internal/gateway/methods/teams_members.go
@@ -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, ¶ms); 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, ¶ms); 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
}
diff --git a/internal/gateway/router.go b/internal/gateway/router.go
index dfee89a6..3a5b8d3a 100644
--- a/internal/gateway/router.go
+++ b/internal/gateway/router.go
@@ -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, ¶ms)
}
+ // 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
diff --git a/internal/http/agents.go b/internal/http/agents.go
index d2b24bf6..cda4991d 100644
--- a/internal/http/agents.go
+++ b/internal/http/agents.go
@@ -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
}
diff --git a/internal/http/agents_instances.go b/internal/http/agents_instances.go
index 7ae5848e..77a96899 100644
--- a/internal/http/agents_instances.go
+++ b/internal/http/agents_instances.go
@@ -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
}
diff --git a/internal/http/agents_sharing.go b/internal/http/agents_sharing.go
index 3f6a6aa2..70b513dd 100644
--- a/internal/http/agents_sharing.go
+++ b/internal/http/agents_sharing.go
@@ -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
}
diff --git a/internal/http/auth.go b/internal/http/auth.go
index 28bee0cd..38df3c5d 100644
--- a/internal/http/auth.go
+++ b/internal/http/auth.go
@@ -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
+}
diff --git a/internal/http/builtin_tools.go b/internal/http/builtin_tools.go
index 59ac5364..0b19fcf1 100644
--- a/internal/http/builtin_tools.go
+++ b/internal/http/builtin_tools.go
@@ -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
}
diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go
index c532bc0b..3cf712c7 100644
--- a/internal/http/channel_instances.go
+++ b/internal/http/channel_instances.go
@@ -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"})
diff --git a/internal/http/chat_completions.go b/internal/http/chat_completions.go
index 90db0e8d..15bb8dbc 100644
--- a/internal/http/chat_completions.go
+++ b/internal/http/chat_completions.go
@@ -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
}
diff --git a/internal/http/custom_tools.go b/internal/http/custom_tools.go
index e85088f3..6a28abae 100644
--- a/internal/http/custom_tools.go
+++ b/internal/http/custom_tools.go
@@ -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
}
diff --git a/internal/http/delegations.go b/internal/http/delegations.go
index bea5d873..49560dc2 100644
--- a/internal/http/delegations.go
+++ b/internal/http/delegations.go
@@ -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
}
diff --git a/internal/http/files.go b/internal/http/files.go
index 32ceadb4..c88ea077 100644
--- a/internal/http/files.go
+++ b/internal/http/files.go
@@ -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
}
diff --git a/internal/http/knowledge_graph_handlers.go b/internal/http/knowledge_graph_handlers.go
index 4b388711..6d3e6aaa 100644
--- a/internal/http/knowledge_graph_handlers.go
+++ b/internal/http/knowledge_graph_handlers.go
@@ -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
}
diff --git a/internal/http/mcp.go b/internal/http/mcp.go
index 82c3aac2..71f67a58 100644
--- a/internal/http/mcp.go
+++ b/internal/http/mcp.go
@@ -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"})
}
-
diff --git a/internal/http/mcp_grants.go b/internal/http/mcp_grants.go
index d8409d80..bf492d94 100644
--- a/internal/http/mcp_grants.go
+++ b/internal/http/mcp_grants.go
@@ -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
}
diff --git a/internal/http/mcp_requests.go b/internal/http/mcp_requests.go
index 4a193258..e0eb7e29 100644
--- a/internal/http/mcp_requests.go
+++ b/internal/http/mcp_requests.go
@@ -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
}
diff --git a/internal/http/mcp_tools.go b/internal/http/mcp_tools.go
index a0202f27..e9f92f21 100644
--- a/internal/http/mcp_tools.go
+++ b/internal/http/mcp_tools.go
@@ -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
}
diff --git a/internal/http/media_serve.go b/internal/http/media_serve.go
index 74ee3d61..1cade70a 100644
--- a/internal/http/media_serve.go
+++ b/internal/http/media_serve.go
@@ -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
}
diff --git a/internal/http/media_upload.go b/internal/http/media_upload.go
index 3141e876..32483dd8 100644
--- a/internal/http/media_upload.go
+++ b/internal/http/media_upload.go
@@ -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
}
diff --git a/internal/http/memory.go b/internal/http/memory.go
index 79fdfd10..3f5a88d4 100644
--- a/internal/http/memory.go
+++ b/internal/http/memory.go
@@ -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
}
}
diff --git a/internal/http/memory_handlers.go b/internal/http/memory_handlers.go
index 2b6d63ed..ee2c287a 100644
--- a/internal/http/memory_handlers.go
+++ b/internal/http/memory_handlers.go
@@ -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
}
diff --git a/internal/http/oauth.go b/internal/http/oauth.go
index e160d4cb..fa13d5e8 100644
--- a/internal/http/oauth.go
+++ b/internal/http/oauth.go
@@ -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
}
diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go
index e9ab803b..17aeb9de 100644
--- a/internal/http/pending_messages.go
+++ b/internal/http/pending_messages.go
@@ -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
}
diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go
index 81e88533..55e2c8b7 100644
--- a/internal/http/provider_models.go
+++ b/internal/http/provider_models.go
@@ -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
}
diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go
index d91d2911..7b3cb4af 100644
--- a/internal/http/provider_verify.go
+++ b/internal/http/provider_verify.go
@@ -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
}
diff --git a/internal/http/providers.go b/internal/http/providers.go
index 809d9b5a..20d7b005 100644
--- a/internal/http/providers.go
+++ b/internal/http/providers.go
@@ -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
}
diff --git a/internal/http/skills.go b/internal/http/skills.go
index aef302ce..28da40fc 100644
--- a/internal/http/skills.go
+++ b/internal/http/skills.go
@@ -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"})
}
-
diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go
index 76bc4082..63bbd8d8 100644
--- a/internal/http/skills_grants.go
+++ b/internal/http/skills_grants.go
@@ -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
}
diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go
index e87a2fe9..b7a918c7 100644
--- a/internal/http/skills_upload.go
+++ b/internal/http/skills_upload.go
@@ -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
}
diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go
index a295f8f4..a2a48cf8 100644
--- a/internal/http/skills_versions.go
+++ b/internal/http/skills_versions.go
@@ -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
}
diff --git a/internal/http/storage.go b/internal/http/storage.go
index dab3109b..969d8930 100644
--- a/internal/http/storage.go
+++ b/internal/http/storage.go
@@ -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
}
diff --git a/internal/http/tools_invoke.go b/internal/http/tools_invoke.go
index a9530612..daaa7de9 100644
--- a/internal/http/tools_invoke.go
+++ b/internal/http/tools_invoke.go
@@ -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
}
diff --git a/internal/http/traces.go b/internal/http/traces.go
index 11684b1e..d9fdb74e 100644
--- a/internal/http/traces.go
+++ b/internal/http/traces.go
@@ -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
}
diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go
new file mode 100644
index 00000000..8d6e614c
--- /dev/null
+++ b/internal/i18n/catalog_en.go
@@ -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",
+ })
+}
diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go
new file mode 100644
index 00000000..533087ff
--- /dev/null
+++ b/internal/i18n/catalog_vi.go
@@ -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ệ",
+ })
+}
diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go
new file mode 100644
index 00000000..ad018797
--- /dev/null
+++ b/internal/i18n/catalog_zh.go
@@ -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 无效",
+ })
+}
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 00000000..f5c84d69
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -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
+}
diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go
new file mode 100644
index 00000000..d59c18bf
--- /dev/null
+++ b/internal/i18n/keys.go
@@ -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"
+)
diff --git a/internal/store/context.go b/internal/store/context.go
index c27c2a86..4edfad5c 100644
--- a/internal/store/context.go
+++ b/internal/store/context.go
@@ -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"
+}
diff --git a/ui/web/package.json b/ui/web/package.json
index 4d7dd850..bdf99949 100644
--- a/ui/web/package.json
+++ b/ui/web/package.json
@@ -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",
diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml
index 25412849..17212fe9 100644
--- a/ui/web/pnpm-lock.yaml
+++ b/ui/web/pnpm-lock.yaml
@@ -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: {}
diff --git a/ui/web/src/api/ws-client.ts b/ui/web/src/api/ws-client.ts
index fe2054ed..07a5832b 100644
--- a/ui/web/src/api/ws-client.ts
+++ b/ui/web/src/api/ws-client.ts
@@ -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;
diff --git a/ui/web/src/components/chat/agent-selector.tsx b/ui/web/src/components/chat/agent-selector.tsx
index b40f85e5..f2d33db4 100644
--- a/ui/web/src/components/chat/agent-selector.tsx
+++ b/ui/web/src/components/chat/agent-selector.tsx
@@ -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([]);
@@ -37,7 +39,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
>
- {selected?.display_name ?? selected?.agent_key ?? (value || "Select agent")}
+ {selected?.display_name ?? selected?.agent_key ?? (value || t("selectAgent"))}
@@ -48,7 +50,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
{agents.length === 0 && (
- No agents available
+ {t("noAgentsAvailable")}
)}
{agents.map((agent) => (
@@ -68,7 +70,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
{agent.display_name || agent.agent_key}
{agent.is_default && (
- default
+ {t("default")}
)}
))}
diff --git a/ui/web/src/components/chat/chat-input.tsx b/ui/web/src/components/chat/chat-input.tsx
index ede4bb27..abea3e98 100644
--- a/ui/web/src/components/chat/chat-input.tsx
+++ b/ui/web/src/components/chat/chat-input.tsx
@@ -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(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")}
>
@@ -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")}
>
diff --git a/ui/web/src/components/chat/rich-content.tsx b/ui/web/src/components/chat/rich-content.tsx
index 30d275d3..daa823c4 100644
--- a/ui/web/src/components/chat/rich-content.tsx
+++ b/ui/web/src/components/chat/rich-content.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import {
Image,
Video,
@@ -126,33 +127,26 @@ const mediaIcons: Record = {
animation: Video,
};
-const mediaLabels: Record = {
- 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 (
- {label} attached
+ {label} {t("media.attached")}
);
}
function ForwardBadge({ from, date }: { from: string; date: string }) {
+ const { t } = useTranslation("chat");
return (
- Forwarded from {from}
+ {t("forwardedFrom")} {from}
{date && · {date}}
@@ -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 (
- Replying to {sender}
+ {t("replyingTo")} {sender}
{body}
diff --git a/ui/web/src/components/chat/session-switcher.tsx b/ui/web/src/components/chat/session-switcher.tsx
index 09a59f6a..3dfe5f24 100644
--- a/ui/web/src/components/chat/session-switcher.tsx
+++ b/ui/web/src/components/chat/session-switcher.tsx
@@ -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 (
@@ -29,7 +31,7 @@ export function SessionSwitcher({
if (sessions.length === 0) {
return (
- No sessions yet. Start a chat!
+ {t("noSessions")}
);
}
@@ -56,7 +58,7 @@ export function SessionSwitcher({
{label}
- {session.messageCount} messages
+ {session.messageCount} {t("messages")}
{formatRelativeTime(session.updated)}
diff --git a/ui/web/src/components/chat/thinking-block.tsx b/ui/web/src/components/chat/thinking-block.tsx
index 11b1749e..59f440ed 100644
--- a/ui/web/src/components/chat/thinking-block.tsx
+++ b/ui/web/src/components/chat/thinking-block.tsx
@@ -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)
>
- Thinking{isStreaming ? "..." : ""}
+ {isStreaming ? t("thinkingStreaming") : t("thinking")}
{expanded ? (
diff --git a/ui/web/src/components/chat/tool-call-card.tsx b/ui/web/src/components/chat/tool-call-card.tsx
index d6b847db..5ab85ffb 100644
--- a/ui/web/src/components/chat/tool-call-card.tsx
+++ b/ui/web/src/components/chat/tool-call-card.tsx
@@ -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 && (
- Arguments
+ {t("toolArguments")}
{JSON.stringify(entry.arguments, null, 2)}
@@ -65,7 +67,7 @@ export function ToolCallCard({ entry }: ToolCallCardProps) {
)}
{entry.result && (
- Result
+ {t("toolResult")}
{entry.result}
@@ -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 = 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 = {
calling: "text-blue-500",
completed: "text-green-500",
diff --git a/ui/web/src/components/layout/app-layout.tsx b/ui/web/src/components/layout/app-layout.tsx
index 163b070e..1e758370 100644
--- a/ui/web/src/components/layout/app-layout.tsx
+++ b/ui/web/src/components/layout/app-layout.tsx
@@ -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 && (
- Disconnected from gateway. Attempting to reconnect...
+ {t("disconnectedGateway")}
)}
diff --git a/ui/web/src/components/layout/connection-status.tsx b/ui/web/src/components/layout/connection-status.tsx
index 123ef00f..d5447014 100644
--- a/ui/web/src/components/layout/connection-status.tsx
+++ b/ui/web/src/components/layout/connection-status.tsx
@@ -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",
)}
/>
- {connected ? "Connected" : "Disconnected"}
+ {connected ? t("connected") : t("disconnected")}
);
}
diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx
index e114784d..4896797e 100644
--- a/ui/web/src/components/layout/sidebar.tsx
+++ b/ui/web/src/components/layout/sidebar.tsx
@@ -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 */}
diff --git a/ui/web/src/components/layout/topbar.tsx b/ui/web/src/components/layout/topbar.tsx
index f5ec3c2a..d2117bd8 100644
--- a/ui/web/src/components/layout/topbar.tsx
+++ b/ui/web/src/components/layout/topbar.tsx
@@ -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 (