forked from niuniu/llm-intelligence
feat(phase1): OpenRouter采集器接入PostgreSQL,数据链路闭环
- 将 fetch_openrouter.go 的 summarize() 实现为 PostgreSQL upsert - 新增 -db 参数和 DATABASE_URL 环境变量支持 - 打通 models + model_prices 表的最小可运行链路 - 创建 llm_intelligence 数据库并运行 migration - 前端 Explorer 验证 T-3.2~T-3.5 全部通过 - 日报生成器正常产出 Markdown 和 latest_models.json
This commit is contained in:
24
frontend/src/data/latest_models.json
Normal file
24
frontend/src/data/latest_models.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"generated_at": "2026-05-08T13:47:39+08:00",
|
||||
"total": 2,
|
||||
"free": 1,
|
||||
"paid": 1,
|
||||
"models": [
|
||||
{
|
||||
"id": "openai/gpt-4o",
|
||||
"context_length": 128000,
|
||||
"pricing": {
|
||||
"input": 2.5,
|
||||
"output": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-3.5-sonnet:free",
|
||||
"context_length": 200000,
|
||||
"pricing": {
|
||||
"input": 0,
|
||||
"output": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
58
frontend/src/data/models.json
Normal file
58
frontend/src/data/models.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"generated_at": "2026-05-06T08:00:00+08:00",
|
||||
"total": 5,
|
||||
"free": 2,
|
||||
"paid": 3,
|
||||
"models": [
|
||||
{
|
||||
"id": "openai/gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"context_length": 128000,
|
||||
"capabilities": ["vision", "function_calling"],
|
||||
"pricing": {
|
||||
"input": 2.5,
|
||||
"output": 10.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-3.5-sonnet:free",
|
||||
"name": "Claude 3.5 Sonnet",
|
||||
"context_length": 200000,
|
||||
"capabilities": ["vision", "function_calling"],
|
||||
"pricing": {
|
||||
"input": 0,
|
||||
"output": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek-ai/DeepSeek-V3",
|
||||
"name": "DeepSeek V3",
|
||||
"context_length": 64000,
|
||||
"capabilities": ["text"],
|
||||
"pricing": {
|
||||
"input": 0.1,
|
||||
"output": 0.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mistralai/Mistral-7B:free",
|
||||
"name": "Mistral-7B Free",
|
||||
"context_length": 32768,
|
||||
"capabilities": ["text"],
|
||||
"pricing": {
|
||||
"input": 0,
|
||||
"output": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "google/gemini-pro",
|
||||
"name": "Gemini Pro",
|
||||
"context_length": 32768,
|
||||
"capabilities": ["vision", "text"],
|
||||
"pricing": {
|
||||
"input": 0.125,
|
||||
"output": 0.5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
248
frontend/src/pages/Explorer.tsx
Normal file
248
frontend/src/pages/Explorer.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
// Explorer.tsx - 模型浏览器页面
|
||||
// 组合筛选 + 卡片/表格视图 + 搜索
|
||||
// Phase 1 脚手架:数据来自日报生成命令可重放的 reports/daily JSON
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// 筛选栏
|
||||
interface Filters {
|
||||
provider: string;
|
||||
modality: string;
|
||||
maxInputPrice: string;
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
// 视图模式
|
||||
type ViewMode = 'card' | 'table';
|
||||
|
||||
// 模型数据占位(TODO: 接入真实 API)
|
||||
interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextLength: number;
|
||||
inputPrice: number;
|
||||
outputPrice: number;
|
||||
isFree: boolean;
|
||||
capabilities: 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 : [],
|
||||
}));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
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 handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleView = (mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h4 className="mb-3">模型浏览器</h4>
|
||||
|
||||
{/* 价格趋势占位图 */}
|
||||
<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>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<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>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<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>
|
||||
|
||||
{/* 分页占位 */}
|
||||
<nav>
|
||||
<ul className="pagination justify-content-center">
|
||||
{/* TODO: 接入真实分页 */}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExplorerPage;
|
||||
Reference in New Issue
Block a user