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:
Your Name
2026-05-08 13:49:12 +08:00
parent dbdf13ea42
commit ba054f04cf
37 changed files with 4617 additions and 0 deletions

View 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
}
}
]
}

View 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
}
}
]
}

View 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;