This commit is contained in:
jjbrunton committed 2026-01-01 00:52:06 +00:00
1 parent f5f350b7f2
commit a066300ed4
14 files changed
+1376 -61

No files matched your search

@@ -12,8 +12,23 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square } from 'lucide-react';
import { generatePack, generateQuestions, suggestCategories, suggestPacks, TONE_LEVELS, INTENSITY_LEVELS, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea, type ToneLevel } from '@/lib/openai';
import { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square, AlertTriangle, XCircle, CheckCircle2, Filter } from 'lucide-react';
import {
generatePack,
generateQuestionsWithCouncil,
suggestCategories,
suggestPacks,
getCouncilConfig,
TONE_LEVELS,
INTENSITY_LEVELS,
type GeneratedPack,
type GeneratedQuestion,
type GeneratedCategoryIdea,
type GeneratedPackIdea,
type ToneLevel,
type QuestionReview,
type CouncilGenerationResult,
} from '@/lib/openai';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
@@ -41,6 +56,7 @@ export function AiGeneratorDialog({
onGenerated,
}: AiGeneratorDialogProps) {
const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState<'generating' | 'reviewing' | null>(null);
const [count, setCount] = useState(10);
// Default tone based on pack's explicit flag (5 for explicit packs, 3 for non-explicit)
const [tone, setTone] = useState<ToneLevel>(context?.isExplicit ? 5 : 3);
@@ -61,11 +77,46 @@ export function AiGeneratorDialog({
const [generatedIdeas, setGeneratedIdeas] = useState<GeneratedCategoryIdea[]>([]);
const [generatedPackIdeas, setGeneratedPackIdeas] = useState<GeneratedPackIdea[]>([]);
// Council review state
const [reviews, setReviews] = useState<QuestionReview[]>([]);
const [reviewSummary, setReviewSummary] = useState<CouncilGenerationResult['summary']>(null);
const [reviewMetadata, setReviewMetadata] = useState<CouncilGenerationResult['metadata'] | null>(null);
const [filter, setFilter] = useState<'all' | 'pass' | 'flag' | 'reject'>('all');
// Selection state for questions
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState<Set<number>>(new Set());
// Get council config
const councilConfig = getCouncilConfig();
// Helper to get review for a question index
const getReviewForIndex = (index: number): QuestionReview | undefined => {
if (!reviews || reviews.length === 0) return undefined;
return reviews.find(r => r.index === index);
};
// Filter questions based on current filter
const getFilteredQuestionIndices = (): number[] => {
if (!generatedQuestions || generatedQuestions.length === 0) {
return [];
}
if (filter === 'all' || !reviews || reviews.length === 0) {
return generatedQuestions.map((_, i) => i);
}
return reviews
.filter(r => r.verdict === filter)
.map(r => r.index);
};
const handleGenerate = async () => {
setLoading(true);
setLoadingStep(null);
// Reset council state
setReviews([]);
setReviewSummary(null);
setReviewMetadata(null);
setFilter('all');
try {
if (type === 'pack') {
const result = await generatePack(context?.categoryName, isExplicit, crudeLang, inspiration || undefined);
@@ -75,10 +126,55 @@ export function AiGeneratorDialog({
toast.error('Pack name is required');
return;
}
const result = await generateQuestions(context.packName, count, undefined, tone, context.packDescription || undefined, context.existingQuestions, crudeLang, inspiration || undefined);
setGeneratedQuestions(result);
// Select all questions by default
setSelectedQuestionIndices(new Set(result.map((_, i) => i)));
// Use council approach
setLoadingStep('generating');
const result = await generateQuestionsWithCouncil(
context.packName,
count,
undefined,
tone,
context.packDescription || undefined,
context.existingQuestions,
crudeLang,
inspiration || undefined
);
const questions = result.questions || [];
const reviewList = result.reviews || [];
setGeneratedQuestions(questions);
setReviews(reviewList);
setReviewSummary(result.summary);
setReviewMetadata(result.metadata);
// Smart selection based on review results
if (reviewList.length > 0) {
// Select passed and flagged questions by default, not rejected
const selectedIndices = new Set<number>();
reviewList.forEach(r => {
if (r.verdict === 'pass' || r.verdict === 'flag') {
selectedIndices.add(r.index);
}
});
setSelectedQuestionIndices(selectedIndices);
// Show toast with summary
if (result.summary) {
const { passed, flagged, rejected } = result.summary;
if (rejected > 0) {
toast.warning(`Review complete: ${passed} passed, ${flagged} flagged, ${rejected} rejected`);
} else {
toast.success(`Review complete: ${passed} passed, ${flagged} flagged`);
}
}
} else {
// No review (council disabled or failed) - select all
setSelectedQuestionIndices(new Set(questions.map((_, i) => i)));
if (councilConfig.enabled && !result.metadata?.reviewerModel) {
toast.warning('Council review failed - showing unreviewed questions');
}
}
} else if (type === 'category-ideas') {
const result = await suggestCategories(context?.existingCategories || [], isExplicit, crudeLang, inspiration || undefined);
setGeneratedIdeas(result);
@@ -92,9 +188,10 @@ export function AiGeneratorDialog({
}
} catch (error) {
console.error('AI generation error:', error);
toast.error('Failed to generate content. Check your OpenAI API key.');
toast.error('Failed to generate content. Check your API key.');
} finally {
setLoading(false);
setLoadingStep(null);
}
};
@@ -141,6 +238,11 @@ export function AiGeneratorDialog({
setSelectedQuestionIndices(new Set());
setCrudeLang(false);
setInspiration('');
// Reset council state
setReviews([]);
setReviewSummary(null);
setReviewMetadata(null);
setFilter('all');
onOpenChange(false);
};
@@ -261,19 +363,30 @@ export function AiGeneratorDialog({
{/* Generate button */}
{!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && (
<Button onClick={handleGenerate} disabled={loading} className="w-full">
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Generate with AI
</>
<div className="space-y-2">
<Button onClick={handleGenerate} disabled={loading} className="w-full">
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{loadingStep === 'generating' && councilConfig.enabled
? 'Step 1/2: Generating...'
: loadingStep === 'reviewing'
? 'Step 2/2: Reviewing...'
: 'Generating...'}
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Generate with AI
</>
)}
</Button>
{councilConfig.enabled && type === 'questions' && !loading && (
<p className="text-xs text-muted-foreground text-center">
Council mode enabled - questions will be reviewed by a second model
</p>
)}
</Button>
</div>
)}
{/* Generated Pack Preview */}
@@ -290,6 +403,37 @@ export function AiGeneratorDialog({
{/* Generated Questions Preview */}
{generatedQuestions.length > 0 && (
<div className="space-y-3">
{/* Review Summary Bar */}
{reviewSummary && (
<div className="rounded-lg border bg-muted/50 p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="h-4 w-4" />
{reviewSummary.passed} Passed
</span>
<span className="flex items-center gap-1 text-yellow-600">
<AlertTriangle className="h-4 w-4" />
{reviewSummary.flagged} Flagged
</span>
<span className="flex items-center gap-1 text-red-600">
<XCircle className="h-4 w-4" />
{reviewSummary.rejected} Rejected
</span>
</div>
<span className="text-sm font-medium">
Quality: {reviewSummary.overallQuality}/10
</span>
</div>
{reviewMetadata && (
<p className="text-xs text-muted-foreground">
Generated in {(reviewMetadata.generationTime / 1000).toFixed(1)}s
{reviewMetadata.reviewTime > 0 && ` • Reviewed in ${(reviewMetadata.reviewTime / 1000).toFixed(1)}s`}
</p>
)}
</div>
)}
<div className="flex items-center justify-between">
<h4 className="font-medium">
Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected)
@@ -317,41 +461,136 @@ export function AiGeneratorDialog({
</Button>
</div>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{generatedQuestions.map((q, i) => (
<div
key={i}
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
selectedQuestionIndices.has(i)
? 'border-primary ring-1 ring-primary/20'
: 'opacity-60 hover:opacity-80'
}`}
onClick={() => toggleQuestionSelection(i)}
{/* Filter Buttons */}
{reviews && reviews.length > 0 && (
<div className="flex gap-2">
<Button
type="button"
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('all')}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedQuestionIndices.has(i)}
onCheckedChange={() => toggleQuestionSelection(i)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
<div className="flex-1">
<p>{q.text}</p>
{q.partner_text && (
<p className="text-muted-foreground text-xs mt-1">
Partner: {q.partner_text}
</p>
)}
<span
className={`inline-block mt-1 text-xs px-2 py-0.5 rounded text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
>
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
</span>
<Filter className="mr-1 h-3 w-3" />
All ({generatedQuestions.length})
</Button>
<Button
type="button"
variant={filter === 'pass' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('pass')}
className={filter === 'pass' ? '' : 'text-green-600 hover:text-green-700'}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Passed ({reviewSummary?.passed || 0})
</Button>
<Button
type="button"
variant={filter === 'flag' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('flag')}
className={filter === 'flag' ? '' : 'text-yellow-600 hover:text-yellow-700'}
>
<AlertTriangle className="mr-1 h-3 w-3" />
Flagged ({reviewSummary?.flagged || 0})
</Button>
<Button
type="button"
variant={filter === 'reject' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('reject')}
className={filter === 'reject' ? '' : 'text-red-600 hover:text-red-700'}
>
<XCircle className="mr-1 h-3 w-3" />
Rejected ({reviewSummary?.rejected || 0})
</Button>
</div>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{getFilteredQuestionIndices().map((i) => {
const q = generatedQuestions[i];
const review = getReviewForIndex(i);
const isRejected = review?.verdict === 'reject';
return (
<div
key={i}
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
isRejected
? 'opacity-50 border-red-300 bg-red-50/50 dark:bg-red-950/20'
: selectedQuestionIndices.has(i)
? 'border-primary ring-1 ring-primary/20'
: 'opacity-60 hover:opacity-80'
}`}
onClick={() => toggleQuestionSelection(i)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedQuestionIndices.has(i)}
onCheckedChange={() => toggleQuestionSelection(i)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
<div className="flex-1 space-y-2">
<div>
<p>{q.text}</p>
{q.partner_text && (
<p className="text-muted-foreground text-xs mt-1">
Partner: {q.partner_text}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<span
className={`inline-block text-xs px-2 py-0.5 rounded text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
>
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
</span>
{/* Review Verdict Badge */}
{review && (
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded ${
review.verdict === 'pass'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: review.verdict === 'flag'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
title={review.scores ? `Scores: ${review.scores.guidelineCompliance}/${review.scores.creativity}/${review.scores.clarity}/${review.scores.intensityAccuracy}` : ''}
>
{review.verdict === 'pass' && <CheckCircle2 className="h-3 w-3" />}
{review.verdict === 'flag' && <AlertTriangle className="h-3 w-3" />}
{review.verdict === 'reject' && <XCircle className="h-3 w-3" />}
{review.verdict.toUpperCase()}
</span>
)}
</div>
{/* Review Issues/Suggestions */}
{review && ((review.issues && review.issues.length > 0) || review.suggestions) && (
<div className="text-xs space-y-1 pt-1 border-t border-dashed">
{review.issues && review.issues.length > 0 && (
<ul className="list-disc list-inside text-muted-foreground">
{review.issues.map((issue, idx) => (
<li key={idx}>{issue}</li>
))}
</ul>
)}
{review.suggestions && (
<p className="text-blue-600 dark:text-blue-400">
Suggestion: {review.suggestions}
</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
)}
@@ -12,6 +12,7 @@ import {
Ticket,
MessageSquareText,
Target,
Bot,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -63,6 +64,12 @@ const systemNav: NavItem[] = [
icon: <Shield className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS,
},
{
label: 'AI Settings',
href: '/ai-settings',
icon: <Bot className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
},
{
label: 'Redemption Codes',
href: '/redemption-codes',
+58
View File
@@ -0,0 +1,58 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }