- {generatedQuestions.map((q, i) => (
-
toggleQuestionSelection(i)}
+
+ {/* Filter Buttons */}
+ {reviews && reviews.length > 0 && (
+
+
)}
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}
+
+
+ Retry
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
AI Settings
+
+ Configure AI models and API settings for content generation
+
+
+
+ {hasChanges && (
+
+ Cancel
+
+ )}
+
+
+ {isSaving ? 'Saving...' : 'Save Changes'}
+
+
+
+
+ {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"
+ />
+ setShowApiKey(!showApiKey)}
+ >
+ {showApiKey ? : }
+
+
+
+ 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 */}
+
+
+
+
+ {/* 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
+
+
+
+
+
+
+
+
+ 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.';