refactor: 统一 AI 搜索与传统搜索布局,并简化 n8n 响应格式处理
- API 只支持 n8n 返回的 {results: [{id, similarity}]} 格式
- AI 搜索结果改用网格布局,与传统搜索保持一致
- 用右上角相似度徽章替代左侧彩色条和黄色高亮框
- 移除顶部的匹配度说明,徽章颜色直观显示匹配质量
Co-Authored-By: Claude (glm-4.7) <noreply@anthropic.com>
This commit is contained in:
@@ -9,13 +9,15 @@ 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(),
|
||||
})
|
||||
)
|
||||
// n8n 返回的搜索结果 Schema(统一格式)
|
||||
const N8NSearchResponseSchema = z.object({
|
||||
results: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
similarity: z.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -50,20 +52,9 @@ export async function POST(request: Request) {
|
||||
// n8n 返回数据
|
||||
const n8nData = await n8nResponse.json()
|
||||
|
||||
// 兼容多种返回格式
|
||||
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')
|
||||
}
|
||||
// 解析 n8n 响应(只支持 {results: [{id, similarity}, ...]} 格式)
|
||||
const parsed = N8NSearchResponseSchema.parse(n8nData)
|
||||
const n8nResults = parsed.results
|
||||
|
||||
if (n8nResults.length === 0) {
|
||||
return NextResponse.json({ projects: [], pagination: { total: 0, page: 1, limit: validatedQuery.limit || 20 } })
|
||||
@@ -75,9 +66,6 @@ export async function POST(request: Request) {
|
||||
// 从数据库批量获取完整项目数据
|
||||
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) => {
|
||||
|
||||
@@ -33,44 +33,24 @@ export function AISearchResults({ results, locale, translations }: AISearchResul
|
||||
}
|
||||
|
||||
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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{results.map(({ project, similarity }) => (
|
||||
<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} translations={translations} />
|
||||
|
||||
{/* 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 className="absolute -top-3 -right-3 z-10">
|
||||
<div
|
||||
className="px-3 py-1.5 rounded-full border-2 border-black dark:border-gray-600 shadow-neo-sm font-display font-bold text-xs"
|
||||
style={{
|
||||
backgroundColor: getSimilarityColor(similarity),
|
||||
color: getSimilarityTextColor(similarity),
|
||||
}}
|
||||
>
|
||||
{(similarity * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 项目卡片 */}
|
||||
<ProjectCard project={project} locale={locale} translations={translations} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -78,8 +58,15 @@ export function AISearchResults({ results, locale, translations }: AISearchResul
|
||||
}
|
||||
|
||||
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
|
||||
if (score > 0.8) return '#dcfce7' // green-100
|
||||
if (score > 0.6) return '#fef9c3' // yellow-100
|
||||
if (score > 0.4) return '#fed7aa' // orange-100
|
||||
return '#fecaca' // red-100
|
||||
}
|
||||
|
||||
function getSimilarityTextColor(score: number): string {
|
||||
if (score > 0.8) return '#166534' // green-800
|
||||
if (score > 0.6) return '#854d0e' // yellow-800
|
||||
if (score > 0.4) return '#9a3412' // orange-800
|
||||
return '#991b1b' // red-800
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user