264 lines
9.2 KiB
TypeScript
264 lines
9.2 KiB
TypeScript
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 { 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 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 }];
|
||
|
||
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 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(' ')
|
||
.toLowerCase()
|
||
.includes(normalizedQuery);
|
||
return matchedProvider && matchedCapability && matchedQuery;
|
||
});
|
||
}, [capability, catalog.items, provider, query]);
|
||
|
||
return (
|
||
<div className="modelsPage">
|
||
<aside className="modelFilters">
|
||
<FilterGroup
|
||
title="模型能力"
|
||
items={capabilityOptions}
|
||
value={capability}
|
||
onChange={setCapability}
|
||
/>
|
||
<FilterGroup
|
||
title="模型厂商"
|
||
items={providerOptions}
|
||
value={provider}
|
||
onChange={setProvider}
|
||
/>
|
||
</aside>
|
||
|
||
<main className="modelsContent">
|
||
<div className="modelsToolbar">
|
||
<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="模型名称、能力或厂商" />
|
||
</div>
|
||
</div>
|
||
|
||
<section className="modelCards">
|
||
{filteredModels.map((model) => (
|
||
<ModelCard model={model} key={model.id} />
|
||
))}
|
||
{!filteredModels.length && (
|
||
<Card>
|
||
<CardContent className="emptyState">
|
||
<Boxes size={18} />
|
||
<strong>没有匹配的模型</strong>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FilterGroup(props: {
|
||
items: ModelCatalogFilterOption[];
|
||
title: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
return (
|
||
<section className="filterGroup">
|
||
<h3>{props.title}</h3>
|
||
<div className="filterChips">
|
||
{props.items.map((item) => (
|
||
<button
|
||
type="button"
|
||
className="filterChip"
|
||
data-active={props.value === item.value}
|
||
key={item.value}
|
||
onClick={() => props.onChange(item.value)}
|
||
>
|
||
<FilterIcon item={item} />
|
||
<span>{item.label}</span>
|
||
<em>{item.count}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 || '暂无模型描述';
|
||
const pricing = modelCardPricing(props.model.pricing);
|
||
return (
|
||
<Card className="modelCard">
|
||
<CardContent>
|
||
<div className="modelCardTop">
|
||
<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>
|
||
</div>
|
||
|
||
<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>{pricing.label}</dt>
|
||
<dd title={pricing.title}>{pricing.lines.join(';')}</dd>
|
||
</div>
|
||
</dl>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function modelCardPricing(pricing: ModelCatalogItem['pricing']) {
|
||
const hasFiveSecondBasis = pricing.lines.some((line) => line.includes('5秒基准'));
|
||
if (!hasFiveSecondBasis) {
|
||
return {
|
||
label: '模型定价',
|
||
lines: pricing.lines,
|
||
title: pricing.title,
|
||
};
|
||
}
|
||
const lines = pricing.lines.map(stripFiveSecondBasis).filter(Boolean);
|
||
const title = stripFiveSecondBasis(pricing.title || pricing.lines.join(';'));
|
||
return {
|
||
label: '模型定价(每5秒)',
|
||
lines: lines.length ? lines : pricing.lines,
|
||
title: title || pricing.title,
|
||
};
|
||
}
|
||
|
||
function stripFiveSecondBasis(value: string) {
|
||
return value
|
||
.replace(/(5秒基准)/g, '')
|
||
.replace(/\s*\/\s*5秒基准/g, '')
|
||
.trim();
|
||
}
|
||
|
||
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.iconPath} alt="" />
|
||
</div>
|
||
);
|
||
}
|
||
return <div className="modelIcon">{providerInitials(props.label)}</div>;
|
||
}
|
||
|
||
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 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) {
|
||
return label
|
||
.split(/\s+/)
|
||
.map((part) => part[0])
|
||
.join('')
|
||
.slice(0, 2)
|
||
.toUpperCase() || 'AI';
|
||
}
|