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,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 { 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 */}
|
||||
<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>}>
|
||||
<SearchBar
|
||||
<ProjectsPageClient
|
||||
locale={locale}
|
||||
searchPlaceholder={t('searchPlaceholder')}
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Projects Section */}
|
||||
|
||||
@@ -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<TaskStatus, TaskStatus[]> = {
|
||||
PENDING: ['IN_PROGRESS'],
|
||||
IN_PROGRESS: ['COMPLETED', 'FAILED'],
|
||||
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": {
|
||||
"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",
|
||||
|
||||
@@ -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 项目",
|
||||
|
||||
Reference in New Issue
Block a user