139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
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 });
|
|
}
|
|
}
|