Skip to content

Commit a6f6bef

Browse files
authored
(SP: 3) [AI] Add AI word helper with Groq integration (#200)
* (SP: 3) [AI] Add AI word helper with Groq integration - Implement Groq API with Llama 3.1 70B model - Add text selection detection on Q&A page - Create floating "Explain" button - Build draggable modal with 3-language support (uk/en/pl) - Add localStorage caching for instant repeated lookups - Implement guest CTA (login/signup) - Add rate limiting (10 requests/min) - Auth-gated feature (registered users only) Components: - SelectableText: Detects text selection - FloatingExplainButton: Appears on selection - AIWordHelper: Main modal with explanations * (SP: 1) i18n: fix Polish locale and set EN as default
1 parent 806ec7a commit a6f6bef

15 files changed

Lines changed: 2037 additions & 317 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import Groq from 'groq-sdk';
3+
import { z } from 'zod';
4+
import {
5+
createExplainPrompt,
6+
type ExplanationResponse,
7+
} from '@/lib/ai/prompts';
8+
9+
const rateLimiter = new Map<string, { count: number; resetAt: number }>();
10+
const MAX_REQUESTS_PER_WINDOW = 10;
11+
const RATE_LIMIT_WINDOW_MS = 20 * 60 * 1000;
12+
13+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
14+
let lastCleanup = Date.now();
15+
16+
function cleanupRateLimiter() {
17+
const now = Date.now();
18+
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
19+
20+
lastCleanup = now;
21+
for (const [ip, entry] of rateLimiter.entries()) {
22+
if (now > entry.resetAt) {
23+
rateLimiter.delete(ip);
24+
}
25+
}
26+
}
27+
28+
const requestSchema = z.object({
29+
term: z
30+
.string()
31+
.min(2, 'Term must be at least 2 characters')
32+
.max(100, 'Term must be at most 100 characters'),
33+
context: z
34+
.string()
35+
.max(1000, 'Context must be at most 1000 characters')
36+
.optional(),
37+
});
38+
39+
function getClientIp(request: NextRequest): string {
40+
const forwarded = request.headers.get('x-forwarded-for');
41+
if (forwarded) {
42+
return forwarded.split(',')[0].trim();
43+
}
44+
return request.headers.get('x-real-ip') || 'unknown';
45+
}
46+
47+
function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number } {
48+
cleanupRateLimiter();
49+
50+
const now = Date.now();
51+
const entry = rateLimiter.get(ip);
52+
53+
if (!entry || now > entry.resetAt) {
54+
rateLimiter.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
55+
return { allowed: true, remaining: MAX_REQUESTS_PER_WINDOW - 1, resetIn: RATE_LIMIT_WINDOW_MS };
56+
}
57+
58+
if (entry.count >= MAX_REQUESTS_PER_WINDOW) {
59+
const resetIn = entry.resetAt - now;
60+
return { allowed: false, remaining: 0, resetIn };
61+
}
62+
63+
entry.count++;
64+
return {
65+
allowed: true,
66+
remaining: MAX_REQUESTS_PER_WINDOW - entry.count,
67+
resetIn: entry.resetAt - now,
68+
};
69+
}
70+
71+
function parseExplanationResponse(content: string): ExplanationResponse {
72+
let cleaned = content.trim();
73+
74+
if (cleaned.startsWith('```json')) {
75+
cleaned = cleaned.slice(7);
76+
} else if (cleaned.startsWith('```')) {
77+
cleaned = cleaned.slice(3);
78+
}
79+
if (cleaned.endsWith('```')) {
80+
cleaned = cleaned.slice(0, -3);
81+
}
82+
cleaned = cleaned.trim();
83+
84+
const parsed = JSON.parse(cleaned);
85+
86+
if (
87+
typeof parsed.uk !== 'string' ||
88+
typeof parsed.en !== 'string' ||
89+
typeof parsed.pl !== 'string'
90+
) {
91+
throw new Error('Invalid response structure');
92+
}
93+
94+
return parsed as ExplanationResponse;
95+
}
96+
97+
export async function POST(request: NextRequest) {
98+
const apiKey = process.env.GROQ_API_KEY;
99+
if (!apiKey) {
100+
console.error('GROQ_API_KEY is not configured');
101+
return NextResponse.json(
102+
{ error: 'AI service not configured', code: 'SERVICE_UNAVAILABLE' },
103+
{ status: 503 }
104+
);
105+
}
106+
107+
const clientIp = getClientIp(request);
108+
const rateLimit = checkRateLimit(clientIp);
109+
110+
if (!rateLimit.allowed) {
111+
const resetMinutes = Math.ceil(rateLimit.resetIn / 60000);
112+
return NextResponse.json(
113+
{
114+
error: `Rate limit exceeded. Try again in ${resetMinutes} minute${resetMinutes > 1 ? 's' : ''}.`,
115+
code: 'RATE_LIMITED',
116+
resetIn: rateLimit.resetIn,
117+
},
118+
{
119+
status: 429,
120+
headers: {
121+
'X-RateLimit-Limit': String(MAX_REQUESTS_PER_WINDOW),
122+
'X-RateLimit-Remaining': '0',
123+
'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetIn / 1000)),
124+
},
125+
}
126+
);
127+
}
128+
129+
let body: unknown;
130+
try {
131+
body = await request.json();
132+
} catch {
133+
return NextResponse.json(
134+
{ error: 'Invalid JSON body', code: 'INVALID_JSON' },
135+
{ status: 400 }
136+
);
137+
}
138+
139+
const validationResult = requestSchema.safeParse(body);
140+
if (!validationResult.success) {
141+
return NextResponse.json(
142+
{
143+
error: 'Invalid request',
144+
code: 'VALIDATION_ERROR',
145+
details: validationResult.error.format(),
146+
},
147+
{ status: 400 }
148+
);
149+
}
150+
151+
const { term, context } = validationResult.data;
152+
153+
const groq = new Groq({ apiKey });
154+
155+
try {
156+
const prompt = createExplainPrompt({ term, context });
157+
158+
const chatCompletion = await groq.chat.completions.create({
159+
messages: [
160+
{
161+
role: 'user',
162+
content: prompt,
163+
},
164+
],
165+
model: 'llama-3.3-70b-versatile',
166+
temperature: 0.7,
167+
max_tokens: 1500,
168+
top_p: 1,
169+
});
170+
171+
const content = chatCompletion.choices[0]?.message?.content;
172+
173+
if (!content) {
174+
throw new Error('No content in response');
175+
}
176+
177+
const explanation = parseExplanationResponse(content);
178+
179+
return NextResponse.json(explanation, {
180+
status: 200,
181+
headers: {
182+
'X-RateLimit-Limit': String(MAX_REQUESTS_PER_WINDOW),
183+
'X-RateLimit-Remaining': String(rateLimit.remaining),
184+
'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetIn / 1000)),
185+
},
186+
});
187+
} catch (error) {
188+
console.error('Groq API error:', error);
189+
190+
const errorMessage =
191+
error instanceof Error ? error.message : 'Unknown error';
192+
const errorName = error instanceof Error ? error.name : 'UnknownError';
193+
194+
console.error('Error details:', {
195+
name: errorName,
196+
message: errorMessage,
197+
stack: error instanceof Error ? error.stack : undefined,
198+
});
199+
200+
if (error instanceof Error) {
201+
if (
202+
error.message.includes('401') ||
203+
error.message.includes('authentication') ||
204+
error.message.includes('Invalid API Key')
205+
) {
206+
return NextResponse.json(
207+
{ error: 'AI service authentication failed', code: 'AUTH_ERROR' },
208+
{ status: 503 }
209+
);
210+
}
211+
if (
212+
error.message.includes('429') ||
213+
error.message.includes('rate limit')
214+
) {
215+
return NextResponse.json(
216+
{ error: 'AI service rate limited', code: 'API_RATE_LIMITED' },
217+
{ status: 503 }
218+
);
219+
}
220+
if (error.message.includes('model')) {
221+
return NextResponse.json(
222+
{
223+
error: 'AI model not available',
224+
code: 'MODEL_ERROR',
225+
details: errorMessage,
226+
},
227+
{ status: 503 }
228+
);
229+
}
230+
}
231+
232+
return NextResponse.json(
233+
{
234+
error: 'Failed to generate explanation',
235+
code: 'AI_ERROR',
236+
details:
237+
process.env.NODE_ENV === 'development' ? errorMessage : undefined,
238+
},
239+
{ status: 500 }
240+
);
241+
}
242+
}

0 commit comments

Comments
 (0)