diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index d59c900..cf53365 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,7 +1,7 @@ import { getTranslations } from 'next-intl/server' import { getProjects } from '@/hooks/useProjects' import { ProjectList } from '@/components/project/ProjectList' -import { SearchBar } from '@/components/search/SearchBar' +import { HomeSearchBar } from '@/components/search/HomeSearchBar' import { Suspense } from 'react' interface HomePageProps { @@ -30,10 +30,17 @@ export default async function HomePage({ params }: HomePageProps) { {t('heroDescription')}

}> - diff --git a/src/app/[locale]/projects/ProjectsPageClient.tsx b/src/app/[locale]/projects/ProjectsPageClient.tsx index f8fbbad..8376b76 100644 --- a/src/app/[locale]/projects/ProjectsPageClient.tsx +++ b/src/app/[locale]/projects/ProjectsPageClient.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' import { AISearchBar } from '@/components/search/AISearchBar' import { AISearchResults } from '@/components/search/AISearchResults' import type { ProjectWithFlatTags } from '@/hooks/useProjects' @@ -18,6 +19,11 @@ interface ProjectsPageClientProps { searchLabel: string aiPlaceholder: string aiLabel: string + toggleToAI: string + toggleToTraditional: string + searching: string + aiHint: string + traditionalHint: string } export function ProjectsPageClient({ @@ -27,22 +33,31 @@ export function ProjectsPageClient({ searchLabel, aiPlaceholder, aiLabel, + toggleToAI, + toggleToTraditional, + searching, + aiHint, + traditionalHint, }: ProjectsPageClientProps) { + const searchParams = useSearchParams() const [aiResults, setAiResults] = useState([]) const [isAIResult, setIsAIResult] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [initialAIHandled, setInitialAIHandled] = useState(false) - 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 + // 检查 URL 参数,如果有 ai=1 且有搜索词,则自动触发 AI 搜索 + useEffect(() => { + const aiParam = searchParams.get('ai') + const searchParam = searchParams.get('search') + + if (aiParam === '1' && searchParam && !initialAIHandled) { + setInitialAIHandled(true) + performAISearch(searchParam) } + }, [searchParams, initialAIHandled]) - // AI 搜索 + const performAISearch = async (query: string) => { setLoading(true) setError(null) setIsAIResult(true) @@ -63,7 +78,31 @@ export function ProjectsPageClient({ } const data = await response.json() - setAiResults(data.results || []) + + // 转换 API 返回格式为组件期望格式 + // API 返回: { projects: [{ ...project, similarity }], pagination } + // 组件期望: [{ project, similarity }] + const { similarity, ...projectData } = data.projects || [] + const results = (data.projects || []).map((p: any) => ({ + project: { + id: p.id, + name: p.name, + nameEn: p.nameEn, + slug: p.slug, + description: p.description, + descriptionEn: p.descriptionEn, + content: p.content, + contentEn: p.contentEn, + status: p.status, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + tags: p.tags || [], + links: p.links || [], + }, + similarity: p.similarity, + })) + + setAiResults(results) } catch (err) { setError(err instanceof Error ? err.message : '未知错误') setAiResults([]) @@ -72,6 +111,19 @@ export function ProjectsPageClient({ } } + 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 搜索 + await performAISearch(query) + } + return ( <> {/* AI 搜索栏 */} @@ -81,6 +133,11 @@ export function ProjectsPageClient({ searchLabel={searchLabel} aiPlaceholder={aiPlaceholder} aiLabel={aiLabel} + toggleToAI={toggleToAI} + toggleToTraditional={toggleToTraditional} + searching={searching} + aiHint={aiHint} + traditionalHint={traditionalHint} onSearch={handleAISearch} loading={loading} /> diff --git a/src/app/[locale]/projects/page.tsx b/src/app/[locale]/projects/page.tsx index 3a324ef..0b45ce0 100644 --- a/src/app/[locale]/projects/page.tsx +++ b/src/app/[locale]/projects/page.tsx @@ -40,6 +40,11 @@ export default async function ProjectsPage({ searchLabel={tCommon('search')} aiPlaceholder={t('aiSearchPlaceholder')} aiLabel={tCommon('aiSearch')} + toggleToAI={tCommon('toggleToAI')} + toggleToTraditional={tCommon('toggleToTraditional')} + searching={tCommon('searching')} + aiHint={tCommon('aiHint')} + traditionalHint={tCommon('traditionalHint')} > {/* Tag Cloud - rendered inside client component for non-AI mode */}
diff --git a/src/app/api/search/ai/route.ts b/src/app/api/search/ai/route.ts index 62bfd5a..5097063 100644 --- a/src/app/api/search/ai/route.ts +++ b/src/app/api/search/ai/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' -import { ZodError } from 'zod' +import { ZodError, z } from 'zod' import { ProjectQuerySchema } from '@/lib/validations' +import { getProjectsByIds, type AISearchResultItem } from '@/hooks/useProjects' const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK! @@ -8,6 +9,14 @@ if (!N8N_WEBHOOK_URL) { throw new Error('N8N_AI_SEARCH_WEBHOOK environment variable is not set') } +// n8n 返回的简化搜索结果 Schema +const N8NSearchResultSchema = z.array( + z.object({ + id: z.string(), + similarity: z.number(), + }) +) + export async function POST(request: Request) { try { const body = await request.json() @@ -38,9 +47,57 @@ export async function POST(request: Request) { throw new Error(`n8n webhook failed: ${n8nResponse.statusText} - ${errorText}`) } - const results = await n8nResponse.json() + // n8n 返回数据 + const n8nData = await n8nResponse.json() - return NextResponse.json(results) + // 兼容多种返回格式 + let n8nResults: Array<{ id: string; similarity: number }> + if (Array.isArray(n8nData)) { + // 数组格式: [{ id: "...", similarity: 0.59 }] + n8nResults = N8NSearchResultSchema.parse(n8nData) + } else if (n8nData.id && typeof n8nData.similarity === 'number') { + // 单个对象格式: { id: "...", similarity: 0.59 } + n8nResults = [n8nData as { id: string; similarity: number }] + } else if (n8nData.data && Array.isArray(n8nData.data)) { + // 包装在 data 字段: { data: [{ id: "...", similarity: 0.59 }] } + n8nResults = N8NSearchResultSchema.parse(n8nData.data) + } else { + throw new Error('Invalid n8n response format') + } + + if (n8nResults.length === 0) { + return NextResponse.json({ projects: [], pagination: { total: 0, page: 1, limit: validatedQuery.limit || 20 } }) + } + + // 提取所有项目 ID + const projectIds = n8nResults.map((r) => r.id) + + // 从数据库批量获取完整项目数据 + const projects = await getProjectsByIds(projectIds) + + // 创建 similarity 映射表(ID -> similarity) + const similarityMap = new Map(n8nResults.map((r) => [r.id, r.similarity])) + + // 将 similarity 合并到项目数据中,并按 n8n 返回的顺序排序 + const results: AISearchResultItem[] = n8nResults + .map((n8nItem) => { + const project = projects.find((p) => p.id === n8nItem.id) + if (!project) return null + return { + ...project, + similarity: n8nItem.similarity, + } + }) + .filter((item): item is AISearchResultItem => item !== null) + + return NextResponse.json({ + projects: results, + pagination: { + total: results.length, + page: 1, + limit: validatedQuery.limit || 20, + }, + }) } catch (error) { console.error('AI search error:', error) diff --git a/src/components/search/AISearchBar.tsx b/src/components/search/AISearchBar.tsx index bc2924a..c9f32a6 100644 --- a/src/components/search/AISearchBar.tsx +++ b/src/components/search/AISearchBar.tsx @@ -9,6 +9,11 @@ interface AISearchBarProps { searchLabel: string aiPlaceholder: string aiLabel: string + toggleToAI: string + toggleToTraditional: string + searching: string + aiHint: string + traditionalHint: string onSearch: (query: string, isAI: boolean) => void loading?: boolean } @@ -19,6 +24,11 @@ export function AISearchBar({ searchLabel, aiPlaceholder, aiLabel, + toggleToAI, + toggleToTraditional, + searching, + aiHint, + traditionalHint, onSearch, loading = false, }: AISearchBarProps) { @@ -34,7 +44,6 @@ export function AISearchBar({ const toggleAIMode = () => { setAiMode(!aiMode) - setQuery('') } return ( @@ -55,40 +64,43 @@ export function AISearchBar({ 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" + className="block w-full pl-12 pr-48 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 */} - + {/* Buttons Container */} +
+ {/* AI Mode Toggle */} + - {/* Search button */} - + {/* Search button */} + +
{/* AI Mode Hint */} {aiMode && (
- 💡 {aiMode ? '试试:"帮我找能生成视频的 AI 工具"' : '输入项目名称或描述'} + 💡 {aiHint}
)} diff --git a/src/components/search/HomeSearchBar.tsx b/src/components/search/HomeSearchBar.tsx new file mode 100644 index 0000000..329a61f --- /dev/null +++ b/src/components/search/HomeSearchBar.tsx @@ -0,0 +1,52 @@ +'use client' + +import { AISearchBar } from './AISearchBar' + +interface HomeSearchBarProps { + locale: string + searchPlaceholder: string + searchLabel: string + aiPlaceholder: string + aiLabel: string + toggleToAI: string + toggleToTraditional: string + searching: string + aiHint: string + traditionalHint: string +} + +export function HomeSearchBar({ + locale, + searchPlaceholder, + searchLabel, + aiPlaceholder, + aiLabel, + toggleToAI, + toggleToTraditional, + searching, + aiHint, + traditionalHint, +}: HomeSearchBarProps) { + const handleSearch = (query: string, isAI: boolean) => { + const params = new URLSearchParams() + if (query) params.set('search', query) + if (isAI) params.set('ai', '1') + window.location.href = `/${locale}/projects?${params.toString()}` + } + + return ( + + ) +} diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index b225452..ef0421a 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -181,3 +181,47 @@ export async function getTopTags(limit: number = 10): Promise & { + similarity: number +} + +// n8n 返回的简化搜索结果类型 +export type N8NSearchResult = { + id: string + similarity: number +} + +/** + * 根据 ID 列表批量获取项目(用于 AI 搜索结果组装) + * @param ids 项目 ID 列表 + * @returns 带有相似度分数的项目列表 + */ +export async function getProjectsByIds(ids: string[]): Promise { + if (ids.length === 0) { + return [] + } + + const projects = await prisma.project.findMany({ + where: { + id: { + in: ids, + }, + }, + include: { + tags: { + include: { + tag: true, + }, + }, + links: true, + }, + }) + + // Transform tags to flatten the structure + return projects.map((project) => ({ + ...project, + tags: project.tags.map((pt) => pt.tag), + })) +} diff --git a/src/messages/en.json b/src/messages/en.json index 318a64d..8ac1c95 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2,6 +2,11 @@ "common": { "search": "Search", "aiSearch": "AI Search", + "searching": "Searching...", + "toggleToAI": "Switch to AI Search", + "toggleToTraditional": "Switch to Traditional Search", + "aiHint": "Try: \"Find AI tools that can generate videos\"", + "traditionalHint": "Enter project name or description", "loading": "Loading...", "noResults": "No results found", "noProjects": "No projects yet", diff --git a/src/messages/zh.json b/src/messages/zh.json index 72c6c6e..efbc015 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -2,6 +2,11 @@ "common": { "search": "搜索", "aiSearch": "AI 搜索", + "searching": "搜索中...", + "toggleToAI": "切换到 AI 搜索", + "toggleToTraditional": "切换到传统搜索", + "aiHint": "试试:\"帮我找能生成视频的 AI 工具\"", + "traditionalHint": "输入项目名称或描述", "loading": "加载中...", "noResults": "未找到结果", "noProjects": "暂无项目",