diff --git a/webui/src/components/config/ProviderConfigSection.tsx b/webui/src/components/config/ProviderConfigSection.tsx index a86996b..a6f8850 100644 --- a/webui/src/components/config/ProviderConfigSection.tsx +++ b/webui/src/components/config/ProviderConfigSection.tsx @@ -138,13 +138,13 @@ export function ProviderRuntimeToolbar({ onNewProxyNameChange(e.target.value)} placeholder={t('configNewProviderName')} className="min-w-[220px] !w-[220px] xl:!w-[280px] bg-zinc-900/70 border-zinc-700" /> - + ); @@ -409,6 +409,14 @@ export function ProviderProxyCard({ const oauthProvider = String(proxy?.oauth?.provider || ''); const [runtimeOpen, setRuntimeOpen] = React.useState(false); const [advancedOpen, setAdvancedOpen] = React.useState(false); + + React.useEffect(() => { + if (showOAuth) { + onLoadOAuthAccounts(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showOAuth]); + const oauthAccountCount = Array.isArray(oauthAccounts) ? oauthAccounts.length : 0; const runtimeErrors = Array.isArray(runtimeItem?.recent_errors) ? runtimeItem.recent_errors : []; const lastQuotaError = runtimeErrors.find((item: any) => String(item?.reason || '').trim() === 'quota') || null; @@ -419,13 +427,13 @@ export function ProviderProxyCard({ : lastQuotaError ? { label: t('providersQuotaLimited'), - tone: 'border-amber-500/30 bg-amber-500/10 text-amber-200', + tone: 'ui-pill ui-pill-warning', detail: ti('providersQuotaLimitedDetail', { when: String(lastQuotaError?.when || '-') }), } : oauthAccounts.some((account) => String(account?.cooldown_until || '').trim()) ? { label: t('providersQuotaCooldown'), - tone: 'border-orange-500/30 bg-orange-500/10 text-orange-200', + tone: 'ui-pill ui-pill-warning', detail: oauthAccounts .map((account) => String(account?.cooldown_until || '').trim()) .find(Boolean) || '-', @@ -433,21 +441,21 @@ export function ProviderProxyCard({ : oauthAccounts.some((account) => Number(account?.health_score || 100) < 60) ? { label: t('providersQuotaHealthLow'), - tone: 'border-rose-500/30 bg-rose-500/10 text-rose-200', + tone: 'ui-pill ui-pill-danger', detail: ti('providersQuotaHealthLowDetail', { score: Math.min(...oauthAccounts.map((account) => Number(account?.health_score || 100))) }), } : connected ? { label: t('providersQuotaHealthy'), - tone: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200', + tone: 'ui-pill ui-pill-success', detail: t('providersQuotaHealthyDetail'), } : { label: t('providersOAuthDisconnected'), - tone: 'border-zinc-700 bg-zinc-900/50 text-zinc-300', + tone: 'ui-pill ui-pill-neutral', detail: t('providersQuotaNoAccountDetail'), }; - const quotaTone = quotaState?.tone || 'border-zinc-700 bg-zinc-900/50 text-zinc-300'; + const quotaTone = quotaState?.tone || 'ui-pill ui-pill-neutral'; const oauthStatusText = oauthAccountsLoading ? t('providersOAuthLoading') : connected @@ -702,11 +710,9 @@ export function ProviderProxyCard({ -
{oauthAccountsLoading ? t('providersOAuthLoadingHelp') @@ -715,8 +721,8 @@ export function ProviderProxyCard({ : t('providersOAuthEmptyHelp')}
{connected ? ti('providersAutoLoadedCount', { count: oauthAccountCount, primary: oauthAccounts[0]?.account_label || oauthAccounts[0]?.email || oauthAccounts[0]?.account_id || '-' }) @@ -734,10 +740,10 @@ export function ProviderProxyCard({
{account?.email || account?.account_id || account?.credential_file}
{String(account?.cooldown_until || '').trim() ? t('providersAccountCooldown') : Number(account?.health_score || 100) < 60 ? t('providersAccountLimited') : t('providersAccountOnline')}
diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 11f12cb..7f0191d 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -298,6 +298,9 @@ const resources = { providersIntroBefore: 'Select a provider tab, then set auth to ', providersIntroMiddle: ' or ', providersIntroAfter: '. The OAuth fields, login link flow, callback paste step, and account list appear in that provider card.', + providersDefaultProvider: 'Default Provider', + providersDefaultProviderHelp: 'Changing this updates the global primary model, clears global fallbacks, and syncs every subagent runtime provider to the selected provider after you save.', + providersDefaultProviderModelRequired: 'Provider "{{name}}" has no model configured yet. Add a model before making it the default provider.', providersQwenLabelTitle: 'Qwen Account Label', providersQwenLabelMessage: 'Qwen OAuth may not return an email. Enter an email or alias to identify this account.', providersQwenImportLabelMessage: 'Enter an email or alias for this imported Qwen account.', @@ -1146,6 +1149,9 @@ const resources = { providersIntroBefore: '先选择一个 provider 标签,再把认证模式切到 ', providersIntroMiddle: ' 或 ', providersIntroAfter: '。对应 provider 卡片里会出现 OAuth 字段、登录链接流程、回调地址回填和账号列表。', + providersDefaultProvider: '默认供应商', + providersDefaultProviderHelp: '切换后会更新全局主模型、清空全局回退列表,并把所有子代理的 runtime provider 同步成这个供应商。保存配置后生效。', + providersDefaultProviderModelRequired: 'provider "{{name}}" 还没有配置模型,先添加模型后才能设为默认供应商。', providersQwenLabelTitle: 'Qwen 账号标识', providersQwenLabelMessage: 'Qwen OAuth 可能不会返回邮箱,请输入一个邮箱或别名来标识该账号。', providersQwenImportLabelMessage: '请为导入的 Qwen 账号输入邮箱或别名。', diff --git a/webui/src/pages/Providers.tsx b/webui/src/pages/Providers.tsx index f7141c2..8fb47e1 100644 --- a/webui/src/pages/Providers.tsx +++ b/webui/src/pages/Providers.tsx @@ -4,6 +4,7 @@ import { RefreshCw, Save } from 'lucide-react'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; import { Button, FixedButton } from '../components/ui/Button'; +import { SelectField } from '../components/ui/FormControls'; import PageHeader from '../components/layout/PageHeader'; import { ConfigDiffModal } from '../components/config/ConfigPageChrome'; import { ProviderProxyCard, ProviderRuntimeDrawer, ProviderRuntimeSummary, ProviderRuntimeToolbar } from '../components/config/ProviderConfigSection'; @@ -13,6 +14,22 @@ import { useConfigRuntimeView } from '../components/config/useConfigRuntimeView' import { useConfigSaveAction } from '../components/config/useConfigSaveAction'; import { cloneJSON } from '../utils/object'; +function parseProviderModelRef(raw: unknown) { + const trimmed = String(raw || '').trim(); + if (!trimmed) return { provider: '', model: '' }; + const idx = trimmed.indexOf('/'); + if (idx <= 0) return { provider: '', model: trimmed }; + return { + provider: trimmed.slice(0, idx).trim(), + model: trimmed.slice(idx + 1).trim(), + }; +} + +function firstProviderModel(provider: any) { + const models = Array.isArray(provider?.models) ? provider.models : []; + return String(models.find((item: any) => String(item || '').trim()) || '').trim(); +} + const Providers: React.FC = () => { const { t } = useTranslation(); const ui = useUI(); @@ -56,6 +73,11 @@ const Providers: React.FC = () => { () => providerEntries.find(([name]) => name === activeProviderName) || null, [providerEntries, activeProviderName], ); + const defaultProviderName = useMemo(() => { + const { provider } = parseProviderModelRef((cfg as any)?.agents?.defaults?.model?.primary); + if (provider && providerEntries.some(([name]) => name === provider)) return provider; + return providerEntries[0]?.[0] || ''; + }, [cfg, providerEntries]); useEffect(() => { latestProviderRuntimeRef.current = Array.isArray(providerRuntimeItems) ? providerRuntimeItems : []; @@ -182,6 +204,38 @@ const Providers: React.FC = () => { ui, }); + const applyDefaultProvider = useCallback((providerName: string) => { + const trimmed = String(providerName || '').trim(); + if (!trimmed) return; + const providerEntry = providerEntries.find(([name]) => name === trimmed); + if (!providerEntry) return; + const [, providerConfig] = providerEntry; + const currentPrimary = parseProviderModelRef((cfg as any)?.agents?.defaults?.model?.primary); + const model = currentPrimary.provider === trimmed && currentPrimary.model + ? currentPrimary.model + : firstProviderModel(providerConfig); + if (!model) { + void ui.notify({ title: t('requestFailed'), message: t('providersDefaultProviderModelRequired', { name: trimmed }) }); + return; + } + setCfg((value) => { + const next = cloneJSON(value || {}); + if (!next.agents || typeof next.agents !== 'object') next.agents = {}; + if (!next.agents.defaults || typeof next.agents.defaults !== 'object') next.agents.defaults = {}; + if (!next.agents.defaults.model || typeof next.agents.defaults.model !== 'object') next.agents.defaults.model = {}; + next.agents.defaults.model.primary = `${trimmed}/${model}`; + next.agents.defaults.model.fallbacks = []; + if (!next.agents.subagents || typeof next.agents.subagents !== 'object') return next; + Object.entries(next.agents.subagents).forEach(([, rawAgent]) => { + const agent = rawAgent as any; + if (!agent || typeof agent !== 'object') return; + if (!agent.runtime || typeof agent.runtime !== 'object') agent.runtime = {}; + agent.runtime.provider = trimmed; + }); + return next; + }); + }, [cfg, providerEntries, setCfg, t, ui]); + return (
@@ -229,6 +283,29 @@ const Providers: React.FC = () => { {t('providersIntroAfter')}
+
+
+
+
{t('providersDefaultProvider')}
+
{t('providersDefaultProviderHelp')}
+
+
+ applyDefaultProvider(e.target.value)} + className="w-full bg-zinc-900/70 border-zinc-700" + > + {providerEntries.map(([name, provider]) => ( + + ))} + +
+
+
+ {providerEntries.length > 0 ? ( <>