easyai-ai-gateway/apps/web/src/pages/admin/PlatformManagementPanel.tsx

1281 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Boxes, CheckCircle2, Gauge, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react';
import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, ModelRateLimitStatus, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
import type { LoadState, PlatformWithModelsInput } from '../../types';
import {
authTypes,
applyProviderDefaults,
baseModelTypes,
baseModelTypeText,
createEmptyPlatformForm,
modelsForProvider,
platformModelPayloads,
platformPayload,
providerLabel,
selectedModelsForForm,
stableModelAlias,
validatePlatformForm,
type PlatformWizardForm,
} from './platform-form';
import { ModelCatalogCard } from './ModelCatalogCard';
export function PlatformManagementPanel(props: {
baseModels: BaseModelCatalogItem[];
message: string;
modelRateLimits: ModelRateLimitStatus[];
networkProxyConfig: { globalHttpProxy?: string; globalHttpProxySet: boolean; globalHttpProxySource?: string } | null;
platforms: IntegrationPlatform[];
platformModels: PlatformModel[];
pricingRuleSets: PricingRuleSet[];
providers: CatalogProvider[];
state: LoadState;
onDeletePlatform: (platformId: string) => Promise<void>;
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
}) {
const defaultProvider = props.providers[0]?.providerKey ?? props.baseModels[0]?.providerKey ?? '';
const [now, setNow] = useState(() => Date.now());
const [dialogOpen, setDialogOpen] = useState(false);
const [viewMode, setViewMode] = useState<'platforms' | 'models' | 'limits'>('platforms');
const [modelQuery, setModelQuery] = useState('');
const [selectedPlatformId, setSelectedPlatformId] = useState('');
const [validationMessage, setValidationMessage] = useState('');
const [globalProxyNoticeOpen, setGlobalProxyNoticeOpen] = useState(false);
const [editingPlatform, setEditingPlatform] = useState<IntegrationPlatform | null>(null);
const [pendingDeletePlatform, setPendingDeletePlatform] = useState<IntegrationPlatform | null>(null);
const providerMap = useMemo(() => new Map(props.providers.map((item) => [item.providerKey, item])), [props.providers]);
const platformMap = useMemo(() => new Map(props.platforms.map((item) => [item.id, item])), [props.platforms]);
const [form, setForm] = useState<PlatformWizardForm>(() => createEmptyPlatformForm(defaultProvider, providerDefaults(providerMap.get(defaultProvider))));
const providerOptions = useMemo(
() => Array.from(new Set([...props.providers.map((item) => item.providerKey), ...props.baseModels.map((item) => item.providerKey)])).filter(Boolean),
[props.baseModels, props.providers],
);
const availableModels = useMemo(() => props.baseModels.filter((item) => item.status !== 'hidden'), [props.baseModels]);
const selectedModels = useMemo(() => selectedModelsForForm(props.baseModels, form), [form, props.baseModels]);
const platformModelCount = useMemo(() => countModelsByPlatform(props.platformModels), [props.platformModels]);
const selectedModelPlatformId = selectedPlatformId || props.platforms[0]?.id || '';
const filteredPlatformModels = useMemo(() => {
const keyword = modelQuery.trim().toLowerCase();
return props.platformModels.filter((model) => {
const matchesPlatform = !selectedModelPlatformId || model.platformId === selectedModelPlatformId;
const platform = platformMap.get(model.platformId);
const text = [
model.displayName,
model.modelName,
model.providerModelName,
model.modelAlias,
...model.modelType,
model.provider,
platform?.name,
platform?.internalName,
platform?.platformKey,
].filter(Boolean).join(' ').toLowerCase();
return matchesPlatform && (!keyword || text.includes(keyword));
});
}, [modelQuery, platformMap, props.platformModels, selectedModelPlatformId]);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
function openCreateDialog() {
const provider = providerOptions[0] ?? '';
const providerName = providerDisplayName(provider, providerMap);
setValidationMessage('');
setEditingPlatform(null);
setForm({
...createEmptyPlatformForm(provider, providerDefaults(providerMap.get(provider))),
name: providerName,
internalName: providerName,
selectedModelIds: defaultSelectedModelIds(props.baseModels, provider),
});
setDialogOpen(true);
}
function openEditDialog(platform: IntegrationPlatform) {
setValidationMessage('');
setEditingPlatform(platform);
setForm(platformToForm(platform, props.baseModels, props.platformModels, providerDefaults(providerMap.get(platform.provider))));
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingPlatform(null);
}
function updateProvider(provider: string) {
const previousProviderName = providerDisplayName(form.provider, providerMap);
const providerName = providerDisplayName(provider, providerMap);
const nextForm = applyProviderDefaults(form, provider, providerDefaults(providerMap.get(provider)));
setForm({
...nextForm,
name: shouldUseProviderDefaultName(form.name, previousProviderName) ? providerName : form.name,
internalName: shouldUseProviderDefaultName(form.internalName, previousProviderName) ? providerName : form.internalName,
selectionMode: 'partial',
selectedModelIds: defaultSelectedModelIds(props.baseModels, provider),
});
}
function updateProxyMode(proxyMode: PlatformWizardForm['proxyMode']) {
if (proxyMode === 'global') {
setGlobalProxyNoticeOpen(true);
}
setForm({
...form,
proxyMode,
httpProxy: proxyMode === 'custom' ? form.httpProxy : '',
});
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const validationMessage = validatePlatformForm(form, selectedModels.length, { allowEmptyCredentials: Boolean(editingPlatform) });
if (validationMessage) {
setValidationMessage(validationMessage);
return;
}
try {
await props.onSavePlatform({
platformId: editingPlatform?.id,
platform: platformPayload(form, { preserveEmptyCredentials: Boolean(editingPlatform) }),
models: platformModelPayloads(props.baseModels, form),
selectionMode: form.selectionMode,
});
closeDialog();
} catch (err) {
setValidationMessage(err instanceof Error ? err.message : editingPlatform ? '更新平台失败' : '创建平台失败');
}
}
async function deletePlatform(platform: IntegrationPlatform) {
try {
await props.onDeletePlatform(platform.id);
setPendingDeletePlatform(null);
} catch (err) {
setValidationMessage(err instanceof Error ? err.message : '删除平台失败');
}
}
return (
<section className="pageStack">
<ScreenMessage message={validationMessage} variant="error" onClose={() => setValidationMessage('')} />
<Card>
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText">Base URL</p>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</CardHeader>
<CardContent>
<div className="platformViewTabs">
<button type="button" data-active={viewMode === 'platforms'} onClick={() => setViewMode('platforms')}></button>
<button type="button" data-active={viewMode === 'models'} onClick={() => setViewMode('models')}></button>
<button type="button" data-active={viewMode === 'limits'} onClick={() => setViewMode('limits')}></button>
</div>
{viewMode === 'platforms' ? (
<PlatformTable
now={now}
platformModelCount={platformModelCount}
platforms={props.platforms}
providerMap={providerMap}
pricingRuleSets={props.pricingRuleSets}
onDelete={setPendingDeletePlatform}
onCreate={openCreateDialog}
onEdit={openEditDialog}
/>
) : viewMode === 'models' ? (
<PlatformModelTable
baseModels={props.baseModels}
modelQuery={modelQuery}
models={filteredPlatformModels}
platformId={selectedModelPlatformId}
platformMap={platformMap}
platforms={props.platforms}
providerMap={providerMap}
onModelQueryChange={setModelQuery}
now={now}
onPlatformChange={setSelectedPlatformId}
/>
) : (
<RateLimitStatusTable statuses={props.modelRateLimits} platformMap={platformMap} now={now} />
)}
{props.message && <p className="formMessage">{props.message}</p>}
</CardContent>
</Card>
<FormDialog
bodyClassName="platformDialogBody"
className="platformDialog"
closeLabel="关闭"
eyebrow="Integration Platform"
footer={(
<>
<Button type="button" variant="outline" onClick={closeDialog}>
<RotateCcw size={15} />
</Button>
<Button type="submit" disabled={props.state === 'loading'}>
<CheckCircle2 size={15} />
{editingPlatform ? '保存修改' : '创建平台'}
</Button>
</>
)}
open={dialogOpen}
title={editingPlatform ? '编辑平台' : '新增平台'}
onClose={closeDialog}
onSubmit={submit}
>
<FormSection icon={<ServerCog size={16} />} title="基础信息">
<Label>
<Select value={form.provider} onChange={(event) => updateProvider(event.target.value)}>
<option value=""></option>
{providerOptions.map((provider) => (
<option value={provider} key={provider}>{providerMap.get(provider)?.displayName ?? provider}</option>
))}
</Select>
</Label>
<Label>
<Input value={form.name} placeholder="对外展示名称,例如 OpenAI 官方账号" onChange={(event) => setForm({ ...form, name: event.target.value })} />
</Label>
<Label>
<Input value={form.internalName} placeholder="仅管理后台内部展示,可空" onChange={(event) => setForm({ ...form, internalName: event.target.value })} />
</Label>
<Label>
Key
<Input value={form.platformKey} placeholder="留空自动生成" onChange={(event) => setForm({ ...form, platformKey: event.target.value })} />
</Label>
<Label>
<Input value={form.priority} inputMode="numeric" onChange={(event) => setForm({ ...form, priority: event.target.value })} />
</Label>
<Label className="spanTwo">
Base URL
<Input value={form.baseUrl} placeholder="https://api.example.com/v1" onChange={(event) => setForm({ ...form, baseUrl: event.target.value })} />
</Label>
</FormSection>
<FormSection icon={<KeyRound size={16} />} title="授权信息">
<Label>
<Select value={form.authType} onChange={(event) => setForm({ ...form, authType: event.target.value })}>
{authTypes.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
</Label>
<AuthFields form={form} onChange={setForm} />
<ToggleField
checked={form.testMode}
description="开启后任务按模拟流程运行;不会覆盖已填写的平台凭证。"
label="测试模式"
onChange={(checked) => setForm({ ...form, testMode: checked })}
/>
</FormSection>
<FormSection icon={<Globe2 size={16} />} title="网络代理">
<Label>
<Select
value={form.proxyMode}
onChange={(event) => updateProxyMode(event.target.value as PlatformWizardForm['proxyMode'])}
>
<option value="none">使</option>
<option value="global">使</option>
<option value="custom"></option>
</Select>
</Label>
{form.proxyMode === 'custom' && (
<Label>
HTTP
<Input
value={form.httpProxy}
placeholder="http://127.0.0.1:7890"
onChange={(event) => setForm({ ...form, httpProxy: event.target.value })}
/>
</Label>
)}
</FormSection>
<FormSection icon={<SlidersHorizontal size={16} />} title="路由与计费">
<Label>
<Select value={form.pricingRuleSetId} onChange={(event) => setForm({ ...form, pricingRuleSetId: event.target.value })}>
<option value=""></option>
{props.pricingRuleSets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</Select>
</Label>
<Label>
<Input value={form.defaultDiscountFactor} inputMode="decimal" onChange={(event) => setForm({ ...form, defaultDiscountFactor: event.target.value })} />
</Label>
<ToggleField checked={form.retryEnabled} label="失败后同平台重试" onChange={(checked) => setForm({ ...form, retryEnabled: checked })} />
<Label>
<Input value={form.retryMaxAttempts} inputMode="numeric" disabled={!form.retryEnabled} onChange={(event) => setForm({ ...form, retryMaxAttempts: event.target.value })} />
</Label>
</FormSection>
<FormSection icon={<ShieldCheck size={16} />} title="限流策略">
<Label>RPM / <Input value={form.rpmLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => setForm({ ...form, rpmLimit: event.target.value })} /></Label>
<Label>RPS / <Input value={form.rpsLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => setForm({ ...form, rpsLimit: event.target.value })} /></Label>
<Label>TPM / Token<Input value={form.tpmLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => setForm({ ...form, tpmLimit: event.target.value })} /></Label>
<Label><Input value={form.concurrencyLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => setForm({ ...form, concurrencyLimit: event.target.value })} /></Label>
<div className="platformTogglePair">
<ToggleField checked={form.supportBase64Input} label="支持 Base64 输入" onChange={(checked) => setForm({ ...form, supportBase64Input: checked })} />
<ToggleField checked={form.supportUrlInput} label="支持 URL 输入" onChange={(checked) => setForm({ ...form, supportUrlInput: checked })} />
</div>
</FormSection>
<FormSection icon={<Boxes size={16} />} title={`模型绑定 · ${selectedModels.length}/${availableModels.length}`}>
<ModelBindingPolicy form={form} onChange={setForm} />
<ModelSelection
currentProvider={form.provider}
form={form}
models={availableModels}
providerMap={providerMap}
providerName={providerLabel(providerMap.get(form.provider)?.displayName ?? form.provider)}
onChange={setForm}
/>
</FormSection>
</FormDialog>
<ConfirmDialog
cancelLabel="关闭"
confirmLabel="知道了"
confirmVariant="default"
description="使用全局代理前,请确认网关服务已设置 AI_GATEWAY_GLOBAL_HTTP_PROXY 或 GLOBAL_HTTP_PROXY 环境变量;也兼容 HTTP_PROXY、HTTPS_PROXY、ALL_PROXY。修改环境变量后需要重启服务才会生效。"
loading={props.state === 'loading'}
open={globalProxyNoticeOpen}
title="使用全局代理"
onCancel={() => setGlobalProxyNoticeOpen(false)}
onConfirm={() => setGlobalProxyNoticeOpen(false)}
>
<p className="platformProxyStatus">{globalProxyStatusText(props.networkProxyConfig)}</p>
</ConfirmDialog>
<ConfirmDialog
confirmLabel="删除平台"
description="已绑定的模型会一并删除,删除后不可恢复。"
loading={props.state === 'loading'}
open={Boolean(pendingDeletePlatform)}
title={`确定删除平台“${pendingDeletePlatform ? platformDisplayName(pendingDeletePlatform) : ''}”?`}
onCancel={() => setPendingDeletePlatform(null)}
onConfirm={() => pendingDeletePlatform ? deletePlatform(pendingDeletePlatform) : undefined}
/>
</section>
);
}
function ModelBindingPolicy(props: { form: PlatformWizardForm; onChange: (value: PlatformWizardForm) => void }) {
const { form, onChange } = props;
return (
<div className="platformModelPolicy spanTwo">
<Label>
<Input value={form.modelDiscountFactor} placeholder="留空继承平台折扣,可在下方逐个覆盖" inputMode="decimal" onChange={(event) => onChange({ ...form, modelDiscountFactor: event.target.value })} />
</Label>
<ToggleField checked={form.modelOverrideRetry} label="覆盖模型重试策略" onChange={(checked) => onChange({ ...form, modelOverrideRetry: checked })} />
{form.modelOverrideRetry && (
<>
<ToggleField checked={form.modelRetryEnabled} label="模型同平台重试" onChange={(checked) => onChange({ ...form, modelRetryEnabled: checked })} />
<Label>
<Input value={form.modelRetryMaxAttempts} inputMode="numeric" disabled={!form.modelRetryEnabled} onChange={(event) => onChange({ ...form, modelRetryMaxAttempts: event.target.value })} />
</Label>
</>
)}
<ToggleField checked={form.modelOverrideRateLimit} label="覆盖模型限流策略" onChange={(checked) => onChange({ ...form, modelOverrideRateLimit: checked })} />
{form.modelOverrideRateLimit && (
<>
<Label> RPM<Input value={form.modelRpmLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => onChange({ ...form, modelRpmLimit: event.target.value })} /></Label>
<Label> RPS<Input value={form.modelRpsLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => onChange({ ...form, modelRpsLimit: event.target.value })} /></Label>
<Label> TPM<Input value={form.modelTpmLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => onChange({ ...form, modelTpmLimit: event.target.value })} /></Label>
<Label><Input value={form.modelConcurrencyLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => onChange({ ...form, modelConcurrencyLimit: event.target.value })} /></Label>
</>
)}
</div>
);
}
function PlatformTable(props: {
now: number;
platformModelCount: Map<string, number>;
platforms: IntegrationPlatform[];
providerMap: Map<string, CatalogProvider>;
pricingRuleSets: PricingRuleSet[];
onCreate: () => void;
onDelete: (platform: IntegrationPlatform) => void;
onEdit: (platform: IntegrationPlatform) => void;
}) {
if (!props.platforms.length) {
return (
<div className="platformEmptyState">
<div className="platformEmptyIcon"><ServerCog size={24} /></div>
<strong></strong>
<span></span>
<Button type="button" onClick={props.onCreate}>
<Plus size={15} />
</Button>
</div>
);
}
return (
<Table className="platformDataTable">
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead>Provider</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{props.platforms.map((platform) => {
const pricing = platformPricingSummary(platform, props.pricingRuleSets);
const rateLimit = platformRateLimitSummary(platform.rateLimitPolicy);
const runtime = platformRuntimeSummary(platform);
const platformCooldownMs = cooldownRemainingMs(platform.cooldownUntil, props.now);
return (
<TableRow key={platform.id}>
<TableCell>
<span className="platformTableName">
<strong>{platformDisplayName(platform)}</strong>
<small>{platform.internalName ? `对外:${platform.name} · ${platform.platformKey}` : platform.platformKey}</small>
</span>
</TableCell>
<TableCell>{props.providerMap.get(platform.provider)?.displayName ?? platform.provider}</TableCell>
<TableCell>
<span className="platformTableName">
<strong title={pricing.title}>{pricing.title}</strong>
<small>{pricing.subtitle}</small>
</span>
</TableCell>
<TableCell>
<span className="platformTableName">
<strong>{formatDiscountFactor(platform.defaultDiscountFactor)}</strong>
<small></small>
</span>
</TableCell>
<TableCell>
<span className="platformTableName">
<strong title={rateLimit.title}>{rateLimit.title}</strong>
<small>{rateLimit.subtitle}</small>
</span>
</TableCell>
<TableCell>{props.platformModelCount.get(platform.id) ?? 0}</TableCell>
<TableCell>
<span className="platformTableName">
<strong>
{platformCooldownMs > 0 ? (
<Badge variant="warning"></Badge>
) : (
<Badge variant={platform.status === 'enabled' ? 'success' : 'secondary'}>{platform.status}</Badge>
)}
</strong>
<small>{platformCooldownMs > 0 ? `剩余 ${formatCooldownRemaining(platformCooldownMs)}` : runtime}</small>
</span>
</TableCell>
<TableCell>
<div className="tableActions">
<Button type="button" variant="ghost" size="icon" aria-label="编辑平台" onClick={() => props.onEdit(platform)}>
<Pencil size={14} />
</Button>
<Button type="button" variant="ghost" size="icon" aria-label="删除平台" onClick={() => props.onDelete(platform)}>
<Trash2 size={14} />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</Table>
);
}
function PlatformModelTable(props: {
baseModels: BaseModelCatalogItem[];
modelQuery: string;
models: PlatformModel[];
platformId: string;
platformMap: Map<string, IntegrationPlatform>;
platforms: IntegrationPlatform[];
providerMap: Map<string, CatalogProvider>;
now: number;
onModelQueryChange: (value: string) => void;
onPlatformChange: (value: string) => void;
}) {
return (
<section className="platformModelView">
<div className="platformModelToolbar">
<Label>
<Select value={props.platformId} onChange={(event) => props.onPlatformChange(event.target.value)}>
{props.platforms.map((platform) => (
<option value={platform.id} key={platform.id}>{platformDisplayName(platform)}</option>
))}
</Select>
</Label>
<Label>
<Input
value={props.modelQuery}
placeholder="模型名称、别名、类型或平台名称"
onChange={(event) => props.onModelQueryChange(event.target.value)}
/>
</Label>
</div>
{!props.platforms.length ? (
<EmptyState title="暂无平台" description="请先添加平台,再查看对应平台模型。" />
) : (
props.models.length ? (
<section className="baseModelGrid platformModelCardGrid">
{props.models.map((model) => {
const platform = props.platformMap.get(model.platformId);
const provider = platform ? props.providerMap.get(platform.provider) : undefined;
const baseModel = findBaseModelForPlatformModel(platform, props.baseModels, model);
const modelIconPath = readPlatformModelIconPath(model, baseModel);
const modelCooldownMs = cooldownRemainingMs(model.cooldownUntil, props.now);
return (
<ModelCatalogCard
key={model.id}
badges={[
<Badge key="type" variant="outline">{model.modelType.join(', ')}</Badge>,
...(modelCooldownMs > 0 ? [<Badge key="cooldown" variant="warning"> · {formatCooldownRemaining(modelCooldownMs)}</Badge>] : []),
<Badge key="enabled" variant={model.enabled ? 'success' : 'secondary'}>{model.enabled ? 'enabled' : 'disabled'}</Badge>,
]}
chips={platformModelChips(model)}
iconPath={modelIconPath || provider?.iconPath}
iconText={provider?.displayName ?? model.provider ?? 'model'}
meta={[
platform ? platformDisplayName(platform) : model.platformName ?? '-',
provider?.displayName ?? platform?.provider ?? model.provider ?? '-',
model.modelAlias || model.modelName || '-',
]}
subtitle={model.providerModelName ? `调用模型名:${model.providerModelName}` : model.modelName}
title={model.displayName || model.modelName}
/>
);
})}
</section>
) : (
<EmptyState title="没有匹配的模型" description="换个平台或搜索关键词试试。" />
)
)}
</section>
);
}
function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platformMap: Map<string, IntegrationPlatform>; now: number }) {
if (!props.statuses.length) {
return <EmptyState title="暂无限流状态" description="模型产生请求后会在这里显示实时 RPM、TPM 和并发窗口。" />;
}
return (
<section className="platformLimitView">
<div className="platformLimitHeader">
<span><Gauge size={15} /></span>
<small> 3 TPM + </small>
</div>
<div className="platformLimitTableViewport">
<Table className="platformDataTable platformLimitTable">
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>TPM</TableHead>
<TableHead>RPM</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{props.statuses.map((status) => {
const platform = props.platformMap.get(status.platformId);
return (
<TableRow key={status.platformModelId}>
<TableCell>
<span className="platformTableName">
<strong>{status.displayName || status.modelAlias || status.modelName}</strong>
<small>{status.providerModelName || status.modelName}</small>
</span>
</TableCell>
<TableCell>
<span className="platformTableName">
<strong>{platform ? platformDisplayName(platform) : status.platformName}</strong>
<small>{status.provider}</small>
</span>
</TableCell>
<TableCell>{metricCell(status.concurrent)}</TableCell>
<TableCell>{metricCell(status.tpm, true)}</TableCell>
<TableCell>{metricCell(status.rpm)}</TableCell>
<TableCell>{modelRuntimeStatusCell(status, props.now)}</TableCell>
<TableCell>
<span className="rateLoadCell">
<strong>{formatPercent(status.loadRatio)}</strong>
<span className="rateLoadTrack"><i style={{ width: `${Math.min(status.loadRatio * 100, 100)}%` }} /></span>
</span>
</TableCell>
</TableRow>
);
})}
</Table>
</div>
</section>
);
}
function platformDisplayName(platform: IntegrationPlatform) {
return platform.internalName?.trim() || platform.name;
}
function FormSection(props: { children: ReactNode; icon: ReactNode; title: string }) {
return (
<section className="platformFormSection spanTwo">
<header>{props.icon}<strong>{props.title}</strong></header>
<div className="platformSectionGrid">{props.children}</div>
</section>
);
}
function AuthFields(props: { form: PlatformWizardForm; onChange: (value: PlatformWizardForm) => void }) {
const { form, onChange } = props;
const apiKeyPreview = credentialPreviewValue(form.credentialsPreview, 'apiKey', 'api_key', 'key');
const tokenPreview = credentialPreviewValue(form.credentialsPreview, 'token');
const accessKeyPreview = credentialPreviewValue(form.credentialsPreview, 'accessKey', 'access_key');
const secretKeyPreview = credentialPreviewValue(form.credentialsPreview, 'secretKey', 'secret_key');
if (form.authType === 'Token') {
return (
<CredentialField label="Token">
<Input
autoComplete="off"
name="gateway-platform-token"
value={form.token}
placeholder={credentialInputPlaceholder(tokenPreview)}
onChange={(event) => onChange({ ...form, token: event.target.value })}
/>
</CredentialField>
);
}
if (form.authType === 'AccessKey-SecretKey') {
return (
<>
<CredentialField label="AccessKey">
<Input
autoComplete="off"
name="gateway-platform-access-key"
value={form.accessKey}
placeholder={credentialInputPlaceholder(accessKeyPreview)}
onChange={(event) => onChange({ ...form, accessKey: event.target.value })}
/>
</CredentialField>
<CredentialField label="SecretKey">
<Input
autoComplete="off"
name="gateway-platform-secret-key"
value={form.secretKey}
placeholder={credentialInputPlaceholder(secretKeyPreview)}
onChange={(event) => onChange({ ...form, secretKey: event.target.value })}
/>
</CredentialField>
</>
);
}
if (form.authType === 'none') {
return <div className="platformReadonlyField"></div>;
}
return (
<CredentialField label="API Key">
<Input
autoComplete="off"
name="gateway-platform-api-key"
value={form.apiKey}
placeholder={credentialInputPlaceholder(apiKeyPreview)}
onChange={(event) => onChange({ ...form, apiKey: event.target.value })}
/>
</CredentialField>
);
}
function CredentialField(props: { children: ReactNode; label: string }) {
return (
<Label className="platformCredentialField">
{props.label}
{props.children}
<small></small>
</Label>
);
}
function ToggleField(props: { checked: boolean; description?: string; label: string; onChange: (checked: boolean) => void }) {
return (
<label className="platformToggle">
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
<span>
<strong>{props.label}</strong>
{props.description && <small>{props.description}</small>}
</span>
</label>
);
}
function ModelSelection(props: {
currentProvider: string;
form: PlatformWizardForm;
models: BaseModelCatalogItem[];
providerMap: Map<string, CatalogProvider>;
providerName: string;
onChange: (value: PlatformWizardForm) => void;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const selectedIds = new Set(props.form.selectedModelIds);
const selectedModels = selectedModelsForForm(props.models, props.form);
function removeModel(modelId: string) {
const next = new Set(selectedIds);
next.delete(modelId);
const modelDiscountFactors = { ...props.form.modelDiscountFactors };
const modelNameMappings = { ...props.form.modelNameMappings };
delete modelDiscountFactors[modelId];
delete modelNameMappings[modelId];
props.onChange({
...props.form,
modelDiscountFactors,
modelNameMappings,
selectionMode: 'partial',
selectedModelIds: Array.from(next),
});
}
function updateSelectedModelIds(modelIds: string[]) {
const visibleIds = new Set(props.models.map((model) => model.id));
const nextIds = Array.from(new Set(modelIds)).filter((modelId) => visibleIds.has(modelId));
props.onChange({ ...props.form, selectionMode: 'partial', selectedModelIds: nextIds });
}
function updateModelDiscount(modelId: string, value: string) {
props.onChange({
...props.form,
modelDiscountFactors: {
...props.form.modelDiscountFactors,
[modelId]: value,
},
});
}
function updateModelNameMapping(modelId: string, value: string) {
props.onChange({
...props.form,
modelNameMappings: {
...props.form.modelNameMappings,
[modelId]: value,
},
});
}
return (
<div className="platformModelSelector spanTwo">
<div className="platformModelSelectorHeader">
<div>
<strong></strong>
<span> {props.providerName} </span>
</div>
<Button type="button" variant="outline" onClick={() => setPickerOpen(true)}>
<Plus size={14} />
</Button>
</div>
{!selectedModels.length ? (
<div className="platformModelEmpty"></div>
) : (
<div className="platformModelChoices">
{selectedModels.map((model) => {
const modelLabel = stableModelAlias(model) || model.providerModelName;
const providerModelName = props.form.modelNameMappings[model.id] ?? model.providerModelName;
return (
<div className="platformModelChoice" key={model.id}>
<div className="platformModelChoiceMain">
<span>
<strong>{modelLabel}</strong>
<small>{props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)}</small>
</span>
</div>
<div className="platformModelChoiceFields">
<Label>
<Input
aria-label={`${modelLabel} 调用模型名`}
placeholder={model.providerModelName}
value={providerModelName}
onChange={(event) => updateModelNameMapping(model.id, event.target.value)}
/>
</Label>
<Label>
<Input
aria-label={`${modelLabel} 折扣率`}
inputMode="decimal"
placeholder="继承"
value={props.form.modelDiscountFactors[model.id] ?? ''}
onChange={(event) => updateModelDiscount(model.id, event.target.value)}
/>
</Label>
</div>
<Button type="button" variant="ghost" size="icon" aria-label="移除模型" onClick={() => removeModel(model.id)}>
<Trash2 size={14} />
</Button>
</div>
);
})}
</div>
)}
<ModelPickerDialog
currentProvider={props.currentProvider}
models={props.models}
open={pickerOpen}
providerMap={props.providerMap}
selectedModelIds={props.form.selectedModelIds}
onClose={() => setPickerOpen(false)}
onConfirm={updateSelectedModelIds}
/>
</div>
);
}
function ModelPickerDialog(props: {
currentProvider: string;
models: BaseModelCatalogItem[];
open: boolean;
providerMap: Map<string, CatalogProvider>;
selectedModelIds: string[];
onClose: () => void;
onConfirm: (modelIds: string[]) => void;
}) {
const [query, setQuery] = useState('');
const [providerFilter, setProviderFilter] = useState(props.currentProvider || 'all');
const [draftIds, setDraftIds] = useState<string[]>(props.selectedModelIds);
const providerOptions = useMemo(() => Array.from(new Set(props.models.map((model) => model.providerKey))).filter(Boolean), [props.models]);
const draftIdSet = new Set(draftIds);
const keyword = query.trim().toLowerCase();
const filteredModels = props.models.filter((model) => {
if (providerFilter !== 'all' && model.providerKey !== providerFilter) return false;
if (!keyword) return true;
return [
stableModelAlias(model),
model.providerModelName,
model.canonicalModelKey,
baseModelTypeText(model),
model.providerKey,
props.providerMap.get(model.providerKey)?.displayName,
JSON.stringify(model.capabilities ?? {}),
].filter(Boolean).join(' ').toLowerCase().includes(keyword);
});
useEffect(() => {
if (!props.open) return;
setQuery('');
setProviderFilter(props.currentProvider || 'all');
setDraftIds(props.selectedModelIds);
}, [props.currentProvider, props.open, props.selectedModelIds]);
useEffect(() => {
if (!props.open) return undefined;
function onKeyDown(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
props.onClose();
}
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [props.open, props.onClose]);
function toggleModel(modelId: string) {
setDraftIds((current) => {
const next = new Set(current);
if (next.has(modelId)) next.delete(modelId);
else next.add(modelId);
return Array.from(next);
});
}
function addFilteredModels() {
setDraftIds((current) => Array.from(new Set([...current, ...filteredModels.map((model) => model.id)])));
}
if (!props.open) return null;
return (
<div className="modelPickerBackdrop" role="presentation">
<section className="modelPickerDialog" role="dialog" aria-modal="true" aria-label="选择模型">
<header className="modelPickerHeader">
<div>
<strong></strong>
<span> {draftIds.length} </span>
</div>
<Button type="button" variant="ghost" size="icon" onClick={props.onClose} aria-label="关闭">
<X size={15} />
</Button>
</header>
<div className="modelPickerToolbar">
<Label>
<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
<option value="all"></option>
{providerOptions.map((provider) => (
<option value={provider} key={provider}>{props.providerMap.get(provider)?.displayName ?? provider}</option>
))}
</Select>
</Label>
<Label className="modelPickerSearch">
<div>
<Search size={14} />
<Input
value={query}
placeholder="模型名称、能力或 Key"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault();
}}
/>
</div>
</Label>
<Button type="button" variant="outline" onClick={addFilteredModels}>
<Plus size={14} />
</Button>
</div>
<div className="modelPickerList">
{filteredModels.length ? filteredModels.map((model) => (
<label className="modelPickerItem" key={model.id}>
<input type="checkbox" checked={draftIdSet.has(model.id)} onChange={() => toggleModel(model.id)} />
<span>
<strong>{stableModelAlias(model) || model.providerModelName}</strong>
<small>{props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)}</small>
</span>
<Badge variant="outline">{baseModelTypeText(model)}</Badge>
</label>
)) : <div className="platformModelEmpty"></div>}
</div>
<footer className="modelPickerActions">
<Button type="button" variant="outline" onClick={props.onClose}></Button>
<Button type="button" onClick={() => {
props.onConfirm(draftIds);
props.onClose();
}}>
</Button>
</footer>
</section>
</div>
);
}
function countModelsByPlatform(models: PlatformModel[]) {
const counts = new Map<string, number>();
models.forEach((model) => counts.set(model.platformId, (counts.get(model.platformId) ?? 0) + 1));
return counts;
}
function providerDefaults(provider?: CatalogProvider) {
return {
defaultAuthType: provider?.defaultAuthType || 'APIKey',
defaultBaseUrl: provider?.defaultBaseUrl || '',
};
}
function platformToForm(
platform: IntegrationPlatform,
baseModels: BaseModelCatalogItem[],
platformModels: PlatformModel[],
defaults?: ReturnType<typeof providerDefaults>,
): PlatformWizardForm {
const config = platform.config ?? {};
const retryPolicy = platform.retryPolicy ?? {};
const rateLimitPolicy = platform.rateLimitPolicy ?? {};
const networkProxy = readNetworkProxyConfig(config);
const currentModels = platformModels.filter((model) => model.platformId === platform.id);
return {
...createEmptyPlatformForm(platform.provider, defaults),
provider: platform.provider,
platformKey: platform.platformKey,
name: platform.name,
internalName: platform.internalName ?? '',
baseUrl: platform.baseUrl ?? defaults?.defaultBaseUrl ?? '',
authType: platform.authType || defaults?.defaultAuthType || 'APIKey',
credentialsPreview: platform.credentialsPreview ?? {},
apiKey: credentialPreviewValue(platform.credentialsPreview, 'apiKey', 'api_key', 'key'),
token: credentialPreviewValue(platform.credentialsPreview, 'token'),
accessKey: credentialPreviewValue(platform.credentialsPreview, 'accessKey', 'access_key'),
secretKey: credentialPreviewValue(platform.credentialsPreview, 'secretKey', 'secret_key'),
pricingRuleSetId: platform.pricingRuleSetId ?? '',
defaultDiscountFactor: numberText(platform.defaultDiscountFactor, '1'),
priority: numberText(platform.priority, '100'),
retryEnabled: readBoolean(retryPolicy, 'enabled', true),
retryMaxAttempts: numberText(readNumber(retryPolicy, 'maxAttempts'), '2'),
rpmLimit: readLimit(rateLimitPolicy, 'rpm'),
rpsLimit: readLimit(rateLimitPolicy, 'rps'),
tpmLimit: readLimit(rateLimitPolicy, 'tpm_total'),
concurrencyLimit: readLimit(rateLimitPolicy, 'concurrent'),
testMode: readBoolean(config, 'testMode', false),
proxyMode: networkProxy.proxyMode,
httpProxy: networkProxy.httpProxy,
supportBase64Input: readBoolean(config, 'supportBase64Input', true),
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
modelDiscountFactors: platformModelDiscountFactors(platform, baseModels, currentModels),
modelNameMappings: platformModelNameMappings(platform, baseModels, currentModels),
selectionMode: 'partial',
};
}
function defaultSelectedModelIds(models: BaseModelCatalogItem[], provider: string) {
return modelsForProvider(models, provider).map((model) => model.id);
}
function platformModelBaseIds(platform: IntegrationPlatform, baseModels: BaseModelCatalogItem[], platformModels: PlatformModel[]) {
const ids = platformModels.map((model) => findBaseModelForPlatformModel(platform, baseModels, model)?.id).filter((id): id is string => Boolean(id));
return Array.from(new Set(ids));
}
function platformModelDiscountFactors(platform: IntegrationPlatform, baseModels: BaseModelCatalogItem[], platformModels: PlatformModel[]) {
return platformModels.reduce<Record<string, string>>((acc, model) => {
const baseModel = findBaseModelForPlatformModel(platform, baseModels, model);
if (baseModel?.id && model.discountFactor) acc[baseModel.id] = String(model.discountFactor);
return acc;
}, {});
}
function platformModelNameMappings(platform: IntegrationPlatform, baseModels: BaseModelCatalogItem[], platformModels: PlatformModel[]) {
return platformModels.reduce<Record<string, string>>((acc, model) => {
const baseModel = findBaseModelForPlatformModel(platform, baseModels, model);
if (baseModel?.id) acc[baseModel.id] = model.providerModelName || model.modelName;
return acc;
}, {});
}
function findBaseModelForPlatformModel(platform: IntegrationPlatform | undefined, baseModels: BaseModelCatalogItem[], model: PlatformModel) {
return baseModels.find((item) => item.id === model.baseModelId) ??
baseModels.find((item) => item.canonicalModelKey === model.modelAlias) ??
baseModels.find((item) => stableModelAlias(item) === model.modelAlias) ??
baseModels.find((item) => item.providerModelName === model.modelName && model.modelType.some((type) => baseModelTypes(item).includes(type))) ??
baseModels.find((item) => item.providerKey === platform?.provider && item.providerModelName === model.modelName && model.modelType.some((type) => baseModelTypes(item).includes(type)));
}
function readPlatformModelIconPath(model: PlatformModel, baseModel?: BaseModelCatalogItem) {
return readString(baseModel?.metadata?.iconPath) ||
readString(readRecord(baseModel?.metadata?.rawModel)?.icon_path) ||
readString(readRecord(model.capabilities)?.iconPath) ||
readString(readRecord(model.capabilities)?.icon_path) ||
readString(readRecord(model.billingConfig)?.iconPath) ||
readString(readRecord(model.billingConfig)?.icon_path);
}
function readString(value: unknown) {
return typeof value === 'string' ? value : '';
}
function credentialPreviewValue(preview: Record<string, unknown> | undefined, ...keys: string[]) {
if (!preview) return '';
for (const key of keys) {
const value = preview[key];
if (typeof value === 'string' && value.trim()) return value;
}
return '';
}
function credentialInputPlaceholder(preview: string) {
return preview ? '填写新凭证以覆盖当前值' : '';
}
function readRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function readBoolean(source: Record<string, unknown>, key: string, fallback: boolean) {
const value = source[key];
return typeof value === 'boolean' ? value : fallback;
}
function readNetworkProxyConfig(config: Record<string, unknown>): Pick<PlatformWizardForm, 'proxyMode' | 'httpProxy'> {
const networkProxy = readRecord(config.networkProxy);
const mode = readString(networkProxy.mode || config.proxyMode);
const httpProxy = readString(networkProxy.httpProxy || config.httpProxy);
if (mode === 'global' || mode === 'custom') {
return { proxyMode: mode, httpProxy };
}
return { proxyMode: 'none', httpProxy: '' };
}
function readNumber(source: Record<string, unknown>, key: string) {
const value = source[key];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function readLimit(policy: Record<string, unknown>, metric: string) {
const rules = Array.isArray(policy.rules) ? policy.rules : [];
const rule = rules.find((item): item is { metric?: unknown; limit?: unknown } => typeof item === 'object' && item !== null && 'metric' in item && item.metric === metric);
return typeof rule?.limit === 'number' ? String(rule.limit) : '';
}
function numberText(value: unknown, fallback: string) {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? String(value) : fallback;
}
function providerDisplayName(providerKey: string, providerMap: Map<string, CatalogProvider>) {
return providerMap.get(providerKey)?.displayName || providerKey;
}
function shouldUseProviderDefaultName(value: string, previousProviderName: string) {
const trimmed = value.trim();
return !trimmed || trimmed === previousProviderName;
}
function platformModelChips(model: PlatformModel) {
const chips: string[] = [model.pricingMode];
if (model.discountFactor) chips.push(`折扣 ${model.discountFactor}`);
if (model.pricingRuleSetId) chips.push('计价规则');
if (model.runtimePolicySetId) chips.push('运行策略');
return chips.filter(Boolean);
}
function platformPricingSummary(platform: IntegrationPlatform, ruleSets: PricingRuleSet[]) {
const matchedRuleSet = platform.pricingRuleSetId ? ruleSets.find((item) => item.id === platform.pricingRuleSetId) : undefined;
const defaultRuleSet = ruleSets.find((item) => item.category === 'default' && item.status === 'active') ?? ruleSets.find((item) => item.category === 'default');
const title = matchedRuleSet?.name || defaultRuleSet?.name || '默认计价规则';
return {
title,
subtitle: pricingModeText(platform.defaultPricingMode),
};
}
function pricingModeText(mode: IntegrationPlatform['defaultPricingMode']) {
if (mode === 'custom') return '自定义计价';
if (mode === 'inherit') return '直接继承';
return '继承并应用折扣';
}
function formatDiscountFactor(value: number | undefined) {
const factor = typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 1;
return `${trimNumber(factor * 100)}%`;
}
function platformRateLimitSummary(policy: IntegrationPlatform['rateLimitPolicy']) {
const rules = Array.isArray(policy?.rules) ? policy.rules : [];
if (!rules.length) {
return { title: '未设置', subtitle: '跟随全局或模型策略' };
}
const labels = rules
.filter((rule) => typeof rule.limit === 'number' && Number.isFinite(rule.limit))
.map((rule) => `${rateLimitMetricText(rule.metric)} ${formatLimit(rule.limit)}`);
if (!labels.length) {
return { title: '未设置', subtitle: '跟随全局或模型策略' };
}
return {
title: labels.slice(0, 2).join(' · '),
subtitle: labels.length > 2 ? labels.slice(2).join(' · ') : '平台级限流',
};
}
function rateLimitMetricText(metric: string) {
const labels: Record<string, string> = {
tpm_total: 'TPM',
tpm_input: '输入TPM',
tpm_output: '输出TPM',
rpm: 'RPM',
rps: 'RPS',
concurrent: '并发',
queue_size: '队列',
};
return labels[metric] ?? metric;
}
function metricCell(metric: ModelRateLimitStatus['rpm'], includeReserved = false) {
if (!metric.limited) return <span className="rateMetricCell"><strong>{formatLimit(metric.currentValue)} / </strong><small></small></span>;
return (
<span className="rateMetricCell">
<strong>{formatLimit(metric.currentValue)} / {formatLimit(metric.limitValue)}</strong>
<small>{includeReserved && metric.reservedValue > 0 ? `已用 ${formatLimit(metric.usedValue)} · 预占 ${formatLimit(metric.reservedValue)}` : `窗口 ${formatPercent(metric.ratio)}`}</small>
</span>
);
}
function modelRuntimeStatusCell(status: ModelRateLimitStatus, now: number) {
const modelCooldownMs = cooldownRemainingMs(status.modelCooldownUntil, now);
const platformCooldownMs = cooldownRemainingMs(status.platformCooldownUntil, now);
if (modelCooldownMs > 0) {
return (
<span className="platformTableName">
<strong><Badge variant="warning"></Badge></strong>
<small> {formatCooldownRemaining(modelCooldownMs)}</small>
</span>
);
}
if (platformCooldownMs > 0) {
return (
<span className="platformTableName">
<strong><Badge variant="warning"></Badge></strong>
<small> {formatCooldownRemaining(platformCooldownMs)}</small>
</span>
);
}
return (
<span className="platformTableName">
<strong><Badge variant={status.enabled ? 'success' : 'secondary'}>{status.enabled ? '可用' : '已停用'}</Badge></strong>
<small>{status.enabled ? '参与路由' : '不参与路由'}</small>
</span>
);
}
function cooldownRemainingMs(cooldownUntil: string | undefined, now: number) {
if (!cooldownUntil) return 0;
const until = Date.parse(cooldownUntil);
if (!Number.isFinite(until)) return 0;
return Math.max(until - now, 0);
}
function formatCooldownRemaining(milliseconds: number) {
const minutes = milliseconds / 60000;
if (minutes >= 1) return `${trimNumber(Math.ceil(minutes * 10) / 10)} 分钟`;
const seconds = Math.ceil(milliseconds / 1000);
return `${Math.max(seconds, 1)}`;
}
function formatPercent(value: number) {
if (!Number.isFinite(value) || value <= 0) return '0%';
return `${trimNumber(value * 100)}%`;
}
function platformRuntimeSummary(platform: IntegrationPlatform) {
const retryPolicy = platform.retryPolicy ?? {};
const retryEnabled = readBoolean(retryPolicy, 'enabled', true);
const maxAttempts = readNumber(retryPolicy, 'maxAttempts') ?? 2;
return `优先级 ${platform.priority} · ${retryEnabled ? `同平台最多尝试 ${maxAttempts}` : '同平台不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`;
}
function proxyModeText(mode: PlatformWizardForm['proxyMode']) {
if (mode === 'global') return '全局代理';
if (mode === 'custom') return '自定义代理';
return '直连';
}
function globalProxyStatusText(config: { globalHttpProxy?: string; globalHttpProxySet: boolean } | null) {
if (!config) return '读取中';
const proxy = config.globalHttpProxy?.trim() ?? '';
return config.globalHttpProxySet && proxy ? proxy : '未设置';
}
function formatLimit(value: number) {
if (Math.abs(value) >= 10000) return `${trimNumber(value / 10000)}`;
if (Math.abs(value) >= 1000) return `${trimNumber(value / 1000)}k`;
return trimNumber(value);
}
function trimNumber(value: number) {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
}