import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { api, userApi } from '../api.js'; import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js'; import SiteBadgeLink from '../components/SiteBadgeLink.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useIsMobile } from '../components/useIsMobile.js'; import { mergeMarketplaceMetadata, shouldHydrateMarketplaceMetadata } from './helpers/modelsMarketplaceMetadata.js'; import { tr } from '../i18n.js'; type SortColumn = 'name' | 'accountCount' | 'tokenCount' | 'avgLatency' | 'successRate'; type ViewMode = 'card' | 'table'; interface ModelTokenInfo { id: number; name: string; isDefault: boolean; } interface ModelGroupPricing { quotaType: number; inputPerMillion?: number; outputPerMillion?: number; perCallInput?: number; perCallOutput?: number; perCallTotal?: number; } interface ModelPricingSource { siteId: number; siteName: string; accountId: number; username: string | null; ownerBy: string | null; enableGroups: string[]; groupPricing: Record; } interface ModelAccountInfo { id: number; site: string; username: string | null; latency: number | null; balance: number; tokens: ModelTokenInfo[]; } interface ModelRow { name: string; accountCount: number; tokenCount: number; avgLatency: number | null; successRate: number | null; description: string | null; tags: string[]; supportedEndpointTypes: string[]; pricingSources: ModelPricingSource[]; accounts: ModelAccountInfo[]; } interface ModelsMarketplaceResponse { models: ModelRow[]; meta?: { refreshRequested?: boolean; refreshQueued?: boolean; refreshReused?: boolean; refreshRunning?: boolean; refreshJobId?: string | null; }; } function isKnownLatency(latency: number | null | undefined): latency is number { return typeof latency === 'number' && Number.isFinite(latency); } function getMetricColor(latency: number | null) { if (!isKnownLatency(latency)) return 'var(--color-text-muted)'; if (latency >= 3000) return 'var(--color-danger)'; if (latency >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))'; if (latency >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))'; if (latency >= 1000) return 'var(--color-warning)'; if (latency > 500) return 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))'; return 'var(--color-success)'; } function getLatencyBadgeClass(latency: number | null) { if (!isKnownLatency(latency)) return 'badge-muted'; if (latency >= 3000) return 'badge-error'; if (latency >= 1000) return 'badge-warning'; return 'badge-success'; } function formatLatency(latency: number | null): string { return isKnownLatency(latency) ? `${latency}ms` : '暂无'; } function getSuccessBadgeClass(rate: number | null) { if (rate == null) return 'badge-muted'; if (rate >= 90) return 'badge-success'; if (rate >= 60) return 'badge-warning'; return 'badge-error'; } function resolveMarketplaceDescription(model: ModelRow, metadataHydrating: boolean): string { if (model.description && model.description.trim().length > 0) return model.description; if (metadataHydrating) return tr('正在加载模型元数据...'); const hasOtherMetadata = model.tags.length > 0 || model.supportedEndpointTypes.length > 0 || model.pricingSources.length > 0; if (hasOtherMetadata) return tr('上游未提供描述文本,但已同步标签、能力或价格信息。'); return tr('当前上游仅返回模型 ID,未返回描述字段。'); } function renderGroupPricingValue(pricing: ModelGroupPricing): string { if (pricing.quotaType === 0) { return `${pricing.inputPerMillion ?? 0}/${pricing.outputPerMillion ?? 0} USD / 1M`; } if (pricing.perCallInput != null || pricing.perCallOutput != null) { return `${pricing.perCallInput ?? 0}/${pricing.perCallOutput ?? 0} USD / call`; } return `${pricing.perCallTotal ?? 0} USD / call`; } async function copyTextToClipboard(text: string) { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } const PAGE_SIZES = [10, 20, 50]; function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number { if (sortBy === 'name') { const cmp = a.name.localeCompare(b.name); return sortDir === 'asc' ? cmp : -cmp; } const resolveNumericSortValue = (model: ModelRow) => { if (sortBy === 'successRate') return model.successRate ?? -1; if (sortBy === 'avgLatency') { if (!isKnownLatency(model.avgLatency)) { return sortDir === 'asc' ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; } return model.avgLatency; } return model[sortBy] ?? 0; }; const va = resolveNumericSortValue(a); const vb = resolveNumericSortValue(b); if (va === vb) return a.name.localeCompare(b.name); return sortDir === 'desc' ? vb - va : va - vb; } /* ---- component ---- */ export default function Models() { const toast = useToast(); const location = useLocation(); const isUserView = location.pathname.startsWith('/user/'); const marketplaceApi = isUserView ? userApi : api; const [data, setData] = useState({ models: [] }); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [sortBy, setSortBy] = useState('accountCount'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); const [activeSite, setActiveSite] = useState(null); const [activeBrand, setActiveBrand] = useState(null); const [expanded, setExpanded] = useState(null); const [viewMode, setViewMode] = useState('card'); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [copied, setCopied] = useState(null); const [filterCollapsed, setFilterCollapsed] = useState(false); const [showFilters, setShowFilters] = useState(false); const [metadataHydrating, setMetadataHydrating] = useState(false); const isMobile = useIsMobile(); const filterPanelPresence = useAnimatedVisibility(!isMobile && !filterCollapsed, 220); const latestPrimaryRequestRef = useRef(0); const latestMetadataRequestRef = useRef(0); const siteIdByName = useMemo(() => { const index = new Map(); for (const model of data.models) { for (const source of model.pricingSources || []) { const siteName = String(source.siteName || '').trim(); const siteId = Number(source.siteId); if (!siteName || !Number.isFinite(siteId) || siteId <= 0 || index.has(siteName)) continue; index.set(siteName, Math.trunc(siteId)); } } return index; }, [data.models]); const loadBaseMarketplace = useCallback(async (refresh = false) => { const requestId = ++latestPrimaryRequestRef.current; latestMetadataRequestRef.current += 1; setMetadataHydrating(false); setLoading(true); try { const res = await marketplaceApi.getModelsMarketplace({ refresh, includePricing: false, }); if (requestId !== latestPrimaryRequestRef.current) return null; const next = res as ModelsMarketplaceResponse; setData(next); if (refresh && next.meta?.refreshRequested) { if (next.meta.refreshReused) { toast.info(tr('模型广场刷新进行中')); } else if (next.meta.refreshQueued) { toast.info(tr('已开始刷新模型广场')); } } return next; } catch { if (requestId !== latestPrimaryRequestRef.current) return null; setData({ models: [] }); return null; } finally { if (requestId === latestPrimaryRequestRef.current) { setLoading(false); } } }, [marketplaceApi, toast]); const hydrateMarketplaceMetadata = useCallback(async (baseModels: ModelRow[]) => { if (!shouldHydrateMarketplaceMetadata(baseModels)) return; const metadataRequestId = ++latestMetadataRequestRef.current; const baseRequestId = latestPrimaryRequestRef.current; setMetadataHydrating(true); try { const res = await marketplaceApi.getModelsMarketplace({ includePricing: true, }); if (metadataRequestId !== latestMetadataRequestRef.current) return; if (baseRequestId !== latestPrimaryRequestRef.current) return; const detailed = res as ModelsMarketplaceResponse; setData((current) => ({ ...current, models: mergeMarketplaceMetadata(current.models, detailed.models), meta: detailed.meta ?? current.meta, })); } catch { // Keep the fast base list when metadata fetch fails. } finally { if (metadataRequestId === latestMetadataRequestRef.current) { setMetadataHydrating(false); } } }, [marketplaceApi]); useEffect(() => { if (!isMobile) return; if (viewMode !== 'card') { setViewMode('card'); } if (!filterCollapsed) { setFilterCollapsed(true); } }, [filterCollapsed, isMobile, viewMode]); useEffect(() => { let cancelled = false; let metadataTimer: ReturnType | null = null; const bootstrap = async () => { const initial = await loadBaseMarketplace(false); if (!initial || cancelled) return; metadataTimer = setTimeout(() => { if (!cancelled) { void hydrateMarketplaceMetadata(initial.models); } }, 1200); }; void bootstrap(); return () => { cancelled = true; if (metadataTimer) clearTimeout(metadataTimer); latestMetadataRequestRef.current += 1; }; }, [hydrateMarketplaceMetadata, loadBaseMarketplace]); const handleRefresh = useCallback(() => { void (async () => { const refreshed = await loadBaseMarketplace(true); if (!refreshed) return; setTimeout(() => { void hydrateMarketplaceMetadata(refreshed.models); }, 600); })(); }, [hydrateMarketplaceMetadata, loadBaseMarketplace]); useEffect(() => { const q = new URLSearchParams(location.search).get('q') || ''; setSearch(q); }, [location.search]); /* ---- derived: brand list ---- */ const brandList = useMemo(() => { const m = new Map(); let otherCount = 0; for (const model of data.models) { const brand = getBrand(model.name); if (brand) { const existing = m.get(brand.name); if (existing) existing.count++; else m.set(brand.name, { count: 1, brand }); } else { otherCount++; } } const list = [...m.entries()].sort((a, b) => b[1].count - a[1].count); return { list, otherCount }; }, [data.models]); /* ---- derived: site list ---- */ const siteMap = useMemo(() => { if (isUserView) return []; const m = new Map(); for (const model of data.models) { for (const a of model.accounts) { m.set(a.site, (m.get(a.site) || 0) + 1); } } return [...m.entries()].sort((a, b) => b[1] - a[1]); }, [data.models, isUserView]); /* ---- filtered ---- */ const filteredModels = useMemo(() => { let list = data.models; if (activeBrand) { if (activeBrand === '__other__') { list = list.filter(m => !getBrand(m.name)); } else { list = list.filter(m => getBrand(m.name)?.name === activeBrand); } } if (activeSite) { list = list.filter(m => m.accounts.some(a => a.site === activeSite)); } if (search) { const q = search.toLowerCase(); list = list.filter(m => m.name.toLowerCase().includes(q)); } return list; }, [data.models, search, activeSite, activeBrand]); // Keep expanded detail consistent with filters (especially site filter). // The list-level filter uses "model has at least one account on this site" semantics; // once a model is shown, its detail should honor the active site as well. const detailModels = useMemo(() => { const scopedModels = activeSite ? filteredModels.map((model) => { const accounts = model.accounts.filter((account) => account.site === activeSite); const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite); const latencyValues = accounts .map((account) => account.latency) .filter(isKnownLatency); return { ...model, accounts, pricingSources, accountCount: accounts.length, tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0), avgLatency: latencyValues.length > 0 ? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length) : null, }; }) : filteredModels; return [...scopedModels].sort((a, b) => compareModels(a, b, isUserView ? 'name' : sortBy, isUserView ? 'asc' : sortDir)); }, [filteredModels, activeSite, sortBy, sortDir, isUserView]); /* ---- pagination ---- */ const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize)); const safePageVal = Math.min(page, totalPages); const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize); useEffect(() => { setPage(1); }, [search, activeSite, activeBrand, pageSize]); /* ---- stats ---- */ const totalCoverageSlots = detailModels.reduce((s, m) => s + m.accountCount, 0); const uniqueAccountCount = (() => { const ids = new Set(); for (const model of detailModels) { for (const account of model.accounts) { ids.add(account.id); } } return ids.size; })(); const latencyMetrics = detailModels .map((model) => model.avgLatency) .filter(isKnownLatency); const avgLatency = latencyMetrics.length > 0 ? Math.round(latencyMetrics.reduce((sum, latency) => sum + latency, 0) / latencyMetrics.length) : null; /* ---- copy ---- */ const copyName = async (name: string) => { try { await copyTextToClipboard(name); setCopied(name); toast.success(tr('已复制模型名')); setTimeout(() => setCopied(null), 1500); } catch (error: any) { toast.error(error?.message || tr('复制模型名失败')); } }; const filterControls = ( <>
{tr('品牌')} {activeBrand && }
setActiveBrand(null)} > ✓ {tr('全部品牌')} {data.models.length}
{brandList.list.map(([brandName, { count, brand }]) => (
setActiveBrand(activeBrand === brandName ? null : brandName)} > {brandName} {count}
))} {brandList.otherCount > 0 && (
setActiveBrand(activeBrand === '__other__' ? null : '__other__')} > ? {tr('其他')} {brandList.otherCount}
)}
{!isUserView && (
{tr('供应商')} {activeSite && }
{siteMap.map(([site, count]) => (
setActiveSite(activeSite === site ? null : site)} > {site.slice(0, 2).toUpperCase()} {site} {count}
))}
)} {!isUserView &&
{tr('排序方式')}
{[ { key: 'accountCount' as SortColumn, label: tr('账号数') }, { key: 'tokenCount' as SortColumn, label: tr('令牌数') }, { key: 'avgLatency' as SortColumn, label: tr('延迟') }, { key: 'successRate' as SortColumn, label: tr('成功率') }, { key: 'name' as SortColumn, label: tr('名称') }, ].map(opt => (
{ if (sortBy === opt.key) { setSortDir(d => d === 'asc' ? 'desc' : 'asc'); } else { setSortBy(opt.key); setSortDir(opt.key === 'name' ? 'asc' : 'desc'); } }} > {opt.label} {sortBy === opt.key && ( {sortDir === 'desc' ? '↓' : '↑'} )}
))}
} ); /* ---- loading skeleton ---- */ if (loading) { return (
{!isMobile && (
{[...Array(6)].map((_, i) =>
)}
)}
{isMobile && ( )}
setShowFilters(false)} mobileTitle={tr('筛选模型')} mobileContent={filterControls} /> {[...Array(4)].map((_, i) =>
)}
); } return (
{!isMobile && filterPanelPresence.shouldRender && (
{filterControls}
)} {/* ====== RIGHT: Content Area ====== */}
{/* Header */}

{activeBrand || activeSite || tr('模型广场')} {tr('共')} {filteredModels.length} {tr('个模型')}

{(activeBrand || activeSite) && (

{activeBrand && activeBrand !== '__other__' ? `${tr('查看')} ${activeBrand} ${tr('品牌的所有模型')}` : activeSite ? `${tr('来自供应商')} ${activeSite} ${tr('的模型')}` : tr('其他未归类的模型')}

)}
{(isMobile || filterCollapsed) && ( )} {metadataHydrating && ( {tr('加载元数据中...')} )} {!isMobile && (
)}
setShowFilters(false)} mobileTitle={tr('筛选模型')} mobileContent={filterControls} /> {/* Toolbar */}
setSearch(e.target.value)} placeholder={tr('搜索模型(支持名称片段)')} />
{/* Quick stats */} {!isUserView && (
{tr('覆盖档位')} {totalCoverageSlots} {tr('去重账号')} {uniqueAccountCount} {tr('平均延迟')} {formatLatency(avgLatency)}
)}
{/* Empty */} {detailModels.length === 0 ? (
{tr('暂无模型结果')}
{tr('请先检查站点与账号状态,然后点击刷新。')}
) : viewMode === 'card' ? ( /* ====== Card View ====== */
{paged.map((m) => { const isExpanded = expanded === m.name; return (
setExpanded(isExpanded ? null : m.name)}>
{m.name}
{!isUserView && {m.accountCount} {tr('个账号')} } {!isUserView && {m.tokenCount} {tr('令牌')} } {tr('延迟')} {formatLatency(m.avgLatency)} {tr('成功率')} {m.successRate != null ? `${m.successRate}%` : '暂无'}
e.stopPropagation()}>
{/* Tags */}
{getBrand(m.name) && ( {getBrand(m.name)!.name} )} {!isUserView && m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => ( {site} ))} {m.successRate != null && m.successRate >= 90 && ( {tr('健康')} )} {m.successRate != null && m.successRate < 60 && ( {tr('风险')} )} {isKnownLatency(m.avgLatency) && m.avgLatency <= 500 && ( {tr('低延迟')} )}
{/* Expand: Account Details */} {isExpanded ? (
e.stopPropagation()}>
{tr('基础信息')}
{resolveMarketplaceDescription(m, metadataHydrating)}
{m.tags.length > 0 ? m.tags.map((tag) => ( {tag} )) : {metadataHydrating ? tr('加载元数据中...') : tr('暂无标签')}}
{tr('接口能力')}
{m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => ( {endpoint} )) : {metadataHydrating ? tr('加载元数据中...') : tr('未提供')}}
{!isUserView && (
{tr('分组计费')}
{m.pricingSources.length > 0 ? (
{m.pricingSources.map((source) => (
{isUserView ? tr('隐藏账号详情') : <> · {source.username || `ID:${source.accountId}`}}
{Object.entries(source.groupPricing).map(([group, pricing]) => ( {group}: {renderGroupPricingValue(pricing)} ))}
))}
) : ( {metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')} )}
)}
{isUserView ? (
{tr('品牌')}
{getBrand(m.name) ? ( {getBrand(m.name)!.name} ) : ( {tr('未识别品牌')} )}
) : isMobile ? (
{tr('账号明细')}
{m.accounts.map((a) => (
{a.username || `ID:${a.id}`}
{tr('延迟')} {a.latency != null ? `${a.latency}ms` : '暂无'}
{tr('余额')} ${(a.balance || 0).toFixed(2)}
{tr('令牌')}
{a.tokens.length > 0 ? a.tokens.map((t) => ( {t.name} )) : 暂无}
))}
) : ( {m.accounts.map(a => ( ))}
{tr('站点')} {tr('账号')} {tr('令牌')} {tr('延迟')} {tr('余额')}
{isUserView ? {tr('隐藏')} : } {isUserView ? tr('隐藏') : (a.username || `ID:${a.id}`)} {a.tokens.length > 0 ? a.tokens.map(t => ( {t.name} )) : 暂无} {a.latency != null ? ( {a.latency}ms ) : '暂无'} ${(a.balance || 0).toFixed(2)}
)}
) : null}
); })}
) : ( /* ====== Table View ====== */
{!isUserView && ( )} {!isUserView && ( )} {paged.map((m) => { const isExpanded = expanded === m.name; return ( setExpanded(isExpanded ? null : m.name)} style={{ cursor: 'pointer' }}> {!isUserView && } {!isUserView && } {isExpanded ? ( ) : null} ); })}
{ setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> {tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''} { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> {tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''} { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> {tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''} { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> {tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''} { setSortBy('successRate'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> {tr('成功率')} {sortBy === 'successRate' ? (sortDir === 'desc' ? '↓' : '↑') : ''} {tr('操作')}
{m.name} {m.accountCount}{m.tokenCount} {formatLatency(m.avgLatency)} {m.successRate != null ? `${m.successRate}%` : '暂无'} e.stopPropagation()}>
{tr('基础信息')}
{resolveMarketplaceDescription(m, metadataHydrating)}
{m.tags.length > 0 ? m.tags.map((tag) => ( {tag} )) : {metadataHydrating ? tr('加载元数据中...') : tr('暂无标签')}}
{tr('接口能力')}
{m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => ( {endpoint} )) : {metadataHydrating ? tr('加载元数据中...') : tr('未提供')}}
{tr('分组计费')}
{m.pricingSources.length > 0 ? (
{m.pricingSources.map((source) => (
· {source.username || `ID:${source.accountId}`}
{Object.entries(source.groupPricing).map(([group, pricing]) => ( {group}: {renderGroupPricingValue(pricing)} ))}
))}
) : ( {metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')} )}
{m.accounts.map(a => ( ))}
{tr('站点')} {tr('账号')} {tr('令牌')} {tr('延迟')} {tr('余额')}
{a.username || `ID:${a.id}`} {a.tokens.length > 0 ? a.tokens.map(t => ( {t.name} )) : '暂无'} {a.latency != null ? `${a.latency}ms` : '暂无'} ${(a.balance || 0).toFixed(2)}
)} {/* Pagination */} {filteredModels.length > 0 && (
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => { let pageNum: number; if (totalPages <= 7) { pageNum = i + 1; } else if (safePageVal <= 4) { pageNum = i + 1; } else if (safePageVal >= totalPages - 3) { pageNum = totalPages - 6 + i; } else { pageNum = safePageVal - 3 + i; } return ( ); })}
{tr('每页条数')}:
setPageSize(Number(nextValue))} options={PAGE_SIZES.map((s) => ({ value: String(s), label: String(s) }))} placeholder={String(pageSize)} />
)}
); }