Compare commits

...

2 commits

Author SHA1 Message Date
a7ad1e9bba
System AI 2026-01-15 19:39:38 -07:00
e732256cbf
Stuff 2026-01-15 19:38:44 -07:00
10 changed files with 302 additions and 3 deletions

View file

@ -42,3 +42,10 @@ AUTHENTIK_BOOTSTRAP_TOKEN=
# OPTIONAL - Logging # OPTIONAL - Logging
# ============================================================================== # ==============================================================================
LOG_REQUESTS=false 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=

View file

@ -100,6 +100,7 @@ services:
OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/ OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/
CORS_ORIGIN: ${CORS_ORIGIN} CORS_ORIGIN: ${CORS_ORIGIN}
LOG_REQUESTS: ${LOG_REQUESTS:-true} LOG_REQUESTS: ${LOG_REQUESTS:-true}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
volumes: volumes:
- kaboot-data:/data - kaboot-data:/data
networks: networks:

View file

@ -615,7 +615,7 @@ export const useGame = () => {
questionCount?: number; questionCount?: number;
files?: File[]; files?: File[];
useOcr?: boolean; useOcr?: boolean;
aiProvider?: 'gemini' | 'openrouter' | 'openai'; aiProvider?: 'gemini' | 'openrouter' | 'openai' | 'system';
apiKey?: string; apiKey?: string;
geminiModel?: string; geminiModel?: string;
openRouterModel?: string; openRouterModel?: string;
@ -642,6 +642,7 @@ export const useGame = () => {
geminiModel: options.geminiModel, geminiModel: options.geminiModel,
openRouterModel: options.openRouterModel, openRouterModel: options.openRouterModel,
openAIModel: options.openAIModel, openAIModel: options.openAIModel,
accessToken: options.aiProvider === 'system' ? auth.user?.access_token : undefined,
}; };
const generatedQuiz = await generateQuiz(generateOptions); const generatedQuiz = await generateQuiz(generateOptions);

View file

@ -36,6 +36,7 @@ print_header
KABOOT_DOMAIN="" KABOOT_DOMAIN=""
AUTH_DOMAIN="" AUTH_DOMAIN=""
GEMINI_API_KEY=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
@ -47,12 +48,17 @@ while [[ $# -gt 0 ]]; do
AUTH_DOMAIN="$2" AUTH_DOMAIN="$2"
shift 2 shift 2
;; ;;
--gemini-key)
GEMINI_API_KEY="$2"
shift 2
;;
--help|-h) --help|-h)
echo "Usage: $0 [OPTIONS]" echo "Usage: $0 [OPTIONS]"
echo "" echo ""
echo "Options:" echo "Options:"
echo " --domain DOMAIN Main application domain (e.g., kaboot.example.com)" echo " --domain DOMAIN Main application domain (e.g., kaboot.example.com)"
echo " --auth-domain DOMAIN Authentication domain (e.g., auth.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 " --help, -h Show this help message"
echo "" echo ""
echo "If options are not provided, you will be prompted for them." echo "If options are not provided, you will be prompted for them."
@ -98,6 +104,18 @@ if [ -z "$AUTH_DOMAIN" ]; then
AUTH_DOMAIN=${AUTH_DOMAIN:-$DEFAULT_AUTH} AUTH_DOMAIN=${AUTH_DOMAIN:-$DEFAULT_AUTH}
fi 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 "" echo ""
print_step "Generating secrets..." print_step "Generating secrets..."
@ -137,6 +155,9 @@ OIDC_JWKS_URI=https://${AUTH_DOMAIN}/application/o/kaboot/jwks/
CORS_ORIGIN=https://${KABOOT_DOMAIN} CORS_ORIGIN=https://${KABOOT_DOMAIN}
NODE_ENV=production NODE_ENV=production
LOG_REQUESTS=true LOG_REQUESTS=true
# System AI (optional - server-side quiz generation)
GEMINI_API_KEY=${GEMINI_API_KEY}
EOF EOF
print_success "Created .env" print_success "Created .env"
@ -195,10 +216,18 @@ fi
print_step "Building frontend with production URLs..." print_step "Building frontend with production URLs..."
VITE_API_URL="https://${KABOOT_DOMAIN}/api" \ VITE_API_URL="https://${KABOOT_DOMAIN}" \
VITE_AUTHENTIK_URL="https://${AUTH_DOMAIN}" \ 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 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" print_success "Frontend built"
echo "" echo ""
@ -210,6 +239,11 @@ echo -e "${BOLD}Configuration Summary${NC}"
echo "────────────────────────────────────────────────────────────" echo "────────────────────────────────────────────────────────────"
echo -e " Main Domain: ${BLUE}https://${KABOOT_DOMAIN}${NC}" echo -e " Main Domain: ${BLUE}https://${KABOOT_DOMAIN}${NC}"
echo -e " Auth Domain: ${BLUE}https://${AUTH_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 ""
echo -e "${BOLD}Authentik Admin${NC}" echo -e "${BOLD}Authentik Admin${NC}"
echo "────────────────────────────────────────────────────────────" echo "────────────────────────────────────────────────────────────"

View file

@ -11,6 +11,7 @@
"test:get-token": "tsx --env-file=.env.test tests/get-token.ts" "test:get-token": "tsx --env-file=.env.test tests/get-token.ts"
}, },
"dependencies": { "dependencies": {
"@google/genai": "^0.14.1",
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",

View file

@ -7,6 +7,7 @@ import quizzesRouter from './routes/quizzes.js';
import usersRouter from './routes/users.js'; import usersRouter from './routes/users.js';
import uploadRouter from './routes/upload.js'; import uploadRouter from './routes/upload.js';
import gamesRouter from './routes/games.js'; import gamesRouter from './routes/games.js';
import generateRouter from './routes/generate.js';
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@ -91,6 +92,7 @@ app.use('/api/quizzes', quizzesRouter);
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/upload', uploadRouter); app.use('/api/upload', uploadRouter);
app.use('/api/games', gamesRouter); app.use('/api/games', gamesRouter);
app.use('/api/generate', generateRouter);
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);

View file

@ -82,3 +82,23 @@ 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();
}

View file

@ -0,0 +1,180 @@
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<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) {
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;

View file

@ -406,9 +406,61 @@ async function generateQuizWithOpenAI(options: GenerateQuizOptions): Promise<Qui
return transformToQuiz(data); 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> => { export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz> => {
const provider = options.aiProvider || 'gemini'; const provider = options.aiProvider || 'gemini';
if (provider === 'system') {
return generateQuizWithServer(options);
}
if (provider === 'openrouter') { if (provider === 'openrouter') {
return generateQuizWithOpenRouter(options); return generateQuizWithOpenRouter(options);
} }

View file

@ -36,7 +36,7 @@ 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' | 'openai'; export type AIProvider = 'gemini' | 'openrouter' | 'openai' | 'system';
export interface UserPreferences { export interface UserPreferences {
colorScheme: string; colorScheme: string;
@ -140,6 +140,7 @@ export interface GenerateQuizOptions {
geminiModel?: string; geminiModel?: string;
openRouterModel?: string; openRouterModel?: string;
openAIModel?: string; openAIModel?: string;
accessToken?: string;
} }
export interface PointsBreakdown { export interface PointsBreakdown {