diff --git a/components/ApiKeyModal.tsx b/components/ApiKeyModal.tsx index 28cf06b..89e3228 100644 --- a/components/ApiKeyModal.tsx +++ b/components/ApiKeyModal.tsx @@ -2,12 +2,13 @@ import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; +import type { AIProvider, UserPreferences } from '../types'; interface ApiKeyModalProps { isOpen: boolean; onClose: () => void; - apiKey: string | undefined; - onSave: (key: string | undefined) => Promise; + preferences: UserPreferences; + onSave: (prefs: Partial) => Promise; saving: boolean; hasAIAccess: boolean; } @@ -15,25 +16,37 @@ interface ApiKeyModalProps { export const ApiKeyModal: React.FC = ({ isOpen, onClose, - apiKey, + preferences, onSave, saving, hasAIAccess, }) => { useBodyScrollLock(isOpen); - const [localApiKey, setLocalApiKey] = useState(apiKey || ''); - const [showApiKey, setShowApiKey] = useState(false); + const [localProvider, setLocalProvider] = useState(preferences.aiProvider || 'gemini'); + const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || ''); + const [localOpenRouterKey, setLocalOpenRouterKey] = useState(preferences.openRouterApiKey || ''); + const [localOpenRouterModel, setLocalOpenRouterModel] = useState(preferences.openRouterModel || ''); + const [showGeminiKey, setShowGeminiKey] = useState(false); + const [showOpenRouterKey, setShowOpenRouterKey] = useState(false); useEffect(() => { if (isOpen) { - setLocalApiKey(apiKey || ''); + setLocalProvider(preferences.aiProvider || 'gemini'); + setLocalGeminiKey(preferences.geminiApiKey || ''); + setLocalOpenRouterKey(preferences.openRouterApiKey || ''); + setLocalOpenRouterModel(preferences.openRouterModel || ''); } - }, [isOpen, apiKey]); + }, [isOpen, preferences]); if (!isOpen) return null; const handleSave = async () => { - await onSave(localApiKey || undefined); + await onSave({ + aiProvider: localProvider, + geminiApiKey: localGeminiKey || undefined, + openRouterApiKey: localOpenRouterKey || undefined, + openRouterModel: localOpenRouterModel || undefined, + }); onClose(); }; @@ -58,8 +71,8 @@ export const ApiKeyModal: React.FC = ({
-

Account Settings

-

Manage your API access

+

AI Settings

+

Configure your AI provider

-
+
{hasAIAccess ? ( -
-

- - Custom Gemini API Key -

-

- Use your own API key for quiz generation. Leave empty to use the system key. -

-
- setLocalApiKey(e.target.value)} - placeholder="AIza..." - className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800" - /> - + <> +
+ +
+ + +
-
+ + {localProvider === 'gemini' ? ( +
+ +

+ Leave empty to use the system key. +

+
+ setLocalGeminiKey(e.target.value)} + placeholder="AIza..." + className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800" + /> + +
+
+ ) : ( + <> +
+ +

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

+
+ setLocalOpenRouterKey(e.target.value)} + placeholder="sk-or-..." + className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800" + /> + +
+
+
+ +

+ Default: google/gemini-3-flash-preview +

+ setLocalOpenRouterModel(e.target.value)} + placeholder="google/gemini-3-flash-preview" + className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800" + /> +
+ + )} + ) : (
diff --git a/components/Landing.tsx b/components/Landing.tsx index b9b61b1..92e0c9f 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -15,8 +15,18 @@ import type { Quiz, GameConfig } from '../types'; type GenerateMode = 'topic' | 'document'; +import type { AIProvider } from '../types'; + interface LandingProps { - onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void; + onGenerate: (options: { + topic?: string; + questionCount?: number; + files?: File[]; + useOcr?: boolean; + aiProvider?: AIProvider; + apiKey?: string; + openRouterModel?: string; + }) => void; onCreateManual: () => void; onLoadQuiz: (quiz: Quiz, quizId?: string) => void; onJoin: (pin: string, name: string) => void; @@ -103,7 +113,10 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences(); - const canUseAI = auth.isAuthenticated && (hasAIAccess || preferences.geminiApiKey); + const hasValidApiKey = preferences.aiProvider === 'openrouter' + ? !!preferences.openRouterApiKey + : (hasAIAccess || !!preferences.geminiApiKey); + const canUseAI = auth.isAuthenticated && hasValidApiKey; const { quizzes, @@ -205,11 +218,19 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const handleHostSubmit = (e: React.FormEvent) => { e.preventDefault(); if (canGenerate && !isLoading) { + const aiProvider = preferences.aiProvider || 'gemini'; + const apiKey = aiProvider === 'openrouter' + ? preferences.openRouterApiKey + : preferences.geminiApiKey; + onGenerate({ topic: generateMode === 'topic' ? topic.trim() : undefined, questionCount, files: generateMode === 'document' ? selectedFiles : undefined, - useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined + useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined, + aiProvider, + apiKey, + openRouterModel: aiProvider === 'openrouter' ? preferences.openRouterModel : undefined, }); } }; @@ -597,9 +618,9 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on setAccountSettingsOpen(false)} - apiKey={preferences.geminiApiKey} - onSave={async (key) => { - await savePreferences({ ...preferences, geminiApiKey: key }); + preferences={preferences} + onSave={async (prefs) => { + await savePreferences({ ...preferences, ...prefs }); }} saving={savingPrefs} hasAIAccess={hasAIAccess} diff --git a/hooks/useGame.ts b/hooks/useGame.ts index c3a5696..090a654 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -600,7 +600,15 @@ export const useGame = () => { return response.json(); }; - const startQuizGen = async (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => { + const startQuizGen = async (options: { + topic?: string; + questionCount?: number; + files?: File[]; + useOcr?: boolean; + aiProvider?: 'gemini' | 'openrouter'; + apiKey?: string; + openRouterModel?: string; + }) => { try { setGameState('GENERATING'); setError(null); @@ -616,7 +624,10 @@ export const useGame = () => { const generateOptions: GenerateQuizOptions = { topic: options.topic, questionCount: options.questionCount, - documents + documents, + aiProvider: options.aiProvider, + apiKey: options.apiKey, + openRouterModel: options.openRouterModel, }; const generatedQuiz = await generateQuiz(generateOptions); diff --git a/hooks/useUserPreferences.ts b/hooks/useUserPreferences.ts index af188b6..ee23703 100644 --- a/hooks/useUserPreferences.ts +++ b/hooks/useUserPreferences.ts @@ -6,6 +6,7 @@ import { COLOR_SCHEMES } from '../types'; const DEFAULT_PREFERENCES: UserPreferences = { colorScheme: 'blue', + aiProvider: 'gemini', }; export const applyColorScheme = (schemeId: string) => { @@ -42,7 +43,10 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { const data = await response.json(); const prefs: UserPreferences = { colorScheme: data.colorScheme || 'blue', + aiProvider: data.aiProvider || 'gemini', geminiApiKey: data.geminiApiKey || undefined, + openRouterApiKey: data.openRouterApiKey || undefined, + openRouterModel: data.openRouterModel || undefined, }; setPreferences(prefs); setHasAIAccess(data.hasAIAccess || false); diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index ce70dfd..2e80937 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -72,6 +72,24 @@ const runMigrations = () => { db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT"); console.log("Migration: Added gemini_api_key to users"); } + + const hasAiProvider = userTableInfo2.some(col => col.name === "ai_provider"); + if (!hasAiProvider) { + db.exec("ALTER TABLE users ADD COLUMN ai_provider TEXT DEFAULT 'gemini'"); + console.log("Migration: Added ai_provider to users"); + } + + const hasOpenRouterKey = userTableInfo2.some(col => col.name === "openrouter_api_key"); + if (!hasOpenRouterKey) { + db.exec("ALTER TABLE users ADD COLUMN openrouter_api_key TEXT"); + console.log("Migration: Added openrouter_api_key to users"); + } + + const hasOpenRouterModel = userTableInfo2.some(col => col.name === "openrouter_model"); + if (!hasOpenRouterModel) { + db.exec("ALTER TABLE users ADD COLUMN openrouter_model TEXT"); + console.log("Migration: Added openrouter_model to users"); + } }; runMigrations(); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 91071ab..4ff063d 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -105,35 +105,50 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => { const userSub = req.user!.sub; const user = db.prepare(` - SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey + SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey, + ai_provider as aiProvider, openrouter_api_key as openRouterApiKey, + openrouter_model as openRouterModel FROM users WHERE id = ? - `).get(userSub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined; + `).get(userSub) as { + colorScheme: string | null; + geminiApiKey: string | null; + aiProvider: string | null; + openRouterApiKey: string | null; + openRouterModel: string | null; + } | undefined; const groups = req.user!.groups || []; const hasAIAccess = groups.includes('kaboot-ai-access'); res.json({ colorScheme: user?.colorScheme || 'blue', + aiProvider: user?.aiProvider || 'gemini', geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub), + openRouterApiKey: decryptForUser(user?.openRouterApiKey || null, userSub), + openRouterModel: user?.openRouterModel || null, hasAIAccess, }); }); router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { const userSub = req.user!.sub; - const { colorScheme, geminiApiKey } = req.body; + const { colorScheme, geminiApiKey, aiProvider, openRouterApiKey, openRouterModel } = req.body; - const encryptedApiKey = encryptForUser(geminiApiKey || null, userSub); + const encryptedGeminiKey = encryptForUser(geminiApiKey || null, userSub); + const encryptedOpenRouterKey = encryptForUser(openRouterApiKey || null, userSub); const encryptedEmail = encryptForUser(req.user!.email || null, userSub); const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub); const upsertUser = db.prepare(` - INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, ai_provider, openrouter_api_key, openrouter_model, last_login) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET color_scheme = ?, gemini_api_key = ?, + ai_provider = ?, + openrouter_api_key = ?, + openrouter_model = ?, last_login = CURRENT_TIMESTAMP `); @@ -143,9 +158,15 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { encryptedEmail, encryptedDisplayName, colorScheme || 'blue', - encryptedApiKey, + encryptedGeminiKey, + aiProvider || 'gemini', + encryptedOpenRouterKey, + openRouterModel || null, colorScheme || 'blue', - encryptedApiKey + encryptedGeminiKey, + aiProvider || 'gemini', + encryptedOpenRouterKey, + openRouterModel || null ); res.json({ success: true }); diff --git a/services/geminiService.ts b/services/geminiService.ts index edcef00..f735f8b 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -1,15 +1,18 @@ import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai"; -import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument } from "../types"; +import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument, AIProvider } from "../types"; import { v4 as uuidv4 } from 'uuid'; -const getClient = () => { - const apiKey = process.env.API_KEY; - if (!apiKey) { - throw new Error("API_KEY environment variable is missing"); +const getGeminiClient = (apiKey?: string) => { + const key = apiKey || process.env.API_KEY; + if (!key) { + throw new Error("Gemini API key is missing"); } - return new GoogleGenAI({ apiKey }); + return new GoogleGenAI({ apiKey: key }); }; +const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3-flash-preview'; + const QUIZ_SCHEMA = { type: Type.OBJECT, properties: { @@ -40,13 +43,35 @@ const QUIZ_SCHEMA = { required: ["title", "questions"] }; -function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean): string { +function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean, includeJsonExample: boolean = false): string { const questionCount = options.questionCount || 10; - const baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty. + let 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 (includeJsonExample) { + baseInstructions += ` + +You MUST respond with a single JSON object in this exact structure: +{ + "title": "Quiz Title Here", + "questions": [ + { + "text": "Question text here?", + "options": [ + { "text": "Option A", "isCorrect": false, "reason": "Explanation why this is wrong" }, + { "text": "Option B", "isCorrect": true, "reason": "Explanation why this is correct" }, + { "text": "Option C", "isCorrect": false, "reason": "Explanation why this is wrong" }, + { "text": "Option D", "isCorrect": false, "reason": "Explanation why this is wrong" } + ] + } + ] +} + +Return ONLY valid JSON with no additional text before or after.`; + } + if (hasDocuments) { const topicContext = options.topic ? ` Focus on aspects related to "${options.topic}".` @@ -118,8 +143,38 @@ async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Pr return { uri: uploadedFile.uri, mimeType: uploadedFile.mimeType }; } -export const generateQuiz = async (options: GenerateQuizOptions): Promise => { - const ai = getClient(); +const JSON_SCHEMA_FOR_OPENROUTER = { + type: "object", + properties: { + title: { type: "string", description: "A catchy title for the quiz" }, + questions: { + type: "array", + items: { + type: "object", + properties: { + text: { type: "string", description: "The question text" }, + options: { + type: "array", + items: { + type: "object", + properties: { + text: { type: "string" }, + isCorrect: { type: "boolean" }, + reason: { type: "string", description: "Brief explanation of why this answer is correct or incorrect" } + }, + required: ["text", "isCorrect", "reason"] + }, + } + }, + required: ["text", "options"] + } + } + }, + required: ["title", "questions"] +}; + +async function generateQuizWithGemini(options: GenerateQuizOptions): Promise { + const ai = getGeminiClient(options.apiKey); const docs = options.documents || []; const hasDocuments = docs.length > 0; @@ -160,4 +215,91 @@ export const generateQuiz = async (options: GenerateQuizOptions): Promise const data = JSON.parse(response.text); return transformToQuiz(data); +} + +async function generateQuizWithOpenRouter(options: GenerateQuizOptions): Promise { + const apiKey = options.apiKey; + if (!apiKey) { + throw new Error("OpenRouter API key is missing"); + } + + const docs = options.documents || []; + const hasDocuments = docs.length > 0; + const prompt = buildPrompt(options, hasDocuments, true); + + let fullPrompt = prompt; + + // For OpenRouter, we can't upload files directly - include text content in the prompt + if (hasDocuments) { + const textParts: string[] = []; + for (const doc of docs) { + if (doc.type === 'text') { + textParts.push(doc.content as string); + } else if (doc.type === 'native') { + // For native documents, they should have been converted to text on the backend + // If not, skip them with a warning + console.warn('Native document type not supported with OpenRouter - document will be skipped'); + } + } + + if (textParts.length > 0) { + fullPrompt = `Here is the content to create a quiz from:\n\n${textParts.join('\n\n---\n\n')}\n\n${prompt}`; + } + } + + const model = options.openRouterModel || DEFAULT_OPENROUTER_MODEL; + + const response = await fetch(OPENROUTER_API_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': window.location.origin, + 'X-Title': 'Kaboot Quiz Generator', + }, + body: JSON.stringify({ + model, + messages: [ + { + role: 'user', + content: fullPrompt + } + ], + response_format: { + type: 'json_schema', + json_schema: { + name: 'quiz', + strict: true, + schema: JSON_SCHEMA_FOR_OPENROUTER + } + } + }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + throw new Error(error.error?.message || `OpenRouter API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices?.[0]?.message?.content; + + // console.log('[OpenRouter] Raw response:', content); + + if (!content) { + throw new Error("Failed to generate quiz content from OpenRouter"); + } + + const data = JSON.parse(content); + return transformToQuiz(data); +} + +export const generateQuiz = async (options: GenerateQuizOptions): Promise => { + const provider = options.aiProvider || 'gemini'; + + if (provider === 'openrouter') { + return generateQuizWithOpenRouter(options); + } + + return generateQuizWithGemini(options); }; diff --git a/types.ts b/types.ts index 6b85014..dffe627 100644 --- a/types.ts +++ b/types.ts @@ -36,9 +36,14 @@ export const COLOR_SCHEMES: ColorScheme[] = [ { id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' }, ]; +export type AIProvider = 'gemini' | 'openrouter'; + export interface UserPreferences { colorScheme: string; + aiProvider?: AIProvider; geminiApiKey?: string; + openRouterApiKey?: string; + openRouterModel?: string; } export type GameRole = 'HOST' | 'CLIENT'; @@ -127,6 +132,9 @@ export interface GenerateQuizOptions { topic?: string; questionCount?: number; documents?: ProcessedDocument[]; + aiProvider?: AIProvider; + apiKey?: string; + openRouterModel?: string; } export interface PointsBreakdown {