diff --git a/.gitignore b/.gitignore index 483a539..e79b70d 100755 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ coverage/ .temp/ .tmp/ *.tmp +nul diff --git a/apps/admin/src/components/ModelCombobox.tsx b/apps/admin/src/components/ModelCombobox.tsx new file mode 100644 index 0000000..afd9fef --- /dev/null +++ b/apps/admin/src/components/ModelCombobox.tsx @@ -0,0 +1,178 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { Input } from '@/components/ui/input'; +import { useOpenRouterModels, formatContextLength, formatModelPricing, OpenRouterModel } from '@/hooks/useOpenRouterModels'; +import { ChevronDown, Loader2, Search, Sparkles } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface ModelComboboxProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + disabled?: boolean; +} + +export function ModelCombobox({ + value, + onChange, + placeholder = 'Select or type model ID...', + className, + disabled = false, +}: ModelComboboxProps) { + const { models, loading } = useOpenRouterModels(); + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const containerRef = useRef(null); + const inputRef = useRef(null); + + // Filter models based on search + const filteredModels = useMemo(() => { + const query = (search || value).toLowerCase().trim(); + if (!query) { + // Show featured models when no search + return models.slice(0, 15); + } + + return models + .filter(m => + m.id.toLowerCase().includes(query) || + m.name.toLowerCase().includes(query) + ) + .slice(0, 20); + }, [models, search, value]); + + // Find current model info + const currentModel = useMemo(() => { + return models.find(m => m.id === value); + }, [models, value]); + + // Close on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearch(''); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false); + setSearch(''); + } else if (e.key === 'ArrowDown' && !isOpen) { + setIsOpen(true); + } + }; + + const handleSelect = (model: OpenRouterModel) => { + onChange(model.id); + setIsOpen(false); + setSearch(''); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setSearch(newValue); + onChange(newValue); + if (!isOpen && newValue) { + setIsOpen(true); + } + }; + + const handleInputFocus = () => { + setIsOpen(true); + }; + + return ( +
+ {/* Input with dropdown trigger */} +
+ + +
+ + {/* Current model info */} + {currentModel && !isOpen && ( +

+ {currentModel.name} - {formatContextLength(currentModel.context_length)} context +

+ )} + + {/* Dropdown */} + {isOpen && ( +
+ {loading ? ( +
+ + Loading models... +
+ ) : filteredModels.length === 0 ? ( +
+ +

No models found

+

Type a custom model ID or refine your search

+
+ ) : ( +
+ {filteredModels.map((model, index) => { + const isFeatured = index < 8 && !search && !value; + return ( + + ); + })} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/admin/src/components/ai/AiGeneratorDialog.tsx b/apps/admin/src/components/ai/AiGeneratorDialog.tsx index 4885325..d36df69 100644 --- a/apps/admin/src/components/ai/AiGeneratorDialog.tsx +++ b/apps/admin/src/components/ai/AiGeneratorDialog.tsx @@ -1,9 +1,5 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Textarea } from '@/components/ui/textarea'; import { Dialog, DialogContent, @@ -12,43 +8,34 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -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, - type GenerationCandidate, -} from '@/lib/openai'; -import { Switch } from '@/components/ui/switch'; +import { Sparkles, Loader2, RefreshCw, Check } from 'lucide-react'; import { toast } from 'sonner'; +// Extracted components +import { useAiGeneration, type GeneratorType, type GeneratorContext } from './hooks/useAiGeneration'; +import { GenerationForm } from './GenerationForm'; +import { GenerationProgressDisplay } from './GenerationProgress'; +import { ReviewSummary, ModelBadge } from './ReviewSummary'; +import { QuestionList } from './QuestionList'; +import { CategoryIdeasList, PackIdeasList } from './IdeasList'; +import { PackPreview } from './PackPreview'; + +// ============================================================================= +// Types +// ============================================================================= + interface AiGeneratorDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - type: 'pack' | 'questions' | 'category-ideas' | 'category-pack-ideas'; - context?: { - categoryName?: string; - packName?: string; - packDescription?: string | null; - existingCategories?: string[]; - existingPacks?: string[]; - existingQuestions?: string[]; - isExplicit?: boolean; - }; + type: GeneratorType; + context?: GeneratorContext; onGenerated: (result: any) => void; } +// ============================================================================= +// Component +// ============================================================================= + export function AiGeneratorDialog({ open, onOpenChange, @@ -56,161 +43,27 @@ export function AiGeneratorDialog({ context, 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(context?.isExplicit ? 5 : 3); - const isExplicit = tone >= 4; // For other generators that still use boolean - const [crudeLang, setCrudeLang] = useState(false); - const [inspiration, setInspiration] = useState(''); + const generation = useAiGeneration(type, context); - // Update tone when dialog opens with new context + // Reset when dialog closes useEffect(() => { - if (open && type === 'questions') { - setTone(context?.isExplicit ? 5 : 3); + if (!open) { + generation.reset(); } - }, [open, context?.isExplicit, type]); + }, [open]); - // Generated results - const [generatedPack, setGeneratedPack] = useState(null); - const [generatedQuestions, setGeneratedQuestions] = useState([]); - const [generatedIdeas, setGeneratedIdeas] = useState([]); - const [generatedPackIdeas, setGeneratedPackIdeas] = useState([]); - - // Council review state - const [reviews, setReviews] = useState([]); - const [reviewSummary, setReviewSummary] = useState(null); - const [reviewMetadata, setReviewMetadata] = useState(null); - const [selectedGeneratorIndex, setSelectedGeneratorIndex] = useState(null); - const [allCandidates, setAllCandidates] = useState(null); - const [filter, setFilter] = useState<'all' | 'pass' | 'flag' | 'reject'>('all'); - - // Selection state for questions - const [selectedQuestionIndices, setSelectedQuestionIndices] = useState>(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); - setSelectedGeneratorIndex(null); - setAllCandidates(null); - setFilter('all'); - - try { - if (type === 'pack') { - const result = await generatePack(context?.categoryName, isExplicit, crudeLang, inspiration || undefined); - setGeneratedPack(result); - } else if (type === 'questions') { - if (!context?.packName) { - toast.error('Pack name is required'); - return; - } - - // 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); - setSelectedGeneratorIndex(result.selectedGeneratorIndex); - setAllCandidates(result.allCandidates); - - // Smart selection based on review results - if (reviewList.length > 0) { - // Select passed and flagged questions by default, not rejected - const selectedIndices = new Set(); - 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; - const generatorInfo = allCandidates && allCandidates.length > 1 && result.selectedGeneratorIndex !== null - ? ` (from generator ${result.selectedGeneratorIndex + 1})` - : ''; - if (rejected > 0) { - toast.warning(`Review complete: ${passed} passed, ${flagged} flagged, ${rejected} rejected${generatorInfo}`); - } else { - toast.success(`Review complete: ${passed} passed, ${flagged} flagged${generatorInfo}`); - } - } - } 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); - } else if (type === 'category-pack-ideas') { - if (!context?.categoryName) { - toast.error('Category name is required'); - return; - } - const result = await suggestPacks(context.categoryName, context?.existingPacks || [], isExplicit, crudeLang, inspiration || undefined); - setGeneratedPackIdeas(result); - } - } catch (error) { - console.error('AI generation error:', error); - toast.error('Failed to generate content. Check your API key.'); - } finally { - setLoading(false); - setLoadingStep(null); - } + const handleClose = () => { + generation.reset(); + onOpenChange(false); }; const handleUse = () => { - if (type === 'pack' && generatedPack) { - onGenerated(generatedPack); - } else if (type === 'questions' && generatedQuestions.length > 0) { - // Only pass selected questions - const selectedQuestions = generatedQuestions.filter((_, i) => selectedQuestionIndices.has(i)); + if (type === 'pack' && generation.results.pack) { + onGenerated(generation.results.pack); + } else if (type === 'questions' && generation.results.questions.length > 0) { + const selectedQuestions = generation.results.questions.filter( + (_, i) => generation.selectedIndices.has(i) + ); if (selectedQuestions.length === 0) { toast.error('Please select at least one question'); return; @@ -219,43 +72,26 @@ export function AiGeneratorDialog({ } }; - const toggleQuestionSelection = (index: number) => { - setSelectedQuestionIndices(prev => { - const newSet = new Set(prev); - if (newSet.has(index)) { - newSet.delete(index); - } else { - newSet.add(index); - } - return newSet; - }); + const getTitle = () => { + switch (type) { + case 'pack': return 'Pack'; + case 'questions': return 'Question'; + case 'category-pack-ideas': return 'Pack Ideas'; + default: return 'Category'; + } }; - const selectAllQuestions = () => { - setSelectedQuestionIndices(new Set(generatedQuestions.map((_, i) => i))); - }; - - const deselectAllQuestions = () => { - setSelectedQuestionIndices(new Set()); - }; - - const handleClose = () => { - // Reset state - setGeneratedPack(null); - setGeneratedQuestions([]); - setGeneratedIdeas([]); - setGeneratedPackIdeas([]); - setSelectedQuestionIndices(new Set()); - setCrudeLang(false); - setInspiration(''); - // Reset council state - setReviews([]); - setReviewSummary(null); - setReviewMetadata(null); - setSelectedGeneratorIndex(null); - setAllCandidates(null); - setFilter('all'); - onOpenChange(false); + const getDescription = () => { + switch (type) { + case 'pack': + return 'Generate a creative question pack name and description using AI.'; + case 'questions': + return 'Generate multiple questions for this pack using AI.'; + case 'category-pack-ideas': + return 'Generate unique pack ideas for this category.'; + default: + return 'Get creative category ideas based on your existing ones.'; + } }; return ( @@ -264,127 +100,37 @@ export function AiGeneratorDialog({ - AI {type === 'pack' ? 'Pack' : type === 'questions' ? 'Question' : type === 'category-pack-ideas' ? 'Pack Ideas' : 'Category'} Generator + AI {getTitle()} Generator - {type === 'pack' - ? 'Generate a creative question pack name and description using AI.' - : type === 'questions' - ? 'Generate multiple questions for this pack using AI.' - : type === 'category-pack-ideas' - ? 'Generate unique pack ideas for this category.' - : 'Get creative category ideas based on your existing ones.'} + {getDescription()}
- {/* Configuration */} - {type === 'questions' && ( -
- - setCount(parseInt(e.target.value) || 10)} - className="w-32" - /> -
- )} + {/* Configuration Form */} + - {/* Wildness Level Selector for Questions */} - {type === 'questions' && ( -
- -
- {TONE_LEVELS.map((t) => ( - - ))} -
-

- {TONE_LEVELS.find(t => t.level === tone)?.description} -

-
- )} - - {/* Wild/Explicit Toggle for other generators */} - {(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && ( -
- setTone(checked ? 5 : 3)} - /> - -
- )} - - {/* Crude Language Override Toggle */} -
- - - (use "fuck", "suck cock" etc. instead of tasteful phrasing) -
- - {/* Inspiration/Suggestions Textarea */} -
- -