refactor: 统一 AI 搜索与传统搜索布局,并简化 n8n 响应格式处理

- API 只支持 n8n 返回的 {results: [{id, similarity}]} 格式
- AI 搜索结果改用网格布局,与传统搜索保持一致
- 用右上角相似度徽章替代左侧彩色条和黄色高亮框
- 移除顶部的匹配度说明,徽章颜色直观显示匹配质量

Co-Authored-By: Claude (glm-4.7) <noreply@anthropic.com>
This commit is contained in:
2026-01-27 19:09:16 +08:00
co-authored by Claude
parent 6c13fc54d6
commit 07457f09b1
2 changed files with 38 additions and 63 deletions
+12 -24
View File
@@ -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) => {
+26 -39
View File
@@ -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
}