- Host can kick players from lobby (removes from game, clears presenter if needed) - Client can voluntarily leave game - Fix browser-compatible base64 decoding for document upload (atob vs Buffer)
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai";
|
|
import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument, AIProvider } from "../types";
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
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: key });
|
|
};
|
|
|
|
const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview';
|
|
|
|
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3-flash-preview';
|
|
|
|
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
|
const DEFAULT_OPENAI_MODEL = 'gpt-5-mini';
|
|
|
|
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(options: GenerateQuizOptions, hasDocuments: boolean, includeJsonExample: boolean = false): string {
|
|
const questionCount = options.questionCount || 10;
|
|
|
|
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}".`
|
|
: '';
|
|
return `Generate a quiz based on the provided content.${topicContext}
|
|
|
|
${baseInstructions}`;
|
|
}
|
|
|
|
return `Generate a trivia quiz about "${options.topic}".
|
|
|
|
${baseInstructions}`;
|
|
}
|
|
|
|
function shuffleArray<T>(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): Quiz {
|
|
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
|
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
|
|
|
const questions: Question[] = data.questions.map((q: any) => {
|
|
const shuffledOpts = shuffleArray(q.options);
|
|
|
|
const options: AnswerOption[] = 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: options,
|
|
timeLimit: 20
|
|
};
|
|
});
|
|
|
|
return {
|
|
title: data.title,
|
|
questions
|
|
};
|
|
}
|
|
|
|
async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Promise<{ uri: string; mimeType: string }> {
|
|
let data: ArrayBuffer;
|
|
|
|
if (typeof doc.content === 'string') {
|
|
const binaryString = atob(doc.content);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
data = bytes.buffer as ArrayBuffer;
|
|
} else {
|
|
data = doc.content;
|
|
}
|
|
|
|
const blob = new Blob([data], { type: doc.mimeType });
|
|
|
|
const uploadedFile = await ai.files.upload({
|
|
file: blob,
|
|
config: { mimeType: doc.mimeType! }
|
|
});
|
|
|
|
if (!uploadedFile.uri || !uploadedFile.mimeType) {
|
|
throw new Error("Failed to upload document to Gemini");
|
|
}
|
|
|
|
return { uri: uploadedFile.uri, mimeType: uploadedFile.mimeType };
|
|
}
|
|
|
|
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"]
|
|
};
|
|
|
|
const JSON_SCHEMA_FOR_OPENAI = {
|
|
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"],
|
|
additionalProperties: false
|
|
},
|
|
}
|
|
},
|
|
required: ["text", "options"],
|
|
additionalProperties: false
|
|
}
|
|
}
|
|
},
|
|
required: ["title", "questions"],
|
|
additionalProperties: false
|
|
};
|
|
|
|
async function generateQuizWithGemini(options: GenerateQuizOptions): Promise<Quiz> {
|
|
const ai = getGeminiClient(options.apiKey);
|
|
|
|
const docs = options.documents || [];
|
|
const hasDocuments = docs.length > 0;
|
|
const prompt = buildPrompt(options, hasDocuments);
|
|
|
|
let contents: any;
|
|
|
|
if (hasDocuments) {
|
|
const parts: any[] = [];
|
|
|
|
for (const doc of docs) {
|
|
if (doc.type === 'native' && doc.mimeType) {
|
|
const uploaded = await uploadNativeDocument(ai, doc);
|
|
parts.push(createPartFromUri(uploaded.uri, uploaded.mimeType));
|
|
} else if (doc.type === 'text') {
|
|
parts.push({ text: doc.content as string });
|
|
}
|
|
}
|
|
|
|
parts.push({ text: prompt });
|
|
contents = createUserContent(parts);
|
|
} else {
|
|
contents = prompt;
|
|
}
|
|
|
|
const model = options.geminiModel || DEFAULT_GEMINI_MODEL;
|
|
|
|
const response = await ai.models.generateContent({
|
|
model,
|
|
contents,
|
|
config: {
|
|
responseMimeType: "application/json",
|
|
responseSchema: QUIZ_SCHEMA
|
|
}
|
|
});
|
|
|
|
if (!response.text) {
|
|
throw new Error("Failed to generate quiz content");
|
|
}
|
|
|
|
const data = JSON.parse(response.text);
|
|
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);
|
|
}
|
|
|
|
async function generateQuizWithOpenAI(options: GenerateQuizOptions): Promise<Quiz> {
|
|
const apiKey = options.apiKey;
|
|
if (!apiKey) {
|
|
throw new Error("OpenAI API key is missing");
|
|
}
|
|
|
|
const docs = options.documents || [];
|
|
const hasDocuments = docs.length > 0;
|
|
const prompt = buildPrompt(options, hasDocuments, true);
|
|
|
|
let fullPrompt = 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') {
|
|
console.warn('Native document type not supported with OpenAI - 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.openAIModel || DEFAULT_OPENAI_MODEL;
|
|
|
|
const response = await fetch(OPENAI_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: fullPrompt
|
|
}
|
|
],
|
|
response_format: {
|
|
type: 'json_schema',
|
|
json_schema: {
|
|
name: 'quiz',
|
|
strict: true,
|
|
schema: JSON_SCHEMA_FOR_OPENAI
|
|
}
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }));
|
|
throw new Error(error.error?.message || `OpenAI API error: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
const content = result.choices?.[0]?.message?.content;
|
|
|
|
// console.log('[OpenAI] Raw response:', content);
|
|
|
|
if (!content) {
|
|
throw new Error("Failed to generate quiz content from OpenAI");
|
|
}
|
|
|
|
const data = JSON.parse(content);
|
|
return transformToQuiz(data);
|
|
}
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
|
|
|
async function generateQuizWithServer(options: GenerateQuizOptions): Promise<Quiz> {
|
|
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<boolean> {
|
|
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<Quiz> => {
|
|
const provider = options.aiProvider || 'gemini';
|
|
|
|
if (provider === 'system') {
|
|
return generateQuizWithServer(options);
|
|
}
|
|
|
|
if (provider === 'openrouter') {
|
|
return generateQuizWithOpenRouter(options);
|
|
}
|
|
|
|
if (provider === 'openai') {
|
|
return generateQuizWithOpenAI(options);
|
|
}
|
|
|
|
return generateQuizWithGemini(options);
|
|
};
|