diff --git a/.env.example b/.env.example index faf2832..343f7c2 100644 --- a/.env.example +++ b/.env.example @@ -42,10 +42,3 @@ AUTHENTIK_BOOTSTRAP_TOKEN= # OPTIONAL - Logging # ============================================================================== LOG_REQUESTS=false - -# ============================================================================== -# OPTIONAL - System AI (Gemini API Key for server-side quiz generation) -# If set, users can generate quizzes using "System AI" without their own key. -# Get a key at: https://aistudio.google.com/apikey -# ============================================================================== -GEMINI_API_KEY= diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a002a82..1cfec27 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -100,7 +100,6 @@ services: OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/ CORS_ORIGIN: ${CORS_ORIGIN} LOG_REQUESTS: ${LOG_REQUESTS:-true} - GEMINI_API_KEY: ${GEMINI_API_KEY:-} volumes: - kaboot-data:/data networks: diff --git a/hooks/useGame.ts b/hooks/useGame.ts index cb2a0e8..6ea27ec 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -615,7 +615,7 @@ export const useGame = () => { questionCount?: number; files?: File[]; useOcr?: boolean; - aiProvider?: 'gemini' | 'openrouter' | 'openai' | 'system'; + aiProvider?: 'gemini' | 'openrouter' | 'openai'; apiKey?: string; geminiModel?: string; openRouterModel?: string; @@ -642,7 +642,6 @@ export const useGame = () => { geminiModel: options.geminiModel, openRouterModel: options.openRouterModel, openAIModel: options.openAIModel, - accessToken: options.aiProvider === 'system' ? auth.user?.access_token : undefined, }; const generatedQuiz = await generateQuiz(generateOptions); diff --git a/scripts/setup-prod.sh b/scripts/setup-prod.sh index 0bcfb4c..00b08d6 100755 --- a/scripts/setup-prod.sh +++ b/scripts/setup-prod.sh @@ -36,7 +36,6 @@ print_header KABOOT_DOMAIN="" AUTH_DOMAIN="" -GEMINI_API_KEY="" while [[ $# -gt 0 ]]; do case $1 in @@ -48,17 +47,12 @@ while [[ $# -gt 0 ]]; do AUTH_DOMAIN="$2" shift 2 ;; - --gemini-key) - GEMINI_API_KEY="$2" - shift 2 - ;; --help|-h) echo "Usage: $0 [OPTIONS]" echo "" echo "Options:" echo " --domain DOMAIN Main application domain (e.g., kaboot.example.com)" echo " --auth-domain DOMAIN Authentication domain (e.g., auth.example.com)" - echo " --gemini-key KEY Gemini API key for system AI (optional)" echo " --help, -h Show this help message" echo "" echo "If options are not provided, you will be prompted for them." @@ -104,18 +98,6 @@ if [ -z "$AUTH_DOMAIN" ]; then AUTH_DOMAIN=${AUTH_DOMAIN:-$DEFAULT_AUTH} fi -echo "" -echo -e "${BOLD}System AI Configuration (Optional)${NC}" -echo "────────────────────────────────────────────────────────────" -echo "You can provide a Gemini API key to enable AI quiz generation" -echo "for all users without requiring them to set up their own key." -echo -e "${YELLOW}Note: This key will be embedded in the frontend and visible to users.${NC}" -echo "" - -if [ -z "$GEMINI_API_KEY" ]; then - read -p "Enter Gemini API key (or press Enter to skip): " GEMINI_API_KEY -fi - echo "" print_step "Generating secrets..." @@ -155,9 +137,6 @@ OIDC_JWKS_URI=https://${AUTH_DOMAIN}/application/o/kaboot/jwks/ CORS_ORIGIN=https://${KABOOT_DOMAIN} NODE_ENV=production LOG_REQUESTS=true - -# System AI (optional - server-side quiz generation) -GEMINI_API_KEY=${GEMINI_API_KEY} EOF print_success "Created .env" @@ -216,18 +195,10 @@ fi print_step "Building frontend with production URLs..." -VITE_API_URL="https://${KABOOT_DOMAIN}" \ +VITE_API_URL="https://${KABOOT_DOMAIN}/api" \ VITE_AUTHENTIK_URL="https://${AUTH_DOMAIN}" \ -VITE_OIDC_CLIENT_ID="kaboot-spa" \ -VITE_OIDC_APP_SLUG="kaboot" \ npm run build --silent 2>/dev/null || npm run build -if [ -n "$GEMINI_API_KEY" ]; then - print_success "System AI will be available (key configured for backend)" -else - print_warning "No Gemini API key provided - users must configure their own" -fi - print_success "Frontend built" echo "" @@ -239,11 +210,6 @@ echo -e "${BOLD}Configuration Summary${NC}" echo "────────────────────────────────────────────────────────────" echo -e " Main Domain: ${BLUE}https://${KABOOT_DOMAIN}${NC}" echo -e " Auth Domain: ${BLUE}https://${AUTH_DOMAIN}${NC}" -if [ -n "$GEMINI_API_KEY" ]; then - echo -e " System AI: ${GREEN}Enabled${NC}" -else - echo -e " System AI: ${YELLOW}Disabled (users need own API key)${NC}" -fi echo "" echo -e "${BOLD}Authentik Admin${NC}" echo "────────────────────────────────────────────────────────────" diff --git a/server/package.json b/server/package.json index e8c603e..53307be 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,6 @@ "test:get-token": "tsx --env-file=.env.test tests/get-token.ts" }, "dependencies": { - "@google/genai": "^0.14.1", "better-sqlite3": "^11.7.0", "cors": "^2.8.5", "express": "^4.21.2", diff --git a/server/src/index.ts b/server/src/index.ts index e77fd69..3a994b1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,7 +7,6 @@ import quizzesRouter from './routes/quizzes.js'; import usersRouter from './routes/users.js'; import uploadRouter from './routes/upload.js'; import gamesRouter from './routes/games.js'; -import generateRouter from './routes/generate.js'; const app = express(); const PORT = process.env.PORT || 3001; @@ -92,7 +91,6 @@ app.use('/api/quizzes', quizzesRouter); app.use('/api/users', usersRouter); app.use('/api/upload', uploadRouter); app.use('/api/games', gamesRouter); -app.use('/api/generate', generateRouter); app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { console.error('Unhandled error:', err); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 0f003e1..bbf6c14 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -82,23 +82,3 @@ export function requireAuth( } ); } - -export function requireAIAccess( - req: AuthenticatedRequest, - res: Response, - next: NextFunction -): void { - if (!req.user) { - res.status(401).json({ error: 'Authentication required' }); - return; - } - - const hasAccess = req.user.groups?.includes('kaboot-ai-access'); - - if (!hasAccess) { - res.status(403).json({ error: 'AI access not granted for this account' }); - return; - } - - next(); -} diff --git a/server/src/routes/generate.ts b/server/src/routes/generate.ts deleted file mode 100644 index 693196a..0000000 --- a/server/src/routes/generate.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Router, Response } from 'express'; -import { GoogleGenAI, Type, createUserContent, createPartFromUri } from '@google/genai'; -import { requireAuth, AuthenticatedRequest, requireAIAccess } from '../middleware/auth.js'; -import { v4 as uuidv4 } from 'uuid'; - -const router = Router(); - -const GEMINI_API_KEY = process.env.GEMINI_API_KEY; -const DEFAULT_MODEL = 'gemini-2.5-flash-preview-05-20'; - -interface GenerateRequest { - topic: string; - questionCount?: number; - documents?: Array<{ - type: 'text' | 'native'; - content: string; - mimeType?: string; - }>; -} - -const QUIZ_SCHEMA = { - type: Type.OBJECT, - properties: { - title: { type: Type.STRING, description: "A catchy title for the quiz" }, - questions: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - text: { type: Type.STRING, description: "The question text" }, - options: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - text: { type: Type.STRING }, - isCorrect: { type: Type.BOOLEAN }, - reason: { type: Type.STRING, description: "Brief explanation of why this answer is correct or incorrect" } - }, - required: ["text", "isCorrect", "reason"] - }, - } - }, - required: ["text", "options"] - } - } - }, - required: ["title", "questions"] -}; - -function buildPrompt(topic: string, questionCount: number, hasDocuments: boolean): string { - const baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty. - -IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`; - - if (hasDocuments) { - const topicContext = topic ? ` Focus on aspects related to "${topic}".` : ''; - return `Generate a quiz based on the provided content.${topicContext}\n\n${baseInstructions}`; - } - - return `Generate a trivia quiz about "${topic}".\n\n${baseInstructions}`; -} - -function shuffleArray(array: T[]): T[] { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; -} - -function transformToQuiz(data: any) { - const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; - const colors = ['red', 'blue', 'yellow', 'green'] as const; - - const questions = data.questions.map((q: any) => { - const shuffledOpts = shuffleArray(q.options); - - const options = shuffledOpts.map((opt: any, index: number) => ({ - text: opt.text, - isCorrect: opt.isCorrect, - shape: shapes[index % 4], - color: colors[index % 4], - reason: opt.reason - })); - - return { - id: uuidv4(), - text: q.text, - options, - timeLimit: 20 - }; - }); - - return { - title: data.title, - questions - }; -} - -router.get('/status', (_req, res: Response) => { - res.json({ - available: !!GEMINI_API_KEY, - model: DEFAULT_MODEL - }); -}); - -router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest, res: Response) => { - if (!GEMINI_API_KEY) { - res.status(503).json({ error: 'System AI is not configured' }); - return; - } - - const { topic, questionCount = 10, documents = [] } = req.body as GenerateRequest; - - if (!topic && documents.length === 0) { - res.status(400).json({ error: 'Topic or documents required' }); - return; - } - - try { - const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); - const hasDocuments = documents.length > 0; - const prompt = buildPrompt(topic, questionCount, hasDocuments); - - let contents: any; - - if (hasDocuments) { - const parts: any[] = []; - - for (const doc of documents) { - if (doc.type === 'native' && doc.mimeType) { - const buffer = Buffer.from(doc.content, 'base64'); - const blob = new Blob([buffer], { type: doc.mimeType }); - - const uploadedFile = await ai.files.upload({ - file: blob, - config: { mimeType: doc.mimeType } - }); - - if (uploadedFile.uri && uploadedFile.mimeType) { - parts.push(createPartFromUri(uploadedFile.uri, uploadedFile.mimeType)); - } - } else if (doc.type === 'text') { - parts.push({ text: doc.content }); - } - } - - parts.push({ text: prompt }); - contents = createUserContent(parts); - } else { - contents = prompt; - } - - const response = await ai.models.generateContent({ - model: DEFAULT_MODEL, - contents, - config: { - responseMimeType: "application/json", - responseSchema: QUIZ_SCHEMA - } - }); - - if (!response.text) { - res.status(500).json({ error: 'Failed to generate quiz content' }); - return; - } - - const data = JSON.parse(response.text); - const quiz = transformToQuiz(data); - - res.json(quiz); - } catch (err: any) { - console.error('AI generation error:', err); - res.status(500).json({ error: err.message || 'Failed to generate quiz' }); - } -}); - -export default router; diff --git a/services/geminiService.ts b/services/geminiService.ts index a8794d4..87f8c40 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -406,61 +406,9 @@ async function generateQuizWithOpenAI(options: GenerateQuizOptions): Promise { - if (!options.accessToken) { - throw new Error("Authentication required for system AI"); - } - - const docs = options.documents || []; - const documentsPayload = docs.map(doc => ({ - type: doc.type, - content: doc.type === 'native' && doc.content instanceof ArrayBuffer - ? btoa(String.fromCharCode(...new Uint8Array(doc.content))) - : doc.content, - mimeType: doc.mimeType - })); - - const response = await fetch(`${API_URL}/api/generate`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${options.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - topic: options.topic, - questionCount: options.questionCount || 10, - documents: documentsPayload - }) - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Server error: ${response.status}`); - } - - return response.json(); -} - -export async function checkSystemAIAvailable(): Promise { - try { - const response = await fetch(`${API_URL}/api/generate/status`); - if (!response.ok) return false; - const data = await response.json(); - return data.available === true; - } catch { - return false; - } -} - export const generateQuiz = async (options: GenerateQuizOptions): Promise => { const provider = options.aiProvider || 'gemini'; - if (provider === 'system') { - return generateQuizWithServer(options); - } - if (provider === 'openrouter') { return generateQuizWithOpenRouter(options); } diff --git a/types.ts b/types.ts index 46064b1..414a38f 100644 --- a/types.ts +++ b/types.ts @@ -36,7 +36,7 @@ export const COLOR_SCHEMES: ColorScheme[] = [ { id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' }, ]; -export type AIProvider = 'gemini' | 'openrouter' | 'openai' | 'system'; +export type AIProvider = 'gemini' | 'openrouter' | 'openai'; export interface UserPreferences { colorScheme: string; @@ -140,7 +140,6 @@ export interface GenerateQuizOptions { geminiModel?: string; openRouterModel?: string; openAIModel?: string; - accessToken?: string; } export interface PointsBreakdown {