chore: prepare repository for publishing
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
phamnazage-jpg
2026-05-13 14:42:45 +08:00
parent 55e506b2b5
commit 77e6610fd2
118 changed files with 27373 additions and 1009 deletions

View File

@@ -1,5 +1,5 @@
{
"generated_at": "2026-05-08T13:47:39+08:00",
"generated_at": "2026-05-09T21:30:54+08:00",
"total": 2,
"free": 1,
"paid": 1,

View File

@@ -1,248 +1,148 @@
// Explorer.tsx - 模型浏览器页面
// 组合筛选 + 卡片/表格视图 + 搜索
// Phase 1 脚手架:数据来自日报生成命令可重放的 reports/daily JSON
import React, { useState } from 'react';
import { useEffect, useMemo, useState } from 'react'
import { formatPrice, loadFallbackModels, normalizeModel, type Model } from '../lib/models'
// 筛选栏
interface Filters {
provider: string;
modality: string;
maxInputPrice: string;
keyword: string;
}
type SortField = 'name' | 'inputPrice' | 'outputPrice' | 'contextLength'
type SortOrder = 'asc' | 'desc'
// 视图模式
type ViewMode = 'card' | 'table';
const PAGE_SIZE = 5
// 模型数据占位TODO: 接入真实 API
interface Model {
id: string;
name: string;
provider: string;
contextLength: number;
inputPrice: number;
outputPrice: number;
isFree: boolean;
capabilities: string[];
}
function Explorer() {
const [models, setModels] = useState<Model[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [sortField, setSortField] = useState<SortField>('inputPrice')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [providerFilter, setProviderFilter] = useState<string>('')
const [modalityFilter, setModalityFilter] = useState<string>('')
// mapAPIResponseToModels — 将 fetch_openrouter.go 输出映射为 Model 结构
function mapAPIResponseToModels(raw: any[]): Model[] {
return raw.map((m) => ({
id: m.id || '',
name: m.name || '',
provider: (m.id || '').split('/')[0] || '',
contextLength: m.context_length || 0,
inputPrice: m.pricing?.input ?? 0,
outputPrice: m.pricing?.output ?? 0,
isFree: (m.pricing?.input ?? 0) === 0 && (m.pricing?.output ?? 0) === 0,
capabilities: Array.isArray(m.capabilities) ? m.capabilities : [],
}));
}
useEffect(() => {
// 从API加载数据
fetch('/api/v1/models')
.then(r => r.json())
.then(data => {
const rawModels: any[] = Array.isArray(data?.data) ? data.data : []
const normalized = rawModels
.map(normalizeModel)
.filter((model: Model | null): model is Model => model !== null)
setModels(normalized)
setLoading(false)
})
.catch(async () => {
// 降级:使用本地静态数据
const fallback = await loadFallbackModels()
setModels(fallback)
setLoading(false)
})
}, [])
// getMockModels — 优先从 latest_models.json 加载,缺失时 fallback 到 models.json
// eslint-disable-next-line @typescript-eslint/no-var-requires
const rawData: any = (function() {
try {
return require('../data/latest_models.json');
} catch(e) {
return require('../data/models.json');
}
})();
function getMockModels(): Model[] {
return mapAPIResponseToModels(rawData.models || []);
}
// filterModels — 四项筛选逻辑provider/modality/maxInputPrice/keyword大小写不敏感
function filterModels(models: Model[], filters: Filters): Model[] {
return models.filter((m) => {
if (filters.provider && m.provider.toLowerCase() !== filters.provider.toLowerCase()) {
return false;
}
if (filters.modality && !m.capabilities.includes(filters.modality)) {
return false;
}
if (filters.maxInputPrice && m.inputPrice > parseFloat(filters.maxInputPrice)) {
return false;
}
if (filters.keyword) {
const kw = filters.keyword.toLowerCase();
if (!m.id.toLowerCase().includes(kw) && !m.name.toLowerCase().includes(kw)) {
return false;
// 动态提取厂商列表
const providers = useMemo(() => {
const set = new Set<string>()
models.forEach(m => {
if (m.providerCN && m.providerCN !== 'Unknown') {
set.add(m.providerCN)
}
})
return Array.from(set).sort()
}, [models])
// 排序+筛选
const filtered = useMemo(() => {
let result = [...models]
if (providerFilter) {
result = result.filter(m => m.providerCN === providerFilter)
}
return true;
});
}
if (modalityFilter) {
result = result.filter(m => m.modality === modalityFilter)
}
result.sort((a, b) => {
const aVal = a[sortField]
const bVal = b[sortField]
if (typeof aVal === 'string') {
return sortOrder === 'asc'
? aVal.localeCompare(bVal as string)
: (bVal as string).localeCompare(aVal)
}
return sortOrder === 'asc'
? (aVal as number) - (bVal as number)
: (bVal as number) - (aVal as number)
})
return result
}, [models, sortField, sortOrder, providerFilter, modalityFilter])
const ExplorerPage: React.FC = () => {
const [filters, setFilters] = useState<Filters>({
provider: '',
modality: '',
maxInputPrice: '',
keyword: '',
});
const [viewMode, setViewMode] = useState<ViewMode>('card');
const filteredResults = filterModels(getMockModels(), filters);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
};
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(o => o === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortOrder('asc')
}
setPage(1)
}
const toggleView = (mode: ViewMode) => {
setViewMode(mode);
};
if (loading) return <div className="loading">...</div>
return (
<div className="container-fluid py-3">
<h4 className="mb-3"></h4>
<div className="explorer">
<h2>🔍 Explorer</h2>
{/* 价格趋势占位图 */}
<div className="card mb-3">
<div className="card-body">
<h6 className="card-title"></h6>
<div
id="price-trend-chart"
className="border rounded bg-light d-flex align-items-center justify-content-center text-muted small"
style={{ width: '100%', height: 200 }}
>
JSON ECharts
</div>
</div>
<div className="filters">
<select value={providerFilter} onChange={e => { setProviderFilter(e.target.value); setPage(1) }}>
<option value=""></option>
{providers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<select value={modalityFilter} onChange={e => { setModalityFilter(e.target.value); setPage(1) }}>
<option value=""></option>
<option value="text"></option>
<option value="multimodal"></option>
</select>
<span className="count"> {filtered.length} </span>
</div>
{/* 筛选栏 */}
<div className="row mb-3 g-2">
<div className="col-md-2">
<select
className="form-select"
value={filters.provider}
onChange={(e) => handleFilterChange('provider', e.target.value)}
>
<option value=""></option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="deepseek">DeepSeek</option>
</select>
</div>
<div className="col-md-2">
<select
className="form-select"
value={filters.modality}
onChange={(e) => handleFilterChange('modality', e.target.value)}
>
<option value=""></option>
<option value="text"></option>
<option value="vision"></option>
<option value="code"></option>
</select>
</div>
<div className="col-md-2">
<input
type="number"
className="form-control"
placeholder="最大输入价($/MT)"
value={filters.maxInputPrice}
onChange={(e) => handleFilterChange('maxInputPrice', e.target.value)}
/>
</div>
<div className="col-md-3">
<input
type="text"
className="form-control"
placeholder="搜索模型名称..."
value={filters.keyword}
onChange={(e) => handleFilterChange('keyword', e.target.value)}
/>
</div>
<div className="col-md-3">
<div className="btn-group w-100" role="group">
<button
type="button"
className={`btn btn-outline-primary ${viewMode === 'card' ? 'active' : ''}`}
onClick={() => toggleView('card')}
>
</button>
<button
type="button"
className={`btn btn-outline-primary ${viewMode === 'table' ? 'active' : ''}`}
onClick={() => toggleView('table')}
>
</button>
</div>
</div>
</div>
<table className="model-table">
<thead>
<tr>
<th onClick={() => toggleSort('name')}> {sortField === 'name' && (sortOrder === 'asc' ? '▲' : '▼')}</th>
<th></th>
<th></th>
<th onClick={() => toggleSort('inputPrice')}> {sortField === 'inputPrice' && (sortOrder === 'asc' ? '▲' : '▼')}</th>
<th onClick={() => toggleSort('outputPrice')}> {sortField === 'outputPrice' && (sortOrder === 'asc' ? '▲' : '▼')}</th>
<th onClick={() => toggleSort('contextLength')}> {sortField === 'contextLength' && (sortOrder === 'asc' ? '▲' : '▼')}</th>
<th></th>
</tr>
</thead>
<tbody>
{paginated.map(m => (
<tr key={m.id} className={`${m.isFree ? 'free' : ''} ${m.stale ? 'stale' : ''}`.trim()}>
<td>
<div className="model-name">{m.name || m.id}</div>
<div className="model-id">{m.id}</div>
</td>
<td>{m.providerCN || m.provider}</td>
<td>
<span className={`status-badge ${m.stale ? 'status-stale' : 'status-fresh'}`}>
{m.stale ? 'stale' : m.dataConfidence}
</span>
</td>
<td>{formatPrice(m, 'input')}</td>
<td>{formatPrice(m, 'output')}</td>
<td>{(m.contextLength / 1000).toFixed(0)}K</td>
<td>{m.modality}</td>
</tr>
))}
</tbody>
</table>
{/* 结果区域 */}
<div id="results" className="row">
{filteredResults.length === 0 ? (
<div className="col-12 text-center text-muted py-5">
{/* TODO: 接入 reports/daily JSON 数据 */}
JSON
</div>
) : viewMode === 'card' ? (
filteredResults.map((model) => (
<div key={model.id} className="col-md-4 mb-3">
<div className="card">
<div className="card-body">
<h6 className="card-title">{model.id}</h6>
<p className="card-text text-muted small">
{model.provider} · {model.contextLength.toLocaleString()} tokens
</p>
<p className="card-text small">
${model.inputPrice}/MT · ${model.outputPrice}/MT
</p>
{model.isFree && (
<span className="badge bg-success"></span>
)}
</div>
</div>
</div>
))
) : (
<table className="table table-striped">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredResults.map((model) => (
<tr key={model.id}>
<td>{model.id}</td>
<td>{model.provider}</td>
<td>{model.contextLength.toLocaleString()}</td>
<td>${model.inputPrice}/MT</td>
<td>${model.outputPrice}/MT</td>
<td>
{model.isFree && (
<span className="badge bg-success"></span>
)}
</td>
<td>{model.capabilities.join(', ')}</td>
</tr>
))}
</tbody>
</table>
)}
<div className="pagination">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)}></button>
<span> {page} / {totalPages} </span>
<button disabled={page === totalPages} onClick={() => setPage(p => p + 1)}></button>
</div>
{/* 分页占位 */}
<nav>
<ul className="pagination justify-content-center">
{/* TODO: 接入真实分页 */}
</ul>
</nav>
</div>
);
};
)
}
export default ExplorerPage;
export default Explorer