Skip to content

Commit ec60ea3

Browse files
committed
fix: improve generate options and AI errors
1 parent 1a1d177 commit ec60ea3

15 files changed

Lines changed: 241 additions & 58 deletions

File tree

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,9 @@ public/config.js
2626
.env
2727
supabase/.temp/
2828
coverage/
29+
30+
# Local agent/task files
31+
.claude/
32+
.codex/
33+
tasks.txt
34+
humanizer.md

‎src/components/features/generate/GenerateForm.tsx‎

Lines changed: 79 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// src/components/features/generate/GenerateForm.tsx
2-
import { useEffect } from 'react';
2+
import { useEffect, useState } from 'react';
33
import { useTranslation } from 'react-i18next';
44
import { useForm } from 'react-hook-form';
55
import { zodResolver } from '@hookform/resolvers/zod';
6-
import { Sparkles, Wand2, Mic, MicOff } from 'lucide-react';
6+
import { ChevronDown, Mic, MicOff, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
77
import { Button } from '@/components/ui/button';
88
import { Input } from '@/components/ui/input';
99
import { Label } from '@/components/ui/label';
@@ -29,6 +29,7 @@ interface GenerateFormProps {
2929
export function GenerateForm({ onSubmit, isLoading }: GenerateFormProps) {
3030
const { t } = useTranslation();
3131
const { generateForm, setGenerateForm } = useSessionStore();
32+
const [advancedOpen, setAdvancedOpen] = useState(false);
3233

3334
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm<GenerateInput>({
3435
resolver: zodResolver(GenerateInputSchema),
@@ -128,7 +129,7 @@ export function GenerateForm({ onSubmit, isLoading }: GenerateFormProps) {
128129
)}
129130
</div>
130131

131-
{/* Two Column Grid */}
132+
{/* Main Options */}
132133
<div className="grid gap-4 sm:grid-cols-2">
133134
{/* Side */}
134135
<div className="space-y-2">
@@ -183,58 +184,86 @@ export function GenerateForm({ onSubmit, isLoading }: GenerateFormProps) {
183184
</Select>
184185
</div>
185186

186-
{/* Length */}
187+
{/* More */}
187188
<div className="space-y-2">
188-
<Label className="text-sm font-medium">{t('generateForm.length')}</Label>
189-
<Select
190-
value={currentValues.length}
191-
onValueChange={(value) => setValue('length', value as GenerateInput['length'])}
189+
<Label className="text-sm font-medium">{t('generateForm.more')}</Label>
190+
<Button
191+
type="button"
192+
variant="outline"
193+
className="h-10 w-full justify-between"
194+
aria-expanded={advancedOpen}
195+
aria-controls="generate-more-options"
196+
onClick={() => setAdvancedOpen((open) => !open)}
192197
>
193-
<SelectTrigger>
194-
<SelectValue />
195-
</SelectTrigger>
196-
<SelectContent>
197-
<SelectItem value="brief">{t('generateForm.lengthBrief')}</SelectItem>
198-
<SelectItem value="standard">{t('generateForm.lengthStandard')}</SelectItem>
199-
<SelectItem value="comprehensive">{t('generateForm.lengthComprehensive')}</SelectItem>
200-
</SelectContent>
201-
</Select>
198+
<span className="flex items-center gap-2">
199+
<SlidersHorizontal className="h-4 w-4" />
200+
{t('generateForm.moreOptions')}
201+
</span>
202+
<ChevronDown className={`h-4 w-4 transition-transform duration-300 ${advancedOpen ? 'rotate-180' : ''}`} />
203+
</Button>
202204
</div>
205+
</div>
203206

204-
{/* Audience */}
205-
<div className="space-y-2">
206-
<Label className="text-sm font-medium">{t('generateForm.audience')}</Label>
207-
<Select
208-
value={currentValues.audience}
209-
onValueChange={(value) => setValue('audience', value as GenerateInput['audience'])}
210-
>
211-
<SelectTrigger>
212-
<SelectValue />
213-
</SelectTrigger>
214-
<SelectContent>
215-
<SelectItem value="beginner">{t('generateForm.audienceBeginner')}</SelectItem>
216-
<SelectItem value="intermediate">{t('generateForm.audienceIntermediate')}</SelectItem>
217-
<SelectItem value="advanced">{t('generateForm.audienceAdvanced')}</SelectItem>
218-
</SelectContent>
219-
</Select>
220-
</div>
207+
<div
208+
id="generate-more-options"
209+
className={`overflow-hidden transition-all duration-300 ease-in-out ${
210+
advancedOpen ? 'max-h-80 opacity-100' : 'max-h-0 opacity-0'
211+
}`}
212+
>
213+
<div className="grid gap-4 pt-1 sm:grid-cols-3">
214+
{/* Length */}
215+
<div className="space-y-2">
216+
<Label className="text-sm font-medium">{t('generateForm.length')}</Label>
217+
<Select
218+
value={currentValues.length}
219+
onValueChange={(value) => setValue('length', value as GenerateInput['length'])}
220+
>
221+
<SelectTrigger>
222+
<SelectValue />
223+
</SelectTrigger>
224+
<SelectContent>
225+
<SelectItem value="brief">{t('generateForm.lengthBrief')}</SelectItem>
226+
<SelectItem value="standard">{t('generateForm.lengthStandard')}</SelectItem>
227+
<SelectItem value="comprehensive">{t('generateForm.lengthComprehensive')}</SelectItem>
228+
</SelectContent>
229+
</Select>
230+
</div>
221231

222-
{/* Tone */}
223-
<div className="space-y-2">
224-
<Label className="text-sm font-medium">{t('generateForm.tone')}</Label>
225-
<Select
226-
value={currentValues.tone}
227-
onValueChange={(value) => setValue('tone', value as GenerateInput['tone'])}
228-
>
229-
<SelectTrigger>
230-
<SelectValue />
231-
</SelectTrigger>
232-
<SelectContent>
233-
<SelectItem value="formal">{t('generateForm.toneFormal')}</SelectItem>
234-
<SelectItem value="conversational">{t('generateForm.toneConversational')}</SelectItem>
235-
<SelectItem value="aggressive">{t('generateForm.toneAggressive')}</SelectItem>
236-
</SelectContent>
237-
</Select>
232+
{/* Audience */}
233+
<div className="space-y-2">
234+
<Label className="text-sm font-medium">{t('generateForm.audience')}</Label>
235+
<Select
236+
value={currentValues.audience}
237+
onValueChange={(value) => setValue('audience', value as GenerateInput['audience'])}
238+
>
239+
<SelectTrigger>
240+
<SelectValue />
241+
</SelectTrigger>
242+
<SelectContent>
243+
<SelectItem value="beginner">{t('generateForm.audienceBeginner')}</SelectItem>
244+
<SelectItem value="intermediate">{t('generateForm.audienceIntermediate')}</SelectItem>
245+
<SelectItem value="advanced">{t('generateForm.audienceAdvanced')}</SelectItem>
246+
</SelectContent>
247+
</Select>
248+
</div>
249+
250+
{/* Tone */}
251+
<div className="space-y-2">
252+
<Label className="text-sm font-medium">{t('generateForm.tone')}</Label>
253+
<Select
254+
value={currentValues.tone}
255+
onValueChange={(value) => setValue('tone', value as GenerateInput['tone'])}
256+
>
257+
<SelectTrigger>
258+
<SelectValue />
259+
</SelectTrigger>
260+
<SelectContent>
261+
<SelectItem value="formal">{t('generateForm.toneFormal')}</SelectItem>
262+
<SelectItem value="conversational">{t('generateForm.toneConversational')}</SelectItem>
263+
<SelectItem value="aggressive">{t('generateForm.toneAggressive')}</SelectItem>
264+
</SelectContent>
265+
</Select>
266+
</div>
238267
</div>
239268
</div>
240269

‎src/components/features/onboarding/OnboardingTour.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,28 @@ export function OnboardingTour() {
110110
callback={handleJoyrideCallback}
111111
styles={{
112112
options: {
113+
arrowColor: 'hsl(var(--popover))',
114+
backgroundColor: 'hsl(var(--popover))',
115+
overlayColor: 'hsl(var(--background) / 0.72)',
113116
primaryColor: 'hsl(var(--primary))',
117+
textColor: 'hsl(var(--popover-foreground))',
114118
zIndex: 10000,
115119
},
116120
tooltip: {
121+
backgroundColor: 'hsl(var(--popover))',
122+
border: '1px solid hsl(var(--border))',
117123
borderRadius: '0.5rem',
124+
boxShadow: '0 20px 45px hsl(var(--foreground) / 0.18)',
125+
color: 'hsl(var(--popover-foreground))',
118126
padding: '1rem',
119127
},
128+
buttonClose: {
129+
color: 'hsl(var(--muted-foreground))',
130+
},
120131
buttonNext: {
121132
backgroundColor: 'hsl(var(--primary))',
122133
borderRadius: '0.375rem',
134+
color: 'hsl(var(--primary-foreground))',
123135
padding: '0.5rem 1rem',
124136
},
125137
buttonBack: {

‎src/lib/ai/client.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ async function callSupabaseAI(
254254
// Handle Supabase function invoke errors
255255
if (error) {
256256
const errorPayload = await extractSupabaseErrorPayload(error);
257-
const errorDetails = parseSupabaseError(error, requestId, errorPayload);
257+
const errorDetails = parseSupabaseError(error, requestId, provider, config.model, errorPayload);
258258
throw errorDetails;
259259
}
260260

@@ -289,8 +289,12 @@ async function callSupabaseAI(
289289
}
290290

291291
// Provider error (OpenAI, Google, etc.)
292-
if (errorMessage.includes('OpenAI') || errorMessage.includes('Google') || errorMessage.includes('Anthropic')) {
293-
throw new AIProviderError(errorMessage, { requestId, provider, details: { model: config.model } });
292+
if (isProviderErrorMessage(errorMessage) || isProviderErrorCode(errorCode)) {
293+
throw new AIProviderError(errorMessage, {
294+
requestId,
295+
provider,
296+
details: { model: config.model, code: errorCode },
297+
});
294298
}
295299

296300
// Generic error from edge function
@@ -369,10 +373,13 @@ async function extractSupabaseErrorPayload(error: { context?: unknown }): Promis
369373
function parseSupabaseError(
370374
error: { message: string; context?: unknown },
371375
requestId: string,
376+
provider: string,
377+
model: string,
372378
payload?: Record<string, unknown> | null
373379
): AIError {
374380
const payloadMessage = typeof payload?.error === 'string' ? payload.error : undefined;
375381
const message = payloadMessage || error.message || 'Unknown error';
382+
const errorCode = typeof payload?.code === 'string' ? payload.code : undefined;
376383

377384
// Detect abort/signal errors and treat as timeout
378385
if (message.includes('abort') || message.includes('signal')) {
@@ -439,13 +446,30 @@ function parseSupabaseError(
439446
});
440447
}
441448

449+
if (isProviderErrorMessage(message) || isProviderErrorCode(errorCode)) {
450+
return new AIProviderError(message, {
451+
statusCode: statusCode || 500,
452+
requestId,
453+
provider,
454+
details: { model, code: errorCode },
455+
});
456+
}
457+
442458
// Default: return generic error with helpful message
443459
return new AIError(
444460
`AI service error: ${message}. Please try again.`,
445461
{ statusCode: statusCode || 500, requestId }
446462
);
447463
}
448464

465+
function isProviderErrorMessage(message: string): boolean {
466+
return /OpenAI|Anthropic|Google|Gemini/i.test(message);
467+
}
468+
469+
function isProviderErrorCode(code?: string): boolean {
470+
return !!code && /quota|billing|api[_-]?key|provider|insufficient|overloaded|rate/i.test(code);
471+
}
472+
449473
/**
450474
* Transforms AI generate response from various formats to the expected GenerateOutput schema.
451475
* This handles cases where the AI returns a different structure than requested.

‎src/lib/ai/error-display.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { AIProviderError } from './client';
2+
3+
const PROVIDER_LABELS: Record<string, string> = {
4+
openai: 'OpenAI',
5+
anthropic: 'Anthropic',
6+
google: 'Google',
7+
gemini: 'Google',
8+
};
9+
10+
function providerLabel(provider?: string, message = ''): string {
11+
if (provider && PROVIDER_LABELS[provider]) return PROVIDER_LABELS[provider];
12+
if (/openai/i.test(message)) return 'OpenAI';
13+
if (/anthropic|claude/i.test(message)) return 'Anthropic';
14+
if (/google|gemini/i.test(message)) return 'Google';
15+
return 'AI provider';
16+
}
17+
18+
export function getUserFacingAIError(error: unknown): string {
19+
if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) {
20+
const provider = providerLabel((error as AIProviderError).provider, error.message);
21+
const raw = error.message;
22+
const normalized = raw.toLowerCase();
23+
24+
if (
25+
normalized.includes('insufficient_quota') ||
26+
normalized.includes('insufficient-quota') ||
27+
normalized.includes('exceeded your current quota') ||
28+
normalized.includes('quota') ||
29+
normalized.includes('billing details')
30+
) {
31+
return `${provider} quota exceeded. Please inform the site admin.`;
32+
}
33+
34+
if (
35+
normalized.includes('invalid_api_key') ||
36+
normalized.includes('invalid api key') ||
37+
normalized.includes('incorrect api key') ||
38+
normalized.includes('api key') ||
39+
normalized.includes('unauthorized')
40+
) {
41+
return `${provider} is not configured correctly. Please inform the site admin.`;
42+
}
43+
44+
if (normalized.includes('rate limit') || normalized.includes('rate_limit') || normalized.includes('too many requests')) {
45+
return `${provider} rate limit exceeded. Please try again later.`;
46+
}
47+
48+
if (
49+
normalized.includes('overloaded') ||
50+
normalized.includes('temporarily unavailable') ||
51+
normalized.includes('service unavailable') ||
52+
normalized.includes('503')
53+
) {
54+
return `${provider} is temporarily unavailable. Please try again later.`;
55+
}
56+
57+
if (normalized.includes('content policy') || normalized.includes('safety') || normalized.includes('blocked')) {
58+
return `${provider} blocked this request for safety reasons. Try rephrasing your input.`;
59+
}
60+
61+
return raw;
62+
}
63+
64+
return error instanceof Error ? error.message : 'An unexpected error occurred';
65+
}

‎src/locales/en.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@
284284
"roleR1": "R1 - Rebuttal/Arg (6 min)",
285285
"roleR2": "R2 - Rebuttal/Build (6 min)",
286286
"roleConc": "Conclusor (3 min)",
287+
"more": "More",
288+
"moreOptions": "More options",
287289
"length": "Case Length",
288290
"lengthBrief": "Brief",
289291
"lengthStandard": "Standard",

‎src/locales/es.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@
284284
"roleR1": "R1 - Refutación/Arg (6 min)",
285285
"roleR2": "R2 - Refutación/Construcción (6 min)",
286286
"roleConc": "Conclusor (3 min)",
287+
"more": "Más",
288+
"moreOptions": "Más opciones",
287289
"length": "Extensión del Caso",
288290
"lengthBrief": "Breve",
289291
"lengthStandard": "Estándar",

‎src/locales/hu.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@
284284
"roleR1": "R1 - Cáfolat/Érv (6 perc)",
285285
"roleR2": "R2 - Cáfolat/Építés (6 perc)",
286286
"roleConc": "Záró (3 perc)",
287+
"more": "Továbbiak",
288+
"moreOptions": "További opciók",
287289
"length": "Eset Hossza",
288290
"lengthBrief": "Rövid",
289291
"lengthStandard": "Standard",

‎src/pages/AskPage.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@/lib/stores/settings-store';
55
import { AskChat } from '@/components/features/ask/AskChat';
66
import { askChat, isTimeoutError } from '@/lib/ai/client';
77
import { useSubscriptionError } from '@/hooks/use-subscription-error';
8+
import { getUserFacingAIError } from '@/lib/ai/error-display';
89
import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription';
910
import { useToast } from '@/hooks/use-toast';
1011
import { ModelSelector } from '@/components/ModelSelector';
@@ -87,7 +88,7 @@ export default function AskPage() {
8788
variant: "destructive"
8889
});
8990
} else {
90-
toast({ title: t('ask.failed'), description: t('ask.failedDesc'), variant: "destructive" });
91+
toast({ title: t('ask.failed'), description: getUserFacingAIError(error) || t('ask.failedDesc'), variant: "destructive" });
9192
}
9293
} finally {
9394
setAskLoading(false);

‎src/pages/AttackPage.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AttackForm } from '@/components/features/attack/AttackForm';
66
import { AttackResults } from '@/components/features/attack/AttackResults';
77
import { attackCase, askChat } from '@/lib/ai/client';
88
import { useSubscriptionError } from '@/hooks/use-subscription-error';
9+
import { getUserFacingAIError } from '@/lib/ai/error-display';
910
import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription';
1011
import type { AttackInput } from '@/lib/ai/schemas/attack';
1112
import { useToast } from '@/hooks/use-toast';
@@ -33,7 +34,7 @@ export default function AttackPage() {
3334
if (!subError.handleSubscriptionError(error)) {
3435
toast({
3536
title: t('attack.failed'),
36-
description: error instanceof Error ? error.message : "An unexpected error occurred",
37+
description: getUserFacingAIError(error),
3738
variant: "destructive",
3839
});
3940
}

0 commit comments

Comments
 (0)