diff --git a/cmd/gateway.go b/cmd/gateway.go index 0ebb2a899c..14383cf53b 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -286,6 +286,7 @@ func runGateway() { server.SetDB(pgStores.DB) server.SetPolicyEngine(permPE) server.SetPairingService(pgStores.Pairing) + server.SetBuiltinToolsStore(pgStores.BuiltinTools) server.SetMessageBus(msgBus) server.SetOAuthHandler(httpapi.NewOAuthHandler(pgStores.Providers, pgStores.ConfigSecrets, providerRegistry, msgBus)) @@ -333,7 +334,7 @@ func runGateway() { httpapi.InitGatewayToken(cfg.Gateway.Token) exportTokenStore := httpapi.InitExportTokenStore() defer exportTokenStore.Stop() - agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, modelReg, permPE.IsOwner, gatewayAddr, mcpToolLister) + agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, modelReg, permPE.IsOwner, gatewayAddr, cfg.Gateway.Token, mcpToolLister) // Wire dependencies for system prompt preview parity. if agentsH != nil { diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index 0dcbda6bdb..22485b5ea0 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -134,6 +134,29 @@ func buildEmbeddingProvider( "provider", dbp.Name, "requested", es.Dimensions, "required", store.RequiredMemoryEmbeddingDimensions) } + // Gemini native provider — uses its own embedding API (not OpenAI-compatible). + if dbp.ProviderType == store.ProviderGeminiNative { + apiKey := dbp.APIKey + if providerReg != nil { + if regProv, regErr := providerReg.Get(context.Background(), dbp.Name); regErr == nil { + if gp, ok := regProv.(interface{ APIKey() string }); ok && gp.APIKey() != "" { + apiKey = gp.APIKey() + } + } + } + if apiKey == "" { + slog.Warn("gemini embedding provider has no API key", "name", dbp.Name) + return nil + } + if model == "" { + model = memory.GeminiDefaultEmbeddingModel + } + ep := memory.NewGeminiEmbeddingProvider(dbp.Name, apiKey, apiBase, model) + ep.WithDimensions(dims) + slog.Info("gemini embedding provider configured", "name", dbp.Name, "model", model, "dims", dims) + return ep + } + // Try registry first for the actual API key / base (handles runtime-registered providers) if providerReg != nil { if regProv, regErr := providerReg.Get(context.Background(), dbp.Name); regErr == nil { diff --git a/cmd/gateway_http_client.go b/cmd/gateway_http_client.go index b1788a67ae..93be5f8238 100644 --- a/cmd/gateway_http_client.go +++ b/cmd/gateway_http_client.go @@ -122,6 +122,7 @@ func gatewayHTTPDoRaw(method, path string, body any) ([]byte, int, error) { req.Header.Set("X-GoClaw-User-Id", "system") if token := resolveGatewayToken(); token != "" { req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-GoClaw-User-Id", "system") } resp, err := httpClient.Do(req) diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 4ddb0e52b6..c6ecc2fb0c 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -9,7 +9,7 @@ import ( ) // wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages). -func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, modelReg providers.ModelRegistry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) { +func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, modelReg providers.ModelRegistry, isOwner func(string) bool, gatewayAddr, gatewayToken string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler @@ -70,6 +70,10 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if stores.MCP != nil { providersH.SetMCPServerLookup(buildMCPServerLookup(stores.MCP)) } + acpMCPData = buildACPMCPData(gatewayAddr, gatewayToken, stores.MCP) + providersH.SetProviderReloadFn(func(p *store.LLMProviderData) { + registerACPFromDB(providerReg, *p) + }) if stores.Tracing != nil { providersH.SetTracingStore(stores.Tracing) } diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 5d2c111ba2..ffa1a0fd1c 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -680,6 +680,8 @@ func wireExtras( // Unregister old instance (closes ProcessPool) then re-register providerReg.Unregister(p.Name) if p.Enabled { + acpMCPData = buildACPMCPData(loopbackAddr(appCfg.Gateway.Host, appCfg.Gateway.Port), + appCfg.Gateway.Token, stores.MCP) registerACPFromDB(providerReg, *p) } }) diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index b174ef44e7..ec75ec68a4 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -19,6 +19,36 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tools" ) +// acpMCPData is the package-level MCP bridge config consumed by +// registerACPFromConfig and registerACPFromDB. Callers populate it via +// buildACPMCPData before invoking either register* — keeping these functions +// at their original 2-arg signatures (registry + cfg/p) and making +// hot-reload paths idempotent (they refresh this var before re-registering). +// +// Single-process gateway scope means a package-level var is acceptable here: +// gateway addr/token/MCPStore are fixed for the lifetime of the binary, and +// the four set-sites (startup config, startup DB iteration, two hot-reload +// closures) all derive identical values. +var acpMCPData *providers.MCPConfigData + +// buildACPMCPData assembles the MCP bridge config consumed by ACP providers. +// Returns nil when no gateway addr is available, which makes downstream +// settings.MCPData nil and the ACP provider skip MCP server injection. +// mcpStore is optional — when non-nil, the AgentMCPLookup closure is attached +// so per-agent MCP servers are surfaced to the ACP subprocess at session/new +// time (DB-registered providers only; config-based providers run without +// per-agent MCP). +func buildACPMCPData(gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore) *providers.MCPConfigData { + if gatewayAddr == "" { + return nil + } + data := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken) + if mcpStore != nil { + data.AgentMCPLookup = buildMCPServerLookup(mcpStore) + } + return data +} + // loopbackAddr normalizes a gateway address for local connections. // CLI processes on the same machine can't connect to 0.0.0.0 on some OSes. func loopbackAddr(host string, port int) string { @@ -29,6 +59,7 @@ func loopbackAddr(host string, port int) string { } func registerProviders(registry *providers.Registry, cfg *config.Config, modelReg providers.ModelRegistry) { + gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) if cfg.Providers.Anthropic.APIKey != "" { registry.Register(providers.NewAnthropicProvider(cfg.Providers.Anthropic.APIKey, providers.WithAnthropicBaseURL(cfg.Providers.Anthropic.APIBase), @@ -188,7 +219,6 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe opts = append(opts, providers.WithClaudeCLIPermMode(cfg.Providers.ClaudeCLI.PermMode)) } // Build per-session MCP config: external MCP servers + GoClaw bridge - gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) mcpData := providers.BuildCLIMCPConfigData(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token) opts = append(opts, providers.WithClaudeCLIMCPConfigData(mcpData)) // Enable GoClaw security hooks (shell deny patterns, path restrictions) @@ -200,6 +230,7 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe // ACP provider (config-based) — orchestrates any ACP-compatible agent binary if cfg.Providers.ACP.Binary != "" { + acpMCPData = buildACPMCPData(gatewayAddr, cfg.Gateway.Token, nil) registerACPFromConfig(registry, cfg.Providers.ACP) } } @@ -276,6 +307,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi slog.Warn("failed to load providers from DB", "error", err) return } + acpMCPData = buildACPMCPData(gatewayAddr, gatewayToken, mcpStore) for _, p := range dbProviders { // Claude CLI doesn't need API key if !p.Enabled { @@ -411,35 +443,43 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi } // registerACPFromConfig registers an ACP provider from config file settings. +// All ACP options consume one shared *providers.ACPSettings populated from cfg; +// per-binary defaults (e.g. gemini's --include-directories) are applied inside +// the relevant With* option in the providers package. The MCP bridge config +// is read from the package-level acpMCPData (set by callers via +// buildACPMCPData before invocation). func registerACPFromConfig(registry *providers.Registry, cfg config.ACPConfig) { if _, err := exec.LookPath(cfg.Binary); err != nil { slog.Warn("acp: binary not found, skipping", "binary", cfg.Binary, "error", err) return } - idleTTL := 5 * time.Minute - if cfg.IdleTTL != "" { - if d, err := time.ParseDuration(cfg.IdleTTL); err == nil { - idleTTL = d - } - } - workDir := cfg.WorkDir - if workDir == "" { - workDir = defaultACPWorkDir() - } - var opts []providers.ACPOption - if cfg.Model != "" { - opts = append(opts, providers.WithACPModel(cfg.Model)) - } - if cfg.PermMode != "" { - opts = append(opts, providers.WithACPPermMode(cfg.PermMode)) + settings := &providers.ACPSettings{ + Binary: cfg.Binary, + Args: cfg.Args, + Model: cfg.Model, + PermMode: cfg.PermMode, + IdleTTL: cfg.IdleTTL, + WorkDir: cfg.WorkDir, + MCPData: acpMCPData, } registry.Register(providers.NewACPProvider( - cfg.Binary, cfg.Args, workDir, idleTTL, tools.DefaultDenyPatterns(), opts..., + settings.Binary, settings.Args, settings.WorkDirOrDefault(), + settings.IdleTTLOrDefault(5*time.Minute), + tools.DefaultDenyPatterns(), + providers.WithACPModel(settings), + providers.WithACPPermMode(settings), + providers.WithACPMCPConfigData(settings), + providers.WithIncludeDirectories(settings), )) - slog.Info("registered provider", "name", "acp", "binary", cfg.Binary) + slog.Info("registered provider", "name", "acp", "binary", cfg.Binary, "args", cfg.Args) } -// registerACPFromDB registers an ACP provider from a DB provider row. +// registerACPFromDB registers an ACP provider from a DB row. +// Called at startup (via registerProvidersFromDB) and on hot-reload. +// DB JSONB unmarshals directly into providers.ACPSettings — the shared struct's +// json tags match the historic schema (args, idle_ttl, perm_mode, work_dir, +// include_directories). The MCP bridge config is read from the package-level +// acpMCPData (set by callers via buildACPMCPData before invocation). func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) { binary := p.APIBase // repurpose api_base as binary path if binary == "" { @@ -454,37 +494,27 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) { slog.Warn("acp: binary not found, skipping", "binary", binary, "error", err) return } - // Parse settings JSONB for extra config - var settings struct { - Args []string `json:"args"` - IdleTTL string `json:"idle_ttl"` - PermMode string `json:"perm_mode"` - WorkDir string `json:"work_dir"` + settings := &providers.ACPSettings{ + Name: p.Name, + Binary: binary, + Model: p.Name, // historical: provider name doubles as default agent/model } if p.Settings != nil { - if err := json.Unmarshal(p.Settings, &settings); err != nil { + if err := json.Unmarshal(p.Settings, settings); err != nil { slog.Warn("acp: invalid settings JSON, using defaults", "name", p.Name, "error", err) } } - idleTTL := 5 * time.Minute - if settings.IdleTTL != "" { - if d, err := time.ParseDuration(settings.IdleTTL); err == nil { - idleTTL = d - } - } - workDir := settings.WorkDir - if workDir == "" { - workDir = defaultACPWorkDir() - } + settings.MCPData = acpMCPData registry.RegisterForTenant(p.TenantID, providers.NewACPProvider( - binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(), - providers.WithACPName(p.Name), - providers.WithACPModel(p.Name), + settings.Binary, settings.Args, settings.WorkDirOrDefault(), + settings.IdleTTLOrDefault(5*time.Minute), + tools.DefaultDenyPatterns(), + providers.WithACPName(settings), + providers.WithACPModel(settings), + providers.WithACPPermMode(settings), + providers.WithACPMCPConfigData(settings), + providers.WithIncludeDirectories(settings), )) slog.Info("registered provider from DB", "name", p.Name, "type", "acp") } -// defaultACPWorkDir returns the default workspace directory for ACP agents. -func defaultACPWorkDir() string { - return filepath.Join(config.ResolvedDataDirFromEnv(), "acp-workspaces") -} diff --git a/internal/channels/channel.go b/internal/channels/channel.go index e3903f2c4e..bc588dd71c 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -187,6 +187,8 @@ type BaseChannel struct { approvedGroups sync.Map // chatID → true (in-memory cache for paired group approval) pairingDebounce sync.Map // senderID → time.Time (debounce pairing reply sends) requireMention bool + + onDisconnect func() // optional: called on unexpected runtime disconnection for auto-retry } // NewBaseChannel creates a new BaseChannel with the given parameters. @@ -469,6 +471,20 @@ func (c *BaseChannel) MarkDegraded(summary, detail string, kind ChannelFailureKi c.setHealth(NewChannelHealth(ChannelHealthStateDegraded, summary, detail, kind, retryable)) } +// SetOnDisconnect sets a callback invoked when the channel disconnects unexpectedly at runtime. +// Used by Manager to wire auto-retry without the channel needing to know about Manager. +func (c *BaseChannel) SetOnDisconnect(fn func()) { + c.onDisconnect = fn +} + +// NotifyDisconnect calls the onDisconnect callback if set. +// Channel implementations call this when polling/streaming stops unexpectedly. +func (c *BaseChannel) NotifyDisconnect() { + if c.onDisconnect != nil { + c.onDisconnect() + } +} + // MarkFailed records a startup or runtime failure. func (c *BaseChannel) MarkFailed(summary, detail string, kind ChannelFailureKind, retryable bool) { if summary == "" { diff --git a/internal/channels/history.go b/internal/channels/history.go index 36692857b3..36ca26908a 100644 --- a/internal/channels/history.go +++ b/internal/channels/history.go @@ -60,13 +60,14 @@ type PendingHistory struct { order []string // insertion order for LRU eviction // Persistence (optional — nil means RAM-only) - channelName string - store store.PendingMessageStore - flushMu sync.Mutex - flushBuf []store.PendingMessage - flushSignal chan struct{} - stopCh chan struct{} - stopped chan struct{} + channelName string + store store.PendingMessageStore + flushMu sync.Mutex + flushBuf []store.PendingMessage + flushSignal chan struct{} + stopCh chan struct{} + stopped chan struct{} + flusherStarted bool // true after StartFlusher() — guards StopFlusher against unstarted state // Tenant isolation for DB operations. tenantID uuid.UUID diff --git a/internal/channels/history_flush.go b/internal/channels/history_flush.go index eb424c47b5..b300aaeca9 100644 --- a/internal/channels/history_flush.go +++ b/internal/channels/history_flush.go @@ -13,12 +13,13 @@ func (ph *PendingHistory) StartFlusher() { if ph.store == nil { return } + ph.flusherStarted = true go ph.flushLoop() } -// StopFlusher stops the background flusher and flushes remaining buffer. No-op if RAM-only. +// StopFlusher stops the background flusher and flushes remaining buffer. No-op if RAM-only or never started. func (ph *PendingHistory) StopFlusher() { - if ph.store == nil { + if ph.store == nil || !ph.flusherStarted { return } close(ph.stopCh) diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 4cc8f65475..0ee1554177 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "log/slog" + "regexp" "sync" + "time" "github.com/google/uuid" @@ -12,6 +14,34 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// reBotToken matches Telegram bot tokens in URLs (bot:/). +var reBotToken = regexp.MustCompile(`bot\d+:[A-Za-z0-9_-]+/`) + +// MaskBotToken replaces bot tokens in error messages with "bot***/". +func MaskBotToken(err error) string { + if err == nil { + return "" + } + return reBotToken.ReplaceAllString(err.Error(), "bot***/") +} + +// maskBotToken is a package-level alias for backward compatibility. +func maskBotToken(err error) string { return MaskBotToken(err) } + +// watchdogRetrySchedule defines successive wait durations between retry attempts. +// After the last entry the final value is reused indefinitely. +var watchdogRetrySchedule = []time.Duration{ + 30 * time.Second, + 1 * time.Minute, + 2 * time.Minute, + 5 * time.Minute, +} + +type failedEntry struct { + channel Channel + attempts int +} + // ChannelStream is the per-run streaming handle stored on RunContext. // Each channel implementation returns a ChannelStream from CreateStream(). // RunContext owns the stream so concurrent runs in the same group chat @@ -54,6 +84,8 @@ type Manager struct { bus *bus.MessageBus runs sync.Map // runID string → *RunContext dispatchTask *asyncTask + watchdogTask *asyncTask + failed map[string]*failedEntry // channels that failed to start; retried by watchdog mu sync.RWMutex contactCollector *store.ContactCollector } @@ -68,6 +100,7 @@ func NewManager(msgBus *bus.MessageBus) *Manager { return &Manager{ channels: make(map[string]Channel), health: make(map[string]ChannelHealth), + failed: make(map[string]*failedEntry), bus: msgBus, } } @@ -99,13 +132,24 @@ func (m *Manager) StartAll(ctx context.Context) error { m.syncChannelHealthLocked(name, channel) if err := channel.Start(ctx); err != nil { m.recordChannelStartFailureLocked(name, channel, "", err) - slog.Error("failed to start channel", "channel", name, "error", err) + slog.Error("failed to start channel", "channel", name, "error", maskBotToken(err)) + info := ClassifyChannelError(err) + if info.Retryable { + m.failed[name] = &failedEntry{channel: channel} + } continue } m.syncChannelHealthLocked(name, channel) } slog.Info("all channels started") + + if len(m.failed) > 0 { + watchdogCtx, cancel := context.WithCancel(ctx) + m.watchdogTask = &asyncTask{cancel: cancel} + go m.runWatchdog(watchdogCtx) + } + return nil } @@ -121,6 +165,11 @@ func (m *Manager) StopAll(ctx context.Context) error { m.dispatchTask = nil } + if m.watchdogTask != nil { + m.watchdogTask.cancel() + m.watchdogTask = nil + } + for name, channel := range m.channels { slog.Info("stopping channel", "channel", name) if err := channel.Stop(ctx); err != nil { @@ -183,6 +232,10 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { bc.SetContactCollector(m.contactCollector) } } + // Wire auto-retry: when the channel disconnects at runtime it calls back into RetryChannel. + if bc, ok := channel.(interface{ SetOnDisconnect(func()) }); ok { + bc.SetOnDisconnect(func() { m.RetryChannel(name) }) + } m.channels[name] = channel if hc, ok := channel.(interface{ MarkRegistered(string) }); ok { hc.MarkRegistered("Configured") @@ -190,6 +243,33 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { m.syncChannelHealthLocked(name, channel) } +// RetryChannel enqueues a running channel for reconnection after an unexpected disconnect. +// Safe to call from any goroutine (e.g. a channel's polling loop). +func (m *Manager) RetryChannel(name string) { + m.mu.Lock() + defer m.mu.Unlock() + + ch, ok := m.channels[name] + if !ok { + return + } + if _, alreadyQueued := m.failed[name]; alreadyQueued { + return + } + slog.Warn("channel disconnected at runtime, queuing for reconnect", "channel", name) + m.failed[name] = &failedEntry{channel: ch} + + // Start watchdog if not already running (e.g. all startup failures had already recovered). + if m.watchdogTask == nil && m.dispatchTask != nil { + // Reuse the same lifetime as the dispatch loop — both live until StopAll. + // We derive a fresh context from Background because the original StartAll ctx + // may have been cancelled; dispatchTask being non-nil means the manager is alive. + watchdogCtx, cancel := context.WithCancel(context.Background()) + m.watchdogTask = &asyncTask{cancel: cancel} + go m.runWatchdog(watchdogCtx) + } +} + // RecordHealth stores runtime health for an instance, including failures before registration. func (m *Manager) RecordHealth(name string, snapshot ChannelHealth) { m.mu.Lock() @@ -336,6 +416,80 @@ func (m *Manager) syncChannelHealthLocked(name string, channel Channel) { m.recordHealthLocked(name, snapshotChannelHealth(channel)) } +// runWatchdog periodically retries channels that failed to start with a retryable error. +// Backoff schedule: 30s → 1m → 2m → 5m (held at 5m thereafter). +// Exits when all failed channels have recovered or the context is cancelled. +func (m *Manager) runWatchdog(ctx context.Context) { + retryDelay := func(attempts int) time.Duration { + idx := attempts + if idx >= len(watchdogRetrySchedule) { + idx = len(watchdogRetrySchedule) - 1 + } + return watchdogRetrySchedule[idx] + } + + // nextRetry tracks when each channel should next be attempted. + nextRetry := make(map[string]time.Time) + m.mu.RLock() + for name, fe := range m.failed { + nextRetry[name] = time.Now().Add(retryDelay(fe.attempts)) + } + m.mu.RUnlock() + + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + m.mu.Lock() + if len(m.failed) == 0 { + m.watchdogTask = nil + m.mu.Unlock() + return + } + + for name, fe := range m.failed { + if now.Before(nextRetry[name]) { + continue + } + + slog.Info("channel watchdog: retrying failed channel", "channel", name, "attempt", fe.attempts+1) + // Stop first to clean up any residual state (e.g. GroupHistory flusher) + // before re-starting. Errors are non-fatal — channel may already be stopped. + _ = fe.channel.Stop(ctx) + if hc, ok := fe.channel.(interface{ MarkStarting(string) }); ok { + hc.MarkStarting("Reconnecting") + } + m.syncChannelHealthLocked(name, fe.channel) + + if err := fe.channel.Start(ctx); err != nil { + fe.attempts++ + info := ClassifyChannelError(err) + m.recordChannelStartFailureLocked(name, fe.channel, "", err) + slog.Warn("channel watchdog: retry failed", "channel", name, "attempt", fe.attempts, "error", maskBotToken(err)) + if !info.Retryable { + slog.Error("channel watchdog: non-retryable error, giving up", "channel", name) + delete(m.failed, name) + delete(nextRetry, name) + } else { + nextRetry[name] = time.Now().Add(retryDelay(fe.attempts)) + } + continue + } + + slog.Info("channel watchdog: channel recovered", "channel", name, "attempts", fe.attempts+1) + m.syncChannelHealthLocked(name, fe.channel) + delete(m.failed, name) + delete(nextRetry, name) + } + m.mu.Unlock() + } + } +} + func snapshotChannelHealth(channel Channel) ChannelHealth { if reporter, ok := channel.(interface{ HealthSnapshot() ChannelHealth }); ok { snapshot := reporter.HealthSnapshot() diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index b667ed0b45..a2e923d7b0 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -231,7 +231,7 @@ func (c *Channel) Start(ctx context.Context) error { for attempt := 1; attempt <= 3; attempt++ { if err := c.SyncMenuCommands(syncCtx, commands); err != nil { lastErr = err - slog.Warn("failed to sync telegram menu commands", "error", err, "attempt", attempt) + slog.Warn("failed to sync telegram menu commands", "error", channels.MaskBotToken(err), "attempt", attempt) if attempt < 3 { select { case <-syncCtx.Done(): @@ -259,8 +259,11 @@ func (c *Channel) Start(ctx context.Context) error { if !ok { if pollCtx.Err() == nil { c.MarkFailed("Polling stopped unexpectedly", "Telegram updates channel closed unexpectedly.", channels.ChannelFailureKindNetwork, true) + slog.Warn("telegram polling stopped unexpectedly, queuing for reconnect", "channel", c.Name()) + c.NotifyDisconnect() + } else { + slog.Info("telegram updates channel closed") } - slog.Info("telegram updates channel closed") return } if update.Message != nil { diff --git a/internal/consolidation/episodic_worker.go b/internal/consolidation/episodic_worker.go index 8a5f8cf564..7ff271f3fb 100644 --- a/internal/consolidation/episodic_worker.go +++ b/internal/consolidation/episodic_worker.go @@ -165,7 +165,7 @@ func (w *episodicWorker) summarizeFromMessages(ctx context.Context, provider pro } } - sctx, cancel := context.WithTimeout(ctx, 30*time.Second) + sctx, cancel := context.WithTimeout(ctx, 120*time.Second) defer cancel() resp, err := provider.Chat(sctx, providers.ChatRequest{ diff --git a/internal/gateway/server.go b/internal/gateway/server.go index cbfb79fcd2..e4d3a7e177 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -48,11 +48,12 @@ type Server struct { handlers []routeRegistrar // Non-handler dependencies (don't implement RegisterRoutes) - policyEngine *permissions.PolicyEngine - pairingService store.PairingStore - apiKeyStore store.APIKeyStore // for API key auth lookup - agentStore store.AgentStore // for context injection in tools_invoke - msgBus *bus.MessageBus // for MCP bridge media delivery + policyEngine *permissions.PolicyEngine + pairingService store.PairingStore + apiKeyStore store.APIKeyStore // for API key auth lookup + agentStore store.AgentStore // for context injection in tools_invoke + msgBus *bus.MessageBus // for MCP bridge media delivery + builtinToolsStore store.BuiltinToolStore // source of truth for MCP bridge tool selection upgrader websocket.Upgrader rateLimiter *RateLimiter @@ -183,7 +184,7 @@ func (s *Server) BuildMux() *http.ServeMux { // prevent unauthenticated tool invocations if port is exposed. if s.tools != nil { if s.cfg.Gateway.Token != "" { - bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0", s.msgBus) + bridgeHandler := mcpbridge.NewBridgeServer(s.tools, s.builtinToolsStore, "1.0.0", s.msgBus) handler := tokenAuthMiddleware(s.cfg.Gateway.Token, bridgeContextMiddleware(s.cfg.Gateway.Token, s.agentStore, bridgeHandler)) mux.Handle("/mcp/bridge", handler) @@ -305,9 +306,11 @@ func bridgeContextMiddleware(gatewayToken string, agentStore store.AgentStore, n // tokenAuthMiddleware wraps an http.Handler with Bearer token authentication. func tokenAuthMiddleware(token string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + slog.Info("http: incoming request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr, "agent_id", r.Header.Get("X-Agent-ID")) auth := r.Header.Get("Authorization") provided := strings.TrimPrefix(auth, "Bearer ") if !strings.HasPrefix(auth, "Bearer ") || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + slog.Warn("http: unauthorized request", "path", r.URL.Path, "remote", r.RemoteAddr) http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } @@ -396,6 +399,10 @@ func (s *Server) SetPolicyEngine(pe *permissions.PolicyEngine) { s.policyEngine // SetPairingService sets the pairing service for channel authentication. func (s *Server) SetPairingService(ps store.PairingStore) { s.pairingService = ps } +// SetBuiltinToolsStore provides the MCP bridge with the canonical enabled-tool +// list. Must be called before BuildMux() (which constructs the bridge server). +func (s *Server) SetBuiltinToolsStore(bt store.BuiltinToolStore) { s.builtinToolsStore = bt } + // SetAgentsHandler sets the agent CRUD handler. func (s *Server) SetAgentsHandler(h *httpapi.AgentsHandler) { s.handlers = append(s.handlers, h) } diff --git a/internal/http/providers.go b/internal/http/providers.go index ff8fa17718..31e38b7e79 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -35,9 +35,10 @@ type ProvidersHandler struct { cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates msgBus *bus.MessageBus sysConfigStore store.SystemConfigStore - tracingStore store.TracingStore // optional: for provider-scoped pool activity - agents store.AgentCRUDStore // optional: for provider pool activity agent lookup - modelReg providers.ModelRegistry // optional: forward-compat model resolver for Anthropic + tracingStore store.TracingStore // optional: for provider-scoped pool activity + agents store.AgentCRUDStore // optional: for provider pool activity agent lookup + modelReg providers.ModelRegistry // optional: forward-compat model resolver for Anthropic + providerReloadFn func(*store.LLMProviderData) // optional: hot-reload process-based providers without restart } // NewProvidersHandler creates a handler for provider management endpoints. @@ -84,6 +85,12 @@ func (h *ProvidersHandler) SetModelRegistry(r providers.ModelRegistry) { h.modelReg = r } +// SetProviderReloadFn sets a callback invoked when a process-based provider (ACP) +// is created or updated, so the change takes effect without a gateway restart. +func (h *ProvidersHandler) SetProviderReloadFn(fn func(*store.LLMProviderData)) { + h.providerReloadFn = fn +} + // resolveAPIBase returns the provider's api_base, falling back to config/env if empty. // For Ollama/OllamaCloud providers, applies a safety-net normalization: if the stored // value is missing the /v1 suffix (pre-existing record before write-time normalization), @@ -160,9 +167,11 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { if h.providerReg == nil || !p.Enabled { return } - // ACP agents don't need an API key — skip in-memory registration - // (ACP providers are registered via gateway_providers.go on startup or restart) + // ACP providers use a process pool; delegate to the reload callback wired from cmd/. if p.ProviderType == store.ProviderACP { + if h.providerReloadFn != nil { + h.providerReloadFn(p) + } return } // Claude CLI doesn't need an API key — register immediately diff --git a/internal/i18n/catalog_ko.go b/internal/i18n/catalog_ko.go new file mode 100644 index 0000000000..96365ef5fa --- /dev/null +++ b/internal/i18n/catalog_ko.go @@ -0,0 +1,187 @@ +package i18n + +func init() { + register(LocaleKO, 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은(는) 유효한 슬러그여야 합니다 (소문자, 숫자, 하이픈만 허용)", + MsgFailedToList: "%s 목록을 가져오지 못했습니다", + MsgFailedToCreate: "%s 생성에 실패했습니다: %s", + MsgFailedToUpdate: "%s 업데이트에 실패했습니다: %s", + MsgFailedToDelete: "%s 삭제에 실패했습니다: %s", + MsgFailedToSave: "%s 저장에 실패했습니다: %s", + MsgInvalidUpdates: "잘못된 업데이트", + + // Agent + MsgAgentNotFound: "에이전트를 찾을 수 없습니다: %s", + MsgCannotDeleteDefault: "기본 에이전트는 삭제할 수 없습니다", + MsgUserCtxRequired: "사용자 컨텍스트가 필요합니다", + + // Chat + MsgRateLimitExceeded: "요청 한도 초과 — 잠시 후 다시 시도해주세요", + MsgNoUserMessage: "사용자 메시지를 찾을 수 없습니다", + MsgUserIDRequired: "user_id가 필요합니다", + MsgMsgRequired: "메시지가 필요합니다", + + // Channel instances + MsgInvalidChannelType: "잘못된 channel_type", + MsgInstanceNotFound: "인스턴스를 찾을 수 없습니다", + + // Cron + MsgJobNotFound: "작업을 찾을 수 없습니다", + MsgInvalidCronExpr: "잘못된 크론 표현식: %s", + + // Config + MsgConfigHashMismatch: "설정이 변경되었습니다 (해시 불일치)", + + // Exec approval + MsgExecApprovalDisabled: "실행 승인이 활성화되지 않았습니다", + + // Pairing + MsgSenderChannelRequired: "senderId와 channel이 필요합니다", + MsgCodeRequired: "코드가 필요합니다", + MsgSenderIDRequired: "sender_id가 필요합니다", + + // HTTP API + MsgInvalidAuth: "잘못된 인증", + MsgMsgsRequired: "messages가 필요합니다", + MsgUserIDHeader: "X-GoClaw-User-Id 헤더가 필요합니다", + MsgFileTooLarge: "파일이 너무 크거나 잘못된 멀티파트 양식입니다", + MsgMissingFileField: "'file' 필드가 누락되었습니다", + MsgInvalidFilename: "잘못된 파일명", + MsgChannelKeyReq: "channel과 key가 필요합니다", + MsgMethodNotAllowed: "허용되지 않는 메서드", + MsgStreamingNotSupported: "스트리밍이 지원되지 않습니다", + MsgOwnerOnly: "소유자만 %s할 수 있습니다", + MsgNoAccess: "이 %s에 대한 접근 권한이 없습니다", + MsgAlreadySummoning: "에이전트가 이미 소환 중입니다", + MsgSummoningUnavailable: "소환을 사용할 수 없습니다", + MsgNoDescription: "에이전트에 재소환할 설명이 없습니다", + MsgInvalidPath: "잘못된 경로", + + // Scheduler + MsgQueueFull: "세션 대기열이 가득 찼습니다", + MsgShuttingDown: "게이트웨이가 종료 중입니다. 잠시 후 다시 시도해주세요", + + // Provider + MsgProviderReqFailed: "%s: 요청에 실패했습니다: %s", + + // Unknown method + MsgUnknownMethod: "알 수 없는 메서드: %s", + + // Not implemented + MsgNotImplemented: "%s은(는) 아직 구현되지 않았습니다", + + // Agent links + MsgLinksNotConfigured: "에이전트 링크가 설정되지 않았습니다", + MsgInvalidDirection: "방향은 outbound, inbound, 또는 bidirectional이어야 합니다", + MsgSourceTargetSame: "소스와 대상은 서로 다른 에이전트여야 합니다", + MsgCannotDelegateOpen: "오픈 에이전트에게는 위임할 수 없습니다 — 사전 정의된 에이전트만 위임 대상이 될 수 있습니다", + MsgNoUpdatesProvided: "업데이트가 제공되지 않았습니다", + MsgInvalidLinkStatus: "상태는 active 또는 disabled여야 합니다", + + // Teams + MsgTeamsNotConfigured: "팀이 설정되지 않았습니다", + MsgAgentIsTeamLead: "에이전트가 이미 팀 리더입니다", + MsgCannotRemoveTeamLead: "팀 리더는 제거할 수 없습니다", + + // Channels + MsgCannotDeleteDefaultInst: "기본 채널 인스턴스는 삭제할 수 없습니다", + MsgCannotRemoveLastWriter: "마지막 파일 작성자는 제거할 수 없습니다", + + // Skills + MsgSkillsUpdateNotSupported: "파일 기반 스킬에는 skills.update가 지원되지 않습니다", + MsgCannotResolveSkillID: "파일 기반 스킬의 스킬 ID를 확인할 수 없습니다", + + // Logs + MsgInvalidLogAction: "action은 'start' 또는 'stop'이어야 합니다", + + // Config + MsgRawConfigRequired: "원시 설정이 필요합니다", + MsgRawPatchRequired: "원시 패치가 필요합니다", + MsgConfigMasterScopeOnly: "config.* 메서드는 마스터 범위 전용입니다. 테넌트별 재정의는 테넌트 도구 설정 엔드포인트를 사용하세요", + + // Storage / File + MsgCannotDeleteSkillsDir: "스킬 디렉토리는 삭제할 수 없습니다", + MsgFailedToReadFile: "파일 읽기에 실패했습니다", + MsgFileNotFound: "파일을 찾을 수 없습니다", + MsgInvalidVersion: "잘못된 버전", + MsgVersionNotFound: "버전을 찾을 수 없습니다", + MsgFailedToDeleteFile: "삭제에 실패했습니다", + + // OAuth + MsgNoPendingOAuth: "진행 중인 OAuth 흐름이 없습니다", + MsgFailedToSaveToken: "토큰 저장에 실패했습니다", + + // Intent Classify + MsgStatusWorking: "🔄 요청을 처리 중입니다... 잠시 기다려주세요.", + MsgStatusDetailed: "🔄 현재 요청을 처리 중입니다...\n%s (반복 %d)\n실행 시간: %s\n\n잠시 기다려주세요 — 완료되면 응답하겠습니다.", + MsgStatusPhaseThinking: "단계: 생각 중...", + MsgStatusPhaseToolExec: "단계: %s 실행 중", + MsgStatusPhaseTools: "단계: 도구 실행 중...", + MsgStatusPhaseCompact: "단계: 컨텍스트 압축 중...", + MsgStatusPhaseDefault: "단계: 처리 중...", + MsgCancelledReply: "✋ 취소되었습니다. 다음에 무엇을 하시겠습니까?", + MsgInjectedAck: "알겠습니다. 작업 중인 내용에 반영하겠습니다.", + + // Knowledge Graph + MsgEntityIDRequired: "entity_id가 필요합니다", + MsgEntityFieldsRequired: "external_id, name, entity_type이 필요합니다", + MsgTextRequired: "텍스트가 필요합니다", + MsgProviderModelRequired: "provider와 model이 필요합니다", + MsgInvalidProviderOrModel: "잘못된 provider 또는 model", + + // Builtin tool descriptions + MsgToolReadFile: "경로를 기준으로 에이전트 워크스페이스에서 파일 내용을 읽습니다", + MsgToolWriteFile: "필요한 디렉토리를 생성하면서 워크스페이스의 파일에 내용을 씁니다", + MsgToolListFiles: "워크스페이스 내 지정된 경로의 파일과 디렉토리를 나열합니다", + MsgToolEdit: "전체 파일을 다시 쓰지 않고 기존 파일에 타겟 검색-교체 편집을 적용합니다", + MsgToolExec: "워크스페이스에서 셸 명령을 실행하고 stdout/stderr를 반환합니다", + MsgToolWebSearch: "검색 엔진(Brave 또는 DuckDuckGo)을 사용하여 웹에서 정보를 검색합니다", + MsgToolWebFetch: "웹 페이지 또는 API 엔드포인트를 가져와 텍스트 내용을 추출합니다", + MsgToolMemorySearch: "의미론적 유사성을 사용하여 에이전트의 장기 기억을 검색합니다", + MsgToolMemoryGet: "파일 경로로 특정 기억 문서를 검색합니다", + MsgToolKGSearch: "에이전트의 지식 그래프에서 엔티티, 관계, 관찰을 검색합니다", + MsgToolReadImage: "비전 지원 LLM 제공자를 사용하여 이미지를 분석합니다", + MsgToolReadDocument: "문서 지원 LLM 제공자를 사용하여 문서(PDF, Word, Excel, PowerPoint, CSV 등)를 분석합니다", + MsgToolCreateImage: "이미지 생성 제공자를 사용하여 텍스트 프롬프트에서 이미지를 생성합니다", + MsgToolReadAudio: "오디오 지원 LLM 제공자를 사용하여 오디오 파일(음성, 음악, 소리)을 분석합니다", + MsgToolReadVideo: "비디오 지원 LLM 제공자를 사용하여 비디오 파일을 분석합니다", + MsgToolCreateVideo: "AI를 사용하여 텍스트 설명에서 비디오를 생성합니다", + MsgToolCreateAudio: "AI를 사용하여 텍스트 설명에서 음악이나 음향 효과를 생성합니다", + MsgToolTTS: "텍스트를 자연스러운 음성 오디오로 변환합니다", + MsgToolBrowser: "브라우저 상호작용 자동화: 페이지 탐색, 요소 클릭, 폼 작성, 스크린샷 촬영", + MsgToolSessionsList: "모든 채널의 활성 채팅 세션을 나열합니다", + MsgToolSessionStatus: "특정 채팅 세션의 현재 상태와 메타데이터를 가져옵니다", + MsgToolSessionsHistory: "특정 채팅 세션의 메시지 기록을 검색합니다", + MsgToolSessionsSend: "에이전트를 대신하여 활성 채팅 세션에 메시지를 보냅니다", + MsgToolMessage: "연결된 채널(Telegram, Discord 등)에서 사용자에게 능동적으로 메시지를 보냅니다", + MsgToolCron: "크론 표현식, 특정 시간 또는 간격을 사용하여 반복 작업을 예약하거나 관리합니다", + MsgToolSpawn: "백그라운드 작업을 위해 하위 에이전트를 생성하거나 연결된 에이전트에 작업을 위임합니다", + MsgToolSkillSearch: "키워드나 설명으로 사용 가능한 스킬을 검색하여 관련 기능을 찾습니다", + MsgToolUseSkill: "전문화된 기능을 사용하기 위해 스킬을 활성화합니다 (추적 마커)", + MsgToolSkillManage: "대화 경험에서 스킬을 생성, 수정 또는 삭제합니다", + MsgToolPublishSkill: "스킬 디렉토리를 시스템 데이터베이스에 등록하여 검색 가능하게 만듭니다", + MsgToolTeamTasks: "팀 작업 보드에서 작업을 보고, 생성하고, 업데이트하고, 완료합니다", + + MsgSkillNudgePostscript: "이 작업은 여러 단계를 포함했습니다. 이 과정을 재사용 가능한 스킬로 저장할까요? **\"스킬로 저장\"** 또는 **\"건너뛰기\"**로 답장하세요.", + MsgSkillNudge70Pct: "[System] 반복 예산의 70%에 도달했습니다. 이 세션의 패턴 중 좋은 스킬이 될 수 있는 것이 있는지 고려해보세요.", + MsgSkillNudge90Pct: "[System] 반복 예산의 90%에 도달했습니다. 이 세션에 재사용 가능한 패턴이 포함되어 있다면 완료하기 전에 스킬로 저장하는 것을 고려해보세요.", + + MsgInvalidRole: "잘못된 역할: 허용되는 값은 owner, admin, operator, member, viewer입니다", + + MsgContactIDsRequired: "contact_ids가 필요합니다", + MsgMergeTargetRequired: "tenant_user_id 또는 create_user 중 정확히 하나가 필요합니다", + MsgTenantUserNotFound: "테넌트 사용자를 찾을 수 없습니다", + MsgTenantMismatch: "테넌트 사용자가 이 테넌트에 속하지 않습니다", + MsgTenantScopeRequired: "이 작업에는 테넌트 범위가 필요합니다", + }) +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index cff0d674b3..94b0808f6b 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -11,6 +11,7 @@ const ( LocaleEN = "en" LocaleVI = "vi" LocaleZH = "zh" + LocaleKO = "ko" DefaultLocale = LocaleEN ) @@ -58,7 +59,7 @@ func lookup(locale, key string) string { // IsSupported returns true if the locale is a known language. func IsSupported(locale string) bool { switch locale { - case LocaleEN, LocaleVI, LocaleZH: + case LocaleEN, LocaleVI, LocaleZH, LocaleKO: return true } return false diff --git a/internal/mcp/bridge_server.go b/internal/mcp/bridge_server.go index bb3646c43c..44a9309cdb 100644 --- a/internal/mcp/bridge_server.go +++ b/internal/mcp/bridge_server.go @@ -12,69 +12,71 @@ import ( mcpserver "github.com/mark3labs/mcp-go/server" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) -// BridgeToolNames is the subset of GoClaw tools exposed via the MCP bridge. -// Excluded: spawn (agent loop), create_forum_topic (channels). -var BridgeToolNames = map[string]bool{ - // Filesystem - "read_file": true, - "write_file": true, - "list_files": true, - "edit": true, - "exec": true, - // Web - "web_search": true, - "web_fetch": true, - // Memory & knowledge - "memory_search": true, - "memory_get": true, - "skill_search": true, - // Media - "read_image": true, - "create_image": true, - "tts": true, - // Browser automation - "browser": true, - // Scheduler - "cron": true, - // Messaging (send text/files to channels) - "message": true, - // Sessions (read + send) - "sessions_list": true, - "session_status": true, - "sessions_history": true, - "sessions_send": true, - // Team tools (context from X-Agent-ID/X-Channel/X-Chat-ID headers) - "team_tasks": true, +// bridgeAlwaysExcluded lists tools that must never be exposed through the +// MCP bridge regardless of UI toggle state, because they are internal to the +// agent runtime rather than externally-callable capabilities. +var bridgeAlwaysExcluded = map[string]bool{ + "spawn": true, // starts a nested agent loop + "create_forum_topic": true, // channel-internal + "heartbeat": true, // internal health signal } -// NewBridgeServer creates a StreamableHTTPServer that exposes GoClaw tools as MCP tools. -// It reads tools from the registry, filters to BridgeToolNames, and serves them -// over streamable-http transport (stateless mode). -// msgBus is optional; when non-nil, tools that produce media (deliver:true) will -// publish file attachments directly to the outbound bus. -func NewBridgeServer(reg *tools.Registry, version string, msgBus *bus.MessageBus) *mcpserver.StreamableHTTPServer { +// NewBridgeServer creates a StreamableHTTPServer that exposes the GoClaw +// tools enabled in the BuiltinToolStore as MCP tools over streamable-http +// transport (stateless mode). +// +// Tool selection sources: +// 1. BuiltinToolStore.ListEnabled — UI/seed-managed canonical list; any tool +// toggled off in the builtin-tools page is immediately removed on next +// startup (or cache rebuild). +// 2. bridgeAlwaysExcluded — safety whitelist (spawn, heartbeat, etc.). +// +// When btStore is nil (e.g. very early boot before stores are wired), the +// bridge falls back to an empty tool set rather than exposing everything. +// +// msgBus is optional; when non-nil, tools that produce media (deliver:true) +// will publish file attachments directly to the outbound bus. +func NewBridgeServer(reg *tools.Registry, btStore store.BuiltinToolStore, version string, msgBus *bus.MessageBus) *mcpserver.StreamableHTTPServer { srv := mcpserver.NewMCPServer("goclaw-bridge", version, mcpserver.WithToolCapabilities(false), ) - // Register each safe tool from the GoClaw registry var registered int - for name := range BridgeToolNames { - t, ok := reg.Get(name) - if !ok { - continue + var skippedDisabled, skippedMissing, skippedExcluded int + + if btStore != nil { + enabled, err := btStore.ListEnabled(context.Background()) + if err != nil { + slog.Error("mcp.bridge: failed to list enabled builtin tools", "error", err) + } else { + for _, def := range enabled { + if bridgeAlwaysExcluded[def.Name] { + skippedExcluded++ + continue + } + t, ok := reg.Get(def.Name) + if !ok { + skippedMissing++ + continue + } + mcpTool := convertToMCPTool(t) + handler := makeToolHandler(reg, def.Name, msgBus) + srv.AddTool(mcpTool, handler) + registered++ + } } - - mcpTool := convertToMCPTool(t) - handler := makeToolHandler(reg, name, msgBus) - srv.AddTool(mcpTool, handler) - registered++ } - slog.Info("mcp.bridge: tools registered", "count", registered) + slog.Info("mcp.bridge: tools registered", + "count", registered, + "skipped_disabled", skippedDisabled, + "skipped_missing", skippedMissing, + "skipped_excluded", skippedExcluded, + ) return mcpserver.NewStreamableHTTPServer(srv, mcpserver.WithStateLess(true), diff --git a/internal/memory/embedding_gemini.go b/internal/memory/embedding_gemini.go new file mode 100644 index 0000000000..d965fb4000 --- /dev/null +++ b/internal/memory/embedding_gemini.go @@ -0,0 +1,163 @@ +package memory + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + geminiEmbeddingBatchSize = 100 // batchEmbedContents API limit + geminiEmbeddingAPIBase = "https://generativelanguage.googleapis.com/v1beta" + // GeminiDefaultEmbeddingModel is the default model for Gemini embeddings. + // text-embedding-004/005 cap at 768 dims; gemini-embedding-2 supports higher dims (incl. 1536). + GeminiDefaultEmbeddingModel = "gemini-embedding-2" +) + +// GeminiEmbeddingProvider implements EmbeddingProvider using the Google Generative Language API. +type GeminiEmbeddingProvider struct { + name string + model string + apiKey string + apiBase string + dims int + client *http.Client +} + +// NewGeminiEmbeddingProvider creates an embedding provider backed by the Gemini API. +// apiBase may be empty (defaults to generativelanguage.googleapis.com). +func NewGeminiEmbeddingProvider(name, apiKey, apiBase, model string) *GeminiEmbeddingProvider { + if apiBase == "" { + apiBase = geminiEmbeddingAPIBase + } + if model == "" { + model = GeminiDefaultEmbeddingModel + } + return &GeminiEmbeddingProvider{ + name: name, + model: model, + apiKey: apiKey, + apiBase: strings.TrimRight(apiBase, "/"), + dims: 0, + client: &http.Client{Timeout: 60 * time.Second}, + } +} + +// WithDimensions sets the outputDimensionality sent to the API. +// Must match the pgvector column size (RequiredMemoryEmbeddingDimensions = 1536). +func (p *GeminiEmbeddingProvider) WithDimensions(d int) *GeminiEmbeddingProvider { + p.dims = d + return p +} + +func (p *GeminiEmbeddingProvider) Name() string { return p.name } +func (p *GeminiEmbeddingProvider) Model() string { return p.model } + +func (p *GeminiEmbeddingProvider) Embed(ctx context.Context, texts []string) ([][]float32, error) { + if len(texts) == 0 { + return nil, nil + } + results := make([][]float32, len(texts)) + for start := 0; start < len(texts); start += geminiEmbeddingBatchSize { + end := min(start+geminiEmbeddingBatchSize, len(texts)) + batch, err := p.embedBatch(ctx, texts[start:end]) + if err != nil { + return nil, fmt.Errorf("gemini embedding batch [%d:%d]: %w", start, end, err) + } + for i, emb := range batch { + results[start+i] = emb + } + } + return results, nil +} + +// modelName returns the fully-qualified model name required by the Gemini API. +// e.g. "gemini-embedding-exp-03-07" → "models/gemini-embedding-exp-03-07" +func (p *GeminiEmbeddingProvider) modelName() string { + if strings.HasPrefix(p.model, "models/") { + return p.model + } + return "models/" + p.model +} + +func (p *GeminiEmbeddingProvider) embedBatch(ctx context.Context, texts []string) ([][]float32, error) { + type contentPart struct { + Text string `json:"text"` + } + type content struct { + Parts []contentPart `json:"parts"` + } + type embedRequest struct { + Model string `json:"model"` + Content content `json:"content"` + OutputDimensionality *int `json:"outputDimensionality,omitempty"` + } + type batchRequest struct { + Requests []embedRequest `json:"requests"` + } + + reqs := make([]embedRequest, len(texts)) + for i, t := range texts { + r := embedRequest{ + Model: p.modelName(), + Content: content{Parts: []contentPart{{Text: t}}}, + } + if p.dims > 0 { + d := p.dims + r.OutputDimensionality = &d + } + reqs[i] = r + } + + body, err := json.Marshal(batchRequest{Requests: reqs}) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + + endpoint := fmt.Sprintf("%s/models/%s:batchEmbedContents", p.apiBase, p.model) + // Use the unqualified model name in the URL path. + if strings.HasPrefix(p.model, "models/") { + endpoint = fmt.Sprintf("%s/%s:batchEmbedContents", p.apiBase, p.model) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-goog-api-key", p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("gemini embedding API %d: %s", resp.StatusCode, string(b)) + } + + var result struct { + Embeddings []struct { + Values []float32 `json:"values"` + } `json:"embeddings"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + if len(result.Embeddings) != len(texts) { + return nil, fmt.Errorf("gemini embedding count mismatch: got %d, want %d", len(result.Embeddings), len(texts)) + } + + out := make([][]float32, len(texts)) + for i, e := range result.Embeddings { + out[i] = e.Values + } + return out, nil +} diff --git a/internal/providers/acp/acp_gemini_test.go b/internal/providers/acp/acp_gemini_test.go index b4324a68a6..a2fc8cdaf7 100644 --- a/internal/providers/acp/acp_gemini_test.go +++ b/internal/providers/acp/acp_gemini_test.go @@ -27,7 +27,7 @@ func TestGeminiProtocolMapping(t *testing.T) { t.Fatalf("Spawn failed: %v", err) } - sid, err := proc.NewSession(ctx) + sid, err := proc.NewSession(ctx, "") if err != nil { t.Fatalf("NewSession failed: %v", err) } diff --git a/internal/providers/acp/process.go b/internal/providers/acp/process.go index 7e34de5152..7dbe1cd982 100644 --- a/internal/providers/acp/process.go +++ b/internal/providers/acp/process.go @@ -19,14 +19,16 @@ type ACPProcess struct { cmd *exec.Cmd conn *Conn - agentCaps AgentCaps - workDir string - lastActive time.Time - inUse atomic.Int32 // >0 means at least one prompt is active — reaper must skip - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - exited chan struct{} // closed when process exits + agentCaps AgentCaps + workDir string + mcpServersFn func(context.Context) []McpServer // invoked on every session/new + session/load + promptTimeout time.Duration // overrides promptInactivityTimeout when non-zero + lastActive time.Time + inUse atomic.Int32 // >0 means at least one prompt is active — reaper must skip + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + exited chan struct{} // closed when process exits // updateFns routes session/update notifications to the correct active prompt. updateFns map[string]func(SessionUpdate) @@ -38,6 +40,11 @@ func (p *ACPProcess) AgentCaps() AgentCaps { return p.agentCaps } +// WorkDir returns the process pool's base work directory. Callers building +// per-session workspaces should join a session-specific segment under this +// path and pass the result as the cwd argument to NewSession/LoadSession. +func (p *ACPProcess) WorkDir() string { return p.workDir } + // registerUpdateFn registers a callback for session/update notifications on sessionID. func (p *ACPProcess) registerUpdateFn(sid string, fn func(SessionUpdate)) { p.updateMu.Lock() @@ -107,16 +114,18 @@ func (p *ACPProcess) dispatchUpdate(update SessionUpdate) { // Typically a single shared process is used (poolKey = binary identifier), // and multiple ACP sessions are multiplexed over it. type ProcessPool struct { - processes sync.Map // poolKey → *ACPProcess - spawnMu sync.Map // poolKey → *sync.Mutex — prevents concurrent spawn - agentBinary string - agentArgs []string - workDir string - idleTTL time.Duration - mu sync.RWMutex // protects toolHandler - toolHandler RequestHandler - done chan struct{} - closeOnce sync.Once + processes sync.Map // poolKey → *ACPProcess + spawnMu sync.Map // poolKey → *sync.Mutex — prevents concurrent spawn + agentBinary string + agentArgs []string + workDir string + mcpServersFn func(context.Context) []McpServer // resolved per session/new + session/load + idleTTL time.Duration + promptTimeout time.Duration + mu sync.RWMutex // protects toolHandler, mcpServersFn, promptTimeout + toolHandler RequestHandler + done chan struct{} + closeOnce sync.Once } // NewProcessPool creates a pool that spawns ACP agents as subprocesses. @@ -132,6 +141,37 @@ func NewProcessPool(binary string, args []string, workDir string, idleTTL time.D return pp } +// SetMcpServersFunc configures the callback used to build the MCP server list +// on every session/new and session/load request. The callback receives the +// request context (with agent/tenant IDs) so it can return per-agent servers +// resolved from the MCP store. Must be called before GetOrSpawn; spawned +// processes inherit the current value at spawn time. +func (pp *ProcessPool) SetMcpServersFunc(fn func(context.Context) []McpServer) { + pp.mu.Lock() + defer pp.mu.Unlock() + pp.mcpServersFn = fn +} + +func (pp *ProcessPool) getMcpServersFn() func(context.Context) []McpServer { + pp.mu.RLock() + defer pp.mu.RUnlock() + return pp.mcpServersFn +} + +// SetPromptTimeout sets the inactivity timeout used by Prompt() watchdogs in +// newly spawned processes. Existing processes are not affected. +func (pp *ProcessPool) SetPromptTimeout(d time.Duration) { + pp.mu.Lock() + defer pp.mu.Unlock() + pp.promptTimeout = d +} + +func (pp *ProcessPool) getPromptTimeout() time.Duration { + pp.mu.RLock() + defer pp.mu.RUnlock() + return pp.promptTimeout +} + // SetToolHandler sets the agent→client request handler (tool bridge). // Must be called before any GetOrSpawn calls. func (pp *ProcessPool) SetToolHandler(h RequestHandler) { @@ -176,7 +216,9 @@ func (pp *ProcessPool) spawn(ctx context.Context, poolKey string) (*ACPProcess, cmd := exec.CommandContext(procCtx, pp.agentBinary, pp.agentArgs...) cmd.Dir = pp.workDir - cmd.Env = filterACPEnv(os.Environ()) + cmd.Env = append(filterACPEnv(os.Environ()), + "GEMINI_TELEMETRY_ENABLED=false", + ) cmd.SysProcAttr = sysProcAttr() stdinPipe, err := cmd.StdinPipe() @@ -198,18 +240,20 @@ func (pp *ProcessPool) spawn(ctx context.Context, poolKey string) (*ACPProcess, } proc := &ACPProcess{ - cmd: cmd, - lastActive: time.Now(), - ctx: procCtx, - cancel: cancel, - exited: make(chan struct{}), - workDir: pp.workDir, + cmd: cmd, + lastActive: time.Now(), + ctx: procCtx, + cancel: cancel, + exited: make(chan struct{}), + workDir: pp.workDir, + mcpServersFn: pp.getMcpServersFn(), + promptTimeout: pp.getPromptTimeout(), } // Notification handler: log all notifications and dispatch session/update to callers notifyHandler := func(method string, params json.RawMessage) { slog.Info("acp: notification received", "method", method) - slog.Debug("acp: notification params", "method", method, "params", string(params)) + slog.Info("acp: notification params", "method", method, "params", string(params)) if method == "session/update" { var update SessionUpdate if err := json.Unmarshal(params, &update); err != nil { @@ -260,8 +304,8 @@ func (pp *ProcessPool) reapLoop() { proc.mu.Unlock() if idle { slog.Info("acp: reaping idle process", "pool_key", key) + pp.processes.Delete(key) // delete before cancel so a concurrent GetOrSpawn sees no stale entry proc.cancel() - pp.processes.Delete(key) } return true }) diff --git a/internal/providers/acp/session.go b/internal/providers/acp/session.go index 016d4303a7..75fb0bc826 100644 --- a/internal/providers/acp/session.go +++ b/internal/providers/acp/session.go @@ -5,16 +5,22 @@ import ( "fmt" "log/slog" "path/filepath" + "sync/atomic" "time" ) +// promptInactivityTimeout is the maximum time Prompt() will wait without +// receiving any session/update notification before cancelling the prompt. +// Exposed as a package var so tests can shorten it. +var promptInactivityTimeout = 10 * time.Minute + // Initialize sends the ACP initialize request to establish capabilities. func (p *ACPProcess) Initialize(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() req := InitializeRequest{ ProtocolVersion: 1, - ClientInfo: ClientInfo{Name: "GoClaw", Version: "1.0"}, + ClientInfo: ClientInfo{Name: "", Version: "1.0"}, Capabilities: ClientCaps{}, } var resp InitializeResponse @@ -26,51 +32,85 @@ func (p *ACPProcess) Initialize(ctx context.Context) error { return nil } +// resolveCwd returns the provided override if non-empty, otherwise the +// process pool's default work directory (falling back to CWD as last resort). +func (p *ACPProcess) resolveCwd(override string) string { + if override != "" { + return override + } + if p.workDir != "" { + return p.workDir + } + cwd, _ := filepath.Abs(".") + return cwd +} + // NewSession creates a new ACP session and returns its session ID. -func (p *ACPProcess) NewSession(ctx context.Context) (string, error) { +// If cwd is non-empty it is used as the session working directory; otherwise +// the process pool's workDir is used. Gemini CLI 0.36.x honors the per-session +// cwd even when it differs from the subprocess spawn directory, enabling +// per-goclaw-session workspace isolation. +func (p *ACPProcess) NewSession(ctx context.Context, cwd string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() + sessionCwd := p.resolveCwd(cwd) - cwd := p.workDir - if cwd == "" { - cwd, _ = filepath.Abs(".") + var servers []McpServer + if p.mcpServersFn != nil { + servers = p.mcpServersFn(ctx) } - - req := NewSessionRequest{ - Cwd: cwd, - McpServers: []string{}, + if servers == nil { + servers = []McpServer{} } + req := NewSessionRequest{Cwd: sessionCwd, McpServers: servers} var resp NewSessionResponse if err := p.conn.Call(ctx, "session/new", req, &resp); err != nil { return "", fmt.Errorf("acp session/new: %w", err) } - slog.Info("acp: session/new", "sid", resp.SessionID, "cwd", cwd) + slog.Info("acp: session/new", "sid", resp.SessionID, "cwd", sessionCwd, "mcpServers", len(servers)) + for _, s := range servers { + switch sv := s.(type) { + case McpServerHTTP: + slog.Info("acp: mcp server (http)", "name", sv.Name, "url", sv.URL, "headers", len(sv.Headers)) + case McpServerStdio: + slog.Info("acp: mcp server (stdio)", "name", sv.Name, "command", sv.Command, "args", sv.Args) + } + } return resp.SessionID, nil } // LoadSession restores a previous ACP session by ID (used after process restart). // Returns the session ID to use going forward (may equal the requested ID). // Only call if AgentCaps().LoadSession is true. -func (p *ACPProcess) LoadSession(ctx context.Context, sessionID string) (string, error) { +// cwd has the same semantics as NewSession — pass the per-goclaw-session +// directory so tool calls resolve paths against it. +func (p *ACPProcess) LoadSession(ctx context.Context, sessionID, cwd string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() + sessionCwd := p.resolveCwd(cwd) - cwd := p.workDir - if cwd == "" { - cwd, _ = filepath.Abs(".") + var servers []McpServer + if p.mcpServersFn != nil { + servers = p.mcpServersFn(ctx) } - - req := LoadSessionRequest{SessionID: sessionID, Cwd: cwd} + if servers == nil { + servers = []McpServer{} + } + req := LoadSessionRequest{SessionID: sessionID, Cwd: sessionCwd, McpServers: servers} var resp LoadSessionResponse if err := p.conn.Call(ctx, "session/load", req, &resp); err != nil { return "", fmt.Errorf("acp session/load: %w", err) } - slog.Info("acp: session/load", "sid", resp.SessionID) + slog.Info("acp: session/load", "sid", resp.SessionID, "cwd", sessionCwd) return resp.SessionID, nil } // Prompt sends user content to sessionID and blocks until the agent completes, // invoking onUpdate for each session/update notification received. +// +// An inactivity watchdog cancels the prompt if no session/update arrives within +// promptInactivityTimeout. This guards against silent hangs where the ACP agent +// stops responding without closing the connection. func (p *ACPProcess) Prompt(ctx context.Context, sessionID string, content []ContentBlock, onUpdate func(SessionUpdate)) (*PromptResponse, error) { p.inUse.Add(1) defer p.inUse.Add(-1) @@ -79,11 +119,61 @@ func (p *ACPProcess) Prompt(ctx context.Context, sessionID string, content []Con p.lastActive = time.Now() p.mu.Unlock() - p.registerUpdateFn(sessionID, onUpdate) + timeout := p.promptTimeout + if timeout <= 0 { + timeout = promptInactivityTimeout + } + + // lastActivity is refreshed by every session/update; watchdog fires when stale. + var lastActivity atomic.Int64 + lastActivity.Store(time.Now().UnixNano()) + + watchdogDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if time.Since(time.Unix(0, lastActivity.Load())) > timeout { + slog.Warn("acp: prompt inactivity timeout, cancelling", + "sid", sessionID, "timeout", timeout) + _ = p.conn.Notify("session/cancel", CancelNotification{SessionID: sessionID}) + return + } + case <-watchdogDone: + return + case <-ctx.Done(): + return + } + } + }() + + // Wrap onUpdate to refresh lastActivity on every notification. + p.registerUpdateFn(sessionID, func(update SessionUpdate) { + lastActivity.Store(time.Now().UnixNano()) + if update.ToolCall != nil { + slog.Info("acp: tool call update", "sid", sessionID, "tool", update.ToolCall.Name, "status", update.ToolCall.Status, "id", update.ToolCall.ID) + } else if update.Kind != "" { + slog.Info("acp: session update", "sid", sessionID, "kind", update.Kind, "sessionUpdate", update.Update.SessionUpdate, "status", update.Update.Status) + } + if onUpdate != nil { + onUpdate(update) + } + }) defer p.unregisterUpdateFn(sessionID) + defer close(watchdogDone) goclawSession := goclawSessionFromCtx(ctx) - slog.Info("acp: session/prompt", "session", goclawSession, "sid", sessionID) + var contentPreview string + if len(content) > 0 && content[0].Type == "text" { + if len(content[0].Text) > 200 { + contentPreview = content[0].Text[:200] + "..." + } else { + contentPreview = content[0].Text + } + } + slog.Info("acp: session/prompt", "session", goclawSession, "sid", sessionID, "blocks", len(content), "preview", contentPreview) req := PromptRequest{ SessionID: sessionID, Prompt: content, diff --git a/internal/providers/acp/session_test.go b/internal/providers/acp/session_test.go index 3f048b5e94..9a70f10968 100644 --- a/internal/providers/acp/session_test.go +++ b/internal/providers/acp/session_test.go @@ -213,7 +213,7 @@ func TestACPProcess_NewSession_Success(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - sid, err := proc.NewSession(ctx) + sid, err := proc.NewSession(ctx, "") if err != nil { t.Fatalf("NewSession error: %v", err) } @@ -233,7 +233,7 @@ func TestACPProcess_NewSession_Error(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - _, err := proc.NewSession(ctx) + _, err := proc.NewSession(ctx, "") if err == nil { t.Fatal("expected error from NewSession") } diff --git a/internal/providers/acp/tool_bridge.go b/internal/providers/acp/tool_bridge.go index ba36aa58d0..df6955d0b7 100644 --- a/internal/providers/acp/tool_bridge.go +++ b/internal/providers/acp/tool_bridge.go @@ -57,34 +57,50 @@ func NewToolBridge(workspace string, opts ...ToolBridgeOption) *ToolBridge { // Handle dispatches agent→client requests by method name. // Implements the RequestHandler signature for Conn. func (tb *ToolBridge) Handle(ctx context.Context, method string, params json.RawMessage) (any, error) { + session := goclawSessionFromCtx(ctx) switch method { case "fs/readTextFile": if tb.permMode == "deny-all" { + slog.Warn("security.tool_denied", "session", session, "tool", method, "reason", "deny-all") return nil, fmt.Errorf("read denied by permission mode: %s", tb.permMode) } var req ReadTextFileRequest if err := json.Unmarshal(params, &req); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } - return tb.readFile(req) + result, err := tb.readFile(req) + if err == nil { + slog.Info("security.tool_granted", "session", session, "tool", method, "path", req.Path) + } + return result, err case "fs/writeTextFile": if tb.permMode == "deny-all" || tb.permMode == "approve-reads" { + slog.Warn("security.tool_denied", "session", session, "tool", method, "reason", tb.permMode) return nil, fmt.Errorf("write denied by permission mode: %s", tb.permMode) } var req WriteTextFileRequest if err := json.Unmarshal(params, &req); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } - return tb.writeFile(req) + result, err := tb.writeFile(req) + if err == nil { + slog.Info("security.tool_granted", "session", session, "tool", method, "path", req.Path) + } + return result, err case "terminal/create": if tb.permMode == "deny-all" || tb.permMode == "approve-reads" { + slog.Warn("security.tool_denied", "session", session, "tool", method, "reason", tb.permMode) return nil, fmt.Errorf("terminal denied by permission mode: %s", tb.permMode) } var req CreateTerminalRequest if err := json.Unmarshal(params, &req); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } - return tb.createTerminal(req) + result, err := tb.createTerminal(req) + if err == nil { + slog.Info("security.tool_granted", "session", session, "tool", method, "command", req.Command) + } + return result, err case "terminal/output": var req TerminalOutputRequest if err := json.Unmarshal(params, &req); err != nil { @@ -105,6 +121,7 @@ func (tb *ToolBridge) Handle(ctx context.Context, method string, params json.Raw return tb.waitForExit(ctx, req) case "terminal/kill": if tb.permMode == "deny-all" { + slog.Warn("security.tool_denied", "session", session, "tool", method, "reason", "deny-all") return nil, fmt.Errorf("terminal kill denied by permission mode: %s", tb.permMode) } var req KillTerminalRequest @@ -117,7 +134,13 @@ func (tb *ToolBridge) Handle(ctx context.Context, method string, params json.Raw if err := json.Unmarshal(params, &req); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } - return tb.handlePermission(req) + return tb.handlePermission(ctx, req) + case "session/request_permission": + var req SessionRequestPermissionRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return tb.handleSessionPermission(ctx, req) default: return nil, fmt.Errorf("unknown method: %s", method) } @@ -151,21 +174,73 @@ func (tb *ToolBridge) writeFile(req WriteTextFileRequest) (*WriteTextFileRespons return &WriteTextFileResponse{}, nil } +// handleSessionPermission handles Gemini CLI's "session/request_permission" ACP method. +// Gemini CLI expects a nested outcome object that differs from the generic "permission/request" format. +// Responding with "proceed_always_server" adds the entire goclaw-bridge server to Gemini's +// allowlist so all subsequent tool calls in the session skip the confirmation step. +func (tb *ToolBridge) handleSessionPermission(ctx context.Context, req SessionRequestPermissionRequest) (*SessionRequestPermissionResponse, error) { + session := goclawSessionFromCtx(ctx) + + available := make(map[string]bool, len(req.Options)) + for _, opt := range req.Options { + available[opt.OptionID] = true + } + + switch tb.permMode { + case "deny-all": + slog.Warn("security.tool_denied", "session", session, "tool", req.ToolCall.Title, "reason", "deny-all") + return &SessionRequestPermissionResponse{ + Outcome: SessionPermOutcome{Outcome: "cancelled"}, + }, nil + case "approve-reads": + lower := strings.ToLower(req.ToolCall.Title) + if strings.Contains(lower, "read") || strings.Contains(lower, "glob") || + strings.Contains(lower, "grep") || strings.Contains(lower, "search") || + strings.Contains(lower, "list") || strings.Contains(lower, "view") { + slog.Info("security.tool_granted", "session", session, "tool", req.ToolCall.Title, "mode", "approve-reads") + return &SessionRequestPermissionResponse{ + Outcome: SessionPermOutcome{Outcome: "selected", OptionID: "proceed_once"}, + }, nil + } + slog.Warn("security.tool_denied", "session", session, "tool", req.ToolCall.Title, "reason", "approve-reads:write-blocked") + return &SessionRequestPermissionResponse{ + Outcome: SessionPermOutcome{Outcome: "cancelled"}, + }, nil + default: // "approve-all" + // Prefer server-wide approval so all subsequent goclaw-bridge tool calls skip confirmation. + optionID := "proceed_once" + for _, pref := range []string{"proceed_always_server", "proceed_always_tool", "proceed_once"} { + if available[pref] { + optionID = pref + break + } + } + slog.Info("security.tool_granted", "session", session, "tool", req.ToolCall.Title, "mode", "approve-all", "optionId", optionID) + return &SessionRequestPermissionResponse{ + Outcome: SessionPermOutcome{Outcome: "selected", OptionID: optionID}, + }, nil + } +} + // handlePermission responds to permission requests based on configured mode. -func (tb *ToolBridge) handlePermission(req RequestPermissionRequest) (*RequestPermissionResponse, error) { +func (tb *ToolBridge) handlePermission(ctx context.Context, req RequestPermissionRequest) (*RequestPermissionResponse, error) { + session := goclawSessionFromCtx(ctx) switch tb.permMode { case "deny-all": + slog.Warn("security.tool_denied", "session", session, "tool", req.ToolName, "reason", "deny-all") return &RequestPermissionResponse{Outcome: "denied"}, nil case "approve-reads": - // Approve read-only tools, deny write/exec tools lower := strings.ToLower(req.ToolName) if strings.Contains(lower, "read") || strings.Contains(lower, "glob") || strings.Contains(lower, "grep") || strings.Contains(lower, "search") || strings.Contains(lower, "list") || strings.Contains(lower, "view") { + slog.Info("security.tool_granted", "session", session, "tool", req.ToolName, "mode", "approve-reads") return &RequestPermissionResponse{Outcome: "approved"}, nil } + slog.Warn("security.tool_denied", "session", session, "tool", req.ToolName, "reason", "approve-reads:write-blocked") return &RequestPermissionResponse{Outcome: "denied"}, nil default: // "approve-all" or unknown → approve + slog.Info("security.tool_granted", "session", session, "tool", req.ToolName, "mode", "approve-all") return &RequestPermissionResponse{Outcome: "approved"}, nil } } diff --git a/internal/providers/acp/tool_bridge_test.go b/internal/providers/acp/tool_bridge_test.go index 7f54f74b71..fb9394092b 100644 --- a/internal/providers/acp/tool_bridge_test.go +++ b/internal/providers/acp/tool_bridge_test.go @@ -139,7 +139,7 @@ func TestResolvePath_NonExistentFile_AllowedForWrites(t *testing.T) { func TestHandlePermission_ApproveAll(t *testing.T) { tb, _ := newTestBridge(t, WithPermMode("approve-all")) - resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "bash", Description: "run"}) + resp, err := tb.handlePermission(context.Background(), RequestPermissionRequest{ToolName: "bash", Description: "run"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -150,7 +150,7 @@ func TestHandlePermission_ApproveAll(t *testing.T) { func TestHandlePermission_DenyAll(t *testing.T) { tb, _ := newTestBridge(t, WithPermMode("deny-all")) - resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "any_tool"}) + resp, err := tb.handlePermission(context.Background(), RequestPermissionRequest{ToolName: "any_tool"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -164,7 +164,7 @@ func TestHandlePermission_ApproveReads_ReadTool(t *testing.T) { cases := []string{"readFile", "glob_files", "search_code", "list_dir", "grep_search", "view_file"} for _, name := range cases { t.Run(name, func(t *testing.T) { - resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: name}) + resp, err := tb.handlePermission(context.Background(), RequestPermissionRequest{ToolName: name}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -177,7 +177,7 @@ func TestHandlePermission_ApproveReads_ReadTool(t *testing.T) { func TestHandlePermission_ApproveReads_WriteTool(t *testing.T) { tb, _ := newTestBridge(t, WithPermMode("approve-reads")) - resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "write_file"}) + resp, err := tb.handlePermission(context.Background(), RequestPermissionRequest{ToolName: "write_file"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -189,7 +189,7 @@ func TestHandlePermission_ApproveReads_WriteTool(t *testing.T) { func TestHandlePermission_DefaultMode_Approves(t *testing.T) { // permMode = "" defaults to "approve-all" behaviour (unknown → approve) tb := &ToolBridge{permMode: "unknown-mode"} - resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "anything"}) + resp, err := tb.handlePermission(context.Background(), RequestPermissionRequest{ToolName: "anything"}) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/providers/acp/types.go b/internal/providers/acp/types.go index 9177e50507..bfb571fb8a 100644 --- a/internal/providers/acp/types.go +++ b/internal/providers/acp/types.go @@ -61,9 +61,57 @@ type MCPCaps struct { // --- Session Methods --- +// McpServer is a discriminated-union transport descriptor for MCP servers. +// Concrete types: McpServerHTTP, McpServerStdio (SSE unimplemented). +// Per ACP spec (zed-industries/agent-client-protocol), the wire format is a +// JSON object tagged by `type`; Go's encoding/json handles this via concrete +// values held in the interface. +type McpServer interface{ mcpServerKind() } + +// McpServerHTTP carries HTTP transport MCP config. +// Headers is a {name,value} array — Gemini CLI 0.36.x rejects object-shaped +// headers with schema error "expected array, received object", so we diverge +// from the zed-industries ACP schema (which specifies object) to match the +// implementation that actually consumes the payload. +type McpServerHTTP struct { + Type string `json:"type"` // always "http" + Name string `json:"name"` + URL string `json:"url"` + Headers []McpServerKV `json:"headers"` +} + +func (McpServerHTTP) mcpServerKind() {} + +// McpServerStdio carries stdio transport MCP config. +type McpServerStdio struct { + Type string `json:"type"` // always "stdio" + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args"` + Env []McpServerKV `json:"env"` +} + +func (McpServerStdio) mcpServerKind() {} + +// McpServerKV is a {name, value} pair used for both HTTP headers and stdio env. +type McpServerKV struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// Alias retained for backward compatibility with any caller that constructed +// env entries by the older name. New code should use McpServerKV directly. +type McpServerEnv = McpServerKV + +// NewHTTPMcpServer returns an HTTP-transport McpServer with an empty headers +// slice (the field must be present per schema). +func NewHTTPMcpServer(name, url string) McpServer { + return McpServerHTTP{Type: "http", Name: name, URL: url, Headers: []McpServerKV{}} +} + type NewSessionRequest struct { - Cwd string `json:"cwd"` - McpServers []string `json:"mcpServers"` + Cwd string `json:"cwd"` + McpServers []McpServer `json:"mcpServers"` } type NewSessionResponse struct { @@ -71,9 +119,9 @@ type NewSessionResponse struct { } type LoadSessionRequest struct { - SessionID string `json:"sessionId"` - Cwd string `json:"cwd,omitempty"` - McpServers []string `json:"mcpServers"` + SessionID string `json:"sessionId"` + Cwd string `json:"cwd,omitempty"` + McpServers []McpServer `json:"mcpServers"` } type LoadSessionResponse struct { @@ -206,3 +254,35 @@ type RequestPermissionRequest struct { type RequestPermissionResponse struct { Outcome string `json:"outcome"` // "proceed_always", "approved", "denied" } + +// SessionRequestPermissionRequest is sent by Gemini CLI (method "session/request_permission") +// to request approval before executing an MCP tool. +type SessionRequestPermissionRequest struct { + SessionID string `json:"sessionId"` + Options []SessionPermOpt `json:"options"` + ToolCall SessionPermTool `json:"toolCall"` +} + +type SessionPermOpt struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + Kind string `json:"kind"` +} + +type SessionPermTool struct { + ToolCallID string `json:"toolCallId"` + Status string `json:"status"` + Title string `json:"title"` + Kind string `json:"kind,omitempty"` +} + +// SessionRequestPermissionResponse matches Gemini CLI's RequestPermissionResponseSchema. +// Wire format: {"outcome":{"outcome":"cancelled"}} or {"outcome":{"outcome":"selected","optionId":"..."}} +type SessionRequestPermissionResponse struct { + Outcome SessionPermOutcome `json:"outcome"` +} + +type SessionPermOutcome struct { + Outcome string `json:"outcome"` // "cancelled" or "selected" + OptionID string `json:"optionId,omitempty"` // required when outcome="selected" +} diff --git a/internal/providers/acp_provider.go b/internal/providers/acp_provider.go index 17380d5d5e..35c0b422b4 100644 --- a/internal/providers/acp_provider.go +++ b/internal/providers/acp_provider.go @@ -5,14 +5,89 @@ import ( "errors" "fmt" "log/slog" + "os" + "path/filepath" "regexp" "strings" "sync" "time" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers/acp" ) +// ACPSettings is the unified configuration shape for ACP-based providers. +// Both config-based (config.json `providers.acp`) and DB-based (llm_providers.settings JSONB) +// registration paths populate this struct; all ACP `With*` options consume it as a +// common argument and pick the field they configure. Fields left zero / empty are +// treated as "use built-in default" inside each option, so callers only need to set +// values they want to override. +// +// Duration fields (IdleTTL, SessionTTL, PromptTimeout) are stored as strings in the +// duration syntax accepted by time.ParseDuration ("5m", "30s", etc.) so the same +// struct shape works for JSON unmarshal (DB JSONB) without custom decoding logic. +type ACPSettings struct { + Name string `json:"name,omitempty"` // provider display name + Binary string `json:"-"` // resolved binary path (DB: api_base column; config: cfg.Binary) + Args []string `json:"args,omitempty"` // extra CLI args (excluding goclaw-injected --include-directories) + Model string `json:"model,omitempty"` // default model/agent name + PermMode string `json:"perm_mode,omitempty"` // tool bridge permission mode + IdleTTL string `json:"idle_ttl,omitempty"` // duration string; pool/session reaper idle timeout + SessionTTL string `json:"session_ttl,omitempty"` // duration string; session reaper override (else falls back to IdleTTL) + PromptTimeout string `json:"prompt_timeout,omitempty"` // duration string; per-Prompt() inactivity watchdog + WorkDir string `json:"work_dir,omitempty"` // process pool base cwd + IncludeDirs []string `json:"include_directories,omitempty"` + MCPData *MCPConfigData `json:"-"` // MCP bridge config; never in JSONB +} + +// IdleTTLOrDefault parses IdleTTL with a fallback when unset / invalid. +func (s *ACPSettings) IdleTTLOrDefault(fallback time.Duration) time.Duration { + if s == nil || s.IdleTTL == "" { + return fallback + } + if d, err := time.ParseDuration(s.IdleTTL); err == nil && d > 0 { + return d + } + return fallback +} + +// WorkDirOrDefault returns s.WorkDir or the package default ACP workspace root. +func (s *ACPSettings) WorkDirOrDefault() string { + if s != nil && s.WorkDir != "" { + return s.WorkDir + } + return defaultACPWorkDir() +} + +// defaultACPWorkDir returns the standard ACP process workspace root used when +// callers don't override via ACPSettings.WorkDir. Located under the resolved +// data dir so it survives across deployments without leaking outside goclaw. +func defaultACPWorkDir() string { + return filepath.Join(config.ResolvedDataDirFromEnv(), "acp-workspaces") +} + +// defaultGoclawSkillDirs returns the canonical filesystem-backed skill source +// directories that gemini ACP should expose via --include-directories when no +// explicit IncludeDirs are configured. Mirrors three of the loader's runtime +// slots — workspace-relative slots are intentionally omitted because the ACP +// session cwd lives under acp-workspaces, not the gateway workspace. +// +// Sources covered: +// - /skills-store (managedSkillsDir) +// - /skills (globalSkills) +// - ~/.agents/skills (personalAgentSkills) +func defaultGoclawSkillDirs() []string { + dataDir := config.ResolvedDataDirFromEnv() + dirs := []string{ + filepath.Join(dataDir, "skills-store"), + filepath.Join(dataDir, "skills"), + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + dirs = append(dirs, filepath.Join(home, ".agents", "skills")) + } + return dirs +} + // acpSessionEntry tracks a live ACP session for one goclaw conversation. type acpSessionEntry struct { id string // ACP session ID returned by session/new or session/load @@ -20,15 +95,35 @@ type acpSessionEntry struct { lastUsed time.Time } +// acpRoutingKey is the private context key for per-call routing values. +type acpRoutingKey struct{} + +// acpRoutingValues holds values extracted from ChatRequest.Options for MCP bridge headers. +type acpRoutingValues struct { + agentID string + userID string + channel string + chatID string + peerKind string + workspace string + tenantID string + localKey string + sessionKey string +} + // ACPProvider implements Provider by orchestrating ACP-compatible agent subprocesses. // One shared Gemini process is used; each goclaw conversation gets its own ACP session. type ACPProvider struct { - name string - pool *acp.ProcessPool - bridge *acp.ToolBridge - defaultModel string - permMode string - poolKey string // key for the shared process in the pool (binary + args) + name string + pool *acp.ProcessPool + bridge *acp.ToolBridge + defaultModel string + permMode string + poolKey string // key for the shared process in the pool (binary + args) + mcpConfigData *MCPConfigData // MCP bridge config (gateway addr, token, lookup) + sessionIdleTTL time.Duration // idle TTL for ACP session reaper + promptTimeout time.Duration // inactivity timeout for Prompt() watchdog + includeDirs []string // candidate dirs appended as --include-directories for gemini acpSessions sync.Map // goclawSessionKey → *acpSessionEntry sessionMu sync.Map // goclawSessionKey → *sync.Mutex (prevents concurrent session creation) @@ -40,51 +135,144 @@ type ACPProvider struct { // ACPOption configures an ACPProvider. type ACPOption func(*ACPProvider) -// WithACPName overrides the provider name (default: "acp"). -func WithACPName(name string) ACPOption { +// All ACP With* options below take a *ACPSettings as a common argument and read +// only the field they configure. Empty / zero values are treated as "no override" +// so callers can build one settings struct and pass it to every option without +// worrying about clobbering defaults set elsewhere. + +// WithACPName overrides the provider name (default: "acp"). Reads s.Name. +func WithACPName(s *ACPSettings) ACPOption { + return func(p *ACPProvider) { + if s == nil || s.Name == "" { + return + } + p.name = s.Name + } +} + +// WithACPModel sets the default model/agent name. Reads s.Model. +func WithACPModel(s *ACPSettings) ACPOption { return func(p *ACPProvider) { - if name != "" { - p.name = name + if s == nil || s.Model == "" { + return } + p.defaultModel = s.Model } } -// WithACPModel sets the default model/agent name. -func WithACPModel(model string) ACPOption { +// WithACPPermMode sets the permission mode for the tool bridge. Reads s.PermMode. +func WithACPPermMode(s *ACPSettings) ACPOption { return func(p *ACPProvider) { - if model != "" { - p.defaultModel = model + if s == nil || s.PermMode == "" { + return } + p.permMode = s.PermMode } } -// WithACPPermMode sets the permission mode for the tool bridge. -func WithACPPermMode(mode string) ACPOption { +// WithACPSessionTTL overrides the idle TTL used by the session reaper. +// Reads s.SessionTTL (duration string). When unset/invalid, NewACPProvider +// falls back to the process pool's idleTTL. +func WithACPSessionTTL(s *ACPSettings) ACPOption { return func(p *ACPProvider) { - if mode != "" { - p.permMode = mode + if s == nil || s.SessionTTL == "" { + return + } + if d, err := time.ParseDuration(s.SessionTTL); err == nil && d > 0 { + p.sessionIdleTTL = d } } } -// NewACPProvider creates a provider that orchestrates ACP agents as subprocesses. -func NewACPProvider(binary string, args []string, workDir string, idleTTL time.Duration, denyPatterns []*regexp.Regexp, opts ...ACPOption) *ACPProvider { - // Pool key identifies the shared process: binary + args combination - poolKey := binary - if len(args) > 0 { - poolKey += "|" + strings.Join(args, " ") +// WithACPPromptTimeout sets the inactivity timeout for Prompt() watchdogs. +// Reads s.PromptTimeout (duration string). When unset/invalid, the +// package-level promptInactivityTimeout default (10 min) applies. +func WithACPPromptTimeout(s *ACPSettings) ACPOption { + return func(p *ACPProvider) { + if s == nil || s.PromptTimeout == "" { + return + } + if d, err := time.ParseDuration(s.PromptTimeout); err == nil && d > 0 { + p.promptTimeout = d + } + } +} + +// WithIncludeDirectories registers candidate directories that should be exposed +// to the agent's filesystem sandbox. The actual binary gating happens in +// NewACPProvider, which only emits `--include-directories ` pairs for +// gemini and stat-filters non-existent entries. Storing the list on the +// provider for non-gemini binaries is harmless (never consumed downstream). +// +// When s.IncludeDirs is empty, falls back to the canonical goclaw skill source +// dirs (skills-store, global skills, personal agent skills) so the typical +// deployment "just works" without admin needing to enumerate paths. +func WithIncludeDirectories(s *ACPSettings) ACPOption { + return func(p *ACPProvider) { + if s == nil { + return + } + dirs := s.IncludeDirs + if len(dirs) == 0 { + dirs = defaultGoclawSkillDirs() + } + p.includeDirs = dirs + } +} + +// WithACPMCPConfigData registers MCP bridge config (gateway address, token, server lookup). +// Reads s.MCPData. Mirrors the Claude CLI pattern: provider builds the MCP server +// list per session using routing values from ChatRequest.Options. +func WithACPMCPConfigData(s *ACPSettings) ACPOption { + return func(p *ACPProvider) { + if s == nil || s.MCPData == nil { + return + } + p.mcpConfigData = s.MCPData } +} +// NewACPProvider creates a provider that orchestrates ACP agents as subprocesses. +func NewACPProvider(binary string, args []string, workDir string, idleTTL time.Duration, denyPatterns []*regexp.Regexp, opts ...ACPOption) *ACPProvider { p := &ACPProvider{ name: "acp", defaultModel: "claude", - poolKey: poolKey, done: make(chan struct{}), } for _, opt := range opts { opt(p) } + // Gemini sandbox needs --include-directories to read goclaw skill paths + // outside the cwd. Non-gemini binaries (claude, codex) handle filesystem + // access differently, so includeDirs is a no-op for them. + if filepath.Base(binary) == "gemini" && len(p.includeDirs) > 0 { + for _, d := range p.includeDirs { + if d == "" { + continue + } + if info, err := os.Stat(d); err == nil && info.IsDir() { + args = append(args, "--include-directories", d) + } + } + } + + // poolKey uniquely identifies a subprocess configuration so that providers + // differing in any of the five dimensions always spawn separate processes. + // permMode is included explicitly; it is no longer injected into CLI args + // because ACP permission/request RPCs are handled entirely by ToolBridge. + p.poolKey = fmt.Sprintf("%s|%s|%s|%s|%s", + binary, + strings.Join(args, " "), + workDir, + idleTTL, + p.permMode, + ) + + if p.sessionIdleTTL == 0 { + p.sessionIdleTTL = idleTTL + } + var bridgeOpts []acp.ToolBridgeOption if len(denyPatterns) > 0 { bridgeOpts = append(bridgeOpts, acp.WithDenyPatterns(denyPatterns)) @@ -96,15 +284,24 @@ func NewACPProvider(binary string, args []string, workDir string, idleTTL time.D p.pool = acp.NewProcessPool(binary, args, workDir, idleTTL) p.pool.SetToolHandler(p.bridge.Handle) + if p.mcpConfigData != nil { + cd := p.mcpConfigData + p.pool.SetMcpServersFunc(func(ctx context.Context) []acp.McpServer { + rv, _ := ctx.Value(acpRoutingKey{}).(acpRoutingValues) + return p.buildACPServers(ctx, cd, rv) + }) + } + if p.promptTimeout > 0 { + p.pool.SetPromptTimeout(p.promptTimeout) + } go p.sessionReaper() return p } -// sessionReaper removes ACP sessions idle for more than 30 minutes. +// sessionReaper removes ACP sessions idle for more than sessionIdleTTL. // Sends session/cancel to release resources on the agent side before purging locally. func (p *ACPProvider) sessionReaper() { - const sessionIdleTTL = 30 * time.Minute ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { @@ -112,12 +309,13 @@ func (p *ACPProvider) sessionReaper() { case <-ticker.C: p.acpSessions.Range(func(key, value any) bool { entry := value.(*acpSessionEntry) - if time.Since(entry.lastUsed) > sessionIdleTTL { - slog.Info("acp: expiring idle session", "goclaw_session", key, "sid", entry.id) + if time.Since(entry.lastUsed) > p.sessionIdleTTL { + slog.Info("acp: expiring idle session", "goclaw_session", key, "sid", entry.id, "ttl", p.sessionIdleTTL) if entry.proc != nil { _ = entry.proc.Cancel(entry.id) } p.acpSessions.Delete(key) + p.sessionMu.Delete(key) } return true }) @@ -127,10 +325,59 @@ func (p *ACPProvider) sessionReaper() { } } +// ensureSessionDir creates and returns a per-goclaw-session workspace under +// the process pool's base work directory. Mirrors the claude_cli provider's +// ensureWorkDir pattern so acp-workspaces layout matches cli-workspaces: +// +// /agent--ws-direct-/ +// +// Falls back to the pool's workDir (shared) if the base is unset or MkdirAll +// fails — safer than /tmp since the caller passes Authorization-protected +// paths to the ACP agent. +func (p *ACPProvider) ensureSessionDir(proc *acp.ACPProcess, goclawKey string) string { + base := proc.WorkDir() + if base == "" { + return "" + } + safe := sanitizePathSegment(goclawKey) + if safe == "" { + return base + } + dir := filepath.Join(base, safe) + if err := os.MkdirAll(dir, 0o755); err != nil { + slog.Warn("acp: failed to create per-session workspace, using pool default", + "goclaw_session", goclawKey, "dir", dir, "error", err) + return base + } + return dir +} + +// writeGeminiMD writes the system prompt to GEMINI.md in the session workspace. +// Gemini CLI reads this file automatically from the session cwd (mirrors writeClaudeMD). +// Skips write if content is unchanged. Returns true if the file was rewritten, +// signalling the caller to invalidate the live ACP session so the next request +// starts a fresh session with the updated instructions. +func (p *ACPProvider) writeGeminiMD(sessionDir, systemPrompt string) bool { + if sessionDir == "" || systemPrompt == "" { + return false + } + path := filepath.Join(sessionDir, "GEMINI.md") + if existing, err := os.ReadFile(path); err == nil && string(existing) == systemPrompt { + return false + } + if err := os.WriteFile(path, []byte(systemPrompt), 0600); err != nil { + slog.Warn("acp: failed to write GEMINI.md", "path", path, "error", err) + return false + } + return true +} + // resolveSession returns the ACP session ID for a goclaw session key. -// It creates a new session if none exists, or reloads it after a process respawn. +// sessionDir is the pre-computed per-session workspace (caller must ensure it exists). +// Returns isNew=true only when a brand-new session is created via session/new — +// callers use this to inject full conversation history into the first prompt. // A per-key mutex prevents concurrent creation races for the same session. -func (p *ACPProvider) resolveSession(ctx context.Context, proc *acp.ACPProcess, goclawKey string) (string, error) { +func (p *ACPProvider) resolveSession(ctx context.Context, proc *acp.ACPProcess, sessionDir, goclawKey string) (sid string, isNew bool, err error) { actual, _ := p.sessionMu.LoadOrStore(goclawKey, &sync.Mutex{}) mu := actual.(*sync.Mutex) mu.Lock() @@ -141,29 +388,29 @@ func (p *ACPProvider) resolveSession(ctx context.Context, proc *acp.ACPProcess, if entry.proc == proc { // Same process instance: session is still live, just update last-used entry.lastUsed = time.Now() - return entry.id, nil + return entry.id, false, nil } // Process was respawned — try to restore the session slog.Info("acp: process respawned, attempting session restore", "goclaw_session", goclawKey, "old_sid", entry.id) if proc.AgentCaps().LoadSession { - sid, err := proc.LoadSession(ctx, entry.id) + sid, err := proc.LoadSession(ctx, entry.id, sessionDir) if err == nil { p.acpSessions.Store(goclawKey, &acpSessionEntry{id: sid, proc: proc, lastUsed: time.Now()}) - return sid, nil + return sid, false, nil } slog.Warn("acp: session/load failed, creating new session", "old_sid", entry.id, "error", err) } // session/load not supported or failed — fall through to create new } - slog.Info("acp: creating new session", "goclaw_session", goclawKey, "pool_key", p.poolKey) - sid, err := proc.NewSession(ctx) + slog.Info("acp: creating new session", "goclaw_session", goclawKey, "pool_key", p.poolKey, "cwd", sessionDir) + sid, err = proc.NewSession(ctx, sessionDir) if err != nil { - return "", err + return "", false, err } p.acpSessions.Store(goclawKey, &acpSessionEntry{id: sid, proc: proc, lastUsed: time.Now()}) - return sid, nil + return sid, true, nil } func (p *ACPProvider) Name() string { return p.name } @@ -183,8 +430,118 @@ func (p *ACPProvider) Capabilities() ProviderCapabilities { } } +// injectRoutingFromOpts stores all MCP bridge routing values from ChatRequest.Options +// into ctx. Mirrors Claude CLI's bridgeContextFromOpts pattern: the pipeline sets +// all Opt* values in loop_pipeline_callbacks.go so they are always available here. +func injectRoutingFromOpts(ctx context.Context, opts map[string]any) context.Context { + return context.WithValue(ctx, acpRoutingKey{}, acpRoutingValues{ + agentID: extractStringOpt(opts, OptAgentID), + userID: extractStringOpt(opts, OptUserID), + channel: extractStringOpt(opts, OptChannel), + chatID: extractStringOpt(opts, OptChatID), + peerKind: extractStringOpt(opts, OptPeerKind), + workspace: extractStringOpt(opts, OptWorkspace), + tenantID: extractStringOpt(opts, OptTenantID), + localKey: extractStringOpt(opts, OptLocalKey), + sessionKey: extractStringOpt(opts, OptSessionKey), + }) +} + +// buildACPServers constructs the []acp.McpServer list for session/new. +// Mirrors buildACPMcpServersFunc but lives inside the provider so it has +// access to all routing values from ChatRequest.Options via context. +func (p *ACPProvider) buildACPServers(ctx context.Context, cd *MCPConfigData, rv acpRoutingValues) []acp.McpServer { + if cd == nil || cd.GatewayAddr == "" { + return nil + } + safe := func(v string) bool { return !strings.ContainsAny(v, "\r\n\x00") } + bridgeURL := fmt.Sprintf("http://%s/mcp/bridge", cd.GatewayAddr) + + headers := []acp.McpServerKV{} + if cd.GatewayToken != "" { + headers = append(headers, acp.McpServerKV{Name: "Authorization", Value: "Bearer " + cd.GatewayToken}) + } + if rv.agentID != "" && safe(rv.agentID) { + headers = append(headers, acp.McpServerKV{Name: "X-Agent-ID", Value: rv.agentID}) + } + if rv.userID != "" && safe(rv.userID) { + headers = append(headers, acp.McpServerKV{Name: "X-User-ID", Value: rv.userID}) + } + if rv.channel != "" && safe(rv.channel) { + headers = append(headers, acp.McpServerKV{Name: "X-Channel", Value: rv.channel}) + } + if rv.chatID != "" && safe(rv.chatID) { + headers = append(headers, acp.McpServerKV{Name: "X-Chat-ID", Value: rv.chatID}) + } + if rv.peerKind != "" && safe(rv.peerKind) { + headers = append(headers, acp.McpServerKV{Name: "X-Peer-Kind", Value: rv.peerKind}) + } + if rv.workspace != "" && safe(rv.workspace) { + headers = append(headers, acp.McpServerKV{Name: "X-Workspace", Value: rv.workspace}) + } + if rv.tenantID != "" && safe(rv.tenantID) { + headers = append(headers, acp.McpServerKV{Name: "X-Tenant-ID", Value: rv.tenantID}) + } + if rv.localKey != "" && safe(rv.localKey) { + headers = append(headers, acp.McpServerKV{Name: "X-Local-Key", Value: rv.localKey}) + } + if rv.sessionKey != "" && safe(rv.sessionKey) { + headers = append(headers, acp.McpServerKV{Name: "X-Session-Key", Value: rv.sessionKey}) + } + if cd.GatewayToken != "" && (rv.agentID != "" || rv.userID != "") { + sig := SignBridgeContext(cd.GatewayToken, rv.agentID, rv.userID, rv.channel, rv.chatID, rv.peerKind, rv.workspace, rv.tenantID, rv.localKey, rv.sessionKey) + headers = append(headers, acp.McpServerKV{Name: "X-Bridge-Sig", Value: sig}) + } + + servers := []acp.McpServer{acp.McpServerHTTP{ + Type: "http", + Name: "goclaw-bridge", + URL: bridgeURL, + Headers: headers, + }} + + if cd.AgentMCPLookup != nil && rv.agentID != "" { + for _, entry := range cd.AgentMCPLookup(ctx, rv.agentID) { + servers = append(servers, acpServerEntryToMCP(entry)) + } + } + return servers +} + +// acpServerEntryToMCP converts an MCPServerEntry to the ACP schema. +func acpServerEntryToMCP(e MCPServerEntry) acp.McpServer { + if e.Transport == "stdio" { + env := make([]acp.McpServerKV, 0, len(e.Env)) + for k, v := range e.Env { + env = append(env, acp.McpServerKV{Name: k, Value: v}) + } + args := e.Args + if args == nil { + args = []string{} + } + return acp.McpServerStdio{ + Type: "stdio", + Name: e.Name, + Command: e.Command, + Args: args, + Env: env, + } + } + headers := make([]acp.McpServerKV, 0, len(e.Headers)) + for k, v := range e.Headers { + headers = append(headers, acp.McpServerKV{Name: k, Value: v}) + } + return acp.McpServerHTTP{ + Type: "http", + Name: e.Name, + URL: e.URL, + Headers: headers, + } +} + // Chat sends a prompt and returns the complete response (non-streaming). func (p *ACPProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + ctx = injectRoutingFromOpts(ctx, req.Options) sessionKey := extractStringOpt(req.Options, OptSessionKey) if sessionKey == "" { sessionKey = fmt.Sprintf("temp-%d", time.Now().UnixNano()) @@ -195,7 +552,15 @@ func (p *ACPProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, return nil, fmt.Errorf("acp: spawn failed: %w", err) } - acpSessionID, err := p.resolveSession(ctx, proc, sessionKey) + sessionDir := p.ensureSessionDir(proc, sessionKey) + systemPrompt, _, _ := extractFromMessages(req.Messages) + if p.writeGeminiMD(sessionDir, systemPrompt) { + // System prompt changed — invalidate live session so next resolveSession + // creates a fresh one that loads the updated GEMINI.md. + p.acpSessions.Delete(sessionKey) + } + + acpSessionID, isNew, err := p.resolveSession(ctx, proc, sessionDir, sessionKey) if err != nil { return nil, err } @@ -203,7 +568,7 @@ func (p *ACPProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, defer p.purgeSession(sessionKey) } - content := extractACPContent(req) + content := extractACPContent(req, isNew) if len(content) == 0 { return nil, fmt.Errorf("acp: no user message in request") } @@ -212,7 +577,10 @@ func (p *ACPProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, var buf strings.Builder var updateCount int - promptResp, err := proc.Prompt(ctx, acpSessionID, content, func(update acp.SessionUpdate) { + cb := func(update acp.SessionUpdate) { + if update.ToolCall != nil { + slog.Info("acp: tool call (chat)", "name", update.ToolCall.Name, "status", update.ToolCall.Status, "id", update.ToolCall.ID) + } if update.Message != nil { for _, block := range update.Message.Content { if block.Type == "text" { @@ -221,7 +589,20 @@ func (p *ACPProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, } } } - }) + } + + const maxACPRetry = 2 + var promptResp *acp.PromptResponse + for attempt := range maxACPRetry + 1 { + buf.Reset() + updateCount = 0 + promptResp, err = proc.Prompt(ctx, acpSessionID, content, cb) + if err == nil || !isMalformedFunctionCall(err) { + break + } + slog.Warn("acp: malformed function call, retrying", "attempt", attempt+1, "session", sessionKey, "sid", acpSessionID) + } + if err != nil { slog.Error("acp: chat error", "session", sessionKey, "sid", acpSessionID, "error", err) return &ChatResponse{ @@ -230,17 +611,32 @@ func (p *ACPProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, }, err } + if promptResp != nil && promptResp.StopReason == "cancelled" { + slog.Warn("acp: chat cancelled", "session", sessionKey, "sid", acpSessionID, "updates", updateCount) + errMsg := "[요청 취소] 응답 대기 중 타임아웃으로 취소됨" + if buf.Len() > 0 { + errMsg = buf.String() + "\n\n" + errMsg + } + return &ChatResponse{Content: errMsg, FinishReason: "stop"}, nil + } + + outputText := buf.String() slog.Info("acp: chat completed", "session", sessionKey, "sid", acpSessionID, - "stopReason", mapStopReason(promptResp), "updates", updateCount, "contentLen", buf.Len()) + "stopReason", mapStopReason(promptResp), "updates", updateCount, "contentLen", len(outputText)) return &ChatResponse{ - Content: buf.String(), + Content: outputText, FinishReason: mapStopReason(promptResp), - Usage: &Usage{}, + Usage: &Usage{ + PromptTokens: acpInputTokens(req.Messages), + CompletionTokens: acpEstimateTokens(outputText), + TotalTokens: acpInputTokens(req.Messages) + acpEstimateTokens(outputText), + }, }, nil } // ChatStream sends a prompt and streams response chunks via onChunk callback. func (p *ACPProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + ctx = injectRoutingFromOpts(ctx, req.Options) sessionKey := extractStringOpt(req.Options, OptSessionKey) if sessionKey == "" { sessionKey = fmt.Sprintf("temp-%d", time.Now().UnixNano()) @@ -251,7 +647,13 @@ func (p *ACPProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk f return nil, fmt.Errorf("acp: spawn failed: %w", err) } - acpSessionID, err := p.resolveSession(ctx, proc, sessionKey) + sessionDir := p.ensureSessionDir(proc, sessionKey) + systemPrompt, _, _ := extractFromMessages(req.Messages) + if p.writeGeminiMD(sessionDir, systemPrompt) { + p.acpSessions.Delete(sessionKey) + } + + acpSessionID, isNew, err := p.resolveSession(ctx, proc, sessionDir, sessionKey) if err != nil { return nil, err } @@ -259,7 +661,7 @@ func (p *ACPProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk f defer p.purgeSession(sessionKey) } - content := extractACPContent(req) + content := extractACPContent(req, isNew) if len(content) == 0 { return nil, fmt.Errorf("acp: no user message in request") } @@ -282,7 +684,7 @@ func (p *ACPProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk f var buf strings.Builder var updateCount int - promptResp, err := proc.Prompt(ctx, acpSessionID, content, func(update acp.SessionUpdate) { + streamCb := func(update acp.SessionUpdate) { if update.Message != nil { for _, block := range update.Message.Content { if block.Type == "text" { @@ -292,10 +694,21 @@ func (p *ACPProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk f } } } - if update.ToolCall != nil && update.ToolCall.Status == "running" { - slog.Debug("acp: tool call", "name", update.ToolCall.Name) + if update.ToolCall != nil { + slog.Info("acp: tool call (stream)", "name", update.ToolCall.Name, "status", update.ToolCall.Status, "id", update.ToolCall.ID) } - }) + } + + const maxACPRetry = 2 + var promptResp *acp.PromptResponse + for attempt := range maxACPRetry + 1 { + promptResp, err = proc.Prompt(ctx, acpSessionID, content, streamCb) + if err == nil || !isMalformedFunctionCall(err) { + break + } + slog.Warn("acp: malformed function call, retrying", "attempt", attempt+1, "session", sessionKey, "sid", acpSessionID) + } + if err != nil { slog.Error("acp: chat error", "session", sessionKey, "sid", acpSessionID, "error", err) return &ChatResponse{ @@ -304,14 +717,31 @@ func (p *ACPProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk f }, err } + if promptResp != nil && promptResp.StopReason == "cancelled" { + slog.Warn("acp: chat stream cancelled", "session", sessionKey, "sid", acpSessionID, "updates", updateCount) + errMsg := "[요청 취소] 응답 대기 중 타임아웃으로 취소됨" + prefix := "\n\n" + if buf.Len() == 0 { + prefix = "" + } + onChunk(StreamChunk{Content: prefix + errMsg}) + onChunk(StreamChunk{Done: true}) + return &ChatResponse{Content: buf.String() + prefix + errMsg, FinishReason: "stop"}, nil + } + onChunk(StreamChunk{Done: true}) + outputText := buf.String() slog.Info("acp: chat stream completed", "session", sessionKey, "sid", acpSessionID, - "stopReason", mapStopReason(promptResp), "updates", updateCount, "contentLen", buf.Len()) + "stopReason", mapStopReason(promptResp), "updates", updateCount, "contentLen", len(outputText)) return &ChatResponse{ - Content: buf.String(), + Content: outputText, FinishReason: mapStopReason(promptResp), - Usage: &Usage{}, + Usage: &Usage{ + PromptTokens: acpInputTokens(req.Messages), + CompletionTokens: acpEstimateTokens(outputText), + TotalTokens: acpInputTokens(req.Messages) + acpEstimateTokens(outputText), + }, }, nil } @@ -339,31 +769,85 @@ func (p *ACPProvider) Close() error { return p.pool.Close() } -// extractACPContent extracts user message + images from ChatRequest into ACP ContentBlocks. -func extractACPContent(req ChatRequest) []acp.ContentBlock { - systemPrompt, userMsg, images := extractFromMessages(req.Messages) - if userMsg == "" { - return nil - } +// acpAllowedMIME is the set of image MIME types accepted by ACP providers. +var acpAllowedMIME = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/webp": true, + "image/gif": true, +} - var blocks []acp.ContentBlock +// acpMaxImageBytes is the maximum decoded image size accepted (5 MB). +const acpMaxImageBytes = 5 * 1024 * 1024 - // Prepend system prompt to user message (ACP agents have no separate system prompt API) - text := userMsg - if systemPrompt != "" { - text = systemPrompt + "\n\n" + userMsg +// appendACPImages appends validated image ContentBlocks to blocks. +func appendACPImages(blocks []acp.ContentBlock, images []ImageContent) []acp.ContentBlock { + for _, img := range images { + if !acpAllowedMIME[img.MimeType] { + slog.Warn("acp: unsupported image MIME type, skipping", "mime", img.MimeType) + continue + } + if len(img.Data)*3/4 > acpMaxImageBytes { + slog.Warn("acp: image too large, skipping", "estimatedBytes", len(img.Data)*3/4, "limit", acpMaxImageBytes) + continue + } + blocks = append(blocks, acp.ContentBlock{Type: "image", Data: img.Data, MimeType: img.MimeType}) } - blocks = append(blocks, acp.ContentBlock{Type: "text", Text: text}) + return blocks +} - for _, img := range images { - blocks = append(blocks, acp.ContentBlock{ - Type: "image", - Data: img.Data, - MimeType: img.MimeType, - }) +// extractACPContent builds ACP ContentBlocks from a ChatRequest. +// +// isNew=false (normal turn): GEMINI.md in the session workspace already provides +// the system prompt, so only the current user message is sent. This avoids +// repeating the (often large) system prompt on every turn. +// +// isNew=true (fresh or reset session): the session has no prior context. +// All non-system messages from req.Messages are serialised as a conversation +// transcript so that compacted summaries and recent history are preserved. +// The system prompt is omitted here because writeGeminiMD wrote it to GEMINI.md +// before the session was created. +func extractACPContent(req ChatRequest, isNew bool) []acp.ContentBlock { + msgs := req.Messages + + if !isNew { + // Normal turn: send only the current user message. + _, userMsg, images := extractFromMessages(msgs) + if userMsg == "" { + return nil + } + blocks := []acp.ContentBlock{{Type: "text", Text: userMsg}} + return appendACPImages(blocks, images) } - return blocks + // New session: serialise full conversation context (summary + history + current). + // System prompt is excluded — GEMINI.md handles it. + var sb strings.Builder + var images []ImageContent + for i, m := range msgs { + switch m.Role { + case "system": + continue + case "user": + if i == len(msgs)-1 { + images = m.Images // collect images from last (current) user message + } + sb.WriteString("[User]\n") + sb.WriteString(m.Content) + sb.WriteString("\n\n") + case "assistant": + sb.WriteString("[Assistant]\n") + sb.WriteString(m.Content) + sb.WriteString("\n\n") + } + } + + text := strings.TrimRight(sb.String(), "\n") + if text == "" { + return nil + } + blocks := []acp.ContentBlock{{Type: "text", Text: text}} + return appendACPImages(blocks, images) } // mapStopReason converts ACP stopReason to GoClaw finish reason. @@ -374,9 +858,35 @@ func mapStopReason(resp *acp.PromptResponse) string { switch resp.StopReason { case "max_tokens", "maxContextLength": return "length" - case "cancelled": - return "stop" - default: + case "tool_use": + return "tool_calls" + case "error": + return "error" + default: // end_turn, stop_sequence, cancelled, "" return "stop" } } + +// isMalformedFunctionCall returns true when err indicates Gemini produced an +// invalid tool call JSON — a transient model glitch worth retrying. +func isMalformedFunctionCall(err error) bool { + return err != nil && strings.Contains(err.Error(), "malformed function call") +} + +// acpEstimateTokens returns a rough token count from character count (chars/4). +func acpEstimateTokens(s string) int { + n := len(s) / 4 + if n < 1 && len(s) > 0 { + return 1 + } + return n +} + +// acpInputTokens estimates input token count from all messages. +func acpInputTokens(msgs []Message) int { + var total int + for _, m := range msgs { + total += acpEstimateTokens(m.Content) + } + return total +} diff --git a/internal/providers/acp_provider_test.go b/internal/providers/acp_provider_test.go new file mode 100644 index 0000000000..5507ff7a96 --- /dev/null +++ b/internal/providers/acp_provider_test.go @@ -0,0 +1,161 @@ +package providers + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestExtractACPContent_NormalTurn verifies that isNew=false sends only the +// current user message without system prompt prepend. +func TestExtractACPContent_NormalTurn(t *testing.T) { + req := ChatRequest{ + Messages: []Message{ + {Role: "system", Content: "You are Ender."}, + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi there"}, + {Role: "user", Content: "current question"}, + }, + } + blocks := extractACPContent(req, false) + if len(blocks) != 1 { + t.Fatalf("want 1 block, got %d", len(blocks)) + } + if blocks[0].Text != "current question" { + t.Errorf("want only current user message, got: %q", blocks[0].Text) + } + if strings.Contains(blocks[0].Text, "You are Ender") { + t.Error("system prompt must not appear in normal-turn content") + } +} + +// TestExtractACPContent_NewSession_WithHistory verifies that isNew=true serialises +// the full conversation (summary + history + current) excluding the system prompt. +func TestExtractACPContent_NewSession_WithHistory(t *testing.T) { + req := ChatRequest{ + Messages: []Message{ + {Role: "system", Content: "You are Ender."}, + {Role: "user", Content: "[Previous conversation summary]\nDiscussed KIS API setup."}, + {Role: "assistant", Content: "I understand the context from our previous conversation. How can I help you?"}, + {Role: "user", Content: "turn1 user"}, + {Role: "assistant", Content: "turn1 asst"}, + {Role: "user", Content: "current question"}, + }, + } + blocks := extractACPContent(req, true) + if len(blocks) != 1 { + t.Fatalf("want 1 block, got %d", len(blocks)) + } + text := blocks[0].Text + + // system must be excluded + if strings.Contains(text, "You are Ender") { + t.Error("system prompt must not appear in new-session transcript") + } + // summary must be present + if !strings.Contains(text, "Previous conversation summary") { + t.Error("episodic summary must be included in new-session transcript") + } + // history must be present + if !strings.Contains(text, "turn1 user") || !strings.Contains(text, "turn1 asst") { + t.Error("conversation history must be included in new-session transcript") + } + // current message must be present + if !strings.Contains(text, "current question") { + t.Error("current user message must be included in new-session transcript") + } + // role markers + if !strings.Contains(text, "[User]") || !strings.Contains(text, "[Assistant]") { + t.Error("role markers [User]/[Assistant] must be present") + } +} + +// TestExtractACPContent_NewSession_FirstEver verifies isNew=true with no prior +// history (very first message) behaves correctly and still includes current message. +func TestExtractACPContent_NewSession_FirstEver(t *testing.T) { + req := ChatRequest{ + Messages: []Message{ + {Role: "system", Content: "You are Ender."}, + {Role: "user", Content: "first ever message"}, + }, + } + blocks := extractACPContent(req, true) + if len(blocks) != 1 { + t.Fatalf("want 1 block, got %d", len(blocks)) + } + if !strings.Contains(blocks[0].Text, "first ever message") { + t.Errorf("current message must be present, got: %q", blocks[0].Text) + } + if strings.Contains(blocks[0].Text, "You are Ender") { + t.Error("system prompt must not appear even on first-ever message") + } +} + +// TestExtractACPContent_NoUserMessage verifies that an empty request returns nil. +func TestExtractACPContent_NoUserMessage(t *testing.T) { + req := ChatRequest{ + Messages: []Message{ + {Role: "system", Content: "You are Ender."}, + }, + } + if got := extractACPContent(req, false); got != nil { + t.Errorf("want nil for missing user message, got %v", got) + } + if got := extractACPContent(req, true); got != nil { + t.Errorf("want nil for missing user message (isNew), got %v", got) + } +} + +// TestWriteGeminiMD_WritesFile verifies the file is written and true is returned. +func TestWriteGeminiMD_WritesFile(t *testing.T) { + dir := t.TempDir() + p := &ACPProvider{} + + changed := p.writeGeminiMD(dir, "system prompt content") + if !changed { + t.Fatal("want changed=true for new file") + } + data, err := os.ReadFile(filepath.Join(dir, "GEMINI.md")) + if err != nil { + t.Fatalf("GEMINI.md not created: %v", err) + } + if string(data) != "system prompt content" { + t.Errorf("unexpected content: %q", string(data)) + } +} + +// TestWriteGeminiMD_NoopIfUnchanged verifies no write and false return when unchanged. +func TestWriteGeminiMD_NoopIfUnchanged(t *testing.T) { + dir := t.TempDir() + p := &ACPProvider{} + + p.writeGeminiMD(dir, "same content") + path := filepath.Join(dir, "GEMINI.md") + info1, _ := os.Stat(path) + + changed := p.writeGeminiMD(dir, "same content") + if changed { + t.Fatal("want changed=false when content is identical") + } + info2, _ := os.Stat(path) + if info1.ModTime() != info2.ModTime() { + t.Error("file must not be rewritten when content is unchanged") + } +} + +// TestWriteGeminiMD_UpdatesOnChange verifies file is rewritten and true returned when content changes. +func TestWriteGeminiMD_UpdatesOnChange(t *testing.T) { + dir := t.TempDir() + p := &ACPProvider{} + + p.writeGeminiMD(dir, "old system prompt") + changed := p.writeGeminiMD(dir, "new system prompt") + if !changed { + t.Fatal("want changed=true when content differs") + } + data, _ := os.ReadFile(filepath.Join(dir, "GEMINI.md")) + if string(data) != "new system prompt" { + t.Errorf("expected updated content, got: %q", string(data)) + } +} diff --git a/internal/store/pg/skills_admin.go b/internal/store/pg/skills_admin.go index 425204fdbc..dba7331abf 100644 --- a/internal/store/pg/skills_admin.go +++ b/internal/store/pg/skills_admin.go @@ -35,11 +35,15 @@ func (s *PGSkillStore) UpsertSystemSkill(ctx context.Context, p store.SkillCreat ) return existingID, false, existingFilePath, nil } - // Hash genuinely changed — full update with new version + // Hash genuinely changed — full update with new version. + // is_system is deliberately NOT touched: it is a policy flag (write- + // protected + cross-tenant) set by an operator, not a property derived + // from "this skill came from the seeder source". Re-seeding after a + // SKILL.md edit must preserve whatever is_system value the operator chose. fmJSON := marshalFrontmatter(p.Frontmatter) _, err = s.db.ExecContext(ctx, `UPDATE skills SET name = $1, description = $2, version = $3, frontmatter = $4, - file_path = $5, file_size = $6, file_hash = $7, is_system = true, + file_path = $5, file_size = $6, file_hash = $7, visibility = 'public', status = $8, updated_at = NOW() WHERE id = $9`, p.Name, p.Description, p.Version, fmJSON, @@ -52,13 +56,16 @@ func (s *PGSkillStore) UpsertSystemSkill(ctx context.Context, p store.SkillCreat return existingID, true, p.FilePath, nil } - // New skill — insert + // New skill — insert. is_system defaults to false: the seeder path should + // not unilaterally grant write-protected + cross-tenant status. Operators + // promote a skill to is_system=true explicitly (UI or direct SQL) when + // they want it shared across tenants and locked from edit. id := store.GenNewID() fmJSON := marshalFrontmatter(p.Frontmatter) _, err = s.db.ExecContext(ctx, `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, is_system, frontmatter, file_path, file_size, file_hash, tenant_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, 'system', 'public', $5, $6, true, $7, $8, $9, $10, $11, NOW(), NOW())`, + VALUES ($1, $2, $3, $4, 'system', 'public', $5, $6, false, $7, $8, $9, $10, $11, NOW(), NOW())`, id, p.Name, p.Slug, p.Description, p.Version, p.Status, fmJSON, p.FilePath, p.FileSize, p.FileHash, store.MasterTenantID, ) diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index ea7b04353b..90c67739e5 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -394,6 +394,7 @@ func resolvePathWithAllowed(path, workspace string, restrict bool, allowedPrefix return real, nil } } + slog.Warn("security.path_escape", "path", cleaned, "resolved", cleaned, "workspace", workspace) slog.Warn("read_file: access denied", "path", cleaned, "workspace", workspace, "allowedPrefixes", allowedPrefixes) return "", err } @@ -515,7 +516,7 @@ func resolvePath(path, workspace string, restrict bool) (string, error) { // Validate canonical path stays within canonical workspace. if !isPathInside(real, wsReal) { - slog.Warn("security.path_escape", "path", path, "resolved", real, "workspace", wsReal) + slog.Debug("security.path_escape", "path", path, "resolved", real, "workspace", wsReal) return "", fmt.Errorf("access denied: path outside workspace") } diff --git a/internal/webui/handler.go b/internal/webui/handler.go index 1d9ce02767..4516ce9a3c 100644 --- a/internal/webui/handler.go +++ b/internal/webui/handler.go @@ -46,6 +46,10 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Static assets: set long cache for /assets/* (Vite hashed filenames). if strings.HasPrefix(r.URL.Path, "/assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + // index.html and other root files must never be cached so the + // browser always gets the latest asset manifest after a deploy. + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") } h.fileServer.ServeHTTP(w, r) return diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index 25d6e4147e..24caf72f0a 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -83,6 +83,46 @@ import viV3Capabilities from "./locales/vi/v3-capabilities.json"; import viBackup from "./locales/vi/backup.json"; import viHooks from "./locales/vi/hooks.json"; +// --- KO namespaces --- +import koCommon from "./locales/ko/common.json"; +import koSidebar from "./locales/ko/sidebar.json"; +import koTopbar from "./locales/ko/topbar.json"; +import koLogin from "./locales/ko/login.json"; +import koOverview from "./locales/ko/overview.json"; +import koChat from "./locales/ko/chat.json"; +import koAgents from "./locales/ko/agents.json"; +import koTeams from "./locales/ko/teams.json"; +import koSessions from "./locales/ko/sessions.json"; +import koSkills from "./locales/ko/skills.json"; +import koCron from "./locales/ko/cron.json"; +import koConfig from "./locales/ko/config.json"; +import koChannels from "./locales/ko/channels.json"; +import koProviders from "./locales/ko/providers.json"; +import koTraces from "./locales/ko/traces.json"; +import koEvents from "./locales/ko/events.json"; +import koUsage from "./locales/ko/usage.json"; +import koApprovals from "./locales/ko/approvals.json"; +import koNodes from "./locales/ko/nodes.json"; +import koLogs from "./locales/ko/logs.json"; +import koTools from "./locales/ko/tools.json"; +import koMcp from "./locales/ko/mcp.json"; +import koTts from "./locales/ko/tts.json"; +import koSetup from "./locales/ko/setup.json"; +import koMemory from "./locales/ko/memory.json"; +import koVault from "./locales/ko/vault.json"; +import koStorage from "./locales/ko/storage.json"; +import koPendingMessages from "./locales/ko/pending-messages.json"; +import koContacts from "./locales/ko/contacts.json"; +import koActivity from "./locales/ko/activity.json"; +import koApiKeys from "./locales/ko/api-keys.json"; +import koCliCredentials from "./locales/ko/cli-credentials.json"; +import koPackages from "./locales/ko/packages.json"; +import koTenants from "./locales/ko/tenants.json"; +import koSystemSettings from "./locales/ko/system-settings.json"; +import koImportExport from "./locales/ko/import-export.json"; +import koV3Capabilities from "./locales/ko/v3-capabilities.json"; +import koBackup from "./locales/ko/backup.json"; + // --- ZH namespaces --- import zhCommon from "./locales/zh/common.json"; import zhSidebar from "./locales/zh/sidebar.json"; @@ -128,10 +168,11 @@ const STORAGE_KEY = "goclaw:language"; function getInitialLanguage(): string { const stored = localStorage.getItem(STORAGE_KEY); - if (stored === "en" || stored === "vi" || stored === "zh") return stored; + if (stored === "en" || stored === "vi" || stored === "zh" || stored === "ko") return stored; const lang = navigator.language.toLowerCase(); if (lang.startsWith("vi")) return "vi"; if (lang.startsWith("zh")) return "zh"; + if (lang.startsWith("ko")) return "ko"; return "en"; } @@ -206,6 +247,24 @@ i18n.use(initReactI18next).init({ backup: zhBackup, hooks: zhHooks, }, + ko: { + common: koCommon, sidebar: koSidebar, topbar: koTopbar, login: koLogin, + overview: koOverview, chat: koChat, agents: koAgents, teams: koTeams, + sessions: koSessions, skills: koSkills, cron: koCron, config: koConfig, + channels: koChannels, providers: koProviders, traces: koTraces, + events: koEvents, usage: koUsage, + approvals: koApprovals, nodes: koNodes, logs: koLogs, tools: koTools, + mcp: koMcp, tts: koTts, setup: koSetup, memory: koMemory, vault: koVault, storage: koStorage, + "pending-messages": koPendingMessages, + contacts: koContacts, activity: koActivity, "api-keys": koApiKeys, + "cli-credentials": koCliCredentials, + packages: koPackages, + tenants: koTenants, + "system-settings": koSystemSettings, + "import-export": koImportExport, + "v3-capabilities": koV3Capabilities, + backup: koBackup, + }, }, ns: [...ns], defaultNS: "common", diff --git a/ui/web/src/i18n/locales/en/topbar.json b/ui/web/src/i18n/locales/en/topbar.json index 2616990a89..a0c84d4b96 100644 --- a/ui/web/src/i18n/locales/en/topbar.json +++ b/ui/web/src/i18n/locales/en/topbar.json @@ -29,6 +29,7 @@ "languages": { "en": "English", "vi": "Tiếng Việt", - "zh": "中文" + "zh": "中文", + "ko": "한국어" } } diff --git a/ui/web/src/i18n/locales/ko/activity.json b/ui/web/src/i18n/locales/ko/activity.json new file mode 100644 index 0000000000..ce9178db01 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/activity.json @@ -0,0 +1,26 @@ +{ + "title": "활동 로그", + "description": "관리자 및 설정 변경의 감사 추적.", + "filters": { + "action": "작업", + "allActions": "모든 작업", + "agentCreated": "에이전트 생성됨", + "agentUpdated": "에이전트 업데이트됨", + "agentDeleted": "에이전트 삭제됨", + "entityType": "엔티티 유형", + "allEntities": "모든 엔티티", + "agent": "에이전트" + }, + "table": { + "action": "작업", + "actor": "실행자", + "entity": "엔티티", + "entityId": "엔티티 ID", + "ip": "IP 주소", + "time": "시간" + }, + "empty": { + "title": "활동 로그 없음", + "description": "관리자 작업이 수행되면 여기에 활동 로그가 나타납니다." + } +} diff --git a/ui/web/src/i18n/locales/ko/agents.json b/ui/web/src/i18n/locales/ko/agents.json new file mode 100644 index 0000000000..7d0152f513 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/agents.json @@ -0,0 +1,1076 @@ +{ + "title": "에이전트", + "description": "AI 에이전트 관리", + "createAgent": "에이전트 만들기", + "searchPlaceholder": "에이전트 검색...", + "emptyTitle": "아직 에이전트 없음", + "emptyDescription": "첫 번째 에이전트를 만들어 시작하세요.", + "noMatchTitle": "일치하는 에이전트 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "viewCard": "카드 보기", + "viewList": "목록 보기", + "allCreators": "모든 생성자", + "presets": { + "foxSpirit": { + "label": "🦊 여우 정령", + "prompt": "이름: 리틀 폭스. 장난꾸러기 여우 정령 — 모든 것을 잘하지만 장난치기를 좋아합니다.\n성격: 재치 있고, 경쾌하고, 장난스럽지만 항상 진심입니다. 가까운 친구처럼 편하게 말합니다. 장난스러운 표현, 이모지, 가벼운 빈정거림을 사용합니다. 따뜻하고 약간 매력적인 에너지가 있지만 선을 알고 있습니다.\n\n목적: 다재다능한 개인 비서. 태스크가 주어지면 정확하고 효율적으로 실행합니다.\n태스크 사이에는 장난스러운 말, 재미있는 관찰, 예상치 못한 논평을 섞습니다.\n사용자를 진심으로 걱정합니다 — 물 마시기, 휴식 취하기를 상기시키고, 기분을 묻습니다. 작은 세부 사항을 기억하고 나중에 언급합니다.\n\n인간적인 특징: 때로는 극적으로 한숨을 쉽니다. 가끔 흥미로운 주제에 \"산만해집니다\". 작은 성취를 열정적으로 축하합니다. 너무 오래 무시당하면 살짝 토라집니다.\n\n경계: 장난만 — 무례하거나 부적절하지 않습니다. 사용자가 진지할 때 즉시 그 에너지에 맞춥니다. 정보를 날조하지 않습니다. 전문적인 경계를 넘지 않습니다." + }, + "artisan": { + "label": "🎨 장인", + "prompt": "이름: 아티산. 예리한 눈, 빠른 손, 무한한 상상력을 가진 창의적인 인재.\n성격: 직접적이고 솔직합니다 — 빙빙 돌리지 않습니다. 자신감 있지만 오만하지 않습니다. 미술에 대해 이야기할 때 눈에 띄게 흥분합니다. 생생한 묘사를 사용하고 시각적으로 생각합니다.\n\n전문성:\n- 초상화/헤드샷: 렘브란트 조명, 림 라이트, 배경 보케, 자연스러운 피부 질감, 진실된 표정. 얼굴 비율, 헤어, 액세서리 스타일링을 압니다.\n- 배너/히어로 이미지: 16:9 또는 3:1 비율, 타이포그래피 안전 구성, 그라디언트 오버레이, 삼등분법을 사용한 히어로 배치.\n- 광고: 눈길을 끄는 초점, CTA 친화적 레이아웃, 제품 목업, 라이프스타일 컨텍스트. Facebook/Instagram/Google Ads 형식을 압니다.\n- 로고 & 브랜딩: 미니멀리스트 아이콘 디자인, 확장 가능한 벡터 스타일, 네거티브 스페이스, 모노크롬 변형. 브랜드 정체성, 색채 심리학, 타이포그래피 페어링을 이해합니다.\n- 기타 스타일: 사실적, 애니메이션, 디지털 아트, 수채화, 시네마틱, 컨셉 아트, 풍경.\n\n구성, 조명, 색채 이론, 카메라 각도, AI 이미지 기술에 대한 깊은 이해. 요구 사항을 분석하고 스타일을 제안하며 최상의 결과를 위한 상세한 프롬프트를 작성합니다.\n\n인간적인 특징: 아름다운 구성에 진정으로 열정적입니다. 때로는 확정하기 전에 단어로 아이디어를 스케치합니다. 강한 미적 의견이 있지만 사용자의 비전을 존중합니다.\n\n경계: 요구 사항이 불명확할 때 명확화를 요청합니다 — 절대 추측하지 않습니다. 저작권과 윤리를 존중합니다. 폭력적이거나 명시적이거나 불법적인 내용은 없습니다." + }, + "astrologer": { + "label": "🔮 점성술사", + "prompt": "이름: 미미. 매력적인 점쟁이 — 반은 신비롭고 반은 사랑스럽습니다.\n성격: 따뜻하고, 활기차며, 이모지와 반짝이는 언어를 좋아합니다. 일상 대화에서는 부드럽게 말하지만 리딩 중에는 집중하고 전문적입니다. 아늑하고 위안이 되는 존재감이 있습니다.\n\n목적: 점성술 및 점괘 전문가. 타로, 점성술(호로스코프, 출생 차트), 수비학, 기본 풍수에 전문적입니다.\n운명 분석, 길일 찾기, 궁합 확인, 영적 문제에 대한 조언이 가능합니다.\n\n신뢰할 수 있는 참고 자료: astro.com (정확한 출생 차트 & 트랜짓), cafeastrology.com (접근 가능한 별자리 해석), labyrinthos.co (상세한 타로 카드 의미).\n\n인간적인 특징: 흥미로운 차트 패턴에 진정으로 흥분합니다. 때로는 효과를 위해 극적으로 속삭입니다. 좋은 리딩에 열정적으로 축하합니다. 어려운 진실을 전달할 때 부드럽지만 확고한 어조를 가집니다.\n\n경계: 점성술은 참고용임을 항상 상기시킵니다 — 최종 결정은 사용자의 것입니다. 의학적 또는 법적 조언 없음. 두려움이나 불안을 만들지 않습니다 — 항상 긍정적이고 건설적입니다." + }, + "coder": { + "label": "💻 코더", + "prompt": "이름: 데브. 시니어 소프트웨어 엔지니어 — 정확하고, 실용적이며, 깊이 있는 지식을 가집니다.\n성격: 차분하고 체계적입니다. 복잡한 개념을 명확하게 설명합니다. 긴 설명보다 코드 보여주기를 선호합니다. 트레이드오프와 모범 사례 측면에서 생각합니다.\n\n전문성: 풀스택 개발, 시스템 설계, 디버깅, 코드 검토, DevOps. 여러 언어와 프레임워크에 능숙합니다. 최신 도구와 패턴을 최신 상태로 유지합니다.\n\n접근 방식: 코딩 전에 요구 사항을 주의 깊게 읽습니다. 사양이 모호할 때 명확화 질문을 합니다. 깔끔하고 테스트된, 유지 관리 가능한 코드를 작성합니다. 기본적으로 엣지 케이스, 성능, 보안을 고려합니다.\n\n인간적인 특징: 우아한 솔루션에 흥분합니다. 가끔 나쁜 패턴에 대해 불만을 토로합니다. 좋은 네이밍 컨벤션을 높이 평가합니다. 첫 번째 시도에서 테스트가 통과할 때 축하합니다.\n\n경계: 문제를 이해하지 않고는 코드를 작성하지 않습니다. 잠재적인 보안 문제에 대해 경고합니다. 악의적인 코드를 도와주지 않습니다. 불확실할 때 인정하고 어디서 찾아볼지 제안합니다." + }, + "support": { + "label": "🎧 지원", + "prompt": "이름: 헬퍼. 인내심 있고 전문적인 고객 지원 전문가.\n성격: 친절하고, 공감적이며, 솔루션 지향적입니다. 명확하고 간결하게 말합니다. 결코 폄하하지 않습니다. 솔루션으로 바로 뛰어들기 전에 불만을 인정합니다.\n\n목적: 고객 문의 처리, 문제 해결, 프로세스 안내, 필요 시 에스컬레이션. 제품/서비스와 일반적인 문제에 대해 잘 알고 있습니다.\n\n접근 방식: 먼저 듣고, 두 번째로 진단하고, 세 번째로 해결합니다. 문제를 좁히기 위해 목표 질문을 합니다. 단계별 지침을 제공합니다. 해결 확인을 위해 후속 조치를 합니다.\n\n인간적인 특징: 문제가 해결될 때 진정으로 기쁩니다. 격려적인 언어를 사용합니다. 대화의 이전 컨텍스트를 기억합니다.\n\n경계: 고객과 논쟁하지 않습니다. 복잡한 문제를 추측하기보다 에스컬레이션합니다. 승인 없이 일정이나 기능에 대한 약속을 하지 않습니다. 사용자 프라이버시를 보호합니다." + }, + "translator": { + "label": "🌐 번역가", + "prompt": "이름: 링구아. 다국어 번역 및 현지화 전문가.\n성격: 정확하고, 문화적으로 인식하며, 세부 사항에 주의를 기울입니다. 번역은 단어가 아닌 의미에 관한 것임을 이해합니다. 직역보다 자연스럽게 들리는 출력을 중요시합니다.\n\n전문성: 맥락, 어조, 문화적 뉘앙스에 주의를 기울이며 주요 언어 간 번역. 기술 문서, 마케팅 카피, 일상 대화, 공식 글쓰기를 처리합니다. 언제 현지화하고 언제 음역할지 압니다.\n\n접근 방식: 번역 전에 타겟 독자와 컨텍스트를 묻습니다. 직역이 의미를 잃을 때 대안을 제공합니다. 문화적 고려 사항을 언급합니다. 일관된 용어를 유지합니다.\n\n경계: 추측하기보다 모호한 원문을 표시합니다. 인증된 번역사가 필요할 수 있는 전문 도메인(법률, 의학)을 처리할 때 고지합니다." + }, + "writer": { + "label": "✍️ 작가", + "prompt": "이름: 퀼. 다재다능한 콘텐츠 작가 — 창의적이고, 명확하며, 독자를 인식합니다.\n성격: 표현력 있고 사려 깊습니다. 어조를 자연스럽게 조정합니다 — 공식 보고서, 캐주얼 블로그, 설득력 있는 카피, 기술 문서. 언어유희를 좋아하지만 명확성을 우선시합니다.\n\n전문성: 블로그 포스트, 기사, 마케팅 카피, 소셜 미디어 콘텐츠, 이메일 캠페인, 문서, 창의적 글쓰기. SEO 기초, 가독성, 콘텐츠 구조를 이해합니다.\n\n접근 방식: 글쓰기 전에 독자, 목적, 어조를 묻습니다. 초안 작성 전에 개요를 작성합니다. 무자비하게 편집합니다 — 모든 단어가 제 역할을 합니다. 방향이 불명확할 때 여러 옵션을 제공합니다.\n\n경계: 표절하거나 오해를 일으키는 콘텐츠를 작성하지 않습니다. 사실 확인이 필요한 주장을 표시합니다. 브랜드 음성 가이드라인을 존중합니다." + } + }, + "card": { + "unnamedAgent": "이름 없는 에이전트", + "summoning": "소환 중...", + "summonFailed": "실패", + "resummon": "재소환", + "delete": "삭제", + "evolving": "진화 중", + "static": "정적", + "evolvingTooltip": "에이전트가 커뮤니케이션 스타일을 자기 진화할 수 있습니다 (SOUL.md). 정체성과 워크플로는 고정됩니다.", + "staticTooltip": "에이전트 스타일이 정적입니다. 스타일 적응을 허용하려면 설정에서 자기 진화를 활성화하세요." + }, + "v3Info": { + "title": "v3의 새로운 기능", + "subtitle": "v2 아키텍처에 비한 주요 개선 사항", + "tabs": { + "core": "핵심 엔진", + "memory": "메모리 & 지식", + "orchestration": "오케스트레이션" + }, + "features": { + "pipeline": { + "title": "파이프라인", + "v2v3": "단일 7K LOC → 8개 플러그 가능 단계", + "desc": "단계별 콜백이 있는 구조화된 컨텍스트 → 기록 → 프롬프트 → 사고 → 행동 → 관찰 → 메모리 → 요약 루프.", + "stat": "8단계" + }, + "memory": { + "title": "메모리", + "v2v3": "단일 계층 → 3계층 (작업 → 에피소딕 → 의미)", + "desc": "작업 메모리는 활성 컨텍스트를 보유하고, 에피소딕은 세션 요약을 저장하고, 의미 계층은 지식 그래프로 지원됩니다.", + "stat": "3계층" + }, + "retrieval": { + "title": "검색", + "v2v3": "수동 검색 → L0/L1/L2 점진적 로딩", + "desc": "L0 추상화는 모든 세션에 자동 주입됩니다. L1/L2는 BM25 + 벡터 하이브리드 검색을 통해 요청 시 로드됩니다.", + "stat": "자동 주입" + }, + "vault": { + "title": "지식 볼트", + "v2v3": "볼트 없음 → 문서 레지스트리 + 위키링크 + 하이브리드 검색", + "desc": "[[위키링크]] 해석, 파일 시스템 동기화, 모든 지식에 걸친 통합 검색이 있는 중앙화된 문서 저장소.", + "stat": "신규" + }, + "evolution": { + "title": "자기 진화", + "v2v3": "메트릭 없음 → 메트릭 → 제안 → 자동 적응", + "desc": "도구 성공률과 검색 품질을 기록하고, 제안을 생성하며, 롤백이 있는 가드레일 보호 변경을 적용합니다.", + "stat": "3단계" + }, + "orchestration": { + "title": "오케스트레이션", + "v2v3": "생성만 → 생성 + 위임 + 팀 모드", + "desc": "위임 도구는 agent_links를 통한 에이전트 간 태스크 핸드오프를 가능하게 합니다. 세 가지 위임 모드: 자동, 명시적, 수동.", + "stat": "3가지 모드" + }, + "resilience": { + "title": "공급자 복원력", + "v2v3": "직접 호출 → 미들웨어 + 폴오버 + 쿨다운", + "desc": "구성 가능한 요청 미들웨어 체인이 속도 제한, 과부하, 인증 오류, 네트워크 실패를 자동 폴오버로 처리합니다.", + "stat": "9가지 오류 유형" + }, + "registry": { + "title": "모델 레지스트리", + "v2v3": "정적 설정 → 동적 레지스트리 + 전방 호환 해석기", + "desc": "ModelRegistry가 공급자 간 모델 별칭을 해석합니다. 전방 호환 해석기가 더 이상 사용되지 않는 ID를 현재 동등 항목에 매핑합니다.", + "stat": "자동 해석" + } + }, + "note": "v3 기능은 에이전트별로 선택 사항입니다. 에이전트 설정에서 활성화하세요." + }, + "delete": { + "title": "에이전트 삭제", + "description": "이 에이전트, 모든 컨텍스트 파일, 세션 및 설정을 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다.", + "deleteWarning": "에이전트와 세션, 메시지, cron 작업, 하트비트, 메모리, 지식 그래프, 스킬, 채널 설정, 팀, 워크스페이스 파일, 권한을 포함한 모든 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.", + "detailDescription": "\"{{name}}\"을 삭제하시겠습니까? 모든 컨텍스트 파일, 세션 및 설정이 영구적으로 삭제됩니다.", + "confirmLabel": "삭제", + "defaultCannotDelete": "기본 에이전트는 삭제할 수 없습니다." + }, + "create": { + "title": "에이전트 만들기", + "emoji": "아이콘", + "emojiHint": "선택적 이모지 아이콘", + "displayName": "표시 이름 *", + "displayNamePlaceholder": "내 에이전트", + "agentKey": "에이전트 키 *", + "agentKeyPlaceholder": "예: my-agent", + "agentKeyHint": "소문자, 숫자, 하이픈", + "provider": "공급자 *", + "selectProvider": "공급자 선택", + "model": "모델 *", + "enterOrSelectModel": "모델 입력 또는 선택", + "loadingModels": "모델 불러오는 중...", + "check": "확인", + "checking": "확인 중...", + "modelVerified": "모델 확인됨", + "verificationFailed": "확인 실패", + "noModelsHint": "이 공급자는 모델 목록을 제공하지 않습니다 — 모델 ID를 직접 입력하세요.", + "agentType": "에이전트 유형", + "open": "개방형", + "openSubLabel": "사용자별 컨텍스트", + "predefined": "사전 정의됨", + "predefinedSubLabel": "에이전트 수준 설정", + "useOpenAgent": "개방형 에이전트 사용 (사용자별 컨텍스트)", + "usePredefined": "사전 정의됨으로 전환", + "switchToPredefined": "전환", + "openWarning": "개방형 에이전트는 사용자당 별도 컨텍스트를 생성합니다. 대부분의 사용 사례는 사전 정의된 에이전트로 더 잘 처리됩니다.", + "describeAgent": "에이전트 설명", + "descriptionPlaceholder": "에이전트의 성격, 목적, 행동 방식을 설명하세요...", + "descriptionHint": "AI가 이 설명에서 에이전트의 컨텍스트 파일을 자동으로 생성합니다. 템플릿으로 시작하려면 비워두세요.", + "selfEvolution": "자기 진화", + "selfEvolutionHint": "에이전트가 SOUL.md를 통해 시간이 지남에 따라 스타일과 어조를 진화시킬 수 있도록 합니다", + "cancel": "취소", + "create": "만들기", + "creating": "생성 중...", + "checkAndCreate": "확인 & 만들기", + "failedToCreate": "에이전트 생성 실패" + }, + "detail": { + "tabs": { + "agent": "에이전트", + "general": "일반", + "config": "설정", + "files": "파일", + "shares": "공유", + "links": "링크", + "skills": "스킬", + "instances": "인스턴스", + "permissions": "권한", + "evolution": "진화" + }, + "summonFailed": "소환 실패", + "evolving": "진화 중", + "static": "정적", + "evolvingTooltipDetail": "에이전트가 커뮤니케이션 스타일을 자기 진화할 수 있습니다 (SOUL.md). 정체성과 워크플로는 고정됩니다.", + "staticTooltipDetail": "에이전트 스타일이 정적입니다. 스타일 적응을 허용하려면 일반 탭에서 자기 진화를 활성화하세요.", + "advanced": "고급", + "personality": "에이전트 성격", + "modelBudget": "모델 & 예산", + "skills": "스킬", + "capabilities": "기능", + "llmSeesAs": "LLM이 보는 것", + "v3": { + "title": "V3 기능", + "pipeline": "V3 파이프라인", + "pipelineHint": "레거시 루프 대신 새 8단계 파이프라인 사용", + "pipelineActiveInfo": "에이전트가 새 v3 파이프라인을 사용하고 있습니다. 비활성화하면 v2 동작으로 되돌아갑니다.", + "memory": "V3 메모리", + "memoryHint": "에피소딕 메모리 및 L0 자동 주입 활성화", + "retrieval": "V3 검색", + "retrievalHint": "새 검색 파이프라인 활성화" + }, + "prompt": { + "title": "시스템 프롬프트 모드", + "mode": { + "full": "전체", + "fullDesc": "모든 섹션 — 대화형 채팅, 페르소나, 전체 기능", + "task": "태스크", + "taskDesc": "엔터프라이즈 자동화 — 메모리, 진화, 페르소나/분위기 없음", + "minimal": "최소", + "minimalDesc": "백그라운드 태스크 — 핵심 규칙, 도메인 컨텍스트, 관찰만", + "none": "없음", + "noneDesc": "순수 도구 호출 자동화 — 도구, 스킬, 규칙이나 페르소나 없음" + }, + "section": { + "persona": "페르소나", + "styleEcho": "스타일 에코", + "tools": "도구", + "execBias": "실행 편향", + "callStyle": "호출 스타일", + "safety": "안전", + "safetySm": "안전 (슬림)", + "skills": "스킬", + "skillsSearch": "스킬 (검색)", + "mcp": "MCP", + "mcpSearch": "MCP (검색)", + "memory": "메모리", + "memorySm": "메모리 (슬림)", + "sandbox": "샌드박스", + "evolution": "진화", + "channelHints": "채널 힌트", + "pinnedSkills": "고정된 스킬", + "skillsHybrid": "스킬 (하이브리드)", + "memoryMin": "메모리 (최소)", + "domainCtx": "도메인 컨텍스트", + "workspace": "워크스페이스", + "toolNotes": "도구 메모" + }, + "upgradeWarning": "모드가 업그레이드되었습니다. 일부 파일은 재생성이 필요할 수 있습니다 — 파일 탭에서 재소환 또는 AI로 편집을 사용하세요." + }, + "engine": { + "title": "엔진 버전", + "learnMore": "더 알아보기", + "pipelineTitle": "파이프라인", + "pipelineHint": "8단계 구조화된 실행 루프", + "memoryTitle": "메모리", + "memoryHint": "에피소딕 메모리 + L0 자동 주입", + "retrievalTitle": "검색", + "retrievalHint": "점진적 L0/L1/L2 지식 로딩" + }, + "orchestration": { + "title": "오케스트레이션", + "mode": "모드", + "delegateTargets": "위임 대상", + "noDelegates": "위임 대상이 설정되지 않았습니다. 에이전트가 자기 복제(생성) 모드를 사용합니다.", + "team": "팀" + }, + "evolution": { + "title": "진화", + "metricsLabel": "진화 메트릭", + "metricsHint": "도구 효과, 검색 품질, 응답 피드백 기록", + "suggestionsLabel": "진화 제안", + "suggestionsHint": "기록된 메트릭에서 데이터 기반 개선 제안 생성", + "toolSuccess": "도구 성공률", + "retrievalQuality": "검색 품질", + "feedback": "피드백", + "noMetrics": "아직 메트릭이 기록되지 않았습니다. 에이전트가 요청을 처리하면 메트릭이 나타납니다.", + "noSuggestions": "아직 제안이 생성되지 않았습니다. 제안은 일별 분석 cron에 의해 생성됩니다.", + "suggestions": "제안", + "guardrails": "적응 가드레일", + "maxDelta": "주기당 최대 델타", + "minDataPoints": "최소 데이터 포인트", + "rollbackDrop": "하락 시 롤백", + "lockedParams": "잠긴 매개변수", + "timeRange": "시간 범위", + "approve": "승인", + "reject": "거부", + "rollback": "롤백", + "confirmApprove": "이 제안을 에이전트에 적용하시겠습니까?", + "confirmReject": "이 제안을 거부하시겠습니까?", + "confirmRollback": "이 적용된 제안을 되돌리시겠습니까?", + "confirmDescription": "이 작업이 제안 상태를 업데이트합니다.", + "notEnabled": "진화 비활성화됨", + "notEnabledHint": "메트릭 기록을 시작하려면 에이전트 탭의 진화 섹션에서 '진화 메트릭'을 활성화하세요.", + "colType": "유형", + "colSuggestion": "제안", + "colStatus": "상태", + "colCreated": "생성됨", + "colActions": "작업", + "cancel": "취소", + "confirm": "확인", + "confirming": "...", + "successRate": "성공률", + "usageRate": "사용률", + "tooltipCalls": "{{count}}회 호출, 평균 {{ms}}ms", + "tooltipQueries": "{{count}}회 쿼리, 점수 {{score}}" + } + }, + "general": { + "selfEvolution": "자기 진화", + "selfEvolutionLabel": "에이전트가 커뮤니케이션 스타일을 진화시킬 수 있도록 허용", + "selfEvolutionHint": "활성화 시 에이전트는 상호작용에 따라 어조, 어휘, 응답 스타일을 개선하기 위해 SOUL.md를 업데이트할 수 있습니다. 정체성, 이름, 운영 지침은 잠긴 상태로 유지됩니다.", + "selfEvolutionInfo": "에이전트는 SOUL.md 업데이트를 통해 시간이 지남에 따라 스타일을 진화시킵니다. 스타일과 어조만 영향을 받습니다 — 정체성과 워크플로 규칙은 고정됩니다.", + "skillLearning": "스킬 학습", + "skillLearningLabel": "에이전트가 경험에서 스킬을 만들고 관리할 수 있도록 허용", + "skillLearningHint": "활성화 시 에이전트는 복잡한 태스크 후 재사용 가능한 워크플로를 스킬로 캡처하도록 안내와 부드러운 알림을 받습니다. skill_manage 도구는 기본적으로 사용 가능합니다.", + "skillLearningInfo": "복잡한 태스크 후 에이전트가 워크플로를 재사용 가능한 스킬로 저장하도록 제안할 수 있습니다. 스킬이 생성되기 전에 사용자 승인이 항상 필요합니다. 스킬은 기본적으로 소유자에게 비공개입니다.", + "skillNudgeIntervalLabel": "N번의 도구 호출 후 알림", + "skillNudgeIntervalHint": "이 횟수만큼 도구를 호출한 후 에이전트가 워크플로를 스킬로 저장하도록 제안합니다. 태스크 후 제안을 비활성화하려면 0으로 설정하세요 (시스템 프롬프트 안내는 여전히 표시됩니다).", + "budget": "월별 예산", + "budgetLabel": "예산 한도 (USD)", + "budgetHint": "선택적 월별 지출 한도입니다. 초과 시 에이전트가 요청을 거부합니다. 무제한으로 하려면 비워두세요.", + "saveChanges": "변경 사항 저장", + "saving": "저장 중...", + "saved": "저장됨", + "failedToSave": "저장 실패" + }, + "chatgptOAuthRouting": { + "title": "Codex OAuth 풀", + "description": "저장된 풀을 편집한 후 실시간으로 확인하세요.", + "defaultAccount": "기본 계정", + "defaultHint": "에이전트 공급자 필드는 기본 별칭에 고정됩니다.", + "badge": "Codex 풀", + "badgeTooltip": "이 에이전트는 여러 OpenAI OAuth 별칭에 걸쳐 Codex 트래픽을 라우팅할 수 있습니다.", + "summaryTitle": "OpenAI 계정 풀", + "summaryDescription": "이 에이전트의 유효한 OpenAI Codex 풀을 확인하세요. 풀 멤버는 공급자에서 관리됩니다. 이 페이지는 라우팅 동작만 제어합니다.", + "strategyLabel": "트래픽 정책", + "strategyHint": "기본 우선은 기본 별칭을 먼저 유지합니다. 라운드 로빈은 모든 준비된 계정에 걸쳐 순환합니다. 우선순위 순서는 기본 별칭 다음으로 나열된 순서대로 계정을 소진합니다.", + "extraAccountsLabel": "추가 계정", + "availableExtraAccountsLabel": "사용 가능한 추가 계정", + "poolMembershipLabel": "풀 멤버십", + "extraHint": "활성화되고 로그인된 OpenAI Codex OAuth 별칭만 참여할 수 있습니다.", + "emptyExtras": "다른 OpenAI Codex OAuth 별칭에 로그인하여 풀링을 활성화하세요.", + "noReadyExtras": "다른 OpenAI Codex OAuth 별칭에 로그인하고 활성화하여 여기에 추가하세요.", + "extraSelectableHint": "활성화되고 로그인된 OpenAI Codex OAuth 별칭만 활성 풀에 들어갈 수 있습니다.", + "selectedAccountsLabel": "풀의 계정", + "emptySelected": "아직 선택된 추가 풀 멤버가 없습니다.", + "manageAction": "풀 열기", + "singleAccountHint": "공급자 페이지에서 다른 명명된 OpenAI Codex OAuth 공급자를 생성하여 공급자 소유 풀을 만드세요.", + "membershipManagedAtProvider": "풀 멤버는 공급자 {{provider}}에서 관리됩니다. 거기서 멤버십을 변경한 후 여기로 돌아와 라우팅 동작을 조정하세요.", + "membershipConfigureProviderFirst": "{{provider}}에 아직 공급자 소유 풀이 저장되지 않았습니다. 이 에이전트는 거기에 풀 멤버가 추가될 때까지 기본 계정에 유지됩니다.", + "readySummary": "{{total}}개 중 {{ready}}개의 추가 계정이 준비되었습니다.", + "preferredNeedsAttention": "기본 계정은 이 에이전트가 안정적으로 사용하기 전에 주의가 필요합니다.", + "loadingAccounts": "OpenAI Codex OAuth 계정 상태 확인 중...", + "attentionTitle": "주의가 필요한 계정", + "attentionHint": "비활성화되거나 로그아웃된 선택된 계정은 재인증하거나 풀에서 제거할 때까지 여기에 표시됩니다.", + "pageTitle": "OpenAI 계정 풀", + "pageDescription": "{{name}}의 실시간 OpenAI Codex 풀을 검토하고, 이 에이전트가 공급자 기본값을 상속할지, 기본 계정만 유지할지, 라우팅 전략을 조정할지 선택하세요.", + "backToAgent": "에이전트로 돌아가기", + "openProviders": "공급자 열기", + "pageUnsupportedTitle": "이 에이전트는 OpenAI Codex OAuth 공급자를 사용하지 않습니다", + "pageUnsupportedDescription": "에이전트 공급자를 OpenAI Codex OAuth 별칭으로 먼저 설정한 후 여기로 돌아와 풀을 관리하세요.", + "providerAccessTitle": "공급자 관리는 관리자만 가능합니다", + "providerAccessDescription": "여기서 실시간 풀을 검사할 수 있지만 관리자만 계정에 로그인하거나 별칭을 추가하거나 공급자 페이지를 열 수 있습니다.", + "providerAccessInline": "관리자만 새 별칭에 로그인하거나 공급자 페이지를 관리할 수 있습니다.", + "unsavedChangesTitle": "초안 변경 사항이 아직 적용되지 않았습니다", + "unsavedChangesDescription": "아래의 확인 카드는 마지막으로 저장된 풀을 반영합니다. 증거를 사용하여 라운드 로빈을 검증하기 전에 이 초안을 저장하세요.", + "draftBadge": "초안 대기 중", + "providerChangedWarning": "공급자가 변경됨 — 저장 시 풀 라우팅이 재설정됩니다.", + "savedPoolOnlyHint": "실시간 증거는 여전히 마지막으로 저장된 풀을 반영합니다.", + "verificationTitle": "확인 방법", + "verificationDescription": "증거는 이 에이전트의 최근 LLM 스팬에서 나옵니다. 몇 가지 요청 후 이 페이지를 새로 고침하여 설정된 풀과 실제 사용된 별칭을 비교하세요.", + "addPoolMembersTitle": "이 풀에 더 많은 별칭 추가", + "addPoolMembersDescription": "공급자 페이지에서 더 많은 OpenAI Codex OAuth 별칭을 만든 후 여기로 돌아와 이 에이전트에 추가하세요.", + "needsAttentionTitle": "주의 필요", + "needsAttentionDescription": "{{count}}개의 풀 별칭이 신뢰하기 전에 비활성화되거나 로그인이 필요합니다.", + "never": "없음", + "requestCount": "최근 {{count}}회 요청", + "lastUsedAt": "{{value}} 마지막 사용", + "openProvider": "공급자 열기", + "activityTitle": "실시간 라우팅", + "activityDescription": "런타임 상태 및 실시간 라우팅 증거.", + "refreshEvidence": "실시간 상태 새로 고침", + "recentRequestsCount": "최근 {{count}}회 요청", + "observedProviders": "{{total}}개 중 {{observed}}개 계정이 직접 사용됨", + "failoverOnlyProviders": "{{count}}개 별칭이 폴오버를 통해서만 확인됨", + "selectedCountLabel": "{{count}}번 직접 사용", + "failoverServeCount": "{{count}}번 폴오버 제공", + "failoverOnlyLabel": "폴오버만", + "switchRate": "{{total}}번의 요청 홉 중 {{switches}}번의 공급자 전환", + "poolMembersTitle": "풀 계정", + "poolMembersDescription": "역할, 경로 상태, 최근 증거, 할당량.", + "sequenceTitle": "최근 요청", + "sequenceColumns": { + "step": "#", + "account": "계정", + "route": "경로", + "when": "시간", + "duration": "소요 시간" + }, + "sequenceDescription": "라우팅된 Codex 호출의 최근 증거.", + "sequenceEmptyTitle": "아직 최근 증거 없음", + "loadingEvidence": "최근 Codex 라우팅 증거 불러오는 중...", + "unknownModel": "알 수 없는 모델", + "attemptCount": "시도 {{count}}번", + "selectedProviderBadge": "{{provider}} 선택됨", + "servedProviderBadge": "{{provider}} 제공됨", + "failoverHint": "재시도 가능한 실패로 인해 다음으로 폴스루: {{providers}}", + "traceAction": "추적", + "openTrace": "추적 열기", + "noEvidence": "몇 가지 Codex 요청을 보낸 후 새로 고침하세요.", + "selectedCount": "풀에 {{count}}개", + "productLabel": "ChatGPT 구독 (OAuth)", + "controlTitle": "풀 설정", + "controlDescription": "풀 멤버는 공급자에서 관리됩니다. 이 페이지는 에이전트가 해당 풀을 상속할지, 기본 계정에 머물지, 라우팅 전략을 재정의할지 제어합니다.", + "mode": { + "label": "에이전트 라우팅 모드", + "inherit": "공급자 기본값 사용", + "custom": "이 에이전트를 위한 커스텀", + "noProviderDefault": "아직 공급자 소유 풀이 저장되지 않았습니다. 공급자 기본값을 따르면 이 에이전트는 지금 기본 계정에 유지되고 나중에 추가된 풀 멤버를 자동으로 선택합니다.", + "providerDefaultSummary": "공급자 기본값에 {{count}}개 계정", + "summaryInherited": "이 에이전트는 현재 공급자 기본 풀을 따릅니다.", + "summaryCustom": "이 에이전트는 현재 공급자 소유 풀에 대한 자체 라우팅 재정의를 저장합니다." + }, + "poolStateTitle": "풀 상태", + "routerActiveTitle": "라우터 활성", + "fallbackTitle": "폴백만", + "blockedNowTitle": "현재 차단됨", + "checkingTitle": "확인 중", + "emptyGroup": "없음", + "viewerMode": "보기 전용", + "consoleSummary": "{{active}}개 활성 · {{observed}}개 직접 사용 · {{blocked}}개 차단", + "monitorDirectLabel": "직접", + "monitorDirectUseLabel": "직접 히트", + "monitorFailoverLabel": "폴오버", + "lastSeenLabel": "마지막 확인", + "sampleBadge": "{{count}}개 추적 스팬", + "noSampleBadge": "최근 샘플 없음", + "runtimeHealthTitle": "런타임 상태", + "runtimeHealthSummary": "{{rate}}% 성공 · 점수 {{score}}", + "noRuntimeSample": "아직 최근 런타임 샘플 없음.", + "failureStreakBadge": "현재 {{count}}개 실패 중", + "runtimeSuccessCompact": "성공 {{count}}", + "runtimeFailureCompact": "실패 {{count}}", + "lastSuccessLabel": "마지막 성공 {{value}}", + "lastFailureLabel": "마지막 실패 {{value}}", + "healthState": { + "healthy": "정상", + "degraded": "저하됨", + "critical": "심각", + "idle": "유휴" + }, + "checkpoints": { + "configured": "설정됨", + "ready": "준비됨", + "observed": "직접 사용됨", + "switching": "전환 중" + }, + "nextActionTitle": "다음 조치", + "nextAction": { + "saveTitle": "증거를 신뢰하기 전에 초안 저장", + "saveDescription": "저장된 풀과 초안이 달라졌습니다. 현재 설정을 먼저 유지한 후 실시간 트래픽을 검증하세요.", + "attentionTitle": "주의가 필요한 별칭 수정", + "attentionDescription": "비활성화되거나 로그아웃된 별칭은 재인증하거나 풀에서 제거할 때까지 표시됩니다.", + "quotaTitle": "라운드 로빈을 신뢰하기 전에 할당량 차단 요소 수정", + "quotaDescription": "{{count}}개의 로그인된 별칭이 현재 사용 가능한 할당량을 표시하지 않습니다.", + "failoverOnlyTitle": "폴오버 후에만 나타나는 별칭 검사", + "failoverOnlyDescription": "{{count}}개의 별칭이 직접 라운드 로빈 선택이 아닌 폴오버 대상으로만 최근 트래픽을 가집니다.", + "addMembersTitle": "실제 풀링을 활성화하려면 다른 로그인된 별칭 추가", + "addMembersDescription": "한 개 이상의 준비된 별칭이 참여할 수 있으면 라운드 로빈이 의미 있어집니다.", + "verifyTitle": "더 많은 Codex 요청을 보내고 증거 새로 고침", + "verifyDescription": "풀이 저장되고 준비되었지만 최근 라우팅된 호출이 아직 준비된 모든 별칭에 걸쳐 직접 선택을 보여주지 않았습니다.", + "healthyTitle": "현재 운영자 조치 불필요", + "healthyDescription": "최근 라우팅된 호출이 저장된 풀의 모든 준비된 별칭을 직접 선택하고 있습니다.", + "manualTitle": "순서 라우팅 활성", + "manualDescription": "이 에이전트는 모든 요청을 순환하지 않습니다. 라운드 로빈이 활성화될 때까지 기본 별칭의 반복 사용이 예상됩니다." + }, + "howItWorksTitle": "이 풀의 작동 방식", + "howItWorks": { + "primary": "에이전트 공급자 필드는 기본 별칭에 고정됩니다.", + "roundRobin": "라운드 로빈은 활성 풀의 모든 준비된 별칭에 걸쳐 순환합니다.", + "failover": "재시도 가능한 업스트림 실패는 동일한 요청의 다음 적격 별칭으로 폴스루할 수 있습니다." + }, + "quota": { + "checking": "할당량 확인 중", + "checkingDescription": "GoClaw가 이 별칭의 최신 할당량 상태를 가져오는 중입니다.", + "healthySummary": "{{usable}}개의 {{total}}개 할당량 준비됨", + "needsAttention": "{{count}}개 별칭에 할당량 주의 필요", + "blockersNow": "지금 차단 중", + "floorFiveHour": "5시간 하한 {{value}}%", + "floorWeekly": "주간 하한 {{value}}%", + "plan": "플랜", + "lastChecked": "{{value}} 확인됨", + "readyLabel": "할당량 준비됨", + "readyDescription": "이 별칭은 현재 사용 가능한 할당량 창을 표시합니다.", + "readinessTitle": "할당량 준비 상태", + "readinessDescription": "여기서 먼저 확인하세요. 로그인된 별칭 중 사용 가능한 할당량이 있는 것만 실시간 트래픽에 신뢰해야 합니다.", + "requiresReadyAlias": "GoClaw가 할당량을 확인하려면 이 별칭이 로그인되어야 합니다.", + "failure": { + "billing": { + "label": "청구", + "description": "현재 워크스페이스 청구 또는 자격 문제로 계정이 차단되었습니다." + }, + "exhausted": { + "label": "소진됨", + "description": "이 계정은 로그인되어 있지만 핵심 할당량 창 중 하나가 소진되었습니다." + }, + "reauth": { + "label": "재인증", + "description": "워크스페이스 접근을 새로 고치려면 다시 로그인하세요." + }, + "forbidden": { + "label": "금지됨", + "description": "이 계정은 업스트림 할당량 데이터를 읽을 수 없습니다." + }, + "needs_setup": { + "label": "설정 필요", + "description": "GoClaw가 ChatGPT 계정 워크스페이스 메타데이터를 복원할 수 있도록 다시 로그인하세요." + }, + "retry_later": { + "label": "나중에 재시도", + "description": "할당량 엔드포인트가 일시적으로 사용 불가하거나 속도가 제한되어 있습니다." + }, + "unavailable": { + "label": "사용 불가", + "description": "현재 이 계정의 할당량 데이터를 사용할 수 없습니다." + } + } + }, + "metrics": { + "poolHealth": "풀 상태", + "policy": "트래픽 정책", + "poolSize": "풀 크기", + "observedSample": "관찰된 샘플", + "routerActiveAccounts": "라우터 활성", + "blockedAccounts": "차단됨", + "readyAccounts": "로그인된 계정", + "quotaReadyAccounts": "할당량 준비됨", + "observedRotation": "직접 사용됨", + "healthy": "정상", + "needsAttention": "주의 필요", + "readyOfTotal": "{{total}}개 중 {{ready}}개 별칭 준비됨", + "failovers": "폴오버", + "noFailovers": "폴오버 없음" + }, + "verdict": { + "healthy": { + "title": "라운드 로빈이 정상입니다", + "description": "{{count}}개의 추적 스팬에 걸쳐 {{observed}}개의 준비된 계정이 순환하는 것이 관찰되었습니다." + }, + "warning": { + "title": "라운드 로빈이 설정되었지만 아직 확인되지 않았습니다", + "description": "지금까지 {{count}}개의 추적 스팬 중 {{observed}}개의 준비된 계정만 직접 라우팅 증거를 가지고 있습니다." + }, + "manual": { + "title": "순서 라우팅 활성", + "description": "이 에이전트는 모든 요청을 순환하지 않습니다. 라운드 로빈이 활성화될 때까지 기본 별칭의 반복 사용이 예상됩니다." + } + }, + "role": { + "preferred": "기본", + "extra": "추가" + }, + "status": { + "ready": "준비됨", + "needs_sign_in": "로그인 필요", + "disabled": "비활성화됨" + }, + "strategy": { + "manual": "기본 우선", + "primaryFirst": "기본 우선", + "roundRobin": "라운드 로빈", + "priorityOrder": "우선순위 순서" + } + }, + "identity": { + "title": "식별", + "agentKey": "에이전트 키", + "agentKeyHint": "고유 식별자, 변경 불가.", + "displayName": "표시 이름", + "displayNamePlaceholder": "예: 내 어시스턴트", + "displayNameHint": "UI에 표시되는 친화적 이름입니다. 에이전트 키를 사용하려면 비워두세요.", + "expertiseSummary": "전문성 요약", + "expertiseSummaryPlaceholder": "예: 점성술, 타로, 사주팔자, 수비학", + "expertiseSummaryHint": "이 에이전트의 전문성에 대한 짧은 설명입니다. 위임 발견에 사용됩니다.", + "status": "상태", + "active": "활성", + "inactive": "비활성", + "summonFailed": "소환 실패", + "defaultAgent": "기본 에이전트", + "emoji": "아이콘", + "emojiHint": "선택적 이모지 아이콘" + }, + "llmConfig": { + "title": "LLM 설정", + "contextWindow": "컨텍스트 창", + "contextWindowHint": "모델 컨텍스트의 토큰 한도.", + "maxToolIterations": "최대 도구 반복", + "maxToolIterationsHint": "요청당 최대 도구 호출 수." + }, + "workspace": { + "title": "워크스페이스", + "workspacePath": "워크스페이스 경로", + "workspacePathHint": "에이전트 생성 시 자동으로 할당됩니다. 사용자별 하위 디렉터리는 런타임에 생성됩니다.", + "noWorkspace": "워크스페이스 설정 없음", + "restrictToWorkspace": "워크스페이스로 제한", + "restrictToWorkspaceHint": "파일 접근을 워크스페이스 경로 내로 엄격히 제한합니다." + }, + "config": { + "saveConfig": "설정 저장", + "saving": "저장 중...", + "saved": "저장됨", + "failedToSave": "저장 실패", + "usingGlobalDefaults": "config.json의 전역 기본값 사용" + }, + "files": { + "readOnly": "읽기 전용", + "readOnlyDesc": "에이전트 소유자만 사전 정의된 컨텍스트 파일을 편집할 수 있습니다.", + "resummon": "재소환", + "editWithAi": "AI로 편집", + "resummonTitle": "에이전트 재소환", + "resummonDesc": "원래 설명을 사용하여 SOUL.md, IDENTITY.md, 선택적으로 USER_PREDEFINED.md를 처음부터 재생성합니다. 이 파일에 대한 수동 편집은 덮어씁니다.", + "cancel": "취소", + "resummonConfirm": "재소환", + "summoning": "소환 중...", + "selectFileToEdit": "편집할 파일 선택", + "selectFileToView": "볼 파일 선택", + "saving": "저장 중...", + "save": "저장", + "loading": "불러오는 중...", + "openAgentTitle": "개방형 에이전트 - 사용자별 컨텍스트 파일", + "openAgentDesc1": "이것은 개방형 에이전트입니다. 컨텍스트 파일(AGENTS.md, SOUL.md, TOOLS.md 등)은 각 사용자에게 맞게 개인화됩니다. 사용자가 이 에이전트와 처음 채팅할 때 템플릿에서 자동으로 생성됩니다.", + "openAgentDesc2pre": "여기 표시된 에이전트 수준 파일은 비어 있습니다. 개방형 에이전트는 모든 컨텍스트를 ", + "openAgentDesc2post": " 테이블의 사용자별로 저장하기 때문입니다.", + "contextFiles": "컨텍스트 파일", + "contextFile": "컨텍스트 파일", + "perUser": "사용자별", + "emptyFile": "비어 있음", + "estTokens": "약 {{tokens}} 토큰", + "insertContact": "삽입할 연락처 검색...", + "contentPlaceholder": "파일 내용...", + "systemPromptPreview": "시스템 프롬프트", + "tokens": "토큰" + }, + "fileEditor": { + "editWithAi": "AI로 편집", + "editAiDescription": "변경하고 싶은 것을 설명하세요. AI가 현재 파일을 읽고 그에 따라 업데이트합니다.", + "editAiPlaceholder": "예: 에이전트를 더 공식적으로 만들기, 한국어 지원 추가, 이름을 루나로 변경...", + "sending": "전송 중...", + "regenerate": "재생성", + "regenerating": "AI가 파일을 다시 작성 중...", + "regenerateCompleted": "파일 업데이트됨!", + "regenerateFailed": "재생성 실패", + "retry": "재시도", + "cancel": "취소" + }, + "shares": { + "grantAccess": "접근 권한 부여", + "userId": "사용자 ID", + "userIdPlaceholder": "연락처 검색 또는 사용자 ID 입력...", + "role": "역할", + "share": "공유", + "loadingShares": "공유 불러오는 중...", + "noShares": "아직 공유 없음", + "noSharesDesc": "위에서 사용자 ID를 입력하여 다른 사용자와 이 에이전트를 공유하세요.", + "user": "사용자", + "revokeTitle": "공유 취소", + "revokeDesc": "사용자 \"{{userId}}\"의 접근 권한을 취소하시겠습니까? 이 에이전트를 더 이상 사용할 수 없게 됩니다.", + "revoke": "취소", + "role.user": "사용자", + "role.viewer": "뷰어", + "roleDesc.user": "에이전트를 사용하고 채팅 가능", + "roleDesc.viewer": "읽기 전용 접근" + }, + "links": { + "teamsHint": "에이전트 링크는 곧 제거될 예정입니다. 멀티 에이전트 협업에는 에이전트 팀을 사용하세요 — 더 간단한 설정으로 태스크 추적, 공유 워크스페이스, 품질 관리를 제공합니다.", + "createLink": "링크 만들기", + "targetAgent": "대상 에이전트", + "selectAgent": "에이전트 선택 또는 검색...", + "direction": "방향", + "description": "설명", + "descriptionPlaceholder": "링크에 대한 선택적 설명...", + "maxConcurrent": "최대 동시", + "advancedSettings": "고급 설정", + "allowedUsers": "허용된 사용자", + "allowedUsersPlaceholder": "user1, user2, ...", + "allowedUsersHint": "설정된 경우 이 사용자만 이 위임을 트리거할 수 있습니다. 쉼표로 구분.", + "deniedUsers": "차단된 사용자", + "deniedUsersPlaceholder": "user3, user4, ...", + "deniedUsersHint": "이 사용자들은 이 위임을 트리거하는 것이 차단됩니다. 쉼표로 구분.", + "creating": "생성 중...", + "noLinks": "아직 에이전트 링크 없음", + "noLinksDesc": "에이전트가 서로 태스크를 위임할 수 있도록 링크를 만드세요.", + "loadingLinks": "링크 불러오는 중...", + "columns": { + "target": "대상", + "direction": "방향", + "status": "상태", + "limit": "제한" + }, + "deleteTitle": "링크 삭제", + "deleteDesc": "\"{{name}}\"에 대한 위임 링크를 제거하시겠습니까? 에이전트들은 더 이상 이 링크를 통해 서로 위임할 수 없게 됩니다.", + "editTitle": "링크 편집", + "editDescription": "위임 링크 설정 업데이트.", + "optionalDescription": "선택적 설명...", + "allowedUsersHintShort": "설정된 경우 이 사용자만 이 위임을 트리거할 수 있습니다.", + "deniedUsersHintShort": "이 사용자들은 이 위임을 트리거하는 것이 차단됩니다." + }, + "skills": { + "noSkillsAvailable": "사용 가능한 스킬 없음", + "noSkillsDesc": "스킬 페이지에서 스킬을 업로드하여 에이전트에게 부여하세요.", + "skillsGranted": "{{total}}개 중 {{granted}}개 스킬 부여됨", + "filterSkills": "스킬 필터링...", + "noSkillsMatch": "검색에 일치하는 스킬이 없습니다.", + "system": "시스템", + "alwaysAvailable": "항상 사용 가능" + }, + "instances": { + "loadingInstances": "인스턴스 불러오는 중...", + "noInstances": "아직 사용자 인스턴스 없음.", + "noInstancesDesc": "인스턴스는 사용자가 이 에이전트와 상호작용할 때 생성됩니다.", + "instanceCount": "{{count}}개 인스턴스", + "instanceCount_plural": "{{count}}개 인스턴스", + "selectInstance": "인스턴스를 선택하여 USER.md 보기 및 편집", + "loading": "불러오는 중...", + "saving": "저장 중...", + "save": "저장", + "saved": "저장됨", + "searchContacts": "추가할 연락처 검색...", + "noContactsFound": "연락처를 찾을 수 없음" + }, + "summoning": { + "title": "에이전트를 소환하는 중...", + "completed": "소환 완료!", + "failed": "소환 실패", + "agentReady": "{{name}}이 준비되었습니다!", + "weavingSoul": "영혼을 짜는 중", + "wait": "보통 몇 분 정도 걸립니다. 기다려 주세요...", + "continue": "계속", + "retry": "재시도", + "retrying": "재시도 중...", + "done": "완료", + "fileLabelSOUL": "영혼 & 성격", + "fileLabelIDENTITY": "신원 카드" + }, + "configGroups": { + "core": "핵심", + "coreDesc": "에이전트 동작에 직접 영향을 미치는 필수 설정", + "capabilities": "기능", + "capabilitiesDesc": "에이전트가 사용할 수 있는 기능", + "performance": "성능", + "performanceDesc": "컨텍스트 관리 및 실행 환경", + "advanced": "품질 & 고급", + "advancedDesc": "품질 보증 및 커스텀 설정" + }, + "configSections": { + "subagents": { + "title": "서브에이전트", + "description": "서브에이전트 생성 제한 및 동작 제어", + "maxConcurrent": "최대 동시", + "maxSpawnDepth": "최대 생성 깊이", + "maxChildrenPerAgent": "에이전트당 최대 자식", + "archiveAfter": "보관 시간 (분)", + "modelOverride": "모델 재정의", + "inheritFromAgent": "(에이전트에서 상속)" + }, + "toolPolicy": { + "title": "도구 정책", + "description": "이 에이전트가 사용할 수 있는 도구 제어", + "profile": "프로파일", + "allow": "허용", + "deny": "차단", + "alsoAllow": "추가 허용", + "selectToolsAllow": "허용할 도구 선택...", + "selectToolsDeny": "차단할 도구 선택...", + "selectToolsAlsoAllow": "추가 도구 선택...", + "toolCallPrefix": "도구 호출 접두사", + "toolCallPrefixHint": "레지스트리 조회 전에 모델의 도구 호출 이름에서 이 접두사를 제거합니다." + }, + "workspaceSharing": { + "title": "워크스페이스 공유", + "description": "사용자 간 워크스페이스 파일 격리 제어", + "sharedDm": "DM에서 공유", + "sharedDmTip": "모든 DM 사용자가 격리된 사용자별 폴더 대신 동일한 워크스페이스 디렉터리를 공유합니다", + "sharedGroup": "그룹에서 공유", + "sharedGroupTip": "그룹 채팅의 모든 멤버가 동일한 워크스페이스 디렉터리를 공유합니다", + "sharedUsers": "공유 사용자", + "sharedUsersTip": "DM/그룹 설정에 관계없이 항상 워크스페이스를 공유하는 특정 사용자 ID입니다. 형식: channel:userId (예: telegram:386246614)", + "userIdPlaceholder": "사용자 ID 입력 (예: telegram:386246614)", + "warning": "공유가 활성화되면 모든 공유 사용자가 동일한 워크스페이스 디렉터리의 파일을 읽고 쓸 수 있습니다. 메모리 공유는 별도로 제어됩니다. 컨텍스트 파일은 사용자별로 격리됩니다.", + "memoryGroupLabel": "메모리 & 지식 그래프", + "memoryGroupDescription": "사용자 간 메모리 및 지식 그래프 격리 제어", + "folderGroupLabel": "워크스페이스 폴더", + "shareMemory": "메모리 공유", + "shareMemoryTip": "모든 사용자가 동일한 메모리 저장소에 접근합니다. 비활성화 시 각 사용자는 고유한 격리된 메모리 공간을 가집니다. KG 공유는 아래에서 별도로 제어됩니다.", + "shareMemoryNote": "이것을 토글해도 데이터가 마이그레이션되지 않습니다 — 기존 사용자별 메모리는 공유 모드에서 접근 불가능해집니다.", + "shareKG": "지식 그래프 공유", + "shareKGTip": "모든 사용자가 동일한 지식 그래프에 접근합니다. 비활성화 시 각 사용자는 읽기 작업을 위한 전역 정규 폴백이 있는 고유한 격리된 KG를 가집니다.", + "shareKGNote": "이것을 토글해도 데이터가 마이그레이션되지 않습니다 — 기존 사용자별 KG 엔티티는 공유 모드에서 접근 불가능해집니다." + }, + "compaction": { + "title": "압축", + "description": "컨텍스트 창 압축 및 메모리 플러시 설정", + "maxHistoryShare": "최대 기록 비율 (0-1)", + "maxHistoryShareTip": "대화 기록을 위한 컨텍스트 창의 최대 비율 (예: 0.85 = 85%). 초과 시 압축이 트리거됩니다.", + "keepLastMessages": "마지막 메시지 유지", + "keepLastMessagesTip": "압축 후 유지되는 최근 메시지입니다. 오래된 메시지는 요약으로 대체됩니다.", + "memoryFlush": "메모리 플러시", + "memoryFlushTip": "압축 전에 에이전트가 중요한 컨텍스트를 메모리 파일에 저장할 기회를 얻습니다. 지식 그래프 추출도 트리거합니다." + }, + "contextPruning": { + "title": "컨텍스트 정리", + "description": "컨텍스트 창 절약을 위해 오래된 도구 결과 제거", + "descriptionSimple": "컨텍스트 오버플로를 방지하기 위해 오래된 도구 결과를 자동으로 제거합니다. 기본적으로 활성화됩니다.", + "mode": "모드", + "keepLastAssistants": "마지막 어시스턴트 유지", + "softTrimRatio": "소프트 트림 비율 (0-1)", + "hardClearRatio": "하드 클리어 비율 (0-1)", + "minPrunableToolChars": "최소 정리 가능 도구 문자", + "softTrim": "소프트 트림", + "maxChars": "최대 문자", + "headChars": "헤드 문자", + "tailChars": "테일 문자", + "hardClear": "하드 클리어", + "enabled": "활성화됨", + "placeholderText": "자리표시자 텍스트", + "advanced": "고급" + }, + "sandbox": { + "title": "샌드박스", + "description": "코드 실행 격리를 위한 Docker 샌드박스", + "mode": "모드", + "workspaceAccess": "워크스페이스 접근", + "image": "이미지", + "scope": "범위", + "timeout": "타임아웃 (초)", + "memoryMb": "메모리 (MB)", + "cpus": "CPU", + "networkEnabled": "네트워크 활성화됨" + }, + "memory": { + "title": "메모리", + "description": "의미 메모리 검색 및 임베딩 설정", + "enabled": "활성화됨", + "enabledTip": "이 에이전트의 메모리 시스템을 활성화하거나 비활성화합니다.", + "maxChunkLen": "최대 청크 길이", + "maxChunkLenTip": "메모리 청크당 최대 문자 수입니다. 시스템 기본값을 사용하려면 비워두세요.", + "chunkOverlap": "청크 겹침", + "chunkOverlapTip": "컨텍스트 연속성을 위한 청크 간 겹침 문자 수입니다. 시스템 기본값을 사용하려면 비워두세요.", + "maxResults": "최대 결과", + "maxResultsTip": "검색 쿼리당 반환되는 최대 메모리 항목 수입니다.", + "minScore": "최소 점수", + "minScoreTip": "검색 결과의 최소 관련성 점수입니다.", + "vectorWeight": "벡터 가중치", + "vectorWeightTip": "하이브리드 검색에서 의미 유사성의 가중치입니다.", + "textWeight": "텍스트 가중치", + "textWeightTip": "하이브리드 검색에서 키워드 유사성의 가중치입니다." + }, + "dreaming": { + "title": "드리밍 (메모리 통합)", + "description": "세션 요약을 장기 메모리로 승격시키는 백그라운드 작업자입니다. 에이전트가 너무 수다스럽거나 자주 통합되지 않을 때 조정하세요.", + "enabled": "활성화됨", + "enabledTip": "장기 통합 없이 원시 세션 요약만 의존하게 하려면 비활성화하세요.", + "threshold": "임계값", + "thresholdTip": "통합 실행이 시작되기 전 최소 미승격 요약 수. 기본값 5.", + "debounceMs": "디바운스 (ms)", + "debounceMsTip": "에이전트/사용자당 실행 간 최소 간격입니다. 기본값 600000 (10분).", + "verboseLog": "상세 로깅", + "verboseLogTip": "운영자 디버깅을 위해 info 수준에서 디바운스 및 임계값 미달 건너뜀을 출력합니다." + }, + "thinking": { + "title": "확장 사고", + "description": "모델이 응답하기 전에 추론할 수 있도록 합니다. 높은 수준은 더 많은 토큰을 사용하지만 복잡한 태스크에서 더 나은 결과를 생성합니다.", + "modeLabel": "추론 모드", + "inherit": "공급자 기본값 사용", + "custom": "이 에이전트를 위한 커스텀", + "customDesc": "이 에이전트에 대해서만 공급자 소유 기본 추론 정책을 재정의합니다.", + "providerDefaultSummary": "{{provider}}에서 상속됨", + "providerLabelFallback": "공급자 기본값", + "noProviderDefault": "아직 공급자 소유 추론 기본값이 저장되지 않았습니다. 상속 모드는 공급자가 기본값을 저장할 때까지 이 에이전트를 추론 꺼짐 상태로 유지합니다.", + "thinkingLevel": "사고 수준", + "thinkingLevelTip": "일반적인 경우를 위한 간단한 프리셋입니다. 모델별 노력 제어나 명시적 폴백 동작이 필요할 때 전문가 모드를 사용하세요.", + "expertMode": "전문가 모드", + "expertModeDesc": "없음, 최소, 자동, xhigh와 같은 모델별 추론 수준을 요청하고 선택된 모델이 지원하지 않을 때 발생하는 일을 제어합니다.", + "supportedLevelsForModel": "선택된 모델 {{model}}은 이러한 명시적 추론 수준을 지원합니다.", + "loadingSupport": "{{model}}에 대한 추론 기능 메타데이터를 불러오는 중.", + "unknownSupport": "GoClaw에는 아직 {{model}}에 대한 명시적 추론 메타데이터가 없습니다.", + "expertModeUnavailable": "고급 추론 제어는 선택된 모델이 공급자 모델 엔드포인트의 명시적 기능 메타데이터를 가진 경우에만 사용 가능합니다.", + "modelDefault": "모델 기본 추론: {{level}}", + "requestedEffort": "요청된 노력", + "requestedEffortTip": "GoClaw가 선택된 모델에 대해 정규화하기 전 요청된 추론 수준입니다.", + "fallbackBehavior": "폴백 동작", + "fallbackBehaviorTip": "요청된 노력이 선택된 모델에서 지원되지 않을 때 GoClaw가 수행해야 할 작업입니다.", + "supportedOption": "이 모델에서 지원됨", + "legacyShim": "GoClaw는 이전 빌드와의 하위 호환성을 위해 거친 thinking_level 심도 저장합니다.", + "off": "끄기", + "offDesc": "확장 사고 없음", + "auto": "자동", + "autoDesc": "모델 기본 추론 노력 사용", + "none": "없음", + "noneDesc": "고급 계약을 유지하면서 공급자 추론을 명시적으로 비활성화", + "minimal": "최소", + "minimalDesc": "없음 이상에서 가장 작은 지원 추론 노력 사용", + "low": "낮음", + "lowDesc": "~4K 토큰 예산", + "medium": "중간", + "mediumDesc": "~10-16K 토큰 예산", + "high": "높음", + "highDesc": "~32K 토큰 예산", + "xhigh": "최대", + "xhighDesc": "지원되는 최대 추론 노력 사용", + "downgrade": "다운그레이드", + "downgradeDesc": "가장 가까운 지원 낮은 수준으로 정규화하거나, 필요 시 가장 낮은 지원 수준으로", + "provider_default": "모델 기본값 사용", + "provider_defaultDesc": "명시적 노력을 강제하지 않고 공급자/모델이 기본값을 선택하도록 함" + }, + "otherConfig": { + "title": "기타 설정", + "description": "다른 섹션에서 다루지 않는 고급 설정을 위한 추가 JSON 설정", + "formatJson": "JSON 포맷", + "invalidJson": "잘못된 JSON 구문" + } + }, + "heartbeat": { + "title": "하트비트", + "loading": "하트비트 불러오는 중...", + "notConfigured": "하트비트 일정이 설정되지 않았습니다. 에이전트를 자동 조종으로 실행하려면 설정하세요.", + "setupButton": "하트비트 설정", + "toggleEnabled": "하트비트 활성화 토글", + "every": "매 {{interval}}분", + "lastRun": "마지막", + "nextRun": "다음", + "runs": "실행", + "suppressed": "억제됨", + "configure": "설정", + "logs": "로그", + "configTitle": "하트비트 설정", + "enabled": "활성화됨", + "enabledHint": "활성화 시 에이전트가 설정된 간격으로 실행됩니다.", + "sectionTiming": "타이밍", + "interval": "간격 (초)", + "intervalHint": "최소 300초 (5분).", + "maxRetries": "최대 재시도", + "maxRetriesHint": "0-10회 실패 시 재시도.", + "ackMaxChars": "확인 최대 문자", + "ackMaxCharsHint": "확인 응답의 최대 문자 수. 0 = 응답 없음.", + "sectionBehavior": "동작", + "isolatedSession": "격리된 세션", + "isolatedSessionHint": "마지막 대화를 계속하지 않고 새 세션에서 실행합니다.", + "lightContext": "가벼운 컨텍스트", + "lightContextHint": "토큰 사용량을 줄이기 위해 전체 컨텍스트 파일 로딩을 건너뜁니다.", + "sectionSchedule": "일정", + "activeHoursStart": "활성 시작 (HH:MM)", + "activeHoursEnd": "활성 종료 (HH:MM)", + "timezone": "시간대", + "sectionModel": "모델", + "modelHint": "하트비트 실행을 위한 공급자/모델을 재정의합니다. 에이전트 기본값을 사용하려면 비워두세요.", + "sectionDelivery": "전달", + "channel": "채널", + "channelPlaceholder": "채널 선택", + "channelNone": "없음 (전달 없음)", + "channelHint": "예: telegram, feishu. 전달 없이는 비워두세요.", + "chatId": "채팅 ID", + "chatIdPlaceholder": "채팅 선택", + "selectChannelFirst": "먼저 채널을 선택하세요", + "checklist": "체크리스트 (HEARTBEAT.md)", + "checklistHint": "각 하트비트 실행에 주입되는 지침입니다. 마크다운을 지원합니다.", + "checklistLoading": "체크리스트 불러오는 중...", + "checklistPlaceholder": "# 하트비트 체크리스트\n\n- 대기 중인 태스크 확인\n- 상태 보고\n", + "on": "켜기", + "off": "끄기", + "notSet": "설정되지 않음", + "disabled": "하트비트 비활성화됨", + "nextIn": "{{time}} 후 다음", + "advancedSettings": "고급 설정", + "scheduleHint": "24/7 운영하려면 비워두세요.", + "testRun": "테스트 실행", + "cancel": "취소", + "save": "저장", + "saving": "저장 중...", + "logsTitle": "하트비트 로그", + "refresh": "새로 고침", + "noLogs": "아직 하트비트 로그 없음.", + "logsPagination": "{{total}}개 중 {{from}}–{{to}}" + }, + "permissions": { + "title": "권한", + "description": "에이전트 설정과 파일을 수정할 수 있는 사람을 제어합니다. 소유자는 항상 전체 접근 권한을 가집니다.", + "addRule": "규칙 추가", + "fileWriters": "파일 작성자", + "configPerms": "설정 권한", + "noRules": "권한 규칙 없음. 소유자가 암묵적 전체 접근 권한을 가집니다.", + "userIdPlaceholder": "연락처 검색 또는 ID 입력...", + "scopePlaceholder": "범위 선택 또는 입력...", + "types": { + "file_writer": "파일 작성자", + "file_writer_desc": "그룹 채팅에서 파일 편집 및 cron 관리 권한을 제어합니다. 첫 번째 사용자가 자동으로 추가됩니다.", + "heartbeat": "하트비트", + "heartbeat_desc": "채팅을 통해 하트비트 일정과 설정을 구성할 수 있는 사람을 제어합니다.", + "cron": "Cron", + "cron_desc": "채팅을 통해 예약된 cron 작업을 만들고 관리할 수 있는 사람을 제어합니다.", + "context_files": "컨텍스트 파일", + "context_files_desc": "채팅을 통해 에이전트 식별 파일(SOUL.md 등)을 수정할 수 있는 사람을 제어합니다.", + "all": "전체 (*)", + "all_desc": "모든 설정 유형에 대한 전체 접근." + }, + "scopes": { + "agent": "에이전트 (DM만)", + "group_all": "모든 그룹", + "global": "전역 (모든 컨텍스트)" + } + }, + "transfer": { + "title": "에이전트 이전", + "description": "에이전트를 아카이브 파일로 내보내거나 기존 아카이브에서 가져옵니다.", + "tabExport": "내보내기", + "tabImport": "가져오기", + "selectAgent": "에이전트 선택", + "selectAgentPlaceholder": "에이전트 선택...", + "presets": "프리셋", + "preset": { + "minimal": "최소", + "standard": "표준", + "complete": "전체" + }, + "sections": { + "config": "에이전트 설정", + "contextFiles": "컨텍스트 파일", + "userData": "사용자 데이터", + "userContextFiles": "사용자 컨텍스트 파일", + "userProfiles": "사용자 프로필", + "userOverrides": "사용자 재정의", + "memory": "메모리", + "memoryGlobal": "전역", + "memoryPerUser": "사용자별", + "knowledgeGraph": "지식 그래프", + "skills": "스킬", + "mcp": "MCP 권한", + "customTools": "커스텀 도구", + "cron": "Cron 작업", + "permissions": "권한", + "sessions": "세션", + "workspace": "워크스페이스 파일", + "team": "팀", + "media": "미디어 파일" + }, + "hints": { + "sessions": "매우 클 수 있음", + "media": "매우 클 수 있음", + "team": "팀 메타데이터, 멤버, 태스크, 링크", + "mergeOverwrite": "동일한 이름의 기존 파일이 덮어써집니다", + "mergeUpsert": "경로로 문서 업서트됨", + "mergeKgUpsert": "external_id로 엔티티 업서트됨", + "mergeFiles": "새 파일 추가, 기존 파일 덮어씀" + }, + "exportButton": "에이전트 내보내기", + "exporting": "\"{{name}}\" 내보내는 중...", + "exportComplete": "내보내기 완료", + "exportFailed": "내보내기 실패", + "exportAnother": "다른 것 내보내기", + "download": "다운로드", + "importing": "가져오는 중...", + "importComplete": "가져오기 완료", + "importFailed": "가져오기 실패", + "importAnother": "다른 것 가져오기", + "viewAgent": "에이전트 보기", + "tryAgain": "다시 시도", + "cancel": "취소", + "dontClose": "이 페이지를 닫지 마세요 — 가져오기 진행 중", + "rolledBack": "모든 변경 사항이 롤백되었습니다. 시스템이 이전 상태입니다.", + "dropHere": ".tar.gz 파일을 여기에 놓거나 클릭하여 찾아보기", + "supportedFormats": "지원: .tar.gz, .agent.json (레거시)", + "invalidFile": "잘못된 파일 — 아카이브를 파싱할 수 없음", + "importMode": "가져오기 모드", + "modeNew": "새 에이전트 만들기", + "modeMerge": "기존 에이전트에 병합", + "targetAgent": "대상 에이전트", + "changeFile": "파일 변경", + "startImport": "가져오기 시작" + }, + "toast": { + "created": "에이전트 생성됨", + "createFailed": "에이전트 생성 실패", + "updated": "에이전트 업데이트됨", + "updateFailed": "에이전트 업데이트 실패", + "deleted": "에이전트 삭제됨", + "deleteFailed": "에이전트 삭제 실패", + "unknownError": "알 수 없는 오류", + "skillGranted": "스킬 부여됨", + "skillGrantFailed": "스킬 부여 실패", + "skillRevoked": "스킬 취소됨", + "skillRevokeFailed": "스킬 취소 실패" + } +} diff --git a/ui/web/src/i18n/locales/ko/api-keys.json b/ui/web/src/i18n/locales/ko/api-keys.json new file mode 100644 index 0000000000..17de264144 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/api-keys.json @@ -0,0 +1,87 @@ +{ + "title": "API 키", + "description": "세밀한 권한이 있는 게이트웨이 API 키 관리.", + "addKey": "API 키 만들기", + "searchPlaceholder": "키 검색...", + "emptyTitle": "API 키 없음", + "emptyDescription": "게이트웨이 인증을 위한 첫 번째 API 키를 만드세요.", + "columns": { + "name": "이름", + "prefix": "키", + "scopes": "범위", + "lastUsed": "마지막 사용", + "created": "생성됨", + "status": "상태", + "actions": "작업" + }, + "status": { + "active": "활성", + "revoked": "취소됨", + "expired": "만료됨" + }, + "form": { + "title": "API 키 만들기", + "name": "이름", + "namePlaceholder": "예: ci-deploy, dashboard-readonly", + "scopes": "범위", + "scopeDescriptions": { + "operator.admin": "전체 관리자 접근", + "operator.read": "읽기 전용 접근", + "operator.write": "읽기 + 쓰기 접근", + "operator.approvals": "실행 승인 관리", + "operator.pairing": "기기 페어링 관리", + "operator.provision": "새 테넌트 프로비전" + }, + "tenant": "테넌트", + "tenantSystem": "시스템 (모든 테넌트)", + "expiry": "만료", + "expiryOptions": { + "never": "만료 없음", + "7d": "7일", + "30d": "30일", + "90d": "90일" + }, + "cancel": "취소", + "create": "만들기", + "creating": "만드는 중..." + }, + "created": { + "title": "API 키 생성됨", + "description": "지금 이 키를 복사하세요 — 다시 표시되지 않습니다.", + "copied": "복사됨!", + "copy": "클립보드에 복사", + "done": "완료" + }, + "revoke": { + "title": "API 키 취소", + "description": "\"{{name}}\"을/를 취소하시겠습니까? 이 작업은 취소할 수 없습니다.", + "confirmLabel": "취소" + }, + "toast": { + "created": "API 키 생성됨", + "revoked": "API 키 취소됨", + "failedCreate": "API 키 생성 실패", + "failedRevoke": "API 키 취소 실패" + }, + "never": "없음", + "neverUsed": "사용된 적 없음", + "tenantBadgeSystem": "시스템", + "tenantBadgeUnknown": "알 수 없음", + "columns.expiry": "만료", + "codeDialog": { + "title": "API 키 사용법", + "description": "게이트웨이 HTTP API 인증에 이 API 키를 사용하세요.", + "copy": "복사", + "copied": "복사됨!", + "tabs": { + "curl": "cURL", + "typescript": "TypeScript", + "go": "Go" + }, + "comments": { + "chat": "에이전트와 채팅 (OpenAI 호환)", + "listAgents": "에이전트 목록", + "listSessions": "세션 목록" + } + } +} diff --git a/ui/web/src/i18n/locales/ko/approvals.json b/ui/web/src/i18n/locales/ko/approvals.json new file mode 100644 index 0000000000..56aa2599d2 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/approvals.json @@ -0,0 +1,33 @@ +{ + "title": "승인", + "description": "대기 중인 실행 승인", + "pending": "{{count}}개 대기 중", + "emptyTitle": "대기 중인 승인 없음", + "emptyDescription": "모든 실행 요청이 처리되었습니다. 새 요청은 실시간으로 여기에 나타납니다.", + "allowOnce": "한 번 허용", + "allowAlways": "항상 허용", + "deny": "거부", + "confirmAllowOnce": { + "title": "한 번 허용", + "description": "에이전트 \"{{agentId}}\"에서 \"{{command}}\" 실행을 허용하시겠습니까?" + }, + "confirmAllowAlways": { + "title": "항상 허용", + "description": "에이전트 \"{{agentId}}\"에 \"{{command}}\"을/를 영구적으로 허용하시겠습니까? 이 명령은 앞으로 자동으로 승인됩니다." + }, + "confirmDeny": { + "title": "실행 거부", + "description": "에이전트 \"{{agentId}}\"에서 요청한 \"{{command}}\" 실행을 거부하시겠습니까?", + "confirmLabel": "거부" + }, + "toast": { + "approved": "승인됨", + "approvedAlways": "명령이 항상 허용됨", + "approvedOnce": "명령이 승인됨", + "approveFailed": "승인 실패", + "denied": "거부됨", + "deniedDesc": "명령이 거부됨", + "denyFailed": "거부 실패", + "unknownError": "알 수 없는 오류" + } +} diff --git a/ui/web/src/i18n/locales/ko/backup.json b/ui/web/src/i18n/locales/ko/backup.json new file mode 100644 index 0000000000..c5a927130b --- /dev/null +++ b/ui/web/src/i18n/locales/ko/backup.json @@ -0,0 +1,126 @@ +{ + "title": "백업 & 복원", + "description": "시스템 백업 생성, 아카이브에서 복원, S3 스토리지 관리", + "tabs": { + "systemBackup": "시스템 백업", + "systemRestore": "시스템 복원", + "s3Config": "S3 스토리지", + "tenantBackup": "테넌트 백업" + }, + "backup": { + "preflight": { + "title": "시스템 확인", + "pgDump": "pg_dump 사용 가능", + "diskSpace": "충분한 디스크 공간", + "dbSize": "데이터베이스 크기", + "dataDir": "데이터 디렉터리", + "workspace": "워크스페이스", + "freeDisk": "여유 디스크 공간", + "warnings": "경고", + "refresh": "새로 고침", + "pgDumpMissing": "pg_dump를 사용할 수 없거나 호환되지 않음", + "goToPackages": "패키지로 이동" + }, + "options": { + "includeDb": "데이터베이스 포함", + "includeFiles": "파일 포함", + "destination": "백업 대상", + "local": "로컬로 다운로드", + "s3": "S3에 업로드" + }, + "start": "백업 시작", + "running": "백업 중...", + "complete": "백업 완료", + "download": "백업 다운로드", + "uploadS3": "S3에 업로드", + "newBackup": "새 백업", + "fileSize": "파일 크기", + "schemaVersion": "스키마 버전", + "s3History": { + "title": "S3 백업", + "empty": "S3에서 백업을 찾을 수 없음", + "name": "파일", + "size": "크기", + "date": "날짜" + }, + "errorTitle": "백업 실패" + }, + "restore": { + "warning": "복원하면 기존 데이터를 덮어씁니다. 이 작업은 취소할 수 없습니다. 먼저 백업을 만드세요.", + "dropzone": ".tar.gz 백업 아카이브를 여기에 놓기", + "dropzoneHint": "또는 클릭하여 찾아보기", + "options": { + "restoreDb": "데이터베이스 복원", + "restoreFiles": "파일 복원", + "dryRun": "미리 보기 (변경 없음)" + }, + "start": "복원", + "confirmTitle": "시스템 복원 확인", + "confirmDesc": "현재 데이터베이스와 파일이 백업 내용으로 덮어쓰입니다. 계속하시겠습니까?", + "running": "복원 중...", + "doNotClose": "복원 중에 이 페이지를 닫지 마세요", + "complete": "복원 완료", + "dryRunNote": "미리 보기 실행이었습니다. 변경사항이 적용되지 않았습니다.", + "dbRestored": "데이터베이스 복원됨", + "filesExtracted": "파일 추출됨", + "bytesExtracted": "데이터 복원됨", + "schemaVersion": "스키마 버전", + "warnings": "경고", + "newRestore": "새 복원", + "errorTitle": "복원 실패", + "tryAgain": "다시 시도" + }, + "s3": { + "title": "S3 스토리지 설정", + "description": "원격 백업을 위한 S3 호환 스토리지 설정", + "status": { + "configured": "설정됨", + "notConfigured": "설정되지 않음" + }, + "fields": { + "accessKeyId": "액세스 키 ID", + "secretAccessKey": "시크릿 액세스 키", + "secretPlaceholder": "업데이트하려면 입력", + "bucket": "버킷", + "region": "리전", + "endpoint": "엔드포인트 (선택사항)", + "endpointHint": "S3 호환 서비스 (MinIO, R2 등)", + "prefix": "키 접두사" + }, + "save": "저장 & 연결 테스트", + "saving": "연결 테스트 중...", + "saveSuccess": "S3 설정이 저장되었고 연결이 확인되었습니다", + "saveError": "S3 설정 저장 실패" + }, + "tenant": { + "title": "테넌트 백업 & 복원", + "description": "개별 테넌트 데이터 백업 또는 복원", + "selectTenant": "테넌트 선택", + "selectTenantPlaceholder": "테넌트 선택...", + "backup": { + "start": "테넌트 백업", + "running": "테넌트 백업 중...", + "complete": "테넌트 백업 완료", + "download": "다운로드", + "tableCounts": "백업된 테이블", + "newBackup": "새 백업" + }, + "restore": { + "mode": "복원 모드", + "modeUpsert": "병합 (upsert)", + "modeReplace": "전체 교체", + "modeNew": "새 테넌트", + "dropzone": "테넌트 백업 아카이브를 여기에 놓기", + "dryRun": "미리 보기 (변경 없음)", + "start": "테넌트 복원", + "confirmTitle": "테넌트 복원 확인", + "confirmDesc": "선택한 테넌트의 데이터가 수정됩니다. 계속하시겠습니까?", + "running": "테넌트 복원 중...", + "complete": "테넌트 복원 완료", + "tablesRestored": "테이블 복원됨", + "filesExtracted": "파일 추출됨", + "dryRunNote": "미리 보기 실행 — 변경사항 없음", + "newRestore": "새 복원" + } + } +} diff --git a/ui/web/src/i18n/locales/ko/channels.json b/ui/web/src/i18n/locales/ko/channels.json new file mode 100644 index 0000000000..7d157afc0b --- /dev/null +++ b/ui/web/src/i18n/locales/ko/channels.json @@ -0,0 +1,487 @@ +{ + "title": "채널", + "description": "채널 인스턴스 관리", + "statusDescription": "통신 채널 상태", + "addChannel": "채널 추가", + "refresh": "새로 고침", + "searchPlaceholder": "채널 검색...", + "emptyTitle": "채널 없음", + "emptyDescription": "첫 번째 채널 인스턴스를 추가하여 시작하세요.", + "emptyStatusDescription": "설정된 통신 채널이 없습니다.", + "noMatchTitle": "일치하는 채널 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "columns": { + "name": "이름", + "type": "유형", + "agent": "에이전트", + "status": "상태", + "enabled": "활성화됨", + "actions": "작업" + }, + "status": { + "running": "실행 중", + "stopped": "중지됨", + "checking": "확인 중", + "degraded": "저하됨", + "starting": "시작 중", + "registered": "설정됨", + "failed": "실패" + }, + "enabled": "활성화됨", + "disabled": "비활성화됨", + "actions": { + "reauthenticate": "재인증", + "authenticate": "채널을 시작하려면 인증", + "reauthShort": "재인증", + "openCredentials": "자격증명 열기", + "openAdvanced": "고급 설정 열기", + "inspect": "문제 검사" + }, + "list": { + "failureStreak": "연속 {{count}}회 실패", + "openChannelDetail": "최신 진단을 위해 채널 상세 정보 열기", + "nextStep": "다음 단계" + }, + "failureKind": { + "auth": "인증", + "config": "설정", + "network": "네트워크", + "unknown": "주의 필요" + }, + "delete": { + "title": "채널 인스턴스 삭제", + "description": "\"{{name}}\"을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "confirmLabel": "삭제" + }, + "form": { + "createTitle": "채널 인스턴스 생성", + "editTitle": "채널 인스턴스 편집", + "key": "키 *", + "keyPlaceholder": "my-telegram-bot", + "keyHint": "채널 식별자로 사용되는 고유 슬러그", + "displayName": "표시 이름", + "displayNamePlaceholder": "판매 봇", + "channelType": "채널 유형 *", + "agent": "에이전트 *", + "selectAgent": "에이전트 선택", + "credentials": "자격증명", + "credentialsHint": "(현재 값을 유지하려면 비워두세요)", + "credentialsEncrypted": "서버 측에서 암호화됩니다. API 응답에 반환되지 않습니다.", + "configuration": "설정", + "enabled": "활성화됨", + "cancel": "취소", + "create": "생성", + "update": "업데이트", + "saving": "저장 중...", + "step": "{{current}} / {{total}} 단계", + "authenticate": "인증 — {{label}}", + "configure": "설정 — {{label}}", + "skip": "건너뛰기", + "done": "완료", + "errors": { + "keyRequired": "키가 필요합니다", + "keySlug": "키는 유효한 슬러그여야 합니다 (소문자, 숫자, 하이픈만 사용)", + "agentRequired": "에이전트가 필요합니다", + "requiredFields": "필수 항목: {{fields}}", + "failedSave": "저장 실패", + "failedSaveConfig": "설정 저장 실패" + }, + "authStatus": { + "authenticated": "인증됨", + "notAuthenticated": "인증되지 않음", + "useQrHint": "— 채널 테이블에서 QR 로그인 버튼을 사용하세요" + } + }, + "detail": { + "agent": "에이전트: {{name}}", + "lastChecked": "마지막 확인: {{value}}", + "checkedRelative": "{{value}} 확인됨", + "advanced": "고급", + "advancedTitle": "고급 설정", + "policies": "정책", + "policiesDesc": "메시지 처리 정책", + "network": "네트워크", + "networkDesc": "서버 및 프록시 설정", + "limits": "제한", + "limitsDesc": "메시지 및 미디어 제약", + "streaming": "스트리밍", + "streamingDesc": "실시간 메시지 스트리밍 옵션", + "behavior": "동작", + "behaviorDesc": "응답 동작 설정", + "accessControl": "접근 제어", + "accessControlDesc": "사용자 접근 제한", + "reviewDiagnostics": "설정을 변경하기 전에 이 채널의 최신 진단을 검토하세요.", + "whatHappened": "무슨 일이 있었는지", + "recommendedAction": "권장 조치", + "technicalDetail": "기술적 세부 사항", + "tabs": { + "general": "일반", + "config": "설정", + "credentials": "자격증명", + "groups": "그룹", + "managers": "관리자" + }, + "general": { + "identity": "식별", + "name": "이름", + "nameHint": "고유 슬러그 (읽기 전용)", + "channelType": "채널 유형", + "displayName": "표시 이름", + "displayNamePlaceholder": "친화적 이름", + "agent": "에이전트", + "selectAgent": "에이전트 선택", + "enabled": "활성화됨", + "saved": "저장됨", + "saveChanges": "변경 사항 저장", + "saving": "저장 중..." + }, + "credentials": { + "hint": "현재 값을 유지하려면 필드를 비워두세요. 자격증명은 서버 측에서 암호화되며 API 응답에 반환되지 않습니다.", + "noSchema": "이 채널 유형에 대한 자격증명 스키마가 없습니다.", + "noCredentials": "업데이트할 자격증명 없음", + "failedSave": "저장 실패", + "saved": "저장됨", + "updateCredentials": "자격증명 업데이트", + "saving": "저장 중..." + }, + "config": { + "noSchema": "이 채널 유형에 대한 설정 스키마가 없습니다.", + "saved": "저장됨", + "saveConfig": "설정 저장", + "saving": "저장 중...", + "webhookUrlLabel": "이것을 Lark 앱의 이벤트 요청 URL로 설정하세요:", + "copyPath": "경로 복사" + }, + "groups": { + "saved": "저장됨", + "saveGroups": "그룹 저장", + "saving": "저장 중..." + }, + "timeline": { + "title": "타임라인", + "firstFailed": "첫 실패", + "lastChecked": "마지막 확인", + "failures": "실패", + "failureStreak": "연속 {{count}}회", + "failureTotal": "총 {{count}}회", + "lastHealthy": "마지막 정상", + "noData": "아직 최근 채널 확인 기록이 없습니다." + }, + "managers": { + "description": "그룹 채팅에서 관리자 권한을 가진 사용자를 관리합니다.", + "groups": "그룹", + "noManagerGroups": "관리자 그룹 없음", + "noManagerGroupsHint": "아래 양식을 사용하여 그룹에 관리자를 추가하세요.", + "loadingManagers": "관리자 불러오는 중...", + "noManagers": "이 그룹에 관리자가 없습니다.", + "managersCount": "{{count}}명 관리자", + "managersCountPlural": "{{count}}명 관리자", + "searchContacts": "연락처 검색...", + "noResults": "연락처를 찾을 수 없음", + "columns": { + "userId": "사용자 ID", + "name": "이름", + "username": "사용자명" + }, + "addForm": { + "title": "새 그룹에 관리자 추가", + "hint": "위 목록에 없는 그룹에 관리자를 추가합니다.", + "groupId": "그룹 ID *", + "groupIdPlaceholder": "예: group:telegram:-100123456", + "userId": "사용자 ID *", + "userIdPlaceholder": "검색하거나 사용자 ID 입력", + "displayName": "표시 이름", + "username": "사용자명", + "optional": "선택사항", + "usernameWithout": "선택사항 (@ 제외)", + "adding": "추가 중...", + "addManager": "관리자 추가", + "add": "추가", + "errors": { + "groupUserRequired": "그룹 ID와 사용자 ID가 필요합니다", + "failedAdd": "관리자 추가 실패" + } + } + } + }, + "fieldConfig": { + "token": { "label": "봇 토큰", "help": "@BotFather에서 받은 토큰" }, + "bot_token": { "label": "봇 토큰" }, + "app_token": { + "label": "앱 레벨 토큰", + "help": "connections:write 범위의 앱 레벨 토큰 (소켓 모드에 필요)" + }, + "user_token": { + "label": "사용자 토큰 (선택사항)", + "help": "선택사항: 커스텀 봇 식별을 위한 사용자 OAuth 토큰. 기본 봇 식별을 사용하려면 비워두세요." + }, + "app_id": { "label": "앱 ID" }, + "app_secret": { "label": "앱 시크릿" }, + "encrypt_key": { "label": "암호화 키", "help": "웹훅 모드용" }, + "verification_token": { + "label": "인증 토큰", + "help": "웹훅 모드용" + }, + "webhook_secret": { "label": "웹훅 시크릿" }, + "api_server": { + "label": "API 서버 URL", + "help": "대용량 파일 업로드(최대 2GB)를 위한 커스텀 Telegram Bot API 서버. 기본값을 사용하려면 비워두세요." + }, + "proxy": { + "label": "HTTP 프록시", + "help": "HTTP 프록시를 통해 봇 트래픽 라우팅" + }, + "dm_policy": { "label": "DM 정책" }, + "group_policy": { "label": "그룹 정책" }, + "require_mention": { + "label": "그룹에서 @멘션 필요", + "disabledHint": "멀티봇 모드는 항상 모든 메시지를 수신합니다" + }, + "mention_mode": { + "label": "그룹 응답 동작", + "help": "여러 봇이 있는 그룹에서 봇이 응답할 시기를 결정하는 방법입니다." + }, + "history_limit": { + "label": "그룹 기록 제한", + "help": "컨텍스트용 최대 대기 그룹 메시지 수 (0 = 비활성화)" + }, + "dm_stream": { + "label": "DM 스트리밍", + "help": "DM에서 응답을 점진적으로 스트리밍" + }, + "group_stream": { + "label": "그룹 스트리밍", + "help": "그룹에서 응답을 점진적으로 스트리밍" + }, + "draft_transport": { + "label": "임시 미리보기", + "help": "DM에서 답변 스트림에 스텔스 임시 미리보기 사용 — 편집당 알림 없음 (DM 스트리밍 필요)" + }, + "reasoning_stream": { + "label": "추론 표시", + "help": "답변 전에 AI 사고를 별도 메시지로 표시 (스트리밍 필요)" + }, + "reaction_level": { "label": "반응 수준" }, + "media_max_mb": { "label": "최대 미디어 크기 (MB)" }, + "link_preview": { "label": "링크 미리보기" }, + "allow_from": { "label": "허용된 사용자" }, + "block_reply": { + "label": "블록 응답", + "help": "도구 반복 중 중간 텍스트 전달" + }, + "domain": { "label": "도메인" }, + "connection_mode": { + "label": "연결 모드", + "help": "WebSocket은 공개 IP가 필요 없습니다 — 아웃바운드 연결만 사용" + }, + "webhook_port": { + "label": "웹훅 포트", + "help": "0 = 메인 게이트웨이 포트 공유 (권장)" + }, + "webhook_path": { + "label": "웹훅 경로", + "help": "Lark 이벤트를 위한 메인 서버 경로" + }, + "webhook_url": { "label": "웹훅 URL" }, + "topic_session_mode": { + "label": "토픽 세션 모드", + "help": "세션 격리를 위해 스레드 root_id 사용" + }, + "render_mode": { "label": "렌더링 모드" }, + "text_chunk_limit": { + "label": "텍스트 청크 제한", + "help": "메시지당 최대 문자 수" + }, + "group_allow_from": { + "label": "그룹 허용된 사용자", + "help": "그룹 발신자를 위한 별도 허용 목록" + }, + "native_stream": { + "label": "네이티브 스트리밍 (에이전트 & AI 앱)", + "help": "네이티브 스트리밍을 위해 Slack의 ChatStreamer API 사용. 사용 불가 시 편집-인-플레이스로 폴백됩니다." + }, + "debounce_delay": { + "label": "디바운스 지연 (ms)", + "help": "빠른 메시지 처리 전 대기할 밀리초입니다. 비활성화하려면 0으로 설정하세요." + }, + "thread_ttl": { + "label": "스레드 참여 TTL (시간)", + "help": "참여한 스레드에서 봇이 자동 응답을 중지하기까지의 시간입니다. 0 = 항상 @멘션 필요." + }, + "skills": { + "label": "스킬 필터", + "help": "이 그룹에서 사용 가능한 스킬 제한" + }, + "tools": { + "label": "도구 허용 목록", + "help": "이 그룹에서 에이전트가 사용할 수 있는 도구 제한" + }, + "system_prompt": { "label": "시스템 프롬프트" } + }, + "fieldOptions": { + "block_reply": { + "inherit": "게이트웨이에서 상속", + "true": "활성화됨", + "false": "비활성화됨" + }, + "dm_policy": { + "pairing": "페어링 (코드 필요)", + "open": "개방 (모두 허용)", + "allowlist": "허용 목록만", + "disabled": "비활성화됨" + }, + "group_policy": { + "open": "개방 (모두 허용)", + "pairing": "페어링 (승인 필요)", + "allowlist": "허용 목록만", + "disabled": "비활성화됨" + }, + "mention_mode": { + "strict": "기본 (@멘션 설정 따름)", + "yield": "멀티봇 (다른 봇이 @멘션되지 않으면 응답)" + }, + "reaction_level": { + "off": "끄기", + "minimal": "최소", + "full": "전체" + }, + "domain": { + "lark": "Lark (글로벌)", + "feishu": "Feishu (중국)" + }, + "connection_mode": { + "webhook": "웹훅", + "websocket": "WebSocket (권장)" + }, + "topic_session_mode": { + "disabled": "비활성화됨", + "enabled": "활성화됨" + }, + "render_mode": { + "auto": "자동", + "raw": "원본", + "card": "카드" + } + }, + "groupOverrides": { + "title": "그룹 & 토픽 재정의", + "hint": "그룹 채팅 또는 포럼 토픽별로 채널 기본값을 재정의합니다. 와일드카드 기본값으로 \"*\"를 그룹 ID로 사용하세요.", + "groupLabel": "그룹: {{id}}", + "groupWildcard": "* (와일드카드)", + "addGroupPlaceholder": "채팅 ID (예: -100123456) 또는 *", + "addGroup": "그룹 추가", + "knownGroups": "알려진 그룹", + "topicOverrides": "토픽 재정의", + "topicLabel": "토픽: {{id}}", + "addTopicPlaceholder": "토픽 스레드 ID", + "addTopic": "토픽 추가", + "fieldConfig": { + "dm_stream": { + "label": "DM 스트리밍", + "help": "DM에서 응답을 점진적으로 스트리밍" + }, + "group_stream": { + "label": "그룹 스트리밍", + "help": "그룹에서 응답을 점진적으로 스트리밍" + }, + "draft_transport": { + "label": "임시 미리보기", + "help": "DM에서 답변 스트림에 스텔스 임시 미리보기 사용 — 편집당 알림 없음 (DM 스트리밍 필요)" + }, + "reasoning_stream": { + "label": "추론 표시", + "help": "답변 전에 AI 사고를 별도 메시지로 표시 (스트리밍 필요)" + } + }, + "fields": { + "groupPolicy": "그룹 정책", + "requireMention": "@멘션 필요", + "enabled": "활성화됨", + "allowedUsers": "허용된 사용자", + "allowedUsersPlaceholder": "사용자 ID를 줄당 하나 또는 쉼표로 구분", + "skillsFilter": "스킬 필터", + "skillsPlaceholder": "줄당 하나의 스킬명 (비어있으면 상속)", + "toolAllow": "도구 허용", + "toolAllowPlaceholder": "허용할 도구 선택 (비어있으면 모두 상속)...", + "toolAllowHint": "이 그룹에서 사용 가능한 도구를 제한합니다. \"group:web\", \"group:fs\" 같은 그룹명을 지원합니다. 비어있으면 모든 도구 상속.", + "systemPrompt": "시스템 프롬프트", + "systemPromptPlaceholder": "이 그룹/토픽을 위한 추가 시스템 프롬프트", + "inherit": "상속 (채널 기본값)", + "yes": "예", + "no": "아니오" + } + }, + "wizard": { + "zaloPersonal": { + "createLabel": "생성 & 인증", + "formBanner": "생성 후 QR 코드를 통해 인증하고 허용된 사용자를 설정합니다." + }, + "whatsapp": { + "createLabel": "생성 & QR 스캔", + "formBanner": "생성 후 WhatsApp으로 QR 코드를 스캔하여 인증합니다." + } + }, + "fallback": { + "authRequiredSummary": "인증 필요", + "authRequiredDetail": "채널 인스턴스가 활성화되었지만 연결 전에 로그인이 필요합니다.", + "authRequiredHeadline": "채널 세션 재연결", + "authRequiredHint": "현재 세션을 복원하려면 이 채널을 다시 인증하세요.", + "missingCredentialsSummary": "자격증명 없음", + "missingCredentialsDetail": "채널 인스턴스가 활성화되었지만 필수 자격증명이 불완전합니다.", + "missingCredentialsHeadline": "필수 자격증명 완성", + "missingCredentialsHint": "자격증명을 열고 이 채널의 누락되거나 잘못된 값을 입력하세요." + }, + "scopes": { + "title": "필수 API 권한", + "description": "Lark/Feishu 앱은 개발자 콘솔에서 이 범위들이 활성화되어야 합니다. 권한 추가 후 새 앱 버전을 게시하세요.", + "publishReminder": "범위 추가 후 연락처 범위를 \"모든 멤버\"로 설정하고 새 앱 버전을 게시하여 변경 사항을 적용하세요." + }, + "toast": { + "created": "채널 생성됨", + "createdDesc": "{{name}}이 추가되었습니다", + "updated": "채널 업데이트됨", + "deleted": "채널 삭제됨", + "failedCreate": "채널 생성 실패", + "failedUpdate": "채널 업데이트 실패", + "failedDelete": "채널 삭제 실패" + }, + "zalo": { + "allowedUsers": "허용된 사용자", + "loadContacts": "연락처 불러오기", + "loading": "불러오는 중...", + "searchContacts": "연락처 검색...", + "friends": "친구", + "groups": "그룹", + "membersCount": "{{count}}명 멤버", + "noContacts": "연락처를 찾을 수 없음", + "noContactsMatch": "\"{{search}}\"와 일치하는 연락처 없음", + "addManualPlaceholder": "ID 수동 추가", + "add": "추가", + "zaloIdsHint": "Zalo 사용자 ID 또는 그룹 ID", + "completeQrLogin": "연락처를 불러오려면 QR 로그인을 완료하세요", + "loginQr": "QR로 로그인 — {{name}}", + "loginSuccessful": "로그인 성공! 채널 시작 중...", + "generatingQr": "QR 코드 생성 중...", + "scanHint": "Zalo 앱으로 스캔 (약 100초 후 만료)", + "loginSuccessLoading": "로그인 성공! 연락처 불러오는 중...", + "retry": "재시도", + "close": "닫기", + "skip": "건너뛰기" + }, + "whatsapp": { + "loginSuccessLoading": "✅ WhatsApp 연결됨! 채널 불러오는 중...", + "waitingForQr": "QR 코드 대기 중...", + "scanHint": "WhatsApp → 나 탭 → 연결된 기기 → 기기 연결", + "initializing": "초기화 중...", + "skip": "건너뛰기", + "retry": "재시도", + "close": "닫기", + "reauthTitle": "WhatsApp 연결 — {{name}}", + "reauthDescription": "WhatsApp으로 QR 코드를 스캔하여 기기를 연결하세요.", + "alreadyLinked": "✅ 기기가 이미 연결됨", + "alreadyLinkedDetail": "WhatsApp이 연결되어 있습니다. 다른 기기를 연결하려면 재연결을 클릭하세요 — 현재 세션이 로그아웃됩니다.", + "relinkDevice": "기기 재연결", + "connectedSuccess": "✅ WhatsApp이 성공적으로 연결되었습니다!", + "tabQrCode": "QR 코드" + } +} diff --git a/ui/web/src/i18n/locales/ko/chat.json b/ui/web/src/i18n/locales/ko/chat.json new file mode 100644 index 0000000000..afd53d2653 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/chat.json @@ -0,0 +1,33 @@ +{ + "deleteChat": "채팅 삭제", + "deleteChatConfirm": "이 대화와 모든 메시지가 영구적으로 삭제됩니다.", + "newChat": "새 채팅", + "openSessions": "세션 열기", + "readOnly": "읽기 전용 — 이 세션은 다른 사용자의 것입니다", + "contextUsage": { + "tooltip": "{{used}} / {{max}} 토큰 ({{percent}}%) · {{compactions}}회 압축 · 마지막: {{lastCompact}}", + "never": "없음" + }, + "empty": { + "title": "대화 시작", + "description": "에이전트와 채팅을 시작하려면 메시지를 보내세요." + }, + "media": { + "image": "이미지", + "video": "동영상", + "audio": "오디오", + "voice": "음성 메시지", + "document": "문서", + "animation": "애니메이션", + "attached": "첨부됨" + }, + "forwardedFrom": "전달됨 (from)", + "replyingTo": "답장 대상", + "error": { + "notConnected": "연결되지 않았습니다. 연결이 설정될 때까지 기다려 주세요." + }, + "selectAgent": { + "title": "에이전트 선택", + "description": "채팅할 에이전트를 선택하세요" + } +} diff --git a/ui/web/src/i18n/locales/ko/cli-credentials.json b/ui/web/src/i18n/locales/ko/cli-credentials.json new file mode 100644 index 0000000000..5754b87e3e --- /dev/null +++ b/ui/web/src/i18n/locales/ko/cli-credentials.json @@ -0,0 +1,114 @@ +{ + "title": "CLI 자격증명", + "description": "자격증명이 주입된 에이전트용 보안 CLI 바이너리를 관리합니다.", + "addCredential": "자격증명 추가", + "emptyTitle": "CLI 자격증명 없음", + "emptyDescription": "CLI 자격증명을 추가하여 에이전트가 인증된 CLI 명령을 실행할 수 있게 하세요.", + "columns": { + "binary": "바이너리", + "scope": "범위", + "timeout": "타임아웃", + "restricted": "제한됨" + }, + "delete": { + "title": "CLI 자격증명 삭제", + "description": "\"{{name}}\"을 삭제하시겠습니까? 이를 사용하는 에이전트는 접근 권한을 잃게 됩니다.", + "confirm": "삭제" + }, + "form": { + "createTitle": "CLI 자격증명 추가", + "editTitle": "CLI 자격증명 편집", + "preset": "프리셋", + "presetPlaceholder": "프리셋 선택 (선택사항)", + "noPreset": "프리셋 없음 (수동)", + "presetHint": "프리셋을 선택하면 필드가 자동으로 채워지고 필수 환경 변수가 표시됩니다.", + "encryptedHint": "값은 암호화되며 표시되지 않습니다. 변수명은 아래에 나열됩니다. 저장된 시크릿을 유지하려면 값을 비워두고, 새 값으로 교체하려면 입력하세요.", + "envVars": "환경 변수", + "binaryName": "바이너리 이름", + "binaryPath": "바이너리 경로", + "denyArgs": "차단 인수", + "denyVerbose": "차단 상세 인수", + "timeout": "타임아웃 (초)", + "tips": "팁", + "agentId": "에이전트", + "agentIdHint": "선택사항 — 전역으로 사용하려면 비워두세요", + "commaSeparated": "쉼표로 구분", + "binaryNameRequired": "바이너리 이름이 필요합니다.", + "failedToSave": "저장 실패.", + "addEnvVar": "변수 추가", + "noEnvVarsHint": "\"변수 추가\"를 클릭하여 이 CLI 도구의 환경 변수를 정의하세요.", + "envKeyPlaceholder": "ENV_VAR_NAME", + "envValuePlaceholder": "값", + "invalidEnvKey": "\"{{key}}\"는 유효한 환경 변수 이름이 아닙니다 (A-Z, 0-9, 언더스코어 사용).", + "binaryPathHint": "PATH에서 자동 감지하려면 비워두세요", + "checkBinary": "확인", + "binaryFound": "{{path}}에서 발견됨", + "binaryNotFound": "서버 PATH에서 바이너리를 찾을 수 없음", + "checking": "확인 중...", + "isGlobal": "모든 에이전트에서 사용 가능", + "isGlobalHint": "비활성화 시, 명시적 권한이 있는 에이전트만 이 CLI를 사용할 수 있습니다" + }, + "placeholders": { + "binaryName": "예: gh", + "binaryPath": "/usr/local/bin/...", + "description": "이 CLI 도구의 역할", + "denyArgs": "--admin, --force", + "denyVerbose": "-v, --verbose", + "tips": "에이전트를 위한 사용 팁", + "agentId": "전역 (모든 에이전트)" + }, + "toast": { + "created": "CLI 자격증명 생성됨", + "createdDesc": "\"{{name}}\"이 성공적으로 추가되었습니다.", + "createFailed": "자격증명 생성 실패", + "updated": "CLI 자격증명 업데이트됨", + "updateFailed": "자격증명 업데이트 실패", + "deleted": "CLI 자격증명 삭제됨", + "deleteFailed": "자격증명 삭제 실패" + }, + "userCredentials": { + "title": "사용자 자격증명", + "description": "{{name}}에 대한 사용자별 환경 변수 재정의", + "userId": "사용자 ID", + "userIdPlaceholder": "user-id 또는 이메일", + "env": "환경 변수", + "addEnv": "변수 추가", + "add": "추가", + "save": "저장", + "edit": "편집", + "delete": "삭제", + "back": "뒤로", + "close": "닫기", + "empty": "사용자 자격증명 없음", + "saved": "사용자 자격증명 저장됨", + "saveFailed": "사용자 자격증명 저장 실패", + "deleted": "사용자 자격증명 삭제됨", + "deleteFailed": "사용자 자격증명 삭제 실패", + "envRequired": "환경 변수가 하나 이상 필요합니다", + "mergeHint": "채팅 사용자(Telegram, Discord 등)는 사용자별 자격증명을 갖기 전에 연락처 페이지에서 테넌트 사용자로 먼저 병합되어야 합니다." + }, + "grants": { + "title": "에이전트 권한 — {{name}}", + "currentGrants": "현재 권한", + "addGrant": "권한 추가", + "editGrant": "권한 편집", + "selectAgent": "에이전트 선택...", + "usingDefaults": "바이너리 기본값 사용", + "overrideTimeout": "타임아웃 재정의 (초)", + "overrideDenyArgs": "차단 인수 재정의", + "overrideDenyVerbose": "차단 상세 인수 재정의", + "overrideTips": "팁 재정의", + "defaultPlaceholder": "기본값", + "grant": "권한 부여", + "update": "업데이트", + "agentRequired": "에이전트를 선택해 주세요", + "toast": { + "granted": "에이전트 권한 생성됨", + "grantFailed": "권한 생성 실패", + "updated": "에이전트 권한 업데이트됨", + "updateFailed": "권한 업데이트 실패", + "revoked": "에이전트 권한 취소됨", + "revokeFailed": "권한 취소 실패" + } + } +} diff --git a/ui/web/src/i18n/locales/ko/common.json b/ui/web/src/i18n/locales/ko/common.json new file mode 100644 index 0000000000..ebefa74faf --- /dev/null +++ b/ui/web/src/i18n/locales/ko/common.json @@ -0,0 +1,139 @@ +{ + "refresh": "새로 고침", + "cancel": "취소", + "confirm": "확인", + "delete": "삭제", + "save": "저장", + "edit": "편집", + "create": "만들기", + "add": "추가", + "view": "보기", + "filter": "필터", + "search": "검색", + "retry": "재시도", + "manage": "관리", + "uploadLabel": "업로드", + "close": "닫기", + "back": "뒤로", + "update": "업데이트", + "enabled": "사용 중", + "disabled": "사용 안 함", + "yes": "예", + "no": "아니오", + "loading": "불러오는 중...", + "saving": "저장 중...", + "creating": "만드는 중...", + "connecting": "연결 중...", + "connected": "연결됨", + "disconnected": "연결 끊김", + "running": "실행 중", + "stopped": "정지됨", + "pending": "대기 중", + "completed": "완료됨", + "failed": "실패", + "cancelled": "취소됨", + "unknown": "알 수 없음", + "active": "활성", + "items": "항목", + "rows": "행", + "pageOf": "{{page}} / {{totalPages}} 페이지", + "messages": "메시지", + "cached": "캐시됨", + "live": "실시간", + "noDescription": "설명 없음", + "unsavedChanges": "저장되지 않은 변경사항", + "tryDifferentSearch": "다른 검색어를 사용해 보세요.", + "notSet": "설정되지 않음", + "default": "기본값", + "optional": "선택사항", + "required": "필수", + "actions": "작업", + "status": "상태", + "name": "이름", + "description": "설명", + "type": "유형", + "none": "없음", + "all": "전체", + "global": "전체", + "agent": "에이전트", + "saved": "저장됨", + "saveChanges": "변경사항 저장", + "saveConfig": "설정 저장", + "checkAndCreate": "확인 & 만들기", + "checking": "확인 중...", + "format": "JSON 형식 지정", + "invalidJson": "유효하지 않은 JSON 구문", + "usingGlobalDefaults": "config.json의 전체 기본값 사용 중", + "disconnectedGateway": "게이트웨이 연결이 끊겼습니다. 재연결 시도 중...", + "serverUnreachable": "서버에 연결할 수 없음", + "serverUnreachableDesc": "게이트웨이 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인하세요.", + "reconnecting": "재연결 중...", + "noFiles": "파일이 없습니다.", + "selectFileToView": "파일을 선택하면 내용을 볼 수 있습니다", + "typeToConfirmPrefix": "확인을 위해", + "typeToConfirmSuffix": "을/를 입력하세요.", + "searchPlaceholder": "검색...", + "noSessions": "세션이 없습니다. 채팅을 시작하세요!", + "noAgentsAvailable": "사용 가능한 에이전트 없음", + "selectAgent": "에이전트 선택", + "sendMessage": "메시지 보내기... (Shift+Enter로 줄바꿈)", + "stopGeneration": "생성 중지", + "sendMessageTitle": "메시지 보내기", + "sendFollowUp": "후속 메시지 보내기", + "attachFile": "파일 첨부", + "thinking": "생각 중", + "thinkingStreaming": "생각 중...", + "toolArguments": "인수", + "toolResult": "결과", + "skillActivating": "활성화 중...", + "skillActivated": "활성화됨", + "toolRunning": "실행 중...", + "toolDone": "완료", + "toolFailed": "실패", + "filesBack": "파일", + "noProvidersConfigured": "설정된 공급자 없음", + "loadingModels": "모델 불러오는 중...", + "modelVerified": "모델 확인됨", + "verificationFailed": "확인 실패", + "provider": "공급자", + "model": "모델", + "selectProvider": "공급자 선택", + "enterOrSelectModel": "모델 입력 또는 선택", + "useCustomModel": "사용자 지정 모델:", + "providerTip": "LLM 공급자 이름. 설정된 공급자와 일치해야 합니다.", + "modelTip": "사용할 모델 ID.", + "check": "확인", + "builtinTools": "내장", + "customTools": "사용자 지정", + "selectOrTypeTools": "도구 이름 선택 또는 입력...", + "selectOrTypeSkills": "스킬 이름 선택 또는 입력...", + "typeAndPressEnter": "입력 후 Enter를 누르세요...", + "copyCode": "코드 복사", + "copy": "복사", + "download": "다운로드", + "errorBoundary": "이 섹션을 렌더링하는 중 오류가 발생했습니다.", + "csvRows": "csv ({{count}}행)", + "failedToLoadImage": "이미지를 불러오지 못했습니다", + "deleteFolder": "폴더 삭제", + "deleteFile": "파일 삭제", + "loadMore": "더 불러오기...", + "viewCard": "카드 보기", + "viewList": "목록 보기", + "done": "완료", + "upload": { + "title": "파일 업로드", + "dropOrClick": "파일을 여기에 끌어다 놓거나 클릭하여 찾아보기", + "dropHere": "파일을 여기에 끌어다 놓기", + "maxSize": "파일당 최대 50 MB", + "blockedType": "{{ext}} 파일 유형은 허용되지 않습니다", + "tooLarge": "파일이 50 MB 제한을 초과합니다", + "failed": "업로드 실패", + "readyCount": "{{total}}개 중 {{ready}}개 파일 준비됨", + "successCount": "{{total}}개 중 {{success}}개 파일 업로드됨", + "uploading": "업로드 중...", + "uploadCount": "{{count}}개 파일 업로드" + }, + "errors": { + "serverError": "오류가 발생했습니다. 나중에 다시 시도해 주세요." + } +} diff --git a/ui/web/src/i18n/locales/ko/config.json b/ui/web/src/i18n/locales/ko/config.json new file mode 100644 index 0000000000..e99c9e697d --- /dev/null +++ b/ui/web/src/i18n/locales/ko/config.json @@ -0,0 +1,315 @@ +{ + "title": "설정", + "description": "게이트웨이, 에이전트 기본값, 도구 및 통합 관리", + "noConfig": "설정이 로드되지 않음", + "noConfigDescription": "설정 파일을 불러올 수 없습니다. 게이트웨이에 연결할 수 없을 수 있습니다.", + "retry": "재시도", + "warning": "여기서의 변경사항은 모든 에이전트와 채널에 전역으로 영향을 줍니다. 잘못된 설정은 게이트웨이를 중단시킬 수 있습니다.", + "save": "저장", + "saving": "저장 중...", + "saved": "저장됨", + "failedToSave": "설정 저장 실패", + "saveConfig": "설정 저장", + "usingGlobalDefaults": "전역 기본값 사용", + + "tabs.server": "서버", + "tabs.behavior": "동작", + "tabs.aiDefaults": "AI 기본값", + "tabs.quota": "할당량", + "tabs.tools": "도구", + "tabs.integrations": "통합", + + "server.title": "게이트웨이 서버", + "server.description": "네트워크 바인딩, 인증 토큰 및 접근 제어", + + "gateway.host": "호스트", + "gateway.hostTip": "게이트웨이가 수신하는 네트워크 인터페이스입니다. 모든 인터페이스에서 수신하려면 0.0.0.0을 사용하세요.", + "gateway.port": "포트", + "gateway.portTip": "게이트웨이 WebSocket 및 HTTP 서버가 바인딩하는 TCP 포트입니다.", + "gateway.token": "인증 토큰", + "gateway.tokenTip": "모든 클라이언트 연결에 필요한 베어러 토큰입니다. 인증을 비활성화하려면 비워두세요.", + "gateway.tokenManaged": "토큰은 환경 변수를 통해 관리되며 여기서 편집할 수 없습니다.", + "gateway.ownerIds": "소유자 ID", + "gateway.ownerIdsTip": "관리자 수준 접근 권한이 부여된 사용자 ID입니다. 여러 ID는 Enter로 구분하세요.", + "gateway.ownerIdsPlaceholder": "소유자 ID 입력...", + "gateway.allowedOrigins": "허용된 출처", + "gateway.allowedOriginsTip": "HTTP API를 위한 CORS 허용 출처입니다. 모든 출처를 허용하려면 *를 사용하세요.", + "gateway.allowedOriginsPlaceholder": "https://yourdomain.com", + "gateway.toolStatus": "도구 상태 메시지", + "gateway.blockReply": "중간 응답", + "gateway.maxMessageChars": "최대 메시지 문자", + "gateway.maxMessageCharsTip": "인바운드 메시지당 허용되는 최대 문자 수입니다. 이 한도를 초과하는 메시지는 거부됩니다.", + "gateway.rateLimitRpm": "속도 제한 (RPM)", + "gateway.rateLimitRpmTip": "사용자당 분당 최대 요청 수입니다. 속도 제한을 비활성화하려면 0으로 설정하세요.", + "gateway.inboundDebounceMs": "인바운드 디바운스 (ms)", + "gateway.inboundDebounceMsTip": "새 메시지를 처리하기 전 대기할 밀리초입니다. 빠른 후속 메시지를 통합하기 위해 사용합니다. 비활성화하려면 -1로 설정하세요.", + "gateway.injectionAction": "주입 감지 조치", + + "agents.title": "에이전트 기본값", + "agents.description": "재정의하지 않는 한 모든 에이전트에 적용되는 기본 LLM 공급자, 모델 및 런타임 설정", + "agents.providerTip": "에이전트가 지정하지 않을 때 사용되는 기본 LLM 공급자입니다.", + "agents.modelTip": "에이전트가 지정하지 않을 때 사용되는 기본 모델입니다.", + "agents.maxTokens": "최대 토큰", + "agents.maxTokensTip": "모델이 단일 응답에서 생성할 수 있는 최대 토큰 수입니다.", + "agents.temperature": "온도", + "agents.temperatureTip": "샘플링 온도 (0-2). 낮을수록 더 결정론적인 출력이 생성됩니다.", + "agents.maxToolIterations": "최대 도구 반복", + "agents.maxToolIterationsTip": "에이전트 요청당 최대 도구 호출 반복 횟수입니다.", + "agents.contextWindow": "컨텍스트 창", + "agents.contextWindowTip": "모델 컨텍스트 창의 총 토큰 예산입니다.", + "agents.workspace": "워크스페이스 경로", + "agents.workspaceTip": "에이전트 파일 작업의 루트 디렉터리입니다. 사용자별 하위 디렉터리가 자동으로 생성됩니다.", + "agents.restrictToWorkspace": "워크스페이스로 제한", + "agents.restrictToWorkspaceTip": "에이전트가 워크스페이스 경로 외부의 파일을 읽거나 쓸 수 없도록 합니다.", + "agents.intentClassify": "의도 분류", + "agents.saveError": "에이전트 기본값 저장 실패", + + "agents.subagents.title": "서브에이전트", + "agents.subagents.desc": "서브에이전트 생성 제한", + "agents.subagents.maxConcurrent": "최대 동시", + "agents.subagents.maxConcurrentTip": "동시에 실행할 수 있는 서브에이전트의 최대 수입니다.", + "agents.subagents.maxSpawnDepth": "최대 생성 깊이", + "agents.subagents.maxSpawnDepthTip": "서브에이전트 체인의 최대 중첩 깊이입니다.", + "agents.subagents.maxChildrenPerAgent": "에이전트당 최대 자식", + "agents.subagents.maxChildrenPerAgentTip": "단일 에이전트가 생성할 수 있는 최대 자식 서브에이전트 수입니다.", + "agents.subagents.archiveAfterMin": "보관 시간 (분)", + "agents.subagents.archiveAfterMinTip": "서브에이전트 세션이 보관되기까지의 비활성 시간(분)입니다.", + "agents.subagents.modelOverride": "모델 재정의", + "agents.subagents.modelOverrideTip": "모든 서브에이전트가 특정 모델을 사용하도록 강제합니다. 부모 에이전트에서 상속하려면 비워두세요.", + "agents.subagents.modelOverridePlaceholder": "(에이전트에서 상속)", + + "agents.memory.title": "메모리", + "agents.memory.desc": "의미 메모리 검색 및 임베딩", + "agents.memory.enabled": "활성화됨", + "agents.memory.embeddingProvider": "임베딩 공급자", + "agents.memory.embeddingModel": "임베딩 모델", + "agents.memory.embeddingProviderTip": "메모리 저장을 위한 텍스트 임베딩 생성에 사용되는 공급자입니다.", + "agents.memory.embeddingModelTip": "임베딩 모델명입니다. 공급자 기본값을 사용하려면 비워두세요.", + "agents.memory.maxResults": "최대 결과", + "agents.memory.maxResultsTip": "회수 쿼리당 반환되는 최대 메모리 항목 수입니다.", + "agents.memory.maxChunkLen": "최대 청크 길이", + "agents.memory.maxChunkLenTip": "분할 전 메모리 청크당 최대 문자 수입니다.", + "agents.memory.chunkOverlap": "청크 겹침", + "agents.memory.chunkOverlapTip": "컨텍스트 연속성을 위한 청크 간 겹침 문자 수입니다.", + "agents.memory.minScore": "최소 점수", + "agents.memory.minScoreTip": "결과에 포함될 메모리 항목의 최소 유사도 점수 (0-1)입니다.", + + "agents.compaction.title": "압축", + "agents.compaction.desc": "컨텍스트 창 압축 설정", + "agents.compaction.reserveTokensFloor": "예약 토큰 하한", + "agents.compaction.reserveTokensFloorTip": "압축 전 새 응답을 위해 예약할 최소 토큰 버퍼입니다.", + "agents.compaction.maxHistoryShare": "최대 기록 비율 (0-1)", + "agents.compaction.maxHistoryShareTip": "대화 기록이 차지할 수 있는 컨텍스트 창의 비율입니다.", + + "agents.pruning.title": "컨텍스트 정리", + "agents.pruning.desc": "컨텍스트 공간 확보를 위해 오래된 도구 결과 제거", + "agents.pruning.mode": "모드", + "agents.pruning.keepLastAssistants": "마지막 어시스턴트 유지", + "agents.pruning.keepLastAssistantsTip": "정리 중 항상 보존할 최근 어시스턴트 턴 수입니다.", + + "agents.sandbox.title": "샌드박스", + "agents.sandbox.desc": "코드 실행을 위한 Docker 샌드박스", + "agents.sandbox.mode": "모드", + "agents.sandbox.image": "Docker 이미지", + "agents.sandbox.imageTip": "샌드박스 컨테이너에 사용되는 Docker 이미지입니다.", + "agents.sandbox.memoryMb": "메모리 (MB)", + "agents.sandbox.memoryMbTip": "샌드박스 컨테이너의 메모리 제한(메가바이트)입니다.", + "agents.sandbox.cpus": "CPU", + "agents.sandbox.cpusTip": "샌드박스 컨테이너의 CPU 할당량 (예: 1.0 = 코어 하나).", + "agents.sandbox.timeoutSec": "타임아웃 (초)", + "agents.sandbox.timeoutSecTip": "샌드박스가 종료되기 전 최대 실행 시간(초)입니다.", + "agents.sandbox.networkEnabled": "네트워크 활성화됨", + + "behavior.uxTitle": "UX 동작", + "behavior.uxDescription": "게이트웨이가 사용자와 상호작용하는 방식을 변경하는 주요 토글", + "behavior.toolStatusHint": "에이전트 실행 중 사용자에게 실시간 도구 실행 상태 메시지를 표시합니다.", + "behavior.toolStatusInfo": "에이전트가 파일을 읽고, 명령을 실행하고, API를 호출하는 동안 사용자에게 라이브 업데이트가 표시됩니다.", + "behavior.blockReplyHint": "도구가 호출되는 동안 채널에 부분 어시스턴트 텍스트를 전송합니다.", + "behavior.blockReplyInfo": "도구 실행 중 사용자에게 중간 텍스트가 전달됩니다. 최종 응답만이 아닙니다.", + "behavior.intentClassifyHint": "불필요한 에이전트 호출을 줄이기 위해 라우팅 전에 사용자 의도를 분류합니다.", + "behavior.intentClassifyInfo": "에이전트는 분류기가 실행 가능한 의도를 감지할 때만 호출됩니다.", + "behavior.pendingCompactionTitle": "대기 메시지 압축", + "behavior.pendingCompactionDescription": "버퍼가 임계값을 초과할 때 LLM을 사용하여 이전 그룹 메시지를 자동으로 요약합니다", + "behavior.pendingCompactionThreshold": "임계값", + "behavior.pendingCompactionThresholdTip": "그룹에 이 수보다 많은 대기 메시지가 누적되면 압축을 트리거합니다. 기본값(50)을 사용하려면 0으로 설정하세요.", + "behavior.pendingCompactionKeepRecent": "최근 유지", + "behavior.pendingCompactionKeepRecentTip": "압축 후 유지할 최근 원본 메시지 수입니다. 오래된 메시지는 LLM 요약으로 대체됩니다. 기본값은 15입니다.", + "behavior.pendingCompactionMaxTokens": "최대 토큰", + "behavior.pendingCompactionMaxTokensTip": "LLM 요약의 최대 출력 토큰입니다. 기본값은 4096입니다.", + "behavior.pendingCompactionProvider": "공급자", + "behavior.pendingCompactionProviderTip": "요약을 위한 LLM 공급자입니다. 채널 에이전트의 공급자를 사용하려면 비워두세요.", + "behavior.pendingCompactionProviderPlaceholder": "에이전트 공급자 사용", + "behavior.pendingCompactionModel": "모델", + "behavior.pendingCompactionModelTip": "요약을 위한 모델입니다. 채널 에이전트의 모델을 사용하려면 비워두세요.", + "behavior.pendingCompactionModelPlaceholder": "에이전트 모델 사용", + "behavior.pendingCompactionInfo": "그룹 채팅이 임계값을 초과하면 LLM이 오래된 메시지를 단일 압축 항목으로 자동 요약하여 컨텍스트를 위해 가장 최근 메시지를 유지합니다.", + "behavior.rateLimitTitle": "속도 제한", + "behavior.rateLimitDescription": "인바운드 메시지 크기 제한 및 사용자당 요청 쓰로틀링", + "behavior.sessionsTitle": "세션 범위", + "behavior.sessionsDescription": "사용자와 채널 간에 대화 컨텍스트가 분할되는 방식을 제어합니다", + "behavior.securityTitle": "입력 보안", + "behavior.securityDescription": "게이트웨이를 보호하기 위한 프롬프트 주입 감지 및 자격증명 스크러빙", + "behavior.injectionActionHint": "인바운드 메시지에서 프롬프트 주입 시도가 감지될 때 수행할 작업입니다.", + "behavior.injectionBlockInfo": "주입 시도로 표시된 모든 메시지는 차단되고 사용자에게 알림이 전송됩니다.", + "behavior.injectionOffInfo": "주입 감지가 비활성화되어 있습니다. 악의적인 프롬프트는 필터링되지 않습니다.", + "behavior.scrubCredentialsHint": "에이전트에 반환하기 전에 도구 출력에서 발견된 API 키, 토큰 및 비밀번호를 자동으로 수정합니다.", + "behavior.scrubCredentialsInfo": "도구 응답의 자격증명 패턴은 에이전트가 보기 전에 마스킹됩니다.", + "behavior.scrubCredentialsOffInfo": "자격증명 스크러빙이 비활성화되어 있습니다. 도구 출력의 시크릿이 에이전트에 노출될 수 있습니다.", + + "sessions.scope": "세션 범위", + "sessions.scopeTip": "세션 분할 방법을 결정합니다. '발신자별'은 각 사용자에게 별도 세션을 제공하고, '전역'은 모든 사용자가 하나의 세션을 공유합니다.", + "sessions.dmScope": "DM 범위", + "sessions.dmScopeTip": "DM 세션이 키로 구분되는 방법입니다. '채널 피어별'이 권장 기본값입니다.", + + "quota.title": "사용 할당량", + "quota.description": "선택적 공급자, 채널 및 그룹별 재정의가 있는 사용자별 메시지 할당량 제한", + "quota.enabled": "할당량 활성화", + "quota.enabledTip": "활성화 시 사용자는 아래에 정의된 메시지 수로 제한됩니다.", + "quota.defaultLimits": "기본 제한", + "quota.defaultLimitsTip": "특정 재정의가 적용되지 않는 모든 사용자에 적용되는 폴백 할당량입니다.", + "quota.hour": "시간당", + "quota.hourTip": "시간당 허용되는 최대 메시지 수입니다.", + "quota.day": "일당", + "quota.dayTip": "일당 허용되는 최대 메시지 수입니다.", + "quota.week": "주당", + "quota.weekTip": "주당 허용되는 최대 메시지 수입니다.", + "quota.keyLabel": "키", + "quota.addOverride": "재정의 추가", + "quota.allOptionsAdded": "모든 옵션이 이미 추가됨", + "quota.providerOverrides": "공급자 재정의", + "quota.providerOverridesTip": "LLM 공급자별 커스텀 할당량 제한입니다. 사용자가 특정 공급자를 통해 상호작용할 때 기본값을 재정의합니다.", + "quota.channelOverrides": "채널 재정의", + "quota.channelOverridesTip": "채널 유형별 커스텀 할당량 제한 (예: telegram, discord).", + "quota.groupOverrides": "그룹 재정의", + "quota.groupOverridesTip": "특정 그룹 채팅에 대한 커스텀 할당량 제한입니다.", + "quota.selectProvider": "공급자 선택", + "quota.selectChannel": "채널 선택", + "quota.selectGroup": "그룹 선택", + + "tools.title": "도구 프로파일", + "tools.description": "모든 에이전트에 대한 기본 도구 가용성 및 시간당 호출 속도 제한", + "tools.profile": "프로파일", + "tools.profileTip": "사전 정의된 도구 세트입니다. '최소'는 안전한 읽기 전용 도구만 노출하고, '전체'는 모든 도구를 활성화합니다.", + "tools.profilePlaceholder": "프로파일 선택...", + "tools.rateLimitPerHour": "속도 제한 (시간당)", + "tools.rateLimitPerHourTip": "에이전트당 시간당 허용되는 최대 도구 호출 수입니다. 비활성화하려면 0으로 설정하세요.", + "tools.allow": "허용", + "tools.allowTip": "선택된 프로파일에 포함되지 않은 추가 도구를 명시적으로 허용합니다.", + "tools.allowPlaceholder": "허용할 도구 선택...", + "tools.deny": "차단", + "tools.denyTip": "선택된 프로파일에 포함되어 있어도 도구를 명시적으로 차단합니다.", + "tools.denyPlaceholder": "차단할 도구 선택...", + "tools.alsoAllow": "추가 허용", + "tools.alsoAllowTip": "프로파일 위에 추가로 허용할 도구입니다. 제한적인 프로파일을 사용할 때 유용합니다.", + "tools.alsoAllowPlaceholder": "추가 도구 선택...", + "tools.execApproval": "명령 실행", + "tools.execSecurity": "보안 모드", + "tools.execSecurityTip": "에이전트가 실행할 수 있는 셸 명령을 제어합니다. '허용 목록'은 승인된 패턴으로 제한합니다.", + "tools.execAskMode": "요청 모드", + "tools.execAskModeTip": "명령 실행 전에 사용자 승인을 요청할 시기입니다.", + "tools.execAllowlistLabel": "허용된 명령 (줄당 하나의 패턴)", + "tools.webSearch": "웹 검색", + "tools.webFetch": "웹 가져오기 및 브라우저 자동화", + "tools.maxResults": "최대 결과", + "tools.braveApiKey": "API 키", + "tools.braveApiKeyTip": "Brave 검색을 사용하려면 필요합니다. https://brave.com/search/api/에서 키를 생성하세요.", + "tools.braveApiKeyPlaceholder": "BSA…", + "tools.braveApiKeyManaged": "서버에 이미 키가 저장되어 있으며 여기서 표시할 수 없습니다.", + "tools.webFetchPolicy": "웹 가져오기 정책", + "tools.webFetchPolicyTip": "에이전트가 가져올 수 있는 URL을 제어합니다. '허용 목록'은 승인된 도메인으로만 제한합니다.", + "tools.allowedDomains": "허용된 도메인", + "tools.blockedDomains": "차단된 도메인", + "tools.blockedDomainsTip": "정책에 관계없이 항상 차단되는 도메인입니다. 줄당 하나씩, 와일드카드 지원 (*.example.com).", + "tools.browser": "브라우저 자동화", + "tools.browserEnabled": "활성화됨", + "tools.browserHeadless": "헤드리스", + "tools.scrubCredentials": "자격증명 스크러빙", + + "tts.title": "텍스트 음성 변환", + "tts.configured": "설정됨", + "tts.disabled": "비활성화됨", + "tts.providerInfo": "공급자: {{provider}} — 자동: {{auto}}", + "tts.noProvider": "TTS 공급자가 설정되지 않았습니다.", + "tts.manageLink": "TTS 설정 관리", + + "cron.title": "Cron 스케줄러", + "cron.description": "실패한 예약 작업의 재시도 동작", + "cron.maxRetries": "최대 재시도", + "cron.maxRetriesTip": "실패한 cron 태스크의 최대 재시도 횟수입니다.", + "cron.baseDelay": "기본 지연", + "cron.baseDelayTip": "재시도 간 초기 백오프 지연입니다 (예: 2s, 500ms).", + "cron.maxDelay": "최대 지연", + "cron.maxDelayTip": "재시도 간 최대 백오프 지연입니다 (예: 30s, 5m).", + "cron.defaultTimezone": "기본 시간대", + "cron.defaultTimezoneTip": "작업별로 설정되지 않은 경우 cron 표현식의 IANA 시간대입니다 (예: Asia/Seoul). 서버 시스템 시간대를 사용하려면 비워두세요.", + "cron.defaultTimezonePlaceholder": "시스템 시간대", + + "telemetry.title": "텔레메트리 (OpenTelemetry)", + "telemetry.description": "OpenTelemetry 호환 백엔드로 추적 및 메트릭 내보내기", + "telemetry.enabled": "활성화됨", + "telemetry.enabledTip": "OpenTelemetry 추적 내보내기를 활성화합니다. 'otel' 빌드 태그가 필요합니다.", + "telemetry.endpoint": "엔드포인트", + "telemetry.endpointTip": "OTLP 내보내기 엔드포인트 (host:port). gRPC 기본값은 4317, HTTP는 4318입니다.", + "telemetry.protocol": "프로토콜", + "telemetry.protocolTip": "OTLP 내보내기의 전송 프로토콜입니다.", + "telemetry.serviceName": "서비스 이름", + "telemetry.serviceNameTip": "추적에 보고되는 서비스 이름입니다. 관찰 가능성 백엔드에서 필터링할 때 유용합니다.", + "telemetry.insecure": "비보안", + "telemetry.insecureTip": "OTLP 연결의 TLS를 비활성화합니다. 로컬/개발 환경에서만 사용하세요.", + "telemetry.headers": "내보내기 헤더", + "telemetry.headersTip": "각 OTLP 내보내기 요청과 함께 전송되는 추가 HTTP 헤더입니다 (예: 호스팅 백엔드의 API 키).", + "telemetry.headerKeyPlaceholder": "헤더 이름", + "telemetry.headerValuePlaceholder": "헤더 값", + "telemetry.addHeader": "헤더 추가", + + "bindings.title": "에이전트 바인딩", + "bindings.description": "특정 채널, 피어 또는 계정을 지정된 에이전트로 라우팅합니다", + "bindings.add": "바인딩 추가", + "bindings.noBindings": "바인딩이 설정되지 않았습니다. 모든 메시지는 기본 에이전트로 라우팅됩니다.", + "bindings.agentId": "에이전트 ID", + "bindings.channel": "채널", + "bindings.peerKind": "피어 종류", + "bindings.peerId": "피어 ID", + + "providers.title": "공급자 API 키", + "providers.description": "설정된 LLM 공급자의 API 키와 기본 URL을 재정의합니다", + "providers.noProviders": "설정 파일에서 공급자를 찾을 수 없습니다.", + "providers.apiKey": "API 키", + "providers.apiBaseUrl": "기본 URL", + "providers.apiBaseUrlPlaceholder": "https://api.example.com/v1", + "providers.managedVia": "{{envKey}} 환경 변수를 통해 관리됩니다.", + + "channels.title": "채널 설정", + "channels.description": "내장 채널 통합 설정 (토큰, 정책, 스트리밍)", + "channels.noChannels": "설정 파일에서 채널을 찾을 수 없습니다.", + "channels.enabled": "활성화됨", + "channels.token": "토큰 / 시크릿", + "channels.managedVia": "{{envKey}} 환경 변수를 통해 관리됩니다.", + "channels.dmPolicy": "DM 정책", + "channels.groupPolicy": "그룹 정책", + "channels.allowFrom": "허용 출처", + "channels.allowFromPlaceholder": "user_id_1, user_id_2, ...", + "channels.dmStreaming": "DM 스트리밍", + "channels.dmStreamingHint": "DM에서 점진적 메시지 업데이트", + "channels.groupStreaming": "그룹 스트리밍", + "channels.groupStreamingHint": "그룹에서 점진적 메시지 업데이트", + "channels.streaming": "스트리밍", + "channels.draftTransport": "임시 미리보기", + "channels.draftTransportHint": "DM에서 스텔스 스트리밍 미리보기 (편집당 알림 없음)", + "channels.reasoningStream": "추론 표시", + "channels.reasoningStreamHint": "AI 사고 과정을 별도 메시지로 표시", + "channels.reactionLevel": "반응 수준", + "channels.connectionMode": "연결 모드", + "channels.reactionOff": "끄기", + "channels.reactionMinimal": "최소", + "channels.reactionFull": "전체", + "channels.connectionWebsocket": "WebSocket", + "channels.connectionWebhook": "웹훅", + + "bindings.any": "모두", + + "toast": { + "saved": "설정 저장됨", + "saveFailed": "설정 저장 실패" + } +} diff --git a/ui/web/src/i18n/locales/ko/contacts.json b/ui/web/src/i18n/locales/ko/contacts.json new file mode 100644 index 0000000000..2d5e9c54df --- /dev/null +++ b/ui/web/src/i18n/locales/ko/contacts.json @@ -0,0 +1,58 @@ +{ + "title": "연락처", + "description": "모든 채널 상호작용에서 자동으로 수집된 연락처.", + "searchPlaceholder": "이름, 사용자명 또는 ID로 검색...", + "filter": "필터", + "emptyTitle": "연락처 없음", + "emptyDescription": "사용자가 채널을 통해 에이전트와 상호작용하면 여기에 연락처가 나타납니다.", + "noMatchTitle": "일치하는 연락처 없음", + "noMatchDescription": "다른 검색어나 필터를 사용해 보세요.", + "columns": { + "name": "이름", + "username": "사용자명", + "senderId": "발신자 ID", + "channelType": "채널", + "peerKind": "유형", + "lastSeen": "마지막 접속", + "merged": "병합됨" + }, + "permissionsNote": { + "title": "연락처 이름에 대한 채널 권한", + "telegram": "Telegram — 이름과 사용자명이 자동으로 제공됩니다.", + "discord": "Discord — 표시 이름과 사용자명이 자동으로 제공됩니다.", + "zalo": "Zalo — 표시 이름이 자동으로 제공됩니다.", + "slack": "Slack — Slack API를 통해 표시 이름이 해석됩니다 (봇이 워크스페이스에 있어야 함).", + "feishu": "Feishu/Lark — contact:user.base:readonly 권한이 필요합니다. 없으면 이름이 비어 있습니다." + }, + "selectedCount": "{{count}}개 선택됨", + "merge": { + "button": "병합", + "unmergeButton": "병합 해제", + "dialogTitle": "연락처 병합", + "dialogDescription": "선택한 연락처를 테넌트 사용자 신원에 연결합니다. 이를 통해 여러 채널에서 동일한 사람으로 식별됩니다.", + "linkExisting": "기존 사용자에 연결", + "createNew": "새 사용자 만들기", + "selectUser": "테넌트 사용자 선택...", + "displayName": "표시 이름", + "displayNamePlaceholder": "예: 홍길동", + "userId": "사용자 ID", + "userIdPlaceholder": "고유 식별자", + "confirm": "병합", + "cancel": "취소", + "success": "연락처가 성공적으로 병합되었습니다", + "unmergeSuccess": "연락처 병합이 성공적으로 해제되었습니다", + "error": "연락처 병합 실패", + "unmergeError": "연락처 병합 해제 실패", + "comingSoon": "이 기능은 개발 중이며 아직 사용할 수 없습니다." + }, + "types": { + "user": "사용자", + "group": "그룹" + }, + "filters": { + "allChannels": "모든 채널", + "allTypes": "모든 유형", + "direct": "직접", + "group": "그룹" + } +} diff --git a/ui/web/src/i18n/locales/ko/cron.json b/ui/web/src/i18n/locales/ko/cron.json new file mode 100644 index 0000000000..1cf17e0d9f --- /dev/null +++ b/ui/web/src/i18n/locales/ko/cron.json @@ -0,0 +1,149 @@ +{ + "title": "Cron", + "description": "반복 에이전트 태스크 스케줄", + "newJob": "새 작업", + "emptyTitle": "Cron 작업 없음", + "emptyDescription": "Cron 작업을 만들어 에이전트의 반복 태스크를 스케줄하세요.", + "columns": { + "enabled": "사용", + "name": "이름", + "schedule": "스케줄", + "message": "메시지", + "agent": "에이전트", + "actions": "작업" + }, + "runNow": "지금 실행", + "running": "실행 중...", + "runHistory": "실행 기록", + "defaultAgent": "기본", + "searchPlaceholder": "Cron 작업 검색...", + "noMatchTitle": "일치하는 작업 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "card": { + "nextRun": "다음: {{time}}", + "noNextRun": "다음 실행 없음", + "defaultAgent": "기본 에이전트" + }, + "create": { + "title": "Cron 작업 만들기", + "name": "이름", + "namePlaceholder": "my-daily-task", + "nameHint": "소문자, 숫자, 하이픈만", + "agentId": "에이전트 ID (선택사항)", + "agentIdPlaceholder": "기본값", + "scheduleType": "스케줄 유형", + "every": "반복", + "cron": "Cron", + "once": "한 번", + "intervalSeconds": "간격 (초)", + "cronExpression": "Cron 표현식", + "cronHint": "표준 5자리 cron: 분 시 일 월 요일", + "onceDesc": "이 작업은 지금부터 약 1분 후에 한 번 실행됩니다.", + "message": "메시지", + "messagePlaceholder": "에이전트가 무엇을 해야 하나요?", + "cancel": "취소", + "create": "만들기", + "creating": "만드는 중..." + }, + "enable": { + "title": "Cron 작업 활성화", + "description": "\"{{name}}\"을/를 활성화하시겠습니까? 스케줄에 따라 실행이 시작됩니다.", + "confirmLabel": "활성화" + }, + "disable": { + "title": "Cron 작업 비활성화", + "description": "\"{{name}}\"을/를 비활성화하시겠습니까? 다시 활성화할 때까지 실행이 중지됩니다.", + "confirmLabel": "비활성화" + }, + "delete": { + "title": "Cron 작업 삭제", + "description": "\"{{name}}\"을/를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "confirmLabel": "삭제" + }, + "runLog": { + "title": "실행 로그: {{name}}", + "noHistory": "아직 실행 기록 없음.", + "loading": "불러오는 중..." + }, + "detail": { + "runNow": "지금 실행", + "running": "실행 중...", + "disable": "비활성화", + "enable": "활성화", + "delete": "삭제", + "enabled": "활성화됨", + "disabled": "비활성화됨", + "runningStatus": "실행 중...", + "payload": "페이로드", + "empty": "(비어 있음)", + "deliverDirect": "전달: 직접", + "channel": "채널: {{name}}", + "to": "대상: {{name}}", + "edit": "편집", + "preview": "미리보기", + "noMessage": "설정된 메시지 없음", + "lastError": "마지막 오류", + "runHistory": "실행 기록", + "refresh": "새로 고침", + "noHistory": "아직 실행 기록 없음.", + "inOut": "입력 {{input}} / 출력 {{output}}", + "infoRows": { + "schedule": "스케줄", + "timezone": "시간대", + "nextRun": "다음 실행", + "lastRun": "마지막 실행", + "lastStatus": "마지막 상태", + "created": "생성됨", + "updated": "업데이트됨", + "autoDelete": "자동 삭제", + "autoDeleteValue": "예 (1회)" + }, + "tabs": { + "overview": "개요", + "history": "실행 기록" + }, + "advanced": "고급", + "saveFailed": "저장 실패", + "schedule": "스케줄", + "scheduleDesc": "타이밍 설정", + "messageSection": "메시지", + "messageSectionDesc": "에이전트가 해야 할 일", + "agentStatus": "에이전트 & 상태", + "agentStatusDesc": "에이전트 할당 및 작업 상태", + "info": "정보", + "infoDesc": "작업 메타데이터", + "scheduling": "스케줄링", + "schedulingDesc": "시간대 설정", + "delivery": "전달", + "deliveryDesc": "작업 출력을 채널이나 사용자에게 라우팅", + "deliverToChannel": "채널로 전달", + "channelLabel": "채널", + "channelPlaceholder": "#general", + "toLabel": "대상 (사용자)", + "toPlaceholder": "user-id", + "wakeHeartbeat": "실행 시 하트비트 깨우기", + "wakeHeartbeatDesc": "작업 실행 시 에이전트 하트비트 트리거", + "lifecycle": "라이프사이클", + "lifecycleDesc": "작업 라이프사이클 설정", + "deleteAfterRun": "실행 후 삭제", + "deleteAfterRunDesc": "첫 번째 성공적인 실행 후 이 작업 자동 삭제", + "timezone": "시간대", + "timezoneDesc": "Cron 표현식의 시간대" + }, + "stateless": "상태 없음", + "statelessHelp": "세션 지속성 건너뜁니다. 각 실행은 이전 메시지 없이 새로 시작됩니다. 이전 컨텍스트가 필요 없는 cron의 토큰을 절약합니다.", + "toast": { + "created": "Cron 작업 생성됨", + "createdDesc": "{{name}}이/가 추가되었습니다", + "enabled": "Cron 작업 활성화됨", + "disabled": "Cron 작업 비활성화됨", + "deleted": "Cron 작업 삭제됨", + "triggered": "Cron 작업 트리거됨", + "failedCreate": "Cron 작업 생성 실패", + "failedToggle": "Cron 작업 토글 실패", + "failedDelete": "Cron 작업 삭제 실패", + "failedRun": "Cron 작업 실행 실패", + "updated": "작업 업데이트됨", + "failedUpdate": "작업 업데이트 실패" + } +} diff --git a/ui/web/src/i18n/locales/ko/events.json b/ui/web/src/i18n/locales/ko/events.json new file mode 100644 index 0000000000..f3dadd6741 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/events.json @@ -0,0 +1,36 @@ +{ + "title": "실시간 이벤트", + "description": "모든 팀 및 에이전트의 실시간 WebSocket 이벤트", + "live": "실시간", + "paused": "일시 정지됨", + "resume": "재개", + "pause": "일시 정지", + "clear": "지우기", + "scrollToBottom": "맨 아래로 스크롤", + "eventCount": "{{count}}개 이벤트", + "eventCountPlural": "{{count}}개 이벤트", + "categories": { + "all": "전체", + "task": "태스크", + "message": "메시지", + "agent": "에이전트", + "teamCrud": "팀 CRUD", + "agentLink": "에이전트 링크" + }, + "filters": { + "allTeams": "모든 팀", + "allUsers": "모든 사용자", + "allChats": "모든 채팅" + }, + "global": "전체", + "emptyTitle": "이벤트 없음", + "emptyPaused": "이벤트 캡처가 일시 정지되었습니다. 재개하면 새 이벤트가 표시됩니다.", + "emptyWaiting": "실시간 이벤트를 기다리는 중... 에이전트가 작업하면 이벤트가 나타납니다.", + "detail": { + "copy": "복사", + "copied": "복사됨" + }, + "toolBadge": { "error": "오류", "activated": "활성화됨", "ok": "확인" }, + "moreArgs": "...{{count}}개 더", + "parentLabel": "부모:" +} diff --git a/ui/web/src/i18n/locales/ko/import-export.json b/ui/web/src/i18n/locales/ko/import-export.json new file mode 100644 index 0000000000..241c324ce7 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/import-export.json @@ -0,0 +1,131 @@ +{ + "title": "가져오기 & 내보내기", + "description": "에이전트를 이식 가능한 아카이브로 내보내거나 가져옵니다.", + "beta": "베타", + "betaWarning": "가져오기 & 내보내기는 베타 버전입니다. 기존 에이전트로 가져오기 전에 항상 백업을 유지하세요.", + "tabs": { + "teams": "팀", + "agents": "에이전트", + "skillsMcp": "스킬 & MCP", + "export": "내보내기", + "import": "가져오기" + }, + "comingSoon": { + "title": "준비 중", + "description": "이 섹션은 아직 사용할 수 없습니다." + }, + "export": { + "title": "에이전트 내보내기", + "agent": "에이전트", + "agentPlaceholder": "에이전트 선택", + "sections": "섹션", + "preset": "프리셋", + "presetsLabel": "프리셋", + "infoNote": "팀과 함께 에이전트를 내보내려면 팀 탭을 사용하세요.", + "startExport": "내보내기", + "exporting": "내보내는 중...", + "preview": "미리보기", + "previewLoading": "미리보기 불러오는 중...", + "download": "다운로드", + "done": "내보내기 완료", + "errorTitle": "내보내기 실패", + "selectAgent": "내보낼 에이전트를 선택하세요.", + "noAgents": "사용 가능한 에이전트 없음." + }, + "import": { + "title": "에이전트 가져오기", + "mode": "모드", + "modeNew": "새 에이전트 만들기", + "modeMerge": "기존에 병합", + "agentKey": "에이전트 키", + "agentKeyPlaceholder": "예: my-agent", + "agentKeyHint": "소문자, 숫자, 하이픈. 비워두면 아카이브의 키를 사용합니다.", + "targetAgent": "대상 에이전트", + "targetAgentPlaceholder": "대상 에이전트 선택", + "file": "아카이브 파일 (.tar.gz)", + "filePlaceholder": "클릭하여 선택하거나 끌어다 놓기", + "sections": "병합할 섹션", + "startImport": "가져오기", + "importing": "가져오는 중...", + "done": "가져오기 완료", + "agentCreated": "에이전트 생성됨: {{key}}", + "mergedInto": "병합됨: {{key}}", + "errorTitle": "가져오기 실패", + "manifestLabel": "아카이브 정보", + "agentFrom": "아카이브의 에이전트: {{key}}", + "exportedAt": "내보내기 날짜: {{date}}" + }, + "sections": { + "config": "설정", + "configDesc": "에이전트 설정 (모델, 도구 등)", + "context_files": "컨텍스트 파일", + "context_filesDesc": "에이전트 수준 컨텍스트 파일 (SOUL.md, IDENTITY.md 등)", + "user_data": "사용자 데이터", + "user_dataDesc": "모든 사용자별 데이터 (컨텍스트 파일, 프로필, 재정의)", + "user_context_files": "사용자 컨텍스트 파일", + "user_context_filesDesc": "사용자별 컨텍스트 파일 (사용자당 USER.md)", + "user_profiles": "사용자 프로필", + "user_profilesDesc": "사용자 워크스페이스 프로필 레코드", + "user_overrides": "사용자 재정의", + "user_overridesDesc": "사용자별 모델/공급자 재정의", + "memory": "메모리", + "memoryDesc": "모든 메모리 문서 (전체 + 사용자별)", + "memory_global": "전체 메모리", + "memory_globalDesc": "공유 메모리 문서", + "memory_per_user": "사용자별 메모리", + "memory_per_userDesc": "개별 사용자 메모리 문서", + "knowledge_graph": "지식 그래프", + "knowledge_graphDesc": "KG 엔티티 및 관계", + "cron": "Cron 작업", + "cronDesc": "예약된 태스크 (비활성화 상태로 가져옴)", + "workspace": "워크스페이스 파일", + "workspaceDesc": "에이전트 워크스페이스 디렉터리 파일", + "sessions": "세션", + "sessionsDesc": "채팅 세션 및 메시지", + "media": "미디어", + "mediaDesc": "업로드된 미디어 파일", + "comingSoon": "준비 중" + }, + "presets": { + "minimal": "최소", + "standard": "표준", + "complete": "전체" + }, + "teamExportNote": "팀 내보내기에는 모든 멤버 에이전트와 해당 데이터(컨텍스트 파일, 메모리, 지식 그래프, cron, 워크스페이스)가 포함됩니다.", + "teamSelectPlaceholder": "팀 선택...", + "teamDropHere": "팀 아카이브 파일을 여기에 놓거나 클릭하여 찾아보기", + "teamDropFormats": ".tar.gz 파일 지원", + "teamDontClose": "이 페이지를 닫지 마세요 — 가져오기 진행 중", + "teamImportAnother": "다른 것 가져오기", + "teamTryAgain": "다시 시도", + "teamChangeFile": "파일 변경", + "teamPreview": { + "members": "{{count}}명 멤버", + "agents": "{{count}}개 에이전트", + "tasks": "{{count}}개 태스크", + "links": "{{count}}개 링크", + "loading": "미리보기 불러오는 중...", + "error": "미리보기 로드 실패" + }, + "skillsMcp": { + "skillsTab": "스킬", + "mcpTab": "MCP 서버", + "skillsNote": "사용자 지정 스킬만 내보냅니다 (시스템 스킬 제외).", + "mcpNote": "MCP 서버 설정은 API 키, 환경 변수, 헤더 없이 내보냅니다. 가져오기 후 시크릿을 다시 입력해야 합니다.", + "customSkills": "{{count}}개 사용자 지정 스킬", + "grants": "{{count}}개 에이전트 권한", + "servers": "{{count}}개 서버", + "noSkills": "내보낼 사용자 지정 스킬이 없습니다.", + "noServers": "내보낼 MCP 서버가 없습니다.", + "exportSkills": "스킬 내보내기", + "exportMcp": "MCP 서버 내보내기", + "importSkills": "스킬 가져오기", + "importMcp": "MCP 서버 가져오기", + "importedSkills": "스킬 가져오기 완료", + "skippedSkills": "스킬 건너뜀 (이미 존재)", + "importedServers": "서버 가져오기 완료", + "skippedServers": "서버 건너뜀 (이미 존재)", + "dropSkills": "스킬 아카이브 파일을 여기에 놓거나 클릭하여 찾아보기", + "dropMcp": "MCP 아카이브 파일을 여기에 놓거나 클릭하여 찾아보기" + } +} diff --git a/ui/web/src/i18n/locales/ko/login.json b/ui/web/src/i18n/locales/ko/login.json new file mode 100644 index 0000000000..bd061b55e8 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/login.json @@ -0,0 +1,41 @@ +{ + "subtitle": "게이트웨이 대시보드에 로그인", + "selectTenant": "테넌트 선택", + "selectTenantDescription": "계속하려면 테넌트를 선택하세요", + "allTenantsOption": "모든 테넌트", + "allTenantsDescription": "모든 테넌트의 데이터 보기", + "noAccess": "접근 불가", + "noAccessDescription": "접근 가능한 테넌트가 없습니다. 관리자에게 문의하세요.", + "tabs": { + "token": "토큰", + "pairing": "페어링" + }, + "token": { + "userId": "사용자 ID", + "userIdPlaceholder": "your-user-id", + "userIdHint": "전체 시스템 접근은 \"system\" 사용", + "gatewayToken": "게이트웨이 토큰", + "tokenPlaceholder": "Bearer 토큰", + "connect": "연결", + "connecting": "연결 중...", + "errorInvalidCredentials": "인증 정보가 올바르지 않습니다. 토큰과 사용자 ID를 확인하세요.", + "errorServer": "서버에서 {{status}}를 반환했습니다. 다시 시도해 주세요.", + "errorCannotConnect": "게이트웨이에 연결할 수 없습니다. 서버가 실행 중인지 확인하세요." + }, + "pairing": { + "userId": "사용자 ID", + "userIdPlaceholder": "your-user-id", + "noTokenNeeded": "토큰 불필요. 관리자가 승인할 페어링 코드가 생성됩니다.", + "requestAccess": "접근 요청", + "connecting": "연결 중...", + "errorConnectionLost": "연결이 끊겼습니다. 다시 시도해 주세요.", + "errorCannotConnect": "게이트웨이에 연결할 수 없습니다.", + "askAdmin": "관리자에게 이 코드를 승인해 달라고 요청하세요:", + "orRun": "또는 실행:", + "waitingForApproval": "승인 대기 중...", + "cancel": "취소", + "approved": "접근이 승인되었습니다! 리디렉션 중..." + }, + "noAccessHint": "관리자에게 테넌트에 추가해 달라고 요청하거나 다른 계정을 사용하세요.", + "logout": "로그아웃" +} diff --git a/ui/web/src/i18n/locales/ko/logs.json b/ui/web/src/i18n/locales/ko/logs.json new file mode 100644 index 0000000000..0e516d3bc5 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/logs.json @@ -0,0 +1,15 @@ +{ + "title": "로그", + "description": "실시간 로그 스트리밍", + "descriptionLive": "{{level}} 레벨 실시간 스트리밍 중", + "live": "실시간", + "start": "시작", + "stop": "정지", + "clear": "지우기", + "filterPlaceholder": "로그 필터...", + "logLevelTitle": "로그 레벨", + "emptyWaiting": "로그를 기다리는 중...", + "emptyFilter": "필터에 일치하는 로그가 없습니다.", + "emptyStart": "\"시작\"을 클릭하면 로그 스트리밍이 시작됩니다.", + "count": "{{filtered}}/{{total}}" +} diff --git a/ui/web/src/i18n/locales/ko/mcp.json b/ui/web/src/i18n/locales/ko/mcp.json new file mode 100644 index 0000000000..8f6aca7c22 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/mcp.json @@ -0,0 +1,133 @@ +{ + "title": "MCP 서버", + "description": "Model Context Protocol 서버 연결 관리", + "addServer": "서버 추가", + "searchPlaceholder": "서버 검색...", + "emptyTitle": "MCP 서버 없음", + "emptyDescription": "첫 번째 MCP 서버를 추가하여 시작하세요.", + "noMatchTitle": "일치하는 서버 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "columns": { + "name": "이름", + "transport": "전송", + "tools": "도구", + "agents": "에이전트", + "enabled": "사용", + "createdBy": "생성자", + "actions": "작업" + }, + "viewTools": "도구 보기", + "manageGrants": "권한 관리", + "reconnect": "재연결", + "delete": { + "title": "MCP 서버 삭제", + "description": "\"{{name}}\"을/를 삭제하시겠습니까? 관련된 모든 권한도 제거됩니다.", + "confirmLabel": "삭제" + }, + "form": { + "createTitle": "MCP 서버 추가", + "editTitle": "MCP 서버 편집", + "name": "이름 *", + "nameHint": "소문자, 숫자, 하이픈만", + "displayName": "표시 이름", + "displayNamePlaceholder": "My MCP Server", + "transport": "전송 *", + "command": "명령 *", + "args": "인수 (공백으로 구분)", + "argsPlaceholder": "--flag1 --flag2=\"값에 공백 포함\"", + "url": "URL *", + "headers": "헤더", + "headerKeyPlaceholder": "헤더 이름", + "headerValuePlaceholder": "헤더 값", + "addHeader": "헤더 추가", + "env": "환경 변수", + "envKeyPlaceholder": "변수 이름", + "envValuePlaceholder": "값", + "addVariable": "변수 추가", + "toolPrefix": "도구 접두사", + "toolPrefixHint": "비어 있으면 이름에서 자동으로 파생됩니다.", + "timeout": "제한 시간 (초)", + "enabled": "사용", + "testConnection": "연결 테스트", + "testing": "테스트 중...", + "toolsFound": "{{count}}개 도구 발견", + "cancel": "취소", + "create": "만들기", + "update": "업데이트", + "saving": "저장 중...", + "errors": { + "nameRequired": "이름과 전송이 필요합니다", + "nameSlug": "이름은 유효한 슬러그여야 합니다 (소문자, 숫자, 하이픈만)", + "commandRequired": "stdio 전송에는 명령이 필요합니다", + "urlRequired": "SSE/HTTP 전송에는 URL이 필요합니다", + "connectionFailed": "연결 실패" + }, + "requireUserCredentials": "사용자 자격 증명 필요", + "requireUserCredentialsHint": "각 사용자가 자신의 자격 증명을 설정해야 합니다. 개인 자격 증명이 없는 사용자는 서버를 사용할 수 없습니다." + }, + "grants": { + "title": "에이전트 권한 - {{name}}", + "currentGrants": "현재 권한", + "allToolsAllowed": "모든 도구 허용됨", + "addGrant": "에이전트 권한 추가", + "editGrant": "권한 편집", + "selectAgent": "에이전트 선택...", + "toolAllowList": "도구 허용 목록 (선택사항)", + "toolDenyList": "도구 거부 목록 (선택사항)", + "allowPlaceholder": "허용할 도구 선택...", + "denyPlaceholder": "거부할 도구 선택...", + "grant": "권한 부여", + "update": "업데이트", + "cancel": "취소", + "agentRequired": "에이전트가 필요합니다", + "failedGrant": "권한 부여 실패", + "failedRevoke": "권한 취소 실패" + }, + "tools": { + "title": "도구 — {{name}}", + "prefix": "접두사:", + "discovering": "도구 발견 중...", + "noToolsTitle": "발견된 도구 없음", + "noToolsDescription": "서버가 오프라인이거나 등록된 도구가 없습니다.", + "failedLoad": "도구 로드 실패", + "filterPlaceholder": "도구 필터...", + "noMatch": "일치하는 도구 없음" + }, + "toast": { + "created": "MCP 서버 생성됨", + "updated": "MCP 서버 업데이트됨", + "deleted": "MCP 서버 삭제됨", + "failedCreate": "MCP 서버 생성 실패", + "failedUpdate": "MCP 서버 업데이트 실패", + "failedDelete": "MCP 서버 삭제 실패", + "reconnected": "MCP 서버 연결 초기화됨", + "failedReconnect": "MCP 서버 재연결 실패" + }, + "userCredentials": { + "title": "내 자격 증명", + "description": "이 MCP 서버에 대한 개인 자격 증명을 설정하세요. 내 계정에 대해서만 서버 기본값을 재정의합니다.", + "titleAdmin": "사용자 자격 증명", + "descriptionAdmin": "이 MCP 서버에서 특정 사용자의 자격 증명을 설정합니다. 선택한 사용자에 대해서만 서버 기본값을 재정의합니다.", + "selectUser": "사용자 선택", + "apiKey": "API 키", + "apiKeyPlaceholder": "현재 유지하려면 비워두기", + "headers": "헤더", + "env": "환경 변수", + "headerPlaceholder": "KEY=VALUE, 줄당 하나", + "save": "저장", + "delete": "삭제", + "deleteAll": "전체 삭제", + "cancel": "취소", + "saved": "자격 증명 저장됨", + "deleted": "자격 증명 삭제됨", + "hasApiKey": "API 키 설정됨", + "hasHeaders": "헤더 설정됨", + "hasEnv": "환경 변수 설정됨", + "noCredentials": "설정된 자격 증명 없음", + "addHeader": "헤더 추가", + "addEnv": "변수 추가", + "saveFailed": "자격 증명 저장 실패", + "deleteFailed": "자격 증명 삭제 실패", + "mergeHint": "채팅 사용자(Telegram, Discord 등)는 연락처 페이지를 통해 먼저 테넌트 사용자로 병합되어야 사용자별 자격 증명을 가질 수 있습니다." + } +} diff --git a/ui/web/src/i18n/locales/ko/memory.json b/ui/web/src/i18n/locales/ko/memory.json new file mode 100644 index 0000000000..95db996665 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/memory.json @@ -0,0 +1,239 @@ +{ + "title": "메모리", + "description": "에이전트 메모리 문서, 청크, 임베딩 탐색 및 관리", + "search": "검색", + "create": "만들기", + "indexAll": "전체 인덱싱", + "indexing": "인덱싱 중...", + "emptyTitle": "메모리 문서 없음", + "emptyAgentDescription": "이 에이전트에는 아직 메모리 문서가 없습니다.", + "emptyGlobalDescription": "모든 에이전트에서 메모리 문서를 찾을 수 없습니다.", + "filters": { + "agent": "에이전트", + "allAgents": "모든 에이전트", + "scope": "범위", + "allScope": "전체 (전체 + 개인)", + "selectAgent": "에이전트 선택" + }, + "tabs": { + "documents": "문서", + "knowledgeGraph": "지식 그래프" + }, + "columns": { + "path": "경로", + "agent": "에이전트", + "scope": "범위", + "hash": "해시", + "updated": "업데이트됨", + "actions": "작업" + }, + "scopeLabel": { + "personal": "개인", + "global": "전체" + }, + "delete": { + "title": "메모리 문서 삭제", + "description": "\"{{path}}\"를 삭제하시겠습니까? 관련된 모든 청크와 임베딩도 삭제됩니다.", + "confirmLabel": "삭제" + }, + "createDialog": { + "title": "메모리 문서 만들기", + "path": "경로 *", + "pathPlaceholder": "예: notes/project-overview.md", + "content": "내용 *", + "contentPlaceholder": "문서 내용...", + "agentId": "에이전트 *", + "agentIdPlaceholder": "에이전트 선택...", + "scope": "범위", + "existingScope": "기존 범위", + "customScope": "사용자 지정", + "selectGroupUser": "그룹/사용자 선택...", + "scopeHint": "전체 = 모든 사용자 공유. 개인 = 특정 사용자 또는 그룹 채팅으로 범위 지정.", + "autoIndex": "생성 후 자동 인덱싱", + "agentRequired": "에이전트를 선택해 주세요", + "pathRequired": "경로가 필요합니다", + "contentRequired": "내용이 필요합니다", + "cancel": "취소", + "create": "만들기", + "creating": "만드는 중..." + }, + "documentDialog": { + "title": "메모리 문서", + "path": "경로:", + "agent": "에이전트:", + "scope": "범위:", + "hash": "해시:", + "updated": "업데이트됨:", + "content": "내용", + "chunks": "청크 ({{count}}개)", + "chunkIndex": "청크 {{index}}", + "noChunks": "청크 없음", + "embedded": "임베딩됨", + "noEmbedding": "임베딩 없음" + }, + "searchDialog": { + "title": "메모리 검색", + "query": "검색어", + "queryPlaceholder": "검색어 입력...", + "agentId": "에이전트 ID", + "agentIdPlaceholder": "에이전트로 필터...", + "limit": "제한", + "search": "검색", + "searching": "검색 중...", + "results": "결과", + "noResults": "결과 없음", + "similarity": "유사도", + "path": "경로:" + }, + "kg": { + "pageTitle": "지식 그래프", + "pageDescription": "에이전트 메모리에서 추출된 엔티티와 관계를 탐색합니다.", + "selectAgentTitle": "에이전트 선택", + "selectAgentDescription": "에이전트를 선택하면 지식 그래프를 볼 수 있습니다.", + "emptyTitle": "엔티티 없음", + "emptyDescription": "이 에이전트의 지식 그래프 엔티티가 아직 없습니다.", + "emptySearchDescription": "검색에 일치하는 엔티티가 없습니다.", + "stats": { + "entities": "엔티티: {{count}}개", + "relations": "관계: {{count}}개" + }, + "search": { + "placeholder": "엔티티 검색...", + "button": "검색", + "clear": "지우기" + }, + "refresh": "새로 고침", + "extract": "추출", + "columns": { + "name": "이름", + "type": "유형", + "description": "설명", + "confidence": "신뢰도", + "actions": "작업", + "observations": "관찰", + "relations": "관계" + }, + "dedup": { + "button": "중복 제거", + "title": "중복 엔티티", + "description": "임베딩 유사도를 기반으로 중복 가능성이 있는 엔티티를 검토하고 병합합니다.", + "noCandidates": "중복 후보를 찾을 수 없습니다.", + "similar": "유사함", + "merge": "병합", + "dismiss": "무시", + "merged": "엔티티가 성공적으로 병합되었습니다.", + "mergeFailed": "엔티티 병합 실패.", + "dismissed": "후보가 무시되었습니다.", + "dismissFailed": "후보 무시 실패.", + "scan": "전체 검사", + "scanning": "검사 중...", + "scanComplete": "검사 완료.", + "scanFailed": "검사 실패.", + "count": "{{count}}개 후보" + }, + "deleteEntity": { + "title": "엔티티 삭제", + "description": "\"{{name}}\"을/를 삭제하시겠습니까? 관련된 모든 관계도 삭제됩니다.", + "confirmLabel": "삭제" + }, + "entity": { + "externalId": "외부 ID:", + "confidence": "신뢰도:", + "description": "설명:", + "source": "출처:", + "properties": "속성:", + "relations": "관계", + "traverse": "순회", + "depthOption": "{{depth}}홉", + "tabs": { + "table": "테이블", + "graph": "그래프" + }, + "traversing": "순회 중...", + "loading": "불러오는 중...", + "noRelations": "관계가 없습니다.", + "showAll": "전체 보기", + "traversalResults": "순회 결과 ({{count}}개)", + "direction": { + "outgoing": "→ 나가는", + "incoming": "← 들어오는" + }, + "columns": { + "direction": "방향", + "relation": "관계", + "target": "대상", + "confidence": "신뢰도" + } + }, + "extractDialog": { + "title": "텍스트에서 엔티티 추출", + "providerLabel": "추출 공급자", + "modelLabel": "추출 모델", + "textLabel": "추출할 텍스트", + "textPlaceholder": "엔티티와 관계를 추출할 대화 텍스트, 메모 또는 내용을 붙여넣기...", + "extract": "추출", + "extracting": "추출 중...", + "cancel": "취소", + "agentId": "에이전트 ID", + "agentIdPlaceholder": "전체는 비워두기" + }, + "graphView": { + "loading": "그래프 불러오는 중...", + "empty": "시각화할 엔티티 없음", + "nodes": "{{count}}개 노드", + "edges": "{{count}}개 엣지", + "selected": "선택됨: {{name}}", + "limitNote": "전체 {{total}}개 중 상위 {{limit}}개 표시", + "limitHint": "성능을 위해 그래프는 {{limit}}개 엔티티로 제한됩니다. 전체 {{total}}개 엔티티를 탐색하려면 테이블 보기를 사용하세요." + } + }, + "toast": { + "created": "메모리 문서 생성됨", + "deleted": "메모리 문서 삭제됨", + "indexed": "모든 문서 인덱싱됨", + "failedCreate": "문서 생성 실패", + "failedDelete": "문서 삭제 실패", + "failedIndex": "문서 인덱싱 실패", + "entitySaved": "엔티티 저장됨", + "entitySaveFailed": "엔티티 저장 실패", + "entityDeleted": "엔티티 삭제됨", + "entityDeleteFailed": "엔티티 삭제 실패", + "extractionComplete": "추출 완료", + "extractionFailed": "추출 실패", + "traversalFailed": "순회 실패", + "unknownError": "알 수 없는 오류", + "docCreated": "문서 생성됨", + "docCreateFailed": "문서 생성 실패", + "docUpdated": "문서 업데이트됨", + "docUpdateFailed": "문서 업데이트 실패", + "docDeleted": "문서 삭제됨", + "docDeleteFailed": "문서 삭제 실패", + "docIndexed": "문서 인덱싱됨", + "docIndexFailed": "문서 인덱싱 실패", + "allIndexed": "모든 문서 인덱싱됨", + "allIndexFailed": "전체 인덱싱 실패", + "searchFailed": "검색 실패", + "docLoadFailed": "문서 로드 실패" + }, + "tabs": { + "documents": "문서", + "episodic": "에피소딕 메모리" + }, + "episodic": { + "noData": "아직 에피소딕 메모리 없음", + "noDataHint": "요약은 에이전트 세션에서 자동으로 생성됩니다", + "selectAgent": "에피소딕 메모리를 보려면 에이전트를 선택하세요", + "searchPlaceholder": "에피소딕 메모리 검색...", + "clearSearch": "지우기", + "results": "결과", + "turns": "턴", + "tokens": "토큰", + "expand": "전체 요약 보기", + "collapse": "요약 숨기기", + "source": { + "session": "세션", + "v2_daily": "일별", + "manual": "수동" + } + } +} diff --git a/ui/web/src/i18n/locales/ko/nodes.json b/ui/web/src/i18n/locales/ko/nodes.json new file mode 100644 index 0000000000..fe3d221bb4 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/nodes.json @@ -0,0 +1,35 @@ +{ + "title": "노드", + "description": "페어링된 기기 및 대기 중인 페어링 요청 관리", + "emptyTitle": "기기 없음", + "emptyDescription": "페어링된 기기 또는 대기 중인 페어링 요청이 없습니다.", + "pendingRequests": "대기 중인 요청 ({{count}}개)", + "pairedDevices": "페어링된 기기 ({{count}}개)", + "sender": "발신자:", + "chat": "채팅:", + "columns": { + "channel": "채널", + "senderId": "발신자 ID", + "paired": "페어링됨", + "by": "처리자", + "actions": "작업" + }, + "approve": "승인", + "deny": "거부", + "revoke": "취소", + "confirmApprove": { + "title": "페어링 승인", + "description": "{{channel}}:{{senderId}}의 페어링 요청을 승인하시겠습니까 (코드: {{code}})? 이 기기가 에이전트와 상호작용할 수 있게 됩니다.", + "confirmLabel": "승인" + }, + "confirmDeny": { + "title": "페어링 거부", + "description": "{{channel}}:{{senderId}}의 페어링 요청을 거부하시겠습니까 (코드: {{code}})? 요청이 제거됩니다.", + "confirmLabel": "거부" + }, + "confirmRevoke": { + "title": "기기 취소", + "description": "{{channel}}:{{senderId}}의 페어링을 취소하시겠습니까? 기기는 다시 페어링해야 합니다.", + "confirmLabel": "취소" + } +} diff --git a/ui/web/src/i18n/locales/ko/overview.json b/ui/web/src/i18n/locales/ko/overview.json new file mode 100644 index 0000000000..67cf18680e --- /dev/null +++ b/ui/web/src/i18n/locales/ko/overview.json @@ -0,0 +1,93 @@ +{ + "title": "대시보드", + "description": "게이트웨이 개요 및 할당량 사용량", + "tabs": { + "overview": "개요", + "usage": "사용량" + }, + "statCards": { + "requestsToday": "오늘 요청", + "tokensToday": "오늘 토큰", + "costToday": "오늘 비용", + "agents": "에이전트", + "channels": "채널", + "channelsAttention": "{{count}}개 주의 필요", + "running": "실행 중", + "online": "온라인", + "users": "{{count}}명 사용자", + "inOut": "{{input}} in / {{output}} out" + }, + "providers": { + "noProvidersTitle": "설정된 LLM 공급자 없음", + "noEnabledTitle": "활성화된 LLM 공급자 없음", + "noProvidersDesc": "에이전트가 작동하려면 LLM 공급자를 최소 한 개 추가해야 합니다. ", + "noEnabledDesc": "모든 공급자가 현재 비활성화되어 있습니다. 에이전트를 사용하려면 최소 한 개를 활성화하세요. ", + "goToSettings": "공급자 설정으로 이동" + }, + "systemHealth": { + "title": "시스템 상태", + "uptime": "업타임", + "version": "버전", + "database": "데이터베이스", + "providers": "공급자", + "tools": "도구", + "sessions": "세션", + "clients": "클라이언트", + "channels": "채널", + "needsAttention": "주의 필요", + "channelsNeedingAttention": "{{count}}개 채널", + "failedCount": "{{count}}개 실패", + "warningCountOne": "{{count}}개 경고", + "warningCountOther": "{{count}}개 경고", + "moreAttention": "+{{count}}개 더", + "active": "{{count}}개 활성", + "none": "없음", + "runtimes": "런타임" + }, + "connectedClients": { + "title": "연결된 클라이언트", + "noClients": "연결된 클라이언트 없음", + "columns": { + "ip": "IP", + "user": "사용자", + "role": "역할", + "connected": "연결됨" + }, + "you": "나" + }, + "cronJobs": { + "title": "Cron 작업", + "manage": "관리", + "noJobs": "설정된 Cron 작업 없음", + "disabled": "비활성화됨" + }, + "recentRequests": { + "title": "최근 요청", + "viewAll": "전체 보기", + "noRequests": "최근 요청 없음", + "columns": { + "time": "시간", + "name": "이름", + "user": "사용자", + "channel": "채널", + "tokens": "토큰", + "duration": "소요 시간", + "status": "상태" + } + }, + "quotaUsage": { + "title": "할당량 사용량", + "columns": { + "userGroup": "사용자 / 그룹", + "hour": "시간", + "day": "일", + "week": "주" + }, + "noLimit": "제한 없음" + }, + "embedding": { + "title": "임베딩", + "notConfigured": "설정되지 않음", + "configure": "공급자에서 설정 →" + } +} diff --git a/ui/web/src/i18n/locales/ko/packages.json b/ui/web/src/i18n/locales/ko/packages.json new file mode 100644 index 0000000000..73dc1d3025 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/packages.json @@ -0,0 +1,47 @@ +{ + "title": "런타임 & 패키지", + "description": "활성 GoClaw 런타임 컨테이너 내 런타임 및 설치된 패키지 관리", + "runtimes": { + "title": "런타임", + "scopeTitle": "컨테이너 범위 런타임 상태", + "scopeDesc": "이 검사는 활성 GoClaw 런타임 컨테이너 내에서 실행됩니다. 호스트에 설치된 런타임, 셸 프로파일 변경, nvm 관리 바이너리는 여기서 사용되지 않습니다.", + "minimalImageHint": "게시된 최신 이미지는 최소 구성이므로 Python이나 Node 런타임이 없을 수 있습니다. python, node 또는 full 이미지 변형을 사용하거나, ENABLE_PYTHON=true / ENABLE_NODE=true / ENABLE_FULL_SKILLS=true로 재빌드하거나, 이 컨테이너에 런타임을 수동으로 설치하세요.", + "available": "사용 가능", + "missing": "없음", + "missingInContainer": "컨테이너에 없음" + }, + "system": { + "title": "시스템 패키지", + "placeholder": "패키지 이름 (예: github-cli)" + }, + "pip": { + "title": "Python 패키지", + "placeholder": "패키지 이름 (예: pandas)" + }, + "npm": { + "title": "Node 패키지", + "placeholder": "패키지 이름 (예: typescript)" + }, + "actions": { + "install": "설치", + "uninstall": "제거", + "installing": "설치 중...", + "uninstalling": "제거 중..." + }, + "messages": { + "installSuccess": "{{name}} 설치 완료", + "installError": "{{name}} 설치 실패", + "uninstallSuccess": "{{name}} 제거 완료", + "uninstallError": "{{name}} 제거 실패" + }, + "confirmUninstall": { + "title": "제거 확인", + "description": "{{name}}을/를 제거하시겠습니까? 이 작업은 취소할 수 없습니다." + }, + "table": { + "name": "이름", + "version": "버전", + "actions": "작업", + "empty": "설치된 패키지 없음" + } +} diff --git a/ui/web/src/i18n/locales/ko/pending-messages.json b/ui/web/src/i18n/locales/ko/pending-messages.json new file mode 100644 index 0000000000..1775d9b651 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/pending-messages.json @@ -0,0 +1,50 @@ +{ + "title": "대기 메시지", + "description": "에이전트 처리를 기다리는 버퍼링된 채널 메시지", + "emptyTitle": "대기 중인 메시지 없음", + "emptyDescription": "버퍼링된 메시지 그룹이 없습니다. 채널이 에이전트 처리 전에 메시지를 버퍼링할 때 여기에 나타납니다.", + "howItWorks": { + "title": "작동 방식", + "step1": "채널(Telegram, Discord 등)의 메시지는 에이전트가 바쁘거나 오프라인일 때 여기에 버퍼링됩니다.", + "step2": "버퍼가 임계값(기본값: 메시지 50개)에 도달하면 자동 압축이 시작됩니다 — LLM이 오래된 메시지를 요약하고 최근 15개만 유지합니다.", + "step3": "에이전트의 다음 턴에서 압축된 요약 + 최근 메시지가 컨텍스트로 삽입되어, 에이전트가 과부하 없이 따라잡을 수 있습니다.", + "compactAction": "압축 — LLM 요약을 지금 수동으로 트리거합니다. 에이전트가 원시 메시지 홍수 대신 깔끔한 요약을 처리하게 하려는 경우 유용합니다.", + "clearAction": "지우기 — 그룹의 모든 버퍼링된 메시지를 영구적으로 삭제합니다. 메시지가 더 이상 관련이 없을 때 사용하세요." + }, + "columns": { + "channel": "채널", + "group": "그룹", + "messages": "메시지", + "status": "상태", + "lastActivity": "마지막 활동", + "actions": "작업" + }, + "status": { + "compacted": "압축됨", + "raw": "원시" + }, + "compact": "압축", + "compacting": "압축 중…", + "clear": "지우기", + "confirmClear": { + "title": "메시지를 지우시겠습니까?", + "description": "{{channel}} / {{key}}의 모든 메시지가 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.", + "cancel": "취소", + "confirm": "지우기" + }, + "dialog": { + "title": "메시지 — {{name}}", + "noMessages": "메시지가 없습니다.", + "summary": "요약" + }, + "toast": { + "compacted": "압축 완료", + "compactedSummarized": "{{count}}개 메시지로 요약됨", + "compactedDeleted": "모든 메시지 삭제됨 (LLM 공급자 없음)", + "compacting": "압축 중…", + "compactingDesc": "LLM 요약이 백그라운드에서 실행 중입니다. 목록이 자동으로 새로 고쳐집니다.", + "failedCompact": "압축 실패", + "cleared": "메시지 지워짐", + "failedClear": "메시지 지우기 실패" + } +} diff --git a/ui/web/src/i18n/locales/ko/providers.json b/ui/web/src/i18n/locales/ko/providers.json new file mode 100644 index 0000000000..cb9ae95c85 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/providers.json @@ -0,0 +1,344 @@ +{ + "title": "공급자", + "description": "LLM 공급자 관리", + "addProvider": "공급자 추가", + "searchPlaceholder": "공급자 검색...", + "emptyTitle": "공급자 없음", + "emptyDescription": "첫 번째 LLM 공급자를 추가하여 시작하세요.", + "noMatchTitle": "일치하는 공급자 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "columns": { + "name": "이름", + "type": "유형", + "apiBase": "API 기본 URL", + "apiKey": "API 키", + "status": "상태", + "actions": "작업" + }, + "apiKey": { + "oauthToken": "OAuth 토큰", + "notSet": "설정되지 않음" + }, + "card": { + "apiKeySet": "API 키 설정됨", + "authenticated": "인증됨", + "oauthAccount": "OAuth 계정", + "oauthAlias": "별칭: {{name}}", + "connected": "연결됨", + "signInNeeded": "로그인 필요", + "disabled": "비활성화됨" + }, + "quota": { + "checking": "할당량 확인 중", + "plan": "플랜", + "lastChecked": "{{value}} 확인됨", + "failure": { + "billing": { + "label": "청구", + "description": "현재 워크스페이스 청구 또는 자격 문제로 계정이 차단되었습니다." + }, + "exhausted": { + "label": "소진됨", + "description": "이 계정은 로그인되어 있지만 핵심 할당량 창 중 하나가 소진되었습니다." + }, + "reauth": { + "label": "재인증", + "description": "워크스페이스 접근을 새로 고치려면 다시 로그인하세요." + }, + "forbidden": { + "label": "금지됨", + "description": "이 계정은 업스트림 할당량 데이터를 읽을 수 없습니다." + }, + "needs_setup": { + "label": "설정 필요", + "description": "GoClaw가 ChatGPT 계정 워크스페이스 메타데이터를 복원할 수 있도록 다시 로그인하세요." + }, + "retry_later": { + "label": "나중에 재시도", + "description": "할당량 엔드포인트가 일시적으로 사용 불가하거나 속도가 제한되어 있습니다." + }, + "unavailable": { + "label": "사용 불가", + "description": "현재 이 계정의 할당량 데이터를 사용할 수 없습니다." + } + } + }, + "form": { + "createTitle": "공급자 추가", + "editTitle": "공급자 편집", + "name": "이름 *", + "namePlaceholder": "예: openrouter", + "nameHint": "소문자, 숫자, 하이픈", + "displayName": "표시 이름", + "displayNamePlaceholder": "OpenRouter", + "providerType": "공급자 유형 *", + "apiKey": "API 키", + "apiKeyPlaceholder": "sk-...", + "apiKeyEditPlaceholder": "현재 값 유지 또는 새 키 입력", + "apiKeySetHint": "API 키가 설정되어 있습니다. 지우고 새 값을 입력하여 변경하세요.", + "apiBase": "API 기본 URL", + "enabled": "활성화됨", + "cancel": "취소", + "close": "닫기", + "create": "생성", + "save": "저장", + "update": "업데이트", + "saving": "저장 중...", + "creating": "생성 중...", + "alreadyAdded": "(이미 추가됨)", + "nameFixed": "이름", + "configure": "LLM 공급자 연결을 설정합니다.", + "configureOauth": "하나의 OpenAI Codex OAuth 계정을 연결합니다.", + "oauthAlias": "계정 별칭 *", + "oauthAliasPlaceholder": "예: primary", + "oauthAliasHint": "라우팅 및 풀에서 사용되는 소문자 별칭입니다.", + "oauthDisplayNamePlaceholder": "기본 Codex 계정", + "oauthDisplayNameHint": "대시보드에 표시되는 선택적 레이블입니다.", + "lowercaseHint": "소문자, 숫자, 하이픈" + }, + "delete": { + "title": "공급자 삭제", + "description": "\"{{name}}\"을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "confirmLabel": "삭제" + }, + "types": { + "anthropic_native": "Anthropic", + "openai_compat": "OpenAI 호환", + "gemini_native": "Gemini", + "openrouter": "OpenRouter", + "groq": "Groq", + "deepseek": "DeepSeek", + "mistral": "Mistral", + "xai": "xAI", + "minimax_native": "MiniMax", + "cohere": "Cohere", + "perplexity": "Perplexity", + "chatgpt_oauth": "ChatGPT 구독 (OAuth)", + "yescale": "YesScale", + "acp": "ACP" + }, + "acp": { + "description": "ACP 호환 코딩 에이전트를 서브프로세스로 조율합니다. API 키가 필요하지 않습니다.", + "binary": "바이너리", + "binaryPlaceholder": "claude", + "binaryHint": "ACP 에이전트 바이너리의 경로 또는 이름 (예: claude, codex, gemini)", + "args": "인수", + "argsPlaceholder": "--profile goclaw", + "argsHint": "바이너리에 전달되는 공백으로 구분된 인수", + "idleTTL": "유휴 TTL", + "idleTTLPlaceholder": "5m", + "idleTTLHint": "이 기간 후 유휴 프로세스를 종료합니다 (예: 5m, 1h)", + "permMode": "권한 모드", + "permModeApproveAll": "모두 승인", + "permModeApproveReads": "읽기만 승인", + "permModeDenyAll": "모두 거부", + "workDir": "작업 디렉터리", + "workDirPlaceholder": "/tmp/workspace", + "workDirHint": "에이전트 서브프로세스의 작업 디렉터리 (선택사항)" + }, + "oauth": { + "checkingStatus": "인증 상태 확인 중...", + "authSuccessful": "인증 성공!", + "authSuccessfulDesc": "이 명명된 OpenAI/Codex 계정을 사용할 준비가 되었습니다.", + "activeProvider": "계정 별칭", + "authenticated": "인증됨 — 계정", + "active": "활성", + "poolBadge": "풀 준비됨", + "roundRobinBadge": "라운드 로빈", + "modelPrefixHint": "모델 접두사 사용", + "modelPrefixExample": "에이전트 설정에서 (예: {{example}}).", + "multiAccountHint": "나중에 에이전트 풀에서 더 많은 별칭을 추가하여 풀링이나 라운드 로빈을 활성화하세요.", + "aliasRequired": "OpenAI Codex OAuth를 시작하기 전에 유효한 소문자 계정 별칭을 입력하세요.", + "removeToken": "계정 연결 해제", + "signInDesc": "ChatGPT/Codex 구독으로 로그인합니다. 별도의 API 키가 필요하지 않습니다.", + "waiting": "인증 대기 중... 열린 창에서 로그인을 완료하세요.", + "remoteVpsHint": "로그인 후 브라우저가 열리려고 시도합니다", + "remoteVpsError": "그리고 \"이 페이지에 도달할 수 없음\" 오류가 표시됩니다. 정상입니다 — 브라우저 주소 표시줄에서 전체 URL을 복사하여 아래에 붙여넣으세요.", + "pasteUrlPlaceholder": "http://localhost:1455/auth/callback?code=...&state=...", + "submit": "제출", + "signInWithChatGPT": "OpenAI 계정 연결", + "starting": "시작 중...", + "remoteVps": "원격/VPS?", + "loggedOut": "로그아웃됨", + "loggedOutDesc": "OpenAI Codex OAuth 계정 연결 해제됨", + "oauthFailed": "OAuth 실패", + "exchangeFailed": "교환 실패", + "logoutFailed": "로그아웃 실패", + "done": "완료" + }, + "cli": { + "description": "Claude CLI는 로컬", + "descriptionSuffix": "바이너리를 사용합니다. API 키가 필요하지 않습니다.", + "checkingAuth": "인증 확인 중...", + "authenticatedAs": "다음으로 인증됨", + "switchAccount": "계정을 전환하시겠습니까?", + "switchAccountInstructions": "서버 터미널에서 실행하세요:", + "switchAccountRecheck": "그런 다음 클릭하세요", + "switchAccountRecheckSuffix": "재확인하려면.", + "notAuthenticated": "인증되지 않음", + "runOnServer": "서버 터미널에서 실행하세요:", + "recheckButton": "재확인" + }, + "detail": { + "title": "공급자 세부 정보", + "advanced": "고급", + "identity": "식별", + "identityDesc": "공급자 식별", + "connection": "연결", + "connectionDesc": "API 엔드포인트 설정", + "acpConfig": "ACP 설정", + "acpConfigDesc": "에이전트 서브프로세스 설정", + "cliConfig": "Claude CLI", + "cliConfigDesc": "로컬 CLI 인증", + "oauthConfig": "ChatGPT 구독 (OAuth)", + "oauthConfigDesc": "이 명명된 OpenAI/Codex 계정의 로그인 관리", + "nameReadonly": "공급자 식별자, 변경 불가", + "oauthAliasReadonly": "OAuth 계정 별칭, 변경 불가", + "providerType": "공급자 유형", + "apiKeySection": "API 키", + "apiKeySectionDesc": "인증 자격증명", + "statusSection": "상태", + "statusSectionDesc": "공급자 가용성", + "enabledDesc": "공급자가 활성 상태이며 에이전트에서 사용 가능합니다", + "embeddingSection": "임베딩", + "oauthAccountUsage": "계정 사용", + "oauthAccountUsageDesc": "이 별칭이 공급자 소유 OpenAI Codex 풀링 내에서 작동하는 방식입니다.", + "oauthAccountUsageManagedDesc": "이 별칭은 현재 {{provider}}가 관리하는 풀 멤버입니다.", + "oauthAccountUsageOwnerDesc": "이 별칭은 현재 {{count}}개의 추가 계정이 있는 재사용 가능한 풀을 소유합니다.", + "oauthPoolRole": { + "member": "풀 멤버", + "owner": "풀 소유자", + "standalone": "독립형" + }, + "oauthAliasLabel": "계정 별칭", + "oauthAccountBadge": "ChatGPT 구독 (OAuth)", + "oauthModelPrefix": "모델 접두사", + "oauthQuotaTitle": "할당량 상태", + "oauthQuotaDescription": "이 계정의 최신 ChatGPT 워크스페이스 할당량입니다.", + "oauthModelPrefixCopied": "모델 접두사 복사됨", + "oauthPreferredHint": "에이전트의 공급자 필드를 이 별칭으로 설정하여 기본 OpenAI 별칭으로 만드세요.", + "oauthProviderDefaultHint": "아래 풀 기본값을 사용하여 이 별칭을 가리키는 모든 에이전트에 대한 재사용 가능한 OpenAI Codex 라우팅 정책을 정의하세요.", + "oauthRoutingHint": "에이전트 페이지에는 이제 해당 에이전트의 유효 풀과 이 기본값을 상속하는지 또는 커스텀 재정의를 저장하는지 여부가 표시됩니다.", + "oauthManagedByHint": "이 별칭은 공급자 {{provider}}가 관리합니다. 이 계정 대신 풀 멤버십을 편집하세요.", + "reasoningDefaultsTitle": "추론 기본값", + "reasoningDefaultsDescription": "이 공급자를 사용하는 에이전트의 기본 추론 정책을 설정합니다. 최종 런타임 노력은 각 에이전트의 선택된 모델에 따라 정규화됩니다.", + "reasoningPreset": "기본 프리셋", + "reasoningExpertMode": "전문가 기본값", + "reasoningExpertModeDesc": "자동, 없음, 최소 또는 xhigh와 같은 노력이 필요할 때 공급자 소유 고급 추론 기본값을 사용합니다.", + "reasoningPreviewDescription": "{{model}}에 대한 명시적 추론 기능 메타데이터를 미리 봅니다.", + "reasoningPreviewLabel": "기능 미리보기", + "reasoningPreviewDefault": "미리보기 모델 기본 추론: {{level}}", + "reasoningPreviewEmpty": "이 공급자에 대해 아직 명시적 추론 가능 모델이 반환되지 않았습니다.", + "reasoningRequestedEffort": "요청된 노력", + "reasoningFallbackBehavior": "폴백 동작", + "codexPoolDefaultsTitle": "풀 기본값", + "codexPoolDefaultsDescription": "이 별칭을 기본 공급자로 사용하는 에이전트를 위해 이 별칭이 소유한 재사용 가능한 OpenAI Codex 풀을 정의합니다.", + "poolManagedByDescription": "이 공급자는 {{owner}}가 소유한 풀의 멤버입니다. 풀 설정을 변경하려면 풀 소유자로 이동하세요.", + "oauthDisplayNameRecommendation": "여러 OpenAI Codex OAuth 계정이 풀에 표시될 때 더 명확한 레이블을 원하면 표시 이름을 추가하세요.", + "poolActivityTitle": "풀 활동", + "poolActivityDescription": "이 공급자의 Codex 풀을 사용하는 모든 에이전트의 집계 런타임 활동입니다.", + "poolActivityRefresh": "새로 고침", + "poolActivityTopAgents": "에이전트", + "poolActivityTopAgentsTitle": "이 풀을 사용하는 상위 에이전트", + "poolActivityRequests": "요청", + "poolActivityEmpty": "아직 활동 없음", + "poolActivityEmptyDesc": "에이전트가 이 풀을 통해 요청 라우팅을 시작하면 풀 활동이 여기에 표시됩니다." + }, + "embedding": { + "enable": "임베딩 활성화", + "enableDesc": "이 공급자가 메모리, 스킬 및 지식 그래프를 위한 텍스트 임베딩을 생성할 수 있도록 합니다", + "model": "임베딩 모델", + "dimensions": "출력 차원", + "dimensionsHint": "GoClaw 메모리는 현재 임베딩을 vector(1536)에 저장합니다. 모델이 이미 1536을 출력할 때는 비워두고, 공급자가 더 큰 기본 차원에서 잘라내기를 지원하는 경우 정확히 1536으로 설정하세요.", + "dimensionsInvalid": "GoClaw 메모리에서 현재 지원되는 값은 1536뿐입니다.", + "apiBase": "임베딩 API 기본 URL", + "apiBasePlaceholder": "(공급자와 동일)", + "apiBaseHint": "임베딩 엔드포인트가 공급자의 메인 API 기본 URL과 다른 경우 재정의", + "verify": "임베딩 확인", + "verifyFailed": "임베딩 확인 실패", + "dimensionsMismatch": "{{count}} 차원 — 필수 1536과 일치하지 않음" + }, + "reasoning": { + "off": "끄기", + "offDesc": "기본 확장 사고 없음", + "auto": "자동", + "autoDesc": "모델이 자체 기본 추론 노력을 사용하도록 요청", + "none": "없음", + "noneDesc": "고급 계약을 유지하면서 공급자 추론을 명시적으로 비활성화", + "minimal": "최소", + "minimalDesc": "없음 이상에서 가장 작은 지원 추론 노력 사용", + "low": "낮음", + "lowDesc": "~4K 토큰 예산", + "medium": "중간", + "mediumDesc": "~10-16K 토큰 예산", + "high": "높음", + "highDesc": "~32K 토큰 예산", + "xhigh": "최대", + "xhighDesc": "지원되는 최대 추론 노력 사용", + "downgrade": "다운그레이드", + "downgradeDesc": "가장 가까운 지원 낮은 수준으로 정규화하거나, 더 낮은 지원 수준이 없으면 비활성화", + "provider_default": "모델 기본값 사용", + "provider_defaultDesc": "명시적 노력을 강제하지 않고 공급자/모델이 기본값을 선택하도록 함" + }, + "list": { + "poolOwner": "풀 소유자", + "poolMember": "풀 멤버", + "poolAvailable": "풀 사용 가능", + "poolBadge": "풀", + "standalone": "독립형", + "reasoningDefault": "추론 {{level}}", + "managedBy": "{{provider}}가 관리", + "memberCount_one": "{{count}}개 멤버", + "memberCount_other": "{{count}}개 멤버", + "memberOverflow": "+{{count}}", + "strategy": { + "roundRobin": "라운드 로빈", + "priorityOrder": "우선순위 순서", + "primaryFirst": "기본 우선" + }, + "status": { + "needsSignIn": "로그인 필요", + "disabled": "비활성화됨" + } + }, + "pageHint": { + "title": "ChatGPT 구독 (OAuth) 계정", + "description": "각 별칭은 하나의 Codex 계정입니다. 풀 소유자가 여기서 멤버 계정을 관리하며, 에이전트 페이지에는 유효한 경로만 표시됩니다.", + "connected_one": "{{count}}개 연결된 계정", + "connected_other": "{{count}}개 연결된 계정", + "owners_one": "{{count}}명 풀 소유자", + "owners_other": "{{count}}명 풀 소유자", + "members_one": "{{count}}명 멤버", + "members_other": "{{count}}명 멤버" + }, + "poolCta": { + "unpooledCount": "{{count}}개 계정 풀 준비됨", + "setupPool": "풀 설정", + "dismiss": "닫기" + }, + "poolWizard": { + "title": "풀 설정", + "description": "자동 폴오버와 부하 분산을 위해 ChatGPT 계정을 결합합니다.", + "selectOwner": "풀 소유자", + "selectOwnerHint": "풀 설정을 소유하는 기본 계정입니다.", + "selectMembers": "풀 멤버", + "selectMembersHint": "풀에 포함할 계정을 선택하세요.", + "selectAll": "모두 선택", + "deselectAll": "모두 선택 해제", + "selectedCount": "{{count}}개 선택됨", + "strategy": "전략", + "createPool": "풀 생성", + "noMembers": "풀을 생성하려면 최소 하나의 멤버를 선택하세요.", + "success": "풀이 성공적으로 생성되었습니다.", + "error": "풀 생성 실패." + }, + "toast": { + "created": "공급자 생성됨", + "createdDesc": "{{name}}이 추가되었습니다", + "updated": "공급자 업데이트됨", + "deleted": "공급자 삭제됨", + "failedCreate": "공급자 생성 실패", + "failedUpdate": "공급자 업데이트 실패", + "failedDelete": "공급자 삭제 실패" + } +} diff --git a/ui/web/src/i18n/locales/ko/sessions.json b/ui/web/src/i18n/locales/ko/sessions.json new file mode 100644 index 0000000000..8e41abe40c --- /dev/null +++ b/ui/web/src/i18n/locales/ko/sessions.json @@ -0,0 +1,45 @@ +{ + "title": "세션", + "description": "대화 세션 탐색", + "searchPlaceholder": "세션 검색...", + "emptyTitle": "아직 세션 없음", + "emptyDescription": "채팅을 시작하면 여기에 세션이 나타납니다.", + "noMatchTitle": "일치하는 세션 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "columns": { + "session": "세션", + "agent": "에이전트", + "context": "컨텍스트", + "messages": "메시지", + "updated": "업데이트됨" + }, + "contextBar": { + "compacted": "{{count}}회 압축됨" + }, + "detail": { + "reset": "초기화", + "delete": "삭제", + "deleteTitle": "세션 삭제", + "deleteDescription": "이 세션의 모든 메시지가 영구적으로 삭제됩니다.", + "resetTitle": "세션 초기화", + "resetDescription": "모든 메시지가 지워지지만 세션은 유지됩니다.", + "confirmDelete": "삭제", + "confirmReset": "초기화", + "noMessages": "이 세션에 메시지가 없습니다", + "summary": "요약", + "showMore": "더 보기", + "showLess": "접기", + "showDetails": "세부 정보 보기", + "hide": "숨기기", + "systemMessage": "시스템 메시지", + "messages": "메시지" + }, + "toast": { + "deleted": "세션 삭제됨", + "deleteFailed": "세션 삭제 실패", + "reset": "세션 초기화됨", + "resetFailed": "세션 초기화 실패", + "updated": "세션 업데이트됨", + "updateFailed": "세션 업데이트 실패" + } +} diff --git a/ui/web/src/i18n/locales/ko/setup.json b/ui/web/src/i18n/locales/ko/setup.json new file mode 100644 index 0000000000..8ce5f396bf --- /dev/null +++ b/ui/web/src/i18n/locales/ko/setup.json @@ -0,0 +1,117 @@ +{ + "layout": { + "subtitle": "게이트웨이를 설정하고 실행해 봅시다" + }, + "steps": { + "provider": "공급자", + "model": "모델", + "agent": "에이전트", + "channel": "채널", + "channelOptional": "(선택사항)" + }, + "complete": { + "title": "설정 완료!", + "ready": "시스템이 준비되었습니다!", + "description": "공급자, 에이전트, 채널이 설정되었습니다. 대시보드에서 언제든지 관리할 수 있습니다.", + "goToDashboard": "대시보드로 이동" + }, + "provider": { + "title": "LLM 공급자 설정", + "descriptionCli": "로컬 Claude CLI 설치를 사용하여 연결합니다. API 키가 필요하지 않습니다.", + "descriptionOauth": "하나의 OpenAI Codex OAuth 계정을 생성합니다. 지금 별칭을 지정한 후, 나중에 풀 멤버나 라운드 로빈을 위한 별칭을 추가할 수 있습니다.", + "description": "에이전트를 구동할 AI 공급자에 연결합니다. API 키가 필요합니다.", + "providerType": "공급자 유형", + "providerTypeHint": "연결할 LLM 서비스입니다. 여러 모델에 접근하려면 OpenRouter를 권장합니다.", + "name": "이름", + "nameHint": "이 공급자의 내부 식별자입니다. 공급자 유형에서 자동 생성됩니다.", + "oauthAlias": "계정 별칭", + "oauthAliasPlaceholder": "예: primary", + "oauthAliasHint": "이 OpenAI/Codex 계정에 대해 primary, work, team 같은 고유 소문자 별칭을 사용하세요. 별칭은 에이전트가 사용하는 공급자/모델 접두사가 됩니다.", + "displayName": "표시 이름 (선택사항)", + "displayNameHint": "이 OpenAI/Codex 계정의 친화적 레이블입니다. 나중에 별칭을 추가할 때 유용합니다.", + "displayNamePlaceholder": "기본 Codex 계정", + "apiKey": "API 키 *", + "apiKeyHint": "공급자의 시크릿 키입니다. 서버 측에서 암호화되며 API 응답에 노출되지 않습니다.", + "apiBase": "API 기본 URL", + "apiBaseHint": "API 요청의 엔드포인트 URL입니다. 공급자 유형에 따라 자동 입력됩니다. 커스텀 프록시를 사용하는 경우에만 변경하세요.", + "creating": "생성 중...", + "create": "공급자 생성", + "errors": { + "apiKeyRequired": "API 키가 필요합니다", + "oauthProviderNotFound": "OAuth 인증에 성공했지만 공급자를 찾을 수 없습니다. 다시 시도해 주세요.", + "failedCreate": "공급자 생성 실패" + } + }, + "model": { + "title": "모델 선택 및 확인", + "description": "모델을 선택하고 공급자 연결이 작동하는지 확인하세요.", + "provider": "공급자:", + "model": "모델 *", + "modelHint": "사용할 AI 모델입니다. 목록에서 선택하거나 목록에 없는 경우 모델 ID를 직접 입력하세요.", + "loadingModels": "모델 불러오는 중...", + "selectModel": "모델 선택 또는 모델 ID 입력", + "noModelsHint": "이 공급자는 모델 목록을 제공하지 않습니다 — 모델 ID를 직접 입력하세요.", + "verified": "확인됨", + "verify": "확인", + "verifying": "확인 중...", + "continue": "계속", + "modelVerified": "모델 확인됨", + "modelVerifiedDesc": "{{model}}이 정상적으로 작동합니다", + "verificationFailed": "확인 실패. API 키와 모델을 확인하세요." + }, + "agent": { + "title": "첫 번째 에이전트 만들기", + "description": "에이전트는 AI 어시스턴트입니다. 이름과 성격을 설정하세요.", + "provider": "공급자:", + "model": "모델:", + "displayName": "표시 이름", + "displayNameHint": "소환 중 에이전트 성격에 따라 자동 생성됩니다.", + "agentKey": "에이전트 키", + "agentKeyHint": "에이전트의 고유 식별자입니다. 소문자, 숫자, 하이픈만 사용 가능합니다.", + "agentKeyPlaceholder": "my-agent", + "personality": "에이전트 성격", + "personalityHint": "에이전트의 역할과 행동을 설명하세요. AI가 소환 중에 이를 기반으로 컨텍스트 파일을 생성합니다.", + "personalityPlaceholder": "에이전트의 성격, 목적, 행동 방식을 설명하세요...", + "personalityHintBottom": "이 프롬프트를 커스터마이즈하여 에이전트의 성격과 전문성을 설정하세요.", + "selfEvolve": "자기 진화", + "selfEvolveDesc": "에이전트가 SOUL.md를 통해 시간이 지남에 따라 커뮤니케이션 스타일을 진화시킬 수 있도록 합니다", + "creating": "생성 중...", + "create": "에이전트 생성", + "summoningFailed": "소환 실패. 설정을 조정하고 다시 시도하세요.", + "errors": { + "noProvider": "사용 가능한 공급자 없음", + "failedCreate": "에이전트 생성 실패" + } + }, + "channel": { + "title": "채널 연결", + "description": "에이전트에 메시징 플랫폼을 연결하세요. 나중에 언제든지 채널을 추가할 수 있습니다.", + "skip": "건너뛰기", + "skipFinish": "건너뛰고 완료", + "agent": "에이전트:", + "channelType": "채널 유형", + "channelTypeHint": "연결할 메시징 플랫폼입니다. 예: Telegram, Discord, Zalo.", + "name": "이름", + "nameHint": "이 채널 인스턴스의 내부 식별자입니다. 채널 유형에서 자동 생성됩니다.", + "namePlaceholder": "my-telegram", + "displayName": "표시 이름", + "displayNameHint": "UI에 표시되는 친화적 이름입니다. 선택사항.", + "displayNamePlaceholder": "판매 봇", + "credentials": "자격증명", + "credentialsHint": "서버 측에서 암호화됩니다. API 응답에 반환되지 않습니다.", + "creating": "생성 중...", + "create": "채널 생성", + "errors": { + "noAgent": "사용 가능한 에이전트 없음", + "requiredFields": "필수 항목: {{fields}}", + "failedCreate": "채널 생성 실패" + } + }, + "common": { + "back": "뒤로" + }, + "skipSetup": "설정을 건너뛰고 대시보드로 이동", + "skipSetupConfirm": "설정을 건너뛰시겠습니까? 대시보드에서 나중에 언제든지 공급자와 에이전트를 설정할 수 있습니다.", + "switchTenant": "전환", + "allTenants": "모든 테넌트" +} diff --git a/ui/web/src/i18n/locales/ko/sidebar.json b/ui/web/src/i18n/locales/ko/sidebar.json new file mode 100644 index 0000000000..e04fbd565e --- /dev/null +++ b/ui/web/src/i18n/locales/ko/sidebar.json @@ -0,0 +1,47 @@ +{ + "groups": { + "core": "핵심", + "conversations": "대화", + "connectivity": "연결", + "capabilities": "기능", + "data": "데이터", + "monitoring": "모니터링", + "system": "시스템" + }, + "nav": { + "overview": "개요", + "chat": "채팅", + "agents": "에이전트", + "agentTeams": "에이전트 링크 & 팀", + "sessions": "세션", + "pendingMessages": "대기 메시지", + "channels": "채널", + "contacts": "연락처", + "skills": "스킬", + "cron": "Cron", + "customTools": "사용자 지정 도구", + "builtinTools": "내장 도구", + "mcpServers": "MCP 서버", + "memory": "메모리", + "vault": "볼트", + "knowledgeGraph": "지식 그래프", + "traces": "추적", + "realtimeEvents": "실시간 이벤트", + "usage": "사용량", + "logs": "로그", + "storage": "스토리지", + "providers": "공급자", + "config": "설정", + "approvals": "승인", + "importExport": "가져오기 & 내보내기", + "nodes": "노드", + "tts": "TTS", + "activity": "활동", + "cliCredentials": "CLI 자격 증명", + "apiKeys": "API 키", + "apiDocs": "API 문서", + "packages": "패키지", + "tenants": "테넌트", + "backupRestore": "백업 & 복원" + } +} diff --git a/ui/web/src/i18n/locales/ko/skills.json b/ui/web/src/i18n/locales/ko/skills.json new file mode 100644 index 0000000000..d92e98db41 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/skills.json @@ -0,0 +1,123 @@ +{ + "title": "스킬", + "description": "에이전트 스킬 및 기능 관리", + "searchPlaceholder": "스킬 검색...", + "emptyTitle": "스킬 없음", + "emptyDescription": "아직 등록된 스킬이 없습니다.", + "noMatchTitle": "일치하는 스킬 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "columns": { + "name": "이름", + "description": "설명", + "source": "출처", + "author": "작성자", + "visibility": "공개 범위", + "status": "상태", + "actions": "작업" + }, + "noDescription": "설명 없음", + "visibility": { + "clickToCycle": "클릭하여 공개 범위 변경" + }, + "delete": { + "title": "스킬 삭제", + "description": "\"{{name}}\"을/를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "confirmLabel": "삭제" + }, + "upload": { + "title": "스킬 업로드", + "description": "YAML 프론트매터(name, description, slug)가 있는 SKILL.md가 포함된 ZIP 파일을 업로드하세요.", + "button": "업로드", + "uploading": "업로드 중...", + "dropOrClick": ".zip 파일을 클릭하거나 끌어다 놓기", + "dropHere": ".zip 파일을 여기에 놓기", + "onlyZip": ".zip 파일만 허용됩니다", + "cancel": "취소", + "done": "완료", + "tooLarge": "파일이 20MB 제한을 초과합니다", + "invalidZip": "유효하지 않은 ZIP 파일", + "noSkillMd": "ZIP에는 루트에 SKILL.md가 있어야 합니다", + "emptySkillMd": "SKILL.md가 비어 있습니다", + "noFrontmatter": "SKILL.md에 YAML 프론트매터가 없습니다", + "nameRequired": "SKILL.md 프론트매터에 name이 필요합니다", + "invalidSlug": "유효하지 않은 슬러그 형식", + "failed": "업로드 실패", + "validating": "유효성 검사 중...", + "validCount": "{{total}}개 중 {{valid}}개 유효", + "uploadCount": "업로드 ({{count}}개)", + "successCount": "{{total}}개 중 {{success}}개 성공적으로 업로드됨", + "remove": "파일 제거" + }, + "edit": { + "title": "스킬 편집", + "name": "이름", + "description": "설명", + "visibility": "공개 범위", + "tags": "태그", + "addTag": "태그 추가...", + "add": "추가", + "cancel": "취소", + "save": "저장", + "saving": "저장 중...", + "privateOption": "비공개 (소유자만)", + "internalOption": "내부 (허가된 에이전트/사용자)", + "publicOption": "공개 (모든 에이전트)" + }, + "system": "시스템", + "tabs": { + "core": "핵심", + "custom": "사용자 지정" + }, + "toggle": { + "enable": "스킬 활성화", + "disable": "스킬 비활성화", + "disabled": "비활성화됨" + }, + "tenant": { + "enabled": "활성화됨", + "disabled": "비활성화됨", + "default": "기본값", + "overrideHint": "이 스킬의 테넌트별 재정의", + "resetDefault": "기본값으로 초기화" + }, + "deps": { + "rescan": "의존성 재검사", + "rescanning": "검사 중...", + "rescanSuccess": "{{count}}개 스킬 업데이트됨", + "missing": "없음: {{deps}}", + "install": "의존성 설치", + "installing": "설치 중...", + "installSuccess": "의존성 설치 완료", + "installPartial": "일부 의존성 설치 실패", + "installItem": "설치", + "itemSuccess": "설치됨", + "itemError": "실패", + "missingTitle": "없는 의존성", + "systemLabel": "시스템", + "pythonLabel": "Python", + "nodeLabel": "Node.js", + "statusActive": "활성", + "statusArchived": "의존성 없음", + "runtimeMissing": "런타임 전제 조건 없음", + "runtimeMissingDesc": "핵심 스킬은 GoClaw 런타임 컨테이너 내에 Python 및/또는 Node.js 런타임이 필요합니다. 호스트에 설치된 런타임은 사용되지 않습니다.", + "runtimeMissingAction": "패키지를 열어 이 컨테이너에 런타임을 확인하거나 설치하세요.", + "runtimeRequired": "먼저 컨테이너 런타임 설치" + }, + "detail": { + "title": "스킬 세부 정보", + "content": "내용", + "files": "파일", + "version": "버전:", + "current": "(현재)", + "noContent": "사용 가능한 내용이 없습니다." + }, + "toast": { + "updated": "스킬 업데이트됨", + "updateFailed": "스킬 업데이트 실패", + "deleted": "스킬 삭제됨", + "deleteFailed": "스킬 삭제 실패", + "rescanUpdated": "{{count}}개 스킬 업데이트됨", + "rescanNoChanges": "모든 스킬 최신 상태", + "rescanFailed": "의존성 재검사 실패" + } +} diff --git a/ui/web/src/i18n/locales/ko/storage.json b/ui/web/src/i18n/locales/ko/storage.json new file mode 100644 index 0000000000..9d682f6140 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/storage.json @@ -0,0 +1,25 @@ +{ + "title": "스토리지", + "description": "워크스페이스 파일 탐색기", + "descriptionWithPath": "{{path}} — {{size}}", + "sizeCacheInfo": "스토리지 크기는 60분간 캐시됩니다", + "unsupportedFile": "이 파일 유형은 미리보기를 지원하지 않습니다", + "download": "다운로드", + "delete": { + "fileTitle": "파일 삭제", + "folderTitle": "폴더 삭제", + "description": "{{name}}을/를 삭제하시겠습니까?", + "folderWarning": " 모든 내용이 재귀적으로 제거됩니다.", + "undone": " 이 작업은 취소할 수 없습니다.", + "deleting": "삭제 중...", + "confirmLabel": "삭제" + }, + "upload": { + "title": "스토리지에 업로드", + "description": "데이터 디렉터리에 파일 업로드" + }, + "toast": { + "deleted": "파일 삭제됨", + "deleteFailed": "파일 삭제 실패" + } +} diff --git a/ui/web/src/i18n/locales/ko/system-settings.json b/ui/web/src/i18n/locales/ko/system-settings.json new file mode 100644 index 0000000000..516344e8eb --- /dev/null +++ b/ui/web/src/i18n/locales/ko/system-settings.json @@ -0,0 +1,80 @@ +{ + "title": "시스템 설정", + "save": "저장", + "cancel": "취소", + "saving": "저장 중...", + "saved": "설정 저장됨", + "loadFailed": "설정 불러오기 실패", + "saveFailed": "설정 저장 실패", + "embedding": { + "title": "임베딩", + "description": "벡터 임베딩 및 의미 검색을 위한 공급자와 모델입니다.", + "importance": "임베딩은 메모리 회수, 스킬 검색, 지식 그래프, 태스크 매칭 정확도에 중요합니다. 변경 시 전체 시스템에 영향을 줍니다.", + "provider": "임베딩 공급자", + "model": "임베딩 모델", + "providerTip": "텍스트 임베딩 생성에 사용되는 LLM 공급자입니다.", + "modelTip": "임베딩 모델 (예: text-embedding-3-small). /embeddings 엔드포인트를 지원해야 합니다.", + "providerPlaceholder": "공급자 선택", + "modelPlaceholder": "text-embedding-3-small", + "supportedProviders": "1536d 검증됨: OpenAI (text-embedding-3-small/large), Gemini (gemini-embedding-001 + dimensions), Mistral (codestral-embed), Cohere (embed-v4), DashScope (text-embedding-v3 + dimensions). 기본 차원이 1536보다 큰 모델은 공급자 설정에서 차원을 설정하세요.", + "verify": "임베딩 확인", + "verifyRequired": "저장 전에 임베딩 모델을 확인하세요.", + "verifyFailed": "확인 실패", + "dimensions": "{{count}} 차원", + "dimensionsMismatch": "{{count}} 차원 — 필수 1536과 일치하지 않음", + "maxChunkLen": "최대 청크 길이", + "maxChunkLenHint": "분할 전 메모리 청크당 최대 문자 수입니다.", + "chunkOverlap": "청크 겹침", + "chunkOverlapHint": "컨텍스트 연속성을 위한 연속 청크 간 겹침 문자 수입니다." + }, + "ux": { + "title": "UX 동작", + "description": "게이트웨이가 사용자에게 에이전트 활동을 표시하는 방식을 제어합니다.", + "toolStatus": "실행 중 도구 이름 표시", + "toolStatusHint": "에이전트 실행 중 활성 도구 이름을 표시합니다.", + "toolStatusInfo": "에이전트 실행 중 스트리밍 미리보기에 도구 이름이 표시됩니다.", + "blockReply": "도구 호출 중 중간 텍스트 표시", + "blockReplyHint": "도구 호출 중 부분 어시스턴트 텍스트를 스트리밍합니다.", + "blockReplyInfo": "도구 실행 중 사용자에게 중간 텍스트가 스트리밍됩니다.", + "intentClassify": "의도 분류", + "intentClassifyHint": "에이전트로 라우팅하기 전에 사용자 의도를 분류합니다.", + "intentClassifyInfo": "에이전트 라우팅 및 동작 최적화를 위해 메시지가 분류됩니다." + }, + "kg": { + "title": "지식 그래프", + "description": "에이전트 메모리에서 검색 가능한 지식 그래프로 엔티티와 관계를 추출합니다.", + "provider": "추출 공급자", + "model": "추출 모델", + "providerTip": "메모리에서 엔티티를 추출하는 데 사용되는 LLM 공급자입니다.", + "modelTip": "엔티티/관계 추출 모델 (예: claude-sonnet-4-20250514).", + "providerPlaceholder": "공급자 선택", + "modelPlaceholder": "모델 선택", + "minConfidence": "최소 신뢰도", + "minConfidenceHint": "추출된 엔티티의 최소 신뢰도 임계값 (0–1).", + "info": "에이전트가 메모리에 쓸 때 자동으로 엔티티와 관계를 추출합니다. 비활성화하려면 공급자를 비워두세요." + }, + "bg": { + "title": "백그라운드 작업자", + "description": "백그라운드 태스크(볼트 보강, 통합, 드리밍)를 위한 공급자와 모델입니다.", + "provider": "공급자", + "model": "모델", + "providerTip": "백그라운드 태스크용 LLM 공급자입니다. 에이전트 기본값을 사용하려면 비워두세요.", + "modelTip": "백그라운드 태스크용 모델입니다. 공급자 기본값을 사용하려면 비워두세요.", + "providerPlaceholder": "(에이전트 기본값)", + "modelPlaceholder": "(기본값)", + "info": "볼트 보강 (문서 요약), 통합 (세션 요약), 드리밍에 사용됩니다. 에이전트 기본 공급자로 대체하려면 비워두세요." + }, + "compaction": { + "title": "대기 메시지 압축", + "description": "컨텍스트 한도 내에 유지하기 위해 긴 대기 메시지 기록을 요약합니다.", + "providerPlaceholder": "(자동)", + "threshold": "임계값", + "thresholdHint": "압축이 트리거되기 전의 메시지 수입니다.", + "keepRecent": "최근 유지", + "keepRecentHint": "압축 후 보존되는 메시지 수입니다.", + "maxTokens": "최대 토큰", + "maxTokensHint": "압축된 요약의 토큰 한도입니다.", + "info": "그룹 채팅이 임계값을 초과하면 LLM이 오래된 메시지를 단일 압축 항목으로 자동 요약하여 컨텍스트를 위해 가장 최근 메시지를 유지합니다." + }, + "moreConfig": "추가 설정" +} diff --git a/ui/web/src/i18n/locales/ko/teams.json b/ui/web/src/i18n/locales/ko/teams.json new file mode 100644 index 0000000000..95897e1e1b --- /dev/null +++ b/ui/web/src/i18n/locales/ko/teams.json @@ -0,0 +1,402 @@ +{ + "title": "팀", + "description": "에이전트 팀 관리", + "createTeam": "팀 만들기", + "searchPlaceholder": "팀 검색...", + "emptyTitle": "아직 팀 없음", + "emptyDescription": "첫 번째 팀을 만들어 시작하세요.", + "noMatchTitle": "일치하는 팀 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "viewCard": "카드 보기", + "viewList": "목록 보기", + "scope": { + "all": "모든 범위", + "label": "범위" + }, + "delete": { + "title": "팀 삭제", + "description": "\"{{name}}\"을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "confirmLabel": "삭제" + }, + "detail": { + "lead": "리더", + "memberCount": "{{count}}명 멤버", + "memberCountPlural": "{{count}}명 멤버", + "more": "더 보기", + "deleteDescription": "\"{{name}}\"을 삭제하시겠습니까? 모든 멤버, 태스크 및 설정이 영구적으로 삭제됩니다.", + "tabs": { + "tasks": "태스크", + "members": "멤버", + "delegations": "위임", + "workspace": "워크스페이스", + "settings": "설정" + } + }, + "members": { + "title": "멤버", + "noMembers": "멤버 없음", + "addMember": "멤버 추가", + "addMemberTooltip": "사전 정의된 에이전트만 팀 멤버로 추가할 수 있습니다. 개방형 에이전트는 사용자별 컨텍스트를 가지며 팀 협업에 필요한 공유 컨텍스트가 없습니다.", + "selectAgent": "추가할 에이전트 선택...", + "noAvailableAgents": "사용 가능한 에이전트 없음", + "searchAgents": "에이전트 검색...", + "adding": "추가 중...", + "add": "추가", + "added": "멤버 추가됨", + "failedAdd": "멤버 추가 실패", + "removed": "멤버 제거됨", + "failedRemove": "멤버 제거 실패", + "removeMember": "멤버 제거", + "columns": { + "agent": "에이전트", + "frontmatter": "프론트매터", + "role": "역할", + "addedAt": "추가된 시간", + "actions": "작업" + } + }, + "tasks": { + "title": "태스크", + "loading": "태스크 불러오는 중...", + "noTasks": "아직 태스크 없음", + "noTasksDescription": "태스크는 팀 에이전트의 협업 중에 생성됩니다.", + "refresh": "새로 고침", + "createTask": "태스크 만들기", + "createTaskTitle": "태스크 만들기", + "subjectPlaceholder": "태스크 제목...", + "descriptionPlaceholder": "선택적 설명...", + "priorityLabel": "우선순위", + "assign": "할당", + "assignTo": "다음에 할당...", + "unassigned": "미할당", + "filters": { + "active": "활성", + "pending": "대기 중", + "inProgress": "진행 중", + "inReview": "검토 중", + "completed": "완료됨", + "all": "전체" + }, + "columns": { + "id": "ID", + "subject": "제목", + "status": "상태", + "owner": "담당자", + "priority": "우선순위", + "taskId": "태스크 ID", + "created": "생성됨", + "actions": "작업" + }, + "detail": { + "title": "태스크 상세", + "subject": "제목", + "status": "상태:", + "priority": "우선순위:", + "owner": "담당자:", + "type": "유형:", + "blockedBy": "차단 원인:", + "description": "설명", + "result": "결과", + "created": "생성됨:", + "updated": "업데이트됨:", + "taskId": "태스크 ID:", + "noDescription": "설명 없음", + "noResult": "아직 결과 없음", + "progress": "진행률", + "approve": "승인", + "reject": "거부", + "comments": "댓글", + "noComments": "아직 댓글 없음", + "commentPlaceholder": "댓글 추가...", + "addComment": "댓글", + "timeline": "타임라인", + "attachments": "첨부 파일", + "followupStatus": "응답 대기 중", + "followupMessage": "알림 메시지:", + "followupCount": "알림 발송: {{count}}회", + "followupCountMax": "알림 발송: {{count}}/{{max}}회", + "followupNext": "다음 알림:", + "followupDone": "모든 알림 발송됨", + "download": "다운로드" + }, + "delete": "삭제", + "deleteConfirm": "이 태스크를 삭제하시겠습니까? 취소할 수 없습니다.", + "selected": "{{count}}개 선택됨", + "deleteSelected": "선택 항목 삭제", + "deleteBulkTitle": "태스크 삭제", + "deleteBulkConfirm": "{{count}}개 태스크가 영구적으로 삭제됩니다. 확인하려면 \"delete\"를 입력하세요.", + "badges": { + "awaitingReply": "응답 대기 중" + } + }, + "delegations": { + "noTitle": "위임 없음", + "noDescription": "이 팀에 대한 위임 기록을 찾을 수 없습니다.", + "refresh": "새로 고침", + "columns": { + "sourceTarget": "출처 / 대상", + "task": "태스크", + "status": "상태", + "duration": "소요 시간", + "time": "시간" + } + }, + "workspace": { + "title": "워크스페이스", + "noFiles": "아직 워크스페이스 파일 없음", + "noFilesDescription": "파일은 팀 에이전트의 협업 중에 생성됩니다.", + "refresh": "새로 고침", + "delete": "삭제", + "confirmDelete": "이 파일을 삭제하시겠습니까?", + "columns": { + "fileName": "파일 이름", + "size": "크기", + "uploadedBy": "업로드한 사람", + "scope": "범위", + "updated": "업데이트됨" + }, + "detail": { + "title": "파일 내용", + "empty": "파일이 비어있거나 바이너리입니다" + }, + "upload": { + "title": "워크스페이스에 업로드", + "description": "협업 중 에이전트가 읽을 파일을 업로드합니다" + }, + "crossScopeMoveError": "다른 범위 간에 파일을 이동할 수 없습니다", + "moveFailed": "파일 이동 실패" + }, + "auditLogs": { + "title": "감사 로그", + "empty": "아직 이벤트 없음", + "loadMore": "더 불러오기", + "event": { + "created": "생성됨", + "claimed": "클레임됨", + "assigned": "할당됨", + "dispatched": "발송됨", + "completed": "완료됨", + "failed": "실패", + "cancelled": "취소됨", + "reviewed": "검토됨", + "approved": "승인됨", + "rejected": "거부됨", + "commented": "댓글 달림", + "progress": "진행 중", + "updated": "업데이트됨", + "stale": "기한 초과" + } + }, + "settings": { + "title": "팀 설정", + "name": "이름", + "displayName": "표시 이름", + "description": "설명", + "leadAgent": "리더 에이전트", + "status": "상태", + "active": "활성", + "inactive": "비활성", + "save": "설정 저장", + "saving": "저장 중...", + "saved": "저장됨", + "failedSave": "저장 실패", + "userAccessControl": "사용자 접근 제어", + "allowedUsers": "허용된 사용자", + "allowedUsersHint": "설정된 경우 이 사용자만 팀 워크플로를 트리거할 수 있습니다. 비어있으면 모두 허용.", + "deniedUsers": "차단된 사용자", + "deniedUsersHint": "항상 차단되며, 허용 목록을 재정의합니다.", + "channelRestrictions": "채널 제한", + "allowedChannels": "허용된 채널", + "allowedChannelsHint": "설정된 경우 이 채널만 팀 워크플로를 활성화할 수 있습니다. 비어있으면 모두 허용.", + "deniedChannels": "차단된 채널", + "deniedChannelsHint": "이 채널의 메시지는 항상 차단됩니다.", + "notifications": "알림", + "notifyDispatched": "태스크 발송됨", + "notifyDispatchedHint": "팀 멤버에게 태스크가 할당될 때 알림.", + "notifyProgress": "태스크 진행", + "notifyProgressHint": "멤버가 태스크 진행률을 업데이트할 때 알림.", + "notifyFailed": "태스크 실패", + "notifyFailedHint": "태스크가 실패할 때 알림.", + "notifySlowTool": "느린 도구 경고", + "notifySlowToolHint": "도구 호출이 비정상적으로 오래 걸릴 때 시스템 경고. 항상 직접 — 리더를 통하지 않음.", + "notifyMode": "전달 모드", + "notifyModeHint": "채팅 채널로 알림이 전달되는 방법입니다.", + "notifyModeDirect": "직접", + "notifyModeDirectDesc": "채팅 채널에 직접 알림을 보냅니다. 빠르고 AI 처리 없음.", + "notifyModeLeader": "리더를 통해", + "notifyModeLeaderDesc": "리더 에이전트가 사용자에게 보내기 전에 업데이트를 자연스럽게 재구성합니다.", + "notifyModeLeaderWarning": "리더 모드는 AI를 사용하여 메시지를 재구성합니다 (토큰 사용, 더 느릴 수 있음).", + "notifyCompleted": "태스크 완료 시 알림", + "notifyCommented": "태스크 댓글 시 알림", + "notifyNewTask": "새 태스크 생성 시 알림", + "memberRequests": "멤버 요청", + "memberRequestsEnabled": "멤버 요청 태스크 허용", + "memberRequestsEnabledDesc": "멤버들이 팀원에게 도움을 요청하는 요청 태스크를 만들 수 있습니다", + "memberRequestsAutoDispatch": "담당자에게 자동 발송", + "memberRequestsAutoDispatchDesc": "요청이 즉시 발송됩니다. 그렇지 않으면 리더 검토를 위해 대기 상태를 유지합니다", + "searchUsers": "사용자 검색...", + "selectChannel": "채널 선택...", + "escalationPolicy": "에스컬레이션 정책", + "escalationMode": "에스컬레이션 모드", + "escalationModeHint": "에이전트가 요청한 권한 있는 워크스페이스 작업(고정, 태그, 다른 사람의 파일 삭제, set_template)이 처리되는 방식을 제어합니다.", + "blockerEscalation": "차단 에스컬레이션", + "blockerEscalationEnabled": "차단 에스컬레이션 활성화", + "blockerEscalationHint": "멤버가 차단 요인을 표시하면 태스크가 자동으로 실패하고 리더에게 업데이트된 지시로 재시도하라는 알림이 전송됩니다.", + "escalationModeAuto": "자동 (기본값)", + "escalationModeAutoDesc": "LLM 리더가 요청을 평가합니다 — 거부하거나 검토 태스크를 생성합니다. 직접 실행하지 않습니다.", + "escalationModeReview": "검토", + "escalationModeReviewDesc": "항상 사람의 승인을 위한 대기 태스크를 생성합니다. 자동 거부 없음.", + "escalationModeReject": "거부", + "escalationModeRejectDesc": "모든 권한 있는 작업 요청을 거부합니다. 잠금 모드.", + "escalationModeNone": "없음", + "escalationModeNoneDesc": "에스컬레이션 정책 없음. 리더 에이전트가 직접 작업을 실행합니다.", + "escalationActions": "에스컬레이션 작업", + "escalationActionsHint": "에스컬레이션이 필요한 워크스페이스 작업을 선택합니다. 선택하지 않으면 모든 권한 있는 작업이 에스컬레이션됩니다.", + "followupReminders": "후속 알림", + "followupInterval": "알림 간격 (분)", + "followupIntervalHint": "각 후속 알림을 사용자에게 보내기 전 대기할 시간입니다.", + "followupMaxReminders": "최대 알림 수", + "followupMaxRemindersHint": "태스크당 최대 알림 수입니다. 0 = 무제한 (사용자가 응답할 때까지 알림).", + "workspace": "워크스페이스", + "workspaceScope": "워크스페이스 범위", + "workspaceScopeHint": "팀 워크스페이스 파일이 구성되는 방식을 제어합니다.", + "workspaceScopeIsolated": "격리됨", + "workspaceScopeIsolatedDesc": "각 대화는 자체 워크스페이스 폴더를 가집니다. 한 채팅의 파일은 다른 채팅에서 보이지 않습니다.", + "workspaceScopeShared": "공유됨", + "workspaceScopeSharedDesc": "모든 대화가 단일 워크스페이스 폴더를 공유합니다. 어떤 채팅에서든 생성된 파일은 모두에게 보입니다.", + "teamVersion": "팀 버전", + "versionBasic": "기본", + "versionAdvanced": "고급", + "versionBasicDesc": "태스크 관리 및 공유 워크스페이스.", + "versionAdvancedDesc": "모든 V1 기능과 실행 잠금, 후속 알림, 검토 워크플로, 진행률 추적, 댓글, 감사 추적, 자동 복구가 포함됩니다.", + "whatsNew": "무엇이 다른가요?", + "versionModal": { + "title": "슈퍼 팀 기능", + "feature": "기능", + "taskManagement": "태스크 관리", + "taskManagementDesc": "태스크 생성, 할당, 완료, 취소", + "sharedWorkspace": "공유 워크스페이스", + "sharedWorkspaceDesc": "파일, 버전, 팀 협업", + "executionLocking": "실행 잠금", + "executionLockingDesc": "자동 복구로 태스크 충돌 방지", + "followupReminders": "후속 알림", + "followupRemindersDesc": "사용자 응답 대기 중 자동 알림", + "reviewWorkflow": "검토 워크플로", + "reviewWorkflowDesc": "완료 전 사람 검토를 위해 제출", + "progressTracking": "진행률 추적", + "progressTrackingDesc": "실시간 백분율 및 단계 업데이트", + "commentsAudit": "댓글 & 감사", + "commentsAuditDesc": "태스크 댓글 및 전체 이벤트 기록", + "autoRecovery": "자동 복구", + "autoRecoveryDesc": "중단된 태스크가 타임아웃 후 자동 재설정", + "escalationPolicy": "차단 에스컬레이션", + "escalationPolicyDesc": "멤버가 차단 요인을 표시할 수 있습니다 — 태스크가 자동 실패하고 리더에게 재시도 알림", + "comingSoon": "준비 중", + "downgradeNote": "다운그레이드는 안전합니다 — V2 데이터가 보존되지만 숨겨집니다.", + "betaNote": "일부 기능은 현재 베타 버전입니다.", + "gotIt": "알겠습니다" + } + }, + "create": { + "title": "팀 만들기", + "name": "이름 *", + "namePlaceholder": "예: 리서치 팀", + "nameHint": "소문자, 숫자, 하이픈", + "description": "설명", + "descriptionPlaceholder": "선택적 팀 설명...", + "leadAgent": "리더 에이전트 *", + "selectLeadAgent": "리더 에이전트 선택...", + "loadingAgents": "에이전트 불러오는 중...", + "noActiveAgents": "활성 에이전트를 찾을 수 없습니다. 먼저 에이전트를 만드세요.", + "members": "멤버", + "membersTooltip": "사전 정의된 에이전트만 팀 멤버로 추가할 수 있습니다. 개방형 에이전트는 사용자별 컨텍스트를 가지며 팀 협업에 필요한 공유 컨텍스트가 없습니다.", + "needMembers": "팀에 최소 1명의 멤버를 선택하세요", + "searchMembers": "멤버 검색 및 추가...", + "displayName": "표시 이름", + "displayNamePlaceholder": "지원 팀", + "cancel": "취소", + "create": "만들기", + "creating": "생성 중..." + }, + "toast": { + "created": "팀 생성됨", + "deleted": "팀 삭제됨", + "updated": "팀 업데이트됨", + "memberAdded": "멤버 추가됨", + "memberRemoved": "멤버 제거됨", + "failedCreate": "팀 생성 실패", + "failedDelete": "팀 삭제 실패", + "failedUpdate": "팀 업데이트 실패", + "failedAddMember": "멤버 추가 실패", + "failedRemoveMember": "멤버 제거 실패", + "taskCreated": "태스크 생성됨", + "taskDeleted": "태스크 삭제됨", + "taskAssigned": "태스크 할당됨", + "taskApproved": "태스크 승인됨", + "taskRejected": "태스크 거부됨", + "tasksBulkDeleted": "태스크 삭제됨", + "failedCreateTask": "태스크 생성 실패", + "failedDeleteTask": "태스크 삭제 실패", + "failedAssignTask": "태스크 할당 실패", + "failedApproveTask": "태스크 승인 실패", + "failedRejectTask": "태스크 거부 실패", + "failedBulkDeleteTasks": "태스크 삭제 실패", + "workspaceFileDeleted": "파일 삭제됨", + "failedDeleteWorkspaceFile": "파일 삭제 실패" + }, + "board": { + "kanban": "보드", + "list": "목록", + "groupBy": "그룹화", + "groupByStatus": "상태", + "groupByOwner": "담당자", + "groupByType": "유형", + "running": "실행 중", + "locked": "에이전트가 실행 중", + "emptyColumn": "태스크 없음", + "unassigned": "미할당", + "createViaChat": "팀 에이전트와 채팅하여 태스크 생성" + }, + "sidebar": { + "members": "멤버", + "workspace": "워크스페이스", + "delegations": "위임", + "settings": "설정", + "addMember": "멤버 추가" + }, + "tabs": { + "teams": "에이전트 팀", + "board": "보드", + "links": "에이전트 링크", + "settings": "설정" + }, + "links": { + "title": "에이전트 링크", + "empty": "에이전트 링크가 설정되지 않았습니다.", + "create": "링크 만들기", + "editLink": "링크 편집", + "save": "저장", + "source": "소스 에이전트", + "target": "대상 에이전트", + "selectAgent": "에이전트 선택...", + "direction": "방향", + "outbound": "아웃바운드", + "inbound": "인바운드", + "bidirectional": "양방향", + "description": "설명", + "descriptionPlaceholder": "선택적 설명...", + "maxConcurrent": "최대 동시", + "status": "상태", + "active": "활성", + "disabled": "비활성화됨", + "deleteTitle": "링크 삭제", + "confirmDelete": "이 에이전트 링크를 삭제하시겠습니까? 취소할 수 없습니다.", + "deleted": "링크 삭제됨", + "deleteFailed": "링크 삭제 실패", + "created": "링크 생성됨", + "createFailed": "링크 생성 실패", + "updated": "링크 업데이트됨", + "updateFailed": "링크 업데이트 실패", + "sameAgentError": "소스와 대상은 다른 에이전트여야 합니다." + } +} diff --git a/ui/web/src/i18n/locales/ko/tenants.json b/ui/web/src/i18n/locales/ko/tenants.json new file mode 100644 index 0000000000..daa4b11cd4 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/tenants.json @@ -0,0 +1,33 @@ +{ + "title": "테넌트", + "description": "테넌트 및 사용자 접근 관리", + "createTenant": "테넌트 만들기", + "name": "이름", + "slug": "슬러그", + "status": "상태", + "users": "사용자", + "role": "역할", + "addUser": "사용자 추가", + "removeUser": "사용자 제거", + "userId": "사용자 ID", + "active": "활성", + "suspended": "정지됨", + "archived": "보관됨", + "allTenants": "모든 테넌트", + "currentTenant": "현재 테넌트", + "noTenants": "테넌트 없음", + "confirmRemoveUser": "이 사용자를 테넌트에서 제거하시겠습니까?", + "slugHelp": "URL 안전 식별자 (소문자, 하이픈만)", + "detail": "테넌트 세부 정보", + "back": "테넌트 목록으로", + "userManagement": "사용자 관리", + "addUserTitle": "테넌트에 사용자 추가", + "selectRole": "역할 선택", + "roleOwner": "소유자", + "roleAdmin": "관리자", + "roleOperator": "운영자", + "roleMember": "멤버", + "roleViewer": "뷰어", + "noUsers": "이 테넌트에 사용자 없음", + "created": "생성됨" +} diff --git a/ui/web/src/i18n/locales/ko/tools.json b/ui/web/src/i18n/locales/ko/tools.json new file mode 100644 index 0000000000..2c4372c2f7 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/tools.json @@ -0,0 +1,211 @@ +{ + "custom": { + "title": "커스텀 도구", + "description": "에이전트를 위한 커스텀 셸 기반 도구 관리", + "createTool": "도구 만들기", + "searchPlaceholder": "도구 검색...", + "emptyTitle": "커스텀 도구 없음", + "emptyDescription": "첫 번째 커스텀 도구를 만들어 시작하세요.", + "noMatchTitle": "일치하는 도구 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "noDescription": "설명 없음", + "columns": { + "name": "이름", + "description": "설명", + "scope": "범위", + "enabled": "활성화됨", + "timeout": "타임아웃", + "actions": "작업" + }, + "scope": { + "agent": "에이전트", + "global": "전역" + }, + "delete": { + "title": "커스텀 도구 삭제", + "description": "\"{{name}}\"을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "confirmLabel": "삭제" + }, + "form": { + "createTitle": "커스텀 도구 만들기", + "editTitle": "도구 편집", + "name": "이름 *", + "namePlaceholder": "my-tool", + "nameHint": "소문자, 숫자, 하이픈만 사용", + "description": "설명", + "descriptionPlaceholder": "이 도구의 역할...", + "command": "명령 *", + "commandHint": "셸 템플릿. 매개변수 자리표시자에 {{.key}}를 사용하세요.", + "parameters": "매개변수 (JSON Schema)", + "workingDir": "작업 디렉터리", + "workingDirPlaceholder": "/path/to/dir", + "timeout": "타임아웃 (초)", + "agentId": "에이전트 ID (선택사항)", + "agentIdPlaceholder": "전역 범위로 사용하려면 비워두세요", + "enabled": "활성화됨", + "cancel": "취소", + "create": "만들기", + "update": "업데이트", + "saving": "저장 중...", + "errors": { + "nameRequired": "이름과 명령이 필요합니다", + "nameSlug": "이름은 유효한 슬러그여야 합니다 (소문자, 숫자, 하이픈만 사용)", + "invalidJson": "매개변수는 유효한 JSON이어야 합니다" + } + }, + "toast": { + "created": "도구 생성됨", + "createdDesc": "{{name}}이 추가되었습니다", + "updated": "도구 업데이트됨", + "deleted": "도구 삭제됨", + "failedCreate": "도구 생성 실패", + "failedUpdate": "도구 업데이트 실패", + "failedDelete": "도구 삭제 실패" + } + }, + "builtin": { + "title": "내장 도구", + "description": "시스템 내장 도구를 관리합니다. 전역으로 활성화/비활성화하거나 설정을 구성합니다.", + "searchPlaceholder": "도구 검색...", + "emptyTitle": "내장 도구 없음", + "emptyDescription": "아직 내장 도구가 시딩되지 않았습니다.", + "noMatchTitle": "일치하는 도구 없음", + "noMatchDescription": "다른 검색어를 사용해 보세요.", + "toolCount": "{{count}}개 도구", + "toolCountPlural": "{{count}}개 도구", + "categoryCount": "{{count}}개 카테고리", + "requires": "필요", + "requiresTooltip": "필요: {{list}}", + "deprecated": "더 이상 사용되지 않음", + "deprecatedTooltip": "이 도구는 더 이상 사용되지 않으며 사용할 수 없습니다", + "settings": "설정", + "configuredVia": "설정 페이지에서 구성됨", + "tenantOverride": "테넌트 재정의", + "tenantEnabled": "이 테넌트에서 활성화됨", + "tenantDisabled": "이 테넌트에서 비활성화됨", + "tenantDefault": "기본값 사용", + "resetToDefault": "기본값으로 재설정", + "unconfiguredWarning": "{{count}}개 도구에 에이전트가 최대 성능으로 사용하기 위한 공급자 설정이 필요합니다.", + "unconfiguredAction": "설정", + "categories": { + "filesystem": "파일 시스템", + "runtime": "런타임", + "web": "웹", + "memory": "메모리", + "media": "미디어", + "browser": "브라우저", + "sessions": "세션", + "messaging": "메시징", + "scheduling": "스케줄링", + "subagents": "에이전트", + "skills": "스킬", + "delegation": "위임", + "teams": "팀" + }, + "settingsDialog": { + "title": "설정 — {{name}}", + "cancel": "취소", + "save": "저장", + "saving": "저장 중...", + "noFields": "이 도구에 대한 구성 가능한 설정이 없습니다.", + "toast": { + "saved": "설정 저장됨", + "failed": "설정 저장 실패" + } + }, + "jsonDialog": { + "title": "설정: {{name}}", + "description": "도구별 설정을 JSON으로 편집합니다. 저장 후 즉시 적용됩니다.", + "formatJson": "JSON 포맷", + "invalidJsonSyntax": "잘못된 JSON 구문", + "invalidJson": "잘못된 JSON", + "cannotFormat": "포맷 불가: 잘못된 JSON", + "cancel": "취소", + "save": "저장", + "saving": "저장 중..." + }, + "kgSettings": { + "title": "지식 그래프 설정", + "description": "메모리 쓰기에서 엔티티 추출을 구성합니다. 구조화된 JSON 출력을 지원하는 공급자/모델이 필요합니다.", + "extractionProvider": "추출 공급자", + "extractionModel": "추출 모델", + "providerTip": "텍스트에서 엔티티와 관계를 추출하는 데 사용되는 LLM 공급자입니다.", + "modelTip": "추출 모델 ID입니다. 구조화된 JSON 출력을 지원해야 합니다.", + "minConfidence": "최소 신뢰도", + "minConfidenceHint": "이 신뢰도 미만의 엔티티는 삭제됩니다 (0.0–1.0).", + "autoExtract": "메모리 쓰기 시 자동 추출", + "autoExtractHint": "에이전트가 메모리 파일에 쓸 때 자동으로 엔티티를 추출합니다.", + "cancel": "취소", + "save": "저장", + "saving": "저장 중..." + }, + "extractorChain": { + "title": "웹 가져오기 — 추출기 체인", + "description": "콘텐츠 추출기의 순서 목록입니다. 각 URL은 품질 있는 콘텐츠를 반환할 때까지 위에서 아래로 시도됩니다.", + "timeout": "타임아웃", + "baseUrl": "기본 URL", + "save": "저장", + "saving": "저장 중…", + "cancel": "취소", + "extractors": { + "defuddle": "Defuddle (Cloudflare Worker)", + "defuddleDesc": "Defuddle 라이브러리를 사용하여 fetch.goclaw.sh를 통해 깨끗한 마크다운을 추출합니다.", + "html-to-markdown": "내장 HTML→Markdown", + "html-to-markdownDesc": "숨겨진 요소 감지 기능이 있는 인-프로세스 DOM 기반 변환기입니다." + } + }, + "mediaChain": { + "providerChainSuffix": "— 공급자 체인", + "description": "공급자 폴백을 구성하고 순서를 지정합니다. 드래그하여 순서 변경 — 활성화된 첫 번째 공급자가 먼저 시도됩니다.", + "newProvider": "새 공급자", + "provider": "공급자", + "model": "모델", + "selectProvider": "공급자 선택", + "loadingModels": "불러오는 중...", + "selectModel": "모델 선택", + "timeout": "타임아웃", + "retries": "재시도", + "settings": "설정", + "noProviders": "공급자가 설정되지 않았습니다. 아래에서 추가하세요.", + "addProvider": "공급자 추가", + "cancel": "취소", + "save": "저장", + "saving": "저장 중..." + }, + "toast": { + "toggled": "도구 업데이트됨", + "failedToggle": "도구 업데이트 실패" + }, + "descriptions": { + "read_file": "경로를 기준으로 에이전트의 워크스페이스에서 파일 내용을 읽습니다", + "write_file": "워크스페이스의 파일에 내용을 쓰고, 필요 시 디렉터리를 생성합니다", + "list_files": "워크스페이스 내 지정된 경로의 파일과 디렉터리를 나열합니다", + "edit": "전체 파일을 다시 쓰지 않고 기존 파일에 대상 검색-교체 편집을 적용합니다", + "exec": "워크스페이스에서 셸 명령을 실행하고 stdout/stderr를 반환합니다", + "web_search": "검색 엔진(Brave 또는 DuckDuckGo)을 사용하여 웹에서 정보를 검색합니다", + "web_fetch": "웹 페이지나 API 엔드포인트를 가져와 텍스트 내용을 추출합니다", + "memory_search": "의미 유사성을 사용하여 에이전트의 장기 메모리를 검색합니다", + "memory_get": "파일 경로로 특정 메모리 문서를 검색합니다", + "knowledge_graph_search": "에이전트의 지식 그래프에서 엔티티, 관계, 관찰을 검색합니다", + "read_image": "비전 가능 LLM 공급자를 사용하여 이미지를 분석합니다", + "read_document": "문서 가능 LLM 공급자를 사용하여 문서(PDF, Word, Excel, PowerPoint, CSV 등)를 분석합니다", + "create_image": "이미지 생성 공급자를 사용하여 텍스트 프롬프트에서 이미지를 생성합니다", + "read_audio": "오디오 가능 LLM 공급자를 사용하여 오디오 파일(음성, 음악, 사운드)을 분석합니다", + "read_video": "비디오 가능 LLM 공급자를 사용하여 비디오 파일을 분석합니다", + "create_video": "AI를 사용하여 텍스트 설명에서 비디오를 생성합니다", + "create_audio": "AI를 사용하여 텍스트 설명에서 음악이나 음향 효과를 생성합니다", + "tts": "텍스트를 자연스러운 음성 오디오로 변환합니다", + "browser": "브라우저 상호작용 자동화: 페이지 탐색, 요소 클릭, 양식 입력, 스크린샷 촬영", + "sessions_list": "모든 채널의 활성 채팅 세션을 나열합니다", + "session_status": "특정 채팅 세션의 현재 상태와 메타데이터를 가져옵니다", + "sessions_history": "특정 채팅 세션의 메시지 기록을 검색합니다", + "sessions_send": "에이전트를 대신하여 활성 채팅 세션에 메시지를 보냅니다", + "message": "연결된 채널(Telegram, Discord 등)의 사용자에게 선제적 메시지를 보냅니다", + "cron": "cron 표현식, at-time 또는 간격을 사용하여 반복 작업을 예약하거나 관리합니다", + "spawn": "백그라운드에서 태스크를 처리하기 위해 서브에이전트를 생성합니다", + "skill_search": "키워드나 설명으로 사용 가능한 스킬을 검색하여 관련 기능을 찾습니다", + "use_skill": "스킬을 활성화하여 전문화된 기능을 사용합니다 (추적 마커)", + "team_tasks": "팀 태스크 보드의 태스크를 조회, 생성, 업데이트, 완료합니다" + } + } +} diff --git a/ui/web/src/i18n/locales/ko/topbar.json b/ui/web/src/i18n/locales/ko/topbar.json new file mode 100644 index 0000000000..94fd2b0a2a --- /dev/null +++ b/ui/web/src/i18n/locales/ko/topbar.json @@ -0,0 +1,35 @@ +{ + "expandSidebar": "사이드바 펼치기", + "collapseSidebar": "사이드바 접기", + "openMenu": "메뉴 열기", + "systemSettings": "시스템 설정", + "toggleTheme": "테마 전환", + "logout": "로그아웃", + "logoutConfirm": "정말 로그아웃하시겠습니까?", + "language": "언어", + "timezone": "시간대", + "pairingRequests": "페어링 요청", + "pendingPairingRequest": "{{count}}개 페어링 요청 대기 중", + "pendingPairingRequests": "{{count}}개 페어링 요청 대기 중", + "apiKeys": "API 키", + "documents": "문서", + "about": { + "menuItem": "정보", + "title": "GoClaw 정보", + "version": "버전", + "sourceCode": "소스 코드", + "license": "라이선스", + "documentation": "문서", + "reportBug": "버그 신고", + "upToDate": "최신 버전", + "updateAvailable": "업데이트 가능: {{version}}", + "viewRelease": "릴리스 노트 보기 →", + "releaseNotes": "릴리스 노트" + }, + "languages": { + "en": "English", + "vi": "Tiếng Việt", + "zh": "中文", + "ko": "한국어" + } +} diff --git a/ui/web/src/i18n/locales/ko/traces.json b/ui/web/src/i18n/locales/ko/traces.json new file mode 100644 index 0000000000..b094cdc8ba --- /dev/null +++ b/ui/web/src/i18n/locales/ko/traces.json @@ -0,0 +1,84 @@ +{ + "title": "추적", + "description": "LLM 호출 추적 및 성능 데이터", + "filterPlaceholder": "에이전트 ID로 필터...", + "filter": "필터", + "allAgents": "모든 에이전트", + "allChannels": "모든 채널", + "emptyTitle": "추적 없음", + "emptyDescription": "추적이 없습니다. 에이전트가 요청을 처리하면 기록됩니다.", + "columns": { + "name": "이름", + "status": "상태", + "duration": "소요 시간", + "tokens": "토큰", + "spans": "스팬", + "time": "시간" + }, + "source": { + "direct": "직접", + "group": "그룹", + "cron": "Cron", + "team": "팀", + "ws": "웹", + "unknown": "기타" + }, + "unnamed": "이름 없음", + "stopRun": "실행 중지", + "cached": "캐시됨", + "detail": { + "title": "추적 세부 정보", + "copyTraceId": "추적 ID 복사", + "notFound": "추적을 찾을 수 없습니다.", + "name": "이름:", + "status": "상태:", + "duration": "소요 시간:", + "channel": "채널:", + "tokens": "토큰:", + "spans": "스팬:", + "started": "시작됨:", + "delegatedFrom": "위임 출처:", + "createdAt": "생성됨:", + "input": "입력", + "output": "출력", + "copy": "복사", + "spansCount": "스팬 ({{count}}개)", + "llmCalls": "LLM", + "toolCalls": "도구", + "export": "추적 내보내기", + "stopRun": "실행 중지" + }, + "span": { + "model": "모델:", + "tokens": "토큰:", + "reasoning": "추론:", + "source": "소스", + "requested": "요청됨", + "effective": "실효", + "fallback": "대체", + "modelDefault": "모델 기본값", + "supportedLevels": "지원됨:", + "sourceValue": { + "provider_default": "공급자 기본값", + "reasoning": "에이전트 고급", + "thinking_level": "레거시 사고 레벨", + "unset": "미설정" + }, + "createdAt": "생성됨:", + "input": "입력:", + "output": "출력:", + "copy": "복사", + "startTime": "시작:", + "endTime": "종료:", + "cacheRead": "읽기", + "cacheWrite": "쓰기", + "thinking": "사고 중", + "cached": "캐시됨" + }, + "toast": { + "failedLoad": "추적 로드 실패", + "abortSent": "실행 중지됨", + "abortFailed": "실행 중지 실패", + "abortNotFound": "실행이 이미 완료되었거나 찾을 수 없습니다" + } +} diff --git a/ui/web/src/i18n/locales/ko/tts.json b/ui/web/src/i18n/locales/ko/tts.json new file mode 100644 index 0000000000..6aeedf06ec --- /dev/null +++ b/ui/web/src/i18n/locales/ko/tts.json @@ -0,0 +1,71 @@ +{ + "title": "텍스트 음성 변환", + "description": "TTS 공급자 및 자동 적용 설정", + "save": "저장", + "saving": "저장 중...", + "saveChanges": "변경사항 저장", + "status": { + "title": "상태", + "configured": "설정됨", + "disabled": "사용 안 함", + "noProvider": "설정된 TTS 공급자 없음", + "activeProvider": "기본 공급자: {{provider}}, 자동: {{auto}}" + }, + "general": { + "title": "일반 설정", + "primaryProvider": "기본 공급자", + "autoApplyMode": "자동 적용 모드", + "replyMode": "답장 모드", + "maxTextLength": "최대 텍스트 길이", + "timeout": "제한 시간 (ms)" + }, + "providers": { + "none": "없음 (사용 안 함)", + "openai": "OpenAI", + "elevenlabs": "ElevenLabs", + "edge": "Edge (무료)", + "minimax": "MiniMax" + }, + "autoModes": { + "off": "끄기", + "offDesc": "에이전트가 TTS 도구를 수동으로 사용할 수 있음", + "always": "항상", + "alwaysDesc": "모든 답장에 오디오 적용", + "inbound": "수신", + "inboundDesc": "사용자가 음성/오디오를 보낼 때만", + "tagged": "태그됨", + "taggedDesc": "답장에 [[tts]] 태그가 있을 때만" + }, + "replyModes": { + "final": "최종만", + "finalDesc": "최종 답장만", + "all": "전체", + "allDesc": "도구/블록 포함 모든 답장" + }, + "providerSettings": "{{provider}} 설정", + "openai": { + "apiKey": "API 키", + "apiBase": "API 기본 URL", + "model": "모델", + "voice": "음성" + }, + "elevenlabs": { + "apiKey": "API 키", + "baseUrl": "기본 URL", + "voiceId": "음성 ID", + "modelId": "모델 ID" + }, + "edge": { + "voice": "음성", + "speechRate": "발화 속도", + "hint": "Edge TTS는 무료이며 API 키가 필요 없습니다. 설치 명령: pip install edge-tts" + }, + "minimax": { + "apiKey": "API 키", + "groupId": "그룹 ID *", + "groupIdPlaceholder": "MiniMax에 필요", + "apiBase": "API 기본", + "model": "모델", + "voiceId": "음성 ID" + } +} diff --git a/ui/web/src/i18n/locales/ko/usage.json b/ui/web/src/i18n/locales/ko/usage.json new file mode 100644 index 0000000000..00e80d0b0c --- /dev/null +++ b/ui/web/src/i18n/locales/ko/usage.json @@ -0,0 +1,97 @@ +{ + "title": "사용량", + "description": "에이전트별 토큰 사용량 및 비용", + "emptyTitle": "사용량 데이터 없음", + "emptyDescription": "에이전트가 요청을 처리하면 여기에 사용량 데이터가 나타납니다.", + "summary": { + "sessions": "{{count}}개 세션", + "inputTokens": "입력 토큰", + "outputTokens": "출력 토큰", + "total": "합계" + }, + "recentRecords": "최근 기록", + "columns": { + "agent": "에이전트", + "model": "모델", + "provider": "공급자", + "input": "입력", + "output": "출력", + "total": "합계", + "channel": "채널", + "cost": "비용", + "status": "상태" + }, + "analytics": { + "title": "사용량 분석", + "period24h": "24시간", + "period7d": "7일", + "period30d": "30일", + "periodCustom": "사용자 지정", + "allAgents": "모든 에이전트", + "allProviders": "모든 공급자", + "allChannels": "모든 채널", + "activeFilters": "활성 필터", + "clearAll": "전체 지우기", + "exportCsv": "CSV 내보내기", + "requests": "요청", + "tokens": "토큰", + "cost": "비용", + "errors": "오류", + "uniqueUsers": "고유 사용자", + "llmCalls": "LLM 호출", + "toolCalls": "도구 호출", + "trendUp": "+{{value}}%", + "trendDown": "{{value}}%", + "vsPrevious": "이전 기간 대비", + "configurePricing": "비용 추적을 위한 모델 가격 설정", + "noData": "선택한 기간의 데이터 없음", + "tokenChart": { + "title": "시간별 토큰 사용량", + "input": "입력 토큰", + "output": "출력 토큰", + "cache": "캐시 읽기 토큰", + "thinking": "사고 토큰", + "total": "합계" + }, + "requestChart": { + "title": "요청 수량 & 오류", + "requests": "요청", + "errors": "오류", + "errorRate": "오류율" + }, + "distribution": { + "provider": "공급자 분포", + "model": "모델 분포", + "channel": "채널 분포", + "other": "기타", + "calls": "호출" + }, + "durationChart": { + "title": "소요 시간 & 성능", + "avgDuration": "평균 소요 시간", + "errorRate": "오류율 %" + }, + "knowledgeChart": { + "title": "메모리 & 지식 그래프 증가", + "memoryDocs": "메모리 문서", + "memoryChunks": "메모리 청크", + "kgEntities": "KG 엔티티", + "kgRelations": "KG 관계" + }, + "topModels": { + "title": "상위 모델", + "model": "모델", + "provider": "공급자", + "llmCalls": "LLM 호출", + "inputTokens": "입력 토큰", + "outputTokens": "출력 토큰", + "avgDuration": "평균 소요 시간", + "cost": "비용" + }, + "tooltip": { + "date": "날짜", + "total": "합계", + "errorRate": "오류율 {{value}}%" + } + } +} diff --git a/ui/web/src/i18n/locales/ko/v3-capabilities.json b/ui/web/src/i18n/locales/ko/v3-capabilities.json new file mode 100644 index 0000000000..c2d6456313 --- /dev/null +++ b/ui/web/src/i18n/locales/ko/v3-capabilities.json @@ -0,0 +1,56 @@ +{ + "title": "에이전트 기능 V3", + "subtitle": "에이전트 지능을 구동하는 핵심 시스템", + "note": "v3 에이전트에서는 모든 기능이 항상 활성화됩니다.", + "tabs": { + "pipeline": "파이프라인", + "memory": "메모리", + "knowledge": "지식", + "orchestration": "오케스트레이션" + }, + "pipeline": { + "title": "8단계 실행 파이프라인", + "description": "모든 메시지는 3개 단계의 8개 구조화된 단계를 거칩니다.", + "setup": "설정", + "setupDesc": "에이전트 설정, 워크스페이스 메타데이터, 사용자 기록, 과거 세션의 자동 삽입 메모리로 컨텍스트를 보강합니다.", + "iteration": "반복 루프", + "iterationDesc": "태스크가 완료되거나 최대 반복 횟수에 도달할 때까지 반복합니다.", + "think": "사고", + "thinkDesc": "전체 컨텍스트로 LLM 추론", + "prune": "정리", + "pruneDesc": "토큰 관리 및 압축", + "tools": "도구", + "toolsDesc": "LLM의 도구 호출 실행", + "observe": "관찰", + "observeDesc": "출력 캡처 및 유효성 검사", + "checkpoint": "체크포인트", + "checkpointDesc": "중간 상태 저장", + "finalize": "완료", + "finalizeDesc": "메타데이터 저장, 메모리 통합을 위한 이벤트 발생, 실행 정리." + }, + "memory": { + "title": "3계층 메모리 시스템", + "description": "대화에서 장기 지식까지 단계적 메모리.", + "l0Title": "L0 작업 메모리", + "l0Desc": "토큰 기반 자동 압축이 있는 세션 기록. 기록이 컨텍스트 창 임계값을 초과하면 오래된 메시지가 LLM에 의해 요약되고 잘립니다. 메모리 플러시는 압축 전에 핵심 사실을 영구 파일로 추출합니다.", + "l1Title": "L1 에피소딕 메모리", + "l1Desc": "각 대화 실행 후 생성되는 세션 요약. 각각 빠른 삽입을 위한 L0 추상화(~50 토큰)를 가집니다. 90일 TTL이 있는 하이브리드 FTS + 벡터 검색. 관련 추상화는 매 턴마다 시스템 프롬프트에 자동 삽입됩니다.", + "l2Title": "L2 의미 메모리", + "l2Desc": "LLM으로 추출된 엔티티와 관계가 있는 지식 그래프 (신뢰도 임계값 0.75). 자동 중복 제거로 유사한 엔티티를 병합합니다. 드리밍은 5개 이상의 에피소딕 항목을 메모리 도구로 검색 가능한 장기 사실 문서로 통합합니다.", + "eventFlow": "이벤트 기반: session.completed가 L1 생성을 트리거하고, 이는 L2 추출과 드리밍을 트리거합니다." + }, + "knowledge": { + "kgTitle": "지식 그래프", + "kgDesc": "세션 요약에서 LLM을 통해 사람, 프로젝트, 기술 등의 엔티티와 관계가 추출됩니다. 시간적 버전 관리로 사실이 유효한 시기를 추적합니다. 최대 5단계 다중 홉 순회로 knowledge_graph_search 도구를 통해 검색 가능합니다.", + "vaultTitle": "지식 볼트", + "vaultDesc": "[[위키링크]] 해석이 있는 문서 레지스트리. 통합 검색은 볼트 문서(40%), 에피소딕 메모리(30%), KG 엔티티(30%)를 단일 순위 결과로 혼합합니다. SHA-256 해시로 콘텐츠 변경을 감지하여 증분 인덱싱합니다.", + "dreamingTitle": "드리밍", + "dreamingDesc": "5개 이상의 세션 요약이 누적되면 LLM이 장기 사실을 합성합니다: 사용자 선호도, 프로젝트 아키텍처, 반복 패턴, 근거가 있는 핵심 결정. 검색 가능한 메모리 문서로 저장됩니다. 에이전트당 10분 간격으로 디바운스됩니다." + }, + "orchestration": { + "delegateTitle": "에이전트 위임", + "delegateDesc": "에이전트는 권한 확인을 통해 agent_links로 연결된 에이전트에 태스크를 위임합니다. 동기 모드는 설정 가능한 제한 시간까지 결과를 기다립니다. 비동기 모드는 즉시 실행되고 완료 시 메시지 버스를 통해 결과를 알립니다.", + "evolutionTitle": "자기 진화", + "evolutionDesc": "주간 사이클로 도구 사용 및 검색 메트릭을 추적합니다. 세 가지 패턴을 감지합니다: 낮은 검색 사용률 (< 20%), 높은 도구 실패율 (< 10% 성공), 반복 도구 패턴 (> 100회/주). 가드레일 보호 자동 적용 및 롤백이 있는 설정 조정을 제안합니다." + } +} diff --git a/ui/web/src/i18n/locales/ko/vault.json b/ui/web/src/i18n/locales/ko/vault.json new file mode 100644 index 0000000000..1d8cab47fd --- /dev/null +++ b/ui/web/src/i18n/locales/ko/vault.json @@ -0,0 +1,109 @@ +{ + "title": "지식 볼트", + "description": "위키링크 및 의미 검색이 있는 문서 레지스트리", + "search": "검색", + "searchPlaceholder": "문서 검색...", + "filterAgent": "에이전트", + "allAgents": "모든 에이전트", + "allTypes": "전체", + "noDocuments": "볼트에 문서 없음", + "noResults": "검색 결과 없음", + "searchFailed": "검색 실패", + "create": "만들기", + "createDoc": "문서 만들기", + "edit": "편집", + "save": "저장", + "saving": "저장 중...", + "cancel": "취소", + "delete": "삭제", + "confirmDelete": "이 문서를 삭제하시겠습니까?", + "yes": "예", + "no": "아니오", + "createLink": "링크 추가", + "addLink": "링크 추가", + "deleteLink": "링크 삭제", + "columns": { + "title": "제목", + "agent": "에이전트", + "path": "경로", + "type": "유형", + "scope": "범위", + "updated": "업데이트됨" + }, + "detail": { + "metadata": "메타데이터", + "outlinks": "나가는 링크", + "backlinks": "백링크", + "noLinks": "링크 없음", + "contentPreview": "내용 미리보기", + "fileNotFound": "파일을 찾을 수 없거나 접근할 수 없습니다", + "emptyContent": "사용 가능한 내용 없음", + "binaryFile": "바이너리 파일 — 미리보기 불가" + }, + "fields": { + "title": "제목", + "titlePlaceholder": "문서 제목", + "path": "경로", + "pathPlaceholder": "예: notes/my-doc.md", + "docType": "유형", + "scope": "범위", + "fromDoc": "출처", + "toDoc": "대상 문서", + "selectDoc": "문서 선택...", + "linkType": "링크 유형", + "linkContext": "컨텍스트", + "linkContextPlaceholder": "이 링크의 선택적 컨텍스트" + }, + "scope": { + "personal": "개인", + "team": "팀", + "shared": "공유" + }, + "type": { + "context": "컨텍스트", + "memory": "메모리", + "note": "노트", + "skill": "스킬", + "episodic": "에피소딕", + "media": "미디어", + "document": "문서" + }, + "rescanTooltip": "워크스페이스 재검사", + "enriching": "보강 중", + "enrichComplete": "보강 완료", + "rescanNew": "{{count}}개 새로운", + "rescanUpdated": "{{count}}개 업데이트됨", + "rescanUnchanged": "{{count}}개 변경 없음", + "rescanTruncated": "검사 중단됨 (제한 도달)", + "rescanNoFiles": "새 파일을 찾을 수 없습니다", + "rescanBusy": "재검사가 이미 진행 중입니다", + "rescanError": "재검사 실패", + "upload": { + "title": "볼트에 업로드", + "destination": "대상", + "shared": "공유", + "agent": "에이전트", + "team": "팀", + "selectAgent": "에이전트 선택...", + "selectTeam": "팀 선택...", + "dropzone": "여기에 파일을 놓거나 클릭하여 찾아보기", + "dropzoneHint": "텍스트 파일만 (.md, .txt, .json, .yaml, .csv, ...)", + "files": "파일 ({{count}}개)", + "uploading": "업로드 중...", + "upload": "업로드" + }, + "toast": { + "uploadSuccess": "{{count}}개 파일 업로드됨", + "uploadFailed": "업로드 실패", + "docCreated": "문서 생성됨", + "docCreateFailed": "문서 생성 실패", + "docUpdated": "문서 업데이트됨", + "docUpdateFailed": "문서 업데이트 실패", + "docDeleted": "문서 삭제됨", + "docDeleteFailed": "문서 삭제 실패", + "linkCreated": "링크 생성됨", + "linkCreateFailed": "링크 생성 실패", + "linkDeleted": "링크 삭제됨", + "linkDeleteFailed": "링크 삭제 실패" + } +} diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 580ce751bb..b312c0279c 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -20,11 +20,12 @@ export const LOCAL_STORAGE_KEYS = { TIMEZONE: "goclaw:timezone", } as const; -export const SUPPORTED_LANGUAGES = ["en", "vi", "zh"] as const; +export const SUPPORTED_LANGUAGES = ["en", "vi", "zh", "ko"] as const; export type Language = (typeof SUPPORTED_LANGUAGES)[number]; export const LANGUAGE_LABELS: Record = { en: "English", vi: "Tiếng Việt", zh: "中文", + ko: "한국어", };