This commit is contained in:
lpf
2026-03-12 13:03:28 +08:00
parent f0a1e9c941
commit 1eca901503
3 changed files with 108 additions and 19 deletions

View File

@@ -138,13 +138,13 @@ export function ProviderRuntimeToolbar({
<option value="all">{t('providersRuntimeAll')}</option>
</SelectField>
<TextField dense value={newProxyName} onChange={(e) => onNewProxyNameChange(e.target.value)} placeholder={t('configNewProviderName')} className="min-w-[220px] !w-[220px] xl:!w-[280px] bg-zinc-900/70 border-zinc-700" />
<Button onClick={onRefreshRuntime} size="xs" radius="lg" variant="neutral" gap="2" noShrink>
<RefreshCw className="w-4 h-4" />
</Button>
<Button onClick={onAddProxy} variant="primary" size="xs" radius="lg" gap="2" noShrink>
<Plus className="w-4 h-4" />
{t('add')}
</Button>
<Button onClick={onRefreshRuntime} size="xs" radius="lg" variant="neutral" gap="2" noShrink>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
);
@@ -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({
<RefreshCw className={`w-4 h-4${oauthAccountsLoading ? ' animate-spin' : ''}`} />
</FixedButton>
</div>
<div className={`rounded-xl border px-3 py-2 text-[11px] ${oauthAccountsLoading
? 'border-sky-500/25 bg-sky-500/10 text-sky-100'
: connected
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-100'
: 'border-zinc-800 bg-zinc-950/30 text-zinc-400'
<div className={`mt-4 rounded-xl border px-4 py-3 text-sm ${connected
? 'ui-pill ui-pill-success'
: 'ui-pill ui-pill-neutral'
}`}>
{oauthAccountsLoading
? t('providersOAuthLoadingHelp')
@@ -715,8 +721,8 @@ export function ProviderProxyCard({
: t('providersOAuthEmptyHelp')}
</div>
<div className={`hidden rounded-xl border px-3 py-2 text-[11px] ${connected
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-100'
: 'border-zinc-800 bg-zinc-950/30 text-zinc-400'
? 'ui-pill ui-pill-success'
: 'ui-pill ui-pill-neutral'
}`}>
{connected
? ti('providersAutoLoadedCount', { count: oauthAccountCount, primary: oauthAccounts[0]?.account_label || oauthAccounts[0]?.email || oauthAccounts[0]?.account_id || '-' })
@@ -734,10 +740,10 @@ export function ProviderProxyCard({
<div className="flex items-center justify-between gap-2">
<div className="text-zinc-200 truncate">{account?.email || account?.account_id || account?.credential_file}</div>
<div className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] ${String(account?.cooldown_until || '').trim()
? 'border-orange-500/30 bg-orange-500/10 text-orange-200'
? 'ui-pill ui-pill-warning'
: Number(account?.health_score || 100) < 60
? 'border-rose-500/30 bg-rose-500/10 text-rose-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
? 'ui-pill ui-pill-danger'
: 'ui-pill ui-pill-success'
}`}>
{String(account?.cooldown_until || '').trim() ? t('providersAccountCooldown') : Number(account?.health_score || 100) < 60 ? t('providersAccountLimited') : t('providersAccountOnline')}
</div>

View File

@@ -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 账号输入邮箱或别名。',

View File

@@ -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 (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-4 flex flex-col min-h-full">
<input ref={oauthImportInputRef} type="file" accept=".json,application/json" className="hidden" onChange={onOAuthImportChange} />
@@ -229,6 +283,29 @@ const Providers: React.FC = () => {
{t('providersIntroAfter')}
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4 md:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-semibold text-zinc-100">{t('providersDefaultProvider')}</div>
<div className="text-xs text-zinc-400">{t('providersDefaultProviderHelp')}</div>
</div>
<div className="w-full lg:w-[320px]">
<SelectField
dense
value={defaultProviderName}
onChange={(e) => applyDefaultProvider(e.target.value)}
className="w-full bg-zinc-900/70 border-zinc-700"
>
{providerEntries.map(([name, provider]) => (
<option key={`default-provider-${name}`} value={name}>
{name} · {String(provider?.auth || 'bearer')}
</option>
))}
</SelectField>
</div>
</div>
</div>
{providerEntries.length > 0 ? (
<>
<div className="flex flex-wrap gap-2">