feat: 实现 AI 智能搜索功能
添加语义搜索能力,支持自然语言查询找到相关项目。 - 数据库:新增 embedding 字段用于向量存储 - 前端:新增 AI 搜索栏和结果组件,支持传统/AI 模式切换 - API:新增 /api/search/ai 端点处理语义搜索请求 - 国际化:添加 AI 搜索相关中英文翻译 - 探索任务:允许 FAILED 状态直接转到 IN_PROGRESS 简化重试 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
|||||||
|
# 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 可供前端调用
|
||||||
|
- 用户可以使用自然语言查询项目
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+17
-15
@@ -39,23 +39,25 @@ enum TaskStatus {
|
|||||||
// ================================
|
// ================================
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
nameEn String?
|
nameEn String?
|
||||||
slug String @unique
|
slug String @unique
|
||||||
description String
|
description String
|
||||||
descriptionEn String?
|
descriptionEn String?
|
||||||
content String? @db.Text
|
content String? @db.Text
|
||||||
contentEn String? @db.Text
|
contentEn String? @db.Text
|
||||||
status ProjectStatus @default(ACTIVE)
|
status ProjectStatus @default(ACTIVE)
|
||||||
source String?
|
source String?
|
||||||
createdAt DateTime @default(now())
|
embedding Unsupported("vector(1536)")?
|
||||||
updatedAt DateTime @updatedAt
|
embeddingUpdatedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
tags ProjectTag[]
|
tags ProjectTag[]
|
||||||
links ExternalLink[]
|
links ExternalLink[]
|
||||||
discoveryTasks ProjectDiscoveryTask[]
|
discoveryTasks ProjectDiscoveryTask[]
|
||||||
|
|
||||||
// Indexes
|
// Indexes
|
||||||
@@index([status, createdAt], map: "idx_project_status_createdAt")
|
@@index([status, createdAt], map: "idx_project_status_createdAt")
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { AISearchBar } from '@/components/search/AISearchBar'
|
||||||
|
import { AISearchResults } from '@/components/search/AISearchResults'
|
||||||
|
import type { ProjectWithFlatTags } from '@/hooks/useProjects'
|
||||||
|
|
||||||
|
interface AISearchResult {
|
||||||
|
project: ProjectWithFlatTags
|
||||||
|
similarity: number
|
||||||
|
matchReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectsPageClientProps {
|
||||||
|
locale: string
|
||||||
|
children: React.ReactNode
|
||||||
|
searchPlaceholder: string
|
||||||
|
searchLabel: string
|
||||||
|
aiPlaceholder: string
|
||||||
|
aiLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsPageClient({
|
||||||
|
locale,
|
||||||
|
children,
|
||||||
|
searchPlaceholder,
|
||||||
|
searchLabel,
|
||||||
|
aiPlaceholder,
|
||||||
|
aiLabel,
|
||||||
|
}: ProjectsPageClientProps) {
|
||||||
|
const [aiResults, setAiResults] = useState<AISearchResult[]>([])
|
||||||
|
const [isAIResult, setIsAIResult] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleAISearch = async (query: string, isAI: boolean) => {
|
||||||
|
if (!isAI) {
|
||||||
|
// 传统搜索:刷新页面到 URL 参数
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query) params.set('search', query)
|
||||||
|
window.location.href = `/${locale}/projects?${params.toString()}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 搜索
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setIsAIResult(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/search/ai', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
search: query,
|
||||||
|
locale: locale,
|
||||||
|
limit: 20
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('搜索失败,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setAiResults(data.results || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '未知错误')
|
||||||
|
setAiResults([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* AI 搜索栏 */}
|
||||||
|
<AISearchBar
|
||||||
|
locale={locale}
|
||||||
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
searchLabel={searchLabel}
|
||||||
|
aiPlaceholder={aiPlaceholder}
|
||||||
|
aiLabel={aiLabel}
|
||||||
|
onSearch={handleAISearch}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tag Cloud / AI 搜索结果 */}
|
||||||
|
{isAIResult ? (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-8 px-4 py-3 bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 rounded-r">
|
||||||
|
<p className="text-red-700 dark:text-red-400 font-display text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AISearchResults results={aiResults} locale={locale} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { getTranslations } from 'next-intl/server'
|
|||||||
import { getProjects, getAllTags, getTopTags } from '@/hooks/useProjects'
|
import { getProjects, getAllTags, getTopTags } from '@/hooks/useProjects'
|
||||||
import { ProjectList } from '@/components/project/ProjectList'
|
import { ProjectList } from '@/components/project/ProjectList'
|
||||||
import { TagCloud } from '@/components/project/TagCloud'
|
import { TagCloud } from '@/components/project/TagCloud'
|
||||||
import { SearchBar } from '@/components/search/SearchBar'
|
import { ProjectsPageClient } from './ProjectsPageClient'
|
||||||
|
|
||||||
interface ProjectsPageProps {
|
interface ProjectsPageProps {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>
|
||||||
@@ -34,24 +34,27 @@ export default async function ProjectsPage({
|
|||||||
{/* Search and Filter Section */}
|
{/* Search and Filter Section */}
|
||||||
<div className="bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-white/20 p-6 md:p-8 mb-12 shadow-neo dark:shadow-none">
|
<div className="bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-white/20 p-6 md:p-8 mb-12 shadow-neo dark:shadow-none">
|
||||||
<Suspense fallback={<div className="h-20"></div>}>
|
<Suspense fallback={<div className="h-20"></div>}>
|
||||||
<SearchBar
|
<ProjectsPageClient
|
||||||
locale={locale}
|
locale={locale}
|
||||||
searchPlaceholder={t('searchPlaceholder')}
|
searchPlaceholder={t('searchPlaceholder')}
|
||||||
searchLabel={tCommon('search')}
|
searchLabel={tCommon('search')}
|
||||||
/>
|
aiPlaceholder={t('aiSearchPlaceholder')}
|
||||||
|
aiLabel={tCommon('aiSearch')}
|
||||||
|
>
|
||||||
|
{/* Tag Cloud - rendered inside client component for non-AI mode */}
|
||||||
|
<div className="border-t-2 border-gray-100 dark:border-gray-800 pt-6 mt-8">
|
||||||
|
<h3 className="font-display font-bold uppercase text-sm mb-4 border-b-2 border-black inline-block dark:border-primary pb-1">
|
||||||
|
Browse by Tags
|
||||||
|
</h3>
|
||||||
|
<TagCloud
|
||||||
|
tags={topTags} // 默认显示前10个
|
||||||
|
allTags={allTags} // 用于展开和搜索
|
||||||
|
locale={locale}
|
||||||
|
activeTag={tag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ProjectsPageClient>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="border-t-2 border-gray-100 dark:border-gray-800 pt-6 mt-8">
|
|
||||||
<h3 className="font-display font-bold uppercase text-sm mb-4 border-b-2 border-black inline-block dark:border-primary pb-1">
|
|
||||||
Browse by Tags
|
|
||||||
</h3>
|
|
||||||
<TagCloud
|
|
||||||
tags={topTags} // 默认显示前10个
|
|
||||||
allTags={allTags} // 用于展开和搜索
|
|
||||||
locale={locale}
|
|
||||||
activeTag={tag}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Projects Section */}
|
{/* Projects Section */}
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import type { Prisma } from '@prisma/client'
|
|||||||
* 有效的任务状态转换规则
|
* 有效的任务状态转换规则
|
||||||
* PENDING -> IN_PROGRESS
|
* PENDING -> IN_PROGRESS
|
||||||
* IN_PROGRESS -> COMPLETED | FAILED
|
* IN_PROGRESS -> COMPLETED | FAILED
|
||||||
* FAILED -> PENDING (允许重试)
|
* FAILED -> PENDING | IN_PROGRESS (允许重试,可直接重试或重置后重试)
|
||||||
* COMPLETED -> (终态,不允许转换)
|
* COMPLETED -> (终态,不允许转换)
|
||||||
*/
|
*/
|
||||||
const VALID_STATUS_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
|
const VALID_STATUS_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
|
||||||
PENDING: ['IN_PROGRESS'],
|
PENDING: ['IN_PROGRESS'],
|
||||||
IN_PROGRESS: ['COMPLETED', 'FAILED'],
|
IN_PROGRESS: ['COMPLETED', 'FAILED'],
|
||||||
COMPLETED: [],
|
COMPLETED: [],
|
||||||
FAILED: ['PENDING'],
|
FAILED: ['PENDING', 'IN_PROGRESS'],
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { ZodError } from 'zod'
|
||||||
|
import { ProjectQuerySchema } from '@/lib/validations'
|
||||||
|
|
||||||
|
const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK!
|
||||||
|
|
||||||
|
if (!N8N_WEBHOOK_URL) {
|
||||||
|
throw new Error('N8N_AI_SEARCH_WEBHOOK environment variable is not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
// 验证查询参数
|
||||||
|
const validatedQuery = ProjectQuerySchema.parse(body)
|
||||||
|
|
||||||
|
// 转发到 n8n 工作流
|
||||||
|
const n8nResponse = await fetch(N8N_WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: validatedQuery.search,
|
||||||
|
locale: body.locale || 'zh',
|
||||||
|
limit: validatedQuery.limit || 20,
|
||||||
|
filters: {
|
||||||
|
tags: validatedQuery.tags,
|
||||||
|
status: validatedQuery.status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!n8nResponse.ok) {
|
||||||
|
throw new Error(`n8n webhook failed: ${n8nResponse.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await n8nResponse.json()
|
||||||
|
|
||||||
|
return NextResponse.json(results)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI search error:', error)
|
||||||
|
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid query parameters', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'AI search failed', message: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AISearchBarProps {
|
||||||
|
locale: string
|
||||||
|
searchPlaceholder: string
|
||||||
|
searchLabel: string
|
||||||
|
aiPlaceholder: string
|
||||||
|
aiLabel: string
|
||||||
|
onSearch: (query: string, isAI: boolean) => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AISearchBar({
|
||||||
|
locale,
|
||||||
|
searchPlaceholder,
|
||||||
|
searchLabel,
|
||||||
|
aiPlaceholder,
|
||||||
|
aiLabel,
|
||||||
|
onSearch,
|
||||||
|
loading = false,
|
||||||
|
}: AISearchBarProps) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [aiMode, setAiMode] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (query.trim()) {
|
||||||
|
onSearch(query, aiMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAIMode = () => {
|
||||||
|
setAiMode(!aiMode)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto">
|
||||||
|
<div className="relative group">
|
||||||
|
{/* Glow effect on hover */}
|
||||||
|
<div className="absolute -inset-1 bg-black dark:bg-primary rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-200"></div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
{/* Search icon */}
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||||
|
<span className="text-gray-400">🔍</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={aiMode ? aiPlaceholder : searchPlaceholder}
|
||||||
|
className="block w-full pl-12 pr-40 py-4 bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-gray-600 text-text-light dark:text-text-dark placeholder-gray-500 focus:ring-0 focus:border-black dark:focus:border-primary font-display shadow-neo transition-all"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* AI Mode Toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAIMode}
|
||||||
|
className={`
|
||||||
|
absolute inset-y-2 right-24 px-3 py-2 font-display font-bold text-sm border-2 transition-all shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px]
|
||||||
|
${aiMode
|
||||||
|
? 'bg-primary text-black border-black hover:bg-yellow-400'
|
||||||
|
: 'bg-white dark:bg-surface-dark text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-white/10'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={aiMode ? '切换到传统搜索' : '切换到 AI 搜索'}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Search button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !query.trim()}
|
||||||
|
className="absolute inset-y-2 right-2 px-4 bg-black dark:bg-primary text-white dark:text-black font-bold font-display text-sm border-2 border-black dark:border-primary hover:bg-gray-800 dark:hover:bg-yellow-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
|
||||||
|
>
|
||||||
|
{loading ? '搜索中...' : (aiMode ? aiLabel : searchLabel)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Mode Hint */}
|
||||||
|
{aiMode && (
|
||||||
|
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400 font-display">
|
||||||
|
💡 {aiMode ? '试试:"帮我找能生成视频的 AI 工具"' : '输入项目名称或描述'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ProjectCard } from '@/components/project/ProjectCard'
|
||||||
|
import type { ProjectWithFlatTags } from '@/hooks/useProjects'
|
||||||
|
|
||||||
|
interface AISearchResult {
|
||||||
|
project: ProjectWithFlatTags
|
||||||
|
similarity: number
|
||||||
|
matchReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AISearchResultsProps {
|
||||||
|
results: AISearchResult[]
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AISearchResults({ results, locale }: AISearchResultsProps) {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 px-4 bg-surface-light dark:bg-surface-dark border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="text-4xl mb-4">🔍</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 font-display">
|
||||||
|
未找到相关项目,试试其他描述吧
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 相似度说明 */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-900 border-l-4 border-primary rounded-r font-display text-sm">
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-300">匹配度:</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-green-600 font-bold">高</span>
|
||||||
|
<span className="text-gray-400">→</span>
|
||||||
|
<span className="text-red-600 font-bold">低</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果列表 */}
|
||||||
|
{results.map(({ project, similarity, matchReason }) => (
|
||||||
|
<div key={project.id} className="relative">
|
||||||
|
{/* 相似度指示条 */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-1.5 rounded-l"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getSimilarityColor(similarity)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 项目卡片 */}
|
||||||
|
<div className="ml-3">
|
||||||
|
<ProjectCard project={project} locale={locale} />
|
||||||
|
|
||||||
|
{/* AI 匹配信息 */}
|
||||||
|
<div className="mt-3 px-4 py-3 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 dark:border-yellow-500 rounded-r">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 text-sm font-display">
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
匹配度: {(similarity * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
{matchReason && (
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">{matchReason}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSimilarityColor(score: number): string {
|
||||||
|
if (score > 0.8) return '#22c55e' // green-500
|
||||||
|
if (score > 0.6) return '#eab308' // yellow-500
|
||||||
|
if (score > 0.4) return '#f97316' // orange-500
|
||||||
|
return '#ef4444' // red-500
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
"aiSearch": "AI Search",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noResults": "No results found",
|
"noResults": "No results found",
|
||||||
"noProjects": "No projects yet",
|
"noProjects": "No projects yet",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"featuredProjects": "Featured Projects",
|
"featuredProjects": "Featured Projects",
|
||||||
"browseByTag": "Browse by Tag",
|
"browseByTag": "Browse by Tag",
|
||||||
"searchPlaceholder": "Search AI projects...",
|
"searchPlaceholder": "Search AI projects...",
|
||||||
|
"aiSearchPlaceholder": "Describe what you're looking for, e.g.: AI tools that can generate videos...",
|
||||||
"metaTitle": "Agent Park - AI Project Navigator",
|
"metaTitle": "Agent Park - AI Project Navigator",
|
||||||
"metaDescription": "Discover and explore quality AI projects from across the web",
|
"metaDescription": "Discover and explore quality AI projects from across the web",
|
||||||
"heroTitle": "AI PROJECT",
|
"heroTitle": "AI PROJECT",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
|
"aiSearch": "AI 搜索",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"noResults": "未找到结果",
|
"noResults": "未找到结果",
|
||||||
"noProjects": "暂无项目",
|
"noProjects": "暂无项目",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"featuredProjects": "精选项目",
|
"featuredProjects": "精选项目",
|
||||||
"browseByTag": "按标签浏览",
|
"browseByTag": "按标签浏览",
|
||||||
"searchPlaceholder": "搜索 AI 项目...",
|
"searchPlaceholder": "搜索 AI 项目...",
|
||||||
|
"aiSearchPlaceholder": "描述你想要的项目,如:能生成视频的 AI 工具...",
|
||||||
"metaTitle": "Agent Park - AI 项目导航",
|
"metaTitle": "Agent Park - AI 项目导航",
|
||||||
"metaDescription": "发现和探索全网优质 AI 项目",
|
"metaDescription": "发现和探索全网优质 AI 项目",
|
||||||
"heroTitle": "AI 项目",
|
"heroTitle": "AI 项目",
|
||||||
|
|||||||
Reference in New Issue
Block a user