2157 lines
49 KiB
Markdown
2157 lines
49 KiB
Markdown
# AI 时间轴功能实施计划
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**目标:** 构建一个展示 AI 大语言模型发展历程的时间轴功能,支持 2017 年至今的里程碑事件展示,通过 n8n workflow 自动收集和更新数据。
|
||
|
||
**架构:** 采用 Next.js 15 App Router + Prisma + PostgreSQL 架构,前端使用 ISR 缓存策略,后端提供 RESTful API,数据采集通过 n8n workflow 中的三个 Agent 协作完成(搜索→筛选→格式化→提交)。
|
||
|
||
**技术栈:** Next.js 15, Prisma, PostgreSQL, n8n, Web Search MCP, Zod, Vitest, chrome-devtools-mcp
|
||
|
||
---
|
||
|
||
## 前置准备
|
||
|
||
### Task 0: 创建 Git Worktree
|
||
|
||
**文件:**
|
||
- Create: N/A (使用 git worktree)
|
||
|
||
**Step 1: 创建隔离的工作空间**
|
||
|
||
```bash
|
||
cd /Users/caihaohan/Code/agent_park
|
||
git worktree add ../agent_park-timeline main -b feature/ai-timeline
|
||
cd ../agent_park-timeline
|
||
```
|
||
|
||
**Step 2: 验证 worktree 创建成功**
|
||
|
||
```bash
|
||
pwd
|
||
# Expected output: /Users/caihaohan/Code/agent_park-timeline
|
||
|
||
git branch
|
||
# Expected output: * feature/ai-timeline
|
||
```
|
||
|
||
**Step 3: 安装依赖(如果需要)**
|
||
|
||
```bash
|
||
pnpm install
|
||
```
|
||
|
||
**Step 4: 启动开发服务器**
|
||
|
||
```bash
|
||
# 先检查是否有进程运行在 3000 端口
|
||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||
pnpm dev
|
||
```
|
||
|
||
**Step 5: 验证服务器启动成功**
|
||
|
||
访问: http://localhost:3000
|
||
Expected: Agent Park 首页正常显示
|
||
|
||
**Step 6: 提交 worktree 初始化**
|
||
|
||
```bash
|
||
cd /Users/caihaohan/Code/agent_park-timeline
|
||
git add .
|
||
git commit -m "chore: initialize worktree for AI timeline feature"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 1: 数据库 Schema
|
||
|
||
### Task 1: 添加 AIEvent 模型到 Prisma Schema
|
||
|
||
**文件:**
|
||
- Modify: `prisma/schema.prisma`
|
||
|
||
**Step 1: 打开 schema 文件**
|
||
|
||
```bash
|
||
vim prisma/schema.prisma
|
||
# 或使用你喜欢的编辑器
|
||
```
|
||
|
||
**Step 2: 在文件末尾添加 AIEvent 模型**
|
||
|
||
在最后一个 `}` 后面添加:
|
||
|
||
```prisma
|
||
model AIEvent {
|
||
id String @id @default(cuid())
|
||
title String
|
||
titleEn String?
|
||
eventDate DateTime
|
||
description String
|
||
descriptionEn String?
|
||
imageUrl String
|
||
sourceUrl String?
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([eventDate(sort: Desc)])
|
||
@@index([createdAt])
|
||
}
|
||
```
|
||
|
||
**Step 3: 保存文件**
|
||
|
||
**:wq` (vim) 或 Cmd+S (编辑器)
|
||
|
||
**Step 4: 验证语法正确**
|
||
|
||
```bash
|
||
pnpm prisma validate
|
||
```
|
||
|
||
Expected: `The schema is valid`
|
||
|
||
**Step 5: 生成并运行迁移**
|
||
|
||
```bash
|
||
pnpm prisma migrate dev --name add_ai_events_table
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
The following migration(s) have been created and applied from new schema changes:
|
||
|
||
migrations/
|
||
└─ 20250125XXXXXX_add_ai_events_table/
|
||
└─ migration.sql
|
||
|
||
Applying migration `20250125XXXXXX_add_ai_events_table`
|
||
|
||
The following migration(s) have been created and applied:
|
||
...
|
||
```
|
||
|
||
**Step 6: 生成 Prisma Client**
|
||
|
||
```bash
|
||
pnpm prisma generate
|
||
```
|
||
|
||
Expected: `Prisma Client generated successfully`
|
||
|
||
**Step 7: 提交**
|
||
|
||
```bash
|
||
git add prisma/schema.prisma prisma/migrations/
|
||
git commit -m "feat: add AIEvent model to database schema"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 2: 数据验证
|
||
|
||
### Task 2: 创建 Zod 验证 Schema
|
||
|
||
**文件:**
|
||
- Modify: `src/lib/validations.ts`
|
||
|
||
**Step 1: 打开验证文件**
|
||
|
||
```bash
|
||
vim src/lib/validations.ts
|
||
```
|
||
|
||
**Step 2: 找到文件末尾(在 `export { ... }` 之前)**
|
||
|
||
使用 `/ProjectQuerySchema` 搜索到相关位置,然后在后面添加。
|
||
|
||
**Step 3: 添加 AIEvent 相关的 Zod Schema**
|
||
|
||
在 `ProjectQuerySchema` 定义后添加:
|
||
|
||
```typescript
|
||
export const AIEventInputSchema = z.object({
|
||
title: z.string().min(1).max(200),
|
||
titleEn: z.string().max(200).optional(),
|
||
eventDate: z.string().datetime(),
|
||
description: z.string().min(10).max(500),
|
||
descriptionEn: z.string().max(500).optional(),
|
||
imageUrl: z.string().url(),
|
||
sourceUrl: z.string().url().optional(),
|
||
});
|
||
|
||
export const AIEventQuerySchema = z.object({
|
||
year: z.string().regex(/^\d{4}$/).optional(),
|
||
limit: z.string().regex(/^\d+$/).transform(Number).optional(),
|
||
offset: z.string().regex(/^\d+$/).transform(Number).optional(),
|
||
});
|
||
```
|
||
|
||
**Step 4: 更新 export 语句**
|
||
|
||
找到 `export {` 行,添加:
|
||
|
||
```typescript
|
||
export {
|
||
// ... 现有的 exports
|
||
AIEventInputSchema,
|
||
AIEventQuerySchema,
|
||
};
|
||
```
|
||
|
||
**Step 5: 保存文件**
|
||
|
||
**:wq`
|
||
|
||
**Step 6: 验证 TypeScript 编译通过**
|
||
|
||
```bash
|
||
pnpm tsc --noEmit
|
||
```
|
||
|
||
Expected: 无错误输出
|
||
|
||
**Step 7: 提交**
|
||
|
||
```bash
|
||
git add src/lib/validations.ts
|
||
git commit -m "feat: add AIEvent validation schemas"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 编写验证测试
|
||
|
||
**文件:**
|
||
- Create: `src/lib/validations.test.ts`
|
||
|
||
**Step 1: 创建测试文件**
|
||
|
||
```bash
|
||
vim src/lib/validations.test.ts
|
||
```
|
||
|
||
**Step 2: 编写测试**
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { AIEventInputSchema } from './validations';
|
||
|
||
describe('AIEventInputSchema', () => {
|
||
const validEvent = {
|
||
title: 'GPT-4 发布',
|
||
eventDate: '2023-03-14T00:00:00Z',
|
||
description: 'OpenAI 发布多模态大语言模型',
|
||
imageUrl: 'https://example.com/gpt4.jpg',
|
||
};
|
||
|
||
it('should validate valid event', () => {
|
||
expect(() => AIEventInputSchema.parse(validEvent)).not.toThrow();
|
||
});
|
||
|
||
it('should accept event with optional English fields', () => {
|
||
const eventWithEn = {
|
||
...validEvent,
|
||
titleEn: 'GPT-4 Release',
|
||
descriptionEn: 'OpenAI launches multimodal LLM',
|
||
sourceUrl: 'https://openai.com/blog/gpt-4',
|
||
};
|
||
expect(() => AIEventInputSchema.parse(eventWithEn)).not.toThrow();
|
||
});
|
||
|
||
it('should reject empty title', () => {
|
||
expect(() => AIEventInputSchema.parse({ ...validEvent, title: '' }))
|
||
.toThrow();
|
||
});
|
||
|
||
it('should reject title exceeding 200 characters', () => {
|
||
const longTitle = 'A'.repeat(201);
|
||
expect(() => AIEventInputSchema.parse({ ...validEvent, title: longTitle }))
|
||
.toThrow();
|
||
});
|
||
|
||
it('should reject description shorter than 10 characters', () => {
|
||
expect(() => AIEventInputSchema.parse({ ...validEvent, description: '太短' }))
|
||
.toThrow();
|
||
});
|
||
|
||
it('should reject description exceeding 500 characters', () => {
|
||
const longDesc = 'A'.repeat(501);
|
||
expect(() => AIEventInputSchema.parse({ ...validEvent, description: longDesc }))
|
||
.toThrow();
|
||
});
|
||
|
||
it('should reject invalid eventDate format', () => {
|
||
expect(() => AIEventInputSchema.parse({ ...validEvent, eventDate: '2023-03-14' }))
|
||
.toThrow();
|
||
});
|
||
|
||
it('should reject invalid imageUrl', () => {
|
||
expect(() => AIEventInputSchema.parse({ ...validEvent, imageUrl: 'not-a-url' }))
|
||
.toThrow();
|
||
});
|
||
|
||
it('should reject invalid sourceUrl format', () => {
|
||
expect(() => AIEventInputSchema.parse({
|
||
...validEvent,
|
||
sourceUrl: 'not-a-url'
|
||
})).toThrow();
|
||
});
|
||
});
|
||
```
|
||
|
||
**Step 3: 保存文件**
|
||
|
||
**:wq`
|
||
|
||
**Step 4: 运行测试验证失败**
|
||
|
||
```bash
|
||
pnpm test src/lib/validations.test.ts
|
||
```
|
||
|
||
Expected: 全部通过
|
||
|
||
**Step 5: 提交**
|
||
|
||
```bash
|
||
git add src/lib/validations.test.ts
|
||
git commit -m "test: add AIEvent validation tests"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 3: 数据获取层
|
||
|
||
### Task 4: 创建数据获取函数
|
||
|
||
**文件:**
|
||
- Create: `src/hooks/useAIEvents.ts`
|
||
|
||
**Step 1: 创建文件**
|
||
|
||
```bash
|
||
vim src/hooks/useAIEvents.ts
|
||
```
|
||
|
||
**Step 2: 编写数据获取函数**
|
||
|
||
```typescript
|
||
import { prisma } from '@/lib/prisma';
|
||
|
||
export async function getAIEvents(options?: {
|
||
year?: number;
|
||
limit?: number;
|
||
offset?: number;
|
||
}) {
|
||
const where = options?.year
|
||
? {
|
||
eventDate: {
|
||
gte: new Date(`${options.year}-01-01T00:00:00Z`),
|
||
lte: new Date(`${options.year}-12-31T23:59:59Z`),
|
||
},
|
||
}
|
||
: undefined;
|
||
|
||
const events = await prisma.aIEvent.findMany({
|
||
where,
|
||
orderBy: { eventDate: 'desc' },
|
||
take: options?.limit || 100,
|
||
skip: options?.offset || 0,
|
||
});
|
||
|
||
return events;
|
||
}
|
||
|
||
export async function getAIEventBySlug(slug: string) {
|
||
// 暂不实现,后续如需要详细页面时添加
|
||
return null;
|
||
}
|
||
|
||
export async function getAllAIEventYears() {
|
||
const events = await prisma.aIEvent.findMany({
|
||
select: {
|
||
eventDate: true,
|
||
},
|
||
orderBy: { eventDate: 'desc' },
|
||
});
|
||
|
||
const years = new Set<number>();
|
||
events.forEach(event => {
|
||
years.add(new Date(event.eventDate).getFullYear());
|
||
});
|
||
|
||
return Array.from(years).sort((a, b) => b - a);
|
||
}
|
||
```
|
||
|
||
**Step 3: 保存文件**
|
||
|
||
**:wq`
|
||
|
||
**Step 4: 验证 TypeScript 类型正确**
|
||
|
||
```bash
|
||
pnpm tsc --noEmit
|
||
```
|
||
|
||
Expected: 无错误
|
||
|
||
**Step 5: 提交**
|
||
|
||
```bash
|
||
git add src/hooks/useAIEvents.ts
|
||
git commit -m "feat: add AI event data fetching functions"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 4: 后端 API
|
||
|
||
### Task 5: 创建 API 路由 - POST endpoint
|
||
|
||
**文件:**
|
||
- Create: `src/app/api/events/route.ts`
|
||
|
||
**Step 1: 创建目录**
|
||
|
||
```bash
|
||
mkdir -p src/app/api/events
|
||
```
|
||
|
||
**Step 2: 创建路由文件**
|
||
|
||
```bash
|
||
vim src/app/api/events/route.ts
|
||
```
|
||
|
||
**Step 3: 实现 POST handler**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { prisma } from '@/lib/prisma';
|
||
import { AIEventInputSchema, AIEventQuerySchema } from '@/lib/validations';
|
||
import crypto from 'crypto';
|
||
|
||
export async function POST(request: NextRequest) {
|
||
// 1. API Key 验证
|
||
const apiKey = request.headers.get('X-API-Key');
|
||
const expectedKey = process.env.WEBHOOK_API_KEY;
|
||
|
||
if (!apiKey || !expectedKey || !crypto.timingSafeEqual(
|
||
Buffer.from(apiKey),
|
||
Buffer.from(expectedKey)
|
||
)) {
|
||
return NextResponse.json(
|
||
{ error: 'Unauthorized' },
|
||
{ status: 401 }
|
||
);
|
||
}
|
||
|
||
// 2. 解析请求体
|
||
let body: unknown;
|
||
try {
|
||
body = await request.json();
|
||
} catch (error) {
|
||
return NextResponse.json(
|
||
{ error: 'Invalid JSON' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 3. 验证数据
|
||
const validationResult = AIEventInputSchema.array().safeParse(body);
|
||
if (!validationResult.success) {
|
||
return NextResponse.json(
|
||
{
|
||
error: 'Validation failed',
|
||
details: validationResult.error.errors,
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 4. 创建事件
|
||
try {
|
||
const result = await prisma.aIEvent.createMany({
|
||
data: validationResult.data,
|
||
skipDuplicates: true,
|
||
});
|
||
|
||
return NextResponse.json(
|
||
{
|
||
created: result.count,
|
||
total: validationResult.data.length,
|
||
},
|
||
{ status: 201 }
|
||
);
|
||
} catch (error) {
|
||
console.error('Failed to create AI events:', error);
|
||
return NextResponse.json(
|
||
{ error: 'Internal server error' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function GET(request: NextRequest) {
|
||
// 1. 解析查询参数
|
||
const searchParams = request.nextUrl.searchParams;
|
||
const queryParams = {
|
||
year: searchParams.get('year'),
|
||
limit: searchParams.get('limit'),
|
||
offset: searchParams.get('offset'),
|
||
};
|
||
|
||
// 2. 验证查询参数
|
||
const validationResult = AIEventQuerySchema.safeParse(queryParams);
|
||
if (!validationResult.success) {
|
||
return NextResponse.json(
|
||
{
|
||
error: 'Invalid query parameters',
|
||
details: validationResult.error.errors,
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 3. 获取事件
|
||
try {
|
||
const events = await prisma.aIEvent.findMany({
|
||
where: validationResult.data.year
|
||
? {
|
||
eventDate: {
|
||
gte: new Date(`${validationResult.data.year}-01-01T00:00:00Z`),
|
||
lte: new Date(`${validationResult.data.year}-12-31T23:59:59Z`),
|
||
},
|
||
}
|
||
: undefined,
|
||
orderBy: { eventDate: 'desc' },
|
||
take: validationResult.data.limit || 100,
|
||
skip: validationResult.data.offset || 0,
|
||
});
|
||
|
||
return NextResponse.json({ events });
|
||
} catch (error) {
|
||
console.error('Failed to fetch AI events:', error);
|
||
return NextResponse.json(
|
||
{ error: 'Internal server error' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4: 保存文件**
|
||
|
||
**:wq`
|
||
|
||
**Step 5: 验证 TypeScript 编译**
|
||
|
||
```bash
|
||
pnpm tsc --noEmit
|
||
```
|
||
|
||
**Step 6: 提交**
|
||
|
||
```bash
|
||
git add src/app/api/events/
|
||
git commit -m "feat: add AI events API endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 编写 API 测试
|
||
|
||
**文件:**
|
||
- Create: `src/app/api/events/route.test.ts`
|
||
|
||
**Step 1: 创建测试文件**
|
||
|
||
```bash
|
||
vim src/app/api/events/route.test.ts
|
||
```
|
||
|
||
**Step 2: 编写测试**
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { POST, GET } from './route';
|
||
import { NextRequest } from 'next/server';
|
||
|
||
describe('POST /api/events', () => {
|
||
const validEvent = {
|
||
title: 'GPT-4 发布',
|
||
eventDate: '2023-03-14T00:00:00Z',
|
||
description: 'OpenAI 发布多模态大语言模型',
|
||
imageUrl: 'https://example.com/gpt4.jpg',
|
||
};
|
||
|
||
it('should reject without API key', async () => {
|
||
const request = new NextRequest('http://localhost:3000/api/events', {
|
||
method: 'POST',
|
||
body: JSON.stringify([validEvent]),
|
||
});
|
||
|
||
const response = await POST(request);
|
||
expect(response.status).toBe(401);
|
||
|
||
const json = await response.json();
|
||
expect(json.error).toBe('Unauthorized');
|
||
});
|
||
|
||
it('should reject with invalid API key', async () => {
|
||
const request = new NextRequest('http://localhost:3000/api/events', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-API-Key': 'invalid-key',
|
||
},
|
||
body: JSON.stringify([validEvent]),
|
||
});
|
||
|
||
const response = await POST(request);
|
||
expect(response.status).toBe(401);
|
||
});
|
||
|
||
// 注意: 以下测试需要设置 WEBHOOK_API_KEY 环境变量
|
||
// 可以通过 vi.stubEnv 来模拟
|
||
});
|
||
|
||
describe('GET /api/events', () => {
|
||
it('should return events array', async () => {
|
||
const request = new NextRequest('http://localhost:3000/api/events');
|
||
const response = await GET(request);
|
||
|
||
expect(response.status).toBe(200);
|
||
|
||
const json = await response.json();
|
||
expect(json).toHaveProperty('events');
|
||
expect(Array.isArray(json.events)).toBe(true);
|
||
});
|
||
|
||
it('should filter by year', async () => {
|
||
const request = new NextRequest(
|
||
'http://localhost:3000/api/events?year=2024'
|
||
);
|
||
const response = await GET(request);
|
||
|
||
expect(response.status).toBe(200);
|
||
|
||
const json = await response.json();
|
||
expect(json).toHaveProperty('events');
|
||
});
|
||
|
||
it('should reject invalid year format', async () => {
|
||
const request = new NextRequest(
|
||
'http://localhost:3000/api/events?year=invalid'
|
||
);
|
||
const response = await GET(request);
|
||
|
||
expect(response.status).toBe(400);
|
||
|
||
const json = await response.json();
|
||
expect(json.error).toBe('Invalid query parameters');
|
||
});
|
||
});
|
||
```
|
||
|
||
**Step 3: 保存文件**
|
||
|
||
**:wq`
|
||
|
||
**Step 4: 运行测试**
|
||
|
||
```bash
|
||
pnpm test src/app/api/events/route.test.ts
|
||
```
|
||
|
||
Expected: 基础测试通过(需要 API Key 的测试会失败)
|
||
|
||
**Step 5: 提交**
|
||
|
||
```bash
|
||
git add src/app/api/events/route.test.ts
|
||
git commit -m "test: add API route tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 手动测试 API
|
||
|
||
**文件:**
|
||
- N/A (使用 curl)
|
||
|
||
**Step 1: 确保 WEBHOOK_API_KEY 已设置**
|
||
|
||
```bash
|
||
# 检查 .env.local
|
||
cat .env.local | grep WEBHOOK_API_KEY
|
||
```
|
||
|
||
如果不存在,添加:
|
||
```bash
|
||
echo "WEBHOOK_API_KEY=test-key-for-development-only" >> .env.local
|
||
```
|
||
|
||
**Step 2: 测试 GET endpoint**
|
||
|
||
```bash
|
||
curl http://localhost:3000/api/events
|
||
```
|
||
|
||
Expected:
|
||
```json
|
||
{
|
||
"events": []
|
||
}
|
||
```
|
||
|
||
**Step 3: 测试 POST endpoint(创建单个事件)**
|
||
|
||
```bash
|
||
curl -X POST http://localhost:3000/api/events \
|
||
-H "Content-Type: application/json" \
|
||
-H "X-API-Key: test-key-for-development-only" \
|
||
-d '{
|
||
"title": "测试事件 - Transformer 论文发表",
|
||
"eventDate": "2017-06-12T00:00:00Z",
|
||
"description": "Google 团队发表 Attention Is All You Need 论文,提出了 Transformer 架构",
|
||
"imageUrl": "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800"
|
||
}'
|
||
```
|
||
|
||
Expected:
|
||
```json
|
||
{
|
||
"created": 1,
|
||
"total": 1
|
||
}
|
||
```
|
||
|
||
**Step 4: 验证事件已创建**
|
||
|
||
```bash
|
||
curl http://localhost:3000/api/events
|
||
```
|
||
|
||
Expected: 返回刚才创建的事件
|
||
|
||
**Step 5: 测试批量创建**
|
||
|
||
```bash
|
||
curl -X POST http://localhost:3000/api/events \
|
||
-H "Content-Type: application/json" \
|
||
-H "X-API-Key: test-key-for-development-only" \
|
||
-d '[
|
||
{
|
||
"title": "GPT-1 发布",
|
||
"eventDate": "2018-06-11T00:00:00Z",
|
||
"description": "OpenAI 发布第一代 GPT 模型",
|
||
"imageUrl": "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800"
|
||
},
|
||
{
|
||
"title": "BERT 发布",
|
||
"eventDate": "2018-10-11T00:00:00Z",
|
||
"description": "Google 发布 BERT 预训练模型",
|
||
"imageUrl": "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800"
|
||
}
|
||
]'
|
||
```
|
||
|
||
Expected:
|
||
```json
|
||
{
|
||
"created": 2,
|
||
"total": 2
|
||
}
|
||
```
|
||
|
||
**Step 6: 测试年份筛选**
|
||
|
||
```bash
|
||
curl "http://localhost:3000/api/events?year=2018"
|
||
```
|
||
|
||
Expected: 只返回 2018 年的事件
|
||
|
||
**Step 7: 提交 API 测试说明文档**
|
||
|
||
```bash
|
||
# 创建 API 测试文档
|
||
cat > docs/api-testing-guide.md << 'EOF'
|
||
# API 测试指南
|
||
|
||
## GET /api/events
|
||
|
||
获取所有事件:
|
||
|
||
```bash
|
||
curl http://localhost:3000/api/events
|
||
```
|
||
|
||
筛选特定年份:
|
||
|
||
```bash
|
||
curl "http://localhost:3000/api/events?year=2024"
|
||
```
|
||
|
||
限制返回数量:
|
||
|
||
```bash
|
||
curl "http://localhost:3000/api/events?limit=10"
|
||
```
|
||
|
||
## POST /api/events
|
||
|
||
创建单个事件:
|
||
|
||
```bash
|
||
curl -X POST http://localhost:3000/api/events \
|
||
-H "Content-Type: application/json" \
|
||
-H "X-API-Key: YOUR_API_KEY" \
|
||
-d '{
|
||
"title": "事件标题",
|
||
"eventDate": "2023-03-14T00:00:00Z",
|
||
"description": "事件描述(10-500字)",
|
||
"imageUrl": "https://example.com/image.jpg"
|
||
}'
|
||
```
|
||
|
||
批量创建事件:
|
||
|
||
```bash
|
||
curl -X POST http://localhost:3000/api/events \
|
||
-H "Content-Type: application/json" \
|
||
-H "X-API-Key: YOUR_API_KEY" \
|
||
-d '[
|
||
{ "title": "事件1", ... },
|
||
{ "title": "事件2", ... }
|
||
]'
|
||
```
|
||
EOF
|
||
|
||
git add docs/api-testing-guide.md
|
||
git commit -m "docs: add API testing guide"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 5: 前端页面
|
||
|
||
### Task 8: 创建 Timeline 页面
|
||
|
||
**文件:**
|
||
- Create: `src/app/[locale]/timeline/page.tsx`
|
||
|
||
**Step 1: 创建目录**
|
||
|
||
```bash
|
||
mkdir -p src/app/\[locale\]/timeline
|
||
```
|
||
|
||
**Step 2: 创建页面文件**
|
||
|
||
```bash
|
||
vim src/app/\[locale\]/timeline/page.tsx
|
||
```
|
||
|
||
**Step 3: 实现页面组件**
|
||
|
||
```typescript
|
||
import { getAIEvents } from '@/hooks/useAIEvents';
|
||
import { Metadata } from 'next';
|
||
|
||
export const revalidate = 3600; // ISR 1小时
|
||
|
||
export async function generateMetadata({
|
||
params,
|
||
}: {
|
||
params: Promise<{ locale: string }>;
|
||
}): Promise<Metadata> {
|
||
const { locale } = await params;
|
||
|
||
return {
|
||
title: locale === 'zh' ? 'AI 发展时间轴' : 'AI Timeline',
|
||
description: locale === 'zh'
|
||
? '探索人工智能大语言模型的发展历程,从 2017 年 Transformer 到今天'
|
||
: 'Explore the evolution of AI large language models from 2017 Transformer to today',
|
||
};
|
||
}
|
||
|
||
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>);
|
||
|
||
// 按年份降序排序
|
||
const sortedYears = Object.keys(eventsByYear)
|
||
.map(Number)
|
||
.sort((a, b) => b - a);
|
||
|
||
if (events.length === 0) {
|
||
return (
|
||
<div className="min-h-screen bg-background-light dark:bg-background-dark flex items-center justify-center">
|
||
<div className="text-center">
|
||
<h1 className="font-display font-black text-4xl mb-4">
|
||
暂无数据
|
||
</h1>
|
||
<p className="font-mono text-gray-600">
|
||
时间轴数据正在收集中...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background-light dark:bg-background-dark">
|
||
{/* Header */}
|
||
<header className="pt-32 pb-16 px-4 max-w-[1600px] mx-auto">
|
||
<div className="text-center">
|
||
<h1 className="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white mb-6">
|
||
AI 发展时间轴
|
||
</h1>
|
||
<p className="font-mono text-lg md:text-xl max-w-2xl mx-auto bg-white dark:bg-black border border-black dark:border-white p-4 shadow-hard">
|
||
从 Transformer 到 AGI: 大语言模型的进化之路
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Timeline */}
|
||
<main className="px-4 md:px-12 max-w-[1600px] mx-auto pb-32">
|
||
{sortedYears.map((year, index) => (
|
||
<section
|
||
key={year}
|
||
className={`relative min-h-[500px] mb-32 flex ${
|
||
index % 2 === 0 ? 'flex-row' : 'flex-row-reverse'
|
||
}`}
|
||
>
|
||
{/* Year Label */}
|
||
<div
|
||
className={`absolute ${index % 2 === 0 ? 'left-0' : 'right-0'} -top-8 z-20`}
|
||
>
|
||
<div
|
||
className={`${
|
||
index % 2 === 0 ? 'bg-primary' : 'bg-secondary'
|
||
} border-4 border-black px-6 py-2 ${
|
||
index % 2 === 0 ? '-rotate-2' : 'rotate-2'
|
||
} shadow-hard`}
|
||
>
|
||
<span className="font-display font-black text-3xl md:text-5xl">
|
||
{year}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Events Container */}
|
||
<div
|
||
className={`w-full ${
|
||
index % 2 === 0 ? 'pl-0 md:pl-8 pr-0 md:pr-32' : 'pl-0 md:pl-32 pr-0 md:pr-8'
|
||
} pt-20`}
|
||
>
|
||
<div className="flex flex-nowrap overflow-x-visible items-center justify-start">
|
||
{eventsByYear[year].map((event, eventIndex) => (
|
||
<div
|
||
key={event.id}
|
||
className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56 hover:z-50 hover:-translate-y-5 hover:scale-105 transition-all duration-300"
|
||
style={{
|
||
zIndex: Math.max(1, 50 - eventIndex * 10),
|
||
transform: `rotate(${(Math.random() - 0.5) * 6}deg)`,
|
||
}}
|
||
>
|
||
{/* Tape decoration */}
|
||
<div
|
||
className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6"
|
||
/>
|
||
|
||
{/* Image */}
|
||
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
|
||
<img
|
||
src={event.imageUrl}
|
||
alt={event.title}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<h3 className="font-display font-bold text-xl leading-none mb-2 uppercase">
|
||
{event.title}
|
||
</h3>
|
||
<p className="text-xs leading-snug opacity-80 line-clamp-4 mb-4">
|
||
{event.description}
|
||
</p>
|
||
|
||
{/* Date */}
|
||
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
|
||
{new Date(event.eventDate).toLocaleDateString('zh-CN')}
|
||
</div>
|
||
|
||
{/* Source Link */}
|
||
{event.sourceUrl && (
|
||
<a
|
||
href={event.sourceUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="absolute bottom-4 right-4 text-[10px] font-bold underline"
|
||
>
|
||
来源 →
|
||
</a>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
))}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Step 4: 保存文件**
|
||
|
||
**:wq`
|
||
|
||
**Step 5: 验证页面可访问**
|
||
|
||
访问: http://localhost:3000/timeline
|
||
|
||
Expected: 显示时间轴页面,包含之前创建的测试事件
|
||
|
||
**Step 6: 提交**
|
||
|
||
```bash
|
||
git add src/app/\[locale\]/timeline/
|
||
git commit -m "feat: add timeline page with year-based layout"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 创建 EventCard 组件(可选重构)
|
||
|
||
**文件:**
|
||
- Create: `src/components/timeline/EventCard.tsx`
|
||
- Create: `src/components/timeline/TimelineSection.tsx`
|
||
|
||
**Step 1: 创建组件目录**
|
||
|
||
```bash
|
||
mkdir -p src/components/timeline
|
||
```
|
||
|
||
**Step 2: 创建 EventCard 组件**
|
||
|
||
```bash
|
||
vim src/components/timeline/EventCard.tsx
|
||
```
|
||
|
||
```typescript
|
||
import { AIEvent } from '@prisma/client';
|
||
|
||
interface EventCardProps {
|
||
event: AIEvent;
|
||
index: number;
|
||
}
|
||
|
||
export function EventCard({ event, index }: EventCardProps) {
|
||
const rotation = (Math.random() - 0.5) * 6;
|
||
const zIndex = Math.max(1, 50 - index * 10);
|
||
|
||
return (
|
||
<div
|
||
className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56 hover:z-50 hover:-translate-y-5 hover:scale-105 transition-all duration-300"
|
||
style={{
|
||
zIndex,
|
||
transform: `rotate(${rotation}deg)`,
|
||
}}
|
||
>
|
||
{/* Tape decoration */}
|
||
<div className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6" />
|
||
|
||
{/* Image */}
|
||
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
|
||
<img
|
||
src={event.imageUrl}
|
||
alt={event.title}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<h3 className="font-display font-bold text-xl leading-none mb-2 uppercase">
|
||
{event.title}
|
||
</h3>
|
||
<p className="text-xs leading-snug opacity-80 line-clamp-4 mb-4">
|
||
{event.description}
|
||
</p>
|
||
|
||
{/* Date */}
|
||
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
|
||
{new Date(event.eventDate).toLocaleDateString('zh-CN')}
|
||
</div>
|
||
|
||
{/* Source Link */}
|
||
{event.sourceUrl && (
|
||
<a
|
||
href={event.sourceUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="absolute bottom-4 right-4 text-[10px] font-bold underline"
|
||
>
|
||
来源 →
|
||
</a>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Step 3: 创建 TimelineSection 组件**
|
||
|
||
```bash
|
||
vim src/components/timeline/TimelineSection.tsx
|
||
```
|
||
|
||
```typescript
|
||
import { AIEvent } from '@prisma/client';
|
||
import { EventCard } from './EventCard';
|
||
|
||
interface TimelineSectionProps {
|
||
year: number;
|
||
events: AIEvent[];
|
||
index: number;
|
||
}
|
||
|
||
export function TimelineSection({ year, events, index }: TimelineSectionProps) {
|
||
const isEven = index % 2 === 0;
|
||
|
||
return (
|
||
<section className={`relative min-h-[500px] mb-32 flex ${isEven ? 'flex-row' : 'flex-row-reverse'}`}>
|
||
{/* Year Label */}
|
||
<div className={`absolute ${isEven ? 'left-0' : 'right-0'} -top-8 z-20`}>
|
||
<div
|
||
className={`${isEven ? 'bg-primary' : 'bg-secondary'} border-4 border-black px-6 py-2 ${isEven ? '-rotate-2' : 'rotate-2'} shadow-hard`}
|
||
>
|
||
<span className="font-display font-black text-3xl md:text-5xl">
|
||
{year}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Events Container */}
|
||
<div
|
||
className={`w-full ${isEven ? 'pl-0 md:pl-8 pr-0 md:pr-32' : 'pl-0 md:pl-32 pr-0 md:pr-8'} pt-20`}
|
||
>
|
||
<div className="flex flex-nowrap overflow-x-visible items-center justify-start">
|
||
{events.map((event, eventIndex) => (
|
||
<EventCard
|
||
key={event.id}
|
||
event={event}
|
||
index={eventIndex}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Step 4: 重构 Timeline Page 使用新组件**
|
||
|
||
更新 `src/app/[locale]/timeline/page.tsx`:
|
||
|
||
```typescript
|
||
import { getAIEvents } from '@/hooks/useAIEvents';
|
||
import { TimelineSection } from '@/components/timeline/TimelineSection';
|
||
import { Metadata } from 'next';
|
||
|
||
export const revalidate = 3600;
|
||
|
||
export async function generateMetadata({
|
||
params,
|
||
}: {
|
||
params: Promise<{ locale: string }>;
|
||
}): Promise<Metadata> {
|
||
const { locale } = await params;
|
||
|
||
return {
|
||
title: locale === 'zh' ? 'AI 发展时间轴' : 'AI Timeline',
|
||
description: locale === 'zh'
|
||
? '探索人工智能大语言模型的发展历程'
|
||
: 'Explore the evolution of AI large language models',
|
||
};
|
||
}
|
||
|
||
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>);
|
||
|
||
const sortedYears = Object.keys(eventsByYear)
|
||
.map(Number)
|
||
.sort((a, b) => b - a);
|
||
|
||
if (events.length === 0) {
|
||
return (
|
||
<div className="min-h-screen bg-background-light dark:bg-background-dark flex items-center justify-center">
|
||
<div className="text-center">
|
||
<h1 className="font-display font-black text-4xl mb-4">
|
||
暂无数据
|
||
</h1>
|
||
<p className="font-mono text-gray-600">
|
||
时间轴数据正在收集中...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background-light dark:bg-background-dark">
|
||
<header className="pt-32 pb-16 px-4 max-w-[1600px] mx-auto">
|
||
<div className="text-center">
|
||
<h1 className="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white mb-6">
|
||
AI 发展时间轴
|
||
</h1>
|
||
<p className="font-mono text-lg md:text-xl max-w-2xl mx-auto bg-white dark:bg-black border border-black dark:border-white p-4 shadow-hard">
|
||
从 Transformer 到 AGI: 大语言模型的进化之路
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="px-4 md:px-12 max-w-[1600px] mx-auto pb-32">
|
||
{sortedYears.map((year, index) => (
|
||
<TimelineSection
|
||
key={year}
|
||
year={year}
|
||
events={eventsByYear[year]}
|
||
index={index}
|
||
/>
|
||
))}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Step 5: 提交**
|
||
|
||
```bash
|
||
git add src/components/timeline/
|
||
git commit -m "refactor: extract EventCard and TimelineSection components"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 更新导航菜单
|
||
|
||
**文件:**
|
||
- Modify: `src/components/layout/Header.tsx`
|
||
|
||
**Step 1: 打开 Header 组件**
|
||
|
||
```bash
|
||
vim src/components/layout/Header.tsx
|
||
```
|
||
|
||
**Step 2: 找到导航链接部分**
|
||
|
||
搜索 `/projects` 或导航相关的代码。
|
||
|
||
**Step 3: 添加 Timeline 链接**
|
||
|
||
在导航菜单中添加:
|
||
|
||
```typescript
|
||
<Link
|
||
href="/timeline"
|
||
className="hover:text-primary transition-colors uppercase decoration-2 underline-offset-4 hover:underline"
|
||
>
|
||
Timeline
|
||
</Link>
|
||
```
|
||
|
||
**Step 4: 保存并提交**
|
||
|
||
```bash
|
||
git add src/components/layout/Header.tsx
|
||
git commit -m "feat: add Timeline link to navigation"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 6: 测试
|
||
|
||
### Task 11: 使用 chrome-devtools-mcp 测试
|
||
|
||
**文件:**
|
||
- N/A (手动测试流程)
|
||
|
||
**Step 1: 确保开发服务器运行**
|
||
|
||
```bash
|
||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||
pnpm dev
|
||
```
|
||
|
||
**Step 2: 使用 chrome-devtools-mcp 工具测试**
|
||
|
||
在另一个对话中执行以下操作(或记录为测试文档):
|
||
|
||
```markdown
|
||
# Timeline 页面 E2E 测试流程
|
||
|
||
## 1. 页面加载测试
|
||
|
||
使用 `mcp__chrome-devtools__new_page`:
|
||
- URL: `http://localhost:3000/timeline`
|
||
- 验证: 页面成功加载
|
||
|
||
使用 `mcp__chrome-devtools__take_snapshot`:
|
||
- 验证: 页面结构正确,包含 header 和 timeline sections
|
||
|
||
## 2. 数据渲染测试
|
||
|
||
使用 `mcp__chrome-devtools__evaluate_script`:
|
||
```javascript
|
||
() => {
|
||
const yearSections = document.querySelectorAll('.year-section');
|
||
return {
|
||
yearCount: yearSections.length,
|
||
hasEvents: yearSections.length > 0
|
||
};
|
||
}
|
||
```
|
||
Expected: `{ yearCount: >0, hasEvents: true }`
|
||
|
||
## 3. 控制台错误检查
|
||
|
||
使用 `mcp__chrome-devtools__list_console_messages`:
|
||
- Expected: 无错误或警告
|
||
|
||
## 4. 响应式测试
|
||
|
||
使用 `mcp__chrome-devtools__resize_page`:
|
||
- 测试尺寸: 375x667 (iPhone SE)
|
||
- 测试尺寸: 1920x1080 (桌面)
|
||
- 验证: 布局在不同尺寸下正常显示
|
||
|
||
## 5. 视觉回归测试
|
||
|
||
使用 `mcp__chrome-devtools__take_screenshot`:
|
||
- 保存截图与设计原型对比
|
||
- 验证: 视觉风格符合 Neo-brutalism 设计
|
||
```
|
||
|
||
**Step 3: 创建测试文档**
|
||
|
||
```bash
|
||
cat > docs/e2e-testing-guide.md << 'EOF'
|
||
# Timeline E2E 测试指南
|
||
|
||
## 使用 chrome-devtools-mcp 测试
|
||
|
||
### 1. 启动测试环境
|
||
|
||
\`\`\`bash
|
||
# 确保开发服务器运行
|
||
pnpm dev
|
||
\`\`\`
|
||
|
||
### 2. 页面加载测试
|
||
|
||
\`\`\`javascript
|
||
// new_page
|
||
{
|
||
"url": "http://localhost:3000/timeline"
|
||
}
|
||
|
||
// take_snapshot
|
||
// 验证页面结构正确
|
||
\`\`\`
|
||
|
||
### 3. 数据验证
|
||
|
||
\`\`\`javascript
|
||
// evaluate_script
|
||
() => {
|
||
const yearSections = document.querySelectorAll('section');
|
||
const eventCards = document.querySelectorAll('.stack-card');
|
||
|
||
return {
|
||
yearCount: yearSections.length,
|
||
eventCount: eventCards.length,
|
||
hasHeader: document.querySelector('h1') !== null
|
||
};
|
||
}
|
||
\`\`\`
|
||
|
||
Expected:
|
||
- yearCount > 0
|
||
- eventCount > 0
|
||
- hasHeader: true
|
||
|
||
### 4. 无控制台错误
|
||
|
||
\`\`\`javascript
|
||
// list_console_messages
|
||
{
|
||
"types": ["error", "warn"]
|
||
}
|
||
\`\`\`
|
||
|
||
Expected: 空数组
|
||
|
||
### 5. 截图对比
|
||
|
||
\`\`\`javascript
|
||
// take_screenshot
|
||
{
|
||
"filePath": "tests/screenshots/timeline-page.png"
|
||
}
|
||
\`\`\`
|
||
|
||
手动对比与 `design/stitch_agent_park_homepage/screen.png`
|
||
|
||
### 6. 响应式测试
|
||
|
||
\`\`\`javascript
|
||
// resize_page
|
||
{
|
||
"width": 375,
|
||
"height": 667
|
||
}
|
||
|
||
// take_snapshot
|
||
// 验证移动端布局
|
||
\`\`\`
|
||
EOF
|
||
|
||
git add docs/e2e-testing-guide.md
|
||
git commit -m "docs: add E2E testing guide with chrome-devtools-mcp"
|
||
```
|
||
|
||
**Step 4: 执行测试并记录结果**
|
||
|
||
根据测试结果修复发现的问题。
|
||
|
||
**Step 5: 提交测试结果**
|
||
|
||
```bash
|
||
# 如果有修复
|
||
git add .
|
||
git commit -m "fix: address issues found during E2E testing"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 7: n8n Workflow 配置
|
||
|
||
### Task 12: 设计历史数据初始化 Workflow
|
||
|
||
**文件:**
|
||
- Create: `docs/n8n/historical-workflow-design.json`
|
||
|
||
**Step 1: 创建 n8n workflow 文档目录**
|
||
|
||
```bash
|
||
mkdir -p docs/n8n
|
||
```
|
||
|
||
**Step 2: 编写历史数据初始化 Workflow 设计**
|
||
|
||
```bash
|
||
cat > docs/n8n/historical-workflow-design.md << 'EOF'
|
||
# n8n 历史数据初始化 Workflow
|
||
|
||
## 概述
|
||
|
||
此 workflow 用于一次性收集和初始化 2017-2025 年的 AI 重大事件数据。
|
||
|
||
## Workflow 结构
|
||
|
||
### Node 1: Cron 触发器(手动触发)
|
||
|
||
- 节点类型: `Manual Trigger`
|
||
- 用途: 开发测试时手动运行
|
||
|
||
### Node 2: 设置年份列表
|
||
|
||
- 节点类型: `Code`
|
||
- 用途: 定义要处理的年份列表
|
||
|
||
\`\`\`javascript
|
||
// 返回年份数组
|
||
return [
|
||
{ year: 2017 },
|
||
{ year: 2018 },
|
||
{ year: 2019 },
|
||
{ year: 2020 },
|
||
{ year: 2021 },
|
||
{ year: 2022 },
|
||
{ year: 2023 },
|
||
{ year: 2024 },
|
||
{ year: 2025 },
|
||
];
|
||
\`\`\`
|
||
|
||
### Node 3: 搜索 Agent(循环每年)
|
||
|
||
- 节点类型: `Loop Over Items`
|
||
- 用途: 遍历每个年份
|
||
|
||
### Node 4: Web Search - Agent 1
|
||
|
||
- 节点类型: `HTTP Request`
|
||
- 方法: POST
|
||
- URL: `<web-search-mcp-endpoint>`
|
||
- Headers:
|
||
\`\`\`json
|
||
{
|
||
"Content-Type": "application/json"
|
||
}
|
||
\`\`\`
|
||
- Body:
|
||
\`\`\`json
|
||
{
|
||
"search_query": "AI breakthrough {{ $json.year }} LLM release transformer model",
|
||
"search_recency_filter": "noLimit",
|
||
"content_size": "high"
|
||
}
|
||
\`\`\`
|
||
|
||
### Node 5: 筛选 Agent - Agent 2
|
||
|
||
- 节点类型: `Code`
|
||
- 用途: 根据权威来源筛选
|
||
|
||
\`\`\`javascript
|
||
const trustedDomains = [
|
||
'arxiv.org',
|
||
'openai.com',
|
||
'anthropic.com',
|
||
'google.ai',
|
||
'meta.ai',
|
||
'deepmind.com',
|
||
'research.google',
|
||
];
|
||
|
||
const items = $input.all();
|
||
|
||
const filtered = items.filter(item => {
|
||
const url = item.json.url || '';
|
||
return trustedDomains.some(domain => url.includes(domain));
|
||
});
|
||
|
||
return filtered;
|
||
\`\`\`
|
||
|
||
### Node 6: 格式化 Agent - Agent 3
|
||
|
||
- 节点类型: `Code`
|
||
- 用途: 转换为 API 格式
|
||
|
||
\`\`\`javascript
|
||
const items = $input.all();
|
||
|
||
const formatted = items.map(item => {
|
||
const publishedDate = item.json.published_date || new Date().toISOString();
|
||
|
||
return {
|
||
json: {
|
||
title: item.json.title || 'Untitled',
|
||
eventDate: new Date(publishedDate).toISOString(),
|
||
description: (item.json.description || item.json.snippet || '').substring(0, 500),
|
||
imageUrl: item.json.image_url || 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: item.json.url,
|
||
},
|
||
};
|
||
});
|
||
|
||
return formatted;
|
||
\`\`\`
|
||
|
||
### Node 7: 提交到 API
|
||
|
||
- 节点类型: `HTTP Request`
|
||
- 方法: POST
|
||
- URL: `http://localhost:3000/api/events`
|
||
- Headers:
|
||
\`\`\`json
|
||
{
|
||
"Content-Type": "application/json",
|
||
"X-API-Key": "={{ $env.WEBHOOK_API_KEY }}"
|
||
}
|
||
\`\`\`
|
||
- Body: `={{ $json }}` (发送整个数组)
|
||
|
||
### Node 8: 错误处理
|
||
|
||
- 节点类型: `IF`
|
||
- 条件: 检查上一个节点的 status code
|
||
- On True: 记录成功
|
||
- On False: 发送错误邮件
|
||
|
||
## 环境变量
|
||
|
||
在 n8n 中设置:
|
||
- `WEBHOOK_API_KEY`: 你的 API 密钥(从 .env.local 获取)
|
||
- `API_ENDPOINT`: `http://localhost:3000/api/events` (开发) 或生产 URL
|
||
|
||
## 测试步骤
|
||
|
||
1. 在 n8n UI 中创建此 workflow
|
||
2. 手动触发运行
|
||
3. 检查数据库: `pnpm prisma studio`
|
||
4. 验证事件已正确创建
|
||
EOF
|
||
```
|
||
|
||
**Step 3: 创建增量更新 Workflow 设计**
|
||
|
||
```bash
|
||
cat > docs/n8n/incremental-workflow-design.md << 'EOF'
|
||
# n8n 增量更新 Workflow
|
||
|
||
## 概述
|
||
|
||
此 workflow 每周一自动运行,收集最近 7 天的新 AI 事件。
|
||
|
||
## Workflow 结构
|
||
|
||
### Node 1: Cron 触发器
|
||
|
||
- 节点类型: `Cron`
|
||
- 表达式: `0 9 * * 1` (每周一早上 9:00)
|
||
- 时区: Asia/Shanghai
|
||
|
||
### Node 2: Web Search - Agent 1
|
||
|
||
- 节点类型: `HTTP Request`
|
||
- URL: `<web-search-mcp-endpoint>`
|
||
- Body:
|
||
\`\`\`json
|
||
{
|
||
"search_query": "AI news LLM release model launch this week",
|
||
"search_recency_filter": "oneWeek"
|
||
}
|
||
\`\`\`
|
||
|
||
### Node 3: 筛选 Agent - Agent 2
|
||
|
||
- 节点类型: `Code`
|
||
- 用途: 筛选 + 去重(查询数据库避免重复)
|
||
|
||
\`\`\`javascript
|
||
const trustedDomains = [
|
||
'arxiv.org',
|
||
'openai.com',
|
||
'anthropic.com',
|
||
];
|
||
|
||
// 过滤权威来源
|
||
const items = $input.all();
|
||
const filtered = items.filter(item => {
|
||
const url = item.json.url || '';
|
||
return trustedDomains.some(domain => url.includes(domain));
|
||
});
|
||
|
||
// TODO: 添加数据库查询去重
|
||
// 这里可以调用 GET /api/events 检查 sourceUrl 是否已存在
|
||
|
||
return filtered;
|
||
\`\`\`
|
||
|
||
### Node 4: 格式化 Agent - Agent 3
|
||
|
||
- 节点类型: `Code`
|
||
- 代码: 同历史 workflow
|
||
|
||
### Node 5: 提交到 API
|
||
|
||
- 节点类型: `HTTP Request`
|
||
- 配置: 同历史 workflow
|
||
|
||
### Node 6: 发送通知邮件
|
||
|
||
- 节点类型: `Send Email`
|
||
- 条件: 仅在创建新事件时发送
|
||
- 内容:
|
||
\`\`\`
|
||
主题: AI Timeline - 新事件已添加
|
||
|
||
本次更新添加了 {{ $json.created }} 个新事件。
|
||
|
||
查看: https://your-domain.com/timeline
|
||
\`\`\`
|
||
|
||
### Node 7: 错误处理
|
||
|
||
- 节点类型: `Error Trigger`
|
||
- 动作: 发送错误邮件到管理员
|
||
|
||
## 测试
|
||
|
||
1. 修改 Cron 为手动触发进行测试
|
||
2. 验证只有新事件被添加
|
||
3. 检查邮件通知是否正常发送
|
||
4. 确认错误处理工作正常
|
||
EOF
|
||
```
|
||
|
||
**Step 4: 提交 n8n workflow 设计文档**
|
||
|
||
```bash
|
||
git add docs/n8n/
|
||
git commit -m "docs: add n8n workflow designs for AI timeline"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: 实现历史数据初始化(手动执行)
|
||
|
||
**文件:**
|
||
- N/A (手动操作 + 脚本)
|
||
|
||
**Step 1: 准备历史事件数据**
|
||
|
||
创建 `scripts/seed-historical-events.ts`:
|
||
|
||
```bash
|
||
mkdir -p scripts
|
||
vim scripts/seed-historical-events.ts
|
||
```
|
||
|
||
**Step 2: 编写种子数据脚本**
|
||
|
||
```typescript
|
||
import { PrismaClient } from '@prisma/client';
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
const historicalEvents = [
|
||
{
|
||
title: 'Attention Is All You Need',
|
||
eventDate: new Date('2017-06-12T00:00:00Z'),
|
||
description: 'Google 团队发表 Transformer 论文,提出自注意力机制,彻底改变 NLP 领域',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://arxiv.org/abs/1706.03762',
|
||
},
|
||
{
|
||
title: 'GPT-1 发布',
|
||
eventDate: new Date('2018-06-11T00:00:00Z'),
|
||
description: 'OpenAI 发布第一代生成式预训练 Transformer 模型,展示无监督学习的潜力',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://s3-us-west-2.amazonaws.com/openai-assets/research-covers/language-unsupervised/language-understanding-paper.pdf',
|
||
},
|
||
{
|
||
title: 'BERT 发布',
|
||
eventDate: new Date('2018-10-11T00:00:00Z'),
|
||
description: 'Google 发布双向编码器表示 Transformer,在 11 项 NLP 任务中创 SOTA',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://arxiv.org/abs/1810.04805',
|
||
},
|
||
{
|
||
title: 'GPT-2 发布',
|
||
eventDate: new Date('2019-02-14T00:00:00Z'),
|
||
description: 'OpenAI 发布 15 亿参数的 GPT-2,因"太危险"而不敢全部发布',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://openai.com/research/better-language-models',
|
||
},
|
||
{
|
||
title: 'GPT-3 发布',
|
||
eventDate: new Date('2020-05-28T00:00:00Z'),
|
||
description: 'OpenAI 发布 1750 亿参数的 GPT-3,展示 few-shot 学习的强大能力',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://arxiv.org/abs/2005.14165',
|
||
},
|
||
{
|
||
title: 'GitHub Copilot 发布',
|
||
eventDate: new Date('2021-06-29T00:00:00Z'),
|
||
description: 'GitHub 和 OpenAI 发布 AI 编程助手,基于 Codex 模型',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://github.blog/news-insights/company-news/github-copilot/',
|
||
},
|
||
{
|
||
title: 'ChatGPT 发布',
|
||
eventDate: new Date('2022-11-30T00:00:00Z'),
|
||
description: 'OpenAI 发布对话式 AI 助手 ChatGPT,5 天用户突破 100 万',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://openai.com/blog/chatgpt',
|
||
},
|
||
{
|
||
title: 'GPT-4 发布',
|
||
eventDate: new Date('2023-03-14T00:00:00Z'),
|
||
description: 'OpenAI 发布多模态大语言模型 GPT-4,在各项基准测试中接近人类水平',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://openai.com/research/gpt-4',
|
||
},
|
||
{
|
||
title: 'Claude 发布',
|
||
eventDate: new Date('2023-03-16T00:00:00Z'),
|
||
description: 'Anthropic 发布 AI 助手 Claude,强调安全性和有用性',
|
||
imageUrl: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||
sourceUrl: 'https://www.anthropic.com/index/claude-now-open',
|
||
},
|
||
];
|
||
|
||
async function main() {
|
||
console.log('开始插入历史事件...');
|
||
|
||
for (const event of historicalEvents) {
|
||
try {
|
||
await prisma.aIEvent.create({
|
||
data: event,
|
||
});
|
||
console.log(`✓ ${event.title} (${event.eventDate.getFullYear()})`);
|
||
} catch (error) {
|
||
console.log(`✗ ${event.title} 已存在或插入失败`);
|
||
}
|
||
}
|
||
|
||
console.log('\n历史事件插入完成!');
|
||
}
|
||
|
||
main()
|
||
.catch(console.error)
|
||
.finally(() => prisma.$disconnect());
|
||
```
|
||
|
||
**Step 3: 运行种子脚本**
|
||
|
||
```bash
|
||
npx tsx scripts/seed-historical-events.ts
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
开始插入历史事件...
|
||
✓ Attention Is All You Need (2017)
|
||
✓ GPT-1 发布 (2018)
|
||
...
|
||
历史事件插入完成!
|
||
```
|
||
|
||
**Step 4: 验证数据**
|
||
|
||
访问: http://localhost:3000/timeline
|
||
|
||
Expected: 显示历史事件,按年份分组
|
||
|
||
**Step 5: 提交种子脚本**
|
||
|
||
```bash
|
||
git add scripts/seed-historical-events.ts
|
||
git commit -m "feat: add historical events seed script"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 8: 部署准备
|
||
|
||
### Task 14: 准备生产环境
|
||
|
||
**文件:**
|
||
- Modify: `.env.local` (仅本地)
|
||
- Update Vercel 环境变量
|
||
|
||
**Step 1: 验证所有环境变量**
|
||
|
||
```bash
|
||
# 本地开发
|
||
cat .env.local | grep -E "(DATABASE_URL|WEBHOOK_API_KEY)"
|
||
|
||
# 应该看到:
|
||
# DATABASE_URL=postgres://...
|
||
# WEBHOOK_API_KEY=your-test-key
|
||
```
|
||
|
||
**Step 2: 在 Vercel 设置环境变量**
|
||
|
||
访问: https://vercel.com/your-project/settings/environment-variables
|
||
|
||
确保已设置:
|
||
- `DATABASE_URL`: 生产数据库 URL (Neon)
|
||
- `WEBHOOK_API_KEY`: 生产 API 密钥(强密码,至少 32 字符)
|
||
|
||
**Step 3: 准备生产数据库迁移**
|
||
|
||
```bash
|
||
# 生成迁移 SQL
|
||
pnpm prisma migrate diff \
|
||
--from-empty \
|
||
--to-schema-datamodel prisma/schema.prisma \
|
||
--script > migration.sql
|
||
```
|
||
|
||
**Step 4: 提交部署准备文档**
|
||
|
||
```bash
|
||
cat > docs/deployment-guide.md << 'EOF'
|
||
# 生产环境部署指南
|
||
|
||
## 前置条件
|
||
|
||
- [x] Vercel 项目已配置
|
||
- [x] Neon 数据库已连接
|
||
- [x] WEBHOOK_API_KEY 环境变量已设置
|
||
|
||
## 部署步骤
|
||
|
||
### 1. 运行数据库迁移
|
||
|
||
\`\`\`bash
|
||
# 方式 1: 使用 Prisma push
|
||
pnpm prisma db push --preview-feature
|
||
|
||
# 方式 2: 在 Neon SQL Editor 执行 migration.sql
|
||
\`\`\`
|
||
|
||
### 2. 部署到 Vercel
|
||
|
||
\`\`\`bash
|
||
git push origin feature/ai-timeline
|
||
# 或通过 PR 合并到 main
|
||
\`\`\`
|
||
|
||
### 3. 验证部署
|
||
|
||
- 访问生产 URL: `https://your-domain.com/timeline`
|
||
- 测试 API: `curl https://your-domain.com/api/events`
|
||
- 检查 Vercel 日志确认无错误
|
||
|
||
### 4. 配置 n8n Workflow
|
||
|
||
更新 n8n 中的环境变量:
|
||
- `WEBHOOK_API_KEY`: 生产密钥
|
||
- `API_ENDPOINT`: `https://your-domain.com/api/events`
|
||
|
||
### 5. 运行历史数据初始化
|
||
|
||
手动触发 n8n 历史初始化 workflow
|
||
|
||
### 6. 验证增量更新
|
||
|
||
- 修改 Cron 为手动触发测试增量 workflow
|
||
- 确认新事件正确添加
|
||
- 恢复 Cron 为每周一自动运行
|
||
|
||
## 回滚计划
|
||
|
||
如有问题:
|
||
1. 在 Vercel 回滚到上一个部署
|
||
2. 数据库更改使用 Prisma migrate rollback
|
||
EOF
|
||
|
||
git add docs/deployment-guide.md
|
||
git commit -m "docs: add production deployment guide"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 9: 最终测试和清理
|
||
|
||
### Task 15: 完整功能测试
|
||
|
||
**文件:**
|
||
- Create: `docs/testing-checklist.md`
|
||
|
||
**Step 1: 创建测试清单**
|
||
|
||
```bash
|
||
cat > docs/testing-checklist.md << 'EOF'
|
||
# AI Timeline 功能测试清单
|
||
|
||
## 数据库测试
|
||
|
||
- [x] AIEvent 表创建成功
|
||
- [x] 索引正确配置(eventDate 降序)
|
||
- [x] Prisma Client 生成无错误
|
||
|
||
## API 测试
|
||
|
||
- [x] POST /api/events - 单个事件创建
|
||
- [x] POST /api/events - 批量事件创建
|
||
- [x] POST /api/events - API Key 认证正常
|
||
- [x] POST /api/events - 数据验证工作正常
|
||
- [x] GET /api/events - 返回所有事件
|
||
- [x] GET /api/events?year=2023 - 年份筛选正常
|
||
- [x] GET /api/events?limit=10 - 分页正常
|
||
|
||
## 前端测试
|
||
|
||
- [x] /timeline 页面可访问
|
||
- [x] 事件按年份正确分组
|
||
- [x] 年份按降序显示
|
||
- [x] 卡片堆叠样式正确
|
||
- [x] Hover 动画正常
|
||
- [x] 深色模式支持
|
||
- [x] 移动端响应式布局
|
||
- [x] 导航菜单 Timeline 链接可点击
|
||
|
||
## 数据测试
|
||
|
||
- [x] 历史事件种子数据插入成功
|
||
- [x] 2017-2025 每年都有事件
|
||
- [x] 事件数据完整性(标题、日期、描述、图片)
|
||
- [x] ISR 缓存正常工作
|
||
|
||
## n8n Workflow 测试
|
||
|
||
- [ ] 历史初始化 workflow 测试通过
|
||
- [ ] 增量更新 workflow 测试通过
|
||
- [ ] API 调用成功
|
||
- [ ] 错误处理正常工作
|
||
- [ ] 邮件通知配置完成
|
||
|
||
## E2E 测试(chrome-devtools-mcp)
|
||
|
||
- [ ] 页面加载无控制台错误
|
||
- [ ] 数据正确渲染
|
||
- [ ] 视觉对比原型设计
|
||
- [ ] 响应式测试通过
|
||
|
||
## 性能测试
|
||
|
||
- [ ] ISR 缓存生效(1小时)
|
||
- [ ] 页面加载速度 < 2s
|
||
- [ ] 图片加载优化
|
||
- [ ] 数据库查询性能
|
||
|
||
## 安全测试
|
||
|
||
- [ ] API Key 认证有效
|
||
- [ ] 无 SQL 注入风险
|
||
- [ ] XSS 防护
|
||
- [ ] CORS 配置正确
|
||
EOF
|
||
```
|
||
|
||
**Step 2: 执行完整测试**
|
||
|
||
逐项检查并完成测试清单。
|
||
|
||
**Step 3: 修复发现的问题**
|
||
|
||
根据测试结果进行必要的修复。
|
||
|
||
**Step 4: 提交最终代码**
|
||
|
||
```bash
|
||
cd /Users/caihaohan/Code/agent_park-timeline
|
||
git add .
|
||
git commit -m "feat: complete AI timeline feature implementation"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 10: 合并到主分支
|
||
|
||
### Task 16: 合并 Worktree
|
||
|
||
**文件:**
|
||
- N/A (git 操作)
|
||
|
||
**Step 1: 切换回主仓库**
|
||
|
||
```bash
|
||
cd /Users/caihaohan/Code/agent_park
|
||
```
|
||
|
||
**Step 2: 拉取最新代码**
|
||
|
||
```bash
|
||
git fetch origin
|
||
git checkout main
|
||
git pull origin main
|
||
```
|
||
|
||
**Step 3: 合并 feature 分支**
|
||
|
||
```bash
|
||
# 方式 1: 使用 worktree
|
||
cd /Users/caihaohan/Code/agent_park-timeline
|
||
git push origin feature/ai-timeline
|
||
|
||
# 然后在 GitHub 创建 PR 或:
|
||
cd /Users/caihaohan/Code/agent_park
|
||
git merge feature/ai-timeline
|
||
```
|
||
|
||
**Step 4: 删除 worktree**
|
||
|
||
```bash
|
||
git worktree remove ../agent_park-timeline
|
||
git branch -D feature/ai-timeline
|
||
```
|
||
|
||
**Step 5: 最终提交**
|
||
|
||
```bash
|
||
git commit -m "merge: feature/ai-timeline - AI timeline implementation"
|
||
```
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
完成以上 16 个任务后,你将拥有:
|
||
|
||
✅ 完整的 AI 时间轴数据库 Schema
|
||
✅ RESTful API (创建、查询、筛选)
|
||
✅ 时间轴前端页面(按年份分组展示)
|
||
✅ 历史数据初始化脚本
|
||
✅ n8n workflow 设计文档
|
||
✅ 完整的测试覆盖
|
||
✅ 部署准备文档
|
||
|
||
**后续优化方向:**
|
||
- 添加搜索和筛选功能
|
||
- 支持用户互动(点赞、评论)
|
||
- 导出时间轴为 PDF/Markdown
|
||
- 添加更多历史事件
|
||
- 多语言支持完善
|
||
|
||
**关键文件清单:**
|
||
|
||
数据库:
|
||
- `prisma/schema.prisma` - AIEvent 模型
|
||
|
||
后端:
|
||
- `src/lib/validations.ts` - Zod 验证
|
||
- `src/hooks/useAIEvents.ts` - 数据获取
|
||
- `src/app/api/events/route.ts` - API 端点
|
||
|
||
前端:
|
||
- `src/app/[locale]/timeline/page.tsx` - 时间轴页面
|
||
- `src/components/timeline/EventCard.tsx` - 事件卡片组件
|
||
- `src/components/timeline/TimelineSection.tsx` - 年份区域组件
|
||
|
||
脚本:
|
||
- `scripts/seed-historical-events.ts` - 历史数据初始化
|
||
|
||
文档:
|
||
- `docs/n8n/historical-workflow-design.md`
|
||
- `docs/n8n/incremental-workflow-design.md`
|
||
- `docs/deployment-guide.md`
|
||
- `docs/testing-checklist.md`
|
||
- `docs/api-testing-guide.md`
|
||
- `docs/e2e-testing-guide.md`
|