fix(pool): improve pool management UX — clickable affordance, stale ref handling, managed-by banner (#671)

* fix(secure-cli): resolve ambiguous column in LookupByBinary JOIN query (#641)

LookupByBinary uses LEFT JOIN with secure_cli_user_credentials but
SELECT columns lacked table alias prefix, causing PostgreSQL error:
"column reference 'id' is ambiguous (SQLSTATE 42702)"

This silently broke ALL credentialed CLI exec — commands fell through
to regular shell exec without injected env vars.

Fix: use b.-prefixed column names for JOIN queries.
Also add diagnostic logging to lookupCredentialedBinary for future debugging.

* fix(agent): defer warning messages after parallel tool results (#644)

When parallel tool calls trigger loop detection warnings, the warning
messages (role="user") were inserted between tool result messages
(role="tool"). This breaks the Anthropic API when routed through
OpenAI-compatible proxies (e.g. LiteLLM): the proxy groups consecutive
tool messages into a single user message with tool_result blocks, but
an intervening user warning splits the group, causing orphaned
tool_results and HTTP 400 "tool_use ids without tool_result blocks".

Fix: accumulate warning messages during parallel result processing and
append them after all tool results, preserving the consecutive grouping.

Closes #642

* fix(docker): resolve @rollup/rollup-linux-arm64-musl missing on Alpine (#647)

Added ui/web/.npmrc with supportedArchitectures for musl+glibc/arm64+x64.
Updated Dockerfile to use --no-frozen-lockfile so pnpm fetches native rollup
binding compatible with Alpine's musl libc. Lockfile still pinned by copy order.

* docs(README): add history stars (#462)

* fix(pool): skip stale pool member references during validation

Unknown pool member references (deleted or disabled providers) now
continue instead of returning an error. Prevents stale data from
blocking provider saves.

Closes #670

* fix(ui): redesign pool member selector and add managed-by banner

Pool member selector:
- Replace invisible outline button with custom element using dashed
  primary border, + icon badge, and "Click to add" hint text
- Visible in both light and dark themes; hover transitions to solid
  border with shadow; active press scales down for tactile feedback

Managed-by banner:
- Show "Pool Defaults" section on pool members with info banner
  explaining which provider owns the pool, plus a Link navigation
- Previously this section was completely hidden with no explanation

i18n: add poolManagedByDescription and clickToAdd keys (en/vi/zh)

* docs: add before/after UI evidence for PR #671

Annotated screenshots with red callout borders marking review areas.
Self-contained HTML comparison report with dark/light theme toggle.

* feat(ui): add pool discovery badges and setup wizard

Replace verbose info banner with per-card "Pool available" badge on
unpooled ChatGPT OAuth providers. Clicking the badge opens a new
pool setup wizard dialog where users select owner, members, and
strategy in one step.

* docs: update UI evidence with pool discovery before/after

* fix(ui): hide pool members from provider selector in agent forms

Pool member providers are managed via the pool owner's routing config.
Showing them as standalone options in the agent Provider dropdown is
confusing — users may select a member directly instead of the owner,
bypassing pool routing entirely.

Filter out providers that exist in ownerByMember from the enabled
providers list in ProviderModelSelect.

* fix(ui): hide pool members from provider selector and add Pool badge

Pool member providers are filtered out of the agent Provider dropdown
in both the Create Agent dialog and the shared ProviderModelSelect
component. Pool owners display a "Pool" badge so users know the
provider routes to multiple accounts automatically.

* docs: add provider selector before/after evidence

* fix: revert stale merge in secure_cli.go and fix hardcoded i18n strings

- Revert secureCLISelectColsAliased: b.agent_id → b.is_global
  (agent_id was dropped in migration 36, stale merge conflict artifact)
- Replace hardcoded "Pool" badge text with t("providers:list.poolBadge")
  in provider-model-select and agent-identity-and-model-fields
- Replace hardcoded "Disabled" with t("common:disabled") in pool wizard
- Add list.poolBadge key to en/vi/zh locale files

---------

Co-authored-by: Viet Tran <viettranx@gmail.com>
Co-authored-by: Plateau Nguyen <nguyennlt.ncc@gmail.com>
Co-authored-by: DNT <ducconit@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-05 08:43:24 +07:00
1 parent 8aa351b02a
commit e9733e08c4
31 files changed
+645 -53

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+152
View File
@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PR #671 — Pool Management UX Evidence</title>
<style>
:root { --bg: #0d0d0d; --fg: #e8e4df; --muted: #8a8278; --accent: #e8733a; --card: #1a1714; --border: #2d2923; --badge-fix-bg: #1a2e1a; --badge-fix-fg: #4ade80; --badge-fix-bd: #2d5a2d; --badge-be-bg: #1a1a2e; --badge-be-fg: #818cf8; --badge-be-bd: #2d2d5a; }
.light { --bg: #f8f6f3; --fg: #1a1714; --muted: #6b6560; --accent: #d4612a; --card: #ffffff; --border: #e5e0da; --badge-fix-bg: #dcfce7; --badge-fix-fg: #166534; --badge-fix-bd: #bbf7d0; --badge-be-bg: #e0e7ff; --badge-be-fg: #3730a3; --badge-be-bd: #c7d2fe; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; transition: background 0.2s, color 0.2s; }
.container { max-width: 1200px; margin: 0 auto; padding: 2rem 1.5rem; }
.top-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.25rem; }
h1 { font-size: 1.5rem; font-weight: 600; }
.theme-toggle { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.35rem 0.75rem; font-size: 0.8rem; color: var(--muted); cursor: pointer; transition: all 0.2s; }
.theme-toggle:hover { color: var(--fg); border-color: var(--accent); }
.subtitle { color: var(--muted); font-size: 0.875rem; margin-bottom: 2.5rem; }
.subtitle a { color: var(--accent); text-decoration: none; }
.comparison { margin-bottom: 3rem; }
.comparison h2 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.25rem; border-left: 3px solid var(--accent); padding-left: 0.75rem; }
.comparison .desc { color: var(--muted); font-size: 0.875rem; margin-bottom: 1rem; padding-left: calc(0.75rem + 3px); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
.col { background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; transition: background 0.2s, border-color 0.2s; }
.col-header { padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border); }
.col-header.before { color: var(--muted); }
.col-header.after { color: var(--accent); }
.col img { width: 100%; display: block; }
.callout { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 2rem; font-size: 0.875rem; color: var(--muted); transition: background 0.2s, border-color 0.2s; }
.callout strong { color: var(--fg); }
.badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; }
.badge-fix { background: var(--badge-fix-bg); color: var(--badge-fix-fg); border: 1px solid var(--badge-fix-bd); }
.badge-backend { background: var(--badge-be-bg); color: var(--badge-be-fg); border: 1px solid var(--badge-be-bd); }
.fixes { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; }
.fix-item { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; flex: 1; min-width: 200px; transition: background 0.2s, border-color 0.2s; }
.fix-item h3 { font-size: 0.8rem; font-weight: 600; margin-bottom: 0.25rem; }
.fix-item p { font-size: 0.75rem; color: var(--muted); }
</style>
</head>
<body>
<div class="container">
<div class="top-bar">
<h1>PR #671 — Pool Management UX</h1>
<button class="theme-toggle" onclick="document.documentElement.classList.toggle('light'); this.textContent = document.documentElement.classList.contains('light') ? '● Dark' : '○ Light'">○ Light</button>
</div>
<p class="subtitle">
<a href="https://github.com/nextlevelbuilder/goclaw/pull/671">nextlevelbuilder/goclaw#671</a> &middot;
Closes <a href="https://github.com/nextlevelbuilder/goclaw/issues/670">#670</a> &middot;
Relates <a href="https://github.com/nextlevelbuilder/goclaw/issues/583">#583</a>
</p>
<div class="fixes">
<div class="fix-item">
<h3><span class="badge badge-fix">Fix 1</span> Selector affordance</h3>
<p>Dashed primary border + icon badge + "Click to add" hint. Unmistakably interactive in both themes.</p>
</div>
<div class="fix-item">
<h3><span class="badge badge-backend">Fix 2</span> Stale pool refs</h3>
<p>Backend skips unknown members instead of blocking saves. Deleted/disabled providers no longer cause errors.</p>
</div>
<div class="fix-item">
<h3><span class="badge badge-fix">Fix 3</span> Managed-by banner</h3>
<p>Pool members now show which pool they belong to, with a direct link to the owner provider.</p>
</div>
<div class="fix-item">
<h3><span class="badge badge-fix">Fix 4</span> Pool discovery</h3>
<p>Per-card "Pool available" badge replaces verbose info banner. One-click wizard to create pools from unpooled accounts.</p>
</div>
<div class="fix-item">
<h3><span class="badge badge-fix">Fix 5</span> Provider selector</h3>
<p>Pool members hidden from agent provider dropdown. Only pool owners listed — routing handles members automatically.</p>
</div>
</div>
<div class="callout">
<strong>Environment:</strong> 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.
</div>
<div class="comparison">
<h2>1. Pool Owner — Available Accounts Selector</h2>
<p class="desc">Before: bare text, no icon, invisible border in dark mode. After: dashed primary border, + icon badge, "Click to add" label.</p>
<div class="grid">
<div class="col">
<div class="col-header before">Before</div>
<img src="before-pool-owner-annotated.png" alt="Before: pool owner selector">
</div>
<div class="col">
<div class="col-header after">After</div>
<img src="after-pool-owner-unselected-annotated.png" alt="After: pool owner selector with dashed border and plus icon">
</div>
</div>
</div>
<div class="comparison">
<h2>2. Pool Member — Managed-By Banner</h2>
<p class="desc">Before: pool section completely hidden, no explanation. After: "Pool Defaults" section shows owner name + clickable link.</p>
<div class="grid">
<div class="col">
<div class="col-header before">Before</div>
<img src="before-pool-member-annotated.png" alt="Before: pool member page, no pool section visible">
</div>
<div class="col">
<div class="col-header after">After</div>
<img src="after-pool-member-annotated.png" alt="After: pool member page with managed-by banner and link">
</div>
</div>
</div>
<div class="comparison">
<h2>3. Provider List — Pool Discovery</h2>
<p class="desc">Before: verbose info banner with pool stats took up space. After: clean per-card "Pool available" badge on each unpooled account.</p>
<div class="grid">
<div class="col">
<div class="col-header before">Before</div>
<img src="before-provider-list-annotated.png" alt="Before: provider list with verbose info banner">
</div>
<div class="col">
<div class="col-header after">After</div>
<img src="after-provider-list-annotated.png" alt="After: provider list with per-card Pool available badges">
</div>
</div>
</div>
<div class="comparison">
<h2>4. Agent Provider Selector — Pool Members Hidden</h2>
<p class="desc">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.</p>
<div class="grid">
<div class="col">
<div class="col-header before">Before</div>
<img src="before-agent-provider-annotated.png" alt="Before: provider dropdown showing pool member as standalone option">
</div>
<div class="col">
<div class="col-header after">After</div>
<img src="after-agent-provider-annotated.png" alt="After: provider dropdown with pool member hidden">
</div>
</div>
</div>
<div class="comparison">
<h2>5. Pool Setup Wizard</h2>
<p class="desc">New one-click wizard dialog opened from any "Pool available" badge. Select owner, members, strategy — all in one step.</p>
<div class="grid" style="grid-template-columns: 1fr;">
<div class="col" style="max-width: 720px; margin: 0 auto;">
<div class="col-header after">New</div>
<img src="after-pool-wizard.png" alt="Pool setup wizard dialog with owner/member selection and strategy toggle">
</div>
</div>
</div>
</div>
</body>
</html>
+10
View File
@@ -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
<a href="https://www.star-history.com/?repos=nextlevelbuilder%2Fgoclaw&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=nextlevelbuilder/goclaw&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=nextlevelbuilder/goclaw&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=nextlevelbuilder/goclaw&type=date&legend=top-left" />
</picture>
</a>
@@ -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)
@@ -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<string, unknown> | 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) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
<span className="flex items-center gap-2">
{p.display_name || p.name}
{poolOwnership.membersByOwner.has(p.name) && (
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary">
{t("providers:list.poolBadge")}
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
+1
View File
@@ -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.",
+24
View File
@@ -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",
+1
View File
@@ -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.",
+24
View File
@@ -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",
+1
View File
@@ -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": "还没有选择额外的池成员。",
+24
View File
@@ -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}} 已添加",
@@ -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
<AgentIdentityAndModelFields
form={form}
enabledProviders={enabledProviders}
poolOwnerNames={poolOwnerNames}
models={models}
modelsLoading={modelsLoading}
verifying={verifying}
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { Check } from "lucide-react";
import { Check, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ChatGPTOAuthAvailability } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses";
import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas";
@@ -97,28 +96,42 @@ export function MembershipSection({
const selected = selectedExtras.has(provider.name);
const failureKind = getQuotaFailureKind(quotaByName?.get(provider.name));
return (
<Button
<button
key={provider.name}
type="button"
variant="outline"
size="sm"
className={cn(
"h-8 justify-start gap-1.5 rounded-lg px-3 text-left text-[13px] xl:h-9 xl:text-sm [@media(max-height:760px)]:h-8",
"group relative flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-lg border px-3 text-left text-sm transition-all duration-200 xl:h-11 [@media(max-height:760px)]:h-9",
"disabled:pointer-events-none disabled:opacity-50",
selected &&
"border-primary/40 bg-primary/10 text-foreground hover:bg-primary/15 dark:border-primary/30 dark:bg-primary/10",
"border-primary/50 bg-primary/12 text-foreground shadow-sm dark:border-primary/40 dark:bg-primary/8",
selected &&
failureKind &&
"border-amber-500/50 bg-amber-500/8 text-amber-900 dark:border-amber-500/40 dark:text-amber-200",
!selected &&
!failureKind &&
"border-dashed border-primary/30 bg-primary/5 text-foreground hover:border-solid hover:border-primary/60 hover:bg-primary/10 hover:shadow-sm active:scale-[0.98] dark:border-primary/25 dark:bg-primary/4 dark:hover:border-primary/50 dark:hover:bg-primary/8",
!selected &&
failureKind &&
"border-amber-500/40 text-amber-700 dark:text-amber-300",
selected &&
failureKind &&
"border-amber-500/40 bg-amber-500/10 text-amber-900 hover:bg-amber-500/15 dark:text-amber-200",
"border-dashed border-amber-500/30 bg-amber-500/5 text-foreground hover:border-solid hover:border-amber-500/60 hover:bg-amber-500/10 active:scale-[0.98] dark:border-amber-500/25 dark:bg-amber-500/4",
)}
onClick={() => onToggleProvider(provider.name)}
disabled={!canEditMembership || mode === "inherit"}
>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
{provider.display_name || provider.name}
</Button>
<span className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-all duration-200",
selected
? "bg-primary/25 text-primary dark:bg-primary/20"
: "bg-primary/10 text-primary/70 group-hover:bg-primary/20 group-hover:text-primary dark:bg-primary/8 dark:text-primary/60 dark:group-hover:bg-primary/15",
)}>
{selected ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
</span>
<span className="min-w-0 truncate font-medium">{provider.display_name || provider.name}</span>
{!selected && (
<span className="ml-auto text-xs text-muted-foreground transition-colors group-hover:text-primary/80">
{t("chatgptOAuthRouting.clickToAdd")}
</span>
)}
</button>
);
})}
</div>
@@ -20,6 +20,7 @@ import type { ModelInfo } from "@/pages/providers/hooks/use-provider-models";
interface AgentIdentityAndModelFieldsProps {
form: UseFormReturn<AgentCreateFormData>;
enabledProviders: ProviderData[];
poolOwnerNames?: Set<string>;
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({
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
<span className="flex items-center gap-2">
{p.display_name || p.name}
{poolOwnerNames?.has(p.name) && (
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary">
{t("providers:list.poolBadge")}
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
@@ -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<void>;
}
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<string>(() => unpooledProviders[0]?.id ?? "");
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(
() => new Set(unpooledProviders.slice(1).map((p) => p.id)),
);
const [strategy, setStrategy] = useState<EffectiveChatGPTOAuthRoutingStrategy>("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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex flex-col gap-0 p-0 sm:max-w-lg max-sm:inset-0 max-sm:overflow-y-auto">
<DialogHeader className="px-4 pt-4 sm:px-6 sm:pt-6 pb-3">
<DialogTitle>{t("poolWizard.title")}</DialogTitle>
<DialogDescription>{t("poolWizard.description")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 sm:px-6 pb-4 max-h-[60vh] sm:max-h-[440px]">
{/* Pool Owner */}
<section>
<p className="text-sm font-medium mb-1">{t("poolWizard.selectOwner")}</p>
<p className="text-xs text-muted-foreground mb-2">{t("poolWizard.selectOwnerHint")}</p>
<div className="rounded-md border divide-y overflow-hidden">
{unpooledProviders.map((provider) => (
<label
key={provider.id}
className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-muted/40 transition-colors"
>
<input
type="radio"
name="pool-owner"
value={provider.id}
checked={ownerId === provider.id}
onChange={() => handleOwnerChange(provider.id)}
className="accent-primary"
/>
<div className="flex-1 min-w-0">
<span className="block text-base md:text-sm font-medium truncate">{provider.display_name || provider.name}</span>
{provider.display_name && provider.display_name !== provider.name && (
<span className="block text-xs text-muted-foreground truncate">{provider.name}</span>
)}
</div>
<ProviderPlanBadge provider={provider} />
</label>
))}
</div>
</section>
{/* Pool Members */}
<section>
<p className="text-sm font-medium mb-1">{t("poolWizard.selectMembers")}</p>
<p className="text-xs text-muted-foreground mb-2">{t("poolWizard.selectMembersHint")}</p>
<div className="rounded-md border divide-y overflow-hidden">
{memberCandidates.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">{t("poolWizard.noMembers")}</div>
)}
{memberCandidates.map((provider) => {
const isDisabled = !provider.enabled;
return (
<label
key={provider.id}
className={[
"flex items-center gap-3 px-3 py-2 transition-colors",
isDisabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-muted/40",
].join(" ")}
>
<input
type="checkbox"
checked={selectedMemberIds.has(provider.id)}
onChange={() => !isDisabled && handleToggleMember(provider.id)}
disabled={isDisabled}
className="accent-primary"
/>
<div className="flex-1 min-w-0">
<span className="block text-base md:text-sm font-medium truncate">{provider.display_name || provider.name}</span>
{provider.display_name && provider.display_name !== provider.name && (
<span className="block text-xs text-muted-foreground truncate">{provider.name}</span>
)}
</div>
<ProviderPlanBadge provider={provider} />
</label>
);
})}
</div>
{memberCandidates.length > 0 && (
<div className="flex items-center gap-2 mt-2">
<button
type="button"
onClick={handleSelectAll}
className="text-xs text-primary hover:underline"
>
{t("poolWizard.selectAll")}
</button>
<span className="text-xs text-muted-foreground">·</span>
<button
type="button"
onClick={handleDeselectAll}
className="text-xs text-primary hover:underline"
>
{t("poolWizard.deselectAll")}
</button>
<span className="ml-auto text-xs text-muted-foreground">
{t("poolWizard.selectedCount", { count: selectedCount })}
</span>
</div>
)}
</section>
{/* Strategy */}
<section>
<p className="text-sm font-medium mb-2">{t("poolWizard.strategy")}</p>
<div className="flex flex-wrap gap-2">
{STRATEGIES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => setStrategy(s.value)}
className={[
"rounded-md border px-3 py-1.5 text-xs font-medium transition-colors",
strategy === s.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted/40",
].join(" ")}
>
{t(s.labelKey)}
</button>
))}
</div>
</section>
</div>
<DialogFooter className="px-4 pb-4 sm:px-6 sm:pb-6 pt-3 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("form.cancel")}
</Button>
<Button onClick={handleCreate} disabled={!canCreate || saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
{t("poolWizard.createPool")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ProviderPlanBadge({ provider }: { provider: ProviderData }) {
const { t } = useTranslation("common");
if (!provider.enabled) {
return (
<span className="text-[10px] text-muted-foreground shrink-0">{t("disabled")}</span>
);
}
// plan_type is not available at this level without quota data, show nothing
return null;
}
@@ -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 ? (
<ChatGPTOAuthRoutingSection title={t("detail.codexPoolDefaultsTitle")} description={t("detail.codexPoolDefaultsDescription")} currentProvider={provider.name} providers={providers} value={poolRouting} onChange={setPoolRouting} showOverrideMode={false} canManageProviders quotaByName={quotaByName} quotaLoading={quotasLoading || quotasFetching} entries={poolEntries} />
) : isOAuth && managedByProvider ? (
<section className="space-y-3 rounded-lg border border-dashed p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.codexPoolDefaultsTitle")}</h3>
<div className="flex items-start gap-3 rounded-lg bg-muted/30 px-3 py-3">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="space-y-1.5 text-sm">
<p className="text-muted-foreground">{t("detail.poolManagedByDescription", { owner: managedByProvider.display_name || managedByProvider.name })}</p>
<Link to={`/providers/${managedByProvider.id}`} className="inline-flex items-center gap-1 text-primary underline-offset-4 hover:underline">
{managedByProvider.display_name || managedByProvider.name}
<ExternalLink className="h-3 w-3" />
</Link>
</div>
</div>
</section>
) : null}
{isPoolOwner ? (
@@ -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")}
</Badge>
)}
{showPoolHint && !hasPoolRole && onPoolSetup ? (
<Badge
variant="outline"
className="h-5 cursor-pointer border-dashed border-primary/40 px-1.5 text-[10px] text-primary transition-colors hover:border-primary hover:bg-primary/10"
onClick={(event) => { event.stopPropagation(); onPoolSetup(); }}
>
{t("list.poolAvailable")}
</Badge>
) : null}
{reasoningDefaults ? (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
{t("list.reasoningDefault", {
+33 -33
View File
@@ -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 <ProviderListView />;
}
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<ProviderData | null>(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() {
/>
</div>
{hasConnectedChatGPTOAuthProvider && (
<Alert className="mt-4 border-primary/20 bg-primary/[0.04] px-3 py-2">
<Info className="h-4 w-4" />
<AlertTitle className="flex flex-wrap items-center gap-2">
<span>{t("pageHint.title")}</span>
<Badge variant="success" className="h-5 px-1.5 text-[10px]">
{t("pageHint.connected", { count: readyOAuthCount })}
</Badge>
{poolOwnerCount > 0 && (
<Badge variant="info" className="h-5 px-1.5 text-[10px]">
{t("pageHint.owners", { count: poolOwnerCount })}
</Badge>
)}
{poolMemberCount > 0 && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{t("pageHint.members", { count: poolMemberCount })}
</Badge>
)}
</AlertTitle>
<AlertDescription className="gap-1 text-xs">
<p>{t("pageHint.description")}</p>
</AlertDescription>
</Alert>
)}
<div className="mt-6">
{showSkeleton ? (
<TableSkeleton />
@@ -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)}
/>
))}
</div>
@@ -256,6 +245,17 @@ function ProviderListView() {
existingProviders={providers}
/>
<PoolSetupWizardDialog
open={wizardOpen}
onOpenChange={setWizardOpen}
providers={providers}
unpooledProviders={unpooledProviders}
onSave={async (ownerId, data) => {
await updateProvider(ownerId, data);
refresh();
}}
/>
<ConfirmDeleteDialog
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}