feat: improve model catalog aggregation
This commit is contained in:
+123
-264
@@ -1,156 +1,37 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Boxes, Search } from 'lucide-react';
|
||||
import type {
|
||||
BaseModelCatalogItem,
|
||||
BillingConfig,
|
||||
CatalogProvider,
|
||||
PlatformModel,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Boxes, Check, Search, X } from 'lucide-react';
|
||||
import type { ModelCatalogFilterOption, ModelCatalogItem, ModelCatalogPermission } from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData } from '../app-state';
|
||||
import { Badge, Card, CardContent, Input } from '../components/ui';
|
||||
import { stableModelAlias } from './admin/platform-form';
|
||||
|
||||
type ModelListItem = {
|
||||
id: string;
|
||||
providerKey: string;
|
||||
platformName?: string;
|
||||
modelName: string;
|
||||
modelAlias?: string;
|
||||
modelType: string[];
|
||||
displayName: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
pricingMode: string;
|
||||
billingConfig?: BillingConfig;
|
||||
billingConfigOverride?: BillingConfig;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const capabilityFilters = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'chat', label: '对话' },
|
||||
{ value: 'image', label: '绘图' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'audio', label: '音频' },
|
||||
{ value: 'embedding', label: 'Embedding' },
|
||||
];
|
||||
|
||||
const publicProviders: CatalogProvider[] = [
|
||||
{
|
||||
id: 'public-openai',
|
||||
providerKey: 'openai',
|
||||
code: 'openai',
|
||||
displayName: 'OpenAI',
|
||||
providerType: 'openai',
|
||||
source: 'server-main.integration-platform',
|
||||
status: 'active',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
{
|
||||
id: 'public-gemini',
|
||||
providerKey: 'gemini',
|
||||
code: 'google-gemini',
|
||||
displayName: 'Google Gemini',
|
||||
providerType: 'gemini',
|
||||
iconPath: 'https://static.51easyai.com/gemini-color.png',
|
||||
source: 'server-main.integration-platform',
|
||||
status: 'active',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
const publicModels: PlatformModel[] = [
|
||||
{
|
||||
id: 'public-openai-gpt-4o-mini',
|
||||
platformId: 'public-openai',
|
||||
provider: 'OpenAI',
|
||||
platformName: 'OpenAI Simulation',
|
||||
modelName: 'gpt-4o-mini',
|
||||
modelAlias: 'gpt-4o-mini',
|
||||
modelType: ['text_generate'],
|
||||
displayName: 'gpt-4o-mini',
|
||||
capabilities: { multimodal: true },
|
||||
pricingMode: 'inherit',
|
||||
enabled: true,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
{
|
||||
id: 'public-openai-gpt-image-1',
|
||||
platformId: 'public-openai',
|
||||
provider: 'OpenAI',
|
||||
platformName: 'OpenAI Simulation',
|
||||
modelName: 'gpt-image-1',
|
||||
modelAlias: 'gpt-image-1',
|
||||
modelType: ['image_generate', 'image_edit'],
|
||||
displayName: 'gpt-image-1',
|
||||
capabilities: { imageEdit: true },
|
||||
pricingMode: 'inherit',
|
||||
enabled: true,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
{
|
||||
id: 'public-gemini-flash',
|
||||
platformId: 'public-gemini',
|
||||
provider: 'Gemini',
|
||||
platformName: 'Gemini Simulation',
|
||||
modelName: 'gemini-2.0-flash',
|
||||
modelAlias: 'gemini-2.0-flash',
|
||||
modelType: ['text_generate'],
|
||||
displayName: 'gemini-2.0-flash',
|
||||
capabilities: { multimodal: true, vision: true },
|
||||
pricingMode: 'inherit_discount',
|
||||
enabled: true,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
import { Card, CardContent, Input } from '../components/ui';
|
||||
|
||||
export function ModelsPage(props: { data: ConsoleData }) {
|
||||
const catalog = props.data.modelCatalog;
|
||||
const [query, setQuery] = useState('');
|
||||
const [provider, setProvider] = useState('all');
|
||||
const [capability, setCapability] = useState('all');
|
||||
const sourceProviders = props.data.providers.length ? props.data.providers : publicProviders;
|
||||
const providerMap = useMemo(() => buildProviderMap(sourceProviders), [sourceProviders]);
|
||||
const sourceModels = useMemo(() => {
|
||||
if (props.data.models.length) {
|
||||
return props.data.models.map(modelFromPlatform);
|
||||
}
|
||||
if (props.data.baseModels.length) {
|
||||
return props.data.baseModels.map(modelFromBaseModel);
|
||||
}
|
||||
return publicModels.map(modelFromPlatform);
|
||||
}, [props.data.baseModels, props.data.models]);
|
||||
const providerOptions = catalog.filters.providers.length ? catalog.filters.providers : [{ value: 'all', label: '全部', count: catalog.items.length }];
|
||||
const capabilityOptions = catalog.filters.capabilities.length ? catalog.filters.capabilities : [{ value: 'all', label: '全部', count: catalog.items.length }];
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
const options = new Map<string, string>();
|
||||
sourceProviders
|
||||
.filter((item) => item.status !== 'hidden')
|
||||
.forEach((item) => options.set(item.providerKey, item.displayName));
|
||||
sourceModels.forEach((model) => {
|
||||
if (!options.has(model.providerKey)) {
|
||||
options.set(model.providerKey, providerMap.get(model.providerKey)?.displayName ?? model.providerKey);
|
||||
}
|
||||
});
|
||||
return [
|
||||
{ value: 'all', label: '全部' },
|
||||
...Array.from(options.entries())
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
}, [providerMap, sourceModels, sourceProviders]);
|
||||
useEffect(() => {
|
||||
if (!providerOptions.some((item) => item.value === provider)) setProvider('all');
|
||||
}, [provider, providerOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capabilityOptions.some((item) => item.value === capability)) setCapability('all');
|
||||
}, [capability, capabilityOptions]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return sourceModels.filter((model) => {
|
||||
const matchedProvider = provider === 'all' || model.providerKey === provider;
|
||||
const matchedCapability = modelMatchesCapability(model.modelType, capability);
|
||||
const matchedQuery = [
|
||||
model.modelName,
|
||||
model.modelAlias,
|
||||
return catalog.items.filter((model) => {
|
||||
const matchedProvider = provider === 'all' || model.providerKeys.includes(provider);
|
||||
const matchedCapability = capability === 'all' || modelMatchesCapability(model, capability);
|
||||
const matchedQuery = !normalizedQuery || [
|
||||
model.alias,
|
||||
model.displayName,
|
||||
model.modelName,
|
||||
model.description,
|
||||
...model.providers.map((item) => item.name),
|
||||
...model.capabilityTags,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
@@ -158,14 +39,14 @@ export function ModelsPage(props: { data: ConsoleData }) {
|
||||
.includes(normalizedQuery);
|
||||
return matchedProvider && matchedCapability && matchedQuery;
|
||||
});
|
||||
}, [capability, provider, query, sourceModels]);
|
||||
}, [capability, catalog.items, provider, query]);
|
||||
|
||||
return (
|
||||
<div className="modelsPage">
|
||||
<aside className="modelFilters">
|
||||
<FilterGroup
|
||||
title="模型能力"
|
||||
items={capabilityFilters}
|
||||
items={capabilityOptions}
|
||||
value={capability}
|
||||
onChange={setCapability}
|
||||
/>
|
||||
@@ -179,16 +60,16 @@ export function ModelsPage(props: { data: ConsoleData }) {
|
||||
|
||||
<main className="modelsContent">
|
||||
<div className="modelsToolbar">
|
||||
<p>共 {sourceModels.length} 个模型,当前显示 {filteredModels.length} 个</p>
|
||||
<p>共 {catalog.summary.sourceCount} 个源,按别名合并为 {catalog.summary.modelCount} 个模型,当前显示 {filteredModels.length} 个</p>
|
||||
<div className="searchField modelHeaderSearch">
|
||||
<Search size={16} />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="模型名称模糊搜索" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="模型名称、能力或厂商" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="modelCards">
|
||||
{filteredModels.map((model) => (
|
||||
<ModelCard model={model} provider={providerMap.get(model.providerKey)} key={model.id} />
|
||||
<ModelCard model={model} key={model.id} />
|
||||
))}
|
||||
{!filteredModels.length && (
|
||||
<Card>
|
||||
@@ -205,7 +86,7 @@ export function ModelsPage(props: { data: ConsoleData }) {
|
||||
}
|
||||
|
||||
function FilterGroup(props: {
|
||||
items: Array<{ value: string; label: string }>;
|
||||
items: ModelCatalogFilterOption[];
|
||||
title: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -222,7 +103,9 @@ function FilterGroup(props: {
|
||||
key={item.value}
|
||||
onClick={() => props.onChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
<FilterIcon item={item} />
|
||||
<span>{item.label}</span>
|
||||
<em>{item.count}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -230,104 +113,118 @@ function FilterGroup(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ModelCard(props: { model: ModelListItem; provider?: CatalogProvider }) {
|
||||
const tags = tagsForModel(props.model);
|
||||
const providerName = props.provider?.displayName ?? props.model.providerKey;
|
||||
function FilterIcon(props: { item: ModelCatalogFilterOption }) {
|
||||
if (props.item.iconPath) {
|
||||
return (
|
||||
<span className="filterChipIcon">
|
||||
<img src={props.item.iconPath} alt="" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (props.item.value === 'all') return null;
|
||||
return <span className="filterChipIcon">{providerInitials(props.item.label)}</span>;
|
||||
}
|
||||
|
||||
function ModelCard(props: { model: ModelCatalogItem }) {
|
||||
const description = props.model.description || '暂无模型描述';
|
||||
return (
|
||||
<Card className="modelCard">
|
||||
<CardContent>
|
||||
<div className="modelCardTop">
|
||||
<ProviderIcon provider={props.provider} label={providerName} />
|
||||
<div>
|
||||
<strong>{props.model.displayName || props.model.modelName}</strong>
|
||||
<span>{providerName} · {props.model.platformName ?? props.provider?.code ?? 'catalog'}</span>
|
||||
<ModelIcon iconPath={props.model.iconPath} label={props.model.displayName || props.model.alias} />
|
||||
<div className="modelCardHeaderText">
|
||||
<strong>{props.model.displayName || props.model.alias}</strong>
|
||||
<p className="modelCardDescription text-xs" title={description}>{description}</p>
|
||||
</div>
|
||||
<Badge variant={props.model.enabled ? 'success' : 'secondary'}>
|
||||
{props.model.enabled ? '启用' : '停用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p>{props.model.modelAlias || props.model.modelName}</p>
|
||||
<div className="modelTags">
|
||||
{tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||
</div>
|
||||
<div className="modelCardFooter">
|
||||
<span>{priceLabel(props.model)}</span>
|
||||
<a href="#docs">查看详情</a>
|
||||
|
||||
<div className="modelCardIntro">
|
||||
<div className="modelTags">
|
||||
{props.model.capabilityTags.map((tag) => <span className="text-xs" key={tag}>{tag}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="modelCardFacts text-xs">
|
||||
<div>
|
||||
<dt>源</dt>
|
||||
<dd title={props.model.source.title}>{props.model.source.label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>折扣率</dt>
|
||||
<dd title={props.model.discount.title}>{props.model.discount.label}</dd>
|
||||
</div>
|
||||
<div className="modelCardFactRateLimit">
|
||||
<dt>限流</dt>
|
||||
<dd title={props.model.rateLimits.title}>{props.model.rateLimits.label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>权限要求</dt>
|
||||
<dd title={props.model.permission.title}>
|
||||
<PermissionValue permission={props.model.permission} />
|
||||
</dd>
|
||||
</div>
|
||||
<div className="modelCardFactFull">
|
||||
<dt>模型定价</dt>
|
||||
<dd title={props.model.pricing.title}>{props.model.pricing.lines.join(';')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderIcon(props: { provider?: CatalogProvider; label?: string }) {
|
||||
const label = props.label ?? props.provider?.displayName ?? props.provider?.providerKey ?? 'AI';
|
||||
if (props.provider?.iconPath) {
|
||||
function PermissionValue(props: { permission: ModelCatalogPermission }) {
|
||||
const allowGroups = props.permission.allowGroups ?? [];
|
||||
const denyGroups = props.permission.denyGroups ?? [];
|
||||
if (!allowGroups.length && !denyGroups.length) {
|
||||
return <span>{props.permission.label}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="modelPermissionTags">
|
||||
{allowGroups.map((group) => (
|
||||
<span className="modelPermissionTag modelPermissionTagAllow" key={`allow-${group}`}>
|
||||
<Check aria-hidden="true" className="modelPermissionIcon" />
|
||||
<span>{group}</span>
|
||||
</span>
|
||||
))}
|
||||
{denyGroups.map((group) => (
|
||||
<span className="modelPermissionTag modelPermissionTagDeny" key={`deny-${group}`}>
|
||||
<X aria-hidden="true" className="modelPermissionIcon" />
|
||||
<span>{group}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelIcon(props: { iconPath?: string; label: string }) {
|
||||
if (props.iconPath) {
|
||||
return (
|
||||
<div className="modelIcon modelIconImage">
|
||||
<img src={props.provider.iconPath} alt="" />
|
||||
<img src={props.iconPath} alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="modelIcon">{providerInitials(label)}</div>;
|
||||
return <div className="modelIcon">{providerInitials(props.label)}</div>;
|
||||
}
|
||||
|
||||
function buildProviderMap(providers: CatalogProvider[]) {
|
||||
const map = new Map<string, CatalogProvider>();
|
||||
providers.forEach((provider) => {
|
||||
[
|
||||
provider.providerKey,
|
||||
provider.code,
|
||||
provider.displayName,
|
||||
stringMetadata(provider.metadata, 'sourceCode'),
|
||||
].filter(Boolean).forEach((key) => map.set(normalizeProviderKey(key), provider));
|
||||
map.set(provider.providerKey, provider);
|
||||
});
|
||||
return map;
|
||||
function modelMatchesCapability(model: ModelCatalogItem, capability: string) {
|
||||
if (model.modelType.some((type) => modelTypeMatchesCapability(type, capability))) return true;
|
||||
if (capability === 'tools') return model.capabilityTags.includes('工具调用');
|
||||
if (capability === 'omni') return model.capabilityTags.includes('全模态');
|
||||
return false;
|
||||
}
|
||||
|
||||
function modelFromPlatform(model: PlatformModel): ModelListItem {
|
||||
return {
|
||||
id: model.id,
|
||||
providerKey: normalizeProviderKey(model.provider ?? model.platformName ?? ''),
|
||||
platformName: model.platformName,
|
||||
modelName: model.modelName,
|
||||
modelAlias: model.modelAlias,
|
||||
modelType: model.modelType,
|
||||
displayName: model.displayName,
|
||||
capabilities: model.capabilities ?? model.capabilityOverride,
|
||||
pricingMode: model.pricingMode,
|
||||
billingConfig: model.billingConfig,
|
||||
billingConfigOverride: model.billingConfigOverride,
|
||||
enabled: model.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function modelFromBaseModel(model: BaseModelCatalogItem): ModelListItem {
|
||||
return {
|
||||
id: model.id,
|
||||
providerKey: model.providerKey,
|
||||
modelName: model.providerModelName,
|
||||
modelAlias: stableModelAlias(model),
|
||||
modelType: model.modelType,
|
||||
displayName: stableModelAlias(model),
|
||||
capabilities: model.capabilities,
|
||||
pricingMode: 'inherit',
|
||||
billingConfig: model.baseBillingConfig,
|
||||
enabled: model.status === 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProviderKey(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return 'unknown';
|
||||
if (normalized.includes('gemini')) return 'gemini';
|
||||
if (normalized.includes('openai')) return 'openai';
|
||||
return normalized.replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
function stringMetadata(metadata: Record<string, unknown> | undefined, key: string) {
|
||||
const value = metadata?.[key];
|
||||
return typeof value === 'string' ? value : '';
|
||||
function modelTypeMatchesCapability(value: string, capability: string) {
|
||||
const type = value.trim().toLowerCase();
|
||||
if (capability === 'chat') return type === 'text_generate' || type === 'chat' || type === 'responses' || type.includes('text');
|
||||
if (capability === 'image') return type.includes('image') && !type.includes('video');
|
||||
if (capability === 'video') return type.includes('video');
|
||||
if (capability === 'audio') return type.includes('audio') || type.includes('speech');
|
||||
if (capability === 'embedding') return type === 'text_embedding' || type === 'embedding';
|
||||
if (capability === 'tools') return type === 'tools_call';
|
||||
if (capability === 'omni') return type === 'omni' || type === 'omni_video';
|
||||
return type === capability;
|
||||
}
|
||||
|
||||
function providerInitials(label: string) {
|
||||
@@ -338,41 +235,3 @@ function providerInitials(label: string) {
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'AI';
|
||||
}
|
||||
|
||||
function tagsForModel(model: ModelListItem) {
|
||||
const tags = model.modelType.map(capabilityName);
|
||||
const capabilities = model.capabilities ?? {};
|
||||
if (capabilities.multimodal || capabilities.vision) tags.push('多模态');
|
||||
if (capabilities.reasoning) tags.push('推理');
|
||||
if (model.pricingMode === 'inherit_discount') tags.push('折扣');
|
||||
if (model.pricingMode === 'custom') tags.push('自定义价');
|
||||
return tags;
|
||||
}
|
||||
|
||||
function capabilityName(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
text_generate: '对话',
|
||||
image_generate: '绘图',
|
||||
image_edit: '图像编辑',
|
||||
video_generate: '视频',
|
||||
image_to_video: '图生视频',
|
||||
audio_generate: '音频',
|
||||
};
|
||||
return labels[type] ?? capabilityFilters.find((item) => item.value === type)?.label ?? type;
|
||||
}
|
||||
|
||||
function modelMatchesCapability(modelTypes: string[], capability: string) {
|
||||
if (capability === 'all') return true;
|
||||
if (capability === 'chat') return modelTypes.includes('text_generate') || modelTypes.includes('chat');
|
||||
if (capability === 'image') return modelTypes.some((type) => type.includes('image'));
|
||||
if (capability === 'video') return modelTypes.some((type) => type.includes('video'));
|
||||
return modelTypes.includes(capability);
|
||||
}
|
||||
|
||||
function priceLabel(model: ModelListItem) {
|
||||
const config = model.billingConfig ?? model.billingConfigOverride;
|
||||
if (typeof config?.basePrice === 'number') {
|
||||
return `${config.basePrice}/${config.unit ?? config.resourceType ?? 'unit'}`;
|
||||
}
|
||||
return model.pricingMode === 'inherit' ? '跟随基准定价' : model.pricingMode;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Copy, CreditCard, KeyRound, ListChecks, Plus, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
||||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData } from '../app-state';
|
||||
import { EntityTable } from '../components/EntityTable';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow, Tabs } from '../components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui';
|
||||
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||
import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types';
|
||||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery } from '../types';
|
||||
|
||||
const tabs = [
|
||||
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
||||
@@ -14,6 +14,8 @@ const tabs = [
|
||||
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
|
||||
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
|
||||
|
||||
const taskPageSizeOptions = [10, 20, 50];
|
||||
|
||||
export function WorkspacePage(props: {
|
||||
apiKeyForm: ApiKeyForm;
|
||||
apiKeySecret: string;
|
||||
@@ -23,11 +25,14 @@ export function WorkspacePage(props: {
|
||||
message: string;
|
||||
section: WorkspaceSection;
|
||||
state: LoadState;
|
||||
taskQuery: WorkspaceTaskQuery;
|
||||
taskTotal: number;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
onSectionChange: (value: WorkspaceSection) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -38,7 +43,7 @@ export function WorkspacePage(props: {
|
||||
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
|
||||
{props.section === 'billing' && <BillingPanel />}
|
||||
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
|
||||
{props.section === 'tasks' && <TaskPanel data={props.data} />}
|
||||
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,62 +301,319 @@ function ApiKeyPanel(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function TaskPanel(props: { data: ConsoleData }) {
|
||||
const tasks = useMemo(() => {
|
||||
const latest = props.data.taskResult;
|
||||
if (!latest) return props.data.tasks;
|
||||
return [latest, ...props.data.tasks.filter((item) => item.id !== latest.id)];
|
||||
}, [props.data.taskResult, props.data.tasks]);
|
||||
function TaskPanel(props: {
|
||||
data: ConsoleData;
|
||||
query: WorkspaceTaskQuery;
|
||||
total: number;
|
||||
onQueryChange: (value: WorkspaceTaskQuery) => void;
|
||||
}) {
|
||||
const [localMessage, setLocalMessage] = useState('');
|
||||
const [jsonTask, setJsonTask] = useState<GatewayTask | null>(null);
|
||||
const [pageJump, setPageJump] = useState(String(props.query.page));
|
||||
const taskQuery = props.query;
|
||||
const tasks = props.data.tasks;
|
||||
const taskTypes = useMemo(() => {
|
||||
const knownTypes = ['text_generate', 'image_generate', 'image_edit', 'video_generate', 'image_to_video'];
|
||||
const values = [...knownTypes, taskQuery.modelType, ...tasks.map((task) => task.modelType)];
|
||||
return Array.from(new Set(values.filter((value): value is string => Boolean(value)))).sort((a, b) => a.localeCompare(b));
|
||||
}, [taskQuery.modelType, tasks]);
|
||||
const pageSizeOptions = useMemo(() => {
|
||||
return Array.from(new Set([...taskPageSizeOptions, taskQuery.pageSize])).sort((a, b) => a - b);
|
||||
}, [taskQuery.pageSize]);
|
||||
const totalPages = Math.max(1, Math.ceil(props.total / taskQuery.pageSize));
|
||||
const currentPage = Math.min(taskQuery.page, totalPages);
|
||||
const pageStart = props.total ? Math.min((currentPage - 1) * taskQuery.pageSize + 1, props.total) : 0;
|
||||
const pageEnd = Math.min(currentPage * taskQuery.pageSize, props.total);
|
||||
const hasActiveFilters = Boolean(taskQuery.query || taskQuery.createdFrom || taskQuery.createdTo || taskQuery.modelType);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskQuery.page > totalPages) {
|
||||
props.onQueryChange({ ...taskQuery, page: totalPages });
|
||||
}
|
||||
}, [props.onQueryChange, taskQuery, totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageJump(String(currentPage));
|
||||
}, [currentPage]);
|
||||
|
||||
async function copyTaskRequestId(task: GatewayTask) {
|
||||
if (!task.requestId) return;
|
||||
await navigator.clipboard.writeText(task.requestId);
|
||||
setLocalMessage(`已复制 RequestID:${task.requestId}`);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
props.onQueryChange({
|
||||
query: '',
|
||||
modelType: '',
|
||||
createdFrom: '',
|
||||
createdTo: '',
|
||||
page: 1,
|
||||
pageSize: taskQuery.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
function updateQuery(value: string) {
|
||||
props.onQueryChange({ ...taskQuery, query: value, page: 1 });
|
||||
}
|
||||
|
||||
function updateTypeFilter(value: string) {
|
||||
props.onQueryChange({ ...taskQuery, modelType: value === 'all' ? '' : value, page: 1 });
|
||||
}
|
||||
|
||||
function updateCreatedRange(value: { from: string; to: string }) {
|
||||
props.onQueryChange({ ...taskQuery, createdFrom: value.from, createdTo: value.to, page: 1 });
|
||||
}
|
||||
|
||||
function updatePageSize(value: string) {
|
||||
const nextPageSize = Number(value);
|
||||
props.onQueryChange({ ...taskQuery, page: 1, pageSize: Number.isFinite(nextPageSize) ? nextPageSize : 10 });
|
||||
}
|
||||
|
||||
function updatePage(page: number) {
|
||||
props.onQueryChange({ ...taskQuery, page });
|
||||
}
|
||||
|
||||
function submitPageJump() {
|
||||
const parsed = Number(pageJump);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
setPageJump(String(currentPage));
|
||||
return;
|
||||
}
|
||||
updatePage(Math.min(totalPages, Math.max(1, Math.floor(parsed))));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>任务记录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tasks.length ? (
|
||||
<div className="taskList">
|
||||
{tasks.map((task) => (
|
||||
<TaskRecord key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<strong>暂无任务</strong>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<>
|
||||
<Card className="shTableViewportCard taskRecordViewport">
|
||||
<CardContent className="shTableViewportPanel">
|
||||
{localMessage && <p className="formMessage">{localMessage}</p>}
|
||||
<TableViewportLayout>
|
||||
<TableToolbar className="taskRecordFilters">
|
||||
<Label className="taskRecordSearchLabel">
|
||||
搜索
|
||||
<span className="taskRecordSearchBox">
|
||||
<Search size={15} />
|
||||
<Input
|
||||
value={taskQuery.query}
|
||||
placeholder="搜索 ID / RequestID / 模型 / API Key"
|
||||
onChange={(event) => updateQuery(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
</Label>
|
||||
<Label>
|
||||
类型
|
||||
<Select value={taskQuery.modelType || 'all'} onChange={(event) => updateTypeFilter(event.target.value)}>
|
||||
<option value="all">全部类型</option>
|
||||
{taskTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label className="taskRecordRangeLabel">
|
||||
创建时间
|
||||
<DateTimeRangePicker
|
||||
from={taskQuery.createdFrom}
|
||||
fromPlaceholder="开始日期"
|
||||
to={taskQuery.createdTo}
|
||||
toPlaceholder="结束日期"
|
||||
onChange={updateCreatedRange}
|
||||
/>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
</TableToolbar>
|
||||
|
||||
{tasks.length ? (
|
||||
<Table className="shTableViewport taskRecordTable" density="compact">
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>RequestID</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>扣费</TableHead>
|
||||
<TableHead>耗时</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>原始 JSON</TableHead>
|
||||
</TableRow>
|
||||
{tasks.map((task) => (
|
||||
<TaskRecord key={task.id} task={task} onCopyRequestId={copyTaskRequestId} onOpenJson={setJsonTask} />
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<strong>{hasActiveFilters ? '没有匹配的任务' : '暂无任务'}</strong>
|
||||
{hasActiveFilters && <span>调整关键词、类型或创建时间后再试。</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TableFooter>
|
||||
<div className="shTableFooterGroup">
|
||||
<Label>
|
||||
每页
|
||||
<Select size="sm" value={String(taskQuery.pageSize)} onChange={(event) => updatePageSize(event.target.value)}>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>{option} 条</option>
|
||||
))}
|
||||
</Select>
|
||||
</Label>
|
||||
<span>共 {props.total} 条 · {pageStart}-{pageEnd}</span>
|
||||
</div>
|
||||
<form
|
||||
className="shTablePageJump"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitPageJump();
|
||||
}}
|
||||
>
|
||||
<span>第 {currentPage} / {totalPages} 页</span>
|
||||
<span>跳至</span>
|
||||
<Input
|
||||
aria-label="跳转页码"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
size="xs"
|
||||
type="number"
|
||||
value={pageJump}
|
||||
onChange={(event) => setPageJump(event.target.value)}
|
||||
/>
|
||||
<span>页</span>
|
||||
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}>跳转</Button>
|
||||
</form>
|
||||
<TablePageActions>
|
||||
<Button type="button" variant="outline" size="sm" disabled={currentPage <= 1} onClick={() => updatePage(Math.max(1, currentPage - 1))}>
|
||||
<ChevronLeft size={14} />
|
||||
上一页
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={currentPage >= totalPages} onClick={() => updatePage(Math.min(totalPages, currentPage + 1))}>
|
||||
下一页
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</TablePageActions>
|
||||
</TableFooter>
|
||||
</TableViewportLayout>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel="查看任务原始 JSON"
|
||||
bodyClassName="taskJsonDialogBody"
|
||||
className="taskJsonDialog"
|
||||
footer={<Button type="button" size="sm" onClick={() => setJsonTask(null)}>关闭</Button>}
|
||||
open={Boolean(jsonTask)}
|
||||
title="任务原始 JSON"
|
||||
onClose={() => setJsonTask(null)}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
<pre className="taskJsonPreview">{JSON.stringify(jsonTask, null, 2)}</pre>
|
||||
</FormDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRecord(props: { task: GatewayTask }) {
|
||||
function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayTask) => Promise<void>; onOpenJson: (task: GatewayTask) => void }) {
|
||||
const usage = props.task.usage ?? {};
|
||||
const tokenText = usage.totalTokens ? `${usage.totalTokens}` : '-';
|
||||
const chargeText = props.task.finalChargeAmount !== undefined ? `${props.task.finalChargeAmount}` : '-';
|
||||
const tokenUsage = formatTokenUsage(usage);
|
||||
const chargeText = props.task.finalChargeAmount !== undefined ? formatCellValue(props.task.finalChargeAmount) : '-';
|
||||
const resolvedModel = props.task.resolvedModel || props.task.model;
|
||||
const badgeVariant = props.task.status === 'succeeded' ? 'success' : props.task.status === 'failed' ? 'destructive' : 'secondary';
|
||||
return (
|
||||
<div className="taskPreview">
|
||||
<div className="taskRecordHeader">
|
||||
<Badge variant={badgeVariant}>{props.task.status}</Badge>
|
||||
<strong>{props.task.kind}</strong>
|
||||
<span>{props.task.model}</span>
|
||||
<span>{formatDateTime(props.task.createdAt)}</span>
|
||||
</div>
|
||||
<div className="infoGrid compact">
|
||||
<InfoItem label="API Key" value={props.task.apiKeyName || props.task.apiKeyId || '-'} />
|
||||
<InfoItem label="RequestID" value={props.task.requestId || '-'} />
|
||||
<InfoItem label="模型类型" value={props.task.modelType || '-'} />
|
||||
<InfoItem label="实际模型" value={props.task.resolvedModel || props.task.model} />
|
||||
<InfoItem label="Token" value={tokenText} />
|
||||
<InfoItem label="扣费" value={chargeText} />
|
||||
<InfoItem label="响应耗时" value={props.task.responseDurationMs ? `${props.task.responseDurationMs}ms` : '-'} />
|
||||
<InfoItem label="错误" value={props.task.errorCode || props.task.errorMessage || '-'} />
|
||||
</div>
|
||||
<pre>{JSON.stringify({ result: props.task.result, usage: props.task.usage, billings: props.task.billings, billingSummary: props.task.billingSummary, metrics: props.task.metrics }, null, 2)}</pre>
|
||||
</div>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="taskRecordPrimaryCell taskRecordIdentityCell">
|
||||
<strong>{props.task.kind}</strong>
|
||||
<span className="taskRecordIdLine">
|
||||
<span>ID</span>
|
||||
<code title={props.task.id}>{props.task.id}</code>
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="taskRecordRequestLine">
|
||||
<code title={props.task.requestId || '-'}>{props.task.requestId || '-'}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={props.task.requestId ? '复制 RequestID' : '暂无 RequestID'}
|
||||
disabled={!props.task.requestId}
|
||||
onClick={() => void props.onCopyRequestId(props.task)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant={badgeVariant}>{props.task.status}</Badge></TableCell>
|
||||
<TableCell className="taskRecordModelCell">
|
||||
<span className="taskRecordPrimaryCell">
|
||||
<strong>{resolvedModel}</strong>
|
||||
{props.task.requestedModel && props.task.requestedModel !== resolvedModel && <small>{props.task.requestedModel}</small>}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{props.task.modelType || '-'}</TableCell>
|
||||
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
|
||||
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
|
||||
<TableCell>{chargeText}</TableCell>
|
||||
<TableCell>{formatDuration(props.task.responseDurationMs)}</TableCell>
|
||||
<TableCell>{formatDateTime(props.task.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" size="sm" className="taskRecordJsonButton" title={taskErrorText(props.task) || '查看原始 JSON'} onClick={() => props.onOpenJson(props.task)}>
|
||||
<Eye size={14} />
|
||||
原始 JSON
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return '-';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatTokenUsage(usage: Record<string, unknown>) {
|
||||
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
|
||||
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens);
|
||||
const total = tokenValue(usage.totalTokens ?? usage.total_tokens ?? (input !== null && output !== null ? input + output : null));
|
||||
if (input === null && output === null && total === null) return '-';
|
||||
return (
|
||||
<span className="taskRecordTokenUsage">
|
||||
<span>输入:{formatCellValue(input)}/输出:{formatCellValue(output)}</span>
|
||||
<span>总计:{formatCellValue(total)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function tokenValue(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const numericValue = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function formatDuration(value?: number) {
|
||||
if (value === undefined || value === null) return '-';
|
||||
const milliseconds = Math.max(0, Math.round(value));
|
||||
if (milliseconds === 0) return '0秒';
|
||||
if (milliseconds < 1000) return `${milliseconds}毫秒`;
|
||||
const totalSeconds = Math.round(milliseconds / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}小时${minutes}分${seconds}秒`;
|
||||
if (minutes > 0) return `${minutes}分${seconds}秒`;
|
||||
return `${seconds}秒`;
|
||||
}
|
||||
|
||||
function taskErrorText(task: GatewayTask) {
|
||||
return task.errorCode || task.errorMessage || task.error || '';
|
||||
}
|
||||
|
||||
function InfoItem(props: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="infoItem">
|
||||
|
||||
@@ -58,6 +58,7 @@ export function PlatformManagementPanel(props: {
|
||||
const text = [
|
||||
model.displayName,
|
||||
model.modelName,
|
||||
model.providerModelName,
|
||||
model.modelAlias,
|
||||
...model.modelType,
|
||||
model.provider,
|
||||
@@ -482,9 +483,9 @@ function PlatformModelTable(props: {
|
||||
meta={[
|
||||
platform ? platformDisplayName(platform) : model.platformName ?? '-',
|
||||
provider?.displayName ?? platform?.provider ?? model.provider ?? '-',
|
||||
model.modelAlias || model.baseModelId || '-',
|
||||
model.modelAlias || model.modelName || '-',
|
||||
]}
|
||||
subtitle={model.modelName}
|
||||
subtitle={model.providerModelName ? `调用模型名:${model.providerModelName}` : model.modelName}
|
||||
title={model.displayName || model.modelName}
|
||||
/>
|
||||
);
|
||||
@@ -608,10 +609,13 @@ function ModelSelection(props: {
|
||||
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),
|
||||
});
|
||||
@@ -633,6 +637,16 @@ function ModelSelection(props: {
|
||||
});
|
||||
}
|
||||
|
||||
function updateModelNameMapping(modelId: string, value: string) {
|
||||
props.onChange({
|
||||
...props.form,
|
||||
modelNameMappings: {
|
||||
...props.form.modelNameMappings,
|
||||
[modelId]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platformModelSelector spanTwo">
|
||||
<div className="platformModelSelectorHeader">
|
||||
@@ -649,26 +663,44 @@ function ModelSelection(props: {
|
||||
<div className="platformModelEmpty">当前没有已选模型,点击“添加模型”从模型库选择。</div>
|
||||
) : (
|
||||
<div className="platformModelChoices">
|
||||
{selectedModels.map((model) => (
|
||||
<div className="platformModelChoice" key={model.id}>
|
||||
<div className="platformModelChoiceMain">
|
||||
<span>
|
||||
<strong>{stableModelAlias(model) || model.providerModelName}</strong>
|
||||
<small>{props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)}</small>
|
||||
</span>
|
||||
{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>
|
||||
<Input
|
||||
aria-label={`${stableModelAlias(model) || model.providerModelName} 折扣率`}
|
||||
inputMode="decimal"
|
||||
placeholder="折扣率"
|
||||
value={props.form.modelDiscountFactors[model.id] ?? ''}
|
||||
onChange={(event) => updateModelDiscount(model.id, event.target.value)}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="移除模型" onClick={() => removeModel(model.id)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ModelPickerDialog
|
||||
@@ -864,6 +896,7 @@ function platformToForm(
|
||||
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
|
||||
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
|
||||
modelDiscountFactors: platformModelDiscountFactors(platform, baseModels, currentModels),
|
||||
modelNameMappings: platformModelNameMappings(platform, baseModels, currentModels),
|
||||
selectionMode: 'partial',
|
||||
};
|
||||
}
|
||||
@@ -885,10 +918,19 @@ function platformModelDiscountFactors(platform: IntegrationPlatform, baseModels:
|
||||
}, {});
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface PlatformWizardForm {
|
||||
supportUrlInput: boolean;
|
||||
modelDiscountFactor: string;
|
||||
modelDiscountFactors: Record<string, string>;
|
||||
modelNameMappings: Record<string, string>;
|
||||
modelOverrideRetry: boolean;
|
||||
modelRetryEnabled: boolean;
|
||||
modelRetryMaxAttempts: string;
|
||||
@@ -78,6 +79,7 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
|
||||
supportUrlInput: true,
|
||||
modelDiscountFactor: '',
|
||||
modelDiscountFactors: {},
|
||||
modelNameMappings: {},
|
||||
modelOverrideRetry: false,
|
||||
modelRetryEnabled: true,
|
||||
modelRetryMaxAttempts: '2',
|
||||
@@ -99,6 +101,7 @@ export function applyProviderDefaults(form: PlatformWizardForm, provider: string
|
||||
authType: defaults?.defaultAuthType ?? 'APIKey',
|
||||
selectedModelIds: [],
|
||||
modelDiscountFactors: {},
|
||||
modelNameMappings: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +151,7 @@ export function platformModelPayloads(models: BaseModelCatalogItem[], form: Plat
|
||||
baseModelId: model.id,
|
||||
canonicalModelKey: model.canonicalModelKey,
|
||||
modelName: model.providerModelName,
|
||||
providerModelName: optionalString(form.modelNameMappings[model.id]) ?? model.providerModelName,
|
||||
modelAlias: stableModelAlias(model),
|
||||
modelType: baseModelTypes(model),
|
||||
displayName: stableModelAlias(model) || model.providerModelName,
|
||||
@@ -329,8 +333,8 @@ function limitRule(metric: string, value: string, windowSeconds = 60) {
|
||||
};
|
||||
}
|
||||
|
||||
function optionalString(value: string) {
|
||||
const trimmed = value.trim();
|
||||
function optionalString(value: string | null | undefined) {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user