diff --git a/n8n-workflows/README.md b/n8n-workflows/README.md new file mode 100644 index 0000000..865f0ca --- /dev/null +++ b/n8n-workflows/README.md @@ -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 可供前端调用 +- 用户可以使用自然语言查询项目 diff --git a/n8n-workflows/ai-semantic-search.json b/n8n-workflows/ai-semantic-search.json new file mode 100644 index 0000000..82147e4 --- /dev/null +++ b/n8n-workflows/ai-semantic-search.json @@ -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" +} diff --git a/n8n-workflows/project-vectorization.json b/n8n-workflows/project-vectorization.json new file mode 100644 index 0000000..1a8fd99 --- /dev/null +++ b/n8n-workflows/project-vectorization.json @@ -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" +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2ed8aa0..59564fe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,23 +39,25 @@ 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? - 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? + embedding Unsupported("vector(1536)")? + embeddingUpdatedAt DateTime? + 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") diff --git a/src/app/[locale]/projects/ProjectsPageClient.tsx b/src/app/[locale]/projects/ProjectsPageClient.tsx new file mode 100644 index 0000000..f8fbbad --- /dev/null +++ b/src/app/[locale]/projects/ProjectsPageClient.tsx @@ -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([]) + const [isAIResult, setIsAIResult] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 搜索栏 */} + + + {/* Tag Cloud / AI 搜索结果 */} + {isAIResult ? ( + <> + {error && ( +
+

{error}

+
+ )} + + + ) : ( + children + )} + + ) +} diff --git a/src/app/[locale]/projects/page.tsx b/src/app/[locale]/projects/page.tsx index f8b77b0..3a324ef 100644 --- a/src/app/[locale]/projects/page.tsx +++ b/src/app/[locale]/projects/page.tsx @@ -3,7 +3,7 @@ import { getTranslations } from 'next-intl/server' import { getProjects, getAllTags, getTopTags } from '@/hooks/useProjects' import { ProjectList } from '@/components/project/ProjectList' import { TagCloud } from '@/components/project/TagCloud' -import { SearchBar } from '@/components/search/SearchBar' +import { ProjectsPageClient } from './ProjectsPageClient' interface ProjectsPageProps { params: Promise<{ locale: string }> @@ -34,24 +34,27 @@ export default async function ProjectsPage({ {/* Search and Filter Section */}
}> - + aiPlaceholder={t('aiSearchPlaceholder')} + aiLabel={tCommon('aiSearch')} + > + {/* Tag Cloud - rendered inside client component for non-AI mode */} +
+

+ Browse by Tags +

+ +
+ - -
-

- Browse by Tags -

- -
{/* Projects Section */} diff --git a/src/app/api/discovery/tasks/[id]/route.ts b/src/app/api/discovery/tasks/[id]/route.ts index 6ea0b5a..4d28ce7 100644 --- a/src/app/api/discovery/tasks/[id]/route.ts +++ b/src/app/api/discovery/tasks/[id]/route.ts @@ -8,14 +8,14 @@ import type { Prisma } from '@prisma/client' * 有效的任务状态转换规则 * PENDING -> IN_PROGRESS * IN_PROGRESS -> COMPLETED | FAILED - * FAILED -> PENDING (允许重试) + * FAILED -> PENDING | IN_PROGRESS (允许重试,可直接重试或重置后重试) * COMPLETED -> (终态,不允许转换) */ const VALID_STATUS_TRANSITIONS: Record = { PENDING: ['IN_PROGRESS'], IN_PROGRESS: ['COMPLETED', 'FAILED'], COMPLETED: [], - FAILED: ['PENDING'], + FAILED: ['PENDING', 'IN_PROGRESS'], } /** diff --git a/src/app/api/search/ai/route.ts b/src/app/api/search/ai/route.ts new file mode 100644 index 0000000..d28cfd7 --- /dev/null +++ b/src/app/api/search/ai/route.ts @@ -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 } + ) + } +} diff --git a/src/components/search/AISearchBar.tsx b/src/components/search/AISearchBar.tsx new file mode 100644 index 0000000..bc2924a --- /dev/null +++ b/src/components/search/AISearchBar.tsx @@ -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 ( +
+
+ {/* Glow effect on hover */} +
+ +
+ {/* Search icon */} +
+ 🔍 +
+ + {/* Input */} + 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 */} + + + {/* Search button */} + +
+
+ + {/* AI Mode Hint */} + {aiMode && ( +
+ 💡 {aiMode ? '试试:"帮我找能生成视频的 AI 工具"' : '输入项目名称或描述'} +
+ )} +
+ ) +} diff --git a/src/components/search/AISearchResults.tsx b/src/components/search/AISearchResults.tsx new file mode 100644 index 0000000..08de47d --- /dev/null +++ b/src/components/search/AISearchResults.tsx @@ -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 ( +
+
🔍
+

+ 未找到相关项目,试试其他描述吧 +

+
+ ) + } + + return ( +
+ {/* 相似度说明 */} +
+ 匹配度: +
+ + + +
+
+ + {/* 结果列表 */} + {results.map(({ project, similarity, matchReason }) => ( +
+ {/* 相似度指示条 */} +
+ + {/* 项目卡片 */} +
+ + + {/* AI 匹配信息 */} +
+
+ + 匹配度: {(similarity * 100).toFixed(0)}% + + {matchReason && ( + {matchReason} + )} +
+
+
+
+ ))} +
+ ) +} + +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 +} diff --git a/src/messages/en.json b/src/messages/en.json index d7b8d6b..318a64d 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1,6 +1,7 @@ { "common": { "search": "Search", + "aiSearch": "AI Search", "loading": "Loading...", "noResults": "No results found", "noProjects": "No projects yet", @@ -21,6 +22,7 @@ "featuredProjects": "Featured Projects", "browseByTag": "Browse by Tag", "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", "metaDescription": "Discover and explore quality AI projects from across the web", "heroTitle": "AI PROJECT", diff --git a/src/messages/zh.json b/src/messages/zh.json index 2ad3166..72c6c6e 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -1,6 +1,7 @@ { "common": { "search": "搜索", + "aiSearch": "AI 搜索", "loading": "加载中...", "noResults": "未找到结果", "noProjects": "暂无项目", @@ -21,6 +22,7 @@ "featuredProjects": "精选项目", "browseByTag": "按标签浏览", "searchPlaceholder": "搜索 AI 项目...", + "aiSearchPlaceholder": "描述你想要的项目,如:能生成视频的 AI 工具...", "metaTitle": "Agent Park - AI 项目导航", "metaDescription": "发现和探索全网优质 AI 项目", "heroTitle": "AI 项目",