diff --git a/.github/pr-assets/671/after-agent-provider-annotated.png b/.github/pr-assets/671/after-agent-provider-annotated.png new file mode 100644 index 00000000..992fe2d1 Binary files /dev/null and b/.github/pr-assets/671/after-agent-provider-annotated.png differ diff --git a/.github/pr-assets/671/after-pool-member-annotated.png b/.github/pr-assets/671/after-pool-member-annotated.png new file mode 100644 index 00000000..344e22d1 Binary files /dev/null and b/.github/pr-assets/671/after-pool-member-annotated.png differ diff --git a/.github/pr-assets/671/after-pool-member.png b/.github/pr-assets/671/after-pool-member.png new file mode 100644 index 00000000..3024b1ef Binary files /dev/null and b/.github/pr-assets/671/after-pool-member.png differ diff --git a/.github/pr-assets/671/after-pool-owner-unselected-annotated.png b/.github/pr-assets/671/after-pool-owner-unselected-annotated.png new file mode 100644 index 00000000..eee7e1c9 Binary files /dev/null and b/.github/pr-assets/671/after-pool-owner-unselected-annotated.png differ diff --git a/.github/pr-assets/671/after-pool-owner-unselected.png b/.github/pr-assets/671/after-pool-owner-unselected.png new file mode 100644 index 00000000..00822f0b Binary files /dev/null and b/.github/pr-assets/671/after-pool-owner-unselected.png differ diff --git a/.github/pr-assets/671/after-pool-owner.png b/.github/pr-assets/671/after-pool-owner.png new file mode 100644 index 00000000..85fa716f Binary files /dev/null and b/.github/pr-assets/671/after-pool-owner.png differ diff --git a/.github/pr-assets/671/after-pool-wizard.png b/.github/pr-assets/671/after-pool-wizard.png new file mode 100644 index 00000000..ce900493 Binary files /dev/null and b/.github/pr-assets/671/after-pool-wizard.png differ diff --git a/.github/pr-assets/671/after-provider-list-annotated.png b/.github/pr-assets/671/after-provider-list-annotated.png new file mode 100644 index 00000000..d56d0538 Binary files /dev/null and b/.github/pr-assets/671/after-provider-list-annotated.png differ diff --git a/.github/pr-assets/671/before-agent-provider-annotated.png b/.github/pr-assets/671/before-agent-provider-annotated.png new file mode 100644 index 00000000..e408db06 Binary files /dev/null and b/.github/pr-assets/671/before-agent-provider-annotated.png differ diff --git a/.github/pr-assets/671/before-pool-member-annotated.png b/.github/pr-assets/671/before-pool-member-annotated.png new file mode 100644 index 00000000..5a346599 Binary files /dev/null and b/.github/pr-assets/671/before-pool-member-annotated.png differ diff --git a/.github/pr-assets/671/before-pool-member.png b/.github/pr-assets/671/before-pool-member.png new file mode 100644 index 00000000..f46da5f5 Binary files /dev/null and b/.github/pr-assets/671/before-pool-member.png differ diff --git a/.github/pr-assets/671/before-pool-owner-annotated.png b/.github/pr-assets/671/before-pool-owner-annotated.png new file mode 100644 index 00000000..72e2183d Binary files /dev/null and b/.github/pr-assets/671/before-pool-owner-annotated.png differ diff --git a/.github/pr-assets/671/before-pool-owner.png b/.github/pr-assets/671/before-pool-owner.png new file mode 100644 index 00000000..92646274 Binary files /dev/null and b/.github/pr-assets/671/before-pool-owner.png differ diff --git a/.github/pr-assets/671/before-provider-list-annotated.png b/.github/pr-assets/671/before-provider-list-annotated.png new file mode 100644 index 00000000..765cfa40 Binary files /dev/null and b/.github/pr-assets/671/before-provider-list-annotated.png differ diff --git a/.github/pr-assets/671/index.html b/.github/pr-assets/671/index.html new file mode 100644 index 00000000..81d2064d --- /dev/null +++ b/.github/pr-assets/671/index.html @@ -0,0 +1,152 @@ + + + + + +PR #671 — Pool Management UX Evidence + + + +
+
+

PR #671 — Pool Management UX

+ +
+

+ nextlevelbuilder/goclaw#671 · + Closes #670 · + Relates #583 +

+ +
+
+

Fix 1 Selector affordance

+

Dashed primary border + icon badge + "Click to add" hint. Unmistakably interactive in both themes.

+
+
+

Fix 2 Stale pool refs

+

Backend skips unknown members instead of blocking saves. Deleted/disabled providers no longer cause errors.

+
+
+

Fix 3 Managed-by banner

+

Pool members now show which pool they belong to, with a direct link to the owner provider.

+
+
+

Fix 4 Pool discovery

+

Per-card "Pool available" badge replaces verbose info banner. One-click wizard to create pools from unpooled accounts.

+
+
+

Fix 5 Provider selector

+

Pool members hidden from agent provider dropdown. Only pool owners listed — routing handles members automatically.

+
+
+ +
+ Environment: Same GoClaw v2.65.0 instance, dark theme, user kai / tenant Master. + Before = production dashboard (claw:3000). After = local dev server (localhost:5173) with this PR. +
+ +
+

1. Pool Owner — Available Accounts Selector

+

Before: bare text, no icon, invisible border in dark mode. After: dashed primary border, + icon badge, "Click to add" label.

+
+
+
Before
+ Before: pool owner selector +
+
+
After
+ After: pool owner selector with dashed border and plus icon +
+
+
+ +
+

2. Pool Member — Managed-By Banner

+

Before: pool section completely hidden, no explanation. After: "Pool Defaults" section shows owner name + clickable link.

+
+
+
Before
+ Before: pool member page, no pool section visible +
+
+
After
+ After: pool member page with managed-by banner and link +
+
+
+ +
+

3. Provider List — Pool Discovery

+

Before: verbose info banner with pool stats took up space. After: clean per-card "Pool available" badge on each unpooled account.

+
+
+
Before
+ Before: provider list with verbose info banner +
+
+
After
+ After: provider list with per-card Pool available badges +
+
+
+ +
+

4. Agent Provider Selector — Pool Members Hidden

+

Before: pool member (codex-secondary) shown as standalone option, no pool indicator. After: member hidden, pool owner shows "Pool" badge so users know it routes to multiple accounts.

+
+
+
Before
+ Before: provider dropdown showing pool member as standalone option +
+
+
After
+ After: provider dropdown with pool member hidden +
+
+
+ +
+

5. Pool Setup Wizard

+

New one-click wizard dialog opened from any "Pool available" badge. Select owner, members, strategy — all in one step.

+
+
+
New
+ Pool setup wizard dialog with owner/member selection and strategy toggle +
+
+
+
+ + diff --git a/README.md b/README.md index 1a25508d..815be4f0 100644 --- a/README.md +++ b/README.md @@ -355,3 +355,13 @@ GoClaw is built upon the original [OpenClaw](https://github.com/openclaw/opencla ## License [CC BY-NC 4.0](LICENSE) — Creative Commons Attribution-NonCommercial 4.0 International + +## Star History + + + + + + Star History Chart + + diff --git a/internal/http/chatgpt_oauth_pool_validation.go b/internal/http/chatgpt_oauth_pool_validation.go index 94a42265..2a8d7219 100644 --- a/internal/http/chatgpt_oauth_pool_validation.go +++ b/internal/http/chatgpt_oauth_pool_validation.go @@ -66,7 +66,9 @@ func validateChatGPTOAuthPoolGraph(providers []store.LLMProviderData) error { member, ok := providersByName[memberName] if !ok { - return fmt.Errorf("provider %q references unknown OpenAI Codex pool member %q", provider.Name, memberName) + // Stale reference — member was deleted or disabled. Skip + // instead of blocking the pool owner from saving. + continue } if member.ProviderType != store.ProviderChatGPTOAuth { return fmt.Errorf("provider %q can only add chatgpt_oauth members; %q is %s", provider.Name, memberName, member.ProviderType) diff --git a/ui/web/src/components/shared/provider-model-select.tsx b/ui/web/src/components/shared/provider-model-select.tsx index d83fe453..5e27ce04 100644 --- a/ui/web/src/components/shared/provider-model-select.tsx +++ b/ui/web/src/components/shared/provider-model-select.tsx @@ -13,6 +13,7 @@ import { import { useProviders } from "@/pages/providers/hooks/use-providers"; import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify"; +import { getChatGPTOAuthPoolOwnership } from "@/pages/providers/provider-utils"; import { InfoLabel } from "./info-label"; interface ProviderModelSelectProps { @@ -66,9 +67,13 @@ export function ProviderModelSelect({ }: ProviderModelSelectProps) { const { t } = useTranslation("common"); const { providers } = useProviders(); + const poolOwnership = useMemo(() => getChatGPTOAuthPoolOwnership(providers), [providers]); + const enabledProviders = useMemo( () => providers.filter((p) => { if (!p.enabled) return false; + // Hide pool members — pool routing is handled via the owner provider + if (poolOwnership.ownerByMember.has(p.name)) return false; if (filterEmbedding) { const s = p.settings as Record | undefined; const emb = s?.embedding as { enabled?: boolean } | undefined; @@ -76,7 +81,7 @@ export function ProviderModelSelect({ } return true; }), - [providers, filterEmbedding], + [providers, poolOwnership, filterEmbedding], ); // Stable ref for callback — prevents the auto-select effect from re-running @@ -143,7 +148,14 @@ export function ProviderModelSelect({ )} {enabledProviders.map((p) => ( - {p.display_name || p.name} + + {p.display_name || p.name} + {poolOwnership.membersByOwner.has(p.name) && ( + + {t("providers:list.poolBadge")} + + )} + ))} diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index c547db5c..a9067358 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -161,6 +161,7 @@ "extraHint": "Only enabled and signed-in OpenAI Codex OAuth aliases can participate.", "emptyExtras": "Sign in another OpenAI Codex OAuth alias to unlock pooling.", "noReadyExtras": "Sign in and enable another OpenAI Codex OAuth alias to add it here.", + "clickToAdd": "Click to add", "extraSelectableHint": "Only enabled, signed-in OpenAI Codex OAuth aliases can enter the active pool.", "selectedAccountsLabel": "Accounts in Pool", "emptySelected": "No extra pool members selected yet.", diff --git a/ui/web/src/i18n/locales/en/providers.json b/ui/web/src/i18n/locales/en/providers.json index 9c24022e..fb4fbbca 100644 --- a/ui/web/src/i18n/locales/en/providers.json +++ b/ui/web/src/i18n/locales/en/providers.json @@ -233,6 +233,7 @@ "reasoningFallbackBehavior": "Fallback Behavior", "codexPoolDefaultsTitle": "Pool Defaults", "codexPoolDefaultsDescription": "Define the reusable OpenAI Codex pool owned by this alias for agents that use it as their primary provider.", + "poolManagedByDescription": "This provider is a member of the pool owned by {{owner}}. To change pool settings, go to the pool owner.", "oauthDisplayNameRecommendation": "Add a display name if you want clearer labels when multiple OpenAI Codex OAuth accounts appear in the pool.", "poolActivityTitle": "Pool Activity", "poolActivityDescription": "Aggregate runtime activity across all agents using this provider's Codex pool.", @@ -282,6 +283,8 @@ "list": { "poolOwner": "Pool owner", "poolMember": "Pool member", + "poolAvailable": "Pool available", + "poolBadge": "Pool", "standalone": "Standalone", "reasoningDefault": "Reasoning {{level}}", "managedBy": "Managed by {{provider}}", @@ -308,6 +311,27 @@ "members_one": "{{count}} member", "members_other": "{{count}} members" }, + "poolCta": { + "unpooledCount": "{{count}} accounts ready to pool", + "setupPool": "Set up pool", + "dismiss": "Dismiss" + }, + "poolWizard": { + "title": "Set Up Pool", + "description": "Combine ChatGPT accounts for automatic failover and load balancing.", + "selectOwner": "Pool Owner", + "selectOwnerHint": "The primary account that owns the pool configuration.", + "selectMembers": "Pool Members", + "selectMembersHint": "Select accounts to include in the pool.", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "selectedCount": "{{count}} selected", + "strategy": "Strategy", + "createPool": "Create Pool", + "noMembers": "Select at least one member to create a pool.", + "success": "Pool created successfully.", + "error": "Failed to create pool." + }, "toast": { "created": "Provider created", "createdDesc": "{{name}} has been added", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 7aa2fba8..bf57ebb9 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -161,6 +161,7 @@ "extraHint": "Chỉ các bí danh OpenAI Codex OAuth đã bật và đã đăng nhập mới được tham gia.", "emptyExtras": "Đăng nhập thêm một bí danh OpenAI Codex OAuth để bật pooling.", "noReadyExtras": "Đăng nhập và bật thêm một bí danh OpenAI Codex OAuth để thêm vào đây.", + "clickToAdd": "Bấm để thêm", "extraSelectableHint": "Chỉ các bí danh OpenAI Codex OAuth đã bật và đã đăng nhập mới được vào active pool.", "selectedAccountsLabel": "Tài khoản trong pool", "emptySelected": "Chưa chọn pool member bổ sung nào.", diff --git a/ui/web/src/i18n/locales/vi/providers.json b/ui/web/src/i18n/locales/vi/providers.json index a2bf56d2..a50b7edf 100644 --- a/ui/web/src/i18n/locales/vi/providers.json +++ b/ui/web/src/i18n/locales/vi/providers.json @@ -237,6 +237,7 @@ "reasoningFallbackBehavior": "Fallback Behavior", "codexPoolDefaultsTitle": "Thiết lập pool mặc định", "codexPoolDefaultsDescription": "Định nghĩa pool OpenAI Codex dùng lại do chính bí danh này sở hữu cho các agent dùng nó làm provider chính.", + "poolManagedByDescription": "Provider này là thành viên của pool thuộc sở hữu bởi {{owner}}. Để thay đổi cài đặt pool, hãy vào trang pool owner.", "oauthDisplayNameRecommendation": "Thêm tên hiển thị nếu bạn muốn nhãn rõ ràng hơn khi nhiều tài khoản OpenAI Codex OAuth xuất hiện trong pool.", "poolActivityTitle": "Hoạt động pool", "poolActivityDescription": "Hoạt động runtime tổng hợp trên tất cả agent sử dụng pool Codex của provider này.", @@ -250,6 +251,8 @@ "list": { "poolOwner": "Chủ pool", "poolMember": "Thành viên pool", + "poolAvailable": "Sẵn sàng pool", + "poolBadge": "Pool", "standalone": "Độc lập", "reasoningDefault": "Reasoning {{level}}", "managedBy": "Được quản lý bởi {{provider}}", @@ -312,6 +315,27 @@ "provider_default": "Use Model Default", "provider_defaultDesc": "Let the selected model fall back to its own default reasoning effort" }, + "poolCta": { + "unpooledCount": "{{count}} tài khoản sẵn sàng tạo pool", + "setupPool": "Thiết lập pool", + "dismiss": "Bỏ qua" + }, + "poolWizard": { + "title": "Thiết lập Pool", + "description": "Gộp các tài khoản ChatGPT để tự động chuyển đổi dự phòng và cân bằng tải.", + "selectOwner": "Chủ sở hữu Pool", + "selectOwnerHint": "Tài khoản chính sở hữu cấu hình pool.", + "selectMembers": "Thành viên Pool", + "selectMembersHint": "Chọn các tài khoản để thêm vào pool.", + "selectAll": "Chọn tất cả", + "deselectAll": "Bỏ chọn tất cả", + "selectedCount": "{{count}} đã chọn", + "strategy": "Chiến lược", + "createPool": "Tạo Pool", + "noMembers": "Chọn ít nhất một thành viên để tạo pool.", + "success": "Tạo pool thành công.", + "error": "Không thể tạo pool." + }, "toast": { "created": "Đã tạo provider", "createdDesc": "{{name}} đã được thêm", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 73a43d42..3277cba5 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -161,6 +161,7 @@ "extraHint": "只有已启用且已登录的 OpenAI Codex OAuth 别名才能参与。", "emptyExtras": "再登录一个 OpenAI Codex OAuth 别名即可启用 pooling。", "noReadyExtras": "请先登录并启用另一个 OpenAI Codex OAuth 别名,然后再添加到这里。", + "clickToAdd": "点击添加", "extraSelectableHint": "只有已启用且已登录的 OpenAI Codex OAuth 别名才能进入 active pool。", "selectedAccountsLabel": "池中的账户", "emptySelected": "还没有选择额外的池成员。", diff --git a/ui/web/src/i18n/locales/zh/providers.json b/ui/web/src/i18n/locales/zh/providers.json index d0d47782..282dcb05 100644 --- a/ui/web/src/i18n/locales/zh/providers.json +++ b/ui/web/src/i18n/locales/zh/providers.json @@ -255,6 +255,7 @@ "reasoningFallbackBehavior": "Fallback Behavior", "codexPoolDefaultsTitle": "默认账户池", "codexPoolDefaultsDescription": "为把这个别名作为主 provider 的 agent 定义由该别名拥有的可复用 OpenAI Codex 账户池。", + "poolManagedByDescription": "此 provider 是 {{owner}} 所拥有的池的成员。如需更改池设置,请前往池所有者页面。", "oauthDisplayNameRecommendation": "如果您希望多个 OpenAI Codex OAuth 账户在池里显示得更清楚,可以补充显示名称。", "poolActivityTitle": "池活动", "poolActivityDescription": "汇总所有使用该 provider Codex 池的 agent 的运行时活动。", @@ -268,6 +269,8 @@ "list": { "poolOwner": "池拥有者", "poolMember": "池成员", + "poolAvailable": "可组池", + "poolBadge": "池", "standalone": "独立账户", "reasoningDefault": "Reasoning {{level}}", "managedBy": "由 {{provider}} 管理", @@ -330,6 +333,27 @@ "provider_default": "Use Model Default", "provider_defaultDesc": "Let the selected model fall back to its own default reasoning effort" }, + "poolCta": { + "unpooledCount": "{{count}} 个账户可以组建池", + "setupPool": "设置池", + "dismiss": "忽略" + }, + "poolWizard": { + "title": "设置池", + "description": "合并 ChatGPT 账户以实现自动故障转移和负载均衡。", + "selectOwner": "池所有者", + "selectOwnerHint": "拥有池配置的主账户。", + "selectMembers": "池成员", + "selectMembersHint": "选择要加入池的账户。", + "selectAll": "全选", + "deselectAll": "取消全选", + "selectedCount": "已选 {{count}} 个", + "strategy": "策略", + "createPool": "创建池", + "noMembers": "至少选择一个成员才能创建池。", + "success": "池创建成功。", + "error": "池创建失败。" + }, "toast": { "created": "Provider已创建", "createdDesc": "{{name}} 已添加", diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index 32ea07a3..8f5995e9 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -14,6 +14,7 @@ import type { AgentData } from "@/types/agent"; import { useProviders } from "@/pages/providers/hooks/use-providers"; import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify"; +import { getChatGPTOAuthPoolOwnership } from "@/pages/providers/provider-utils"; import { useAgentPresets } from "./agent-presets"; import { agentCreateSchema, type AgentCreateFormData } from "@/schemas/agent.schema"; import { AgentIdentityAndModelFields } from "./agent-identity-and-model-fields"; @@ -55,7 +56,15 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const agentKey = watch("agentKey"); const displayName = watch("displayName"); - const enabledProviders = providers.filter((p) => p.enabled); + const poolOwnership = useMemo(() => getChatGPTOAuthPoolOwnership(providers), [providers]); + const enabledProviders = useMemo( + () => providers.filter((p) => p.enabled && !poolOwnership.ownerByMember.has(p.name)), + [providers, poolOwnership], + ); + const poolOwnerNames = useMemo( + () => new Set(poolOwnership.membersByOwner.keys()), + [poolOwnership], + ); const selectedProvider = useMemo( () => enabledProviders.find((p) => p.name === provider), [enabledProviders, provider], @@ -125,6 +134,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD onToggleProvider(provider.name)} disabled={!canEditMembership || mode === "inherit"} > - {selected ? : null} - {provider.display_name || provider.name} - + + {selected ? : } + + {provider.display_name || provider.name} + {!selected && ( + + {t("chatgptOAuthRouting.clickToAdd")} + + )} + ); })} diff --git a/ui/web/src/pages/agents/agent-identity-and-model-fields.tsx b/ui/web/src/pages/agents/agent-identity-and-model-fields.tsx index 4373daf8..50f4e46e 100644 --- a/ui/web/src/pages/agents/agent-identity-and-model-fields.tsx +++ b/ui/web/src/pages/agents/agent-identity-and-model-fields.tsx @@ -20,6 +20,7 @@ import type { ModelInfo } from "@/pages/providers/hooks/use-provider-models"; interface AgentIdentityAndModelFieldsProps { form: UseFormReturn; enabledProviders: ProviderData[]; + poolOwnerNames?: Set; models: ModelInfo[]; modelsLoading: boolean; verifying: boolean; @@ -35,6 +36,7 @@ interface AgentIdentityAndModelFieldsProps { export function AgentIdentityAndModelFields({ form, enabledProviders, + poolOwnerNames, models, modelsLoading, verifying, @@ -109,7 +111,14 @@ export function AgentIdentityAndModelFields({ {enabledProviders.map((p) => ( - {p.display_name || p.name} + + {p.display_name || p.name} + {poolOwnerNames?.has(p.name) && ( + + {t("providers:list.poolBadge")} + + )} + ))} diff --git a/ui/web/src/pages/providers/pool-setup-wizard-dialog.tsx b/ui/web/src/pages/providers/pool-setup-wizard-dialog.tsx new file mode 100644 index 00000000..00684077 --- /dev/null +++ b/ui/web/src/pages/providers/pool-setup-wizard-dialog.tsx @@ -0,0 +1,277 @@ +import { useState, useMemo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { toast } from "@/stores/use-toast-store"; +import { + buildProviderSettingsWithChatGPTOAuthRouting, +} from "@/types/provider"; +import type { EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; +import type { ProviderData, ProviderInput } from "@/types/provider"; + +interface PoolSetupWizardDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + providers: ProviderData[]; + unpooledProviders: ProviderData[]; + onSave: (ownerId: string, data: ProviderInput) => Promise; +} + +const STRATEGIES: { value: EffectiveChatGPTOAuthRoutingStrategy; labelKey: string }[] = [ + { value: "primary_first", labelKey: "list.strategy.primaryFirst" }, + { value: "round_robin", labelKey: "list.strategy.roundRobin" }, + { value: "priority_order", labelKey: "list.strategy.priorityOrder" }, +]; + +export function PoolSetupWizardDialog({ + open, + onOpenChange, + unpooledProviders, + onSave, +}: PoolSetupWizardDialogProps) { + const { t } = useTranslation("providers"); + + const [ownerId, setOwnerId] = useState(() => unpooledProviders[0]?.id ?? ""); + const [selectedMemberIds, setSelectedMemberIds] = useState>( + () => new Set(unpooledProviders.slice(1).map((p) => p.id)), + ); + const [strategy, setStrategy] = useState("primary_first"); + const [saving, setSaving] = useState(false); + + // When owner changes, auto-exclude owner from members + const handleOwnerChange = useCallback((id: string) => { + setOwnerId(id); + setSelectedMemberIds((prev) => { + const next = new Set(prev); + next.delete(id); + // add the previously-selected owner back as a potential member + if (ownerId && ownerId !== id) { + next.add(ownerId); + } + return next; + }); + }, [ownerId]); + + const ownerProvider = useMemo( + () => unpooledProviders.find((p) => p.id === ownerId), + [unpooledProviders, ownerId], + ); + + // Members list = all unpooled except the current owner + const memberCandidates = useMemo( + () => unpooledProviders.filter((p) => p.id !== ownerId), + [unpooledProviders, ownerId], + ); + + const selectedCount = useMemo( + () => memberCandidates.filter((p) => selectedMemberIds.has(p.id)).length, + [memberCandidates, selectedMemberIds], + ); + + const handleToggleMember = (id: string) => { + setSelectedMemberIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleSelectAll = () => { + setSelectedMemberIds(new Set(memberCandidates.filter((p) => p.enabled).map((p) => p.id))); + }; + + const handleDeselectAll = () => { + setSelectedMemberIds(new Set()); + }; + + const canCreate = ownerProvider && selectedCount > 0; + + const handleCreate = async () => { + if (!ownerProvider) return; + const selectedMembers = memberCandidates.filter((p) => selectedMemberIds.has(p.id)); + if (selectedMembers.length === 0) return; + + setSaving(true); + try { + const settings = buildProviderSettingsWithChatGPTOAuthRouting( + ownerProvider.settings, + { + strategy, + extra_provider_names: selectedMembers.map((p) => p.name), + }, + ); + await onSave(ownerProvider.id, { + name: ownerProvider.name, + provider_type: "chatgpt_oauth", + settings, + }); + toast.success(t("poolWizard.success")); + onOpenChange(false); + } catch { + toast.error(t("poolWizard.error")); + } finally { + setSaving(false); + } + }; + + return ( + + + + {t("poolWizard.title")} + {t("poolWizard.description")} + + +
+ {/* Pool Owner */} +
+

{t("poolWizard.selectOwner")}

+

{t("poolWizard.selectOwnerHint")}

+
+ {unpooledProviders.map((provider) => ( + + ))} +
+
+ + {/* Pool Members */} +
+

{t("poolWizard.selectMembers")}

+

{t("poolWizard.selectMembersHint")}

+
+ {memberCandidates.length === 0 && ( +
{t("poolWizard.noMembers")}
+ )} + {memberCandidates.map((provider) => { + const isDisabled = !provider.enabled; + return ( + + ); + })} +
+ + {memberCandidates.length > 0 && ( +
+ + · + + + {t("poolWizard.selectedCount", { count: selectedCount })} + +
+ )} +
+ + {/* Strategy */} +
+

{t("poolWizard.strategy")}

+
+ {STRATEGIES.map((s) => ( + + ))} +
+
+
+ + + + + +
+
+ ); +} + +function ProviderPlanBadge({ provider }: { provider: ProviderData }) { + const { t } = useTranslation("common"); + if (!provider.enabled) { + return ( + {t("disabled")} + ); + } + // plan_type is not available at this level without quota data, show nothing + return null; +} diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx index 84be4f93..da71bdf0 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx @@ -1,6 +1,11 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Copy } from "lucide-react"; +import { Link } from "react-router"; +import { + Copy, + Info, + ExternalLink, +} from "lucide-react"; import { StickySaveBar } from "@/components/shared/sticky-save-bar"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -249,6 +254,20 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) {canEditPoolRouting ? ( + ) : isOAuth && managedByProvider ? ( +
+

{t("detail.codexPoolDefaultsTitle")}

+
+ +
+

{t("detail.poolManagedByDescription", { owner: managedByProvider.display_name || managedByProvider.name })}

+ + {managedByProvider.display_name || managedByProvider.name} + + +
+
+
) : null} {isPoolOwner ? ( diff --git a/ui/web/src/pages/providers/provider-list-row.tsx b/ui/web/src/pages/providers/provider-list-row.tsx index c797a8ce..b8bd95ab 100644 --- a/ui/web/src/pages/providers/provider-list-row.tsx +++ b/ui/web/src/pages/providers/provider-list-row.tsx @@ -25,8 +25,10 @@ interface ProviderOAuthPoolSummary { interface ProviderListRowProps { provider: ProviderData; oauthPool?: ProviderOAuthPoolSummary; + showPoolHint?: boolean; onClick: () => void; onDelete?: () => void; + onPoolSetup?: () => void; } function strategyLabelKey(strategy: EffectiveChatGPTOAuthRoutingStrategy): string { @@ -38,8 +40,10 @@ function strategyLabelKey(strategy: EffectiveChatGPTOAuthRoutingStrategy): strin export function ProviderListRow({ provider, oauthPool, + showPoolHint, onClick, onDelete, + onPoolSetup, }: ProviderListRowProps) { const { t: tc } = useTranslation("common"); const { t } = useTranslation("providers"); @@ -139,6 +143,15 @@ export function ProviderListRow({ {t(oauthPool.role === "owner" ? "list.poolOwner" : "list.poolMember")} )} + {showPoolHint && !hasPoolRole && onPoolSetup ? ( + { event.stopPropagation(); onPoolSetup(); }} + > + {t("list.poolAvailable")} + + ) : null} {reasoningDefaults ? ( {t("list.reasoningDefault", { diff --git a/ui/web/src/pages/providers/providers-page.tsx b/ui/web/src/pages/providers/providers-page.tsx index 6a9917c4..e8025944 100644 --- a/ui/web/src/pages/providers/providers-page.tsx +++ b/ui/web/src/pages/providers/providers-page.tsx @@ -1,9 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useParams, useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; -import { Cpu, Info, Plus } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; +import { Cpu, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; @@ -16,6 +14,7 @@ import { useChatGPTOAuthProviderQuotas } from "./hooks/use-chatgpt-oauth-provide import { useChatGPTOAuthProviderStatuses } from "./hooks/use-chatgpt-oauth-provider-statuses"; import { ProviderFormDialog } from "./provider-form-dialog"; import { ProviderListRow } from "./provider-list-row"; +import { PoolSetupWizardDialog } from "./pool-setup-wizard-dialog"; import { getChatGPTOAuthPoolOwnership, sortProvidersForPoolHierarchy, @@ -40,19 +39,21 @@ export function ProvidersPage() { return ; } + function ProviderListView() { const { t } = useTranslation("providers"); const navigate = useNavigate(); const { providers, loading, refresh, - createProvider, deleteProvider, + createProvider, updateProvider, deleteProvider, } = useProviders(); const showSkeleton = useDeferredLoading(loading && providers.length === 0); const { statuses } = useChatGPTOAuthProviderStatuses(providers); const [search, setSearch] = useState(""); const [formOpen, setFormOpen] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); const providerByName = useMemo( @@ -67,10 +68,16 @@ function ProviderListView() { () => new Map(statuses.map((status) => [status.provider.name, status.availability])), [statuses], ); - const readyOAuthCount = statuses.filter((status) => status.authenticated).length; - const poolOwnerCount = poolOwnership.membersByOwner.size; - const poolMemberCount = poolOwnership.ownerByMember.size; - const hasConnectedChatGPTOAuthProvider = readyOAuthCount > 0; + // Unpooled = chatgpt_oauth providers that are neither pool owners nor members + const unpooledProviders = useMemo( + () => providers.filter( + (p) => + p.provider_type === "chatgpt_oauth" && + !poolOwnership.membersByOwner.has(p.name) && + !poolOwnership.ownerByMember.has(p.name), + ), + [providers, poolOwnership], + ); const filtered = useMemo(() => providers.filter( (provider) => @@ -165,31 +172,6 @@ function ProviderListView() { /> - {hasConnectedChatGPTOAuthProvider && ( - - - - {t("pageHint.title")} - - {t("pageHint.connected", { count: readyOAuthCount })} - - {poolOwnerCount > 0 && ( - - {t("pageHint.owners", { count: poolOwnerCount })} - - )} - {poolMemberCount > 0 && ( - - {t("pageHint.members", { count: poolMemberCount })} - - )} - - -

{t("pageHint.description")}

-
-
- )} -
{showSkeleton ? ( @@ -227,8 +209,15 @@ function ProviderListView() { ? quotasLoading || quotasFetching : false, } : undefined} + showPoolHint={ + p.provider_type === "chatgpt_oauth" && + !poolOwnership.ownerByMember.has(p.name) && + !poolOwnership.membersByOwner.has(p.name) && + unpooledProviders.length >= 2 + } onClick={() => navigate(`/providers/${p.id}`)} onDelete={() => setDeleteTarget(p)} + onPoolSetup={() => setWizardOpen(true)} /> ))}
@@ -256,6 +245,17 @@ function ProviderListView() { existingProviders={providers} /> + { + await updateProvider(ownerId, data); + refresh(); + }} + /> + !v && setDeleteTarget(null)}