diff --git a/.env.example b/.env.example index d398ceb..490b0f0 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park" # Webhook API - Generate a secure key for production WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars" +# n8n AI Search Webhook +N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search" + # Internationalization NEXT_INTL_DEFAULT_LOCALE="zh" NEXT_INTL_SUPPORTED_LOCALES="zh,en" diff --git a/n8n-workflows/README.md b/n8n-workflows/README.md deleted file mode 100644 index 865f0ca..0000000 --- a/n8n-workflows/README.md +++ /dev/null @@ -1,210 +0,0 @@ -# n8n 工作流导入指南 - -本指南将帮助您导入和配置 AI 智能搜索系统的两个 n8n 工作流。 - -## 前置条件 - -确保您已经完成: -- [ ] n8n 实例已运行 -- [ ] 已配置 `OpenAI Embeddings` 凭证 -- [ ] 已配置 `Neon Database` 凭证 -- [ ] Neon 数据库已应用迁移(添加 embedding 字段) - ---- - -## 工作流 1: Project Vectorization(项目向量化) - -### 功能说明 -每 5 分钟自动执行一次,查询未向量化的项目,生成 OpenAI embeddings 并存储到数据库。 - -### 导入步骤 - -1. **导入工作流** - - 打开 n8n 实例 - - 点击右上角 **+** → **Import from File** - - 选择 `project-vectorization.json` - - 点击 **Import** - -2. **配置凭证** - - 点击 **查询未向量化项目** 节点 - - 在 **Credentials** 下拉框中选择 `Neon Database` - - 点击 **Save** - - - 点击 **OpenAI Embeddings** 节点 - - 在 **Credentials** 下拉框中选择 `OpenAI Embeddings` - - 点击 **Save** - - - 点击 **更新 Embedding** 节点 - - 在 **Credentials** 下拉框中选择 `Neon Database` - - 点击 **Save** - -3. **测试工作流** - - 点击工作流右上角 **Test Workflow** - - 手动点击 **Cron** 节点的执行按钮 - - 查看每个节点的输出: - - `查询未向量化项目` 应返回项目列表(或空数组) - - `构造文本内容` 应添加 `textContent` 字段 - - `OpenAI Embeddings` 应返回向量数组 - - `更新 Embedding` 应成功更新数据库 - -4. **激活工作流** - - 点击左上角 **Inactive** 开关,变为 **Active** - - 工作流将每 5 分钟自动执行 - -### 节点说明 - -| 节点 | 功能 | -|------|------| -| Cron | 定时触发器(每 5 分钟) | -| 查询未向量化项目 | 查询 embedding 为空的 ACTIVE 项目 | -| 构造文本内容 | 合并项目字段生成用于向量化的文本 | -| Split in Batches | 分批处理(每批 5 个,避免 API 限流) | -| OpenAI Embeddings | 调用 OpenAI API 生成向量 | -| 更新 Embedding | 将向量写入数据库 | - ---- - -## 工作流 2: AI Semantic Search(AI 语义搜索) - -### 功能说明 -接收 Webhook 请求,生成查询向量,执行向量相似度搜索,返回排序结果。 - -### 导入步骤 - -1. **导入工作流** - - 打开 n8n 实例 - - 点击右上角 **+** → **Import from File** - - 选择 `ai-semantic-search.json` - - 点击 **Import** - -2. **配置凭证** - - 依次配置以下节点的凭证为 `OpenAI Embeddings`: - - **生成查询向量** 节点 - - - 依次配置以下节点的凭证为 `Neon Database`: - - **向量相似度搜索** 节点 - - **查询标签** 节点 - -3. **获取 Webhook URL** - - 点击 **Webhook** 节点 - - 复制 **Production URL**(格式类似:`https://your-n8n.com/webhook/ai-search`) - - 将此 URL 更新到 `.env.local` 的 `N8N_AI_SEARCH_WEBHOOK` - -4. **测试工作流** - - 点击工作流右上角 **Test Workflow** - - 在 **Webhook** 节点中点击 **Listen for Test Event** - - 使用以下命令测试: - -```bash -curl -X POST https://your-n8n.com/webhook/ai-search \ - -H "Content-Type: application/json" \ - -d '{"query":"视频生成工具","locale":"zh","limit":5}' -``` - - - 预期响应: - -```json -{ - "results": [ - { - "project": { /* 项目数据 */ }, - "similarity": 0.89, - "matchReason": "相似度: 89%" - } - ], - "total": 5, - "searchTime": 1234 -} -``` - -5. **激活工作流** - - 点击左上角 **Inactive** 开关,变为 **Active** - -### 节点说明 - -| 节点 | 功能 | -|------|------| -| Webhook | 接收搜索请求(POST /webhook/ai-search) | -| 生成查询向量 | 将查询文本转换为向量 | -| 向量相似度搜索 | 使用 pgvector 执行余弦相似度搜索 | -| 准备标签查询 | 准备项目 ID 列表 | -| 查询标签 | 查询每个项目的标签 | -| 合并标签 | 将标签合并到搜索结果 | -| 格式化响应 | 生成最终的 JSON 响应 | - ---- - -## 常见问题 - -### Q1: 节点连接错误? -导入后如果节点连接线丢失,手动按以下顺序连接: - -**工作流 1 连接顺序:** -``` -Cron → 查询未向量化项目 → 构造文本内容 → Split in Batches → OpenAI Embeddings → 更新 Embedding → (循环回) Split in Batches -``` - -**工作流 2 连接顺序:** -``` -Webhook → 生成查询向量 → 向量相似度搜索 → 准备标签查询 → 查询标签 → 合并标签 → 格式化响应 -``` - -### Q2: 凭证选择框为空? -- 确保已在 n8n 中创建了 `OpenAI Embeddings` 和 `Neon Database` 凭证 -- 如果凭证已创建但不可见,重新导入工作流 - -### Q3: OpenAI API 错误? -- 检查 API Key 是否有效 -- 确认 API Key 有足够的配额 -- 检查网络连接 - -### Q4: 数据库连接错误? -- 验证 Neon 数据库凭证配置正确 -- 检查数据库是否已应用迁移 -- 确认 pgvector 扩展已安装 - ---- - -## 更新环境变量 - -将获取的 Webhook URL 更新到项目的 `.env.local` 文件: - -```bash -# n8n AI Search Webhook -N8N_AI_SEARCH_WEBHOOK="https://your-n8n.com/webhook/ai-search" -``` - -然后重启开发服务器: - -```bash -pnpm dev -``` - ---- - -## 验证完整流程 - -1. **启动向量化** - - 确保 Project Vectorization 工作流已激活 - - 等待 5 分钟或手动执行 - - 在 Neon SQL Editor 中检查: - -```sql -SELECT COUNT(*) FROM "projects" WHERE "embedding" IS NOT NULL; -``` - -2. **测试 AI 搜索** - - 访问 `http://localhost:3000/zh/projects` - - 点击 ✨ 按钮切换到 AI 模式 - - 输入查询:"帮我找能生成视频的 AI 工具" - - 验证返回相关结果 - ---- - -## 完成后 - -所有工作流配置完成后,您的 AI 智能搜索系统就已就绪! - -- 向量化工作流会在后台自动运行 -- AI 搜索 API 可供前端调用 -- 用户可以使用自然语言查询项目 diff --git a/n8n-workflows/ai-semantic-search.json b/n8n-workflows/ai-semantic-search.json deleted file mode 100644 index 82147e4..0000000 --- a/n8n-workflows/ai-semantic-search.json +++ /dev/null @@ -1,180 +0,0 @@ -{ - "name": "AI Semantic Search", - "nodes": [ - { - "parameters": { - "path": "ai-search", - "responseMode": "whenLastNodeFinishes", - "options": {} - }, - "id": "webhook-node", - "name": "Webhook", - "type": "n8n-nodes-base.webhook", - "typeVersion": 2, - "position": [250, 300], - "webhookId": "ai-search-webhook" - }, - { - "parameters": { - "resource": "embedding", - "model": "text-embedding-3-small", - "input": "={{ $json.query }}", - "options": {} - }, - "id": "openai-embeddings", - "name": "生成查询向量", - "type": "@n8n/n8n-nodes-langchain.openai", - "typeVersion": 1.4, - "position": [470, 300], - "credentials": { - "openaiApi": { - "id": "OPENAI_CREDENTIAL_ID", - "name": "OpenAI Embeddings" - } - } - }, - { - "parameters": { - "operation": "executeQuery", - "query": "=SELECT\n p.id,\n p.name,\n p.\"nameEn\",\n p.slug,\n p.description,\n p.\"descriptionEn\",\n p.status,\n p.\"createdAt\",\n 1 - (p.\"embedding\" <=> '{{ $json.data[0].embedding }}'::vector) as similarity\nFROM \"projects\" p\nWHERE p.\"embedding\" IS NOT NULL\n AND p.status = 'ACTIVE'\nORDER BY p.\"embedding\" <=> '{{ $json.data[0].embedding }}'::vector\nLIMIT {{ $json.limit || 20 }}", - "options": {} - }, - "id": "postgres-vector-search", - "name": "向量相似度搜索", - "type": "n8n-nodes-base.postgres", - "typeVersion": 2.5, - "position": [690, 300], - "credentials": { - "postgres": { - "id": "NEON_DATABASE_CREDENTIAL_ID", - "name": "Neon Database" - } - } - }, - { - "parameters": { - "jsCode": "// 为每个项目查询标签\nconst results = $input.all();\n\nreturn results.map(item => {\n return {\n json: {\n ...item.json,\n projectId: item.json.id\n }\n };\n});" - }, - "id": "prepare-tag-query", - "name": "准备标签查询", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [910, 300] - }, - { - "parameters": { - "operation": "executeQuery", - "query": "=SELECT\n t.id,\n t.name,\n t.\"nameEn\",\n t.slug,\n pt.\"projectId\"\nFROM \"tags\" t\nINNER JOIN \"project_tags\" pt ON t.id = pt.\"tagId\"\nWHERE pt.\"projectId\" IN (SELECT UNNEST(STRING_TO_ARRAY('{{ $json.projectIds }}', ','))::INTEGER)", - "options": {} - }, - "id": "query-tags", - "name": "查询标签", - "type": "n8n-nodes-base.postgres", - "typeVersion": 2.5, - "position": [1130, 300], - "credentials": { - "postgres": { - "id": "NEON_DATABASE_CREDENTIAL_ID", - "name": "Neon Database" - } - } - }, - { - "parameters": { - "jsCode": "// 合并标签到项目\nconst projects = $('向量相似度搜索').all();\nconst tags = $('查询标签').all();\n\n// 合并标签到项目\nconst results = projects.map(project => {\n const projectTags = tags\n .filter(t => t.json.projectId === project.json.id)\n .map(t => ({\n id: t.json.id,\n name: t.json.name,\n nameEn: t.json.nameEn,\n slug: t.json.slug\n }));\n\n return {\n json: {\n project: {\n ...project.json,\n tags: projectTags\n },\n similarity: project.json.similarity,\n matchReason: `相似度: ${(project.json.similarity * 100).toFixed(0)}%`\n }\n };\n});\n\nreturn results;" - }, - "id": "merge-tags", - "name": "合并标签", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [1350, 300] - }, - { - "parameters": { - "jsCode": "// 格式化最终响应\nconst results = $input.all();\n\nreturn {\n json: {\n results: results.map(r => r.json),\n total: results.length,\n searchTime: Date.now() - $('Webhook').item.json.startTime\n }\n};" - }, - "id": "format-response", - "name": "格式化响应", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [1570, 300] - } - ], - "connections": { - "Webhook": { - "main": [ - [ - { - "node": "生成查询向量", - "type": "main", - "index": 0 - } - ] - ] - }, - "生成查询向量": { - "main": [ - [ - { - "node": "向量相似度搜索", - "type": "main", - "index": 0 - } - ] - ] - }, - "向量相似度搜索": { - "main": [ - [ - { - "node": "准备标签查询", - "type": "main", - "index": 0 - } - ] - ] - }, - "准备标签查询": { - "main": [ - [ - { - "node": "查询标签", - "type": "main", - "index": 0 - } - ] - ] - }, - "查询标签": { - "main": [ - [ - { - "node": "合并标签", - "type": "main", - "index": 0 - } - ] - ] - }, - "合并标签": { - "main": [ - [ - { - "node": "格式化响应", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "pinData": {}, - "settings": { - "executionOrder": "v1" - }, - "staticData": null, - "tags": [], - "triggerCount": 0, - "updatedAt": "2026-01-26T00:00:00.000Z", - "versionId": "1" -} diff --git a/n8n-workflows/project-vectorization.json b/n8n-workflows/project-vectorization.json deleted file mode 100644 index 1a8fd99..0000000 --- a/n8n-workflows/project-vectorization.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "name": "Project Vectorization", - "nodes": [ - { - "parameters": { - "rule": { - "interval": [ - { - "field": "minutes", - "minutesInterval": 5 - } - ] - } - }, - "id": "cron-node", - "name": "Cron", - "type": "n8n-nodes-base.cron", - "typeVersion": 1.2, - "position": [250, 300] - }, - { - "parameters": { - "operation": "executeQuery", - "query": "SELECT id, name, \"nameEn\", description, \"descriptionEn\", content, \"contentEn\"\nFROM \"projects\"\nWHERE \"embedding\" IS NULL\n AND \"status\" = 'ACTIVE'\nLIMIT 20", - "options": {} - }, - "id": "postgres-query", - "name": "查询未向量化项目", - "type": "n8n-nodes-base.postgres", - "typeVersion": 2.5, - "position": [470, 300], - "credentials": { - "postgres": { - "id": "NEON_DATABASE_CREDENTIAL_ID", - "name": "Neon Database" - } - } - }, - { - "parameters": { - "jsCode": "// 为每个项目构造用于向量化的文本内容\nconst projects = $input.all();\n\nreturn projects.map(item => {\n const project = item.json;\n\n // 合并字段,按权重构造\n const parts = [\n project.name || '',\n project.nameEn || '',\n project.description || '',\n project.descriptionEn || '',\n (project.content || '').substring(0, 500),\n (project.contentEn || '').substring(0, 500)\n ].filter(Boolean);\n\n const textContent = parts.join('\\n\\n');\n\n return {\n json: {\n ...project,\n textContent: textContent\n }\n };\n});" - }, - "id": "construct-content", - "name": "构造文本内容", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [690, 300] - }, - { - "parameters": { - "batchSize": 5, - "options": {} - }, - "id": "split-batches", - "name": "Split in Batches", - "type": "n8n-nodes-base.splitInBatches", - "typeVersion": 3, - "position": [910, 300] - }, - { - "parameters": { - "resource": "embedding", - "model": "text-embedding-3-small", - "input": "={{ $json.textContent }}", - "options": {} - }, - "id": "openai-embeddings", - "name": "OpenAI Embeddings", - "type": "@n8n/n8n-nodes-langchain.openai", - "typeVersion": 1.4, - "position": [1130, 300], - "credentials": { - "openaiApi": { - "id": "OPENAI_CREDENTIAL_ID", - "name": "OpenAI Embeddings" - } - } - }, - { - "parameters": { - "operation": "executeQuery", - "query": "=UPDATE \"projects\"\nSET\n \"embedding\" = '{{ $json.data[0].embedding }}'::vector,\n \"embeddingUpdatedAt\" = NOW()\nWHERE \"id\" = {{ $json.id }}", - "options": {} - }, - "id": "postgres-update", - "name": "更新 Embedding", - "type": "n8n-nodes-base.postgres", - "typeVersion": 2.5, - "position": [1350, 300], - "credentials": { - "postgres": { - "id": "NEON_DATABASE_CREDENTIAL_ID", - "name": "Neon Database" - } - } - } - ], - "connections": { - "Cron": { - "main": [ - [ - { - "node": "查询未向量化项目", - "type": "main", - "index": 0 - } - ] - ] - }, - "查询未向量化项目": { - "main": [ - [ - { - "node": "构造文本内容", - "type": "main", - "index": 0 - } - ] - ] - }, - "构造文本内容": { - "main": [ - [ - { - "node": "Split in Batches", - "type": "main", - "index": 0 - } - ] - ] - }, - "Split in Batches": { - "main": [ - [ - { - "node": "OpenAI Embeddings", - "type": "main", - "index": 0 - } - ] - ] - }, - "OpenAI Embeddings": { - "main": [ - [ - { - "node": "更新 Embedding", - "type": "main", - "index": 0 - } - ] - ] - }, - "更新 Embedding": { - "main": [ - [ - { - "node": "Split in Batches", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "pinData": {}, - "settings": { - "executionOrder": "v1" - }, - "staticData": null, - "tags": [], - "triggerCount": 0, - "updatedAt": "2026-01-26T00:00:00.000Z", - "versionId": "1" -} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 59564fe..2ed8aa0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,25 +39,23 @@ enum TaskStatus { // ================================ model Project { - id String @id @default(cuid()) - name String - nameEn String? - slug String @unique - description String - descriptionEn String? - content String? @db.Text - contentEn String? @db.Text - status ProjectStatus @default(ACTIVE) - source String? - embedding Unsupported("vector(1536)")? - embeddingUpdatedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + nameEn String? + slug String @unique + description String + descriptionEn String? + content String? @db.Text + contentEn String? @db.Text + status ProjectStatus @default(ACTIVE) + source String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - tags ProjectTag[] - links ExternalLink[] - discoveryTasks ProjectDiscoveryTask[] + tags ProjectTag[] + links ExternalLink[] + discoveryTasks ProjectDiscoveryTask[] // Indexes @@index([status, createdAt], map: "idx_project_status_createdAt")