docs: 添加 AI 时间轴功能设计文档

This commit is contained in:
2026-01-25 18:12:18 +08:00
parent 52f36c9e4a
commit 72bf531e91
@@ -0,0 +1,494 @@
# AI 时间轴功能设计文档
**创建日期**: 2025-01-25
**目标用户**: 开发者和 AI 研究者
**设计理念**: 学术+产品平衡,聚焦大语言模型发展历程(从 2017 Transformer 开始)
---
## 1. 系统架构
### 核心组件
**前端展示系统**
- 路由: `src/app/[locale]/timeline/page.tsx`
- 渲染策略: ISR (1小时缓存)
- 设计风格: Neo-brutalism,复用现有设计系统
- 数据展示: 按年份分组的卡片堆叠时间轴
**后端 API**
- `POST /api/events` - 创建事件(供 n8n 调用,需 API Key 认证)
- `GET /api/events` - 获取所有事件(支持年份筛选)
- `GET /api/events/:id` - 获取单个事件详情
- `PATCH /api/events/:id` - 更新事件(可选)
- `DELETE /api/events/:id` - 删除事件(可选)
**数据采集系统 (n8n)**
- 历史数据初始化流程: 一次性运行,批量收集 2017-2025 事件
- 增量更新流程: 每周一运行,收集最近 7 天新事件
- 三 Agent 协作: 搜索 → 筛选 → 格式化 → HTTP Request 提交
**数据库**
- `AIEvent` 表: 存储所有事件
- 无需标签系统(基础版数据结构)
---
## 2. 数据库 Schema
### Prisma 模型
```prisma
model AIEvent {
id String @id @default(cuid())
title String
titleEn String? // 英文标题(可选)
eventDate DateTime // 事件发生的具体日期
description String
descriptionEn String? // 英文描述
imageUrl String // 事件配图 URL
sourceUrl String? // 原始来源链接
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([eventDate(sort: Desc)]) // 按日期降序索引
@@index([createdAt])
}
```
### 字段说明
- **国际化字段**: `title`/`titleEn`, `description`/`descriptionEn` 与项目表保持一致
- **日期字段**: 使用 `DateTime` 支持时间范围筛选和排序
- **索引优化**: `eventDate` 降序索引提升"最近事件"查询性能
- **图片存储**: 直接存储 URL(可以是 Unsplash、项目截图等)
### 迁移命令
```bash
pnpm prisma migrate dev --name add_ai_events_table
```
---
## 3. API 端点设计
### 数据验证 Schema
`src/lib/validations.ts` 新增:
```typescript
const AIEventInputSchema = z.object({
title: z.string().min(1).max(200),
titleEn: z.string().max(200).optional(),
eventDate: z.string().datetime(), // ISO 8601 格式
description: z.string().min(10).max(500),
descriptionEn: z.string().max(500).optional(),
imageUrl: z.string().url(),
sourceUrl: z.string().url().optional(),
});
```
### POST /api/events (n8n 调用)
**认证**:
```typescript
const apiKey = request.headers.get('X-API-Key');
if (!apiKey || !crypto.timingSafeEqual(
Buffer.from(apiKey),
Buffer.from(process.env.WEBHOOK_API_KEY!)
)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
```
**验证**:
```typescript
const body = await request.json();
const validationResult = AIEventInputSchema.array().safeParse(body);
if (!validationResult.success) {
return NextResponse.json({
error: 'Validation failed',
details: validationResult.error
}, { status: 400 });
}
```
**创建**:
```typescript
const events = await prisma.aIEvent.createMany({
data: validationResult.data,
skipDuplicates: true,
});
return NextResponse.json({ created: events.count }, { status: 201 });
```
### GET /api/events (前端调用)
支持查询参数:
- `?year=2024` - 筛选特定年份
- `?limit=50` - 限制返回数量
- `?offset=0` - 分页偏移
返回按 `eventDate` 降序排列的事件列表。
### 调用示例
```bash
curl -X POST https://your-domain.com/api/events \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_WEBHOOK_API_KEY" \
-d '{
"title": "GPT-4 发布",
"eventDate": "2023-03-14T00:00:00Z",
"description": "OpenAI 发布多模态大语言模型",
"imageUrl": "https://example.com/gpt4.jpg"
}'
```
---
## 4. 前端页面实现
### 页面结构
`src/app/[locale]/timeline/page.tsx`:
```tsx
import { getAIEvents } from '@/hooks/useAIEvents';
import { TimelineSection } from '@/components/timeline/TimelineSection';
export const revalidate = 3600; // ISR 1小时
export default async function TimelinePage() {
const events = await getAIEvents();
// 按年份分组
const eventsByYear = events.reduce((acc, event) => {
const year = new Date(event.eventDate).getFullYear();
if (!acc[year]) acc[year] = [];
acc[year].push(event);
return acc;
}, {} as Record<number, typeof events>);
return (
<div className="timeline-container">
<header>
<h1>AI </h1>
</header>
<main>
{Object.entries(eventsByYear)
.sort(([a], [b]) => Number(b) - Number(a)) // 降序
.map(([year, yearEvents]) => (
<TimelineSection
key={year}
year={year}
events={yearEvents}
/>
))}
</main>
</div>
);
}
```
### 数据获取函数
`src/hooks/useAIEvents.ts`:
```typescript
export async function getAIEvents() {
const events = await prisma.aIEvent.findMany({
orderBy: { eventDate: 'desc' },
take: 100, // 最多 100 条
});
return events;
}
```
### 组件设计
- **TimelineSection**: 渲染单个年份的卡片堆叠区域,复用原型的动画效果
- **EventCard**: 单个事件卡片(图片、标题、描述、日期)
- **响应式**: 移动端垂直堆叠,桌面端横向卡片堆叠
---
## 5. n8n Workflow 设计
### 流程 A: 历史数据初始化(一次性运行)
**1. 历史搜索 Agent**
- 按年份批量搜索: 2017-2025
- 搜索关键词: `"AI breakthrough YEAR"`, `"LLM release YEAR"`, `"GPT model YEAR"`
- 时间范围: 每年 1月1日 - 12月31日
- 输出: 每年 50-100 条搜索结果
**2. 历史筛选 Agent**
- 权威来源: `['arxiv.org', 'openai.com', 'anthropic.com', 'google.ai', 'meta.ai', 'deepmind.com']`
- 去重逻辑: 相同标题或 URL 只保留一个
- 输出: 每年精选 10-20 个事件
**3. 历史格式化 Agent**
- 提取完整信息: 标题、准确日期、详细描述、配图
- 对描述进行润色和本地化(翻译成中文)
- 补充缺失图片(使用 Unsplash 或项目官网截图)
**4. HTTP Request: 批量提交**
- POST `/api/events`
- 每次提交 10-20 个事件(按年份分批)
- 记录成功/失败状态
### 流程 B: 增量更新(每周运行)
**触发器**: Cron node,每周一早上 9:00
**1. 增量搜索 Agent**
- 时间范围: 最近 7 天
- 搜索关键词: `["AI news", "LLM release", "model launch"]`
- 输出: 10-20 条最新搜索结果
**2. 增量筛选 Agent**
- 权威来源检查(同历史流程)
- 额外检查: 查询数据库避免重复 `sourceUrl`
- 输出: 2-5 个新事件
**3. 增量格式化 Agent**
- 快速格式化: 提取核心信息
- 描述保持简短(直接使用摘要)
- 图片优先使用新闻配图
**4. HTTP Request: 增量提交**
- POST `/api/events`
- 一次性提交所有新事件
- 失败时发送告警邮件
### 错误处理
- **重试策略**: 每个 Agent 失败后等待 5s/10s/20s 重试,最多 3 次
- **降级处理**: 图片提取失败使用默认图片; 日期模糊使用当月 1 日
- **告警机制**: 最终失败时发送邮件并附执行日志
- **部分成功**: 批量提交时即使部分失败,也记录成功的事件
---
## 6. 数据验证和错误处理
### API 端点保护
**认证机制**: 使用 `crypto.timingSafeEqual` 防止时序攻击
**请求体验证**: Zod schema 验证所有字段
**去重逻辑**: 基于 `sourceUrl` 的唯一索引(如果提供)
**长度限制**:
- 标题 ≤200 字符
- 描述 ≤500 字符
**必填字段**: `title`, `eventDate`, `description`, `imageUrl`
**URL 验证**: `imageUrl``sourceUrl` 必须是有效的 HTTP/HTTPS URL
### 前端错误边界
```tsx
export default async function TimelinePage() {
try {
const events = await getAIEvents();
if (!events.length) {
return <EmptyState message="暂无事件数据" />;
}
return <TimelineContent events={events} />;
} catch (error) {
console.error('Failed to load events:', error);
return <ErrorState message="加载失败,请稍后重试" />;
}
}
```
---
## 7. 测试策略
### 单元测试 (Vitest)
**API 端点测试** (`src/app/api/events/route.test.ts`):
```typescript
describe('POST /api/events', () => {
it('should create event with valid data', async () => {
const response = await POST(request_mock);
expect(response.status).toBe(201);
});
it('should reject invalid API key', async () => {
const response = await POST(bad_request_mock);
expect(response.status).toBe(401);
});
it('should validate required fields', async () => {
const response = await POST(invalid_data_mock);
expect(response.status).toBe(400);
});
});
```
**数据验证测试** (`src/lib/validations.test.ts`):
```typescript
describe('AIEventInputSchema', () => {
it('should validate valid event', () => {
expect(() => AIEventInputSchema.parse(validEvent)).not.toThrow();
});
it('should reject missing title', () => {
expect(() => AIEventInputSchema.parse({ ...validEvent, title: '' }))
.toThrow();
});
});
```
### E2E 测试 (chrome-devtools-mcp)
**测试流程**:
1. 启动开发服务器: `pnpm dev`
2. 使用 chrome-devtools-mcp 工具:
- `new_page`: http://localhost:3000/timeline
- `take_snapshot`: 验证页面结构
- `take_screenshot`: 对比设计原型
- `evaluate_script`: 检查事件数据渲染
- `list_console_messages`: 确保无 JavaScript 错误
3. 测试筛选功能:
- `navigate_page`: url=/timeline?year=2024
- `take_snapshot`: 验证只显示 2024 年事件
**手动测试清单**:
- [ ] 页面加载成功,无 console 错误
- [ ] 事件按年份正确分组显示
- [ ] 卡片堆叠动画正常工作
- [ ] 响应式布局在移动端正常
- [ ] ISR 缓存在 1 小时后正确更新
### 集成测试 (n8n Workflow)
- 手动触发历史数据初始化流程,验证数据库事件创建
- 手动触发增量更新流程,验证新事件添加
- 测试错误场景: API 不可用、数据格式错误、网络超时
### 测试数据准备
使用 `pnpm prisma db seed` 创建种子数据:
- Seed 脚本插入 5-10 个示例事件(2023-2025 真实事件)
- 方便开发和手动测试
---
## 8. 部署和实施步骤
### 阶段 1: 数据库和后端 (1-2 天)
```bash
# 更新 schema
vim prisma/schema.prisma
# 生成并运行迁移
pnpm prisma migrate dev --name add_ai_events_table
pnpm prisma generate
# 创建验证 schema
vim src/lib/validations.ts
# 创建 API route
mkdir src/app/api/events
vim src/app/api/events/route.ts
# 创建数据获取函数
vim src/hooks/useAIEvents.ts
# 手动测试 API
curl -X POST http://localhost:3000/api/events \
-H "Content-Type: application/json" \
-H "X-API-Key: $WEBHOOK_API_KEY" \
-d '{
"title": "测试事件",
"eventDate": "2025-01-25T00:00:00Z",
"description": "这是一个测试事件",
"imageUrl": "https://example.com/image.jpg"
}'
```
### 阶段 2: 前端页面 (2-3 天)
```bash
# 创建 timeline 页面
mkdir -p src/app/[locale]/timeline
vim src/app/[locale]/timeline/page.tsx
# 创建组件
mkdir src/components/timeline
vim src/components/timeline/TimelineSection.tsx
vim src/components/timeline/EventCard.tsx
# 更新 header 添加导航链接
vim src/components/layout/Header.tsx
# 使用 chrome-devtools-mcp 测试页面效果
```
### 阶段 3: n8n Workflow 配置 (1-2 天)
1. 在 n8n 中创建历史数据初始化 workflow
2. 创建增量更新 workflow,设置每周一 9:00 触发
3. 配置环境变量 `WEBHOOK_API_KEY`
4. 手动运行历史 workflow,初始化 2017-2025 数据
5. 验证增量 workflow 是否正常工作
### 阶段 4: 测试和优化 (1 天)
1. 使用 chrome-devtools-mcp 进行完整测试
2. 修复发现的问题
3. 优化性能(ISR 缓存、图片加载)
4. 准备生产环境部署
### 阶段 5: 部署到生产
1. 更新 Vercel 环境变量(确保 `WEBHOOK_API_KEY` 已设置)
2. 运行数据库迁移(如果需要): `pnpm prisma db push`
3. 部署代码到 Vercel
4. 在 n8n 中更新 API endpoint 为生产地址
5. 监控第一次增量运行
---
## 9. 关键设计决策
### 为什么选择基础版事件数据?
- 符合 YAGNI 原则,快速上线
- 避免过度设计,聚焦核心价值
- 后续可扩展(如添加用户互动、点赞等)
### 为什么不需要标签系统?
- 从 UI 原型看,卡片只显示核心信息(标题、日期、描述、图片)
- 时间轴本身就是按年份组织,无需额外分类
- 减少数据复杂度,提升性能
### 为什么设计两套 n8n 流程?
- **历史流程**: 注重数据质量和完整性,处理 2017-2025 的大量数据
- **增量流程**: 注重效率和及时性,每周自动更新
- 分离关注点,便于独立优化和调试
### 为什么使用 ISR 而非纯静态?
- 事件数据每周更新,需要一定时效性
- ISR 1小时缓存平衡了性能和新鲜度
- 避免每次请求都查询数据库
---
## 10. 后续优化方向
1. **搜索和筛选**: 添加关键词搜索、标签筛选
2. **用户互动**: 点赞、评论、分享功能
3. **相关推荐**: 基于事件标签推荐相关项目
4. **多语言支持**: 完善英文版本的描述和翻译
5. **图片优化**: 使用 Next.js Image 组件优化图片加载
6. **数据可视化**: 添加图表展示 AI 发展趋势
7. **导出功能**: 支持导出时间轴为 PDF/Markdown