Files
easyai-ai-gateway/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx
T

540 lines
23 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 { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import type {
BaseModelCatalogItem,
BaseModelUpsertRequest,
CatalogProvider,
PricingRuleSet,
RuntimePolicySet,
} from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, FormItem, Label, Input, Select, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
import { BaseModelCapabilityEditor } from './BaseModelCapabilityEditor';
import {
addCapabilityType,
capabilitiesFromForm,
capabilitiesToForm,
createCapabilityEditorState,
platformModelTypeDefinitions,
removeCapabilityType,
syncPrimaryCapability,
type CapabilityEditorState,
} from './base-model-capabilities';
import { ModelCatalogCard } from './ModelCatalogCard';
type ModelForm = {
providerKey: string;
canonicalModelKey: string;
providerModelName: string;
modelAlias: string;
status: string;
pricingRuleSetId: string;
runtimePolicySetId: string;
runtimePolicyOverrideJson: string;
pricingVersion: string;
capabilities: CapabilityEditorState;
billingJson: string;
rateLimitJson: string;
metadataJson: string;
};
const statuses = ['active', 'deprecated', 'hidden'];
const fallbackTypes = platformModelTypeDefinitions.map((item) => item.key);
function createEmptyForm(modelType = 'text_generate'): ModelForm {
return {
providerKey: '',
canonicalModelKey: '',
providerModelName: '',
modelAlias: '',
status: 'active',
pricingRuleSetId: '',
runtimePolicySetId: '',
runtimePolicyOverrideJson: '{}',
pricingVersion: '1',
capabilities: createCapabilityEditorState(modelType),
billingJson: '{}',
rateLimitJson: '{}',
metadataJson: '{}',
};
}
export function BaseModelCatalogPanel(props: {
baseModels: BaseModelCatalogItem[];
message: string;
pricingRuleSets: PricingRuleSet[];
providers: CatalogProvider[];
runtimePolicySets: RuntimePolicySet[];
state: LoadState;
onDeleteBaseModel: (baseModelId: string) => Promise<void>;
onResetAllBaseModels: () => Promise<void>;
onResetBaseModel: (baseModelId: string) => Promise<void>;
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<ModelForm>(() => createEmptyForm());
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const [providerFilter, setProviderFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [pendingDeleteModel, setPendingDeleteModel] = useState<BaseModelCatalogItem | null>(null);
const [pendingResetModel, setPendingResetModel] = useState<BaseModelCatalogItem | null>(null);
const [pendingResetAll, setPendingResetAll] = useState(false);
const providerNames = useMemo(
() => new Map(props.providers.map((item) => [item.providerKey, item.displayName])),
[props.providers],
);
const typeOptions = useMemo(
() => Array.from(new Set([...fallbackTypes, ...props.baseModels.flatMap(readModelTypes)])),
[props.baseModels],
);
const providerOptions = useMemo(
() => Array.from(new Set([...props.providers.map((item) => item.providerKey), ...props.baseModels.map((item) => item.providerKey)])),
[props.baseModels, props.providers],
);
const defaultPricingRuleSetId = useMemo(() => defaultRuleSetId(props.pricingRuleSets), [props.pricingRuleSets]);
const defaultRuntimePolicySetId = useMemo(() => defaultRuntimePolicyId(props.runtimePolicySets), [props.runtimePolicySets]);
const systemModelCount = useMemo(() => props.baseModels.filter(isSystemBaseModel).length, [props.baseModels]);
const customizedSystemModelCount = useMemo(
() => props.baseModels.filter((item) => isSystemBaseModel(item) && item.customizedAt).length,
[props.baseModels],
);
const filteredModels = useMemo(() => {
const keyword = query.trim().toLowerCase();
return props.baseModels.filter((item) => {
const matchesProvider = providerFilter === 'all' || item.providerKey === providerFilter;
const matchesType = typeFilter === 'all' || readModelTypes(item).includes(typeFilter);
const text = `${baseModelAlias(item)} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase();
return matchesProvider && matchesType && (!keyword || text.includes(keyword));
});
}, [props.baseModels, providerFilter, query, typeFilter]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveBaseModel(formToPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型保存失败');
}
}
function openCreateDialog() {
const providerKey = providerOptions[0] ?? '';
setEditingId('');
setForm({
...createEmptyForm(),
providerKey,
canonicalModelKey: providerKey ? `${providerKey}:` : '',
pricingRuleSetId: defaultPricingRuleSetId,
runtimePolicySetId: defaultRuntimePolicySetId,
});
setDialogOpen(true);
}
function editModel(model: BaseModelCatalogItem) {
setEditingId(model.id);
setForm({
...modelToForm(model),
pricingRuleSetId: model.pricingRuleSetId || defaultPricingRuleSetId,
runtimePolicySetId: model.runtimePolicySetId || defaultRuntimePolicySetId,
});
setDialogOpen(true);
}
function closeDialog() {
setEditingId('');
setForm(createEmptyForm());
setLocalError('');
setDialogOpen(false);
}
async function deleteModel(model: BaseModelCatalogItem) {
try {
await props.onDeleteBaseModel(model.id);
setPendingDeleteModel(null);
if (editingId === model.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型删除失败');
}
}
async function resetModel(model: BaseModelCatalogItem) {
try {
await props.onResetBaseModel(model.id);
setPendingResetModel(null);
if (editingId === model.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型重置失败');
}
}
async function resetAllModels() {
try {
await props.onResetAllBaseModels();
setPendingResetAll(false);
if (editingId) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型批量重置失败');
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<CardTitle>基准模型库</CardTitle>
<Badge variant="secondary">{props.baseModels.length}</Badge>
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p>默认模型来自原 server-main integration-platform,保留模型类型、能力、图标、计费和限流元数据。</p>
<div className="inlineActions">
<Button type="button" variant="outline" disabled={!systemModelCount || props.state === 'loading'} onClick={() => setPendingResetAll(true)}>
<RotateCcw size={15} />
重置所有
</Button>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
新增模型
</Button>
</div>
</div>
<div className="modelCatalogFilters">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" />
<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
<option value="all">全部厂商</option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
<option value="all">全部能力</option>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
</CardContent>
</Card>
<section className="baseModelGrid">
{filteredModels.map((model) => (
<ModelCard
key={model.id}
model={model}
pricingRuleSetName={pricingRuleSetName(model.pricingRuleSetId, props.pricingRuleSets)}
providerName={providerNames.get(model.providerKey)}
runtimePolicySetName={runtimePolicySetName(model.runtimePolicySetId, props.runtimePolicySets)}
onDelete={() => setPendingDeleteModel(model)}
onEdit={() => editModel(model)}
onReset={() => setPendingResetModel(model)}
/>
))}
{!filteredModels.length && (
<Card>
<CardContent className="emptyState">
<strong>没有匹配的基准模型</strong>
</CardContent>
</Card>
)}
</section>
<FormDialog
ariaLabel={editingId ? '编辑基准模型' : '新增基准模型'}
className="baseModelDialog"
eyebrow={editingId ? 'Edit Base Model' : 'New Base Model'}
footer={(
<>
<Button type="submit" disabled={props.state === 'loading'}>
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
{editingId ? '保存修改' : '新增模型'}
</Button>
<Button type="button" variant="outline" onClick={closeDialog}>
<RotateCcw size={15} />
取消
</Button>
</>
)}
formClassName="baseModelForm"
open={dialogOpen}
title={editingId ? '编辑基准模型' : '新增基准模型'}
onClose={closeDialog}
onSubmit={submit}
>
<FormItem label="Provider">
<Select value={form.providerKey} onChange={(event) => setForm({ ...form, providerKey: event.target.value })}>
<option value="">选择厂商</option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
</FormItem>
<FormItem label="模型名">
<Input value={form.providerModelName} onChange={(event) => setForm({ ...form, providerModelName: event.target.value })} />
</FormItem>
<FormItem
description="作为调用和展示的模型名;多个不同平台模型设置同一别名后,可按该别名自动负载。"
label="模型别名"
>
<Input value={form.modelAlias} onChange={(event) => setForm({ ...form, modelAlias: event.target.value })} />
</FormItem>
<FormItem className="spanTwo" label="Canonical Key">
<Input value={form.canonicalModelKey} onChange={(event) => setForm({ ...form, canonicalModelKey: event.target.value })} />
</FormItem>
<FormItem label="状态">
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
{statuses.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</FormItem>
<FormItem label="计价规则">
<Select value={form.pricingRuleSetId || defaultPricingRuleSetId} onChange={(event) => setForm({ ...form, pricingRuleSetId: event.target.value })}>
{!props.pricingRuleSets.length && <option value="">暂无可用计价规则</option>}
{props.pricingRuleSets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</Select>
</FormItem>
<FormItem label="运行策略">
<Select value={form.runtimePolicySetId || defaultRuntimePolicySetId} onChange={(event) => setForm({ ...form, runtimePolicySetId: event.target.value })}>
{!props.runtimePolicySets.length && <option value="">暂无可用运行策略</option>}
{props.runtimePolicySets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</Select>
</FormItem>
<FormItem label="定价版本">
<Input value={form.pricingVersion} onChange={(event) => setForm({ ...form, pricingVersion: event.target.value })} />
</FormItem>
<BaseModelCapabilityEditor
modelType={primaryCapabilityType(form.capabilities)}
typeOptions={typeOptions}
value={form.capabilities}
onChange={(capabilities) => setForm({ ...form, capabilities })}
/>
<JsonField label="基准计费 JSON" value={form.billingJson} onChange={(value) => setForm({ ...form, billingJson: value })} />
<JsonField label="旧版限流 JSON(兼容字段)" value={form.rateLimitJson} onChange={(value) => setForm({ ...form, rateLimitJson: value })} />
<JsonField label="运行策略覆盖 JSON" value={form.runtimePolicyOverrideJson} onChange={(value) => setForm({ ...form, runtimePolicyOverrideJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除模型"
description="删除后该基准模型不可恢复,已关联的平台模型可能无法继续跟随基准配置。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteModel)}
title={`确认删除基准模型 ${baseModelAlias(pendingDeleteModel)}`}
onCancel={() => setPendingDeleteModel(null)}
onConfirm={() => pendingDeleteModel ? deleteModel(pendingDeleteModel) : undefined}
/>
<ConfirmDialog
confirmLabel="重置为默认"
description="系统内置模型会恢复到默认模型库快照,当前手动修改的能力、计价、运行策略和元数据会被默认值覆盖。"
loading={props.state === 'loading'}
open={Boolean(pendingResetModel)}
title={`确认重置 ${baseModelAlias(pendingResetModel)}`}
onCancel={() => setPendingResetModel(null)}
onConfirm={() => pendingResetModel ? resetModel(pendingResetModel) : undefined}
/>
<ConfirmDialog
confirmLabel="重置所有"
description={`将恢复 ${systemModelCount} 个系统内置基准模型的默认快照,其中 ${customizedSystemModelCount} 个显示为已修改。用户添加的模型不会被影响。`}
loading={props.state === 'loading'}
open={pendingResetAll}
title="确认重置所有系统内置模型?"
onCancel={() => setPendingResetAll(false)}
onConfirm={resetAllModels}
/>
</div>
);
}
function ModelCard(props: {
model: BaseModelCatalogItem;
pricingRuleSetName: string;
providerName?: string;
runtimePolicySetName: string;
onDelete: () => void;
onEdit: () => void;
onReset: () => void;
}) {
const types = readModelTypes(props.model);
const chips = [...types.slice(0, 5), ...(types.length > 5 ? [`+${types.length - 5}`] : [])];
const isSystem = isSystemBaseModel(props.model);
return (
<ModelCatalogCard
actions={(
<>
{isSystem && (
<Button type="button" variant="outline" size="sm" onClick={props.onReset}>
<RotateCcw size={14} />
重置
</Button>
)}
<Button type="button" variant="outline" size="sm" onClick={props.onEdit}>
<Pencil size={14} />
修改
</Button>
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
<Trash2 size={14} />
删除
</Button>
</>
)}
badges={[
<Badge key="status" variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>,
<Badge key="catalog-type" variant={isSystem ? 'secondary' : 'outline'}>{baseModelCatalogTypeLabel(props.model)}</Badge>,
...(isSystem && props.model.customizedAt ? [<Badge key="customized" variant="warning">已修改</Badge>] : []),
]}
chips={chips}
iconPath={readModelIconPath(props.model)}
iconText={props.model.providerKey}
meta={[props.providerName ?? props.model.providerKey, props.pricingRuleSetName, props.runtimePolicySetName, props.model.canonicalModelKey]}
subtitle={props.model.providerModelName}
title={baseModelAlias(props.model)}
/>
);
}
function JsonField(props: { label: string; value: string; onChange: (value: string) => void }) {
return (
<Label className="spanTwo">
{props.label}
<Textarea value={props.value} onChange={(event) => props.onChange(event.target.value)} rows={5} spellCheck={false} />
</Label>
);
}
function modelToForm(model: BaseModelCatalogItem): ModelForm {
const capabilities = readObject(model.capabilities);
const metadataTypes = Array.isArray(model.metadata?.originalTypes) ? { originalTypes: model.metadata.originalTypes } : {};
const modelTypes = readModelTypes(model);
const capabilityState = capabilitiesToForm({ ...capabilities, ...metadataTypes }, modelTypes[0] ?? 'text_generate');
return {
providerKey: model.providerKey,
canonicalModelKey: model.canonicalModelKey,
providerModelName: model.providerModelName,
modelAlias: baseModelAlias(model),
status: model.status,
pricingRuleSetId: model.pricingRuleSetId ?? '',
runtimePolicySetId: model.runtimePolicySetId ?? '',
pricingVersion: String(model.pricingVersion || 1),
capabilities: capabilityState,
billingJson: stringifyJson(model.baseBillingConfig),
rateLimitJson: stringifyJson(model.defaultRateLimitPolicy),
runtimePolicyOverrideJson: stringifyJson(model.runtimePolicyOverride),
metadataJson: stringifyJson(model.metadata),
};
}
function readModelIconPath(model: BaseModelCatalogItem) {
return readString(model.metadata?.iconPath) || readString(readRecord(model.metadata?.rawModel)?.icon_path);
}
function baseModelAlias(model?: BaseModelCatalogItem | null) {
return model?.modelAlias || model?.displayName || model?.providerModelName || '';
}
function primaryCapabilityType(capabilities: CapabilityEditorState) {
return capabilities.types[0] ?? 'text_generate';
}
function isSystemBaseModel(model: BaseModelCatalogItem) {
if (model.catalogType) return model.catalogType === 'system';
return model.metadata?.source === 'server-main.integration-platform' || Boolean(model.metadata?.rawModel || model.metadata?.sourceProviderCode);
}
function baseModelCatalogTypeLabel(model: BaseModelCatalogItem) {
return isSystemBaseModel(model) ? '系统内置' : '用户添加';
}
function formToPayload(form: ModelForm): BaseModelUpsertRequest {
const modelTypes = uniqueStrings(form.capabilities.types).filter(Boolean);
const primaryType = modelTypes[0] ?? 'text_generate';
const capabilities = syncCapabilityTypes(form.capabilities, modelTypes);
const modelAlias = form.modelAlias.trim() || form.providerModelName.trim();
const metadata = parseJsonObject(form.metadataJson, '元数据 JSON');
return {
providerKey: form.providerKey.trim(),
canonicalModelKey: form.canonicalModelKey.trim() || undefined,
providerModelName: form.providerModelName.trim(),
modelType: modelTypes.length ? modelTypes : [primaryType],
modelAlias,
capabilities: capabilitiesFromForm(syncPrimaryCapability(capabilities, primaryType)),
baseBillingConfig: parseJsonObject(form.billingJson, '基准计费 JSON'),
defaultRateLimitPolicy: parseJsonObject(form.rateLimitJson, '限流 JSON'),
pricingRuleSetId: form.pricingRuleSetId.trim() || undefined,
runtimePolicySetId: form.runtimePolicySetId.trim() || undefined,
runtimePolicyOverride: parseJsonObject(form.runtimePolicyOverrideJson, '运行策略覆盖 JSON'),
metadata: { ...metadata, alias: modelAlias, originalTypes: modelTypes },
pricingVersion: Number.parseInt(form.pricingVersion, 10) || 1,
status: form.status,
};
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
}
function syncCapabilityTypes(state: CapabilityEditorState, modelTypes: string[]): CapabilityEditorState {
const nextTypes = uniqueStrings(modelTypes).filter(Boolean);
if (!nextTypes.length) return state;
let next = state;
nextTypes.forEach((type) => {
next = addCapabilityType(next, type);
});
next.types
.filter((type) => !nextTypes.includes(type))
.forEach((type) => {
next = removeCapabilityType(next, type);
});
return { ...next, types: nextTypes };
}
function readModelTypes(model: BaseModelCatalogItem) {
if (Array.isArray(model.modelType)) return model.modelType.map(String);
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
if (Array.isArray(values)) return values.map(String);
return [];
}
function defaultRuleSetId(ruleSets: PricingRuleSet[]) {
return ruleSets.find((item) => item.ruleSetKey === 'default-multimodal-v1')?.id ?? ruleSets[0]?.id ?? '';
}
function defaultRuntimePolicyId(policySets: RuntimePolicySet[]) {
return policySets.find((item) => item.policyKey === 'default-runtime-v1')?.id ?? policySets[0]?.id ?? '';
}
function pricingRuleSetName(ruleSetId: string | undefined, ruleSets: PricingRuleSet[]) {
if (!ruleSetId) return defaultRuleSetId(ruleSets) ? '默认计价规则' : '未绑定计价规则';
return ruleSets.find((item) => item.id === ruleSetId)?.name ?? '未知计价规则';
}
function runtimePolicySetName(policySetId: string | undefined, policySets: RuntimePolicySet[]) {
if (!policySetId) return defaultRuntimePolicyId(policySets) ? '默认运行策略' : '未绑定运行策略';
return policySets.find((item) => item.id === policySetId)?.name ?? '未知运行策略';
}
function parseJsonObject(value: string, label: string) {
try {
const parsed = value.trim() ? JSON.parse(value) : {};
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error(`${label} 必须是 JSON object`);
}
return parsed as Record<string, unknown>;
} catch (err) {
if (err instanceof Error && err.message.includes('必须是')) throw err;
throw new Error(`${label} 格式不正确`);
}
}
function stringifyJson(value?: object) {
return JSON.stringify(value ?? {}, null, 2);
}
function readObject(value: unknown) {
return value && !Array.isArray(value) && typeof value === 'object' ? value as Record<string, unknown> : {};
}
function readString(value: unknown) {
return typeof value === 'string' ? value : '';
}
function readRecord(value: unknown) {
return value && !Array.isArray(value) && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
}