mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
8b73781272
commit
75509b0e45
41 files changed
+5896
-2931
No files matched your search
@@ -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<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setSearch(newValue);
|
||||
onChange(newValue);
|
||||
if (!isOpen && newValue) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative', className)}>
|
||||
{/* Input with dropdown trigger */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={search || value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="pr-8 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className="absolute right-0 top-0 h-full px-2 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current model info */}
|
||||
{currentModel && !isOpen && (
|
||||
<p className="text-xs text-muted-foreground mt-1 truncate">
|
||||
{currentModel.name} - {formatContextLength(currentModel.context_length)} context
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full max-h-80 overflow-auto rounded-md border bg-popover shadow-lg">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading models...
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="py-6 text-center text-muted-foreground">
|
||||
<Search className="h-4 w-4 mx-auto mb-2" />
|
||||
<p className="text-sm">No models found</p>
|
||||
<p className="text-xs mt-1">Type a custom model ID or refine your search</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{filteredModels.map((model, index) => {
|
||||
const isFeatured = index < 8 && !search && !value;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(model)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left hover:bg-accent focus:bg-accent outline-none',
|
||||
model.id === value && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{isFeatured && (
|
||||
<Sparkles className="h-3 w-3 text-yellow-500 mt-1 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-sm truncate">
|
||||
{model.id}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{formatContextLength(model.context_length)}</span>
|
||||
<span>-</span>
|
||||
<span>{formatModelPricing(model)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ToneLevel>(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<GeneratedPack | null>(null);
|
||||
const [generatedQuestions, setGeneratedQuestions] = useState<GeneratedQuestion[]>([]);
|
||||
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 [selectedGeneratorIndex, setSelectedGeneratorIndex] = useState<number | null>(null);
|
||||
const [allCandidates, setAllCandidates] = useState<GenerationCandidate[] | 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);
|
||||
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<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;
|
||||
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({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
AI {type === 'pack' ? 'Pack' : type === 'questions' ? 'Question' : type === 'category-pack-ideas' ? 'Pack Ideas' : 'Category'} Generator
|
||||
AI {getTitle()} Generator
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Configuration */}
|
||||
{type === 'questions' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="count">Number of Questions</Label>
|
||||
<Input
|
||||
id="count"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={count}
|
||||
onChange={(e) => setCount(parseInt(e.target.value) || 10)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Configuration Form */}
|
||||
<GenerationForm
|
||||
type={type}
|
||||
context={context}
|
||||
config={generation.config}
|
||||
onCountChange={generation.setCount}
|
||||
onToneChange={generation.setTone}
|
||||
onCrudeLangChange={generation.setCrudeLang}
|
||||
onInspirationChange={generation.setInspiration}
|
||||
/>
|
||||
|
||||
{/* Wildness Level Selector for Questions */}
|
||||
{type === 'questions' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Wildness Level</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TONE_LEVELS.map((t) => (
|
||||
<Button
|
||||
key={t.level}
|
||||
type="button"
|
||||
variant={tone === t.level ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTone(t.level as ToneLevel)}
|
||||
className={tone === t.level ? 'ring-2 ring-offset-1' : ''}
|
||||
title={t.description}
|
||||
>
|
||||
{t.level}. {t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TONE_LEVELS.find(t => t.level === tone)?.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wild/Explicit Toggle for other generators */}
|
||||
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="explicit-mode"
|
||||
checked={isExplicit}
|
||||
onCheckedChange={(checked) => setTone(checked ? 5 : 3)}
|
||||
/>
|
||||
<Label htmlFor="explicit-mode">Include Wild/Adult Ideas</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crude Language Override Toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="crude-lang"
|
||||
checked={crudeLang}
|
||||
onCheckedChange={setCrudeLang}
|
||||
/>
|
||||
<Label htmlFor="crude-lang">Crude Language Override</Label>
|
||||
<span className="text-xs text-muted-foreground">(use "fuck", "suck cock" etc. instead of tasteful phrasing)</span>
|
||||
</div>
|
||||
|
||||
{/* Inspiration/Suggestions Textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inspiration">Inspiration / Suggestions (optional)</Label>
|
||||
<Textarea
|
||||
id="inspiration"
|
||||
placeholder="Provide any themes, ideas, or guidance for the AI to consider..."
|
||||
value={inspiration}
|
||||
onChange={(e) => setInspiration(e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add freetext suggestions to guide the AI generation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Context info */}
|
||||
{context?.categoryName && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<strong>Category:</strong> {context.categoryName}
|
||||
</div>
|
||||
)}
|
||||
{context?.packName && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
|
||||
<div><strong>Pack:</strong> {context.packName}</div>
|
||||
{context.packDescription && (
|
||||
<div className="text-muted-foreground text-xs">{context.packDescription}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate button */}
|
||||
{!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleGenerate} disabled={loading} className="w-full">
|
||||
{loading ? (
|
||||
{/* Generate Button (only when no results) */}
|
||||
{!generation.hasResults && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={generation.generate}
|
||||
disabled={generation.loading}
|
||||
className="w-full"
|
||||
>
|
||||
{generation.loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{loadingStep === 'generating' && councilConfig.enabled
|
||||
? `Step 1/2: Generating${councilConfig.generators.length > 1 ? ` with ${councilConfig.generators.length} models` : ''}...`
|
||||
: loadingStep === 'reviewing'
|
||||
? 'Step 2/2: Reviewing & selecting best...'
|
||||
: 'Generating...'}
|
||||
{generation.progress?.message || 'Generating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -393,303 +139,114 @@ export function AiGeneratorDialog({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{councilConfig.enabled && type === 'questions' && !loading && (
|
||||
|
||||
{/* Generation Progress */}
|
||||
{generation.loading && generation.progress && (
|
||||
<GenerationProgressDisplay
|
||||
progress={generation.progress}
|
||||
councilConfig={generation.councilConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Council info */}
|
||||
{generation.councilConfig.enabled && type === 'questions' && !generation.loading && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Council mode: {councilConfig.generators.length} generator{councilConfig.generators.length > 1 ? 's' : ''} + reviewer
|
||||
{councilConfig.generators.length > 1 && ' (best output will be selected)'}
|
||||
Council mode: {generation.councilConfig.generators.length} generator
|
||||
{generation.councilConfig.generators.length > 1 ? 's' : ''} + reviewer
|
||||
{generation.councilConfig.generators.length > 1 && ' (best output will be selected)'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated Pack Preview */}
|
||||
{generatedPack && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Generated Pack</h4>
|
||||
<div className="rounded-lg border bg-card p-4 space-y-2">
|
||||
<p className="font-semibold text-lg">{generatedPack.name}</p>
|
||||
<p className="text-muted-foreground">{generatedPack.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{generation.results.pack && (
|
||||
<PackPreview
|
||||
pack={generation.results.pack}
|
||||
sourceModel={generation.sourceModel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
Generated in {(reviewMetadata.totalGenerationTime / 1000).toFixed(1)}s
|
||||
{reviewMetadata.reviewTime > 0 && ` • Reviewed in ${(reviewMetadata.reviewTime / 1000).toFixed(1)}s`}
|
||||
</p>
|
||||
{allCandidates && allCandidates.length > 1 && selectedGeneratorIndex !== null && (
|
||||
<p className="text-primary font-medium">
|
||||
Selected: Generator {selectedGeneratorIndex + 1} of {allCandidates.length}
|
||||
{' '}({allCandidates[selectedGeneratorIndex]?.generatorModel.split('/').pop()})
|
||||
</p>
|
||||
)}
|
||||
{reviewMetadata.generatorModels.length > 1 && (
|
||||
<p>
|
||||
Compared {reviewMetadata.generatorModels.length} generators: {reviewMetadata.generatorModels.map(m => m.split('/').pop()).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{generation.results.questions.length > 0 && (
|
||||
<>
|
||||
{/* Simple model badge when council is disabled */}
|
||||
{!generation.reviewState.summary && generation.reviewState.metadata && (
|
||||
<ModelBadge
|
||||
model={generation.reviewState.metadata.generatorModels[0]}
|
||||
generationTime={generation.reviewState.metadata.totalGenerationTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected)
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAllQuestions}
|
||||
disabled={selectedQuestionIndices.size === generatedQuestions.length}
|
||||
>
|
||||
<CheckSquare className="mr-1 h-3 w-3" />
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deselectAllQuestions}
|
||||
disabled={selectedQuestionIndices.size === 0}
|
||||
>
|
||||
<Square className="mr-1 h-3 w-3" />
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Review Summary */}
|
||||
<ReviewSummary
|
||||
summary={generation.reviewState.summary}
|
||||
metadata={generation.reviewState.metadata}
|
||||
selectionMode={generation.reviewState.selectionMode}
|
||||
allCandidates={generation.reviewState.allCandidates}
|
||||
selectedGeneratorIndex={generation.reviewState.selectedGeneratorIndex}
|
||||
cherryPickResult={generation.reviewState.cherryPickResult}
|
||||
/>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
{reviews && reviews.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
<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>
|
||||
{/* Question List */}
|
||||
<QuestionList
|
||||
questions={generation.results.questions}
|
||||
reviews={generation.reviewState.reviews}
|
||||
reviewSummary={generation.reviewState.summary}
|
||||
selectionMode={generation.reviewState.selectionMode}
|
||||
selectedIndices={generation.selectedIndices}
|
||||
filter={generation.filter}
|
||||
filteredIndices={generation.getFilteredQuestionIndices()}
|
||||
onToggleSelection={generation.toggleSelection}
|
||||
onSelectAll={generation.selectAll}
|
||||
onDeselectAll={generation.deselectAll}
|
||||
onFilterChange={generation.setFilter}
|
||||
getReviewForIndex={generation.getReviewForIndex}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Generated Ideas Preview */}
|
||||
{generatedIdeas.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Suggested Categories</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{generatedIdeas.map((idea, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between rounded-lg border bg-card p-3 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onGenerated(idea)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{idea.icon}</span>
|
||||
<div>
|
||||
<span className="font-medium block">{idea.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{idea.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost">Use</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">Click an item to use it</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Generated Category Ideas */}
|
||||
<CategoryIdeasList
|
||||
ideas={generation.results.categoryIdeas}
|
||||
sourceModel={generation.sourceModel}
|
||||
onSelectIdea={onGenerated}
|
||||
/>
|
||||
|
||||
{/* Generated Pack Ideas Preview */}
|
||||
{generatedPackIdeas.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Suggested Packs</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{generatedPackIdeas.map((idea, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between rounded-lg border bg-card p-3 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onGenerated(idea)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{idea.icon}</span>
|
||||
<div>
|
||||
<span className="font-medium block">{idea.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{idea.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost">Use</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">Click an item to use it</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Generated Pack Ideas */}
|
||||
<PackIdeasList
|
||||
ideas={generation.results.packIdeas}
|
||||
sourceModel={generation.sourceModel}
|
||||
onSelectIdea={onGenerated}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{(generatedPack || generatedQuestions.length > 0 || generatedIdeas.length > 0 || generatedPackIdeas.length > 0) && (
|
||||
{generation.hasResults && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleGenerate} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={generation.generate}
|
||||
disabled={generation.loading}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${generation.loading ? 'animate-spin' : ''}`} />
|
||||
Regenerate
|
||||
</Button>
|
||||
{type !== 'category-ideas' && type !== 'category-pack-ideas' && (
|
||||
<Button onClick={handleUse} disabled={type === 'questions' && selectedQuestionIndices.size === 0}>
|
||||
<Button
|
||||
onClick={handleUse}
|
||||
disabled={type === 'questions' && generation.selectedIndices.size === 0}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{type === 'questions' ? `Use Selected (${selectedQuestionIndices.size})` : 'Use This'}
|
||||
{type === 'questions'
|
||||
? `Use Selected (${generation.selectedIndices.size})`
|
||||
: 'Use This'
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && (
|
||||
{!generation.hasResults && (
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { TONE_LEVELS, type ToneLevel } from '@/lib/openai';
|
||||
import type { GeneratorType, GeneratorContext, GenerationConfig } from './hooks/useAiGeneration';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface GenerationFormProps {
|
||||
type: GeneratorType;
|
||||
context?: GeneratorContext;
|
||||
config: GenerationConfig;
|
||||
onCountChange: (count: number) => void;
|
||||
onToneChange: (tone: ToneLevel) => void;
|
||||
onCrudeLangChange: (crudeLang: boolean) => void;
|
||||
onInspirationChange: (inspiration: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function GenerationForm({
|
||||
type,
|
||||
context,
|
||||
config,
|
||||
onCountChange,
|
||||
onToneChange,
|
||||
onCrudeLangChange,
|
||||
onInspirationChange,
|
||||
}: GenerationFormProps) {
|
||||
const isExplicit = config.tone >= 4;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Question Count (questions only) */}
|
||||
{type === 'questions' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="count">Number of Questions</Label>
|
||||
<Input
|
||||
id="count"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.count}
|
||||
onChange={(e) => onCountChange(parseInt(e.target.value) || 10)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wildness Level Selector (questions only) */}
|
||||
{type === 'questions' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Wildness Level</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TONE_LEVELS.map((t) => (
|
||||
<Button
|
||||
key={t.level}
|
||||
type="button"
|
||||
variant={config.tone === t.level ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onToneChange(t.level as ToneLevel)}
|
||||
className={config.tone === t.level ? 'ring-2 ring-offset-1' : ''}
|
||||
title={t.description}
|
||||
>
|
||||
{t.level}. {t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TONE_LEVELS.find(t => t.level === config.tone)?.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wild/Explicit Toggle (for other generators) */}
|
||||
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="explicit-mode"
|
||||
checked={isExplicit}
|
||||
onCheckedChange={(checked: boolean) => onToneChange(checked ? 5 : 3)}
|
||||
/>
|
||||
<Label htmlFor="explicit-mode">Include Wild/Adult Ideas</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crude Language Override Toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="crude-lang"
|
||||
checked={config.crudeLang}
|
||||
onCheckedChange={onCrudeLangChange}
|
||||
/>
|
||||
<Label htmlFor="crude-lang">Crude Language Override</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(use "fuck", "suck cock" etc. instead of tasteful phrasing)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Inspiration/Suggestions Textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inspiration">Inspiration / Suggestions (optional)</Label>
|
||||
<Textarea
|
||||
id="inspiration"
|
||||
placeholder="Provide any themes, ideas, or guidance for the AI to consider..."
|
||||
value={config.inspiration}
|
||||
onChange={(e) => onInspirationChange(e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add freetext suggestions to guide the AI generation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Context info */}
|
||||
{context?.categoryName && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<strong>Category:</strong> {context.categoryName}
|
||||
</div>
|
||||
)}
|
||||
{context?.packName && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
|
||||
<div><strong>Pack:</strong> {context.packName}</div>
|
||||
{context.packDescription && (
|
||||
<div className="text-muted-foreground text-xs">{context.packDescription}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import type { GenerationProgress, CouncilConfig } from '@/lib/openai';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface GenerationProgressDisplayProps {
|
||||
progress: GenerationProgress;
|
||||
councilConfig: CouncilConfig;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function GenerationProgressDisplay({
|
||||
progress,
|
||||
councilConfig,
|
||||
}: GenerationProgressDisplayProps) {
|
||||
if (!councilConfig.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||||
{/* Phase indicator */}
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className={progress.phase === 'generating' ? 'text-primary' : 'text-muted-foreground'}>
|
||||
Step 1: Generate
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className={progress.phase === 'reviewing' ? 'text-primary' : 'text-muted-foreground'}>
|
||||
Step 2: Review
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Generator statuses */}
|
||||
<div className="space-y-1">
|
||||
{progress.generators.map((gen, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
{gen.status === 'pending' && (
|
||||
<span className="w-4 h-4 rounded-full border border-muted-foreground/30" />
|
||||
)}
|
||||
{gen.status === 'generating' && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
)}
|
||||
{gen.status === 'completed' && (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
{gen.status === 'failed' && (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className={
|
||||
gen.status === 'completed'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: gen.status === 'failed'
|
||||
? 'text-red-600'
|
||||
: ''
|
||||
}>
|
||||
{gen.shortName}
|
||||
</span>
|
||||
{gen.status === 'completed' && gen.questionCount !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
({gen.questionCount} questions in {((gen.timeMs || 0) / 1000).toFixed(1)}s)
|
||||
</span>
|
||||
)}
|
||||
{gen.status === 'failed' && (
|
||||
<span className="text-red-500">failed</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reviewer status */}
|
||||
{progress.reviewer && (
|
||||
<div className="flex items-center gap-2 text-xs pt-1 border-t border-muted-foreground/20">
|
||||
{progress.reviewer.status === 'pending' && (
|
||||
<span className="w-4 h-4 rounded-full border border-muted-foreground/30" />
|
||||
)}
|
||||
{progress.reviewer.status === 'reviewing' && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
)}
|
||||
{progress.reviewer.status === 'completed' && (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
<span className={
|
||||
progress.reviewer.status === 'reviewing'
|
||||
? 'text-primary'
|
||||
: progress.reviewer.status === 'completed'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-muted-foreground'
|
||||
}>
|
||||
Reviewer: {progress.reviewer.shortName}
|
||||
</span>
|
||||
{progress.reviewer.status === 'reviewing' && (
|
||||
<span className="text-muted-foreground">(selecting best questions...)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bot } from 'lucide-react';
|
||||
import type { GeneratedCategoryIdea, GeneratedPackIdea } from '@/lib/openai';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface IdeasListProps<T extends GeneratedCategoryIdea | GeneratedPackIdea> {
|
||||
title: string;
|
||||
ideas: T[];
|
||||
sourceModel: string | null;
|
||||
onSelectIdea: (idea: T) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper
|
||||
// =============================================================================
|
||||
|
||||
function formatModelName(model: string): string {
|
||||
const parts = model.split('/');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : model;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function IdeasList<T extends GeneratedCategoryIdea | GeneratedPackIdea>({
|
||||
title,
|
||||
ideas,
|
||||
sourceModel,
|
||||
onSelectIdea,
|
||||
}: IdeasListProps<T>) {
|
||||
if (ideas.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{title}</h4>
|
||||
{sourceModel && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
<Bot className="h-3 w-3" />
|
||||
{formatModelName(sourceModel)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{ideas.map((idea, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between rounded-lg border bg-card p-3 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectIdea(idea)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{idea.icon}</span>
|
||||
<div>
|
||||
<span className="font-medium block">{idea.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{idea.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost">Use</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">Click an item to use it</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Specialized Exports (type-safe wrappers)
|
||||
// =============================================================================
|
||||
|
||||
interface CategoryIdeasListProps {
|
||||
ideas: GeneratedCategoryIdea[];
|
||||
sourceModel: string | null;
|
||||
onSelectIdea: (idea: GeneratedCategoryIdea) => void;
|
||||
}
|
||||
|
||||
export function CategoryIdeasList({ ideas, sourceModel, onSelectIdea }: CategoryIdeasListProps) {
|
||||
return (
|
||||
<IdeasList
|
||||
title="Suggested Categories"
|
||||
ideas={ideas}
|
||||
sourceModel={sourceModel}
|
||||
onSelectIdea={onSelectIdea}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PackIdeasListProps {
|
||||
ideas: GeneratedPackIdea[];
|
||||
sourceModel: string | null;
|
||||
onSelectIdea: (idea: GeneratedPackIdea) => void;
|
||||
}
|
||||
|
||||
export function PackIdeasList({ ideas, sourceModel, onSelectIdea }: PackIdeasListProps) {
|
||||
return (
|
||||
<IdeasList
|
||||
title="Suggested Packs"
|
||||
ideas={ideas}
|
||||
sourceModel={sourceModel}
|
||||
onSelectIdea={onSelectIdea}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Bot } from 'lucide-react';
|
||||
import type { GeneratedPack } from '@/lib/openai';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface PackPreviewProps {
|
||||
pack: GeneratedPack;
|
||||
sourceModel: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper
|
||||
// =============================================================================
|
||||
|
||||
function formatModelName(model: string): string {
|
||||
const parts = model.split('/');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : model;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function PackPreview({ pack, sourceModel }: PackPreviewProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Generated Pack</h4>
|
||||
{sourceModel && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
<Bot className="h-3 w-3" />
|
||||
{formatModelName(sourceModel)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4 space-y-2">
|
||||
<p className="font-semibold text-lg">{pack.name}</p>
|
||||
<p className="text-muted-foreground">{pack.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
CheckSquare,
|
||||
Square,
|
||||
Filter,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
INTENSITY_LEVELS,
|
||||
type GeneratedQuestion,
|
||||
type QuestionReview,
|
||||
type CouncilGenerationResult,
|
||||
type CouncilSelectionMode,
|
||||
} from '@/lib/openai';
|
||||
import type { ReviewFilter } from './hooks/useAiGeneration';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface QuestionListProps {
|
||||
questions: GeneratedQuestion[];
|
||||
reviews: QuestionReview[];
|
||||
reviewSummary: CouncilGenerationResult['summary'];
|
||||
selectionMode: CouncilSelectionMode;
|
||||
selectedIndices: Set<number>;
|
||||
filter: ReviewFilter;
|
||||
filteredIndices: number[];
|
||||
onToggleSelection: (index: number) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onFilterChange: (filter: ReviewFilter) => void;
|
||||
getReviewForIndex: (index: number) => QuestionReview | undefined;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function QuestionList({
|
||||
questions,
|
||||
reviews,
|
||||
reviewSummary,
|
||||
selectionMode,
|
||||
selectedIndices,
|
||||
filter,
|
||||
filteredIndices,
|
||||
onToggleSelection,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onFilterChange,
|
||||
getReviewForIndex,
|
||||
}: QuestionListProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header with selection controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
Generated Questions ({selectedIndices.size}/{questions.length} selected)
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSelectAll}
|
||||
disabled={selectedIndices.size === questions.length}
|
||||
>
|
||||
<CheckSquare className="mr-1 h-3 w-3" />
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDeselectAll}
|
||||
disabled={selectedIndices.size === 0}
|
||||
>
|
||||
<Square className="mr-1 h-3 w-3" />
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
{reviews.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFilterChange('all')}
|
||||
>
|
||||
<Filter className="mr-1 h-3 w-3" />
|
||||
All ({questions.length})
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={filter === 'pass' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFilterChange('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={() => onFilterChange('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={() => onFilterChange('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>
|
||||
)}
|
||||
|
||||
{/* Question List */}
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{filteredIndices.map((i) => (
|
||||
<QuestionItem
|
||||
key={i}
|
||||
index={i}
|
||||
question={questions[i]}
|
||||
review={getReviewForIndex(i)}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedIndices.has(i)}
|
||||
onToggle={() => onToggleSelection(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Question Item
|
||||
// =============================================================================
|
||||
|
||||
interface QuestionItemProps {
|
||||
index: number;
|
||||
question: GeneratedQuestion;
|
||||
review: QuestionReview | undefined;
|
||||
selectionMode: CouncilSelectionMode;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function QuestionItem({
|
||||
question,
|
||||
review,
|
||||
selectionMode,
|
||||
isSelected,
|
||||
onToggle,
|
||||
}: QuestionItemProps) {
|
||||
const isRejected = review?.verdict === 'reject';
|
||||
|
||||
return (
|
||||
<div
|
||||
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'
|
||||
: isSelected
|
||||
? 'border-primary ring-1 ring-primary/20'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<p>{question.text}</p>
|
||||
{question.partner_text && (
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
Partner: {question.partner_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Intensity Badge */}
|
||||
<span
|
||||
className={`inline-block text-xs px-2 py-0.5 rounded text-white ${
|
||||
INTENSITY_LEVELS[question.intensity - 1]?.color || 'bg-muted'
|
||||
}`}
|
||||
title={INTENSITY_LEVELS[question.intensity - 1]?.description}
|
||||
>
|
||||
{question.intensity} - {INTENSITY_LEVELS[question.intensity - 1]?.label || 'Unknown'}
|
||||
</span>
|
||||
|
||||
{/* Source Generator Badge (cherry-pick mode) */}
|
||||
{selectionMode === 'cherry_pick' && question.sourceGeneratorModel && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
<Bot className="h-3 w-3" />
|
||||
{question.sourceGeneratorModel.split('/').pop()}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { CheckCircle2, AlertTriangle, XCircle, Cherry, Bot } from 'lucide-react';
|
||||
import type {
|
||||
CouncilGenerationResult,
|
||||
GenerationCandidate,
|
||||
CherryPickResult,
|
||||
CouncilSelectionMode,
|
||||
} from '@/lib/openai';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ReviewSummaryProps {
|
||||
summary: CouncilGenerationResult['summary'];
|
||||
metadata: CouncilGenerationResult['metadata'] | null;
|
||||
selectionMode: CouncilSelectionMode;
|
||||
allCandidates: GenerationCandidate[] | null;
|
||||
selectedGeneratorIndex: number | null;
|
||||
cherryPickResult: CherryPickResult | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function ReviewSummary({
|
||||
summary,
|
||||
metadata,
|
||||
selectionMode,
|
||||
allCandidates,
|
||||
selectedGeneratorIndex,
|
||||
cherryPickResult,
|
||||
}: ReviewSummaryProps) {
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />
|
||||
{summary.passed} Passed
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-yellow-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{summary.flagged} Flagged
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-red-600">
|
||||
<XCircle className="h-4 w-4" />
|
||||
{summary.rejected} Rejected
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
Quality: {summary.overallQuality}/10
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{metadata && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
Generated in {(metadata.totalGenerationTime / 1000).toFixed(1)}s
|
||||
{metadata.reviewTime > 0 && ` • Reviewed in ${(metadata.reviewTime / 1000).toFixed(1)}s`}
|
||||
</p>
|
||||
|
||||
{/* Cherry-pick mode info */}
|
||||
{selectionMode === 'cherry_pick' && cherryPickResult && (
|
||||
<div className="flex items-center gap-2 text-primary font-medium">
|
||||
<Cherry className="h-3 w-3" />
|
||||
<span>
|
||||
Cherry-picked from pool of {cherryPickResult.poolSize} questions
|
||||
{cherryPickResult.duplicatesRemoved > 0 &&
|
||||
` • ${cherryPickResult.duplicatesRemoved} duplicate${cherryPickResult.duplicatesRemoved > 1 ? 's' : ''} removed`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whole-set mode info */}
|
||||
{selectionMode === 'whole_set' && allCandidates && allCandidates.length > 1 && selectedGeneratorIndex !== null && (
|
||||
<p className="text-primary font-medium">
|
||||
Selected: Generator {selectedGeneratorIndex + 1} of {allCandidates.length}
|
||||
{' '}({allCandidates[selectedGeneratorIndex]?.generatorModel.split('/').pop()})
|
||||
</p>
|
||||
)}
|
||||
|
||||
{metadata.generatorModels.length > 1 && (
|
||||
<p>
|
||||
Compared {metadata.generatorModels.length} generators: {metadata.generatorModels.map(m => m.split('/').pop()).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Intensity distribution for cherry-pick */}
|
||||
{selectionMode === 'cherry_pick' && cherryPickResult && Object.keys(cherryPickResult.intensityDistribution).length > 0 && (
|
||||
<p>
|
||||
Intensity distribution: {Object.entries(cherryPickResult.intensityDistribution)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([level, count]) => `L${level}:${count}`)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Simple Model Badge (for non-council generation)
|
||||
// =============================================================================
|
||||
|
||||
interface ModelBadgeProps {
|
||||
model: string;
|
||||
generationTime?: number;
|
||||
}
|
||||
|
||||
export function ModelBadge({ model, generationTime }: ModelBadgeProps) {
|
||||
const formatModelName = (m: string) => {
|
||||
const parts = m.split('/');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : m;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Bot className="h-3 w-3" />
|
||||
<span>Generated by {formatModelName(model)}</span>
|
||||
{generationTime && generationTime > 0 && (
|
||||
<span>in {(generationTime / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
generatePack,
|
||||
generateQuestionsWithCouncil,
|
||||
suggestCategories,
|
||||
suggestPacks,
|
||||
getCouncilConfig,
|
||||
getModel,
|
||||
type GeneratedPack,
|
||||
type GeneratedQuestion,
|
||||
type GeneratedCategoryIdea,
|
||||
type GeneratedPackIdea,
|
||||
type ToneLevel,
|
||||
type QuestionReview,
|
||||
type CouncilGenerationResult,
|
||||
type GenerationCandidate,
|
||||
type CherryPickResult,
|
||||
type CouncilSelectionMode,
|
||||
type GenerationProgress,
|
||||
} from '@/lib/openai';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type GeneratorType = 'pack' | 'questions' | 'category-ideas' | 'category-pack-ideas';
|
||||
|
||||
export interface GeneratorContext {
|
||||
categoryName?: string;
|
||||
packName?: string;
|
||||
packDescription?: string | null;
|
||||
existingCategories?: string[];
|
||||
existingPacks?: string[];
|
||||
existingQuestions?: string[];
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
export interface GenerationConfig {
|
||||
count: number;
|
||||
tone: ToneLevel;
|
||||
crudeLang: boolean;
|
||||
inspiration: string;
|
||||
}
|
||||
|
||||
export interface GenerationResults {
|
||||
pack: GeneratedPack | null;
|
||||
questions: GeneratedQuestion[];
|
||||
categoryIdeas: GeneratedCategoryIdea[];
|
||||
packIdeas: GeneratedPackIdea[];
|
||||
}
|
||||
|
||||
export interface ReviewState {
|
||||
reviews: QuestionReview[];
|
||||
summary: CouncilGenerationResult['summary'];
|
||||
metadata: CouncilGenerationResult['metadata'] | null;
|
||||
selectedGeneratorIndex: number | null;
|
||||
allCandidates: GenerationCandidate[] | null;
|
||||
cherryPickResult: CherryPickResult | null;
|
||||
selectionMode: CouncilSelectionMode;
|
||||
}
|
||||
|
||||
export type ReviewFilter = 'all' | 'pass' | 'flag' | 'reject';
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
||||
export function useAiGeneration(
|
||||
type: GeneratorType,
|
||||
context?: GeneratorContext
|
||||
) {
|
||||
// Loading state
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState<GenerationProgress | null>(null);
|
||||
|
||||
// Config state
|
||||
const [config, setConfig] = useState<GenerationConfig>({
|
||||
count: 10,
|
||||
tone: context?.isExplicit ? 5 : 3,
|
||||
crudeLang: false,
|
||||
inspiration: '',
|
||||
});
|
||||
|
||||
// Results state
|
||||
const [results, setResults] = useState<GenerationResults>({
|
||||
pack: null,
|
||||
questions: [],
|
||||
categoryIdeas: [],
|
||||
packIdeas: [],
|
||||
});
|
||||
|
||||
// Review state
|
||||
const [reviewState, setReviewState] = useState<ReviewState>({
|
||||
reviews: [],
|
||||
summary: null,
|
||||
metadata: null,
|
||||
selectedGeneratorIndex: null,
|
||||
allCandidates: null,
|
||||
cherryPickResult: null,
|
||||
selectionMode: 'whole_set',
|
||||
});
|
||||
|
||||
// Selection state
|
||||
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set());
|
||||
const [filter, setFilter] = useState<ReviewFilter>('all');
|
||||
|
||||
// Source model tracking
|
||||
const [sourceModel, setSourceModel] = useState<string | null>(null);
|
||||
|
||||
// Previous suggestions tracking (avoid repetition on regenerate)
|
||||
const [previousNames, setPreviousNames] = useState<{
|
||||
packs: string[];
|
||||
categories: string[];
|
||||
packIdeas: string[];
|
||||
}>({ packs: [], categories: [], packIdeas: [] });
|
||||
|
||||
// Council config
|
||||
const councilConfig = getCouncilConfig();
|
||||
|
||||
// Update tone when context changes
|
||||
useEffect(() => {
|
||||
if (type === 'questions') {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
tone: context?.isExplicit ? 5 : 3,
|
||||
}));
|
||||
}
|
||||
}, [context?.isExplicit, type]);
|
||||
|
||||
// Config setters
|
||||
const setCount = useCallback((count: number) => {
|
||||
setConfig(prev => ({ ...prev, count }));
|
||||
}, []);
|
||||
|
||||
const setTone = useCallback((tone: ToneLevel) => {
|
||||
setConfig(prev => ({ ...prev, tone }));
|
||||
}, []);
|
||||
|
||||
const setCrudeLang = useCallback((crudeLang: boolean) => {
|
||||
setConfig(prev => ({ ...prev, crudeLang }));
|
||||
}, []);
|
||||
|
||||
const setInspiration = useCallback((inspiration: string) => {
|
||||
setConfig(prev => ({ ...prev, inspiration }));
|
||||
}, []);
|
||||
|
||||
// Helper functions
|
||||
const getReviewForIndex = useCallback((index: number): QuestionReview | undefined => {
|
||||
return reviewState.reviews.find(r => r.index === index);
|
||||
}, [reviewState.reviews]);
|
||||
|
||||
const getFilteredQuestionIndices = useCallback((): number[] => {
|
||||
if (results.questions.length === 0) return [];
|
||||
if (filter === 'all' || reviewState.reviews.length === 0) {
|
||||
return results.questions.map((_, i) => i);
|
||||
}
|
||||
return reviewState.reviews
|
||||
.filter(r => r.verdict === filter)
|
||||
.map(r => r.index);
|
||||
}, [results.questions, reviewState.reviews, filter]);
|
||||
|
||||
// Selection handlers
|
||||
const toggleSelection = useCallback((index: number) => {
|
||||
setSelectedIndices(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIndices(new Set(results.questions.map((_, i) => i)));
|
||||
}, [results.questions]);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedIndices(new Set());
|
||||
}, []);
|
||||
|
||||
// Reset all state
|
||||
const reset = useCallback(() => {
|
||||
setResults({ pack: null, questions: [], categoryIdeas: [], packIdeas: [] });
|
||||
setSelectedIndices(new Set());
|
||||
setConfig(prev => ({ ...prev, crudeLang: false, inspiration: '' }));
|
||||
setReviewState({
|
||||
reviews: [],
|
||||
summary: null,
|
||||
metadata: null,
|
||||
selectedGeneratorIndex: null,
|
||||
allCandidates: null,
|
||||
cherryPickResult: null,
|
||||
selectionMode: 'whole_set',
|
||||
});
|
||||
setFilter('all');
|
||||
setProgress(null);
|
||||
setSourceModel(null);
|
||||
setPreviousNames({ packs: [], categories: [], packIdeas: [] });
|
||||
}, []);
|
||||
|
||||
// Main generate function
|
||||
const generate = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setProgress(null);
|
||||
|
||||
// Reset review state
|
||||
setReviewState({
|
||||
reviews: [],
|
||||
summary: null,
|
||||
metadata: null,
|
||||
selectedGeneratorIndex: null,
|
||||
allCandidates: null,
|
||||
cherryPickResult: null,
|
||||
selectionMode: 'whole_set',
|
||||
});
|
||||
setFilter('all');
|
||||
setSourceModel(null);
|
||||
|
||||
const isExplicit = config.tone >= 4;
|
||||
|
||||
try {
|
||||
if (type === 'pack') {
|
||||
// Track previous names to avoid repetition
|
||||
const avoidNames = results.pack
|
||||
? [...previousNames.packs, results.pack.name]
|
||||
: previousNames.packs;
|
||||
if (results.pack) {
|
||||
setPreviousNames(prev => ({ ...prev, packs: avoidNames }));
|
||||
}
|
||||
|
||||
const model = getModel('generate');
|
||||
const result = await generatePack(
|
||||
context?.categoryName,
|
||||
isExplicit,
|
||||
config.crudeLang,
|
||||
config.inspiration || undefined,
|
||||
avoidNames
|
||||
);
|
||||
setResults(prev => ({ ...prev, pack: result }));
|
||||
setSourceModel(model);
|
||||
|
||||
} else if (type === 'questions') {
|
||||
if (!context?.packName) {
|
||||
toast.error('Pack name is required');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await generateQuestionsWithCouncil(
|
||||
context.packName,
|
||||
config.count,
|
||||
undefined,
|
||||
config.tone,
|
||||
context.packDescription || undefined,
|
||||
context.existingQuestions,
|
||||
config.crudeLang,
|
||||
config.inspiration || undefined,
|
||||
setProgress
|
||||
);
|
||||
|
||||
const questions = result.questions || [];
|
||||
const reviews = result.reviews || [];
|
||||
|
||||
setResults(prev => ({ ...prev, questions }));
|
||||
setReviewState({
|
||||
reviews,
|
||||
summary: result.summary,
|
||||
metadata: result.metadata,
|
||||
selectedGeneratorIndex: result.selectedGeneratorIndex,
|
||||
allCandidates: result.allCandidates,
|
||||
cherryPickResult: result.cherryPickResult || null,
|
||||
selectionMode: result.selectionMode || 'whole_set',
|
||||
});
|
||||
|
||||
// Smart selection based on review results
|
||||
if (reviews.length > 0) {
|
||||
const selected = new Set<number>();
|
||||
reviews.forEach(r => {
|
||||
if (r.verdict === 'pass' || r.verdict === 'flag') {
|
||||
selected.add(r.index);
|
||||
}
|
||||
});
|
||||
setSelectedIndices(selected);
|
||||
|
||||
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 {
|
||||
setSelectedIndices(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 avoidNames = results.categoryIdeas.length > 0
|
||||
? [...previousNames.categories, ...results.categoryIdeas.map(i => i.name)]
|
||||
: previousNames.categories;
|
||||
if (results.categoryIdeas.length > 0) {
|
||||
setPreviousNames(prev => ({ ...prev, categories: avoidNames }));
|
||||
}
|
||||
|
||||
const model = getModel('generate');
|
||||
const result = await suggestCategories(
|
||||
context?.existingCategories || [],
|
||||
isExplicit,
|
||||
config.crudeLang,
|
||||
config.inspiration || undefined,
|
||||
avoidNames
|
||||
);
|
||||
setResults(prev => ({ ...prev, categoryIdeas: result }));
|
||||
setSourceModel(model);
|
||||
|
||||
} else if (type === 'category-pack-ideas') {
|
||||
if (!context?.categoryName) {
|
||||
toast.error('Category name is required');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const avoidNames = results.packIdeas.length > 0
|
||||
? [...previousNames.packIdeas, ...results.packIdeas.map(i => i.name)]
|
||||
: previousNames.packIdeas;
|
||||
if (results.packIdeas.length > 0) {
|
||||
setPreviousNames(prev => ({ ...prev, packIdeas: avoidNames }));
|
||||
}
|
||||
|
||||
const model = getModel('generate');
|
||||
const result = await suggestPacks(
|
||||
context.categoryName,
|
||||
context?.existingPacks || [],
|
||||
isExplicit,
|
||||
config.crudeLang,
|
||||
config.inspiration || undefined,
|
||||
avoidNames
|
||||
);
|
||||
setResults(prev => ({ ...prev, packIdeas: result }));
|
||||
setSourceModel(model);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI generation error:', error);
|
||||
toast.error('Failed to generate content. Check your API key.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [type, context, config, results, previousNames, councilConfig.enabled]);
|
||||
|
||||
// Check if there are any results
|
||||
const hasResults = Boolean(
|
||||
results.pack ||
|
||||
results.questions.length > 0 ||
|
||||
results.categoryIdeas.length > 0 ||
|
||||
results.packIdeas.length > 0
|
||||
);
|
||||
|
||||
return {
|
||||
// Loading state
|
||||
loading,
|
||||
progress,
|
||||
|
||||
// Config
|
||||
config,
|
||||
setCount,
|
||||
setTone,
|
||||
setCrudeLang,
|
||||
setInspiration,
|
||||
|
||||
// Results
|
||||
results,
|
||||
hasResults,
|
||||
sourceModel,
|
||||
|
||||
// Review state
|
||||
reviewState,
|
||||
filter,
|
||||
setFilter,
|
||||
councilConfig,
|
||||
|
||||
// Selection
|
||||
selectedIndices,
|
||||
toggleSelection,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
|
||||
// Helpers
|
||||
getReviewForIndex,
|
||||
getFilteredQuestionIndices,
|
||||
|
||||
// Actions
|
||||
generate,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { EmojiPicker } from '@/components/ui/emoji-picker';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Loader2, X } from 'lucide-react';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface Topic {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PackFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
is_premium: boolean;
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
}
|
||||
|
||||
export interface PackFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
formData: PackFormData;
|
||||
onFormChange: (data: PackFormData) => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
isEditing: boolean;
|
||||
allTopics: Topic[];
|
||||
selectedTopicIds: Set<string>;
|
||||
onTopicsChange: (topicIds: Set<string>) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function PackFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
formData,
|
||||
onFormChange,
|
||||
onSave,
|
||||
saving,
|
||||
isEditing,
|
||||
allTopics,
|
||||
selectedTopicIds,
|
||||
onTopicsChange,
|
||||
}: PackFormDialogProps) {
|
||||
const setField = <K extends keyof PackFormData>(field: K, value: PackFormData[K]) => {
|
||||
onFormChange({ ...formData, [field]: value });
|
||||
};
|
||||
|
||||
const addTopic = (topicId: string) => {
|
||||
const next = new Set(selectedTopicIds);
|
||||
next.add(topicId);
|
||||
onTopicsChange(next);
|
||||
};
|
||||
|
||||
const removeTopic = (topicId: string) => {
|
||||
const next = new Set(selectedTopicIds);
|
||||
next.delete(topicId);
|
||||
onTopicsChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? 'Edit Pack' : 'Create Pack'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? 'Update the pack details below.'
|
||||
: 'Add a new question pack to this category.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Icon */}
|
||||
<div className="space-y-2">
|
||||
<Label>Icon</Label>
|
||||
<EmojiPicker
|
||||
value={formData.icon}
|
||||
onChange={(emoji) => setField('icon', emoji)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<AIPolishButton
|
||||
text={formData.name}
|
||||
type="pack_name"
|
||||
onPolished={(val) => setField('name', val)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setField('name', e.target.value)}
|
||||
placeholder="e.g., 36 Questions to Fall in Love"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<AIPolishButton
|
||||
text={formData.description}
|
||||
type="pack_description"
|
||||
onPolished={(val) => setField('description', val)}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setField('description', e.target.value)}
|
||||
placeholder="Describe this pack..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Premium Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Premium Pack</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only available to premium users
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_premium}
|
||||
onCheckedChange={(checked) => setField('is_premium', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Public</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visible to all users by default
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_public}
|
||||
onCheckedChange={(checked) => setField('is_public', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Explicit Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Explicit Content</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Contains adult or mature content
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_explicit}
|
||||
onCheckedChange={(checked) => setField('is_explicit', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Topics */}
|
||||
<div className="space-y-2">
|
||||
<Label>Topics</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select topics/kinks for filtering
|
||||
</p>
|
||||
|
||||
{/* Selected topics */}
|
||||
{selectedTopicIds.size > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{Array.from(selectedTopicIds).map(id => {
|
||||
const topic = allTopics.find(t => t.id === id);
|
||||
if (!topic) return null;
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
variant="secondary"
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => removeTopic(id)}
|
||||
>
|
||||
{topic.name}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available topics */}
|
||||
{allTopics.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 p-2 border rounded-md max-h-32 overflow-y-auto">
|
||||
{allTopics
|
||||
.filter(t => !selectedTopicIds.has(t.id))
|
||||
.map(topic => (
|
||||
<Badge
|
||||
key={topic.id}
|
||||
variant="outline"
|
||||
className="cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={() => addTopic(topic.id)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{topic.name}
|
||||
</Badge>
|
||||
))}
|
||||
{allTopics.filter(t => !selectedTopicIds.has(t.id)).length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">All topics selected</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No topics yet. Use "Extract Topics" on a pack to create some.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export interface CouncilGenerator {
|
||||
model: string;
|
||||
}
|
||||
|
||||
export type CouncilSelectionMode = 'whole_set' | 'cherry_pick';
|
||||
|
||||
export interface AiConfig {
|
||||
id: string;
|
||||
openrouter_api_key: string | null;
|
||||
@@ -16,6 +18,8 @@ export interface AiConfig {
|
||||
council_generator_model: string | null; // Legacy - kept for backwards compatibility
|
||||
council_generators: CouncilGenerator[] | null; // New array of generators
|
||||
council_reviewer_model: string | null;
|
||||
council_selection_mode: CouncilSelectionMode | null; // 'whole_set' or 'cherry_pick'
|
||||
cherry_pick_ensure_intensity_distribution: boolean | null; // Balance intensity levels in cherry-pick mode
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type SupabaseFilterFn<T> = (query: ReturnType<typeof supabase.from>) => ReturnType<typeof supabase.from>;
|
||||
|
||||
interface UseCrudOperationsOptions<T> {
|
||||
/**
|
||||
* The Supabase table name
|
||||
*/
|
||||
table: string;
|
||||
|
||||
/**
|
||||
* Default ordering for fetches
|
||||
*/
|
||||
orderBy?: {
|
||||
column: keyof T | string;
|
||||
ascending?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Select expression for fetches
|
||||
* @default '*'
|
||||
*/
|
||||
select?: string;
|
||||
|
||||
/**
|
||||
* Success messages for operations
|
||||
*/
|
||||
messages?: {
|
||||
createSuccess?: string;
|
||||
updateSuccess?: string;
|
||||
deleteSuccess?: string;
|
||||
fetchError?: string;
|
||||
createError?: string;
|
||||
updateError?: string;
|
||||
deleteError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic CRUD operations hook for Supabase tables
|
||||
* Reduces boilerplate for data fetching and mutations across pages.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const {
|
||||
* items,
|
||||
* loading,
|
||||
* fetchAll,
|
||||
* create,
|
||||
* update,
|
||||
* remove,
|
||||
* } = useCrudOperations<Category>({
|
||||
* table: 'categories',
|
||||
* orderBy: { column: 'sort_order', ascending: true },
|
||||
* messages: {
|
||||
* createSuccess: 'Category created',
|
||||
* updateSuccess: 'Category updated',
|
||||
* deleteSuccess: 'Category deleted',
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useCrudOperations<T extends { id: string }>({
|
||||
table,
|
||||
orderBy,
|
||||
select = '*',
|
||||
messages = {},
|
||||
}: UseCrudOperationsOptions<T>) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const defaultMessages = {
|
||||
createSuccess: 'Created successfully',
|
||||
updateSuccess: 'Updated successfully',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
fetchError: 'Failed to load data',
|
||||
createError: 'Failed to create',
|
||||
updateError: 'Failed to update',
|
||||
deleteError: 'Failed to delete',
|
||||
...messages,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all items from the table
|
||||
* @param filter Optional filter function to modify the query
|
||||
*/
|
||||
const fetchAll = useCallback(async (
|
||||
filter?: SupabaseFilterFn<T>
|
||||
): Promise<T[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let query = supabase.from(table).select(select);
|
||||
|
||||
if (filter) {
|
||||
query = filter(query) as typeof query;
|
||||
}
|
||||
|
||||
if (orderBy) {
|
||||
query = query.order(orderBy.column as string, {
|
||||
ascending: orderBy.ascending ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
const { data, error: fetchError } = await query;
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
const result = (data || []) as T[];
|
||||
setItems(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
setError(error);
|
||||
toast.error(defaultMessages.fetchError);
|
||||
console.error(error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [table, select, orderBy, defaultMessages.fetchError]);
|
||||
|
||||
/**
|
||||
* Fetch a single item by ID
|
||||
*/
|
||||
const fetchById = useCallback(async (id: string): Promise<T | null> => {
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from(table)
|
||||
.select(select)
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
return data as T | null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}, [table, select]);
|
||||
|
||||
/**
|
||||
* Create a new item
|
||||
* @param data The data to insert
|
||||
* @param options.refetch Whether to refetch after creating (default: true)
|
||||
* @returns The created item, or null on error
|
||||
*/
|
||||
const create = useCallback(async (
|
||||
data: Omit<T, 'id' | 'created_at'> | Record<string, unknown>,
|
||||
options: { refetch?: boolean; silent?: boolean } = {}
|
||||
): Promise<T | null> => {
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
const { data: created, error: createError } = await auditedSupabase.insert(table, data);
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
if (!silent) {
|
||||
toast.success(defaultMessages.createSuccess);
|
||||
}
|
||||
|
||||
if (refetch) {
|
||||
await fetchAll();
|
||||
}
|
||||
|
||||
return created as T;
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
toast.error(defaultMessages.createError);
|
||||
}
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}, [table, fetchAll, defaultMessages.createSuccess, defaultMessages.createError]);
|
||||
|
||||
/**
|
||||
* Update an existing item
|
||||
* @param id The item ID
|
||||
* @param data The data to update
|
||||
* @param options.refetch Whether to refetch after updating (default: true)
|
||||
*/
|
||||
const update = useCallback(async (
|
||||
id: string,
|
||||
data: Partial<T> | Record<string, unknown>,
|
||||
options: { refetch?: boolean; silent?: boolean } = {}
|
||||
): Promise<boolean> => {
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
const { error: updateError } = await auditedSupabase.update(table, id, data);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
if (!silent) {
|
||||
toast.success(defaultMessages.updateSuccess);
|
||||
}
|
||||
|
||||
if (refetch) {
|
||||
await fetchAll();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
toast.error(defaultMessages.updateError);
|
||||
}
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}, [table, fetchAll, defaultMessages.updateSuccess, defaultMessages.updateError]);
|
||||
|
||||
/**
|
||||
* Delete an item
|
||||
* @param id The item ID
|
||||
* @param options.confirm Confirmation message (if provided, shows confirm dialog)
|
||||
* @param options.refetch Whether to refetch after deleting (default: true)
|
||||
*/
|
||||
const remove = useCallback(async (
|
||||
id: string,
|
||||
options: { confirm?: string; refetch?: boolean; silent?: boolean } = {}
|
||||
): Promise<boolean> => {
|
||||
const { confirm: confirmMessage, refetch = true, silent = false } = options;
|
||||
|
||||
if (confirmMessage && !window.confirm(confirmMessage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: deleteError } = await auditedSupabase.delete(table, id);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
if (!silent) {
|
||||
toast.success(defaultMessages.deleteSuccess);
|
||||
}
|
||||
|
||||
if (refetch) {
|
||||
await fetchAll();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
toast.error(defaultMessages.deleteError);
|
||||
}
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}, [table, fetchAll, defaultMessages.deleteSuccess, defaultMessages.deleteError]);
|
||||
|
||||
/**
|
||||
* Optimistically update an item in local state
|
||||
* Useful for immediate UI feedback before server confirms
|
||||
*/
|
||||
const optimisticUpdate = useCallback((id: string, updates: Partial<T>) => {
|
||||
setItems(prev => prev.map(item =>
|
||||
item.id === id ? { ...item, ...updates } : item
|
||||
));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Optimistically add an item to local state
|
||||
*/
|
||||
const optimisticAdd = useCallback((item: T) => {
|
||||
setItems(prev => [...prev, item]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Optimistically remove an item from local state
|
||||
*/
|
||||
const optimisticRemove = useCallback((id: string) => {
|
||||
setItems(prev => prev.filter(item => item.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
items,
|
||||
setItems,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Read operations
|
||||
fetchAll,
|
||||
fetchById,
|
||||
|
||||
// Write operations
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
|
||||
// Optimistic updates
|
||||
optimisticUpdate,
|
||||
optimisticAdd,
|
||||
optimisticRemove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Generic form state management hook for entity CRUD dialogs
|
||||
* Reduces boilerplate across pages like PacksPage, CategoriesPage, QuestionsPage, etc.
|
||||
*
|
||||
* @template TFormData - The shape of the form data
|
||||
* @template TEntity - The shape of the entity being edited (extends the form data)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* interface CategoryForm {
|
||||
* name: string;
|
||||
* description: string;
|
||||
* icon: string;
|
||||
* }
|
||||
*
|
||||
* interface Category extends CategoryForm {
|
||||
* id: string;
|
||||
* created_at: string;
|
||||
* }
|
||||
*
|
||||
* const {
|
||||
* formData,
|
||||
* setField,
|
||||
* dialogOpen,
|
||||
* setDialogOpen,
|
||||
* editingItem,
|
||||
* saving,
|
||||
* setSaving,
|
||||
* openCreate,
|
||||
* openEdit,
|
||||
* reset,
|
||||
* isEditing,
|
||||
* } = useEntityForm<CategoryForm, Category>(
|
||||
* { name: '', description: '', icon: '📚' },
|
||||
* (category) => ({
|
||||
* name: category.name,
|
||||
* description: category.description || '',
|
||||
* icon: category.icon || '📚',
|
||||
* })
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useEntityForm<
|
||||
TFormData extends Record<string, unknown>,
|
||||
TEntity extends Record<string, unknown> = TFormData
|
||||
>(
|
||||
initialFormData: TFormData,
|
||||
/**
|
||||
* Transform an entity into form data for editing.
|
||||
* Handles nullable fields and default values.
|
||||
*/
|
||||
entityToFormData?: (entity: TEntity) => TFormData
|
||||
) {
|
||||
const [formData, setFormData] = useState<TFormData>(initialFormData);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<TEntity | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
/**
|
||||
* Update a single field in the form data
|
||||
*/
|
||||
const setField = useCallback(<K extends keyof TFormData>(
|
||||
field: K,
|
||||
value: TFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update multiple fields at once
|
||||
*/
|
||||
const setFields = useCallback((updates: Partial<TFormData>) => {
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset form to initial state
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
setFormData(initialFormData);
|
||||
setEditingItem(null);
|
||||
}, [initialFormData]);
|
||||
|
||||
/**
|
||||
* Open dialog for creating a new entity
|
||||
*/
|
||||
const openCreate = useCallback(() => {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormData);
|
||||
setDialogOpen(true);
|
||||
}, [initialFormData]);
|
||||
|
||||
/**
|
||||
* Open dialog for editing an existing entity
|
||||
*/
|
||||
const openEdit = useCallback((entity: TEntity) => {
|
||||
setEditingItem(entity);
|
||||
if (entityToFormData) {
|
||||
setFormData(entityToFormData(entity));
|
||||
} else {
|
||||
// Best effort: assume entity shape matches form data
|
||||
setFormData(entity as unknown as TFormData);
|
||||
}
|
||||
setDialogOpen(true);
|
||||
}, [entityToFormData]);
|
||||
|
||||
/**
|
||||
* Prefill form with data (e.g., from AI suggestion) and open create dialog
|
||||
*/
|
||||
const openCreateWith = useCallback((data: Partial<TFormData>) => {
|
||||
setEditingItem(null);
|
||||
setFormData({ ...initialFormData, ...data });
|
||||
setDialogOpen(true);
|
||||
}, [initialFormData]);
|
||||
|
||||
/**
|
||||
* Close the dialog
|
||||
*/
|
||||
const close = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Close dialog and reset form
|
||||
*/
|
||||
const closeAndReset = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
// Small delay to allow dialog close animation
|
||||
setTimeout(() => {
|
||||
setFormData(initialFormData);
|
||||
setEditingItem(null);
|
||||
}, 150);
|
||||
}, [initialFormData]);
|
||||
|
||||
return {
|
||||
// Form state
|
||||
formData,
|
||||
setFormData,
|
||||
setField,
|
||||
setFields,
|
||||
|
||||
// Dialog state
|
||||
dialogOpen,
|
||||
setDialogOpen,
|
||||
|
||||
// Entity state
|
||||
editingItem,
|
||||
setEditingItem,
|
||||
|
||||
// Loading state
|
||||
saving,
|
||||
setSaving,
|
||||
|
||||
// Actions
|
||||
openCreate,
|
||||
openEdit,
|
||||
openCreateWith,
|
||||
reset,
|
||||
close,
|
||||
closeAndReset,
|
||||
|
||||
// Computed
|
||||
isEditing: editingItem !== null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
export interface OpenRouterModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
context_length: number;
|
||||
pricing: {
|
||||
prompt: string;
|
||||
completion: string;
|
||||
};
|
||||
top_provider?: {
|
||||
max_completion_tokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UseOpenRouterModelsReturn {
|
||||
models: OpenRouterModel[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Cache for models list
|
||||
let cachedModels: OpenRouterModel[] = [];
|
||||
let modelsCacheTimestamp: number = 0;
|
||||
const MODELS_CACHE_TTL = 30 * 60 * 1000; // 30 minutes - models don't change often
|
||||
|
||||
// Popular/recommended models to show at top
|
||||
const FEATURED_MODEL_IDS = [
|
||||
'anthropic/claude-sonnet-4',
|
||||
'anthropic/claude-3.5-sonnet',
|
||||
'openai/gpt-4o',
|
||||
'openai/gpt-4o-mini',
|
||||
'google/gemini-pro-1.5',
|
||||
'google/gemini-2.0-flash-001',
|
||||
'meta-llama/llama-3.1-405b-instruct',
|
||||
'deepseek/deepseek-chat',
|
||||
];
|
||||
|
||||
export function useOpenRouterModels(): UseOpenRouterModelsReturn {
|
||||
const [models, setModels] = useState<OpenRouterModel[]>(cachedModels);
|
||||
const [loading, setLoading] = useState(cachedModels.length === 0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
// Use cache if valid
|
||||
if (cachedModels.length > 0 && Date.now() - modelsCacheTimestamp < MODELS_CACHE_TTL) {
|
||||
setModels(cachedModels);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// OpenRouter's models endpoint is public, no auth needed
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Filter to only text models and sort by featured + name
|
||||
const textModels: OpenRouterModel[] = (data.data || [])
|
||||
.filter((m: OpenRouterModel) => {
|
||||
// Filter out image-only, audio-only models
|
||||
const id = m.id.toLowerCase();
|
||||
return !id.includes('dall-e') &&
|
||||
!id.includes('stable-diffusion') &&
|
||||
!id.includes('midjourney') &&
|
||||
!id.includes('whisper') &&
|
||||
!id.includes('tts');
|
||||
})
|
||||
.map((m: OpenRouterModel) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
description: m.description || '',
|
||||
context_length: m.context_length || 0,
|
||||
pricing: m.pricing || { prompt: '0', completion: '0' },
|
||||
top_provider: m.top_provider,
|
||||
}))
|
||||
.sort((a: OpenRouterModel, b: OpenRouterModel) => {
|
||||
// Featured models first
|
||||
const aFeatured = FEATURED_MODEL_IDS.indexOf(a.id);
|
||||
const bFeatured = FEATURED_MODEL_IDS.indexOf(b.id);
|
||||
|
||||
if (aFeatured !== -1 && bFeatured !== -1) {
|
||||
return aFeatured - bFeatured;
|
||||
}
|
||||
if (aFeatured !== -1) return -1;
|
||||
if (bFeatured !== -1) return 1;
|
||||
|
||||
// Then alphabetically by name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
cachedModels = textModels;
|
||||
modelsCacheTimestamp = Date.now();
|
||||
setModels(textModels);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch models');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels();
|
||||
}, [fetchModels]);
|
||||
|
||||
return {
|
||||
models,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchModels,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook variant that provides filtered model suggestions
|
||||
*/
|
||||
export function useModelSuggestions(searchQuery: string, maxResults: number = 20) {
|
||||
const { models, loading, error } = useOpenRouterModels();
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
// Return featured models when no search
|
||||
return models.slice(0, maxResults);
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return models
|
||||
.filter(m =>
|
||||
m.id.toLowerCase().includes(query) ||
|
||||
m.name.toLowerCase().includes(query)
|
||||
)
|
||||
.slice(0, maxResults);
|
||||
}, [models, searchQuery, maxResults]);
|
||||
|
||||
return {
|
||||
models: filteredModels,
|
||||
allModels: models,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pricing for display
|
||||
*/
|
||||
export function formatModelPricing(model: OpenRouterModel): string {
|
||||
const promptCost = parseFloat(model.pricing.prompt) * 1000000;
|
||||
const completionCost = parseFloat(model.pricing.completion) * 1000000;
|
||||
|
||||
if (promptCost === 0 && completionCost === 0) {
|
||||
return 'Free';
|
||||
}
|
||||
|
||||
return `$${promptCost.toFixed(2)}/$${completionCost.toFixed(2)} per 1M tokens`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format context length for display
|
||||
*/
|
||||
export function formatContextLength(contextLength: number): string {
|
||||
if (contextLength >= 1000000) {
|
||||
return `${(contextLength / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (contextLength >= 1000) {
|
||||
return `${Math.round(contextLength / 1000)}K`;
|
||||
}
|
||||
return contextLength.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached models synchronously
|
||||
*/
|
||||
export function getCachedModels(): OpenRouterModel[] {
|
||||
return cachedModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload models (call on app init)
|
||||
*/
|
||||
export async function preloadOpenRouterModels(): Promise<void> {
|
||||
if (cachedModels.length > 0 && Date.now() - modelsCacheTimestamp < MODELS_CACHE_TTL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
cachedModels = (data.data || [])
|
||||
.filter((m: OpenRouterModel) => {
|
||||
const id = m.id.toLowerCase();
|
||||
return !id.includes('dall-e') &&
|
||||
!id.includes('stable-diffusion') &&
|
||||
!id.includes('midjourney') &&
|
||||
!id.includes('whisper') &&
|
||||
!id.includes('tts');
|
||||
})
|
||||
.map((m: OpenRouterModel) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
description: m.description || '',
|
||||
context_length: m.context_length || 0,
|
||||
pricing: m.pricing || { prompt: '0', completion: '0' },
|
||||
top_provider: m.top_provider,
|
||||
}));
|
||||
modelsCacheTimestamp = Date.now();
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - we'll show manual input as fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// =============================================================================
|
||||
// Target Analysis Functions
|
||||
// Analyze questions for gender targeting and couple compatibility
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import type { TargetAnalysis } from '../types';
|
||||
|
||||
/**
|
||||
* Analyze questions for gender targeting suggestions
|
||||
*/
|
||||
export async function analyzeQuestionTargets(
|
||||
questions: {
|
||||
id: string;
|
||||
text: string;
|
||||
partner_text?: string | null;
|
||||
allowed_couple_genders: string[] | null;
|
||||
target_user_genders: string[] | null;
|
||||
}[]
|
||||
): Promise<TargetAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const simplifiedQuestions = questions.map(q => ({
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
current_targets: q.allowed_couple_genders,
|
||||
current_initiator: q.target_user_genders
|
||||
}));
|
||||
|
||||
const prompt = `Analyze the following questions for a couples app and suggest:
|
||||
1. "allowed_couple_genders" (which couple types can see this question)
|
||||
2. "target_user_genders" (who should see this question FIRST as initiator - only relevant for two-part questions with partner_text)
|
||||
|
||||
COUPLE TARGET OPTIONS: 'male+male', 'female+male', 'female+female'
|
||||
INITIATOR OPTIONS: 'male', 'female', 'non-binary'
|
||||
|
||||
RULES FOR COUPLE TARGETS (allowed_couple_genders):
|
||||
- Default to ALL (null) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
|
||||
- Limit ONLY if there is a clear, explicit anatomical requirement (e.g. genitalia-specific terms).
|
||||
- SEX TOYS ARE GENDER-NEUTRAL: Vibrators, dildos, plugs, etc. can be used by ANYONE. Do NOT restrict based on toys.
|
||||
- "Control your partner's vibrator" -> ALL couples, ANY initiator (vibrators work on any body)
|
||||
- "Use a butt plug" -> ALL couples (everyone has a butt)
|
||||
|
||||
Examples of ACTUAL restrictions:
|
||||
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
|
||||
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
|
||||
- "Vibrator", "dildo", "toy" -> NO restriction, works for everyone
|
||||
|
||||
RULES FOR INITIATOR (target_user_genders):
|
||||
- ONLY relevant for TWO-PART questions (those with partner_text set).
|
||||
- Analyze the "text" field to determine who should see this card FIRST as the initiator.
|
||||
- ONLY set initiator if there is an EXPLICIT anatomical requirement in the text.
|
||||
- SEX TOYS DO NOT IMPLY GENDER: "vibrator", "dildo", "toy", "plug" -> initiator: null (anyone can use/control toys)
|
||||
- If text is gender-neutral, leave as null. When in doubt, use null.
|
||||
|
||||
Key considerations for initiator:
|
||||
1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context)
|
||||
2. Does the "text" describe an action typically done BY someone with a penis (e.g., "my cum", "cum on you")? -> Male initiator
|
||||
3. Does the "text" describe receiving penetration? -> Consider who is typically penetrated
|
||||
4. Is the action truly symmetric with no gendered context? -> null
|
||||
|
||||
Examples:
|
||||
- text: "Swallow your partner's cum" -> Receiving cum requires partner has penis, so in M+F this is female-oriented -> initiator: ['female']
|
||||
- text: "Lick my cum off your body" -> "my cum" implies initiator produces cum (has penis) -> initiator: ['male']
|
||||
- text: "Deep throat your partner" -> Requires partner has penis, so receiver-focused -> initiator: ['female'] for M+F
|
||||
- text: "Give your partner a massage" -> Truly gender-neutral -> initiator: null
|
||||
- text: "Let her ride you" / partner_text: "Ride your partner" -> "her" implies female partner -> initiator: ['male']
|
||||
- text: "Finger your partner" / partner_text: "Be fingered" -> Receiver needs vagina -> suggested_targets: exclude M+M, initiator: null (either can finger)
|
||||
|
||||
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation.
|
||||
Include ALL fields even if null. Include a "reason" string explaining your suggestions.
|
||||
|
||||
Structure:
|
||||
{
|
||||
"analysis": [
|
||||
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "suggested_initiator": ["male"], "reason": "Text implies male initiator with female partner" }
|
||||
]
|
||||
}
|
||||
|
||||
Questions:
|
||||
${JSON.stringify(simplifiedQuestions)}
|
||||
`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a content analyzer for a couples app. You understand human anatomy and sexual acts. Return valid JSON.'
|
||||
},
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.5,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.analysis || [];
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// =============================================================================
|
||||
// Text Analysis Functions
|
||||
// Analyze and suggest improvements for question text
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import type { TextAnalysis } from '../types';
|
||||
|
||||
/**
|
||||
* Analyze question text and suggest improvements
|
||||
*/
|
||||
export async function analyzeQuestionText(
|
||||
questions: { id: string; text: string; partner_text?: string | null }[],
|
||||
isExplicit: boolean = false
|
||||
): Promise<TextAnalysis[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const simplifiedQuestions = questions.map(q => ({
|
||||
id: q.id,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text,
|
||||
}));
|
||||
|
||||
const toneInstruction = isExplicit
|
||||
? 'TONE: This is an EXPLICIT pack with NUANCED language. Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms). BUT keep crude/specific terms when they ARE the activity: "Cum on your partner\'s tits" (cum is the act), "Use a cock ring" (that\'s the name), "Edge until they beg". The rule: crude terms for specific acts/objects, tasteful terms for general sex/oral. NEVER sanitize "cum" to "come" - they have different meanings.'
|
||||
: 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.';
|
||||
|
||||
// Use different examples based on explicit/non-explicit
|
||||
const symmetricExamples = isExplicit
|
||||
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together", "Have a threesome"'
|
||||
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"';
|
||||
|
||||
const asymmetricExamples = isExplicit
|
||||
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
|
||||
- GOOD: "Spank your partner" / "Be spanked by your partner"`
|
||||
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
|
||||
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
|
||||
|
||||
const prompt = `Analyze the following questions for a couples app and suggest improved text that follows our style guidelines.
|
||||
|
||||
${toneInstruction}
|
||||
|
||||
The app uses a swipe-based interface (Like/Dislike/Maybe).
|
||||
Cards should be "Proposals" relative to a specific action, NOT interview questions.
|
||||
|
||||
For each question, decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC action (one-way).
|
||||
|
||||
1. SYMMETRIC Activities (Single Card - partner_text should be null):
|
||||
- Use for shared experiences where both partners do the same thing together.
|
||||
- Phrasing: Direct action proposal.
|
||||
- ${symmetricExamples}
|
||||
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we..."
|
||||
|
||||
2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text):
|
||||
- Use for actions where one partner does something TO/FOR the other, or where roles differ.
|
||||
- text = what the INITIATOR does (their action/perspective)
|
||||
- partner_text = what the RECEIVER does/experiences (their action/perspective)
|
||||
- ${asymmetricExamples}
|
||||
- IMPORTANT: partner_text should describe the RECEIVER's experience clearly:
|
||||
- text: "Send your partner to the bathroom and follow a minute later"
|
||||
- partner_text: "Go to the bathroom and wait for your partner to join you" (NOT "Wait for your partner to follow you")
|
||||
- When initiator CAUSES a response (moan, cum, beg, etc.), partner_text should frame as ALLOWING/RECEIVING:
|
||||
- text: "Make your partner moan in public" -> partner_text: "Let your partner make you moan in public"
|
||||
- NOT: "Moan for your partner in public" (sounds like deliberate performance, not natural response)
|
||||
- BAD: "Would you like to...?" (Ambiguous who "me" is)
|
||||
|
||||
CRITICAL RULES:
|
||||
1. PRESERVE THE CORE ACTION - Do NOT change what the question is about. Only improve phrasing.
|
||||
2. Keep text SHORT and DIRECT - no explanatory parentheticals. Aim for 5-12 words.
|
||||
3. Remove wishy-washy language: "Would you want to...", "Have you ever...", "Do you think...", "...fantasy"
|
||||
4. Make it direct and actionable.
|
||||
5. For asymmetric cards, ensure text is initiator-focused and partner_text is receiver-focused.
|
||||
6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral.
|
||||
7. If a question is ALREADY well-phrased, do NOT include it in the results.
|
||||
8. NEVER add explanatory text in parentheses - keep the proposal clean and simple.
|
||||
9. NEVER MIX ANATOMICALLY INCOMPATIBLE ACTIVITIES - Do NOT combine male-specific and female-specific acts as alternatives in the same question.
|
||||
10. MAKE PARTNER_TEXT APPEALING - Don't just grammatically flip the text. Make the receiver feel excited.
|
||||
11. FLAG CLICHES for improvement - "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill" are overused
|
||||
|
||||
Return a JSON object with a "suggestions" array containing ONLY questions that need improvement.
|
||||
Each object should have:
|
||||
- id: The question ID
|
||||
- suggested_text: The improved text (short, direct, no parentheticals)
|
||||
- suggested_partner_text: The improved partner_text (or null if symmetric)
|
||||
- reason: Brief explanation of what was improved
|
||||
|
||||
Questions:
|
||||
${JSON.stringify(simplifiedQuestions)}`;
|
||||
|
||||
const systemMessage = isExplicit
|
||||
? 'You are an adult content editor for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Preserve original intent. Always respond with valid JSON only.'
|
||||
: 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemMessage
|
||||
},
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.5,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.suggestions || [];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// =============================================================================
|
||||
// OpenAI Client Initialization
|
||||
// Handles client creation, caching, and model selection
|
||||
// =============================================================================
|
||||
|
||||
import OpenAI from 'openai';
|
||||
import { getCachedAiConfig } from '@/hooks/useAiConfig';
|
||||
import type { ModelPurpose } from './types';
|
||||
|
||||
// Cache for the OpenAI instance to avoid recreating on every call
|
||||
let cachedOpenAI: OpenAI | null = null;
|
||||
let cachedApiKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Get or create an OpenAI client instance
|
||||
* Uses OpenRouter API by default, falls back to direct OpenAI
|
||||
*/
|
||||
export function getOpenAI(): OpenAI {
|
||||
// First, try to get API key from remote config
|
||||
const remoteConfig = getCachedAiConfig();
|
||||
const remoteKey = remoteConfig?.openrouter_api_key;
|
||||
|
||||
// Fall back to env vars if remote config not available
|
||||
const openRouterKey = remoteKey || import.meta.env.VITE_OPENROUTER_API_KEY;
|
||||
const openAIKey = import.meta.env.VITE_OPENAI_API_KEY;
|
||||
|
||||
// Check if we can reuse cached instance
|
||||
if (cachedOpenAI && cachedApiKey === openRouterKey) {
|
||||
return cachedOpenAI;
|
||||
}
|
||||
|
||||
if (openRouterKey) {
|
||||
cachedOpenAI = new OpenAI({
|
||||
apiKey: openRouterKey,
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
cachedApiKey = openRouterKey;
|
||||
return cachedOpenAI;
|
||||
}
|
||||
|
||||
if (!openAIKey) {
|
||||
throw new Error('OpenRouter API key not configured. Please configure it in AI Settings or set VITE_OPENROUTER_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
cachedOpenAI = new OpenAI({
|
||||
apiKey: openAIKey,
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
cachedApiKey = openAIKey;
|
||||
return cachedOpenAI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate model for a given purpose
|
||||
* Uses remote config > env vars > defaults
|
||||
*/
|
||||
export function getModel(purpose?: ModelPurpose): string {
|
||||
const remoteConfig = getCachedAiConfig();
|
||||
const defaultModel = 'gpt-4o-mini';
|
||||
|
||||
// Get fallback model (remote config default > env var > hardcoded default)
|
||||
const fallback = remoteConfig?.default_model || import.meta.env.VITE_AI_MODEL || defaultModel;
|
||||
|
||||
if (!purpose) return fallback;
|
||||
|
||||
switch (purpose) {
|
||||
case 'generate':
|
||||
return remoteConfig?.model_generate || import.meta.env.VITE_AI_MODEL_GENERATE || fallback;
|
||||
case 'fix':
|
||||
return remoteConfig?.model_fix || import.meta.env.VITE_AI_MODEL_FIX || fallback;
|
||||
case 'polish':
|
||||
return remoteConfig?.model_polish || import.meta.env.VITE_AI_MODEL_POLISH || fallback;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a short display name from a model identifier
|
||||
* e.g., "anthropic/claude-3.5-sonnet" -> "Claude 3.5 Sonnet"
|
||||
*/
|
||||
export function getShortModelName(model: string): string {
|
||||
const shortName = model.split('/').pop() || model;
|
||||
return shortName
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
.replace('Gpt ', 'GPT-')
|
||||
.replace('Claude ', 'Claude ')
|
||||
.replace('Gemini ', 'Gemini ')
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// =============================================================================
|
||||
// AI Configuration Constants
|
||||
// Council config, tone levels, intensity levels, and prompt guides
|
||||
// =============================================================================
|
||||
|
||||
import { getCachedAiConfig, preloadAiConfig, type AiConfig } from '@/hooks/useAiConfig';
|
||||
import type { CouncilConfig, CouncilGenerator, ToneLevel } from './types';
|
||||
|
||||
// =============================================================================
|
||||
// COUNCIL CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_GENERATORS: CouncilGenerator[] = [{ model: 'anthropic/claude-3.5-sonnet' }];
|
||||
|
||||
/**
|
||||
* Get the current council configuration from remote config or environment
|
||||
*/
|
||||
export function getCouncilConfig(): CouncilConfig {
|
||||
const remoteConfig = getCachedAiConfig();
|
||||
|
||||
if (remoteConfig) {
|
||||
// Use council_generators array if available, otherwise fall back to legacy single model
|
||||
let generators: CouncilGenerator[] = DEFAULT_GENERATORS;
|
||||
if (remoteConfig.council_generators && Array.isArray(remoteConfig.council_generators) && remoteConfig.council_generators.length > 0) {
|
||||
generators = remoteConfig.council_generators;
|
||||
} else if (remoteConfig.council_generator_model) {
|
||||
generators = [{ model: remoteConfig.council_generator_model }];
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: remoteConfig.council_enabled || false,
|
||||
generators,
|
||||
reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5',
|
||||
selectionMode: remoteConfig.council_selection_mode || 'whole_set',
|
||||
cherryPickEnsureIntensityDistribution: remoteConfig.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to env vars
|
||||
return {
|
||||
enabled: import.meta.env.VITE_COUNCIL_ENABLED === 'true',
|
||||
generators: [{ model: import.meta.env.VITE_COUNCIL_GENERATOR_MODEL || 'anthropic/claude-3.5-sonnet' }],
|
||||
reviewerModel: import.meta.env.VITE_COUNCIL_REVIEWER_MODEL || 'google/gemini-pro-1.5',
|
||||
selectionMode: 'whole_set',
|
||||
cherryPickEnsureIntensityDistribution: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize AI config by preloading from Supabase
|
||||
* Call this on app startup to ensure config is available
|
||||
*/
|
||||
export async function initializeAiConfig(): Promise<AiConfig | null> {
|
||||
return preloadAiConfig();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TONE LEVELS
|
||||
// Controls how adventurous/extreme activities are
|
||||
// =============================================================================
|
||||
|
||||
export const TONE_LEVELS = [
|
||||
{ level: 0, label: 'Everyday', description: 'Non-romantic activities - games, cooking, conversations' },
|
||||
{ level: 1, label: 'Sweet', description: 'Romantic but innocent - compliments, date nights, cuddles' },
|
||||
{ level: 2, label: 'Flirty', description: 'Playful teasing - flirty texts, light touching, innuendo' },
|
||||
{ level: 3, label: 'Intimate', description: 'Sensual but soft - massage, making out, showering together' },
|
||||
{ level: 4, label: 'Sexual', description: 'Standard sexual activities - oral, sex, toys' },
|
||||
{ level: 5, label: 'Wild', description: 'Extreme/kinky - public sex, creampies, BDSM, exhibitionism' },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
// INTENSITY LEVELS
|
||||
// For UI display - consistent with AI grading
|
||||
// =============================================================================
|
||||
|
||||
export const INTENSITY_LEVELS = [
|
||||
{ level: 1, label: 'Light', description: 'Talking, emotional sharing, compliments, non-sexual', color: 'bg-green-500' },
|
||||
{ level: 2, label: 'Mild', description: 'Hugging, cuddling, gentle kisses, flirty texts', color: 'bg-lime-500' },
|
||||
{ level: 3, label: 'Moderate', description: 'Making out, massage, sexy/suggestive photos', color: 'bg-yellow-500' },
|
||||
{ level: 4, label: 'Spicy', description: 'Oral, foreplay, nudes, explicit sexting', color: 'bg-orange-500' },
|
||||
{ level: 5, label: 'Intense', description: 'Sex, kinks, BDSM, explicit videos', color: 'bg-red-500' },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
// INTENSITY GRADING GUIDE
|
||||
// Full version for detailed AI prompts
|
||||
// =============================================================================
|
||||
|
||||
export const INTENSITY_GUIDE = `
|
||||
INTENSITY GRADING GUIDE - Use this to assign intensity levels consistently:
|
||||
|
||||
Level 1 - LIGHT (Emotional/Non-Sexual):
|
||||
- Talking, sharing feelings, compliments, emotional vulnerability
|
||||
- Non-physical activities: writing letters, planning dates, sharing dreams
|
||||
- Clean activities with no sexual undertones
|
||||
- Examples: "Share your biggest dream", "Write a love note", "Plan a surprise date"
|
||||
|
||||
Level 2 - MILD (Affectionate/Flirty):
|
||||
- Light physical affection: holding hands, hugging, cuddling
|
||||
- Gentle kisses, playing with hair, light touches
|
||||
- Flirty but non-sexual: dancing together, sleeping in each other's arms
|
||||
- Examples: "Give your partner a forehead kiss", "Cuddle while watching a movie", "Send a flirty text"
|
||||
|
||||
Level 3 - MODERATE (Sensual/Suggestive):
|
||||
- Making out, passionate kissing, intimate massage
|
||||
- Sensual touch over clothes, caressing, teasing
|
||||
- Suggestive photos (lingerie, shirtless), sexy selfies, teasing texts
|
||||
- Showering together, skinny dipping
|
||||
- Examples: "Give a sensual massage", "Send a sexy photo", "Make out somewhere risky"
|
||||
|
||||
Level 4 - SPICY (Sexual/Explicit Content):
|
||||
- Oral sex, fingering, handjobs, mutual masturbation
|
||||
- Foreplay activities, using hands/mouth on private parts
|
||||
- Nudes, explicit photos, dirty pics, sexting explicit content
|
||||
- Using toys externally, edging, making your partner cum
|
||||
- Examples: "Go down on your partner", "Send a nude", "Sext each other something filthy"
|
||||
|
||||
Level 5 - INTENSE (Penetration/Advanced Kinks):
|
||||
- Sex (vaginal or anal), toys inside, fingers inside
|
||||
- Kinks: bondage, role-play with power dynamics, BDSM
|
||||
- Threesomes, exhibitionism, voyeurism
|
||||
- Making/sharing explicit videos, masturbating on video call
|
||||
- Examples: "Fuck in a new location", "Try anal", "Record yourselves having sex"
|
||||
|
||||
IMPORTANT: Intensity is about SEXUAL CONTENT level, not just physical touch.
|
||||
Sending nudes = Level 4, Sexting explicit content = Level 4, Dirty/filthy photos = Level 4.
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// INTENSITY GUIDE (SHORT)
|
||||
// Condensed version for prompts to save tokens
|
||||
// =============================================================================
|
||||
|
||||
export const INTENSITY_GUIDE_SHORT = `
|
||||
INTENSITY LEVELS:
|
||||
1 (Light): Talking, emotional sharing, compliments, non-sexual activities
|
||||
2 (Mild): Holding hands, hugging, cuddling, gentle kisses, flirty texts
|
||||
3 (Moderate): Making out, sensual massage, sexy/suggestive photos, teasing
|
||||
4 (Spicy): Oral sex, fingering, handjobs, nudes, explicit photos/sexting, foreplay
|
||||
5 (Intense): Sex, anal, kinks, BDSM, explicit videos, threesomes
|
||||
|
||||
IMPORTANT: "Filthy photo", "nude", "dirty pic" = Level 4. "Sexy photo", "lingerie pic" = Level 3.
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// REVIEW GUIDELINES
|
||||
// Used by council reviewer for quality assessment
|
||||
// =============================================================================
|
||||
|
||||
export const REVIEW_GUIDELINES = `
|
||||
REVIEW CRITERIA - Score each 1-10:
|
||||
|
||||
1. GUIDELINE COMPLIANCE:
|
||||
- Uses "your partner" (not "me", "I", "you", "him", "her")
|
||||
- Card is a proposal, not a question ("Have dinner" not "Would you want to have dinner?")
|
||||
- No wishy-washy language ("Would you...", "Have you ever...", "Do you think...")
|
||||
- No time-specific words ("tonight", "now", "today", "right now") - activities should be timeless
|
||||
- Appropriate length (5-12 words ideal)
|
||||
|
||||
2. CREATIVITY:
|
||||
- Not cliche (avoid: candlelit dinner, rose petals, bubble bath, Netflix and chill)
|
||||
- Specific and engaging (not generic)
|
||||
- Good variety in sentence openers
|
||||
|
||||
3. CLARITY:
|
||||
- Clear, actionable proposal
|
||||
- Partner text (if present) clearly describes receiver's experience
|
||||
- No confusing or ambiguous phrasing
|
||||
|
||||
4. INTENSITY ACCURACY:
|
||||
Level 1 (Light): Talking, emotional sharing, compliments, non-sexual
|
||||
Level 2 (Mild): Holding hands, hugging, cuddling, gentle kisses, flirty texts
|
||||
Level 3 (Moderate): Making out, sensual massage, sexy/suggestive photos, teasing
|
||||
Level 4 (Spicy): Oral sex, fingering, handjobs, nudes, explicit photos/sexting
|
||||
Level 5 (Intense): Sex, anal, kinks, BDSM, explicit videos, threesomes
|
||||
|
||||
5. ANATOMICAL CONSISTENCY:
|
||||
- No mixed male/female anatomy in alternatives
|
||||
- BAD: "Finger or give your partner a handjob"
|
||||
- GOOD: Pick one activity
|
||||
|
||||
6. PARTNER TEXT QUALITY (if applicable):
|
||||
- Engaging and enticing, not clinical
|
||||
- BAD: "Receive oral from your partner"
|
||||
- GOOD: "Let your partner pleasure you with their mouth"
|
||||
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// TONE INSTRUCTIONS
|
||||
// Per-tone-level prompts for question generation
|
||||
// =============================================================================
|
||||
|
||||
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
|
||||
0: 'WILDNESS LEVEL: EVERYDAY. Generate non-romantic activities only. Focus on communication, teamwork, fun, personal growth, and bonding WITHOUT any romantic or sexual undertones. Examples: "Cook a new recipe together", "Play a board game", "Send your partner a compliment while they\'re at work", "Plan a weekend adventure". NO flirting, NO romance, NO intimacy.',
|
||||
1: 'WILDNESS LEVEL: SWEET. Generate romantic but innocent activities. Focus on emotional connection, love, and tenderness. Examples: "Write your partner a love letter", "Plan a surprise date night", "Cuddle and watch the sunset", "Give your partner a back massage". NO sexual content.',
|
||||
2: 'WILDNESS LEVEL: FLIRTY. Generate playful, teasing activities with light innuendo. Examples: "Send a flirty text during the day", "Whisper something suggestive in your partner\'s ear", "Give your partner a lingering kiss goodbye". Suggestive but NOT explicitly sexual.',
|
||||
3: 'WILDNESS LEVEL: INTIMATE. Generate sensual activities that imply intimacy without being graphic. Examples: "Give your partner a full-body massage", "Shower together", "Make out somewhere unexpected", "Sleep naked together". Passionate but soft - no explicit sex acts.',
|
||||
4: 'WILDNESS LEVEL: SEXUAL. Generate standard sexual activities. Examples: "Go down on your partner", "Have sex in a new position", "Use a vibrator together", "Wake your partner up with oral". Direct language, avoid clinical terms. This is vanilla sex - adventurous but not extreme.',
|
||||
5: 'WILDNESS LEVEL: WILD. Generate extreme, kinky, or taboo activities. Think: public sex, exhibitionism, BDSM, risky situations, intense kinks. Examples: "Get creampied by your partner and go to dinner", "Have sex somewhere you might get caught", "Let your partner tie you up and use you", "Edge each other for an hour before allowing release". Push boundaries. Use crude terms when they ARE the activity (cum, cock ring) but tasteful phrasing for general acts (have sex, not fuck). NEVER sanitize "cum" to "come".',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SYSTEM MESSAGES
|
||||
// Per-tone-level system prompts for question generation
|
||||
// =============================================================================
|
||||
|
||||
export const SYSTEM_MESSAGES: Record<ToneLevel, string> = {
|
||||
0: 'You are a content writer for a couples app. Generate everyday activities focused on bonding, communication, and fun. No romance or intimacy. Always respond with valid JSON only.',
|
||||
1: 'You are a content writer for a couples app. Generate sweet, romantic activities that help couples connect emotionally. Keep it innocent and wholesome. Always respond with valid JSON only.',
|
||||
2: 'You are a content writer for a couples app. Generate flirty, playful activities with light teasing and innuendo. Suggestive but not sexual. Always respond with valid JSON only.',
|
||||
3: 'You are a content writer for a couples app. Generate intimate, sensual activities that imply passion without being explicit. Always respond with valid JSON only.',
|
||||
4: 'You are a content writer for an adult couples app. Generate sexual activities with direct, natural language. Write like a sex-positive friend - not clinical. Always respond with valid JSON only.',
|
||||
5: 'You are a content writer for an adult couples app. Generate wild, extreme, kinky activities that push boundaries. Use crude terms when they ARE the act but tasteful phrasing for general sex/oral. Never use clinical terms. Always respond with valid JSON only.',
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
// =============================================================================
|
||||
// Category Generation Functions
|
||||
// Generate category names and category ideas
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import type { GeneratedCategoryIdea } from '../types';
|
||||
|
||||
/**
|
||||
* Generate a single category
|
||||
*/
|
||||
export async function generateCategory(): Promise<{ name: string; description: string; icon: string }> {
|
||||
const openai = getOpenAI();
|
||||
const prompt = `Generate a unique category for organizing activity packs in a couples' intimacy/connection app.
|
||||
|
||||
This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
|
||||
Each partner independently swipes Like/Dislike/Maybe, and when both swipe positively they "match".
|
||||
Categories organize collections of activity packs (date ideas, intimate experiences, adventures, challenges, etc.).
|
||||
This is NOT a Q&A app - it's about discovering shared interests in activities.
|
||||
|
||||
Return a JSON object with:
|
||||
- name: Category name (1-3 words, e.g., "Romance", "Adventure", "Date Nights")
|
||||
- description: Brief description of what activity packs in this category contain (1 sentence)
|
||||
- icon: A single emoji that represents this category
|
||||
|
||||
Be creative and think of categories that help couples explore different types of activities together.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a creative content organizer for a couples activity/intimacy app. The app helps couples discover shared interests by swiping on activity proposals. Generate engaging category ideas for organizing activity packs. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest multiple category ideas
|
||||
*/
|
||||
export async function suggestCategories(
|
||||
existingCategories: string[],
|
||||
explicit: boolean,
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string,
|
||||
previousSuggestions?: string[]
|
||||
): Promise<GeneratedCategoryIdea[]> {
|
||||
const openai = getOpenAI();
|
||||
const existingList = existingCategories.length > 0
|
||||
? existingCategories.join(', ')
|
||||
: 'None';
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Include bold, spicy, and explicitly intimate categories (NSFW is allowed).'
|
||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||
|
||||
const crudeLangInstruction = crudeLang
|
||||
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the names and descriptions.'
|
||||
: '';
|
||||
|
||||
const inspirationInstruction = inspiration
|
||||
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your category suggestions.`
|
||||
: '';
|
||||
|
||||
const avoidInstruction = previousSuggestions && previousSuggestions.length > 0
|
||||
? `\n\nIMPORTANT - DO NOT suggest any of these category names (already suggested): ${previousSuggestions.join(', ')}\nGenerate completely different and fresh ideas.`
|
||||
: '';
|
||||
|
||||
const prompt = `Here are the current categories in our couples' activity/intimacy app: ${existingList}.
|
||||
|
||||
IMPORTANT - HOW THE APP WORKS:
|
||||
- This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals
|
||||
- Each partner independently swipes Like/Dislike/Maybe on activities
|
||||
- When BOTH partners swipe positively, they "match" and can discuss the activity
|
||||
- Categories organize collections of activity packs (date ideas, intimate experiences, adventures, etc.)
|
||||
- This is NOT a Q&A app - it's about discovering shared interests in activities to do together
|
||||
|
||||
Suggest 5 NEW, UNIQUE category ideas that differ from the existing ones.
|
||||
${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
|
||||
|
||||
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
|
||||
- name: Category name (1-3 words)
|
||||
- description: Brief description of what activity packs in this category contain (1 sentence)
|
||||
- icon: A single descriptive emoji
|
||||
|
||||
Focus on diverse activity themes like: date ideas, adventures, intimate experiences, bonding activities, challenges, travel, home activities, etc.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a creative content strategist for a couples activity/intimacy app. The app helps couples discover shared interests by swiping on activity proposals. Generate engaging, diverse category ideas for organizing activity packs. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.ideas as GeneratedCategoryIdea[];
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// =============================================================================
|
||||
// Cherry-Pick Mode Functions
|
||||
// Pool questions from multiple generators and select the best individuals
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI } from '../client';
|
||||
import { getCouncilConfig, REVIEW_GUIDELINES, TONE_LEVELS } from '../config';
|
||||
import type {
|
||||
GeneratedQuestion,
|
||||
QuestionReview,
|
||||
ReviewResult,
|
||||
ToneLevel,
|
||||
GenerationCandidate,
|
||||
PooledQuestion,
|
||||
CherryPickEvaluation,
|
||||
CherryPickResult,
|
||||
CouncilConfig,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Evaluate all questions in the pool and identify duplicates
|
||||
*/
|
||||
export async function evaluateQuestionPool(
|
||||
pooledQuestions: PooledQuestion[],
|
||||
packContext: { name: string; description?: string | null; isExplicit: boolean; tone: ToneLevel },
|
||||
requestedCount: number
|
||||
): Promise<CherryPickEvaluation[]> {
|
||||
const openai = getOpenAI();
|
||||
const config = getCouncilConfig();
|
||||
|
||||
const toneDescription = TONE_LEVELS.find(t => t.level === packContext.tone)?.label || 'Unknown';
|
||||
|
||||
const questionsForEval = pooledQuestions.map((q, i) => ({
|
||||
index: i,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text || null,
|
||||
intensity: q.intensity,
|
||||
generatorIndex: q.sourceGeneratorIndex,
|
||||
}));
|
||||
|
||||
const prompt = `You are evaluating a pool of ${pooledQuestions.length} questions from ${new Set(pooledQuestions.map(q => q.sourceGeneratorIndex)).size} different AI generators.
|
||||
|
||||
Your task is to:
|
||||
1. Score each question individually (1-10 on each criterion)
|
||||
2. Identify duplicates/semantically similar questions
|
||||
3. Assess uniqueness relative to the pool
|
||||
4. Consider pack theme fit
|
||||
|
||||
PACK CONTEXT:
|
||||
- Pack Name: "${packContext.name}"
|
||||
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
||||
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
|
||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||
- Requested question count: ${requestedCount}
|
||||
|
||||
${REVIEW_GUIDELINES}
|
||||
|
||||
ADDITIONAL CRITERIA FOR CHERRY-PICK:
|
||||
|
||||
5. UNIQUENESS (1-10):
|
||||
- How unique is this question compared to others in the pool?
|
||||
- 10: Completely unique concept
|
||||
- 7-9: Somewhat similar theme but distinct execution
|
||||
- 4-6: Similar to another question but different enough to keep
|
||||
- 1-3: Near-duplicate of another question
|
||||
|
||||
DUPLICATE DETECTION:
|
||||
Two questions are duplicates if they:
|
||||
- Describe the same core activity/action
|
||||
- Differ only in minor phrasing (e.g., "Have sex outdoors" vs "Make love outside")
|
||||
- Would feel repetitive if both appeared in the same pack
|
||||
|
||||
For duplicates, keep the version with:
|
||||
1. Better phrasing/creativity
|
||||
2. More appropriate intensity grading
|
||||
3. Better partner_text (if applicable)
|
||||
|
||||
VERDICT RULES:
|
||||
- SELECT: High quality (avg score >= 7), no major issues - should be included
|
||||
- CONSIDER: Medium quality (avg score 5-7) - include if needed to fill count
|
||||
- SKIP: Low quality (avg score < 5) OR is a duplicate of a better question
|
||||
|
||||
QUESTIONS TO EVALUATE:
|
||||
${JSON.stringify(questionsForEval, null, 2)}
|
||||
|
||||
Return a JSON object with:
|
||||
{
|
||||
"evaluations": [
|
||||
{
|
||||
"questionIndex": 0,
|
||||
"overallScore": 82,
|
||||
"scores": {
|
||||
"guidelineCompliance": 8,
|
||||
"creativity": 9,
|
||||
"clarity": 8,
|
||||
"intensityAccuracy": 8,
|
||||
"uniqueness": 9
|
||||
},
|
||||
"isDuplicate": false,
|
||||
"duplicateOf": null,
|
||||
"issues": ["Minor issue 1"],
|
||||
"verdict": "select"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Evaluate ALL ${pooledQuestions.length} questions.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: config.reviewerModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a quality reviewer for a couples intimacy app. You evaluate and compare questions from multiple AI generators to select the best ones. Be thorough and fair. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No evaluation content generated');
|
||||
|
||||
// Strip markdown code blocks if present
|
||||
let jsonContent = content.trim();
|
||||
if (jsonContent.startsWith('```')) {
|
||||
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonContent);
|
||||
return parsed.evaluations || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select top N questions from evaluations, optionally balancing intensity levels
|
||||
*/
|
||||
export function selectTopQuestions(
|
||||
pooledQuestions: PooledQuestion[],
|
||||
evaluations: CherryPickEvaluation[],
|
||||
requestedCount: number,
|
||||
options: {
|
||||
ensureIntensityDistribution: boolean;
|
||||
requestedIntensity?: number;
|
||||
}
|
||||
): CherryPickResult {
|
||||
// Step 1: Filter out duplicates (keep only the original, not the duplicate)
|
||||
const duplicateIndices = new Set<number>();
|
||||
for (const evaluation of evaluations) {
|
||||
if (evaluation.isDuplicate && evaluation.duplicateOf !== undefined) {
|
||||
duplicateIndices.add(evaluation.questionIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create scored list of non-duplicate questions
|
||||
const scoredQuestions = evaluations
|
||||
.filter(e => !duplicateIndices.has(e.questionIndex))
|
||||
.map(e => ({
|
||||
evaluation: e,
|
||||
question: pooledQuestions[e.questionIndex],
|
||||
// Weighted overall score
|
||||
weightedScore: (
|
||||
(e.scores.guidelineCompliance * 0.25) +
|
||||
(e.scores.creativity * 0.20) +
|
||||
(e.scores.clarity * 0.20) +
|
||||
(e.scores.intensityAccuracy * 0.15) +
|
||||
(e.scores.uniqueness * 0.20)
|
||||
) * 10,
|
||||
}))
|
||||
.sort((a, b) => b.weightedScore - a.weightedScore);
|
||||
|
||||
let selected: typeof scoredQuestions = [];
|
||||
|
||||
// Step 3: Select questions
|
||||
if (options.requestedIntensity) {
|
||||
// If specific intensity requested, just take top N of that intensity
|
||||
const matchingIntensity = scoredQuestions.filter(
|
||||
sq => sq.question.intensity === options.requestedIntensity
|
||||
);
|
||||
selected = matchingIntensity.slice(0, requestedCount);
|
||||
} else if (options.ensureIntensityDistribution) {
|
||||
// Balance across intensity levels
|
||||
const perIntensity = Math.ceil(requestedCount / 5);
|
||||
const byIntensity: Record<number, typeof scoredQuestions> = { 1: [], 2: [], 3: [], 4: [], 5: [] };
|
||||
|
||||
for (const sq of scoredQuestions) {
|
||||
const intensity = sq.question.intensity;
|
||||
if (intensity >= 1 && intensity <= 5) {
|
||||
byIntensity[intensity].push(sq);
|
||||
}
|
||||
}
|
||||
|
||||
// Take top N from each intensity level
|
||||
for (let intensity = 1; intensity <= 5; intensity++) {
|
||||
const toTake = byIntensity[intensity].slice(0, perIntensity);
|
||||
selected.push(...toTake);
|
||||
}
|
||||
|
||||
// If we still need more, fill from remaining top-scored questions
|
||||
if (selected.length < requestedCount) {
|
||||
const selectedIndices = new Set(selected.map(s => s.evaluation.questionIndex));
|
||||
const remaining = scoredQuestions.filter(
|
||||
sq => !selectedIndices.has(sq.evaluation.questionIndex)
|
||||
);
|
||||
selected.push(...remaining.slice(0, requestedCount - selected.length));
|
||||
}
|
||||
|
||||
// Sort final selection by score
|
||||
selected.sort((a, b) => b.weightedScore - a.weightedScore);
|
||||
selected = selected.slice(0, requestedCount);
|
||||
} else {
|
||||
// Simple top N by score
|
||||
selected = scoredQuestions.slice(0, requestedCount);
|
||||
}
|
||||
|
||||
// Step 4: Build result
|
||||
const selectedQuestions = selected.map(s => s.question);
|
||||
|
||||
// Calculate intensity distribution
|
||||
const intensityDistribution: Record<number, number> = {};
|
||||
for (const q of selectedQuestions) {
|
||||
intensityDistribution[q.intensity] = (intensityDistribution[q.intensity] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
selectedQuestions,
|
||||
evaluations,
|
||||
poolSize: pooledQuestions.length,
|
||||
duplicatesRemoved: duplicateIndices.size,
|
||||
intensityDistribution,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform cherry-pick selection: pool all questions, evaluate, and select best
|
||||
*/
|
||||
export async function performCherryPickSelection(
|
||||
candidates: GenerationCandidate[],
|
||||
packContext: { name: string; description?: string | null; isExplicit: boolean; tone: ToneLevel },
|
||||
requestedCount: number,
|
||||
config: CouncilConfig
|
||||
): Promise<{
|
||||
questions: GeneratedQuestion[];
|
||||
reviews: QuestionReview[];
|
||||
summary: ReviewResult['summary'];
|
||||
cherryPickResult: CherryPickResult;
|
||||
}> {
|
||||
// Step 1: Pool all questions with provenance
|
||||
const pooledQuestions: PooledQuestion[] = [];
|
||||
for (const candidate of candidates) {
|
||||
for (let qi = 0; qi < candidate.questions.length; qi++) {
|
||||
pooledQuestions.push({
|
||||
...candidate.questions[qi],
|
||||
sourceGeneratorIndex: candidate.generatorIndex,
|
||||
sourceGeneratorModel: candidate.generatorModel,
|
||||
sourceQuestionIndex: qi,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Evaluate entire pool
|
||||
const evaluations = await evaluateQuestionPool(pooledQuestions, packContext, requestedCount);
|
||||
|
||||
// Step 3: Select top questions
|
||||
const cherryPickResult = selectTopQuestions(pooledQuestions, evaluations, requestedCount, {
|
||||
ensureIntensityDistribution: config.cherryPickEnsureIntensityDistribution,
|
||||
requestedIntensity: undefined,
|
||||
});
|
||||
|
||||
// Step 4: Convert to standard review format for UI compatibility
|
||||
const reviews: QuestionReview[] = cherryPickResult.selectedQuestions.map((q, i) => {
|
||||
// Find the evaluation for this question
|
||||
const poolIndex = pooledQuestions.findIndex(
|
||||
pq => pq.text === q.text && pq.sourceGeneratorIndex === q.sourceGeneratorIndex
|
||||
);
|
||||
const evaluation = evaluations.find(e => e.questionIndex === poolIndex);
|
||||
|
||||
if (!evaluation) {
|
||||
return {
|
||||
index: i,
|
||||
verdict: 'pass' as const,
|
||||
issues: [],
|
||||
scores: { guidelineCompliance: 7, creativity: 7, clarity: 7, intensityAccuracy: 7 },
|
||||
};
|
||||
}
|
||||
|
||||
// Map cherry-pick verdict to standard verdict
|
||||
const verdict: 'pass' | 'flag' | 'reject' =
|
||||
evaluation.verdict === 'select' ? 'pass' :
|
||||
evaluation.verdict === 'consider' ? 'flag' : 'reject';
|
||||
|
||||
return {
|
||||
index: i,
|
||||
verdict,
|
||||
issues: evaluation.issues,
|
||||
scores: {
|
||||
guidelineCompliance: evaluation.scores.guidelineCompliance,
|
||||
creativity: evaluation.scores.creativity,
|
||||
clarity: evaluation.scores.clarity,
|
||||
intensityAccuracy: evaluation.scores.intensityAccuracy,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Step 5: Calculate summary
|
||||
const passed = reviews.filter(r => r.verdict === 'pass').length;
|
||||
const flagged = reviews.filter(r => r.verdict === 'flag').length;
|
||||
const rejected = reviews.filter(r => r.verdict === 'reject').length;
|
||||
|
||||
let totalScore = 0;
|
||||
let scoreCount = 0;
|
||||
for (const review of reviews) {
|
||||
if (review.scores) {
|
||||
totalScore += review.scores.guidelineCompliance || 0;
|
||||
totalScore += review.scores.creativity || 0;
|
||||
totalScore += review.scores.clarity || 0;
|
||||
totalScore += review.scores.intensityAccuracy || 0;
|
||||
scoreCount += 4;
|
||||
}
|
||||
}
|
||||
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
|
||||
|
||||
return {
|
||||
questions: cherryPickResult.selectedQuestions,
|
||||
reviews,
|
||||
summary: { passed, flagged, rejected, overallQuality },
|
||||
cherryPickResult,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// =============================================================================
|
||||
// Council Generation
|
||||
// Multi-model generation with review and selection
|
||||
// =============================================================================
|
||||
|
||||
import { getModel, getShortModelName } from '../client';
|
||||
import { getCouncilConfig } from '../config';
|
||||
import { generateQuestions, generateQuestionsWithModel } from './questions';
|
||||
import { selectBestGeneration } from '../review';
|
||||
import { performCherryPickSelection } from './cherry-pick';
|
||||
import type {
|
||||
ToneLevel,
|
||||
GenerationProgress,
|
||||
ProgressCallback,
|
||||
GeneratorStatus,
|
||||
GenerationCandidate,
|
||||
CouncilGenerationResult,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Generate questions using the council system
|
||||
* Runs multiple generators in parallel and uses a reviewer to select the best output
|
||||
*/
|
||||
export async function generateQuestionsWithCouncil(
|
||||
packName: string,
|
||||
count: number = 10,
|
||||
intensity?: number,
|
||||
tone: ToneLevel = 3,
|
||||
packDescription?: string,
|
||||
existingQuestions?: string[],
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<CouncilGenerationResult> {
|
||||
const config = getCouncilConfig();
|
||||
|
||||
// Helper to build progress update
|
||||
const buildProgress = (
|
||||
phase: GenerationProgress['phase'],
|
||||
generatorStatuses: GeneratorStatus[],
|
||||
generatorResults: (GenerationCandidate | null)[],
|
||||
reviewerStatus?: 'pending' | 'reviewing' | 'completed'
|
||||
): GenerationProgress => {
|
||||
const completedCount = generatorStatuses.filter(s => s === 'completed').length;
|
||||
const totalCount = config.generators.length;
|
||||
|
||||
let message = '';
|
||||
if (phase === 'generating') {
|
||||
if (completedCount === 0) {
|
||||
message = `Generating with ${totalCount} model${totalCount > 1 ? 's' : ''}...`;
|
||||
} else if (completedCount < totalCount) {
|
||||
message = `${completedCount}/${totalCount} generators complete...`;
|
||||
} else {
|
||||
message = 'All generators complete';
|
||||
}
|
||||
} else if (phase === 'reviewing') {
|
||||
message = 'Reviewing and selecting best questions...';
|
||||
} else {
|
||||
message = 'Complete';
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
generators: config.generators.map((gen, i) => ({
|
||||
model: gen.model,
|
||||
shortName: getShortModelName(gen.model),
|
||||
status: generatorStatuses[i],
|
||||
questionCount: generatorResults[i]?.questions.length,
|
||||
timeMs: generatorResults[i]?.generationTime,
|
||||
})),
|
||||
reviewer: config.enabled ? {
|
||||
model: config.reviewerModel,
|
||||
shortName: getShortModelName(config.reviewerModel),
|
||||
status: reviewerStatus || 'pending',
|
||||
} : undefined,
|
||||
message,
|
||||
};
|
||||
};
|
||||
|
||||
// If council is disabled, use the default generation model
|
||||
if (!config.enabled) {
|
||||
const defaultModel = getModel('generate');
|
||||
onProgress?.({
|
||||
phase: 'generating',
|
||||
generators: [{ model: defaultModel, shortName: getShortModelName(defaultModel), status: 'generating' }],
|
||||
message: 'Generating questions...',
|
||||
});
|
||||
|
||||
const startGen = Date.now();
|
||||
const questions = await generateQuestions(
|
||||
packName,
|
||||
count,
|
||||
intensity,
|
||||
tone,
|
||||
packDescription,
|
||||
existingQuestions,
|
||||
crudeLang,
|
||||
inspiration
|
||||
);
|
||||
const genTime = Date.now() - startGen;
|
||||
|
||||
onProgress?.({
|
||||
phase: 'complete',
|
||||
generators: [{ model: defaultModel, shortName: getShortModelName(defaultModel), status: 'completed', questionCount: questions.length, timeMs: genTime }],
|
||||
message: 'Complete',
|
||||
});
|
||||
|
||||
return {
|
||||
questions,
|
||||
reviews: [],
|
||||
summary: null,
|
||||
selectedGeneratorIndex: null,
|
||||
allCandidates: null,
|
||||
selectionMode: 'whole_set',
|
||||
metadata: {
|
||||
generatorModels: [getModel('generate')],
|
||||
reviewerModel: null,
|
||||
totalGenerationTime: genTime,
|
||||
reviewTime: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize tracking for parallel generation
|
||||
const generatorStatuses: GeneratorStatus[] = config.generators.map(() => 'pending');
|
||||
const generatorResults: (GenerationCandidate | null)[] = config.generators.map(() => null);
|
||||
|
||||
// Initial progress - all generators starting
|
||||
generatorStatuses.forEach((_, i) => { generatorStatuses[i] = 'generating'; });
|
||||
onProgress?.(buildProgress('generating', generatorStatuses, generatorResults));
|
||||
|
||||
// Step 1: Generate questions with all configured generators in parallel
|
||||
const startGen = Date.now();
|
||||
const generatorPromises = config.generators.map(async (gen, index) => {
|
||||
const genStart = Date.now();
|
||||
try {
|
||||
const questions = await generateQuestionsWithModel(
|
||||
gen.model,
|
||||
packName,
|
||||
count,
|
||||
intensity,
|
||||
tone,
|
||||
packDescription,
|
||||
existingQuestions,
|
||||
crudeLang,
|
||||
inspiration
|
||||
);
|
||||
const result = {
|
||||
generatorIndex: index,
|
||||
generatorModel: gen.model,
|
||||
questions,
|
||||
generationTime: Date.now() - genStart,
|
||||
} as GenerationCandidate;
|
||||
|
||||
// Update status and notify
|
||||
generatorStatuses[index] = 'completed';
|
||||
generatorResults[index] = result;
|
||||
onProgress?.(buildProgress('generating', generatorStatuses, generatorResults));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Generator ${index} (${gen.model}) failed:`, error);
|
||||
generatorStatuses[index] = 'failed';
|
||||
onProgress?.(buildProgress('generating', generatorStatuses, generatorResults));
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(generatorPromises);
|
||||
const successfulCandidates = results.filter((r): r is GenerationCandidate => r !== null);
|
||||
const totalGenerationTime = Date.now() - startGen;
|
||||
|
||||
// If no generators succeeded, throw error
|
||||
if (successfulCandidates.length === 0) {
|
||||
throw new Error('All generators failed to produce results');
|
||||
}
|
||||
|
||||
// Step 2: Selection based on mode
|
||||
const startReview = Date.now();
|
||||
const isExplicit = tone >= 4;
|
||||
const packContext = { name: packName, description: packDescription, isExplicit, tone };
|
||||
|
||||
// Notify that we're entering review phase
|
||||
onProgress?.(buildProgress('reviewing', generatorStatuses, generatorResults, 'reviewing'));
|
||||
|
||||
// Branch based on selection mode
|
||||
if (config.selectionMode === 'cherry_pick') {
|
||||
// Cherry-pick mode: pool all questions and select best individuals
|
||||
try {
|
||||
const cherryPickResult = await performCherryPickSelection(
|
||||
successfulCandidates,
|
||||
packContext,
|
||||
count,
|
||||
config
|
||||
);
|
||||
const reviewTime = Date.now() - startReview;
|
||||
|
||||
// Calculate total pooled questions
|
||||
const pooledCount = successfulCandidates.reduce((sum, c) => sum + c.questions.length, 0);
|
||||
|
||||
// Notify completion
|
||||
onProgress?.(buildProgress('complete', generatorStatuses, generatorResults, 'completed'));
|
||||
|
||||
return {
|
||||
questions: cherryPickResult.questions,
|
||||
reviews: cherryPickResult.reviews,
|
||||
summary: cherryPickResult.summary,
|
||||
selectedGeneratorIndex: null, // Not applicable for cherry-pick
|
||||
allCandidates: successfulCandidates,
|
||||
cherryPickResult: cherryPickResult.cherryPickResult,
|
||||
selectionMode: 'cherry_pick',
|
||||
metadata: {
|
||||
generatorModels: config.generators.map(g => g.model),
|
||||
reviewerModel: config.reviewerModel,
|
||||
totalGenerationTime,
|
||||
reviewTime,
|
||||
pooledQuestionCount: pooledCount,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If cherry-pick fails, fall back to first generation
|
||||
console.error('Cherry-pick selection failed:', error);
|
||||
const firstCandidate = successfulCandidates[0];
|
||||
|
||||
onProgress?.(buildProgress('complete', generatorStatuses, generatorResults, 'completed'));
|
||||
|
||||
return {
|
||||
questions: firstCandidate.questions,
|
||||
reviews: [],
|
||||
summary: null,
|
||||
selectedGeneratorIndex: 0,
|
||||
allCandidates: successfulCandidates,
|
||||
selectionMode: 'cherry_pick',
|
||||
metadata: {
|
||||
generatorModels: config.generators.map(g => g.model),
|
||||
reviewerModel: null,
|
||||
totalGenerationTime,
|
||||
reviewTime: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Whole-set mode: select best complete set from one generator
|
||||
try {
|
||||
const selectionResult = await selectBestGeneration(successfulCandidates, packContext);
|
||||
const reviewTime = Date.now() - startReview;
|
||||
|
||||
const selectedCandidate = successfulCandidates[selectionResult.selectedIndex];
|
||||
|
||||
// Notify completion
|
||||
onProgress?.(buildProgress('complete', generatorStatuses, generatorResults, 'completed'));
|
||||
|
||||
return {
|
||||
questions: selectedCandidate.questions,
|
||||
reviews: selectionResult.reviews,
|
||||
summary: selectionResult.summary,
|
||||
selectedGeneratorIndex: selectionResult.selectedIndex,
|
||||
allCandidates: successfulCandidates,
|
||||
selectionMode: 'whole_set',
|
||||
metadata: {
|
||||
generatorModels: config.generators.map(g => g.model),
|
||||
reviewerModel: config.reviewerModel,
|
||||
totalGenerationTime,
|
||||
reviewTime,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If selection/review fails, return the first successful generation without review
|
||||
console.error('Council selection/review failed:', error);
|
||||
const firstCandidate = successfulCandidates[0];
|
||||
|
||||
onProgress?.(buildProgress('complete', generatorStatuses, generatorResults, 'completed'));
|
||||
|
||||
return {
|
||||
questions: firstCandidate.questions,
|
||||
reviews: [],
|
||||
summary: null,
|
||||
selectedGeneratorIndex: 0,
|
||||
allCandidates: successfulCandidates,
|
||||
selectionMode: 'whole_set',
|
||||
metadata: {
|
||||
generatorModels: config.generators.map(g => g.model),
|
||||
reviewerModel: null,
|
||||
totalGenerationTime,
|
||||
reviewTime: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// =============================================================================
|
||||
// Pack Generation Functions
|
||||
// Generate pack names, descriptions, and pack ideas
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import type { GeneratedPack, GeneratedPackIdea } from '../types';
|
||||
|
||||
/**
|
||||
* Generate a single pack name and description
|
||||
*/
|
||||
export async function generatePack(
|
||||
categoryName?: string,
|
||||
explicit: boolean = false,
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string,
|
||||
previousNames?: string[]
|
||||
): Promise<GeneratedPack> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Include bold, spicy, and uncensored themes (NSFW is allowed).'
|
||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||
|
||||
const crudeLangInstruction = crudeLang
|
||||
? 'CRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms throughout - "fuck", "cock", "pussy" etc.'
|
||||
: '';
|
||||
|
||||
const inspirationInstruction = inspiration
|
||||
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your generation.`
|
||||
: '';
|
||||
|
||||
const avoidInstruction = previousNames && previousNames.length > 0
|
||||
? `\n\nIMPORTANT - DO NOT generate any of these pack names (already suggested): ${previousNames.join(', ')}\nGenerate something completely different and fresh.`
|
||||
: '';
|
||||
|
||||
const appContext = `This is an ACTIVITY-FOCUSED app where couples swipe on activity proposals.
|
||||
Each partner independently swipes Like/Dislike/Maybe, and when both swipe positively they "match".
|
||||
Packs contain collections of activities to try together (date ideas, intimate experiences, adventures, challenges, etc.).
|
||||
This is NOT a Q&A app - it's about discovering shared interests in activities.`;
|
||||
|
||||
const prompt = categoryName
|
||||
? `Generate a creative activity pack for a couples' intimacy/connection app in the category "${categoryName}".
|
||||
|
||||
${appContext}
|
||||
|
||||
The pack should contain a themed collection of activities couples can explore together.
|
||||
|
||||
${explicitInstruction}
|
||||
${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
|
||||
|
||||
Return a JSON object with:
|
||||
- name: A catchy, engaging pack name (3-6 words) that evokes activities/experiences
|
||||
- description: A brief, enticing description (1-2 sentences) focusing on the activities in the pack
|
||||
|
||||
Make it romantic, playful, and activity-focused.`
|
||||
: `Generate a creative activity pack for a couples' intimacy/connection app.
|
||||
|
||||
${appContext}
|
||||
|
||||
The pack should contain a themed collection of activities couples can explore together.
|
||||
|
||||
${explicitInstruction}
|
||||
${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
|
||||
|
||||
Return a JSON object with:
|
||||
- name: A catchy, engaging pack name (3-6 words) that evokes activities/experiences
|
||||
- description: A brief, enticing description (1-2 sentences) focusing on the activities in the pack
|
||||
|
||||
Make it romantic, playful, and activity-focused.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a creative content writer for a couples activity/intimacy app. The app helps couples discover shared interests by swiping on activity proposals. Generate engaging pack ideas that contain collections of activities couples can do together. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.8,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
return JSON.parse(content) as GeneratedPack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate pack ideas for a category
|
||||
*/
|
||||
export async function suggestPacks(
|
||||
categoryName: string,
|
||||
existingPacks: string[],
|
||||
explicit: boolean,
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string,
|
||||
previousSuggestions?: string[]
|
||||
): Promise<GeneratedPackIdea[]> {
|
||||
const openai = getOpenAI();
|
||||
const existingList = existingPacks.length > 0
|
||||
? existingPacks.join(', ')
|
||||
: 'None';
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Include bold, spicy, and uncensored pack ideas (NSFW is allowed).'
|
||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||
|
||||
const crudeLangInstruction = crudeLang
|
||||
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the pack names and descriptions.'
|
||||
: '';
|
||||
|
||||
const inspirationInstruction = inspiration
|
||||
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide your pack suggestions.`
|
||||
: '';
|
||||
|
||||
const avoidInstruction = previousSuggestions && previousSuggestions.length > 0
|
||||
? `\n\nIMPORTANT - DO NOT suggest any of these pack names (already suggested): ${previousSuggestions.join(', ')}\nGenerate completely different and fresh ideas.`
|
||||
: '';
|
||||
|
||||
const prompt = `We are building activity packs for the category "${categoryName}" in a couples' intimacy app.
|
||||
|
||||
IMPORTANT - HOW THE APP WORKS:
|
||||
- This is an ACTIVITY-FOCUSED app, NOT a Q&A app
|
||||
- Packs contain ACTIVITY PROPOSALS (things couples can do together)
|
||||
- Each partner independently swipes Like/Dislike/Maybe on activities
|
||||
- When BOTH partners swipe positively on the same activity, they "match" and can discuss it
|
||||
- Activities are things like: date ideas, intimate experiences, adventures, challenges, conversations to have, etc.
|
||||
- This is NOT about asking each other questions - it's about discovering shared interests in activities
|
||||
|
||||
PACK THEMES should focus on:
|
||||
- Collections of related activities couples might want to try
|
||||
- Experiences to share together
|
||||
- Things to do, not questions to ask
|
||||
- Date ideas, adventures, intimate moments, challenges, bonding activities
|
||||
|
||||
Existing packs in this category: ${existingList}.
|
||||
|
||||
Suggest 5 NEW, UNIQUE pack ideas that fit this category and differ from existing ones.
|
||||
${explicitInstruction}${crudeLangInstruction}${inspirationInstruction}${avoidInstruction}
|
||||
|
||||
Return a JSON object with an "ideas" array containing 5 objects, where each object has:
|
||||
- name: Pack name (catchy, 3-6 words) - should evoke activities/experiences, not questions
|
||||
- description: Brief description (1-2 sentences) focusing on the activities/experiences in the pack
|
||||
- icon: A single descriptive emoji
|
||||
|
||||
Make them engaging and specific to "${categoryName}".`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a creative content strategist for a couples activity/intimacy app. The app helps couples discover shared interests by swiping on activity proposals. Generate engaging pack ideas that contain collections of activities couples can do together. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.ideas as GeneratedPackIdea[];
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// =============================================================================
|
||||
// Question Generation Functions
|
||||
// Generate questions for packs with various tone/intensity settings
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import { INTENSITY_GUIDE_SHORT, TONE_INSTRUCTIONS, SYSTEM_MESSAGES } from '../config';
|
||||
import type { GeneratedQuestion, ToneLevel } from '../types';
|
||||
|
||||
/**
|
||||
* Build the question generation prompt
|
||||
*/
|
||||
function buildQuestionPrompt(
|
||||
packName: string,
|
||||
count: number,
|
||||
intensity: number | undefined,
|
||||
tone: ToneLevel,
|
||||
packDescription?: string,
|
||||
existingQuestions?: string[],
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string
|
||||
): string {
|
||||
const isClean = tone === 0;
|
||||
const isExplicit = tone >= 4;
|
||||
|
||||
const crudeLangInstruction = crudeLang
|
||||
? '\n\nCRUDE LANGUAGE OVERRIDE: Ignore nuanced language rules. Use crude, vulgar terms throughout - "fuck" instead of "have sex", "suck cock" instead of "perform oral", etc. Be raw and direct like uncensored sexting.'
|
||||
: '';
|
||||
|
||||
const inspirationInstruction = inspiration
|
||||
? `\n\nINSPIRATION/GUIDANCE FROM ADMIN:\n${inspiration}\n\nUse the above inspiration to guide the types of questions you generate.`
|
||||
: '';
|
||||
|
||||
const intensityInstruction = isClean
|
||||
? 'All questions should be intensity level 1 (non-physical activities).'
|
||||
: intensity
|
||||
? `All questions should be at intensity level ${intensity}.`
|
||||
: 'Vary the intensity levels from 1 to 5 across the questions for good variety.';
|
||||
|
||||
const toneInstruction = TONE_INSTRUCTIONS[tone];
|
||||
|
||||
// Examples based on tone level
|
||||
const symmetricExamples = isClean
|
||||
? 'GOOD: "Cook a new recipe together", "Play a board game", "Go on a hike", "Have a deep conversation about your goals".'
|
||||
: isExplicit
|
||||
? 'GOOD: "Fuck in a risky place", "Try a new position", "Sixty-nine together", "Watch porn and recreate a scene".'
|
||||
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share dreams", "Give each other massages", "Dance together at home".';
|
||||
|
||||
const asymmetricExamples = isClean
|
||||
? `Examples:
|
||||
- text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal"
|
||||
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"
|
||||
- text: "Give your partner a compliment" → partner_text: "Receive a compliment from your partner"`
|
||||
: isExplicit
|
||||
? `Examples:
|
||||
- text: "Go down on your partner" → partner_text: "Have your partner go down on you"
|
||||
- text: "Send your partner a nude" → partner_text: "Receive a nude from your partner"
|
||||
- text: "Tie your partner up" → partner_text: "Get tied up by your partner"
|
||||
- text: "Spank your partner" → partner_text: "Get spanked by your partner"`
|
||||
: `Examples:
|
||||
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
|
||||
- text: "Write your partner a love letter" → partner_text: "Receive a love letter from your partner"
|
||||
- text: "Plan a surprise date for your partner" → partner_text: "Be surprised with a date by your partner"`;
|
||||
|
||||
const explicitWarning = isClean
|
||||
? '\n\nCRITICAL: This is a CLEAN pack with NO romantic or sexual content. Focus ONLY on communication, activities, challenges, and bonding. NO romance, NO flirting, NO intimacy, NO physical affection beyond friendly gestures.'
|
||||
: isExplicit
|
||||
? ''
|
||||
: '\n\nCRITICAL: This is a NON-EXPLICIT pack. Do NOT include any sexual acts, crude language, or NSFW content. Keep all proposals romantic, playful, or emotionally intimate without being sexually explicit.';
|
||||
|
||||
const descriptionContext = packDescription
|
||||
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
|
||||
: '';
|
||||
|
||||
const existingQuestionsContext = existingQuestions && existingQuestions.length > 0
|
||||
? `\n\nEXISTING QUESTIONS IN THIS PACK (DO NOT DUPLICATE OR CREATE SIMILAR VARIATIONS):
|
||||
${existingQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
|
||||
|
||||
CRITICAL: Generate completely NEW and DIFFERENT questions. Do not repeat any of the above questions or create minor variations of them. Each new question must offer a genuinely distinct activity or experience.`
|
||||
: '';
|
||||
|
||||
return `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".${descriptionContext}${existingQuestionsContext}
|
||||
|
||||
${INTENSITY_GUIDE_SHORT}
|
||||
|
||||
${intensityInstruction}
|
||||
${toneInstruction}${explicitWarning}${crudeLangInstruction}${inspirationInstruction}
|
||||
|
||||
IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe).
|
||||
Cards should be "Proposals" for specific actions, NOT interview questions.
|
||||
|
||||
CRITICAL LANGUAGE RULES:
|
||||
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as the receiver), "him", "her"
|
||||
- BAD: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
|
||||
- GOOD: "Send your partner a photo", "Spank your partner", "Give your partner..."
|
||||
- The card reader is the DOER. Their partner is "your partner".
|
||||
|
||||
For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to the other):
|
||||
|
||||
1. SYMMETRIC Activities (partner_text = null):
|
||||
- Both partners do the same thing together.
|
||||
- ${symmetricExamples}
|
||||
|
||||
2. ASYMMETRIC Actions (requires partner_text):
|
||||
- One partner does something TO/FOR the other, or roles differ.
|
||||
- text = what the INITIATOR does
|
||||
- partner_text = what the RECEIVER does/experiences
|
||||
- ${asymmetricExamples}
|
||||
|
||||
Required JSON structure:
|
||||
- text: The proposal (DOER's perspective, uses "your partner")
|
||||
- partner_text: For asymmetric only - RECEIVER's perspective
|
||||
- intensity: 1-5 based on INTENSITY LEVELS above.
|
||||
- requires_props: (optional) Array of items needed
|
||||
- location_type: (optional) "home" | "public" | "outdoors" | "travel" | "anywhere"
|
||||
- effort_level: (optional) "spontaneous" | "low" | "medium" | "planned"
|
||||
|
||||
Return a JSON object with a "questions" array.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate questions using the default generation model
|
||||
*/
|
||||
export async function generateQuestions(
|
||||
packName: string,
|
||||
count: number = 10,
|
||||
intensity?: number,
|
||||
tone: ToneLevel = 3,
|
||||
packDescription?: string,
|
||||
existingQuestions?: string[],
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string
|
||||
): Promise<GeneratedQuestion[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const prompt = buildQuestionPrompt(
|
||||
packName,
|
||||
count,
|
||||
intensity,
|
||||
tone,
|
||||
packDescription,
|
||||
existingQuestions,
|
||||
crudeLang,
|
||||
inspiration
|
||||
);
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('generate'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: SYSTEM_MESSAGES[tone],
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.questions as GeneratedQuestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate questions using a specific model
|
||||
* Used by council generation for parallel model comparison
|
||||
*/
|
||||
export async function generateQuestionsWithModel(
|
||||
model: string,
|
||||
packName: string,
|
||||
count: number,
|
||||
intensity: number | undefined,
|
||||
tone: ToneLevel,
|
||||
packDescription: string | undefined,
|
||||
existingQuestions: string[] | undefined,
|
||||
crudeLang: boolean,
|
||||
inspiration: string | undefined
|
||||
): Promise<GeneratedQuestion[]> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const prompt = buildQuestionPrompt(
|
||||
packName,
|
||||
count,
|
||||
intensity,
|
||||
tone,
|
||||
packDescription,
|
||||
existingQuestions,
|
||||
crudeLang,
|
||||
inspiration
|
||||
);
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: SYSTEM_MESSAGES[tone],
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.9,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.questions as GeneratedQuestion[];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// =============================================================================
|
||||
// AI Module - Main Entry Point
|
||||
// Re-exports all public APIs for backwards compatibility
|
||||
// =============================================================================
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ModelPurpose,
|
||||
CouncilGenerator,
|
||||
CouncilSelectionMode,
|
||||
CouncilConfig,
|
||||
GeneratedPack,
|
||||
GeneratedQuestion,
|
||||
GeneratedCategoryIdea,
|
||||
GeneratedPackIdea,
|
||||
QuestionReview,
|
||||
ReviewResult,
|
||||
GenerationCandidate,
|
||||
CouncilGenerationResult,
|
||||
GeneratorStatus,
|
||||
GenerationProgress,
|
||||
ProgressCallback,
|
||||
PooledQuestion,
|
||||
CherryPickEvaluation,
|
||||
CherryPickResult,
|
||||
TargetAnalysis,
|
||||
TextAnalysis,
|
||||
ExtractedTopic,
|
||||
TopicExtractionResult,
|
||||
SelectionResult,
|
||||
ToneLevel,
|
||||
} from './types';
|
||||
|
||||
// Client
|
||||
export { getOpenAI, getModel, getShortModelName } from './client';
|
||||
|
||||
// Config
|
||||
export {
|
||||
getCouncilConfig,
|
||||
initializeAiConfig,
|
||||
TONE_LEVELS,
|
||||
INTENSITY_LEVELS,
|
||||
INTENSITY_GUIDE,
|
||||
INTENSITY_GUIDE_SHORT,
|
||||
REVIEW_GUIDELINES,
|
||||
TONE_INSTRUCTIONS,
|
||||
SYSTEM_MESSAGES,
|
||||
} from './config';
|
||||
|
||||
// Generators
|
||||
export { generatePack, suggestPacks } from './generators/packs';
|
||||
export { generateCategory, suggestCategories } from './generators/categories';
|
||||
export { generateQuestions, generateQuestionsWithModel } from './generators/questions';
|
||||
export { generateQuestionsWithCouncil } from './generators/council';
|
||||
|
||||
// Review
|
||||
export { reviewGeneratedQuestions, selectBestGeneration } from './review';
|
||||
|
||||
// Analyzers
|
||||
export { analyzeQuestionText } from './analyzers/text';
|
||||
export { analyzeQuestionTargets } from './analyzers/targets';
|
||||
|
||||
// Tools
|
||||
export { polishContent } from './tools/polish';
|
||||
export { extractTopicsFromPack } from './tools/topics';
|
||||
@@ -0,0 +1,276 @@
|
||||
// =============================================================================
|
||||
// Question Review Functions
|
||||
// Council reviewer for quality assessment of generated questions
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI } from './client';
|
||||
import { getCouncilConfig, REVIEW_GUIDELINES, TONE_LEVELS } from './config';
|
||||
import type {
|
||||
GeneratedQuestion,
|
||||
QuestionReview,
|
||||
ReviewResult,
|
||||
ToneLevel,
|
||||
GenerationCandidate,
|
||||
SelectionResult,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Review generated questions for quality
|
||||
*/
|
||||
export async function reviewGeneratedQuestions(
|
||||
questions: GeneratedQuestion[],
|
||||
packContext: { name: string; description?: string | null; isExplicit: boolean; tone: ToneLevel }
|
||||
): Promise<ReviewResult> {
|
||||
const openai = getOpenAI();
|
||||
const config = getCouncilConfig();
|
||||
|
||||
// Null safety check
|
||||
if (!questions || questions.length === 0) {
|
||||
return {
|
||||
reviews: [],
|
||||
summary: { passed: 0, flagged: 0, rejected: 0, overallQuality: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const questionsForReview = questions.map((q, i) => ({
|
||||
index: i,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text || null,
|
||||
intensity: q.intensity,
|
||||
location_type: q.location_type,
|
||||
effort_level: q.effort_level,
|
||||
}));
|
||||
|
||||
const toneDescription = TONE_LEVELS.find(t => t.level === packContext.tone)?.label || 'Unknown';
|
||||
|
||||
const prompt = `You are a quality reviewer for a couples intimacy app. Review each generated question against our guidelines.
|
||||
|
||||
PACK CONTEXT:
|
||||
- Pack Name: "${packContext.name}"
|
||||
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
||||
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
|
||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||
|
||||
${REVIEW_GUIDELINES}
|
||||
|
||||
VERDICT RULES:
|
||||
- PASS: All scores >= 7, no major issues
|
||||
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
|
||||
- REJECT: Any score < 5, or major violations (mixed anatomy, wrong intensity, severe guideline violation)
|
||||
|
||||
QUESTIONS TO REVIEW:
|
||||
${JSON.stringify(questionsForReview, null, 2)}
|
||||
|
||||
Return a JSON object with:
|
||||
{
|
||||
"reviews": [
|
||||
{
|
||||
"index": 0,
|
||||
"verdict": "pass" | "flag" | "reject",
|
||||
"issues": ["Issue 1", "Issue 2"],
|
||||
"suggestions": "Optional improvement suggestion",
|
||||
"scores": {
|
||||
"guidelineCompliance": 8,
|
||||
"creativity": 7,
|
||||
"clarity": 9,
|
||||
"intensityAccuracy": 8
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Review ALL questions. Be thorough but fair - only flag/reject for genuine issues.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: config.reviewerModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a quality assurance reviewer for a couples intimacy app. You evaluate generated questions for guideline compliance, creativity, clarity, and accuracy. Be thorough but fair. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No review content generated');
|
||||
|
||||
// Strip markdown code blocks if present (some models ignore response_format)
|
||||
let jsonContent = content.trim();
|
||||
if (jsonContent.startsWith('```')) {
|
||||
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonContent);
|
||||
const reviews: QuestionReview[] = parsed.reviews || [];
|
||||
|
||||
// Calculate summary
|
||||
const passed = reviews.filter(r => r.verdict === 'pass').length;
|
||||
const flagged = reviews.filter(r => r.verdict === 'flag').length;
|
||||
const rejected = reviews.filter(r => r.verdict === 'reject').length;
|
||||
|
||||
// Calculate overall quality as average of all scores
|
||||
let totalScore = 0;
|
||||
let scoreCount = 0;
|
||||
for (const review of reviews) {
|
||||
if (review.scores) {
|
||||
totalScore += review.scores.guidelineCompliance || 0;
|
||||
totalScore += review.scores.creativity || 0;
|
||||
totalScore += review.scores.clarity || 0;
|
||||
totalScore += review.scores.intensityAccuracy || 0;
|
||||
scoreCount += 4;
|
||||
}
|
||||
}
|
||||
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
|
||||
|
||||
return {
|
||||
reviews,
|
||||
summary: {
|
||||
passed,
|
||||
flagged,
|
||||
rejected,
|
||||
overallQuality,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the reviewer to select the best generation from multiple candidates
|
||||
*/
|
||||
export async function selectBestGeneration(
|
||||
candidates: GenerationCandidate[],
|
||||
packContext: { name: string; description?: string | null; isExplicit: boolean; tone: ToneLevel }
|
||||
): Promise<SelectionResult> {
|
||||
const openai = getOpenAI();
|
||||
const config = getCouncilConfig();
|
||||
|
||||
// If only one candidate, just review it normally
|
||||
if (candidates.length === 1) {
|
||||
const reviewResult = await reviewGeneratedQuestions(candidates[0].questions, packContext);
|
||||
return {
|
||||
selectedIndex: 0,
|
||||
reviews: reviewResult.reviews,
|
||||
summary: reviewResult.summary,
|
||||
reasoning: 'Single generator - no selection needed',
|
||||
};
|
||||
}
|
||||
|
||||
const toneDescription = TONE_LEVELS.find(t => t.level === packContext.tone)?.label || 'Unknown';
|
||||
|
||||
// Build prompt for selection
|
||||
const candidatesForReview = candidates.map((c, i) => ({
|
||||
generatorIndex: i,
|
||||
generatorModel: c.generatorModel,
|
||||
questions: c.questions.map((q, qi) => ({
|
||||
index: qi,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text || null,
|
||||
intensity: q.intensity,
|
||||
})),
|
||||
}));
|
||||
|
||||
const prompt = `You are a quality reviewer for a couples intimacy app. You have received ${candidates.length} different sets of generated questions from different AI models. Your task is to:
|
||||
|
||||
1. COMPARE all sets and SELECT the BEST one overall
|
||||
2. REVIEW the selected set in detail
|
||||
|
||||
PACK CONTEXT:
|
||||
- Pack Name: "${packContext.name}"
|
||||
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
||||
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
|
||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||
|
||||
${REVIEW_GUIDELINES}
|
||||
|
||||
CANDIDATE SETS:
|
||||
${JSON.stringify(candidatesForReview, null, 2)}
|
||||
|
||||
SELECTION CRITERIA (in order of importance):
|
||||
1. Overall quality and creativity of questions
|
||||
2. Variety and uniqueness within the set
|
||||
3. Adherence to pack theme and tone
|
||||
4. Proper intensity grading
|
||||
5. Correct use of "your partner" language
|
||||
6. Quality of partner_text for asymmetric questions
|
||||
|
||||
Return a JSON object with:
|
||||
{
|
||||
"selectedIndex": <0-based index of the best candidate set>,
|
||||
"reasoning": "<brief explanation of why this set was chosen>",
|
||||
"reviews": [
|
||||
{
|
||||
"index": <question index within selected set>,
|
||||
"verdict": "pass" | "flag" | "reject",
|
||||
"issues": ["Issue 1", "Issue 2"],
|
||||
"suggestions": "Optional improvement suggestion",
|
||||
"scores": {
|
||||
"guidelineCompliance": 1-10,
|
||||
"creativity": 1-10,
|
||||
"clarity": 1-10,
|
||||
"intensityAccuracy": 1-10
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Select the best set and review ALL questions in that set.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: config.reviewerModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a quality assurance reviewer for a couples intimacy app. You compare multiple AI-generated question sets and select the best one, then provide detailed reviews. Be thorough but fair. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No selection content generated');
|
||||
|
||||
// Strip markdown code blocks if present
|
||||
let jsonContent = content.trim();
|
||||
if (jsonContent.startsWith('```')) {
|
||||
jsonContent = jsonContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonContent);
|
||||
const selectedIndex = parsed.selectedIndex ?? 0;
|
||||
const reviews: QuestionReview[] = parsed.reviews || [];
|
||||
const reasoning = parsed.reasoning || 'No reasoning provided';
|
||||
|
||||
// Calculate summary
|
||||
const passed = reviews.filter(r => r.verdict === 'pass').length;
|
||||
const flagged = reviews.filter(r => r.verdict === 'flag').length;
|
||||
const rejected = reviews.filter(r => r.verdict === 'reject').length;
|
||||
|
||||
let totalScore = 0;
|
||||
let scoreCount = 0;
|
||||
for (const review of reviews) {
|
||||
if (review.scores) {
|
||||
totalScore += review.scores.guidelineCompliance || 0;
|
||||
totalScore += review.scores.creativity || 0;
|
||||
totalScore += review.scores.clarity || 0;
|
||||
totalScore += review.scores.intensityAccuracy || 0;
|
||||
scoreCount += 4;
|
||||
}
|
||||
}
|
||||
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
reviews,
|
||||
summary: {
|
||||
passed,
|
||||
flagged,
|
||||
rejected,
|
||||
overallQuality,
|
||||
},
|
||||
reasoning,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// =============================================================================
|
||||
// Content Polish Functions
|
||||
// Polish and improve content text
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
|
||||
type PolishType = 'question' | 'partner_text' | 'pack_name' | 'pack_description' | 'category_name';
|
||||
|
||||
/**
|
||||
* Polish and improve content text
|
||||
*/
|
||||
export async function polishContent(
|
||||
text: string,
|
||||
type: PolishType,
|
||||
explicit: boolean = false
|
||||
): Promise<string> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const explicitInstruction = explicit
|
||||
? 'Use nuanced language: tasteful phrasing for general acts ("have sex", "perform oral") but crude/specific terms when relevant to the activity ("cum on", "cock ring", "edge"). Do not over-sanitize - keep explicit terms that are essential to the activity. NEVER change "cum" to "come" - they have different meanings.'
|
||||
: 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.';
|
||||
|
||||
const contextMap: Record<PolishType, string> = {
|
||||
question: 'a question for a couples app',
|
||||
partner_text: 'the partner-facing text for a two-part question',
|
||||
pack_name: 'a title for a question pack',
|
||||
pack_description: 'a description for a question pack',
|
||||
category_name: 'a category name',
|
||||
};
|
||||
|
||||
let additionalRules = '';
|
||||
let fewShotExamples = '';
|
||||
if (type === 'question' || type === 'partner_text') {
|
||||
additionalRules = `
|
||||
IMPORTANT RULES - READ CAREFULLY:
|
||||
1. STYLE: Cards should be "Proposals" relative to a specific action, rather than interview questions.
|
||||
- GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage".
|
||||
- BAD: "Do you want to have...", "Have you ever thought about...".
|
||||
2. ACCURACY: Do NOT rewrite the text effectively changing the action.
|
||||
- "Want a candlelit dinner?" -> "Have a candlelit dinner with your partner"
|
||||
- DO NOT change it to something unrelated like "Go to the movies".
|
||||
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL.
|
||||
3. TONE: Engaging, romantic, or spicy (depending on context), but direct.
|
||||
4. ANATOMICAL CONSISTENCY: NEVER combine male-specific and female-specific acts as alternatives.
|
||||
- BAD: "Finger or give your partner a handjob" (requires different anatomy)
|
||||
- BAD: "Suck their cock or eat them out" (male vs female anatomy)
|
||||
- If text has mixed anatomy alternatives, keep ONE activity and remove the incompatible one.
|
||||
`;
|
||||
fewShotExamples = `
|
||||
=== FEW-SHOT EXAMPLES ===
|
||||
${type === 'question' ? `
|
||||
"Would you want to give me a massage?" -> "Give your partner a sensual massage"
|
||||
"Have you ever thought about sex in public?" -> "Have sex somewhere you might get caught"
|
||||
"maybe we could try using a blindfold sometime" -> "Blindfold your partner and tease their senses"
|
||||
"cum on partner" -> "Cum on your partner" (keep "cum", don't change to "come")
|
||||
` : `
|
||||
"Receive a massage from your partner" -> "Let your partner's hands work the tension from your body"
|
||||
"Get oral from your partner" -> "Let your partner pleasure you with their mouth"
|
||||
"Be tied up" -> "Let your partner tie you up and surrender control"
|
||||
"Moan for your partner" -> "Let your partner make you moan"
|
||||
`}
|
||||
=== END EXAMPLES ===
|
||||
`;
|
||||
}
|
||||
|
||||
const prompt = `Please polish, improve, and tidy up the following text, which is used as ${contextMap[type] || 'text in the app'}.
|
||||
|
||||
Original text: "${text}"
|
||||
|
||||
${explicitInstruction}
|
||||
|
||||
${additionalRules}
|
||||
${fewShotExamples}
|
||||
|
||||
Make it concise, engaging, and grammatically correct.
|
||||
Maintain the original intent and meaning.
|
||||
|
||||
Return a JSON object with:
|
||||
- polished: The improved text string`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('polish'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a professional editor for a couples app. Improve the copy while maintaining the tone. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.polished;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// =============================================================================
|
||||
// Topic Extraction Functions
|
||||
// Extract topics from packs for categorization
|
||||
// =============================================================================
|
||||
|
||||
import { getOpenAI, getModel } from '../client';
|
||||
import type { TopicExtractionResult } from '../types';
|
||||
|
||||
/**
|
||||
* Extract topics from a pack based on its name, description, and questions
|
||||
*/
|
||||
export async function extractTopicsFromPack(
|
||||
packName: string,
|
||||
packDescription: string | null,
|
||||
questions: string[],
|
||||
existingTopics: { id: string; name: string }[]
|
||||
): Promise<TopicExtractionResult> {
|
||||
const openai = getOpenAI();
|
||||
|
||||
const existingTopicNames = existingTopics.map(t => t.name);
|
||||
const existingTopicsJson = JSON.stringify(existingTopics);
|
||||
|
||||
const prompt = `Analyze the following question pack and extract relevant topics/kinks/interests that describe its content.
|
||||
|
||||
PACK NAME: "${packName}"
|
||||
${packDescription ? `PACK DESCRIPTION: "${packDescription}"` : ''}
|
||||
|
||||
QUESTIONS IN THIS PACK:
|
||||
${questions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
|
||||
|
||||
EXISTING TOPICS IN THE SYSTEM (prefer matching these when appropriate):
|
||||
${existingTopicNames.length > 0 ? existingTopicNames.join(', ') : 'None yet'}
|
||||
|
||||
EXISTING TOPICS WITH IDs:
|
||||
${existingTopicsJson}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Identify 1-5 topics that best describe this pack's content
|
||||
2. Topics should be specific kinks, interests, or themes (e.g., "Bondage", "Exhibitionism", "Voyeurism", "Role Play", "Sensory Play", "Oral", "Anal", "Communication", "Romance", "Adventure")
|
||||
3. ALWAYS prefer matching an existing topic if one fits - use the EXACT name and include the existingTopicId
|
||||
4. Only suggest a new topic if nothing in the existing list is appropriate
|
||||
5. Topic names should be title case (e.g., "Bondage" not "bondage")
|
||||
6. Keep topics broad enough to be reusable across packs
|
||||
|
||||
Return a JSON object with:
|
||||
- topics: Array of objects with:
|
||||
- name: The topic name (use exact existing name if matching)
|
||||
- isNew: false if matching an existing topic, true if suggesting a new one
|
||||
- existingTopicId: The ID of the existing topic if isNew is false (from the EXISTING TOPICS WITH IDs list)
|
||||
- reasoning: Brief explanation of why these topics were chosen`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: getModel('fix'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a content categorization expert for a couples intimacy app. You analyze question packs and identify relevant topics, kinks, and themes. Always prefer matching existing topics when possible. Always respond with valid JSON only.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('No content generated');
|
||||
|
||||
return JSON.parse(content) as TopicExtractionResult;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// =============================================================================
|
||||
// AI Module Types
|
||||
// All shared interfaces and type definitions
|
||||
// =============================================================================
|
||||
|
||||
// Model purpose for selecting appropriate model
|
||||
export type ModelPurpose = 'generate' | 'fix' | 'polish';
|
||||
|
||||
// =============================================================================
|
||||
// COUNCIL CONFIGURATION TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface CouncilGenerator {
|
||||
model: string;
|
||||
}
|
||||
|
||||
export type CouncilSelectionMode = 'whole_set' | 'cherry_pick';
|
||||
|
||||
export interface CouncilConfig {
|
||||
enabled: boolean;
|
||||
generators: CouncilGenerator[];
|
||||
reviewerModel: string;
|
||||
selectionMode: CouncilSelectionMode;
|
||||
cherryPickEnsureIntensityDistribution: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATION TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface GeneratedPack {
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedQuestion {
|
||||
text: string;
|
||||
partner_text?: string;
|
||||
intensity: number;
|
||||
requires_props?: string[] | null;
|
||||
location_type?: 'home' | 'public' | 'outdoors' | 'travel' | 'anywhere';
|
||||
effort_level?: 'spontaneous' | 'low' | 'medium' | 'planned';
|
||||
// Cherry-pick provenance (only set in cherry-pick mode)
|
||||
sourceGeneratorIndex?: number;
|
||||
sourceGeneratorModel?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedCategoryIdea {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface GeneratedPackIdea {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REVIEW TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface QuestionReview {
|
||||
index: number;
|
||||
verdict: 'pass' | 'flag' | 'reject';
|
||||
issues: string[];
|
||||
suggestions?: string;
|
||||
scores: {
|
||||
guidelineCompliance: number;
|
||||
creativity: number;
|
||||
clarity: number;
|
||||
intensityAccuracy: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
reviews: QuestionReview[];
|
||||
summary: {
|
||||
passed: number;
|
||||
flagged: number;
|
||||
rejected: number;
|
||||
overallQuality: number;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COUNCIL GENERATION TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface GenerationCandidate {
|
||||
generatorIndex: number;
|
||||
generatorModel: string;
|
||||
questions: GeneratedQuestion[];
|
||||
generationTime: number;
|
||||
}
|
||||
|
||||
export interface CouncilGenerationResult {
|
||||
questions: GeneratedQuestion[];
|
||||
reviews: QuestionReview[];
|
||||
summary: ReviewResult['summary'] | null;
|
||||
selectedGeneratorIndex: number | null;
|
||||
allCandidates: GenerationCandidate[] | null;
|
||||
cherryPickResult?: CherryPickResult | null;
|
||||
selectionMode: CouncilSelectionMode;
|
||||
metadata: {
|
||||
generatorModels: string[];
|
||||
reviewerModel: string | null;
|
||||
totalGenerationTime: number;
|
||||
reviewTime: number;
|
||||
pooledQuestionCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRESS CALLBACK TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type GeneratorStatus = 'pending' | 'generating' | 'completed' | 'failed';
|
||||
|
||||
export interface GenerationProgress {
|
||||
phase: 'generating' | 'reviewing' | 'complete';
|
||||
generators: {
|
||||
model: string;
|
||||
shortName: string;
|
||||
status: GeneratorStatus;
|
||||
questionCount?: number;
|
||||
timeMs?: number;
|
||||
}[];
|
||||
reviewer?: {
|
||||
model: string;
|
||||
shortName: string;
|
||||
status: 'pending' | 'reviewing' | 'completed';
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (progress: GenerationProgress) => void;
|
||||
|
||||
// =============================================================================
|
||||
// CHERRY-PICK MODE TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface PooledQuestion extends GeneratedQuestion {
|
||||
sourceGeneratorIndex: number;
|
||||
sourceGeneratorModel: string;
|
||||
sourceQuestionIndex: number;
|
||||
}
|
||||
|
||||
export interface CherryPickEvaluation {
|
||||
questionIndex: number;
|
||||
overallScore: number;
|
||||
scores: {
|
||||
guidelineCompliance: number;
|
||||
creativity: number;
|
||||
clarity: number;
|
||||
intensityAccuracy: number;
|
||||
uniqueness: number;
|
||||
};
|
||||
isDuplicate: boolean;
|
||||
duplicateOf?: number;
|
||||
issues: string[];
|
||||
verdict: 'select' | 'consider' | 'skip';
|
||||
}
|
||||
|
||||
export interface CherryPickResult {
|
||||
selectedQuestions: PooledQuestion[];
|
||||
evaluations: CherryPickEvaluation[];
|
||||
poolSize: number;
|
||||
duplicatesRemoved: number;
|
||||
intensityDistribution: Record<number, number>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANALYSIS TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface TargetAnalysis {
|
||||
id: string;
|
||||
suggested_targets: string[] | null;
|
||||
suggested_initiator: string[] | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface TextAnalysis {
|
||||
id: string;
|
||||
suggested_text: string;
|
||||
suggested_partner_text: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ExtractedTopic {
|
||||
name: string;
|
||||
isNew: boolean;
|
||||
existingTopicId?: string;
|
||||
}
|
||||
|
||||
export interface TopicExtractionResult {
|
||||
topics: ExtractedTopic[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SELECTION TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface SelectionResult {
|
||||
selectedIndex: number;
|
||||
reviews: QuestionReview[];
|
||||
summary: ReviewResult['summary'];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TONE LEVEL TYPE
|
||||
// =============================================================================
|
||||
|
||||
export type ToneLevel = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
+14
-1804
File diff suppressed because it is too large.
Load diff
@@ -9,26 +9,10 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Save, Eye, EyeOff, AlertTriangle, Bot, Users, Sparkles, RefreshCw, Plus, Trash2 } from 'lucide-react';
|
||||
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// Popular OpenRouter models for suggestions
|
||||
const POPULAR_MODELS = {
|
||||
generation: [
|
||||
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
|
||||
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
|
||||
{ id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' },
|
||||
{ id: 'google/gemini-pro-1.5', name: 'Gemini Pro 1.5' },
|
||||
{ id: 'meta-llama/llama-3.1-405b-instruct', name: 'Llama 3.1 405B' },
|
||||
],
|
||||
review: [
|
||||
{ id: 'google/gemini-pro-1.5', name: 'Gemini Pro 1.5' },
|
||||
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
|
||||
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
|
||||
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus' },
|
||||
],
|
||||
};
|
||||
import { ModelCombobox } from '@/components/ModelCombobox';
|
||||
|
||||
export function AiSettingsPage() {
|
||||
const { hasPermission, isSuperAdmin } = useAuth();
|
||||
@@ -61,6 +45,8 @@ export function AiSettingsPage() {
|
||||
council_generator_model: config.council_generator_model || '',
|
||||
council_generators: generators,
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -104,6 +90,8 @@ export function AiSettingsPage() {
|
||||
council_generator_model: config.council_generator_model || '',
|
||||
council_generators: generators,
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -274,12 +262,10 @@ export function AiSettingsPage() {
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-model">Default Model</Label>
|
||||
<Input
|
||||
id="default-model"
|
||||
<ModelCombobox
|
||||
value={formData.default_model || ''}
|
||||
onChange={(e) => handleChange('default_model', e.target.value)}
|
||||
onChange={(value) => handleChange('default_model', value)}
|
||||
placeholder="openai/gpt-4o-mini"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fallback model when no specific model is configured
|
||||
@@ -288,122 +274,68 @@ export function AiSettingsPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-generate">Generation Model</Label>
|
||||
<Input
|
||||
id="model-generate"
|
||||
value={formData.model_generate || ''}
|
||||
onChange={(e) => handleChange('model_generate', e.target.value)}
|
||||
placeholder="Uses default"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For generating questions & content
|
||||
</p>
|
||||
{/* Generation Section - changes based on council mode */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Question Generation</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formData.council_enabled
|
||||
? 'Multiple models generate in parallel, reviewer picks the best'
|
||||
: 'Single model generates content directly'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="council-toggle" className="text-xs text-muted-foreground">
|
||||
Council Mode
|
||||
</Label>
|
||||
<Switch
|
||||
id="council-toggle"
|
||||
checked={formData.council_enabled || false}
|
||||
onCheckedChange={(checked: boolean) => handleChange('council_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-fix">Analysis Model</Label>
|
||||
<Input
|
||||
id="model-fix"
|
||||
value={formData.model_fix || ''}
|
||||
onChange={(e) => handleChange('model_fix', e.target.value)}
|
||||
placeholder="Uses default"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For analyzing & fixing content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-polish">Polish Model</Label>
|
||||
<Input
|
||||
id="model-polish"
|
||||
value={formData.model_polish || ''}
|
||||
onChange={(e) => handleChange('model_polish', e.target.value)}
|
||||
placeholder="Uses default"
|
||||
list="generation-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For polishing & improving text
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datalist for model suggestions */}
|
||||
<datalist id="generation-models">
|
||||
{POPULAR_MODELS.generation.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Council Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Council Mode
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enable multi-model review for quality assurance. Multiple generators can compete, and a reviewer picks the best output.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="council-enabled">Enable Council Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, generators run in parallel and a reviewer selects the best result
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="council-enabled"
|
||||
checked={formData.council_enabled || false}
|
||||
onCheckedChange={(checked: boolean) => handleChange('council_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.council_enabled && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Generators Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Generator Models ({generators.length})</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Models that generate content in parallel. The reviewer picks the best output.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addGenerator}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Generator
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!formData.council_enabled ? (
|
||||
/* Single generator mode */
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-generate">Generation Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_generate || ''}
|
||||
onChange={(value) => handleChange('model_generate', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For questions, category ideas, and pack ideas
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Council mode - multiple generators + reviewer */
|
||||
<div className="space-y-4 pl-4 border-l-2 border-primary/30">
|
||||
{/* Generators */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Generators ({generators.length})</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addGenerator}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{generators.map((gen, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-medium">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
<Input
|
||||
<ModelCombobox
|
||||
value={gen.model}
|
||||
onChange={(e) => updateGeneratorModel(index, e.target.value)}
|
||||
placeholder={index === 0 ? "anthropic/claude-3.5-sonnet" : "Enter model ID..."}
|
||||
list="generation-models"
|
||||
onChange={(value) => updateGeneratorModel(index, value)}
|
||||
placeholder={index === 0 ? "anthropic/claude-3.5-sonnet" : "Select model..."}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
@@ -412,7 +344,7 @@ export function AiSettingsPage() {
|
||||
size="icon"
|
||||
onClick={() => removeGenerator(index)}
|
||||
disabled={generators.length <= 1}
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="text-destructive hover:text-destructive h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -420,48 +352,127 @@ export function AiSettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{generators.length > 1 && (
|
||||
{/* Reviewer */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="council-reviewer" className="text-sm">Reviewer Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.council_reviewer_model || ''}
|
||||
onChange={(value) => handleChange('council_reviewer_model', value)}
|
||||
placeholder="google/gemini-pro-1.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All {generators.length} generators will run in parallel. The reviewer will compare all outputs and select the best one.
|
||||
Reviews outputs and selects the best
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selection Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Selection Mode</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
|
||||
formData.council_selection_mode === 'whole_set'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => handleChange('council_selection_mode', 'whole_set')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Whole Set</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Pick best complete set
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
|
||||
formData.council_selection_mode === 'cherry_pick'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => handleChange('council_selection_mode', 'cherry_pick')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cherry className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Cherry Pick</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Mix best from each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cherry-pick intensity option */}
|
||||
{formData.council_selection_mode === 'cherry_pick' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">Ensure Intensity Variety</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Balance across intensity levels 1-5
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.cherry_pick_ensure_intensity_distribution ?? true}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
handleChange('cherry_pick_ensure_intensity_distribution', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reviewer Section */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="council-reviewer">Reviewer Model</Label>
|
||||
<Input
|
||||
id="council-reviewer"
|
||||
value={formData.council_reviewer_model || ''}
|
||||
onChange={(e) => handleChange('council_reviewer_model', e.target.value)}
|
||||
placeholder="google/gemini-pro-1.5"
|
||||
list="review-models"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model used to review, score, and select the best generated content
|
||||
Uses {generators.length + 1} API calls ({generators.length} generator{generators.length > 1 ? 's' : ''} + reviewer)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<datalist id="review-models">
|
||||
{POPULAR_MODELS.review.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
{/* Ideas Model - shown when council mode is ON (since council only applies to questions) */}
|
||||
{formData.council_enabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-generate">Ideas Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_generate || ''}
|
||||
onChange={(value) => handleChange('model_generate', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For category ideas and pack ideas (council mode only applies to questions)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Other models - always visible */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-fix">Analysis Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_fix || ''}
|
||||
onChange={(value) => handleChange('model_fix', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For analyzing & fixing content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-polish">Polish Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.model_polish || ''}
|
||||
onChange={(value) => handleChange('model_polish', value)}
|
||||
placeholder="Uses default model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For polishing & improving text
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Usage Note</AlertTitle>
|
||||
<AlertDescription>
|
||||
Council mode uses {generators.length + 1} AI call{generators.length + 1 > 1 ? 's' : ''} per generation ({generators.length} generator{generators.length > 1 ? 's' : ''} + 1 reviewer), which increases API costs.
|
||||
Using different model providers gives diverse perspectives.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -18,11 +19,15 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, Lightbulb } from 'lucide-react';
|
||||
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -33,22 +38,37 @@ interface Category {
|
||||
pack_count?: number;
|
||||
}
|
||||
|
||||
interface CategoryFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function CategoriesPage() {
|
||||
// Data state
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '📚',
|
||||
});
|
||||
// Form state using the new hook
|
||||
const form = useEntityForm<CategoryFormData, Category>(
|
||||
{ name: '', description: '', icon: '📚' },
|
||||
(category) => ({
|
||||
name: category.name,
|
||||
description: category.description || '',
|
||||
icon: category.icon || '📚',
|
||||
})
|
||||
);
|
||||
|
||||
const fetchCategories = async () => {
|
||||
// =============================================================================
|
||||
// Data Fetching
|
||||
// =============================================================================
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch categories with pack count
|
||||
@@ -83,77 +103,59 @@ export function CategoriesPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, []);
|
||||
}, [fetchCategories]);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingCategory(null);
|
||||
setFormData({ name: '', description: '', icon: '📚' });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
// =============================================================================
|
||||
// Handlers
|
||||
// =============================================================================
|
||||
|
||||
const openEditDialog = (category: Category) => {
|
||||
setEditingCategory(category);
|
||||
setFormData({
|
||||
name: category.name,
|
||||
description: category.description || '',
|
||||
icon: category.icon || '📚',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAiIdea = (idea: any) => {
|
||||
const handleAiIdea = (idea: { name: string; description?: string; icon?: string }) => {
|
||||
setAiIdeasOpen(false);
|
||||
setEditingCategory(null);
|
||||
setFormData({
|
||||
form.openCreateWith({
|
||||
name: idea.name,
|
||||
description: idea.description || '',
|
||||
icon: idea.icon,
|
||||
icon: idea.icon || '📚',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
if (!form.formData.name.trim()) {
|
||||
toast.error('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
form.setSaving(true);
|
||||
try {
|
||||
if (editingCategory) {
|
||||
// Update
|
||||
const { error } = await auditedSupabase.update('categories', editingCategory.id, {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
});
|
||||
const data = {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
};
|
||||
|
||||
if (form.editingItem) {
|
||||
const { error } = await auditedSupabase.update('categories', form.editingItem.id, data);
|
||||
if (error) throw error;
|
||||
toast.success('Category updated');
|
||||
} else {
|
||||
// Create
|
||||
const { error } = await auditedSupabase.insert('categories', {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
...data,
|
||||
sort_order: categories.length,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Category created');
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
form.close();
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
toast.error('Failed to save category');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
form.setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,7 +166,6 @@ export function CategoriesPage() {
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('categories', category.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Category deleted');
|
||||
fetchCategories();
|
||||
@@ -174,6 +175,38 @@ export function CategoriesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = async (category: Category, direction: 'up' | 'down') => {
|
||||
const currentIndex = categories.findIndex(c => c.id === category.id);
|
||||
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= categories.length) return;
|
||||
|
||||
const targetCategory = categories[targetIndex];
|
||||
|
||||
try {
|
||||
const currentOrder = category.sort_order ?? currentIndex;
|
||||
const targetOrder = targetCategory.sort_order ?? targetIndex;
|
||||
|
||||
await Promise.all([
|
||||
auditedSupabase.update('categories', category.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('categories', targetCategory.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
// Optimistic update
|
||||
const newCategories = [...categories];
|
||||
[newCategories[currentIndex], newCategories[targetIndex]] = [newCategories[targetIndex], newCategories[currentIndex]];
|
||||
setCategories(newCategories);
|
||||
} catch (error) {
|
||||
toast.error('Failed to reorder categories');
|
||||
console.error(error);
|
||||
fetchCategories();
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
// =============================================================================
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -201,9 +234,9 @@ export function CategoriesPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Button onClick={form.openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Category
|
||||
</Button>
|
||||
@@ -215,10 +248,10 @@ export function CategoriesPage() {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCategory ? 'Edit Category' : 'Create Category'}
|
||||
{form.isEditing ? 'Edit Category' : 'Create Category'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingCategory
|
||||
{form.isEditing
|
||||
? 'Update the category details below.'
|
||||
: 'Add a new category to organize your question packs.'}
|
||||
</DialogDescription>
|
||||
@@ -228,8 +261,8 @@ export function CategoriesPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Icon</Label>
|
||||
<EmojiPicker
|
||||
value={formData.icon}
|
||||
onChange={(emoji) => setFormData(d => ({ ...d, icon: emoji }))}
|
||||
value={form.formData.icon}
|
||||
onChange={(emoji) => form.setField('icon', emoji)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -237,15 +270,15 @@ export function CategoriesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<AIPolishButton
|
||||
text={formData.name}
|
||||
text={form.formData.name}
|
||||
type="category_name"
|
||||
onPolished={(val) => setFormData(d => ({ ...d, name: val }))}
|
||||
onPolished={(val) => form.setField('name', val)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(d => ({ ...d, name: e.target.value }))}
|
||||
value={form.formData.name}
|
||||
onChange={(e) => form.setField('name', e.target.value)}
|
||||
placeholder="e.g., Romance"
|
||||
/>
|
||||
</div>
|
||||
@@ -254,15 +287,15 @@ export function CategoriesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<AIPolishButton
|
||||
text={formData.description}
|
||||
text={form.formData.description}
|
||||
type="pack_description"
|
||||
onPolished={(val) => setFormData(d => ({ ...d, description: val }))}
|
||||
onPolished={(val) => form.setField('description', val)}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(d => ({ ...d, description: e.target.value }))}
|
||||
value={form.formData.description}
|
||||
onChange={(e) => form.setField('description', e.target.value)}
|
||||
placeholder="Describe this category..."
|
||||
rows={3}
|
||||
/>
|
||||
@@ -270,11 +303,11 @@ export function CategoriesPage() {
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
<Button variant="outline" onClick={form.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<Button onClick={handleSave} disabled={form.saving}>
|
||||
{form.saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
@@ -297,23 +330,45 @@ export function CategoriesPage() {
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create your first category to get started
|
||||
</p>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Button onClick={form.openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Category
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((category) => (
|
||||
{categories.map((category, index) => (
|
||||
<Card key={category.id} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-4xl mb-2">{category.icon || '📚'}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMove(category, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMove(category, 'down')}
|
||||
disabled={index === categories.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-4xl mb-2">{category.icon || '📚'}</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(category)}
|
||||
onClick={() => form.openEdit(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -347,6 +402,7 @@ export function CategoriesPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Dialog */}
|
||||
<AiGeneratorDialog
|
||||
open={aiIdeasOpen}
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { EmojiPicker } from '@/components/ui/emoji-picker';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff, Flame, Heart, Tags, X } from 'lucide-react';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -52,31 +43,48 @@ interface QuestionPack {
|
||||
topics?: Topic[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function PacksPage() {
|
||||
const { categoryId } = useParams<{ categoryId: string }>();
|
||||
|
||||
// Data state
|
||||
const [category, setCategory] = useState<Category | null>(null);
|
||||
const [packs, setPacks] = useState<QuestionPack[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [aiDialogOpen, setAiDialogOpen] = useState(false);
|
||||
const [editingPack, setEditingPack] = useState<QuestionPack | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [extractTopicsDialogOpen, setExtractTopicsDialogOpen] = useState(false);
|
||||
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
|
||||
const [allTopics, setAllTopics] = useState<Topic[]>([]);
|
||||
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '💕',
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
});
|
||||
// Form state using the hook
|
||||
const form = useEntityForm<PackFormData, QuestionPack>(
|
||||
{
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '💕',
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
},
|
||||
(pack) => ({
|
||||
name: pack.name,
|
||||
description: pack.description || '',
|
||||
icon: pack.icon || '💕',
|
||||
is_premium: pack.is_premium,
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
})
|
||||
);
|
||||
|
||||
const fetchData = async () => {
|
||||
// =============================================================================
|
||||
// Data Fetching
|
||||
// =============================================================================
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch category info
|
||||
@@ -86,7 +94,6 @@ export function PacksPage() {
|
||||
.select('id, name, icon')
|
||||
.eq('id', categoryId)
|
||||
.single();
|
||||
|
||||
setCategory(cat);
|
||||
}
|
||||
|
||||
@@ -151,87 +158,68 @@ export function PacksPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [categoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [categoryId]);
|
||||
}, [fetchData]);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingPack(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '💕',
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
});
|
||||
// =============================================================================
|
||||
// Handlers
|
||||
// =============================================================================
|
||||
|
||||
const openCreate = () => {
|
||||
form.openCreate();
|
||||
setSelectedTopicIds(new Set());
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (pack: QuestionPack) => {
|
||||
setEditingPack(pack);
|
||||
setFormData({
|
||||
name: pack.name,
|
||||
description: pack.description || '',
|
||||
icon: pack.icon || '💕',
|
||||
is_premium: pack.is_premium,
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
});
|
||||
const openEdit = (pack: QuestionPack) => {
|
||||
form.openEdit(pack);
|
||||
setSelectedTopicIds(new Set(pack.topics?.map(t => t.id) || []));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
if (!form.formData.name.trim()) {
|
||||
toast.error('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
form.setSaving(true);
|
||||
try {
|
||||
let packId: string;
|
||||
|
||||
if (editingPack) {
|
||||
const { error } = await auditedSupabase.update('question_packs', editingPack.id, {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
is_premium: formData.is_premium,
|
||||
is_public: formData.is_public,
|
||||
is_explicit: formData.is_explicit,
|
||||
if (form.editingItem) {
|
||||
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
is_premium: form.formData.is_premium,
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
packId = editingPack.id;
|
||||
packId = form.editingItem.id;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('question_packs')
|
||||
.insert({
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
icon: formData.icon || null,
|
||||
is_premium: formData.is_premium,
|
||||
is_public: formData.is_public,
|
||||
is_explicit: formData.is_explicit,
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
is_premium: form.formData.is_premium,
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: categoryId || null,
|
||||
sort_order: packs.length,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
packId = data.id;
|
||||
}
|
||||
|
||||
// Update topics - delete existing and insert new
|
||||
await supabase
|
||||
.from('pack_topics')
|
||||
.delete()
|
||||
.eq('pack_id', packId);
|
||||
// Update topics
|
||||
await supabase.from('pack_topics').delete().eq('pack_id', packId);
|
||||
|
||||
if (selectedTopicIds.size > 0) {
|
||||
const { error: topicsError } = await supabase
|
||||
@@ -242,18 +230,17 @@ export function PacksPage() {
|
||||
topic_id: topicId,
|
||||
}))
|
||||
);
|
||||
|
||||
if (topicsError) throw topicsError;
|
||||
}
|
||||
|
||||
toast.success(editingPack ? 'Pack updated' : 'Pack created');
|
||||
setDialogOpen(false);
|
||||
toast.success(form.isEditing ? 'Pack updated' : 'Pack created');
|
||||
form.close();
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
toast.error('Failed to save pack');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
form.setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,7 +251,6 @@ export function PacksPage() {
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('question_packs', pack.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Pack deleted');
|
||||
fetchData();
|
||||
@@ -274,20 +260,53 @@ export function PacksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiGenerated = (result: any) => {
|
||||
// Handle pack generation
|
||||
const handleAiGenerated = (result: { name?: string; description?: string; icon?: string }) => {
|
||||
if (result.description) {
|
||||
setFormData({
|
||||
...formData,
|
||||
name: result.name,
|
||||
form.openCreateWith({
|
||||
name: result.name || '',
|
||||
description: result.description,
|
||||
icon: result.icon || '💕',
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
is_explicit: false,
|
||||
});
|
||||
setAiDialogOpen(false);
|
||||
setDialogOpen(true);
|
||||
setSelectedTopicIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = async (pack: QuestionPack, direction: 'up' | 'down') => {
|
||||
const currentIndex = packs.findIndex(p => p.id === pack.id);
|
||||
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= packs.length) return;
|
||||
|
||||
const targetPack = packs[targetIndex];
|
||||
|
||||
try {
|
||||
const currentOrder = pack.sort_order ?? currentIndex;
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
await Promise.all([
|
||||
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
// Optimistic update
|
||||
const newPacks = [...packs];
|
||||
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
|
||||
setPacks(newPacks);
|
||||
} catch (error) {
|
||||
toast.error('Failed to reorder packs');
|
||||
console.error(error);
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
// =============================================================================
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -329,196 +348,27 @@ export function PacksPage() {
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Ideas
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Pack
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingPack ? 'Edit Pack' : 'Create Pack'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingPack
|
||||
? 'Update the pack details below.'
|
||||
: 'Add a new question pack to this category.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Icon</Label>
|
||||
<EmojiPicker
|
||||
value={formData.icon}
|
||||
onChange={(emoji) => setFormData(d => ({ ...d, icon: emoji }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<AIPolishButton
|
||||
text={formData.name}
|
||||
type="pack_name"
|
||||
onPolished={(val) => setFormData(d => ({ ...d, name: val }))}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(d => ({ ...d, name: e.target.value }))}
|
||||
placeholder="e.g., 36 Questions to Fall in Love"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<AIPolishButton
|
||||
text={formData.description}
|
||||
type="pack_description"
|
||||
onPolished={(val) => setFormData(d => ({ ...d, description: val }))}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(d => ({ ...d, description: e.target.value }))}
|
||||
placeholder="Describe this pack..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Premium Pack</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only available to premium users
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_premium}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(d => ({ ...d, is_premium: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Public</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visible to all users by default
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_public}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(d => ({ ...d, is_public: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Explicit Content</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Contains adult or mature content
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.is_explicit}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(d => ({ ...d, is_explicit: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Topics */}
|
||||
<div className="space-y-2">
|
||||
<Label>Topics</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select topics/kinks for filtering
|
||||
</p>
|
||||
{/* Selected topics */}
|
||||
{selectedTopicIds.size > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{Array.from(selectedTopicIds).map(id => {
|
||||
const topic = allTopics.find(t => t.id === id);
|
||||
if (!topic) return null;
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
variant="secondary"
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => {
|
||||
const next = new Set(selectedTopicIds);
|
||||
next.delete(id);
|
||||
setSelectedTopicIds(next);
|
||||
}}
|
||||
>
|
||||
{topic.name}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Available topics */}
|
||||
{allTopics.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 p-2 border rounded-md max-h-32 overflow-y-auto">
|
||||
{allTopics
|
||||
.filter(t => !selectedTopicIds.has(t.id))
|
||||
.map(topic => (
|
||||
<Badge
|
||||
key={topic.id}
|
||||
variant="outline"
|
||||
className="cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={() => {
|
||||
const next = new Set(selectedTopicIds);
|
||||
next.add(topic.id);
|
||||
setSelectedTopicIds(next);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{topic.name}
|
||||
</Badge>
|
||||
))}
|
||||
{allTopics.filter(t => !selectedTopicIds.has(t.id)).length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">All topics selected</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No topics yet. Use "Extract Topics" on a pack to create some.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Pack
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pack Form Dialog */}
|
||||
<PackFormDialog
|
||||
open={form.dialogOpen}
|
||||
onOpenChange={form.setDialogOpen}
|
||||
formData={form.formData}
|
||||
onFormChange={form.setFormData}
|
||||
onSave={handleSave}
|
||||
saving={form.saving}
|
||||
isEditing={form.isEditing}
|
||||
allTopics={allTopics}
|
||||
selectedTopicIds={selectedTopicIds}
|
||||
onTopicsChange={setSelectedTopicIds}
|
||||
/>
|
||||
|
||||
{/* AI Generator Dialog */}
|
||||
<AiGeneratorDialog
|
||||
open={aiDialogOpen}
|
||||
@@ -554,7 +404,7 @@ export function PacksPage() {
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Ideas
|
||||
</Button>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Pack
|
||||
</Button>
|
||||
@@ -562,11 +412,33 @@ export function PacksPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{packs.map((pack) => (
|
||||
{packs.map((pack, index) => (
|
||||
<Card key={pack.id} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-4xl mb-2">{pack.icon || '💕'}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMove(pack, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMove(pack, 'down')}
|
||||
disabled={index === packs.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-4xl mb-2">{pack.icon || '💕'}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{pack.is_premium && (
|
||||
<Badge variant="default" className="bg-amber-500">
|
||||
@@ -615,7 +487,7 @@ export function PacksPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(pack)}
|
||||
onClick={() => openEdit(pack)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
pack_count?: number;
|
||||
}
|
||||
|
||||
export interface CategoryFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Read Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all categories with pack counts
|
||||
*/
|
||||
export async function fetchCategories(): Promise<Category[]> {
|
||||
// Fetch categories
|
||||
const { data: categories, error: catError } = await supabase
|
||||
.from('categories')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (catError) throw catError;
|
||||
|
||||
// Fetch pack counts per category
|
||||
const { data: packs } = await supabase
|
||||
.from('question_packs')
|
||||
.select('category_id');
|
||||
|
||||
const packCounts: Record<string, number> = {};
|
||||
packs?.forEach(p => {
|
||||
if (p.category_id) {
|
||||
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return (categories || []).map(c => ({
|
||||
...c,
|
||||
pack_count: packCounts[c.id] || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single category by ID
|
||||
*/
|
||||
export async function fetchCategoryById(id: string): Promise<Category | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('categories')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Write Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a new category
|
||||
*/
|
||||
export async function createCategory(
|
||||
data: CategoryFormData,
|
||||
sortOrder: number
|
||||
): Promise<Category> {
|
||||
const { data: created, error } = await auditedSupabase.insert('categories', {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return created as Category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing category
|
||||
*/
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
data: Partial<CategoryFormData>
|
||||
): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('categories', id, {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a category
|
||||
*/
|
||||
export async function deleteCategory(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.delete('categories', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap sort order between two categories (for reordering)
|
||||
*/
|
||||
export async function swapCategorySortOrder(
|
||||
category1Id: string,
|
||||
category1Order: number,
|
||||
category2Id: string,
|
||||
category2Order: number
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
auditedSupabase.update('categories', category1Id, { sort_order: category2Order }),
|
||||
auditedSupabase.update('categories', category2Id, { sort_order: category1Order }),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// =============================================================================
|
||||
// Services Layer
|
||||
// Centralized data access for the admin portal
|
||||
// =============================================================================
|
||||
|
||||
// Category operations
|
||||
export * from './categories';
|
||||
|
||||
// Pack operations (including topics)
|
||||
export * from './packs';
|
||||
|
||||
// Question operations
|
||||
export * from './questions';
|
||||
|
||||
// Dashboard statistics
|
||||
export * from './stats';
|
||||
@@ -0,0 +1,215 @@
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface Pack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
sort_order: number | null;
|
||||
category_id: string | null;
|
||||
created_at: string | null;
|
||||
category?: { name: string };
|
||||
question_count?: number;
|
||||
}
|
||||
|
||||
export interface PackFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
is_premium: boolean;
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Read Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all packs for a category with question counts
|
||||
*/
|
||||
export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]> {
|
||||
const { data: packs, error: packError } = await supabase
|
||||
.from('question_packs')
|
||||
.select('*, category:categories(name)')
|
||||
.eq('category_id', categoryId)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (packError) throw packError;
|
||||
|
||||
// Get question counts per pack
|
||||
const { data: questions } = await supabase
|
||||
.from('questions')
|
||||
.select('pack_id');
|
||||
|
||||
const questionCounts: Record<string, number> = {};
|
||||
questions?.forEach(q => {
|
||||
if (q.pack_id) {
|
||||
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return (packs || []).map(p => ({
|
||||
...p,
|
||||
question_count: questionCounts[p.id] || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single pack by ID
|
||||
*/
|
||||
export async function fetchPackById(id: string): Promise<Pack | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('question_packs')
|
||||
.select('*, category:categories(name)')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all topics
|
||||
*/
|
||||
export async function fetchAllTopics(): Promise<Topic[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('topics')
|
||||
.select('*')
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch topic IDs for a specific pack
|
||||
*/
|
||||
export async function fetchPackTopicIds(packId: string): Promise<Set<string>> {
|
||||
const { data, error } = await supabase
|
||||
.from('pack_topics')
|
||||
.select('topic_id')
|
||||
.eq('pack_id', packId);
|
||||
|
||||
if (error) throw error;
|
||||
return new Set((data || []).map(pt => pt.topic_id));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Write Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a new pack
|
||||
*/
|
||||
export async function createPack(
|
||||
categoryId: string,
|
||||
data: PackFormData,
|
||||
sortOrder: number
|
||||
): Promise<Pack> {
|
||||
const { data: created, error } = await auditedSupabase.insert('question_packs', {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
is_premium: data.is_premium,
|
||||
is_public: data.is_public,
|
||||
is_explicit: data.is_explicit,
|
||||
category_id: categoryId,
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return created as Pack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing pack
|
||||
*/
|
||||
export async function updatePack(
|
||||
id: string,
|
||||
data: Partial<PackFormData>
|
||||
): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('question_packs', id, {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
is_premium: data.is_premium,
|
||||
is_public: data.is_public,
|
||||
is_explicit: data.is_explicit,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pack
|
||||
*/
|
||||
export async function deletePack(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.delete('question_packs', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap sort order between two packs (for reordering)
|
||||
*/
|
||||
export async function swapPackSortOrder(
|
||||
pack1Id: string,
|
||||
pack1Order: number,
|
||||
pack2Id: string,
|
||||
pack2Order: number
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
auditedSupabase.update('question_packs', pack1Id, { sort_order: pack2Order }),
|
||||
auditedSupabase.update('question_packs', pack2Id, { sort_order: pack1Order }),
|
||||
]);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Topic Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Update pack topics (replaces all existing topics)
|
||||
*/
|
||||
export async function updatePackTopics(
|
||||
packId: string,
|
||||
topicIds: string[]
|
||||
): Promise<void> {
|
||||
// Delete existing topics
|
||||
await supabase.from('pack_topics').delete().eq('pack_id', packId);
|
||||
|
||||
// Insert new topics
|
||||
if (topicIds.length > 0) {
|
||||
const { error } = await supabase.from('pack_topics').insert(
|
||||
topicIds.map(topicId => ({ pack_id: packId, topic_id: topicId }))
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new topic
|
||||
*/
|
||||
export async function createTopic(name: string): Promise<Topic> {
|
||||
const { data, error } = await supabase
|
||||
.from('topics')
|
||||
.insert({ name })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
text: string;
|
||||
partner_text: string | null;
|
||||
intensity: number;
|
||||
pack_id: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface QuestionFormData {
|
||||
text: string;
|
||||
partner_text: string;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Read Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all questions for a pack
|
||||
*/
|
||||
export async function fetchQuestionsForPack(packId: string): Promise<Question[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.eq('pack_id', packId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single question by ID
|
||||
*/
|
||||
export async function fetchQuestionById(id: string): Promise<Question | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch question count for a pack
|
||||
*/
|
||||
export async function fetchQuestionCount(packId: string): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('pack_id', packId);
|
||||
|
||||
if (error) throw error;
|
||||
return count || 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Write Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a new question
|
||||
*/
|
||||
export async function createQuestion(
|
||||
packId: string,
|
||||
data: QuestionFormData
|
||||
): Promise<Question> {
|
||||
const { data: created, error } = await auditedSupabase.insert('questions', {
|
||||
text: data.text,
|
||||
partner_text: data.partner_text || null,
|
||||
intensity: data.intensity,
|
||||
pack_id: packId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return created as Question;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing question
|
||||
*/
|
||||
export async function updateQuestion(
|
||||
id: string,
|
||||
data: Partial<QuestionFormData>
|
||||
): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('questions', id, {
|
||||
text: data.text,
|
||||
partner_text: data.partner_text || null,
|
||||
intensity: data.intensity,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a question
|
||||
*/
|
||||
export async function deleteQuestion(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.delete('questions', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple questions
|
||||
*/
|
||||
export async function deleteQuestions(ids: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
ids.map(id => auditedSupabase.delete('questions', id))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create questions (for AI generation)
|
||||
*/
|
||||
export async function bulkCreateQuestions(
|
||||
packId: string,
|
||||
questions: Array<{ text: string; partner_text?: string; intensity: number }>
|
||||
): Promise<Question[]> {
|
||||
const results: Question[] = [];
|
||||
|
||||
for (const q of questions) {
|
||||
const { data, error } = await auditedSupabase.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text || null,
|
||||
intensity: q.intensity,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
if (data) results.push(data as Question);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { supabase } from '@/config';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardStats {
|
||||
categories: number;
|
||||
packs: number;
|
||||
questions: number;
|
||||
users: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stats Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch dashboard statistics (counts for main entities)
|
||||
*/
|
||||
export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
const [
|
||||
{ count: categories },
|
||||
{ count: packs },
|
||||
{ count: questions },
|
||||
{ count: users },
|
||||
] = await Promise.all([
|
||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
]);
|
||||
|
||||
return {
|
||||
categories: categories || 0,
|
||||
packs: packs || 0,
|
||||
questions: questions || 0,
|
||||
users: users || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch count for a specific table
|
||||
*/
|
||||
export async function fetchTableCount(tableName: string): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
.from(tableName)
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
if (error) throw error;
|
||||
return count || 0;
|
||||
}
|
||||
@@ -6,8 +6,9 @@ EXPO_PUBLIC_SUPABASE_ANON_KEY=
|
||||
# Sentry error tracking (optional - get DSN from https://sentry.io)
|
||||
EXPO_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# RevenueCat (iOS subscriptions only - Android not supported)
|
||||
# Get your API key from https://app.revenuecat.com
|
||||
# RevenueCat (for in-app subscriptions)
|
||||
# Get your API keys from https://app.revenuecat.com
|
||||
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
|
||||
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=
|
||||
# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set)
|
||||
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro
|
||||
@@ -2,9 +2,15 @@ import { Platform } from "react-native";
|
||||
|
||||
const REVENUECAT_IOS_API_KEY =
|
||||
process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || "";
|
||||
const REVENUECAT_ANDROID_API_KEY =
|
||||
process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || "";
|
||||
const ENTITLEMENT_ID =
|
||||
process.env.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID || "pro"; // Your RevenueCat entitlement identifier
|
||||
|
||||
function getApiKey(): string {
|
||||
return Platform.OS === "ios" ? REVENUECAT_IOS_API_KEY : REVENUECAT_ANDROID_API_KEY;
|
||||
}
|
||||
|
||||
export interface SubscriptionState {
|
||||
isProUser: boolean;
|
||||
activeSubscription: string | null;
|
||||
@@ -48,7 +54,7 @@ class RevenueCatService {
|
||||
private Purchases: any = null;
|
||||
|
||||
isAvailable(): boolean {
|
||||
return Platform.OS === "ios";
|
||||
return Platform.OS === "ios" || Platform.OS === "android";
|
||||
}
|
||||
|
||||
private async loadPurchases(): Promise<boolean> {
|
||||
@@ -67,15 +73,17 @@ class RevenueCatService {
|
||||
async initialize(userId?: string): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Only initialize on iOS
|
||||
if (Platform.OS !== "ios") {
|
||||
console.log("RevenueCat: Skipping initialization on non-iOS platform");
|
||||
// Only initialize on iOS and Android
|
||||
if (Platform.OS !== "ios" && Platform.OS !== "android") {
|
||||
console.log("RevenueCat: Skipping initialization on unsupported platform");
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = getApiKey();
|
||||
|
||||
// Skip if API key is missing or placeholder
|
||||
if (!REVENUECAT_IOS_API_KEY || REVENUECAT_IOS_API_KEY === "your_revenuecat_ios_api_key") {
|
||||
console.log("RevenueCat: Skipping - no valid API key configured");
|
||||
if (!apiKey || apiKey.startsWith("your_revenuecat")) {
|
||||
console.log(`RevenueCat: Skipping - no valid ${Platform.OS} API key configured`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,11 +94,11 @@ class RevenueCatService {
|
||||
// Always enable debug logging for now to diagnose issues
|
||||
this.Purchases.setLogLevel(this.Purchases.LOG_LEVEL?.DEBUG || 4);
|
||||
|
||||
const keyPrefix = REVENUECAT_IOS_API_KEY.substring(0, 10);
|
||||
console.log("RevenueCat: Configuring with API key prefix:", keyPrefix + "...");
|
||||
const keyPrefix = apiKey.substring(0, 10);
|
||||
console.log(`RevenueCat: Configuring ${Platform.OS} with API key prefix:`, keyPrefix + "...");
|
||||
|
||||
await this.Purchases.configure({
|
||||
apiKey: REVENUECAT_IOS_API_KEY,
|
||||
apiKey: apiKey,
|
||||
appUserID: userId,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user