Files
clawgo/webui/src/pages/Providers.tsx
2026-03-12 00:32:56 +08:00

310 lines
13 KiB
TypeScript

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';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import PageHeader from '../components/PageHeader';
import { ConfigDiffModal } from '../components/config/ConfigPageChrome';
import { ProviderProxyCard, ProviderRuntimeDrawer, ProviderRuntimeSummary, ProviderRuntimeToolbar } from '../components/config/ProviderConfigSection';
import { buildDiffRows, RuntimeWindow } from '../components/config/configUtils';
import { useConfigProviderActions } from '../components/config/useConfigProviderActions';
import { useConfigRuntimeView } from '../components/config/useConfigRuntimeView';
import { useConfigSaveAction } from '../components/config/useConfigSaveAction';
import { cloneJSON } from '../utils/object';
const Providers: React.FC = () => {
const { t } = useTranslation();
const ui = useUI();
const { cfg, setCfg, cfgRaw, loadConfig, q, setConfigEditing, providerRuntimeItems, setToken } = useAppContext();
const [newProxyName, setNewProxyName] = useState('');
const [runtimeAutoRefresh, setRuntimeAutoRefresh] = useState(true);
const [runtimeRefreshSec, setRuntimeRefreshSec] = useState(10);
const [runtimeWindow, setRuntimeWindow] = useState<RuntimeWindow>('24h');
const [runtimeDrawerProvider, setRuntimeDrawerProvider] = useState('');
const [selectedProviderTab, setSelectedProviderTab] = useState('');
const [showDiff, setShowDiff] = useState(false);
const [baseline, setBaseline] = useState<any>(null);
const oauthImportInputRef = useRef<HTMLInputElement | null>(null);
const latestProviderRuntimeRef = useRef<any[]>([]);
const [displayedProviderRuntimeItems, setDisplayedProviderRuntimeItems] = useState<any[]>([]);
const [oauthAccounts, setOAuthAccounts] = useState<Record<string, Array<any>>>({});
const [oauthAccountsLoading, setOAuthAccountsLoading] = useState<Record<string, boolean>>({});
const [oauthAccountsLoaded, setOAuthAccountsLoaded] = useState<Record<string, boolean>>({});
const providerEntries = useMemo(() => {
const providers = (((cfg as any)?.models || {}) as any)?.providers || {};
const entries: Array<[string, any]> = [];
if (providers && typeof providers === 'object' && !Array.isArray(providers)) {
Object.entries(providers).forEach(([name, value]) => entries.push([name, value]));
}
return entries;
}, [cfg]);
const providerRuntimeMap = useMemo(() => {
const entries = Array.isArray(displayedProviderRuntimeItems) ? displayedProviderRuntimeItems : [];
return Object.fromEntries(entries.map((item: any) => [String(item?.name || ''), item]));
}, [displayedProviderRuntimeItems]);
const activeProviderName = useMemo(() => {
if (providerEntries.length === 0) return '';
if (providerEntries.some(([name]) => name === selectedProviderTab)) return selectedProviderTab;
return providerEntries[0]?.[0] || '';
}, [providerEntries, selectedProviderTab]);
const activeProviderEntry = useMemo(
() => providerEntries.find(([name]) => name === activeProviderName) || null,
[providerEntries, activeProviderName],
);
useEffect(() => {
latestProviderRuntimeRef.current = Array.isArray(providerRuntimeItems) ? providerRuntimeItems : [];
if (runtimeAutoRefresh && runtimeRefreshSec <= 1) {
setDisplayedProviderRuntimeItems(latestProviderRuntimeRef.current);
}
}, [providerRuntimeItems, runtimeAutoRefresh, runtimeRefreshSec]);
useEffect(() => {
if (!runtimeAutoRefresh) return;
setDisplayedProviderRuntimeItems(latestProviderRuntimeRef.current);
const timer = window.setInterval(() => {
setDisplayedProviderRuntimeItems(latestProviderRuntimeRef.current);
}, Math.max(1, runtimeRefreshSec) * 1000);
return () => window.clearInterval(timer);
}, [runtimeAutoRefresh, runtimeRefreshSec]);
useEffect(() => {
if (!activeProviderName && providerEntries.length > 0) {
setSelectedProviderTab(providerEntries[0][0]);
}
}, [activeProviderName, providerEntries]);
useEffect(() => {
if (baseline == null && cfg && Object.keys(cfg).length > 0) {
setBaseline(cloneJSON(cfg));
}
}, [cfg, baseline]);
const diffRows = useMemo(() => buildDiffRows(baseline, cfg, t('configRoot')), [baseline, cfg, t]);
const isDirty = useMemo(() => {
if (baseline == null) return false;
return JSON.stringify(baseline) !== JSON.stringify(cfg || {});
}, [baseline, cfg]);
useEffect(() => {
setConfigEditing(isDirty);
return () => setConfigEditing(false);
}, [isDirty, setConfigEditing]);
const { filterRuntimeEvents, renderRuntimeEventList, runtimeSectionOpen, toggleRuntimeSection } = useConfigRuntimeView(runtimeWindow);
const {
addProxy,
clearAPIKeyCooldown,
clearOAuthCooldown,
clearProviderHistory,
deleteOAuthAccount,
exportProviderHistory,
loadOAuthAccounts,
onOAuthImportChange,
refreshOAuthAccount,
refreshProviderRuntimeNow,
removeProxy,
startOAuthLogin,
triggerOAuthImport,
updateProxyField,
} = useConfigProviderActions({
inputRef: oauthImportInputRef,
loadConfig,
onProviderRuntimeRefreshed: () => setDisplayedProviderRuntimeItems(latestProviderRuntimeRef.current),
providerRuntimeMap,
q,
setCfg,
setNewProxyName,
setOAuthAccounts,
t,
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,
loadConfig,
q,
setBaseline,
setConfigEditing,
setToken,
setShowDiff,
showRaw: false,
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} />
<PageHeader
title={t('providers')}
titleClassName="ui-text-primary"
actions={
<div className="flex items-center gap-2 flex-wrap justify-end">
<FixedButton onClick={async () => {
const reloaded = await loadConfig(true);
setBaseline(cloneJSON(reloaded ?? cfg));
}} label={t('reload')}>
<RefreshCw className="w-4 h-4" />
</FixedButton>
<Button onClick={() => setShowDiff(true)} size="sm">{t('configDiffPreview')}</Button>
<Button onClick={saveConfig} variant="primary" size="sm" radius="lg" gap="1">
<Save className="w-4 h-4" />
{t('saveChanges')}
</Button>
</div>
}
/>
<div className="brand-card ui-border-subtle border rounded-[30px] p-4 md:p-6 space-y-4">
<ProviderRuntimeToolbar
newProxyName={newProxyName}
onAddProxy={() => addProxy(newProxyName)}
onNewProxyNameChange={setNewProxyName}
onRefreshRuntime={refreshProviderRuntimeNow}
onRuntimeAutoRefreshChange={setRuntimeAutoRefresh}
onRuntimeRefreshSecChange={setRuntimeRefreshSec}
onRuntimeWindowChange={setRuntimeWindow}
runtimeAutoRefresh={runtimeAutoRefresh}
runtimeRefreshSec={runtimeRefreshSec}
runtimeWindow={runtimeWindow}
t={t}
/>
<div className="rounded-xl border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-xs text-zinc-400">
{t('providersIntroBefore')}
<span className="font-mono text-zinc-200">oauth</span>
{t('providersIntroMiddle')}
<span className="font-mono text-zinc-200">hybrid</span>
{t('providersIntroAfter')}
</div>
{providerEntries.length > 0 ? (
<>
<div className="flex flex-wrap gap-2">
{providerEntries.map(([name, p]) => {
const auth = String(p?.auth || 'bearer');
const active = name === activeProviderName;
return (
<Button
key={`provider-tab-${name}`}
onClick={() => setSelectedProviderTab(name)}
variant={active ? 'primary' : 'neutral'}
size="sm"
radius="xl"
>
{name}
<span className="ml-1 text-[11px] opacity-80">{auth}</span>
</Button>
);
})}
</div>
{activeProviderEntry && (
<ProviderProxyCard
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={() => loadOAuthAccountsNow(activeProviderEntry[0])}
onRefreshOAuthAccount={(credentialFile) => refreshOAuthAccount(activeProviderEntry[0], credentialFile)}
onRemove={() => removeProxy(activeProviderEntry[0])}
onStartOAuthLogin={() => startOAuthLogin(activeProviderEntry[0], activeProviderEntry[1])}
onTriggerOAuthImport={() => triggerOAuthImport(activeProviderEntry[0], activeProviderEntry[1])}
proxy={activeProviderEntry[1]}
runtimeItem={providerRuntimeMap[activeProviderEntry[0]]}
runtimeSummary={providerRuntimeMap[activeProviderEntry[0]] ? (
<ProviderRuntimeSummary
item={providerRuntimeMap[activeProviderEntry[0]]}
name={activeProviderEntry[0]}
onClearApiCooldown={() => clearAPIKeyCooldown(activeProviderEntry[0])}
onClearHistory={() => clearProviderHistory(activeProviderEntry[0])}
onExportHistory={() => exportProviderHistory(activeProviderEntry[0])}
onOpenHistory={() => setRuntimeDrawerProvider(activeProviderEntry[0])}
renderRuntimeEventList={renderRuntimeEventList}
runtimeSectionOpen={(section) => runtimeSectionOpen(activeProviderEntry[0], section)}
toggleRuntimeSection={(section) => toggleRuntimeSection(activeProviderEntry[0], section)}
filterRuntimeEvents={filterRuntimeEvents}
/>
) : null}
t={t}
/>
)}
</>
) : (
<div className="text-xs text-zinc-500">{t('configNoCustomProviders')}</div>
)}
</div>
{runtimeDrawerProvider && providerRuntimeMap[runtimeDrawerProvider] && (
<ProviderRuntimeDrawer
filterRuntimeEvents={filterRuntimeEvents}
item={providerRuntimeMap[runtimeDrawerProvider]}
name={runtimeDrawerProvider}
onClearHistory={() => clearProviderHistory(runtimeDrawerProvider)}
onClose={() => setRuntimeDrawerProvider('')}
onExportHistory={() => exportProviderHistory(runtimeDrawerProvider)}
renderRuntimeEventList={renderRuntimeEventList}
/>
)}
{showDiff && <ConfigDiffModal diffRows={diffRows} onClose={() => setShowDiff(false)} t={t} />}
</div>
);
};
export default Providers;