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

No files matched your search

+7
View File
@@ -11,3 +11,10 @@ VITE_AI_MODEL=openai/gpt-4o-mini
# VITE_AI_MODEL_GENERATE - for pack/question/category generation
# VITE_AI_MODEL_FIX - for fix targets and fix text analysis
# VITE_AI_MODEL_POLISH - for polishing/tidying up text
# AI Council Configuration (multi-model review system)
# Set VITE_COUNCIL_ENABLED=true to enable multi-model review for question generation
# When enabled, a second model reviews generated questions for quality
VITE_COUNCIL_ENABLED=false
VITE_COUNCIL_GENERATOR_MODEL=anthropic/claude-3.5-sonnet
VITE_COUNCIL_REVIEWER_MODEL=google/gemini-pro-1.5
+6
View File
@@ -20,6 +20,9 @@ ARG VITE_AI_MODEL
ARG VITE_AI_MODEL_GENERATE
ARG VITE_AI_MODEL_FIX
ARG VITE_AI_MODEL_POLISH
ARG VITE_COUNCIL_ENABLED
ARG VITE_COUNCIL_GENERATOR_MODEL
ARG VITE_COUNCIL_REVIEWER_MODEL
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
@@ -29,6 +32,9 @@ ENV VITE_AI_MODEL=$VITE_AI_MODEL
ENV VITE_AI_MODEL_GENERATE=$VITE_AI_MODEL_GENERATE
ENV VITE_AI_MODEL_FIX=$VITE_AI_MODEL_FIX
ENV VITE_AI_MODEL_POLISH=$VITE_AI_MODEL_POLISH
ENV VITE_COUNCIL_ENABLED=$VITE_COUNCIL_ENABLED
ENV VITE_COUNCIL_GENERATOR_MODEL=$VITE_COUNCIL_GENERATOR_MODEL
ENV VITE_COUNCIL_REVIEWER_MODEL=$VITE_COUNCIL_REVIEWER_MODEL
RUN npx turbo run build --filter=@sauci/admin
+3
View File
@@ -12,4 +12,7 @@ services:
- VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE}
- VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX}
- VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH}
- VITE_COUNCIL_ENABLED=${VITE_COUNCIL_ENABLED}
- VITE_COUNCIL_GENERATOR_MODEL=${VITE_COUNCIL_GENERATOR_MODEL}
- VITE_COUNCIL_REVIEWER_MODEL=${VITE_COUNCIL_REVIEWER_MODEL}
restart: unless-stopped
+3
View File
@@ -14,6 +14,9 @@ services:
- VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE}
- VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX}
- VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH}
- VITE_COUNCIL_ENABLED=${VITE_COUNCIL_ENABLED}
- VITE_COUNCIL_GENERATOR_MODEL=${VITE_COUNCIL_GENERATOR_MODEL}
- VITE_COUNCIL_REVIEWER_MODEL=${VITE_COUNCIL_REVIEWER_MODEL}
volumes:
- ./src:/app/apps/admin/src
- ./index.html:/app/apps/admin/index.html
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sauci Admin</title>
</head>
+9
View File
@@ -15,6 +15,7 @@ import { AdminsPage } from '@/pages/admins/AdminsPage';
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
import { FeedbackPage } from '@/pages/FeedbackPage';
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
import { AiSettingsPage } from '@/pages/AiSettingsPage';
function App() {
return (
@@ -128,6 +129,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/ai-settings"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
<AiSettingsPage />
</ProtectedRoute>
}
/>
</Route>
{/* Catch all - redirect to dashboard */}
@@ -12,8 +12,23 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square } from 'lucide-react';
import { generatePack, generateQuestions, suggestCategories, suggestPacks, TONE_LEVELS, INTENSITY_LEVELS, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea, type ToneLevel } from '@/lib/openai';
import { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square, AlertTriangle, XCircle, CheckCircle2, Filter } from 'lucide-react';
import {
generatePack,
generateQuestionsWithCouncil,
suggestCategories,
suggestPacks,
getCouncilConfig,
TONE_LEVELS,
INTENSITY_LEVELS,
type GeneratedPack,
type GeneratedQuestion,
type GeneratedCategoryIdea,
type GeneratedPackIdea,
type ToneLevel,
type QuestionReview,
type CouncilGenerationResult,
} from '@/lib/openai';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
@@ -41,6 +56,7 @@ export function AiGeneratorDialog({
onGenerated,
}: AiGeneratorDialogProps) {
const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState<'generating' | 'reviewing' | null>(null);
const [count, setCount] = useState(10);
// Default tone based on pack's explicit flag (5 for explicit packs, 3 for non-explicit)
const [tone, setTone] = useState<ToneLevel>(context?.isExplicit ? 5 : 3);
@@ -61,11 +77,46 @@ export function AiGeneratorDialog({
const [generatedIdeas, setGeneratedIdeas] = useState<GeneratedCategoryIdea[]>([]);
const [generatedPackIdeas, setGeneratedPackIdeas] = useState<GeneratedPackIdea[]>([]);
// Council review state
const [reviews, setReviews] = useState<QuestionReview[]>([]);
const [reviewSummary, setReviewSummary] = useState<CouncilGenerationResult['summary']>(null);
const [reviewMetadata, setReviewMetadata] = useState<CouncilGenerationResult['metadata'] | null>(null);
const [filter, setFilter] = useState<'all' | 'pass' | 'flag' | 'reject'>('all');
// Selection state for questions
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState<Set<number>>(new Set());
// Get council config
const councilConfig = getCouncilConfig();
// Helper to get review for a question index
const getReviewForIndex = (index: number): QuestionReview | undefined => {
if (!reviews || reviews.length === 0) return undefined;
return reviews.find(r => r.index === index);
};
// Filter questions based on current filter
const getFilteredQuestionIndices = (): number[] => {
if (!generatedQuestions || generatedQuestions.length === 0) {
return [];
}
if (filter === 'all' || !reviews || reviews.length === 0) {
return generatedQuestions.map((_, i) => i);
}
return reviews
.filter(r => r.verdict === filter)
.map(r => r.index);
};
const handleGenerate = async () => {
setLoading(true);
setLoadingStep(null);
// Reset council state
setReviews([]);
setReviewSummary(null);
setReviewMetadata(null);
setFilter('all');
try {
if (type === 'pack') {
const result = await generatePack(context?.categoryName, isExplicit, crudeLang, inspiration || undefined);
@@ -75,10 +126,55 @@ export function AiGeneratorDialog({
toast.error('Pack name is required');
return;
}
const result = await generateQuestions(context.packName, count, undefined, tone, context.packDescription || undefined, context.existingQuestions, crudeLang, inspiration || undefined);
setGeneratedQuestions(result);
// Select all questions by default
setSelectedQuestionIndices(new Set(result.map((_, i) => i)));
// Use council approach
setLoadingStep('generating');
const result = await generateQuestionsWithCouncil(
context.packName,
count,
undefined,
tone,
context.packDescription || undefined,
context.existingQuestions,
crudeLang,
inspiration || undefined
);
const questions = result.questions || [];
const reviewList = result.reviews || [];
setGeneratedQuestions(questions);
setReviews(reviewList);
setReviewSummary(result.summary);
setReviewMetadata(result.metadata);
// Smart selection based on review results
if (reviewList.length > 0) {
// Select passed and flagged questions by default, not rejected
const selectedIndices = new Set<number>();
reviewList.forEach(r => {
if (r.verdict === 'pass' || r.verdict === 'flag') {
selectedIndices.add(r.index);
}
});
setSelectedQuestionIndices(selectedIndices);
// Show toast with summary
if (result.summary) {
const { passed, flagged, rejected } = result.summary;
if (rejected > 0) {
toast.warning(`Review complete: ${passed} passed, ${flagged} flagged, ${rejected} rejected`);
} else {
toast.success(`Review complete: ${passed} passed, ${flagged} flagged`);
}
}
} else {
// No review (council disabled or failed) - select all
setSelectedQuestionIndices(new Set(questions.map((_, i) => i)));
if (councilConfig.enabled && !result.metadata?.reviewerModel) {
toast.warning('Council review failed - showing unreviewed questions');
}
}
} else if (type === 'category-ideas') {
const result = await suggestCategories(context?.existingCategories || [], isExplicit, crudeLang, inspiration || undefined);
setGeneratedIdeas(result);
@@ -92,9 +188,10 @@ export function AiGeneratorDialog({
}
} catch (error) {
console.error('AI generation error:', error);
toast.error('Failed to generate content. Check your OpenAI API key.');
toast.error('Failed to generate content. Check your API key.');
} finally {
setLoading(false);
setLoadingStep(null);
}
};
@@ -141,6 +238,11 @@ export function AiGeneratorDialog({
setSelectedQuestionIndices(new Set());
setCrudeLang(false);
setInspiration('');
// Reset council state
setReviews([]);
setReviewSummary(null);
setReviewMetadata(null);
setFilter('all');
onOpenChange(false);
};
@@ -261,19 +363,30 @@ export function AiGeneratorDialog({
{/* Generate button */}
{!generatedPack && generatedQuestions.length === 0 && generatedIdeas.length === 0 && generatedPackIdeas.length === 0 && (
<Button onClick={handleGenerate} disabled={loading} className="w-full">
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Generate with AI
</>
<div className="space-y-2">
<Button onClick={handleGenerate} disabled={loading} className="w-full">
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{loadingStep === 'generating' && councilConfig.enabled
? 'Step 1/2: Generating...'
: loadingStep === 'reviewing'
? 'Step 2/2: Reviewing...'
: 'Generating...'}
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Generate with AI
</>
)}
</Button>
{councilConfig.enabled && type === 'questions' && !loading && (
<p className="text-xs text-muted-foreground text-center">
Council mode enabled - questions will be reviewed by a second model
</p>
)}
</Button>
</div>
)}
{/* Generated Pack Preview */}
@@ -290,6 +403,37 @@ export function AiGeneratorDialog({
{/* Generated Questions Preview */}
{generatedQuestions.length > 0 && (
<div className="space-y-3">
{/* Review Summary Bar */}
{reviewSummary && (
<div className="rounded-lg border bg-muted/50 p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="h-4 w-4" />
{reviewSummary.passed} Passed
</span>
<span className="flex items-center gap-1 text-yellow-600">
<AlertTriangle className="h-4 w-4" />
{reviewSummary.flagged} Flagged
</span>
<span className="flex items-center gap-1 text-red-600">
<XCircle className="h-4 w-4" />
{reviewSummary.rejected} Rejected
</span>
</div>
<span className="text-sm font-medium">
Quality: {reviewSummary.overallQuality}/10
</span>
</div>
{reviewMetadata && (
<p className="text-xs text-muted-foreground">
Generated in {(reviewMetadata.generationTime / 1000).toFixed(1)}s
{reviewMetadata.reviewTime > 0 && ` • Reviewed in ${(reviewMetadata.reviewTime / 1000).toFixed(1)}s`}
</p>
)}
</div>
)}
<div className="flex items-center justify-between">
<h4 className="font-medium">
Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected)
@@ -317,41 +461,136 @@ export function AiGeneratorDialog({
</Button>
</div>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{generatedQuestions.map((q, i) => (
<div
key={i}
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
selectedQuestionIndices.has(i)
? 'border-primary ring-1 ring-primary/20'
: 'opacity-60 hover:opacity-80'
}`}
onClick={() => toggleQuestionSelection(i)}
{/* Filter Buttons */}
{reviews && reviews.length > 0 && (
<div className="flex gap-2">
<Button
type="button"
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('all')}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedQuestionIndices.has(i)}
onCheckedChange={() => toggleQuestionSelection(i)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
<div className="flex-1">
<p>{q.text}</p>
{q.partner_text && (
<p className="text-muted-foreground text-xs mt-1">
Partner: {q.partner_text}
</p>
)}
<span
className={`inline-block mt-1 text-xs px-2 py-0.5 rounded text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
>
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
</span>
<Filter className="mr-1 h-3 w-3" />
All ({generatedQuestions.length})
</Button>
<Button
type="button"
variant={filter === 'pass' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('pass')}
className={filter === 'pass' ? '' : 'text-green-600 hover:text-green-700'}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Passed ({reviewSummary?.passed || 0})
</Button>
<Button
type="button"
variant={filter === 'flag' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('flag')}
className={filter === 'flag' ? '' : 'text-yellow-600 hover:text-yellow-700'}
>
<AlertTriangle className="mr-1 h-3 w-3" />
Flagged ({reviewSummary?.flagged || 0})
</Button>
<Button
type="button"
variant={filter === 'reject' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('reject')}
className={filter === 'reject' ? '' : 'text-red-600 hover:text-red-700'}
>
<XCircle className="mr-1 h-3 w-3" />
Rejected ({reviewSummary?.rejected || 0})
</Button>
</div>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{getFilteredQuestionIndices().map((i) => {
const q = generatedQuestions[i];
const review = getReviewForIndex(i);
const isRejected = review?.verdict === 'reject';
return (
<div
key={i}
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
isRejected
? 'opacity-50 border-red-300 bg-red-50/50 dark:bg-red-950/20'
: selectedQuestionIndices.has(i)
? 'border-primary ring-1 ring-primary/20'
: 'opacity-60 hover:opacity-80'
}`}
onClick={() => toggleQuestionSelection(i)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedQuestionIndices.has(i)}
onCheckedChange={() => toggleQuestionSelection(i)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
<div className="flex-1 space-y-2">
<div>
<p>{q.text}</p>
{q.partner_text && (
<p className="text-muted-foreground text-xs mt-1">
Partner: {q.partner_text}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<span
className={`inline-block text-xs px-2 py-0.5 rounded text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
>
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
</span>
{/* Review Verdict Badge */}
{review && (
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded ${
review.verdict === 'pass'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: review.verdict === 'flag'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
title={review.scores ? `Scores: ${review.scores.guidelineCompliance}/${review.scores.creativity}/${review.scores.clarity}/${review.scores.intensityAccuracy}` : ''}
>
{review.verdict === 'pass' && <CheckCircle2 className="h-3 w-3" />}
{review.verdict === 'flag' && <AlertTriangle className="h-3 w-3" />}
{review.verdict === 'reject' && <XCircle className="h-3 w-3" />}
{review.verdict.toUpperCase()}
</span>
)}
</div>
{/* Review Issues/Suggestions */}
{review && ((review.issues && review.issues.length > 0) || review.suggestions) && (
<div className="text-xs space-y-1 pt-1 border-t border-dashed">
{review.issues && review.issues.length > 0 && (
<ul className="list-disc list-inside text-muted-foreground">
{review.issues.map((issue, idx) => (
<li key={idx}>{issue}</li>
))}
</ul>
)}
{review.suggestions && (
<p className="text-blue-600 dark:text-blue-400">
Suggestion: {review.suggestions}
</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
)}
@@ -12,6 +12,7 @@ import {
Ticket,
MessageSquareText,
Target,
Bot,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -63,6 +64,12 @@ const systemNav: NavItem[] = [
icon: <Shield className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS,
},
{
label: 'AI Settings',
href: '/ai-settings',
icon: <Bot className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
},
{
label: 'Redemption Codes',
href: '/redemption-codes',
+58
View File
@@ -0,0 +1,58 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+9
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { supabase } from '../config';
import type { User, Session } from '@supabase/supabase-js';
import { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig';
// Define admin roles
export type AdminRole = 'pack_creator' | 'super_admin';
@@ -20,6 +21,7 @@ export const PERMISSION_KEYS = {
// System permissions
MANAGE_CODES: 'manage_codes',
MANAGE_ADMINS: 'manage_admins',
MANAGE_AI_CONFIG: 'manage_ai_config',
VIEW_AUDIT_LOGS: 'view_audit_logs',
} as const;
@@ -37,6 +39,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
[PERMISSION_KEYS.VIEW_AUDIT_LOGS]: { label: 'Can view audit logs', description: 'Access audit log history', group: 'system' },
};
@@ -77,6 +80,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const storedPermissions = localStorage.getItem(PERMISSIONS_KEY);
if (storedPermissions) {
setPermissions(JSON.parse(storedPermissions));
// Preload AI config if user has an existing session (non-blocking)
preloadAiConfig().catch(console.error);
}
setLoading(false);
@@ -128,11 +133,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(perms));
setPermissions(perms);
// Preload AI config after successful login (non-blocking)
preloadAiConfig().catch(console.error);
};
const logout = async () => {
localStorage.removeItem(PERMISSIONS_KEY);
setPermissions(null);
clearAiConfigCache();
await supabase.auth.signOut();
};
+158
View File
@@ -0,0 +1,158 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
export interface AiConfig {
id: string;
openrouter_api_key: string | null;
default_model: string | null;
model_generate: string | null;
model_fix: string | null;
model_polish: string | null;
council_enabled: boolean;
council_generator_model: string | null;
council_reviewer_model: string | null;
updated_at: string | null;
updated_by: string | null;
}
interface UseAiConfigReturn {
config: AiConfig | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
updateConfig: (updates: Partial<AiConfig>) => Promise<{ error: string | null }>;
}
// Cache for the config to avoid refetching on every use
let cachedConfig: AiConfig | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function useAiConfig(): UseAiConfigReturn {
const [config, setConfig] = useState<AiConfig | null>(cachedConfig);
const [loading, setLoading] = useState(!cachedConfig);
const [error, setError] = useState<string | null>(null);
const fetchConfig = useCallback(async () => {
// Use cache if valid
if (cachedConfig && Date.now() - cacheTimestamp < CACHE_TTL) {
setConfig(cachedConfig);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const { data, error: fetchError } = await supabase
.from('ai_config')
.select('*')
.limit(1)
.maybeSingle();
if (fetchError) {
// If user doesn't have access (not super admin), that's expected
if (fetchError.code === 'PGRST116' || fetchError.message.includes('permission')) {
setError('Access denied. Super admin privileges required.');
} else {
setError(fetchError.message);
}
setConfig(null);
} else if (data) {
cachedConfig = data as AiConfig;
cacheTimestamp = Date.now();
setConfig(data as AiConfig);
} else {
// No config row exists yet
setConfig(null);
setError('AI config not initialized. Please contact support.');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch AI config');
} finally {
setLoading(false);
}
}, []);
const updateConfig = useCallback(async (updates: Partial<AiConfig>): Promise<{ error: string | null }> => {
if (!config?.id) {
return { error: 'No config to update' };
}
try {
const { error: updateError } = await supabase
.from('ai_config')
.update(updates)
.eq('id', config.id);
if (updateError) {
return { error: updateError.message };
}
// Invalidate cache and refetch
cachedConfig = null;
cacheTimestamp = 0;
await fetchConfig();
return { error: null };
} catch (err) {
return { error: err instanceof Error ? err.message : 'Failed to update AI config' };
}
}, [config?.id, fetchConfig]);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
return {
config,
loading,
error,
refetch: fetchConfig,
updateConfig,
};
}
/**
* Get the cached AI config synchronously (for use in openai.ts)
* Returns null if not yet loaded
*/
export function getCachedAiConfig(): AiConfig | null {
return cachedConfig;
}
/**
* Preload the AI config (call this on app init)
*/
export async function preloadAiConfig(): Promise<AiConfig | null> {
if (cachedConfig && Date.now() - cacheTimestamp < CACHE_TTL) {
return cachedConfig;
}
try {
const { data } = await supabase
.from('ai_config')
.select('*')
.limit(1)
.maybeSingle();
if (data) {
cachedConfig = data as AiConfig;
cacheTimestamp = Date.now();
return cachedConfig;
}
} catch {
// Silently fail - we'll fall back to env vars
}
return null;
}
/**
* Clear the config cache (useful when logging out)
*/
export function clearAiConfigCache(): void {
cachedConfig = null;
cacheTimestamp = 0;
}
+356 -9
View File
@@ -1,52 +1,149 @@
import OpenAI from 'openai';
import { getCachedAiConfig, preloadAiConfig, type AiConfig } from '@/hooks/useAiConfig';
// Note: In production, this should be called from a backend/edge function
// to avoid exposing the API key
// Cache for the OpenAI instance to avoid recreating on every call
let cachedOpenAI: OpenAI | null = null;
let cachedApiKey: string | null = null;
const getOpenAI = () => {
const openRouterKey = import.meta.env.VITE_OPENROUTER_API_KEY;
// 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) {
return new OpenAI({
cachedOpenAI = new OpenAI({
apiKey: openRouterKey,
baseURL: 'https://openrouter.ai/api/v1',
dangerouslyAllowBrowser: true,
});
cachedApiKey = openRouterKey;
return cachedOpenAI;
}
if (!openAIKey) {
throw new Error('VITE_OPENAI_API_KEY or VITE_OPENROUTER_API_KEY is missing');
throw new Error('OpenRouter API key not configured. Please configure it in AI Settings or set VITE_OPENROUTER_API_KEY environment variable.');
}
return new OpenAI({
cachedOpenAI = new OpenAI({
apiKey: openAIKey,
dangerouslyAllowBrowser: true,
});
cachedApiKey = openAIKey;
return cachedOpenAI;
};
type ModelPurpose = 'generate' | 'fix' | 'polish';
const getModel = (purpose?: ModelPurpose) => {
// First, try to get model from remote config
const remoteConfig = getCachedAiConfig();
// Granular model selection by purpose
// Falls back to VITE_AI_MODEL, then to default
// Priority: remote config > env vars > default
const defaultModel = 'gpt-4o-mini';
const fallback = import.meta.env.VITE_AI_MODEL || defaultModel;
// 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 import.meta.env.VITE_AI_MODEL_GENERATE || fallback;
return remoteConfig?.model_generate || import.meta.env.VITE_AI_MODEL_GENERATE || fallback;
case 'fix':
return import.meta.env.VITE_AI_MODEL_FIX || fallback;
return remoteConfig?.model_fix || import.meta.env.VITE_AI_MODEL_FIX || fallback;
case 'polish':
return import.meta.env.VITE_AI_MODEL_POLISH || fallback;
return remoteConfig?.model_polish || import.meta.env.VITE_AI_MODEL_POLISH || fallback;
default:
return fallback;
}
};
// =============================================================================
// COUNCIL CONFIGURATION - Multi-model review system
// =============================================================================
export interface CouncilConfig {
enabled: boolean;
generatorModel: string;
reviewerModel: string;
}
export function getCouncilConfig(): CouncilConfig {
// First, try to get config from remote
const remoteConfig = getCachedAiConfig();
if (remoteConfig) {
return {
enabled: remoteConfig.council_enabled || false,
generatorModel: remoteConfig.council_generator_model || 'anthropic/claude-3.5-sonnet',
reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5',
};
}
// Fall back to env vars
return {
enabled: import.meta.env.VITE_COUNCIL_ENABLED === 'true',
generatorModel: 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',
};
}
/**
* 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();
}
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;
};
}
export interface CouncilGenerationResult {
questions: GeneratedQuestion[];
reviews: QuestionReview[];
summary: ReviewResult['summary'] | null;
metadata: {
generatorModel: string;
reviewerModel: string | null;
generationTime: number;
reviewTime: number;
};
}
export interface GeneratedPack {
name: string;
description: string;
@@ -313,6 +410,12 @@ ANATOMICAL CONSISTENCY RULE:
- GOOD: "Give your partner a handjob" (single activity for target anatomy)
- GOOD: "Go down on your partner" (gender-neutral oral phrasing)
TIMELESS LANGUAGE RULE:
- NEVER use time-specific words like "tonight", "now", "today", "this evening", "right now"
- Activities are proposals that couples may do at ANY time in the future
- BAD: "Give your partner a massage tonight", "Text your partner right now"
- GOOD: "Give your partner a massage", "Send your partner a flirty text"
For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to the other):
1. SYMMETRIC Activities (partner_text = null):
@@ -1093,3 +1196,247 @@ ${JSON.stringify(simplifiedQuestions)}
const parsed = JSON.parse(content);
return parsed.suggestions || [];
}
// =============================================================================
// COUNCIL REVIEW FUNCTIONS
// =============================================================================
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"
`;
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('```')) {
// Remove opening ```json or ``` and closing ```
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,
},
};
}
export async function generateQuestionsWithCouncil(
packName: string,
count: number = 10,
intensity?: number,
tone: ToneLevel = 3,
packDescription?: string,
existingQuestions?: string[],
crudeLang: boolean = false,
inspiration?: string
): Promise<CouncilGenerationResult> {
const config = getCouncilConfig();
// Step 1: Generate questions
const startGen = Date.now();
const questions = await generateQuestions(
packName,
count,
intensity,
tone,
packDescription,
existingQuestions,
crudeLang,
inspiration
);
const genTime = Date.now() - startGen;
// Step 2: Review if council is enabled
if (!config.enabled) {
return {
questions,
reviews: [],
summary: null,
metadata: {
generatorModel: getModel('generate'),
reviewerModel: null,
generationTime: genTime,
reviewTime: 0,
},
};
}
const startReview = Date.now();
const isExplicit = tone >= 4;
try {
const reviewResult = await reviewGeneratedQuestions(questions, {
name: packName,
description: packDescription,
isExplicit,
tone,
});
const reviewTime = Date.now() - startReview;
return {
questions,
reviews: reviewResult.reviews,
summary: reviewResult.summary,
metadata: {
generatorModel: config.generatorModel,
reviewerModel: config.reviewerModel,
generationTime: genTime,
reviewTime,
},
};
} catch (error) {
// If review fails, return questions without review (with warning in metadata)
console.error('Council review failed:', error);
return {
questions,
reviews: [],
summary: null,
metadata: {
generatorModel: config.generatorModel,
reviewerModel: null, // null indicates review failed
generationTime: genTime,
reviewTime: 0,
},
};
}
}
+394
View File
@@ -0,0 +1,394 @@
import { useState, useEffect } from 'react';
import { useAiConfig, AiConfig } from '@/hooks/useAiConfig';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
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 } 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' },
],
};
export function AiSettingsPage() {
const { hasPermission, isSuperAdmin } = useAuth();
const { config, loading, error, updateConfig, refetch } = useAiConfig();
// Local form state
const [formData, setFormData] = useState<Partial<AiConfig>>({});
const [showApiKey, setShowApiKey] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Permission check - only super admins or those with MANAGE_AI_CONFIG can access
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
// Initialize form data when config loads
useEffect(() => {
if (config) {
setFormData({
openrouter_api_key: config.openrouter_api_key || '',
default_model: config.default_model || '',
model_generate: config.model_generate || '',
model_fix: config.model_fix || '',
model_polish: config.model_polish || '',
council_enabled: config.council_enabled || false,
council_generator_model: config.council_generator_model || '',
council_reviewer_model: config.council_reviewer_model || '',
});
setHasChanges(false);
}
}, [config]);
const handleChange = <K extends keyof AiConfig>(field: K, value: AiConfig[K]) => {
setFormData(prev => ({ ...prev, [field]: value }));
setHasChanges(true);
};
const handleSave = async () => {
setIsSaving(true);
try {
const { error: saveError } = await updateConfig(formData);
if (saveError) {
toast.error(saveError);
} else {
toast.success('AI settings saved successfully');
setHasChanges(false);
}
} catch {
toast.error('Failed to save settings');
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
if (config) {
setFormData({
openrouter_api_key: config.openrouter_api_key || '',
default_model: config.default_model || '',
model_generate: config.model_generate || '',
model_fix: config.model_fix || '',
model_polish: config.model_polish || '',
council_enabled: config.council_enabled || false,
council_generator_model: config.council_generator_model || '',
council_reviewer_model: config.council_reviewer_model || '',
});
setHasChanges(false);
}
};
if (!canManage) {
return (
<div className="flex items-center justify-center h-[50vh]">
<Alert variant="destructive" className="max-w-md">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>
You don't have permission to manage AI settings. Contact a super admin for access.
</AlertDescription>
</Alert>
</div>
);
}
if (loading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">AI Settings</h1>
<p className="text-muted-foreground">Configure AI models and API settings</p>
</div>
<div className="grid gap-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">AI Settings</h1>
<p className="text-muted-foreground">Configure AI models and API settings</p>
</div>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error loading settings</AlertTitle>
<AlertDescription className="flex items-center gap-4">
{error}
<Button variant="outline" size="sm" onClick={refetch}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">AI Settings</h1>
<p className="text-muted-foreground">
Configure AI models and API settings for content generation
</p>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<Button variant="ghost" onClick={handleReset}>
Cancel
</Button>
)}
<Button onClick={handleSave} disabled={!hasChanges || isSaving}>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{config?.updated_at && (
<p className="text-sm text-muted-foreground">
Last updated: {format(new Date(config.updated_at), 'MMM d, yyyy HH:mm')}
</p>
)}
{/* API Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="h-5 w-5" />
API Configuration
</CardTitle>
<CardDescription>
Configure your OpenRouter API key for AI model access
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">OpenRouter API Key</Label>
<div className="relative">
<Input
id="api-key"
type={showApiKey ? 'text' : 'password'}
value={formData.openrouter_api_key || ''}
onChange={(e) => handleChange('openrouter_api_key', e.target.value)}
placeholder="sk-or-v1-..."
className="pr-10 font-mono"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Get your API key from{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
openrouter.ai/keys
</a>
</p>
</div>
</CardContent>
</Card>
{/* Model Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Model Configuration
</CardTitle>
<CardDescription>
Configure which AI models to use for different tasks. Leave blank to use the default model.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="default-model">Default Model</Label>
<Input
id="default-model"
value={formData.default_model || ''}
onChange={(e) => handleChange('default_model', e.target.value)}
placeholder="openai/gpt-4o-mini"
list="generation-models"
/>
<p className="text-xs text-muted-foreground">
Fallback model when no specific model is configured
</p>
</div>
<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>
</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. A second AI model reviews generated content.
</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, generated questions are reviewed by a second AI model
</p>
</div>
<Switch
id="council-enabled"
checked={formData.council_enabled || false}
onCheckedChange={(checked: boolean) => handleChange('council_enabled', checked)}
/>
</div>
{formData.council_enabled && (
<>
<Separator />
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="council-generator">Generator Model</Label>
<Input
id="council-generator"
value={formData.council_generator_model || ''}
onChange={(e) => handleChange('council_generator_model', e.target.value)}
placeholder="anthropic/claude-3.5-sonnet"
list="generation-models"
/>
<p className="text-xs text-muted-foreground">
Model used to generate content (overrides model_generate in council mode)
</p>
</div>
<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 and score generated content
</p>
</div>
</div>
<datalist id="review-models">
{POPULAR_MODELS.review.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</datalist>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Usage Note</AlertTitle>
<AlertDescription>
Council mode uses two AI calls per generation, which increases API costs.
It's recommended to use different model providers to get diverse perspectives.
</AlertDescription>
</Alert>
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,75 @@
-- AI Configuration table for admin portal
-- Stores AI settings that can be configured by super admins
CREATE TABLE IF NOT EXISTS ai_config (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- API Configuration
openrouter_api_key text,
-- Model Configuration
default_model text DEFAULT 'openai/gpt-4o-mini',
model_generate text,
model_fix text,
model_polish text,
-- Council Configuration
council_enabled boolean DEFAULT false,
council_generator_model text DEFAULT 'anthropic/claude-3.5-sonnet',
council_reviewer_model text DEFAULT 'google/gemini-pro-1.5',
-- Metadata
updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES auth.users(id)
);
-- Only allow one row in this table (singleton pattern)
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true));
-- Insert default config row
INSERT INTO ai_config (id) VALUES (gen_random_uuid())
ON CONFLICT DO NOTHING;
-- Enable RLS
ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY;
-- Only super admins can read/write (checked via admin_users table)
CREATE POLICY "Super admins can read ai_config"
ON ai_config FOR SELECT
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'super_admin'
)
);
CREATE POLICY "Super admins can update ai_config"
ON ai_config FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'super_admin'
)
);
-- Create function to update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_ai_config_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
NEW.updated_by = auth.uid();
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create trigger to auto-update timestamp
DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config;
CREATE TRIGGER ai_config_updated_at
BEFORE UPDATE ON ai_config
FOR EACH ROW
EXECUTE FUNCTION update_ai_config_timestamp();
-- Add comment for documentation
COMMENT ON TABLE ai_config IS 'Singleton table storing AI configuration for admin portal. Only super admins can access.';