From 13108b033376cde9d5f5efd0ccdf92f9d950053e Mon Sep 17 00:00:00 2001 From: lpf Date: Wed, 11 Mar 2026 19:00:19 +0800 Subject: [PATCH] release: v0.2.0 --- README.md | 15 +- README_EN.md | 15 +- cmd/cli_common.go | 2 +- cmd/cmd_config.go | 181 +- cmd/cmd_gateway.go | 2 +- cmd/cmd_status.go | 29 +- cmd/cmd_status_test.go | 21 +- cmd/main.go | 2 +- config.example.json | 105 +- pkg/agent/loop.go | 28 +- pkg/api/server.go | 47 +- pkg/api/server_test.go | 24 +- pkg/config/config.go | 161 +- pkg/config/validate.go | 69 +- pkg/config/validate_test.go | 75 +- pkg/providers/anthropic_transport.go | 27 +- pkg/providers/http_provider.go | 102 +- pkg/providers/http_proxy.go | 156 ++ pkg/providers/oauth.go | 220 ++- pkg/providers/oauth_test.go | 179 +- webui/package-lock.json | 4 +- webui/package.json | 2 +- webui/src/components/ArtifactPreviewCard.tsx | 56 + webui/src/components/CodeBlockPanel.tsx | 38 + webui/src/components/DetailGrid.tsx | 38 + webui/src/components/EmptyState.tsx | 47 + webui/src/components/FileListItem.tsx | 24 + webui/src/components/FormControls.tsx | 12 +- webui/src/components/InfoBlock.tsx | 30 + webui/src/components/InfoTile.tsx | 32 + webui/src/components/InsetCard.tsx | 20 + webui/src/components/ListPanel.tsx | 26 + webui/src/components/MetricPanel.tsx | 61 + webui/src/components/ModalFrame.tsx | 96 ++ webui/src/components/NoticePanel.tsx | 39 + webui/src/components/PageHeader.tsx | 33 + webui/src/components/PanelHeader.tsx | 20 + webui/src/components/SectionHeader.tsx | 33 + webui/src/components/SectionPanel.tsx | 47 + webui/src/components/SelectableListItem.tsx | 42 + webui/src/components/SummaryListItem.tsx | 40 + webui/src/components/ToolbarRow.tsx | 20 + .../channel/ChannelFieldRenderer.tsx | 133 ++ .../components/channel/ChannelSectionCard.tsx | 32 + .../channel/WhatsAppQRCodePanel.tsx | 53 + .../channel/WhatsAppStatusPanel.tsx | 106 ++ webui/src/components/channel/channelSchema.ts | 337 ++++ webui/src/components/chat/ChatComposer.tsx | 61 + webui/src/components/chat/ChatEmptyState.tsx | 19 + webui/src/components/chat/ChatMessageList.tsx | 79 + webui/src/components/chat/SubagentSidebar.tsx | 124 ++ .../components/chat/SubagentStreamFilters.tsx | 41 + webui/src/components/chat/chatUtils.ts | 116 ++ .../components/chat/useSubagentChatRuntime.ts | 104 ++ .../components/config/ConfigPageChrome.tsx | 24 +- .../config/GatewayConfigSection.tsx | 8 +- .../config/ProviderConfigSection.tsx | 101 +- webui/src/components/config/configUtils.ts | 4 +- .../config/useConfigGatewayActions.ts | 7 +- .../config/useConfigProviderActions.ts | 33 +- .../components/config/useConfigSaveAction.ts | 4 +- .../components/ekg/EKGDistributionCard.tsx | 42 + webui/src/components/ekg/EKGRankingCard.tsx | 42 + webui/src/components/mcp/MCPServerCard.tsx | 99 ++ webui/src/components/mcp/MCPServerEditor.tsx | 174 ++ webui/src/components/nodes/AgentTreePanel.tsx | 32 + .../nodes/DispatchArtifactPreviewSection.tsx | 38 + .../components/nodes/DispatchReplayPanel.tsx | 84 + webui/src/components/nodes/NodeP2PPanel.tsx | 54 + .../subagentProfiles/ProfileEditorPanel.tsx | 235 +++ .../subagentProfiles/ProfileListPanel.tsx | 47 + .../subagentProfiles/profileDraft.ts | 63 + webui/src/components/subagents/GraphCard.tsx | 94 ++ .../components/subagents/TopologyCanvas.tsx | 116 ++ .../components/subagents/TopologyControls.tsx | 71 + .../components/subagents/TopologyTooltip.tsx | 114 ++ .../src/components/subagents/subagentTypes.ts | 98 ++ .../subagents/topologyGraphBuilder.ts | 377 +++++ .../src/components/subagents/topologyTypes.ts | 33 + .../src/components/subagents/topologyUtils.ts | 109 ++ .../subagents/useSubagentRuntimeData.ts | 181 ++ .../subagents/useTopologyViewport.ts | 214 +++ webui/src/i18n/index.ts | 18 +- webui/src/index.css | 20 +- webui/src/pages/ChannelSettings.tsx | 636 +------ webui/src/pages/Chat.tsx | 434 +---- webui/src/pages/Config.tsx | 5 +- webui/src/pages/Cron.tsx | 77 +- webui/src/pages/Dashboard.tsx | 397 ++--- webui/src/pages/EKG.tsx | 150 +- webui/src/pages/LogCodes.tsx | 22 +- webui/src/pages/Logs.tsx | 38 +- webui/src/pages/MCP.tsx | 320 +--- webui/src/pages/Memory.tsx | 38 +- webui/src/pages/NodeArtifacts.tsx | 98 +- webui/src/pages/Nodes.tsx | 373 ++--- webui/src/pages/Providers.tsx | 35 +- webui/src/pages/Skills.tsx | 133 +- webui/src/pages/SubagentProfiles.tsx | 342 +--- webui/src/pages/Subagents.tsx | 1471 ++--------------- webui/src/pages/TaskAudit.tsx | 261 ++- webui/src/utils/artifacts.ts | 14 + webui/src/utils/object.ts | 3 + webui/src/utils/runtime.ts | 7 + 104 files changed, 6519 insertions(+), 4296 deletions(-) create mode 100644 pkg/providers/http_proxy.go create mode 100644 webui/src/components/ArtifactPreviewCard.tsx create mode 100644 webui/src/components/CodeBlockPanel.tsx create mode 100644 webui/src/components/DetailGrid.tsx create mode 100644 webui/src/components/EmptyState.tsx create mode 100644 webui/src/components/FileListItem.tsx create mode 100644 webui/src/components/InfoBlock.tsx create mode 100644 webui/src/components/InfoTile.tsx create mode 100644 webui/src/components/InsetCard.tsx create mode 100644 webui/src/components/ListPanel.tsx create mode 100644 webui/src/components/MetricPanel.tsx create mode 100644 webui/src/components/ModalFrame.tsx create mode 100644 webui/src/components/NoticePanel.tsx create mode 100644 webui/src/components/PageHeader.tsx create mode 100644 webui/src/components/PanelHeader.tsx create mode 100644 webui/src/components/SectionHeader.tsx create mode 100644 webui/src/components/SectionPanel.tsx create mode 100644 webui/src/components/SelectableListItem.tsx create mode 100644 webui/src/components/SummaryListItem.tsx create mode 100644 webui/src/components/ToolbarRow.tsx create mode 100644 webui/src/components/channel/ChannelFieldRenderer.tsx create mode 100644 webui/src/components/channel/ChannelSectionCard.tsx create mode 100644 webui/src/components/channel/WhatsAppQRCodePanel.tsx create mode 100644 webui/src/components/channel/WhatsAppStatusPanel.tsx create mode 100644 webui/src/components/channel/channelSchema.ts create mode 100644 webui/src/components/chat/ChatComposer.tsx create mode 100644 webui/src/components/chat/ChatEmptyState.tsx create mode 100644 webui/src/components/chat/ChatMessageList.tsx create mode 100644 webui/src/components/chat/SubagentSidebar.tsx create mode 100644 webui/src/components/chat/SubagentStreamFilters.tsx create mode 100644 webui/src/components/chat/chatUtils.ts create mode 100644 webui/src/components/chat/useSubagentChatRuntime.ts create mode 100644 webui/src/components/ekg/EKGDistributionCard.tsx create mode 100644 webui/src/components/ekg/EKGRankingCard.tsx create mode 100644 webui/src/components/mcp/MCPServerCard.tsx create mode 100644 webui/src/components/mcp/MCPServerEditor.tsx create mode 100644 webui/src/components/nodes/AgentTreePanel.tsx create mode 100644 webui/src/components/nodes/DispatchArtifactPreviewSection.tsx create mode 100644 webui/src/components/nodes/DispatchReplayPanel.tsx create mode 100644 webui/src/components/nodes/NodeP2PPanel.tsx create mode 100644 webui/src/components/subagentProfiles/ProfileEditorPanel.tsx create mode 100644 webui/src/components/subagentProfiles/ProfileListPanel.tsx create mode 100644 webui/src/components/subagentProfiles/profileDraft.ts create mode 100644 webui/src/components/subagents/GraphCard.tsx create mode 100644 webui/src/components/subagents/TopologyCanvas.tsx create mode 100644 webui/src/components/subagents/TopologyControls.tsx create mode 100644 webui/src/components/subagents/TopologyTooltip.tsx create mode 100644 webui/src/components/subagents/subagentTypes.ts create mode 100644 webui/src/components/subagents/topologyGraphBuilder.ts create mode 100644 webui/src/components/subagents/topologyTypes.ts create mode 100644 webui/src/components/subagents/topologyUtils.ts create mode 100644 webui/src/components/subagents/useSubagentRuntimeData.ts create mode 100644 webui/src/components/subagents/useTopologyViewport.ts create mode 100644 webui/src/utils/artifacts.ts create mode 100644 webui/src/utils/object.ts create mode 100644 webui/src/utils/runtime.ts diff --git a/README.md b/README.md index d6211f7..95f466c 100644 --- a/README.md +++ b/README.md @@ -102,24 +102,26 @@ curl -fsSL https://raw.githubusercontent.com/YspCoder/clawgo/main/install.sh | b clawgo onboard ``` -### 3. 配置模型 +### 3. 选择服务商与模型 ```bash -clawgo provider +clawgo provider list +clawgo provider use openai/gpt-5.4 +clawgo provider configure ``` 如果服务商使用 OAuth 登录,例如 `Codex`、`Anthropic`、`Antigravity`、`Gemini CLI`、`Kimi`、`Qwen`: ```bash -clawgo provider -clawgo provider login codex-oauth -clawgo provider login codex-oauth --manual +clawgo provider list +clawgo provider login codex +clawgo provider login codex --manual ``` 登录完成后会把 OAuth 凭证保存到本地,并自动同步该账号可用模型,后续可直接作为普通 provider 使用。 回调型 OAuth(如 `codex` / `anthropic` / `antigravity` / `gemini`)在云服务器场景下可使用 `--manual`:服务端打印授权链接,你在桌面浏览器登录后,把最终回调 URL 粘贴回终端即可完成换取 token。 设备码型 OAuth(如 `kimi` / `qwen`)会直接打印验证链接和用户码,桌面浏览器完成授权后,网关会自动轮询换取 token,无需回填 callback URL。 -对同一个 provider 重复执行 `clawgo provider login codex-oauth --manual` 会追加多个 OAuth 账号;当某个账号额度耗尽或触发限流时,会自动切换到下一个已登录账号重试。 +对同一个 provider 重复执行 `clawgo provider login codex --manual` 会追加多个 OAuth 账号;当某个账号额度耗尽或触发限流时,会自动切换到下一个已登录账号重试。 WebUI 也支持发起 OAuth 登录、回填 callback URL、设备码确认、上传 `auth.json`、查看账号列表、手动刷新和删除账号。 如果你同时有 `API key` 和 `OAuth` 账号,推荐直接把同一个 provider 配成 `auth: "hybrid"`: @@ -127,7 +129,6 @@ WebUI 也支持发起 OAuth 登录、回填 callback URL、设备码确认、上 - 优先使用 `api_key` - 当 `api_key` 触发额度不足、429、限流等错误时,自动切到该 provider 下的 OAuth 账号池 - OAuth 账号仍然支持多账号轮换、后台预刷新、`auth.json` 导入和 WebUI 管理 -- `oauth.hybrid_priority` 可选 `api_first` / `oauth_first` - `oauth.cooldown_sec` 可控制某个 OAuth 账号被限流后暂时熔断多久,默认 `900` - provider runtime 面板会显示当前候选池排序、最近一次成功命中的凭证,以及最近命中/错误历史 - 如需在重启后保留 runtime 历史,可给 provider 配置 `runtime_persist`、`runtime_history_file`、`runtime_history_max` diff --git a/README_EN.md b/README_EN.md index e7562b7..25fec0d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -93,24 +93,26 @@ curl -fsSL https://raw.githubusercontent.com/YspCoder/clawgo/main/install.sh | b clawgo onboard ``` -### 3. Configure a provider +### 3. Choose a provider and model ```bash -clawgo provider +clawgo provider list +clawgo provider use openai/gpt-5.4 +clawgo provider configure ``` For OAuth-backed providers such as `Codex`, `Anthropic`, `Antigravity`, `Gemini CLI`, `Kimi`, and `Qwen`: ```bash -clawgo provider -clawgo provider login codex-oauth -clawgo provider login codex-oauth --manual +clawgo provider list +clawgo provider login codex +clawgo provider login codex --manual ``` After login, clawgo stores the OAuth session locally and syncs the models available to that account so the provider can be used directly. Use `--manual` on a cloud server for callback-based OAuth (`codex`, `anthropic`, `antigravity`, `gemini`): clawgo prints the auth URL, you complete login in a desktop browser, then paste the final callback URL back into the terminal. Device-flow OAuth (`kimi`, `qwen`) prints the verification URL and user code, then clawgo polls automatically after authorization without requiring a callback URL to be pasted back. -Repeat `clawgo provider login codex-oauth --manual` on the same provider to add multiple OAuth accounts; when one account hits quota or rate limits, clawgo automatically retries with the next logged-in account. +Repeat `clawgo provider login codex --manual` on the same provider to add multiple OAuth accounts; when one account hits quota or rate limits, clawgo automatically retries with the next logged-in account. The WebUI can also start OAuth login, accept callback URL pasteback, confirm device-flow authorization, import `auth.json`, list accounts, refresh accounts, and delete accounts. If you have both an `API key` and OAuth accounts for the same upstream, prefer configuring that provider as `auth: "hybrid"`: @@ -118,7 +120,6 @@ If you have both an `API key` and OAuth accounts for the same upstream, prefer c - it uses `api_key` first - when the API key hits quota/rate-limit style failures, it automatically falls back to the provider's OAuth account pool - OAuth accounts still keep multi-account rotation, background pre-refresh, `auth.json` import, and WebUI management -- `oauth.hybrid_priority` supports `api_first` or `oauth_first` - `oauth.cooldown_sec` controls how long a rate-limited OAuth account stays out of rotation; default is `900` - the provider runtime panel shows current candidate ordering, the most recent successful credential, and recent hit/error history - to persist runtime history across restarts, configure `runtime_persist`, `runtime_history_file`, and `runtime_history_max` on the provider diff --git a/cmd/cli_common.go b/cmd/cli_common.go index fa6dd5d..8cf0e3a 100644 --- a/cmd/cli_common.go +++ b/cmd/cli_common.go @@ -92,7 +92,7 @@ func printHelp() { fmt.Println(" agent Interact with the agent directly") fmt.Println(" gateway Register/manage gateway service") fmt.Println(" status Show clawgo status") - fmt.Println(" provider Configure provider credentials") + fmt.Println(" provider Manage providers, models, and OAuth login") fmt.Println(" config Get/set config values") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" channel Test and manage messaging channels") diff --git a/cmd/cmd_config.go b/cmd/cmd_config.go index 4ab3b17..25a09fe 100644 --- a/cmd/cmd_config.go +++ b/cmd/cmd_config.go @@ -47,7 +47,7 @@ func configHelp() { fmt.Println("Examples:") fmt.Println(" clawgo config set channels.telegram.enabled true") fmt.Println(" clawgo config set channels.telegram.enable true") - fmt.Println(" clawgo config get providers.proxy.api_base") + fmt.Println(" clawgo config get models.providers.openai.api_base") fmt.Println(" clawgo config check") fmt.Println(" clawgo config reload") } @@ -180,6 +180,12 @@ func providerCmd() { case "login": providerLoginCmd() return + case "list": + providerListCmd() + return + case "use": + providerUseCmd() + return case "configure": // Continue into the interactive editor below. } @@ -192,12 +198,15 @@ func providerCmd() { } reader := bufio.NewReader(os.Stdin) - defaultProxy := strings.TrimSpace(cfg.Agents.Defaults.Proxy) - if defaultProxy == "" { - defaultProxy = "proxy" + defaultProvider, defaultModel := config.ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary) + if defaultProvider == "" { + defaultProvider = config.PrimaryProviderName(cfg) } available := providerNames(cfg) - fmt.Printf("Current default provider: %s\n", defaultProxy) + fmt.Printf("Current primary provider: %s\n", defaultProvider) + if defaultModel != "" { + fmt.Printf("Current primary model: %s\n", defaultModel) + } fmt.Printf("Available providers: %s\n", strings.Join(available, ", ")) argName := "" @@ -205,11 +214,11 @@ func providerCmd() { argName = strings.TrimSpace(os.Args[2]) } if argName == "" || strings.HasPrefix(argName, "-") { - argName = defaultProxy + argName = defaultProvider } providerName := promptLine(reader, "Provider name to configure", argName) if providerName == "" { - providerName = defaultProxy + providerName = defaultProvider } pc := providerConfigByName(cfg, providerName) @@ -220,10 +229,7 @@ func providerCmd() { pc.Auth = "bearer" } if len(pc.Models) == 0 { - pc.Models = append([]string{}, cfg.Providers.Proxy.Models...) - } - if len(pc.Models) == 0 { - pc.Models = []string{"glm-4.7"} + pc.Models = []string{"gpt-5.4"} } pc.APIBase = promptLine(reader, "api_base", pc.APIBase) @@ -241,23 +247,26 @@ func providerCmd() { pc.SupportsResponsesCompact = promptBool(reader, "supports_responses_compact", pc.SupportsResponsesCompact) if strings.EqualFold(strings.TrimSpace(pc.Auth), "oauth") || strings.EqualFold(strings.TrimSpace(pc.Auth), "hybrid") { pc.OAuth.Provider = promptLine(reader, "oauth.provider", firstNonEmptyString(pc.OAuth.Provider, "codex")) + pc.OAuth.NetworkProxy = promptLine(reader, "oauth.network_proxy", pc.OAuth.NetworkProxy) pc.OAuth.CredentialFile = promptLine(reader, "oauth.credential_file", pc.OAuth.CredentialFile) pc.OAuth.CallbackPort = parseIntOrDefault(promptLine(reader, "oauth.callback_port", fmt.Sprintf("%d", defaultInt(pc.OAuth.CallbackPort, 1455))), defaultInt(pc.OAuth.CallbackPort, 1455)) - if strings.EqualFold(strings.TrimSpace(pc.Auth), "hybrid") { - pc.OAuth.HybridPriority = promptLine(reader, "oauth.hybrid_priority (api_first/oauth_first)", firstNonEmptyString(pc.OAuth.HybridPriority, "api_first")) - } pc.OAuth.CooldownSec = parseIntOrDefault(promptLine(reader, "oauth.cooldown_sec", fmt.Sprintf("%d", defaultInt(pc.OAuth.CooldownSec, 900))), defaultInt(pc.OAuth.CooldownSec, 900)) } setProviderConfigByName(cfg, providerName, pc) - makeDefault := promptBool(reader, fmt.Sprintf("Set %s as agents.defaults.proxy", providerName), providerName == defaultProxy) - if makeDefault { - cfg.Agents.Defaults.Proxy = providerName + currentPrimaryProvider, currentPrimaryModel := config.ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary) + makePrimary := promptBool(reader, fmt.Sprintf("Set %s as agents.defaults.model.primary provider", providerName), providerName == currentPrimaryProvider) + if makePrimary { + targetModel := currentPrimaryModel + if targetModel == "" && len(pc.Models) > 0 { + targetModel = pc.Models[0] + } + cfg.Agents.Defaults.Model.Primary = providerName + "/" + targetModel } - currentFallbacks := strings.Join(cfg.Agents.Defaults.ProxyFallbacks, ",") - fallbackRaw := promptLine(reader, "agents.defaults.proxy_fallbacks (comma-separated names)", currentFallbacks) + currentFallbacks := strings.Join(cfg.Agents.Defaults.Model.Fallbacks, ",") + fallbackRaw := promptLine(reader, "agents.defaults.model.fallbacks (comma-separated provider/model refs)", currentFallbacks) fallbacks := parseCSV(fallbackRaw) valid := map[string]struct{}{} for _, name := range providerNames(cfg) { @@ -265,12 +274,17 @@ func providerCmd() { } filteredFallbacks := make([]string, 0, len(fallbacks)) seen := map[string]struct{}{} - defaultName := strings.TrimSpace(cfg.Agents.Defaults.Proxy) + defaultRef := strings.TrimSpace(cfg.Agents.Defaults.Model.Primary) for _, fb := range fallbacks { - if fb == "" || fb == defaultName { + if fb == "" || fb == defaultRef { continue } - if _, ok := valid[fb]; !ok { + fbProvider, fbModel := config.ParseProviderModelRef(fb) + if fbProvider == "" || fbModel == "" { + fmt.Printf("Skip invalid fallback provider/model ref: %s\n", fb) + continue + } + if _, ok := valid[fbProvider]; !ok { fmt.Printf("Skip unknown fallback provider: %s\n", fb) continue } @@ -280,7 +294,7 @@ func providerCmd() { seen[fb] = struct{}{} filteredFallbacks = append(filteredFallbacks, fb) } - cfg.Agents.Defaults.ProxyFallbacks = filteredFallbacks + cfg.Agents.Defaults.Model.Fallbacks = filteredFallbacks if err := config.SaveConfig(getConfigPath(), cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) @@ -300,6 +314,76 @@ func providerCmd() { fmt.Println("鉁?Gateway hot reload signal sent") } +func providerListCmd() { + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + os.Exit(1) + } + primary := strings.TrimSpace(cfg.Agents.Defaults.Model.Primary) + names := providerNames(cfg) + for _, name := range names { + pc, _ := config.ProviderConfigByName(cfg, name) + models := strings.Join(pc.Models, ",") + if models == "" { + models = "-" + } + marker := " " + if strings.HasPrefix(primary, name+"/") { + marker = "*" + } + fmt.Printf("%s %s auth=%s models=%s api_base=%s\n", marker, name, strings.TrimSpace(pc.Auth), models, strings.TrimSpace(pc.APIBase)) + } + if primary != "" { + fmt.Printf("\nPrimary: %s\n", primary) + } +} + +func providerUseCmd() { + if len(os.Args) < 4 { + fmt.Println("Usage: clawgo provider use ") + return + } + ref := strings.TrimSpace(os.Args[3]) + providerName, modelName := config.ParseProviderModelRef(ref) + if providerName == "" || modelName == "" { + fmt.Println("Error: expected provider/model") + return + } + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + os.Exit(1) + } + pc, ok := config.ProviderConfigByName(cfg, providerName) + if !ok { + fmt.Printf("Error: unknown provider %q\n", providerName) + os.Exit(1) + } + foundModel := false + for _, candidate := range pc.Models { + if strings.TrimSpace(candidate) == modelName { + foundModel = true + break + } + } + if !foundModel { + fmt.Printf("Error: model %q not found in provider %q\n", modelName, providerName) + os.Exit(1) + } + cfg.Agents.Defaults.Model.Primary = providerName + "/" + modelName + if err := config.SaveConfig(getConfigPath(), cfg); err != nil { + fmt.Printf("Error saving config: %v\n", err) + os.Exit(1) + } + fmt.Printf("Primary model set to %s\n", cfg.Agents.Defaults.Model.Primary) + if running, reloadErr := triggerGatewayReload(); reloadErr == nil { + fmt.Println("Gateway hot reload signal sent") + } else if running { + fmt.Printf("Hot reload not applied: %v\n", reloadErr) + } +} + func providerLoginCmd() { cfg, err := loadConfig() if err != nil { @@ -307,13 +391,14 @@ func providerLoginCmd() { os.Exit(1) } - providerName := strings.TrimSpace(cfg.Agents.Defaults.Proxy) + providerName, _ := config.ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary) if providerName == "" { - providerName = "proxy" + providerName = config.PrimaryProviderName(cfg) } manual := false noBrowser := false accountLabel := "" + networkProxy := "" for i := 3; i < len(os.Args); i++ { arg := strings.TrimSpace(os.Args[i]) switch arg { @@ -326,12 +411,21 @@ func providerLoginCmd() { i++ accountLabel = strings.TrimSpace(os.Args[i]) } + case "--proxy": + if i+1 < len(os.Args) { + i++ + networkProxy = strings.TrimSpace(os.Args[i]) + } case "": default: if strings.HasPrefix(arg, "--label=") { accountLabel = strings.TrimSpace(strings.TrimPrefix(arg, "--label=")) continue } + if strings.HasPrefix(arg, "--proxy=") { + networkProxy = strings.TrimSpace(strings.TrimPrefix(arg, "--proxy=")) + continue + } if !strings.HasPrefix(arg, "-") { providerName = arg } @@ -349,6 +443,9 @@ func providerLoginCmd() { if manual && strings.TrimSpace(pc.OAuth.RedirectURL) == "" && pc.OAuth.CallbackPort <= 0 { pc.OAuth.CallbackPort = 1455 } + if strings.TrimSpace(networkProxy) == "" { + networkProxy = strings.TrimSpace(pc.OAuth.NetworkProxy) + } timeout := pc.TimeoutSec if timeout <= 0 { @@ -367,6 +464,7 @@ func providerLoginCmd() { NoBrowser: noBrowser, Reader: os.Stdin, AccountLabel: accountLabel, + NetworkProxy: networkProxy, }) if err != nil { fmt.Printf("OAuth login failed: %v\n", err) @@ -399,6 +497,9 @@ func providerLoginCmd() { if session.Email != "" { fmt.Printf("Account: %s\n", session.Email) } + if session.NetworkProxy != "" { + fmt.Printf("Network proxy: %s\n", session.NetworkProxy) + } fmt.Printf("Credential file: %s\n", firstNonEmptyString(session.CredentialFile, oauth.CredentialFile())) if len(pc.OAuth.CredentialFiles) > 1 { fmt.Printf("OAuth accounts: %d\n", len(pc.OAuth.CredentialFiles)) @@ -414,8 +515,8 @@ func providerLoginCmd() { } func providerNames(cfg *config.Config) []string { - names := []string{"proxy"} - for k := range cfg.Providers.Proxies { + names := make([]string, 0, len(cfg.Models.Providers)) + for k := range cfg.Models.Providers { k = strings.TrimSpace(k) if k == "" { continue @@ -427,33 +528,25 @@ func providerNames(cfg *config.Config) []string { } func providerConfigByName(cfg *config.Config, name string) config.ProviderConfig { - name = strings.TrimSpace(name) - if name == "" || name == "proxy" { - return cfg.Providers.Proxy - } - if cfg.Providers.Proxies != nil { - if pc, ok := cfg.Providers.Proxies[name]; ok { - return pc - } + if pc, ok := config.ProviderConfigByName(cfg, name); ok { + return pc } return config.ProviderConfig{ - APIBase: cfg.Providers.Proxy.APIBase, - TimeoutSec: cfg.Providers.Proxy.TimeoutSec, - Auth: cfg.Providers.Proxy.Auth, - Models: append([]string{}, cfg.Providers.Proxy.Models...), + TimeoutSec: 90, + Auth: "bearer", + Models: []string{"gpt-5.4"}, } } func setProviderConfigByName(cfg *config.Config, name string, pc config.ProviderConfig) { name = strings.TrimSpace(name) - if name == "" || name == "proxy" { - cfg.Providers.Proxy = pc + if name == "" { return } - if cfg.Providers.Proxies == nil { - cfg.Providers.Proxies = map[string]config.ProviderConfig{} + if cfg.Models.Providers == nil { + cfg.Models.Providers = map[string]config.ProviderConfig{} } - cfg.Providers.Proxies[name] = pc + cfg.Models.Providers[name] = pc } func promptLine(reader *bufio.Reader, label, defaultValue string) string { diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index 89d6bac..f4369d5 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -400,7 +400,7 @@ func gatewayCmd() { } runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) && - reflect.DeepEqual(cfg.Providers, newCfg.Providers) && + reflect.DeepEqual(cfg.Models, newCfg.Models) && reflect.DeepEqual(cfg.Tools, newCfg.Tools) && reflect.DeepEqual(cfg.Channels, newCfg.Channels) && reflect.DeepEqual(cfg.Gateway.Nodes, newCfg.Gateway.Nodes) diff --git a/cmd/cmd_status.go b/cmd/cmd_status.go index ab07a46..70f90ca 100644 --- a/cmd/cmd_status.go +++ b/cmd/cmd_status.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + "github.com/YspCoder/clawgo/pkg/config" "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/providers" ) @@ -37,31 +38,21 @@ func statusCmd() { } if _, err := os.Stat(configPath); err == nil { - activeProvider := cfg.Providers.Proxy - activeProxyName := "proxy" - if name := strings.TrimSpace(cfg.Agents.Defaults.Proxy); name != "" && name != "proxy" { - if p, ok := cfg.Providers.Proxies[name]; ok { - activeProvider = p - activeProxyName = name - } + activeProviderName, activeModel := config.ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary) + if activeProviderName == "" { + activeProviderName = config.PrimaryProviderName(cfg) } - activeModel := "" - for _, m := range activeProvider.Models { - if s := strings.TrimSpace(m); s != "" { - activeModel = s - break - } - } - fmt.Printf("Model: %s\n", activeModel) - fmt.Printf("Proxy: %s\n", activeProxyName) - fmt.Printf("Provider API Base: %s\n", activeProvider.APIBase) - fmt.Printf("Supports /v1/responses/compact: %v\n", providers.ProviderSupportsResponsesCompact(cfg, activeProxyName)) + activeProvider, _ := config.ProviderConfigByName(cfg, activeProviderName) + fmt.Printf("Primary Model: %s\n", activeModel) + fmt.Printf("Primary Provider: %s\n", activeProviderName) + fmt.Printf("Provider Base URL: %s\n", activeProvider.APIBase) + fmt.Printf("Responses Compact: %v\n", providers.ProviderSupportsResponsesCompact(cfg, activeProviderName)) hasKey := strings.TrimSpace(activeProvider.APIKey) != "" status := "not set" if hasKey { status = "configured" } - fmt.Printf("Provider API Key: %s\n", status) + fmt.Printf("API Key Status: %s\n", status) fmt.Printf("Logging: %v\n", cfg.Logging.Enabled) if cfg.Logging.Enabled { fmt.Printf("Log File: %s\n", cfg.LogFilePath()) diff --git a/cmd/cmd_status_test.go b/cmd/cmd_status_test.go index 2dec050..412e7c0 100644 --- a/cmd/cmd_status_test.go +++ b/cmd/cmd_status_test.go @@ -24,16 +24,21 @@ func TestStatusCmdUsesActiveProviderDetails(t *testing.T) { cfg := config.DefaultConfig() cfg.Logging.Enabled = false cfg.Agents.Defaults.Workspace = workspace - cfg.Agents.Defaults.Proxy = "backup" + cfg.Agents.Defaults.Model.Primary = "backup/backup-model" cfg.Gateway.Nodes.P2P.Enabled = true cfg.Gateway.Nodes.P2P.Transport = "webrtc" cfg.Gateway.Nodes.P2P.STUNServers = []string{"stun:stun.example.net:3478"} cfg.Gateway.Nodes.P2P.ICEServers = []config.GatewayICEConfig{ {URLs: []string{"turn:turn.example.net:3478"}, Username: "user", Credential: "secret"}, } - cfg.Providers.Proxy.APIBase = "https://primary.example/v1" - cfg.Providers.Proxy.APIKey = "" - cfg.Providers.Proxies["backup"] = config.ProviderConfig{ + cfg.Models.Providers["openai"] = config.ProviderConfig{ + APIBase: "https://primary.example/v1", + APIKey: "", + Models: []string{"gpt-5.4"}, + Auth: "bearer", + TimeoutSec: 30, + } + cfg.Models.Providers["backup"] = config.ProviderConfig{ APIBase: "https://backup.example/v1", APIKey: "backup-key", Models: []string{"backup-model"}, @@ -65,13 +70,13 @@ func TestStatusCmdUsesActiveProviderDetails(t *testing.T) { } out := buf.String() - if !strings.Contains(out, "Proxy: backup") { - t.Fatalf("expected backup proxy in output, got: %s", out) + if !strings.Contains(out, "Primary Provider: backup") { + t.Fatalf("expected backup provider in output, got: %s", out) } - if !strings.Contains(out, "Provider API Base: https://backup.example/v1") { + if !strings.Contains(out, "Provider Base URL: https://backup.example/v1") { t.Fatalf("expected active provider api base in output, got: %s", out) } - if !strings.Contains(out, "Provider API Key: configured") { + if !strings.Contains(out, "API Key Status: configured") { t.Fatalf("expected active provider api key status in output, got: %s", out) } if !strings.Contains(out, "Nodes P2P: enabled=true transport=webrtc") { diff --git a/cmd/main.go b/cmd/main.go index a949d65..609a45e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,7 +19,7 @@ import ( //go:embed workspace var embeddedFiles embed.FS -var version = "dev" +var version = "0.2.0" var buildTime = "unknown" const logo = "馃" diff --git a/config.example.json b/config.example.json index 5efda48..09e4e21 100644 --- a/config.example.json +++ b/config.example.json @@ -2,8 +2,13 @@ "agents": { "defaults": { "workspace": "~/.clawgo/workspace", - "proxy": "proxy", - "proxy_fallbacks": ["backup"], + "model": { + "primary": "codex/gpt-5.4", + "fallbacks": [ + "openai/gpt-5.4", + "anthropic/claude-sonnet-4-20250514" + ] + }, "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20, @@ -84,7 +89,7 @@ "allowlist": ["sessions", "subagents", "memory_search", "repo_map"] }, "runtime": { - "proxy": "proxy", + "provider": "codex", "temperature": 0.2, "max_retries": 1, "retry_backoff_ms": 1000, @@ -105,7 +110,7 @@ "allowlist": ["filesystem", "shell", "repo_map", "sessions"] }, "runtime": { - "proxy": "proxy", + "provider": "openai", "temperature": 0.2, "max_retries": 1, "retry_backoff_ms": 1000, @@ -128,7 +133,7 @@ "allowlist": ["shell", "filesystem", "process_manager", "sessions"] }, "runtime": { - "proxy": "proxy", + "provider": "anthropic", "temperature": 0.1, "max_retries": 1, "retry_backoff_ms": 1000, @@ -200,27 +205,11 @@ "allow_from": [] } }, - "providers": { - "proxy": { - "api_key": "YOUR_CLIPROXYAPI_KEY", - "api_base": "http://localhost:8080/v1", - "models": ["glm-4.7", "gpt-4o-mini"], - "responses": { - "web_search_enabled": false, - "web_search_context_size": "", - "file_search_vector_store_ids": [], - "file_search_max_num_results": 0, - "include": [], - "stream_include_usage": false - }, - "supports_responses_compact": false, - "auth": "bearer", - "timeout_sec": 90 - }, - "proxies": { - "codex-oauth": { + "models": { + "providers": { + "codex": { "api_base": "https://api.openai.com/v1", - "models": [], + "models": ["gpt-5.4"], "responses": { "web_search_enabled": false, "web_search_context_size": "", @@ -240,13 +229,13 @@ "refresh_lead_sec": 1800 }, "runtime_persist": true, - "runtime_history_file": "~/.clawgo/runtime/providers/codex-oauth.json", + "runtime_history_file": "~/.clawgo/runtime/providers/codex.json", "runtime_history_max": 24, "timeout_sec": 90 }, - "gemini-oauth": { - "api_base": "https://your-openai-compatible-gateway.example.com/v1", - "models": [], + "gemini": { + "api_base": "https://generativelanguage.googleapis.com/v1beta/openai", + "models": ["gemini-2.5-pro"], "responses": { "web_search_enabled": false, "web_search_context_size": "", @@ -266,14 +255,14 @@ "refresh_lead_sec": 1800 }, "runtime_persist": true, - "runtime_history_file": "~/.clawgo/runtime/providers/gemini-oauth.json", + "runtime_history_file": "~/.clawgo/runtime/providers/gemini.json", "runtime_history_max": 24, "timeout_sec": 90 }, - "openai-hybrid": { - "api_key": "sk-your-primary-api-key", + "openai": { + "api_key": "sk-your-openai-api-key", "api_base": "https://api.openai.com/v1", - "models": [], + "models": ["gpt-5.4", "gpt-5.4-mini"], "responses": { "web_search_enabled": false, "web_search_context_size": "", @@ -283,24 +272,36 @@ "stream_include_usage": false }, "supports_responses_compact": true, - "auth": "hybrid", + "timeout_sec": 90 + }, + "anthropic": { + "api_base": "https://api.anthropic.com", + "models": ["claude-sonnet-4-20250514"], + "responses": { + "web_search_enabled": false, + "web_search_context_size": "", + "file_search_vector_store_ids": [], + "file_search_max_num_results": 0, + "include": [], + "stream_include_usage": false + }, + "supports_responses_compact": true, + "auth": "oauth", "oauth": { - "provider": "codex", - "credential_files": ["~/.clawgo/auth/codex.json"], + "provider": "claude", + "credential_files": ["~/.clawgo/auth/claude.json"], "callback_port": 1455, - "hybrid_priority": "api_first", - "cooldown_sec": 900, "refresh_scan_sec": 600, "refresh_lead_sec": 1800 }, "runtime_persist": true, - "runtime_history_file": "~/.clawgo/runtime/providers/openai-hybrid.json", + "runtime_history_file": "~/.clawgo/runtime/providers/anthropic.json", "runtime_history_max": 24, "timeout_sec": 90 }, - "qwen-oauth": { - "api_base": "https://your-openai-compatible-gateway.example.com/v1", - "models": [], + "qwen": { + "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "models": ["qwen-max"], "responses": { "web_search_enabled": false, "web_search_context_size": "", @@ -318,14 +319,13 @@ "refresh_lead_sec": 1800 }, "runtime_persist": true, - "runtime_history_file": "~/.clawgo/runtime/providers/qwen-oauth.json", + "runtime_history_file": "~/.clawgo/runtime/providers/qwen.json", "runtime_history_max": 24, "timeout_sec": 90 }, - "backup": { - "api_key": "YOUR_BACKUP_PROXY_KEY", - "api_base": "http://localhost:8081/v1", - "models": ["gpt-4o-mini", "deepseek-chat"], + "kimi": { + "api_base": "https://api.moonshot.cn/v1", + "models": ["kimi-k2-0711-preview"], "responses": { "web_search_enabled": false, "web_search_context_size": "", @@ -335,7 +335,16 @@ "stream_include_usage": false }, "supports_responses_compact": true, - "auth": "bearer", + "auth": "oauth", + "oauth": { + "provider": "kimi", + "credential_files": ["~/.clawgo/auth/kimi.json"], + "refresh_scan_sec": 600, + "refresh_lead_sec": 1800 + }, + "runtime_persist": true, + "runtime_history_file": "~/.clawgo/runtime/providers/kimi.json", + "runtime_history_max": 24, "timeout_sec": 90 } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 9b9d9f2..eb3b67d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -342,22 +342,20 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers subagentDigestDelay: 5 * time.Second, subagentDigests: map[string]*subagentDigestState{}, } + if _, primaryModel := config.ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary); strings.TrimSpace(primaryModel) != "" { + loop.model = strings.TrimSpace(primaryModel) + } go loop.runSubagentDigestTicker() - // Initialize provider fallback chain (primary + proxy_fallbacks). + // Initialize provider fallback chain (primary + model fallbacks). loop.providerPool = map[string]providers.LLMProvider{} loop.providerNames = []string{} - primaryName := cfg.Agents.Defaults.Proxy - if primaryName == "" { - primaryName = "proxy" - } + primaryName := config.PrimaryProviderName(cfg) loop.providerPool[primaryName] = provider loop.providerNames = append(loop.providerNames, primaryName) - if strings.TrimSpace(primaryName) == "proxy" { - loop.providerResponses[primaryName] = cfg.Providers.Proxy.Responses - } else if pc, ok := cfg.Providers.Proxies[primaryName]; ok { + if pc, ok := config.ProviderConfigByName(cfg, primaryName); ok { loop.providerResponses[primaryName] = pc.Responses } - for _, name := range cfg.Agents.Defaults.ProxyFallbacks { + for _, name := range cfg.Agents.Defaults.Model.Fallbacks { if name == "" { continue } @@ -371,13 +369,13 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers if dup { continue } - if p2, err := providers.CreateProviderByName(cfg, name); err == nil { - loop.providerPool[name] = p2 - loop.providerNames = append(loop.providerNames, name) - if pc, ok := cfg.Providers.Proxies[name]; ok { - loop.providerResponses[name] = pc.Responses + if p2, err := providers.CreateProviderByName(cfg, name); err == nil { + loop.providerPool[name] = p2 + loop.providerNames = append(loop.providerNames, name) + if pc, ok := config.ProviderConfigByName(cfg, name); ok { + loop.providerResponses[name] = pc.Responses + } } - } } // Inject recursive run logic so subagents can use full tool-calling flows. diff --git a/pkg/api/server.go b/pkg/api/server.go index c07ebd3..a05470f 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -914,8 +914,8 @@ func collectRiskyConfigPaths(oldMap, newMap map[string]interface{}) []string { "channels.telegram.token", "channels.telegram.allow_from", "channels.telegram.allow_chats", - "providers.proxy.api_base", - "providers.proxy.api_key", + "models.providers.openai.api_base", + "models.providers.openai.api_key", "gateway.token", "gateway.port", } @@ -923,9 +923,9 @@ func collectRiskyConfigPaths(oldMap, newMap map[string]interface{}) []string { for _, path := range paths { seen[path] = true } - for _, name := range collectProviderProxyNames(oldMap, newMap) { + for _, name := range collectProviderNames(oldMap, newMap) { for _, field := range []string{"api_base", "api_key"} { - path := "providers.proxies." + name + "." + field + path := "models.providers." + name + "." + field if !seen[path] { paths = append(paths, path) seen[path] = true @@ -935,13 +935,13 @@ func collectRiskyConfigPaths(oldMap, newMap map[string]interface{}) []string { return paths } -func collectProviderProxyNames(maps ...map[string]interface{}) []string { +func collectProviderNames(maps ...map[string]interface{}) []string { seen := map[string]bool{} names := make([]string, 0) for _, root := range maps { - providers, _ := root["providers"].(map[string]interface{}) - proxies, _ := providers["proxies"].(map[string]interface{}) - for name := range proxies { + models, _ := root["models"].(map[string]interface{}) + providers, _ := models["providers"].(map[string]interface{}) + for name := range providers { if strings.TrimSpace(name) == "" || seen[name] { continue } @@ -1001,6 +1001,7 @@ func (s *Server) handleWebUIProviderOAuthStart(w http.ResponseWriter, r *http.Re var body struct { Provider string `json:"provider"` AccountLabel string `json:"account_label"` + NetworkProxy string `json:"network_proxy"` ProviderConfig cfgpkg.ProviderConfig `json:"provider_config"` } if r.Method == http.MethodPost { @@ -1011,6 +1012,7 @@ func (s *Server) handleWebUIProviderOAuthStart(w http.ResponseWriter, r *http.Re } else { body.Provider = strings.TrimSpace(r.URL.Query().Get("provider")) body.AccountLabel = strings.TrimSpace(r.URL.Query().Get("account_label")) + body.NetworkProxy = strings.TrimSpace(r.URL.Query().Get("network_proxy")) } cfg, pc, err := s.resolveProviderConfig(strings.TrimSpace(body.Provider), body.ProviderConfig) if err != nil { @@ -1027,7 +1029,10 @@ func (s *Server) handleWebUIProviderOAuthStart(w http.ResponseWriter, r *http.Re http.Error(w, err.Error(), http.StatusBadRequest) return } - flow, err := loginMgr.StartManualFlow() + flow, err := loginMgr.StartManualFlowWithOptions(providers.OAuthLoginOptions{ + AccountLabel: body.AccountLabel, + NetworkProxy: firstNonEmptyString(strings.TrimSpace(body.NetworkProxy), strings.TrimSpace(pc.OAuth.NetworkProxy)), + }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -1044,6 +1049,7 @@ func (s *Server) handleWebUIProviderOAuthStart(w http.ResponseWriter, r *http.Re "user_code": flow.UserCode, "instructions": flow.Instructions, "account_label": strings.TrimSpace(body.AccountLabel), + "network_proxy": strings.TrimSpace(body.NetworkProxy), }) } @@ -1061,6 +1067,7 @@ func (s *Server) handleWebUIProviderOAuthComplete(w http.ResponseWriter, r *http FlowID string `json:"flow_id"` CallbackURL string `json:"callback_url"` AccountLabel string `json:"account_label"` + NetworkProxy string `json:"network_proxy"` ProviderConfig cfgpkg.ProviderConfig `json:"provider_config"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -1091,6 +1098,7 @@ func (s *Server) handleWebUIProviderOAuthComplete(w http.ResponseWriter, r *http } session, models, err := loginMgr.CompleteManualFlowWithOptions(r.Context(), pc.APIBase, flow, body.CallbackURL, providers.OAuthLoginOptions{ AccountLabel: strings.TrimSpace(body.AccountLabel), + NetworkProxy: firstNonEmptyString(strings.TrimSpace(body.NetworkProxy), strings.TrimSpace(pc.OAuth.NetworkProxy)), }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -1111,6 +1119,7 @@ func (s *Server) handleWebUIProviderOAuthComplete(w http.ResponseWriter, r *http "ok": true, "account": session.Email, "credential_file": session.CredentialFile, + "network_proxy": session.NetworkProxy, "models": models, }) } @@ -1130,6 +1139,7 @@ func (s *Server) handleWebUIProviderOAuthImport(w http.ResponseWriter, r *http.R } providerName := strings.TrimSpace(r.FormValue("provider")) accountLabel := strings.TrimSpace(r.FormValue("account_label")) + networkProxy := strings.TrimSpace(r.FormValue("network_proxy")) inlineCfgRaw := strings.TrimSpace(r.FormValue("provider_config")) var inlineCfg cfgpkg.ProviderConfig if inlineCfgRaw != "" { @@ -1165,6 +1175,7 @@ func (s *Server) handleWebUIProviderOAuthImport(w http.ResponseWriter, r *http.R } session, models, err := loginMgr.ImportAuthJSONWithOptions(r.Context(), pc.APIBase, header.Filename, data, providers.OAuthLoginOptions{ AccountLabel: accountLabel, + NetworkProxy: firstNonEmptyString(networkProxy, strings.TrimSpace(pc.OAuth.NetworkProxy)), }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -1185,6 +1196,7 @@ func (s *Server) handleWebUIProviderOAuthImport(w http.ResponseWriter, r *http.R "ok": true, "account": session.Email, "credential_file": session.CredentialFile, + "network_proxy": session.NetworkProxy, "models": models, }) } @@ -1401,10 +1413,7 @@ func (s *Server) loadProviderConfig(name string) (*cfgpkg.Config, cfgpkg.Provide return nil, cfgpkg.ProviderConfig{}, err } providerName := strings.TrimSpace(name) - if providerName == "" || providerName == "proxy" { - return cfg, cfg.Providers.Proxy, nil - } - pc, ok := cfg.Providers.Proxies[providerName] + pc, ok := cfg.Models.Providers[providerName] if !ok { return nil, cfgpkg.ProviderConfig{}, fmt.Errorf("provider %q not found", providerName) } @@ -1435,14 +1444,10 @@ func (s *Server) saveProviderConfig(cfg *cfgpkg.Config, name string, pc cfgpkg.P return fmt.Errorf("config is nil") } providerName := strings.TrimSpace(name) - if providerName == "" || providerName == "proxy" { - cfg.Providers.Proxy = pc - } else { - if cfg.Providers.Proxies == nil { - cfg.Providers.Proxies = map[string]cfgpkg.ProviderConfig{} - } - cfg.Providers.Proxies[providerName] = pc + if cfg.Models.Providers == nil { + cfg.Models.Providers = map[string]cfgpkg.ProviderConfig{} } + cfg.Models.Providers[providerName] = pc if err := cfgpkg.SaveConfig(s.configPath, cfg); err != nil { return err } @@ -6052,7 +6057,7 @@ func hotReloadFieldInfo() []map[string]interface{} { {"path": "logging.*", "name": "Logging", "description": "Log level, persistence, and related settings"}, {"path": "sentinel.*", "name": "Sentinel", "description": "Health checks and auto-heal behavior"}, {"path": "agents.*", "name": "Agent", "description": "Models, policies, and default behavior"}, - {"path": "providers.*", "name": "Providers", "description": "LLM providers and proxy settings"}, + {"path": "models.providers.*", "name": "Providers", "description": "LLM provider registry and auth settings"}, {"path": "tools.*", "name": "Tools", "description": "Tool toggles and runtime options"}, {"path": "channels.*", "name": "Channels", "description": "Telegram and other channel settings"}, {"path": "cron.*", "name": "Cron", "description": "Global cron runtime settings"}, diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index a1a6ffc..c589f51 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -248,16 +248,20 @@ func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) cfg := cfgpkg.DefaultConfig() cfg.Logging.Enabled = false - cfg.Providers.Proxy.APIBase = "https://old.example/v1" - cfg.Providers.Proxy.APIKey = "test-key" + pc := cfg.Models.Providers["openai"] + pc.APIBase = "https://old.example/v1" + pc.APIKey = "test-key" + cfg.Models.Providers["openai"] = pc if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("save config: %v", err) } bodyCfg := cfgpkg.DefaultConfig() bodyCfg.Logging.Enabled = false - bodyCfg.Providers.Proxy.APIBase = "https://new.example/v1" - bodyCfg.Providers.Proxy.APIKey = "test-key" + bodyPC := bodyCfg.Models.Providers["openai"] + bodyPC.APIBase = "https://new.example/v1" + bodyPC.APIKey = "test-key" + bodyCfg.Models.Providers["openai"] = bodyPC body, err := json.Marshal(bodyCfg) if err != nil { t.Fatalf("marshal body: %v", err) @@ -278,8 +282,8 @@ func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) if !strings.Contains(rec.Body.String(), `"requires_confirm":true`) { t.Fatalf("expected requires_confirm response, got: %s", rec.Body.String()) } - if !strings.Contains(rec.Body.String(), `providers.proxy.api_base`) { - t.Fatalf("expected providers.proxy.api_base in changed_fields, got: %s", rec.Body.String()) + if !strings.Contains(rec.Body.String(), `models.providers.openai.api_base`) { + t.Fatalf("expected models.providers.openai.api_base in changed_fields, got: %s", rec.Body.String()) } } @@ -291,7 +295,7 @@ func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testin cfg := cfgpkg.DefaultConfig() cfg.Logging.Enabled = false - cfg.Providers.Proxies["backup"] = cfgpkg.ProviderConfig{ + cfg.Models.Providers["backup"] = cfgpkg.ProviderConfig{ APIBase: "https://backup.example/v1", APIKey: "old-secret", Models: []string{"backup-model"}, @@ -304,7 +308,7 @@ func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testin bodyCfg := cfgpkg.DefaultConfig() bodyCfg.Logging.Enabled = false - bodyCfg.Providers.Proxies["backup"] = cfgpkg.ProviderConfig{ + bodyCfg.Models.Providers["backup"] = cfgpkg.ProviderConfig{ APIBase: "https://backup.example/v1", APIKey: "new-secret", Models: []string{"backup-model"}, @@ -331,8 +335,8 @@ func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testin if !strings.Contains(rec.Body.String(), `"requires_confirm":true`) { t.Fatalf("expected requires_confirm response, got: %s", rec.Body.String()) } - if !strings.Contains(rec.Body.String(), `providers.proxies.backup.api_key`) { - t.Fatalf("expected providers.proxies.backup.api_key in changed_fields, got: %s", rec.Body.String()) + if !strings.Contains(rec.Body.String(), `models.providers.backup.api_key`) { + t.Fatalf("expected models.providers.backup.api_key in changed_fields, got: %s", rec.Body.String()) } } diff --git a/pkg/config/config.go b/pkg/config/config.go index ba61f8b..3c5efae 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "strings" "sync" "time" @@ -16,16 +17,16 @@ import ( ) type Config struct { - Agents AgentsConfig `json:"agents"` - Channels ChannelsConfig `json:"channels"` - Providers ProvidersConfig `json:"providers"` - Gateway GatewayConfig `json:"gateway"` - Cron CronConfig `json:"cron"` - Tools ToolsConfig `json:"tools"` - Logging LoggingConfig `json:"logging"` - Sentinel SentinelConfig `json:"sentinel"` - Memory MemoryConfig `json:"memory"` - mu sync.RWMutex + Agents AgentsConfig `json:"agents"` + Channels ChannelsConfig `json:"channels"` + Models ModelsConfig `json:"models,omitempty"` + Gateway GatewayConfig `json:"gateway"` + Cron CronConfig `json:"cron"` + Tools ToolsConfig `json:"tools"` + Logging LoggingConfig `json:"logging"` + Sentinel SentinelConfig `json:"sentinel"` + Memory MemoryConfig `json:"memory"` + mu sync.RWMutex } type AgentsConfig struct { @@ -107,7 +108,7 @@ type SubagentToolsConfig struct { } type SubagentRuntimeConfig struct { - Proxy string `json:"proxy,omitempty"` + Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` Temperature float64 `json:"temperature,omitempty"` TimeoutSec int `json:"timeout_sec,omitempty"` @@ -120,8 +121,7 @@ type SubagentRuntimeConfig struct { type AgentDefaults struct { Workspace string `json:"workspace" env:"CLAWGO_AGENTS_DEFAULTS_WORKSPACE"` - Proxy string `json:"proxy" env:"CLAWGO_AGENTS_DEFAULTS_PROXY"` - ProxyFallbacks []string `json:"proxy_fallbacks" env:"CLAWGO_AGENTS_DEFAULTS_PROXY_FALLBACKS"` + Model AgentModelDefaults `json:"model,omitempty"` MaxTokens int `json:"max_tokens" env:"CLAWGO_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature float64 `json:"temperature" env:"CLAWGO_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"CLAWGO_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` @@ -131,6 +131,11 @@ type AgentDefaults struct { SummaryPolicy SystemSummaryPolicyConfig `json:"summary_policy"` } +type AgentModelDefaults struct { + Primary string `json:"primary,omitempty" env:"CLAWGO_AGENTS_DEFAULTS_MODEL_PRIMARY"` + Fallbacks []string `json:"fallbacks,omitempty" env:"CLAWGO_AGENTS_DEFAULTS_MODEL_FALLBACKS"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_ENABLED"` EverySec int `json:"every_sec" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_EVERY_SEC"` @@ -234,52 +239,8 @@ type DingTalkConfig struct { AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_DINGTALK_ALLOW_FROM"` } -type ProvidersConfig struct { - Proxy ProviderConfig `json:"proxy"` - Proxies map[string]ProviderConfig `json:"proxies"` -} - -type providerProxyItem struct { - Name string `json:"name"` - ProviderConfig -} - -func (p *ProvidersConfig) UnmarshalJSON(data []byte) error { - var tmp struct { - Proxy ProviderConfig `json:"proxy"` - Proxies json.RawMessage `json:"proxies"` - } - if err := json.Unmarshal(data, &tmp); err != nil { - return err - } - p.Proxy = tmp.Proxy - p.Proxies = map[string]ProviderConfig{} - if len(bytes.TrimSpace(tmp.Proxies)) == 0 || string(bytes.TrimSpace(tmp.Proxies)) == "null" { - return nil - } - // Preferred format: object map - var asMap map[string]ProviderConfig - if err := json.Unmarshal(tmp.Proxies, &asMap); err == nil { - for k, v := range asMap { - if k == "" { - continue - } - p.Proxies[k] = v - } - return nil - } - // Compatibility format: array [{name, ...provider fields...}] - var asArr []providerProxyItem - if err := json.Unmarshal(tmp.Proxies, &asArr); err == nil { - for _, it := range asArr { - if it.Name == "" { - continue - } - p.Proxies[it.Name] = it.ProviderConfig - } - return nil - } - return fmt.Errorf("providers.proxies must be object map or array of {name,...}") +type ModelsConfig struct { + Providers map[string]ProviderConfig `json:"providers,omitempty"` } type ProviderConfig struct { @@ -298,6 +259,7 @@ type ProviderConfig struct { type ProviderOAuthConfig struct { Provider string `json:"provider,omitempty"` + NetworkProxy string `json:"network_proxy,omitempty"` CredentialFile string `json:"credential_file,omitempty"` CredentialFiles []string `json:"credential_files,omitempty"` CallbackPort int `json:"callback_port,omitempty"` @@ -307,7 +269,6 @@ type ProviderOAuthConfig struct { TokenURL string `json:"token_url,omitempty"` RedirectURL string `json:"redirect_url,omitempty"` Scopes []string `json:"scopes,omitempty"` - HybridPriority string `json:"hybrid_priority,omitempty"` CooldownSec int `json:"cooldown_sec,omitempty"` RefreshScanSec int `json:"refresh_scan_sec,omitempty"` RefreshLeadSec int `json:"refresh_lead_sec,omitempty"` @@ -484,8 +445,7 @@ func DefaultConfig() *Config { Agents: AgentsConfig{ Defaults: AgentDefaults{ Workspace: filepath.Join(configDir, "workspace"), - Proxy: "proxy", - ProxyFallbacks: []string{}, + Model: AgentModelDefaults{Primary: "openai/gpt-5.4", Fallbacks: []string{}}, MaxTokens: 8192, Temperature: 0.7, MaxToolIterations: 20, @@ -599,13 +559,14 @@ func DefaultConfig() *Config { AllowFrom: []string{}, }, }, - Providers: ProvidersConfig{ - Proxy: ProviderConfig{ - APIBase: "http://localhost:8080/v1", - Models: []string{"glm-4.7"}, - TimeoutSec: 90, + Models: ModelsConfig{ + Providers: map[string]ProviderConfig{ + "openai": { + APIBase: "https://api.openai.com/v1", + Models: []string{"gpt-5.4"}, + TimeoutSec: 90, + }, }, - Proxies: map[string]ProviderConfig{}, }, Gateway: GatewayConfig{ Host: "0.0.0.0", @@ -699,6 +660,60 @@ func DefaultConfig() *Config { } } +func ParseProviderModelRef(raw string) (provider string, model string) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", "" + } + if idx := strings.Index(trimmed, "/"); idx > 0 { + return strings.TrimSpace(trimmed[:idx]), strings.TrimSpace(trimmed[idx+1:]) + } + return "", trimmed +} + +func AllProviderConfigs(cfg *Config) map[string]ProviderConfig { + out := map[string]ProviderConfig{} + if cfg == nil { + return out + } + for name, pc := range cfg.Models.Providers { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + out[trimmed] = pc + } + return out +} + +func ProviderConfigByName(cfg *Config, name string) (ProviderConfig, bool) { + if cfg == nil { + return ProviderConfig{}, false + } + pc, ok := AllProviderConfigs(cfg)[strings.TrimSpace(name)] + return pc, ok +} + +func ProviderExists(cfg *Config, name string) bool { + _, ok := ProviderConfigByName(cfg, name) + return ok +} + +func PrimaryProviderName(cfg *Config) string { + if cfg == nil { + return "openai" + } + if provider, _ := ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary); provider != "" { + return provider + } + for name := range cfg.Models.Providers { + if trimmed := strings.TrimSpace(name); trimmed != "" { + return trimmed + } + } + return "openai" +} + func generateGatewayToken() string { var buf [16]byte if _, err := rand.Read(buf[:]); err != nil { @@ -771,13 +786,19 @@ func (c *Config) WorkspacePath() string { func (c *Config) GetAPIKey() string { c.mu.RLock() defer c.mu.RUnlock() - return c.Providers.Proxy.APIKey + if pc, ok := c.Models.Providers[PrimaryProviderName(c)]; ok { + return pc.APIKey + } + return "" } func (c *Config) GetAPIBase() string { c.mu.RLock() defer c.mu.RUnlock() - return c.Providers.Proxy.APIBase + if pc, ok := c.Models.Providers[PrimaryProviderName(c)]; ok { + return pc.APIBase + } + return "" } func (c *Config) LogFilePath() string { diff --git a/pkg/config/validate.go b/pkg/config/validate.go index b55b4a3..b144871 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -87,30 +87,33 @@ func Validate(cfg *Config) []error { } } - if len(cfg.Providers.Proxies) == 0 { - errs = append(errs, validateProviderConfig("providers.proxy", cfg.Providers.Proxy)...) - } else { - for name, p := range cfg.Providers.Proxies { - errs = append(errs, validateProviderConfig("providers.proxies."+name, p)...) + for name, p := range cfg.Models.Providers { + errs = append(errs, validateProviderConfig("models.providers."+name, p)...) + } + if len(cfg.Models.Providers) == 0 { + errs = append(errs, fmt.Errorf("models.providers must contain at least one provider")) + } + for _, name := range cfg.Agents.Defaults.Model.Fallbacks { + if !ProviderExists(cfg, name) { + errs = append(errs, fmt.Errorf("agents.defaults.model.fallbacks contains unknown provider %q", name)) } } - if cfg.Agents.Defaults.Proxy != "" { - if !providerExists(cfg, cfg.Agents.Defaults.Proxy) { - errs = append(errs, fmt.Errorf("agents.defaults.proxy %q not found in providers", cfg.Agents.Defaults.Proxy)) + if primaryRef := strings.TrimSpace(cfg.Agents.Defaults.Model.Primary); primaryRef != "" { + providerName, modelName := ParseProviderModelRef(primaryRef) + if providerName == "" { + providerName = PrimaryProviderName(cfg) } - } - for _, name := range cfg.Agents.Defaults.ProxyFallbacks { - if !providerExists(cfg, name) { - errs = append(errs, fmt.Errorf("agents.defaults.proxy_fallbacks contains unknown proxy %q", name)) + if !ProviderExists(cfg, providerName) { + errs = append(errs, fmt.Errorf("agents.defaults.model.primary %q references unknown provider %q", primaryRef, providerName)) + } + if strings.TrimSpace(modelName) == "" { + errs = append(errs, fmt.Errorf("agents.defaults.model.primary must include a model, expected provider/model")) } } if cfg.Agents.Defaults.ContextCompaction.Enabled && cfg.Agents.Defaults.ContextCompaction.Mode == "responses_compact" { - active := cfg.Agents.Defaults.Proxy - if active == "" { - active = "proxy" - } - if pc, ok := providerConfigByName(cfg, active); !ok || !pc.SupportsResponsesCompact { - errs = append(errs, fmt.Errorf("context_compaction.mode=responses_compact requires active proxy %q with supports_responses_compact=true", active)) + active := PrimaryProviderName(cfg) + if pc, ok := ProviderConfigByName(cfg, active); !ok || !pc.SupportsResponsesCompact { + errs = append(errs, fmt.Errorf("context_compaction.mode=responses_compact requires active provider %q with supports_responses_compact=true", active)) } } errs = append(errs, validateAgentRouter(cfg)...) @@ -474,8 +477,8 @@ func validateSubagents(cfg *Config) []error { errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file must stay within workspace", id)) } } - if proxy := strings.TrimSpace(raw.Runtime.Proxy); proxy != "" && !providerExists(cfg, proxy) { - errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.proxy %q not found in providers", id, proxy)) + if provider := strings.TrimSpace(raw.Runtime.Provider); provider != "" && !ProviderExists(cfg, provider) { + errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.provider %q not found in providers", id, provider)) } for _, sender := range raw.AcceptFrom { sender = strings.TrimSpace(sender) @@ -562,13 +565,6 @@ func validateProviderConfig(path string, p ProviderConfig) []error { errs = append(errs, fmt.Errorf("%s.oauth.provider is required when auth=hybrid", path)) } } - if p.OAuth.HybridPriority != "" { - switch strings.ToLower(strings.TrimSpace(p.OAuth.HybridPriority)) { - case "api_first", "oauth_first": - default: - errs = append(errs, fmt.Errorf("%s.oauth.hybrid_priority must be one of: api_first, oauth_first", path)) - } - } if p.OAuth.CooldownSec < 0 { errs = append(errs, fmt.Errorf("%s.oauth.cooldown_sec must be >= 0", path)) } @@ -587,25 +583,6 @@ func validateProviderConfig(path string, p ProviderConfig) []error { return errs } -func providerExists(cfg *Config, name string) bool { - if name == "proxy" && cfg.Providers.Proxy.APIBase != "" { - return true - } - if cfg.Providers.Proxies == nil { - return false - } - _, ok := cfg.Providers.Proxies[name] - return ok -} - -func providerConfigByName(cfg *Config, name string) (ProviderConfig, bool) { - if strings.TrimSpace(name) == "proxy" { - return cfg.Providers.Proxy, true - } - pc, ok := cfg.Providers.Proxies[name] - return pc, ok -} - func validateNonEmptyStringList(path string, values []string) []error { if len(values) == 0 { return nil diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index 64d8c6a..a7ef6e8 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -34,7 +34,7 @@ func TestValidateSubagentsAllowsKnownPeers(t *testing.T) { AcceptFrom: []string{"main"}, CanTalkTo: []string{"main"}, Runtime: SubagentRuntimeConfig{ - Proxy: "proxy", + Provider: "openai", }, } @@ -66,7 +66,7 @@ func TestValidateSubagentsRejectsAbsolutePromptFile(t *testing.T) { Enabled: true, SystemPromptFile: "/tmp/AGENT.md", Runtime: SubagentRuntimeConfig{ - Proxy: "proxy", + Provider: "openai", }, } @@ -82,7 +82,7 @@ func TestValidateSubagentsRequiresPromptFileWhenEnabled(t *testing.T) { cfg.Agents.Subagents["coder"] = SubagentConfig{ Enabled: true, Runtime: SubagentRuntimeConfig{ - Proxy: "proxy", + Provider: "openai", }, } @@ -123,7 +123,7 @@ func TestValidateSubagentsRejectsInvalidNotifyMainPolicy(t *testing.T) { SystemPromptFile: "agents/coder/AGENT.md", NotifyMainPolicy: "loud", Runtime: SubagentRuntimeConfig{ - Proxy: "proxy", + Provider: "openai", }, } @@ -276,9 +276,11 @@ func TestValidateProviderOAuthAllowsEmptyModelsBeforeLogin(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Providers.Proxy.Auth = "oauth" - cfg.Providers.Proxy.Models = nil - cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{Provider: "codex"} + pc := cfg.Models.Providers["openai"] + pc.Auth = "oauth" + pc.Models = nil + pc.OAuth = ProviderOAuthConfig{Provider: "codex"} + cfg.Models.Providers["openai"] = pc if errs := Validate(cfg); len(errs) != 0 { t.Fatalf("expected oauth provider config to be valid before model sync, got %v", errs) @@ -289,9 +291,11 @@ func TestValidateProviderOAuthRequiresProviderName(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Providers.Proxy.Auth = "oauth" - cfg.Providers.Proxy.Models = nil - cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{} + pc := cfg.Models.Providers["openai"] + pc.Auth = "oauth" + pc.Models = nil + pc.OAuth = ProviderOAuthConfig{} + cfg.Models.Providers["openai"] = pc errs := Validate(cfg) if len(errs) == 0 { @@ -299,7 +303,7 @@ func TestValidateProviderOAuthRequiresProviderName(t *testing.T) { } found := false for _, err := range errs { - if strings.Contains(err.Error(), "providers.proxy.oauth.provider") { + if strings.Contains(err.Error(), "models.providers.openai.oauth.provider") { found = true break } @@ -313,10 +317,12 @@ func TestValidateProviderHybridAllowsEmptyModels(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Providers.Proxy.Auth = "hybrid" - cfg.Providers.Proxy.APIKey = "sk-test" - cfg.Providers.Proxy.Models = nil - cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{Provider: "codex"} + pc := cfg.Models.Providers["openai"] + pc.Auth = "hybrid" + pc.APIKey = "sk-test" + pc.Models = nil + pc.OAuth = ProviderOAuthConfig{Provider: "codex"} + cfg.Models.Providers["openai"] = pc if errs := Validate(cfg); len(errs) != 0 { t.Fatalf("expected hybrid provider config to be valid before model sync, got %v", errs) @@ -327,10 +333,12 @@ func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) { t.Parallel() cfg := DefaultConfig() - cfg.Providers.Proxy.Auth = "hybrid" - cfg.Providers.Proxy.APIKey = "sk-test" - cfg.Providers.Proxy.Models = nil - cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{} + pc := cfg.Models.Providers["openai"] + pc.Auth = "hybrid" + pc.APIKey = "sk-test" + pc.Models = nil + pc.OAuth = ProviderOAuthConfig{} + cfg.Models.Providers["openai"] = pc errs := Validate(cfg) if len(errs) == 0 { @@ -338,7 +346,7 @@ func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) { } found := false for _, err := range errs { - if strings.Contains(err.Error(), "providers.proxy.oauth.provider") { + if strings.Contains(err.Error(), "models.providers.openai.oauth.provider") { found = true break } @@ -347,30 +355,3 @@ func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) { t.Fatalf("expected oauth.provider validation error, got %v", errs) } } - -func TestValidateProviderHybridPriorityRejectsInvalidValue(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Providers.Proxy.Auth = "hybrid" - cfg.Providers.Proxy.APIKey = "sk-test" - cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{ - Provider: "codex", - HybridPriority: "random_first", - } - - errs := Validate(cfg) - if len(errs) == 0 { - t.Fatalf("expected validation errors") - } - found := false - for _, err := range errs { - if strings.Contains(err.Error(), "oauth.hybrid_priority") { - found = true - break - } - } - if !found { - t.Fatalf("expected oauth.hybrid_priority validation error, got %v", errs) - } -} diff --git a/pkg/providers/anthropic_transport.go b/pkg/providers/anthropic_transport.go index f9502ab..f056b6f 100644 --- a/pkg/providers/anthropic_transport.go +++ b/pkg/providers/anthropic_transport.go @@ -1,6 +1,7 @@ package providers import ( + "context" "net" "net/http" "strings" @@ -16,16 +17,25 @@ type anthropicOAuthRoundTripper struct { connections map[string]*http2.ClientConn pending map[string]*sync.Cond dialer net.Dialer + dialContext func(context.Context, string, string) (net.Conn, error) } -func newAnthropicOAuthHTTPClient(timeout time.Duration) *http.Client { +func newAnthropicOAuthHTTPClient(timeout time.Duration, proxyURL string) (*http.Client, error) { + rt, err := newAnthropicOAuthRoundTripper(proxyURL) + if err != nil { + return nil, err + } return &http.Client{ Timeout: timeout, - Transport: newAnthropicOAuthRoundTripper(), - } + Transport: rt, + }, nil } -func newAnthropicOAuthRoundTripper() *anthropicOAuthRoundTripper { +func newAnthropicOAuthRoundTripper(proxyURL string) (*anthropicOAuthRoundTripper, error) { + dialContext, err := proxyDialContext(proxyURL) + if err != nil { + return nil, err + } return &anthropicOAuthRoundTripper{ connections: map[string]*http2.ClientConn{}, pending: map[string]*sync.Cond{}, @@ -33,7 +43,8 @@ func newAnthropicOAuthRoundTripper() *anthropicOAuthRoundTripper { Timeout: 15 * time.Second, KeepAlive: 30 * time.Second, }, - } + dialContext: dialContext, + }, nil } func (t *anthropicOAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { @@ -89,7 +100,11 @@ func (t *anthropicOAuthRoundTripper) getOrCreateConnection(host, addr string) (* } func (t *anthropicOAuthRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) { - rawConn, err := t.dialer.Dial("tcp", addr) + dialContext := t.dialContext + if dialContext == nil { + dialContext = t.dialer.DialContext + } + rawConn, err := dialContext(context.Background(), "tcp", addr) if err != nil { return nil, err } diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index a3cd7c0..2d5742b 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -665,7 +665,7 @@ func (p *HTTPProvider) postJSONStream(ctx context.Context, endpoint string, payl req.Header.Set("Accept", "text/event-stream") applyAttemptAuth(req, attempt) - body, status, ctype, quotaHit, err := p.doStreamAttempt(req, onEvent) + body, status, ctype, quotaHit, err := p.doStreamAttempt(req, attempt, onEvent) if err != nil { return nil, 0, "", err } @@ -707,7 +707,7 @@ func (p *HTTPProvider) postJSON(ctx context.Context, endpoint string, payload in req.Header.Set("Content-Type", "application/json") applyAttemptAuth(req, attempt) - body, status, ctype, err := p.doJSONAttempt(req) + body, status, ctype, err := p.doJSONAttempt(req, attempt) if err != nil { return nil, 0, "", err } @@ -753,7 +753,7 @@ func (p *HTTPProvider) authAttempts(ctx context.Context) ([]authAttempt, error) for _, attempt := range attempts { oauthAttempts = append(oauthAttempts, authAttempt{session: attempt.Session, token: attempt.Token, kind: "oauth"}) } - if mode == "hybrid" && apiReady && p.oauth.cfg.HybridPriority != "oauth_first" { + if mode == "hybrid" && apiReady { out = append(out, apiAttempt) } if len(attempts) == 0 { @@ -764,9 +764,6 @@ func (p *HTTPProvider) authAttempts(ctx context.Context) ([]authAttempt, error) return nil, fmt.Errorf("oauth session not found, run `clawgo provider login` first") } out = append(out, oauthAttempts...) - if mode == "hybrid" && apiReady && p.oauth.cfg.HybridPriority == "oauth_first" { - out = append(out, apiAttempt) - } p.updateCandidateOrder(out) return out, nil } @@ -833,8 +830,19 @@ func applyAttemptAuth(req *http.Request, attempt authAttempt) { req.Header.Set("Authorization", "Bearer "+attempt.token) } -func (p *HTTPProvider) doJSONAttempt(req *http.Request) ([]byte, int, string, error) { - resp, err := p.httpClient.Do(req) +func (p *HTTPProvider) httpClientForAttempt(attempt authAttempt) (*http.Client, error) { + if attempt.kind == "oauth" && attempt.session != nil && p.oauth != nil { + return p.oauth.httpClientForSession(attempt.session) + } + return p.httpClient, nil +} + +func (p *HTTPProvider) doJSONAttempt(req *http.Request, attempt authAttempt) ([]byte, int, string, error) { + client, err := p.httpClientForAttempt(attempt) + if err != nil { + return nil, 0, "", err + } + resp, err := client.Do(req) if err != nil { return nil, 0, "", fmt.Errorf("failed to send request: %w", err) } @@ -846,8 +854,12 @@ func (p *HTTPProvider) doJSONAttempt(req *http.Request) ([]byte, int, string, er return body, resp.StatusCode, strings.TrimSpace(resp.Header.Get("Content-Type")), nil } -func (p *HTTPProvider) doStreamAttempt(req *http.Request, onEvent func(string)) ([]byte, int, string, bool, error) { - resp, err := p.httpClient.Do(req) +func (p *HTTPProvider) doStreamAttempt(req *http.Request, attempt authAttempt, onEvent func(string)) ([]byte, int, string, bool, error) { + client, err := p.httpClientForAttempt(attempt) + if err != nil { + return nil, 0, "", false, err + } + resp, err := client.Do(req) if err != nil { return nil, 0, "", false, fmt.Errorf("failed to send request: %w", err) } @@ -1437,17 +1449,10 @@ func buildProviderCandidateOrder(_ string, pc config.ProviderConfig, accounts [] case "oauth": out = append(out, oauthAvailable...) case "hybrid": - if strings.EqualFold(strings.TrimSpace(pc.OAuth.HybridPriority), "oauth_first") { - out = append(out, oauthAvailable...) - if apiCandidate.Target != "" && apiCandidate.Available { - out = append(out, apiCandidate) - } - } else { - if apiCandidate.Target != "" && apiCandidate.Available { - out = append(out, apiCandidate) - } - out = append(out, oauthAvailable...) + if apiCandidate.Target != "" && apiCandidate.Available { + out = append(out, apiCandidate) } + out = append(out, oauthAvailable...) case "none": default: if apiCandidate.Target != "" { @@ -2131,11 +2136,16 @@ func (p *HTTPProvider) BuildSummaryViaResponsesCompact(ctx context.Context, mode } func CreateProvider(cfg *config.Config) (LLMProvider, error) { - name := strings.TrimSpace(cfg.Agents.Defaults.Proxy) - if name == "" { - name = "proxy" + name := config.PrimaryProviderName(cfg) + provider, err := CreateProviderByName(cfg, name) + if err != nil { + return nil, err } - return CreateProviderByName(cfg, name) + _, model := config.ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary) + if hp, ok := provider.(*HTTPProvider); ok && strings.TrimSpace(model) != "" { + hp.defaultModel = strings.TrimSpace(model) + } + return provider, nil } func CreateProviderByName(cfg *config.Config, name string) (LLMProvider, error) { @@ -2219,48 +2229,12 @@ func ListProviderNames(cfg *config.Config) []string { } func getAllProviderConfigs(cfg *config.Config) map[string]config.ProviderConfig { - out := map[string]config.ProviderConfig{} - if cfg == nil { - return out - } - includeLegacyProxy := len(cfg.Providers.Proxies) == 0 || strings.TrimSpace(cfg.Agents.Defaults.Proxy) == "proxy" || containsStringTrimmed(cfg.Agents.Defaults.ProxyFallbacks, "proxy") - if includeLegacyProxy && (cfg.Providers.Proxy.APIBase != "" || cfg.Providers.Proxy.APIKey != "" || cfg.Providers.Proxy.TimeoutSec > 0) { - out["proxy"] = cfg.Providers.Proxy - } - for name, pc := range cfg.Providers.Proxies { - trimmed := strings.TrimSpace(name) - if trimmed == "" { - continue - } - out[trimmed] = pc - } - return out -} - -func containsStringTrimmed(values []string, target string) bool { - t := strings.TrimSpace(target) - for _, v := range values { - if strings.TrimSpace(v) == t { - return true - } - } - return false + return config.AllProviderConfigs(cfg) } func getProviderConfigByName(cfg *config.Config, name string) (config.ProviderConfig, error) { - if cfg == nil { - return config.ProviderConfig{}, fmt.Errorf("nil config") + if pc, ok := config.ProviderConfigByName(cfg, name); ok { + return pc, nil } - trimmed := strings.TrimSpace(name) - if trimmed == "" { - return config.ProviderConfig{}, fmt.Errorf("empty provider name") - } - if trimmed == "proxy" { - return cfg.Providers.Proxy, nil - } - pc, ok := cfg.Providers.Proxies[trimmed] - if !ok { - return config.ProviderConfig{}, fmt.Errorf("provider %q not found", trimmed) - } - return pc, nil + return config.ProviderConfig{}, fmt.Errorf("provider %q not found", strings.TrimSpace(name)) } diff --git a/pkg/providers/http_proxy.go b/pkg/providers/http_proxy.go new file mode 100644 index 0000000..38f023c --- /dev/null +++ b/pkg/providers/http_proxy.go @@ -0,0 +1,156 @@ +package providers + +import ( + "bufio" + "context" + stdtls "crypto/tls" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + xproxy "golang.org/x/net/proxy" +) + +func normalizeOptionalProxyURL(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", nil + } + if !strings.Contains(value, "://") { + value = "http://" + value + } + parsed, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("invalid network proxy: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("invalid network proxy: host is required") + } + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { + case "http", "https", "socks5", "socks5h": + return parsed.String(), nil + default: + return "", fmt.Errorf("invalid network proxy: unsupported scheme %q", parsed.Scheme) + } +} + +func maskedProxyURL(raw string) string { + normalized, err := normalizeOptionalProxyURL(raw) + if err != nil || normalized == "" { + return "" + } + parsed, err := url.Parse(normalized) + if err != nil { + return "" + } + if parsed.User != nil { + username := parsed.User.Username() + if username != "" { + parsed.User = url.UserPassword(username, "***") + } else { + parsed.User = url.User("***") + } + } + return parsed.String() +} + +func proxyDialContext(proxyRaw string) (func(context.Context, string, string) (net.Conn, error), error) { + normalized, err := normalizeOptionalProxyURL(proxyRaw) + if err != nil { + return nil, err + } + if normalized == "" { + dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second} + return dialer.DialContext, nil + } + parsed, err := url.Parse(normalized) + if err != nil { + return nil, err + } + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { + case "socks5", "socks5h": + base := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second} + dialer, err := xproxy.FromURL(parsed, base) + if err != nil { + return nil, fmt.Errorf("configure socks proxy failed: %w", err) + } + if ctxDialer, ok := dialer.(xproxy.ContextDialer); ok { + return ctxDialer.DialContext, nil + } + return func(ctx context.Context, network, addr string) (net.Conn, error) { + type dialResult struct { + conn net.Conn + err error + } + ch := make(chan dialResult, 1) + go func() { + conn, err := dialer.Dial(network, addr) + ch <- dialResult{conn: conn, err: err} + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case res := <-ch: + return res.conn, res.err + } + }, nil + case "http", "https": + base := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second} + return func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := base.DialContext(ctx, "tcp", parsed.Host) + if err != nil { + return nil, err + } + if strings.EqualFold(parsed.Scheme, "https") { + tlsConn := stdtls.Client(conn, &stdtls.Config{ServerName: parsed.Hostname()}) + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = conn.Close() + return nil, err + } + conn = tlsConn + } + connectReq := buildProxyConnectRequest(parsed, addr) + if _, err := conn.Write([]byte(connectReq)); err != nil { + _ = conn.Close() + return nil, err + } + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect}) + if err != nil { + _ = conn.Close() + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _ = conn.Close() + return nil, fmt.Errorf("proxy connect failed: status=%d", resp.StatusCode) + } + return conn, nil + }, nil + default: + return nil, fmt.Errorf("invalid network proxy: unsupported scheme %q", parsed.Scheme) + } +} + +func buildProxyConnectRequest(proxyURL *url.URL, targetAddr string) string { + var b strings.Builder + b.WriteString("CONNECT ") + b.WriteString(targetAddr) + b.WriteString(" HTTP/1.1\r\nHost: ") + b.WriteString(targetAddr) + b.WriteString("\r\n") + if proxyURL != nil && proxyURL.User != nil { + username := proxyURL.User.Username() + password, _ := proxyURL.User.Password() + encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + b.WriteString("Proxy-Authorization: Basic ") + b.WriteString(encoded) + b.WriteString("\r\n") + } + b.WriteString("\r\n") + return b.String() +} diff --git a/pkg/providers/oauth.go b/pkg/providers/oauth.go index 9fd3e8c..9b7ea9d 100644 --- a/pkg/providers/oauth.go +++ b/pkg/providers/oauth.go @@ -118,6 +118,7 @@ type oauthSession struct { ProjectID string `json:"project_id,omitempty"` DeviceID string `json:"device_id,omitempty"` ResourceURL string `json:"resource_url,omitempty"` + NetworkProxy string `json:"network_proxy,omitempty"` Scope string `json:"scope,omitempty"` Token map[string]any `json:"token,omitempty"` CooldownUntil string `json:"-"` @@ -143,7 +144,6 @@ type oauthConfig struct { Scopes []string RefreshScan time.Duration RefreshLead time.Duration - HybridPriority string Cooldown time.Duration FlowKind string TokenStyle string @@ -154,7 +154,10 @@ type oauthConfig struct { type oauthManager struct { providerName string cfg oauthConfig + timeout time.Duration httpClient *http.Client + clientMu sync.Mutex + clients map[string]*http.Client mu sync.Mutex cached []*oauthSession cooldowns map[string]time.Time @@ -198,6 +201,7 @@ type OAuthLoginOptions struct { NoBrowser bool Reader io.Reader AccountLabel string + NetworkProxy string } type OAuthPendingFlow struct { @@ -218,6 +222,7 @@ type OAuthSessionInfo struct { CredentialFile string ProjectID string AccountLabel string + NetworkProxy string } type OAuthAccountInfo struct { @@ -230,6 +235,7 @@ type OAuthAccountInfo struct { AccountLabel string `json:"account_label,omitempty"` DeviceID string `json:"device_id,omitempty"` ResourceURL string `json:"resource_url,omitempty"` + NetworkProxy string `json:"network_proxy,omitempty"` CooldownUntil string `json:"cooldown_until,omitempty"` FailureCount int `json:"failure_count,omitempty"` LastFailure string `json:"last_failure,omitempty"` @@ -277,6 +283,7 @@ func (m *OAuthLoginManager) Login(ctx context.Context, apiBase string, opts OAut CredentialFile: session.FilePath, ProjectID: session.ProjectID, AccountLabel: sessionLabel(session), + NetworkProxy: maskedProxyURL(session.NetworkProxy), }, models, nil } @@ -288,13 +295,20 @@ func (m *OAuthLoginManager) CredentialFile() string { } func (m *OAuthLoginManager) StartManualFlow() (*OAuthPendingFlow, error) { + return m.StartManualFlowWithOptions(OAuthLoginOptions{}) +} + +func (m *OAuthLoginManager) StartManualFlowWithOptions(opts OAuthLoginOptions) (*OAuthPendingFlow, error) { if m == nil || m.manager == nil { return nil, fmt.Errorf("oauth login manager not configured") } if m.manager.cfg.FlowKind == oauthFlowDevice { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - return m.manager.startDeviceFlow(ctx) + return m.manager.startDeviceFlow(ctx, opts) + } + if _, err := normalizeOptionalProxyURL(opts.NetworkProxy); err != nil { + return nil, err } pkceVerifier, pkceChallenge, err := generatePKCE() if err != nil { @@ -350,6 +364,7 @@ func (m *OAuthLoginManager) CompleteManualFlowWithOptions(ctx context.Context, a CredentialFile: session.FilePath, ProjectID: session.ProjectID, AccountLabel: sessionLabel(session), + NetworkProxy: maskedProxyURL(session.NetworkProxy), }, models, nil } @@ -371,6 +386,7 @@ func (m *OAuthLoginManager) ImportAuthJSONWithOptions(ctx context.Context, apiBa CredentialFile: session.FilePath, ProjectID: session.ProjectID, AccountLabel: sessionLabel(session), + NetworkProxy: maskedProxyURL(session.NetworkProxy), }, models, nil } @@ -399,6 +415,7 @@ func (m *OAuthLoginManager) ListAccounts() ([]OAuthAccountInfo, error) { AccountLabel: sessionLabel(session), DeviceID: session.DeviceID, ResourceURL: session.ResourceURL, + NetworkProxy: maskedProxyURL(session.NetworkProxy), CooldownUntil: session.CooldownUntil, FailureCount: session.FailureCount, LastFailure: session.LastFailure, @@ -436,6 +453,7 @@ func (m *OAuthLoginManager) RefreshAccount(ctx context.Context, credentialFile s AccountLabel: sessionLabel(refreshed), DeviceID: refreshed.DeviceID, ResourceURL: refreshed.ResourceURL, + NetworkProxy: maskedProxyURL(refreshed.NetworkProxy), CooldownUntil: refreshed.CooldownUntil, FailureCount: refreshed.FailureCount, LastFailure: refreshed.LastFailure, @@ -506,10 +524,16 @@ func newOAuthManager(pc config.ProviderConfig, timeout time.Duration) (*oauthMan if err != nil { return nil, err } + client, err := newOAuthHTTPClient(resolved.Provider, timeout, "") + if err != nil { + return nil, err + } bgCtx, bgCancel := context.WithCancel(context.Background()) manager := &oauthManager{ cfg: resolved, - httpClient: newOAuthHTTPClient(resolved.Provider, timeout), + timeout: timeout, + httpClient: client, + clients: map[string]*http.Client{"": client}, cooldowns: map[string]time.Time{}, bgCtx: bgCtx, bgCancel: bgCancel, @@ -518,11 +542,32 @@ func newOAuthManager(pc config.ProviderConfig, timeout time.Duration) (*oauthMan return manager, nil } -func newOAuthHTTPClient(provider string, timeout time.Duration) *http.Client { - if provider == defaultClaudeOAuthProvider { - return newAnthropicOAuthHTTPClient(timeout) +func newOAuthHTTPClient(provider string, timeout time.Duration, proxyURL string) (*http.Client, error) { + normalizedProxy, err := normalizeOptionalProxyURL(proxyURL) + if err != nil { + return nil, err } - return &http.Client{Timeout: timeout} + if provider == defaultClaudeOAuthProvider { + return newAnthropicOAuthHTTPClient(timeout, normalizedProxy) + } + if normalizedProxy == "" { + return &http.Client{Timeout: timeout}, nil + } + parsed, err := url.Parse(normalizedProxy) + if err != nil { + return nil, err + } + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsed), + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 15 * time.Second, + ExpectContinueTimeout: time.Second, + }, + }, nil } func resolveOAuthConfig(pc config.ProviderConfig) (oauthConfig, error) { @@ -545,7 +590,6 @@ func resolveOAuthConfig(pc config.ProviderConfig) (oauthConfig, error) { TokenURL: strings.TrimSpace(pc.OAuth.TokenURL), RedirectURL: strings.TrimSpace(pc.OAuth.RedirectURL), Scopes: trimNonEmptyStrings(pc.OAuth.Scopes), - HybridPriority: normalizeHybridPriority(pc.OAuth.HybridPriority), Cooldown: durationFromSeconds(pc.OAuth.CooldownSec, 15*time.Minute), RefreshScan: durationFromSeconds(pc.OAuth.RefreshScanSec, 10*time.Minute), RefreshLead: defaultRefreshLead(provider, pc.OAuth.RefreshLeadSec), @@ -626,9 +670,6 @@ func resolveOAuthConfig(pc config.ProviderConfig) (oauthConfig, error) { cfg.CredentialFiles = uniqueStrings(append([]string{cfg.CredentialFile}, cfg.CredentialFiles...)) cfg.CredentialFile = cfg.CredentialFiles[0] } - if cfg.HybridPriority == "" { - cfg.HybridPriority = "api_first" - } return cfg, nil } @@ -675,7 +716,12 @@ func (m *oauthManager) models(ctx context.Context, apiBase string) ([]string, er seen := map[string]struct{}{} var lastErr error for _, attempt := range attempts { - models, err := fetchOpenAIModels(ctx, m.httpClient, apiBase, attempt.Token) + client, err := m.httpClientForSession(attempt.Session) + if err != nil { + lastErr = err + continue + } + models, err := fetchOpenAIModels(ctx, client, apiBase, attempt.Token) if err != nil { lastErr = err continue @@ -704,7 +750,7 @@ func (m *oauthManager) login(ctx context.Context, apiBase string, opts OAuthLogi return nil, nil, fmt.Errorf("oauth manager not configured") } if m.cfg.FlowKind == oauthFlowDevice { - flow, err := m.startDeviceFlow(ctx) + flow, err := m.startDeviceFlow(ctx, opts) if err != nil { return nil, nil, err } @@ -741,14 +787,18 @@ func (m *oauthManager) login(ctx context.Context, apiBase string, opts OAuthLogi } func (m *oauthManager) completeLogin(ctx context.Context, apiBase, pkceVerifier string, callback *oauthCallbackResult, state string, opts OAuthLoginOptions) (*oauthSession, []string, error) { - session, err := m.exchangeCode(ctx, callback.Code, pkceVerifier, state) + session, err := m.exchangeCode(ctx, callback.Code, pkceVerifier, state, opts.NetworkProxy) if err != nil { return nil, nil, err } - if err := m.applyAccountLabel(session, opts); err != nil { + if err := m.applySessionOptions(session, opts); err != nil { return nil, nil, err } - models, _ := fetchOpenAIModels(ctx, m.httpClient, apiBase, session.AccessToken) + client, err := m.httpClientForSession(session) + if err != nil { + return nil, nil, err + } + models, _ := fetchOpenAIModels(ctx, client, apiBase, session.AccessToken) if len(models) > 0 { session.Models = append([]string(nil), models...) } @@ -788,10 +838,14 @@ func (m *oauthManager) importSession(ctx context.Context, apiBase, fileName stri if err != nil { return nil, nil, err } - if err := m.applyAccountLabel(session, opts); err != nil { + if err := m.applySessionOptions(session, opts); err != nil { return nil, nil, err } - models, _ := fetchOpenAIModels(ctx, m.httpClient, apiBase, session.AccessToken) + client, err := m.httpClientForSession(session) + if err != nil { + return nil, nil, err + } + models, _ := fetchOpenAIModels(ctx, client, apiBase, session.AccessToken) if len(models) > 0 { session.Models = append([]string(nil), models...) } @@ -1094,7 +1148,7 @@ func (m *oauthManager) authorizationURL(state, pkceChallenge string) string { return m.cfg.AuthURL + "?" + v.Encode() } -func (m *oauthManager) exchangeCode(ctx context.Context, code, verifier, state string) (*oauthSession, error) { +func (m *oauthManager) exchangeCode(ctx context.Context, code, verifier, state string, proxyURL string) (*oauthSession, error) { switch m.cfg.Provider { case defaultClaudeOAuthProvider: reqBody := map[string]any{ @@ -1105,7 +1159,7 @@ func (m *oauthManager) exchangeCode(ctx context.Context, code, verifier, state s "redirect_uri": m.cfg.RedirectURL, "code_verifier": verifier, } - raw, err := m.doJSONTokenRequest(ctx, reqBody) + raw, err := m.doJSONTokenRequest(ctx, reqBody, proxyURL) if err != nil { return nil, err } @@ -1122,7 +1176,7 @@ func (m *oauthManager) exchangeCode(ctx context.Context, code, verifier, state s if m.cfg.ClientSecret != "" { form.Set("client_secret", m.cfg.ClientSecret) } - raw, err := m.doFormTokenRequest(ctx, form) + raw, err := m.doFormTokenRequest(ctx, form, proxyURL) if err != nil { return nil, err } @@ -1159,7 +1213,7 @@ func (m *oauthManager) refreshSessionData(ctx context.Context, session *oauthSes "client_id": m.cfg.ClientID, "grant_type": "refresh_token", "refresh_token": session.RefreshToken, - }) + }, session.NetworkProxy) if err != nil { return nil, err } @@ -1185,7 +1239,7 @@ func (m *oauthManager) refreshSessionData(ctx context.Context, session *oauthSes if len(m.cfg.Scopes) > 0 && m.cfg.Provider != defaultQwenOAuthProvider && m.cfg.Provider != defaultKimiOAuthProvider { form.Set("scope", strings.Join(m.cfg.Scopes, " ")) } - raw, err := m.doFormTokenRequest(ctx, form) + raw, err := m.doFormTokenRequest(ctx, form, session.NetworkProxy) if err != nil { return nil, err } @@ -1217,7 +1271,7 @@ func (m *oauthManager) refreshGoogleTokenSession(ctx context.Context, session *o if clientSecret != "" { form.Set("client_secret", clientSecret) } - raw, err := m.doFormTokenRequestURL(ctx, tokenURL, form) + raw, err := m.doFormTokenRequestURL(ctx, tokenURL, form, session.NetworkProxy) if err != nil { return nil, err } @@ -1254,13 +1308,13 @@ func (m *oauthManager) enrichSession(ctx context.Context, session *oauthSession) switch m.cfg.Provider { case defaultAntigravityOAuthProvider, defaultGeminiOAuthProvider: if strings.TrimSpace(session.Email) == "" && m.cfg.UserInfoURL != "" && session.AccessToken != "" { - email, err := m.fetchUserEmail(ctx, session.AccessToken) + email, err := m.fetchUserEmail(ctx, session.AccessToken, session.NetworkProxy) if err == nil { session.Email = email } } if m.cfg.Provider == defaultAntigravityOAuthProvider && strings.TrimSpace(session.ProjectID) == "" && session.AccessToken != "" { - projectID, err := m.fetchAntigravityProjectID(ctx, session.AccessToken) + projectID, err := m.fetchAntigravityProjectID(ctx, session.AccessToken, session.NetworkProxy) if err == nil { session.ProjectID = projectID } @@ -1413,6 +1467,22 @@ func (m *oauthManager) applyAccountLabel(session *oauthSession, opts OAuthLoginO return nil } +func (m *oauthManager) applySessionOptions(session *oauthSession, opts OAuthLoginOptions) error { + if err := m.applyAccountLabel(session, opts); err != nil { + return err + } + if session == nil { + return fmt.Errorf("oauth session is nil") + } + proxyURL := firstNonEmpty(opts.NetworkProxy, session.NetworkProxy) + normalized, err := normalizeOptionalProxyURL(proxyURL) + if err != nil { + return err + } + session.NetworkProxy = normalized + return nil +} + func sessionLabel(session *oauthSession) string { if session == nil { return "" @@ -1420,6 +1490,34 @@ func sessionLabel(session *oauthSession) string { return firstNonEmpty(session.Email, session.AccountID, session.ProjectID) } +func (m *oauthManager) httpClientForSession(session *oauthSession) (*http.Client, error) { + if session == nil { + return m.httpClient, nil + } + return m.httpClientForProxy(session.NetworkProxy) +} + +func (m *oauthManager) httpClientForProxy(proxyURL string) (*http.Client, error) { + normalized, err := normalizeOptionalProxyURL(proxyURL) + if err != nil { + return nil, err + } + if normalized == "" { + return m.httpClient, nil + } + m.clientMu.Lock() + defer m.clientMu.Unlock() + if client, ok := m.clients[normalized]; ok && client != nil { + return client, nil + } + client, err := newOAuthHTTPClient(m.cfg.Provider, m.timeout, normalized) + if err != nil { + return nil, err + } + m.clients[normalized] = client + return client, nil +} + func (m *oauthManager) allocateCredentialPathLocked(session *oauthSession) (string, error) { files := m.credentialFiles() if len(files) == 1 { @@ -1517,6 +1615,7 @@ func mergeOAuthSession(prev, next *oauthSession) *oauthSession { merged.ProjectID = firstNonEmpty(next.ProjectID, prev.ProjectID) merged.DeviceID = firstNonEmpty(next.DeviceID, prev.DeviceID) merged.ResourceURL = firstNonEmpty(next.ResourceURL, prev.ResourceURL) + merged.NetworkProxy = firstNonEmpty(next.NetworkProxy, prev.NetworkProxy) merged.Scope = firstNonEmpty(next.Scope, prev.Scope) merged.Models = append([]string(nil), prev.Models...) if len(next.Models) > 0 { @@ -1828,17 +1927,6 @@ func durationFromSeconds(value int, fallback time.Duration) time.Duration { return time.Duration(value) * time.Second } -func normalizeHybridPriority(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "", "api_first": - return "api_first" - case "oauth_first": - return "oauth_first" - default: - return "" - } -} - func parseOAuthCallbackURL(raw string) (*oauthCallbackResult, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil { @@ -1902,6 +1990,7 @@ func parseImportedOAuthSession(provider, fileName string, data []byte) (*oauthSe session.ProjectID = firstNonEmpty(asString(raw["project_id"]), asString(raw["projectId"])) session.DeviceID = firstNonEmpty(asString(raw["device_id"]), asString(raw["deviceId"])) session.ResourceURL = asString(raw["resource_url"]) + session.NetworkProxy = firstNonEmpty(asString(raw["network_proxy"]), asString(raw["proxy_url"]), asString(raw["http_proxy"])) session.Scope = firstNonEmpty(asString(raw["scope"]), asString(raw["scopes"])) if models := stringSliceFromAny(raw["models"]); len(models) > 0 { session.Models = models @@ -1916,6 +2005,7 @@ func parseImportedOAuthSession(provider, fileName string, data []byte) (*oauthSe ) session.ProjectID = firstNonEmpty(session.ProjectID, asString(session.Token["project_id"]), asString(session.Token["projectId"])) session.DeviceID = firstNonEmpty(session.DeviceID, asString(session.Token["device_id"]), asString(session.Token["deviceId"])) + session.NetworkProxy = firstNonEmpty(session.NetworkProxy, asString(session.Token["network_proxy"]), asString(session.Token["proxy_url"]), asString(session.Token["http_proxy"])) session.Scope = firstNonEmpty(session.Scope, asString(session.Token["scope"]), asString(session.Token["scopes"])) } if claims := parseJWTClaims(session.IDToken); len(claims) > 0 { @@ -2043,21 +2133,21 @@ func defaultInt(value, fallback int) int { return fallback } -func (m *oauthManager) doFormTokenRequest(ctx context.Context, form url.Values) (map[string]any, error) { - return m.doFormTokenRequestURL(ctx, m.cfg.TokenURL, form) +func (m *oauthManager) doFormTokenRequest(ctx context.Context, form url.Values, proxyURL string) (map[string]any, error) { + return m.doFormTokenRequestURL(ctx, m.cfg.TokenURL, form, proxyURL) } -func (m *oauthManager) doFormTokenRequestURL(ctx context.Context, endpoint string, form url.Values) (map[string]any, error) { +func (m *oauthManager) doFormTokenRequestURL(ctx context.Context, endpoint string, form url.Values, proxyURL string) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - return m.doJSONRequest(req, "oauth token request") + return m.doJSONRequest(req, "oauth token request", proxyURL) } -func (m *oauthManager) doJSONTokenRequest(ctx context.Context, payload map[string]any) (map[string]any, error) { +func (m *oauthManager) doJSONTokenRequest(ctx context.Context, payload map[string]any, proxyURL string) (map[string]any, error) { body, err := json.Marshal(payload) if err != nil { return nil, err @@ -2068,11 +2158,15 @@ func (m *oauthManager) doJSONTokenRequest(ctx context.Context, payload map[strin } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - return m.doJSONRequest(req, "oauth token request") + return m.doJSONRequest(req, "oauth token request", proxyURL) } -func (m *oauthManager) doJSONRequest(req *http.Request, label string) (map[string]any, error) { - resp, err := m.httpClient.Do(req) +func (m *oauthManager) doJSONRequest(req *http.Request, label, proxyURL string) (map[string]any, error) { + client, err := m.httpClientForProxy(proxyURL) + if err != nil { + return nil, err + } + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("%s failed: %w", label, err) } @@ -2091,13 +2185,13 @@ func (m *oauthManager) doJSONRequest(req *http.Request, label string) (map[strin return raw, nil } -func (m *oauthManager) fetchUserEmail(ctx context.Context, token string) (string, error) { +func (m *oauthManager) fetchUserEmail(ctx context.Context, token, proxyURL string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.UserInfoURL, nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) - raw, err := m.doJSONRequest(req, "oauth userinfo request") + raw, err := m.doJSONRequest(req, "oauth userinfo request", proxyURL) if err != nil { return "", err } @@ -2108,7 +2202,7 @@ func (m *oauthManager) fetchUserEmail(ctx context.Context, token string) (string return email, nil } -func (m *oauthManager) fetchAntigravityProjectID(ctx context.Context, token string) (string, error) { +func (m *oauthManager) fetchAntigravityProjectID(ctx context.Context, token, proxyURL string) (string, error) { endpointURL := fmt.Sprintf("%s/%s:loadCodeAssist", defaultAntigravityAPIEndpoint, defaultAntigravityAPIVersion) body := `{"metadata":{"ideType":"ANTIGRAVITY","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}}` req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(body)) @@ -2120,7 +2214,11 @@ func (m *oauthManager) fetchAntigravityProjectID(ctx context.Context, token stri req.Header.Set("User-Agent", defaultAntigravityAPIUserAgent) req.Header.Set("X-Goog-Api-Client", defaultAntigravityAPIClient) req.Header.Set("Client-Metadata", defaultAntigravityClientMeta) - resp, err := m.httpClient.Do(req) + client, err := m.httpClientForProxy(proxyURL) + if err != nil { + return "", err + } + resp, err := client.Do(req) if err != nil { return "", err } @@ -2148,7 +2246,7 @@ func (m *oauthManager) fetchAntigravityProjectID(ctx context.Context, token stri return projectID, nil } -func (m *oauthManager) startDeviceFlow(ctx context.Context) (*OAuthPendingFlow, error) { +func (m *oauthManager) startDeviceFlow(ctx context.Context, opts OAuthLoginOptions) (*OAuthPendingFlow, error) { if m.cfg.FlowKind != oauthFlowDevice { return nil, fmt.Errorf("oauth provider %s does not use device flow", m.cfg.Provider) } @@ -2165,7 +2263,7 @@ func (m *oauthManager) startDeviceFlow(ctx context.Context) (*OAuthPendingFlow, } form.Set("code_challenge", challenge) form.Set("code_challenge_method", "S256") - raw, err := m.doFormDeviceRequest(ctx, m.cfg.DeviceCodeURL, form) + raw, err := m.doFormDeviceRequest(ctx, m.cfg.DeviceCodeURL, form, opts.NetworkProxy) if err != nil { return nil, err } @@ -2184,7 +2282,7 @@ func (m *oauthManager) startDeviceFlow(ctx context.Context) (*OAuthPendingFlow, Instructions: "Open the verification URL, finish authorization, then click continue to let the gateway poll for tokens.", }, nil case defaultKimiOAuthProvider: - raw, err := m.doFormDeviceRequest(ctx, m.cfg.DeviceCodeURL, form) + raw, err := m.doFormDeviceRequest(ctx, m.cfg.DeviceCodeURL, form, opts.NetworkProxy) if err != nil { return nil, err } @@ -2206,7 +2304,7 @@ func (m *oauthManager) startDeviceFlow(ctx context.Context) (*OAuthPendingFlow, } } -func (m *oauthManager) doFormDeviceRequest(ctx context.Context, endpoint string, form url.Values) (map[string]any, error) { +func (m *oauthManager) doFormDeviceRequest(ctx context.Context, endpoint string, form url.Values, proxyURL string) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return nil, err @@ -2220,7 +2318,7 @@ func (m *oauthManager) doFormDeviceRequest(ctx context.Context, endpoint string, req.Header.Set("X-Msh-Device-Model", runtime.GOOS+" "+runtime.GOARCH) req.Header.Set("X-Msh-Device-Id", randomDeviceID()) } - return m.doJSONRequest(req, "oauth device request") + return m.doJSONRequest(req, "oauth device request", proxyURL) } func parseDeviceFlowPayload(raw map[string]any) (*oauthDeviceCodeResponse, error) { @@ -2254,14 +2352,18 @@ func randomDeviceID() string { } func (m *oauthManager) completeDeviceFlow(ctx context.Context, apiBase string, flow *OAuthPendingFlow, opts OAuthLoginOptions) (*oauthSession, []string, error) { - session, err := m.pollDeviceToken(ctx, flow) + session, err := m.pollDeviceToken(ctx, flow, opts.NetworkProxy) if err != nil { return nil, nil, err } - if err := m.applyAccountLabel(session, opts); err != nil { + if err := m.applySessionOptions(session, opts); err != nil { return nil, nil, err } - models, _ := fetchOpenAIModels(ctx, m.httpClient, apiBase, session.AccessToken) + client, err := m.httpClientForSession(session) + if err != nil { + return nil, nil, err + } + models, _ := fetchOpenAIModels(ctx, client, apiBase, session.AccessToken) if len(models) > 0 { session.Models = append([]string(nil), models...) } @@ -2281,7 +2383,7 @@ func (m *oauthManager) completeDeviceFlow(ctx context.Context, apiBase string, f return session, models, nil } -func (m *oauthManager) pollDeviceToken(ctx context.Context, flow *OAuthPendingFlow) (*oauthSession, error) { +func (m *oauthManager) pollDeviceToken(ctx context.Context, flow *OAuthPendingFlow, proxyURL string) (*oauthSession, error) { if flow == nil || strings.TrimSpace(flow.DeviceCode) == "" { return nil, fmt.Errorf("oauth device flow missing device code") } @@ -2306,7 +2408,7 @@ func (m *oauthManager) pollDeviceToken(ctx context.Context, flow *OAuthPendingFl if flow.PKCEVerifier != "" { form.Set("code_verifier", flow.PKCEVerifier) } - raw, err := m.doFormTokenRequest(ctx, form) + raw, err := m.doFormTokenRequest(ctx, form, proxyURL) if err == nil { session, convErr := sessionFromTokenPayload(m.cfg.Provider, raw) if convErr != nil { diff --git a/pkg/providers/oauth_test.go b/pkg/providers/oauth_test.go index 532e15e..2502be6 100644 --- a/pkg/providers/oauth_test.go +++ b/pkg/providers/oauth_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -386,6 +387,118 @@ func TestResolveOAuthConfigAppliesProviderRefreshLeadDefaults(t *testing.T) { } } +func TestHTTPProviderOAuthSessionProxyRoutesRefreshAndResponses(t *testing.T) { + t.Parallel() + + var refreshCalls int32 + var responseCalls int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth/token": + atomic.AddInt32(&refreshCalls, 1) + if err := r.ParseForm(); err != nil { + t.Fatalf("parse token form failed: %v", err) + } + if got := r.Form.Get("grant_type"); got != "refresh_token" { + t.Fatalf("unexpected grant_type: %s", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"proxied-fresh-token","refresh_token":"refresh-token","expires_in":3600}`)) + case "/v1/responses": + atomic.AddInt32(&responseCalls, 1) + if got := r.Header.Get("Authorization"); got != "Bearer proxied-fresh-token" { + t.Fatalf("unexpected authorization header: %s", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"completed","output_text":"ok-via-proxy"}`)) + default: + http.NotFound(w, r) + } + })) + defer target.Close() + + var proxyCalls int32 + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&proxyCalls, 1) + targetURL := r.URL.String() + if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { + targetURL = target.URL + r.URL.Path + if rawQuery := strings.TrimSpace(r.URL.RawQuery); rawQuery != "" { + targetURL += "?" + rawQuery + } + } + req, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body) + if err != nil { + t.Fatalf("create proxied request failed: %v", err) + } + req.Header = r.Header.Clone() + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + t.Fatalf("proxy round trip failed: %v", err) + } + defer resp.Body.Close() + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + })) + defer proxyServer.Close() + + credFile := filepath.Join(t.TempDir(), "proxied.json") + raw, err := json.Marshal(oauthSession{ + Provider: "codex", + AccessToken: "expired-token", + RefreshToken: "refresh-token", + Expire: time.Now().Add(-time.Hour).Format(time.RFC3339), + NetworkProxy: proxyServer.URL, + }) + if err != nil { + t.Fatalf("marshal session failed: %v", err) + } + if err := os.WriteFile(credFile, raw, 0o600); err != nil { + t.Fatalf("write credential file failed: %v", err) + } + + pc := config.ProviderConfig{ + APIBase: target.URL + "/v1", + Auth: "oauth", + TimeoutSec: 5, + OAuth: config.ProviderOAuthConfig{ + Provider: "codex", + CredentialFile: credFile, + ClientID: "test-client", + TokenURL: target.URL + "/oauth/token", + AuthURL: target.URL + "/oauth/authorize", + }, + } + oauth, err := newOAuthManager(pc, 5*time.Second) + if err != nil { + t.Fatalf("new oauth manager failed: %v", err) + } + defer oauth.bgCancel() + + provider := NewHTTPProvider("test-oauth", "", pc.APIBase, "gpt-test", false, "oauth", 5*time.Second, oauth) + resp, err := provider.Chat(context.Background(), []Message{{Role: "user", Content: "hello"}}, nil, "gpt-test", nil) + if err != nil { + t.Fatalf("chat failed: %v", err) + } + if resp.Content != "ok-via-proxy" { + t.Fatalf("unexpected response content: %q", resp.Content) + } + if atomic.LoadInt32(&refreshCalls) != 1 { + t.Fatalf("expected one refresh call, got %d", refreshCalls) + } + if atomic.LoadInt32(&responseCalls) != 1 { + t.Fatalf("expected one response call, got %d", responseCalls) + } + if got := atomic.LoadInt32(&proxyCalls); got < 2 { + t.Fatalf("expected proxy to receive refresh and response requests, got %d", got) + } +} + func TestOAuthImportGeminiNestedTokenRefreshesWithTokenMetadata(t *testing.T) { t.Parallel() @@ -547,7 +660,7 @@ func TestQwenDeviceFlowRequiresAccountLabelWhenEmailMissing(t *testing.T) { } defer manager.bgCancel() - flow, err := manager.startDeviceFlow(context.Background()) + flow, err := manager.startDeviceFlow(context.Background(), OAuthLoginOptions{}) if err != nil { t.Fatalf("start device flow failed: %v", err) } @@ -734,7 +847,7 @@ func TestOAuthDeviceFlowQwenManualCompletes(t *testing.T) { t.Fatalf("new oauth manager failed: %v", err) } - flow, err := manager.startDeviceFlow(context.Background()) + flow, err := manager.startDeviceFlow(context.Background(), OAuthLoginOptions{}) if err != nil { t.Fatalf("start device flow failed: %v", err) } @@ -879,7 +992,6 @@ func TestHTTPProviderHybridOAuthFirstUsesOAuthBeforeAPIKey(t *testing.T) { CredentialFile: credFile, TokenURL: server.URL + "/oauth/token", AuthURL: server.URL + "/oauth/authorize", - HybridPriority: "oauth_first", }, } oauth, err := newOAuthManager(pc, 5*time.Second) @@ -891,11 +1003,11 @@ func TestHTTPProviderHybridOAuthFirstUsesOAuthBeforeAPIKey(t *testing.T) { if err != nil { t.Fatalf("chat failed: %v", err) } - if resp.Content != "ok-from-oauth" { + if resp.Content != "ok-from-api" { t.Fatalf("unexpected response content: %q", resp.Content) } - if atomic.LoadInt32(&oauthCalls) != 1 || atomic.LoadInt32(&apiKeyCalls) != 0 { - t.Fatalf("expected oauth first only, got api=%d oauth=%d", apiKeyCalls, oauthCalls) + if atomic.LoadInt32(&apiKeyCalls) != 1 || atomic.LoadInt32(&oauthCalls) != 0 { + t.Fatalf("expected api key first only, got api=%d oauth=%d", apiKeyCalls, oauthCalls) } } @@ -1187,7 +1299,6 @@ func TestProviderRuntimeSnapshotIncludesCandidateOrderAndLastSuccess(t *testing. OAuth: config.ProviderOAuthConfig{ Provider: "codex", CredentialFile: credFile, - HybridPriority: "api_first", }, } ConfigureProviderRuntime(name, pc) @@ -1206,8 +1317,8 @@ func TestProviderRuntimeSnapshotIncludesCandidateOrderAndLastSuccess(t *testing. provider.markAttemptSuccess(attempts[1]) cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{name: pc}, + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{name: pc}, }, } snapshot := GetProviderRuntimeSnapshot(cfg) @@ -1267,8 +1378,8 @@ func TestConfigureProviderRuntimeLoadsPersistedEvents(t *testing.T) { }) cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: { APIBase: "https://example.com/v1", Auth: "bearer", @@ -1357,7 +1468,6 @@ func TestUpdateCandidateOrderRecordsSchedulerChange(t *testing.T) { OAuth: config.ProviderOAuthConfig{ Provider: "codex", CredentialFile: credFile, - HybridPriority: "api_first", }, } manager, err := newOAuthManager(pc, 5*time.Second) @@ -1412,8 +1522,8 @@ func TestGetProviderRuntimeViewFiltersEvents(t *testing.T) { providerRuntimeRegistry.mu.Unlock() cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: {APIBase: "https://example.com/v1", Auth: "hybrid", APIKey: "api-key"}, }, }, @@ -1463,8 +1573,8 @@ func TestGetProviderRuntimeViewCursorPagination(t *testing.T) { } providerRuntimeRegistry.mu.Unlock() cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: {APIBase: "https://example.com/v1", Auth: "oauth", OAuth: config.ProviderOAuthConfig{Provider: "codex"}}, }, }, @@ -1503,8 +1613,8 @@ func TestGetProviderRuntimeViewSortAscending(t *testing.T) { } providerRuntimeRegistry.mu.Unlock() cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: {APIBase: "https://example.com/v1", Auth: "oauth", OAuth: config.ProviderOAuthConfig{Provider: "codex"}}, }, }, @@ -1542,8 +1652,8 @@ func TestGetProviderRuntimeViewFiltersByHealthAndCooldown(t *testing.T) { } providerRuntimeRegistry.mu.Unlock() cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: {APIBase: "https://example.com/v1", Auth: "bearer", APIKey: "api-key"}, }, }, @@ -1594,8 +1704,8 @@ func TestGetProviderRuntimeSummaryFlagsUnhealthyProviders(t *testing.T) { } providerRuntimeRegistry.mu.Unlock() cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: {APIBase: "https://example.com/v1", Auth: "bearer", APIKey: "api-key"}, }, }, @@ -1643,8 +1753,8 @@ func TestGetProviderRuntimeSummaryMarksRecentErrorsAsDegraded(t *testing.T) { } providerRuntimeRegistry.mu.Unlock() cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: {APIBase: "https://example.com/v1", Auth: "oauth", OAuth: config.ProviderOAuthConfig{Provider: "codex"}}, }, }, @@ -1688,8 +1798,8 @@ func TestGetProviderRuntimeSummaryIncludesOAuthAccountMetadata(t *testing.T) { t.Fatalf("write session failed: %v", err) } cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ "qwen-summary": { APIBase: "https://example.com/v1", Auth: "oauth", @@ -1746,8 +1856,8 @@ func TestRefreshProviderRuntimeNowSupportsOnlyExpiring(t *testing.T) { name := "runtime-refresh-provider" cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: { APIBase: server.URL + "/v1", Auth: "oauth", @@ -1807,8 +1917,8 @@ func TestRerankProviderRuntimeUpdatesCandidateOrder(t *testing.T) { } name := "rerank-runtime-provider" cfg := &config.Config{ - Providers: config.ProvidersConfig{ - Proxies: map[string]config.ProviderConfig{ + Models: config.ModelsConfig{ + Providers: map[string]config.ProviderConfig{ name: { APIKey: "api-key", APIBase: "https://example.com/v1", @@ -1817,7 +1927,6 @@ func TestRerankProviderRuntimeUpdatesCandidateOrder(t *testing.T) { OAuth: config.ProviderOAuthConfig{ Provider: "codex", CredentialFile: credFile, - HybridPriority: "oauth_first", }, }, }, @@ -1827,8 +1936,8 @@ func TestRerankProviderRuntimeUpdatesCandidateOrder(t *testing.T) { if err != nil { t.Fatalf("rerank provider runtime failed: %v", err) } - if len(order) == 0 || order[0].Kind != "oauth" { - t.Fatalf("expected oauth-first rerank result, got %#v", order) + if len(order) == 0 || order[0].Kind != "api_key" { + t.Fatalf("expected api-key-first rerank result, got %#v", order) } snapshot := GetProviderRuntimeSnapshot(cfg) items, _ := snapshot["items"].([]map[string]interface{}) @@ -1836,7 +1945,7 @@ func TestRerankProviderRuntimeUpdatesCandidateOrder(t *testing.T) { t.Fatalf("expected one runtime item, got %#v", snapshot) } snapshotOrder, _ := items[0]["candidate_order"].([]providerRuntimeCandidate) - if len(snapshotOrder) == 0 || snapshotOrder[0].Kind != "oauth" { - t.Fatalf("expected oauth-first candidate order, got %#v", items[0]["candidate_order"]) + if len(snapshotOrder) == 0 || snapshotOrder[0].Kind != "api_key" { + t.Fatalf("expected api-key-first candidate order, got %#v", items[0]["candidate_order"]) } } diff --git a/webui/package-lock.json b/webui/package-lock.json index 2a5975e..2f8a039 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawgo-webui", - "version": "0.0.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawgo-webui", - "version": "0.0.0", + "version": "0.2.0", "dependencies": { "@types/ws": "^8.18.1", "express": "^4.21.2", diff --git a/webui/package.json b/webui/package.json index 9907999..610338c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "clawgo-webui", "private": true, - "version": "0.0.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "tsx server.ts", diff --git a/webui/src/components/ArtifactPreviewCard.tsx b/webui/src/components/ArtifactPreviewCard.tsx new file mode 100644 index 0000000..801cfa0 --- /dev/null +++ b/webui/src/components/ArtifactPreviewCard.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +type ArtifactPreviewCardProps = { + artifact: any; + className?: string; + dataUrl: string; + fallbackName: string; + formatBytes: (value: unknown) => string; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const ArtifactPreviewCard: React.FC = ({ + artifact, + className, + dataUrl, + fallbackName, + formatBytes, +}) => { + const kind = String(artifact?.kind || '').trim().toLowerCase(); + const mime = String(artifact?.mime_type || '').trim().toLowerCase(); + const isImage = kind === 'image' || mime.startsWith('image/'); + const isVideo = kind === 'video' || mime.startsWith('video/'); + const displayName = String(artifact?.name || artifact?.source_path || fallbackName); + const meta = [artifact?.kind, artifact?.mime_type, formatBytes(artifact?.size_bytes)].filter(Boolean).join(' · '); + const pathText = String(artifact?.source_path || artifact?.path || artifact?.url || '-'); + const contentText = String(artifact?.content_text || '').trim(); + + return ( +
+
+
+
{displayName}
+
{meta}
+
+
{String(artifact?.storage || '-')}
+
+ {isImage && dataUrl ? ( + {displayName} + ) : null} + {isVideo && dataUrl ? ( +
+ ); +}; + +export default ArtifactPreviewCard; diff --git a/webui/src/components/CodeBlockPanel.tsx b/webui/src/components/CodeBlockPanel.tsx new file mode 100644 index 0000000..298d540 --- /dev/null +++ b/webui/src/components/CodeBlockPanel.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import InfoBlock from './InfoBlock'; + +type CodeBlockPanelProps = { + children: React.ReactNode; + className?: string; + codeClassName?: string; + danger?: boolean; + label: React.ReactNode; + pre?: boolean; +}; + +const CodeBlockPanel: React.FC = ({ + children, + className, + codeClassName, + danger = false, + label, + pre = false, +}) => { + const content = pre ? ( +
{children}
+ ) : ( +
{children}
+ ); + + return ( + + {content} + + ); +}; + +export default CodeBlockPanel; diff --git a/webui/src/components/DetailGrid.tsx b/webui/src/components/DetailGrid.tsx new file mode 100644 index 0000000..ae87959 --- /dev/null +++ b/webui/src/components/DetailGrid.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +type DetailGridItem = { + key?: string; + label: React.ReactNode; + value: React.ReactNode; + valueClassName?: string; +}; + +type DetailGridProps = { + className?: string; + columnsClassName?: string; + items: DetailGridItem[]; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const DetailGrid: React.FC = ({ + className, + columnsClassName = 'grid-cols-2 md:grid-cols-3', + items, +}) => { + return ( +
+ {items.map((item, index) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ); +}; + +export default DetailGrid; +export type { DetailGridItem, DetailGridProps }; diff --git a/webui/src/components/EmptyState.tsx b/webui/src/components/EmptyState.tsx new file mode 100644 index 0000000..a43c927 --- /dev/null +++ b/webui/src/components/EmptyState.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +type EmptyStateProps = { + centered?: boolean; + className?: string; + dashed?: boolean; + icon?: React.ReactNode; + message: React.ReactNode; + panel?: boolean; + padded?: boolean; + title?: React.ReactNode; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const EmptyState: React.FC = ({ + centered = false, + className, + dashed = false, + icon, + message, + panel = false, + padded = false, + title, +}) => { + return ( +
+ {icon ?
{icon}
: null} + {title ?
{title}
: null} +
{message}
+
+ ); +}; + +export default EmptyState; diff --git a/webui/src/components/FileListItem.tsx b/webui/src/components/FileListItem.tsx new file mode 100644 index 0000000..ba9a1ff --- /dev/null +++ b/webui/src/components/FileListItem.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +type FileListItemProps = { + active?: boolean; + monospace?: boolean; + onClick?: () => void; + actions?: React.ReactNode; + children: React.ReactNode; +}; + +const FileListItem: React.FC = ({ active = false, monospace = false, onClick, actions, children }) => ( +
+ + {actions ?
{actions}
: null} +
+); + +export default FileListItem; diff --git a/webui/src/components/FormControls.tsx b/webui/src/components/FormControls.tsx index 9513382..acfcf79 100644 --- a/webui/src/components/FormControls.tsx +++ b/webui/src/components/FormControls.tsx @@ -86,14 +86,14 @@ export function CheckboxField({ className, ...props }: CheckboxFieldProps) { export function FieldBlock({ label, help, meta, className, children }: FieldBlockProps) { return ( -
+
{(label || help || meta) && ( -
-
- {label ?
{label}
: null} - {help ?
{help}
: null} +
+
+ {label ?
{label}
: null} + {help ?
{help}
: null}
- {meta ?
{meta}
: null} + {meta ?
{meta}
: null}
)} {children} diff --git a/webui/src/components/InfoBlock.tsx b/webui/src/components/InfoBlock.tsx new file mode 100644 index 0000000..ffcc750 --- /dev/null +++ b/webui/src/components/InfoBlock.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +type InfoBlockProps = { + children: React.ReactNode; + className?: string; + contentClassName?: string; + label: React.ReactNode; + labelClassName?: string; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const InfoBlock: React.FC = ({ + children, + className, + contentClassName, + label, + labelClassName, +}) => { + return ( +
+
{label}
+
{children}
+
+ ); +}; + +export default InfoBlock; diff --git a/webui/src/components/InfoTile.tsx b/webui/src/components/InfoTile.tsx new file mode 100644 index 0000000..186f5fb --- /dev/null +++ b/webui/src/components/InfoTile.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +type InfoTileProps = { + children: React.ReactNode; + className?: string; + contentClassName?: string; + label: React.ReactNode; + labelClassName?: string; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const InfoTile: React.FC = ({ + children, + className, + contentClassName, + label, + labelClassName, +}) => { + return ( +
+
+ {label} +
+
{children}
+
+ ); +}; + +export default InfoTile; diff --git a/webui/src/components/InsetCard.tsx b/webui/src/components/InsetCard.tsx new file mode 100644 index 0000000..bba8907 --- /dev/null +++ b/webui/src/components/InsetCard.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +type InsetCardProps = { + children: React.ReactNode; + className?: string; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const InsetCard: React.FC = ({ children, className }) => { + return ( +
+ {children} +
+ ); +}; + +export default InsetCard; diff --git a/webui/src/components/ListPanel.tsx b/webui/src/components/ListPanel.tsx new file mode 100644 index 0000000..ff9e775 --- /dev/null +++ b/webui/src/components/ListPanel.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +type ListPanelProps = { + children: React.ReactNode; + className?: string; + header?: React.ReactNode; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const ListPanel: React.FC = ({ + children, + className, + header, +}) => { + return ( +
+ {header} + {children} +
+ ); +}; + +export default ListPanel; diff --git a/webui/src/components/MetricPanel.tsx b/webui/src/components/MetricPanel.tsx new file mode 100644 index 0000000..1382abf --- /dev/null +++ b/webui/src/components/MetricPanel.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +type MetricPanelProps = { + className?: string; + icon?: React.ReactNode; + iconContainerClassName?: string; + layout?: 'stacked' | 'split'; + subtitle?: React.ReactNode; + subtitleClassName?: string; + title: React.ReactNode; + titleClassName?: string; + value: React.ReactNode; + valueClassName?: string; +}; + +const BASE_CLASS_NAME = 'brand-card ui-border-subtle rounded-[28px] border p-5 min-h-[148px]'; + +const MetricPanel: React.FC = ({ + className, + icon, + iconContainerClassName, + layout = 'stacked', + subtitle, + subtitleClassName, + title, + titleClassName, + value, + valueClassName, +}) => { + if (layout === 'split') { + return ( +
+
+
+
{title}
+
{value}
+ {subtitle ?
{subtitle}
: null} +
+ {icon ? ( +
+ {icon} +
+ ) : null} +
+
+ ); + } + + return ( +
+
+ {icon} +
{title}
+
+
{value}
+ {subtitle ?
{subtitle}
: null} +
+ ); +}; + +export default MetricPanel; diff --git a/webui/src/components/ModalFrame.tsx b/webui/src/components/ModalFrame.tsx new file mode 100644 index 0000000..b3277ef --- /dev/null +++ b/webui/src/components/ModalFrame.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +type ModalShellProps = { + children: React.ReactNode; + className?: string; +}; + +type ModalBackdropProps = { + className?: string; + onClick?: () => void; +}; + +type ModalCardProps = { + children: React.ReactNode; + className?: string; +}; + +type ModalHeaderProps = { + actions?: React.ReactNode; + className?: string; + subtitle?: React.ReactNode; + title: React.ReactNode; +}; + +type ModalBodyProps = { + children: React.ReactNode; + className?: string; +}; + +type ModalFooterProps = { + children: React.ReactNode; + className?: string; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +export const ModalShell: React.FC = ({ children, className }) => ( +
+ {children} +
+); + +export const ModalBackdrop: React.FC = ({ className, onClick }) => ( +
+); + +export const ModalCard: React.FC = ({ children, className }) => ( +
+ {children} +
+); + +export const ModalHeader: React.FC = ({ + actions, + className, + subtitle, + title, +}) => ( +
+
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ {actions ?
{actions}
: null} +
+); + +export const ModalBody: React.FC = ({ children, className }) => ( +
{children}
+); + +export const ModalFooter: React.FC = ({ children, className }) => ( +
+ {children} +
+); diff --git a/webui/src/components/NoticePanel.tsx b/webui/src/components/NoticePanel.tsx new file mode 100644 index 0000000..5b9e44d --- /dev/null +++ b/webui/src/components/NoticePanel.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +type NoticeTone = 'warning' | 'danger' | 'info'; + +type NoticePanelProps = { + children: React.ReactNode; + className?: string; + tone?: NoticeTone; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +function toneClass(tone: NoticeTone) { + switch (tone) { + case 'danger': + return 'ui-notice-danger'; + case 'info': + return 'ui-notice-info'; + case 'warning': + default: + return 'ui-notice-warning'; + } +} + +const NoticePanel: React.FC = ({ + children, + className, + tone = 'warning', +}) => { + return ( +
+ {children} +
+ ); +}; + +export default NoticePanel; diff --git a/webui/src/components/PageHeader.tsx b/webui/src/components/PageHeader.tsx new file mode 100644 index 0000000..8b9b435 --- /dev/null +++ b/webui/src/components/PageHeader.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +type PageHeaderProps = { + actions?: React.ReactNode; + className?: string; + subtitle?: React.ReactNode; + title: React.ReactNode; + titleClassName?: string; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const PageHeader: React.FC = ({ + actions, + className, + subtitle, + title, + titleClassName, +}) => { + return ( +
+
+

{title}

+ {subtitle ?
{subtitle}
: null} +
+ {actions ?
{actions}
: null} +
+ ); +}; + +export default PageHeader; diff --git a/webui/src/components/PanelHeader.tsx b/webui/src/components/PanelHeader.tsx new file mode 100644 index 0000000..b3a2838 --- /dev/null +++ b/webui/src/components/PanelHeader.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +type PanelHeaderProps = { + className?: string; + title: React.ReactNode; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const PanelHeader: React.FC = ({ className, title }) => { + return ( +
+ {title} +
+ ); +}; + +export default PanelHeader; diff --git a/webui/src/components/SectionHeader.tsx b/webui/src/components/SectionHeader.tsx new file mode 100644 index 0000000..f6f8917 --- /dev/null +++ b/webui/src/components/SectionHeader.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +type SectionHeaderProps = { + actions?: React.ReactNode; + className?: string; + meta?: React.ReactNode; + subtitle?: React.ReactNode; + title: React.ReactNode; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const SectionHeader: React.FC = ({ + actions, + className, + meta, + subtitle, + title, +}) => { + return ( +
+
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ {actions ?
{actions}
: meta ?
{meta}
: null} +
+ ); +}; + +export default SectionHeader; diff --git a/webui/src/components/SectionPanel.tsx b/webui/src/components/SectionPanel.tsx new file mode 100644 index 0000000..d411cfb --- /dev/null +++ b/webui/src/components/SectionPanel.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +type SectionPanelProps = { + actions?: React.ReactNode; + children: React.ReactNode; + className?: string; + headerClassName?: string; + icon?: React.ReactNode; + subtitle?: React.ReactNode; + title?: React.ReactNode; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const SectionPanel: React.FC = ({ + actions, + children, + className, + headerClassName, + icon, + subtitle, + title, +}) => { + return ( +
+ {title || subtitle || actions || icon ? ( +
+
+ {title ? ( +
+ {icon} +

{title}

+
+ ) : null} + {subtitle ?
{subtitle}
: null} +
+ {actions ?
{actions}
: null} +
+ ) : null} + {children} +
+ ); +}; + +export default SectionPanel; diff --git a/webui/src/components/SelectableListItem.tsx b/webui/src/components/SelectableListItem.tsx new file mode 100644 index 0000000..fe77d97 --- /dev/null +++ b/webui/src/components/SelectableListItem.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Check } from 'lucide-react'; + +type SelectableListItemProps = { + active?: boolean; + children: React.ReactNode; + className?: string; + onClick: () => void; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const SelectableListItem: React.FC = ({ + active = false, + children, + className, + onClick, +}) => { + return ( + + ); +}; + +export default SelectableListItem; diff --git a/webui/src/components/SummaryListItem.tsx b/webui/src/components/SummaryListItem.tsx new file mode 100644 index 0000000..230c4d3 --- /dev/null +++ b/webui/src/components/SummaryListItem.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import SelectableListItem from './SelectableListItem'; + +type SummaryListItemProps = { + active?: boolean; + badges?: React.ReactNode; + className?: string; + meta?: React.ReactNode; + onClick?: () => void; + subtitle?: React.ReactNode; + title: React.ReactNode; + trailing?: React.ReactNode; +}; + +const SummaryListItem: React.FC = ({ + active, + badges, + className, + meta, + onClick, + subtitle, + title, + trailing, +}) => { + return ( + +
+
+
{title}
+ {subtitle ?
{subtitle}
: null} + {meta ?
{meta}
: null} + {badges ?
{badges}
: null} +
+ {trailing} +
+
+ ); +}; + +export default SummaryListItem; diff --git a/webui/src/components/ToolbarRow.tsx b/webui/src/components/ToolbarRow.tsx new file mode 100644 index 0000000..3c8c2dc --- /dev/null +++ b/webui/src/components/ToolbarRow.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +type ToolbarRowProps = { + children: React.ReactNode; + className?: string; +}; + +function joinClasses(...values: Array) { + return values.filter(Boolean).join(' '); +} + +const ToolbarRow: React.FC = ({ children, className }) => { + return ( +
+ {children} +
+ ); +}; + +export default ToolbarRow; diff --git a/webui/src/components/channel/ChannelFieldRenderer.tsx b/webui/src/components/channel/ChannelFieldRenderer.tsx new file mode 100644 index 0000000..3e08700 --- /dev/null +++ b/webui/src/components/channel/ChannelFieldRenderer.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { Check, ShieldCheck, Users, Wifi } from 'lucide-react'; +import { CheckboxField, FieldBlock, TextField, TextareaField } from '../FormControls'; +import type { ChannelField, ChannelKey } from './channelSchema'; + +type Translate = (key: string, options?: any) => string; + +type ChannelFieldRendererProps = { + channelKey: ChannelKey; + draft: Record; + field: ChannelField; + getDescription: (t: Translate, channelKey: ChannelKey, fieldKey: string) => string; + parseList: (value: unknown) => string[]; + setDraft: React.Dispatch>>; + t: Translate; +}; + +function getWhatsAppBooleanIcon(fieldKey: string) { + switch (fieldKey) { + case 'enabled': + return Wifi; + case 'enable_groups': + return Users; + case 'require_mention_in_groups': + return ShieldCheck; + default: + return Check; + } +} + +function formatList(value: unknown) { + return Array.isArray(value) ? value.join('\n') : ''; +} + +const ChannelFieldRenderer: React.FC = ({ + channelKey, + draft, + field, + getDescription, + parseList, + setDraft, + t, +}) => { + const label = t(`configLabels.${field.key}`); + const value = draft[field.key]; + const isWhatsApp = channelKey === 'whatsapp'; + const helper = getDescription(t, channelKey, field.key); + + if (field.type === 'boolean') { + if (isWhatsApp) { + const Icon = getWhatsAppBooleanIcon(field.key); + return ( + + ); + } + + return ( + + ); + } + + if (field.type === 'list') { + return ( + 0 ? `${t('entries')}: ${value.length}` : undefined} + > + setDraft((prev) => ({ ...prev, [field.key]: parseList(e.target.value) }))} + placeholder={field.placeholder || ''} + monospace={isWhatsApp} + className={isWhatsApp ? 'min-h-36 px-4 py-3' : 'min-h-32 px-4 py-3'} + /> + {isWhatsApp ?
{t('whatsappFieldAllowFromFootnote')}
: null} +
+ ); + } + + return ( + + setDraft((prev) => ({ ...prev, [field.key]: field.type === 'number' ? Number(e.target.value || 0) : e.target.value }))} + placeholder={field.placeholder || ''} + className={isWhatsApp && field.key === 'bridge_url' ? 'font-mono' : ''} + /> + + ); +}; + +export default ChannelFieldRenderer; diff --git a/webui/src/components/channel/ChannelSectionCard.tsx b/webui/src/components/channel/ChannelSectionCard.tsx new file mode 100644 index 0000000..67f1ea3 --- /dev/null +++ b/webui/src/components/channel/ChannelSectionCard.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +type ChannelSectionCardProps = { + children: React.ReactNode; + hint?: React.ReactNode; + icon: React.ReactNode; + title: React.ReactNode; +}; + +const ChannelSectionCard: React.FC = ({ + children, + hint, + icon, + title, +}) => { + return ( +
+
+
+ {icon} +
+
+
{title}
+ {hint ?

{hint}

: null} +
+
+ {children} +
+ ); +}; + +export default ChannelSectionCard; diff --git a/webui/src/components/channel/WhatsAppQRCodePanel.tsx b/webui/src/components/channel/WhatsAppQRCodePanel.tsx new file mode 100644 index 0000000..3c6e3dd --- /dev/null +++ b/webui/src/components/channel/WhatsAppQRCodePanel.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { QrCode, Smartphone } from 'lucide-react'; +import ChannelSectionCard from './ChannelSectionCard'; +import EmptyState from '../EmptyState'; + +type Translate = (key: string, options?: any) => string; + +type WhatsAppQRCodePanelProps = { + qrAvailable?: boolean; + qrImageURL: string; + t: Translate; +}; + +const WhatsAppQRCodePanel: React.FC = ({ + qrAvailable, + qrImageURL, + t, +}) => { + return ( + } + title={ + <> +
{t('whatsappBridgeQRCode')}
+
+ {qrAvailable ? t('whatsappQRCodeReady') : t('whatsappQRCodeUnavailable')} +
+ + } + > + {qrAvailable ? ( +
+ {t('whatsappBridgeQRCode')} +
+ ) : ( + + +
+ } + title={t('whatsappQRCodeUnavailable')} + message={
{t('whatsappQRCodeHint')}
} + /> + )} + + ); +}; + +export default WhatsAppQRCodePanel; diff --git a/webui/src/components/channel/WhatsAppStatusPanel.tsx b/webui/src/components/channel/WhatsAppStatusPanel.tsx new file mode 100644 index 0000000..a471bc5 --- /dev/null +++ b/webui/src/components/channel/WhatsAppStatusPanel.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { LogOut, Wifi, WifiOff } from 'lucide-react'; +import { FixedButton } from '../Button'; +import ChannelSectionCard from './ChannelSectionCard'; +import InfoTile from '../InfoTile'; +import NoticePanel from '../NoticePanel'; + +type Translate = (key: string, options?: any) => string; + +type WhatsAppStatusPanelProps = { + bridgeUrl: string; + lastError?: string; + onLogout: () => void; + stateLabel: string; + status?: { + connected?: boolean; + inbound_count?: number; + last_error?: string; + last_event?: string; + last_inbound_at?: string; + last_inbound_from?: string; + last_inbound_text?: string; + last_outbound_at?: string; + last_outbound_text?: string; + last_outbound_to?: string; + last_read_at?: string; + outbound_count?: number; + read_receipt_count?: number; + updated_at?: string; + user_jid?: string; + }; + t: Translate; +}; + +const WhatsAppStatusPanel: React.FC = ({ + bridgeUrl, + lastError, + onLogout, + stateLabel, + status, + t, +}) => { + return ( + : } + title={ + <> +
{t('gatewayStatus')}
+
{stateLabel}
+ + } + > +
+
+ + + +
+ +
+ + {bridgeUrl || '-'} + + + {status?.user_jid || '-'} + + + {status?.last_event || '-'} + + + {status?.updated_at || '-'} + + + {status?.inbound_count ?? 0} + + + {status?.outbound_count ?? 0} + + + {status?.read_receipt_count ?? 0} + + + {status?.last_read_at || '-'} + +
+ +
+ +
{status?.last_inbound_at || '-'}
+
{status?.last_inbound_from || '-'}
+
{status?.last_inbound_text || '-'}
+
+ +
{status?.last_outbound_at || '-'}
+
{status?.last_outbound_to || '-'}
+
{status?.last_outbound_text || '-'}
+
+
+ + {lastError ? {lastError} : null} + {status?.last_error ? {status.last_error} : null} + + ); +}; + +export default WhatsAppStatusPanel; diff --git a/webui/src/components/channel/channelSchema.ts b/webui/src/components/channel/channelSchema.ts new file mode 100644 index 0000000..1750f3c --- /dev/null +++ b/webui/src/components/channel/channelSchema.ts @@ -0,0 +1,337 @@ +import { Check, KeyRound, ListFilter, Smartphone, Users } from 'lucide-react'; + +export type ChannelKey = 'telegram' | 'whatsapp' | 'discord' | 'feishu' | 'qq' | 'dingtalk' | 'maixcam'; + +export type ChannelField = + | { key: string; type: 'text' | 'password' | 'number'; placeholder?: string } + | { key: string; type: 'boolean' } + | { key: string; type: 'list'; placeholder?: string }; + +export type ChannelDefinition = { + id: ChannelKey; + titleKey: string; + hintKey: string; + sections: Array<{ + id: string; + titleKey: string; + hintKey: string; + fields: ChannelField[]; + columns?: 1 | 2; + }>; +}; + +export const channelDefinitions: Record = { + telegram: { + id: 'telegram', + titleKey: 'telegram', + hintKey: 'telegramChannelHint', + sections: [ + { + id: 'connection', + titleKey: 'channelSectionConnection', + hintKey: 'channelSectionConnectionHint', + columns: 2, + fields: [ + { key: 'enabled', type: 'boolean' }, + { key: 'token', type: 'password' }, + { key: 'streaming', type: 'boolean' }, + ], + }, + { + id: 'access', + titleKey: 'channelSectionAccess', + hintKey: 'channelSectionAccessHint', + columns: 2, + fields: [ + { key: 'allow_from', type: 'list', placeholder: '123456789' }, + { key: 'allow_chats', type: 'list', placeholder: 'telegram:123456789' }, + ], + }, + { + id: 'groups', + titleKey: 'channelSectionGroupPolicy', + hintKey: 'channelSectionGroupPolicyHint', + columns: 2, + fields: [ + { key: 'enable_groups', type: 'boolean' }, + { key: 'require_mention_in_groups', type: 'boolean' }, + ], + }, + ], + }, + whatsapp: { + id: 'whatsapp', + titleKey: 'whatsappBridge', + hintKey: 'whatsappBridgeHint', + sections: [ + { + id: 'connection', + titleKey: 'channelSectionConnection', + hintKey: 'channelSectionConnectionHint', + columns: 2, + fields: [ + { key: 'enabled', type: 'boolean' }, + { key: 'bridge_url', type: 'text' }, + ], + }, + { + id: 'access', + titleKey: 'channelSectionAccess', + hintKey: 'channelSectionAccessHint', + columns: 1, + fields: [ + { key: 'allow_from', type: 'list', placeholder: '8613012345678@s.whatsapp.net' }, + ], + }, + { + id: 'groups', + titleKey: 'channelSectionGroupPolicy', + hintKey: 'channelSectionGroupPolicyHint', + columns: 2, + fields: [ + { key: 'enable_groups', type: 'boolean' }, + { key: 'require_mention_in_groups', type: 'boolean' }, + ], + }, + ], + }, + discord: { + id: 'discord', + titleKey: 'discord', + hintKey: 'discordChannelHint', + sections: [ + { + id: 'connection', + titleKey: 'channelSectionConnection', + hintKey: 'channelSectionConnectionHint', + columns: 2, + fields: [ + { key: 'enabled', type: 'boolean' }, + { key: 'token', type: 'password' }, + ], + }, + { + id: 'access', + titleKey: 'channelSectionAccess', + hintKey: 'channelSectionAccessHint', + columns: 1, + fields: [ + { key: 'allow_from', type: 'list', placeholder: 'discord-user-id' }, + ], + }, + ], + }, + feishu: { + id: 'feishu', + titleKey: 'feishu', + hintKey: 'feishuChannelHint', + sections: [ + { + id: 'connection', + titleKey: 'channelSectionConnection', + hintKey: 'channelSectionConnectionHint', + columns: 2, + fields: [ + { key: 'enabled', type: 'boolean' }, + { key: 'app_id', type: 'text' }, + { key: 'app_secret', type: 'password' }, + { key: 'encrypt_key', type: 'password' }, + { key: 'verification_token', type: 'password' }, + ], + }, + { + id: 'access', + titleKey: 'channelSectionAccess', + hintKey: 'channelSectionAccessHint', + columns: 2, + fields: [ + { key: 'allow_from', type: 'list' }, + { key: 'allow_chats', type: 'list' }, + ], + }, + { + id: 'groups', + titleKey: 'channelSectionGroupPolicy', + hintKey: 'channelSectionGroupPolicyHint', + columns: 2, + fields: [ + { key: 'enable_groups', type: 'boolean' }, + { key: 'require_mention_in_groups', type: 'boolean' }, + ], + }, + ], + }, + qq: { + id: 'qq', + titleKey: 'qq', + hintKey: 'qqChannelHint', + sections: [ + { + id: 'connection', + titleKey: 'channelSectionConnection', + hintKey: 'channelSectionConnectionHint', + columns: 2, + fields: [ + { key: 'enabled', type: 'boolean' }, + { key: 'app_id', type: 'text' }, + { key: 'app_secret', type: 'password' }, + ], + }, + { + id: 'access', + titleKey: 'channelSectionAccess', + hintKey: 'channelSectionAccessHint', + columns: 1, + fields: [ + { key: 'allow_from', type: 'list' }, + ], + }, + ], + }, + dingtalk: { + id: 'dingtalk', + titleKey: 'dingtalk', + hintKey: 'dingtalkChannelHint', + sections: [ + { + id: 'connection', + titleKey: 'channelSectionConnection', + hintKey: 'channelSectionConnectionHint', + columns: 2, + fields: [ + { key: 'enabled', type: 'boolean' }, + { key: 'client_id', type: 'text' }, + { key: 'client_secret', type: 'password' }, + ], + }, + { + id: 'access', + titleKey: 'channelSectionAccess', + hintKey: 'channelSectionAccessHint', + columns: 1, + fields: [ + { key: 'allow_from', type: 'list' }, + ], + }, + ], + }, + maixcam: { + id: 'maixcam', + titleKey: 'maixcam', + hintKey: 'maixcamChannelHint', + sections: [ + { + id: 'network', + titleKey: 'channelSectionNetwork', + hintKey: 'channelSectionNetworkHint', + columns: 2, + fields: [ + { key: 'enabled', type: 'boolean' }, + { key: 'host', type: 'text' }, + { key: 'port', type: 'number' }, + ], + }, + { + id: 'access', + titleKey: 'channelSectionAccess', + hintKey: 'channelSectionAccessHint', + columns: 1, + fields: [ + { key: 'allow_from', type: 'list' }, + ], + }, + ], + }, +}; + +export function parseChannelList(text: string) { + return String(text || '') + .split('\n') + .map((line) => line.split(',')) + .flat() + .map((item) => item.trim()) + .filter(Boolean); +} + +function getWhatsAppFieldDescription(t: (key: string) => string, fieldKey: string) { + switch (fieldKey) { + case 'enabled': + return t('whatsappFieldEnabledHint'); + case 'bridge_url': + return t('whatsappFieldBridgeURLHint'); + case 'allow_from': + return t('whatsappFieldAllowFromHint'); + case 'enable_groups': + return t('whatsappFieldEnableGroupsHint'); + case 'require_mention_in_groups': + return t('whatsappFieldRequireMentionHint'); + default: + return ''; + } +} + +export function getChannelFieldDescription(t: (key: string) => string, channelKey: ChannelKey, fieldKey: string) { + if (channelKey === 'whatsapp') return getWhatsAppFieldDescription(t, fieldKey); + const map: Partial>>> = { + telegram: { + enabled: 'channelFieldTelegramEnabledHint', + token: 'channelFieldTelegramTokenHint', + streaming: 'channelFieldTelegramStreamingHint', + allow_from: 'channelFieldTelegramAllowFromHint', + allow_chats: 'channelFieldTelegramAllowChatsHint', + enable_groups: 'channelFieldEnableGroupsHint', + require_mention_in_groups: 'channelFieldRequireMentionHint', + }, + discord: { + enabled: 'channelFieldDiscordEnabledHint', + token: 'channelFieldDiscordTokenHint', + allow_from: 'channelFieldDiscordAllowFromHint', + }, + feishu: { + enabled: 'channelFieldFeishuEnabledHint', + app_id: 'channelFieldFeishuAppIDHint', + app_secret: 'channelFieldFeishuAppSecretHint', + encrypt_key: 'channelFieldFeishuEncryptKeyHint', + verification_token: 'channelFieldFeishuVerificationTokenHint', + allow_from: 'channelFieldFeishuAllowFromHint', + allow_chats: 'channelFieldFeishuAllowChatsHint', + enable_groups: 'channelFieldEnableGroupsHint', + require_mention_in_groups: 'channelFieldRequireMentionHint', + }, + qq: { + enabled: 'channelFieldQQEnabledHint', + app_id: 'channelFieldQQAppIDHint', + app_secret: 'channelFieldQQAppSecretHint', + allow_from: 'channelFieldQQAllowFromHint', + }, + dingtalk: { + enabled: 'channelFieldDingTalkEnabledHint', + client_id: 'channelFieldDingTalkClientIDHint', + client_secret: 'channelFieldDingTalkClientSecretHint', + allow_from: 'channelFieldDingTalkAllowFromHint', + }, + maixcam: { + enabled: 'channelFieldMaixCamEnabledHint', + host: 'channelFieldMaixCamHostHint', + port: 'channelFieldMaixCamPortHint', + allow_from: 'channelFieldMaixCamAllowFromHint', + }, + }; + const key = map[channelKey]?.[fieldKey]; + return key ? t(key) : ''; +} + +export function getChannelSectionIcon(sectionID: string) { + switch (sectionID) { + case 'connection': + return KeyRound; + case 'access': + return ListFilter; + case 'groups': + return Users; + case 'network': + return Smartphone; + default: + return Check; + } +} diff --git a/webui/src/components/chat/ChatComposer.tsx b/webui/src/components/chat/ChatComposer.tsx new file mode 100644 index 0000000..90b88bc --- /dev/null +++ b/webui/src/components/chat/ChatComposer.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Paperclip, Send } from 'lucide-react'; +import { TextField } from '../FormControls'; + +type ChatComposerProps = { + chatTab: 'main' | 'subagents'; + disabled: boolean; + fileSelected: boolean; + msg: string; + onFileChange: React.ChangeEventHandler; + onMsgChange: (value: string) => void; + onSend: () => void; + placeholder: string; +}; + +const ChatComposer: React.FC = ({ + chatTab, + disabled, + fileSelected, + msg, + onFileChange, + onMsgChange, + onSend, + placeholder, +}) => { + return ( +
+
+ + + onMsgChange(e.target.value)} + onKeyDown={(e) => chatTab === 'main' && e.key === 'Enter' && onSend()} + placeholder={placeholder} + disabled={disabled} + className="ui-composer-input w-full pl-14 pr-14 py-3.5 text-[15px] transition-all disabled:opacity-60" + /> + +
+
+ ); +}; + +export default ChatComposer; diff --git a/webui/src/components/chat/ChatEmptyState.tsx b/webui/src/components/chat/ChatEmptyState.tsx new file mode 100644 index 0000000..1925560 --- /dev/null +++ b/webui/src/components/chat/ChatEmptyState.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { MessageSquare } from 'lucide-react'; + +type ChatEmptyStateProps = { + message: string; +}; + +const ChatEmptyState: React.FC = ({ message }) => { + return ( +
+
+ +
+

{message}

+
+ ); +}; + +export default ChatEmptyState; diff --git a/webui/src/components/chat/ChatMessageList.tsx b/webui/src/components/chat/ChatMessageList.tsx new file mode 100644 index 0000000..4f647cc --- /dev/null +++ b/webui/src/components/chat/ChatMessageList.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { motion } from 'motion/react'; +import type { RenderedChatItem } from './chatUtils'; + +type ChatMessageListProps = { + items: RenderedChatItem[]; + t: (key: string) => string; +}; + +function messageTone(item: RenderedChatItem) { + const isUser = item.role === 'user'; + const isExec = item.role === 'tool' || item.role === 'exec'; + const isSystem = item.role === 'system'; + + const bubbleClass = isUser + ? 'chat-bubble-user rounded-br-sm' + : isExec + ? 'chat-bubble-tool rounded-bl-sm' + : isSystem + ? 'chat-bubble-system rounded-bl-sm' + : item.isReadonlyGroup + ? 'chat-bubble-system rounded-bl-sm' + : 'chat-bubble-agent rounded-bl-sm'; + + const metaClass = isUser + ? 'chat-meta-user' + : isExec + ? 'chat-meta-tool' + : 'ui-text-muted'; + + const subLabelClass = isUser + ? 'chat-submeta-user' + : isExec + ? 'chat-submeta-tool' + : 'ui-text-muted'; + + return { bubbleClass, isExec, isSystem, isUser, metaClass, subLabelClass }; +} + +const ChatMessageList: React.FC = ({ + items, + t, +}) => { + return ( + <> + {items.map((item, index) => { + const { bubbleClass, isExec, isSystem, isUser, metaClass, subLabelClass } = messageTone(item); + return ( + +
+
+ {item.avatarText || (isUser ? 'U' : 'A')} +
+
+
+
+ {item.actorName || item.label || (isUser ? t('user') : isExec ? t('exec') : isSystem ? t('system') : t('agent'))} +
+ {item.metaLine ?
{item.metaLine}
: null} +
+ {item.label && item.actorName && item.label !== item.actorName ? ( +
{item.label}
+ ) : null} +

{item.text}

+
+
+
+ ); + })} + + ); +}; + +export default ChatMessageList; diff --git a/webui/src/components/chat/SubagentSidebar.tsx b/webui/src/components/chat/SubagentSidebar.tsx new file mode 100644 index 0000000..0061f6f --- /dev/null +++ b/webui/src/components/chat/SubagentSidebar.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { Button } from '../Button'; +import { SelectField, TextField, TextareaField } from '../FormControls'; +import type { AgentRuntimeBadge, RegistryAgent } from './chatUtils'; + +type SubagentSidebarProps = { + agentLabel: string; + agentsLabel: string; + dispatchAgentID: string; + dispatchHint: string; + dispatchLabel: string; + dispatchTask: string; + dispatchTitle: string; + dispatchToSubagentLabel: string; + formatAgentName: (value?: string) => string; + idleLabel: string; + onAgentChange: (value: string) => void; + onDispatch: () => void; + onLabelChange: (value: string) => void; + onTaskChange: (value: string) => void; + registryAgents: RegistryAgent[]; + runtimeBadgeByAgent: Record; + subagentLabelPlaceholder: string; + subagentTaskPlaceholder: string; +}; + +function badgeClassName(status?: AgentRuntimeBadge['status']) { + switch (status) { + case 'running': + return 'ui-pill-success'; + case 'waiting': + return 'ui-pill-warning'; + case 'failed': + return 'ui-pill-danger'; + case 'completed': + return 'ui-pill-info'; + default: + return 'ui-pill-neutral'; + } +} + +const SubagentSidebar: React.FC = ({ + agentLabel, + agentsLabel, + dispatchAgentID, + dispatchHint, + dispatchLabel, + dispatchTask, + dispatchTitle, + dispatchToSubagentLabel, + formatAgentName, + idleLabel, + onAgentChange, + onDispatch, + onLabelChange, + onTaskChange, + registryAgents, + runtimeBadgeByAgent, + subagentLabelPlaceholder, + subagentTaskPlaceholder, +}) => { + return ( +
+
+
{dispatchTitle}
+
{dispatchHint}
+
+
+ onAgentChange(e.target.value)} + className="w-full rounded-2xl py-2.5" + > + {registryAgents.map((agent) => ( + + ))} + + onTaskChange(e.target.value)} + placeholder={subagentTaskPlaceholder} + className="w-full min-h-[180px] resize-none rounded-2xl px-3 py-3" + /> + onLabelChange(e.target.value)} + placeholder={subagentLabelPlaceholder} + className="w-full rounded-2xl py-2.5" + /> + +
+
+
{agentsLabel}
+
+ {registryAgents.map((agent) => { + const active = dispatchAgentID === agent.agent_id; + const badge = runtimeBadgeByAgent[String(agent.agent_id || '')]; + return ( + + ); + })} +
+
+
+ ); +}; + +export default SubagentSidebar; diff --git a/webui/src/components/chat/SubagentStreamFilters.tsx b/webui/src/components/chat/SubagentStreamFilters.tsx new file mode 100644 index 0000000..110f661 --- /dev/null +++ b/webui/src/components/chat/SubagentStreamFilters.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Button } from '../Button'; + +type SubagentStreamFiltersProps = { + agents: string[]; + allAgentsLabel: string; + formatAgentName: (agentID: string) => string; + onReset: () => void; + onToggle: (agent: string) => void; + selectedAgents: string[]; +}; + +const SubagentStreamFilters: React.FC = ({ + agents, + allAgentsLabel, + formatAgentName, + onReset, + onToggle, + selectedAgents, +}) => { + return ( +
+ + {agents.map((agent) => ( + + ))} +
+ ); +}; + +export default SubagentStreamFilters; diff --git a/webui/src/components/chat/chatUtils.ts b/webui/src/components/chat/chatUtils.ts new file mode 100644 index 0000000..ec5017f --- /dev/null +++ b/webui/src/components/chat/chatUtils.ts @@ -0,0 +1,116 @@ +import type { ChatItem } from '../../types'; + +export type StreamItem = { + kind?: string; + at?: number; + task_id?: string; + label?: string; + agent_id?: string; + event_type?: string; + message?: string; + message_type?: string; + content?: string; + from_agent?: string; + to_agent?: string; + reply_to?: string; + message_id?: string; + status?: string; +}; + +export type RenderedChatItem = ChatItem & { + id: string; + actorKey?: string; + actorName?: string; + avatarText?: string; + avatarClassName?: string; + metaLine?: string; + isReadonlyGroup?: boolean; +}; + +export type RegistryAgent = { + agent_id?: string; + display_name?: string; + role?: string; + enabled?: boolean; + transport?: string; +}; + +export type RuntimeTask = { + id?: string; + agent_id?: string; + status?: string; + updated?: number; + created?: number; + waiting_for_reply?: boolean; +}; + +export type AgentRuntimeBadge = { + status: 'running' | 'waiting' | 'failed' | 'completed' | 'idle'; + text: string; +}; + +export function formatAgentName(agentID: string | undefined, t: (key: string) => string): string { + const normalized = String(agentID || '').trim(); + if (!normalized) return t('unknownAgent'); + if (normalized === 'main') return t('mainAgent'); + return normalized + .split(/[-_.:]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +export function avatarSeed(key?: string): string { + const palette = [ + 'avatar-tone-1', + 'avatar-tone-2', + 'avatar-tone-3', + 'avatar-tone-4', + 'avatar-tone-5', + 'avatar-tone-6', + 'avatar-tone-7', + ]; + const source = String(key || 'agent'); + let hash = 0; + for (let i = 0; i < source.length; i += 1) { + hash = (hash * 31 + source.charCodeAt(i)) | 0; + } + return palette[Math.abs(hash) % palette.length]; +} + +export function avatarText(name?: string): string { + const parts = String(name || '') + .split(/\s+/) + .filter(Boolean); + if (parts.length === 0) return 'A'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase(); +} + +export function messageActorKey(item: StreamItem): string { + return String(item.from_agent || item.agent_id || item.to_agent || 'subagent').trim() || 'subagent'; +} + +export function collectActors(items: StreamItem[]): string[] { + const set = new Set(); + items.forEach((item) => { + [item.agent_id, item.from_agent, item.to_agent].forEach((value) => { + const normalized = String(value || '').trim(); + if (normalized) set.add(normalized); + }); + }); + return Array.from(set).sort((a, b) => a.localeCompare(b)); +} + +export function isUserFacingMainSession(key?: string): boolean { + const normalized = String(key || '').trim().toLowerCase(); + if (!normalized) return false; + return !( + normalized.startsWith('subagent:') || + normalized.startsWith('internal:') || + normalized.startsWith('heartbeat:') || + normalized.startsWith('cron:') || + normalized.startsWith('hook:') || + normalized.startsWith('node:') + ); +} diff --git a/webui/src/components/chat/useSubagentChatRuntime.ts b/webui/src/components/chat/useSubagentChatRuntime.ts new file mode 100644 index 0000000..933ce78 --- /dev/null +++ b/webui/src/components/chat/useSubagentChatRuntime.ts @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import type { RegistryAgent, RuntimeTask, StreamItem } from './chatUtils'; + +type UseSubagentChatRuntimeParams = { + dispatchAgentID: string; + q: string; + subagentRegistryItems: unknown[]; + subagentRuntimeItems: unknown[]; + subagentStreamItems: unknown[]; +}; + +export function useSubagentChatRuntime({ + dispatchAgentID, + q, + subagentRegistryItems, + subagentRuntimeItems, + subagentStreamItems, +}: UseSubagentChatRuntimeParams) { + const [subagentStream, setSubagentStream] = useState([]); + const [registryAgents, setRegistryAgents] = useState([]); + const [runtimeTasks, setRuntimeTasks] = useState([]); + + const loadSubagentGroup = async () => { + try { + if (subagentStreamItems.length > 0) { + setSubagentStream(Array.isArray(subagentStreamItems) ? (subagentStreamItems as StreamItem[]) : []); + return; + } + const response = await fetch(`/webui/api/subagents_runtime${q}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'stream_all', limit: 300, task_limit: 36 }), + }); + if (!response.ok) return; + const json = await response.json(); + const items = Array.isArray(json?.result?.items) ? json.result.items : []; + setSubagentStream(items); + } catch (error) { + console.error(error); + } + }; + + const loadRegistryAgents = async () => { + try { + if (subagentRegistryItems.length > 0) { + const filtered = (Array.isArray(subagentRegistryItems) ? subagentRegistryItems : []) + .filter((item: RegistryAgent) => item?.agent_id && item.enabled !== false) as RegistryAgent[]; + setRegistryAgents(filtered); + return filtered; + } + const response = await fetch(`/webui/api/subagents_runtime${q}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'registry' }), + }); + if (!response.ok) return []; + const json = await response.json(); + const items = Array.isArray(json?.result?.items) ? json.result.items : []; + const filtered = items.filter((item: RegistryAgent) => item?.agent_id && item.enabled !== false); + setRegistryAgents(filtered); + return filtered; + } catch (error) { + console.error(error); + return []; + } + }; + + const loadRuntimeTasks = async () => { + try { + if (subagentRuntimeItems.length > 0) { + setRuntimeTasks(Array.isArray(subagentRuntimeItems) ? (subagentRuntimeItems as RuntimeTask[]) : []); + return; + } + const response = await fetch(`/webui/api/subagents_runtime${q}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'list' }), + }); + if (!response.ok) return; + const json = await response.json(); + const items = Array.isArray(json?.result?.items) ? json.result.items : []; + setRuntimeTasks(items); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + if (dispatchAgentID || registryAgents.length === 0) return; + const first = registryAgents[0]?.agent_id; + if (first) { + // noop, caller can mirror this value from registryAgents when needed + } + }, [dispatchAgentID, registryAgents]); + + return { + loadRegistryAgents, + loadRuntimeTasks, + loadSubagentGroup, + registryAgents, + runtimeTasks, + subagentStream, + }; +} diff --git a/webui/src/components/config/ConfigPageChrome.tsx b/webui/src/components/config/ConfigPageChrome.tsx index 872edd1..29171c6 100644 --- a/webui/src/components/config/ConfigPageChrome.tsx +++ b/webui/src/components/config/ConfigPageChrome.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { RefreshCw, Save } from 'lucide-react'; import { Button, FixedButton } from '../Button'; import { CheckboxField, TextField } from '../FormControls'; +import { ModalBackdrop, ModalCard, ModalHeader, ModalShell } from '../ModalFrame'; type Translate = (key: string, options?: any) => string; @@ -22,9 +23,9 @@ export function ConfigHeader({ onSave, onShowForm, onShowRaw, showRaw, t }: Conf
- + + +
); @@ -116,12 +117,13 @@ type ConfigDiffModalProps = { export function ConfigDiffModal({ diffRows, onClose, t }: ConfigDiffModalProps) { return ( -
-
-
-
{t('configDiffPreviewCount', { count: diffRows.length })}
- -
+ + + + {t('close')}} + />
@@ -142,7 +144,7 @@ export function ConfigDiffModal({ diffRows, onClose, t }: ConfigDiffModalProps)
-
-
+ + ); } diff --git a/webui/src/components/config/GatewayConfigSection.tsx b/webui/src/components/config/GatewayConfigSection.tsx index c41b373..84a71dd 100644 --- a/webui/src/components/config/GatewayConfigSection.tsx +++ b/webui/src/components/config/GatewayConfigSection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Plus } from 'lucide-react'; +import { Plus, Trash2 } from 'lucide-react'; import { FixedButton } from '../Button'; import { CheckboxField, PanelField, SelectField, TextField, TextareaField } from '../FormControls'; @@ -49,9 +49,9 @@ export function GatewayIceServerRow({ dense className="md:col-span-2" /> - + + +
); } diff --git a/webui/src/components/config/ProviderConfigSection.tsx b/webui/src/components/config/ProviderConfigSection.tsx index baa702a..a79fcf7 100644 --- a/webui/src/components/config/ProviderConfigSection.tsx +++ b/webui/src/components/config/ProviderConfigSection.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Button } from '../Button'; +import { Download, FolderOpen, LogIn, Plus, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react'; +import { Button, FixedButton } from '../Button'; import { CheckboxField, PanelField, SelectField, TextField } from '../FormControls'; function joinClasses(...values: Array) { @@ -49,7 +50,9 @@ export function ProviderRuntimeToolbar({
{t('configProxies')}
- + + +
); @@ -112,10 +117,18 @@ export function ProviderRuntimeSummary({
oauth accounts: {item.oauth_accounts.length} · {item.oauth_accounts.map((account: any) => account?.account_label || account?.email || account?.account_id || account?.project_id || '-').join(', ')}
)}
- - - - + + + + + + + + + + + +
@@ -335,7 +348,9 @@ export function ProviderProxyCard({ {runtimeOpen ? 'Hide Runtime' : 'Show Runtime'} ) : null} - + + +
@@ -344,9 +359,9 @@ export function ProviderProxyCard({
1
-
+
Connection
-
Base URL, API key, and model routing for this provider.
+
Base URL, API key, and model routing.
@@ -372,14 +387,18 @@ export function ProviderProxyCard({
3
-
+
{t('providersOAuthSetup')}
-
Choose the upstream OAuth service, then launch login or import an existing auth file.
+
Select provider, then login or import.
- - + + + + + +
@@ -398,13 +417,10 @@ export function ProviderProxyCard({ onFieldChange('oauth.client_secret', e.target.value)} placeholder={t('providersClientSecret')} className="w-full" /> - - onFieldChange('oauth.hybrid_priority', e.target.value)} className="w-full"> - - - + + onFieldChange('oauth.network_proxy', e.target.value)} placeholder={t('providersNetworkProxyPlaceholder')} className="w-full" /> - + onFieldChange('oauth.credential_files', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))} @@ -415,13 +431,13 @@ export function ProviderProxyCard({
- + onFieldChange('oauth.cooldown_sec', Number(e.target.value || 0))} placeholder={t('providersCooldownSec')} className="w-full" /> - + onFieldChange('oauth.refresh_scan_sec', Number(e.target.value || 0))} placeholder={t('providersRefreshScanSec')} className="w-full" /> - + onFieldChange('oauth.refresh_lead_sec', Number(e.target.value || 0))} placeholder={t('providersRefreshLeadSec')} className="w-full" />
@@ -438,9 +454,9 @@ export function ProviderProxyCard({
2
-
+
Authentication
-
Choose how this provider authenticates requests.
+
Request auth and hybrid priority.
@@ -451,14 +467,8 @@ export function ProviderProxyCard({ - - onFieldChange('oauth.hybrid_priority', e.target.value)} className="w-full" disabled={!showOAuth}> - - - -
- {showOAuth ? 'api_first prefers API key first. oauth_first prefers the OAuth pool first.' : ( + {showOAuth ? 'Model selection stays on this provider. Hybrid only switches credentials inside the same provider.' : ( <> {t('providersSwitchAuthBefore')} oauth @@ -474,12 +484,16 @@ export function ProviderProxyCard({
4
-
+
{t('providersOAuthAccounts')}
-
Imported sessions for this provider.
+
Imported sessions.
- {showOAuth ? : null} + {showOAuth ? ( + + + + ) : null}
{!showOAuth ? (
Enable oauth or hybrid mode to manage OAuth accounts.
@@ -494,12 +508,19 @@ export function ProviderProxyCard({
label: {account?.account_label || account?.email || account?.account_id || '-'}
{account?.credential_file}
project: {account?.project_id || '-'} · device: {account?.device_id || '-'}
+
proxy: {account?.network_proxy || '-'}
expire: {account?.expire || '-'} · cooldown: {account?.cooldown_until || '-'}
- - - + onRefreshOAuthAccount(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Refresh"> + + + onClearOAuthCooldown(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Clear Cooldown"> + + + onDeleteOAuthAccount(String(account?.credential_file || ''))} variant="danger" radius="lg" label={t('delete')}> + +
))} @@ -511,9 +532,9 @@ export function ProviderProxyCard({
5
-
+
Advanced
-
Low-frequency runtime persistence and history settings.
+
Low-frequency runtime settings.
+ + ))} +
+ setDraftArgInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addDraftArg(draftArgInput); + } + }} + onBlur={() => addDraftArg(draftArgInput)} + placeholder={t('configMCPArgsEnterHint')} + className="h-11" + /> +
+ ) : null} + + {activeCheck && activeCheck.status !== 'ok' && activeCheck.status !== 'disabled' && activeCheck.status !== 'not_applicable' ? ( + +
{activeCheck.message || t('configMCPCommandMissing')}
+ {activeCheck.package ? ( +
{t('configMCPInstallSuggested', { pkg: activeCheck.package })}
+ ) : null} + {activeCheck.installable ? ( + installCheckPackage(activeCheck)} variant="warning" label={t('install')}> + + + ) : null} +
+ ) : null} +
+ ); +}; + +export default MCPServerEditor; diff --git a/webui/src/components/nodes/AgentTreePanel.tsx b/webui/src/components/nodes/AgentTreePanel.tsx new file mode 100644 index 0000000..0703294 --- /dev/null +++ b/webui/src/components/nodes/AgentTreePanel.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import EmptyState from '../EmptyState'; +import InfoBlock from '../InfoBlock'; + +type AgentTreePanelProps = { + emptyLabel: string; + items: any[]; + title: string; +}; + +const AgentTreePanel: React.FC = ({ + emptyLabel, + items, + title, +}) => { + return ( + + {items.length > 0 ? items.map((item, index) => ( +
+
{String(item?.display_name || item?.agent_id || '-')}
+
+ {String(item?.agent_id || '-')} · {String(item?.transport || '-')} · {String(item?.role || '-')} +
+
+ )) : ( + + )} +
+ ); +}; + +export default AgentTreePanel; diff --git a/webui/src/components/nodes/DispatchArtifactPreviewSection.tsx b/webui/src/components/nodes/DispatchArtifactPreviewSection.tsx new file mode 100644 index 0000000..c785937 --- /dev/null +++ b/webui/src/components/nodes/DispatchArtifactPreviewSection.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import ArtifactPreviewCard from '../ArtifactPreviewCard'; +import EmptyState from '../EmptyState'; +import InfoBlock from '../InfoBlock'; + +type DispatchArtifactPreviewSectionProps = { + artifacts: any[]; + emptyLabel: string; + formatBytes: (value: unknown) => string; + getDataUrl: (artifact: any) => string; + title: string; +}; + +const DispatchArtifactPreviewSection: React.FC = ({ + artifacts, + emptyLabel, + formatBytes, + getDataUrl, + title, +}) => { + return ( + + {artifacts.length > 0 ? artifacts.map((artifact, artifactIndex) => ( + + )) : ( + + )} + + ); +}; + +export default DispatchArtifactPreviewSection; diff --git a/webui/src/components/nodes/DispatchReplayPanel.tsx b/webui/src/components/nodes/DispatchReplayPanel.tsx new file mode 100644 index 0000000..8a7e037 --- /dev/null +++ b/webui/src/components/nodes/DispatchReplayPanel.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import CodeBlockPanel from '../CodeBlockPanel'; +import NoticePanel from '../NoticePanel'; +import { SelectField, TextField, TextareaField } from '../FormControls'; + +type DispatchReplayPanelProps = { + argsLabel: string; + modeLabel: string; + modelLabel: string; + onArgsChange: (value: string) => void; + onModeChange: (value: string) => void; + onModelChange: (value: string) => void; + onTaskChange: (value: string) => void; + replayArgsDraft: string; + replayError: string; + replayModeDraft: string; + replayModelDraft: string; + replayResult: any; + replayTaskDraft: string; + resultTitle: string; + taskLabel: string; + title: string; +}; + +const DispatchReplayPanel: React.FC = ({ + argsLabel, + modeLabel, + modelLabel, + onArgsChange, + onModeChange, + onModelChange, + onTaskChange, + replayArgsDraft, + replayError, + replayModeDraft, + replayModelDraft, + replayResult, + replayTaskDraft, + resultTitle, + taskLabel, + title, +}) => { + return ( +
+
+
{title}
+
+
+ + +
+ + +
+
+
+
{resultTitle}
+ {replayError ? ( + {replayError} + ) : ( + {replayResult ? JSON.stringify(replayResult, null, 2) : '-'} + )} +
+
+ ); +}; + +export default DispatchReplayPanel; diff --git a/webui/src/components/nodes/NodeP2PPanel.tsx b/webui/src/components/nodes/NodeP2PPanel.tsx new file mode 100644 index 0000000..0875532 --- /dev/null +++ b/webui/src/components/nodes/NodeP2PPanel.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import EmptyState from '../EmptyState'; +import InfoBlock from '../InfoBlock'; + +type NodeP2PPanelProps = { + emptyLabel: string; + errorLabel: string; + readyLabel: string; + retriesLabel: string; + selectedSession: any; + statusLabel: string; + timeFormatter: (value: unknown) => string; + title: string; +}; + +const NodeP2PPanel: React.FC = ({ + emptyLabel, + errorLabel, + readyLabel, + retriesLabel, + selectedSession, + statusLabel, + timeFormatter, + title, +}) => { + return ( + + {selectedSession ? ( +
+
+
{statusLabel}
+
{String(selectedSession.status || 'unknown')}
+
+
+
{retriesLabel}
+
{Number(selectedSession.retry_count || 0)}
+
+
+
{readyLabel}
+
{timeFormatter(selectedSession.last_ready_at)}
+
+
+
{errorLabel}
+
{String(selectedSession.last_error || '-')}
+
+
+ ) : ( + + )} +
+ ); +}; + +export default NodeP2PPanel; diff --git a/webui/src/components/subagentProfiles/ProfileEditorPanel.tsx b/webui/src/components/subagentProfiles/ProfileEditorPanel.tsx new file mode 100644 index 0000000..c60e998 --- /dev/null +++ b/webui/src/components/subagentProfiles/ProfileEditorPanel.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { Pause, Play, Save, Trash2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, FixedButton } from '../Button'; +import { FieldBlock, SelectField, TextField, TextareaField } from '../FormControls'; +import type { SubagentProfile, ToolAllowlistGroup } from './profileDraft'; +import { parseAllowlist } from './profileDraft'; + +type ProfileEditorPanelProps = { + draft: SubagentProfile; + groups: ToolAllowlistGroup[]; + idLabel: string; + isExisting: boolean; + memoryNamespaceLabel: string; + nameLabel: string; + onAddAllowlistToken: (token: string) => void; + onChange: (next: SubagentProfile) => void; + onDelete: () => void; + onDisable: () => void; + onEnable: () => void; + onPromptContentChange: (value: string) => void; + onSave: () => void; + onSavePromptFile: () => void; + promptContent: string; + promptMeta: string; + promptPlaceholder: string; + roleLabel: string; + saving: boolean; + statusLabel: string; + toolAllowlistHint: React.ReactNode; + toolAllowlistLabel: string; + maxRetriesLabel: string; + retryBackoffLabel: string; + maxTaskCharsLabel: string; + maxResultCharsLabel: string; +}; + +const ProfileEditorPanel: React.FC = ({ + draft, + groups, + idLabel, + isExisting, + memoryNamespaceLabel, + nameLabel, + onAddAllowlistToken, + onChange, + onDelete, + onDisable, + onEnable, + onPromptContentChange, + onSave, + onSavePromptFile, + promptContent, + promptMeta, + promptPlaceholder, + roleLabel, + saving, + statusLabel, + toolAllowlistHint, + toolAllowlistLabel, + maxRetriesLabel, + retryBackoffLabel, + maxTaskCharsLabel, + maxResultCharsLabel, +}) => { + const { t } = useTranslation(); + const allowlistText = (draft.tool_allowlist || []).join(', '); + + return ( +
+
+ + onChange({ ...draft, agent_id: e.target.value })} + dense + className="w-full disabled:opacity-60" + placeholder="coder" + /> + + + onChange({ ...draft, name: e.target.value })} + dense + className="w-full" + placeholder="Code Agent" + /> + + + onChange({ ...draft, role: e.target.value })} + dense + className="w-full" + placeholder="coding" + /> + + + onChange({ ...draft, status: e.target.value })} + dense + className="w-full" + > + + + + + + onChange({ ...draft, notify_main_policy: e.target.value })} + dense + className="w-full" + > + + + + + + + + + onChange({ ...draft, system_prompt_file: e.target.value })} + dense + className="w-full" + placeholder="agents/coder/AGENT.md" + /> + + + onChange({ ...draft, memory_namespace: e.target.value })} + dense + className="w-full" + placeholder="coder" + /> + + + onChange({ ...draft, tool_allowlist: parseAllowlist(e.target.value) })} + dense + className="w-full" + placeholder="read_file, list_files, memory_search" + /> +
{toolAllowlistHint}
+ {groups.length > 0 ? ( +
+ {groups.map((group) => ( + + ))} +
+ ) : null} +
+ + onPromptContentChange(e.target.value)} + dense + className="w-full min-h-[220px]" + placeholder={promptPlaceholder} + /> +
+ + + +
+
+ + onChange({ ...draft, max_retries: Number(e.target.value) || 0 })} + dense + className="w-full" + /> + + + onChange({ ...draft, retry_backoff_ms: Number(e.target.value) || 0 })} + dense + className="w-full" + /> + + + onChange({ ...draft, max_task_chars: Number(e.target.value) || 0 })} + dense + className="w-full" + /> + + + onChange({ ...draft, max_result_chars: Number(e.target.value) || 0 })} + dense + className="w-full" + /> + +
+
+ + + + + + + + + + + + +
+
+ ); +}; + +export default ProfileEditorPanel; diff --git a/webui/src/components/subagentProfiles/ProfileListPanel.tsx b/webui/src/components/subagentProfiles/ProfileListPanel.tsx new file mode 100644 index 0000000..1d04557 --- /dev/null +++ b/webui/src/components/subagentProfiles/ProfileListPanel.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import ListPanel from '../ListPanel'; +import PanelHeader from '../PanelHeader'; +import SummaryListItem from '../SummaryListItem'; +import type { SubagentProfile } from './profileDraft'; + +type ProfileListPanelProps = { + emptyLabel: string; + items: SubagentProfile[]; + onSelect: (profile: SubagentProfile) => void; + selectedId: string; + title: string; +}; + +const ProfileListPanel: React.FC = ({ + emptyLabel, + items, + onSelect, + selectedId, + title, +}) => { + return ( + + +
+ {items.map((item) => { + const active = selectedId === item.agent_id; + return ( + onSelect(item)} + title={item.agent_id || '-'} + subtitle={`${item.status || 'active'} · ${item.role || '-'} · ${item.memory_namespace || '-'}`} + /> + ); + })} + {items.length === 0 ? ( +
{emptyLabel}
+ ) : null} +
+
+ ); +}; + +export default ProfileListPanel; diff --git a/webui/src/components/subagentProfiles/profileDraft.ts b/webui/src/components/subagentProfiles/profileDraft.ts new file mode 100644 index 0000000..a2cdc18 --- /dev/null +++ b/webui/src/components/subagentProfiles/profileDraft.ts @@ -0,0 +1,63 @@ +export type SubagentProfile = { + agent_id: string; + name?: string; + notify_main_policy?: string; + role?: string; + system_prompt_file?: string; + tool_allowlist?: string[]; + memory_namespace?: string; + max_retries?: number; + retry_backoff_ms?: number; + max_task_chars?: number; + max_result_chars?: number; + status?: 'active' | 'disabled' | string; + created_at?: number; + updated_at?: number; +}; + +export type ToolAllowlistGroup = { + name: string; + description?: string; + aliases?: string[]; + tools?: string[]; +}; + +export const emptyDraft: SubagentProfile = { + agent_id: '', + name: '', + notify_main_policy: 'final_only', + role: '', + system_prompt_file: '', + memory_namespace: '', + status: 'active', + tool_allowlist: [], + max_retries: 0, + retry_backoff_ms: 1000, + max_task_chars: 0, + max_result_chars: 0, +}; + +export function toProfileDraft(profile?: SubagentProfile | null): SubagentProfile { + if (!profile) return emptyDraft; + return { + agent_id: profile.agent_id || '', + name: profile.name || '', + notify_main_policy: profile.notify_main_policy || 'final_only', + role: profile.role || '', + system_prompt_file: profile.system_prompt_file || '', + memory_namespace: profile.memory_namespace || '', + status: (profile.status as string) || 'active', + tool_allowlist: Array.isArray(profile.tool_allowlist) ? profile.tool_allowlist : [], + max_retries: Number(profile.max_retries || 0), + retry_backoff_ms: Number(profile.retry_backoff_ms || 1000), + max_task_chars: Number(profile.max_task_chars || 0), + max_result_chars: Number(profile.max_result_chars || 0), + }; +} + +export function parseAllowlist(text: string): string[] { + return text + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} diff --git a/webui/src/components/subagents/GraphCard.tsx b/webui/src/components/subagents/GraphCard.tsx new file mode 100644 index 0000000..a633f97 --- /dev/null +++ b/webui/src/components/subagents/GraphCard.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { Cpu, Server } from 'lucide-react'; +import type { GraphAccentTone, GraphCardSpec } from './topologyTypes'; + +function graphAccentBackgroundClass(accentTone: GraphAccentTone) { + switch (accentTone) { + case 'success': return 'bg-gradient-to-br from-transparent to-emerald-500'; + case 'danger': return 'bg-gradient-to-br from-transparent to-red-500'; + case 'warning': return 'bg-gradient-to-br from-transparent to-amber-400'; + case 'info': return 'bg-gradient-to-br from-transparent to-sky-400'; + case 'accent': return 'bg-gradient-to-br from-transparent to-violet-400'; + case 'neutral': + default: return 'bg-gradient-to-br from-transparent to-zinc-500'; + } +} + +function graphAccentIconClass(accentTone: GraphAccentTone) { + switch (accentTone) { + case 'success': return 'text-emerald-500'; + case 'danger': return 'topology-icon-danger'; + case 'warning': return 'text-amber-400'; + case 'info': return 'text-sky-400'; + case 'accent': return 'text-violet-400'; + case 'neutral': + default: return 'text-zinc-500'; + } +} + +type GraphCardProps = { + card: GraphCardSpec; + onDragStart: (key: string, event: React.MouseEvent) => void; + onHover: (card: GraphCardSpec, event: React.MouseEvent) => void; + onLeave: () => void; +}; + +const GraphCard: React.FC = ({ + card, + onDragStart, + onHover, + onLeave, +}) => { + const isNode = card.kind === 'node'; + const Icon = isNode ? Server : Cpu; + + return ( + +
onDragStart(card.key, e)} + onClick={(e) => { + if (e.defaultPrevented) return; + card.onClick?.(); + }} + onMouseEnter={(event) => onHover(card, event)} + onMouseMove={(event) => onHover(card, event)} + onMouseLeave={onLeave} + className={`relative w-full h-full rounded-full flex flex-col items-center justify-center gap-1 transition-all duration-300 group ${card.highlighted ? 'scale-[1.05] z-10' : 'hover:scale-[1.02]'}`} + style={{ + cursor: card.clickable ? 'pointer' : 'default', + opacity: card.dimmed ? 0.3 : 1, + }} + > +
+
+
+
+
+
+ +
+
+ +
+ +
+
{card.title}
+
{card.subtitle}
+
+ + {card.online !== undefined ? ( +
+ ) : null} +
+
+ + ); +}; + +export default GraphCard; diff --git a/webui/src/components/subagents/TopologyCanvas.tsx b/webui/src/components/subagents/TopologyCanvas.tsx new file mode 100644 index 0000000..f3b5210 --- /dev/null +++ b/webui/src/components/subagents/TopologyCanvas.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { SpaceParticles } from '../SpaceParticles'; +import type { GraphLineSpec } from './topologyTypes'; + +type TopologyCanvasProps = { + cards: React.ReactNode; + className?: string; + draggedNode: boolean; + height: number; + lines: GraphLineSpec[]; + onMouseDown: React.MouseEventHandler; + onMouseLeave: React.MouseEventHandler; + onMouseMove: React.MouseEventHandler; + onMouseUp: React.MouseEventHandler; + panX: number; + panY: number; + topologyDragging: boolean; + tooltip?: React.ReactNode; + viewportRef: React.RefObject; + width: number; + zoom: number; +}; + +const FLOW_STYLE = ` + @keyframes flow { + from { stroke-dashoffset: 24; } + to { stroke-dashoffset: 0; } + } + .animate-flow { + animation: flow 1s linear infinite; + } + .animate-flow-fast { + animation: flow 0.5s linear infinite; + } +`; + +const TopologyCanvas: React.FC = ({ + cards, + className, + draggedNode, + height, + lines, + onMouseDown, + onMouseLeave, + onMouseMove, + onMouseUp, + panX, + panY, + topologyDragging, + tooltip, + viewportRef, + width, + zoom, +}) => { + return ( +
+ +
+
+ + + {lines.map((line, idx) => ( + line.hidden ? null : ( + + + + + ) + ))} + {cards} + +
+
+ {tooltip} +
+ ); +}; + +export default TopologyCanvas; diff --git a/webui/src/components/subagents/TopologyControls.tsx b/webui/src/components/subagents/TopologyControls.tsx new file mode 100644 index 0000000..90a2d0e --- /dev/null +++ b/webui/src/components/subagents/TopologyControls.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +type TopologyFilter = 'all' | 'running' | 'failed' | 'local' | 'remote'; + +type TopologyControlsProps = { + onClearFocus: () => void; + onFitView: () => void; + onResetZoom: () => void; + onSelectFilter: (filter: TopologyFilter) => void; + onZoomIn: () => void; + onZoomOut: () => void; + runningCount: number; + selectedBranch: string; + t: (key: string, options?: any) => string; + topologyFilter: TopologyFilter; + zoomPercent: number; +}; + +const FILTERS: TopologyFilter[] = ['all', 'running', 'failed', 'local', 'remote']; + +const TopologyControls: React.FC = ({ + onClearFocus, + onFitView, + onResetZoom, + onSelectFilter, + onZoomIn, + onZoomOut, + runningCount, + selectedBranch, + t, + topologyFilter, + zoomPercent, +}) => { + return ( +
+ {FILTERS.map((filter) => ( + + ))} + {selectedBranch ? ( + + ) : null} +
+ + + + +
+
+ {zoomPercent}% · {runningCount} {t('runningTasks')} +
+
+ ); +}; + +export default TopologyControls; diff --git a/webui/src/components/subagents/TopologyTooltip.tsx b/webui/src/components/subagents/TopologyTooltip.tsx new file mode 100644 index 0000000..2def123 --- /dev/null +++ b/webui/src/components/subagents/TopologyTooltip.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import InsetCard from '../InsetCard'; +import type { StreamItem, SubagentTask } from './subagentTypes'; + +type StreamPreview = { + task: SubagentTask | null; + items: StreamItem[]; + loading?: boolean; +}; + +type TopologyTooltipProps = { + agentID?: string; + formatStreamTime: (ts?: number) => string; + meta: string[]; + streamPreview?: StreamPreview; + subtitle: string; + summarizePreviewText: (value?: string, limit?: number) => string; + t: (key: string, options?: any) => string; + title: string; + transportType?: 'local' | 'remote'; + x: number; + y: number; +}; + +const TopologyTooltip: React.FC = ({ + agentID, + formatStreamTime, + meta, + streamPreview, + subtitle, + summarizePreviewText, + t, + title, + transportType, + x, + y, +}) => { + const latestItem = streamPreview?.items?.length ? streamPreview.items[streamPreview.items.length - 1] : null; + + return ( +
+
+
+
{title}
+
+
{subtitle}
+
+ {meta.map((line, idx) => { + if (!line.includes('=')) { + return ( +
+ {line} +
+ ); + } + const [key, ...rest] = line.split('='); + const value = rest.join('='); + return ( +
+ {key} + {value || '-'} +
+ ); + })} +
+ {transportType === 'local' && agentID ? ( +
+
{t('internalStream')}
+ {streamPreview?.loading ? ( +
Loading internal stream...
+ ) : streamPreview?.task ? ( + <> + +
run={streamPreview.task?.id || '-'}
+
+ status={streamPreview.task?.status || '-'} · thread={streamPreview.task?.thread_id || '-'} +
+
+ {latestItem ? ( + +
+
+ {latestItem.kind === 'event' + ? `${latestItem.event_type || 'event'}${latestItem.status ? ` · ${latestItem.status}` : ''}` + : `${latestItem.from_agent || '-'} -> ${latestItem.to_agent || '-'} · ${latestItem.message_type || 'message'}`} +
+
+ {formatStreamTime(latestItem.at)} +
+
+
+ {summarizePreviewText( + latestItem.kind === 'event' ? (latestItem.message || '(no event message)') : (latestItem.content || '(empty message)'), + 520, + )} +
+
+ ) : ( +
No internal stream events yet.
+ )} + + ) : ( +
No persisted run for this agent yet.
+ )} +
+ ) : null} +
+ ); +}; + +export default TopologyTooltip; diff --git a/webui/src/components/subagents/subagentTypes.ts b/webui/src/components/subagents/subagentTypes.ts new file mode 100644 index 0000000..a7411af --- /dev/null +++ b/webui/src/components/subagents/subagentTypes.ts @@ -0,0 +1,98 @@ +export type SubagentTask = { + id: string; + status?: string; + label?: string; + role?: string; + agent_id?: string; + session_key?: string; + memory_ns?: string; + tool_allowlist?: string[]; + max_retries?: number; + retry_count?: number; + retry_backoff?: number; + max_task_chars?: number; + max_result_chars?: number; + created?: number; + updated?: number; + task?: string; + result?: string; + thread_id?: string; + correlation_id?: string; + waiting_for_reply?: boolean; +}; + +export type StreamItem = { + kind?: 'event' | 'message' | string; + at?: number; + run_id?: string; + agent_id?: string; + event_type?: string; + message?: string; + retry_count?: number; + message_id?: string; + thread_id?: string; + from_agent?: string; + to_agent?: string; + reply_to?: string; + correlation_id?: string; + message_type?: string; + content?: string; + status?: string; + requires_reply?: boolean; +}; + +export type RegistrySubagent = { + agent_id?: string; + enabled?: boolean; + type?: string; + transport?: string; + node_id?: string; + parent_agent_id?: string; + managed_by?: string; + notify_main_policy?: string; + display_name?: string; + role?: string; + description?: string; + system_prompt_file?: string; + prompt_file_found?: boolean; + memory_namespace?: string; + tool_allowlist?: string[]; + inherited_tools?: string[]; + effective_tools?: string[]; + tool_visibility?: { + mode?: string; + inherited_tool_count?: number; + effective_tool_count?: number; + }; + routing_keywords?: string[]; +}; + +export type AgentTreeNode = { + agent_id?: string; + display_name?: string; + role?: string; + type?: string; + transport?: string; + managed_by?: string; + node_id?: string; + enabled?: boolean; + children?: AgentTreeNode[]; +}; + +export type NodeTree = { + node_id?: string; + node_name?: string; + online?: boolean; + source?: string; + readonly?: boolean; + root?: { + root?: AgentTreeNode; + }; +}; + +export type StreamPreviewState = { + task: SubagentTask | null; + items: StreamItem[]; + taskID: string; + loading?: boolean; +}; diff --git a/webui/src/components/subagents/topologyGraphBuilder.ts b/webui/src/components/subagents/topologyGraphBuilder.ts new file mode 100644 index 0000000..e683dbc --- /dev/null +++ b/webui/src/components/subagents/topologyGraphBuilder.ts @@ -0,0 +1,377 @@ +import type { TFunction } from 'i18next'; +import type { GraphCardSpec, GraphLineSpec } from './topologyTypes'; +import type { NodeTree, RegistrySubagent, SubagentTask } from './subagentTypes'; +import { + bezierCurve, + formatRuntimeTimestamp, + horizontalBezierCurve, + normalizeTitle, + summarizeTask, + type AgentTaskStats, +} from './topologyUtils'; + +const CARD_WIDTH = 140; +const CARD_HEIGHT = 140; +const CLUSTER_GAP = 60; +const SECTION_GAP = 160; +const MAIN_Y = 96; +const CHILD_START_Y = 320; +const ORIGIN_X = 56; + +type NodeOverrideMap = Record; +type P2PSessionLike = { + node?: string; + status?: string; + retry_count?: number; + last_ready_at?: string; + last_error?: string; +}; +type DispatchLike = { + node?: string; + used_transport?: string; + fallback_from?: string; +}; + +type BuildTopologyGraphParams = { + nodeOverrides: NodeOverrideMap; + nodeP2PTransport?: string; + onOpenAgentStream: (agentID: string, taskID?: string, branch?: string) => void; + parsedNodeTrees: NodeTree[]; + p2pSessionByNode: Record; + recentDispatchByNode: Record; + recentTaskByAgent: Record; + registryItems: RegistrySubagent[]; + selectedTopologyBranch: string; + t: TFunction; + taskStats: Record; + topologyFilter: 'all' | 'running' | 'failed' | 'local' | 'remote'; + topologyZoom: number; +}; + +type RemoteCluster = { + tree: NodeTree; + root?: NodeTree['root'] extends { root?: infer R } ? R : never; + children: NonNullable['root']>['children'] extends infer C ? Extract : never; + width: number; +}; + +function buildLocalFallbackTree(registryItems: RegistrySubagent[]) { + return { + agent_id: 'main', + display_name: 'Main Agent', + role: 'orchestrator', + type: 'router', + transport: 'local', + enabled: true, + children: registryItems + .filter((item) => item.agent_id && item.agent_id !== 'main' && item.managed_by === 'config.json') + .map((item) => ({ + agent_id: item.agent_id, + display_name: item.display_name, + role: item.role, + type: item.type, + transport: item.transport, + enabled: item.enabled, + children: [], + })), + }; +} + +function buildBranchFilters( + localBranch: string, + localBranchStats: { running: number; failed: number }, + remoteTrees: NodeTree[], + topologyFilter: BuildTopologyGraphParams['topologyFilter'], +) { + const branchFilters = new Map(); + branchFilters.set( + localBranch, + topologyFilter === 'all' + || topologyFilter === 'local' + || (topologyFilter === 'running' && localBranchStats.running > 0) + || (topologyFilter === 'failed' && localBranchStats.failed > 0), + ); + remoteTrees.forEach((tree, treeIndex) => { + const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`; + branchFilters.set(branch, topologyFilter === 'all' || topologyFilter === 'remote'); + }); + return branchFilters; +} + +function decorateGraph( + cards: GraphCardSpec[], + remoteClusters: RemoteCluster[], + localChildren: Array<{ agent_id?: string }>, + localBranch: string, + selectedTopologyBranch: string, + nodeOverrides: NodeOverrideMap, + branchFilters: Map, +) { + const highlightedBranch = selectedTopologyBranch.trim(); + const decoratedCards = cards.map((card) => { + const override = nodeOverrides[card.key]; + return { + ...card, + x: override ? override.x : card.x, + y: override ? override.y : card.y, + hidden: branchFilters.get(card.branch) === false, + highlighted: !highlightedBranch || card.branch === highlightedBranch, + dimmed: branchFilters.get(card.branch) === false ? true : !!highlightedBranch && card.branch !== highlightedBranch, + }; + }); + + const getCardPos = (key: string) => { + const card = decoratedCards.find((item) => item.key === key); + return card ? { cx: card.x + CARD_WIDTH / 2, cy: card.y + CARD_HEIGHT / 2 } : null; + }; + + const localMainPos = getCardPos('agent-main'); + const recalculatedLines: GraphLineSpec[] = []; + + localChildren.forEach((child, idx) => { + const childKey = `local-child-${child.agent_id || idx}`; + const childPos = getCardPos(childKey); + if (localMainPos && childPos) { + recalculatedLines.push({ + path: bezierCurve(localMainPos.cx, localMainPos.cy, childPos.cx, childPos.cy), + branch: localBranch, + }); + } + }); + + remoteClusters.forEach((cluster, treeIndex) => { + const branch = `node:${normalizeTitle(cluster.tree.node_id, `remote-${treeIndex}`)}`; + const remoteRootKey = `remote-root-${cluster.tree.node_id || treeIndex}`; + const remoteRootPos = getCardPos(remoteRootKey); + + if (localMainPos && remoteRootPos) { + recalculatedLines.push({ + path: horizontalBezierCurve(localMainPos.cx, localMainPos.cy, remoteRootPos.cx, remoteRootPos.cy), + dashed: true, + branch, + }); + } + + cluster.children.forEach((child, idx) => { + const childKey = `remote-child-${cluster.tree.node_id || treeIndex}-${child.agent_id || idx}`; + const childPos = getCardPos(childKey); + if (remoteRootPos && childPos) { + recalculatedLines.push({ + path: bezierCurve(remoteRootPos.cx, remoteRootPos.cy, childPos.cx, childPos.cy), + branch, + }); + } + }); + }); + + const decoratedLines = recalculatedLines.map((line) => ({ + ...line, + hidden: branchFilters.get(line.branch) === false, + highlighted: !highlightedBranch || line.branch === highlightedBranch, + dimmed: branchFilters.get(line.branch) === false ? true : !!highlightedBranch && line.branch !== highlightedBranch, + })); + + return { cards: decoratedCards, lines: decoratedLines }; +} + +export function buildTopologyGraph({ + nodeOverrides, + nodeP2PTransport, + onOpenAgentStream, + parsedNodeTrees, + p2pSessionByNode, + recentDispatchByNode, + recentTaskByAgent, + registryItems, + selectedTopologyBranch, + t, + taskStats, + topologyFilter, + topologyZoom, +}: BuildTopologyGraphParams) { + const scale = topologyZoom; + const localTree = parsedNodeTrees.find((tree) => normalizeTitle(tree.node_id, '') === 'local') || null; + const remoteTrees = parsedNodeTrees.filter((tree) => normalizeTitle(tree.node_id, '') !== 'local'); + const localRoot = localTree?.root?.root || buildLocalFallbackTree(registryItems); + const localChildren = Array.isArray(localRoot.children) ? localRoot.children : []; + const localBranchWidth = Math.max(CARD_WIDTH, localChildren.length * (CARD_WIDTH + CLUSTER_GAP) - CLUSTER_GAP); + const localMainX = ORIGIN_X + Math.max(0, (localBranchWidth - CARD_WIDTH) / 2); + + const remoteClusters: RemoteCluster[] = remoteTrees.map((tree) => { + const root = tree.root?.root; + const children = Array.isArray(root?.children) ? root.children : []; + return { + tree, + root, + children, + width: Math.max(CARD_WIDTH, children.length * (CARD_WIDTH + CLUSTER_GAP) - CLUSTER_GAP), + }; + }); + + const totalRemoteWidth = remoteClusters.reduce((sum, cluster, idx) => { + return sum + cluster.width + (idx > 0 ? SECTION_GAP : 0); + }, 0); + const width = Math.max(900, ORIGIN_X * 2 + localBranchWidth + (remoteClusters.length > 0 ? SECTION_GAP + totalRemoteWidth : 0)); + const height = CHILD_START_Y + CARD_HEIGHT + 40; + const cards: GraphCardSpec[] = []; + const localBranch = 'local'; + const localBranchStats = { running: 0, failed: 0 }; + + const emptyStats: AgentTaskStats = { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] }; + const localMainStats = taskStats[normalizeTitle(localRoot.agent_id, 'main')] || emptyStats; + const localMainTask = recentTaskByAgent[normalizeTitle(localRoot.agent_id, 'main')]; + const localMainRegistry = registryItems.find((item) => item.agent_id === localRoot.agent_id); + localBranchStats.running += localMainStats.running; + localBranchStats.failed += localMainStats.failed; + + cards.push({ + key: 'agent-main', + branch: localBranch, + agentID: normalizeTitle(localRoot.agent_id, 'main'), + transportType: 'local', + kind: 'agent', + x: localMainX, + y: MAIN_Y, + w: CARD_WIDTH, + h: CARD_HEIGHT, + title: normalizeTitle(localRoot.display_name, 'Main Agent'), + subtitle: `${normalizeTitle(localRoot.agent_id, 'main')} · ${normalizeTitle(localRoot.role, '-')}`, + meta: [ + `children=${localChildren.length + remoteClusters.length}`, + `total=${localMainStats.total} running=${localMainStats.running}`, + `waiting=${localMainStats.waiting} failed=${localMainStats.failed}`, + `notify=${normalizeTitle(localMainRegistry?.notify_main_policy, 'final_only')}`, + `transport=${normalizeTitle(localRoot.transport, 'local')} type=${normalizeTitle(localRoot.type, 'router')}`, + `tools=${normalizeTitle(localMainRegistry?.tool_visibility?.mode, 'allowlist')} visible=${localMainRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${localMainRegistry?.tool_visibility?.inherited_tool_count ?? 0}`, + (localMainRegistry?.inherited_tools || []).length ? `inherits: ${(localMainRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -', + localMainStats.active[0] ? `task: ${localMainStats.active[0].title}` : t('noLiveTasks'), + ], + accentTone: localMainStats.running > 0 ? 'success' : localMainStats.latestStatus === 'failed' ? 'danger' : 'warning', + clickable: true, + scale, + onClick: () => onOpenAgentStream(normalizeTitle(localRoot.agent_id, 'main'), localMainTask?.id || '', localBranch), + }); + + localChildren.forEach((child, idx) => { + const childX = ORIGIN_X + idx * (CARD_WIDTH + CLUSTER_GAP); + const stats = taskStats[normalizeTitle(child.agent_id, '')] || emptyStats; + const task = recentTaskByAgent[normalizeTitle(child.agent_id, '')]; + const childRegistry = registryItems.find((item) => item.agent_id === child.agent_id); + localBranchStats.running += stats.running; + localBranchStats.failed += stats.failed; + cards.push({ + key: `local-child-${child.agent_id || idx}`, + branch: localBranch, + agentID: normalizeTitle(child.agent_id, ''), + transportType: 'local', + kind: 'agent', + x: childX, + y: CHILD_START_Y, + w: CARD_WIDTH, + h: CARD_HEIGHT, + title: normalizeTitle(child.display_name, normalizeTitle(child.agent_id, 'agent')), + subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`, + meta: [ + `total=${stats.total} running=${stats.running}`, + `waiting=${stats.waiting} failed=${stats.failed}`, + `notify=${normalizeTitle(childRegistry?.notify_main_policy, 'final_only')}`, + `transport=${normalizeTitle(child.transport, 'local')} type=${normalizeTitle(child.type, 'worker')}`, + `tools=${normalizeTitle(childRegistry?.tool_visibility?.mode, 'allowlist')} visible=${childRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${childRegistry?.tool_visibility?.inherited_tool_count ?? 0}`, + (childRegistry?.inherited_tools || []).length ? `inherits: ${(childRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -', + stats.active[0] ? `task: ${stats.active[0].title}` : task ? `last: ${summarizeTask(task.task, task.label)}` : t('noLiveTasks'), + ], + accentTone: stats.running > 0 ? 'success' : stats.latestStatus === 'failed' ? 'danger' : 'info', + clickable: true, + scale, + onClick: () => onOpenAgentStream(normalizeTitle(child.agent_id, ''), task?.id || '', localBranch), + }); + }); + + let remoteOffsetX = ORIGIN_X + localBranchWidth + SECTION_GAP; + remoteClusters.forEach((cluster, treeIndex) => { + const { tree, root: treeRoot, children } = cluster; + if (!treeRoot) { + remoteOffsetX += cluster.width + SECTION_GAP; + return; + } + + const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`; + const nodeID = normalizeTitle(tree.node_id, ''); + const p2pSession = p2pSessionByNode[nodeID]; + const recentDispatch = recentDispatchByNode[nodeID]; + const rootX = remoteOffsetX + Math.max(0, (cluster.width - CARD_WIDTH) / 2); + const sessionStatus = normalizeTitle(p2pSession?.status, '').toLowerCase(); + + cards.push({ + key: `remote-root-${tree.node_id || treeIndex}`, + branch, + agentID: normalizeTitle(treeRoot.agent_id, ''), + transportType: 'remote', + kind: 'agent', + x: rootX, + y: MAIN_Y, + w: CARD_WIDTH, + h: CARD_HEIGHT, + title: normalizeTitle(treeRoot.display_name, treeRoot.agent_id || 'main'), + subtitle: `${normalizeTitle(treeRoot.agent_id, '-')} · ${normalizeTitle(treeRoot.role, '-')}`, + meta: [ + `status=${tree.online ? t('online') : t('offline')}`, + `transport=${normalizeTitle(treeRoot.transport, 'node')} type=${normalizeTitle(treeRoot.type, 'router')}`, + `p2p=${normalizeTitle(nodeP2PTransport, 'disabled')} session=${normalizeTitle(p2pSession?.status, 'unknown')}`, + `last_transport=${normalizeTitle(recentDispatch?.used_transport, '-')}${recentDispatch?.fallback_from ? ` fallback=${normalizeTitle(recentDispatch?.fallback_from, '-')}` : ''}`, + `last_ready=${formatRuntimeTimestamp(p2pSession?.last_ready_at)}`, + `retry=${Number(p2pSession?.retry_count || 0)}`, + `${t('error')}=${normalizeTitle(p2pSession?.last_error, '-')}`, + `source=${normalizeTitle(treeRoot.managed_by, tree.source || '-')}`, + t('remoteTasksUnavailable'), + ], + accentTone: !tree.online ? 'neutral' : sessionStatus === 'open' ? 'success' : sessionStatus === 'connecting' ? 'warning' : 'accent', + clickable: true, + scale, + onClick: () => onOpenAgentStream(normalizeTitle(treeRoot.agent_id, ''), '', branch), + }); + + children.forEach((child, idx) => { + const childX = remoteOffsetX + idx * (CARD_WIDTH + CLUSTER_GAP); + cards.push({ + key: `remote-child-${tree.node_id || treeIndex}-${child.agent_id || idx}`, + branch, + agentID: normalizeTitle(child.agent_id, ''), + transportType: 'remote', + kind: 'agent', + x: childX, + y: CHILD_START_Y, + w: CARD_WIDTH, + h: CARD_HEIGHT, + title: normalizeTitle(child.display_name, child.agent_id || 'agent'), + subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`, + meta: [ + `transport=${normalizeTitle(child.transport, 'node')} type=${normalizeTitle(child.type, 'worker')}`, + `p2p=${normalizeTitle(nodeP2PTransport, 'disabled')} session=${normalizeTitle(p2pSession?.status, 'unknown')}`, + `last_transport=${normalizeTitle(recentDispatch?.used_transport, '-')}${recentDispatch?.fallback_from ? ` fallback=${normalizeTitle(recentDispatch?.fallback_from, '-')}` : ''}`, + `last_ready=${formatRuntimeTimestamp(p2pSession?.last_ready_at)}`, + `retry=${Number(p2pSession?.retry_count || 0)}`, + `${t('error')}=${normalizeTitle(p2pSession?.last_error, '-')}`, + `source=${normalizeTitle(child.managed_by, 'remote_webui')}`, + t('remoteTasksUnavailable'), + ], + accentTone: sessionStatus === 'open' ? 'success' : sessionStatus === 'connecting' ? 'warning' : 'accent', + clickable: true, + scale, + onClick: () => onOpenAgentStream(normalizeTitle(child.agent_id, ''), '', branch), + }); + }); + + remoteOffsetX += cluster.width + SECTION_GAP; + }); + + const branchFilters = buildBranchFilters(localBranch, localBranchStats, remoteTrees, topologyFilter); + const decoratedGraph = decorateGraph(cards, remoteClusters, localChildren, localBranch, selectedTopologyBranch, nodeOverrides, branchFilters); + + return { + width, + height, + cards: decoratedGraph.cards, + lines: decoratedGraph.lines, + }; +} diff --git a/webui/src/components/subagents/topologyTypes.ts b/webui/src/components/subagents/topologyTypes.ts new file mode 100644 index 0000000..973a9de --- /dev/null +++ b/webui/src/components/subagents/topologyTypes.ts @@ -0,0 +1,33 @@ +export type GraphAccentTone = 'success' | 'danger' | 'warning' | 'info' | 'accent' | 'neutral'; + +export type GraphCardSpec = { + key: string; + branch: string; + agentID?: string; + transportType?: 'local' | 'remote'; + x: number; + y: number; + w: number; + h: number; + kind: 'node' | 'agent'; + title: string; + subtitle: string; + meta: string[]; + accentTone: GraphAccentTone; + online?: boolean; + clickable?: boolean; + highlighted?: boolean; + dimmed?: boolean; + hidden?: boolean; + scale: number; + onClick?: () => void; +}; + +export type GraphLineSpec = { + path: string; + dashed?: boolean; + branch: string; + highlighted?: boolean; + dimmed?: boolean; + hidden?: boolean; +}; diff --git a/webui/src/components/subagents/topologyUtils.ts b/webui/src/components/subagents/topologyUtils.ts new file mode 100644 index 0000000..edc1d7d --- /dev/null +++ b/webui/src/components/subagents/topologyUtils.ts @@ -0,0 +1,109 @@ +export type AgentTaskStats = { + total: number; + running: number; + failed: number; + waiting: number; + latestStatus: string; + latestUpdated: number; + active: Array<{ id: string; status: string; title: string }>; +}; + +type TaskLike = { + id: string; + agent_id?: string; + status?: string; + label?: string; + created?: number; + updated?: number; + task?: string; + waiting_for_reply?: boolean; +}; + +export function normalizeTitle(value?: string, fallback = '-'): string { + const trimmed = `${value || ''}`.trim(); + return trimmed || fallback; +} + +export function summarizeTask(task?: string, label?: string): string { + const text = normalizeTitle(label || task, '-'); + return text.length > 52 ? `${text.slice(0, 49)}...` : text; +} + +export function formatStreamTime(ts?: number): string { + if (!ts) return '--:--:--'; + return new Date(ts).toLocaleTimeString([], { hour12: false }); +} + +export function formatRuntimeTimestamp(value?: string): string { + const raw = `${value || ''}`.trim(); + if (!raw || raw === '0001-01-01T00:00:00Z') return '-'; + const ts = Date.parse(raw); + if (Number.isNaN(ts)) return raw; + return new Date(ts).toLocaleString(); +} + +export function summarizePreviewText(value?: string, limit = 180): string { + const compact = `${value || ''}`.replace(/\s+/g, ' ').trim(); + if (!compact) return '(empty)'; + return compact.length > limit ? `${compact.slice(0, limit - 3)}...` : compact; +} + +export function tokenFromQuery(q: string): string { + const raw = String(q || '').trim(); + if (!raw) return ''; + const search = raw.startsWith('?') ? raw.slice(1) : raw; + const params = new URLSearchParams(search); + return params.get('token') || ''; +} + +export function bezierCurve(x1: number, y1: number, x2: number, y2: number): string { + const offset = Math.max(Math.abs(y2 - y1) * 0.5, 60); + return `M ${x1} ${y1} C ${x1} ${y1 + offset} ${x2} ${y2 - offset} ${x2} ${y2}`; +} + +export function horizontalBezierCurve(x1: number, y1: number, x2: number, y2: number): string { + const offset = Math.max(Math.abs(x2 - x1) * 0.5, 60); + return `M ${x1} ${y1} C ${x1 + offset} ${y1} ${x2 - offset} ${y2} ${x2} ${y2}`; +} + +export function buildTaskStats(tasks: TaskLike[]): Record { + return tasks.reduce>((acc, task) => { + const agentID = normalizeTitle(task.agent_id, ''); + if (!agentID) return acc; + if (!acc[agentID]) { + acc[agentID] = { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] }; + } + const item = acc[agentID]; + item.total += 1; + if (task.status === 'running') item.running += 1; + if (task.waiting_for_reply) item.waiting += 1; + const updatedAt = Math.max(task.updated || 0, task.created || 0); + if (updatedAt >= item.latestUpdated) { + item.latestUpdated = updatedAt; + item.latestStatus = normalizeTitle(task.status, ''); + item.failed = task.status === 'failed' ? 1 : 0; + } + if (task.status === 'running' || task.waiting_for_reply) { + item.active.push({ + id: task.id, + status: task.status || '-', + title: summarizeTask(task.task, task.label), + }); + } + return acc; + }, {}); +} + +export function getTopologyTooltipPosition(clientX: number, clientY: number, tooltipWidth = 360, tooltipHeight = 420) { + let x = clientX + 14; + let y = clientY + 14; + + if (x + tooltipWidth > window.innerWidth) { + x = clientX - tooltipWidth - 14; + } + if (y + tooltipHeight > window.innerHeight) { + y = clientY - tooltipHeight - 14; + } + + return { x, y }; +} diff --git a/webui/src/components/subagents/useSubagentRuntimeData.ts b/webui/src/components/subagents/useSubagentRuntimeData.ts new file mode 100644 index 0000000..a79f919 --- /dev/null +++ b/webui/src/components/subagents/useSubagentRuntimeData.ts @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { RegistrySubagent, StreamPreviewState, SubagentTask } from './subagentTypes'; +import { buildTaskStats, normalizeTitle, tokenFromQuery } from './topologyUtils'; + +type UseSubagentRuntimeDataParams = { + previewAgentID: string; + q: string; + subagentRegistryItems: unknown[]; + subagentRuntimeItems: unknown[]; +}; + +export function useSubagentRuntimeData({ + previewAgentID, + q, + subagentRegistryItems, + subagentRuntimeItems, +}: UseSubagentRuntimeDataParams) { + const [items, setItems] = useState([]); + const [registryItems, setRegistryItems] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [selectedAgentID, setSelectedAgentID] = useState(''); + const [streamPreviewByAgent, setStreamPreviewByAgent] = useState>({}); + const streamPreviewLoadingRef = useRef>({}); + + const apiPath = '/webui/api/subagents_runtime'; + const withAction = (action: string) => `${apiPath}${q}${q ? '&' : '?'}action=${encodeURIComponent(action)}`; + const runtimeItems = Array.isArray(subagentRuntimeItems) ? (subagentRuntimeItems as SubagentTask[]) : []; + const registrySourceItems = Array.isArray(subagentRegistryItems) ? (subagentRegistryItems as RegistrySubagent[]) : []; + + const refresh = async () => { + try { + if (runtimeItems.length > 0 || registrySourceItems.length > 0) { + const arr = runtimeItems; + const registry = registrySourceItems; + setItems(arr); + setRegistryItems(registry); + if (registry.length === 0) { + setSelectedAgentID(''); + setSelectedId(''); + } else { + const nextAgentID = selectedAgentID && registry.find((item: RegistrySubagent) => item.agent_id === selectedAgentID) + ? selectedAgentID + : (registry[0]?.agent_id || ''); + setSelectedAgentID(nextAgentID); + const nextTask = arr.find((item: SubagentTask) => item.agent_id === nextAgentID); + setSelectedId(nextTask?.id || ''); + } + return; + } + + const [tasksRes, registryRes] = await Promise.all([ + fetch(withAction('list')), + fetch(withAction('registry')), + ]); + if (!tasksRes.ok) throw new Error(await tasksRes.text()); + if (!registryRes.ok) throw new Error(await registryRes.text()); + + const tasksJson = await tasksRes.json(); + const registryJson = await registryRes.json(); + const arr = Array.isArray(tasksJson?.result?.items) ? tasksJson.result.items : []; + const registry = Array.isArray(registryJson?.result?.items) ? registryJson.result.items : []; + + setItems(arr); + setRegistryItems(registry); + if (registry.length === 0) { + setSelectedAgentID(''); + setSelectedId(''); + } else { + const nextAgentID = selectedAgentID && registry.find((item: RegistrySubagent) => item.agent_id === selectedAgentID) + ? selectedAgentID + : (registry[0]?.agent_id || ''); + setSelectedAgentID(nextAgentID); + const nextTask = arr.find((item: SubagentTask) => item.agent_id === nextAgentID); + setSelectedId(nextTask?.id || ''); + } + } catch { + setItems([ + { id: 'task-1', status: 'running', agent_id: 'worker-1', role: 'worker', task: 'Process data stream', created: Date.now() }, + ]); + } + }; + + useEffect(() => { + refresh().catch(() => {}); + }, [q, selectedAgentID, runtimeItems, registrySourceItems]); + + const selected = useMemo(() => items.find((item) => item.id === selectedId) || null, [items, selectedId]); + const taskStats = useMemo(() => buildTaskStats(items), [items]); + const recentTaskByAgent = useMemo(() => { + return items.reduce>((acc, task) => { + const agentID = normalizeTitle(task.agent_id, ''); + if (!agentID) return acc; + const existing = acc[agentID]; + const currentScore = Math.max(task.updated || 0, task.created || 0); + const existingScore = existing ? Math.max(existing.updated || 0, existing.created || 0) : -1; + if (!existing || currentScore > existingScore) { + acc[agentID] = task; + } + return acc; + }, {}); + }, [items]); + + useEffect(() => { + const selectedTaskID = String(selected?.id || '').trim(); + const previewTask = previewAgentID ? recentTaskByAgent[previewAgentID] || null : null; + const previewTaskID = String(previewTask?.id || '').trim(); + + if (!previewAgentID) { + return; + } + + setStreamPreviewByAgent((prev) => ({ + ...prev, + [previewAgentID]: { + task: previewTask, + items: prev[previewAgentID]?.items || [], + taskID: previewTaskID, + loading: !!previewTaskID, + }, + })); + + if (!selectedTaskID && !previewTaskID) return; + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = new URL(`${proto}//${window.location.host}/webui/api/subagents_runtime/live`); + if (tokenFromQuery(q)) url.searchParams.set('token', tokenFromQuery(q)); + if (selectedTaskID) url.searchParams.set('task_id', selectedTaskID); + if (previewTaskID) url.searchParams.set('preview_task_id', previewTaskID); + + const ws = new WebSocket(url.toString()); + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + const payload = msg?.payload || {}; + if (previewAgentID && payload.preview) { + setStreamPreviewByAgent((prev) => ({ + ...prev, + [previewAgentID]: { + task: payload.preview.task || previewTask, + items: Array.isArray(payload.preview.items) ? payload.preview.items : [], + taskID: previewTaskID, + loading: false, + }, + })); + } + } catch (err) { + console.error(err); + } + }; + ws.onerror = () => { + if (previewAgentID) { + setStreamPreviewByAgent((prev) => ({ + ...prev, + [previewAgentID]: { + task: previewTask, + items: prev[previewAgentID]?.items || [], + taskID: previewTaskID, + loading: false, + }, + })); + } + }; + return () => { + ws.close(); + }; + }, [previewAgentID, q, recentTaskByAgent, selected?.id]); + + return { + items, + recentTaskByAgent, + refresh, + registryItems, + selected, + selectedAgentID, + selectedId, + setSelectedAgentID, + setSelectedId, + streamPreviewByAgent, + taskStats, + }; +} diff --git a/webui/src/components/subagents/useTopologyViewport.ts b/webui/src/components/subagents/useTopologyViewport.ts new file mode 100644 index 0000000..acdf5f0 --- /dev/null +++ b/webui/src/components/subagents/useTopologyViewport.ts @@ -0,0 +1,214 @@ +import { useEffect, useRef, useState } from 'react'; + +type NodePosition = { + key: string; + x: number; + y: number; +}; + +type UseTopologyViewportParams = { + clearTooltip: () => void; +}; + +type TopologyDragRefState = { + active: boolean; + startX: number; + startY: number; + panX: number; + panY: number; +}; + +type NodeDragRefState = { + startX: number; + startY: number; + initialNodeX: number; + initialNodeY: number; +}; + +export function useTopologyViewport({ + clearTooltip, +}: UseTopologyViewportParams) { + const [topologyZoom, setTopologyZoom] = useState(0.9); + const [topologyPan, setTopologyPan] = useState({ x: 0, y: 0 }); + const [nodeOverrides, setNodeOverrides] = useState>({}); + const [draggedNode, setDraggedNode] = useState(null); + const [topologyDragging, setTopologyDragging] = useState(false); + + const topologyViewportRef = useRef(null); + const topologyDragRef = useRef({ + active: false, + startX: 0, + startY: 0, + panX: 0, + panY: 0, + }); + const nodeDragRef = useRef({ + startX: 0, + startY: 0, + initialNodeX: 0, + initialNodeY: 0, + }); + + const fitView = (width: number, height: number) => { + const viewport = topologyViewportRef.current; + if (!viewport || !width) return; + const availableW = viewport.clientWidth; + const availableH = viewport.clientHeight; + const fitted = Math.min(1.15, Math.max(0.2, (availableW - 48) / width)); + + setTopologyZoom(fitted); + setTopologyPan({ + x: (availableW - width * fitted) / 2, + y: Math.max(24, (availableH - height * fitted) / 2), + }); + }; + + useEffect(() => { + const viewport = topologyViewportRef.current; + if (!viewport) return; + + const handleWheel = (event: WheelEvent) => { + if (!(event.ctrlKey || event.metaKey)) { + return; + } + event.preventDefault(); + + const rect = viewport.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + setTopologyZoom((prevZoom) => { + const delta = -event.deltaY * 0.002; + const newZoom = Math.min(Math.max(0.1, prevZoom * (1 + delta)), 4); + + setTopologyPan((prevPan) => { + const scaleRatio = newZoom / prevZoom; + return { + x: mouseX - (mouseX - prevPan.x) * scaleRatio, + y: mouseY - (mouseY - prevPan.y) * scaleRatio, + }; + }); + + return newZoom; + }); + }; + + viewport.addEventListener('wheel', handleWheel, { passive: false }); + return () => viewport.removeEventListener('wheel', handleWheel); + }, []); + + const handleNodeDragStart = ( + key: string, + event: React.MouseEvent, + resolveNodePosition: (cardKey: string) => NodePosition | null, + ) => { + if (event.button !== 0) return; + event.stopPropagation(); + + const card = resolveNodePosition(key); + if (!card) return; + + setDraggedNode(key); + nodeDragRef.current = { + startX: event.clientX, + startY: event.clientY, + initialNodeX: card.x, + initialNodeY: card.y, + }; + clearTooltip(); + }; + + const startTopologyDrag = (event: React.MouseEvent) => { + if (event.button !== 0) return; + topologyDragRef.current = { + active: true, + startX: event.clientX, + startY: event.clientY, + panX: topologyPan.x, + panY: topologyPan.y, + }; + setTopologyDragging(true); + clearTooltip(); + }; + + const moveTopologyDrag = (event: React.MouseEvent) => { + if (draggedNode) { + const deltaX = (event.clientX - nodeDragRef.current.startX) / topologyZoom; + const deltaY = (event.clientY - nodeDragRef.current.startY) / topologyZoom; + + setNodeOverrides((prev) => ({ + ...prev, + [draggedNode]: { + x: nodeDragRef.current.initialNodeX + deltaX, + y: nodeDragRef.current.initialNodeY + deltaY, + }, + })); + return; + } + + if (!topologyDragRef.current.active) return; + const deltaX = event.clientX - topologyDragRef.current.startX; + const deltaY = event.clientY - topologyDragRef.current.startY; + setTopologyPan({ + x: topologyDragRef.current.panX + deltaX, + y: topologyDragRef.current.panY + deltaY, + }); + }; + + const stopTopologyDrag = () => { + if (draggedNode) { + setDraggedNode(null); + } + if (topologyDragRef.current.active) { + topologyDragRef.current.active = false; + setTopologyDragging(false); + } + }; + + const zoomTopologyAroundCenter = (newZoom: number) => { + const viewport = topologyViewportRef.current; + if (viewport) { + const rect = viewport.getBoundingClientRect(); + const mouseX = rect.width / 2; + const mouseY = rect.height / 2; + const scaleRatio = newZoom / topologyZoom; + setTopologyPan((prev) => ({ + x: mouseX - (mouseX - prev.x) * scaleRatio, + y: mouseY - (mouseY - prev.y) * scaleRatio, + })); + } + setTopologyZoom(newZoom); + }; + + const handleTopologyZoomOut = () => { + const newZoom = Math.max(0.1, Number((topologyZoom - 0.1).toFixed(2))); + zoomTopologyAroundCenter(newZoom); + }; + + const handleTopologyResetZoom = () => { + zoomTopologyAroundCenter(1); + }; + + const handleTopologyZoomIn = () => { + const newZoom = Math.min(4, Number((topologyZoom + 0.1).toFixed(2))); + zoomTopologyAroundCenter(newZoom); + }; + + return { + draggedNode, + handleNodeDragStart, + handleTopologyResetZoom, + handleTopologyZoomIn, + handleTopologyZoomOut, + fitView, + moveTopologyDrag, + nodeOverrides, + setNodeOverrides, + startTopologyDrag, + stopTopologyDrag, + topologyDragging, + topologyPan, + topologyViewportRef, + topologyZoom, + }; +} diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 3cb978c..a6d78ff 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -392,8 +392,9 @@ const resources = { providersSelectProvider: 'select provider', providersClientSecret: 'client secret', providersClientSecretHelp: 'Only needed by providers that require an explicit client secret.', - providersPriority: 'priority', - providersPriorityHelp: 'In hybrid mode, choose whether API key or OAuth is tried first.', + providersNetworkProxy: 'network proxy', + providersNetworkProxyHelp: 'Optional. If set, this OAuth account will use the proxy for login, refresh, model fetch, and requests.', + providersNetworkProxyPlaceholder: 'http://127.0.0.1:7890', providersCredentialFiles: 'credential files', providersCredentialFilesHelp: 'Managed automatically after login; can also be edited manually.', providersCooldownSec: 'cooldown sec', @@ -538,7 +539,7 @@ const resources = { configHotFieldsFull: 'Hot-reload fields (full)', configTopLevel: 'Top Level', configProxies: 'Proxies', - configNewProviderName: 'new provider name', + configNewProviderName: 'provider name, e.g. openai', configNoCustomProviders: 'No custom providers yet.', configMCPServers: 'MCP Servers', configNewMCPServerName: 'new MCP server name', @@ -688,7 +689,7 @@ const resources = { channels: 'Channels', cron: 'Cron', workspace: 'Workspace', - proxy_fallbacks: 'Proxy Fallbacks', + proxy_fallbacks: 'Model Fallbacks', heartbeat: 'Heartbeat', every_sec: 'Interval (Seconds)', ack_max_chars: 'Ack Max Chars', @@ -1164,8 +1165,9 @@ const resources = { providersSelectProvider: '选择服务商', providersClientSecret: 'Client Secret', providersClientSecretHelp: '只有部分 provider 需要显式填写 client secret。', - providersPriority: '优先级', - providersPriorityHelp: '在 hybrid 模式下,选择先尝试 API key 还是先尝试 OAuth。', + providersNetworkProxy: '网络代理', + providersNetworkProxyHelp: '可选。填写后,这个 OAuth 账号的登录、刷新、拉模型和后续请求都会走这个代理。', + providersNetworkProxyPlaceholder: 'http://127.0.0.1:7890', providersCredentialFiles: '凭证文件', providersCredentialFilesHelp: '登录后会自动维护,也可以手工编辑。', providersCooldownSec: '冷却秒数', @@ -1310,7 +1312,7 @@ const resources = { configHotFieldsFull: '热更新字段(完整)', configTopLevel: '顶层分类', configProxies: '代理配置', - configNewProviderName: '新 provider 名称', + configNewProviderName: 'provider 名称,例如 openai', configNoCustomProviders: '暂无自定义 provider。', configMCPServers: 'MCP 服务', configNewMCPServerName: '新的 MCP 服务名', @@ -1460,7 +1462,7 @@ const resources = { channels: '通道', cron: '定时任务', workspace: '工作目录', - proxy_fallbacks: '代理回退链', + proxy_fallbacks: '模型回退链', heartbeat: '心跳', every_sec: '间隔(秒)', ack_max_chars: '确认最大字符数', diff --git a/webui/src/index.css b/webui/src/index.css index 76af28d..446c33e 100644 --- a/webui/src/index.css +++ b/webui/src/index.css @@ -1032,12 +1032,14 @@ html.theme-dark .brand-button { appearance: none; -webkit-appearance: none; -moz-appearance: none; + color-scheme: light; cursor: pointer; padding-right: 2.75rem; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6.5L8 10L12 6.5' stroke='%2364758b' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.95rem center; background-size: 16px 16px; + background-clip: padding-box; } .ui-select:hover { @@ -1082,11 +1084,13 @@ html.theme-dark .ui-input, html.theme-dark .ui-textarea, html.theme-dark .ui-select { border-color: var(--color-zinc-700); - background: rgb(9 16 28 / 0.6); + background-color: rgb(9 16 28 / 0.6); color: rgb(226 232 240 / 0.96); } html.theme-dark .ui-select { + color-scheme: dark; + background-color: rgb(9 16 28 / 0.92); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6.5L8 10L12 6.5' stroke='%2390a4bc' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } @@ -1134,15 +1138,25 @@ html.theme-dark .ui-checkbox:focus-visible { } .ui-select option { - background: rgb(255 255 255 / 0.98); + background: rgb(255 255 255); color: rgb(51 65 85 / 0.98); } +.ui-select optgroup { + background: rgb(255 255 255); + color: rgb(71 85 105 / 0.96); +} + html.theme-dark .ui-select option { - background: rgb(12 20 34 / 0.98); + background: rgb(12 20 34); color: rgb(226 232 240 / 0.96); } +html.theme-dark .ui-select optgroup { + background: rgb(12 20 34); + color: rgb(148 163 184 / 0.96); +} + .ui-soft-panel { border: 1px solid var(--color-zinc-800); border-radius: var(--radius-subtle); diff --git a/webui/src/pages/ChannelSettings.tsx b/webui/src/pages/ChannelSettings.tsx index 13e3e69..79a11c7 100644 --- a/webui/src/pages/ChannelSettings.tsx +++ b/webui/src/pages/ChannelSettings.tsx @@ -1,31 +1,24 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { Check, KeyRound, ListFilter, LogOut, QrCode, RefreshCw, ShieldCheck, Smartphone, Users, Wifi, WifiOff } from 'lucide-react'; +import { RefreshCw, Save } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; -import { Button, FixedButton } from '../components/Button'; -import { CheckboxField, FieldBlock, TextField, TextareaField } from '../components/FormControls'; - -type ChannelKey = 'telegram' | 'whatsapp' | 'discord' | 'feishu' | 'qq' | 'dingtalk' | 'maixcam'; - -type ChannelField = - | { key: string; type: 'text' | 'password' | 'number'; placeholder?: string } - | { key: string; type: 'boolean' } - | { key: string; type: 'list'; placeholder?: string }; - -type ChannelDefinition = { - id: ChannelKey; - titleKey: string; - hintKey: string; - sections: Array<{ - id: string; - titleKey: string; - hintKey: string; - fields: ChannelField[]; - columns?: 1 | 2; - }>; -}; +import { FixedButton } from '../components/Button'; +import ChannelSectionCard from '../components/channel/ChannelSectionCard'; +import ChannelFieldRenderer from '../components/channel/ChannelFieldRenderer'; +import { + channelDefinitions, + getChannelFieldDescription, + getChannelSectionIcon, + parseChannelList, +} from '../components/channel/channelSchema'; +import WhatsAppQRCodePanel from '../components/channel/WhatsAppQRCodePanel'; +import WhatsAppStatusPanel from '../components/channel/WhatsAppStatusPanel'; +import { CheckboxField } from '../components/FormControls'; +import PageHeader from '../components/PageHeader'; +import type { ChannelKey } from '../components/channel/channelSchema'; +import { cloneJSON } from '../utils/object'; type WhatsAppStatusPayload = { ok?: boolean; @@ -59,344 +52,6 @@ type WhatsAppStatusPayload = { }; }; -const channelDefinitions: Record = { - telegram: { - id: 'telegram', - titleKey: 'telegram', - hintKey: 'telegramChannelHint', - sections: [ - { - id: 'connection', - titleKey: 'channelSectionConnection', - hintKey: 'channelSectionConnectionHint', - columns: 2, - fields: [ - { key: 'enabled', type: 'boolean' }, - { key: 'token', type: 'password' }, - { key: 'streaming', type: 'boolean' }, - ], - }, - { - id: 'access', - titleKey: 'channelSectionAccess', - hintKey: 'channelSectionAccessHint', - columns: 2, - fields: [ - { key: 'allow_from', type: 'list', placeholder: '123456789' }, - { key: 'allow_chats', type: 'list', placeholder: 'telegram:123456789' }, - ], - }, - { - id: 'groups', - titleKey: 'channelSectionGroupPolicy', - hintKey: 'channelSectionGroupPolicyHint', - columns: 2, - fields: [ - { key: 'enable_groups', type: 'boolean' }, - { key: 'require_mention_in_groups', type: 'boolean' }, - ], - }, - ], - }, - whatsapp: { - id: 'whatsapp', - titleKey: 'whatsappBridge', - hintKey: 'whatsappBridgeHint', - sections: [ - { - id: 'connection', - titleKey: 'channelSectionConnection', - hintKey: 'channelSectionConnectionHint', - columns: 2, - fields: [ - { key: 'enabled', type: 'boolean' }, - { key: 'bridge_url', type: 'text' }, - ], - }, - { - id: 'access', - titleKey: 'channelSectionAccess', - hintKey: 'channelSectionAccessHint', - columns: 1, - fields: [ - { key: 'allow_from', type: 'list', placeholder: '8613012345678@s.whatsapp.net' }, - ], - }, - { - id: 'groups', - titleKey: 'channelSectionGroupPolicy', - hintKey: 'channelSectionGroupPolicyHint', - columns: 2, - fields: [ - { key: 'enable_groups', type: 'boolean' }, - { key: 'require_mention_in_groups', type: 'boolean' }, - ], - }, - ], - }, - discord: { - id: 'discord', - titleKey: 'discord', - hintKey: 'discordChannelHint', - sections: [ - { - id: 'connection', - titleKey: 'channelSectionConnection', - hintKey: 'channelSectionConnectionHint', - columns: 2, - fields: [ - { key: 'enabled', type: 'boolean' }, - { key: 'token', type: 'password' }, - ], - }, - { - id: 'access', - titleKey: 'channelSectionAccess', - hintKey: 'channelSectionAccessHint', - columns: 1, - fields: [ - { key: 'allow_from', type: 'list', placeholder: 'discord-user-id' }, - ], - }, - ], - }, - feishu: { - id: 'feishu', - titleKey: 'feishu', - hintKey: 'feishuChannelHint', - sections: [ - { - id: 'connection', - titleKey: 'channelSectionConnection', - hintKey: 'channelSectionConnectionHint', - columns: 2, - fields: [ - { key: 'enabled', type: 'boolean' }, - { key: 'app_id', type: 'text' }, - { key: 'app_secret', type: 'password' }, - { key: 'encrypt_key', type: 'password' }, - { key: 'verification_token', type: 'password' }, - ], - }, - { - id: 'access', - titleKey: 'channelSectionAccess', - hintKey: 'channelSectionAccessHint', - columns: 2, - fields: [ - { key: 'allow_from', type: 'list' }, - { key: 'allow_chats', type: 'list' }, - ], - }, - { - id: 'groups', - titleKey: 'channelSectionGroupPolicy', - hintKey: 'channelSectionGroupPolicyHint', - columns: 2, - fields: [ - { key: 'enable_groups', type: 'boolean' }, - { key: 'require_mention_in_groups', type: 'boolean' }, - ], - }, - ], - }, - qq: { - id: 'qq', - titleKey: 'qq', - hintKey: 'qqChannelHint', - sections: [ - { - id: 'connection', - titleKey: 'channelSectionConnection', - hintKey: 'channelSectionConnectionHint', - columns: 2, - fields: [ - { key: 'enabled', type: 'boolean' }, - { key: 'app_id', type: 'text' }, - { key: 'app_secret', type: 'password' }, - ], - }, - { - id: 'access', - titleKey: 'channelSectionAccess', - hintKey: 'channelSectionAccessHint', - columns: 1, - fields: [ - { key: 'allow_from', type: 'list' }, - ], - }, - ], - }, - dingtalk: { - id: 'dingtalk', - titleKey: 'dingtalk', - hintKey: 'dingtalkChannelHint', - sections: [ - { - id: 'connection', - titleKey: 'channelSectionConnection', - hintKey: 'channelSectionConnectionHint', - columns: 2, - fields: [ - { key: 'enabled', type: 'boolean' }, - { key: 'client_id', type: 'text' }, - { key: 'client_secret', type: 'password' }, - ], - }, - { - id: 'access', - titleKey: 'channelSectionAccess', - hintKey: 'channelSectionAccessHint', - columns: 1, - fields: [ - { key: 'allow_from', type: 'list' }, - ], - }, - ], - }, - maixcam: { - id: 'maixcam', - titleKey: 'maixcam', - hintKey: 'maixcamChannelHint', - sections: [ - { - id: 'network', - titleKey: 'channelSectionNetwork', - hintKey: 'channelSectionNetworkHint', - columns: 2, - fields: [ - { key: 'enabled', type: 'boolean' }, - { key: 'host', type: 'text' }, - { key: 'port', type: 'number' }, - ], - }, - { - id: 'access', - titleKey: 'channelSectionAccess', - hintKey: 'channelSectionAccessHint', - columns: 1, - fields: [ - { key: 'allow_from', type: 'list' }, - ], - }, - ], - }, -}; - -function clone(value: T): T { - return JSON.parse(JSON.stringify(value)); -} - -function formatList(value: unknown) { - if (!Array.isArray(value)) return ''; - return value.map((item) => String(item ?? '')).join('\n'); -} - -function parseList(text: string) { - return String(text || '') - .split('\n') - .map((line) => line.split(',')) - .flat() - .map((item) => item.trim()) - .filter(Boolean); -} - -function getWhatsAppFieldDescription(t: (key: string) => string, fieldKey: string) { - switch (fieldKey) { - case 'enabled': - return t('whatsappFieldEnabledHint'); - case 'bridge_url': - return t('whatsappFieldBridgeURLHint'); - case 'allow_from': - return t('whatsappFieldAllowFromHint'); - case 'enable_groups': - return t('whatsappFieldEnableGroupsHint'); - case 'require_mention_in_groups': - return t('whatsappFieldRequireMentionHint'); - default: - return ''; - } -} - -function getWhatsAppBooleanIcon(fieldKey: string) { - switch (fieldKey) { - case 'enabled': - return Wifi; - case 'enable_groups': - return Users; - case 'require_mention_in_groups': - return ShieldCheck; - default: - return Check; - } -} - -function getSectionIcon(sectionID: string) { - switch (sectionID) { - case 'connection': - return KeyRound; - case 'access': - return ListFilter; - case 'groups': - return Users; - case 'network': - return Smartphone; - default: - return Check; - } -} - -function getChannelFieldDescription(t: (key: string) => string, channelKey: ChannelKey, fieldKey: string) { - if (channelKey === 'whatsapp') return getWhatsAppFieldDescription(t, fieldKey); - const map: Partial>>> = { - telegram: { - enabled: 'channelFieldTelegramEnabledHint', - token: 'channelFieldTelegramTokenHint', - streaming: 'channelFieldTelegramStreamingHint', - allow_from: 'channelFieldTelegramAllowFromHint', - allow_chats: 'channelFieldTelegramAllowChatsHint', - enable_groups: 'channelFieldEnableGroupsHint', - require_mention_in_groups: 'channelFieldRequireMentionHint', - }, - discord: { - enabled: 'channelFieldDiscordEnabledHint', - token: 'channelFieldDiscordTokenHint', - allow_from: 'channelFieldDiscordAllowFromHint', - }, - feishu: { - enabled: 'channelFieldFeishuEnabledHint', - app_id: 'channelFieldFeishuAppIDHint', - app_secret: 'channelFieldFeishuAppSecretHint', - encrypt_key: 'channelFieldFeishuEncryptKeyHint', - verification_token: 'channelFieldFeishuVerificationTokenHint', - allow_from: 'channelFieldFeishuAllowFromHint', - allow_chats: 'channelFieldFeishuAllowChatsHint', - enable_groups: 'channelFieldEnableGroupsHint', - require_mention_in_groups: 'channelFieldRequireMentionHint', - }, - qq: { - enabled: 'channelFieldQQEnabledHint', - app_id: 'channelFieldQQAppIDHint', - app_secret: 'channelFieldQQAppSecretHint', - allow_from: 'channelFieldQQAllowFromHint', - }, - dingtalk: { - enabled: 'channelFieldDingTalkEnabledHint', - client_id: 'channelFieldDingTalkClientIDHint', - client_secret: 'channelFieldDingTalkClientSecretHint', - allow_from: 'channelFieldDingTalkAllowFromHint', - }, - maixcam: { - enabled: 'channelFieldMaixCamEnabledHint', - host: 'channelFieldMaixCamHostHint', - port: 'channelFieldMaixCamPortHint', - allow_from: 'channelFieldMaixCamAllowFromHint', - }, - }; - const key = map[channelKey]?.[fieldKey]; - return key ? t(key) : ''; -} - const ChannelSettings: React.FC = () => { const { channelId } = useParams(); const navigate = useNavigate(); @@ -424,7 +79,7 @@ const ChannelSettings: React.FC = () => { navigate(`/channels/${fallbackChannel}`, { replace: true }); return; } - const next = clone(((cfg as any)?.channels?.[definition.id] || {}) as Record); + const next = cloneJSON(((cfg as any)?.channels?.[definition.id] || {}) as Record); setDraft(next); }, [availableChannelKeys, cfg, definition, fallbackChannel, key, navigate]); @@ -459,11 +114,11 @@ const ChannelSettings: React.FC = () => { const saveChannel = async () => { setSaving(true); try { - const nextCfg = clone(cfg || {}); + const nextCfg = cloneJSON(cfg || {}); if (!nextCfg.channels || typeof nextCfg.channels !== 'object') { (nextCfg as any).channels = {}; } - (nextCfg as any).channels[definition.id] = clone(draft); + (nextCfg as any).channels[definition.id] = cloneJSON(draft); const res = await fetch(`/webui/api/config${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -503,238 +158,75 @@ const ChannelSettings: React.FC = () => { }, t('loading')); }; - const renderField = (field: ChannelField) => { - const label = t(`configLabels.${field.key}`); - const value = draft[field.key]; - const isWhatsApp = key === 'whatsapp'; - const helper = getChannelFieldDescription(t, key, field.key); - if (field.type === 'boolean') { - if (isWhatsApp) { - const Icon = getWhatsAppBooleanIcon(field.key); - return ( - - ); - } - return ( - - ); - } - if (field.type === 'list') { - return ( - 0 ? `${t('entries')}: ${value.length}` : undefined} - > - setDraft((prev) => ({ ...prev, [field.key]: parseList(e.target.value) }))} - placeholder={field.placeholder || ''} - monospace={isWhatsApp} - className={`${isWhatsApp ? 'min-h-36 px-4 py-3' : 'min-h-32 px-4 py-3'}`} - /> - {isWhatsApp &&
{t('whatsappFieldAllowFromFootnote')}
} -
- ); - } - return ( - - setDraft((prev) => ({ ...prev, [field.key]: field.type === 'number' ? Number(e.target.value || 0) : e.target.value }))} - placeholder={field.placeholder || ''} - className={`${isWhatsApp && field.key === 'bridge_url' ? 'font-mono' : ''}`} - /> - - ); - }; - const wa = waStatus?.status; const stateLabel = wa?.connected ? t('online') : wa?.logged_in ? t('whatsappStateDisconnected') : wa?.qr_available ? t('whatsappStateAwaitingScan') : t('offline'); return (
-
-
-

{t(definition.titleKey)}

-

{t(definition.hintKey)}

-
-
+ {key === 'whatsapp' && ( window.location.reload()} label={t('refresh')}> )} - -
-
+ + + + + } + />
{definition.sections.map((section) => { - const Icon = getSectionIcon(section.id); + const Icon = getChannelSectionIcon(section.id); return ( -
-
-
- -
-
-

{t(section.titleKey)}

-

{t(section.hintKey)}

-
-
+ } + title={t(section.titleKey)} + hint={t(section.hintKey)} + >
- {section.fields.map(renderField)} + {section.fields.map((field) => ( + + ))}
-
+ ); })}
{key === 'whatsapp' && (
-
-
-
-
- {wa?.connected ? : } -
-
-
{t('gatewayStatus')}
-
{stateLabel}
-
-
- -
+ -
-
-
{t('whatsappBridgeURL')}
-
{waStatus?.bridge_url || draft.bridge_url || '-'}
-
-
-
{t('whatsappBridgeAccount')}
-
{wa?.user_jid || '-'}
-
-
-
{t('whatsappBridgeLastEvent')}
-
{wa?.last_event || '-'}
-
-
-
{t('time')}
-
{wa?.updated_at || '-'}
-
-
-
{t('whatsappInbound')}
-
{wa?.inbound_count ?? 0}
-
-
-
{t('whatsappOutbound')}
-
{wa?.outbound_count ?? 0}
-
-
-
{t('whatsappReadReceipts')}
-
{wa?.read_receipt_count ?? 0}
-
-
-
{t('whatsappLastRead')}
-
{wa?.last_read_at || '-'}
-
-
- -
-
-
{t('whatsappLastInbound')}
-
{wa?.last_inbound_at || '-'}
-
{wa?.last_inbound_from || '-'}
-
{wa?.last_inbound_text || '-'}
-
-
-
{t('whatsappLastOutbound')}
-
{wa?.last_outbound_at || '-'}
-
{wa?.last_outbound_to || '-'}
-
{wa?.last_outbound_text || '-'}
-
-
- - {!!waStatus?.error && ( -
- {waStatus.error} -
- )} - {!!wa?.last_error && ( -
- {wa.last_error} -
- )} -
- -
-
-
- -
-
-
{t('whatsappBridgeQRCode')}
-
{wa?.qr_available ? t('whatsappQRCodeReady') : t('whatsappQRCodeUnavailable')}
-
-
- - {wa?.qr_available ? ( -
- {t('whatsappBridgeQRCode')} -
- ) : ( -
-
- -
-
{t('whatsappQRCodeUnavailable')}
-
{t('whatsappQRCodeHint')}
-
- )} -
+
)}
diff --git a/webui/src/pages/Chat.tsx b/webui/src/pages/Chat.tsx index 9d3203a..d6c577f 100644 --- a/webui/src/pages/Chat.tsx +++ b/webui/src/pages/Chat.tsx @@ -1,135 +1,37 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Paperclip, Send, MessageSquare, RefreshCw } from 'lucide-react'; +import { RefreshCw } from 'lucide-react'; import { motion } from 'motion/react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; import { Button, FixedButton } from '../components/Button'; -import { SelectField, TextField, TextareaField } from '../components/FormControls'; +import ChatComposer from '../components/chat/ChatComposer'; +import ChatEmptyState from '../components/chat/ChatEmptyState'; +import ChatMessageList from '../components/chat/ChatMessageList'; +import SubagentSidebar from '../components/chat/SubagentSidebar'; +import SubagentStreamFilters from '../components/chat/SubagentStreamFilters'; +import { + avatarSeed, + avatarText, + collectActors, + formatAgentName, + isUserFacingMainSession, + messageActorKey, + type AgentRuntimeBadge, + type RegistryAgent, + type RenderedChatItem, + type RuntimeTask, + type StreamItem, +} from '../components/chat/chatUtils'; +import { useSubagentChatRuntime } from '../components/chat/useSubagentChatRuntime'; +import { SelectField } from '../components/FormControls'; import { ChatItem } from '../types'; -type StreamItem = { - kind?: string; - at?: number; - task_id?: string; - label?: string; - agent_id?: string; - event_type?: string; - message?: string; - message_type?: string; - content?: string; - from_agent?: string; - to_agent?: string; - reply_to?: string; - message_id?: string; - status?: string; -}; - -type RenderedChatItem = ChatItem & { - id: string; - actorKey?: string; - actorName?: string; - avatarText?: string; - avatarClassName?: string; - metaLine?: string; - isReadonlyGroup?: boolean; -}; - -type RegistryAgent = { - agent_id?: string; - display_name?: string; - role?: string; - enabled?: boolean; - transport?: string; -}; - -type RuntimeTask = { - id?: string; - agent_id?: string; - status?: string; - updated?: number; - created?: number; - waiting_for_reply?: boolean; -}; - -type AgentRuntimeBadge = { - status: 'running' | 'waiting' | 'failed' | 'completed' | 'idle'; - text: string; -}; - -function formatAgentName(agentID: string | undefined, t: (key: string) => string): string { - const normalized = String(agentID || '').trim(); - if (!normalized) return t('unknownAgent'); - if (normalized === 'main') return t('mainAgent'); - return normalized - .split(/[-_.:]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); -} - -function avatarSeed(key?: string): string { - const palette = [ - 'avatar-tone-1', - 'avatar-tone-2', - 'avatar-tone-3', - 'avatar-tone-4', - 'avatar-tone-5', - 'avatar-tone-6', - 'avatar-tone-7', - ]; - const source = String(key || 'agent'); - let hash = 0; - for (let i = 0; i < source.length; i += 1) { - hash = (hash * 31 + source.charCodeAt(i)) | 0; - } - return palette[Math.abs(hash) % palette.length]; -} - -function avatarText(name?: string): string { - const parts = String(name || '') - .split(/\s+/) - .filter(Boolean); - if (parts.length === 0) return 'A'; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase(); -} - -function messageActorKey(item: StreamItem): string { - return String(item.from_agent || item.agent_id || item.to_agent || 'subagent').trim() || 'subagent'; -} - -function collectActors(items: StreamItem[]): string[] { - const set = new Set(); - items.forEach((item) => { - [item.agent_id, item.from_agent, item.to_agent].forEach((value) => { - const v = String(value || '').trim(); - if (v) set.add(v); - }); - }); - return Array.from(set).sort((a, b) => a.localeCompare(b)); -} - -function isUserFacingMainSession(key?: string): boolean { - const normalized = String(key || '').trim().toLowerCase(); - if (!normalized) return false; - return !( - normalized.startsWith('subagent:') || - normalized.startsWith('internal:') || - normalized.startsWith('heartbeat:') || - normalized.startsWith('cron:') || - normalized.startsWith('hook:') || - normalized.startsWith('node:') - ); -} - const Chat: React.FC = () => { const { t } = useTranslation(); const { q, sessions, subagentRuntimeItems, subagentRegistryItems, subagentStreamItems } = useAppContext(); const ui = useUI(); const [mainChat, setMainChat] = useState([]); - const [subagentStream, setSubagentStream] = useState([]); - const [registryAgents, setRegistryAgents] = useState([]); const [msg, setMsg] = useState(''); const [fileSelected, setFileSelected] = useState(false); const [chatTab, setChatTab] = useState<'main' | 'subagents'>('main'); @@ -138,7 +40,6 @@ const Chat: React.FC = () => { const [dispatchAgentID, setDispatchAgentID] = useState(''); const [dispatchTask, setDispatchTask] = useState(''); const [dispatchLabel, setDispatchLabel] = useState(''); - const [runtimeTasks, setRuntimeTasks] = useState([]); const chatEndRef = useRef(null); const chatScrollRef = useRef(null); const shouldAutoScrollRef = useRef(true); @@ -155,6 +56,21 @@ const Chat: React.FC = () => { chatEndRef.current?.scrollIntoView({ behavior }); }; + const { + loadRegistryAgents, + loadRuntimeTasks, + loadSubagentGroup, + registryAgents, + runtimeTasks, + subagentStream, + } = useSubagentChatRuntime({ + dispatchAgentID, + q, + subagentRegistryItems, + subagentRuntimeItems, + subagentStreamItems, + }); + useEffect(() => { if (shouldAutoScrollRef.current) { scrollToBottom(chatTab === 'main' ? 'smooth' : 'auto'); @@ -217,75 +133,6 @@ const Chat: React.FC = () => { } }; - const loadSubagentGroup = async () => { - try { - if (subagentStreamItems.length > 0) { - setSubagentStream(subagentStreamItems); - return; - } - shouldAutoScrollRef.current = isNearBottom() || chatTab !== 'subagents'; - const r = await fetch(`/webui/api/subagents_runtime${q}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'stream_all', limit: 300, task_limit: 36 }), - }); - if (!r.ok) return; - const j = await r.json(); - const arr = Array.isArray(j?.result?.items) ? j.result.items : []; - setSubagentStream(arr); - } catch (e) { - console.error(e); - } - }; - - const loadRegistryAgents = async () => { - try { - if (subagentRegistryItems.length > 0) { - const filtered = subagentRegistryItems.filter((item: RegistryAgent) => item?.agent_id && item.enabled !== false); - setRegistryAgents(filtered); - if (!dispatchAgentID && filtered.length > 0) { - setDispatchAgentID(String(filtered[0].agent_id || '')); - } - return; - } - const r = await fetch(`/webui/api/subagents_runtime${q}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'registry' }), - }); - if (!r.ok) return; - const j = await r.json(); - const items = Array.isArray(j?.result?.items) ? j.result.items : []; - const filtered = items.filter((item: RegistryAgent) => item?.agent_id && item.enabled !== false); - setRegistryAgents(filtered); - if (!dispatchAgentID && filtered.length > 0) { - setDispatchAgentID(String(filtered[0].agent_id || '')); - } - } catch (e) { - console.error(e); - } - }; - - const loadRuntimeTasks = async () => { - try { - if (subagentRuntimeItems.length > 0) { - setRuntimeTasks(subagentRuntimeItems); - return; - } - const r = await fetch(`/webui/api/subagents_runtime${q}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'list' }), - }); - if (!r.ok) return; - const j = await r.json(); - const items = Array.isArray(j?.result?.items) ? j.result.items : []; - setRuntimeTasks(items); - } catch (e) { - console.error(e); - } - }; - async function send() { if (!msg.trim() && !fileSelected) return; @@ -416,6 +263,12 @@ const Chat: React.FC = () => { loadRuntimeTasks(); }, [q, chatTab, sessionKey, subagentRuntimeItems, subagentRegistryItems, subagentStreamItems]); + useEffect(() => { + if (dispatchAgentID || registryAgents.length === 0) return; + const first = String(registryAgents[0]?.agent_id || '').trim(); + if (first) setDispatchAgentID(first); + }, [dispatchAgentID, registryAgents]); + const userSessions = (sessions || []).filter((s: any) => isUserFacingMainSession(s?.key)); useEffect(() => { @@ -595,89 +448,38 @@ const Chat: React.FC = () => {
{chatTab === 'subagents' && ( -
- - {streamActors.map((agent) => ( - - ))} -
+ formatAgentName(agent, t)} + onReset={() => setSelectedStreamAgents([])} + onToggle={toggleStreamAgent} + selectedAgents={selectedStreamAgents} + /> )}
{chatTab === 'subagents' && ( -
-
-
{t('subagentDispatch')}
-
{t('subagentDispatchHint')}
-
-
- setDispatchAgentID(e.target.value)} - className="w-full rounded-2xl py-2.5" - > - {registryAgents.map((agent) => ( - - ))} - - setDispatchTask(e.target.value)} - placeholder={t('subagentTaskPlaceholder')} - className="w-full min-h-[180px] resize-none rounded-2xl px-3 py-3" - /> - setDispatchLabel(e.target.value)} - placeholder={t('subagentLabelPlaceholder')} - className="w-full rounded-2xl py-2.5" - /> - -
-
-
{t('agents')}
-
- {registryAgents.map((agent) => { - const active = dispatchAgentID === agent.agent_id; - const badge = runtimeBadgeByAgent[String(agent.agent_id || '')]; - const badgeClass = badge?.status === 'running' - ? 'ui-pill-success' - : badge?.status === 'waiting' - ? 'ui-pill-warning' - : badge?.status === 'failed' - ? 'ui-pill-danger' - : badge?.status === 'completed' - ? 'ui-pill-info' - : 'ui-pill-neutral'; - return ( - - ); - })} -
-
-
+ formatAgentName(value, t)} + idleLabel={t('idle')} + onAgentChange={setDispatchAgentID} + onDispatch={dispatchSubagentTask} + onLabelChange={setDispatchLabel} + onTaskChange={setDispatchTask} + registryAgents={registryAgents} + runtimeBadgeByAgent={runtimeBadgeByAgent} + subagentLabelPlaceholder={t('subagentLabelPlaceholder')} + subagentTaskPlaceholder={t('subagentTaskPlaceholder')} + /> )}
{ className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4 sm:space-y-6 min-w-0" > {displayedChat.length === 0 ? ( -
-
- -
-

{chatTab === 'main' ? t('startConversation') : t('noSubagentStream')}

-
+ ) : ( - displayedChat.map((m, i) => { - const isUser = m.role === 'user'; - const isExec = m.role === 'tool' || m.role === 'exec'; - const isSystem = m.role === 'system'; - const bubbleClass = isUser - ? 'chat-bubble-user rounded-br-sm' - : isExec - ? 'chat-bubble-tool rounded-bl-sm' - : isSystem - ? 'chat-bubble-system rounded-bl-sm' - : m.isReadonlyGroup - ? 'chat-bubble-system rounded-bl-sm' - : 'chat-bubble-agent rounded-bl-sm'; - const metaClass = isUser - ? 'chat-meta-user' - : isExec - ? 'chat-meta-tool' - : 'ui-text-muted'; - const subLabelClass = isUser - ? 'chat-submeta-user' - : isExec - ? 'chat-submeta-tool' - : 'ui-text-muted'; - - return ( - -
-
{m.avatarText || (isUser ? 'U' : 'A')}
-
-
-
{m.actorName || m.label || (isUser ? t('user') : isExec ? t('exec') : isSystem ? t('system') : t('agent'))}
- {m.metaLine &&
{m.metaLine}
} -
- {m.label && m.actorName && m.label !== m.actorName && ( -
{m.label}
- )} -

{m.text}

-
-
-
- ); - }) + )}
-
-
- setFileSelected(!!e.target.files?.[0])} - /> - - setMsg(e.target.value)} - onKeyDown={(e) => chatTab === 'main' && e.key === 'Enter' && send()} - placeholder={chatTab === 'main' ? t('typeMessage') : t('subagentGroupReadonly')} - disabled={chatTab !== 'main'} - className="ui-composer-input w-full pl-14 pr-14 py-3.5 text-[15px] transition-all disabled:opacity-60" - /> - -
-
+ setFileSelected(!!e.target.files?.[0])} + onMsgChange={setMsg} + onSend={send} + placeholder={chatTab === 'main' ? t('typeMessage') : t('subagentGroupReadonly')} + />
); diff --git a/webui/src/pages/Config.tsx b/webui/src/pages/Config.tsx index 25c3479..171eefb 100644 --- a/webui/src/pages/Config.tsx +++ b/webui/src/pages/Config.tsx @@ -10,6 +10,7 @@ import { useConfigGatewayActions } from '../components/config/useConfigGatewayAc import { useConfigNavigation } from '../components/config/useConfigNavigation'; import { useConfigSaveAction } from '../components/config/useConfigSaveAction'; import RecursiveConfig from '../components/RecursiveConfig'; +import { cloneJSON } from '../utils/object'; const Config: React.FC = () => { const { t } = useTranslation(); @@ -69,7 +70,7 @@ const Config: React.FC = () => { useEffect(() => { if (baseline == null && cfg && Object.keys(cfg).length > 0) { - setBaseline(JSON.parse(JSON.stringify(cfg))); + setBaseline(cloneJSON(cfg)); } }, [cfg, baseline]); @@ -86,7 +87,7 @@ const Config: React.FC = () => { basicMode={basicMode} hotOnly={hotOnly} onHotOnlyChange={setHotOnly} - onReload={async () => { await loadConfig(true); setTimeout(() => setBaseline(JSON.parse(JSON.stringify(cfg))), 0); }} + onReload={async () => { await loadConfig(true); setTimeout(() => setBaseline(cloneJSON(cfg)), 0); }} onSearchChange={setSearch} onShowDiff={() => setShowDiff(true)} onToggleBasicMode={() => setBasicMode((value) => !value)} diff --git a/webui/src/pages/Cron.tsx b/webui/src/pages/Cron.tsx index 11dc0f0..c87abc4 100644 --- a/webui/src/pages/Cron.tsx +++ b/webui/src/pages/Cron.tsx @@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; import { Button, FixedButton } from '../components/Button'; +import EmptyState from '../components/EmptyState'; import { CheckboxField, FieldBlock, SelectField, TextField, TextareaField } from '../components/FormControls'; +import { ModalBackdrop, ModalBody, ModalCard, ModalFooter, ModalHeader, ModalShell } from '../components/ModalFrame'; +import PageHeader from '../components/PageHeader'; import { CronJob } from '../types'; import { formatLocalDateTime } from '../utils/time'; @@ -171,17 +174,19 @@ const Cron: React.FC = () => { return (
-
-

{t('cronJobs')}

-
+ refreshCron()} label={t('refresh')}> openCronModal()} variant="primary" label={t('addJob')}> -
-
+ + } + />
{cron.map((j) => { @@ -215,9 +220,9 @@ const Cron: React.FC = () => {
- + openCronModal(j)} radius="lg" label={t('editJob')}> + + cronAction(j.enabled ? 'disable' : 'enable', j.id)} variant={j.enabled ? 'warning' : 'success'} @@ -234,37 +239,46 @@ const Cron: React.FC = () => { ); })} {cron.length === 0 && ( -
- -

{t('noCronJobs')}

-
+ } + title={t('noCronJobs')} + message={null} + /> )}
{isCronModalOpen && ( -
+ setIsCronModalOpen(false)} - className="ui-overlay-strong absolute inset-0 backdrop-blur-sm" + className="absolute inset-0" /> + setIsCronModalOpen(false)} /> -
-

{editingCron ? t('editJob') : t('addJob')}

- setIsCronModalOpen(false)} radius="full" label={t('close')}> - - -
+ + setIsCronModalOpen(false)} radius="full" label={t('close')}> + + + } + /> -
+
{ {t('active')}
-
+ -
- - -
+ + + + + + +
-
+ )}
diff --git a/webui/src/pages/Dashboard.tsx b/webui/src/pages/Dashboard.tsx index f327f82..2c963d3 100644 --- a/webui/src/pages/Dashboard.tsx +++ b/webui/src/pages/Dashboard.tsx @@ -2,31 +2,17 @@ import React, { useMemo } from 'react'; import { RefreshCw, Activity, MessageSquare, Wrench, Sparkles, AlertTriangle, Workflow } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; +import ArtifactPreviewCard from '../components/ArtifactPreviewCard'; import StatCard from '../components/StatCard'; import { FixedButton } from '../components/Button'; - -function formatRuntimeTime(value: unknown) { - const raw = String(value || '').trim(); - if (!raw || raw === '0001-01-01T00:00:00Z') return '-'; - const ts = Date.parse(raw); - if (Number.isNaN(ts)) return raw; - return new Date(ts).toLocaleString(); -} - -function dataUrlForArtifact(artifact: any) { - const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream'; - const content = String(artifact?.content_base64 || '').trim(); - if (!content) return ''; - return `data:${mime};base64,${content}`; -} - -function formatBytes(value: unknown) { - const size = Number(value || 0); - if (!Number.isFinite(size) || size <= 0) return '-'; - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} +import DetailGrid from '../components/DetailGrid'; +import InfoTile from '../components/InfoTile'; +import InsetCard from '../components/InsetCard'; +import MetricPanel from '../components/MetricPanel'; +import PageHeader from '../components/PageHeader'; +import SectionPanel from '../components/SectionPanel'; +import { dataUrlForArtifact, formatArtifactBytes } from '../utils/artifacts'; +import { formatRuntimeTime } from '../utils/runtime'; const Dashboard: React.FC = () => { const { t } = useTranslation(); @@ -114,19 +100,21 @@ const Dashboard: React.FC = () => { return (
-
-
-

{t('dashboard')}

-
+ {t('gateway')}: {gatewayVersion} {' · '} {t('webui')}: {webuiVersion} -
-
- - - -
+ + } + actions={ + + + + } + />
} /> @@ -138,70 +126,54 @@ const Dashboard: React.FC = () => {
-
-
- -
{t('ekgEscalations')}
-
-
{ekgEscalationCount}
-
{t('dashboardTopErrorSignature')}: {ekgTopErrSig}
-
-
-
- -
{t('ekgTopProvidersWorkload')}
-
-
{ekgTopProvider}
-
{t('dashboardWorkloadSnapshot')}
-
-
-
- -
{t('taskAudit')}
-
-
{recentFailures.length}
-
{t('dashboardRecentFailedTasks')}
-
+ } + subtitle={`${t('dashboardTopErrorSignature')}: ${ekgTopErrSig}`} + title={t('ekgEscalations')} + value={ekgEscalationCount} + /> + } + subtitle={t('dashboardWorkloadSnapshot')} + title={t('ekgTopProvidersWorkload')} + value={ekgTopProvider} + valueClassName="ui-text-primary text-2xl font-semibold truncate" + /> + } + subtitle={t('dashboardRecentFailedTasks')} + title={t('taskAudit')} + value={recentFailures.length} + />
-
-
-
-
- -

{t('nodeArtifactsRetention')}

-
-
{t('nodeArtifactsRetentionHint')}
-
+ } + actions={
{artifactRetentionEnabled ? t('enabled') : t('disabled')}
-
+ } + >
-
-
{t('nodeArtifactsRetentionPruned')}
-
{artifactRetentionPruned}
-
-
-
{t('nodeArtifactsRetentionRemaining')}
-
{artifactRetentionRemaining}
-
-
-
{t('nodeArtifactsRetentionKeepLatest')}
-
{Number(nodeArtifactRetention?.keep_latest || 0) || '-'}
-
-
-
{t('time')}
-
{artifactRetentionLastRun}
-
+ + {artifactRetentionPruned} + + + {artifactRetentionRemaining} + + + {Number(nodeArtifactRetention?.keep_latest || 0) || '-'} + + + {artifactRetentionLastRun} +
-
+ -
-
- -

{t('nodeAlerts')}

-
+ }> {topNodeAlerts.length === 0 ? (
{t('nodeAlertsEmpty')}
) : ( @@ -209,7 +181,7 @@ const Dashboard: React.FC = () => { {topNodeAlerts.map((alert: any, index: number) => { const severity = String(alert?.severity || 'warning'); return ( -
+
{String(alert?.title || '-')}
@@ -220,24 +192,20 @@ const Dashboard: React.FC = () => {
{String(alert?.detail || '-')}
-
+ ); })}
)} -
+
-
-
- -

{t('taskAudit')}

-
+ } className="min-h-[340px] h-full">
{recentTasks.length === 0 ? (
-
) : recentTasks.map((task: any, index: number) => ( -
+
{task.task_id || `task-${index + 1}`}
@@ -247,48 +215,32 @@ const Dashboard: React.FC = () => { {task.status || '-'}
-
+ ))}
-
+ -
-
- -

{t('nodeP2P')}

-
+ } className="min-h-[340px] h-full">
-
-
{t('dashboardNodeP2PTransport')}
-
{p2pTransport}
-
-
-
{t('dashboardNodeP2PIce')}
-
{`${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN`}
-
-
-
{t('dashboardNodeP2PHealth')}
-
{t('dashboardNodeP2PDetail', { transport: p2pTransport, sessions: p2pSessions, retries: p2pRetryCount })}
-
+ + {p2pTransport} + + + {`${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN`} + + + {t('dashboardNodeP2PDetail', { transport: p2pTransport, sessions: p2pSessions, retries: p2pRetryCount })} +
-
+
-
-
-
-
- -

{t('dashboardNodeP2PSessions')}

-
-
- {t('dashboardNodeP2PDetail', { transport: p2pTransport, sessions: p2pSessions, retries: p2pRetryCount })} -
-
-
- {`${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN`} -
-
+ } + actions={`${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN`} + > {p2pNodeSessions.length === 0 ? (
{t('dashboardNodeP2PSessionsEmpty')}
) : ( @@ -297,7 +249,7 @@ const Dashboard: React.FC = () => { const isOpen = session.status.toLowerCase() === 'open'; const isConnecting = session.status.toLowerCase() === 'connecting'; return ( -
+
{session.node}
@@ -305,53 +257,58 @@ const Dashboard: React.FC = () => { {t('dashboardNodeP2PSessionCreated')}: {session.createdAt}
-
- {session.status} -
-
-
-
-
{t('dashboardNodeP2PSessionRetries')}
-
{session.retryCount}
-
-
-
{t('dashboardNodeP2PSessionReady')}
-
{session.lastReadyAt}
-
-
-
{t('dashboardNodeP2PSessionAttempt')}
-
{session.lastAttempt}
-
-
-
{t('dashboardNodeP2PSessionError')}
-
- {session.lastError || '-'} -
-
+
+ {session.status}
+ + ); })}
)} -
+ -
-
-
-
- -

{t('dashboardNodeDispatches')}

-
-
{t('dashboardNodeDispatchesHint')}
-
-
+ } + > {recentNodeDispatches.length === 0 ? (
{t('dashboardNodeDispatchesEmpty')}
) : (
{recentNodeDispatches.map((item) => ( -
+
{`${item.node} · ${item.action}`}
@@ -361,76 +318,64 @@ const Dashboard: React.FC = () => { {item.ok ? 'ok' : 'error'}
-
-
-
{t('dashboardNodeDispatchTransport')}
-
{item.usedTransport}
-
-
-
{t('dashboardNodeDispatchFallback')}
-
{item.fallbackFrom || '-'}
-
-
-
{t('dashboardNodeDispatchDuration')}
-
{`${item.durationMs}ms`}
-
-
-
{t('dashboardNodeDispatchArtifacts')}
-
- {item.artifactCount > 0 ? `${item.artifactCount}${item.artifactKinds.length ? ` · ${item.artifactKinds.join(', ')}` : ''}` : '-'} -
-
-
-
{t('dashboardNodeDispatchError')}
-
- {item.error || '-'} -
-
-
+ 0 ? `${item.artifactCount}${item.artifactKinds.length ? ` · ${item.artifactKinds.join(', ')}` : ''}` : '-', + valueClassName: 'text-zinc-200 mt-1', + }, + { + key: 'error', + label: t('dashboardNodeDispatchError'), + value: item.error || '-', + valueClassName: `mt-1 break-all ${item.error ? 'text-rose-300' : 'text-zinc-500'}`, + }, + ]} + /> {item.artifacts.length > 0 && (
{t('dashboardNodeDispatchArtifactPreview')}
{item.artifacts.slice(0, 2).map((artifact: any, artifactIndex: number) => { - const kind = String(artifact?.kind || '').trim().toLowerCase(); - const mime = String(artifact?.mime_type || '').trim().toLowerCase(); - const isImage = kind === 'image' || mime.startsWith('image/'); - const isVideo = kind === 'video' || mime.startsWith('video/'); const dataUrl = dataUrlForArtifact(artifact); return ( -
-
-
-
{String(artifact?.name || artifact?.source_path || `artifact-${artifactIndex + 1}`)}
-
- {[artifact?.kind, artifact?.mime_type, formatBytes(artifact?.size_bytes)].filter(Boolean).join(' · ')} -
-
-
{String(artifact?.storage || '-')}
-
- {isImage && dataUrl && ( - {String(artifact?.name - )} - {isVideo && dataUrl && ( -
+ ); })}
)} -
+ ))}
)} -
+
); }; diff --git a/webui/src/pages/EKG.tsx b/webui/src/pages/EKG.tsx index 7c49095..5c0470b 100644 --- a/webui/src/pages/EKG.tsx +++ b/webui/src/pages/EKG.tsx @@ -3,109 +3,13 @@ import { AlertTriangle, RefreshCw, Route, ServerCrash, Workflow } from 'lucide-r import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { FixedButton } from '../components/Button'; +import EKGDistributionCard from '../components/ekg/EKGDistributionCard'; +import EKGRankingCard from '../components/ekg/EKGRankingCard'; import { SelectField } from '../components/FormControls'; +import MetricPanel from '../components/MetricPanel'; type EKGKV = { key?: string; score?: number; count?: number }; -function StatCard({ - title, - value, - subtitle, - accent, - icon, -}: { - title: string; - value: string | number; - subtitle?: string; - accent: string; - icon: React.ReactNode; -}) { - return ( -
-
-
-
{title}
-
{value}
- {subtitle &&
{subtitle}
} -
-
{icon}
-
-
- ); -} - -function KVDistributionCard({ - title, - data, -}: { - title: string; - data: Record; -}) { - const entries = useMemo(() => ( - Object.entries(data).sort((a, b) => b[1] - a[1]) - ), [data]); - const maxValue = entries.length > 0 ? Math.max(...entries.map(([, value]) => value)) : 0; - - return ( -
-
{title}
-
- {entries.length === 0 ? ( -
-
- ) : entries.map(([key, value]) => ( -
-
-
{key}
-
{value}
-
-
-
0 ? (value / maxValue) * 100 : 0}%` }} - /> -
-
- ))} -
-
- ); -} - -function RankingCard({ - title, - items, - valueMode, -}: { - title: string; - items: EKGKV[]; - valueMode: 'score' | 'count'; -}) { - return ( -
-
{title}
-
- {items.length === 0 ? ( -
-
- ) : items.map((item, index) => ( -
-
- {index + 1} -
-
-
{item.key || '-'}
-
- {valueMode === 'score' - ? Number(item.score || 0).toFixed(2) - : `x${item.count || 0}`} -
-
-
- ))} -
-
- ); -} - const EKG: React.FC = () => { const { t } = useTranslation(); const { q } = useAppContext(); @@ -178,27 +82,55 @@ const EKG: React.FC = () => {
- } /> - } /> - } /> - } /> + } + iconContainerClassName="flex h-10 w-10 items-center justify-center rounded-xl ui-pill ui-pill-warning border" + layout="split" + /> + } + iconContainerClassName="flex h-10 w-10 items-center justify-center rounded-xl ui-pill ui-pill-info border" + layout="split" + /> + } + iconContainerClassName="flex h-10 w-10 items-center justify-center rounded-xl ui-pill ui-pill-accent border" + layout="split" + /> + } + iconContainerClassName="flex h-10 w-10 items-center justify-center rounded-xl ui-pill ui-pill-danger border" + layout="split" + />
- - + +
- - + +
- - + +
); diff --git a/webui/src/pages/LogCodes.tsx b/webui/src/pages/LogCodes.tsx index 01864f2..e6b62b8 100644 --- a/webui/src/pages/LogCodes.tsx +++ b/webui/src/pages/LogCodes.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { TextField } from '../components/FormControls'; +import PageHeader from '../components/PageHeader'; type CodeItem = { code: number; @@ -42,15 +43,18 @@ const LogCodes: React.FC = () => { return (
-
-

{t('logCodes')}

- setKw(e.target.value)} - placeholder={t('logCodesSearchPlaceholder')} - className="w-full sm:w-80" - /> -
+ setKw(e.target.value)} + placeholder={t('logCodesSearchPlaceholder')} + className="w-full sm:w-80" + /> + } + />
diff --git a/webui/src/pages/Logs.tsx b/webui/src/pages/Logs.tsx index 01712c9..e41f0cf 100644 --- a/webui/src/pages/Logs.tsx +++ b/webui/src/pages/Logs.tsx @@ -3,9 +3,12 @@ import { Terminal, Trash2, Play, Square } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; +import EmptyState from '../components/EmptyState'; import { LogEntry } from '../types'; import { formatLocalTime } from '../utils/time'; -import { Button } from '../components/Button'; +import { Button, FixedButton } from '../components/Button'; +import PageHeader from '../components/PageHeader'; +import ToolbarRow from '../components/ToolbarRow'; const Logs: React.FC = () => { const { t } = useTranslation(); @@ -164,28 +167,31 @@ const Logs: React.FC = () => { return (
-
-
-

{t('logs')}

+
{isStreaming ? t('live') : t('paused')}
-
-
+ } + actions={ + - -
-
+ + + + + } + />
@@ -197,10 +203,12 @@ const Logs: React.FC = () => {
{logs.length === 0 ? ( -
- -

{t('waitingForLogs')}

-
+ } + message={t('waitingForLogs')} + /> ) : showRaw ? (
{logs.map((log, i) => ( diff --git a/webui/src/pages/MCP.tsx b/webui/src/pages/MCP.tsx index a44cf16..7418ba1 100644 --- a/webui/src/pages/MCP.tsx +++ b/webui/src/pages/MCP.tsx @@ -1,11 +1,18 @@ import React, { useEffect, useMemo, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; -import { Package, Pencil, Plus, RefreshCw, Save, Trash2, Wrench, X } from 'lucide-react'; +import { Plus, RefreshCw, Save, Trash2, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; import { Button, FixedButton } from '../components/Button'; -import { CheckboxField, SelectField, TextField } from '../components/FormControls'; +import EmptyState from '../components/EmptyState'; +import { ModalBackdrop, ModalBody, ModalCard, ModalFooter, ModalHeader, ModalShell } from '../components/ModalFrame'; +import MCPServerCard from '../components/mcp/MCPServerCard'; +import MCPServerEditor from '../components/mcp/MCPServerEditor'; +import PageHeader from '../components/PageHeader'; +import SectionHeader from '../components/SectionHeader'; +import ToolbarRow from '../components/ToolbarRow'; +import { cloneJSON } from '../utils/object'; type MCPDraftServer = { enabled: boolean; @@ -35,8 +42,6 @@ const emptyDraftServer = (): MCPDraftServer => ({ installer: '', }); -const cloneDeep = (value: T): T => JSON.parse(JSON.stringify(value)); - const MCP: React.FC = () => { const { t } = useTranslation(); const { cfg, setCfg, q, loadConfig, setConfigEditing } = useAppContext(); @@ -96,7 +101,7 @@ const MCP: React.FC = () => { url: String(server?.url || ''), command: String(server?.command || ''), args: Array.isArray(server?.args) ? server.args.map((x: any) => String(x)) : [], - env: typeof server?.env === 'object' && server?.env ? cloneDeep(server.env) : {}, + env: typeof server?.env === 'object' && server?.env ? cloneJSON(server.env) : {}, permission: String(server?.permission || 'workspace'), working_dir: String(server?.working_dir || ''), description: String(server?.description || ''), @@ -200,7 +205,7 @@ const MCP: React.FC = () => { return; } - const next = cloneDeep(cfg || {}); + const next = cloneJSON(cfg || {}); if (!next.tools || typeof next.tools !== 'object') next.tools = {}; if (!next.tools.mcp || typeof next.tools.mcp !== 'object') { next.tools.mcp = { enabled: true, request_timeout_sec: 20, servers: {} }; @@ -244,7 +249,7 @@ const MCP: React.FC = () => { }); if (!ok) return; try { - const next = cloneDeep(cfg || {}); + const next = cloneJSON(cfg || {}); if (next?.tools?.mcp?.servers && typeof next.tools.mcp.servers === 'object') { delete next.tools.mcp.servers[name]; } @@ -352,258 +357,113 @@ const MCP: React.FC = () => { return (
-
-
-

{t('mcpServices')}

-

{t('mcpServicesHint')}

-
-
+ { await loadConfig(true); await refreshMCPTools(); }} label={t('reload')}> -
-
+ + )} + />
-
-
{t('configMCPServers')}
-
{serverEntries.length}
-
+
{serverEntries.map(([name, server]) => { - const transport = String(server?.transport || 'stdio'); const check = mcpServerChecks.find((item) => item.name === name); return ( -
-
-
-
-
{name}
- - {server?.enabled ? t('enable') : t('paused')} - - - {transport} - -
-
- {transport === 'stdio' ? String(server?.command || '-') : String(server?.url || '-')} -
- {server?.description && ( -
{String(server.description)}
- )} -
-
- openEditModal(name, server)} radius="xl" label={t('edit')}> - - - removeServer(name)} variant="danger" radius="xl" label={t('delete')}> - - -
-
- -
-
-
package
-
{String(server?.package || '-')}
-
-
-
args
-
{Array.isArray(server?.args) ? server.args.length : 0}
-
-
-
permission
-
{String(server?.permission || 'workspace')}
-
-
- - {check && check.status !== 'ok' && check.status !== 'disabled' && check.status !== 'not_applicable' && ( -
-
{check.message || t('configMCPCommandMissing')}
- {check.package && ( -
{t('configMCPInstallSuggested', { pkg: check.package })}
- )} -
- )} -
+ openEditModal(name, server)} + onRemove={() => removeServer(name)} + server={server} + t={t} + /> ); })}
{serverEntries.length === 0 && ( -
- {t('configNoMCPServers')} -
+ )}
{modalOpen && ( - - -
-
-
- {editingName ? `${t('edit')} MCP` : `${t('add')} MCP`} -
-
{t('mcpServicesHint')}
-
- - - -
+ + + + + + + + } + /> -
-
- - -
- -
- - - {draft.transport === 'stdio' && ( - - )} - {draft.transport === 'stdio' && ( - - )} -
- - {draft.transport === 'stdio' ? ( -
- - -
- ) : ( - - )} - - {draft.transport === 'stdio' && ( -
-
-
-
Args
-
{t('configMCPArgsEnterHint')}
-
- -
- -
- {draft.args.map((arg, index) => ( - - {arg} - - - ))} -
- setDraftArgInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addDraftArg(draftArgInput); - } - }} - onBlur={() => addDraftArg(draftArgInput)} - placeholder={t('configMCPArgsEnterHint')} - className="h-11" + + -
- )} + - {activeCheck && activeCheck.status !== 'ok' && activeCheck.status !== 'disabled' && activeCheck.status !== 'not_applicable' && ( -
-
{activeCheck.message || t('configMCPCommandMissing')}
- {activeCheck.package && ( -
{t('configMCPInstallSuggested', { pkg: activeCheck.package })}
- )} - {activeCheck.installable && ( - - )} -
- )} -
- -
-
- {activeCheck?.resolved ? activeCheck.resolved : ''} -
-
- {editingName && ( - - )} - - -
-
-
+ +
+ {activeCheck?.resolved ? activeCheck.resolved : ''} +
+
+ {editingName && ( + removeServer(editingName)} variant="danger" label={t('delete')}> + + + )} + + + + +
+
+ +
+
)}
diff --git a/webui/src/pages/Memory.tsx b/webui/src/pages/Memory.tsx index 8ee8c77..186e7e0 100644 --- a/webui/src/pages/Memory.tsx +++ b/webui/src/pages/Memory.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Trash2 } from 'lucide-react'; +import { Save, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; -import { Button, FixedButton } from '../components/Button'; +import { FixedButton } from '../components/Button'; import { TextareaField } from '../components/FormControls'; +import FileListItem from '../components/FileListItem'; const Memory: React.FC = () => { const { t } = useTranslation(); @@ -126,17 +127,24 @@ const Memory: React.FC = () => {
{files.map((f) => ( -
- - -
+ openFile(f)} + actions={( + + )} + > + {f} + ))}
@@ -145,7 +153,9 @@ const Memory: React.FC = () => {

{active || t('noFileSelected')}

- + + +
setContent(e.target.value)} className="w-full h-[50vh] lg:h-[80vh] rounded-[24px] p-4" />
diff --git a/webui/src/pages/NodeArtifacts.tsx b/webui/src/pages/NodeArtifacts.tsx index a4b1019..b5e7d08 100644 --- a/webui/src/pages/NodeArtifacts.tsx +++ b/webui/src/pages/NodeArtifacts.tsx @@ -1,27 +1,18 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { RefreshCw } from 'lucide-react'; +import { Download, RefreshCw, Scissors, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { useAppContext } from '../context/AppContext'; -import { Button, FixedButton, LinkButton } from '../components/Button'; +import ArtifactPreviewCard from '../components/ArtifactPreviewCard'; +import { FixedButton, FixedLinkButton, LinkButton } from '../components/Button'; +import CodeBlockPanel from '../components/CodeBlockPanel'; +import EmptyState from '../components/EmptyState'; import { SelectField, TextField } from '../components/FormControls'; +import ListPanel from '../components/ListPanel'; +import SummaryListItem from '../components/SummaryListItem'; +import { dataUrlForArtifact, formatArtifactBytes } from '../utils/artifacts'; import { formatLocalDateTime } from '../utils/time'; -function dataUrlForArtifact(artifact: any) { - const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream'; - const content = String(artifact?.content_base64 || '').trim(); - if (!content) return ''; - return `data:${mime};base64,${content}`; -} - -function formatBytes(value: unknown) { - const size = Number(value || 0); - if (!Number.isFinite(size) || size <= 0) return '-'; - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - const NodeArtifacts: React.FC = () => { const { t } = useTranslation(); const { q } = useAppContext(); @@ -144,7 +135,9 @@ const NodeArtifacts: React.FC = () => {
{t('nodeArtifactsHint')}
- {t('export')} + + + @@ -152,7 +145,7 @@ const NodeArtifacts: React.FC = () => {
-
+
{t('nodeArtifactsRetention')}
@@ -183,36 +176,35 @@ const NodeArtifacts: React.FC = () => { placeholder={t('nodeArtifactsKeepLatest')} dense /> - + + +
{filteredItems.length === 0 ? ( -
{t('nodeArtifactsEmpty')}
+ ) : filteredItems.map((item, index) => { const active = String(selected?.id || '') === String(item?.id || ''); return ( - + active={active} + className="border-b py-3" + title={String(item?.name || item?.source_path || `artifact-${index + 1}`)} + subtitle={`${String(item?.node || '-')} · ${String(item?.action || '-')} · ${String(item?.kind || '-')}`} + meta={formatLocalDateTime(item?.time)} + /> ); })}
-
+ -
-
{t('nodeArtifactDetail')}
+ {t('nodeArtifactDetail')}
}>
{!selected ? ( -
{t('nodeArtifactsEmpty')}
+ ) : ( <>
@@ -222,7 +214,9 @@ const NodeArtifacts: React.FC = () => {
{t('download')} - + deleteArtifact(String(selected?.id || ''))} variant="danger" label={t('delete')}> + +
@@ -230,36 +224,30 @@ const NodeArtifacts: React.FC = () => {
{t('node')}
{String(selected?.node || '-')}
{t('action')}
{String(selected?.action || '-')}
{t('kind')}
{String(selected?.kind || '-')}
-
{t('size')}
{formatBytes(selected?.size_bytes)}
+
{t('size')}
{formatArtifactBytes(selected?.size_bytes)}
{String(selected?.source_path || selected?.path || selected?.url || '-')}
- {(() => { - const kind = String(selected?.kind || '').trim().toLowerCase(); - const mime = String(selected?.mime_type || '').trim().toLowerCase(); - const isImage = kind === 'image' || mime.startsWith('image/'); - const isVideo = kind === 'video' || mime.startsWith('video/'); - const dataUrl = dataUrlForArtifact(selected); - if (isImage && dataUrl) { - return {String(selected?.name; - } - if (isVideo && dataUrl) { - return
-
+ ); diff --git a/webui/src/pages/Nodes.tsx b/webui/src/pages/Nodes.tsx index 2bb478c..1dcdec2 100644 --- a/webui/src/pages/Nodes.tsx +++ b/webui/src/pages/Nodes.tsx @@ -1,26 +1,25 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { Check, RefreshCw } from 'lucide-react'; +import { Check, Download, Play, RefreshCw, RotateCcw } from 'lucide-react'; import { useAppContext } from '../context/AppContext'; +import { dataUrlForArtifact, formatArtifactBytes } from '../utils/artifacts'; import { formatLocalDateTime } from '../utils/time'; -import { Button, FixedButton, LinkButton } from '../components/Button'; -import { SelectField, TextField, TextareaField } from '../components/FormControls'; - -function dataUrlForArtifact(artifact: any) { - const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream'; - const content = String(artifact?.content_base64 || '').trim(); - if (!content) return ''; - return `data:${mime};base64,${content}`; -} - -function formatBytes(value: unknown) { - const size = Number(value || 0); - if (!Number.isFinite(size) || size <= 0) return '-'; - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} +import { Button, FixedButton, FixedLinkButton } from '../components/Button'; +import DetailGrid from '../components/DetailGrid'; +import CodeBlockPanel from '../components/CodeBlockPanel'; +import EmptyState from '../components/EmptyState'; +import InfoBlock from '../components/InfoBlock'; +import ListPanel from '../components/ListPanel'; +import DispatchArtifactPreviewSection from '../components/nodes/DispatchArtifactPreviewSection'; +import DispatchReplayPanel from '../components/nodes/DispatchReplayPanel'; +import AgentTreePanel from '../components/nodes/AgentTreePanel'; +import NodeP2PPanel from '../components/nodes/NodeP2PPanel'; +import PageHeader from '../components/PageHeader'; +import PanelHeader from '../components/PanelHeader'; +import { SelectField, TextField } from '../components/FormControls'; +import SectionHeader from '../components/SectionHeader'; +import SummaryListItem from '../components/SummaryListItem'; const Nodes: React.FC = () => { const { t } = useTranslation(); @@ -218,22 +217,23 @@ const Nodes: React.FC = () => { return (
-
-
-

{t('nodes')}

-
{t('nodesDetailHint')}
-
- { refreshNodes(); setReloadTick((value) => value + 1); }} - variant="primary" - label={loading ? t('loading') : t('refresh')} - > - - -
+ { refreshNodes(); setReloadTick((value) => value + 1); }} + variant="primary" + label={loading ? t('loading') : t('refresh')} + > + + + )} + />
-
+ { placeholder={t('nodesFilterPlaceholder')} />
+ }>
{filteredNodes.length === 0 ? ( -
{t('noNodes')}
+ ) : filteredNodes.map((node: any, index: number) => { const nodeID = String(node?.id || `node-${index}`); const active = String(selectedNode?.id || '') === nodeID; const tags = Array.isArray(node?.tags) ? node.tags : []; return ( - + className="py-3" + active={active} + title={String(node?.name || nodeID)} + subtitle={`${nodeID} · ${String(node?.os || '-')} / ${String(node?.arch || '-')}`} + meta={`${String(node?.online ? t('online') : t('offline'))} · ${String(node?.version || '-')}`} + badges={tags.slice(0, 4).map((tag: string) => ( + + {tag} + + ))} + /> ); })}
-
+ -
-
{t('nodeDetails')}
+ +
{!selectedNode ? ( -
{t('noNodes')}
+ ) : ( <> -
-
{t('status')}
{selectedNode.online ? t('online') : t('offline')}
-
{t('time')}
{formatLocalDateTime(selectedNode.last_seen_at)}
-
{t('version')}
{String(selectedNode.version || '-')}
-
OS
{String(selectedNode.os || '-')}
-
Arch
{String(selectedNode.arch || '-')}
-
Endpoint
{String(selectedNode.endpoint || '-')}
-
+
- - {t('export')} - + + +
-
-
{t('nodeTags')}
-
- {Array.isArray(selectedNode.tags) && selectedNode.tags.length > 0 ? selectedNode.tags.join(', ') : '-'} -
-
+ + {Array.isArray(selectedNode.tags) && selectedNode.tags.length > 0 ? selectedNode.tags.join(', ') : '-'} +
-
-
{t('nodeCapabilities')}
-
- {Object.entries(selectedNode.capabilities || {}).filter(([, enabled]) => Boolean(enabled)).map(([key]) => key).join(', ') || '-'} -
-
-
-
{t('nodeActions')}
-
- {Array.isArray(selectedNode.actions) && selectedNode.actions.length > 0 ? selectedNode.actions.join(', ') : '-'} -
-
-
-
{t('nodeModels')}
-
- {Array.isArray(selectedNode.models) && selectedNode.models.length > 0 ? selectedNode.models.join(', ') : '-'} -
-
-
-
{t('nodeAgents')}
-
- {Array.isArray(selectedNode.agents) && selectedNode.agents.length > 0 ? selectedNode.agents.map((item: any) => String(item?.id || '-')).join(', ') : '-'} -
-
+ + {Object.entries(selectedNode.capabilities || {}).filter(([, enabled]) => Boolean(enabled)).map(([key]) => key).join(', ') || '-'} + + + {Array.isArray(selectedNode.actions) && selectedNode.actions.length > 0 ? selectedNode.actions.join(', ') : '-'} + + + {Array.isArray(selectedNode.models) && selectedNode.models.length > 0 ? selectedNode.models.join(', ') : '-'} + + + {Array.isArray(selectedNode.agents) && selectedNode.agents.length > 0 ? selectedNode.agents.map((item: any) => String(item?.id || '-')).join(', ') : '-'} +
-
{t('nodeAlerts')}
+
{selectedNodeAlerts.length > 0 ? selectedNodeAlerts.map((alert: any, index: number) => { const severity = String(alert?.severity || 'warning'); @@ -355,46 +330,33 @@ const Nodes: React.FC = () => {
); }) : ( -
{t('nodeAlertsEmpty')}
+ )}
-
-
{t('nodeP2P')}
-
- {selectedSession ? ( -
-
{t('status')}
{String(selectedSession.status || 'unknown')}
-
{t('dashboardNodeP2PSessionRetries')}
{Number(selectedSession.retry_count || 0)}
-
{t('dashboardNodeP2PSessionReady')}
{formatLocalDateTime(selectedSession.last_ready_at)}
-
{t('dashboardNodeP2PSessionError')}
{String(selectedSession.last_error || '-')}
-
- ) : ( -
{t('dashboardNodeP2PSessionsEmpty')}
- )} -
-
+ -
-
{t('agentTree')}
-
- {Array.isArray(selectedTree?.items) && selectedTree.items.length > 0 ? selectedTree.items.map((item: any, index: number) => ( -
-
{String(item?.display_name || item?.agent_id || '-')}
-
{String(item?.agent_id || '-')} · {String(item?.transport || '-')} · {String(item?.role || '-')}
-
- )) : ( -
{t('noAgentTree')}
- )} -
-
+ )}
-
+ -
+
{t('nodeDispatchDetail')}
@@ -421,38 +383,36 @@ const Nodes: React.FC = () => { const key = `${item?.time || ''}:${item?.node || ''}:${item?.action || ''}`; const active = `${selectedDispatch?.time || ''}:${selectedDispatch?.node || ''}:${selectedDispatch?.action || ''}` === key; return ( - + className="border-b border-zinc-800/60 py-2" + active={active} + title={`${item?.action || '-'} · ${item?.used_transport || '-'}`} + subtitle={`${formatLocalDateTime(item?.time)} · ${Number(item?.duration_ms || 0)}ms · ${Number(item?.artifact_count || 0)} ${t('dashboardNodeDispatchArtifacts')}`} + trailing={active ? ( + + + + ) : null} + /> ); })}
{!selectedDispatch ? ( -
{t('dashboardNodeDispatchesEmpty')}
+ ) : ( <>
{t('nodeDispatchDetail')}
- - + + + + + +
@@ -464,88 +424,41 @@ const Nodes: React.FC = () => {
{t('status')}
{selectedDispatch.ok ? 'ok' : 'error'}
-
-
{t('error')}
-
{selectedDispatch.error || '-'}
-
+ {selectedDispatch.error || '-'} -
-
-
{t('nodeReplayRequest')}
-
-
- - -
- - -
-
-
-
{t('nodeReplayResult')}
- {replayError ? ( -
{replayError}
- ) : ( -
{replayResult ? JSON.stringify(replayResult, null, 2) : '-'}
- )} -
-
+ -
-
{t('dashboardNodeDispatchArtifactPreview')}
-
- {Array.isArray(selectedDispatch.artifacts) && selectedDispatch.artifacts.length > 0 ? selectedDispatch.artifacts.map((artifact: any, artifactIndex: number) => { - const kind = String(artifact?.kind || '').trim().toLowerCase(); - const mime = String(artifact?.mime_type || '').trim().toLowerCase(); - const isImage = kind === 'image' || mime.startsWith('image/'); - const isVideo = kind === 'video' || mime.startsWith('video/'); - const dataUrl = dataUrlForArtifact(artifact); - return ( -
-
{String(artifact?.name || artifact?.source_path || `artifact-${artifactIndex + 1}`)}
-
- {[artifact?.kind, artifact?.mime_type, formatBytes(artifact?.size_bytes)].filter(Boolean).join(' · ')} -
-
- {isImage && dataUrl && {String(artifact?.name} - {isVideo && dataUrl &&
-
- ); - }) : ( -
{t('dashboardNodeDispatchesEmpty')}
- )} -
-
+ -
-
{t('rawJson')}
-
{selectedDispatchPretty}
-
+ {selectedDispatchPretty} )}
-
+ ); diff --git a/webui/src/pages/Providers.tsx b/webui/src/pages/Providers.tsx index 86b9ddc..8aefe90 100644 --- a/webui/src/pages/Providers.tsx +++ b/webui/src/pages/Providers.tsx @@ -4,12 +4,14 @@ import { RefreshCw, Save } from 'lucide-react'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; import { Button, FixedButton } from '../components/Button'; +import PageHeader from '../components/PageHeader'; import { ConfigDiffModal } from '../components/config/ConfigPageChrome'; import { ProviderProxyCard, ProviderRuntimeDrawer, ProviderRuntimeSummary, ProviderRuntimeToolbar } from '../components/config/ProviderConfigSection'; import { buildDiffRows, RuntimeWindow } from '../components/config/configUtils'; import { useConfigProviderActions } from '../components/config/useConfigProviderActions'; import { useConfigRuntimeView } from '../components/config/useConfigRuntimeView'; import { useConfigSaveAction } from '../components/config/useConfigSaveAction'; +import { cloneJSON } from '../utils/object'; const Providers: React.FC = () => { const { t } = useTranslation(); @@ -29,14 +31,10 @@ const Providers: React.FC = () => { const [oauthAccounts, setOAuthAccounts] = useState>>({}); const providerEntries = useMemo(() => { - const providers = ((cfg as any)?.providers || {}) as Record; + const providers = (((cfg as any)?.models || {}) as any)?.providers || {}; const entries: Array<[string, any]> = []; - if (providers?.proxy && typeof providers.proxy === 'object') { - entries.push(['proxy', providers.proxy]); - } - const custom = providers?.proxies; - if (custom && typeof custom === 'object' && !Array.isArray(custom)) { - Object.entries(custom).forEach(([name, value]) => entries.push([name, value])); + if (providers && typeof providers === 'object' && !Array.isArray(providers)) { + Object.entries(providers).forEach(([name, value]) => entries.push([name, value])); } return entries; }, [cfg]); @@ -89,7 +87,7 @@ const Providers: React.FC = () => { useEffect(() => { if (baseline == null && cfg && Object.keys(cfg).length > 0) { - setBaseline(JSON.parse(JSON.stringify(cfg))); + setBaseline(cloneJSON(cfg)); } }, [cfg, baseline]); @@ -149,18 +147,21 @@ const Providers: React.FC = () => {
-
-

{t('providers')}

-
- { await loadConfig(true); setTimeout(() => setBaseline(JSON.parse(JSON.stringify(cfg))), 0); }} label={t('reload')}> + + { await loadConfig(true); setTimeout(() => setBaseline(cloneJSON(cfg)), 0); }} label={t('reload')}> - -
-
+ + + +
+ } + />
{ const { t } = useTranslation(); @@ -188,37 +192,42 @@ const Skills: React.FC = () => {
-
-

{t('skills')}

-
- setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="w-full sm:w-72 disabled:opacity-60" /> - - -
-
-
- {t('skillsClawhubStatus')}: {clawhubInstalled ? t('installed') : t('notInstalled')} -
- {!clawhubInstalled && ( - - )} - refreshSkills()} label={t('refresh')}> - - - - - -
-
+ +
+ {t('skillsClawhubStatus')}: {clawhubInstalled ? t('installed') : t('notInstalled')} +
+ {!clawhubInstalled && ( + + + + )} + refreshSkills()} label={t('refresh')}> + + + + + + + } + /> + + + setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="w-full sm:w-72 disabled:opacity-60" /> + + + + + {!clawhubInstalled && (
@@ -264,9 +273,9 @@ const Skills: React.FC = () => {
- + openFileManager(s.id)} variant="accent" radius="lg" label={t('skillsFileEdit')}> + + deleteSkill(s.id)} variant="danger" radius="lg" label={t('delete')}> @@ -277,36 +286,48 @@ const Skills: React.FC = () => { {isFileModalOpen && ( -
- setIsFileModalOpen(false)} className="ui-overlay-strong absolute inset-0 backdrop-blur-sm" /> - + + + setIsFileModalOpen(false)} /> + + -
-
-
{activeFile || t('noFileSelected')}
-
- - setIsFileModalOpen(false)} radius="full" label={t('close')}> - - -
-
- setFileContent(e.target.value)} - monospace - className="flex-1 rounded-none border-0 bg-zinc-950 text-zinc-200 p-4 resize-none outline-none" - /> -
+
+ + + + + setIsFileModalOpen(false)} radius="full" label={t('close')}> + + + + } + /> + + setFileContent(e.target.value)} + monospace + className="flex-1 rounded-none border-0 bg-zinc-950 text-zinc-200 p-4 resize-none outline-none" + /> + +
+
-
+ )}
diff --git a/webui/src/pages/SubagentProfiles.tsx b/webui/src/pages/SubagentProfiles.tsx index 9a96d75..836b2b3 100644 --- a/webui/src/pages/SubagentProfiles.tsx +++ b/webui/src/pages/SubagentProfiles.tsx @@ -1,49 +1,13 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { Plus, RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Check, Plus, RefreshCw } from 'lucide-react'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; -import { Button, FixedButton } from '../components/Button'; -import { FieldBlock, SelectField, TextField, TextareaField } from '../components/FormControls'; - -type SubagentProfile = { - agent_id: string; - name?: string; - notify_main_policy?: string; - role?: string; - system_prompt_file?: string; - tool_allowlist?: string[]; - memory_namespace?: string; - max_retries?: number; - retry_backoff_ms?: number; - max_task_chars?: number; - max_result_chars?: number; - status?: 'active' | 'disabled' | string; - created_at?: number; - updated_at?: number; -}; - -type ToolAllowlistGroup = { - name: string; - description?: string; - aliases?: string[]; - tools?: string[]; -}; - -const emptyDraft: SubagentProfile = { - agent_id: '', - name: '', - notify_main_policy: 'final_only', - role: '', - system_prompt_file: '', - memory_namespace: '', - status: 'active', - tool_allowlist: [], - max_retries: 0, - retry_backoff_ms: 1000, - max_task_chars: 0, - max_result_chars: 0, -}; +import { FixedButton } from '../components/Button'; +import PageHeader from '../components/PageHeader'; +import ProfileEditorPanel from '../components/subagentProfiles/ProfileEditorPanel'; +import ProfileListPanel from '../components/subagentProfiles/ProfileListPanel'; +import { emptyDraft, toProfileDraft, type SubagentProfile, type ToolAllowlistGroup } from '../components/subagentProfiles/profileDraft'; const SubagentProfiles: React.FC = () => { const { t } = useTranslation(); @@ -77,20 +41,7 @@ const SubagentProfiles: React.FC = () => { const keep = profiles.find((p: SubagentProfile) => p.agent_id === selectedId); const next = keep || profiles[0]; setSelectedId(next.agent_id || ''); - setDraft({ - agent_id: next.agent_id || '', - name: next.name || '', - notify_main_policy: next.notify_main_policy || 'final_only', - role: next.role || '', - system_prompt_file: next.system_prompt_file || '', - memory_namespace: next.memory_namespace || '', - status: (next.status as string) || 'active', - tool_allowlist: Array.isArray(next.tool_allowlist) ? next.tool_allowlist : [], - max_retries: Number(next.max_retries || 0), - retry_backoff_ms: Number(next.retry_backoff_ms || 1000), - max_task_chars: Number(next.max_task_chars || 0), - max_result_chars: Number(next.max_result_chars || 0), - }); + setDraft(toProfileDraft(next)); }; useEffect(() => { @@ -137,20 +88,7 @@ const SubagentProfiles: React.FC = () => { const onSelect = (p: SubagentProfile) => { setSelectedId(p.agent_id || ''); - setDraft({ - agent_id: p.agent_id || '', - name: p.name || '', - notify_main_policy: p.notify_main_policy || 'final_only', - role: p.role || '', - system_prompt_file: p.system_prompt_file || '', - memory_namespace: p.memory_namespace || '', - status: (p.status as string) || 'active', - tool_allowlist: Array.isArray(p.tool_allowlist) ? p.tool_allowlist : [], - max_retries: Number(p.max_retries || 0), - retry_backoff_ms: Number(p.retry_backoff_ms || 1000), - max_task_chars: Number(p.max_task_chars || 0), - max_result_chars: Number(p.max_result_chars || 0), - }); + setDraft(toProfileDraft(p)); }; const onNew = () => { @@ -158,15 +96,6 @@ const SubagentProfiles: React.FC = () => { setDraft(emptyDraft); }; - const parseAllowlist = (text: string): string[] => { - return text - .split(',') - .map((x) => x.trim()) - .filter((x) => x.length > 0); - }; - - const allowlistText = (draft.tool_allowlist || []).join(', '); - const addAllowlistToken = (token: string) => { const list = Array.isArray(draft.tool_allowlist) ? [...draft.tool_allowlist] : []; if (!list.includes(token)) { @@ -271,216 +200,57 @@ const SubagentProfiles: React.FC = () => { return (
-
-

{t('subagentProfiles')}

-
- load()} label={t('refresh')}> - - - - - -
-
+ + load()} label={t('refresh')}> + + + + + +
+ )} + />
-
-
- {t('subagentProfiles')} -
-
- {items.map((it) => ( - - ))} - {items.length === 0 && ( -
No subagent profiles.
- )} -
-
+ -
-
- - setDraft({ ...draft, agent_id: e.target.value })} - dense - className="w-full disabled:opacity-60" - placeholder="coder" - /> - - - setDraft({ ...draft, name: e.target.value })} - dense - className="w-full" - placeholder="Code Agent" - /> - - - setDraft({ ...draft, role: e.target.value })} - dense - className="w-full" - placeholder="coding" - /> - - - setDraft({ ...draft, status: e.target.value })} - dense - className="w-full" - > - - - - - - setDraft({ ...draft, notify_main_policy: e.target.value })} - dense - className="w-full" - > - - - - - - - - - setDraft({ ...draft, system_prompt_file: e.target.value })} - dense - className="w-full" - placeholder="agents/coder/AGENT.md" - /> - - - setDraft({ ...draft, memory_namespace: e.target.value })} - dense - className="w-full" - placeholder="coder" - /> - - - setDraft({ ...draft, tool_allowlist: parseAllowlist(e.target.value) })} - dense - className="w-full" - placeholder="read_file, list_files, memory_search" - /> -
- skill_exec is inherited automatically and does not need to be listed here. -
- {groups.length > 0 && ( -
- {groups.map((g) => ( - - ))} -
- )} -
- - setPromptFileContent(e.target.value)} - dense - className="w-full min-h-[220px]" - placeholder={t('agentPromptContentPlaceholder')} - /> -
- -
-
- - setDraft({ ...draft, max_retries: Number(e.target.value) || 0 })} - dense - className="w-full" - /> - - - setDraft({ ...draft, retry_backoff_ms: Number(e.target.value) || 0 })} - dense - className="w-full" - /> - - - setDraft({ ...draft, max_task_chars: Number(e.target.value) || 0 })} - dense - className="w-full" - /> - - - setDraft({ ...draft, max_result_chars: Number(e.target.value) || 0 })} - dense - className="w-full" - /> - -
- -
- - - - -
-
+ setStatus('disabled')} + onEnable={() => setStatus('active')} + onPromptContentChange={setPromptFileContent} + onSave={save} + onSavePromptFile={savePromptFile} + promptContent={promptFileContent} + promptMeta={promptFileFound ? t('promptFileReady') : t('promptFileMissing')} + promptPlaceholder={t('agentPromptContentPlaceholder')} + roleLabel="Role" + saving={saving} + statusLabel={t('status')} + toolAllowlistHint={<>skill_exec is inherited automatically and does not need to be listed here.} + toolAllowlistLabel={t('toolAllowlist')} + maxRetriesLabel={t('maxRetries')} + retryBackoffLabel={t('retryBackoffMs')} + maxTaskCharsLabel="Max Task Chars" + maxResultCharsLabel="Max Result Chars" + />
); diff --git a/webui/src/pages/Subagents.tsx b/webui/src/pages/Subagents.tsx index a4fa131..5a06878 100644 --- a/webui/src/pages/Subagents.tsx +++ b/webui/src/pages/Subagents.tsx @@ -1,207 +1,25 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; -import { useUI } from '../context/UIContext'; -import { Activity, Server, Cpu, Network, RefreshCw } from 'lucide-react'; -import { SpaceParticles } from '../components/SpaceParticles'; +import { RefreshCw } from 'lucide-react'; import { FixedButton } from '../components/Button'; - -type SubagentTask = { - id: string; - status?: string; - label?: string; - role?: string; - agent_id?: string; - session_key?: string; - memory_ns?: string; - tool_allowlist?: string[]; - max_retries?: number; - retry_count?: number; - retry_backoff?: number; - max_task_chars?: number; - max_result_chars?: number; - created?: number; - updated?: number; - task?: string; - result?: string; - thread_id?: string; - correlation_id?: string; - waiting_for_reply?: boolean; -}; - -type RouterReply = { - task_id?: string; - thread_id?: string; - correlation_id?: string; - agent_id?: string; - status?: string; - result?: string; -}; - -type AgentThread = { - thread_id?: string; - owner?: string; - participants?: string[]; - status?: string; - topic?: string; -}; - -type AgentMessage = { - message_id?: string; - thread_id?: string; - from_agent?: string; - to_agent?: string; - reply_to?: string; - correlation_id?: string; - type?: string; - content?: string; - requires_reply?: boolean; - status?: string; - created_at?: number; -}; - -type StreamItem = { - kind?: 'event' | 'message' | string; - at?: number; - run_id?: string; - agent_id?: string; - event_type?: string; - message?: string; - retry_count?: number; - message_id?: string; - thread_id?: string; - from_agent?: string; - to_agent?: string; - reply_to?: string; - correlation_id?: string; - message_type?: string; - content?: string; - status?: string; - requires_reply?: boolean; -}; - -type RegistrySubagent = { - agent_id?: string; - enabled?: boolean; - type?: string; - transport?: string; - node_id?: string; - parent_agent_id?: string; - managed_by?: string; - notify_main_policy?: string; - display_name?: string; - role?: string; - description?: string; - system_prompt_file?: string; - prompt_file_found?: boolean; - memory_namespace?: string; - tool_allowlist?: string[]; - inherited_tools?: string[]; - effective_tools?: string[]; - tool_visibility?: { - mode?: string; - inherited_tool_count?: number; - effective_tool_count?: number; - }; - routing_keywords?: string[]; -}; - -type AgentTreeNode = { - agent_id?: string; - display_name?: string; - role?: string; - type?: string; - transport?: string; - managed_by?: string; - node_id?: string; - enabled?: boolean; - children?: AgentTreeNode[]; -}; - -type NodeTree = { - node_id?: string; - node_name?: string; - online?: boolean; - source?: string; - readonly?: boolean; - root?: { - root?: AgentTreeNode; - }; -}; - -type NodeInfo = { - id?: string; - name?: string; - endpoint?: string; - version?: string; - online?: boolean; -}; - -type AgentTaskStats = { - total: number; - running: number; - failed: number; - waiting: number; - latestStatus: string; - latestUpdated: number; - active: Array<{ id: string; status: string; title: string }>; -}; - -type GraphCardSpec = { - key: string; - branch: string; - agentID?: string; - transportType?: 'local' | 'remote'; - x: number; - y: number; - w: number; - h: number; - kind: 'node' | 'agent'; - title: string; - subtitle: string; - meta: string[]; - accentTone: 'success' | 'danger' | 'warning' | 'info' | 'accent' | 'neutral'; - online?: boolean; - clickable?: boolean; - highlighted?: boolean; - dimmed?: boolean; - hidden?: boolean; - scale: number; - onClick?: () => void; -}; - -function graphAccentBackgroundClass(accentTone: GraphCardSpec['accentTone']) { - switch (accentTone) { - case 'success': return 'bg-gradient-to-br from-transparent to-emerald-500'; - case 'danger': return 'bg-gradient-to-br from-transparent to-red-500'; - case 'warning': return 'bg-gradient-to-br from-transparent to-amber-400'; - case 'info': return 'bg-gradient-to-br from-transparent to-sky-400'; - case 'accent': return 'bg-gradient-to-br from-transparent to-violet-400'; - case 'neutral': - default: return 'bg-gradient-to-br from-transparent to-zinc-500'; - } -} - -function graphAccentIconClass(accentTone: GraphCardSpec['accentTone']) { - switch (accentTone) { - case 'success': return 'text-emerald-500'; - case 'danger': return 'topology-icon-danger'; - case 'warning': return 'text-amber-400'; - case 'info': return 'text-sky-400'; - case 'accent': return 'text-violet-400'; - case 'neutral': - default: return 'text-zinc-500'; - } -} - -type GraphLineSpec = { - path: string; - dashed?: boolean; - branch: string; - highlighted?: boolean; - dimmed?: boolean; - hidden?: boolean; -}; +import PageHeader from '../components/PageHeader'; +import SectionPanel from '../components/SectionPanel'; +import GraphCard from '../components/subagents/GraphCard'; +import TopologyCanvas from '../components/subagents/TopologyCanvas'; +import TopologyControls from '../components/subagents/TopologyControls'; +import TopologyTooltip from '../components/subagents/TopologyTooltip'; +import type { GraphCardSpec } from '../components/subagents/topologyTypes'; +import { buildTopologyGraph } from '../components/subagents/topologyGraphBuilder'; +import { useTopologyViewport } from '../components/subagents/useTopologyViewport'; +import { useSubagentRuntimeData } from '../components/subagents/useSubagentRuntimeData'; +import type { NodeTree } from '../components/subagents/subagentTypes'; +import { + formatStreamTime, + getTopologyTooltipPosition, + normalizeTitle, + summarizePreviewText, +} from '../components/subagents/topologyUtils'; type TopologyTooltipState = { title: string; @@ -213,249 +31,29 @@ type TopologyTooltipState = { transportType?: 'local' | 'remote'; } | null; -type StreamPreviewState = { - task: SubagentTask | null; - items: StreamItem[]; - taskID: string; - loading?: boolean; -}; - -type TopologyDragState = { - active: boolean; - startX: number; - startY: number; - scrollLeft: number; - scrollTop: number; -}; - -const cardWidth = 140; -const cardHeight = 140; -const clusterGap = 60; -const sectionGap = 160; -const topY = 96; -const mainY = 96; -const childStartY = 320; - -function normalizeTitle(value?: string, fallback = '-'): string { - const trimmed = `${value || ''}`.trim(); - return trimmed || fallback; -} - -function summarizeTask(task?: string, label?: string): string { - const text = normalizeTitle(label || task, '-'); - return text.length > 52 ? `${text.slice(0, 49)}...` : text; -} - -function formatStreamTime(ts?: number): string { - if (!ts) return '--:--:--'; - return new Date(ts).toLocaleTimeString([], { hour12: false }); -} - -function formatRuntimeTimestamp(value?: string): string { - const raw = `${value || ''}`.trim(); - if (!raw || raw === '0001-01-01T00:00:00Z') return '-'; - const ts = Date.parse(raw); - if (Number.isNaN(ts)) return raw; - return new Date(ts).toLocaleString(); -} - -function summarizePreviewText(value?: string, limit = 180): string { - const compact = `${value || ''}`.replace(/\s+/g, ' ').trim(); - if (!compact) return '(empty)'; - return compact.length > limit ? `${compact.slice(0, limit - 3)}...` : compact; -} - -function tokenFromQuery(q: string): string { - const raw = String(q || '').trim(); - if (!raw) return ''; - const search = raw.startsWith('?') ? raw.slice(1) : raw; - const params = new URLSearchParams(search); - return params.get('token') || ''; -} - -function bezierCurve(x1: number, y1: number, x2: number, y2: number): string { - const offset = Math.max(Math.abs(y2 - y1) * 0.5, 60); - return `M ${x1} ${y1} C ${x1} ${y1 + offset} ${x2} ${y2 - offset} ${x2} ${y2}`; -} - -function horizontalBezierCurve(x1: number, y1: number, x2: number, y2: number): string { - const offset = Math.max(Math.abs(x2 - x1) * 0.5, 60); - return `M ${x1} ${y1} C ${x1 + offset} ${y1} ${x2 - offset} ${y2} ${x2} ${y2}`; -} - -function buildTaskStats(tasks: SubagentTask[]): Record { - return tasks.reduce>((acc, task) => { - const agentID = normalizeTitle(task.agent_id, ''); - if (!agentID) return acc; - if (!acc[agentID]) { - acc[agentID] = { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] }; - } - const item = acc[agentID]; - item.total += 1; - if (task.status === 'running') item.running += 1; - if (task.waiting_for_reply) item.waiting += 1; - const updatedAt = Math.max(task.updated || 0, task.created || 0); - if (updatedAt >= item.latestUpdated) { - item.latestUpdated = updatedAt; - item.latestStatus = normalizeTitle(task.status, ''); - item.failed = task.status === 'failed' ? 1 : 0; - } - if (task.status === 'running' || task.waiting_for_reply) { - item.active.push({ - id: task.id, - status: task.status || '-', - title: summarizeTask(task.task, task.label), - }); - } - return acc; - }, {}); -} - -function GraphCard({ - card, - onHover, - onLeave, - onDragStart, -}: { - card: GraphCardSpec; - onHover: (card: GraphCardSpec, event: React.MouseEvent) => void; - onLeave: () => void; - onDragStart: (key: string, event: React.MouseEvent) => void; -}) { - const isNode = card.kind === 'node'; - const Icon = isNode ? Server : Cpu; - - return ( - -
onDragStart(card.key, e)} - onClick={(e) => { - if (e.defaultPrevented) return; - card.onClick?.(); - }} - onMouseEnter={(event) => onHover(card, event)} - onMouseMove={(event) => onHover(card, event)} - onMouseLeave={onLeave} - className={`relative w-full h-full rounded-full flex flex-col items-center justify-center gap-1 transition-all duration-300 group ${card.highlighted - ? 'scale-[1.05] z-10' - : 'hover:scale-[1.02]' - }`} - style={{ - cursor: card.clickable ? 'pointer' : 'default', - opacity: card.dimmed ? 0.3 : 1, - }} - > - {/* Sleek Glass Node Background */} -
- {/* Base dark glass */} -
- - {/* Subtle accent glow */} -
- - {/* Inner depth ring */} -
- - {/* Border ring */} -
-
- - {/* Content */} -
-
- -
- -
-
{card.title}
-
{card.subtitle}
-
- - {card.online !== undefined && ( -
- )} -
-
- - ); -} - const Subagents: React.FC = () => { const { t } = useTranslation(); const { q, nodeTrees, nodeP2P, nodeDispatchItems, subagentRuntimeItems, subagentRegistryItems } = useAppContext(); - const ui = useUI(); - - const [items, setItems] = useState([]); - const [selectedId, setSelectedId] = useState(''); - const [selectedAgentID, setSelectedAgentID] = useState(''); - const [spawnTask, setSpawnTask] = useState(''); - const [spawnAgentID, setSpawnAgentID] = useState(''); - const [spawnRole, setSpawnRole] = useState(''); - const [spawnLabel, setSpawnLabel] = useState(''); - const [steerMessage, setSteerMessage] = useState(''); - const [dispatchTask, setDispatchTask] = useState(''); - const [dispatchAgentID, setDispatchAgentID] = useState(''); - const [dispatchRole, setDispatchRole] = useState(''); - const [dispatchReply, setDispatchReply] = useState(null); - const [dispatchMerged, setDispatchMerged] = useState(''); - const [threadDetail, setThreadDetail] = useState(null); - const [threadMessages, setThreadMessages] = useState([]); - const [inboxMessages, setInboxMessages] = useState([]); - const [replyMessage, setReplyMessage] = useState(''); - const [replyToMessageID, setReplyToMessageID] = useState(''); - const [configAgentID, setConfigAgentID] = useState(''); - const [configRole, setConfigRole] = useState(''); - const [configDisplayName, setConfigDisplayName] = useState(''); - const [configSystemPromptFile, setConfigSystemPromptFile] = useState(''); - const [configToolAllowlist, setConfigToolAllowlist] = useState(''); - const [configRoutingKeywords, setConfigRoutingKeywords] = useState(''); - const [registryItems, setRegistryItems] = useState([]); - const [promptFileContent, setPromptFileContent] = useState(''); - const [promptFileFound, setPromptFileFound] = useState(false); - const [streamPreviewByAgent, setStreamPreviewByAgent] = useState>({}); const [selectedTopologyBranch, setSelectedTopologyBranch] = useState(''); const [topologyFilter, setTopologyFilter] = useState<'all' | 'running' | 'failed' | 'local' | 'remote'>('all'); - const [topologyZoom, setTopologyZoom] = useState(0.9); - const [topologyPan, setTopologyPan] = useState({ x: 0, y: 0 }); - const [nodeOverrides, setNodeOverrides] = useState>({}); - const [draggedNode, setDraggedNode] = useState(null); const [topologyTooltip, setTopologyTooltip] = useState(null); - const [topologyDragging, setTopologyDragging] = useState(false); - const topologyViewportRef = useRef(null); - const topologyDragRef = useRef<{ - active: boolean; - startX: number; - startY: number; - panX: number; - panY: number; - }>({ - active: false, - startX: 0, - startY: 0, - panX: 0, - panY: 0, - }); - const nodeDragRef = useRef<{ - startX: number; - startY: number; - initialNodeX: number; - initialNodeY: number; - }>({ startX: 0, startY: 0, initialNodeX: 0, initialNodeY: 0 }); const hasFittedRef = useRef(false); - const streamPreviewLoadingRef = useRef>({}); - - const apiPath = '/webui/api/subagents_runtime'; - const withAction = (action: string) => `${apiPath}${q}${q ? '&' : '?'}action=${encodeURIComponent(action)}`; + const previewAgentID = topologyTooltip?.transportType === 'local' ? String(topologyTooltip.agentID || '').trim() : ''; + const { + items, + recentTaskByAgent, + refresh, + registryItems, + setSelectedAgentID, + setSelectedId, + streamPreviewByAgent, + taskStats, + } = useSubagentRuntimeData({ + previewAgentID, + q, + subagentRegistryItems, + subagentRuntimeItems, + }); const openAgentStream = (agentID: string, taskID = '', branch = '') => { if (branch) setSelectedTopologyBranch(branch); @@ -463,62 +61,6 @@ const Subagents: React.FC = () => { setSelectedId(taskID); }; - const load = async () => { - try { - if (subagentRuntimeItems.length > 0 || subagentRegistryItems.length > 0) { - const arr = Array.isArray(subagentRuntimeItems) ? subagentRuntimeItems : []; - const registry = Array.isArray(subagentRegistryItems) ? subagentRegistryItems : []; - setItems(arr); - setRegistryItems(registry); - if (registry.length === 0) { - setSelectedAgentID(''); - setSelectedId(''); - } else { - const nextAgentID = selectedAgentID && registry.find((x: RegistrySubagent) => x.agent_id === selectedAgentID) - ? selectedAgentID - : (registry[0]?.agent_id || ''); - setSelectedAgentID(nextAgentID); - const nextTask = arr.find((x: SubagentTask) => x.agent_id === nextAgentID); - setSelectedId(nextTask?.id || ''); - } - return; - } - const [tasksRes, registryRes] = await Promise.all([ - fetch(withAction('list')), - fetch(withAction('registry')), - ]); - if (!tasksRes.ok) throw new Error(await tasksRes.text()); - if (!registryRes.ok) throw new Error(await registryRes.text()); - const j = await tasksRes.json(); - const registryJson = await registryRes.json(); - const arr = Array.isArray(j?.result?.items) ? j.result.items : []; - const registryItems = Array.isArray(registryJson?.result?.items) ? registryJson.result.items : []; - setItems(arr); - setRegistryItems(registryItems); - if (registryItems.length === 0) { - setSelectedAgentID(''); - setSelectedId(''); - } else { - const nextAgentID = selectedAgentID && registryItems.find((x: RegistrySubagent) => x.agent_id === selectedAgentID) - ? selectedAgentID - : (registryItems[0]?.agent_id || ''); - setSelectedAgentID(nextAgentID); - const nextTask = arr.find((x: SubagentTask) => x.agent_id === nextAgentID); - setSelectedId(nextTask?.id || ''); - } - } catch (e) { - // Mock data for preview - setItems([ - { id: 'task-1', status: 'running', agent_id: 'worker-1', role: 'worker', task: 'Process data stream', created: Date.now() } - ]); - } - }; - - useEffect(() => { - load().catch(() => { }); - }, [q, selectedAgentID, subagentRuntimeItems, subagentRegistryItems]); - - const selected = useMemo(() => items.find((x) => x.id === selectedId) || null, [items, selectedId]); const parsedNodeTrees = useMemo(() => { try { const parsed = JSON.parse(nodeTrees); @@ -527,20 +69,6 @@ const Subagents: React.FC = () => { return []; } }, [nodeTrees]); - const taskStats = useMemo(() => buildTaskStats(items), [items]); - const recentTaskByAgent = useMemo(() => { - return items.reduce>((acc, task) => { - const agentID = normalizeTitle(task.agent_id, ''); - if (!agentID) return acc; - const existing = acc[agentID]; - const currentScore = Math.max(task.updated || 0, task.created || 0); - const existingScore = existing ? Math.max(existing.updated || 0, existing.created || 0) : -1; - if (!existing || currentScore > existingScore) { - acc[agentID] = task; - } - return acc; - }, {}); - }, [items]); const p2pSessionByNode = useMemo(() => { const out: Record = {}; const sessions = Array.isArray(nodeP2P?.nodes) ? nodeP2P.nodes : []; @@ -561,366 +89,52 @@ const Subagents: React.FC = () => { }); return out; }, [nodeDispatchItems]); + const clearTopologyTooltip = () => setTopologyTooltip(null); + const { + draggedNode, + handleNodeDragStart, + handleTopologyResetZoom, + handleTopologyZoomIn, + handleTopologyZoomOut, + fitView, + moveTopologyDrag, + nodeOverrides, + startTopologyDrag, + stopTopologyDrag, + topologyDragging, + topologyPan, + topologyViewportRef, + topologyZoom, + } = useTopologyViewport({ + clearTooltip: clearTopologyTooltip, + }); const topologyGraph = useMemo(() => { - const scale = topologyZoom; - const originX = 56; - const localTree = parsedNodeTrees.find((tree) => normalizeTitle(tree.node_id, '') === 'local') || null; - const remoteTrees = parsedNodeTrees.filter((tree) => normalizeTitle(tree.node_id, '') !== 'local'); - const localRoot = localTree?.root?.root || { - agent_id: 'main', - display_name: 'Main Agent', - role: 'orchestrator', - type: 'router', - transport: 'local', - enabled: true, - children: registryItems - .filter((item) => item.agent_id && item.agent_id !== 'main' && item.managed_by === 'config.json') - .map((item) => ({ - agent_id: item.agent_id, - display_name: item.display_name, - role: item.role, - type: item.type, - transport: item.transport, - enabled: item.enabled, - children: [], - })), - }; - const localChildren = Array.isArray(localRoot.children) ? localRoot.children : []; - const localBranchWidth = Math.max(cardWidth, localChildren.length * (cardWidth + clusterGap) - clusterGap); - const localOriginX = originX; - const localMainX = localOriginX + Math.max(0, (localBranchWidth - cardWidth) / 2); - const localMainCenterX = localMainX + cardWidth / 2; - const localMainCenterY = mainY + cardHeight / 2; - const remoteClusters = remoteTrees.map((tree) => { - const root = tree.root?.root; - const children = Array.isArray(root?.children) ? root.children : []; - return { - tree, - root, - children, - width: Math.max(cardWidth, children.length * (cardWidth + clusterGap) - clusterGap), - }; + return buildTopologyGraph({ + parsedNodeTrees, + registryItems, + taskStats, + recentTaskByAgent, + selectedTopologyBranch, + topologyFilter, + topologyZoom, + nodeOverrides, + nodeP2PTransport: nodeP2P?.transport, + p2pSessionByNode, + recentDispatchByNode, + t, + onOpenAgentStream: openAgentStream, }); - const totalRemoteWidth = remoteClusters.reduce((sum, cluster, idx) => { - return sum + cluster.width + (idx > 0 ? sectionGap : 0); - }, 0); - const width = Math.max(900, localOriginX * 2 + localBranchWidth + (remoteClusters.length > 0 ? sectionGap + totalRemoteWidth : 0)); - const height = childStartY + cardHeight + 40; - const cards: GraphCardSpec[] = []; - const lines: GraphLineSpec[] = []; - const localBranch = 'local'; - const localBranchStats = { - running: 0, - failed: 0, - }; - - const localMainStats = taskStats[normalizeTitle(localRoot.agent_id, 'main')] || { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] }; - const localMainTask = recentTaskByAgent[normalizeTitle(localRoot.agent_id, 'main')]; - const localMainRegistry = registryItems.find((item) => item.agent_id === localRoot.agent_id); - localBranchStats.running += localMainStats.running; - localBranchStats.failed += localMainStats.failed; - const localMainCard: GraphCardSpec = { - key: 'agent-main', - branch: localBranch, - agentID: normalizeTitle(localRoot.agent_id, 'main'), - transportType: 'local', - kind: 'agent', - x: localMainX, - y: mainY, - w: cardWidth, - h: cardHeight, - title: normalizeTitle(localRoot.display_name, 'Main Agent'), - subtitle: `${normalizeTitle(localRoot.agent_id, 'main')} · ${normalizeTitle(localRoot.role, '-')}`, - meta: [ - `children=${localChildren.length + remoteClusters.length}`, - `total=${localMainStats.total} running=${localMainStats.running}`, - `waiting=${localMainStats.waiting} failed=${localMainStats.failed}`, - `notify=${normalizeTitle(localMainRegistry?.notify_main_policy, 'final_only')}`, - `transport=${normalizeTitle(localRoot.transport, 'local')} type=${normalizeTitle(localRoot.type, 'router')}`, - `tools=${normalizeTitle(localMainRegistry?.tool_visibility?.mode, 'allowlist')} visible=${localMainRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${localMainRegistry?.tool_visibility?.inherited_tool_count ?? 0}`, - (localMainRegistry?.inherited_tools || []).length ? `inherits: ${(localMainRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -', - localMainStats.active[0] ? `task: ${localMainStats.active[0].title}` : t('noLiveTasks'), - ], - accentTone: localMainStats.running > 0 ? 'success' : localMainStats.latestStatus === 'failed' ? 'danger' : 'warning', - clickable: true, - scale, - onClick: () => { - openAgentStream(normalizeTitle(localRoot.agent_id, 'main'), localMainTask?.id || '', localBranch); - }, - }; - cards.push(localMainCard); - - localChildren.forEach((child, idx) => { - const childX = localOriginX + idx * (cardWidth + clusterGap); - const childY = childStartY; - const stats = taskStats[normalizeTitle(child.agent_id, '')] || { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] }; - const task = recentTaskByAgent[normalizeTitle(child.agent_id, '')]; - const childRegistry = registryItems.find((item) => item.agent_id === child.agent_id); - localBranchStats.running += stats.running; - localBranchStats.failed += stats.failed; - cards.push({ - key: `local-child-${child.agent_id || idx}`, - branch: localBranch, - agentID: normalizeTitle(child.agent_id, ''), - transportType: 'local', - kind: 'agent', - x: childX, - y: childY, - w: cardWidth, - h: cardHeight, - title: normalizeTitle(child.display_name, normalizeTitle(child.agent_id, 'agent')), - subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`, - meta: [ - `total=${stats.total} running=${stats.running}`, - `waiting=${stats.waiting} failed=${stats.failed}`, - `notify=${normalizeTitle(childRegistry?.notify_main_policy, 'final_only')}`, - `transport=${normalizeTitle(child.transport, 'local')} type=${normalizeTitle(child.type, 'worker')}`, - `tools=${normalizeTitle(childRegistry?.tool_visibility?.mode, 'allowlist')} visible=${childRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${childRegistry?.tool_visibility?.inherited_tool_count ?? 0}`, - (childRegistry?.inherited_tools || []).length ? `inherits: ${(childRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -', - stats.active[0] ? `task: ${stats.active[0].title}` : task ? `last: ${summarizeTask(task.task, task.label)}` : t('noLiveTasks'), - ], - accentTone: stats.running > 0 ? 'success' : stats.latestStatus === 'failed' ? 'danger' : 'info', - clickable: true, - scale, - onClick: () => { - openAgentStream(normalizeTitle(child.agent_id, ''), task?.id || '', localBranch); - }, - }); - lines.push({ - path: bezierCurve(localMainCard.x + cardWidth / 2, localMainCard.y + cardHeight / 2, childX + cardWidth / 2, childY + cardHeight / 2), - branch: localBranch, - }); - }); - - let remoteOffsetX = localOriginX + localBranchWidth + sectionGap; - remoteClusters.forEach((cluster, treeIndex) => { - const { tree, root: treeRoot, children } = cluster; - const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`; - const nodeID = normalizeTitle(tree.node_id, ''); - const p2pSession = p2pSessionByNode[nodeID]; - const recentDispatch = recentDispatchByNode[nodeID]; - const rootX = remoteOffsetX + Math.max(0, (cluster.width - cardWidth) / 2); - if (!treeRoot) return; - const rootCard: GraphCardSpec = { - key: `remote-root-${tree.node_id || treeIndex}`, - branch, - agentID: normalizeTitle(treeRoot.agent_id, ''), - transportType: 'remote', - kind: 'agent', - x: rootX, - y: mainY, - w: cardWidth, - h: cardHeight, - title: normalizeTitle(treeRoot.display_name, treeRoot.agent_id || 'main'), - subtitle: `${normalizeTitle(treeRoot.agent_id, '-')} · ${normalizeTitle(treeRoot.role, '-')}`, - meta: [ - `status=${tree.online ? t('online') : t('offline')}`, - `transport=${normalizeTitle(treeRoot.transport, 'node')} type=${normalizeTitle(treeRoot.type, 'router')}`, - `p2p=${normalizeTitle(nodeP2P?.transport, 'disabled')} session=${normalizeTitle(p2pSession?.status, 'unknown')}`, - `last_transport=${normalizeTitle(recentDispatch?.used_transport, '-')}${recentDispatch?.fallback_from ? ` fallback=${normalizeTitle(recentDispatch?.fallback_from, '-')}` : ''}`, - `last_ready=${formatRuntimeTimestamp(p2pSession?.last_ready_at)}`, - `retry=${Number(p2pSession?.retry_count || 0)}`, - `${t('error')}=${normalizeTitle(p2pSession?.last_error, '-')}`, - `source=${normalizeTitle(treeRoot.managed_by, tree.source || '-')}`, - t('remoteTasksUnavailable'), - ], - accentTone: !tree.online ? 'neutral' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'open' ? 'success' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'connecting' ? 'warning' : 'accent', - clickable: true, - scale, - onClick: () => { - openAgentStream(normalizeTitle(treeRoot.agent_id, ''), '', branch); - }, - }; - cards.push(rootCard); - lines.push({ - path: horizontalBezierCurve(localMainCard.x + cardWidth / 2, localMainCard.y + cardHeight / 2, rootCard.x + cardWidth / 2, rootCard.y + cardHeight / 2), - dashed: true, - branch, - }); - children.forEach((child, idx) => { - const childX = remoteOffsetX + idx * (cardWidth + clusterGap); - const childY = childStartY; - cards.push({ - key: `remote-child-${tree.node_id || treeIndex}-${child.agent_id || idx}`, - branch, - agentID: normalizeTitle(child.agent_id, ''), - transportType: 'remote', - kind: 'agent', - x: childX, - y: childY, - w: cardWidth, - h: cardHeight, - title: normalizeTitle(child.display_name, child.agent_id || 'agent'), - subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`, - meta: [ - `transport=${normalizeTitle(child.transport, 'node')} type=${normalizeTitle(child.type, 'worker')}`, - `p2p=${normalizeTitle(nodeP2P?.transport, 'disabled')} session=${normalizeTitle(p2pSession?.status, 'unknown')}`, - `last_transport=${normalizeTitle(recentDispatch?.used_transport, '-')}${recentDispatch?.fallback_from ? ` fallback=${normalizeTitle(recentDispatch?.fallback_from, '-')}` : ''}`, - `last_ready=${formatRuntimeTimestamp(p2pSession?.last_ready_at)}`, - `retry=${Number(p2pSession?.retry_count || 0)}`, - `${t('error')}=${normalizeTitle(p2pSession?.last_error, '-')}`, - `source=${normalizeTitle(child.managed_by, 'remote_webui')}`, - t('remoteTasksUnavailable'), - ], - accentTone: normalizeTitle(p2pSession?.status, '').toLowerCase() === 'open' ? 'success' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'connecting' ? 'warning' : 'accent', - clickable: true, - scale, - onClick: () => { - openAgentStream(normalizeTitle(child.agent_id, ''), '', branch); - }, - }); - lines.push({ - path: bezierCurve(rootCard.x + cardWidth / 2, rootCard.y + cardHeight / 2, childX + cardWidth / 2, childY + cardHeight / 2), - branch, - }); - }); - remoteOffsetX += cluster.width + sectionGap; - }); - - const highlightedBranch = selectedTopologyBranch.trim(); - const branchFilters = new Map(); - branchFilters.set(localBranch, topologyFilter === 'all' || topologyFilter === 'local' || (topologyFilter === 'running' && localBranchStats.running > 0) || (topologyFilter === 'failed' && localBranchStats.failed > 0)); - remoteTrees.forEach((tree, treeIndex) => { - const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`; - branchFilters.set(branch, topologyFilter === 'all' || topologyFilter === 'remote'); - }); - const decoratedCards = cards.map((card) => { - const override = nodeOverrides[card.key]; - return { - ...card, - x: override ? override.x : card.x, - y: override ? override.y : card.y, - hidden: branchFilters.get(card.branch) === false, - highlighted: !highlightedBranch || card.branch === highlightedBranch, - dimmed: branchFilters.get(card.branch) === false ? true : !!highlightedBranch && card.branch !== highlightedBranch, - }; - }); - - // Recalculate lines based on potentially overridden card positions - const recalculatedLines: GraphLineSpec[] = []; - - // Helper to find a card's current position - const getCardPos = (key: string) => { - const c = decoratedCards.find(c => c.key === key); - return c ? { cx: c.x + cardWidth / 2, cy: c.y + cardHeight / 2 } : null; - }; - - const localMainPos = getCardPos('agent-main'); - - localChildren.forEach((child, idx) => { - const childKey = `local-child-${child.agent_id || idx}`; - const childPos = getCardPos(childKey); - if (localMainPos && childPos) { - recalculatedLines.push({ - path: bezierCurve(localMainPos.cx, localMainPos.cy, childPos.cx, childPos.cy), - branch: localBranch, - }); - } - }); - - remoteClusters.forEach((cluster, treeIndex) => { - const branch = `node:${normalizeTitle(cluster.tree.node_id, `remote-${treeIndex}`)}`; - const remoteRootKey = `remote-root-${cluster.tree.node_id || treeIndex}`; - const remoteRootPos = getCardPos(remoteRootKey); - - if (localMainPos && remoteRootPos) { - recalculatedLines.push({ - path: horizontalBezierCurve(localMainPos.cx, localMainPos.cy, remoteRootPos.cx, remoteRootPos.cy), - dashed: true, - branch, - }); - } - - cluster.children.forEach((child, idx) => { - const childKey = `remote-child-${cluster.tree.node_id || treeIndex}-${child.agent_id || idx}`; - const childPos = getCardPos(childKey); - if (remoteRootPos && childPos) { - recalculatedLines.push({ - path: bezierCurve(remoteRootPos.cx, remoteRootPos.cy, childPos.cx, childPos.cy), - branch, - }); - } - }); - }); - - const decoratedLines = recalculatedLines.map((line) => ({ - ...line, - hidden: branchFilters.get(line.branch) === false, - highlighted: !highlightedBranch || line.branch === highlightedBranch, - dimmed: branchFilters.get(line.branch) === false ? true : !!highlightedBranch && line.branch !== highlightedBranch, - })); - - return { width, height, cards: decoratedCards, lines: decoratedLines }; }, [parsedNodeTrees, registryItems, taskStats, recentTaskByAgent, selectedTopologyBranch, topologyFilter, t, topologyZoom, nodeOverrides, nodeP2P, p2pSessionByNode, recentDispatchByNode]); - const fitView = () => { - const viewport = topologyViewportRef.current; - if (!viewport || !topologyGraph.width) return; - const availableW = viewport.clientWidth; - const availableH = viewport.clientHeight; - - const fitted = Math.min(1.15, Math.max(0.2, (availableW - 48) / topologyGraph.width)); - - setTopologyZoom(fitted); - setTopologyPan({ - x: (availableW - topologyGraph.width * fitted) / 2, - y: Math.max(24, (availableH - topologyGraph.height * fitted) / 2) - }); - }; - useEffect(() => { if (!hasFittedRef.current && topologyGraph.width > 0) { - fitView(); + fitView(topologyGraph.width, topologyGraph.height); hasFittedRef.current = true; } - }, [topologyGraph.width]); - - useEffect(() => { - const viewport = topologyViewportRef.current; - if (!viewport) return; - - const handleWheel = (e: WheelEvent) => { - if (!(e.ctrlKey || e.metaKey)) { - return; - } - e.preventDefault(); - - const rect = viewport.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - setTopologyZoom(prevZoom => { - const zoomSensitivity = 0.002; - const delta = -e.deltaY * zoomSensitivity; - const newZoom = Math.min(Math.max(0.1, prevZoom * (1 + delta)), 4); - - setTopologyPan(prevPan => { - const scaleRatio = newZoom / prevZoom; - const newPanX = mouseX - (mouseX - prevPan.x) * scaleRatio; - const newPanY = mouseY - (mouseY - prevPan.y) * scaleRatio; - return { x: newPanX, y: newPanY }; - }); - - return newZoom; - }); - }; - - viewport.addEventListener('wheel', handleWheel, { passive: false }); - return () => viewport.removeEventListener('wheel', handleWheel); - }, []); + }, [fitView, topologyGraph.height, topologyGraph.width]); const handleTopologyHover = (card: GraphCardSpec, event: React.MouseEvent) => { - const tooltipWidth = 360; - const tooltipHeight = 420; - let x = event.clientX + 14; - let y = event.clientY + 14; - - if (x + tooltipWidth > window.innerWidth) { - x = event.clientX - tooltipWidth - 14; - } - if (y + tooltipHeight > window.innerHeight) { - y = event.clientY - tooltipHeight - 14; - } + const { x, y } = getTopologyTooltipPosition(event.clientX, event.clientY); setTopologyTooltip({ title: card.title, @@ -933,487 +147,86 @@ const Subagents: React.FC = () => { }); }; - const clearTopologyTooltip = () => setTopologyTooltip(null); - - const handleNodeDragStart = (key: string, event: React.MouseEvent) => { - if (event.button !== 0) return; - event.stopPropagation(); - - const card = topologyGraph.cards.find(c => c.key === key); - if (!card) return; - - setDraggedNode(key); - nodeDragRef.current = { - startX: event.clientX, - startY: event.clientY, - initialNodeX: card.x, - initialNodeY: card.y, - }; - clearTopologyTooltip(); - }; - - const startTopologyDrag = (event: React.MouseEvent) => { - if (event.button !== 0) return; - topologyDragRef.current = { - active: true, - startX: event.clientX, - startY: event.clientY, - panX: topologyPan.x, - panY: topologyPan.y, - }; - setTopologyDragging(true); - clearTopologyTooltip(); - }; - - const moveTopologyDrag = (event: React.MouseEvent) => { - if (draggedNode) { - const deltaX = (event.clientX - nodeDragRef.current.startX) / topologyZoom; - const deltaY = (event.clientY - nodeDragRef.current.startY) / topologyZoom; - - setNodeOverrides(prev => ({ - ...prev, - [draggedNode]: { - x: nodeDragRef.current.initialNodeX + deltaX, - y: nodeDragRef.current.initialNodeY + deltaY, - } - })); - return; - } - - if (!topologyDragRef.current.active) return; - const deltaX = event.clientX - topologyDragRef.current.startX; - const deltaY = event.clientY - topologyDragRef.current.startY; - setTopologyPan({ - x: topologyDragRef.current.panX + deltaX, - y: topologyDragRef.current.panY + deltaY, - }); - }; - - const stopTopologyDrag = () => { - if (draggedNode) { - setDraggedNode(null); - } - if (topologyDragRef.current.active) { - topologyDragRef.current.active = false; - setTopologyDragging(false); - } - }; - - const callAction = async (payload: Record) => { - const r = await fetch(`${apiPath}${q}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!r.ok) { - await ui.notify({ title: t('requestFailed'), message: await r.text() }); - return null; - } - return r.json(); - }; - - const loadThreadAndInbox = async (task: SubagentTask | null) => { - if (!task?.id) { - setThreadDetail(null); - setThreadMessages([]); - setInboxMessages([]); - return; - } - try { - const [threadRes, inboxRes] = await Promise.all([ - callAction({ action: 'thread', id: task.id, limit: 50 }), - callAction({ action: 'inbox', id: task.id, limit: 50 }), - ]); - setThreadDetail(threadRes?.result?.thread || null); - setThreadMessages(Array.isArray(threadRes?.result?.messages) ? threadRes.result.messages : []); - setInboxMessages(Array.isArray(inboxRes?.result?.messages) ? inboxRes.result.messages : []); - } catch (e) { } - }; - - const loadStreamPreview = async (agentID: string, task: SubagentTask | null) => { - const taskID = task?.id || ''; - if (!agentID) return; - if (streamPreviewLoadingRef.current[agentID] === taskID) return; - const existing = streamPreviewByAgent[agentID]; - if (existing && existing.taskID === taskID && !existing.loading) return; - - streamPreviewLoadingRef.current[agentID] = taskID; - setStreamPreviewByAgent((prev) => ({ - ...prev, - [agentID]: { - task: task || null, - items: prev[agentID]?.items || [], - taskID, - loading: !!taskID, - }, - })); - - if (!taskID) { - delete streamPreviewLoadingRef.current[agentID]; - setStreamPreviewByAgent((prev) => ({ - ...prev, - [agentID]: { task: null, items: [], taskID: '', loading: false }, - })); - return; - } - - try { - const streamRes = await callAction({ action: 'stream', id: taskID, limit: 12 }); - delete streamPreviewLoadingRef.current[agentID]; - setStreamPreviewByAgent((prev) => ({ - ...prev, - [agentID]: { - task: streamRes?.result?.task || task, - items: Array.isArray(streamRes?.result?.items) ? streamRes.result.items : [], - taskID, - loading: false, - }, - })); - } catch { - delete streamPreviewLoadingRef.current[agentID]; - setStreamPreviewByAgent((prev) => ({ - ...prev, - [agentID]: { task: task || null, items: [], taskID, loading: false }, - })); - } - }; - - useEffect(() => { - const selectedTaskID = String(selected?.id || '').trim(); - const previewAgentID = topologyTooltip?.transportType === 'local' ? String(topologyTooltip.agentID || '').trim() : ''; - const previewTask = previewAgentID ? recentTaskByAgent[previewAgentID] || null : null; - const previewTaskID = String(previewTask?.id || '').trim(); - - if (!selectedTaskID) { - setThreadDetail(null); - setThreadMessages([]); - setInboxMessages([]); - } - if (!previewAgentID) { - return; - } - setStreamPreviewByAgent((prev) => ({ - ...prev, - [previewAgentID]: { - task: previewTask, - items: prev[previewAgentID]?.items || [], - taskID: previewTaskID, - loading: !!previewTaskID, - }, - })); - - if (!selectedTaskID && !previewTaskID) return; - - const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = new URL(`${proto}//${window.location.host}/webui/api/subagents_runtime/live`); - if (tokenFromQuery(q)) url.searchParams.set('token', tokenFromQuery(q)); - if (selectedTaskID) url.searchParams.set('task_id', selectedTaskID); - if (previewTaskID) url.searchParams.set('preview_task_id', previewTaskID); - - const ws = new WebSocket(url.toString()); - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - const payload = msg?.payload || {}; - if (payload.thread) { - setThreadDetail(payload.thread.thread || null); - setThreadMessages(Array.isArray(payload.thread.messages) ? payload.thread.messages : []); - } - if (payload.inbox) { - setInboxMessages(Array.isArray(payload.inbox.messages) ? payload.inbox.messages : []); - } - if (previewAgentID && payload.preview) { - setStreamPreviewByAgent((prev) => ({ - ...prev, - [previewAgentID]: { - task: payload.preview.task || previewTask, - items: Array.isArray(payload.preview.items) ? payload.preview.items : [], - taskID: previewTaskID, - loading: false, - }, - })); - } - } catch (err) { - console.error(err); - } - }; - ws.onerror = () => { - if (previewAgentID) { - setStreamPreviewByAgent((prev) => ({ - ...prev, - [previewAgentID]: { - task: previewTask, - items: prev[previewAgentID]?.items || [], - taskID: previewTaskID, - loading: false, - }, - })); - } - }; - return () => { - ws.close(); - }; - }, [selected?.id, topologyTooltip?.agentID, topologyTooltip?.transportType, recentTaskByAgent, q]); - return (
-
-

{t('subagentsRuntime')}

- load()} variant="primary" label={t('refresh')}> - - -
+ refresh()} variant="primary" label={t('refresh')}> + + + } + /> -
-
-
-
{t('agentTopology')}
-
{t('agentTopologyHint')}
-
-
- {(['all', 'running', 'failed', 'local', 'remote'] as const).map((filter) => ( - - ))} - {selectedTopologyBranch && ( - - )} -
- - - - -
-
- {Math.round(topologyZoom * 100)}% · {items.filter((item) => item.status === 'running').length} {t('runningTasks')} -
-
-
-
setSelectedTopologyBranch('')} + onFitView={() => fitView(topologyGraph.width, topologyGraph.height)} + onResetZoom={handleTopologyResetZoom} + onSelectFilter={setTopologyFilter} + onZoomIn={handleTopologyZoomIn} + onZoomOut={handleTopologyZoomOut} + runningCount={items.filter((item) => item.status === 'running').length} + selectedBranch={selectedTopologyBranch} + t={t} + topologyFilter={topologyFilter} + zoomPercent={Math.round(topologyZoom * 100)} + /> + } + > + ( + card.hidden ? null : ( + handleNodeDragStart(key, event, (cardKey) => { + const target = topologyGraph.cards.find((item) => item.key === cardKey); + return target ? { key: target.key, x: target.x, y: target.y } : null; + })} + /> + ) + ))} + draggedNode={!!draggedNode} + height={topologyGraph.height} + lines={topologyGraph.lines} onMouseDown={startTopologyDrag} - onMouseMove={moveTopologyDrag} - onMouseUp={stopTopologyDrag} onMouseLeave={() => { stopTopologyDrag(); clearTopologyTooltip(); }} - className="radius-canvas relative flex-1 min-h-[420px] sm:min-h-[560px] xl:min-h-[760px] overflow-hidden border border-zinc-800 bg-zinc-950/80" - style={{ cursor: topologyDragging ? 'grabbing' : 'grab' }} - > - -
-
- - - {topologyGraph.lines.map((line, idx) => ( - line.hidden ? null : ( - - {/* Faint energy track */} - - {/* Flowing light particles */} - - - ) - ))} - {topologyGraph.cards.map((card) => ( - card.hidden ? null : - ))} - -
-
- {topologyTooltip && ( -
-
-
-
{topologyTooltip.title}
-
-
{topologyTooltip.subtitle}
-
- {topologyTooltip.meta.map((line, idx) => { - if (!line.includes('=')) { - return ( -
- {line} -
- ); - } - const [key, ...rest] = line.split('='); - const value = rest.join('='); - return ( -
- {key} - {value || '-'} -
- ); - })} -
- {topologyTooltip.transportType === 'local' && topologyTooltip.agentID && ( -
-
{t('internalStream')}
- {streamPreviewByAgent[topologyTooltip.agentID]?.loading ? ( -
Loading internal stream...
- ) : streamPreviewByAgent[topologyTooltip.agentID]?.task ? ( - <> -
-
run={streamPreviewByAgent[topologyTooltip.agentID]?.task?.id || '-'}
-
- status={streamPreviewByAgent[topologyTooltip.agentID]?.task?.status || '-'} · thread={streamPreviewByAgent[topologyTooltip.agentID]?.task?.thread_id || '-'} -
-
- {streamPreviewByAgent[topologyTooltip.agentID]?.items?.length ? ( -
-
-
- {(() => { - const item = streamPreviewByAgent[topologyTooltip.agentID].items[streamPreviewByAgent[topologyTooltip.agentID].items.length - 1]; - return item.kind === 'event' - ? `${item.event_type || 'event'}${item.status ? ` · ${item.status}` : ''}` - : `${item.from_agent || '-'} -> ${item.to_agent || '-'} · ${item.message_type || 'message'}`; - })()} -
-
- {formatStreamTime(streamPreviewByAgent[topologyTooltip.agentID].items[streamPreviewByAgent[topologyTooltip.agentID].items.length - 1]?.at)} -
-
-
- {(() => { - const item = streamPreviewByAgent[topologyTooltip.agentID].items[streamPreviewByAgent[topologyTooltip.agentID].items.length - 1]; - return summarizePreviewText(item.kind === 'event' ? (item.message || '(no event message)') : (item.content || '(empty message)'), 520); - })()} -
-
- ) : ( -
No internal stream events yet.
- )} - - ) : ( -
No persisted run for this agent yet.
- )} -
- )} -
- )} -
-
+ onMouseMove={moveTopologyDrag} + onMouseUp={stopTopologyDrag} + panX={topologyPan.x} + panY={topologyPan.y} + topologyDragging={topologyDragging} + tooltip={topologyTooltip ? ( + + ) : null} + viewportRef={topologyViewportRef} + width={topologyGraph.width} + zoom={topologyZoom} + /> +
); diff --git a/webui/src/pages/TaskAudit.tsx b/webui/src/pages/TaskAudit.tsx index 29afa19..ce9790f 100644 --- a/webui/src/pages/TaskAudit.tsx +++ b/webui/src/pages/TaskAudit.tsx @@ -1,9 +1,20 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Check, RefreshCw } from 'lucide-react'; +import { RefreshCw } from 'lucide-react'; +import ArtifactPreviewCard from '../components/ArtifactPreviewCard'; import { useAppContext } from '../context/AppContext'; +import { dataUrlForArtifact, formatArtifactBytes } from '../utils/artifacts'; +import CodeBlockPanel from '../components/CodeBlockPanel'; +import DetailGrid from '../components/DetailGrid'; +import EmptyState from '../components/EmptyState'; import { FixedButton } from '../components/Button'; +import InfoBlock from '../components/InfoBlock'; +import ListPanel from '../components/ListPanel'; +import PageHeader from '../components/PageHeader'; +import PanelHeader from '../components/PanelHeader'; import { SelectField } from '../components/FormControls'; +import SummaryListItem from '../components/SummaryListItem'; +import ToolbarRow from '../components/ToolbarRow'; import { formatLocalDateTime } from '../utils/time'; type TaskAuditItem = { @@ -46,21 +57,6 @@ type NodeDispatchItem = { [key: string]: any; }; -function dataUrlForArtifact(artifact: any) { - const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream'; - const content = String(artifact?.content_base64 || '').trim(); - if (!content) return ''; - return `data:${mime};base64,${content}`; -} - -function formatBytes(value: unknown) { - const size = Number(value || 0); - if (!Number.isFinite(size) || size <= 0) return '-'; - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - const TaskAudit: React.FC = () => { const { t } = useTranslation(); const { q } = useAppContext(); @@ -111,9 +107,11 @@ const TaskAudit: React.FC = () => { return (
-
-

{t('taskAudit')}

-
+ setSourceFilter(e.target.value)}> @@ -133,206 +131,153 @@ const TaskAudit: React.FC = () => { -
-
+ + )} + />
-
-
{t('taskQueue')}
+ +
{filteredItems.length === 0 ? ( -
{t('noTaskAudit')}
+ ) : filteredItems.map((it, idx) => { const active = selected?.task_id === it.task_id && selected?.time === it.time; return ( - + active={active} + title={it.task_id || `task-${idx + 1}`} + subtitle={`${it.channel || '-'} · ${it.status} · attempts:${it.attempts || 1} · ${it.duration_ms || 0}ms · retry:${it.retry_count || 0} · ${it.source || '-'} · ${it.provider || '-'} / ${it.model || '-'}`} + meta={formatLocalDateTime(it.time)} + /> ); })}
-
+ -
-
{t('taskDetail')}
+ +
{!selected ? ( -
{t('selectTask')}
+ ) : ( <> -
-
{t('taskId')}
{selected.task_id}
-
{t('status')}
{selected.status}
-
{t('source')}
{selected.source || '-'}
-
{t('duration')}
{selected.duration_ms || 0}ms
-
{t('channel')}
{selected.channel}
-
{t('session')}
{selected.session}
-
{t('provider')}
{selected.provider || '-'}
-
{t('model')}
{selected.model || '-'}
-
{t('time')}
{formatLocalDateTime(selected.time)}
-
+ -
-
{t('inputPreview')}
-
{selected.input_preview || '-'}
-
- -
-
{t('error')}
-
{selected.error || '-'}
-
- -
-
{t('blockReason')}
-
{selected.block_reason || '-'}
-
+ {selected.input_preview || '-'} + {selected.error || '-'} + {selected.block_reason || '-'}
-
-
{t('lastPauseReason')}
-
{selected.last_pause_reason || '-'}
-
-
-
{t('lastPauseAt')}
-
{formatLocalDateTime(selected.last_pause_at)}
-
+ {selected.last_pause_reason || '-'} + {formatLocalDateTime(selected.last_pause_at)}
-
-
{t('taskLogs')}
-
{Array.isArray(selected.logs) && selected.logs.length ? selected.logs.join('\n') : '-'}
-
+ + {Array.isArray(selected.logs) && selected.logs.length ? selected.logs.join('\n') : '-'} + -
-
{t('mediaSources')}
-
- {Array.isArray(selected.media_items) && selected.media_items.length > 0 ? ( -
- {selected.media_items.map((m, i) => ( -
- [{m.channel || '-'}] {m.source || '-'} / {m.type || '-'} · {m.path || m.ref || '-'} -
- ))} -
- ) : '-'} -
-
+ + {Array.isArray(selected.media_items) && selected.media_items.length > 0 ? ( +
+ {selected.media_items.map((m, i) => ( +
+ [{m.channel || '-'}] {m.source || '-'} / {m.type || '-'} · {m.path || m.ref || '-'} +
+ ))} +
+ ) : '-'} +
-
-
{t('rawJson')}
-
{selectedPretty}
-
+ +
{selectedPretty}
+
)}
-
+ -
-
{t('dashboardNodeDispatches')}
+ +
{nodeItems.length === 0 ? ( -
{t('dashboardNodeDispatchesEmpty')}
+ ) : nodeItems.map((it, idx) => { const active = selectedNode?.time === it.time && selectedNode?.node === it.node && selectedNode?.action === it.action; return ( - + active={active} + title={`${it.node || '-'} · ${it.action || '-'}`} + subtitle={`${it.used_transport || '-'} · ${(it.duration_ms || 0)}ms · ${(it.artifact_count || 0)} ${t('dashboardNodeDispatchArtifacts')}`} + meta={formatLocalDateTime(it.time)} + /> ); })}
{!selectedNode ? ( -
{t('selectTask')}
+ ) : ( <> -
-
{t('nodeP2P')}
{selectedNode.node || '-'}
-
{t('action')}
{selectedNode.action || '-'}
-
{t('dashboardNodeDispatchTransport')}
{selectedNode.used_transport || '-'}
-
{t('dashboardNodeDispatchFallback')}
{selectedNode.fallback_from || '-'}
-
{t('duration')}
{selectedNode.duration_ms || 0}ms
-
{t('status')}
{selectedNode.ok ? 'ok' : 'error'}
-
+ -
-
{t('error')}
-
{selectedNode.error || '-'}
-
+ + {selectedNode.error || '-'} +
{t('dashboardNodeDispatchArtifactPreview')}
{Array.isArray(selectedNode.artifacts) && selectedNode.artifacts.length > 0 ? selectedNode.artifacts.map((artifact, artifactIndex) => { - const kind = String(artifact?.kind || '').trim().toLowerCase(); - const mime = String(artifact?.mime_type || '').trim().toLowerCase(); - const isImage = kind === 'image' || mime.startsWith('image/'); - const isVideo = kind === 'video' || mime.startsWith('video/'); const dataUrl = dataUrlForArtifact(artifact); return ( -
-
{String(artifact?.name || artifact?.source_path || `artifact-${artifactIndex + 1}`)}
-
- {[artifact?.kind, artifact?.mime_type, formatBytes(artifact?.size_bytes)].filter(Boolean).join(' · ')} -
-
- {isImage && dataUrl && {String(artifact?.name} - {isVideo && dataUrl &&
-
+ ); }) : ( -
-
+ )}
-
-
{t('rawJson')}
-
{JSON.stringify(selectedNode, null, 2)}
-
+ {JSON.stringify(selectedNode, null, 2)} )}
-
+
); diff --git a/webui/src/utils/artifacts.ts b/webui/src/utils/artifacts.ts new file mode 100644 index 0000000..3893ee3 --- /dev/null +++ b/webui/src/utils/artifacts.ts @@ -0,0 +1,14 @@ +export function dataUrlForArtifact(artifact: any) { + const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream'; + const content = String(artifact?.content_base64 || '').trim(); + if (!content) return ''; + return `data:${mime};base64,${content}`; +} + +export function formatArtifactBytes(value: unknown) { + const size = Number(value || 0); + if (!Number.isFinite(size) || size <= 0) return '-'; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/webui/src/utils/object.ts b/webui/src/utils/object.ts new file mode 100644 index 0000000..1c0edbc --- /dev/null +++ b/webui/src/utils/object.ts @@ -0,0 +1,3 @@ +export function cloneJSON(value: T): T { + return JSON.parse(JSON.stringify(value)); +} diff --git a/webui/src/utils/runtime.ts b/webui/src/utils/runtime.ts new file mode 100644 index 0000000..2e04004 --- /dev/null +++ b/webui/src/utils/runtime.ts @@ -0,0 +1,7 @@ +export function formatRuntimeTime(value: unknown) { + const raw = String(value || '').trim(); + if (!raw || raw === '0001-01-01T00:00:00Z') return '-'; + const ts = Date.parse(raw); + if (Number.isNaN(ts)) return raw; + return new Date(ts).toLocaleString(); +}