mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Update
This commit is contained in:
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',
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user