easyai-ai-gateway/apps/web/src/pages/ModelsPage.tsx

264 lines
9.2 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 } 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';
}