From a066300ed4fb87c245247c210e71a3bbfe9b8be8 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Thu, 1 Jan 2026 00:52:06 +0000 Subject: [PATCH] Update --- apps/admin/.env.example | 7 + apps/admin/Dockerfile | 6 + apps/admin/docker-compose.prod.yml | 3 + apps/admin/docker-compose.yml | 3 + apps/admin/index.html | 2 +- apps/admin/src/App.tsx | 9 + .../src/components/ai/AiGeneratorDialog.tsx | 341 ++++++++++++--- apps/admin/src/components/layout/Sidebar.tsx | 7 + apps/admin/src/components/ui/alert.tsx | 58 +++ apps/admin/src/contexts/AuthContext.tsx | 9 + apps/admin/src/hooks/useAiConfig.ts | 158 +++++++ apps/admin/src/lib/openai.ts | 365 +++++++++++++++- apps/admin/src/pages/AiSettingsPage.tsx | 394 ++++++++++++++++++ .../20260101000000_create_ai_config_table.sql | 75 ++++ 14 files changed, 1376 insertions(+), 61 deletions(-) create mode 100644 apps/admin/src/components/ui/alert.tsx create mode 100644 apps/admin/src/hooks/useAiConfig.ts create mode 100644 apps/admin/src/pages/AiSettingsPage.tsx create mode 100644 apps/supabase/migrations/20260101000000_create_ai_config_table.sql diff --git a/apps/admin/.env.example b/apps/admin/.env.example index dcaaa06..adffaeb 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -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 diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile index 7bb6a32..9a97088 100755 --- a/apps/admin/Dockerfile +++ b/apps/admin/Dockerfile @@ -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 diff --git a/apps/admin/docker-compose.prod.yml b/apps/admin/docker-compose.prod.yml index 497c9d9..b1ef66f 100644 --- a/apps/admin/docker-compose.prod.yml +++ b/apps/admin/docker-compose.prod.yml @@ -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 diff --git a/apps/admin/docker-compose.yml b/apps/admin/docker-compose.yml index 3bf5618..b738c9b 100644 --- a/apps/admin/docker-compose.yml +++ b/apps/admin/docker-compose.yml @@ -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 diff --git a/apps/admin/index.html b/apps/admin/index.html index a97911e..3a5c690 100755 --- a/apps/admin/index.html +++ b/apps/admin/index.html @@ -2,7 +2,7 @@ - + Sauci Admin diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index c44f59b..4760795 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> + + + + } + /> {/* Catch all - redirect to dashboard */} diff --git a/apps/admin/src/components/ai/AiGeneratorDialog.tsx b/apps/admin/src/components/ai/AiGeneratorDialog.tsx index d71b19a..361dbd4 100644 --- a/apps/admin/src/components/ai/AiGeneratorDialog.tsx +++ b/apps/admin/src/components/ai/AiGeneratorDialog.tsx @@ -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(context?.isExplicit ? 5 : 3); @@ -61,11 +77,46 @@ export function AiGeneratorDialog({ const [generatedIdeas, setGeneratedIdeas] = useState([]); const [generatedPackIdeas, setGeneratedPackIdeas] = useState([]); + // Council review state + const [reviews, setReviews] = useState([]); + const [reviewSummary, setReviewSummary] = useState(null); + const [reviewMetadata, setReviewMetadata] = useState(null); + const [filter, setFilter] = useState<'all' | 'pass' | 'flag' | 'reject'>('all'); + // Selection state for questions const [selectedQuestionIndices, setSelectedQuestionIndices] = useState>(new Set()); + // Get council config + const councilConfig = getCouncilConfig(); + + // Helper to get review for a question index + const getReviewForIndex = (index: number): QuestionReview | undefined => { + if (!reviews || reviews.length === 0) return undefined; + return reviews.find(r => r.index === index); + }; + + // Filter questions based on current filter + const getFilteredQuestionIndices = (): number[] => { + if (!generatedQuestions || generatedQuestions.length === 0) { + return []; + } + if (filter === 'all' || !reviews || reviews.length === 0) { + return generatedQuestions.map((_, i) => i); + } + return reviews + .filter(r => r.verdict === filter) + .map(r => r.index); + }; + const handleGenerate = async () => { setLoading(true); + setLoadingStep(null); + // Reset council state + setReviews([]); + setReviewSummary(null); + setReviewMetadata(null); + 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(); + 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 && ( - + {councilConfig.enabled && type === 'questions' && !loading && ( +

+ Council mode enabled - questions will be reviewed by a second model +

)} - + )} {/* Generated Pack Preview */} @@ -290,6 +403,37 @@ export function AiGeneratorDialog({ {/* Generated Questions Preview */} {generatedQuestions.length > 0 && (
+ {/* Review Summary Bar */} + {reviewSummary && ( +
+
+
+ + + {reviewSummary.passed} Passed + + + + {reviewSummary.flagged} Flagged + + + + {reviewSummary.rejected} Rejected + +
+ + Quality: {reviewSummary.overallQuality}/10 + +
+ {reviewMetadata && ( +

+ Generated in {(reviewMetadata.generationTime / 1000).toFixed(1)}s + {reviewMetadata.reviewTime > 0 && ` • Reviewed in ${(reviewMetadata.reviewTime / 1000).toFixed(1)}s`} +

+ )} +
+ )} +

Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected) @@ -317,41 +461,136 @@ export function AiGeneratorDialog({

-
- {generatedQuestions.map((q, i) => ( -
toggleQuestionSelection(i)} + + {/* Filter Buttons */} + {reviews && reviews.length > 0 && ( +
+ + + + +
+ )} + +
+ {getFilteredQuestionIndices().map((i) => { + const q = generatedQuestions[i]; + const review = getReviewForIndex(i); + const isRejected = review?.verdict === 'reject'; + + return ( +
toggleQuestionSelection(i)} + > +
+ toggleQuestionSelection(i)} + onClick={(e) => e.stopPropagation()} + className="mt-0.5" + /> +
+
+

{q.text}

+ {q.partner_text && ( +

+ Partner: {q.partner_text} +

+ )} +
+
+ + {q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'} + + + {/* Review Verdict Badge */} + {review && ( + + {review.verdict === 'pass' && } + {review.verdict === 'flag' && } + {review.verdict === 'reject' && } + {review.verdict.toUpperCase()} + + )} +
+ + {/* Review Issues/Suggestions */} + {review && ((review.issues && review.issues.length > 0) || review.suggestions) && ( +
+ {review.issues && review.issues.length > 0 && ( +
    + {review.issues.map((issue, idx) => ( +
  • {issue}
  • + ))} +
+ )} + {review.suggestions && ( +

+ Suggestion: {review.suggestions} +

+ )} +
+ )} +
-
- ))} + ); + })}
)} diff --git a/apps/admin/src/components/layout/Sidebar.tsx b/apps/admin/src/components/layout/Sidebar.tsx index aecd7ca..edda53d 100644 --- a/apps/admin/src/components/layout/Sidebar.tsx +++ b/apps/admin/src/components/layout/Sidebar.tsx @@ -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: , requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS, }, + { + label: 'AI Settings', + href: '/ai-settings', + icon: , + requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG, + }, { label: 'Redemption Codes', href: '/redemption-codes', diff --git a/apps/admin/src/components/ui/alert.tsx b/apps/admin/src/components/ui/alert.tsx new file mode 100644 index 0000000..ab761d1 --- /dev/null +++ b/apps/admin/src/components/ui/alert.tsx @@ -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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/apps/admin/src/contexts/AuthContext.tsx b/apps/admin/src/contexts/AuthContext.tsx index e195436..33fc322 100644 --- a/apps/admin/src/contexts/AuthContext.tsx +++ b/apps/admin/src/contexts/AuthContext.tsx @@ -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 { localStorage.removeItem(PERMISSIONS_KEY); setPermissions(null); + clearAiConfigCache(); await supabase.auth.signOut(); }; diff --git a/apps/admin/src/hooks/useAiConfig.ts b/apps/admin/src/hooks/useAiConfig.ts new file mode 100644 index 0000000..2b6ef5e --- /dev/null +++ b/apps/admin/src/hooks/useAiConfig.ts @@ -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; + updateConfig: (updates: Partial) => 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(cachedConfig); + const [loading, setLoading] = useState(!cachedConfig); + const [error, setError] = useState(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): 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 { + 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; +} diff --git a/apps/admin/src/lib/openai.ts b/apps/admin/src/lib/openai.ts index 4fb8746..5cca677 100644 --- a/apps/admin/src/lib/openai.ts +++ b/apps/admin/src/lib/openai.ts @@ -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 { + 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 { + 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 { + 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, + }, + }; + } +} diff --git a/apps/admin/src/pages/AiSettingsPage.tsx b/apps/admin/src/pages/AiSettingsPage.tsx new file mode 100644 index 0000000..443f1e0 --- /dev/null +++ b/apps/admin/src/pages/AiSettingsPage.tsx @@ -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>({}); + 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 = (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 ( +
+ + + Access Denied + + You don't have permission to manage AI settings. Contact a super admin for access. + + +
+ ); + } + + if (loading) { + return ( +
+
+

AI Settings

+

Configure AI models and API settings

+
+
+ + + +
+
+ ); + } + + if (error) { + return ( +
+
+

AI Settings

+

Configure AI models and API settings

+
+ + + Error loading settings + + {error} + + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

AI Settings

+

+ Configure AI models and API settings for content generation +

+
+
+ {hasChanges && ( + + )} + +
+
+ + {config?.updated_at && ( +

+ Last updated: {format(new Date(config.updated_at), 'MMM d, yyyy HH:mm')} +

+ )} + + {/* API Configuration */} + + + + + API Configuration + + + Configure your OpenRouter API key for AI model access + + + +
+ +
+ handleChange('openrouter_api_key', e.target.value)} + placeholder="sk-or-v1-..." + className="pr-10 font-mono" + /> + +
+

+ Get your API key from{' '} + + openrouter.ai/keys + +

+
+
+
+ + {/* Model Configuration */} + + + + + Model Configuration + + + Configure which AI models to use for different tasks. Leave blank to use the default model. + + + +
+ + handleChange('default_model', e.target.value)} + placeholder="openai/gpt-4o-mini" + list="generation-models" + /> +

+ Fallback model when no specific model is configured +

+
+ + + +
+
+ + handleChange('model_generate', e.target.value)} + placeholder="Uses default" + list="generation-models" + /> +

+ For generating questions & content +

+
+ +
+ + handleChange('model_fix', e.target.value)} + placeholder="Uses default" + list="generation-models" + /> +

+ For analyzing & fixing content +

+
+ +
+ + handleChange('model_polish', e.target.value)} + placeholder="Uses default" + list="generation-models" + /> +

+ For polishing & improving text +

+
+
+ + {/* Datalist for model suggestions */} + + {POPULAR_MODELS.generation.map((model) => ( + + ))} + +
+
+ + {/* Council Configuration */} + + + + + Council Mode + + + Enable multi-model review for quality assurance. A second AI model reviews generated content. + + + +
+
+ +

+ When enabled, generated questions are reviewed by a second AI model +

+
+ handleChange('council_enabled', checked)} + /> +
+ + {formData.council_enabled && ( + <> + +
+
+ + handleChange('council_generator_model', e.target.value)} + placeholder="anthropic/claude-3.5-sonnet" + list="generation-models" + /> +

+ Model used to generate content (overrides model_generate in council mode) +

+
+ +
+ + handleChange('council_reviewer_model', e.target.value)} + placeholder="google/gemini-pro-1.5" + list="review-models" + /> +

+ Model used to review and score generated content +

+
+
+ + + {POPULAR_MODELS.review.map((model) => ( + + ))} + + + + + Usage Note + + Council mode uses two AI calls per generation, which increases API costs. + It's recommended to use different model providers to get diverse perspectives. + + + + )} +
+
+
+ ); +} diff --git a/apps/supabase/migrations/20260101000000_create_ai_config_table.sql b/apps/supabase/migrations/20260101000000_create_ai_config_table.sql new file mode 100644 index 0000000..f218f8d --- /dev/null +++ b/apps/supabase/migrations/20260101000000_create_ai_config_table.sql @@ -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.';