Add openrouter
This commit is contained in:
parent
7c03c594c1
commit
36b686bbd4
8 changed files with 380 additions and 61 deletions
|
|
@ -2,12 +2,13 @@ import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react';
|
import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
import type { AIProvider, UserPreferences } from '../types';
|
||||||
|
|
||||||
interface ApiKeyModalProps {
|
interface ApiKeyModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
apiKey: string | undefined;
|
preferences: UserPreferences;
|
||||||
onSave: (key: string | undefined) => Promise<void>;
|
onSave: (prefs: Partial<UserPreferences>) => Promise<void>;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
hasAIAccess: boolean;
|
hasAIAccess: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -15,25 +16,37 @@ interface ApiKeyModalProps {
|
||||||
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
apiKey,
|
preferences,
|
||||||
onSave,
|
onSave,
|
||||||
saving,
|
saving,
|
||||||
hasAIAccess,
|
hasAIAccess,
|
||||||
}) => {
|
}) => {
|
||||||
useBodyScrollLock(isOpen);
|
useBodyScrollLock(isOpen);
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey || '');
|
const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await onSave(localApiKey || undefined);
|
await onSave({
|
||||||
|
aiProvider: localProvider,
|
||||||
|
geminiApiKey: localGeminiKey || undefined,
|
||||||
|
openRouterApiKey: localOpenRouterKey || undefined,
|
||||||
|
openRouterModel: localOpenRouterModel || undefined,
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -58,8 +71,8 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
||||||
<Key size={24} />
|
<Key size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-black">Account Settings</h2>
|
<h2 className="text-xl font-black">AI Settings</h2>
|
||||||
<p className="text-sm opacity-80">Manage your API access</p>
|
<p className="text-sm opacity-80">Configure your AI provider</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -70,33 +83,114 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-gray-50">
|
<div className="p-6 bg-gray-50 space-y-4">
|
||||||
{hasAIAccess ? (
|
{hasAIAccess ? (
|
||||||
<div>
|
<>
|
||||||
<h3 className="font-bold text-gray-800 mb-3 flex items-center gap-2">
|
<div>
|
||||||
<Key size={18} />
|
<label className="block font-bold text-gray-800 mb-2">AI Provider</label>
|
||||||
Custom Gemini API Key
|
<div className="flex bg-gray-200 p-1 rounded-xl">
|
||||||
</h3>
|
<button
|
||||||
<p className="text-sm text-gray-500 mb-3">
|
type="button"
|
||||||
Use your own API key for quiz generation. Leave empty to use the system key.
|
onClick={() => setLocalProvider('gemini')}
|
||||||
</p>
|
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${
|
||||||
<div className="relative">
|
localProvider === 'gemini'
|
||||||
<input
|
? 'bg-white shadow-sm text-gray-800'
|
||||||
type={showApiKey ? 'text' : 'password'}
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
value={localApiKey}
|
}`}
|
||||||
onChange={(e) => setLocalApiKey(e.target.value)}
|
>
|
||||||
placeholder="AIza..."
|
Gemini
|
||||||
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"
|
</button>
|
||||||
/>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setLocalProvider('openrouter')}
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
localProvider === 'openrouter'
|
||||||
>
|
? 'bg-white shadow-sm text-gray-800'
|
||||||
{showApiKey ? <EyeOff size={20} /> : <Eye size={20} />}
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
</button>
|
}`}
|
||||||
|
>
|
||||||
|
OpenRouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{localProvider === 'gemini' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block font-bold text-gray-800 mb-2">
|
||||||
|
Gemini API Key
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
Leave empty to use the system key.
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showGeminiKey ? 'text' : 'password'}
|
||||||
|
value={localGeminiKey}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowGeminiKey(!showGeminiKey)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block font-bold text-gray-800 mb-2">
|
||||||
|
OpenRouter API Key
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
Get your key from{' '}
|
||||||
|
<a
|
||||||
|
href="https://openrouter.ai/keys"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-theme-primary hover:underline"
|
||||||
|
>
|
||||||
|
openrouter.ai/keys
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showOpenRouterKey ? 'text' : 'password'}
|
||||||
|
value={localOpenRouterKey}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOpenRouterKey(!showOpenRouterKey)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showOpenRouterKey ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block font-bold text-gray-800 mb-2">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
Default: google/gemini-3-flash-preview
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localOpenRouterModel}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<Key size={32} className="mx-auto mb-3 text-gray-400" />
|
<Key size={32} className="mx-auto mb-3 text-gray-400" />
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,18 @@ import type { Quiz, GameConfig } from '../types';
|
||||||
|
|
||||||
type GenerateMode = 'topic' | 'document';
|
type GenerateMode = 'topic' | 'document';
|
||||||
|
|
||||||
|
import type { AIProvider } from '../types';
|
||||||
|
|
||||||
interface LandingProps {
|
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;
|
onCreateManual: () => void;
|
||||||
onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
|
onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
|
||||||
onJoin: (pin: string, name: string) => void;
|
onJoin: (pin: string, name: string) => void;
|
||||||
|
|
@ -103,7 +113,10 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
|
|
||||||
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
|
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
|
||||||
const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
|
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 {
|
const {
|
||||||
quizzes,
|
quizzes,
|
||||||
|
|
@ -205,11 +218,19 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
const handleHostSubmit = (e: React.FormEvent) => {
|
const handleHostSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (canGenerate && !isLoading) {
|
if (canGenerate && !isLoading) {
|
||||||
|
const aiProvider = preferences.aiProvider || 'gemini';
|
||||||
|
const apiKey = aiProvider === 'openrouter'
|
||||||
|
? preferences.openRouterApiKey
|
||||||
|
: preferences.geminiApiKey;
|
||||||
|
|
||||||
onGenerate({
|
onGenerate({
|
||||||
topic: generateMode === 'topic' ? topic.trim() : undefined,
|
topic: generateMode === 'topic' ? topic.trim() : undefined,
|
||||||
questionCount,
|
questionCount,
|
||||||
files: generateMode === 'document' ? selectedFiles : undefined,
|
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<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
<ApiKeyModal
|
<ApiKeyModal
|
||||||
isOpen={accountSettingsOpen}
|
isOpen={accountSettingsOpen}
|
||||||
onClose={() => setAccountSettingsOpen(false)}
|
onClose={() => setAccountSettingsOpen(false)}
|
||||||
apiKey={preferences.geminiApiKey}
|
preferences={preferences}
|
||||||
onSave={async (key) => {
|
onSave={async (prefs) => {
|
||||||
await savePreferences({ ...preferences, geminiApiKey: key });
|
await savePreferences({ ...preferences, ...prefs });
|
||||||
}}
|
}}
|
||||||
saving={savingPrefs}
|
saving={savingPrefs}
|
||||||
hasAIAccess={hasAIAccess}
|
hasAIAccess={hasAIAccess}
|
||||||
|
|
|
||||||
|
|
@ -600,7 +600,15 @@ export const useGame = () => {
|
||||||
return response.json();
|
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 {
|
try {
|
||||||
setGameState('GENERATING');
|
setGameState('GENERATING');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -616,7 +624,10 @@ export const useGame = () => {
|
||||||
const generateOptions: GenerateQuizOptions = {
|
const generateOptions: GenerateQuizOptions = {
|
||||||
topic: options.topic,
|
topic: options.topic,
|
||||||
questionCount: options.questionCount,
|
questionCount: options.questionCount,
|
||||||
documents
|
documents,
|
||||||
|
aiProvider: options.aiProvider,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
openRouterModel: options.openRouterModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
const generatedQuiz = await generateQuiz(generateOptions);
|
const generatedQuiz = await generateQuiz(generateOptions);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { COLOR_SCHEMES } from '../types';
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
colorScheme: 'blue',
|
colorScheme: 'blue',
|
||||||
|
aiProvider: 'gemini',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyColorScheme = (schemeId: string) => {
|
export const applyColorScheme = (schemeId: string) => {
|
||||||
|
|
@ -42,7 +43,10 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const prefs: UserPreferences = {
|
const prefs: UserPreferences = {
|
||||||
colorScheme: data.colorScheme || 'blue',
|
colorScheme: data.colorScheme || 'blue',
|
||||||
|
aiProvider: data.aiProvider || 'gemini',
|
||||||
geminiApiKey: data.geminiApiKey || undefined,
|
geminiApiKey: data.geminiApiKey || undefined,
|
||||||
|
openRouterApiKey: data.openRouterApiKey || undefined,
|
||||||
|
openRouterModel: data.openRouterModel || undefined,
|
||||||
};
|
};
|
||||||
setPreferences(prefs);
|
setPreferences(prefs);
|
||||||
setHasAIAccess(data.hasAIAccess || false);
|
setHasAIAccess(data.hasAIAccess || false);
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,24 @@ const runMigrations = () => {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT");
|
db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT");
|
||||||
console.log("Migration: Added gemini_api_key to users");
|
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();
|
runMigrations();
|
||||||
|
|
|
||||||
|
|
@ -105,35 +105,50 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userSub = req.user!.sub;
|
const userSub = req.user!.sub;
|
||||||
|
|
||||||
const user = db.prepare(`
|
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
|
FROM users
|
||||||
WHERE id = ?
|
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 groups = req.user!.groups || [];
|
||||||
const hasAIAccess = groups.includes('kaboot-ai-access');
|
const hasAIAccess = groups.includes('kaboot-ai-access');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
colorScheme: user?.colorScheme || 'blue',
|
colorScheme: user?.colorScheme || 'blue',
|
||||||
|
aiProvider: user?.aiProvider || 'gemini',
|
||||||
geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
|
geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
|
||||||
|
openRouterApiKey: decryptForUser(user?.openRouterApiKey || null, userSub),
|
||||||
|
openRouterModel: user?.openRouterModel || null,
|
||||||
hasAIAccess,
|
hasAIAccess,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userSub = req.user!.sub;
|
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 encryptedEmail = encryptForUser(req.user!.email || null, userSub);
|
||||||
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
|
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
|
||||||
|
|
||||||
const upsertUser = db.prepare(`
|
const upsertUser = db.prepare(`
|
||||||
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login)
|
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)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
color_scheme = ?,
|
color_scheme = ?,
|
||||||
gemini_api_key = ?,
|
gemini_api_key = ?,
|
||||||
|
ai_provider = ?,
|
||||||
|
openrouter_api_key = ?,
|
||||||
|
openrouter_model = ?,
|
||||||
last_login = CURRENT_TIMESTAMP
|
last_login = CURRENT_TIMESTAMP
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -143,9 +158,15 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||||
encryptedEmail,
|
encryptedEmail,
|
||||||
encryptedDisplayName,
|
encryptedDisplayName,
|
||||||
colorScheme || 'blue',
|
colorScheme || 'blue',
|
||||||
encryptedApiKey,
|
encryptedGeminiKey,
|
||||||
|
aiProvider || 'gemini',
|
||||||
|
encryptedOpenRouterKey,
|
||||||
|
openRouterModel || null,
|
||||||
colorScheme || 'blue',
|
colorScheme || 'blue',
|
||||||
encryptedApiKey
|
encryptedGeminiKey,
|
||||||
|
aiProvider || 'gemini',
|
||||||
|
encryptedOpenRouterKey,
|
||||||
|
openRouterModel || null
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai";
|
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';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
const getClient = () => {
|
const getGeminiClient = (apiKey?: string) => {
|
||||||
const apiKey = process.env.API_KEY;
|
const key = apiKey || process.env.API_KEY;
|
||||||
if (!apiKey) {
|
if (!key) {
|
||||||
throw new Error("API_KEY environment variable is missing");
|
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 = {
|
const QUIZ_SCHEMA = {
|
||||||
type: Type.OBJECT,
|
type: Type.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
|
|
@ -40,13 +43,35 @@ const QUIZ_SCHEMA = {
|
||||||
required: ["title", "questions"]
|
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 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.`;
|
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) {
|
if (hasDocuments) {
|
||||||
const topicContext = options.topic
|
const topicContext = options.topic
|
||||||
? ` Focus on aspects related to "${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 };
|
return { uri: uploadedFile.uri, mimeType: uploadedFile.mimeType };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz> => {
|
const JSON_SCHEMA_FOR_OPENROUTER = {
|
||||||
const ai = getClient();
|
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<Quiz> {
|
||||||
|
const ai = getGeminiClient(options.apiKey);
|
||||||
|
|
||||||
const docs = options.documents || [];
|
const docs = options.documents || [];
|
||||||
const hasDocuments = docs.length > 0;
|
const hasDocuments = docs.length > 0;
|
||||||
|
|
@ -160,4 +215,91 @@ export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz>
|
||||||
|
|
||||||
const data = JSON.parse(response.text);
|
const data = JSON.parse(response.text);
|
||||||
return transformToQuiz(data);
|
return transformToQuiz(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateQuizWithOpenRouter(options: GenerateQuizOptions): Promise<Quiz> {
|
||||||
|
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<Quiz> => {
|
||||||
|
const provider = options.aiProvider || 'gemini';
|
||||||
|
|
||||||
|
if (provider === 'openrouter') {
|
||||||
|
return generateQuizWithOpenRouter(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateQuizWithGemini(options);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
8
types.ts
8
types.ts
|
|
@ -36,9 +36,14 @@ export const COLOR_SCHEMES: ColorScheme[] = [
|
||||||
{ id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' },
|
{ id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type AIProvider = 'gemini' | 'openrouter';
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
colorScheme: string;
|
colorScheme: string;
|
||||||
|
aiProvider?: AIProvider;
|
||||||
geminiApiKey?: string;
|
geminiApiKey?: string;
|
||||||
|
openRouterApiKey?: string;
|
||||||
|
openRouterModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameRole = 'HOST' | 'CLIENT';
|
export type GameRole = 'HOST' | 'CLIENT';
|
||||||
|
|
@ -127,6 +132,9 @@ export interface GenerateQuizOptions {
|
||||||
topic?: string;
|
topic?: string;
|
||||||
questionCount?: number;
|
questionCount?: number;
|
||||||
documents?: ProcessedDocument[];
|
documents?: ProcessedDocument[];
|
||||||
|
aiProvider?: AIProvider;
|
||||||
|
apiKey?: string;
|
||||||
|
openRouterModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PointsBreakdown {
|
export interface PointsBreakdown {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue