feat: 新增项目标签重置接口与n8n流程

This commit is contained in:
2026-02-22 11:58:29 +08:00
parent 93c616793d
commit 285af9a039
45 changed files with 844 additions and 11506 deletions
+3
View File
@@ -9,6 +9,7 @@ node_modules
# Next.js
/.next/
/.next_backup_*/
/out/
# Production
@@ -61,6 +62,8 @@ tmp/
temp/
.cache/
.trending-workspace/
.omc/
.sisyphus/
# Package manager lock files (optional - uncomment if needed)
# package-lock.json
-551
View File
@@ -1,551 +0,0 @@
# AI 项目导航网站 - 下一阶段功能实施计划
**创建日期**: 2026-02-20
**计划版本**: 1.0
**预计工期**: 6-8 周
---
## 需求摘要
本计划涵盖两个并行推进的方向:
### 方向 A - 视觉与交互体验
提升用户体验的视觉优化,包括深浅主题切换、移动端适配、无障碍访问和布局优化。
### 方向 B - 质量控制体系
建立项目审核流程和质量评分机制,确保平台内容质量,包含审核状态工作流、质量评分、垃圾内容过滤和标签规范化。
---
## 验收标准(可测试)
### 方向 A 验收标准
- [ ] **A1**: 用户可通过切换按钮在深色/浅色主题间切换,选择持久化到 localStorage
- [ ] **A2**: 在 375px-768px-1440px 三个断点下,所有页面布局正确无溢出
- [ ] **A3**: 通过 Lighthouse 无障碍审计得分 >= 90
- [ ] **A4**: 所有交互元素可通过键盘访问(Tab 导航 + Enter/Space 激活)
- [ ] **A5**: 首屏加载 LCP <= 2.5s(移动端 4G 网络)
### 方向 B 验收标准
- [ ] **B1**: 新项目默认状态为 `PENDING_REVIEW`,管理员可将其变为 `APPROVED`/`REJECTED`
- [ ] **B2**: 项目详情页显示质量评分(0-100),评分依据内容完整度和标签准确性
- [ ] **B3**: 系统可自动标记疑似垃圾/低质项目(评分 < 30)
- [ ] **B4**: Tag Janitor API 支持批量标签规范化操作
- [ ] **B5**: 审核日志记录所有状态变更(谁、何时、从什么状态改为什么状态)
---
## 分阶段实施步骤
---
## 阶段 1:基础设施准备(Week 1)
### 1.1 数据库 Schema 扩展(方向 B)
**目标**: 为质量控制体系添加必要的数据库字段和模型
**涉及的文件**:
- `prisma/schema.prisma`
**具体变更**:
```prisma
// 新增:项目审核状态枚举
enum ReviewStatus {
PENDING_REVIEW // 待审核
APPROVED // 已批准
REJECTED // 已拒绝
FLAGGED // 已标记(可疑内容)
}
// 修改:Project 模型添加审核和质量字段
model Project {
// ... 现有字段 ...
// 审核相关
reviewStatus ReviewStatus @default(PENDING_REVIEW)
reviewedAt DateTime?
reviewedBy String? // 审核人标识(未来可关联用户系统)
// 质量评分
qualityScore Int? // 0-100 分
qualityFactors Json? // 评分因素明细
qualityUpdatedAt DateTime?
// 索引更新
@@index([reviewStatus], map: "idx_project_reviewStatus")
@@index([qualityScore], map: "idx_project_qualityScore")
}
// 新增:审核日志模型
model ReviewLog {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
fromStatus ReviewStatus
toStatus ReviewStatus
reason String? // 变更原因
reviewedBy String? // 审核人
createdAt DateTime @default(now())
@@index([projectId], map: "idx_reviewLog_projectId")
@@index([createdAt], map: "idx_reviewLog_createdAt")
@@map("review_logs")
}
```
**验收检查**:
```bash
pnpm prisma migrate dev --name add_review_system
pnpm prisma generate
```
---
### 1.2 主题系统基础(方向 A)
**目标**: 建立主题切换的客户端基础设施
**涉及的文件**:
- `src/components/theme/ThemeProvider.tsx` (新建)
- `src/components/theme/ThemeToggle.tsx` (新建)
- `src/app/layout.tsx`
- `src/hooks/useTheme.ts` (新建)
**具体实现要点**:
1. **ThemeProvider.tsx**: 创建客户端主题上下文
- 读取 localStorage 中的 `theme`
- 支持 `light``dark``system` 三种模式
- 通过 `next-themes` 或自定义 Context 实现
2. **ThemeToggle.tsx**: 主题切换按钮组件
- 显示当前主题图标(太阳/月亮/系统)
- 点击切换主题
- 适配 neo-brutalism 设计风格
3. **layout.tsx 修改**: 包装 ThemeProvider
```tsx
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
```
**验收检查**:
- 切换主题后刷新页面,主题保持不变
- 系统主题变化时自动跟随(当选择 `system` 模式)
---
## 阶段 2:核心功能实现(Week 2-3)
### 2.1 主题切换 UI 集成(方向 A)
**目标**: 将主题切换功能集成到网站导航栏
**涉及的文件**:
- `src/app/[locale]/layout.tsx`
- `src/components/theme/ThemeToggle.tsx`
- `src/messages/zh.json`
- `src/messages/en.json`
**具体变更**:
1. 在导航栏右侧添加 ThemeToggle 按钮(LocaleSwitcher 旁边)
2. 添加国际化文本:
```json
// zh.json
"theme": {
"light": "浅色模式",
"dark": "深色模式",
"system": "跟随系统"
}
// en.json
"theme": {
"light": "Light Mode",
"dark": "Dark Mode",
"system": "System"
}
```
3. 确保所有 `dark:` Tailwind 类正确应用
**验收检查**:
- [ ] 导航栏显示主题切换按钮
- [ ] 点击切换立即生效,无闪烁
- [ ] 两种主题下所有组件颜色正确
---
### 2.2 审核状态 API(方向 B
**目标**: 创建项目审核相关的 API 端点
**涉及的文件**:
- `src/lib/validations.ts`
- `src/app/api/admin/review/route.ts` (新建)
- `src/app/api/admin/review/service.ts` (新建)
- `src/hooks/useProjects.ts`
**具体实现**:
1. **validations.ts**: 添加审核相关 Schema
```typescript
export const ReviewStatusEnum = z.enum([
"PENDING_REVIEW", "APPROVED", "REJECTED", "FLAGGED"
]);
export const UpdateReviewStatusSchema = z.object({
apiKey: z.string().min(32),
projectId: z.string().min(1),
status: ReviewStatusEnum,
reason: z.string().max(500).optional(),
reviewedBy: z.string().max(100).optional(),
});
export const GetPendingReviewsQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(50).default(20),
offset: z.coerce.number().int().nonnegative().default(0),
sortBy: z.enum(['createdAt', 'qualityScore']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('asc'),
});
```
2. **route.ts**: 实现 PATCH 端点更新审核状态
3. **service.ts**: 业务逻辑
- 更新项目审核状态
- 创建审核日志记录
- 验证状态转换合法性
**验收检查**:
```bash
# 测试 API
curl -X PATCH http://localhost:3000/api/admin/review \
-H "Content-Type: application/json" \
-d '{"apiKey":"...","projectId":"xxx","status":"APPROVED"}'
```
---
### 2.3 质量评分系统(方向 B)
**目标**: 实现自动计算项目质量评分的算法
**涉及的文件**:
- `src/lib/quality-scorer.ts` (新建)
- `src/app/api/webhook/projects/route.ts`
- `src/lib/validations.ts`
**评分规则**(总分 100:
| 因素 | 分值 | 说明 |
|------|------|------|
| 描述长度 | 0-15 | 10-50字=5分,50-200字=10分,200-500字=15分 |
| 内容完整性 | 0-20 | 有 content=10分,content > 200字=+10分 |
| 标签数量 | 0-15 | 1-3个=5分,4-6个=10分,7-10个=15分 |
| 链接完整性 | 0-20 | 有 GITHUB=10分,有 WEBSITE=+5分,有其他=+5分 |
| 英文翻译 | 0-15 | nameEn 存在=5分,descriptionEn 存在=5分,contentEn 存在=5分 |
| 媒体丰富度 | 0-15 | 未来扩展(截图、视频等) |
**具体实现**:
```typescript
// quality-scorer.ts
export function calculateQualityScore(project: ProjectInput): QualityScoreResult {
const factors: QualityFactor[] = [];
// 描述长度评分
const descLength = project.description.length;
let descScore = 0;
if (descLength >= 10 && descLength < 50) descScore = 5;
else if (descLength >= 50 && descLength < 200) descScore = 10;
else if (descLength >= 200 && descLength <= 500) descScore = 15;
factors.push({ name: 'descriptionLength', score: descScore, maxScore: 15 });
// ... 其他评分逻辑
return {
totalScore: factors.reduce((sum, f) => sum + f.score, 0),
factors,
flagged: totalScore < 30, // 低质量标记
};
}
```
**验收检查**:
- [ ] 新项目通过 webhook 导入时自动计算评分
- [ ] 评分 < 30 的项目自动标记为 `FLAGGED`
---
## 阶段 3UI 完善(Week 4-5
### 3.1 移动端适配优化(方向 A)
**目标**: 优化移动端布局和交互体验
**涉及的文件**:
- `src/app/[locale]/layout.tsx`
- `src/components/layout/MobileMenu.tsx` (新建)
- `src/components/project/ProjectCard.tsx`
- `src/components/project/ProjectList.tsx`
- `src/app/[locale]/projects/[id]/page.tsx`
**具体变更**:
1. **MobileMenu.tsx**: 实现汉堡菜单
- 使用 Radix UI Dialog 或自定义实现
- 包含导航链接、语言切换、主题切换
- 平滑的打开/关闭动画
2. **layout.tsx**: 替换现有的简单按钮
- 在移动端显示 MobileMenu
- 添加 `aria-label` 和键盘支持
3. **ProjectCard.tsx**: 优化移动端布局
- 字体大小调整
- 触摸目标至少 44x44px
- 标签横向滚动或折叠
**验收检查**:
- [ ] Chrome DevTools 模拟 375px 宽度下无布局溢出
- [ ] 所有按钮/链接触摸区域 >= 44x44px
- [ ] 移动端菜单正常工作
---
### 3.2 无障碍访问优化(方向 A)
**目标**: 通过 Lighthouse 无障碍审计
**涉及的文件**:
- 所有组件文件
- `src/app/layout.tsx`
**具体变更**:
1. **语义化 HTML**:
- 使用 `<nav>`, `<main>`, `<article>`, `<section>`
- 表单控件关联 `<label>`
- 图片添加 `alt` 属性
2. **ARIA 增强**:
- 按钮添加 `aria-label`(图标按钮)
- 导航添加 `aria-current="page"`
- 模态框添加 `aria-modal`, `role="dialog"`
3. **键盘导航**:
- 焦点可见样式(`focus:ring-2`
- 跳过导航链接(Skip to content
- Tab 顺序合理
4. **色彩对比度**:
- 确保文本对比度 >= 4.5:1
- 大文本对比度 >= 3:1
**验收检查**:
```bash
# Lighthouse CLI 审计
npx lighthouse http://localhost:3000/zh --only-categories=accessibility --output=json
```
---
### 3.3 审核管理界面(方向 B)
**目标**: 创建管理员审核项目的页面(可选,可先用 API)
**涉及的文件**:
- `src/app/[locale]/admin/review/page.tsx` (新建)
- `src/app/[locale]/admin/review/ReviewList.tsx` (新建)
- `src/app/[locale]/admin/review/QualityBadge.tsx` (新建)
**具体实现**:
1. **ReviewList.tsx**: 待审核项目列表
- 显示项目基本信息、质量评分
- 批量操作(批准/拒绝)
- 筛选和排序
2. **QualityBadge.tsx**: 质量评分徽章
- 高分(>=80: 绿色
- 中分(50-79: 黄色
- 低分(<50: 红色
**验收检查**:
- [ ] 管理员可查看待审核项目列表
- [ ] 可单个/批量更新审核状态
- [ ] 显示质量评分和因素明细
---
## 阶段 4:集成与优化(Week 6)
### 4.1 标签规范化增强(方向 B)
**目标**: 增强 Tag Janitor API 支持更多规范化操作
**涉及的文件**:
- `src/app/api/tags/maintenance/service.ts`
- `src/lib/validations.ts`
**新增功能**:
1. **标签重命名**: 批量更新标签名称
2. **标签分类**: 为标签添加分类(技术/应用/状态)
3. **相似标签检测**: 基于名称相似度推荐合并
**Schema 扩展**:
```typescript
export const TagNormalizationSchema = z.object({
apiKey: z.string().min(32),
operations: z.array(z.union([
z.object({ type: z.literal('rename'), tagId: z.string(), newName: z.string(), newNameEn: z.string().optional() }),
z.object({ type: z.literal('categorize'), tagId: z.string(), category: z.enum(['tech', 'application', 'status']) }),
z.object({ type: z.literal('merge'), sourceIds: z.array(z.string()), targetId: z.string() }),
])),
});
```
---
### 4.2 性能优化(方向 A
**目标**: 优化首屏加载性能
**涉及的文件**:
- `next.config.js`
- `src/app/[locale]/page.tsx`
- `src/components/` 相关组件
**具体优化**:
1. **图片优化**:
- 使用 Next.js Image 组件
- 配置图片优先级和占位符
2. **代码分割**:
- 动态导入大型组件(如 MarkdownContent
- 使用 `loading.tsx` 实现页面级加载状态
3. **字体优化**:
- 预加载关键字体
- 使用 `font-display: swap`
**验收检查**:
```bash
# Lighthouse 性能审计
npx lighthouse http://localhost:3000/zh --only-categories=performance --output=json
```
---
## 阶段 5:测试与文档(Week 7-8)
### 5.1 单元测试
**涉及的文件**:
- `src/lib/quality-scorer.test.ts` (新建)
- `src/app/api/admin/review/service.test.ts` (新建)
- `src/components/theme/ThemeProvider.test.tsx` (新建)
**测试覆盖**:
- 质量评分算法边界情况
- 审核状态转换逻辑
- 主题切换持久化
---
### 5.2 E2E 测试
**涉及的文件**:
- `tests/e2e/theme-toggle.spec.ts` (新建)
- `tests/e2e/mobile-layout.spec.ts` (新建)
- `tests/e2e/review-workflow.spec.ts` (新建)
---
### 5.3 文档更新
**涉及的文件**:
- `CLAUDE.md`
- `README.md`
- `n8n-workflows/README.md`(如涉及 n8n 集成)
---
## 风险与缓解措施
| 风险 | 影响 | 可能性 | 缓解措施 |
|------|------|--------|----------|
| 主题切换导致样式不一致 | 高 | 中 | 建立完整的 dark: 类检查清单,使用 CSS 变量 |
| 审核流程复杂度超预期 | 中 | 中 | 先实现 API,管理界面可延后 |
| 质量评分算法不准确 | 中 | 中 | 先小规模测试,根据反馈迭代 |
| 移动端适配工作量大 | 中 | 高 | 优先核心页面(首页、项目列表、项目详情) |
| 无障碍改造影响现有设计 | 低 | 低 | 使用 Tailwind 的 focus: 类,不改变视觉设计 |
---
## 验证步骤
### 方向 A 验证清单
1. 运行 `pnpm build` 确保无构建错误
2. 运行 `pnpm lint` 确保无 ESLint 错误
3. Chrome DevTools 切换设备模拟器测试响应式
4. 运行 Lighthouse 审计(性能 + 无障碍)
5. 手动测试主题切换在所有页面正常工作
6. 键盘导航测试(仅使用 Tab/Enter/Space
### 方向 B 验证清单
1. 运行数据库迁移 `pnpm prisma migrate dev`
2. 测试审核 API 端点(curl 或 Postman
3. 验证质量评分计算逻辑(单元测试)
4. 测试 Tag Janitor API 新增功能
5. 审核日志正确记录状态变更
---
## 文件变更总览
### 新建文件
- `src/components/theme/ThemeProvider.tsx`
- `src/components/theme/ThemeToggle.tsx`
- `src/hooks/useTheme.ts`
- `src/lib/quality-scorer.ts`
- `src/app/api/admin/review/route.ts`
- `src/app/api/admin/review/service.ts`
- `src/components/layout/MobileMenu.tsx`
- `src/app/[locale]/admin/review/page.tsx`(可选)
- `src/app/[locale]/admin/review/ReviewList.tsx`(可选)
- `src/app/[locale]/admin/review/QualityBadge.tsx`(可选)
### 修改文件
- `prisma/schema.prisma`
- `src/app/layout.tsx`
- `src/app/[locale]/layout.tsx`
- `src/lib/validations.ts`
- `src/hooks/useProjects.ts`
- `src/components/project/ProjectCard.tsx`
- `src/components/project/ProjectList.tsx`
- `src/messages/zh.json`
- `src/messages/en.json`
- `tailwind.config.ts`(可能需要调整)
---
## 下一步行动
确认此计划后,运行以下命令开始实施:
```bash
/oh-my-claudecode:start-work next-phase-features
```
建议优先级:
1. **Phase 1.1** - 数据库 Schema 扩展(阻塞其他方向 B 任务)
2. **Phase 1.2** - 主题系统基础(阻塞其他方向 A 任务)
3. 之后两个方向可并行推进
-88
View File
@@ -1,88 +0,0 @@
# 暂停开发的功能
本目录存放暂停开发的功能的设计文档和计划,这些功能将在后续版本中继续开发。
## 目录结构
```
postponed-features/
├── README.md # 本文件
├── drop-tables.sql # 数据库表删除脚本
├── keyword-cloud/ # AI 词云功能
│ ├── 2026-01-25-keyword-cloud-system-design.md
│ └── 2026-01-25-keyword-cloud-implementation.md
├── timeline/ # AI 时间轴功能
│ ├── 2025-01-25-ai-timeline-feature-design.md
│ └── 2025-01-25-ai-timeline-implementation.md
├── hotpot cloud/ # 词云设计原型
└── timeline/ # 时间轴设计原型
```
## 暂停功能说明
### 1. AI 词云 (Keyword Cloud)
**功能描述**: 自动化采集 Google Trends 数据,展示季度 AI 热点词汇词云。
**暂停原因**: 优先级调整,后续版本继续开发。
**技术栈**:
- 前端: Next.js 15 + React + Tailwind CSS
- 后端: Prisma + PostgreSQL
- 自动化: n8n 工作流
- 数据源: Google Trends API
**数据库表**:
- `keywords` - 关键词数据
- `quarters` - 季度元数据
- `visual_style_rules` - 视觉样式规则
- `keyword_cloud_error_logs` - 错误日志
### 2. AI 时间轴 (AI Timeline)
**功能描述**: 展示 AI 大语言模型的发展历程,从 2017 年 Transformer 到今天。
**暂停原因**: 优先级调整,后续版本继续开发。
**技术栈**:
- 前端: Next.js 15 + React + Tailwind CSS
- 后端: Prisma + PostgreSQL
**数据库表**:
- `ai_events` - AI 事件数据
### 3. 博客 (Blog)
**功能描述**: 博客文章发布和管理系统。
**状态**: 未实现,仅保留了导航链接占位。
**暂停原因**: 优先级调整,后续版本继续开发。
## 恢复开发指南
当需要恢复这些功能时:
1. **恢复数据库表**:
```bash
# 参考 drop-tables.sql 中的表结构定义
# 在 prisma/schema.prisma 中恢复对应的模型
pnpm prisma migrate dev --name restore-postponed-features
```
2. **恢复代码**:
- 从 git 历史中恢复相关代码文件
- 或参考设计文档重新实现
3. **恢复国际化消息**:
- 在 `src/messages/zh.json` 和 `src/messages/en.json` 中添加对应的命名空间
4. **恢复导航链接**:
- 在国际化消息的 `navigation` 部分添加对应的导航项
## 相关文档
- [AI 词云系统设计](./keyword-cloud/2026-01-25-keyword-cloud-system-design.md)
- [AI 词云实现文档](./keyword-cloud/2026-01-25-keyword-cloud-implementation.md)
- [AI 时间轴功能设计](./timeline/2025-01-25-ai-timeline-feature-design.md)
- [AI 时间轴实现文档](./timeline/2025-01-25-ai-timeline-implementation.md)
@@ -1,45 +0,0 @@
-- =====================================================
-- 暂停功能的数据库表删除脚本
-- 创建时间: 2026-02-21
-- 说明: 删除 AI 词云和 AI 时间轴相关的数据库表
-- 警告: 执行前请确保已备份相关数据
-- =====================================================
-- ================================
-- AI 词云相关表
-- ================================
-- 删除关键词表 (依赖 quarters 表)
DROP TABLE IF EXISTS keywords CASCADE;
-- 删除季度元数据表
DROP TABLE IF EXISTS quarters CASCADE;
-- 删除视觉样式规则表
DROP TABLE IF EXISTS visual_style_rules CASCADE;
-- 删除词云错误日志表
DROP TABLE IF EXISTS keyword_cloud_error_logs CASCADE;
-- ================================
-- AI 时间轴相关表
-- ================================
-- 删除 AI 事件表
DROP TABLE IF EXISTS ai_events CASCADE;
-- ================================
-- 清理相关索引 (PostgreSQL 会自动删除)
-- ================================
-- 注意: 以下索引会随表删除自动清理
-- idx_keyword_quarterId
-- idx_keyword_trendScore
-- idx_keyword_word
-- idx_quarter_displayOrder
-- idx_quarter_quarter
-- idx_visualStyleRule_enabled
-- idx_visualStyleRule_scoreRange
-- idx_keywordCloudErrorLog_errorType
-- idx_keywordCloudErrorLog_quarter
-- ai_events 的 eventDate 和 createdAt 索引
@@ -1,323 +0,0 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Agent Park: AI Keyword Evolution</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&amp;family=Work+Sans:wght@400;600;800&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
primary: "#FFD700", // Bright MotherDuck Yellow
secondary: "#7FB5FF", // Soft Blue
accent: "#C39BD3", // Purple
"background-light": "#F7F5F0", // Warm off-white
"background-dark": "#1A1A1A", // Dark charcoal
"border-dark": "#000000",
},
fontFamily: {
display: ["'Space Mono'", "monospace"],
body: ["'Work Sans'", "sans-serif"],
},
borderRadius: {
DEFAULT: "4px",
},
boxShadow: {
'hard': '4px 4px 0px 0px rgba(0,0,0,1)',
'hard-sm': '2px 2px 0px 0px rgba(0,0,0,1)',
}
},
},
};
</script>
<style type="text/tailwindcss">
.geometric-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
opacity: 0.15;
}
.shape {
position: absolute;
border: 2px solid currentColor;
opacity: 0.3;
}
.shape-sq { width: 40px; height: 40px; }
.shape-tri {
width: 0; height: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
border-bottom: 35px solid currentColor;
background: transparent;
border-top: none;
}
.shape-line { height: 2px; width: 60px; background: currentColor; border: none; }
.cloud-word {
@apply transition-all duration-200 cursor-pointer inline-block whitespace-nowrap relative;
}.popover {
@apply absolute bottom-[calc(100%+12px)] left-1/2 -translate-x-1/2 w-48 bg-white dark:bg-gray-800 border-2 border-black shadow-hard-sm opacity-0 pointer-events-none transition-all duration-300 translate-y-2 z-50 text-left;
}
.cloud-word:hover .popover {
@apply opacity-100 translate-y-0 pointer-events-auto;
}
.popover::after {
content: '';
@apply absolute -bottom-2 left-1/2 -translate-x-1/2 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-black;
}
.word-cluster {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: center;
max-width: 100%;
}
::-webkit-scrollbar { width: 12px }
::-webkit-scrollbar-track { background: #f1f1f1; border-left: 2px solid black }
::-webkit-scrollbar-thumb { background: #FFD700; border: 2px solid black }
.nav-button {
@apply flex items-center justify-center w-12 h-12 md:w-16 md:h-16 bg-white dark:bg-gray-800 border-4 border-black shadow-hard hover:translate-y-0.5 hover:shadow-none transition-all cursor-pointer;
}
.progress-step {
@apply h-3 flex-1 border-2 border-black transition-colors duration-300;
}
</style>
</head>
<body class="bg-background-light dark:bg-background-dark text-black dark:text-gray-100 font-body transition-colors duration-300 min-h-screen relative overflow-x-hidden">
<div class="geometric-bg text-black dark:text-white">
<div class="shape shape-sq top-[10%] left-[5%] rotate-12"></div>
<div class="shape shape-tri top-[25%] left-[85%] rotate-45"></div>
<div class="shape shape-line top-[45%] left-[15%] -rotate-12"></div>
<div class="shape shape-sq top-[65%] left-[90%] -rotate-6"></div>
<div class="shape shape-tri top-[80%] left-[10%] rotate-[160deg]"></div>
<div class="shape shape-line top-[15%] left-[50%] rotate-90"></div>
<div class="shape shape-sq top-[85%] left-[55%] rotate-45"></div>
<div class="shape shape-line top-[70%] left-[30%] rotate-45"></div>
<div class="shape shape-tri top-[5%] left-[70%] -rotate-12"></div>
</div>
<nav class="sticky top-0 z-[100] w-full border-b-2 border-black bg-primary dark:bg-yellow-600">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<div class="flex items-center gap-3">
<div class="h-8 w-8 bg-black rounded-full flex items-center justify-center text-white font-bold border-2 border-white">AP</div>
<span class="font-display font-bold text-xl tracking-tighter uppercase">Agent Park</span>
</div>
<div class="hidden md:flex space-x-8 font-display font-bold text-sm">
<a class="underline decoration-4 underline-offset-4" href="#">Navigator</a>
<a class="hover:underline decoration-2 underline-offset-4" href="#">DuckDB Stats</a>
<a class="hover:underline decoration-2 underline-offset-4" href="#">Manifesto</a>
</div>
<button class="bg-white dark:bg-gray-800 border-2 border-black shadow-hard-sm px-4 py-1 font-display font-bold text-sm hover:translate-y-0.5 hover:shadow-none transition-all dark:text-white">
JOIN THE FLOCK
</button>
</div>
</div>
</nav>
<header class="relative z-10 pt-16 pb-8 text-center max-w-4xl mx-auto px-4">
<div class="inline-block bg-accent dark:bg-purple-700 border-2 border-black px-3 py-1 font-display font-bold text-xs mb-4 shadow-hard-sm rotate-[-2deg]">
EXPERIMENTAL DATA
</div>
<h1 class="font-display text-5xl md:text-7xl font-bold mb-6 tracking-tighter leading-tight">
THE EVOLUTION OF <br/>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-purple-500" style="-webkit-text-stroke: 1.5px black;">AI DISCOURSE</span>
</h1>
<p class="text-lg md:text-xl max-w-2xl mx-auto font-medium text-gray-700 dark:text-gray-300 mb-8">
From "Large Language Models" to "Agentic Workflows". Use the arrows below to explore the shifting narrative.
</p>
</header>
<main class="relative z-10 max-w-6xl mx-auto px-4 pb-32 overflow-visible">
<div class="bg-white/50 dark:bg-black/20 backdrop-blur-sm border-4 border-black p-8 md:p-12 shadow-hard relative overflow-visible">
<div class="flex gap-2 mb-12 max-w-md mx-auto">
<div class="progress-step bg-primary"></div>
<div class="progress-step bg-gray-200 dark:bg-gray-700"></div>
<div class="progress-step bg-gray-200 dark:bg-gray-700"></div>
<div class="progress-step border-dashed bg-transparent border-gray-400"></div>
</div>
<div class="flex flex-col md:flex-row items-center justify-between gap-8 mb-12">
<button class="nav-button order-2 md:order-1 opacity-50 cursor-not-allowed">
<span class="material-symbols-outlined text-4xl">chevron_left</span>
</button>
<div class="text-center order-1 md:order-2 flex-1">
<div class="inline-block bg-primary border-4 border-black px-10 py-4 shadow-hard font-display font-bold text-4xl md:text-5xl mb-4 rotate-1">
Q1 2023
</div>
<p class="font-display text-lg text-gray-600 dark:text-gray-400 italic">"The dawn of the chat interface."</p>
</div>
<button class="nav-button order-3">
<span class="material-symbols-outlined text-4xl">chevron_right</span>
</button>
</div>
<div class="relative py-12 px-4">
<div class="word-cluster">
<span class="cloud-word border-4 border-black bg-secondary px-6 py-3 rounded-full text-4xl md:text-5xl font-black shadow-hard rotate-1">
ChatGPT
<div class="popover">
<div class="bg-primary text-black font-display font-bold p-2 border-b-2 border-black text-sm uppercase">ChatGPT</div>
<div class="p-3 space-y-2 text-xs font-medium dark:text-gray-100">
<div class="flex gap-2 items-start"><span>Mass consumer breakout</span></div>
<div class="flex gap-2 items-start"><span>GPT-3.5 architecture</span></div>
<div class="flex gap-2 items-start"><span>Conversational UI focus</span></div>
</div>
</div>
</span>
<span class="cloud-word border-2 border-black bg-white dark:bg-gray-700 px-4 py-2 rounded-lg text-xl md:text-2xl font-bold -rotate-2">
Parameters
<div class="popover">
<div class="bg-accent text-white font-display font-bold p-2 border-b-2 border-black text-sm uppercase">Parameters</div>
<div class="p-3 space-y-2 text-xs font-medium dark:text-gray-100">
<div class="flex gap-2 items-start"><span>Internal model weights</span></div>
<div class="flex gap-2 items-start"><span>Scaling law metric</span></div>
</div>
</div>
</span>
<span class="cloud-word border-4 border-black bg-accent text-white px-7 py-3 rounded-full text-2xl md:text-3xl font-bold shadow-hard-sm rotate-3">
Hallucination
<div class="popover">
<div class="bg-white text-black font-display font-bold p-2 border-b-2 border-black text-sm uppercase">Hallucination</div>
<div class="p-3 space-y-2 text-xs font-medium text-black">
<div class="flex gap-2 items-start"><span>Factually incorrect output</span></div>
<div class="flex gap-2 items-start"><span>Stochastic behavior</span></div>
<div class="flex gap-2 items-start"><span>Trust barrier</span></div>
</div>
</div>
</span>
<span class="cloud-word border-2 border-black bg-gray-100 dark:bg-gray-600 px-3 py-1 rounded text-lg">
Transformer
<div class="popover">
<div class="bg-secondary text-black font-display font-bold p-2 border-b-2 border-black text-sm uppercase">Transformer</div>
<div class="p-3 space-y-2 text-xs font-medium dark:text-gray-100">
<div class="flex gap-2 items-start"><span>Attention mechanism</span></div>
<div class="flex gap-2 items-start"><span>Parallel processing</span></div>
</div>
</div>
</span>
<span class="cloud-word border-4 border-black bg-white dark:bg-gray-700 px-6 py-3 rounded-xl text-3xl md:text-4xl font-semibold -rotate-1">
Prompt Engineering
<div class="popover">
<div class="bg-primary text-black font-display font-bold p-2 border-b-2 border-black text-sm uppercase">Prompting</div>
<div class="p-3 space-y-2 text-xs font-medium dark:text-gray-100">
<div class="flex gap-2 items-start"><span>Instruction crafting</span></div>
<div class="flex gap-2 items-start"><span>Few-shot prompting</span></div>
<div class="flex gap-2 items-start"><span>Iterative tuning</span></div>
</div>
</div>
</span>
<span class="cloud-word border-2 border-black bg-white dark:bg-gray-700 px-4 py-2 rounded-full text-xl">
OpenAI
<div class="popover w-40">
<div class="bg-black text-white font-display font-bold p-2 border-b-2 border-black text-xs uppercase">The Lab</div>
<div class="p-2 space-y-1 text-[10px] font-medium dark:text-gray-100">
<div>• Research pioneer</div>
<div>• Closed-source shift</div>
</div>
</div>
</span>
<span class="cloud-word border-2 border-black bg-primary px-5 py-2 rounded-md text-2xl font-bold rotate-6">
LLM
<div class="popover">
<div class="bg-white text-black font-display font-bold p-2 border-b-2 border-black text-sm uppercase">LLM</div>
<div class="p-3 space-y-2 text-xs font-medium text-black">
<div class="flex gap-2 items-start"><span>Large Language Model</span></div>
<div class="flex gap-2 items-start"><span>Next token prediction</span></div>
</div>
</div>
</span>
<span class="cloud-word border-2 border-black bg-white dark:bg-gray-700 px-3 py-1 rounded text-base">RLHF</span>
<span class="cloud-word border-4 border-black bg-secondary px-5 py-2.5 rounded-full text-3xl font-bold -rotate-3">Bard</span>
<span class="cloud-word border-2 border-black bg-white dark:bg-gray-700 px-4 py-2 rounded-lg text-xl">Foundation Model</span>
<span class="cloud-word border-2 border-black bg-accent text-white px-5 py-2 rounded-full text-2xl shadow-hard-sm">Emergence</span>
<span class="cloud-word border-2 border-black bg-gray-100 px-6 py-2 rounded-xl text-2xl font-black rotate-1">Tokenization</span>
<span class="cloud-word border-2 border-black bg-white px-4 py-1.5 rounded text-lg -rotate-6">Context Window</span>
<span class="cloud-word border-2 border-black bg-primary px-5 py-2 rounded-full text-2xl font-bold">GPT-4</span>
<span class="cloud-word border-4 border-black bg-secondary px-6 py-3 rounded text-3xl font-bold shadow-hard-sm">Few-Shot</span>
<span class="cloud-word border-2 border-black bg-white px-3 py-1.5 rounded-md text-base">Zero-Shot</span>
<span class="cloud-word border-2 border-black bg-accent/20 px-4 py-2 rounded-full text-xl italic">Multi-turn</span>
<span class="cloud-word border-2 border-black bg-white px-4 py-2 rounded-lg text-xl font-bold rotate-2">Supervised Learning</span>
</div>
<div class="absolute -top-16 -right-8 md:right-0 bg-black text-white p-5 rounded-xl text-sm md:text-base font-display w-64 shadow-hard rotate-6 hidden lg:block">
"ChatGPT" search volume peaked +400% in March 2023!
<div class="absolute -bottom-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-black transform rotate-45"></div>
</div>
<div class="absolute -bottom-10 -left-10 w-24 h-24 bg-primary rounded-full border-4 border-black flex items-center justify-center font-bold font-display text-xs text-center shadow-hard rotate-[-15deg] z-20">
GENESIS<br/>OF CHAT
</div>
</div>
</div>
<section class="relative mt-24">
<div class="text-center mb-10">
<div class="inline-block bg-white dark:bg-gray-700 border-4 border-black border-dashed px-8 py-4 font-display font-bold text-3xl text-gray-500 transform -rotate-1">
WHAT'S NEXT?
</div>
</div>
<div class="max-w-3xl mx-auto bg-gray-100 dark:bg-gray-900 border-4 border-black border-dashed p-10 rounded-3xl relative opacity-80 hover:opacity-100 transition-opacity shadow-xl">
<h3 class="font-display font-bold text-2xl text-center text-gray-500 uppercase tracking-widest mb-8">Future Prediction Zone</h3>
<div class="word-cluster text-gray-400 italic">
<span class="text-4xl">???</span>
<span class="text-xl">AGI?</span>
<span class="text-2xl">Small Language Models</span>
<span class="text-3xl">On-Device AI</span>
<span class="text-xl">Liquid Neural Nets</span>
<span class="text-2xl">Sovereign AI</span>
<span class="text-xl">World Models</span>
</div>
<div class="text-center mt-12">
<button class="bg-primary text-black border-4 border-black px-8 py-3 font-bold font-display text-lg hover:bg-yellow-500 shadow-hard transform hover:-translate-y-1 transition-all">
Submit a Prediction
</button>
</div>
</div>
</section>
</main>
<footer class="bg-black text-white border-t-4 border-primary py-16 relative overflow-hidden z-[100]">
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(#ffffff 1px, transparent 1px); background-size: 20px 20px;"></div>
<div class="max-w-7xl mx-auto px-4 relative z-10">
<div class="grid md:grid-cols-4 gap-12 mb-16">
<div class="col-span-1 md:col-span-2">
<div class="flex items-center gap-3 mb-6">
<div class="h-10 w-10 bg-primary rounded-full flex items-center justify-center text-black font-bold border-2 border-white text-xl">AP</div>
<h2 class="font-display font-bold text-3xl">AGENT PARK</h2>
</div>
<p class="font-body text-gray-400 max-w-md text-lg leading-relaxed">
Curating the chaos of the AI revolution, one keyword at a time. Built with 💛 by the ducklings.
</p>
</div>
<div>
<h4 class="font-display font-bold text-primary mb-6 text-lg uppercase tracking-widest">EXPLORE</h4>
<ul class="space-y-3 font-body">
<li><a class="hover:text-primary transition-colors text-gray-300" href="#">The Archive</a></li>
<li><a class="hover:text-primary transition-colors text-gray-300" href="#">Trends Dashboard</a></li>
<li><a class="hover:text-primary transition-colors text-gray-300" href="#">Contributor Guide</a></li>
</ul>
</div>
<div>
<h4 class="font-display font-bold text-primary mb-6 text-lg uppercase tracking-widest">CONNECT</h4>
<div class="flex space-x-4">
<a class="w-10 h-10 bg-white text-black rounded-lg flex items-center justify-center hover:bg-primary transition-colors border-2 border-black shadow-hard-sm" href="#">
<span class="font-bold">X</span>
</a>
<a class="w-10 h-10 bg-white text-black rounded-lg flex items-center justify-center hover:bg-primary transition-colors border-2 border-black shadow-hard-sm" href="#">
<span class="font-bold">Li</span>
</a>
<a class="w-10 h-10 bg-white text-black rounded-lg flex items-center justify-center hover:bg-primary transition-colors border-2 border-black shadow-hard-sm" href="#">
<span class="font-bold">Gh</span>
</a>
</div>
</div>
</div>
<div class="border-t border-gray-800 pt-8 flex flex-col md:flex-row justify-between items-center text-sm font-mono text-gray-500">
<p>© 2024 Agent Park. All rights reserved.</p>
<div class="mt-4 md:mt-0 flex gap-8">
<span class="hover:text-white cursor-pointer transition-colors">Privacy Policy</span>
<span class="hover:text-white cursor-pointer transition-colors">Terms of Quack</span>
</div>
</div>
</div>
</footer>
</body></html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

@@ -1,716 +0,0 @@
# 季度 AI 热点词云系统设计文档
**创建日期**: 2026-01-25
**功能类型**: 数据驱动可视化
**技术栈**: n8n + Next.js + PostgreSQL + Prisma
---
## 一、功能概述
季度 AI 热点词云是一个**自动化数据驱动的可视化词云系统**,通过 n8n 工作流从 Google Trends 采集 AI 相关热点词汇,经 AI 清洗和规则引擎处理后,自动入库并在前端展示。
### 核心目标
1. **内容营销**: 吸引访客回访查看每季度更新,提供社交分享素材
2. **教育参考**: 帮助新手理解 AI 技术演进历程
3. **数据洞察**: 展示 AI 领域热点变化趋势
### 用户旅程
1. 用户访问 `/keyword-cloud` 页面
2. 看到当前季度的 AI 热点词云(如 2024-Q1
3. 鼠标悬停在词汇上,查看详细描述和要点
4. 点击左右箭头切换不同季度,浏览历史热点
5. 可视化展示:词汇大小代表搜索热度,颜色代表分类
---
## 二、系统架构
### 架构图
```
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Google │ │ n8n │ │ PostgreSQL │
│ Trends API │───▶│ Workflow │───▶│ Database │
└─────────────┘ └─────────────┘ └──────────────┘
┌─────────────┐
│ Next.js │
│ Frontend │
└─────────────┘
```
### 数据流向
1. **定时触发**: n8n Schedule 每季度末自动执行
2. **数据采集**: Google Trends API 获取热门搜索词
3. **AI 清洗**: 过滤无关词汇,生成描述和要点
4. **规则匹配**: 根据热度分数分配视觉样式
5. **数据入库**: 写入 PostgreSQL 数据库
6. **前端展示**: Next.js 从数据库读取并渲染词云
---
## 三、数据库模型
### 3.1 Quarter 表(季度元数据)
```prisma
model Quarter {
id Int @id @default(autoincrement())
quarter String @unique // "2023-Q1", "2023-Q2"
title String @db.Text // "2023年第一季度"
titleEn String? @db.Text // "Q1 2023"
subtitle String? @db.Text // "聊天界面的黎明"
subtitleEn String? @db.Text // "The dawn of chat interface"
displayOrder Int @default(0) // 前端排序
isActive Boolean @default(true) // 是否显示
keywords Keyword[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([quarter])
@@index([displayOrder])
}
```
### 3.2 Keyword 表(关键词核心数据)
```prisma
model Keyword {
id Int @id @default(autoincrement())
word String // "ChatGPT"
trendScore Int // 0-100, 从 Google Trends 获取
// 外键关联
quarterId Int
quarter Quarter @relation(fields: [quarterId], references: [id], onDelete: Cascade)
// 内容字段(支持中英双语)
description String @db.Text // AI 生成的一句话描述
descriptionEn String? @db.Text // 英文描述
detailPoints Json // JSON 数组: ["要点1", "要点2", "要点3"]
detailPointsEn Json? // 英文版要点
// 视觉样式配置
visualConfig Json // {color, size, rotation, border}
// 元数据
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([quarterId])
@@index([trendScore])
@@index([word])
}
```
**visualConfig 字段结构示例**:
```json
{
"color": "secondary",
"size": "text-5xl",
"rotation": "rotate-1",
"border": "border-4"
}
```
### 3.3 VisualStyleRule 表(视觉样式规则配置)
```prisma
model VisualStyleRule {
id Int @id @default(autoincrement())
name String @unique // "热门大词-金色"
// 分数区间
minScore Int // 90
maxScore Int // 100
// 视觉属性
color String // "primary", "secondary", "accent"
size String // "text-5xl", "text-3xl", "text-xl"
border String // "border-4", "border-2"
rotation String? // "rotate-1", "rotate-2", null
// 控制
priority Int @default(0) // 优先级,分数重叠时按优先级
enabled Boolean @default(true) // 是否启用
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([enabled])
@@index([minScore, maxScore])
}
```
### 3.4 KeywordCloudErrorLog 表(错误日志)
```prisma
model KeywordCloudErrorLog {
id Int @id @default(autoincrement())
quarter String // "2023-Q1"
keyword String? // "ChatGPT"
errorType String // "INVALID_DATA", "API_ERROR", "DB_ERROR"
errorMessage String @db.Text // 详细错误信息
rawData Json? // 原始数据便于调试
createdAt DateTime @default(now())
@@index([quarter])
@@index([errorType])
}
```
---
## 四、n8n 工作流设计
### 4.1 工作流概览
5 个核心节点实现从数据采集到入库的完整流程。
### 4.2 节点详细配置
#### 节点 1: Schedule Trigger(定时触发)
- **类型**: `n8n-nodes-base.scheduleTrigger`
- **Cron 表达式**: `0 0 23 28-31 * *` (每季度末最后一天的 23:00)
- **月份判断**: Function 节点检查当前月份(3/6/9/12),如果不是则跳过
- **输出**: 当前季度的标识(如 "2024-Q1"
#### 节点 2: Google Trends 采集
- **类型**: `@gamal.dev/n8n-nodes-google-trends`
- **配置参数**:
- `keywords`: ["AI", "artificial intelligence", "machine learning", "GPT", "LLM"]
- `timeRange`: 当前季度的 3 个月(如 2024-01-01 to 2024-03-31
- `category`: "Science > Computer Science > AI"
- `geo`: "GB"
- **输出示例**:
```json
{
"keyword": "ChatGPT",
"trendScore": 95,
"rising": true
}
```
#### 节点 3: AI 清洗和内容生成
- **类型**: `@n8n/n8n-nodes-langchain.lmChatChain`
- **Prompt 模板**:
```
你是一个 AI 领域专家。以下是 Google Trends 采集的热门关键词列表:
{{ $json.all() }}
任务:
1. 过滤掉与 AI/机器学习无关的关键词
2. 为每个关键词生成中文描述(10-50字)
3. 生成 3 条详细要点(每条 10-30 字,客观描述,避免营销用语)
输出格式(JSON 数组):
[
{
"word": "ChatGPT",
"trendScore": 95,
"description": "OpenAI 开发的对话式人工智能助手",
"detailPoints": ["支持多轮对话", "基于 GPT-3.5 架构", "2023年用户突破1亿"]
}
]
```
#### 节点 4: 规则引擎匹配
- **类型**: `n8n-nodes-base.code`
- **逻辑**:
1. HTTP Request 获取 `VisualStyleRule` 表数据
- 方法: GET
- URL: `{{ $env.API_URL }}/api/keyword-cloud/rules`
2. 按 `priority` 排序规则
3. 遍历每个关键词,匹配第一个符合的规则(`minScore <= trendScore <= maxScore`
4. 将视觉配置注入数据
#### 节点 5: 批量写入数据库
- **类型**: `n8n-nodes-base.httpRequest`
- **方法**: POST
- **URL**: `{{ $env.API_URL }}/api/keyword-cloud/keywords`
- **认证**: Bearer Token(环境变量 `API_KEY`
- **Body**:
```json
{
"quarter": "2024-Q1",
"keywords": [
{
"word": "ChatGPT",
"trendScore": 95,
"description": "...",
"detailPoints": ["...", "...", "..."],
"visualConfig": {...}
}
]
}
```
- **批量处理**: 超过 20 个关键词时分批提交
### 4.3 错误处理
- 每个节点设置 `continueOnFail: true`
- 错误日志写入 `KeywordCloudErrorLog` 表
- 关键错误发送通知(Email/Slack
---
## 五、API 端点设计
### 5.1 GET /api/keyword-cloud/quarters
获取季度列表。
**查询参数**:
- `isActive` (可选): 只返回激活的季度
**响应示例**:
```json
{
"quarters": [
{
"id": 1,
"quarter": "2024-Q1",
"title": "2024年第一季度",
"titleEn": "Q1 2024",
"subtitle": "聊天界面的黎明",
"subtitleEn": "The dawn of chat interface",
"displayOrder": 0,
"keywordCount": 15
}
]
}
```
**排序**: 按 `displayOrder` ASC
### 5.2 GET /api/keyword-cloud/keywords/[quarter]
获取指定季度的关键词列表。
**路径参数**:
- `quarter`: 季度标识(如 "2024-Q1"
**响应示例**:
```json
{
"quarter": "2024-Q1",
"title": "2024年第一季度",
"keywords": [
{
"id": 1,
"word": "ChatGPT",
"trendScore": 95,
"description": "OpenAI 开发的对话式 AI 助手",
"detailPoints": ["支持多轮对话", "基于 GPT-3.5", "2023用户破亿"],
"visualConfig": {
"color": "secondary",
"size": "text-5xl",
"border": "border-4",
"rotation": "rotate-1"
}
}
]
}
```
**排序**: 按 `trendScore` DESC
### 5.3 POST /api/keyword-cloud/keywords
n8n 工作流写入关键词数据。
**认证**: Bearer Token (WEBHOOK_API_KEY)
**请求体**:
```json
{
"quarter": "2024-Q1",
"keywords": [
{
"word": "ChatGPT",
"trendScore": 95,
"description": "...",
"detailPoints": ["...", "...", "..."],
"visualConfig": {...}
}
]
}
```
**响应**:
```json
{
"success": true,
"created": 15,
"failed": 2,
"errors": [
{ "word": "Invalid", "error": "trendScore out of range" }
]
}
```
**逻辑**:
1. 验证 API Key
2. 查找或创建 `Quarter` 记录
3. 批量创建 `Keyword` 记录(Prisma `createMany`
4. 失败记录写入 `KeywordCloudErrorLog` 表
5. 返回成功/失败统计
### 5.4 GET /api/keyword-cloud/rules
获取视觉样式规则配置(n8n 规则引擎使用)。
**查询参数**:
- `enabled` (可选): 只返回启用的规则
**响应示例**:
```json
{
"rules": [
{
"id": 1,
"name": "热门大词-金色",
"minScore": 90,
"maxScore": 100,
"visualConfig": {
"color": "primary",
"size": "text-5xl",
"border": "border-4",
"rotation": "rotate-1"
},
"priority": 0,
"enabled": true
}
]
}
```
**排序**: 按 `priority` ASC
### 5.5 POST /api/keyword-cloud/rules
创建新的视觉样式规则(管理员功能)。
**请求体**: 同单个规则对象
**验证**:
- `minScore < maxScore`
- 必填字段检查
- 颜色值必须是预定义的颜色类别
---
## 六、前端组件设计
### 6.1 路由结构
```
src/app/[locale]/keyword-cloud/
├── page.tsx # 主页面
└── components/
├── KeywordCloud.tsx # 词云容器组件
├── CloudWord.tsx # 单个词汇组件
├── QuarterNavigator.tsx # 季度切换导航
├── WordPopover.tsx # 弹出框详情
└── ProgressIndicator.tsx # 进度条
```
### 6.2 核心组件
#### KeywordCloud.tsx
词云容器组件,负责数据获取和布局。
```typescript
interface KeywordCloudProps {
quarter: string;
}
function KeywordCloud({ quarter }: KeywordCloudProps) {
const { data, isLoading } = useKeywordData(quarter);
return (
<div className="bg-white/50 dark:bg-black/20 backdrop-blur-sm border-4 border-black p-8 md:p-12 shadow-hard">
<ProgressIndicator currentQuarter={quarter} totalQuarters={4} />
<QuarterNavigator current={quarter} />
<div className="word-cluster py-12">
{data?.keywords.map((keyword) => (
<CloudWord key={keyword.id} data={keyword} />
))}
</div>
<FunFactBubble fact={data.funFact} />
</div>
);
}
```
#### CloudWord.tsx
单个词汇组件,应用视觉样式和悬停交互。
```typescript
interface CloudWordProps {
data: Keyword;
}
function CloudWord({ data }: CloudWordProps) {
const { word, visualConfig, description, detailPoints } = data;
const { color, size, border, rotation } = visualConfig;
const colorClass = colorMap[color];
return (
<span className={cn(
"cloud-word",
border,
"border-black",
colorClass,
"px-6 py-3",
"rounded-full",
size,
"font-black",
"shadow-hard",
rotation,
"transition-all",
"hover:scale-105",
"cursor-pointer"
)}>
{word}
<WordPopover title={word} description={description} points={detailPoints} />
</span>
);
}
```
#### WordPopover.tsx
弹出框组件,显示词汇的详细信息。
```typescript
interface WordPopoverProps {
title: string;
description: string;
points: string[];
}
function WordPopover({ title, description, points }: WordPopoverProps) {
return (
<div className="popover">
<div className="bg-primary text-black font-display font-bold p-2 border-b-2 border-black text-sm uppercase">
{title}
</div>
<div className="p-3 space-y-2 text-xs font-medium dark:text-gray-100">
<div className="flex gap-2 items-start">
<span>•</span>
<span>{description}</span>
</div>
{points.map((point, i) => (
<div key={i} className="flex gap-2 items-start">
<span>•</span>
<span>{point}</span>
</div>
))}
</div>
</div>
);
}
```
#### QuarterNavigator.tsx
季度切换导航组件。
```typescript
function QuarterNavigator({ current }: { current: string }) {
const quarters = ["2023-Q1", "2023-Q2", "2023-Q3", "2023-Q4"];
const currentIndex = quarters.indexOf(current);
return (
<div className="flex items-center justify-between gap-8">
<button
disabled={currentIndex === 0}
className="nav-button"
onClick={() => navigate(quarters[currentIndex - 1])}
>
<span className="material-symbols-outlined text-4xl">chevron_left</span>
</button>
<div className="text-center">
<div className="bg-primary border-4 border-black px-10 py-4 shadow-hard font-display font-bold text-5xl">
{current}
</div>
</div>
<button
disabled={currentIndex === quarters.length - 1}
className="nav-button"
onClick={() => navigate(quarters[currentIndex + 1])}
>
<span className="material-symbols-outlined text-4xl">chevron_right</span>
</button>
</div>
);
}
```
### 6.3 数据获取
```typescript
// src/hooks/useKeywordCloud.ts
export async function getKeywordData(quarter: string) {
const response = await fetch(`${API_URL}/api/keyword-cloud/keywords/${quarter}`);
return response.json();
}
```
### 6.4 样式系统
复用项目现有的 Tailwind 配置和样式类:
- `.cloud-word`: 词云词汇的基础样式
- `.popover`: 弹出框样式(包括箭头)
- `.word-cluster`: 词汇容器布局
- `.nav-button`: 导航按钮样式
响应式断点:`md:`, `lg:`
---
## 七、错误处理和监控
### 7.1 分层错误处理
**n8n 工作流层**:
- 每个节点 `continueOnFail: true`
- 失败记录写入 `KeywordCloudErrorLog` 表
- 关键错误发送通知
**API 层**:
- Zod schema 验证
- 部分成功响应模式
- HTTP 状态码规范
**前端层**:
- 友好的错误提示
- Error Boundary
- 重试机制
### 7.2 监控策略
- n8n 执行日志监控
- 定期检查 `KeywordCloudErrorLog` 表
- API 响应时间监控
- 前端错误追踪
---
## 八、测试策略
### 8.1 单元测试(Vitest
- API 路由处理逻辑
- 规则引擎匹配算法
- 数据验证 schemas
### 8.2 E2E 测试
- 季度切换功能
- 弹出框交互
- 响应式布局
### 8.3 n8n 工作流测试
- 使用测试环境 API 手动触发
- 验证生成的数据质量
- 检查规则匹配结果
---
## 九、部署指南
### 9.1 环境变量
```bash
# .env.local
DATABASE_URL="..."
WEBHOOK_API_KEY="..."
N8N_WEBHOOK_URL="https://your-n8n-instance.com/..."
```
### 9.2 数据库迁移
```bash
pnpm prisma migrate dev --name add_keyword_cloud_tables
```
### 9.3 n8n 部署
1. 使用自托管 n8n 或 n8n Cloud
2. 配置环境变量(API_URL, API_KEY
3. 设置 Cron 定时任务
4. 测试工作流执行
### 9.4 初始化数据
1. 创建第一个 `Quarter` 记录(2023-Q1
2. 配置 3-5 条 `VisualStyleRule`:
- 90-100: 热门大词(金色, text-5xl, border-4
- 70-89: 中等词汇(蓝色, text-3xl, border-2
- 50-69: 小词汇(紫色, text-xl, border-2
- 0-49: 长尾词(灰色, text-base, border-2
### 9.5 监控设置
- n8n 执行日志告警
- 错误日志定期检查
- API 性能监控
---
## 十、未来扩展
1. **预测功能**: 基于历史数据预测下一个热点词汇
2. **趋势分析**: 展示词汇热度的季度变化曲线
3. **用户贡献**: 允许用户提交词汇建议
4. **多维度**: 按技术栈、应用领域等维度分类
5. **导出功能**: 导出季度报告(PDF/图片)
---
## 附录
### A. 颜色系统
```typescript
const colorMap = {
primary: "bg-primary", // Gold (#FFD700)
secondary: "bg-secondary", // Blue (#7FB5FF)
accent: "bg-accent", // Purple (#C39BD3)
gray: "bg-gray-100", // Gray
};
```
### B. 字体大小映射
```typescript
const sizeMap = {
hot: "text-5xl", // 90-100 分
medium: "text-3xl", // 70-89 分
small: "text-xl", // 50-69 分
tiny: "text-base", // 0-49 分
};
```
### C. 参考资源
- Google Trends API: https://trends.google.com/
- n8n 文档: https://docs.n8n.io/
- Tailwind CSS: https://tailwindcss.com/
@@ -1,494 +0,0 @@
# 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
File diff suppressed because it is too large Load Diff
@@ -1,284 +0,0 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Agent Park - AI History Timeline Pinboard</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,container-queries"></script>
<link href="https://fonts.googleapis.com" rel="preconnect"/>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&amp;family=Syne:wght@400;700;800&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
primary: "#FFDE03",
secondary: "#FF90E8",
"background-light": "#F9F7F2",
"background-dark": "#121212",
"surface-light": "#FFFFFF",
"surface-dark": "#1E1E1E",
},
fontFamily: {
display: ["'Syne'", "sans-serif"],
mono: ["'Space Mono'", "monospace"],
},
boxShadow: {
'hard': '4px 4px 0px 0px #000000',
'hard-hover': '8px 8px 0px 0px #000000',
'hard-dark': '4px 4px 0px 0px #FFFFFF',
'card-stack': '2px 2px 0px 0px #000, 4px 4px 0px 0px #000, 6px 6px 0px 0px #000',
},
backgroundImage: {
'grid-pattern': "linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px)",
'grid-pattern-dark': "linear-gradient(to right, #333 1px, transparent 1px), linear-gradient(to bottom, #333 1px, transparent 1px)",
}
},
},
};
</script>
<style type="text/tailwindcss">
@layer utilities {
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: #F9F7F2;
border-left: 2px solid black;
}
.dark ::-webkit-scrollbar-track {
background: #121212;
border-left: 2px solid white;
}
::-webkit-scrollbar-thumb {
background: #FFDE03;
border: 2px solid black;
}
.dark ::-webkit-scrollbar-thumb {
background: #FFDE03;
border: 2px solid white;
}
.tape {
background-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
backdrop-filter: blur(2px);
border: 1px solid rgba(255,255,255,0.6);
}.stack-card {
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}.stack-card:hover {
z-index: 50 !important;
transform: translateY(-20px) scale(1.05) rotate(0deg) !important;
margin-right: 20px !important;
margin-left: 20px !important;
}.timeline-path {
stroke-dasharray: 20 10;
animation: dash 60s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -1000;
}
}
</style>
</head>
<body class="bg-background-light dark:bg-background-dark text-black dark:text-gray-100 font-mono overflow-x-hidden selection:bg-secondary selection:text-black">
<div class="fixed top-0 left-0 w-full z-50 border-b-2 border-black dark:border-white">
<div class="bg-primary py-2 px-4 text-center border-b-2 border-black">
<p class="text-xs font-bold tracking-widest uppercase flex items-center justify-center gap-2">
<span class="material-icons-round text-sm">auto_awesome</span>
Welcome to Agent Park: The Evolution of Intelligence
<span class="material-icons-round text-sm">arrow_forward</span>
</p>
</div>
<nav class="bg-background-light dark:bg-background-dark px-6 py-4 flex justify-between items-center transition-colors duration-300">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-black dark:bg-white text-white dark:text-black flex items-center justify-center border-2 border-transparent">
<span class="material-icons-round text-2xl">smart_toy</span>
</div>
<span class="font-display font-black text-2xl tracking-tighter uppercase">Agent Park</span>
</div>
<div class="hidden md:flex gap-8 text-sm font-bold">
<a class="hover:text-primary transition-colors uppercase decoration-2 underline-offset-4 hover:underline" href="#">Timeline</a>
<a class="hover:text-primary transition-colors uppercase decoration-2 underline-offset-4 hover:underline" href="#">Agents</a>
<a class="hover:text-primary transition-colors uppercase decoration-2 underline-offset-4 hover:underline" href="#">About</a>
</div>
<button class="w-10 h-10 border-2 border-black dark:border-white flex items-center justify-center hover:bg-black hover:text-white dark:hover:bg-white dark:hover:text-black transition-all shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,1)] active:translate-y-[2px] active:shadow-none" onclick="document.documentElement.classList.toggle('dark')">
<span class="material-icons-round text-lg">light_mode</span>
</button>
</nav>
</div>
<div class="fixed inset-0 pointer-events-none z-0 opacity-10 dark:opacity-20 overflow-hidden">
<div class="absolute inset-0 bg-[size:40px_40px] bg-grid-pattern dark:bg-grid-pattern-dark"></div>
</div>
<div class="relative z-10 pt-32 pb-20 px-4 max-w-[1600px] mx-auto">
<header class="text-center mb-20 relative">
<div class="inline-block relative">
<svg class="absolute -top-6 -left-8 w-[120%] h-[150%] text-primary opacity-80 -z-10 animate-pulse" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<path d="M44.7,-51.2C57.1,-41.5,66.1,-27.6,68.9,-12.8C71.7,2,68.3,17.7,60.4,30.9C52.5,44.1,40.1,54.8,26.4,60.1C12.7,65.4,-2.3,65.3,-17.1,61.1C-31.9,56.9,-46.5,48.6,-56.3,36.4C-66.1,24.2,-71.1,8.1,-67.3,-5.7C-63.5,-19.5,-50.9,-31,-38.7,-40.8C-26.5,-50.6,-14.7,-58.7,0.1,-58.8C14.9,-59,29.8,-51.2,32.3,-60.9" fill="currentColor" transform="translate(100 100)"></path>
</svg>
<h1 class="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white drop-shadow-sm">
THE STORY <br/> OF A.I.
</h1>
</div>
<p class="mt-6 text-lg md:text-xl font-mono max-w-2xl mx-auto bg-white dark:bg-black border border-black dark:border-white p-2 rotate-1 inline-block shadow-[4px_4px_0px_0px_#000] dark:shadow-[4px_4px_0px_0px_#fff]">
Pinned. Stacked. Zigzagged.
</p>
</header>
<main class="relative px-4 md:px-12">
<div class="relative w-full">
<div class="absolute inset-0 pointer-events-none hidden md:block -z-10" style="top: 15rem; bottom: 15rem;">
<div class="absolute top-0 left-0 right-[4rem] h-4 bg-black dark:bg-white"></div>
<div class="absolute top-0 right-[4rem] bottom-0 w-4 bg-black dark:bg-white"></div>
<div class="absolute bottom-0 right-[4rem] left-0 h-4 bg-black dark:bg-white"></div>
</div>
<div class="relative min-h-[500px] mb-32 flex flex-col md:flex-row items-center md:items-start md:justify-start pt-20">
<div class="absolute left-0 -top-8 z-20">
<div class="bg-primary border-4 border-black px-6 py-2 -rotate-2 shadow-hard">
<span class="font-display font-black text-3xl md:text-5xl">2024 — 2017</span>
</div>
</div>
<div class="w-full pl-0 md:pl-8 pr-0 md:pr-32 flex flex-nowrap overflow-x-visible items-center justify-start py-10 perspective-1000">
<div class="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 z-30 rotate-[-2deg]">
<div class="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6 rotate-1"></div>
<div class="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
<span class="material-icons-round text-6xl opacity-50">smart_toy</span>
</div>
<h3 class="font-display font-bold text-xl leading-none mb-2 uppercase">The Agent Era</h3>
<p class="text-xs leading-snug opacity-80 line-clamp-4">Autonomous agents begin to populate the web. They plan, execute, and iterate. It's no longer just chat; it's action.</p>
<div class="absolute bottom-4 right-4 text-[10px] font-bold bg-black text-white px-2">FIG 1.2</div>
</div>
<div class="stack-card relative w-72 h-96 flex-shrink-0 bg-secondary border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56 z-20 rotate-[3deg]">
<div class="tape absolute -top-4 right-8 w-24 h-8 rotate-3"></div>
<div class="h-40 bg-white dark:bg-black border-2 border-black dark:border-white mb-4 flex items-center justify-center">
<div class="grid grid-cols-2 gap-2 p-2 w-full h-full opacity-60">
<div class="bg-black/10 dark:bg-white/10 rounded-full"></div>
<div class="bg-black/10 dark:bg-white/10"></div>
<div class="bg-black/10 dark:bg-white/10"></div>
<div class="bg-black/10 dark:bg-white/10 rounded-full"></div>
</div>
</div>
<h3 class="font-display font-bold text-xl leading-none mb-2 uppercase">Multimodal</h3>
<p class="text-xs leading-snug opacity-80 line-clamp-4">Vision, audio, and text merge into single unified models. AI now perceives the world as humans do.</p>
<div class="absolute bottom-4 right-4 text-[10px] font-bold bg-black text-white px-2">FIG 1.1</div>
</div>
<div class="stack-card relative w-72 h-96 flex-shrink-0 bg-white dark:bg-gray-800 border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56 z-10 rotate-[-1deg]">
<div class="tape absolute -top-4 left-4 w-24 h-8 -rotate-2 bg-yellow-200/50"></div>
<div class="relative border-2 border-black dark:border-white bg-gray-100 h-40 mb-4 overflow-hidden group">
<img alt="Abstract AI Art" class="w-full h-full object-cover grayscale mix-blend-multiply group-hover:scale-110 transition-transform duration-500" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBd0bidYXReahZeHaO8_otbsOwtu_ky-BZEt5qAwkvMWUZayFj_lhA0WO4mW5rAg-BkCOnkGlBQMl_Vmce-cXrqLih3nxlly6yB7NHzSWgS_TiujzG79dYtRIdbhizZCFbTGu8fLVCwSrBmjr7ockX90-T8EA5xYet5tJf5Ip0xucPUUyusI0GmF6d99lc6csGj3Puqk2P8QSRwZEDgOvskQ2I_60FS9ZXGIK9tlhTzajIdPup4Kaz9d04o7-4MQpDMSOjbFR-rkUw"/>
</div>
<h3 class="font-display font-bold text-xl leading-none mb-2 uppercase">Generative Explosion</h3>
<p class="text-xs leading-snug opacity-80 line-clamp-4">ChatGPT launches. The world changes overnight. LLMs become household utilities.</p>
<div class="absolute bottom-4 right-4 text-[10px] font-bold bg-black text-white px-2">FIG 1.0</div>
</div>
<div class="stack-card relative w-72 h-96 flex-shrink-0 bg-primary border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56 z-0 rotate-[2deg]">
<div class="tape absolute -top-3 right-1/2 translate-x-1/2 w-16 h-8 rotate-0"></div>
<div class="h-40 bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-white mb-4 flex items-center justify-center">
<span class="font-mono text-4xl font-black tracking-tighter">T</span>
</div>
<h3 class="font-display font-bold text-xl leading-none mb-2 uppercase">The Transformer</h3>
<p class="text-xs leading-snug opacity-80 line-clamp-4">"Attention Is All You Need". The paper that killed RNNs and birthed the GPT architecture.</p>
<div class="absolute bottom-4 right-4 text-[10px] font-bold bg-black text-white px-2">FIG 0.9</div>
</div>
</div>
</div>
<div class="relative min-h-[500px] flex flex-col md:flex-row-reverse items-center md:items-start md:justify-start pt-20">
<div class="absolute right-0 -top-8 z-20">
<div class="bg-secondary border-4 border-black px-6 py-2 rotate-2 shadow-hard">
<span class="font-display font-black text-3xl md:text-5xl">2016 — 1950</span>
</div>
</div>
<div class="w-full pl-0 md:pl-32 pr-0 md:pr-8 flex flex-nowrap flex-row-reverse overflow-x-visible items-center justify-start py-10 perspective-1000">
<div class="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 -ml-48 md:-ml-56 z-30 rotate-[2deg]">
<div class="tape absolute -top-4 right-4 w-16 h-8 rotate-12 bg-red-400/30"></div>
<div class="relative border-2 border-black dark:border-white h-40 mb-4 overflow-hidden">
<img alt="Go Board Game" class="w-full h-full object-cover grayscale contrast-125" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD-xRSKl7qyBPowfXI_vdiJvvkVuLPFBQNsKpb0-4D_hlXpGHzNYv08vInYTw3Gt56PR_iHA6_ZbDL6a_veUQk4UZpwMa3_LnVJKVujkxpgKUVQCpfXmSImXaWqJuPHbmXGQF5yPiA00cYl3tHGrjOHekWDBGFJo7HboDYGfhne8CKhvcJhzfoAqqydkxOrvE6Jv_sfIVeI9rB_StLXI_4BmkAPGBgo4Dj4-x3Kignm-pBGEORawu_taKEhLLftAQ1ospTyZb3ouN4"/>
<div class="absolute inset-0 bg-primary mix-blend-multiply opacity-30"></div>
</div>
<h3 class="font-display font-bold text-xl leading-none mb-2 uppercase">Move 37</h3>
<p class="text-xs leading-snug opacity-80 line-clamp-4">AlphaGo defeats Lee Sedol. A move of "inhuman" intuition that shocked the world.</p>
<div class="absolute bottom-4 right-4 text-[10px] font-bold bg-black text-white px-2">FIG 0.8</div>
</div>
<div class="stack-card relative w-72 h-96 flex-shrink-0 bg-black border-4 border-black dark:border-white p-4 shadow-hard -ml-48 md:-ml-56 z-20 rotate-[-3deg]">
<div class="tape absolute -top-4 left-8 w-20 h-6 -rotate-3 bg-white/20"></div>
<div class="h-40 bg-blue-800 border-2 border-white mb-4 flex items-center justify-center">
<span class="material-icons-round text-white text-6xl">grid_on</span>
</div>
<h3 class="font-display font-bold text-xl text-white leading-none mb-2 uppercase">Deep Blue</h3>
<p class="text-xs leading-snug text-gray-300 line-clamp-4">IBM's machine defeats Kasparov. Brute force calculation triumphs over human strategy.</p>
<div class="absolute bottom-4 right-4 text-[10px] font-bold bg-white text-black px-2">FIG 0.5</div>
</div>
<div class="stack-card relative w-72 h-96 flex-shrink-0 bg-gray-200 dark:bg-gray-800 border-4 border-black dark:border-white p-4 shadow-hard -ml-48 md:-ml-56 z-10 rotate-[1deg]">
<div class="tape absolute -top-3 right-1/2 translate-x-1/2 w-24 h-8 rotate-2 bg-blue-200/40"></div>
<div class="h-40 border-2 border-black dark:border-white border-dashed mb-4 flex items-center justify-center opacity-50">
<span class="material-icons-round text-5xl">ac_unit</span>
</div>
<h3 class="font-display font-bold text-xl leading-none mb-2 uppercase">The Winter</h3>
<p class="text-xs leading-snug opacity-80 line-clamp-4">Funding dries up. The promises of early AI fail to materialize. Research goes underground.</p>
<div class="absolute bottom-4 right-4 text-[10px] font-bold bg-black text-white px-2">FIG 0.2</div>
</div>
<div class="stack-card relative w-72 h-96 flex-shrink-0 bg-secondary border-4 border-black dark:border-white p-4 shadow-hard -ml-48 md:-ml-56 z-0 rotate-[-2deg]">
<div class="tape absolute -top-4 right-4 w-20 h-6 -rotate-6"></div>
<div class="relative border-2 border-dashed border-black dark:border-white mb-4 h-40 bg-white dark:bg-black flex items-center justify-center">
<svg class="w-16 h-16 text-black dark:text-white" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" viewBox="0 0 24 24">
<rect height="10" rx="2" width="18" x="3" y="11"></rect>
<circle cx="12" cy="5" r="2"></circle>
<path d="M12 7v4"></path>
</svg>
</div>
<h3 class="font-display font-bold text-xl leading-none mb-2 uppercase">Imitation Game</h3>
<p class="text-xs leading-snug opacity-80 line-clamp-4">Turing's question: "Can machines think?" The philosophical start of it all.</p>
<div class="absolute bottom-4 right-4 text-[10px] font-bold bg-black text-white px-2">FIG 0.1</div>
</div>
</div>
</div>
</div>
</main>
<div class="mt-40 max-w-3xl mx-auto px-4">
<div class="bg-primary border-4 border-black p-8 relative shadow-[12px_12px_0px_0px_#000] dark:shadow-[12px_12px_0px_0px_#fff]">
<div class="absolute -top-10 -right-6 w-20 h-20 bg-white dark:bg-gray-800 border-4 border-black flex items-center justify-center rounded-full animate-bounce">
<span class="material-icons-round text-4xl text-black dark:text-white">mail</span>
</div>
<h2 class="font-display font-black text-3xl md:text-5xl mb-4 text-black uppercase tracking-tight">Join the Park</h2>
<p class="font-mono text-black mb-8 text-base font-bold">
Subscribe to the Agent Park weekly zine. No spam, just ducks and data.
</p>
<form class="flex flex-col md:flex-row gap-4">
<input class="flex-1 bg-white border-4 border-black px-6 py-4 font-mono focus:ring-0 focus:border-black focus:shadow-[4px_4px_0px_0px_#000] transition-all placeholder:text-gray-500 text-black text-lg" placeholder="Your email here..." type="email"/>
<button class="bg-black text-white px-10 py-4 font-black border-4 border-transparent hover:bg-white hover:text-black hover:border-black transition-all hover:shadow-[6px_6px_0px_0px_#000] uppercase text-lg" type="button">
SUBSCRIBE
</button>
</form>
<div class="mt-6 flex items-center gap-3">
<input class="w-6 h-6 border-4 border-black text-black focus:ring-0 rounded-none bg-white checked:bg-black" id="check" type="checkbox"/>
<label class="text-sm font-black text-black uppercase" for="check">I agree to be cool.</label>
</div>
</div>
</div>
</div>
<div class="fixed bottom-6 right-6 z-50">
<div class="bg-white dark:bg-black border-4 border-black dark:border-white p-3 shadow-hard dark:shadow-hard-dark cursor-pointer hover:-translate-y-2 transition-transform">
<span class="material-icons-round text-3xl text-black dark:text-white">arrow_upward</span>
</div>
</div>
<footer class="bg-black dark:bg-white text-white dark:text-black py-16 mt-20 border-t-8 border-primary">
<div class="max-w-7xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-12">
<div class="text-center md:text-left">
<h4 class="font-display font-black text-4xl uppercase tracking-tighter">Agent Park</h4>
<p class="font-mono text-sm opacity-60 mt-3">© 2025 Synthetic History Project — Zigzagged and Zined.</p>
</div>
<div class="flex gap-10">
<a class="font-black text-xl hover:text-primary dark:hover:text-primary transition-colors underline decoration-4 underline-offset-8" href="#">Twitter</a>
<a class="font-black text-xl hover:text-primary dark:hover:text-primary transition-colors underline decoration-4 underline-offset-8" href="#">GitHub</a>
<a class="font-black text-xl hover:text-primary dark:hover:text-primary transition-colors underline decoration-4 underline-offset-8" href="#">Discord</a>
</div>
</div>
</footer>
</body></html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

-320
View File
@@ -1,320 +0,0 @@
{
"version": "1.0.0",
"lastScanned": 1771588891228,
"projectRoot": "/Users/caihaohan/Code/agent_park",
"techStack": {
"languages": [
{
"name": "JavaScript/TypeScript",
"version": null,
"confidence": "high",
"markers": [
"package.json"
]
},
{
"name": "TypeScript",
"version": null,
"confidence": "high",
"markers": [
"tsconfig.json"
]
}
],
"frameworks": [
{
"name": "next",
"version": "15.1.11",
"category": "fullstack"
},
{
"name": "react",
"version": "19.0.0",
"category": "frontend"
},
{
"name": "react-dom",
"version": "19.0.0",
"category": "frontend"
},
{
"name": "@playwright/test",
"version": "1.49.1",
"category": "testing"
},
{
"name": "vitest",
"version": "2.1.8",
"category": "testing"
}
],
"packageManager": "pnpm",
"runtime": null
},
"build": {
"buildCommand": "pnpm build",
"testCommand": "pnpm test",
"lintCommand": "pnpm lint",
"devCommand": "pnpm dev",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest",
"test:e2e": "playwright test"
}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": "type-based"
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [
"docs",
"scripts",
"src",
"tests"
],
"gitBranches": {
"defaultBranch": "main",
"branchingStrategy": null
}
},
"customNotes": [],
"directoryMap": {
"design": {
"path": "design",
"purpose": null,
"fileCount": 3,
"lastAccessed": 1771588891214,
"keyFiles": [
"detail.html",
"homepage.html",
"projectlist.html"
]
},
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 7,
"lastAccessed": 1771588891218,
"keyFiles": [
"analyst.md",
"api-reference.md",
"api-testing-guide.md",
"discovery-workflow.md",
"e2e-testing-guide.md"
]
},
"n8n-workflows": {
"path": "n8n-workflows",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1771588891218,
"keyFiles": [
"README.md",
"keyword-cloud-workflow.json"
]
},
"prisma": {
"path": "prisma",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1771588891219,
"keyFiles": [
"schema.prisma",
"seed.ts"
]
},
"scripts": {
"path": "scripts",
"purpose": "Build/utility scripts",
"fileCount": 4,
"lastAccessed": 1771588891219,
"keyFiles": [
"list-tags.js",
"seed-historical-events.ts",
"seed-keyword-cloud.ts",
"test-keyword-api.ts"
]
},
"src": {
"path": "src",
"purpose": "Source code",
"fileCount": 1,
"lastAccessed": 1771588891219,
"keyFiles": [
"middleware.ts"
]
},
"tests": {
"path": "tests",
"purpose": "Test files",
"fileCount": 0,
"lastAccessed": 1771588891219,
"keyFiles": []
},
"prisma/migrations": {
"path": "prisma/migrations",
"purpose": "Database migrations",
"fileCount": 1,
"lastAccessed": 1771588891220,
"keyFiles": [
"migration_lock.toml"
]
},
"src/app": {
"path": "src/app",
"purpose": "Application code",
"fileCount": 3,
"lastAccessed": 1771588891220,
"keyFiles": [
"AGENTS.md",
"globals.css",
"layout.tsx"
]
},
"src/components": {
"path": "src/components",
"purpose": "UI components",
"fileCount": 1,
"lastAccessed": 1771588891220,
"keyFiles": [
"AGENTS.md"
]
},
"src/lib": {
"path": "src/lib",
"purpose": "Library code",
"fileCount": 6,
"lastAccessed": 1771588891220,
"keyFiles": [
"AGENTS.md",
"prisma.ts",
"slug.ts"
]
}
},
"hotPaths": [
{
"path": "AGENTS.md",
"accessCount": 6,
"lastAccessed": 1771593272139,
"type": "file"
},
{
"path": ".opencode/mcp.json",
"accessCount": 4,
"lastAccessed": 1771592799554,
"type": "file"
},
{
"path": "src/app/AGENTS.md",
"accessCount": 3,
"lastAccessed": 1771593311370,
"type": "file"
},
{
"path": "src/hooks/AGENTS.md",
"accessCount": 3,
"lastAccessed": 1771593529726,
"type": "file"
},
{
"path": "src/lib/AGENTS.md",
"accessCount": 3,
"lastAccessed": 1771593529730,
"type": "file"
},
{
"path": "src/components/AGENTS.md",
"accessCount": 2,
"lastAccessed": 1771593512972,
"type": "file"
},
{
"path": "prisma/schema.prisma",
"accessCount": 2,
"lastAccessed": 1771594876904,
"type": "file"
},
{
"path": ".env",
"accessCount": 1,
"lastAccessed": 1771589498743,
"type": "file"
},
{
"path": "package.json",
"accessCount": 1,
"lastAccessed": 1771593111981,
"type": "file"
},
{
"path": "CLAUDE.md",
"accessCount": 1,
"lastAccessed": 1771593111989,
"type": "file"
},
{
"path": "prisma/seed.ts",
"accessCount": 1,
"lastAccessed": 1771593360708,
"type": "file"
},
{
"path": "prisma/AGENTS.md",
"accessCount": 1,
"lastAccessed": 1771593361809,
"type": "file"
},
{
"path": "docs/analyst.md",
"accessCount": 1,
"lastAccessed": 1771594824291,
"type": "file"
},
{
"path": "docs/plans/2025-01-25-ai-timeline-feature-design.md",
"accessCount": 1,
"lastAccessed": 1771594834076,
"type": "file"
},
{
"path": "docs/plans/2026-01-25-keyword-cloud-system-design.md",
"accessCount": 1,
"lastAccessed": 1771594834081,
"type": "file"
},
{
"path": "docs/plans/2025-01-25-ai-timeline-implementation.md",
"accessCount": 1,
"lastAccessed": 1771594834472,
"type": "file"
},
{
"path": "docs/plans/2026-01-25-keyword-cloud-implementation.md",
"accessCount": 1,
"lastAccessed": 1771594834666,
"type": "file"
},
{
"path": "src/app/[locale]/timeline/page.tsx",
"accessCount": 1,
"lastAccessed": 1771594876522,
"type": "file"
},
{
"path": "src/app/[locale]/keyword-cloud/components/KeywordCloud.tsx",
"accessCount": 1,
"lastAccessed": 1771594892847,
"type": "file"
}
],
"userDirectives": []
}
@@ -1 +0,0 @@
{"t":0,"agent":"a37a7c5","agent_type":"unknown","event":"agent_stop","success":true}
@@ -1 +0,0 @@
{"t":0,"agent":"a8591e0","agent_type":"unknown","event":"agent_stop","success":true}
@@ -1,2 +0,0 @@
{"t":0,"agent":"a567d02","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a6f63da","agent_type":"unknown","event":"agent_stop","success":true}
@@ -1,7 +0,0 @@
{"t":0,"agent":"aa3c21d","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a13b2eb","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a791a1b","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a70151c","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a488f88","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a588376","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a0e7cd4","agent_type":"unknown","event":"agent_stop","success":true}
@@ -1,4 +0,0 @@
{"t":0,"agent":"a04e80e","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a9b9f24","agent_type":"explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a9b9f24","agent_type":"explore","event":"agent_stop","success":true,"duration_ms":60625}
{"t":0,"agent":"a895674","agent_type":"unknown","event":"agent_stop","success":true}
@@ -1 +0,0 @@
{"t":0,"agent":"af530c0","agent_type":"unknown","event":"agent_stop","success":true}
@@ -1,4 +0,0 @@
{"t":0,"agent":"aaa41bb","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"system","event":"skill_invoked","skill_name":"find-skills"}
{"t":0,"agent":"aa7d07a","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a604ba3","agent_type":"unknown","event":"agent_stop","success":true}
@@ -1,2 +0,0 @@
{"t":0,"agent":"system","event":"skill_invoked","skill_name":"oh-my-claudecode:hud"}
{"t":0,"agent":"afab084","agent_type":"unknown","event":"agent_stop","success":true}
-6
View File
@@ -1,6 +0,0 @@
{
"timestamp": "2026-02-20T13:40:21.901Z",
"backgroundTasks": [],
"sessionStartTimestamp": "2026-02-20T12:55:14.824Z",
"sessionId": "f1da58ef16330f2a"
}
-17
View File
@@ -1,17 +0,0 @@
{
"agents": [
{
"agent_id": "a9b9f24",
"agent_type": "oh-my-claudecode:explore",
"started_at": "2026-02-20T13:10:01.740Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-02-20T13:11:02.365Z",
"duration_ms": 60625
}
],
"total_spawned": 1,
"total_completed": 1,
"total_failed": 0,
"last_updated": "2026-02-20T13:47:26.026Z"
}
-281
View File
@@ -1,281 +0,0 @@
# Tag Janitor: n8n 每日 AI 标签合并
## TL;DR
目标:解决 AI 生成导致的“标签爆炸”,不改现有入库逻辑(允许自由生成 tags),通过 **n8n 每日任务**调用站点 API 自动做 **标签语义合并 + nameEn 补全**,并保证合并后项目仍正确绑定到新标签。
交付物:
- 新增 tags 维护 APIlist + maintenance/merge
- n8n workflow(每日运行):拉取 tags → AI 产出合并计划/英文补全 → 调用 API 执行 → 输出日报
- Vitest 自动化测试(route + 核心 merge 逻辑)
---
## Context
### 现状(已验证)
- Tag 数据模型:`prisma/schema.prisma``Tag` + `ProjectTag`
- `Tag.name` 全局唯一,`Tag.slug` 全局唯一,`Tag.nameEn` 可空,`Tag.createdAt` 存在
- `ProjectTag` 复合主键 `@@id([projectId, tagId])`(同一项目不能重复绑定同一 tag
- Tag 入库:
- Webhook`src/app/api/webhook/projects/route.ts`(批量 upsert tags;使用 `generateSlug()`;存在 slug 冲突兜底)
- Discovery`src/app/api/discovery/lib/discovery-service.ts``upsertTags()` 与 webhook 类似)
- UI 过滤:
- 项目列表按 `tag slug` 过滤:`src/hooks/useProjects.ts``where.tags.some.tag.slug = tag`
- TagCloud 搜索大小写不敏感,但展示/过滤依赖 slug:`src/components/project/TagCloud.tsx`
- Slug 生成:`src/lib/slug.ts`(优先 nameEn,否则 namelowercase;保留中文;空格→-;最长 100)
### 约束与偏好(用户确认)
- 不做复杂治理:无审核 UI、无回滚系统、无 alias/redirect 层、无 merge memory。
- 合并后旧标签直接删除。
- 新项目入库仍可自由生成 tags(不做入库侧规范化/映射),一切靠每日合并流程兜底。
- 标签双语:需要 `name` + `nameEn`(可相同,例如 Python)。
- AI 判定逻辑放在 n8n;站点 API 只负责“安全执行合并”。
- 需要自动化测试(Vitest)。
---
## 关键设计(最小可用)
### 1) API 只做“合并执行器”
站点侧不做语义判断、不接 embedding、不做候选生成;只接收 n8n 给出的“合并计划”,并以 Prisma 事务保证一致性。
必须满足:
- 合并后 `ProjectTag` 绑定迁移完成(不会丢失项目-标签关系)
- 避免因 `@@id([projectId, tagId])` 造成重复冲突(迁移时要去重)
- 执行完删除旧 tagssource tags
- 合并完触发缓存更新(ISR)以尽快反映 tag counts
### 2) n8n 负责 AI:语义聚类 + 合并计划 + nameEn 补全
最简单闭环:
- 每日拉取全量 tags(含 projectCount
- 让 LLM 输出:
- merges[{ target: {name, nameEn}, sourceTagIds: [...] }]
- updates[{ tagId, nameEn }]
- 调用 API 执行 merges/updates
- 输出日报(合并数量、删除 tags 数、nameEn 补全数、失败明细)
---
## 默认策略(无须再问用户)
- 旧 slug 不做重定向:合并后如果用户用旧 `?tag=oldSlug` 访问,页面会显示 0 结果(符合“不要复杂化”的约束)。
- Merge 过程中不强制改动 `slug`
- 对“新建 canonical tag”由 API 按 `generateSlug(name, nameEn)` 生成 slug
- 对“复用已有 tagId 作为 target”不强制改 slug(避免破坏既有链接);只更新 `name/nameEn`(如果 n8n 传了)
- API 鉴权复用 `WEBHOOK_API_KEY`(与 `src/app/api/webhook/projects/route.ts` 同一密钥),用 `crypto.timingSafeEqual()`
---
## Execution Strategy (Waves)
Wave 1 (Design + Audit)
- Task 1: 明确 API contractlist + maintenance+ 错误码
- Task 2: 设计 n8n workflow(节点、prompt、chunking、重试策略)
Wave 2 (Backend Implementation)
- Task 3: 新增 tags list API(给 n8n 拉取 tags + counts
- Task 4: 新增 tags maintenance APImerges + updates;事务;去重;删除旧 tag)
- Task 5: 触发 ISR revalidate(项目列表页)
Wave 3 (Automation + Tests)
- Task 6: 写 Vitest 测试(route + merge 逻辑)
- Task 7: 搭建 n8n workflow 并做一次 dry run + 一次真实合并
Critical Path: Task 1 → Task 4 → Task 6 → Task 7
---
## TODOs
### 1) 定义 API Contractlist + maintenance
**What to do**:
- 设计 2 个 endpoint
- `GET /api/tags`:返回 tags 列表(id/name/nameEn/slug/createdAt/\_count.projects
- `POST /api/tags/maintenance`:鉴权 + 批量执行 `updates``merges`
- 约定响应结构:`{ success: boolean, ... }`,失败时带 `error` + `details`
**Must NOT do**:
- 不引入 alias/redirect
- 不引入 merge history/回滚
**References**:
- `prisma/schema.prisma` - `Tag`/`ProjectTag` 约束(unique/复合主键)
- `src/app/api/webhook/projects/route.ts` - API key 鉴权 + Zod safeParse + 返回结构风格
- `src/lib/validations.ts` - Zod schema 放置位置/风格
**Acceptance Criteria**:
- 产出一个明确的 JSON schema(写进实现用的 Zod schema),覆盖:空 merges、重复 tagId、自合并、无效 tagId、nameEn 缺失。
---
### 2) 设计 n8n 每日 Tag Janitor Workflow
**What to do**:
- 节点建议:Cron → HTTP(GET /api/tags) → Code(预处理/分块) → LLM(生成 merges+updates) → Code(JSON 校验/拆批) → HTTP(POST /api/tags/maintenance) → 汇总通知
- Prompt 输出必须是严格 JSON(避免解析失败)
- 需要考虑 LLM 输入过大时的分块策略(按字母/按 projectCount/按 createdAt
**Must NOT do**:
- 不要求人工审核
**References**:
- `src/hooks/useProjects.ts` - tags 的 `_count.projects` 查询方式(作为 /api/tags 的实现依据)
- `src/components/project/TagCloud.tsx` - UI 依赖 slug(理解合并后旧 slug 失效的表现)
**Acceptance Criteria**:
- n8n workflow 最终只依赖两个站点接口(GET /api/tags, POST /api/tags/maintenance
- LLM 输出 JSON 包含:`merges`(数组)与 `updates`(数组)
---
### 3) 实现 `GET /api/tags`
**What to do**:
- 新增 route`src/app/api/tags/route.ts`(实现 GET
- 用 Prisma 查询 Tag 并 include `_count.projects`
**References**:
- `src/hooks/useProjects.ts` - `getAllTags()` 的 Prisma 查询(可直接复用/抽成共享函数)
**Acceptance Criteria (agent-executable)**:
```bash
curl -s http://localhost:3000/api/tags | jq '.success'
# Assert: true
curl -s http://localhost:3000/api/tags | jq '.tags[0] | has("id") and has("slug") and has("_count")'
# Assert: true
```
---
### 4) 实现 `POST /api/tags/maintenance`updates + merges
**What to do**:
- 新增 route`src/app/api/tags/maintenance/route.ts`(实现 POST
- 鉴权:复用 `WEBHOOK_API_KEY` + `crypto.timingSafeEqual()`(参考 webhook
- Zod 校验请求体
- Prisma 事务执行:
1. 执行 `updates`(主要是 nameEn 补全;允许 name/nameEn 相同)
2. 执行 `merges`
- target tag:若 n8n 给的是 id → 使用;若给的是 name/nameEn → upsert/createslug 用 `generateSlug()`
- 迁移 `ProjectTag`:把 sourceTagIds 关联的 projectId 迁移到 targetTagId(需去重,避免复合主键冲突)
- 删除 source tags`Tag` 记录)
3. 结束后 revalidate 相关路径
**References**:
- `prisma/schema.prisma` - `ProjectTag` 复合主键导致去重需求
- `src/lib/slug.ts` - `generateSlug()`
- `src/app/api/webhook/projects/route.ts` - `timingSafeEqual` 鉴权与错误返回格式
**Acceptance Criteria (agent-executable)**:
```bash
# 1) 未授权
curl -s -X POST http://localhost:3000/api/tags/maintenance \
-H 'Content-Type: application/json' \
-d '{"updates":[],"merges":[]}' \
| jq '.error'
# Assert: "Unauthorized" (or equivalent)
# 2) 授权空操作成功
curl -s -X POST http://localhost:3000/api/tags/maintenance \
-H 'Content-Type: application/json' \
-d '{"apiKey":"'"$WEBHOOK_API_KEY"'","updates":[],"merges":[]}' \
| jq '.success'
# Assert: true
```
---
### 5) ISR/缓存刷新策略
**What to do**:
- 在 maintenance 成功后调用 `revalidatePath()` 刷新 projects 列表页(至少 `/{locale}/projects` 对应的路径)
**References**:
- `src/hooks/AGENTS.md` - 建议在数据更新后使用 `revalidatePath()`
**Acceptance Criteria**:
- 合并后 1 次刷新内(无需等待 5 分钟)能看到 tags count 变化(用 Playwright 或 curl + 页面渲染断言)。
---
### 6) Vitest 测试(route + merge 逻辑)
**What to do**:
- 新增测试文件(参考风格):`src/app/api/events/route.test.ts`
- 至少覆盖:
- 未授权 401
- body schema 校验失败 400
- 合并执行时:
- 迁移 projectTag 去重逻辑不会抛错
- source tag 会被删除
- target tag 的 nameEn 会被补全/更新
**References**:
- `src/app/api/events/route.test.ts` - NextRequest 调用 route handler 的测试方式
**Acceptance Criteria (agent-executable)**:
```bash
pnpm test
# Assert: exit code 0
```
---
### 7) n8n Workflow 落地与一次端到端验证
**What to do**:
- 在 n8n 中配置:
- 站点 baseUrl
- `WEBHOOK_API_KEY`
- LLM 凭证
- 先 dry-run(只输出 merges/updates,不调用 maintenance
- 再 real-run(调用 maintenance
**Acceptance Criteria (agent-executable)**:
- 运行一次后:
- `/api/tags` 返回的 tag 总数下降(或至少不增长)
- 采样 1-2 个被合并的 source tag,其 slug 再用于 `/{locale}/projects?tag=` 时返回 0 项(可接受)
- 被合并的项目在新 canonical tag slug 下能被筛出来(`src/hooks/useProjects.ts` 的 tag filter 生效)
---
## Notes / Gotchas
- `Tag.name``Tag.slug` 均为唯一:当 AI 生成 canonical 名称时,可能撞车;API 需要在创建/更新时处理并返回可读错误(让 n8n 重试或改名)。
- 合并迁移时必须去重:`ProjectTag``@@id([projectId, tagId])` 会在“同一项目已拥有 target tag”时导致冲突。
- 如果未来 tag 规模增大,n8n 端需要升级候选生成(先 lexical/embedding 粗筛,再让 LLM 决策)。本计划先按“规模尚可”实现全量日更。
-615
View File
@@ -1,615 +0,0 @@
{
"totalTags": 859,
"keepCount": 100,
"dropCount": 759,
"uncoveredProjects": [],
"forcedAdded": [
{
"id": "cmkqnj1pt0001l504vmrywqeg",
"name": "需要配置",
"projects": 163
}
],
"keepTop100": [
{
"id": "cmk9fpg4h0000kt048l3a2nqu",
"name": "Python",
"nameEn": "Python",
"projects": 268
},
{
"id": "cmkqimx520005jx04vyegybp9",
"name": "AI开发工具",
"nameEn": "AI Development Tool",
"projects": 213
},
{
"id": "cmkqnj1pt0001l504vmrywqeg",
"name": "需要配置",
"nameEn": "Requires Configuration",
"projects": 163
},
{
"id": "cmkjjb37v00064j12b84dsrwg",
"name": "TypeScript",
"nameEn": "TypeScript",
"projects": 118
},
{
"id": "cmks0yz0h002xl1040w65w47d",
"name": "AI代理",
"nameEn": "AI Agents",
"projects": 52
},
{
"id": "cmkql448t0002l504m7bkovze",
"name": "自动化",
"nameEn": "Automation",
"projects": 46
},
{
"id": "cmkqjvuo5000ejr04y9ksio48",
"name": "Docker",
"nameEn": "Docker",
"projects": 43
},
{
"id": "cmkjjb37v00074j122wpemyas",
"name": "Node.js",
"nameEn": "Node.js",
"projects": 40
},
{
"id": "cmkoutfvz0005jr04knetubim",
"name": "JavaScript",
"nameEn": "JavaScript",
"projects": 34
},
{
"id": "cmkqklwxm0018jr04zyzlrmlv",
"name": "React",
"nameEn": "React",
"projects": 34
},
{
"id": "cmkqjgpzq0002l104qknt9pho",
"name": "开源",
"nameEn": "Open Source",
"projects": 33
},
{
"id": "cmkqnvcbg0001jo04us31pcaj",
"name": "低学习成本",
"nameEn": "Low Learning Curve",
"projects": 31
},
{
"id": "cmkqk1dbi000ljr045f2rm18m",
"name": "PyTorch",
"nameEn": "PyTorch",
"projects": 30
},
{
"id": "cmkrv2jpg0000l204swrx5xh4",
"name": "LangChain",
"nameEn": "LangChain",
"projects": 29
},
{
"id": "cmkqlcukz000dl5044c6z8542",
"name": "多智能体系统",
"nameEn": "Multi-Agent System",
"projects": 28
},
{
"id": "cmkqk1dc5000ojr04jq0exq1z",
"name": "大语言模型",
"nameEn": null,
"projects": 25
},
{
"id": "cmkqimwxl0000jx04l8q0rdzo",
"name": "LLM",
"nameEn": "LLM",
"projects": 22
},
{
"id": "cmks0rbk1002jl104uc7yg2xv",
"name": "Go",
"nameEn": "Go",
"projects": 20
},
{
"id": "cmkqm1c6j0003jl04clkn9qyb",
"name": "企业级",
"nameEn": "Enterprise",
"projects": 20
},
{
"id": "cmkqnn8hj0006l504j2uedblz",
"name": "需要基础",
"nameEn": "Requires Basics",
"projects": 20
},
{
"id": "cmks0yz1e002yl104nw4apfcq",
"name": "Library",
"nameEn": "Library",
"projects": 18
},
{
"id": "cmkrx8euz0007ky04hrgq5ryg",
"name": "MCP",
"nameEn": "Model Context Protocol",
"projects": 18
},
{
"id": "cmks16lb0003ml104acf4zi7e",
"name": "Markdown",
"nameEn": "Markdown",
"projects": 17
},
{
"id": "cmkqklwr20016jr04opezrtpo",
"name": "Next.js",
"nameEn": "Next.js",
"projects": 17
},
{
"id": "cmks003y10015l104xruieqo9",
"name": "RAG",
"nameEn": "RAG",
"projects": 17
},
{
"id": "cmkqm1bzv0000jl04c3ks3w7w",
"name": "Java",
"nameEn": "Java",
"projects": 16
},
{
"id": "cmks0c9ll0022l10436svssah",
"name": "MIT许可",
"nameEn": "MIT License",
"projects": 16
},
{
"id": "cmkqnn8gl0005l5042jdeuuur",
"name": "LangGraph",
"nameEn": "LangGraph",
"projects": 15
},
{
"id": "cmkrx664z0000ky0481j124cq",
"name": "FastAPI",
"nameEn": "FastAPI",
"projects": 14
},
{
"id": "cmkqjvuir0008jr048591dzwp",
"name": "Rust",
"nameEn": "Rust",
"projects": 14
},
{
"id": "cmkrzlq020001l104sosii49r",
"name": "复杂部署",
"nameEn": "Complex Deployment",
"projects": 14
},
{
"id": "cmkjjb38l000c4j12oucpveph",
"name": "工作流自动化",
"nameEn": "Workflow Automation",
"projects": 14
},
{
"id": "cmks3iu8m006wl104ndk8no10",
"name": "C#",
"nameEn": "C#",
"projects": 11
},
{
"id": "cmkqnn8mm0007l5045q6z4zb0",
"name": "OpenAI",
"nameEn": "OpenAI",
"projects": 11
},
{
"id": "cmkrzvdfk000tl104jsq77zig",
"name": "OpenAI API",
"nameEn": "OpenAI API",
"projects": 11
},
{
"id": "cmks3d1wy006ll104gltxc3et",
"name": "SDK",
"nameEn": "SDK",
"projects": 11
},
{
"id": "cmks1aoxo003xl104gap1drce",
"name": "强化学习",
"nameEn": "Reinforcement Learning",
"projects": 11
},
{
"id": "cmks1aoyn003yl104r4zspprz",
"name": "机器学习",
"nameEn": "Machine Learning",
"projects": 11
},
{
"id": "cmksczul60015jv04ft4n1cb2",
"name": "GitHub",
"nameEn": "GitHub",
"projects": 10
},
{
"id": "cmks0m380002gl104o6f9cjtj",
"name": "PostgreSQL",
"nameEn": "PostgreSQL",
"projects": 10
},
{
"id": "cmkrx8f0j000aky046uomv3rs",
"name": "多模态",
"nameEn": "Multimodal",
"projects": 10
},
{
"id": "cmkqjs7e50004jr042mrpi3ac",
"name": "桌面应用",
"nameEn": null,
"projects": 10
},
{
"id": "cmkqk4i0d000vjr04ai18b8h3",
"name": "知识管理",
"nameEn": null,
"projects": 10
},
{
"id": "cmkqk4i0g000wjr04t82lzvaq",
"name": "向量数据库",
"nameEn": null,
"projects": 9
},
{
"id": "cmkrzlq0q0003l10453qhsniu",
"name": "Transformers",
"nameEn": "Transformers",
"projects": 8
},
{
"id": "cmks1ubzb004pl104mvs08pmu",
"name": "vLLM",
"nameEn": "vLLM",
"projects": 8
},
{
"id": "cmks0eqvq0028l10426ogrvkb",
"name": "云端服务",
"nameEn": "Cloud Service",
"projects": 8
},
{
"id": "cmks13z53003el104re4468xt",
"name": "代码生成",
"nameEn": "Code Generation",
"projects": 8
},
{
"id": "cmks05ci1001gl104lus7os2q",
"name": "企业级AI应用",
"nameEn": "Enterprise AI",
"projects": 8
},
{
"id": "cmks1s7p2004il104kpwprttq",
"name": "智能体框架",
"nameEn": "Agent Framework",
"projects": 8
},
{
"id": "cmkqjs7dw0003jr04uy2bguck",
"name": "跨平台",
"nameEn": null,
"projects": 8
},
{
"id": "cmkqklwxr0019jr048wp0hds8",
"name": "AI 助手",
"nameEn": "AI Assistant",
"projects": 7
},
{
"id": "cmks5ey0j001el204vjk69nwg",
"name": "AI安全",
"nameEn": "AI Security",
"projects": 7
},
{
"id": "cmkqjs77s0000jr041cypbjkn",
"name": "Electron",
"nameEn": "Electron",
"projects": 7
},
{
"id": "cmkry59tc0001jy04kr21i343",
"name": "Playwright",
"nameEn": "Playwright",
"projects": 7
},
{
"id": "cmk9fpg5f0003kt04fcgbvj7y",
"name": "多智能体",
"nameEn": "Multi-Agent",
"projects": 7
},
{
"id": "cmkrzxk4i0011l1045cubzwwr",
"name": "深度学习",
"nameEn": "Deep Learning",
"projects": 7
},
{
"id": "cmks13z46003dl1047ba0entr",
"name": "AI代理框架",
"nameEn": "AI Agent Framework",
"projects": 6
},
{
"id": "cmkry59u70003jy049eoxzkc7",
"name": "AI研究",
"nameEn": "AI Research",
"projects": 6
},
{
"id": "cmks2a6r1005il104ormxbqud",
"name": "Web应用",
"nameEn": "Web Application",
"projects": 6
},
{
"id": "cmkshrboj0000la04h6bmugvj",
"name": "学术研究",
"nameEn": "Academic Research",
"projects": 6
},
{
"id": "cmks112wx0039l104zltcl2hp",
"name": "工作流编排",
"nameEn": "Workflow Orchestration",
"projects": 6
},
{
"id": "cmkrzvdfe000sl104q0gge67f",
"name": "教程",
"nameEn": "Tutorial",
"projects": 6
},
{
"id": "cmks3w0g0000bl904u4lmk5d5",
"name": "浏览器自动化",
"nameEn": "Browser Automation",
"projects": 6
},
{
"id": "cmkt8m55q000nla04kgfld9x3",
"name": "知识图谱",
"nameEn": "Knowledge Graph",
"projects": 6
},
{
"id": "cmkqklwxj0017jr04czubkbbi",
"name": "知识库",
"nameEn": "Knowledge Base",
"projects": 6
},
{
"id": "cmkshubus0004la04zjuh5nn5",
"name": "自主智能体",
"nameEn": "Autonomous Agents",
"projects": 6
},
{
"id": "cmkqk4i0d000ujr045d2njmc7",
"name": "隐私保护",
"nameEn": null,
"projects": 6
},
{
"id": "cmks02o6m001al104gi1jztpn",
"name": "Apache-2.0",
"nameEn": "Apache-2.0",
"projects": 5
},
{
"id": "cmks4roac000el204paypouzl",
"name": "CLI",
"nameEn": "CLI",
"projects": 5
},
{
"id": "cmkt7yhut0000la040ij92vgk",
"name": "MCP协议",
"nameEn": "MCP Protocol",
"projects": 5
},
{
"id": "cmks5rr2e0020l204sn9ydvka",
"name": "Nest.js",
"nameEn": "Nest.js",
"projects": 5
},
{
"id": "cmkjjb37z00084j121651lnjl",
"name": "Vue.js",
"nameEn": "Vue.js",
"projects": 5
},
{
"id": "cmksg1wc90061jv04114izr2d",
"name": "Web自动化",
"nameEn": "Web Automation",
"projects": 5
},
{
"id": "cmkuulu5d000jjc04u7hx67sz",
"name": "分布式系统",
"nameEn": "Distributed Systems",
"projects": 5
},
{
"id": "cmkqimx3n0001jx043mvrmarx",
"name": "可视化",
"nameEn": "Visualization",
"projects": 5
},
{
"id": "cmksf0ik1004ijv047hiusscw",
"name": "多模态AI",
"nameEn": "Multimodal AI",
"projects": 5
},
{
"id": "cmktoexz8001wld046f13barg",
"name": "多语言支持",
"nameEn": "Multi-language Support",
"projects": 5
},
{
"id": "cmks2gqie005ol104v4d6nvck",
"name": "数据分析",
"nameEn": "Data Analytics",
"projects": 5
},
{
"id": "cmks0equs0026l104sl4td34d",
"name": "聊天机器人",
"nameEn": "Chatbot",
"projects": 5
},
{
"id": "cmkjjb38j000b4j125a3fg282",
"name": "自托管",
"nameEn": "Self-hosted",
"projects": 5
},
{
"id": "cmktkj5to001hl704wtmqjft4",
"name": "自然语言处理",
"nameEn": "Natural Language Processing",
"projects": 5
},
{
"id": "cmkrzxk4a0010l104u7irj9je",
"name": "计算机视觉",
"nameEn": "Computer Vision",
"projects": 5
},
{
"id": "cmkto2g8o0019ld04b61pgj4y",
"name": "AI研究资源",
"nameEn": "AI Research Resource",
"projects": 4
},
{
"id": "cmktk5hid000sl7049a2bo5al",
"name": "Benchmark",
"nameEn": "Benchmark",
"projects": 4
},
{
"id": "cmkv6j7uq000gk4043x1gfnp1",
"name": "Bun",
"nameEn": "Bun",
"projects": 4
},
{
"id": "cmkti8i470000kz04bpsajkzk",
"name": "ChromaDB",
"nameEn": "ChromaDB",
"projects": 4
},
{
"id": "cmks47ct90013l904x03rupwb",
"name": "Flask",
"nameEn": "Flask",
"projects": 4
},
{
"id": "cmkql9bho0007l50406oflxr0",
"name": "Gradio",
"nameEn": "Gradio",
"projects": 4
},
{
"id": "cmks112qf0034l104gkihwp10",
"name": "JavaScript/TypeScript",
"nameEn": "JavaScript/TypeScript",
"projects": 4
},
{
"id": "cmks1s7ut004jl104gvlybgfs",
"name": "LiteLLM",
"nameEn": "LiteLLM",
"projects": 4
},
{
"id": "cmks4pb54000bl204erpb9bbp",
"name": "LlamaIndex",
"nameEn": "LlamaIndex",
"projects": 4
},
{
"id": "cmksfnrz1005qjv044pnraikr",
"name": "LLM应用",
"nameEn": "LLM Applications",
"projects": 4
},
{
"id": "cmkqimx480003jx04bcqob7pb",
"name": "NLP",
"nameEn": "NLP",
"projects": 4
},
{
"id": "cmkqm1c690001jl04ge5elv46",
"name": "Spring Boot",
"nameEn": "Spring Boot",
"projects": 4
},
{
"id": "cmks2a6l3005gl1040u4s0k4s",
"name": "Streamlit",
"nameEn": "Streamlit",
"projects": 4
},
{
"id": "cmkth0k4x0026ju04t0euxxwk",
"name": "VS Code扩展",
"nameEn": "VS Code Extension",
"projects": 4
},
{
"id": "cmkscxy490012jv04rf4k4h4h",
"name": "学术资源",
"nameEn": "Academic Resource",
"projects": 4
},
{
"id": "cmkscgc0z0007jv04nwmzbn8c",
"name": "工具调用",
"nameEn": "Tool Calling",
"projects": 4
},
{
"id": "cmkuxtw0m002ojm04pl9qazxs",
"name": "用户通知",
"nameEn": "User Notification",
"projects": 1
}
]
}
@@ -1,102 +0,0 @@
[
"cmk9fpg4h0000kt048l3a2nqu",
"cmkjjb37v00064j12b84dsrwg",
"cmkqimx520005jx04vyegybp9",
"cmks16lb0003ml104acf4zi7e",
"cmks0yz0h002xl1040w65w47d",
"cmkqjgpzq0002l104qknt9pho",
"cmkql448t0002l504m7bkovze",
"cmkqm1bzv0000jl04c3ks3w7w",
"cmkoutfvz0005jr04knetubim",
"cmks0rbk1002jl104uc7yg2xv",
"cmks3iu8m006wl104ndk8no10",
"cmkqk1dc5000ojr04jq0exq1z",
"cmkqimwxl0000jx04l8q0rdzo",
"cmks0yz1e002yl104nw4apfcq",
"cmkqjvuo5000ejr04y9ksio48",
"cmkjjb37v00074j122wpemyas",
"cmkqnvcbg0001jo04us31pcaj",
"cmkrzlq020001l104sosii49r",
"cmks1aoyn003yl104r4zspprz",
"cmks5ey0j001el204vjk69nwg",
"cmkshrboj0000la04h6bmugvj",
"cmkscgc0z0007jv04nwmzbn8c",
"cmkscxy490012jv04rf4k4h4h",
"cmkuxtw0m002ojm04pl9qazxs",
"cmkqklwxm0018jr04zyzlrmlv",
"cmkqk1dbi000ljr045f2rm18m",
"cmkrv2jpg0000l204swrx5xh4",
"cmkqlcukz000dl5044c6z8542",
"cmkqm1c6j0003jl04clkn9qyb",
"cmkqnn8hj0006l504j2uedblz",
"cmkrx8euz0007ky04hrgq5ryg",
"cmkqklwr20016jr04opezrtpo",
"cmks003y10015l104xruieqo9",
"cmks0c9ll0022l10436svssah",
"cmkqnn8gl0005l5042jdeuuur",
"cmkrx664z0000ky0481j124cq",
"cmkqjvuir0008jr048591dzwp",
"cmkjjb38l000c4j12oucpveph",
"cmkqnn8mm0007l5045q6z4zb0",
"cmkrzvdfk000tl104jsq77zig",
"cmks3d1wy006ll104gltxc3et",
"cmks1aoxo003xl104gap1drce",
"cmksczul60015jv04ft4n1cb2",
"cmks0m380002gl104o6f9cjtj",
"cmkrx8f0j000aky046uomv3rs",
"cmkqjs7e50004jr042mrpi3ac",
"cmkqk4i0d000vjr04ai18b8h3",
"cmkqk4i0g000wjr04t82lzvaq",
"cmkrzlq0q0003l10453qhsniu",
"cmks1ubzb004pl104mvs08pmu",
"cmks0eqvq0028l10426ogrvkb",
"cmks13z53003el104re4468xt",
"cmks05ci1001gl104lus7os2q",
"cmks1s7p2004il104kpwprttq",
"cmkqjs7dw0003jr04uy2bguck",
"cmkqklwxr0019jr048wp0hds8",
"cmkqjs77s0000jr041cypbjkn",
"cmkry59tc0001jy04kr21i343",
"cmk9fpg5f0003kt04fcgbvj7y",
"cmkrzxk4i0011l1045cubzwwr",
"cmks13z46003dl1047ba0entr",
"cmkry59u70003jy049eoxzkc7",
"cmks2a6r1005il104ormxbqud",
"cmks112wx0039l104zltcl2hp",
"cmkrzvdfe000sl104q0gge67f",
"cmks3w0g0000bl904u4lmk5d5",
"cmkt8m55q000nla04kgfld9x3",
"cmkqklwxj0017jr04czubkbbi",
"cmkshubus0004la04zjuh5nn5",
"cmkqk4i0d000ujr045d2njmc7",
"cmks02o6m001al104gi1jztpn",
"cmks4roac000el204paypouzl",
"cmkt7yhut0000la040ij92vgk",
"cmks5rr2e0020l204sn9ydvka",
"cmkjjb37z00084j121651lnjl",
"cmksg1wc90061jv04114izr2d",
"cmkuulu5d000jjc04u7hx67sz",
"cmkqimx3n0001jx043mvrmarx",
"cmksf0ik1004ijv047hiusscw",
"cmktoexz8001wld046f13barg",
"cmks2gqie005ol104v4d6nvck",
"cmks0equs0026l104sl4td34d",
"cmkjjb38j000b4j125a3fg282",
"cmktkj5to001hl704wtmqjft4",
"cmkrzxk4a0010l104u7irj9je",
"cmkto2g8o0019ld04b61pgj4y",
"cmktk5hid000sl7049a2bo5al",
"cmkv6j7uq000gk4043x1gfnp1",
"cmkti8i470000kz04bpsajkzk",
"cmks47ct90013l904x03rupwb",
"cmkql9bho0007l50406oflxr0",
"cmks112qf0034l104gkihwp10",
"cmks1s7ut004jl104gvlybgfs",
"cmks4pb54000bl204erpb9bbp",
"cmksfnrz1005qjv044pnraikr",
"cmkqimx480003jx04bcqob7pb",
"cmkqm1c690001jl04ge5elv46",
"cmks2a6l3005gl1040u4s0k4s",
"cmkth0k4x0026ju04t0euxxwk",
"cmkqnj1pt0001l504vmrywqeg"
]
-102
View File
@@ -1,102 +0,0 @@
[
"cmk9fpg4h0000kt048l3a2nqu",
"cmkqimx520005jx04vyegybp9",
"cmkjjb37v00064j12b84dsrwg",
"cmks0yz0h002xl1040w65w47d",
"cmkql448t0002l504m7bkovze",
"cmkqjvuo5000ejr04y9ksio48",
"cmkjjb37v00074j122wpemyas",
"cmkoutfvz0005jr04knetubim",
"cmkqklwxm0018jr04zyzlrmlv",
"cmkqjgpzq0002l104qknt9pho",
"cmkqnvcbg0001jo04us31pcaj",
"cmkqk1dbi000ljr045f2rm18m",
"cmkrv2jpg0000l204swrx5xh4",
"cmkqlcukz000dl5044c6z8542",
"cmkqk1dc5000ojr04jq0exq1z",
"cmkqimwxl0000jx04l8q0rdzo",
"cmks0rbk1002jl104uc7yg2xv",
"cmkqm1c6j0003jl04clkn9qyb",
"cmkqnn8hj0006l504j2uedblz",
"cmks0yz1e002yl104nw4apfcq",
"cmkrx8euz0007ky04hrgq5ryg",
"cmks16lb0003ml104acf4zi7e",
"cmkqklwr20016jr04opezrtpo",
"cmks003y10015l104xruieqo9",
"cmkqm1bzv0000jl04c3ks3w7w",
"cmks0c9ll0022l10436svssah",
"cmkqnn8gl0005l5042jdeuuur",
"cmkrzlq020001l104sosii49r",
"cmkrx664z0000ky0481j124cq",
"cmkqjvuir0008jr048591dzwp",
"cmkjjb38l000c4j12oucpveph",
"cmks3iu8m006wl104ndk8no10",
"cmks1aoyn003yl104r4zspprz",
"cmkqnn8mm0007l5045q6z4zb0",
"cmkrzvdfk000tl104jsq77zig",
"cmks3d1wy006ll104gltxc3et",
"cmks1aoxo003xl104gap1drce",
"cmksczul60015jv04ft4n1cb2",
"cmks0m380002gl104o6f9cjtj",
"cmkrx8f0j000aky046uomv3rs",
"cmkqjs7e50004jr042mrpi3ac",
"cmkqk4i0d000vjr04ai18b8h3",
"cmkqk4i0g000wjr04t82lzvaq",
"cmkrzlq0q0003l10453qhsniu",
"cmks1ubzb004pl104mvs08pmu",
"cmks0eqvq0028l10426ogrvkb",
"cmks13z53003el104re4468xt",
"cmks05ci1001gl104lus7os2q",
"cmks1s7p2004il104kpwprttq",
"cmkqjs7dw0003jr04uy2bguck",
"cmks5ey0j001el204vjk69nwg",
"cmkqklwxr0019jr048wp0hds8",
"cmkqjs77s0000jr041cypbjkn",
"cmkry59tc0001jy04kr21i343",
"cmk9fpg5f0003kt04fcgbvj7y",
"cmkrzxk4i0011l1045cubzwwr",
"cmkshrboj0000la04h6bmugvj",
"cmks13z46003dl1047ba0entr",
"cmkry59u70003jy049eoxzkc7",
"cmks2a6r1005il104ormxbqud",
"cmks112wx0039l104zltcl2hp",
"cmkrzvdfe000sl104q0gge67f",
"cmks3w0g0000bl904u4lmk5d5",
"cmkt8m55q000nla04kgfld9x3",
"cmkqklwxj0017jr04czubkbbi",
"cmkshubus0004la04zjuh5nn5",
"cmkqk4i0d000ujr045d2njmc7",
"cmks02o6m001al104gi1jztpn",
"cmks4roac000el204paypouzl",
"cmkt7yhut0000la040ij92vgk",
"cmks5rr2e0020l204sn9ydvka",
"cmkjjb37z00084j121651lnjl",
"cmksg1wc90061jv04114izr2d",
"cmkuulu5d000jjc04u7hx67sz",
"cmkqimx3n0001jx043mvrmarx",
"cmksf0ik1004ijv047hiusscw",
"cmktoexz8001wld046f13barg",
"cmks2gqie005ol104v4d6nvck",
"cmks0equs0026l104sl4td34d",
"cmkjjb38j000b4j125a3fg282",
"cmktkj5to001hl704wtmqjft4",
"cmkrzxk4a0010l104u7irj9je",
"cmkscgc0z0007jv04nwmzbn8c",
"cmkscxy490012jv04rf4k4h4h",
"cmkto2g8o0019ld04b61pgj4y",
"cmktk5hid000sl7049a2bo5al",
"cmkv6j7uq000gk4043x1gfnp1",
"cmkti8i470000kz04bpsajkzk",
"cmks47ct90013l904x03rupwb",
"cmkql9bho0007l50406oflxr0",
"cmks112qf0034l104gkihwp10",
"cmks1s7ut004jl104gvlybgfs",
"cmks4pb54000bl204erpb9bbp",
"cmksfnrz1005qjv044pnraikr",
"cmkqimx480003jx04bcqob7pb",
"cmkqm1c690001jl04ge5elv46",
"cmks2a6l3005gl1040u4s0k4s",
"cmkth0k4x0026ju04t0euxxwk",
"cmkuuqo3g000njc04l72uwte3",
"cmkuxtw0m002ojm04pl9qazxs"
]
-784
View File
@@ -1,784 +0,0 @@
{
"totalTags": 859,
"candidatesAfterGenericFilter": 831,
"selectedCount": 100,
"selectedProjectsCoverage": {
"coveredProjects": 453,
"totalProjects": 454,
"coverageRatio": 0.9978,
"uncoveredProjects": 1
},
"dropCount": 759,
"topSelected": [
{
"id": "cmk9fpg4h0000kt048l3a2nqu",
"name": "Python",
"nameEn": "Python",
"projects": 268
},
{
"id": "cmkqimx520005jx04vyegybp9",
"name": "AI开发工具",
"nameEn": "AI Development Tool",
"projects": 213
},
{
"id": "cmkjjb37v00064j12b84dsrwg",
"name": "TypeScript",
"nameEn": "TypeScript",
"projects": 118
},
{
"id": "cmks0yz0h002xl1040w65w47d",
"name": "AI代理",
"nameEn": "AI Agents",
"projects": 52
},
{
"id": "cmkql448t0002l504m7bkovze",
"name": "自动化",
"nameEn": "Automation",
"projects": 46
},
{
"id": "cmkqjvuo5000ejr04y9ksio48",
"name": "Docker",
"nameEn": "Docker",
"projects": 43
},
{
"id": "cmkjjb37v00074j122wpemyas",
"name": "Node.js",
"nameEn": "Node.js",
"projects": 40
},
{
"id": "cmkoutfvz0005jr04knetubim",
"name": "JavaScript",
"nameEn": "JavaScript",
"projects": 34
},
{
"id": "cmkqklwxm0018jr04zyzlrmlv",
"name": "React",
"nameEn": "React",
"projects": 34
},
{
"id": "cmkqjgpzq0002l104qknt9pho",
"name": "开源",
"nameEn": "Open Source",
"projects": 33
},
{
"id": "cmkqnvcbg0001jo04us31pcaj",
"name": "低学习成本",
"nameEn": "Low Learning Curve",
"projects": 31
},
{
"id": "cmkqk1dbi000ljr045f2rm18m",
"name": "PyTorch",
"nameEn": "PyTorch",
"projects": 30
},
{
"id": "cmkrv2jpg0000l204swrx5xh4",
"name": "LangChain",
"nameEn": "LangChain",
"projects": 29
},
{
"id": "cmkqlcukz000dl5044c6z8542",
"name": "多智能体系统",
"nameEn": "Multi-Agent System",
"projects": 28
},
{
"id": "cmkqk1dc5000ojr04jq0exq1z",
"name": "大语言模型",
"nameEn": null,
"projects": 25
},
{
"id": "cmkqimwxl0000jx04l8q0rdzo",
"name": "LLM",
"nameEn": "LLM",
"projects": 22
},
{
"id": "cmks0rbk1002jl104uc7yg2xv",
"name": "Go",
"nameEn": "Go",
"projects": 20
},
{
"id": "cmkqm1c6j0003jl04clkn9qyb",
"name": "企业级",
"nameEn": "Enterprise",
"projects": 20
},
{
"id": "cmkqnn8hj0006l504j2uedblz",
"name": "需要基础",
"nameEn": "Requires Basics",
"projects": 20
},
{
"id": "cmks0yz1e002yl104nw4apfcq",
"name": "Library",
"nameEn": "Library",
"projects": 18
},
{
"id": "cmkrx8euz0007ky04hrgq5ryg",
"name": "MCP",
"nameEn": "Model Context Protocol",
"projects": 18
},
{
"id": "cmks16lb0003ml104acf4zi7e",
"name": "Markdown",
"nameEn": "Markdown",
"projects": 17
},
{
"id": "cmkqklwr20016jr04opezrtpo",
"name": "Next.js",
"nameEn": "Next.js",
"projects": 17
},
{
"id": "cmks003y10015l104xruieqo9",
"name": "RAG",
"nameEn": "RAG",
"projects": 17
},
{
"id": "cmkqm1bzv0000jl04c3ks3w7w",
"name": "Java",
"nameEn": "Java",
"projects": 16
},
{
"id": "cmks0c9ll0022l10436svssah",
"name": "MIT许可",
"nameEn": "MIT License",
"projects": 16
},
{
"id": "cmkqnn8gl0005l5042jdeuuur",
"name": "LangGraph",
"nameEn": "LangGraph",
"projects": 15
},
{
"id": "cmkrzlq020001l104sosii49r",
"name": "复杂部署",
"nameEn": "Complex Deployment",
"projects": 14
},
{
"id": "cmkrx664z0000ky0481j124cq",
"name": "FastAPI",
"nameEn": "FastAPI",
"projects": 14
},
{
"id": "cmkqjvuir0008jr048591dzwp",
"name": "Rust",
"nameEn": "Rust",
"projects": 14
},
{
"id": "cmkjjb38l000c4j12oucpveph",
"name": "工作流自动化",
"nameEn": "Workflow Automation",
"projects": 14
},
{
"id": "cmks3iu8m006wl104ndk8no10",
"name": "C#",
"nameEn": "C#",
"projects": 11
},
{
"id": "cmks1aoyn003yl104r4zspprz",
"name": "机器学习",
"nameEn": "Machine Learning",
"projects": 11
},
{
"id": "cmkqnn8mm0007l5045q6z4zb0",
"name": "OpenAI",
"nameEn": "OpenAI",
"projects": 11
},
{
"id": "cmkrzvdfk000tl104jsq77zig",
"name": "OpenAI API",
"nameEn": "OpenAI API",
"projects": 11
},
{
"id": "cmks3d1wy006ll104gltxc3et",
"name": "SDK",
"nameEn": "SDK",
"projects": 11
},
{
"id": "cmks1aoxo003xl104gap1drce",
"name": "强化学习",
"nameEn": "Reinforcement Learning",
"projects": 11
},
{
"id": "cmksczul60015jv04ft4n1cb2",
"name": "GitHub",
"nameEn": "GitHub",
"projects": 10
},
{
"id": "cmks0m380002gl104o6f9cjtj",
"name": "PostgreSQL",
"nameEn": "PostgreSQL",
"projects": 10
},
{
"id": "cmkrx8f0j000aky046uomv3rs",
"name": "多模态",
"nameEn": "Multimodal",
"projects": 10
},
{
"id": "cmkqjs7e50004jr042mrpi3ac",
"name": "桌面应用",
"nameEn": null,
"projects": 10
},
{
"id": "cmkqk4i0d000vjr04ai18b8h3",
"name": "知识管理",
"nameEn": null,
"projects": 10
},
{
"id": "cmkqk4i0g000wjr04t82lzvaq",
"name": "向量数据库",
"nameEn": null,
"projects": 9
},
{
"id": "cmkrzlq0q0003l10453qhsniu",
"name": "Transformers",
"nameEn": "Transformers",
"projects": 8
},
{
"id": "cmks1ubzb004pl104mvs08pmu",
"name": "vLLM",
"nameEn": "vLLM",
"projects": 8
},
{
"id": "cmks0eqvq0028l10426ogrvkb",
"name": "云端服务",
"nameEn": "Cloud Service",
"projects": 8
},
{
"id": "cmks13z53003el104re4468xt",
"name": "代码生成",
"nameEn": "Code Generation",
"projects": 8
},
{
"id": "cmks05ci1001gl104lus7os2q",
"name": "企业级AI应用",
"nameEn": "Enterprise AI",
"projects": 8
},
{
"id": "cmks1s7p2004il104kpwprttq",
"name": "智能体框架",
"nameEn": "Agent Framework",
"projects": 8
},
{
"id": "cmkqjs7dw0003jr04uy2bguck",
"name": "跨平台",
"nameEn": null,
"projects": 8
},
{
"id": "cmks5ey0j001el204vjk69nwg",
"name": "AI安全",
"nameEn": "AI Security",
"projects": 7
},
{
"id": "cmkqklwxr0019jr048wp0hds8",
"name": "AI 助手",
"nameEn": "AI Assistant",
"projects": 7
},
{
"id": "cmkqjs77s0000jr041cypbjkn",
"name": "Electron",
"nameEn": "Electron",
"projects": 7
},
{
"id": "cmkry59tc0001jy04kr21i343",
"name": "Playwright",
"nameEn": "Playwright",
"projects": 7
},
{
"id": "cmk9fpg5f0003kt04fcgbvj7y",
"name": "多智能体",
"nameEn": "Multi-Agent",
"projects": 7
},
{
"id": "cmkrzxk4i0011l1045cubzwwr",
"name": "深度学习",
"nameEn": "Deep Learning",
"projects": 7
},
{
"id": "cmkshrboj0000la04h6bmugvj",
"name": "学术研究",
"nameEn": "Academic Research",
"projects": 6
},
{
"id": "cmks13z46003dl1047ba0entr",
"name": "AI代理框架",
"nameEn": "AI Agent Framework",
"projects": 6
},
{
"id": "cmkry59u70003jy049eoxzkc7",
"name": "AI研究",
"nameEn": "AI Research",
"projects": 6
},
{
"id": "cmks2a6r1005il104ormxbqud",
"name": "Web应用",
"nameEn": "Web Application",
"projects": 6
},
{
"id": "cmks112wx0039l104zltcl2hp",
"name": "工作流编排",
"nameEn": "Workflow Orchestration",
"projects": 6
},
{
"id": "cmkrzvdfe000sl104q0gge67f",
"name": "教程",
"nameEn": "Tutorial",
"projects": 6
},
{
"id": "cmks3w0g0000bl904u4lmk5d5",
"name": "浏览器自动化",
"nameEn": "Browser Automation",
"projects": 6
},
{
"id": "cmkt8m55q000nla04kgfld9x3",
"name": "知识图谱",
"nameEn": "Knowledge Graph",
"projects": 6
},
{
"id": "cmkqklwxj0017jr04czubkbbi",
"name": "知识库",
"nameEn": "Knowledge Base",
"projects": 6
},
{
"id": "cmkshubus0004la04zjuh5nn5",
"name": "自主智能体",
"nameEn": "Autonomous Agents",
"projects": 6
},
{
"id": "cmkqk4i0d000ujr045d2njmc7",
"name": "隐私保护",
"nameEn": null,
"projects": 6
},
{
"id": "cmks02o6m001al104gi1jztpn",
"name": "Apache-2.0",
"nameEn": "Apache-2.0",
"projects": 5
},
{
"id": "cmks4roac000el204paypouzl",
"name": "CLI",
"nameEn": "CLI",
"projects": 5
},
{
"id": "cmkt7yhut0000la040ij92vgk",
"name": "MCP协议",
"nameEn": "MCP Protocol",
"projects": 5
},
{
"id": "cmks5rr2e0020l204sn9ydvka",
"name": "Nest.js",
"nameEn": "Nest.js",
"projects": 5
},
{
"id": "cmkjjb37z00084j121651lnjl",
"name": "Vue.js",
"nameEn": "Vue.js",
"projects": 5
},
{
"id": "cmksg1wc90061jv04114izr2d",
"name": "Web自动化",
"nameEn": "Web Automation",
"projects": 5
},
{
"id": "cmkuulu5d000jjc04u7hx67sz",
"name": "分布式系统",
"nameEn": "Distributed Systems",
"projects": 5
},
{
"id": "cmkqimx3n0001jx043mvrmarx",
"name": "可视化",
"nameEn": "Visualization",
"projects": 5
},
{
"id": "cmksf0ik1004ijv047hiusscw",
"name": "多模态AI",
"nameEn": "Multimodal AI",
"projects": 5
},
{
"id": "cmktoexz8001wld046f13barg",
"name": "多语言支持",
"nameEn": "Multi-language Support",
"projects": 5
},
{
"id": "cmks2gqie005ol104v4d6nvck",
"name": "数据分析",
"nameEn": "Data Analytics",
"projects": 5
},
{
"id": "cmks0equs0026l104sl4td34d",
"name": "聊天机器人",
"nameEn": "Chatbot",
"projects": 5
},
{
"id": "cmkjjb38j000b4j125a3fg282",
"name": "自托管",
"nameEn": "Self-hosted",
"projects": 5
},
{
"id": "cmktkj5to001hl704wtmqjft4",
"name": "自然语言处理",
"nameEn": "Natural Language Processing",
"projects": 5
},
{
"id": "cmkrzxk4a0010l104u7irj9je",
"name": "计算机视觉",
"nameEn": "Computer Vision",
"projects": 5
},
{
"id": "cmkscgc0z0007jv04nwmzbn8c",
"name": "工具调用",
"nameEn": "Tool Calling",
"projects": 4
},
{
"id": "cmkscxy490012jv04rf4k4h4h",
"name": "学术资源",
"nameEn": "Academic Resource",
"projects": 4
},
{
"id": "cmkto2g8o0019ld04b61pgj4y",
"name": "AI研究资源",
"nameEn": "AI Research Resource",
"projects": 4
},
{
"id": "cmktk5hid000sl7049a2bo5al",
"name": "Benchmark",
"nameEn": "Benchmark",
"projects": 4
},
{
"id": "cmkv6j7uq000gk4043x1gfnp1",
"name": "Bun",
"nameEn": "Bun",
"projects": 4
},
{
"id": "cmkti8i470000kz04bpsajkzk",
"name": "ChromaDB",
"nameEn": "ChromaDB",
"projects": 4
},
{
"id": "cmks47ct90013l904x03rupwb",
"name": "Flask",
"nameEn": "Flask",
"projects": 4
},
{
"id": "cmkql9bho0007l50406oflxr0",
"name": "Gradio",
"nameEn": "Gradio",
"projects": 4
},
{
"id": "cmks112qf0034l104gkihwp10",
"name": "JavaScript/TypeScript",
"nameEn": "JavaScript/TypeScript",
"projects": 4
},
{
"id": "cmks1s7ut004jl104gvlybgfs",
"name": "LiteLLM",
"nameEn": "LiteLLM",
"projects": 4
},
{
"id": "cmks4pb54000bl204erpb9bbp",
"name": "LlamaIndex",
"nameEn": "LlamaIndex",
"projects": 4
},
{
"id": "cmksfnrz1005qjv044pnraikr",
"name": "LLM应用",
"nameEn": "LLM Applications",
"projects": 4
},
{
"id": "cmkqimx480003jx04bcqob7pb",
"name": "NLP",
"nameEn": "NLP",
"projects": 4
},
{
"id": "cmkqm1c690001jl04ge5elv46",
"name": "Spring Boot",
"nameEn": "Spring Boot",
"projects": 4
},
{
"id": "cmks2a6l3005gl1040u4s0k4s",
"name": "Streamlit",
"nameEn": "Streamlit",
"projects": 4
},
{
"id": "cmkth0k4x0026ju04t0euxxwk",
"name": "VS Code扩展",
"nameEn": "VS Code Extension",
"projects": 4
},
{
"id": "cmkuuqo3g000njc04l72uwte3",
"name": "文件系统操作",
"nameEn": "File System Operations",
"projects": 2
},
{
"id": "cmkuxtw0m002ojm04pl9qazxs",
"name": "用户通知",
"nameEn": "User Notification",
"projects": 1
}
],
"genericRemovedTop": [
{
"id": "cmkoutfta0001jr04qlwpr3xh",
"name": "活跃维护",
"nameEn": "Active Maintenance",
"projects": 317
},
{
"id": "cmkqk1dbs000njr0418v38jh4",
"name": "可商用",
"nameEn": null,
"projects": 171
},
{
"id": "cmkqnj1pt0001l504vmrywqeg",
"name": "需要配置",
"nameEn": "Requires Configuration",
"projects": 163
},
{
"id": "cmkqk1div000qjr04ckie91k5",
"name": "AI",
"nameEn": "AI",
"projects": 115
},
{
"id": "cmkoutfub0004jr049qn5fxc0",
"name": "易上手",
"nameEn": "Easy to Start",
"projects": 107
},
{
"id": "cmkoutftj0002jr04xqiuu0cb",
"name": "框架",
"nameEn": "Framework",
"projects": 90
},
{
"id": "cmkqk1d5o000kjr049wqf7i5o",
"name": "开发工具",
"nameEn": null,
"projects": 88
},
{
"id": "cmkqnvc540000jo04qrexxuhd",
"name": "开箱即用",
"nameEn": "Ready to Use",
"projects": 72
},
{
"id": "cmkqkapuh0010jr043s59my3y",
"name": "文档完善",
"nameEn": "Well Documented",
"projects": 69
},
{
"id": "cmkqk4i0g000xjr04sn06e7wt",
"name": "本地部署",
"nameEn": null,
"projects": 38
},
{
"id": "cmkoutfu10003jr04dw11uzvu",
"name": "有示例",
"nameEn": "Has Examples",
"projects": 36
},
{
"id": "cmkql442c0000l504qbomadup",
"name": "需要Docker",
"nameEn": "Requires Docker",
"projects": 36
},
{
"id": "cmkryxmob0003l80414uo5zqz",
"name": "CLI工具",
"nameEn": "CLI Tool",
"projects": 33
},
{
"id": "cmks1fjqc0042l104cqi0dh9v",
"name": "工具",
"nameEn": "Tool",
"projects": 33
},
{
"id": "cmkqkaq140011jr04n5aqr763",
"name": "平台",
"nameEn": "Platform",
"projects": 29
},
{
"id": "cmkrzlq0e0002l104jfnarvv8",
"name": "智能体",
"nameEn": "Agent",
"projects": 16
},
{
"id": "cmk9fpg560001kt04ab5369hg",
"name": "开发框架",
"nameEn": "Development Framework",
"projects": 10
},
{
"id": "cmk9fpg580002kt04sslxarl5",
"name": "人工智能",
"nameEn": "Artificial Intelligence",
"projects": 7
},
{
"id": "cmkrv2jvc0001l204703u5836",
"name": "开发者工具",
"nameEn": "Developer Tool",
"projects": 4
},
{
"id": "cmksdlj9o0029jv04x9tdyc11",
"name": "科研工具",
"nameEn": "Research Tool",
"projects": 4
},
{
"id": "cmks4tb3k000jl204z6oxfuyh",
"name": "终端工具",
"nameEn": "Terminal Tools",
"projects": 4
},
{
"id": "cmkqjgpzv0003l104asyyc6dg",
"name": "低代码平台",
"nameEn": "Low-Code Platform",
"projects": 3
},
{
"id": "cmkrxh1kc000hky04rpxvrj1w",
"name": "数据库工具",
"nameEn": "Database Tools",
"projects": 3
},
{
"id": "cmks05cng001il1049zztjibt",
"name": "Agent平台",
"nameEn": "Agent Platform",
"projects": 3
},
{
"id": "cmkjjb38300094j12lum5vwjj",
"name": "集成平台",
"nameEn": "Integration Platform",
"projects": 2
},
{
"id": "cmkt8ex6g000ala0488t486bj",
"name": "平台/框架",
"nameEn": "Platform/Framework",
"projects": 1
},
{
"id": "cmktpw9pe003tld04fjjgo4zt",
"name": "系统监控",
"nameEn": "System Monitoring",
"projects": 1
},
{
"id": "cmkuxtvzn002mjm04wdcx9wwi",
"name": "系统通知",
"nameEn": "System Notification",
"projects": 1
}
]
}
@@ -1,305 +0,0 @@
{
"totalTags": 859,
"totalProjects": 454,
"activeProjects": 444,
"distributionAtLeast": {
"1": 859,
"2": 283,
"3": 169,
"4": 130,
"5": 100,
"6": 85,
"8": 67,
"10": 59,
"15": 43,
"20": 34,
"30": 26,
"50": 13
},
"top100CoverageProjects": 453,
"projectsWithoutTop100Tag": 1,
"top20": [
{
"name": "活跃维护",
"nameEn": "Active Maintenance",
"projects": 317
},
{
"name": "Python",
"nameEn": "Python",
"projects": 268
},
{
"name": "AI开发工具",
"nameEn": "AI Development Tool",
"projects": 213
},
{
"name": "可商用",
"nameEn": null,
"projects": 171
},
{
"name": "需要配置",
"nameEn": "Requires Configuration",
"projects": 163
},
{
"name": "TypeScript",
"nameEn": "TypeScript",
"projects": 118
},
{
"name": "AI",
"nameEn": "AI",
"projects": 115
},
{
"name": "易上手",
"nameEn": "Easy to Start",
"projects": 107
},
{
"name": "框架",
"nameEn": "Framework",
"projects": 90
},
{
"name": "开发工具",
"nameEn": null,
"projects": 88
},
{
"name": "开箱即用",
"nameEn": "Ready to Use",
"projects": 72
},
{
"name": "文档完善",
"nameEn": "Well Documented",
"projects": 69
},
{
"name": "AI代理",
"nameEn": "AI Agents",
"projects": 52
},
{
"name": "自动化",
"nameEn": "Automation",
"projects": 46
},
{
"name": "Docker",
"nameEn": "Docker",
"projects": 43
},
{
"name": "Node.js",
"nameEn": "Node.js",
"projects": 40
},
{
"name": "本地部署",
"nameEn": null,
"projects": 38
},
{
"name": "有示例",
"nameEn": "Has Examples",
"projects": 36
},
{
"name": "需要Docker",
"nameEn": "Requires Docker",
"projects": 36
},
{
"name": "JavaScript",
"nameEn": "JavaScript",
"projects": 34
}
],
"genericTagsTop30": [
{
"id": "cmkqimx520005jx04vyegybp9",
"name": "AI开发工具",
"nameEn": "AI Development Tool",
"projects": 213
},
{
"id": "cmkqk1div000qjr04ckie91k5",
"name": "AI",
"nameEn": "AI",
"projects": 115
},
{
"id": "cmkoutftj0002jr04xqiuu0cb",
"name": "框架",
"nameEn": "Framework",
"projects": 90
},
{
"id": "cmkqk1d5o000kjr049wqf7i5o",
"name": "开发工具",
"nameEn": null,
"projects": 88
},
{
"id": "cmks0yz0h002xl1040w65w47d",
"name": "AI代理",
"nameEn": "AI Agents",
"projects": 52
},
{
"id": "cmkryxmob0003l80414uo5zqz",
"name": "CLI工具",
"nameEn": "CLI Tool",
"projects": 33
},
{
"id": "cmks1fjqc0042l104cqi0dh9v",
"name": "工具",
"nameEn": "Tool",
"projects": 33
},
{
"id": "cmkqkaq140011jr04n5aqr763",
"name": "平台",
"nameEn": "Platform",
"projects": 29
},
{
"id": "cmkqlcukz000dl5044c6z8542",
"name": "多智能体系统",
"nameEn": "Multi-Agent System",
"projects": 28
},
{
"id": "cmkrzlq0e0002l104jfnarvv8",
"name": "智能体",
"nameEn": "Agent",
"projects": 16
},
{
"id": "cmk9fpg560001kt04ab5369hg",
"name": "开发框架",
"nameEn": "Development Framework",
"projects": 10
},
{
"id": "cmks05ci1001gl104lus7os2q",
"name": "企业级AI应用",
"nameEn": "Enterprise AI",
"projects": 8
},
{
"id": "cmks1s7p2004il104kpwprttq",
"name": "智能体框架",
"nameEn": "Agent Framework",
"projects": 8
},
{
"id": "cmk9fpg580002kt04sslxarl5",
"name": "人工智能",
"nameEn": "Artificial Intelligence",
"projects": 7
},
{
"id": "cmk9fpg5f0003kt04fcgbvj7y",
"name": "多智能体",
"nameEn": "Multi-Agent",
"projects": 7
},
{
"id": "cmkqklwxr0019jr048wp0hds8",
"name": "AI 助手",
"nameEn": "AI Assistant",
"projects": 7
},
{
"id": "cmkshubus0004la04zjuh5nn5",
"name": "自主智能体",
"nameEn": "Autonomous Agents",
"projects": 6
},
{
"id": "cmks13z46003dl1047ba0entr",
"name": "AI代理框架",
"nameEn": "AI Agent Framework",
"projects": 6
},
{
"id": "cmksf0ik1004ijv047hiusscw",
"name": "多模态AI",
"nameEn": "Multimodal AI",
"projects": 5
},
{
"id": "cmkrv2jvc0001l204703u5836",
"name": "开发者工具",
"nameEn": "Developer Tool",
"projects": 4
},
{
"id": "cmksdlj9o0029jv04x9tdyc11",
"name": "科研工具",
"nameEn": "Research Tool",
"projects": 4
},
{
"id": "cmksf2ncg004pjv0446rsr0e6",
"name": "生成式AI",
"nameEn": "Generative AI",
"projects": 4
},
{
"id": "cmks4tb3k000jl204z6oxfuyh",
"name": "终端工具",
"nameEn": "Terminal Tools",
"projects": 4
},
{
"id": "cmkqjgpzv0003l104asyyc6dg",
"name": "低代码平台",
"nameEn": "Low-Code Platform",
"projects": 3
},
{
"id": "cmkqlcuqx000fl504ywms66me",
"name": "AI开发框架",
"nameEn": "AI Development Framework",
"projects": 3
},
{
"id": "cmkujqy2l0007jx04w5zxt8be",
"name": "游戏AI",
"nameEn": "Game AI",
"projects": 3
},
{
"id": "cmkrxh1kc000hky04rpxvrj1w",
"name": "数据库工具",
"nameEn": "Database Tools",
"projects": 3
},
{
"id": "cmks05cng001il1049zztjibt",
"name": "Agent平台",
"nameEn": "Agent Platform",
"projects": 3
},
{
"id": "cmks3y6z0000hl9048h3bv62n",
"name": "记忆系统",
"nameEn": "Memory System",
"projects": 3
},
{
"id": "cmkjjb38300094j12lum5vwjj",
"name": "集成平台",
"nameEn": "Integration Platform",
"projects": 2
}
]
}
@@ -1,163 +0,0 @@
{
"totalTags": 100,
"totalProjectTags": 1888,
"zeroTags": 0,
"aiTagExists": false,
"aiTag": null,
"projectCoverage": {
"covered": 454,
"total": 454
},
"top30": [
{
"name": "Python",
"nameEn": "Python",
"projects": 268
},
{
"name": "AI开发工具",
"nameEn": "AI Development Tool",
"projects": 213
},
{
"name": "需要配置",
"nameEn": "Requires Configuration",
"projects": 163
},
{
"name": "TypeScript",
"nameEn": "TypeScript",
"projects": 118
},
{
"name": "AI代理",
"nameEn": "AI Agents",
"projects": 52
},
{
"name": "自动化",
"nameEn": "Automation",
"projects": 46
},
{
"name": "Docker",
"nameEn": "Docker",
"projects": 43
},
{
"name": "Node.js",
"nameEn": "Node.js",
"projects": 40
},
{
"name": "JavaScript",
"nameEn": "JavaScript",
"projects": 34
},
{
"name": "React",
"nameEn": "React",
"projects": 34
},
{
"name": "开源",
"nameEn": "Open Source",
"projects": 33
},
{
"name": "低学习成本",
"nameEn": "Low Learning Curve",
"projects": 31
},
{
"name": "PyTorch",
"nameEn": "PyTorch",
"projects": 30
},
{
"name": "LangChain",
"nameEn": "LangChain",
"projects": 29
},
{
"name": "多智能体系统",
"nameEn": "Multi-Agent System",
"projects": 28
},
{
"name": "大语言模型",
"nameEn": null,
"projects": 25
},
{
"name": "LLM",
"nameEn": "LLM",
"projects": 22
},
{
"name": "Go",
"nameEn": "Go",
"projects": 20
},
{
"name": "企业级",
"nameEn": "Enterprise",
"projects": 20
},
{
"name": "需要基础",
"nameEn": "Requires Basics",
"projects": 20
},
{
"name": "Library",
"nameEn": "Library",
"projects": 18
},
{
"name": "MCP",
"nameEn": "Model Context Protocol",
"projects": 18
},
{
"name": "Markdown",
"nameEn": "Markdown",
"projects": 17
},
{
"name": "Next.js",
"nameEn": "Next.js",
"projects": 17
},
{
"name": "RAG",
"nameEn": "RAG",
"projects": 17
},
{
"name": "Java",
"nameEn": "Java",
"projects": 16
},
{
"name": "MIT许可",
"nameEn": "MIT License",
"projects": 16
},
{
"name": "LangGraph",
"nameEn": "LangGraph",
"projects": 15
},
{
"name": "FastAPI",
"nameEn": "FastAPI",
"projects": 14
},
{
"name": "Rust",
"nameEn": "Rust",
"projects": 14
}
]
}
@@ -1,44 +0,0 @@
{
"generatedAt": "2026-02-21T03:47:31.204Z",
"mergeGroupCount": 6,
"mergePairCount": 6,
"mergePairs": [
{
"sourceId": "cmkoutg0o0006jr04goozjhft",
"targetId": "cmkqimx520005jx04vyegybp9",
"key": "ai development tool"
},
{
"sourceId": "cmkjjb387000a4j12pdf123zn",
"targetId": "cmks0yz0h002xl1040w65w47d",
"key": "ai agent"
},
{
"sourceId": "cmkun0ao70048jx04atm2lwwq",
"targetId": "cmkryxmob0003l80414uo5zqz",
"key": "cli tool"
},
{
"sourceId": "cmkrzqdlu000fl104qmy9gu2u",
"targetId": "cmkqlcukz000dl5044c6z8542",
"key": "multi agent system"
},
{
"sourceId": "cmktk3biu000nl704iiohmv4i",
"targetId": "cmksdlj9o0029jv04x9tdyc11",
"key": "research tool"
},
{
"sourceId": "cmktpotkp003eld04mv09ckde",
"targetId": "cmkshubus0004la04zjuh5nn5",
"key": "autonomous agent"
}
],
"before": {
"totalTags": 955,
"totalProjectTags": 4131,
"zeroTags": 90,
"missingNameEnAscii": null
},
"sqlPath": ".sisyphus/sql/tag-cleanup-2026-02-21.sql"
}
@@ -1,42 +0,0 @@
{
"after": {
"totalTags": 859,
"totalProjectTags": 4131,
"zeroTags": 0,
"missingNameEn": 17,
"missingNameEnAscii": 0
},
"deletedSourceTagsStillExists": [],
"mergedTargetProjectCounts": [
{
"id": "cmkshubus0004la04zjuh5nn5",
"name": "自主智能体",
"projects": 6
},
{
"id": "cmkryxmob0003l80414uo5zqz",
"name": "CLI工具",
"projects": 33
},
{
"id": "cmkqlcukz000dl5044c6z8542",
"name": "多智能体系统",
"projects": 28
},
{
"id": "cmkqimx520005jx04vyegybp9",
"name": "AI开发工具",
"projects": 213
},
{
"id": "cmks0yz0h002xl1040w65w47d",
"name": "AI代理",
"projects": 52
},
{
"id": "cmksdlj9o0029jv04x9tdyc11",
"name": "科研工具",
"projects": 4
}
]
}
File diff suppressed because it is too large Load Diff
@@ -1,132 +0,0 @@
{
"executedAt": "2026-02-21T04:05:08.555Z",
"totalTags": 83,
"totalProjectTags": 1547,
"zeroTags": 0,
"totalProjects": 454,
"projectsWithTags": 454,
"projectsWithoutTags": 0,
"aiTagExists": false,
"removedNoise": [
"开源",
"学术资源",
"复杂部署",
"用户通知",
"需要配置",
"低学习成本",
"云端服务",
"MIT许可",
"Apache-2.0",
"教程",
"企业级",
"需要基础",
"多语言支持",
"AI研究资源",
"跨平台",
"自托管",
"学术研究"
],
"removedNoiseStillExists": [],
"topTags": [
{
"name": "Python",
"nameEn": "Python",
"projectCount": 268
},
{
"name": "AI开发工具",
"nameEn": "AI Development Tool",
"projectCount": 215
},
{
"name": "TypeScript",
"nameEn": "TypeScript",
"projectCount": 118
},
{
"name": "AI代理",
"nameEn": "AI Agents",
"projectCount": 54
},
{
"name": "自动化",
"nameEn": "Automation",
"projectCount": 47
},
{
"name": "Docker",
"nameEn": "Docker",
"projectCount": 43
},
{
"name": "Node.js",
"nameEn": "Node.js",
"projectCount": 40
},
{
"name": "JavaScript",
"nameEn": "JavaScript",
"projectCount": 34
},
{
"name": "React",
"nameEn": "React",
"projectCount": 34
},
{
"name": "PyTorch",
"nameEn": "PyTorch",
"projectCount": 30
},
{
"name": "LangChain",
"nameEn": "LangChain",
"projectCount": 29
},
{
"name": "多智能体系统",
"nameEn": "Multi-Agent System",
"projectCount": 28
},
{
"name": "大语言模型",
"nameEn": null,
"projectCount": 25
},
{
"name": "LLM",
"nameEn": "LLM",
"projectCount": 22
},
{
"name": "Go",
"nameEn": "Go",
"projectCount": 20
},
{
"name": "Library",
"nameEn": "Library",
"projectCount": 18
},
{
"name": "MCP",
"nameEn": "Model Context Protocol",
"projectCount": 18
},
{
"name": "RAG",
"nameEn": "RAG",
"projectCount": 17
},
{
"name": "Next.js",
"nameEn": "Next.js",
"projectCount": 17
},
{
"name": "Markdown",
"nameEn": "Markdown",
"projectCount": 17
}
]
}
@@ -1,127 +0,0 @@
{
"executedAt": "2026-02-21T04:03:10.320Z",
"removedNames": [
"自托管",
"跨平台",
"企业级",
"需要基础",
"AI研究资源",
"多语言支持",
"教程",
"Apache-2.0",
"学术研究",
"MIT许可",
"云端服务",
"低学习成本"
],
"removedStillExists": [],
"totalTags": 88,
"totalProjectTags": 1754,
"zeroTags": 0,
"totalProjects": 454,
"projectsWithTags": 454,
"projectsWithoutTags": 0,
"aiTagExists": false,
"topTags": [
{
"name": "Python",
"nameEn": "Python",
"projectCount": 268
},
{
"name": "AI开发工具",
"nameEn": "AI Development Tool",
"projectCount": 213
},
{
"name": "需要配置",
"nameEn": "Requires Configuration",
"projectCount": 163
},
{
"name": "TypeScript",
"nameEn": "TypeScript",
"projectCount": 118
},
{
"name": "AI代理",
"nameEn": "AI Agents",
"projectCount": 52
},
{
"name": "自动化",
"nameEn": "Automation",
"projectCount": 46
},
{
"name": "Docker",
"nameEn": "Docker",
"projectCount": 43
},
{
"name": "Node.js",
"nameEn": "Node.js",
"projectCount": 40
},
{
"name": "JavaScript",
"nameEn": "JavaScript",
"projectCount": 34
},
{
"name": "React",
"nameEn": "React",
"projectCount": 34
},
{
"name": "开源",
"nameEn": "Open Source",
"projectCount": 33
},
{
"name": "PyTorch",
"nameEn": "PyTorch",
"projectCount": 30
},
{
"name": "LangChain",
"nameEn": "LangChain",
"projectCount": 29
},
{
"name": "多智能体系统",
"nameEn": "Multi-Agent System",
"projectCount": 28
},
{
"name": "大语言模型",
"nameEn": null,
"projectCount": 25
},
{
"name": "LLM",
"nameEn": "LLM",
"projectCount": 22
},
{
"name": "Go",
"nameEn": "Go",
"projectCount": 20
},
{
"name": "MCP",
"nameEn": "Model Context Protocol",
"projectCount": 18
},
{
"name": "Library",
"nameEn": "Library",
"projectCount": 18
},
{
"name": "RAG",
"nameEn": "RAG",
"projectCount": 17
}
]
}
-211
View File
@@ -1,211 +0,0 @@
BEGIN;
DELETE FROM "project_tags"
WHERE "tagId" NOT IN (
'cmk9fpg4h0000kt048l3a2nqu',
'cmkjjb37v00064j12b84dsrwg',
'cmkqimx520005jx04vyegybp9',
'cmks16lb0003ml104acf4zi7e',
'cmks0yz0h002xl1040w65w47d',
'cmkqjgpzq0002l104qknt9pho',
'cmkql448t0002l504m7bkovze',
'cmkqm1bzv0000jl04c3ks3w7w',
'cmkoutfvz0005jr04knetubim',
'cmks0rbk1002jl104uc7yg2xv',
'cmks3iu8m006wl104ndk8no10',
'cmkqk1dc5000ojr04jq0exq1z',
'cmkqimwxl0000jx04l8q0rdzo',
'cmks0yz1e002yl104nw4apfcq',
'cmkqjvuo5000ejr04y9ksio48',
'cmkjjb37v00074j122wpemyas',
'cmkqnvcbg0001jo04us31pcaj',
'cmkrzlq020001l104sosii49r',
'cmks1aoyn003yl104r4zspprz',
'cmks5ey0j001el204vjk69nwg',
'cmkshrboj0000la04h6bmugvj',
'cmkscgc0z0007jv04nwmzbn8c',
'cmkscxy490012jv04rf4k4h4h',
'cmkuxtw0m002ojm04pl9qazxs',
'cmkqklwxm0018jr04zyzlrmlv',
'cmkqk1dbi000ljr045f2rm18m',
'cmkrv2jpg0000l204swrx5xh4',
'cmkqlcukz000dl5044c6z8542',
'cmkqm1c6j0003jl04clkn9qyb',
'cmkqnn8hj0006l504j2uedblz',
'cmkrx8euz0007ky04hrgq5ryg',
'cmkqklwr20016jr04opezrtpo',
'cmks003y10015l104xruieqo9',
'cmks0c9ll0022l10436svssah',
'cmkqnn8gl0005l5042jdeuuur',
'cmkrx664z0000ky0481j124cq',
'cmkqjvuir0008jr048591dzwp',
'cmkjjb38l000c4j12oucpveph',
'cmkqnn8mm0007l5045q6z4zb0',
'cmkrzvdfk000tl104jsq77zig',
'cmks3d1wy006ll104gltxc3et',
'cmks1aoxo003xl104gap1drce',
'cmksczul60015jv04ft4n1cb2',
'cmks0m380002gl104o6f9cjtj',
'cmkrx8f0j000aky046uomv3rs',
'cmkqjs7e50004jr042mrpi3ac',
'cmkqk4i0d000vjr04ai18b8h3',
'cmkqk4i0g000wjr04t82lzvaq',
'cmkrzlq0q0003l10453qhsniu',
'cmks1ubzb004pl104mvs08pmu',
'cmks0eqvq0028l10426ogrvkb',
'cmks13z53003el104re4468xt',
'cmks05ci1001gl104lus7os2q',
'cmks1s7p2004il104kpwprttq',
'cmkqjs7dw0003jr04uy2bguck',
'cmkqklwxr0019jr048wp0hds8',
'cmkqjs77s0000jr041cypbjkn',
'cmkry59tc0001jy04kr21i343',
'cmk9fpg5f0003kt04fcgbvj7y',
'cmkrzxk4i0011l1045cubzwwr',
'cmks13z46003dl1047ba0entr',
'cmkry59u70003jy049eoxzkc7',
'cmks2a6r1005il104ormxbqud',
'cmks112wx0039l104zltcl2hp',
'cmkrzvdfe000sl104q0gge67f',
'cmks3w0g0000bl904u4lmk5d5',
'cmkt8m55q000nla04kgfld9x3',
'cmkqklwxj0017jr04czubkbbi',
'cmkshubus0004la04zjuh5nn5',
'cmkqk4i0d000ujr045d2njmc7',
'cmks02o6m001al104gi1jztpn',
'cmks4roac000el204paypouzl',
'cmkt7yhut0000la040ij92vgk',
'cmks5rr2e0020l204sn9ydvka',
'cmkjjb37z00084j121651lnjl',
'cmksg1wc90061jv04114izr2d',
'cmkuulu5d000jjc04u7hx67sz',
'cmkqimx3n0001jx043mvrmarx',
'cmksf0ik1004ijv047hiusscw',
'cmktoexz8001wld046f13barg',
'cmks2gqie005ol104v4d6nvck',
'cmks0equs0026l104sl4td34d',
'cmkjjb38j000b4j125a3fg282',
'cmktkj5to001hl704wtmqjft4',
'cmkrzxk4a0010l104u7irj9je',
'cmkto2g8o0019ld04b61pgj4y',
'cmktk5hid000sl7049a2bo5al',
'cmkv6j7uq000gk4043x1gfnp1',
'cmkti8i470000kz04bpsajkzk',
'cmks47ct90013l904x03rupwb',
'cmkql9bho0007l50406oflxr0',
'cmks112qf0034l104gkihwp10',
'cmks1s7ut004jl104gvlybgfs',
'cmks4pb54000bl204erpb9bbp',
'cmksfnrz1005qjv044pnraikr',
'cmkqimx480003jx04bcqob7pb',
'cmkqm1c690001jl04ge5elv46',
'cmks2a6l3005gl1040u4s0k4s',
'cmkth0k4x0026ju04t0euxxwk',
'cmkqnj1pt0001l504vmrywqeg'
);
DELETE FROM "tags"
WHERE "id" NOT IN (
'cmk9fpg4h0000kt048l3a2nqu',
'cmkjjb37v00064j12b84dsrwg',
'cmkqimx520005jx04vyegybp9',
'cmks16lb0003ml104acf4zi7e',
'cmks0yz0h002xl1040w65w47d',
'cmkqjgpzq0002l104qknt9pho',
'cmkql448t0002l504m7bkovze',
'cmkqm1bzv0000jl04c3ks3w7w',
'cmkoutfvz0005jr04knetubim',
'cmks0rbk1002jl104uc7yg2xv',
'cmks3iu8m006wl104ndk8no10',
'cmkqk1dc5000ojr04jq0exq1z',
'cmkqimwxl0000jx04l8q0rdzo',
'cmks0yz1e002yl104nw4apfcq',
'cmkqjvuo5000ejr04y9ksio48',
'cmkjjb37v00074j122wpemyas',
'cmkqnvcbg0001jo04us31pcaj',
'cmkrzlq020001l104sosii49r',
'cmks1aoyn003yl104r4zspprz',
'cmks5ey0j001el204vjk69nwg',
'cmkshrboj0000la04h6bmugvj',
'cmkscgc0z0007jv04nwmzbn8c',
'cmkscxy490012jv04rf4k4h4h',
'cmkuxtw0m002ojm04pl9qazxs',
'cmkqklwxm0018jr04zyzlrmlv',
'cmkqk1dbi000ljr045f2rm18m',
'cmkrv2jpg0000l204swrx5xh4',
'cmkqlcukz000dl5044c6z8542',
'cmkqm1c6j0003jl04clkn9qyb',
'cmkqnn8hj0006l504j2uedblz',
'cmkrx8euz0007ky04hrgq5ryg',
'cmkqklwr20016jr04opezrtpo',
'cmks003y10015l104xruieqo9',
'cmks0c9ll0022l10436svssah',
'cmkqnn8gl0005l5042jdeuuur',
'cmkrx664z0000ky0481j124cq',
'cmkqjvuir0008jr048591dzwp',
'cmkjjb38l000c4j12oucpveph',
'cmkqnn8mm0007l5045q6z4zb0',
'cmkrzvdfk000tl104jsq77zig',
'cmks3d1wy006ll104gltxc3et',
'cmks1aoxo003xl104gap1drce',
'cmksczul60015jv04ft4n1cb2',
'cmks0m380002gl104o6f9cjtj',
'cmkrx8f0j000aky046uomv3rs',
'cmkqjs7e50004jr042mrpi3ac',
'cmkqk4i0d000vjr04ai18b8h3',
'cmkqk4i0g000wjr04t82lzvaq',
'cmkrzlq0q0003l10453qhsniu',
'cmks1ubzb004pl104mvs08pmu',
'cmks0eqvq0028l10426ogrvkb',
'cmks13z53003el104re4468xt',
'cmks05ci1001gl104lus7os2q',
'cmks1s7p2004il104kpwprttq',
'cmkqjs7dw0003jr04uy2bguck',
'cmkqklwxr0019jr048wp0hds8',
'cmkqjs77s0000jr041cypbjkn',
'cmkry59tc0001jy04kr21i343',
'cmk9fpg5f0003kt04fcgbvj7y',
'cmkrzxk4i0011l1045cubzwwr',
'cmks13z46003dl1047ba0entr',
'cmkry59u70003jy049eoxzkc7',
'cmks2a6r1005il104ormxbqud',
'cmks112wx0039l104zltcl2hp',
'cmkrzvdfe000sl104q0gge67f',
'cmks3w0g0000bl904u4lmk5d5',
'cmkt8m55q000nla04kgfld9x3',
'cmkqklwxj0017jr04czubkbbi',
'cmkshubus0004la04zjuh5nn5',
'cmkqk4i0d000ujr045d2njmc7',
'cmks02o6m001al104gi1jztpn',
'cmks4roac000el204paypouzl',
'cmkt7yhut0000la040ij92vgk',
'cmks5rr2e0020l204sn9ydvka',
'cmkjjb37z00084j121651lnjl',
'cmksg1wc90061jv04114izr2d',
'cmkuulu5d000jjc04u7hx67sz',
'cmkqimx3n0001jx043mvrmarx',
'cmksf0ik1004ijv047hiusscw',
'cmktoexz8001wld046f13barg',
'cmks2gqie005ol104v4d6nvck',
'cmks0equs0026l104sl4td34d',
'cmkjjb38j000b4j125a3fg282',
'cmktkj5to001hl704wtmqjft4',
'cmkrzxk4a0010l104u7irj9je',
'cmkto2g8o0019ld04b61pgj4y',
'cmktk5hid000sl7049a2bo5al',
'cmkv6j7uq000gk4043x1gfnp1',
'cmkti8i470000kz04bpsajkzk',
'cmks47ct90013l904x03rupwb',
'cmkql9bho0007l50406oflxr0',
'cmks112qf0034l104gkihwp10',
'cmks1s7ut004jl104gvlybgfs',
'cmks4pb54000bl204erpb9bbp',
'cmksfnrz1005qjv044pnraikr',
'cmkqimx480003jx04bcqob7pb',
'cmkqm1c690001jl04ge5elv46',
'cmks2a6l3005gl1040u4s0k4s',
'cmkth0k4x0026ju04t0euxxwk',
'cmkqnj1pt0001l504vmrywqeg'
);
COMMIT;
-46
View File
@@ -1,46 +0,0 @@
BEGIN;
-- Step 1: Merge duplicate tags by high-confidence canonicalized nameEn groups
WITH merge_map(source_id, target_id) AS (
VALUES
('cmkoutg0o0006jr04goozjhft', 'cmkqimx520005jx04vyegybp9'),
('cmkjjb387000a4j12pdf123zn', 'cmks0yz0h002xl1040w65w47d'),
('cmkun0ao70048jx04atm2lwwq', 'cmkryxmob0003l80414uo5zqz'),
('cmkrzqdlu000fl104qmy9gu2u', 'cmkqlcukz000dl5044c6z8542'),
('cmktk3biu000nl704iiohmv4i', 'cmksdlj9o0029jv04x9tdyc11'),
('cmktpotkp003eld04mv09ckde', 'cmkshubus0004la04zjuh5nn5')
)
INSERT INTO "project_tags" ("projectId", "tagId")
SELECT pt."projectId", mm.target_id
FROM "project_tags" pt
JOIN merge_map mm ON mm.source_id = pt."tagId"
ON CONFLICT ("projectId", "tagId") DO NOTHING;
WITH merge_map(source_id, target_id) AS (
VALUES
('cmkoutg0o0006jr04goozjhft', 'cmkqimx520005jx04vyegybp9'),
('cmkjjb387000a4j12pdf123zn', 'cmks0yz0h002xl1040w65w47d'),
('cmkun0ao70048jx04atm2lwwq', 'cmkryxmob0003l80414uo5zqz'),
('cmkrzqdlu000fl104qmy9gu2u', 'cmkqlcukz000dl5044c6z8542'),
('cmktk3biu000nl704iiohmv4i', 'cmksdlj9o0029jv04x9tdyc11'),
('cmktpotkp003eld04mv09ckde', 'cmkshubus0004la04zjuh5nn5')
)
DELETE FROM "tags" t
USING merge_map mm
WHERE t."id" = mm.source_id;
-- Step 2: Fill missing English names for ASCII-only tags
UPDATE "tags"
SET "nameEn" = "name"
WHERE ("nameEn" IS NULL OR "nameEn" = '')
AND "name" ~ '^[[:ascii:]]+$';
-- Step 3: Delete tags no longer referenced by any project
DELETE FROM "tags" t
WHERE NOT EXISTS (
SELECT 1
FROM "project_tags" pt
WHERE pt."tagId" = t."id"
);
COMMIT;
@@ -1,76 +0,0 @@
BEGIN;
WITH noise AS (
SELECT id
FROM tags
WHERE name IN (
'开源',
'学术资源',
'复杂部署',
'用户通知',
'需要配置'
)
),
projects_without_meaningful_tags AS (
SELECT p.id, p.name
FROM projects p
LEFT JOIN project_tags pt ON pt."projectId" = p.id
LEFT JOIN noise n ON n.id = pt."tagId"
GROUP BY p.id, p.name
HAVING COUNT(pt."tagId") > 0 AND COUNT(pt."tagId") = COUNT(n.id)
),
fallback_tags AS (
SELECT
(SELECT id FROM tags WHERE name = 'AI研究' LIMIT 1) AS research_id,
(SELECT id FROM tags WHERE name = 'AI 助手' LIMIT 1) AS assistant_id,
(SELECT id FROM tags WHERE name = 'AI代理' LIMIT 1) AS agent_id,
(SELECT id FROM tags WHERE name = '自动化' LIMIT 1) AS automation_id,
(SELECT id FROM tags WHERE name = 'AI开发工具' LIMIT 1) AS tool_id
),
assignments AS (
SELECT
p.id AS project_id,
CASE
WHEN p.name ~* '(论文|研究|paper|survey)' THEN (SELECT research_id FROM fallback_tags)
WHEN p.name ~* '(助手|assistant)' THEN (SELECT assistant_id FROM fallback_tags)
WHEN p.name ~* '(框架|agent|代理|智能体)' THEN (SELECT agent_id FROM fallback_tags)
WHEN p.name ~* '(通知|alert|quota|配额)' THEN (SELECT automation_id FROM fallback_tags)
ELSE (SELECT tool_id FROM fallback_tags)
END AS tag_id
FROM projects_without_meaningful_tags p
)
INSERT INTO project_tags ("projectId", "tagId")
SELECT project_id, tag_id
FROM assignments
WHERE tag_id IS NOT NULL
ON CONFLICT ("projectId", "tagId") DO NOTHING;
WITH noise AS (
SELECT id
FROM tags
WHERE name IN (
'开源',
'学术资源',
'复杂部署',
'用户通知',
'需要配置'
)
)
DELETE FROM project_tags
WHERE "tagId" IN (SELECT id FROM noise);
WITH noise AS (
SELECT id
FROM tags
WHERE name IN (
'开源',
'学术资源',
'复杂部署',
'用户通知',
'需要配置'
)
)
DELETE FROM tags
WHERE id IN (SELECT id FROM noise);
COMMIT;
@@ -1,43 +0,0 @@
BEGIN;
WITH target_tags AS (
SELECT id FROM tags
WHERE name IN (
'自托管',
'跨平台',
'企业级',
'需要基础',
'AI研究资源',
'多语言支持',
'教程',
'Apache-2.0',
'学术研究',
'MIT许可',
'云端服务',
'低学习成本'
)
)
DELETE FROM project_tags
WHERE "tagId" IN (SELECT id FROM target_tags);
WITH target_tags AS (
SELECT id FROM tags
WHERE name IN (
'自托管',
'跨平台',
'企业级',
'需要基础',
'AI研究资源',
'多语言支持',
'教程',
'Apache-2.0',
'学术研究',
'MIT许可',
'云端服务',
'低学习成本'
)
)
DELETE FROM tags
WHERE id IN (SELECT id FROM target_tags);
COMMIT;
+10 -1
View File
@@ -19,7 +19,15 @@ docs/
├── n8n/ # n8n workflow documentation
│ ├── historical-workflow-design.md
│ ├── incremental-workflow-design.md
── tag-janitor-workflow.md
── tag-janitor-workflow.json
│ ├── project-tag-reset-workflow.json
│ ├── project-tag-reset-workflow.md
│ ├── github-link-janitor-workflow.json
│ ├── github-link-janitor-workflow.md
│ ├── github-link-repair-workflow.json
│ ├── github-link-repair-workflow.md
│ ├── github-stars-refresh-workflow.json
│ └── github-stars-refresh-workflow.md
└── plans/ # Design & implementation plans
├── 2026-01-25-ai-search-system-design.md
├── 2026-01-25-ai-search-implementation.md
@@ -37,6 +45,7 @@ docs/
| `discovery-workflow.md` | Project discovery architecture |
| `plans/*.md` | Feature design & implementation specs |
| `n8n/*.md` | n8n workflow design docs |
| `n8n/project-tag-reset-workflow.json` | Project tag reset workflow (multi-AI category classification) |
## FOR AI AGENTS
+246
View File
@@ -0,0 +1,246 @@
{
"name": "Project Tag Reset - Multi AI Classifier",
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"url": "={{$env.SITE_BASE_URL}}/api/tags",
"options": {}
},
"id": "fetch-tags",
"name": "Fetch Tags",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [220, 0]
},
{
"parameters": {
"url": "={{$env.SITE_BASE_URL}}/api/projects?limit=100&page=1&sort=latest",
"options": {}
},
"id": "fetch-projects",
"name": "Fetch Projects",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [440, 0]
},
{
"parameters": {
"jsCode": "const tagsResponse = $('Fetch Tags').first().json;\nconst projectsResponse = $input.first().json;\n\nif (!tagsResponse?.success || !Array.isArray(tagsResponse?.tags)) {\n throw new Error('Failed to fetch tags from /api/tags');\n}\n\nif (!Array.isArray(projectsResponse?.projects)) {\n throw new Error('Failed to fetch projects from /api/projects');\n}\n\nif ((projectsResponse?.pagination?.total || 0) > projectsResponse.projects.length) {\n throw new Error(`Only fetched ${projectsResponse.projects.length} projects but total is ${projectsResponse.pagination.total}. Increase API limit or add pagination in workflow.`);\n}\n\nconst allowedCategories = [\n 'FIXED_PROJECT_TYPE',\n 'TECH_STACK',\n 'AI_PARADIGM',\n 'PRODUCT_FORM',\n 'DOMAIN_SCENARIO',\n];\n\nconst tagPools = Object.fromEntries(\n allowedCategories.map((category) => [\n category,\n tagsResponse.tags\n .filter((tag) => tag.category === category)\n .map((tag) => ({\n id: tag.id,\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n })),\n ])\n);\n\nreturn projectsResponse.projects.map((project) => ({\n json: {\n projectSlug: project.slug,\n projectName: project.name,\n projectNameEn: project.nameEn || '',\n projectDescription: project.description,\n projectDescriptionEn: project.descriptionEn || '',\n currentTags: Array.isArray(project.tags)\n ? project.tags.map((tag) => ({\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n }))\n : [],\n tagPools,\n },\n}));"
},
"id": "prepare-classification-items",
"name": "Prepare Classification Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [660, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify projects into FIXED_PROJECT_TYPE. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- nameEn: {{$json.projectNameEn}}\\n- description: {{$json.projectDescription}}\\n- descriptionEn: {{$json.projectDescriptionEn}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (FIXED_PROJECT_TYPE):\\n{{JSON.stringify($json.tagPools.FIXED_PROJECT_TYPE)}}\\n\\nTask:\\n- Choose exactly ONE best slug from candidate pool.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"one-slug\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-fixed-project-type",
"name": "AI Fixed Project Type",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [880, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify DOMAIN_SCENARIO tags. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (DOMAIN_SCENARIO):\\n{{JSON.stringify($json.tagPools.DOMAIN_SCENARIO)}}\\n\\nTask:\\n- Choose 1 to 3 slugs from candidate pool.\\n- Keep only the most representative domains for this project.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-domain-scenario",
"name": "AI Domain Scenario",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [1100, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify PRODUCT_FORM tags. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (PRODUCT_FORM):\\n{{JSON.stringify($json.tagPools.PRODUCT_FORM)}}\\n\\nTask:\\n- Choose 1 to 3 slugs from candidate pool that best represent product form.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-product-form",
"name": "AI Product Form",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [1320, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify TECH_STACK tags. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (TECH_STACK):\\n{{JSON.stringify($json.tagPools.TECH_STACK)}}\\n\\nTask:\\n- Choose 1 to 8 slugs from candidate pool for the main technologies used by this project.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-tech-stack",
"name": "AI Tech Stack",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [1540, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify AI_PARADIGM tags. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (AI_PARADIGM):\\n{{JSON.stringify($json.tagPools.AI_PARADIGM)}}\\n\\nTask:\\n- Choose 0 to 5 slugs from candidate pool for AI paradigm.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-ai-paradigm",
"name": "AI AI Paradigm",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [1760, 0]
},
{
"parameters": {
"jsCode": "function parseNodeJson(nodeJson) {\n const content = nodeJson?.message?.content || nodeJson?.text || nodeJson?.response || nodeJson;\n\n if (typeof content === 'string') {\n const trimmed = content.trim();\n try {\n return JSON.parse(trimmed);\n } catch {\n const jsonMatch = trimmed.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n return JSON.parse(jsonMatch[0]);\n }\n return { selected: [] };\n }\n }\n\n if (typeof content === 'object' && content !== null) {\n return content;\n }\n\n return { selected: [] };\n}\n\nfunction normalizeSelected(parsed, pool, minCount, maxCount) {\n const poolSlugs = pool.map((tag) => String(tag.slug).toLowerCase());\n const allowed = new Set(poolSlugs);\n const selectedRaw = Array.isArray(parsed?.selected) ? parsed.selected : [];\n const selected = [];\n\n for (const slug of selectedRaw) {\n const normalizedSlug = String(slug || '').trim().toLowerCase();\n if (!normalizedSlug || !allowed.has(normalizedSlug) || selected.includes(normalizedSlug)) {\n continue;\n }\n selected.push(normalizedSlug);\n if (selected.length >= maxCount) {\n break;\n }\n }\n\n if (selected.length < minCount) {\n return poolSlugs.slice(0, minCount);\n }\n return selected;\n}\n\nconst preparedItems = $items('Prepare Classification Items', 0);\nconst fixedItems = $items('AI Fixed Project Type', 0);\nconst domainItems = $items('AI Domain Scenario', 0);\nconst productItems = $items('AI Product Form', 0);\nconst techItems = $items('AI Tech Stack', 0);\nconst paradigmItems = $items('AI AI Paradigm', 0);\n\nif (\n preparedItems.length !== fixedItems.length ||\n preparedItems.length !== domainItems.length ||\n preparedItems.length !== productItems.length ||\n preparedItems.length !== techItems.length ||\n preparedItems.length !== paradigmItems.length\n) {\n throw new Error('AI node output item count mismatch');\n}\n\nconst output = [];\nfor (let i = 0; i < preparedItems.length; i++) {\n const base = preparedItems[i].json;\n const pools = base.tagPools;\n\n const fixed = normalizeSelected(\n parseNodeJson(fixedItems[i].json),\n pools.FIXED_PROJECT_TYPE,\n 1,\n 1\n );\n const domains = normalizeSelected(\n parseNodeJson(domainItems[i].json),\n pools.DOMAIN_SCENARIO,\n 1,\n 3\n );\n const productForms = normalizeSelected(\n parseNodeJson(productItems[i].json),\n pools.PRODUCT_FORM,\n 1,\n 3\n );\n const techStack = normalizeSelected(\n parseNodeJson(techItems[i].json),\n pools.TECH_STACK,\n 1,\n 8\n );\n const paradigms = normalizeSelected(\n parseNodeJson(paradigmItems[i].json),\n pools.AI_PARADIGM,\n 0,\n 5\n );\n\n output.push({\n json: {\n projectSlug: base.projectSlug,\n selectedTagSlugsByCategory: {\n FIXED_PROJECT_TYPE: fixed,\n TECH_STACK: techStack,\n AI_PARADIGM: paradigms,\n PRODUCT_FORM: productForms,\n DOMAIN_SCENARIO: domains,\n },\n },\n });\n}\n\nreturn output;"
},
"id": "build-reset-payload",
"name": "Build Reset Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1980, 0]
},
{
"parameters": {
"method": "POST",
"url": "={{$env.SITE_BASE_URL}}/api/tags/reset-projects",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"dryRun\": false,\n \"replaceAllCategories\": true,\n \"projects\": [\n {\n \"projectSlug\": \"{{$json.projectSlug}}\",\n \"selectedTagSlugsByCategory\": {{JSON.stringify($json.selectedTagSlugsByCategory)}}\n }\n ]\n}",
"options": {}
},
"id": "reset-project-tags",
"name": "Reset Project Tags",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [2200, 0]
},
{
"parameters": {
"jsCode": "const results = $input.all().map((item) => item.json);\nconst summary = {\n totalRequests: results.length,\n successRequests: results.filter((r) => r.success === true).length,\n failedRequests: results.filter((r) => r.success !== true).length,\n timestamp: new Date().toISOString(),\n};\n\nreturn [{ json: { summary, results } }];"
},
"id": "summarize-results",
"name": "Summarize Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [2420, 0]
}
],
"connections": {
"Manual Trigger": {
"main": [[{ "node": "Fetch Tags", "type": "main", "index": 0 }]]
},
"Fetch Tags": {
"main": [[{ "node": "Fetch Projects", "type": "main", "index": 0 }]]
},
"Fetch Projects": {
"main": [[{ "node": "Prepare Classification Items", "type": "main", "index": 0 }]]
},
"Prepare Classification Items": {
"main": [[{ "node": "AI Fixed Project Type", "type": "main", "index": 0 }]]
},
"AI Fixed Project Type": {
"main": [[{ "node": "AI Domain Scenario", "type": "main", "index": 0 }]]
},
"AI Domain Scenario": {
"main": [[{ "node": "AI Product Form", "type": "main", "index": 0 }]]
},
"AI Product Form": {
"main": [[{ "node": "AI Tech Stack", "type": "main", "index": 0 }]]
},
"AI Tech Stack": {
"main": [[{ "node": "AI AI Paradigm", "type": "main", "index": 0 }]]
},
"AI AI Paradigm": {
"main": [[{ "node": "Build Reset Payload", "type": "main", "index": 0 }]]
},
"Build Reset Payload": {
"main": [[{ "node": "Reset Project Tags", "type": "main", "index": 0 }]]
},
"Reset Project Tags": {
"main": [[{ "node": "Summarize Results", "type": "main", "index": 0 }]]
}
},
"settings": {
"executionOrder": "v1"
},
"meta": {
"templateCredsSetupCompleted": true
}
}
+40
View File
@@ -0,0 +1,40 @@
# Project Tag Reset Workflow
## Goal
Reset every project's tags according to the new taxonomy, using **one dedicated AI node per tag category**:
- `FIXED_PROJECT_TYPE`
- `DOMAIN_SCENARIO`
- `PRODUCT_FORM`
- `TECH_STACK`
- `AI_PARADIGM`
Each AI node must select only from its own category pool.
## Files
- Workflow JSON: `docs/n8n/project-tag-reset-workflow.json`
- API endpoint used by workflow: `POST /api/tags/reset-projects`
## Required Environment Variables in n8n
- `SITE_BASE_URL`: e.g. `https://your-site.com`
- `WEBHOOK_API_KEY`: same key configured on Next.js server
## Execution Flow
1. `Manual Trigger`
2. `HTTP GET /api/tags` to load full tag pool
3. `HTTP GET /api/projects?limit=100&page=1` to load projects
4. `Code` prepares per-project classification items and category pools
5. Five independent AI nodes classify by category
6. `Code` normalizes/parses AI outputs into `selectedTagSlugsByCategory`
7. `HTTP POST /api/tags/reset-projects` updates tags per project
8. `Code` summarizes success/failure counts
## Notes
- Current workflow fetches up to 100 projects in one run. If total > 100, add pagination or increase API limit strategy.
- Endpoint supports `dryRun`. You can set `"dryRun": true` first in the request node for safe validation.
- Endpoint performs category validation and rejects cross-category slug usage.
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "./route";
const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } =
vi.hoisted(() => {
const tx = {
projectTag: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
};
return {
revalidatePathMock: vi.fn(),
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)),
txMock: tx,
projectFindManyMock: vi.fn(),
tagFindManyMock: vi.fn(),
};
});
vi.mock("@/lib/prisma", () => ({
prisma: {
project: {
findMany: projectFindManyMock,
},
tag: {
findMany: tagFindManyMock,
},
$transaction: transactionMock,
},
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
function buildRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost:3000/api/tags/reset-projects", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
function buildValidPayload(apiKey: string) {
return {
apiKey,
projects: [
{
projectSlug: "project-one",
selectedTagSlugsByCategory: {
FIXED_PROJECT_TYPE: ["agent-tooling"],
TECH_STACK: ["typescript"],
AI_PARADIGM: ["ai-agents"],
PRODUCT_FORM: ["web-application"],
DOMAIN_SCENARIO: ["code-dev"],
},
},
],
};
}
describe("POST /api/tags/reset-projects", () => {
const validApiKey = "k".repeat(32);
beforeEach(() => {
process.env.WEBHOOK_API_KEY = validApiKey;
transactionMock.mockClear();
revalidatePathMock.mockClear();
txMock.projectTag.deleteMany.mockReset();
txMock.projectTag.createMany.mockReset();
projectFindManyMock.mockReset();
tagFindManyMock.mockReset();
});
it("returns 401 for wrong API key", async () => {
const response = await POST(buildRequest(buildValidPayload("a".repeat(32))));
const json = await response.json();
expect(response.status).toBe(401);
expect(json.success).toBe(false);
expect(json.error).toBe("Unauthorized");
});
it("returns 400 when FIXED_PROJECT_TYPE is missing", async () => {
const payload = buildValidPayload(validApiKey);
payload.projects[0].selectedTagSlugsByCategory.FIXED_PROJECT_TYPE = [];
const response = await POST(buildRequest(payload));
const json = await response.json();
expect(response.status).toBe(400);
expect(json.success).toBe(false);
expect(json.error).toBe("Validation error");
expect(transactionMock).not.toHaveBeenCalled();
});
it("returns partial failure when project is missing", async () => {
projectFindManyMock.mockResolvedValue([]);
tagFindManyMock.mockResolvedValue([
{ id: "t1", slug: "agent-tooling", category: "FIXED_PROJECT_TYPE" },
{ id: "t2", slug: "typescript", category: "TECH_STACK" },
{ id: "t3", slug: "ai-agents", category: "AI_PARADIGM" },
{ id: "t4", slug: "web-application", category: "PRODUCT_FORM" },
{ id: "t5", slug: "code-dev", category: "DOMAIN_SCENARIO" },
]);
const response = await POST(buildRequest(buildValidPayload(validApiKey)));
const json = await response.json();
expect(response.status).toBe(200);
expect(json.success).toBe(false);
expect(json.result.failedCount).toBe(1);
expect(transactionMock).not.toHaveBeenCalled();
});
it("updates project tags and revalidates pages", async () => {
projectFindManyMock.mockResolvedValue([
{
id: "p1",
slug: "project-one",
tags: [
{
tag: {
id: "old-tech",
slug: "javascript",
category: "TECH_STACK",
},
},
{
tag: {
id: "old-domain",
slug: "automation-workflow",
category: "DOMAIN_SCENARIO",
},
},
],
},
]);
tagFindManyMock.mockResolvedValue([
{ id: "t1", slug: "agent-tooling", category: "FIXED_PROJECT_TYPE" },
{ id: "t2", slug: "typescript", category: "TECH_STACK" },
{ id: "t3", slug: "ai-agents", category: "AI_PARADIGM" },
{ id: "t4", slug: "web-application", category: "PRODUCT_FORM" },
{ id: "t5", slug: "code-dev", category: "DOMAIN_SCENARIO" },
]);
const response = await POST(buildRequest(buildValidPayload(validApiKey)));
const json = await response.json();
expect(response.status).toBe(200);
expect(json.success).toBe(true);
expect(json.result.updatedCount).toBe(1);
expect(transactionMock).toHaveBeenCalledTimes(1);
expect(txMock.projectTag.deleteMany).toHaveBeenCalledTimes(1);
expect(txMock.projectTag.createMany).toHaveBeenCalledTimes(1);
expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects", "page");
expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects", "page");
expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects/project-one", "page");
expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects/project-one", "page");
});
});
+293
View File
@@ -0,0 +1,293 @@
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { revalidatePath } from 'next/cache'
import type { TagCategory } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
ProjectTagResetRequestSchema,
type ProjectTagResetItem,
type ResettableTagCategory,
} from '@/lib/validations'
type ResetResultItem = {
projectSlug: string
status: 'updated' | 'dry-run' | 'failed'
selectedTagCount: number
addedCount: number
removedCount: number
details: string[]
}
const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [
'FIXED_PROJECT_TYPE',
'TECH_STACK',
'AI_PARADIGM',
'PRODUCT_FORM',
'DOMAIN_SCENARIO',
]
function isApiKeyValid(providedApiKey: string, expectedApiKey?: string): boolean {
if (!expectedApiKey) {
return false
}
const providedBuf = Buffer.from(providedApiKey)
const expectedBuf = Buffer.from(expectedApiKey)
return (
providedBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(providedBuf, expectedBuf)
)
}
function normalizeSlug(slug: string): string {
return slug.trim().toLowerCase()
}
function collectSelectedTagSlugs(
item: ProjectTagResetItem,
categories: ResettableTagCategory[]
): string[] {
const selectedTagSlugSet = new Set<string>()
for (const category of categories) {
const categorySlugs = item.selectedTagSlugsByCategory[category] || []
for (const slug of categorySlugs) {
selectedTagSlugSet.add(normalizeSlug(slug))
}
}
return [...selectedTagSlugSet]
}
function collectValidationErrorsForProjectItem(params: {
item: ProjectTagResetItem
categories: ResettableTagCategory[]
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>
}): string[] {
const { item, categories, existingTagBySlug } = params
const errors: string[] = []
for (const category of categories) {
for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) {
const normalizedSlug = normalizeSlug(rawSlug)
const existingTag = existingTagBySlug.get(normalizedSlug)
if (!existingTag) {
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`)
continue
}
if (existingTag.category !== category) {
errors.push(
`Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}`
)
}
}
}
return errors
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validation = ProjectTagResetRequestSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validation.error.errors.map((issue) => issue.message),
},
{ status: 400 }
)
}
const {
apiKey,
dryRun,
replaceAllCategories,
projects,
categories: requestedCategories,
} = validation.data
if (!isApiKeyValid(apiKey, process.env.WEBHOOK_API_KEY)) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
details: ['Invalid or missing API Key'],
},
{ status: 401 }
)
}
const categories = requestedCategories.length > 0
? requestedCategories
: DEFAULT_RESET_CATEGORIES
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug))
const normalizedSelectedTagSlugs = [
...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))),
]
const [existingProjects, existingTags] = await Promise.all([
prisma.project.findMany({
where: { slug: { in: normalizedProjectSlugs } },
select: {
id: true,
slug: true,
tags: {
include: {
tag: {
select: {
id: true,
slug: true,
category: true,
},
},
},
},
},
}),
prisma.tag.findMany({
where: { slug: { in: normalizedSelectedTagSlugs } },
select: {
id: true,
slug: true,
category: true,
},
}),
])
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]))
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]))
const results: ResetResultItem[] = []
const updatedProjectSlugs: string[] = []
for (const item of projects) {
const projectSlug = normalizeSlug(item.projectSlug)
const project = projectBySlug.get(projectSlug)
if (!project) {
results.push({
projectSlug,
status: 'failed',
selectedTagCount: 0,
addedCount: 0,
removedCount: 0,
details: [`Project with slug "${projectSlug}" not found`],
})
continue
}
const validationErrors = collectValidationErrorsForProjectItem({
item,
categories,
existingTagBySlug,
})
if (validationErrors.length > 0) {
results.push({
projectSlug,
status: 'failed',
selectedTagCount: 0,
addedCount: 0,
removedCount: 0,
details: validationErrors,
})
continue
}
const selectedTagIds = collectSelectedTagSlugs(item, categories)
.map((slug) => existingTagBySlug.get(slug)?.id)
.filter((id): id is string => Boolean(id))
const previousCategoryTagIds = new Set(
project.tags
.filter((projectTag) => categories.includes(projectTag.tag.category as ResettableTagCategory))
.map((projectTag) => projectTag.tag.id)
)
const nextTagIdSet = new Set(selectedTagIds)
const removedCount = replaceAllCategories
? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length
: 0
const addedCount = [...nextTagIdSet].filter((tagId) => !previousCategoryTagIds.has(tagId)).length
if (!dryRun) {
await prisma.$transaction(async (tx) => {
if (replaceAllCategories) {
await tx.projectTag.deleteMany({
where: {
projectId: project.id,
tag: {
category: {
in: categories as TagCategory[],
},
},
},
})
}
if (selectedTagIds.length > 0) {
await tx.projectTag.createMany({
data: selectedTagIds.map((tagId) => ({
projectId: project.id,
tagId,
})),
skipDuplicates: true,
})
}
})
updatedProjectSlugs.push(projectSlug)
}
results.push({
projectSlug,
status: dryRun ? 'dry-run' : 'updated',
selectedTagCount: selectedTagIds.length,
addedCount,
removedCount,
details: [],
})
}
const updatedCount = results.filter((item) => item.status === 'updated').length
const dryRunCount = results.filter((item) => item.status === 'dry-run').length
const failedCount = results.filter((item) => item.status === 'failed').length
if (!dryRun && updatedProjectSlugs.length > 0) {
revalidatePath('/zh/projects', 'page')
revalidatePath('/en/projects', 'page')
for (const projectSlug of updatedProjectSlugs) {
revalidatePath(`/zh/projects/${projectSlug}`, 'page')
revalidatePath(`/en/projects/${projectSlug}`, 'page')
}
}
return NextResponse.json({
success: failedCount === 0,
result: {
dryRun,
replaceAllCategories,
categories,
total: projects.length,
updatedCount,
dryRunCount,
failedCount,
results,
},
})
} catch (error) {
console.error('[POST /api/tags/reset-projects] Error:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
details: [error instanceof Error ? error.message : 'Unknown error'],
},
{ status: 500 }
)
}
}
+86
View File
@@ -260,6 +260,88 @@ export const TagMaintenanceRequestSchema = z.object({
});
});
// ================================
// Project Tag Reset API Schemas
// ================================
const ResettableTagCategorySchema = z.enum([
"FIXED_PROJECT_TYPE",
"TECH_STACK",
"AI_PARADIGM",
"PRODUCT_FORM",
"DOMAIN_SCENARIO",
]);
const ProjectTagSelectionByCategorySchema = z.object({
FIXED_PROJECT_TYPE: z.array(z.string().trim().min(1)).min(1).max(1),
TECH_STACK: z.array(z.string().trim().min(1)).max(20).default([]),
AI_PARADIGM: z.array(z.string().trim().min(1)).max(20).default([]),
PRODUCT_FORM: z.array(z.string().trim().min(1)).max(20).default([]),
DOMAIN_SCENARIO: z.array(z.string().trim().min(1)).max(20).default([]),
});
export const ProjectTagResetItemSchema = z.object({
projectSlug: z.string().trim().min(1).max(200),
selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema,
});
export const ProjectTagResetRequestSchema = z.object({
apiKey: z.string().min(32, "Invalid API key format"),
dryRun: z.boolean().default(false),
replaceAllCategories: z.boolean().default(true),
categories: z.array(ResettableTagCategorySchema).min(1).default([
"FIXED_PROJECT_TYPE",
"TECH_STACK",
"AI_PARADIGM",
"PRODUCT_FORM",
"DOMAIN_SCENARIO",
]),
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
}).superRefine((data, ctx) => {
const seenProjectSlugs = new Set<string>();
data.projects.forEach((project, projectIndex) => {
const normalizedSlug = project.projectSlug.trim().toLowerCase();
if (seenProjectSlugs.has(normalizedSlug)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["projects", projectIndex, "projectSlug"],
message: `Duplicate projectSlug: ${project.projectSlug}`,
});
return;
}
seenProjectSlugs.add(normalizedSlug);
const categories = Object.keys(project.selectedTagSlugsByCategory) as Array<
keyof z.infer<typeof ProjectTagSelectionByCategorySchema>
>;
categories.forEach((category) => {
const selectedSlugs = project.selectedTagSlugsByCategory[category];
const seenTagSlugs = new Set<string>();
selectedSlugs.forEach((slug, slugIndex) => {
const normalizedTagSlug = slug.trim().toLowerCase();
if (seenTagSlugs.has(normalizedTagSlug)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [
"projects",
projectIndex,
"selectedTagSlugsByCategory",
category,
slugIndex,
],
message: `Duplicate tag slug "${slug}" in category ${category}`,
});
return;
}
seenTagSlugs.add(normalizedTagSlug);
});
});
});
});
// ================================
// Types
// ================================
@@ -268,3 +350,7 @@ export type TagUpdate = z.infer<typeof TagUpdateSchema>;
export type MergeTarget = z.infer<typeof MergeTargetSchema>;
export type TagMerge = z.infer<typeof TagMergeSchema>;
export type TagMaintenanceRequest = z.infer<typeof TagMaintenanceRequestSchema>;
export type ResettableTagCategory = z.infer<typeof ResettableTagCategorySchema>;
export type ProjectTagSelectionByCategory = z.infer<typeof ProjectTagSelectionByCategorySchema>;
export type ProjectTagResetItem = z.infer<typeof ProjectTagResetItemSchema>;
export type ProjectTagResetRequest = z.infer<typeof ProjectTagResetRequestSchema>;