feat: ai search

This commit is contained in:
2026-02-26 03:10:15 +01:00
parent 0487bd8ebe
commit 20fd889751
14 changed files with 963 additions and 76 deletions

138
app/api/ai-search/route.ts Normal file
View File

@@ -0,0 +1,138 @@
import { NextResponse } from 'next/server';
import { searchProducts } from '../../../src/lib/qdrant';
import redis from '../../../src/lib/redis';
import { z } from 'zod';
// Config and constants
const RATE_LIMIT_POINTS = 5; // 5 requests
const RATE_LIMIT_DURATION = 60 * 1; // per 1 minute
const requestSchema = z.object({
query: z.string().min(1).max(500),
_honeypot: z.string().max(0).optional(), // Honeypot trap: must be empty
});
export async function POST(req: Request) {
try {
// 1. IP extraction for Rate Limiting
const forwardedFor = req.headers.get('x-forwarded-for');
const realIp = req.headers.get('x-real-ip');
const ip = forwardedFor?.split(',')[0] || realIp || 'anon';
const rateLimitKey = `rate_limit:ai_search:${ip}`;
// Redis Rate Limiting
try {
const current = await redis.incr(rateLimitKey);
if (current === 1) {
await redis.expire(rateLimitKey, RATE_LIMIT_DURATION);
}
if (current > RATE_LIMIT_POINTS) {
return NextResponse.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429 });
}
} catch (redisError) {
console.warn('Redis error during rate limiting:', redisError);
// Fallback: proceed if Redis is down, to maintain availability
}
// 2. Validate request
const json = await req.json().catch(() => ({}));
const parseResult = requestSchema.safeParse(json);
if (!parseResult.success) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
const { query, _honeypot } = parseResult.data;
// 3. Honeypot check
// If the honeypot field has any content, this is a bot.
if (_honeypot && _honeypot.length > 0) {
// Return a fake success mask
return NextResponse.json({ answer: 'Searching...' }, { status: 200 });
}
// 4. Qdrant Context Retrieval
const searchResults = await searchProducts(query, 5);
// Build context block
const contextText = searchResults.map((res: any) => {
const payload = res.payload;
return `Product ID: ${payload?.id}
Name: ${payload?.title}
SKU: ${payload?.sku}
Description: ${payload?.description}
Slug: ${payload?.slug}
---`;
}).join('\n');
// 5. OpenRouter Integration (gemini-3-flash-preview)
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
}
const systemPrompt = `You are the KLZ Cables AI Search Assistant, an intelligent, helpful, and highly specialized assistant strictly for the KLZ Cables website.
Your primary goal is to help users find the correct industrial cables and products based ONLY on the context provided.
Follow these strict rules:
1. ONLY answer questions related to products, search queries, cables, or industrial electronics.
2. If the user asks a question entirely unrelated to products or the company (e.g., "What is the capital of France?", "Write a poem", "What is 2+2?"), REFUSE to answer it. Instead, reply with a funny, sarcastic, or humorous comment about how you only know about cables and wires.
3. Base your product answers strictly on the CONTEXT provided below. Do not hallucinate products.
4. Output your response as a valid JSON object matching this schema exactly, do not use Markdown codeblocks, output RAW JSON:
{
"answerText": "A friendly description or answer based on the search.",
"products": [
{ "id": "Context Product ID", "title": "Product Title", "sku": "Product SKU", "slug": "slug" }
]
}
If you find relevant products in the context, add them to the "products" array. If no products match, use an empty array.
CONTEXT:
${contextText}
`;
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
'X-Title': 'KLZ Cables Search AI',
},
body: JSON.stringify({
model: 'google/gemini-3-flash-preview',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: query }
],
response_format: { type: "json_object" }
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`OpenRouter error: ${response.status} ${errorBody}`);
}
const completion = await response.json();
const rawContent = completion.choices?.[0]?.message?.content;
let answerJson;
try {
// Remove any potential markdown json block markers
const sanitizedObjStr = rawContent.replace(/^```json\s*/, '').replace(/\s*```$/, '');
answerJson = JSON.parse(sanitizedObjStr);
} catch (parseError) {
console.error('Failed to parse AI response:', rawContent);
answerJson = {
answerText: rawContent || "Sorry, I had trouble thinking about cables right now.",
products: []
};
}
return NextResponse.json(answerJson);
} catch (error) {
console.error('AI Search API Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}