From e2cea0bce2ac13307d3566b1f534ade4736cd47a Mon Sep 17 00:00:00 2001 From: LPF Date: Thu, 12 Mar 2026 00:32:56 +0800 Subject: [PATCH] fix ui and oauth --- pkg/providers/oauth.go | 124 +++++++++++++----- pkg/providers/oauth_test.go | 62 +++++++++ webui/src/components/FormControls.tsx | 80 +++++++++++ webui/src/components/RecursiveConfig.tsx | 37 +++--- .../channel/ChannelFieldRenderer.tsx | 42 +++--- webui/src/components/chat/ChatComposer.tsx | 8 +- .../components/config/ConfigPageChrome.tsx | 17 ++- .../config/ProviderConfigSection.tsx | 120 ++++++++++++++--- webui/src/components/mcp/MCPServerEditor.tsx | 15 ++- .../subagentProfiles/ProfileEditorPanel.tsx | 27 ++-- .../components/subagents/TopologyControls.tsx | 9 +- webui/src/i18n/index.ts | 68 ++++++++++ webui/src/index.css | 89 +++++++++++-- webui/src/pages/ChannelSettings.tsx | 25 +++- webui/src/pages/Cron.tsx | 26 ++-- webui/src/pages/MCP.tsx | 9 +- webui/src/pages/Memory.tsx | 11 +- webui/src/pages/Providers.tsx | 71 +++++++--- webui/src/pages/Skills.tsx | 22 ++-- 19 files changed, 674 insertions(+), 188 deletions(-) diff --git a/pkg/providers/oauth.go b/pkg/providers/oauth.go index 9b7ea9d..2cb9d69 100644 --- a/pkg/providers/oauth.go +++ b/pkg/providers/oauth.go @@ -240,6 +240,12 @@ type OAuthAccountInfo struct { FailureCount int `json:"failure_count,omitempty"` LastFailure string `json:"last_failure,omitempty"` HealthScore int `json:"health_score,omitempty"` + PlanType string `json:"plan_type,omitempty"` + QuotaSource string `json:"quota_source,omitempty"` + BalanceLabel string `json:"balance_label,omitempty"` + BalanceDetail string `json:"balance_detail,omitempty"` + SubActiveStart string `json:"subscription_active_start,omitempty"` + SubActiveUntil string `json:"subscription_active_until,omitempty"` } type oauthAttempt struct { @@ -405,22 +411,7 @@ func (m *OAuthLoginManager) ListAccounts() ([]OAuthAccountInfo, error) { if session == nil { continue } - out = append(out, OAuthAccountInfo{ - Email: session.Email, - AccountID: session.AccountID, - CredentialFile: session.FilePath, - Expire: session.Expire, - LastRefresh: session.LastRefresh, - ProjectID: session.ProjectID, - AccountLabel: sessionLabel(session), - DeviceID: session.DeviceID, - ResourceURL: session.ResourceURL, - NetworkProxy: maskedProxyURL(session.NetworkProxy), - CooldownUntil: session.CooldownUntil, - FailureCount: session.FailureCount, - LastFailure: session.LastFailure, - HealthScore: sessionHealthScore(session), - }) + out = append(out, buildOAuthAccountInfo(session)) } return out, nil } @@ -443,26 +434,38 @@ func (m *OAuthLoginManager) RefreshAccount(ctx context.Context, credentialFile s if err != nil { return nil, err } - return &OAuthAccountInfo{ - Email: refreshed.Email, - AccountID: refreshed.AccountID, - CredentialFile: refreshed.FilePath, - Expire: refreshed.Expire, - LastRefresh: refreshed.LastRefresh, - ProjectID: refreshed.ProjectID, - AccountLabel: sessionLabel(refreshed), - DeviceID: refreshed.DeviceID, - ResourceURL: refreshed.ResourceURL, - NetworkProxy: maskedProxyURL(refreshed.NetworkProxy), - CooldownUntil: refreshed.CooldownUntil, - FailureCount: refreshed.FailureCount, - LastFailure: refreshed.LastFailure, - HealthScore: sessionHealthScore(refreshed), - }, nil + info := buildOAuthAccountInfo(refreshed) + return &info, nil } return nil, fmt.Errorf("oauth credential not found") } +func buildOAuthAccountInfo(session *oauthSession) OAuthAccountInfo { + planType, quotaSource, balanceLabel, balanceDetail, subActiveStart, subActiveUntil := extractOAuthBalanceMetadata(session) + return OAuthAccountInfo{ + Email: session.Email, + AccountID: session.AccountID, + CredentialFile: session.FilePath, + Expire: session.Expire, + LastRefresh: session.LastRefresh, + ProjectID: session.ProjectID, + AccountLabel: sessionLabel(session), + DeviceID: session.DeviceID, + ResourceURL: session.ResourceURL, + NetworkProxy: maskedProxyURL(session.NetworkProxy), + CooldownUntil: session.CooldownUntil, + FailureCount: session.FailureCount, + LastFailure: session.LastFailure, + HealthScore: sessionHealthScore(session), + PlanType: planType, + QuotaSource: quotaSource, + BalanceLabel: balanceLabel, + BalanceDetail: balanceDetail, + SubActiveStart: subActiveStart, + SubActiveUntil: subActiveUntil, + } +} + func (m *OAuthLoginManager) DeleteAccount(credentialFile string) error { if m == nil || m.manager == nil { return fmt.Errorf("oauth login manager not configured") @@ -1833,6 +1836,63 @@ func parseJWTClaims(token string) map[string]any { return claims } +func extractOAuthBalanceMetadata(session *oauthSession) (planType, quotaSource, balanceLabel, balanceDetail, subActiveStart, subActiveUntil string) { + if session == nil { + return "", "", "", "", "", "" + } + provider := normalizeOAuthProvider(session.Provider) + switch provider { + case defaultCodexOAuthProvider: + claims := parseJWTClaims(session.IDToken) + auth := mapFromAny(claims["https://api.openai.com/auth"]) + planType = firstNonEmpty(asString(auth["chatgpt_plan_type"]), asString(auth["plan_type"])) + subActiveStart = normalizeBalanceTime(firstNonEmpty(asString(auth["chatgpt_subscription_active_start"]), asString(auth["subscription_active_start"]))) + subActiveUntil = normalizeBalanceTime(firstNonEmpty(asString(auth["chatgpt_subscription_active_until"]), asString(auth["subscription_active_until"]))) + if planType != "" || subActiveUntil != "" || subActiveStart != "" { + quotaSource = provider + if planType != "" { + balanceLabel = strings.ToUpper(planType) + } else { + balanceLabel = "subscription" + } + switch { + case subActiveStart != "" && subActiveUntil != "": + balanceDetail = fmt.Sprintf("%s ~ %s", subActiveStart, subActiveUntil) + case subActiveUntil != "": + balanceDetail = fmt.Sprintf("until %s", subActiveUntil) + case subActiveStart != "": + balanceDetail = fmt.Sprintf("from %s", subActiveStart) + } + } + case defaultGeminiOAuthProvider, defaultAntigravityOAuthProvider: + if pid := strings.TrimSpace(session.ProjectID); pid != "" { + quotaSource = provider + balanceLabel = "project" + balanceDetail = pid + } + } + return planType, quotaSource, balanceLabel, balanceDetail, subActiveStart, subActiveUntil +} + +func normalizeBalanceTime(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, trimmed); err == nil { + return parsed.Format(time.RFC3339) + } + } + return trimmed +} + func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { diff --git a/pkg/providers/oauth_test.go b/pkg/providers/oauth_test.go index 2502be6..9bbecbc 100644 --- a/pkg/providers/oauth_test.go +++ b/pkg/providers/oauth_test.go @@ -3,6 +3,7 @@ package providers import ( "bytes" "context" + "encoding/base64" "encoding/json" "io" "net/http" @@ -1136,6 +1137,67 @@ func TestOAuthManagerPrefersHealthierAccount(t *testing.T) { } } +func TestOAuthLoginManagerListAccountsIncludesCodexPlanMetadata(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + credFile := filepath.Join(dir, "codex-plan.json") + idToken := buildTestJWT(map[string]any{ + "email": "plan@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct-plan", + "chatgpt_plan_type": "pro", + "chatgpt_subscription_active_start": "2026-03-01T00:00:00Z", + "chatgpt_subscription_active_until": "2026-04-01T00:00:00Z", + }, + }) + raw, err := json.Marshal(oauthSession{ + Provider: "codex", + AccessToken: "token-plan", + RefreshToken: "refresh-plan", + IDToken: idToken, + Expire: time.Now().Add(time.Hour).Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("marshal session failed: %v", err) + } + if err := os.WriteFile(credFile, raw, 0o600); err != nil { + t.Fatalf("write session failed: %v", err) + } + + manager, err := newOAuthManager(config.ProviderConfig{ + TimeoutSec: 5, + OAuth: config.ProviderOAuthConfig{ + Provider: "codex", + CredentialFile: credFile, + }, + }, 5*time.Second) + if err != nil { + t.Fatalf("new oauth manager failed: %v", err) + } + + accounts, err := (&OAuthLoginManager{manager: manager}).ListAccounts() + if err != nil { + t.Fatalf("list accounts failed: %v", err) + } + if len(accounts) != 1 { + t.Fatalf("expected one account, got %#v", accounts) + } + account := accounts[0] + if account.PlanType != "pro" { + t.Fatalf("expected plan type to be extracted, got %#v", account) + } + if account.BalanceLabel != "PRO" || account.SubActiveUntil != "2026-04-01T00:00:00Z" { + t.Fatalf("expected subscription metadata in account info, got %#v", account) + } +} + +func buildTestJWT(claims map[string]any) string { + header, _ := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"}) + payload, _ := json.Marshal(claims) + return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + "." +} + func TestClassifyOAuthFailureDifferentiatesReasons(t *testing.T) { t.Parallel() diff --git a/webui/src/components/FormControls.tsx b/webui/src/components/FormControls.tsx index acfcf79..aa00f65 100644 --- a/webui/src/components/FormControls.tsx +++ b/webui/src/components/FormControls.tsx @@ -37,6 +37,30 @@ type PanelFieldProps = FieldBlockProps & { dense?: boolean; }; +type CheckboxCardFieldProps = { + checked: boolean; + className?: string; + help?: React.ReactNode; + label: React.ReactNode; + onChange: (checked: boolean) => void; +}; + +type ToolbarCheckboxFieldProps = { + checked: boolean; + className?: string; + help?: React.ReactNode; + label: React.ReactNode; + onChange: (checked: boolean) => void; +}; + +type InlineCheckboxFieldProps = { + checked: boolean; + className?: string; + help?: React.ReactNode; + label: React.ReactNode; + onChange: (checked: boolean) => void; +}; + export function TextField({ dense = false, monospace = false, className, ...props }: TextFieldProps) { return ( ; } +export function CheckboxCardField({ + checked, + className, + help, + label, + onChange, +}: CheckboxCardFieldProps) { + return ( + + ); +} + +export function ToolbarCheckboxField({ + checked, + className, + help, + label, + onChange, +}: ToolbarCheckboxFieldProps) { + return ( + + ); +} + +export function InlineCheckboxField({ + checked, + className, + help, + label, + onChange, +}: InlineCheckboxFieldProps) { + return ( + + ); +} + export function FieldBlock({ label, help, meta, className, children }: FieldBlockProps) { return (
diff --git a/webui/src/components/RecursiveConfig.tsx b/webui/src/components/RecursiveConfig.tsx index 6c7d85e..ca08b69 100644 --- a/webui/src/components/RecursiveConfig.tsx +++ b/webui/src/components/RecursiveConfig.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import { Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { FixedButton } from './Button'; -import { CheckboxField, SelectField, TextField, TextareaField } from './FormControls'; +import { CheckboxCardField, SelectField, TextField, TextareaField } from './FormControls'; interface RecursiveConfigProps { data: any; @@ -175,25 +175,26 @@ const RecursiveConfig: React.FC = ({ data, labels, path = return (
-
- {label} - {currentPath} -
{typeof value === 'boolean' ? ( - - ) : ( - onChange(currentPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} - className="w-full transition-colors font-mono" + {currentPath}} + label={label} + onChange={(checked) => onChange(currentPath, checked)} /> + ) : ( + <> +
+ {label} + {currentPath} +
+ onChange(currentPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} + className="w-full transition-colors font-mono" + /> + )}
); diff --git a/webui/src/components/channel/ChannelFieldRenderer.tsx b/webui/src/components/channel/ChannelFieldRenderer.tsx index 89c0854..883d8fb 100644 --- a/webui/src/components/channel/ChannelFieldRenderer.tsx +++ b/webui/src/components/channel/ChannelFieldRenderer.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Check, ShieldCheck, Users, Wifi } from 'lucide-react'; -import { CheckboxField, FieldBlock, TextField } from '../FormControls'; +import { CheckboxCardField, FieldBlock, TextField } from '../FormControls'; import type { ChannelField, ChannelKey } from './channelSchema'; type Translate = (key: string, options?: any) => string; @@ -125,39 +125,35 @@ const ChannelFieldRenderer: React.FC = ({ if (isWhatsApp) { const Icon = getWhatsAppBooleanIcon(field.key); return ( -
- setDraft((prev) => ({ ...prev, [field.key]: e.target.checked }))} - className="ui-checkbox mt-1" - /> - + )} + label={label} + onChange={(checked) => setDraft((prev) => ({ ...prev, [field.key]: checked }))} + /> ); } return ( - + setDraft((prev) => ({ ...prev, [field.key]: checked }))} + /> ); } diff --git a/webui/src/components/chat/ChatComposer.tsx b/webui/src/components/chat/ChatComposer.tsx index 90b88bc..aedbfcf 100644 --- a/webui/src/components/chat/ChatComposer.tsx +++ b/webui/src/components/chat/ChatComposer.tsx @@ -25,7 +25,7 @@ const ChatComposer: React.FC = ({ }) => { return (
-
+
= ({ /> @@ -44,12 +44,12 @@ const ChatComposer: React.FC = ({ 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" + className="ui-composer-input w-full h-[72px] pl-16 pr-16 py-0 text-[15px] leading-none transition-all disabled:opacity-60" /> diff --git a/webui/src/components/config/ConfigPageChrome.tsx b/webui/src/components/config/ConfigPageChrome.tsx index 29171c6..5c6b966 100644 --- a/webui/src/components/config/ConfigPageChrome.tsx +++ b/webui/src/components/config/ConfigPageChrome.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { RefreshCw, Save } from 'lucide-react'; import { Button, FixedButton } from '../Button'; -import { CheckboxField, TextField } from '../FormControls'; +import { TextField, ToolbarCheckboxField } from '../FormControls'; import { ModalBackdrop, ModalCard, ModalHeader, ModalShell } from '../ModalFrame'; type Translate = (key: string, options?: any) => string; @@ -23,9 +23,10 @@ export function ConfigHeader({ onSave, onShowForm, onShowRaw, showRaw, t }: Conf
- +
); @@ -64,10 +65,12 @@ export function ConfigToolbar({ - + onSearchChange(e.target.value)} placeholder={t('configSearchPlaceholder')} className="min-w-[240px] flex-1" /> diff --git a/webui/src/components/config/ProviderConfigSection.tsx b/webui/src/components/config/ProviderConfigSection.tsx index 0a99c84..05acd0d 100644 --- a/webui/src/components/config/ProviderConfigSection.tsx +++ b/webui/src/components/config/ProviderConfigSection.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Download, FolderOpen, LogIn, LogOut, Plus, RefreshCw, RotateCcw, ShieldCheck, Trash2, Upload, Wallet, X } from 'lucide-react'; import { Button, FixedButton } from '../Button'; -import { CheckboxField, PanelField, SelectField, TextField } from '../FormControls'; +import { CheckboxField, InlineCheckboxField, PanelField, SelectField, TextField, ToolbarCheckboxField } from '../FormControls'; function joinClasses(...values: Array) { return values.filter(Boolean).join(' '); @@ -112,21 +112,23 @@ export function ProviderRuntimeToolbar({ t, }: ProviderRuntimeToolbarProps) { return ( -
+
{t('configProxies')}
Runtime filters and provider creation are split so the status controls stay attached to each other.
-
-
+
- +
Runtime onRuntimeRefreshSecChange(Number(e.target.value || 10))} className="min-w-[124px] bg-zinc-900/70 border-zinc-700"> @@ -142,14 +144,11 @@ export function ProviderRuntimeToolbar({
-
-
- onNewProxyNameChange(e.target.value)} placeholder={t('configNewProviderName')} className="min-w-[220px] flex-1 bg-zinc-900/70 border-zinc-700 xl:max-w-[280px]" /> + onNewProxyNameChange(e.target.value)} placeholder={t('configNewProviderName')} className="min-w-[220px] bg-zinc-900/70 border-zinc-700 xl:w-[280px]" /> -
); @@ -360,6 +359,7 @@ export function ProviderRuntimeDrawer({ type ProviderProxyCardProps = { name: string; oauthAccounts: Array; + oauthAccountsLoading?: boolean; onClearOAuthCooldown: (credentialFile: string) => void; onDeleteOAuthAccount: (credentialFile: string) => void; onFieldChange: (field: string, value: any) => void; @@ -377,6 +377,7 @@ type ProviderProxyCardProps = { export function ProviderProxyCard({ name, oauthAccounts, + oauthAccountsLoading, onClearOAuthCooldown, onDeleteOAuthAccount, onFieldChange, @@ -402,6 +403,7 @@ export function ProviderProxyCard({ const runtimeErrors = Array.isArray(runtimeItem?.recent_errors) ? runtimeItem.recent_errors : []; const lastQuotaError = runtimeErrors.find((item: any) => String(item?.reason || '').trim() === 'quota') || null; const connected = showOAuth && oauthAccountCount > 0; + const primaryAccount = oauthAccounts[0] || null; const quotaState = !showOAuth ? null : lastQuotaError @@ -435,6 +437,19 @@ export function ProviderProxyCard({ tone: 'border-zinc-700 bg-zinc-900/50 text-zinc-300', detail: '还没有可用的 OAuth 账号。', }; + const quotaTone = quotaState?.tone || 'border-zinc-700 bg-zinc-900/50 text-zinc-300'; + const oauthStatusText = oauthAccountsLoading + ? t('providersOAuthLoading') + : connected + ? `${t('providersOAuthAutoLoaded')} ${oauthAccountCount} ${t('providersOAuthAccountUnit')}` + : t('providersNoOAuthAccounts'); + const oauthStatusDetail = oauthAccountsLoading + ? t('providersOAuthLoadingHelp') + : connected + ? `${t('providersOAuthPrimaryAccount')} ${primaryAccount?.account_label || primaryAccount?.email || primaryAccount?.account_id || '-'}` + : t('providersOAuthEmptyHelp'); + const primaryBalanceText = primaryAccount?.balance_label || primaryAccount?.plan_type || ''; + const primaryBalanceDetail = primaryAccount?.balance_detail || primaryAccount?.subscription_active_until || ''; return (
@@ -562,6 +577,39 @@ export function ProviderProxyCard({
+
+
+
+
{t('providersOAuthLoginStatus')}
+
+ + {oauthAccountsLoading ? t('providersOAuthLoading') : connected ? oauthStatusText : t('providersOAuthDisconnected')} +
+
{oauthStatusDetail}
+
+
+ {oauthAccountsLoading ? t('providersLoading') : connected ? t('providersConnected') : t('providersDisconnected')} +
+
+
+
+
+
+
{t('providersQuotaStatus')}
+
+ + {primaryBalanceText || quotaState?.label || t('providersQuotaPending')} +
+
{primaryBalanceDetail || quotaState?.detail || t('providersQuotaHelp')}
+
+
+ {lastQuotaError ? t('providersQuotaBadge') : connected ? t('providersRuntimeBadge') : t('providersPending')} +
+
+
+
+ +
@@ -641,10 +689,23 @@ export function ProviderProxyCard({
- +
+ {oauthAccountsLoading + ? t('providersOAuthLoadingHelp') + : connected + ? `${oauthStatusText}。${oauthStatusDetail}` + : t('providersOAuthEmptyHelp')} +
+
- {oauthAccounts.length === 0 ? ( + {oauthAccountsLoading ? ( +
{t('providersOAuthLoading')}
+ ) : oauthAccounts.length === 0 ? (
{t('providersNoOAuthAccounts')}
) : (
@@ -674,9 +737,29 @@ export function ProviderProxyCard({
label: {account?.account_label || account?.email || account?.account_id || '-'}
{account?.credential_file}
+ {(account?.balance_label || account?.plan_type) ? ( +
+ {t('providersBalanceDisplay')}: {account?.balance_label || account?.plan_type} + {account?.balance_detail ? ` · ${account.balance_detail}` : ''} +
+ ) : null} + {account?.subscription_active_until ? ( +
+ {t('providersSubscriptionUntil')}: {account?.subscription_active_until} +
+ ) : null}
project: {account?.project_id || '-'} · device: {account?.device_id || '-'}
proxy: {account?.network_proxy || '-'}
expire: {account?.expire || '-'} · cooldown: {account?.cooldown_until || '-'}
+
+ {t('providersQuotaStatus')}: {String(account?.cooldown_until || '').trim() + ? `${t('providersQuotaCooldown')} · ${account?.cooldown_until || '-'}` + : Number(account?.health_score || 100) < 60 + ? `${t('providersQuotaHealthLow')} · ${t('providersQuotaLowestHealth')} ${Number(account?.health_score || 100)}` + : lastQuotaError + ? `${t('providersQuotaLimited')} · ${String(lastQuotaError?.when || '-')}` + : t('providersQuotaHealthy')} +
health: {Number(account?.health_score || 100)} · failures: {Number(account?.failure_count || 0)} · last failure: {account?.last_failure || '-'}
@@ -710,9 +793,12 @@ export function ProviderProxyCard({ {advancedOpen ? 'Hide' : 'Show'}
- - onFieldChange('runtime_persist', e.target.checked)} /> - + onFieldChange('runtime_persist', checked)} + /> {advancedOpen ? (
diff --git a/webui/src/components/mcp/MCPServerEditor.tsx b/webui/src/components/mcp/MCPServerEditor.tsx index f70cb81..9304e2e 100644 --- a/webui/src/components/mcp/MCPServerEditor.tsx +++ b/webui/src/components/mcp/MCPServerEditor.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Package, Wrench } from 'lucide-react'; import { Button, FixedButton } from '../Button'; -import { CheckboxField, SelectField, TextField } from '../FormControls'; +import { CheckboxCardField, SelectField, TextField } from '../FormControls'; import NoticePanel from '../NoticePanel'; type MCPDraftServer = { @@ -77,12 +77,15 @@ const MCPServerEditor: React.FC = ({ -
- + diff --git a/webui/src/components/subagents/TopologyControls.tsx b/webui/src/components/subagents/TopologyControls.tsx index 90a2d0e..7212d09 100644 --- a/webui/src/components/subagents/TopologyControls.tsx +++ b/webui/src/components/subagents/TopologyControls.tsx @@ -17,6 +17,13 @@ type TopologyControlsProps = { }; const FILTERS: TopologyFilter[] = ['all', 'running', 'failed', 'local', 'remote']; +const FILTER_LABELS: Record = { + all: 'All', + running: 'Running', + failed: 'Failed', + local: 'Local', + remote: 'Remote', +}; const TopologyControls: React.FC = ({ onClearFocus, @@ -39,7 +46,7 @@ const TopologyControls: React.FC = ({ onClick={() => onSelectFilter(filter)} className={`px-2 py-1 rounded-xl text-[11px] ${topologyFilter === filter ? 'control-chip-active' : 'control-chip'}`} > - {t(`topologyFilter.${filter}`)} + {t(`topologyFilter.${filter}`, { defaultValue: FILTER_LABELS[filter] })} ))} {selectedBranch ? ( diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index a58c3dc..255917c 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -126,6 +126,13 @@ const resources = { agentTopology: 'Agent Topology', agentTopologyHint: 'Unified graph for local agents, registered nodes, and mirrored remote agent branches.', runningTasks: 'running', + topologyFilter: { + all: 'All', + running: 'Running', + failed: 'Failed', + local: 'Local', + remote: 'Remote', + }, clearFocus: 'Clear Focus', zoomIn: 'Zoom In', zoomOut: 'Zoom Out', @@ -365,6 +372,33 @@ const resources = { providersOAuthAccounts: 'OAuth Accounts', providersRefreshList: 'Refresh List', providersNoOAuthAccounts: 'No imported OAuth accounts yet.', + providersOAuthLoginStatus: 'login status', + providersOAuthDisconnected: 'not logged in', + providersConnected: 'connected', + providersDisconnected: 'disconnected', + providersLoading: 'loading', + providersOAuthLoading: 'loading accounts', + providersOAuthLoadingHelp: 'Loading imported OAuth accounts and runtime quota signals.', + providersOAuthAutoLoaded: 'Auto-loaded', + providersOAuthAccountUnit: 'OAuth account(s)', + providersOAuthPrimaryAccount: 'Primary account:', + providersOAuthEmptyHelp: 'No available account yet. You can click OAuth Login or import auth.json.', + providersQuotaStatus: 'quota status', + providersQuotaHelp: 'The backend does not expose a real balance API yet. This view shows quota, cooldown, and health signals.', + providersQuotaLimited: 'quota limited', + providersQuotaLastHit: 'Last quota hit:', + providersQuotaCooldown: 'cooldown', + providersQuotaCooldownUntil: 'Cooldown until:', + providersQuotaHealthLow: 'health low', + providersQuotaLowestHealth: 'Lowest health:', + providersQuotaHealthy: 'healthy', + providersQuotaHealthyHelp: 'Current account can still join OAuth rotation.', + providersQuotaPending: 'pending', + providersQuotaPendingHelp: 'No OAuth account has been loaded yet.', + providersQuotaBadge: 'Quota', + providersRuntimeBadge: 'Runtime', + providersBalanceDisplay: 'balance', + providersSubscriptionUntil: 'subscription until', system: 'System', pauseJob: 'Pause Job', startJob: 'Start Job', @@ -847,6 +881,13 @@ const resources = { agentTopology: 'Agent 拓扑', agentTopologyHint: '统一展示本地 agent、注册 node 以及远端镜像 agent 分支的关系图。', runningTasks: '运行中', + topologyFilter: { + all: '全部', + running: '运行中', + failed: '失败', + local: '本地', + remote: '远端', + }, clearFocus: '清除聚焦', zoomIn: '放大', zoomOut: '缩小', @@ -1083,6 +1124,33 @@ const resources = { providersOAuthAccounts: 'OAuth 账号', providersRefreshList: '刷新列表', providersNoOAuthAccounts: '当前还没有导入 OAuth 账号。', + providersOAuthLoginStatus: '登录状态', + providersOAuthDisconnected: '尚未登录', + providersConnected: '已连接', + providersDisconnected: '未连接', + providersLoading: '加载中', + providersOAuthLoading: '正在加载账号', + providersOAuthLoadingHelp: '正在加载已导入的 OAuth 账号和运行时额度信号。', + providersOAuthAutoLoaded: '已自动加载', + providersOAuthAccountUnit: '个 OAuth 账号', + providersOAuthPrimaryAccount: '当前主账号:', + providersOAuthEmptyHelp: '当前没有可用账号。可以点击 OAuth 登录,或者导入 auth.json。', + providersQuotaStatus: '额度状态', + providersQuotaHelp: '后端暂时没有真实余额接口,这里展示 quota、cooldown 和 health 这些运行时信号。', + providersQuotaLimited: '额度受限', + providersQuotaLastHit: '最近一次限额命中:', + providersQuotaCooldown: '冷却中', + providersQuotaCooldownUntil: '冷却到:', + providersQuotaHealthLow: '健康偏低', + providersQuotaLowestHealth: '最低健康分:', + providersQuotaHealthy: '可用', + providersQuotaHealthyHelp: '当前账号仍可参与 OAuth 轮换。', + providersQuotaPending: '待加载', + providersQuotaPendingHelp: '当前还没有加载到 OAuth 账号。', + providersQuotaBadge: '限额', + providersRuntimeBadge: '运行态', + providersBalanceDisplay: '额度显示', + providersSubscriptionUntil: '订阅到期', system: '系统', pauseJob: '暂停任务', startJob: '启动任务', diff --git a/webui/src/index.css b/webui/src/index.css index 6fe2c73..fe98171 100644 --- a/webui/src/index.css +++ b/webui/src/index.css @@ -966,19 +966,45 @@ html.theme-dark .brand-button { padding: 1.1rem 1.15rem; } +.ui-checkbox-field { + display: flex; + min-height: 92px; + flex-direction: column; + justify-content: space-between; + padding: 1rem 1.1rem; +} + +.ui-toolbar-checkbox { + display: inline-flex; + min-width: 150px; + align-items: center; + justify-content: space-between; + gap: 0.85rem; + border: 1px solid var(--color-zinc-800); + border-radius: 0.9rem; + background: rgb(255 255 255 / 0.78); + box-shadow: inset 0 1px 0 var(--card-inner-highlight); + padding: 0.55rem 0.75rem; + transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; +} + +.ui-toolbar-checkbox:hover { + border-color: color-mix(in srgb, var(--color-indigo-400) 34%, var(--color-zinc-800)); +} + .ui-checkbox { appearance: none; -webkit-appearance: none; - width: 1.1rem; - height: 1.1rem; + width: 0.95rem; + height: 0.95rem; flex-shrink: 0; border: 1.5px solid rgb(148 163 184 / 0.9); - border-radius: 0.4rem; + border-radius: 0.3rem; background: rgb(255 255 255 / 0.94); box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.95); background-repeat: no-repeat; background-position: center; - background-size: 0.72rem 0.72rem; + background-size: 0.62rem 0.62rem; transition: border-color 160ms ease, background-color 160ms ease, @@ -1000,9 +1026,8 @@ html.theme-dark .brand-button { url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M3 7.2L5.7 10L11 4.6' stroke='white' stroke-width='2.1' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"), linear-gradient(135deg, var(--button-start) 0%, var(--button-end) 100%); box-shadow: - 0 0 0 3px rgb(249 115 22 / 0.14), - 0 8px 18px rgb(249 115 22 / 0.2); - transform: translateY(-0.5px); + 0 0 0 2px rgb(249 115 22 / 0.12), + 0 4px 12px rgb(249 115 22 / 0.18); } .ui-checkbox:focus-visible { @@ -1069,6 +1094,19 @@ html.theme-dark .ui-toggle-card { background: rgb(9 16 28 / 0.32); } +.ui-toolbar-checkbox { + color: inherit; +} + +html.theme-dark .ui-toolbar-checkbox { + border-color: var(--color-zinc-700); + background: rgb(9 16 28 / 0.32); +} + +html.theme-dark .ui-toolbar-checkbox:hover { + border-color: color-mix(in srgb, var(--color-indigo-400) 38%, var(--color-zinc-700)); +} + html.theme-dark .ui-toggle-card:hover { border-color: color-mix(in srgb, var(--color-indigo-400) 38%, var(--color-zinc-700)); } @@ -1128,8 +1166,8 @@ html.theme-dark .ui-checkbox:checked { linear-gradient(135deg, rgb(249 115 22 / 0.96) 0%, rgb(245 158 11 / 0.96) 100%); box-shadow: 0 0 0 1px rgb(255 196 128 / 0.24), - 0 0 0 4px rgb(232 132 58 / 0.18), - 0 10px 22px rgb(232 132 58 / 0.22); + 0 0 0 3px rgb(232 132 58 / 0.16), + 0 6px 16px rgb(232 132 58 / 0.2); } html.theme-dark .ui-checkbox:focus-visible { @@ -1169,6 +1207,7 @@ html.theme-dark .ui-select optgroup { border-radius: 9999px; background: linear-gradient(180deg, var(--card-subtle-a), var(--card-subtle-b)); box-shadow: inset 0 1px 0 var(--card-inner-highlight); + transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease; } .ui-composer-input { @@ -1178,10 +1217,25 @@ html.theme-dark .ui-select optgroup { background: transparent; color: rgb(51 65 85 / 0.98); outline: none; + display: flex; + align-items: center; + caret-color: rgb(234 88 12 / 0.92); +} + +.ui-composer:focus-within { + border-color: rgb(249 115 22 / 0.72); + box-shadow: + 0 0 0 3px rgb(249 115 22 / 0.12), + inset 0 1px 0 var(--card-inner-highlight); } .ui-composer-input::placeholder { - color: rgb(100 116 139 / 0.9); + color: rgb(113 128 150 / 0.86); +} + +.ui-composer-input::selection { + background: rgb(251 191 36 / 0.32); + color: rgb(30 41 59 / 0.98); } .ui-composer-input:focus { @@ -1220,10 +1274,23 @@ html.theme-dark .ui-composer { html.theme-dark .ui-composer-input { color: rgb(226 232 240 / 0.96); + caret-color: rgb(251 146 60 / 0.96); +} + +html.theme-dark .ui-composer:focus-within { + border-color: rgb(251 146 60 / 0.8); + box-shadow: + 0 0 0 3px rgb(251 146 60 / 0.14), + inset 0 1px 0 rgb(255 255 255 / 0.05); } html.theme-dark .ui-composer-input::placeholder { - color: rgb(111 131 155 / 0.9); + color: rgb(126 146 171 / 0.86); +} + +html.theme-dark .ui-composer-input::selection { + background: rgb(251 146 60 / 0.28); + color: rgb(248 250 252 / 0.98); } html.theme-dark .ui-code-panel { diff --git a/webui/src/pages/ChannelSettings.tsx b/webui/src/pages/ChannelSettings.tsx index fc86430..87d3643 100644 --- a/webui/src/pages/ChannelSettings.tsx +++ b/webui/src/pages/ChannelSettings.tsx @@ -4,7 +4,7 @@ import { RefreshCw, Save } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; -import { FixedButton } from '../components/Button'; +import { Button, FixedButton } from '../components/Button'; import ChannelSectionCard from '../components/channel/ChannelSectionCard'; import ChannelFieldRenderer from '../components/channel/ChannelFieldRenderer'; import { @@ -69,6 +69,15 @@ const ChannelSettings: React.FC = () => { const [draft, setDraft] = useState>({}); const [saving, setSaving] = useState(false); const [waStatus, setWaStatus] = useState(null); + const draftRef = React.useRef>({}); + + const updateDraft: React.Dispatch>> = React.useCallback((next) => { + setDraft((prev) => { + const resolved = typeof next === 'function' ? (next as (value: Record) => Record)(prev) : next; + draftRef.current = resolved; + return resolved; + }); + }, []); useEffect(() => { if (!fallbackChannel) { @@ -80,6 +89,7 @@ const ChannelSettings: React.FC = () => { return; } const next = cloneJSON(((cfg as any)?.channels?.[definition.id] || {}) as Record); + draftRef.current = next; setDraft(next); }, [availableChannelKeys, cfg, definition, fallbackChannel, key, navigate]); @@ -112,13 +122,17 @@ const ChannelSettings: React.FC = () => { if (!definition || !availableChannelKeys.includes(key)) return null; const saveChannel = async () => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } setSaving(true); try { const nextCfg = cloneJSON(cfg || {}); if (!nextCfg.channels || typeof nextCfg.channels !== 'object') { (nextCfg as any).channels = {}; } - (nextCfg as any).channels[definition.id] = cloneJSON(draft); + (nextCfg as any).channels[definition.id] = cloneJSON(draftRef.current || {}); const submit = async (confirmRisky: boolean) => { const body = confirmRisky ? { ...nextCfg, confirm_risky: true } : nextCfg; return ui.withLoading(async () => { @@ -202,9 +216,10 @@ const ChannelSettings: React.FC = () => { )} - + } /> @@ -229,7 +244,7 @@ const ChannelSettings: React.FC = () => { field={field} getDescription={getChannelFieldDescription} parseList={parseChannelList} - setDraft={setDraft} + setDraft={updateDraft} t={t} /> ))} diff --git a/webui/src/pages/Cron.tsx b/webui/src/pages/Cron.tsx index c87abc4..0bf9dc4 100644 --- a/webui/src/pages/Cron.tsx +++ b/webui/src/pages/Cron.tsx @@ -6,7 +6,7 @@ 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 { CheckboxCardField, 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'; @@ -343,24 +343,20 @@ const Cron: React.FC = () => {
-
- - -
+
diff --git a/webui/src/pages/MCP.tsx b/webui/src/pages/MCP.tsx index 7418ba1..67cc3e9 100644 --- a/webui/src/pages/MCP.tsx +++ b/webui/src/pages/MCP.tsx @@ -456,10 +456,11 @@ const MCP: React.FC = () => { )} - - - -
+ +
diff --git a/webui/src/pages/Memory.tsx b/webui/src/pages/Memory.tsx index 186e7e0..1438eca 100644 --- a/webui/src/pages/Memory.tsx +++ b/webui/src/pages/Memory.tsx @@ -3,7 +3,7 @@ import { Save, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; -import { FixedButton } from '../components/Button'; +import { Button, FixedButton } from '../components/Button'; import { TextareaField } from '../components/FormControls'; import FileListItem from '../components/FileListItem'; @@ -153,10 +153,11 @@ 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/Providers.tsx b/webui/src/pages/Providers.tsx index 8ebc961..47e83fa 100644 --- a/webui/src/pages/Providers.tsx +++ b/webui/src/pages/Providers.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RefreshCw, Save } from 'lucide-react'; import { useAppContext } from '../context/AppContext'; @@ -29,6 +29,8 @@ const Providers: React.FC = () => { const latestProviderRuntimeRef = useRef([]); const [displayedProviderRuntimeItems, setDisplayedProviderRuntimeItems] = useState([]); const [oauthAccounts, setOAuthAccounts] = useState>>({}); + const [oauthAccountsLoading, setOAuthAccountsLoading] = useState>({}); + const [oauthAccountsLoaded, setOAuthAccountsLoaded] = useState>({}); const providerEntries = useMemo(() => { const providers = (((cfg as any)?.models || {}) as any)?.providers || {}; @@ -77,14 +79,6 @@ const Providers: React.FC = () => { } }, [activeProviderName, providerEntries]); - useEffect(() => { - providerEntries.forEach(([name, p]) => { - if (['oauth', 'hybrid'].includes(String(p?.auth || ''))) { - loadOAuthAccounts(name); - } - }); - }, [providerEntries]); - useEffect(() => { if (baseline == null && cfg && Object.keys(cfg).length > 0) { setBaseline(cloneJSON(cfg)); @@ -131,6 +125,49 @@ const Providers: React.FC = () => { ui, }); + const loadOAuthAccountsNow = useCallback(async (name: string) => { + if (!name) return; + setOAuthAccountsLoading((prev) => ({ ...prev, [name]: true })); + try { + await loadOAuthAccounts(name); + setOAuthAccountsLoaded((prev) => ({ ...prev, [name]: true })); + } finally { + setOAuthAccountsLoading((prev) => ({ ...prev, [name]: false })); + } + }, [loadOAuthAccounts]); + + useEffect(() => { + providerEntries.forEach(([name, p]) => { + if (!['oauth', 'hybrid'].includes(String(p?.auth || ''))) return; + if (oauthAccountsLoaded[name] || oauthAccountsLoading[name]) return; + void loadOAuthAccountsNow(name); + }); + }, [loadOAuthAccountsNow, oauthAccountsLoaded, oauthAccountsLoading, providerEntries]); + + useEffect(() => { + if (!activeProviderEntry) return; + const [name, provider] = activeProviderEntry; + if (!['oauth', 'hybrid'].includes(String(provider?.auth || ''))) return; + if (oauthAccountsLoaded[name] || oauthAccountsLoading[name]) return; + void loadOAuthAccountsNow(name); + }, [activeProviderEntry, loadOAuthAccountsNow, oauthAccountsLoaded, oauthAccountsLoading]); + + useEffect(() => { + const oauthProviderNames = new Set( + providerEntries + .filter(([, provider]) => ['oauth', 'hybrid'].includes(String(provider?.auth || ''))) + .map(([name]) => name), + ); + setOAuthAccountsLoaded((prev) => { + const next = Object.fromEntries(Object.entries(prev).filter(([name]) => oauthProviderNames.has(name))); + return Object.keys(next).length === Object.keys(prev).length ? prev : next; + }); + setOAuthAccountsLoading((prev) => { + const next = Object.fromEntries(Object.entries(prev).filter(([name]) => oauthProviderNames.has(name))); + return Object.keys(next).length === Object.keys(prev).length ? prev : next; + }); + }, [providerEntries]); + const { saveConfig } = useConfigSaveAction({ cfg, cfgRaw, @@ -161,12 +198,13 @@ const Providers: React.FC = () => { - - - - - } - /> + + + } + />
{ key={activeProviderEntry[0]} name={activeProviderEntry[0]} oauthAccounts={oauthAccounts[activeProviderEntry[0]] || []} + oauthAccountsLoading={!!oauthAccountsLoading[activeProviderEntry[0]]} onClearOAuthCooldown={(credentialFile) => clearOAuthCooldown(activeProviderEntry[0], credentialFile)} onDeleteOAuthAccount={(credentialFile) => deleteOAuthAccount(activeProviderEntry[0], credentialFile)} onFieldChange={(field, value) => updateProxyField(activeProviderEntry[0], field, value)} - onLoadOAuthAccounts={() => loadOAuthAccounts(activeProviderEntry[0])} + onLoadOAuthAccounts={() => loadOAuthAccountsNow(activeProviderEntry[0])} onRefreshOAuthAccount={(credentialFile) => refreshOAuthAccount(activeProviderEntry[0], credentialFile)} onRemove={() => removeProxy(activeProviderEntry[0])} onStartOAuthLogin={() => startOAuthLogin(activeProviderEntry[0], activeProviderEntry[1])} diff --git a/webui/src/pages/Skills.tsx b/webui/src/pages/Skills.tsx index ab86d94..0f229bd 100644 --- a/webui/src/pages/Skills.tsx +++ b/webui/src/pages/Skills.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; import { Button, FixedButton } from '../components/Button'; -import { CheckboxField, TextField, TextareaField } from '../components/FormControls'; +import { TextField, TextareaField, ToolbarCheckboxField } from '../components/FormControls'; import { ModalBackdrop, ModalBody, ModalCard, ModalHeader, ModalShell } from '../components/ModalFrame'; import PageHeader from '../components/PageHeader'; import ToolbarRow from '../components/ToolbarRow'; @@ -231,14 +231,13 @@ const Skills: React.FC = () => { > {installingSkill ? t('loading') : t('install')} - + {!clawhubInstalled && ( @@ -319,9 +318,10 @@ const Skills: React.FC = () => { className="px-4 py-3" actions={ <> - + setIsFileModalOpen(false)} radius="full" label={t('close')}>