commit 0d20abfed9602db14323225482ae0cd505311604 Author: wyuc Date: Thu Mar 12 16:41:07 2026 +0800 Initial commit: OpenMAIC AI classroom platform Next.js 16 App Router application with: - AI-powered interactive classroom generation from PDF/text requirements - Multi-agent discussion system (teacher, students, assistant roles) - Provider abstraction for LLM, TTS, ASR, image, video, web search - Local-first data architecture (IndexedDB/Dexie) - i18n support (zh-CN/en-US) - Structured logger, SSRF guard, lint/build passing (0 errors) Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..6e93066e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build +/dist + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* +!.env.example + +# server provider config (contains API keys) +server-providers.yml +server-providers-*.yml + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# IDE +.idea +.vscode + +# generated data +/data +/logs diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..7da0064d1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "proseWrap": "preserve", + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto" +} diff --git a/app/api/azure-voices/route.ts b/app/api/azure-voices/route.ts new file mode 100644 index 000000000..14e92891c --- /dev/null +++ b/app/api/azure-voices/route.ts @@ -0,0 +1,67 @@ +import { NextRequest } from 'next/server'; +import { createLogger } from '@/lib/logger' +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Azure Voices') + + +export const maxDuration = 30; + +/** + * Azure TTS Voice List API + * Fetches available voices from Azure Speech Services + */ +export async function POST(req: NextRequest) { + try { + const { apiKey, baseUrl } = await req.json(); + + if (!apiKey) { + return apiError('MISSING_API_KEY', 400, 'API Key is required'); + } + + if (!baseUrl) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Base URL is required'); + } + + // Validate baseUrl against SSRF + const ssrfError = validateUrlForSSRF(baseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + + // Call Azure voices list endpoint; disable redirect following to prevent SSRF via redirect + const response = await fetch(`${baseUrl}/cognitiveservices/voices/list`, { + method: 'GET', + headers: { + 'Ocp-Apim-Subscription-Key': apiKey, + }, + redirect: 'manual', + }); + + if (response.status >= 300 && response.status < 400) { + return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed'); + } + + if (!response.ok) { + const errorText = await response.text(); + return apiError( + 'UPSTREAM_ERROR', + response.status, + 'Failed to fetch voices from Azure', + errorText || response.statusText, + ); + } + + const voices = await response.json(); + + return apiSuccess({ voices }); + } catch (error) { + log.error('API error:', error); + return apiError( + 'INTERNAL_ERROR', + 500, + 'Failed to fetch voices', + error instanceof Error ? error.message : 'Unknown error', + ); + } +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 000000000..fbd47219c --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,179 @@ +/** + * Stateless Chat API Endpoint + * + * POST /api/chat - Send message, receive SSE stream + * + * This endpoint: + * 1. Receives full state from client (messages + storeState) + * 2. Runs single-pass generation + * 3. Streams events as SSE (text deltas + tool calls) + * + * Fully stateless: interruption is handled by the client aborting + * the fetch request, which triggers req.signal on the server side. + */ + +import { NextRequest } from 'next/server'; +import { statelessGenerate } from '@/lib/orchestration/stateless-generate'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat'; +import type { ThinkingConfig } from '@/lib/types/provider'; +import { apiError } from '@/lib/server/api-response' +import { createLogger } from '@/lib/logger' +const log = createLogger('Chat API') + + +// Allow streaming responses up to 60 seconds +export const maxDuration = 60; + +/** + * POST /api/chat + * Send a message and receive SSE stream of generation events + * + * Request body: StatelessChatRequest + * { + * messages: UIMessage[], + * storeState: { stage, scenes, currentSceneId, mode }, + * config: { agentIds, sessionType? }, + * apiKey: string, + * baseUrl?: string, + * model?: string + * } + * + * Response: SSE stream of StatelessEvent + */ +export async function POST(req: NextRequest) { + const encoder = new TextEncoder(); + + try { + const body: StatelessChatRequest = await req.json(); + + // Validate required fields + if (!body.messages || !Array.isArray(body.messages)) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: messages'); + } + + if (!body.storeState) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: storeState'); + } + + if (!body.config || !body.config.agentIds || body.config.agentIds.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: config.agentIds'); + } + + // Resolve API key: client > server > empty + const modelString = body.model || 'gpt-4o-mini'; + const { providerId, modelId } = parseModelString(modelString); + const effectiveApiKey = resolveApiKey(providerId, body.apiKey); + const effectiveBaseUrl = resolveBaseUrl(providerId, body.baseUrl); + const proxy = resolveProxy(providerId); + + if (!effectiveApiKey) { + return apiError('MISSING_API_KEY', 401, 'API Key is required'); + } + + log.info('Processing request'); + log.info(`Agents: ${body.config.agentIds.join(', ')}, Messages: ${body.messages.length}, Turn: ${body.directorState?.turnCount ?? 0}`); + + // Create LanguageModel via the unified provider system + const { model: languageModel } = getModel({ + providerId, + modelId, + apiKey: effectiveApiKey, + baseUrl: effectiveBaseUrl, + proxy, + }); + + // Use the native request signal for abort propagation + const signal = req.signal; + + // Create SSE stream + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + + // Stream generation in background with heartbeat to prevent connection timeout + const HEARTBEAT_INTERVAL_MS = 15_000; + (async () => { + // Heartbeat: periodically send SSE comments to keep the connection alive. + // Proxies / browsers may close idle SSE connections after 30-120s of silence. + let heartbeatTimer: ReturnType | null = null; + const startHeartbeat = () => { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + try { + writer.write(encoder.encode(`:heartbeat\n\n`)).catch(() => stopHeartbeat()); + } catch { + stopHeartbeat(); + } + }, HEARTBEAT_INTERVAL_MS); + }; + const stopHeartbeat = () => { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + }; + + try { + startHeartbeat(); + + const generator = statelessGenerate( + { + ...body, + apiKey: effectiveApiKey, + }, + signal, + languageModel, + { enabled: false } satisfies ThinkingConfig, + ); + + for await (const event of generator) { + if (signal.aborted) { + log.info('Request was aborted'); + break; + } + + const data = `data: ${JSON.stringify(event)}\n\n`; + await writer.write(encoder.encode(data)); + } + + stopHeartbeat(); + await writer.close(); + } catch (error) { + stopHeartbeat(); + + // If aborted, just close the writer silently + if (signal.aborted) { + log.info('Request aborted during streaming'); + try { await writer.close(); } catch { /* already closed */ } + return; + } + + log.error('Stream error:', error); + + // Try to send error event + try { + const errorEvent: StatelessEvent = { + type: 'error', + data: { message: error instanceof Error ? error.message : String(error) }, + }; + await writer.write(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)); + await writer.close(); + } catch { + // Writer may already be closed + } + } + })(); + + return new Response(readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch (error) { + log.error('Error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed to process request'); + } +} diff --git a/app/api/generate-classroom/route.ts b/app/api/generate-classroom/route.ts new file mode 100644 index 000000000..bd62bbe3a --- /dev/null +++ b/app/api/generate-classroom/route.ts @@ -0,0 +1,252 @@ +import { NextRequest } from "next/server"; +import { getModel, parseModelString } from "@/lib/ai/providers"; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from "@/lib/server/provider-config"; +import { callLLM } from "@/lib/ai/llm"; +import { z } from "zod"; +import { nanoid } from "nanoid"; +import type { ParsedPdfContent } from "@/lib/types/pdf"; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response' +const log = createLogger('Classroom') + + +// Schema for the generated classroom structure +const SlideSchema = z.object({ + title: z.string().describe("Slide title"), + content: z.string().describe("Main text content for the slide"), + keyPoints: z.array(z.string()).describe("Key bullet points"), + imageIndex: z + .number() + .optional() + .describe("Index of image to use from PDF (0-based), if applicable"), +}); + +const ClassroomSchema = z.object({ + title: z.string().describe("Course title"), + description: z.string().describe("Course description"), + slides: z.array(SlideSchema).describe("Generated slides"), +}); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + pdfContent, + background, + model, + apiKey, + baseUrl, + providerType, + requiresApiKey, + } = body as { + pdfContent: ParsedPdfContent; + background?: string; + model?: string; + apiKey?: string; + baseUrl?: string; + providerType?: string; + requiresApiKey?: boolean; + }; + + if (!pdfContent || !pdfContent.text) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'No PDF content provided'); + } + + const modelString = model || "gpt-4o-mini"; + const { providerId, modelId } = parseModelString(modelString); + const effectiveApiKey = resolveApiKey(providerId, apiKey); + const effectiveBaseUrl = resolveBaseUrl(providerId, baseUrl); + const proxy = resolveProxy(providerId); + log.info(`Using model: provider=${providerId}, model=${modelId}`); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey: effectiveApiKey || '', + baseUrl: effectiveBaseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + const backgroundContext = background + ? `\n\nStudent background: ${background}\nPlease adapt the content complexity and examples based on this background.` + : ""; + + const imagesContext = + pdfContent.images.length > 0 + ? `\n\nNote: This PDF contains ${pdfContent.images.length} images. You can reference them in slides by including "imageIndex" field (0-based index).` + : ""; + + const systemMessage = + "You are an expert educator. You MUST respond with valid JSON only, no additional text or markdown formatting."; + const userMessage = `Create a presentation from the following PDF content. + +PDF Content: +${pdfContent.text}${backgroundContext}${imagesContext} + +Generate a JSON object with this exact structure: +{ + "title": "Course title", + "description": "Course description", + "slides": [ + { + "title": "Slide title", + "content": "Main text content for the slide", + "keyPoints": ["Point 1", "Point 2", "Point 3"], + "imageIndex": 0 // Optional: index of image to display (0-based), only include if relevant + } + ] +} + +Requirements: +1. Create an engaging title and description +2. Generate 10-15 slides that cover the main topics +3. Each slide should have a title, main content, and 3-5 key points +4. Structure the content logically and pedagogically +5. Use clear explanations with practical examples +6. If images are available, use "imageIndex" to reference them in appropriate slides + +Respond with ONLY the JSON object, no markdown code blocks or additional text.`; + + log.debug('AI Generation', { systemMessage: systemMessage.substring(0, 100), userMessageLength: userMessage.length, background: background || '(none)', imagesAvailable: pdfContent.images.length }) + + const result = await callLLM({ + model: languageModel, + messages: [ + { + role: "system", + content: systemMessage, + }, + { + role: "user", + content: userMessage, + }, + ], + maxOutputTokens: modelInfo?.outputWindow, + }, 'generate-classroom'); + + log.debug('AI Response', { length: result.text.length, preview: result.text.substring(0, 200) }) + + // Parse the JSON response + let outputData: z.infer; + try { + const jsonText = result.text + .replace(/```json\n?/g, "") + .replace(/```\n?/g, "") + .trim(); + outputData = ClassroomSchema.parse(JSON.parse(jsonText)); + + log.debug('Parsed data', { title: outputData.title, slides: outputData.slides.length }) + } catch (parseError) { + log.error('Failed to parse AI response', parseError) + throw new Error("Failed to parse AI-generated content"); + } + + // Transform the result into the Stage format + const stageId = nanoid(10); + const scenes = outputData.slides.map((slide, index) => ({ + id: `scene_${nanoid(10)}`, + type: "slide" as const, + title: slide.title, + transcript: slide.content, + order: index, + content: { + type: "slide" as const, + canvas: { + id: `slide_${nanoid(10)}`, + viewportSize: 1000, + viewportRatio: 0.5625, + theme: { + themeColors: ["#5b9bd5", "#70ad47", "#ffc000", "#ed7d31"], + fontColor: "#333333", + fontName: "Microsoft YaHei", + backgroundColor: "#ffffff", + outline: { + width: 0, + color: "#000000", + style: "solid" as const, + }, + shadow: { + h: 0, + v: 0, + blur: 0, + color: "#000000", + }, + }, + elements: [ + // Title element + { + type: "text" as const, + id: `text_${nanoid(10)}`, + left: 100, + top: 100, + width: 800, + height: 80, + rotate: 0, + content: `

${slide.title}

`, + defaultFontName: "Microsoft YaHei", + defaultColor: "#5b9bd5", + lineHeight: 1.2, + opacity: 1, + }, + // Image element (if imageIndex is provided and valid) + ...(slide.imageIndex !== undefined && + slide.imageIndex >= 0 && + slide.imageIndex < pdfContent.images.length + ? [ + { + type: "image" as const, + id: `image_${nanoid(10)}`, + left: 550, + top: 220, + width: 350, + height: 350, + rotate: 0, + src: pdfContent.images[slide.imageIndex], + fixedRatio: true, + opacity: 1, + }, + ] + : []), + // Content element + { + type: "text" as const, + id: `text_${nanoid(10)}`, + left: 100, + top: 220, + width: slide.imageIndex !== undefined ? 400 : 800, + height: 400, + rotate: 0, + content: `

${slide.content}

    ${slide.keyPoints.map((point) => `
  • ${point}
  • `).join("")}
`, + defaultFontName: "Microsoft YaHei", + defaultColor: "#333333", + lineHeight: 1.8, + opacity: 1, + }, + ], + background: { + type: "solid" as const, + color: "#ffffff", + }, + }, + }, + })); + + const stage = { + id: stageId, + name: outputData.title, + description: outputData.description, + language: "zh-CN", + style: "interactive", + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + log.debug('Final output', { stageId: stage.id, stageName: stage.name, scenesCount: scenes.length }) + + return apiSuccess({ stage, scenes }); + } catch (error) { + log.error('Error generating classroom:', error); + return apiError('INTERNAL_ERROR', 500, 'Failed to generate classroom', error instanceof Error ? error.message : 'Unknown error'); + } +} diff --git a/app/api/generate/agent-profiles/route.ts b/app/api/generate/agent-profiles/route.ts new file mode 100644 index 000000000..2469655a3 --- /dev/null +++ b/app/api/generate/agent-profiles/route.ts @@ -0,0 +1,177 @@ +/** + * Agent Profiles Generation API + * + * Generates agent profiles (teacher, assistant, student) for a course stage + * based on stage info and scene outlines. + */ + +import { NextRequest } from 'next/server'; +import { nanoid } from 'nanoid'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('Agent Profiles API'); + +export const maxDuration = 120; + +const COLOR_PALETTE = [ + '#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#06b6d4', + '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#6366f1', + '#84cc16', '#a855f7', +]; + +interface RequestBody { + stageInfo: { name: string; description?: string }; + sceneOutlines?: { title: string; description?: string }[]; + language: string; + availableAvatars: string[]; +} + +function stripCodeFences(text: string): string { + let cleaned = text.trim(); + // Remove markdown code fences (```json ... ``` or ``` ... ```) + if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); + } + return cleaned.trim(); +} + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as RequestBody; + const { stageInfo, sceneOutlines, language, availableAvatars } = body; + + // ── Validate required fields ── + if (!stageInfo?.name) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageInfo.name is required'); + } + if (!language) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'language is required'); + } + if (!availableAvatars || availableAvatars.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'availableAvatars is required and must not be empty'); + } + + // ── Model resolution from request headers ── + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // ── Build prompt ── + const sceneSummary = sceneOutlines?.length + ? sceneOutlines + .map((s, i) => `${i + 1}. ${s.title}${s.description ? ` — ${s.description}` : ''}`) + .join('\n') + : null; + + const systemPrompt = `You are an expert instructional designer. Generate agent profiles for a multi-agent classroom simulation. Decide the appropriate number of agents (typically 3-5) based on the course content and complexity. Return ONLY valid JSON, no markdown or explanation.`; + + const userPrompt = `Generate agent profiles for the following course: + +Course name: ${stageInfo.name} +${stageInfo.description ? `Course description: ${stageInfo.description}` : ''} +${sceneSummary ? `\nScene outlines:\n${sceneSummary}\n` : ''} +Requirements: +- Decide the appropriate number of agents based on the course content (typically 3-5) +- Exactly 1 agent must have role "teacher", the rest can be "assistant" or "student" +- Priority values: teacher=10 (highest), assistant=7, student=4-6 +- Each agent needs: name, role, persona (2-3 sentences describing personality and teaching/learning style) +- Names and personas must be in language: ${language} +- Each agent must be assigned one avatar from this list: ${JSON.stringify(availableAvatars)} + - Try to use different avatars for each agent +- Each agent must be assigned one color from this list: ${JSON.stringify(COLOR_PALETTE)} + - Each agent must have a different color + +Return a JSON object with this exact structure: +{ + "agents": [ + { + "name": "string", + "role": "teacher" | "assistant" | "student", + "persona": "string (2-3 sentences)", + "avatar": "string (from available list)", + "color": "string (hex color from palette)", + "priority": number (10 for teacher, 7 for assistant, 4-6 for student) + } + ] +}`; + + log.info(`Generating agent profiles for "${stageInfo.name}" [model=${modelString}]`); + + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + }, + 'agent-profiles', + ); + + // ── Parse LLM response ── + const rawText = stripCodeFences(result.text); + let parsed: { agents: Array<{ + name: string; + role: string; + persona: string; + avatar: string; + color: string; + priority: number; + }> }; + + try { + parsed = JSON.parse(rawText); + } catch { + log.error('Failed to parse LLM response as JSON:', rawText.substring(0, 500)); + return apiError('PARSE_FAILED', 500, 'Failed to parse agent profiles from LLM response'); + } + + // ── Validate parsed structure ── + if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) { + log.error(`Expected at least 2 agents, got ${parsed.agents?.length ?? 0}`); + return apiError('GENERATION_FAILED', 500, `Expected at least 2 agents but LLM returned ${parsed.agents?.length ?? 0}`); + } + + const teacherCount = parsed.agents.filter((a) => a.role === 'teacher').length; + if (teacherCount !== 1) { + log.error(`Expected exactly 1 teacher, got ${teacherCount}`); + return apiError('GENERATION_FAILED', 500, `Expected exactly 1 teacher but LLM returned ${teacherCount}`); + } + + // ── Build output with IDs ── + const agents = parsed.agents.map((agent, index) => ({ + id: `gen-${nanoid(8)}`, + name: agent.name, + role: agent.role, + persona: agent.persona, + avatar: agent.avatar || availableAvatars[index % availableAvatars.length], + color: agent.color || COLOR_PALETTE[index % COLOR_PALETTE.length], + priority: agent.priority ?? (agent.role === 'teacher' ? 10 : agent.role === 'assistant' ? 7 : 5), + })); + + log.info(`Successfully generated ${agents.length} agent profiles for "${stageInfo.name}"`); + + return apiSuccess({ agents }); + } catch (error) { + log.error('Agent profiles generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/image/route.ts b/app/api/generate/image/route.ts new file mode 100644 index 000000000..da105f9d7 --- /dev/null +++ b/app/api/generate/image/route.ts @@ -0,0 +1,77 @@ +/** + * Image Generation API + * + * Generates an image from a text prompt using the specified provider. + * Called by the client during media generation after slides are produced. + * + * POST /api/generate/image + * + * Headers: + * x-image-provider: ImageProviderId (default: 'seedream') + * x-api-key: string (optional, server fallback) + * x-base-url: string (optional, server fallback) + * + * Body: { prompt, negativePrompt?, width?, height?, aspectRatio?, style? } + * Response: { success: boolean, result?: ImageGenerationResult, error?: string } + */ + +import { NextRequest } from 'next/server'; +import { generateImage, aspectRatioToDimensions } from '@/lib/media/image-providers'; +import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-config'; +import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('ImageGeneration API'); + +export const maxDuration = 60; + +export async function POST(request: NextRequest) { + try { + const body = await request.json() as ImageGenerationOptions; + + if (!body.prompt) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt'); + } + + const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId; + const clientApiKey = request.headers.get('x-api-key') || undefined; + const clientBaseUrl = request.headers.get('x-base-url') || undefined; + const clientModel = request.headers.get('x-image-model') || undefined; + + const apiKey = resolveImageApiKey(providerId, clientApiKey); + if (!apiKey) { + return apiError('MISSING_API_KEY', 401, `No API key configured for image provider: ${providerId}`); + } + + const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl); + + // Resolve dimensions from aspect ratio if not explicitly set + if (!body.width && !body.height && body.aspectRatio) { + const dims = aspectRatioToDimensions(body.aspectRatio); + body.width = dims.width; + body.height = dims.height; + } + + log.info( + `Generating image: provider=${providerId}, model=${clientModel || 'default'}, ` + + `prompt="${body.prompt.slice(0, 80)}...", size=${body.width ?? 'auto'}x${body.height ?? 'auto'}`, + ); + + const result = await generateImage( + { providerId, apiKey, baseUrl, model: clientModel }, + body, + ); + + return apiSuccess({ result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Detect content safety filter rejections (e.g. Seedream OutputImageSensitiveContentDetected) + if (message.includes('SensitiveContent') || message.includes('sensitive information')) { + log.warn(`Image blocked by content safety filter: ${message}`); + return apiError('CONTENT_SENSITIVE', 400, message); + } + log.error('Image generation error:', error); + return apiError('INTERNAL_ERROR', 500, message); + } +} diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 000000000..163d2165c --- /dev/null +++ b/app/api/generate/route.ts @@ -0,0 +1,120 @@ +/** + * Generation API Route + * + * Triggers the three-stage generation pipeline + */ + +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { + runGenerationPipeline, + createGenerationSession, + type GenerationCallbacks, +} from '@/lib/generation/generation-pipeline'; +import type { UserRequirements } from '@/lib/types/generation'; +import type { Stage, Scene } from '@/lib/types/stage'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response' +const log = createLogger('Generate') + + +// Create a mock store adapter for server-side generation +function createMockStore(initialStage: Stage, initialScenes: Scene[]) { + let state: { + stage: Stage | null; + scenes: Scene[]; + currentSceneId: string | null; + mode: 'autonomous' | 'playback'; + } = { + stage: initialStage, + scenes: initialScenes, + currentSceneId: initialScenes[0]?.id || null, + mode: 'autonomous', + }; + + return { + getState: () => state, + setState: (newState: Partial) => { + state = { ...state, ...newState }; + }, + subscribe: () => () => {}, + }; +} + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { requirements, stage, scenes } = body as { + requirements: UserRequirements; + stage: Stage; + scenes: Scene[]; + }; + + // Get API configuration from request headers + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + // Parse model string and get configured model instance + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // AI call function for the pipeline + const aiCall = async (systemPrompt: string, userPrompt: string): Promise => { + const result = await callLLM({ + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }, 'generate-pipeline'); + return result.text; + }; + + // Create mock store + const mockStore = createMockStore(stage, scenes); + + // Create session + const session = createGenerationSession(requirements); + + // Track progress + const progressLog: string[] = []; + const callbacks: GenerationCallbacks = { + onProgress: (progress) => { + progressLog.push(`[Stage ${progress.currentStage}] ${progress.statusMessage} (${progress.overallProgress}%)`); + }, + onStageComplete: (stageNum, _result) => { + progressLog.push(`Stage ${stageNum} completed`); + }, + onError: (error) => { + progressLog.push(`Error: ${error}`); + }, + }; + + // Run pipeline + const result = await runGenerationPipeline(session, mockStore, aiCall, callbacks); + + if (!result.success) { + return apiError('GENERATION_FAILED', 500, result.error || 'Generation failed', JSON.stringify(progressLog)); + } + + // Return generated scenes + return apiSuccess({ data: { session: result.data, scenes: mockStore.getState().scenes, progressLog } }); + } catch (error) { + log.error('Generation error:', error); + return apiError('INTERNAL_ERROR', 500, String(error)); + } +} diff --git a/app/api/generate/scene-actions/route.ts b/app/api/generate/scene-actions/route.ts new file mode 100644 index 000000000..08a412aca --- /dev/null +++ b/app/api/generate/scene-actions/route.ts @@ -0,0 +1,173 @@ +/** + * Scene Actions Generation API + * + * Generates actions for a scene given its outline and content, + * then assembles the complete Scene object. + * This is the second half of the two-step scene generation pipeline. + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { + generateSceneActions, + buildCompleteScene, + buildVisionUserContent, + type SceneGenerationContext, + type AgentInfo, +} from '@/lib/generation/generation-pipeline'; +import type { SceneOutline } from '@/lib/types/generation'; +import type { + GeneratedSlideContent, + GeneratedQuizContent, + GeneratedInteractiveContent, + GeneratedPBLContent, +} from '@/lib/types/generation'; +import type { SpeechAction } from '@/lib/types/action'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('Scene Actions API'); + +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + outline, + allOutlines, + content, + stageId, + agents, + previousSpeeches: incomingPreviousSpeeches, + userProfile, + } = body as { + outline: SceneOutline; + allOutlines: SceneOutline[]; + content: GeneratedSlideContent | GeneratedQuizContent | GeneratedInteractiveContent | GeneratedPBLContent; + stageId: string; + agents?: AgentInfo[]; + previousSpeeches?: string[]; + userProfile?: string; + }; + + // Validate required fields + if (!outline) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required'); + } + if (!allOutlines || allOutlines.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty'); + } + if (!content) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'content is required'); + } + if (!stageId) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required'); + } + + // ── Model resolution from request headers ── + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // Detect vision capability + const hasVision = !!modelInfo?.capabilities?.vision; + + // AI call function (actions typically don't use vision, but kept for consistency) + const aiCall = async ( + systemPrompt: string, + userPrompt: string, + images?: Array<{ id: string; src: string }>, + ): Promise => { + if (images?.length && hasVision) { + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + messages: [ + { role: 'user' as const, content: buildVisionUserContent(userPrompt, images) }, + ], + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene-actions', + ); + return result.text; + } + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene-actions', + ); + return result.text; + }; + + // ── Build cross-scene context ── + const allTitles = allOutlines.map((o) => o.title); + const pageIndex = allOutlines.findIndex((o) => o.id === outline.id); + const ctx: SceneGenerationContext = { + pageIndex: (pageIndex >= 0 ? pageIndex : 0) + 1, + totalPages: allOutlines.length, + allTitles, + previousSpeeches: incomingPreviousSpeeches ?? [], + }; + + // ── Generate actions ── + log.info(`Generating actions: "${outline.title}" (${outline.type}) [model=${modelString}]`); + + const actions = await generateSceneActions( + outline, + content, + aiCall, + ctx, + agents, + userProfile, + ); + + log.info(`Generated ${actions.length} actions for: "${outline.title}"`); + + // ── Build complete scene ── + const scene = buildCompleteScene(outline, content, actions, stageId); + + if (!scene) { + log.error(`Failed to build scene: "${outline.title}"`); + + return apiError('GENERATION_FAILED', 500, `Failed to build scene: ${outline.title}`); + } + + // ── Extract speeches for cross-scene coherence ── + const outputPreviousSpeeches = (scene.actions || []) + .filter((a): a is SpeechAction => a.type === 'speech') + .map((a) => a.text); + + log.info( + `Scene assembled successfully: "${outline.title}" — ${scene.actions?.length ?? 0} actions`, + ); + + return apiSuccess({ scene, previousSpeeches: outputPreviousSpeeches }); + } catch (error) { + log.error('Scene actions generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene-content/route.ts b/app/api/generate/scene-content/route.ts new file mode 100644 index 000000000..2eebdbb7e --- /dev/null +++ b/app/api/generate/scene-content/route.ts @@ -0,0 +1,173 @@ +/** + * Scene Content Generation API + * + * Generates scene content (slides/quiz/interactive/pbl) from an outline. + * This is the first half of the two-step scene generation pipeline. + * Does NOT generate actions — use /api/generate/scene-actions for that. + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { + applyOutlineFallbacks, + generateSceneContent, + buildVisionUserContent, +} from '@/lib/generation/generation-pipeline'; +import type { AgentInfo } from '@/lib/generation/generation-pipeline'; +import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('Scene Content API'); + +export const maxDuration = 300; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + outline: rawOutline, + allOutlines, + pdfImages, + imageMapping, + stageInfo, + stageId, + agents, + } = body as { + outline: SceneOutline; + allOutlines: SceneOutline[]; + pdfImages?: PdfImage[]; + imageMapping?: ImageMapping; + stageInfo: { + name: string; + description?: string; + language?: string; + style?: string; + }; + stageId: string; + agents?: AgentInfo[]; + }; + + // Validate required fields + if (!rawOutline) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required'); + } + if (!allOutlines || allOutlines.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty'); + } + if (!stageId) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required'); + } + + // Ensure outline has language from stageInfo (fallback for older outlines) + const outline: SceneOutline = { + ...rawOutline, + language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN', + }; + + // ── Model resolution from request headers ── + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // Detect vision capability + const hasVision = !!modelInfo?.capabilities?.vision; + + // Vision-aware AI call function + const aiCall = async ( + systemPrompt: string, + userPrompt: string, + images?: Array<{ id: string; src: string }>, + ): Promise => { + if (images?.length && hasVision) { + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + messages: [ + { role: 'user' as const, content: buildVisionUserContent(userPrompt, images) }, + ], + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene-content', + ); + return result.text; + } + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene-content', + ); + return result.text; + }; + + // ── Apply fallbacks ── + const effectiveOutline = applyOutlineFallbacks(outline, !!languageModel); + + // ── Filter images assigned to this outline ── + let assignedImages: PdfImage[] | undefined; + if ( + pdfImages && + pdfImages.length > 0 && + effectiveOutline.suggestedImageIds && + effectiveOutline.suggestedImageIds.length > 0 + ) { + const suggestedIds = new Set(effectiveOutline.suggestedImageIds); + assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id)); + } + + // ── Media generation is handled client-side in parallel (media-orchestrator.ts) ── + // The content generator receives placeholder IDs (gen_img_1, gen_vid_1) as-is. + // resolveImageIds() in generation-pipeline.ts will keep these placeholders in elements. + const generatedMediaMapping: ImageMapping = {}; + + // ── Generate content ── + log.info(`Generating content: "${effectiveOutline.title}" (${effectiveOutline.type}) [model=${modelString}]`); + + const content = await generateSceneContent( + effectiveOutline, + aiCall, + assignedImages, + imageMapping, + effectiveOutline.type === 'pbl' ? languageModel : undefined, + hasVision, + generatedMediaMapping, + agents, + ); + + if (!content) { + log.error(`Failed to generate content for: "${effectiveOutline.title}"`); + + return apiError('GENERATION_FAILED', 500, `Failed to generate content: ${effectiveOutline.title}`); + } + + log.info(`Content generated successfully: "${effectiveOutline.title}"`); + + return apiSuccess({ content, effectiveOutline }); + } catch (error) { + log.error('Scene content generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene-outlines-stream/route.ts b/app/api/generate/scene-outlines-stream/route.ts new file mode 100644 index 000000000..df267362c --- /dev/null +++ b/app/api/generate/scene-outlines-stream/route.ts @@ -0,0 +1,322 @@ +/** + * Scene Outlines Streaming API (SSE) + * + * Streams outline generation via Server-Sent Events. + * Emits individual outline objects as they're parsed from the LLM response, + * so the frontend can display them incrementally. + * + * SSE events: + * { type: 'outline', data: SceneOutline, index: number } + * { type: 'done', outlines: SceneOutline[] } + * { type: 'error', error: string } + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { streamLLM } from '@/lib/ai/llm'; +import { buildPrompt, PROMPT_IDS } from '@/lib/generation/prompts'; +import { formatImageDescription, formatImagePlaceholder, buildVisionUserContent, uniquifyMediaElementIds, formatTeacherPersonaForPrompt } from '@/lib/generation/generation-pipeline'; +import type { AgentInfo } from '@/lib/generation/generation-pipeline'; +import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; +import { nanoid } from 'nanoid'; +import type { UserRequirements, PdfImage, SceneOutline, ImageMapping } from '@/lib/types/generation'; +import { apiError } from '@/lib/server/api-response' +import { createLogger } from '@/lib/logger' +const log = createLogger('Outlines Stream') + + +export const maxDuration = 300; + +/** + * Incremental JSON array parser. + * Extracts complete top-level objects from a partially-streamed JSON array. + * Returns newly found objects (skipping `alreadyParsed` count). + */ +function extractNewOutlines(buffer: string, alreadyParsed: number): SceneOutline[] { + const results: SceneOutline[] = []; + + // Find the start of the JSON array (skip any markdown fencing) + const stripped = buffer.replace(/^[\s\S]*?(?=\[)/, ''); + const arrayStart = stripped.indexOf('['); + if (arrayStart === -1) return results; + + let depth = 0; + let objectStart = -1; + let inString = false; + let escaped = false; + let objectCount = 0; + + for (let i = arrayStart + 1; i < stripped.length; i++) { + const char = stripped[i]; + + if (escaped) { escaped = false; continue; } + if (char === '\\' && inString) { escaped = true; continue; } + if (char === '"') { inString = !inString; continue; } + if (inString) continue; + + if (char === '{') { + if (depth === 0) objectStart = i; + depth++; + } else if (char === '}') { + depth--; + if (depth === 0 && objectStart >= 0) { + objectCount++; + if (objectCount > alreadyParsed) { + try { + const obj = JSON.parse(stripped.substring(objectStart, i + 1)); + results.push(obj); + } catch { + // Incomplete or invalid JSON — skip + } + } + objectStart = -1; + } + } + } + + return results; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // Get API configuration from request headers + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + if (!body.requirements) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required'); + } + + const { requirements, pdfText, pdfImages, imageMapping, researchContext, agents } = body as { + requirements: UserRequirements; + pdfText?: string; + pdfImages?: PdfImage[]; + imageMapping?: ImageMapping; + researchContext?: string; + agents?: AgentInfo[]; + }; + + // Detect vision capability + const hasVision = !!modelInfo?.capabilities?.vision; + + // Build prompt (same logic as generateSceneOutlinesFromRequirements) + let availableImagesText = requirements.language === 'zh-CN' ? '无可用图片' : 'No images available'; + let visionImages: Array<{ id: string; src: string }> | undefined; + + if (pdfImages && pdfImages.length > 0) { + if (hasVision && imageMapping) { + // Vision mode: split into vision images (first N) and text-only (rest) + const allWithSrc = pdfImages.filter(img => imageMapping[img.id]); + const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES); + const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES); + const noSrcImages = pdfImages.filter(img => !imageMapping[img.id]); + + const visionDescriptions = visionSlice.map(img => formatImagePlaceholder(img, requirements.language)); + const textDescriptions = [...textOnlySlice, ...noSrcImages].map(img => formatImageDescription(img, requirements.language)); + availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n'); + + visionImages = visionSlice.map(img => ({ id: img.id, src: imageMapping[img.id], width: img.width, height: img.height })); + } else { + // Text-only mode: full descriptions + availableImagesText = pdfImages.map(img => + formatImageDescription(img, requirements.language) + ).join('\n'); + } + } + + // Build media generation policy based on enabled flags + const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true'; + const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true'; + let mediaGenerationPolicy = ''; + if (!imageGenerationEnabled && !videoGenerationEnabled) { + mediaGenerationPolicy = '**IMPORTANT: Do NOT include any mediaGenerations in the outlines. Both image and video generation are disabled.**'; + } else if (!imageGenerationEnabled) { + mediaGenerationPolicy = '**IMPORTANT: Do NOT include any image mediaGenerations (type: "image") in the outlines. Image generation is disabled. Video generation is allowed.**'; + } else if (!videoGenerationEnabled) { + mediaGenerationPolicy = '**IMPORTANT: Do NOT include any video mediaGenerations (type: "video") in the outlines. Video generation is disabled. Image generation is allowed.**'; + } + + // Build teacher context from agents (if available) + const teacherContext = formatTeacherPersonaForPrompt(agents); + + const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, { + requirement: requirements.requirement, + language: requirements.language, + pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : (requirements.language === 'zh-CN' ? '无' : 'None'), + availableImages: availableImagesText, + researchContext: researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'), + mediaGenerationPolicy, + teacherContext, + }); + + if (!prompts) { + return apiError('INTERNAL_ERROR', 500, 'Prompt template not found'); + } + + log.info(`Generating outlines: "${requirements.requirement.substring(0, 50)}" [model=${modelString}]`); + + // Create SSE stream with heartbeat to prevent connection timeout + const encoder = new TextEncoder(); + const HEARTBEAT_INTERVAL_MS = 15_000; + const stream = new ReadableStream({ + async start(controller) { + // Heartbeat: periodically send SSE comments to keep the connection alive. + let heartbeatTimer: ReturnType | null = null; + const startHeartbeat = () => { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + try { + controller.enqueue(encoder.encode(`:heartbeat\n\n`)); + } catch { + stopHeartbeat(); + } + }, HEARTBEAT_INTERVAL_MS); + }; + const stopHeartbeat = () => { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + }; + + const MAX_STREAM_RETRIES = 2; + + try { + startHeartbeat(); + + const streamParams = (visionImages?.length) + ? { + model: languageModel, + system: prompts.system, + messages: [{ role: 'user' as const, content: buildVisionUserContent(prompts.user, visionImages) }], + maxOutputTokens: modelInfo?.outputWindow, + } + : { + model: languageModel, + system: prompts.system, + prompt: prompts.user, + maxOutputTokens: modelInfo?.outputWindow, + }; + + let parsedOutlines: SceneOutline[] = []; + let lastError: string | undefined; + + for (let attempt = 1; attempt <= MAX_STREAM_RETRIES + 1; attempt++) { + try { + const result = streamLLM(streamParams, 'scene-outlines-stream'); + + let fullText = ''; + parsedOutlines = []; + + for await (const chunk of result.textStream) { + fullText += chunk; + + // Try to extract new outlines from the accumulated text + const newOutlines = extractNewOutlines(fullText, parsedOutlines.length); + for (const outline of newOutlines) { + // Ensure ID and order + const enriched = { + ...outline, + id: outline.id || nanoid(), + order: parsedOutlines.length + 1, + }; + parsedOutlines.push(enriched); + + const event = JSON.stringify({ + type: 'outline', + data: enriched, + index: parsedOutlines.length - 1, + }); + controller.enqueue(encoder.encode(`data: ${event}\n\n`)); + } + } + + // Validate: got outlines? + if (parsedOutlines.length > 0) break; + + // Empty result — retry if we have attempts left + lastError = fullText.trim() + ? 'LLM response could not be parsed into outlines' + : 'LLM returned empty response'; + + if (attempt <= MAX_STREAM_RETRIES) { + log.warn(`Empty outlines (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`); + // Notify client a retry is happening + const retryEvent = JSON.stringify({ type: 'retry', attempt, maxAttempts: MAX_STREAM_RETRIES + 1 }); + controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`)); + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + + if (attempt <= MAX_STREAM_RETRIES) { + log.warn(`Stream error (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`, error); + const retryEvent = JSON.stringify({ type: 'retry', attempt, maxAttempts: MAX_STREAM_RETRIES + 1 }); + controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`)); + continue; + } + } + } + + if (parsedOutlines.length > 0) { + // Replace sequential gen_img_N/gen_vid_N with globally unique IDs + const uniquifiedOutlines = uniquifyMediaElementIds(parsedOutlines); + // Send done event with all outlines + const doneEvent = JSON.stringify({ + type: 'done', + outlines: uniquifiedOutlines, + }); + controller.enqueue(encoder.encode(`data: ${doneEvent}\n\n`)); + } else { + // All retries exhausted, no outlines produced + log.error(`Outline generation failed after ${MAX_STREAM_RETRIES + 1} attempts: ${lastError}`); + const errorEvent = JSON.stringify({ + type: 'error', + error: lastError || 'Failed to generate outlines', + }); + controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`)); + } + } catch (error) { + const errorEvent = JSON.stringify({ + type: 'error', + error: error instanceof Error ? error.message : String(error), + }); + controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`)); + } finally { + stopHeartbeat(); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } catch (error) { + log.error('Streaming error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene-outlines/route.ts b/app/api/generate/scene-outlines/route.ts new file mode 100644 index 000000000..deb8d67bb --- /dev/null +++ b/app/api/generate/scene-outlines/route.ts @@ -0,0 +1,83 @@ +/** + * Scene Outlines Generation API + * + * Generates scene outlines directly from user requirements. + */ + +import { NextRequest } from 'next/server'; +import { generateText } from 'ai'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { generateSceneOutlinesFromRequirements } from '@/lib/generation/generation-pipeline'; +import type { UserRequirements, PdfImage } from '@/lib/types/generation'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response' +const log = createLogger('Outlines') + + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // Get API configuration from request headers + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + // Parse model string and resolve server-side fallback + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // AI call function for the pipeline + const aiCall = async (systemPrompt: string, userPrompt: string): Promise => { + const result = await generateText({ + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }); + return result.text; + }; + + // Validate request body + if (!body.requirements) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required'); + } + + const { requirements, pdfText, pdfImages } = body as { + requirements: UserRequirements; + pdfText?: string; + pdfImages?: PdfImage[]; + }; + + const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true'; + const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true'; + + const result = await generateSceneOutlinesFromRequirements(requirements, pdfText, pdfImages, aiCall, undefined, { + imageGenerationEnabled, + videoGenerationEnabled, + }); + + if (!result.success) { + return apiError('GENERATION_FAILED', 500, result.error || 'Failed to generate scene outlines'); + } + + return apiSuccess({ outlines: result.data }); + } catch (error) { + log.error('Generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene/route.ts b/app/api/generate/scene/route.ts new file mode 100644 index 000000000..24d5c809a --- /dev/null +++ b/app/api/generate/scene/route.ts @@ -0,0 +1,191 @@ +/** + * Single-Scene Generation API (JSON) + * + * Generates one scene at a time from a given outline. + * No streaming, no TTS — just scene content generation. + * The client calls this once per scene and handles orchestration. + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { + buildSceneFromOutline, + buildVisionUserContent, + type SceneGenerationContext, + type AgentInfo, +} from '@/lib/generation/generation-pipeline'; +import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation'; +import type { SpeechAction } from '@/lib/types/action'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('Scene API'); + +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + outline, + allOutlines, + pdfImages, + imageMapping, + stageInfo, + stageId, + agents, + previousSpeeches: incomingPreviousSpeeches, + userProfile, + } = body as { + outline: SceneOutline; + allOutlines: SceneOutline[]; + pdfImages?: PdfImage[]; + imageMapping?: ImageMapping; + stageInfo: { + name: string; + description?: string; + language?: string; + style?: string; + }; + stageId: string; + agents?: AgentInfo[]; + previousSpeeches?: string[]; + userProfile?: string; + }; + + // Validate required fields + if (!outline) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required'); + } + if (!allOutlines || allOutlines.length === 0) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty'); + } + if (!stageId) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required'); + } + + // Ensure outline has language from stageInfo (fallback for older outlines) + if (!outline.language) { + outline.language = (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN'; + } + + // ── Model resolution from request headers ── + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel, modelInfo } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + // Detect vision capability + const hasVision = !!modelInfo?.capabilities?.vision; + + // Vision-aware AI call function for the pipeline + const aiCall = async ( + systemPrompt: string, + userPrompt: string, + images?: Array<{ id: string; src: string }>, + ): Promise => { + if (images?.length && hasVision) { + // Multimodal: use messages format with image content parts + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + messages: [ + { role: 'user' as const, content: buildVisionUserContent(userPrompt, images) }, + ], + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene', + ); + return result.text; + } + // Text-only fallback + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: modelInfo?.outputWindow, + }, + 'scene', + ); + return result.text; + }; + + // ── Build cross-scene context ── + const allTitles = allOutlines.map((o) => o.title); + const pageIndex = allOutlines.findIndex((o) => o.id === outline.id); + const ctx: SceneGenerationContext = { + pageIndex: (pageIndex >= 0 ? pageIndex : 0) + 1, + totalPages: allOutlines.length, + allTitles, + previousSpeeches: incomingPreviousSpeeches ?? [], + }; + + // ── Filter images assigned to this outline ── + let assignedImages: PdfImage[] | undefined; + if ( + pdfImages && + pdfImages.length > 0 && + outline.suggestedImageIds && + outline.suggestedImageIds.length > 0 + ) { + const suggestedIds = new Set(outline.suggestedImageIds); + assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id)); + } + + // ── Generate the scene ── + log.info(`Generating scene: "${outline.title}" (${outline.type}) [model=${modelString}]`); + + const scene = await buildSceneFromOutline( + outline, + aiCall, + stageId, + assignedImages, + imageMapping, + outline.type === 'pbl' ? languageModel : undefined, + hasVision, + ctx, + agents, + undefined, // onPhaseChange — not needed for non-streaming + userProfile, + ); + + if (!scene) { + log.error(`Failed to generate scene: "${outline.title}"`); + + return apiError('GENERATION_FAILED', 500, `Failed to generate scene: ${outline.title}`); + } + + // ── Extract speeches for cross-scene coherence ── + const outputPreviousSpeeches = (scene.actions || []) + .filter((a): a is SpeechAction => a.type === 'speech') + .map((a) => a.text); + + log.info( + `Scene generated successfully: "${outline.title}" — ${scene.actions?.length ?? 0} actions`, + ); + + return apiSuccess({ scene, previousSpeeches: outputPreviousSpeeches }); + } catch (error) { + log.error('Scene generation error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/tts/route.ts b/app/api/generate/tts/route.ts new file mode 100644 index 000000000..88f942428 --- /dev/null +++ b/app/api/generate/tts/route.ts @@ -0,0 +1,70 @@ +/** + * Single TTS Generation API + * + * Generates TTS audio for a single text string and returns base64-encoded audio. + * Called by the client in parallel for each speech action after a scene is generated. + * + * POST /api/generate/tts + */ + +import { NextRequest } from 'next/server'; +import { generateTTS } from '@/lib/audio/tts-providers'; +import { resolveTTSApiKey, resolveTTSBaseUrl } from '@/lib/server/provider-config'; +import type { TTSProviderId } from '@/lib/audio/types'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('TTS API'); + +export const maxDuration = 30; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { text, audioId, ttsProviderId, ttsVoice, ttsSpeed, ttsApiKey, ttsBaseUrl } = body as { + text: string; + audioId: string; + ttsProviderId: TTSProviderId; + ttsVoice: string; + ttsSpeed?: number; + ttsApiKey?: string; + ttsBaseUrl?: string; + }; + + // Validate required fields + if (!text || !audioId || !ttsProviderId || !ttsVoice) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required fields: text, audioId, ttsProviderId, ttsVoice'); + } + + // Reject browser-native TTS — must be handled client-side + if (ttsProviderId === 'browser-native-tts') { + return apiError('INVALID_REQUEST', 400, 'browser-native-tts must be handled client-side'); + } + + // Resolve API key and base URL (server-side fallback) + const apiKey = resolveTTSApiKey(ttsProviderId, ttsApiKey || undefined); + const baseUrl = resolveTTSBaseUrl(ttsProviderId, ttsBaseUrl || undefined); + + // Build TTS config + const config = { + providerId: ttsProviderId, + voice: ttsVoice, + speed: ttsSpeed ?? 1.0, + apiKey, + baseUrl, + }; + + log.info(`Generating TTS: provider=${ttsProviderId}, voice=${ttsVoice}, audioId=${audioId}, textLen=${text.length}`); + + // Generate audio + const { audio, format } = await generateTTS(config, text); + + // Convert to base64 + const base64 = Buffer.from(audio).toString('base64'); + + return apiSuccess({ audioId, base64, format }); + } catch (error) { + log.error('TTS generation error:', error); + return apiError('GENERATION_FAILED', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/video/route.ts b/app/api/generate/video/route.ts new file mode 100644 index 000000000..781e12220 --- /dev/null +++ b/app/api/generate/video/route.ts @@ -0,0 +1,77 @@ +/** + * Video Generation API + * + * Generates a video from a text prompt using the specified provider. + * Uses async task pattern (submit → poll) so maxDuration is set to 5 minutes. + * + * POST /api/generate/video + * + * Headers: + * x-video-provider: VideoProviderId (default: 'seedance') + * x-video-model: string (optional model override) + * x-api-key: string (optional, server fallback) + * x-base-url: string (optional, server fallback) + * + * Body: { prompt, duration?, aspectRatio?, resolution? } + * Response: { success: boolean, result?: VideoGenerationResult, error?: string } + */ + +import { NextRequest } from 'next/server'; +import { generateVideo, normalizeVideoOptions } from '@/lib/media/video-providers'; +import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-config'; +import type { VideoProviderId, VideoGenerationOptions } from '@/lib/media/types'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('VideoGeneration API'); + +export const maxDuration = 300; + +export async function POST(request: NextRequest) { + try { + const body = await request.json() as VideoGenerationOptions; + + if (!body.prompt) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt'); + } + + const providerId = (request.headers.get('x-video-provider') || 'seedance') as VideoProviderId; + const clientApiKey = request.headers.get('x-api-key') || undefined; + const clientBaseUrl = request.headers.get('x-base-url') || undefined; + const clientModel = request.headers.get('x-video-model') || undefined; + + const apiKey = resolveVideoApiKey(providerId, clientApiKey); + if (!apiKey) { + return apiError('MISSING_API_KEY', 401, `No API key configured for video provider: ${providerId}`); + } + + const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl); + + // Normalize options against provider capabilities + const options = normalizeVideoOptions(providerId, body); + + log.info( + `Generating video: provider=${providerId}, model=${clientModel || 'default'}, ` + + `prompt="${body.prompt.slice(0, 80)}...", duration=${options.duration ?? 'auto'}, ` + + `aspect=${options.aspectRatio ?? 'auto'}, resolution=${options.resolution ?? 'auto'}`, + ); + + const result = await generateVideo( + { providerId, apiKey, baseUrl, model: clientModel }, + options, + ); + + log.info(`Video generated: url=${result.url ? 'yes' : 'no'}, ${result.width}x${result.height}, ${result.duration}s`); + + return apiSuccess({ result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Detect content safety filter rejections (e.g. Seedance SensitiveContent errors) + if (message.includes('SensitiveContent') || message.includes('sensitive information')) { + log.warn(`Video blocked by content safety filter: ${message}`); + return apiError('CONTENT_SENSITIVE', 400, message); + } + log.error('Video generation error:', error); + return apiError('INTERNAL_ERROR', 500, message); + } +} diff --git a/app/api/parse-pdf/route.ts b/app/api/parse-pdf/route.ts new file mode 100644 index 000000000..50aeb7b8a --- /dev/null +++ b/app/api/parse-pdf/route.ts @@ -0,0 +1,69 @@ +import { NextRequest } from "next/server"; +import { parsePDF } from "@/lib/pdf/pdf-providers"; +import { resolvePDFApiKey, resolvePDFBaseUrl } from "@/lib/server/provider-config"; +import type { PDFProviderId } from "@/lib/pdf/types"; +import type { ParsedPdfContent } from "@/lib/types/pdf"; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Parse PDF') + + +export async function POST(req: NextRequest) { + try { + const contentType = req.headers.get('content-type') || ''; + if (!contentType.includes('multipart/form-data')) { + log.error('Invalid Content-Type for PDF upload:', contentType); + return apiError( + 'INVALID_REQUEST', + 400, + `Invalid Content-Type: expected multipart/form-data, got "${contentType}"`, + ); + } + + const formData = await req.formData(); + const pdfFile = formData.get("pdf") as File | null; + const providerId = formData.get("providerId") as PDFProviderId | null; + const apiKey = formData.get("apiKey") as string | null; + const baseUrl = formData.get("baseUrl") as string | null; + + if (!pdfFile) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'No PDF file provided'); + } + + // providerId is required from the client — no server-side store to fall back to + const effectiveProviderId = providerId || ('unpdf' as PDFProviderId); + + const config = { + providerId: effectiveProviderId, + apiKey: resolvePDFApiKey(effectiveProviderId, apiKey || undefined), + baseUrl: resolvePDFBaseUrl(effectiveProviderId, baseUrl || undefined), + }; + + // Convert PDF to buffer + const arrayBuffer = await pdfFile.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Parse PDF using the provider system + const result = await parsePDF(config, buffer); + + // Add file metadata + const resultWithMetadata: ParsedPdfContent = { + ...result, + metadata: { + pageCount: result.metadata?.pageCount || 0, // Ensure pageCount is always a number + ...result.metadata, + fileName: pdfFile.name, + fileSize: pdfFile.size, + }, + }; + + return apiSuccess({ data: resultWithMetadata }); + } catch (error) { + log.error('Error parsing PDF:', error); + return apiError( + 'PARSE_FAILED', + 500, + error instanceof Error ? error.message : 'Unknown error', + ); + } +} diff --git a/app/api/pbl/chat/route.ts b/app/api/pbl/chat/route.ts new file mode 100644 index 000000000..5f566378c --- /dev/null +++ b/app/api/pbl/chat/route.ts @@ -0,0 +1,87 @@ +/** + * PBL Runtime Chat API + * + * Handles @mention routing during PBL runtime. + * Students @question or @judge an agent, and this endpoint generates a response. + */ + +import { NextRequest } from 'next/server' +import { getModel, parseModelString } from '@/lib/ai/providers' +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config' +import { callLLM } from '@/lib/ai/llm' +import type { PBLAgent, PBLIssue } from '@/lib/pbl/types' +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response' +const log = createLogger('PBL Chat') + + +interface PBLChatRequest { + message: string + agent: PBLAgent + currentIssue: PBLIssue | null + recentMessages: { agent_name: string; message: string }[] + userRole: string + agentType?: 'question' | 'judge' +} + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as PBLChatRequest + const { message, agent, currentIssue, recentMessages, userRole, agentType } = body + + if (!message || !agent) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Message and agent are required'); + } + + // Get model config from headers (same pattern as scene-stream) + const modelString = req.headers.get('x-model') || 'gpt-4o-mini' + const clientApiKey = req.headers.get('x-api-key') || '' + const clientBaseUrl = req.headers.get('x-base-url') || undefined + const providerType = req.headers.get('x-provider-type') || undefined + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true' + + const { providerId, modelId } = parseModelString(modelString) + const apiKey = resolveApiKey(providerId, clientApiKey) + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl) + const proxy = resolveProxy(providerId); + const { model } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }) + + // Build context for the agent, differentiating question vs judge + let issueContext = '' + if (currentIssue) { + issueContext = `\n\n## Current Issue\nTitle: ${currentIssue.title}\nDescription: ${currentIssue.description}\nPerson in Charge: ${currentIssue.person_in_charge}` + if (currentIssue.generated_questions) { + if (agentType === 'judge') { + issueContext += `\n\nQuestions to Evaluate Against:\n${currentIssue.generated_questions}` + } else { + issueContext += `\n\nGenerated Questions:\n${currentIssue.generated_questions}` + } + } + } + + const recentContext = recentMessages.length > 0 + ? `\n\n## Recent Conversation\n${recentMessages.slice(-5).map(m => `${m.agent_name}: ${m.message}`).join('\n')}` + : '' + + const systemPrompt = `${agent.system_prompt}${issueContext}${recentContext}${userRole ? `\n\nThe student's role is: ${userRole}` : ''}` + + const result = await callLLM({ + model, + system: systemPrompt, + prompt: message, + }, 'pbl-chat') + + return apiSuccess({ message: result.text, agentName: agent.name }); + } catch (error) { + log.error('Error:', error) + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/proxy-media/route.ts b/app/api/proxy-media/route.ts new file mode 100644 index 000000000..88999e368 --- /dev/null +++ b/app/api/proxy-media/route.ts @@ -0,0 +1,60 @@ +/** + * Media Proxy API + * + * Server-side proxy for fetching remote media URLs (images/videos). + * Required because browser fetch() to remote CDN URLs fails with CORS errors. + * The media orchestrator uses this to download generated media as blobs + * for IndexedDB persistence. + * + * POST /api/proxy-media + * Body: { url: string } + * Response: Binary blob with appropriate Content-Type + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; +import { apiError } from '@/lib/server/api-response'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('ProxyMedia'); + +export const maxDuration = 60; + +export async function POST(request: NextRequest) { + try { + const { url } = await request.json(); + + if (!url || typeof url !== 'string') { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing or invalid url'); + } + + // Block local/private network URLs to prevent SSRF + const ssrfError = validateUrlForSSRF(url); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + + // Disable redirect following to prevent redirect-to-internal attacks + const response = await fetch(url, { redirect: 'manual' }); + if (response.status >= 300 && response.status < 400) { + return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed'); + } + if (!response.ok) { + return apiError('UPSTREAM_ERROR', 502, `Upstream returned ${response.status}`); + } + + const blob = await response.blob(); + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + + return new NextResponse(blob, { + headers: { + 'Content-Type': contentType, + 'Content-Length': String(blob.size), + 'Cache-Control': 'private, max-age=3600', + }, + }); + } catch (error) { + log.error('Proxy media error:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts new file mode 100644 index 000000000..b31ea6df4 --- /dev/null +++ b/app/api/quiz-grade/route.ts @@ -0,0 +1,113 @@ +/** + * Quiz Grading API + * + * POST: Receives a text question + user answer, calls LLM for scoring and feedback. + * Used for short-answer (text) questions that cannot be graded locally. + */ + +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { callLLM } from '@/lib/ai/llm'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Quiz Grade') + + +interface GradeRequest { + question: string; + userAnswer: string; + points: number; + commentPrompt?: string; + language?: string; +} + +interface GradeResponse { + score: number; + comment: string; +} + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as GradeRequest; + const { question, userAnswer, points, commentPrompt, language } = body; + + if (!question || !userAnswer) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'question and userAnswer are required'); + } + + // Resolve model from request headers (same pattern as scene-stream) + const modelString = req.headers.get('x-model') || 'gpt-4o-mini'; + const clientApiKey = req.headers.get('x-api-key') || ''; + const clientBaseUrl = req.headers.get('x-base-url') || undefined; + const providerType = req.headers.get('x-provider-type') || undefined; + const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'; + + const { providerId, modelId } = parseModelString(modelString); + const apiKey = resolveApiKey(providerId, clientApiKey); + const baseUrl = resolveBaseUrl(providerId, clientBaseUrl); + const proxy = resolveProxy(providerId); + const { model: languageModel } = getModel({ + providerId, + modelId, + apiKey, + baseUrl, + proxy, + providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined, + requiresApiKey, + }); + + const isZh = language === 'zh-CN'; + + const systemPrompt = isZh + ? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。 +必须以如下 JSON 格式回复(不要包含其他内容): +{"score": <0到${points}的整数>, "comment": "<一两句评语>"}` + : `You are a professional educational assessor. Grade the student's answer and provide brief feedback. +You must reply in the following JSON format only (no other content): +{"score": , "comment": ""}`; + + const userPrompt = isZh + ? `题目:${question} +满分:${points}分 +${commentPrompt ? `评分要点:${commentPrompt}\n` : ''}学生答案:${userAnswer}` + : `Question: ${question} +Full marks: ${points} points +${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${userAnswer}`; + + const result = await callLLM( + { + model: languageModel, + system: systemPrompt, + prompt: userPrompt, + }, + 'quiz-grade', + ); + + // Parse the LLM response as JSON + const text = result.text.trim(); + let gradeResult: GradeResponse; + + try { + // Try to extract JSON from the response + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error('No JSON found'); + const parsed = JSON.parse(jsonMatch[0]); + gradeResult = { + score: Math.max(0, Math.min(points, Math.round(Number(parsed.score)))), + comment: String(parsed.comment || ''), + }; + } catch { + // Fallback: give partial credit with a generic comment + gradeResult = { + score: Math.round(points * 0.5), + comment: isZh ? '已作答,请参考标准答案。' : 'Answer received. Please refer to the standard answer.', + }; + } + + return apiSuccess({ ...gradeResult }); + } catch (error) { + log.error('Error:', error); + return apiError('INTERNAL_ERROR', 500, 'Failed to grade answer'); + } +} diff --git a/app/api/server-providers/route.ts b/app/api/server-providers/route.ts new file mode 100644 index 000000000..00db6c899 --- /dev/null +++ b/app/api/server-providers/route.ts @@ -0,0 +1,30 @@ +import { + getServerProviders, + getServerTTSProviders, + getServerASRProviders, + getServerPDFProviders, + getServerImageProviders, + getServerVideoProviders, + getServerWebSearchProviders, +} from '@/lib/server/provider-config'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('ServerProviders'); + +export async function GET() { + try { + return apiSuccess({ + providers: getServerProviders(), + tts: getServerTTSProviders(), + asr: getServerASRProviders(), + pdf: getServerPDFProviders(), + image: getServerImageProviders(), + video: getServerVideoProviders(), + webSearch: getServerWebSearchProviders(), + }); + } catch (error) { + log.error('Error fetching server providers:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Unknown error'); + } +} diff --git a/app/api/transcription/route.ts b/app/api/transcription/route.ts new file mode 100644 index 000000000..d03efa4b5 --- /dev/null +++ b/app/api/transcription/route.ts @@ -0,0 +1,52 @@ +import { NextRequest } from 'next/server'; +import { transcribeAudio } from '@/lib/audio/asr-providers'; +import { resolveASRApiKey, resolveASRBaseUrl } from '@/lib/server/provider-config'; +import type { ASRProviderId } from '@/lib/audio/types'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Transcription') + + +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const audioFile = formData.get('audio') as File; + const providerId = formData.get('providerId') as ASRProviderId | null; + const language = formData.get('language') as string | null; + const apiKey = formData.get('apiKey') as string | null; + const baseUrl = formData.get('baseUrl') as string | null; + + if (!audioFile) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Audio file is required'); + } + + // providerId is required from the client — no server-side store to fall back to + const effectiveProviderId = providerId || ('openai-whisper' as ASRProviderId); + + const config = { + providerId: effectiveProviderId, + language: language || 'auto', + apiKey: resolveASRApiKey(effectiveProviderId, apiKey || undefined), + baseUrl: resolveASRBaseUrl(effectiveProviderId, baseUrl || undefined), + }; + + // Convert audio file to buffer + const arrayBuffer = await audioFile.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Transcribe using the provider system + const result = await transcribeAudio(config, buffer); + + return apiSuccess({ text: result.text }); + } catch (error) { + log.error('Transcription error:', error); + return apiError( + 'TRANSCRIPTION_FAILED', + 500, + 'Transcription failed', + error instanceof Error ? error.message : 'Unknown error', + ); + } +} diff --git a/app/api/verify-image-provider/route.ts b/app/api/verify-image-provider/route.ts new file mode 100644 index 000000000..75dc8fd51 --- /dev/null +++ b/app/api/verify-image-provider/route.ts @@ -0,0 +1,56 @@ +/** + * Verify Image Provider API + * + * Lightweight endpoint that validates provider credentials without generating images. + * + * POST /api/verify-image-provider + * + * Headers: + * x-image-provider: ImageProviderId + * x-image-model: string (optional) + * x-api-key: string (optional, server fallback) + * x-base-url: string (optional, server fallback) + * + * Response: { success: boolean, message: string } + */ + +import { NextRequest } from 'next/server'; +import { testImageConnectivity } from '@/lib/media/image-providers'; +import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-config'; +import type { ImageProviderId } from '@/lib/media/types'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('VerifyImageProvider'); + +export async function POST(request: NextRequest) { + try { + const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId; + const model = request.headers.get('x-image-model') || undefined; + const clientApiKey = request.headers.get('x-api-key') || undefined; + const clientBaseUrl = request.headers.get('x-base-url') || undefined; + + const apiKey = resolveImageApiKey(providerId, clientApiKey); + const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl); + + if (!apiKey) { + return apiError('MISSING_API_KEY', 400, 'No API key configured'); + } + + const result = await testImageConnectivity({ + providerId, + apiKey, + baseUrl, + model, + }); + + if (!result.success) { + return apiError('UPSTREAM_ERROR', 500, result.message); + } + + return apiSuccess({ message: result.message }); + } catch (err) { + log.error('Connectivity test error:', err); + return apiError('INTERNAL_ERROR', 500, `Connectivity test error: ${err}`); + } +} diff --git a/app/api/verify-model/route.ts b/app/api/verify-model/route.ts new file mode 100644 index 000000000..3f7d490e6 --- /dev/null +++ b/app/api/verify-model/route.ts @@ -0,0 +1,72 @@ +import { NextRequest } from 'next/server'; +import { getModel, parseModelString } from '@/lib/ai/providers'; +import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { generateText } from 'ai'; +import { createLogger } from '@/lib/logger' +import { apiError, apiSuccess } from '@/lib/server/api-response'; +const log = createLogger('Verify Model') + + +export async function POST(req: NextRequest) { + try { + const { apiKey, baseUrl, model, providerType, requiresApiKey } = await req.json(); + + if (!model) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'Model name is required'); + } + + // Parse model string and resolve server-side fallback + const { providerId, modelId } = parseModelString(model); + const effectiveApiKey = resolveApiKey(providerId, apiKey); + const effectiveBaseUrl = resolveBaseUrl(providerId, baseUrl); + const proxy = resolveProxy(providerId); + let languageModel; + try { + const result = getModel({ + providerId, + modelId, + apiKey: effectiveApiKey || "", + baseUrl: effectiveBaseUrl || undefined, + proxy, + providerType: providerType as "openai" | "anthropic" | "google" | undefined, + requiresApiKey, + }); + languageModel = result.model; + } catch (error) { + return apiError('INVALID_REQUEST', 401, error instanceof Error ? error.message : String(error)); + } + + // Send a minimal test message + const { text } = await generateText({ + model: languageModel, + prompt: 'Say "OK" if you can hear me.', + }); + + return apiSuccess({ + message: 'Connection successful', + response: text, + }); + } catch (error) { + log.error('API test error:', error); + + let errorMessage = 'Connection failed'; + if (error instanceof Error) { + // Parse common error messages + if (error.message.includes('401') || error.message.includes('Unauthorized')) { + errorMessage = 'API key is invalid or expired'; + } else if (error.message.includes('404') || error.message.includes('not found')) { + errorMessage = 'Model not found or API endpoint error'; + } else if (error.message.includes('429')) { + errorMessage = 'API rate limit exceeded, please try again later'; + } else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { + errorMessage = 'Cannot connect to API server, please check the Base URL'; + } else if (error.message.includes('timeout')) { + errorMessage = 'Connection timed out, please check your network'; + } else { + errorMessage = error.message; + } + } + + return apiError('INTERNAL_ERROR', 500, errorMessage); + } +} diff --git a/app/api/verify-video-provider/route.ts b/app/api/verify-video-provider/route.ts new file mode 100644 index 000000000..d11f9717c --- /dev/null +++ b/app/api/verify-video-provider/route.ts @@ -0,0 +1,56 @@ +/** + * Verify Video Provider API + * + * Lightweight endpoint that validates provider credentials without generating video. + * + * POST /api/verify-video-provider + * + * Headers: + * x-video-provider: VideoProviderId + * x-video-model: string (optional) + * x-api-key: string (optional, server fallback) + * x-base-url: string (optional, server fallback) + * + * Response: { success: boolean, message: string } + */ + +import { NextRequest } from 'next/server'; +import { testVideoConnectivity } from '@/lib/media/video-providers'; +import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-config'; +import type { VideoProviderId } from '@/lib/media/types'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('VerifyVideoProvider'); + +export async function POST(request: NextRequest) { + try { + const providerId = (request.headers.get('x-video-provider') || 'seedance') as VideoProviderId; + const model = request.headers.get('x-video-model') || undefined; + const clientApiKey = request.headers.get('x-api-key') || undefined; + const clientBaseUrl = request.headers.get('x-base-url') || undefined; + + const apiKey = resolveVideoApiKey(providerId, clientApiKey); + const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl); + + if (!apiKey) { + return apiError('MISSING_API_KEY', 400, 'No API key configured'); + } + + const result = await testVideoConnectivity({ + providerId, + apiKey, + baseUrl, + model, + }); + + if (!result.success) { + return apiError('UPSTREAM_ERROR', 500, result.message); + } + + return apiSuccess({ message: result.message }); + } catch (err) { + log.error('Connectivity test error:', err); + return apiError('INTERNAL_ERROR', 500, `Connectivity test error: ${err}`); + } +} diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts new file mode 100644 index 000000000..f2ff627f4 --- /dev/null +++ b/app/api/web-search/route.ts @@ -0,0 +1,51 @@ +/** + * Web Search API + * + * POST /api/web-search + * Simple JSON request/response using Tavily search. + */ + +import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; +import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; + +const log = createLogger('WebSearch'); + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { query, apiKey: clientApiKey } = body as { + query?: string; + apiKey?: string; + }; + + if (!query || !query.trim()) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required'); + } + + const apiKey = resolveWebSearchApiKey(clientApiKey); + if (!apiKey) { + return apiError( + 'MISSING_API_KEY', + 400, + 'Tavily API key is not configured. Set it in Settings → Web Search or set TAVILY_API_KEY env var.', + ); + } + + const result = await searchWithTavily({ query: query.trim(), apiKey }); + const context = formatSearchResultsAsContext(result); + + return apiSuccess({ + answer: result.answer, + sources: result.sources, + context, + query: result.query, + responseTime: result.responseTime, + }); + } catch (err) { + log.error('[WebSearch] Error:', err); + const message = err instanceof Error ? err.message : 'Web search failed'; + return apiError('INTERNAL_ERROR', 500, message); + } +} diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 000000000..a373fd12b Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx new file mode 100644 index 000000000..97e616bf3 --- /dev/null +++ b/app/classroom/[id]/page.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { Stage } from '@/components/stage'; +import { ThemeProvider } from '@/lib/use-theme'; +import { useStageStore } from '@/lib/store'; +import { loadImageMapping } from '@/lib/utils/image-storage'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import { useSceneGenerator } from '@/lib/hooks/use-scene-generator'; +import { useMediaGenerationStore } from '@/lib/store/media-generation'; +import { createLogger } from '@/lib/logger'; +import { MediaStageProvider } from '@/lib/store/media-stage-context'; +import { generateMediaForOutlines } from '@/lib/media/media-orchestrator'; + +const log = createLogger('Classroom'); + +export default function ClassroomDetailPage() { + const params = useParams(); + const classroomId = params?.id as string; + + const { loadFromStorage } = useStageStore(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const generationStartedRef = useRef(false); + + const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({ + onComplete: () => { + log.info('[Classroom] All scenes generated'); + }, + }); + + const loadClassroom = useCallback(async () => { + try { + await loadFromStorage(classroomId); + // Restore completed media generation tasks from IndexedDB + await useMediaGenerationStore.getState().restoreFromDB(classroomId); + // Restore generated agents for this stage + const { loadGeneratedAgentsForStage } = await import('@/lib/orchestration/registry/store'); + const agentIds = await loadGeneratedAgentsForStage(classroomId); + if (agentIds.length > 0) { + const { useSettingsStore } = await import('@/lib/store/settings'); + useSettingsStore.getState().setSelectedAgentIds(agentIds); + } + } catch (error) { + log.error('Failed to load classroom:', error); + setError(error instanceof Error ? error.message : 'Failed to load classroom'); + } finally { + setLoading(false); + } + }, [classroomId, loadFromStorage]); + + useEffect(() => { + // Reset loading state on course switch to unmount Stage during transition, + // preventing stale data from syncing back to the new course + setLoading(true); + setError(null); + generationStartedRef.current = false; + + // Clear previous classroom's media tasks to prevent cross-classroom contamination. + // Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages, + // so stale tasks from a previous classroom would shadow the new one's. + const mediaStore = useMediaGenerationStore.getState(); + mediaStore.revokeObjectUrls(); + useMediaGenerationStore.setState({ tasks: {} }); + + loadClassroom(); + + // Cancel ongoing generation when classroomId changes or component unmounts + return () => { + stop(); + }; + }, [classroomId, loadClassroom, stop]); + + // Auto-resume generation for pending outlines + useEffect(() => { + if (loading || error || generationStartedRef.current) return; + + const state = useStageStore.getState(); + const { outlines, scenes, stage } = state; + + // Check if there are pending outlines + const completedOrders = new Set(scenes.map(s => s.order)); + const hasPending = outlines.some(o => !completedOrders.has(o.order)); + + if (hasPending && stage) { + generationStartedRef.current = true; + + // Load generation params from sessionStorage (stored by generation-preview before navigating) + const genParamsStr = sessionStorage.getItem('generationParams'); + const params = genParamsStr ? JSON.parse(genParamsStr) : {}; + + // Reconstruct imageMapping from IndexedDB using pdfImages storageIds + const storageIds = (params.pdfImages || []) + .map((img: { storageId?: string }) => img.storageId) + .filter(Boolean); + + loadImageMapping(storageIds).then((imageMapping) => { + generateRemaining({ + pdfImages: params.pdfImages, + imageMapping, + stageInfo: { + name: stage.name || '', + description: stage.description, + language: stage.language, + style: stage.style, + }, + agents: params.agents, + userProfile: params.userProfile, + }); + }); + } else if (outlines.length > 0 && stage) { + // All scenes are generated, but some media may not have finished. + // Resume media generation for any tasks not yet in IndexedDB. + // generateMediaForOutlines skips already-completed tasks automatically. + generationStartedRef.current = true; + generateMediaForOutlines(outlines, stage.id).catch((err) => { + log.warn('[Classroom] Media generation resume error:', err); + }); + } + }, [loading, error, generateRemaining]); + + return ( + + +
+ {loading ? ( +
+
+

Loading classroom...

+
+
+ ) : error ? ( +
+
+

Error: {error}

+ +
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 000000000..615f7801f Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/generation-preview/layout.tsx b/app/generation-preview/layout.tsx new file mode 100644 index 000000000..6e4898b0a --- /dev/null +++ b/app/generation-preview/layout.tsx @@ -0,0 +1,10 @@ +// Force dynamic rendering since this page uses client-side hooks (useI18n) +export const dynamic = 'force-dynamic'; + +export default function GenerationPreviewLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx new file mode 100644 index 000000000..213c0645d --- /dev/null +++ b/app/generation-preview/page.tsx @@ -0,0 +1,1652 @@ +'use client'; + +import { useEffect, useState, Suspense, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion, AnimatePresence } from 'motion/react'; +import { + CheckCircle2, + FileText, + ScanLine, + Search, + Sparkles, + AlertCircle, + AlertTriangle, + ArrowLeft, + BarChart3, + Bot, + Puzzle, + Globe, + MousePointer2, + LayoutPanelLeft, + Clapperboard, + MessageSquare, + Focus, + Play, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { useStageStore } from '@/lib/store/stage'; +import { useSettingsStore } from '@/lib/store/settings'; +import { useAgentRegistry } from '@/lib/orchestration/registry/store'; +import { useI18n } from '@/lib/use-i18n'; +import { loadImageMapping, loadPdfBlob, cleanupOldImages, storeImages } from '@/lib/utils/image-storage'; +import { getCurrentModelConfig } from '@/lib/utils/model-config'; +import { db } from '@/lib/utils/database'; +import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; +import { nanoid } from 'nanoid'; +import type { Stage } from '@/lib/types/stage'; +import type { SceneOutline, UserRequirements, PdfImage, ImageMapping } from '@/lib/types/generation'; +import { AgentRevealModal } from '@/components/agent-reveal-modal'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('GenerationPreview'); + +// Session state stored in sessionStorage +interface GenerationSessionState { + sessionId: string; + requirements: UserRequirements; + pdfText: string; + pdfImages?: PdfImage[]; + imageStorageIds?: string[]; + imageMapping?: ImageMapping; + sceneOutlines?: SceneOutline[] | null; + currentStep: "generating" | "complete"; + // PDF deferred parsing fields + pdfStorageKey?: string; + pdfFileName?: string; + pdfProviderId?: string; + pdfProviderConfig?: { apiKey?: string; baseUrl?: string }; + // Web search context + researchContext?: string; + researchSources?: Array<{ title: string; url: string }>; +} + +type GenerationStep = { + id: string; + title: string; + description: string; + icon: React.ElementType; + type: 'analysis' | 'writing' | 'visual'; +}; + +const ALL_STEPS: GenerationStep[] = [ + { + id: 'pdf-analysis', + title: 'generation.analyzingPdf', + description: 'generation.analyzingPdfDesc', + icon: ScanLine, + type: 'analysis' + }, + { + id: 'web-search', + title: 'generation.webSearching', + description: 'generation.webSearchingDesc', + icon: Search, + type: 'analysis' + }, + { + id: 'agent-generation', + title: 'generation.agentGeneration', + description: 'generation.agentGenerationDesc', + icon: Bot, + type: 'writing' + }, + { + id: 'outline', + title: 'generation.generatingOutlines', + description: 'generation.generatingOutlinesDesc', + icon: FileText, + type: 'writing' + }, + { + id: 'slide-content', + title: 'generation.generatingSlideContent', + description: 'generation.generatingSlideContentDesc', + icon: LayoutPanelLeft, + type: 'visual' + }, + { + id: 'actions', + title: 'generation.generatingActions', + description: 'generation.generatingActionsDesc', + icon: Clapperboard, + type: 'visual' + }, +]; + +const getActiveSteps = (session: GenerationSessionState | null) => { + return ALL_STEPS.filter(step => { + if (step.id === 'pdf-analysis') return !!session?.pdfStorageKey; + if (step.id === 'web-search') return !!session?.requirements?.webSearch; + if (step.id === 'agent-generation') return useSettingsStore.getState().agentMode === 'auto'; + return true; + }); +}; + +function GenerationPreviewContent() { + const router = useRouter(); + const { t } = useI18n(); + const hasStartedRef = useRef(false); + const abortControllerRef = useRef(null); + + const [session, setSession] = useState(null); + const [sessionLoaded, setSessionLoaded] = useState(false); + const [error, setError] = useState(null); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isComplete] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + const [streamingOutlines, setStreamingOutlines] = useState(null); + const [truncationWarnings, setTruncationWarnings] = useState([]); + const [webSearchSources, setWebSearchSources] = useState>([]); + const [showAgentReveal, setShowAgentReveal] = useState(false); + const [generatedAgents, setGeneratedAgents] = useState>([]); + const agentRevealResolveRef = useRef<(() => void) | null>(null); + + // Compute active steps based on session state + const activeSteps = getActiveSteps(session); + + // Load session from sessionStorage + useEffect(() => { + cleanupOldImages(24).catch((e) => log.error(e)); + + const saved = sessionStorage.getItem("generationSession"); + if (saved) { + try { + const parsed = JSON.parse(saved) as GenerationSessionState; + setSession(parsed); + } catch (e) { + log.error("Failed to parse generation session:", e); + } + } + setSessionLoaded(true); + }, []); + + // Abort all in-flight requests on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + // Get API credentials from localStorage + const getApiHeaders = () => { + const modelConfig = getCurrentModelConfig(); + const settings = useSettingsStore.getState(); + const imageProviderConfig = settings.imageProvidersConfig?.[settings.imageProviderId]; + const videoProviderConfig = settings.videoProvidersConfig?.[settings.videoProviderId]; + return { + "Content-Type": "application/json", + "x-model": modelConfig.modelString, + "x-api-key": modelConfig.apiKey, + "x-base-url": modelConfig.baseUrl, + "x-provider-type": modelConfig.providerType || '', + "x-requires-api-key": modelConfig.requiresApiKey ? 'true' : 'false', + // Image generation provider + "x-image-provider": settings.imageProviderId || '', + "x-image-model": settings.imageModelId || '', + "x-image-api-key": imageProviderConfig?.apiKey || '', + "x-image-base-url": imageProviderConfig?.baseUrl || '', + // Video generation provider + "x-video-provider": settings.videoProviderId || '', + "x-video-model": settings.videoModelId || '', + "x-video-api-key": videoProviderConfig?.apiKey || '', + "x-video-base-url": videoProviderConfig?.baseUrl || '', + // Media generation toggles + "x-image-generation-enabled": String(settings.imageGenerationEnabled ?? false), + "x-video-generation-enabled": String(settings.videoGenerationEnabled ?? false), + }; + }; + + + // Auto-start generation when session is loaded + useEffect(() => { + if (session && !hasStartedRef.current) { + hasStartedRef.current = true; + startGeneration(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session]); + + // Main generation flow + const startGeneration = async () => { + if (!session) return; + + // Create AbortController for this generation run + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const signal = controller.signal; + + // Use a local mutable copy so we can update it after PDF parsing + let currentSession = session; + + setError(null); + setCurrentStepIndex(0); + + try { + // Compute active steps for this session (recomputed after session mutations) + let activeSteps = getActiveSteps(currentSession); + + // Determine if we need the PDF analysis step + const hasPdfToAnalyze = !!currentSession.pdfStorageKey && !currentSession.pdfText; + // If no PDF to analyze, skip to the next available step + if (!hasPdfToAnalyze) { + const firstNonPdfIdx = activeSteps.findIndex(s => s.id !== 'pdf-analysis'); + setCurrentStepIndex(Math.max(0, firstNonPdfIdx)); + } + + // Step 0: Parse PDF if needed + if (hasPdfToAnalyze) { + log.debug('=== Generation Preview: Parsing PDF ==='); + const pdfBlob = await loadPdfBlob(currentSession.pdfStorageKey!); + if (!pdfBlob) { + throw new Error(t('generation.pdfLoadFailed')); + } + + // Ensure pdfBlob is a valid Blob with content + if (!(pdfBlob instanceof Blob) || pdfBlob.size === 0) { + log.error('Invalid PDF blob:', { type: typeof pdfBlob, size: pdfBlob instanceof Blob ? pdfBlob.size : 'N/A' }); + throw new Error(t('generation.pdfLoadFailed')); + } + + // Wrap as a File to guarantee multipart/form-data with correct content-type + const pdfFile = new File([pdfBlob], currentSession.pdfFileName || 'document.pdf', { + type: 'application/pdf', + }); + + const parseFormData = new FormData(); + parseFormData.append('pdf', pdfFile); + + if (currentSession.pdfProviderId) { + parseFormData.append('providerId', currentSession.pdfProviderId); + } + if (currentSession.pdfProviderConfig?.apiKey?.trim()) { + parseFormData.append('apiKey', currentSession.pdfProviderConfig.apiKey); + } + if (currentSession.pdfProviderConfig?.baseUrl?.trim()) { + parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl); + } + + const parseResponse = await fetch('/api/parse-pdf', { + method: 'POST', + body: parseFormData, + signal, + }); + + if (!parseResponse.ok) { + const errorData = await parseResponse.json(); + throw new Error(errorData.error || t('generation.pdfParseFailed')); + } + + const parseResult = await parseResponse.json(); + if (!parseResult.success || !parseResult.data) { + throw new Error(t('generation.pdfParseFailed')); + } + + let pdfText = parseResult.data.text as string; + + // Truncate if needed + if (pdfText.length > MAX_PDF_CONTENT_CHARS) { + pdfText = pdfText.substring(0, MAX_PDF_CONTENT_CHARS); + } + + // Create image metadata and store images + // Prefer metadata.pdfImages (both parsers now return this) + const rawPdfImages = parseResult.data.metadata?.pdfImages; + const images = rawPdfImages + ? rawPdfImages.map((img: { id: string; src?: string; pageNumber?: number; description?: string; width?: number; height?: number }) => ({ + id: img.id, + src: img.src || '', + pageNumber: img.pageNumber || 1, + description: img.description, + width: img.width, + height: img.height, + })) + : (parseResult.data.images as string[]).map((src: string, i: number) => ({ + id: `img_${i + 1}`, + src, + pageNumber: 1, + })); + + const imageStorageIds = await storeImages(images); + + const pdfImages: PdfImage[] = images.map((img: { id: string; src: string; pageNumber: number; description?: string; width?: number; height?: number }, i: number) => ({ + id: img.id, + src: '', + pageNumber: img.pageNumber, + description: img.description, + width: img.width, + height: img.height, + storageId: imageStorageIds[i], + })); + + // Update session with parsed PDF data + const updatedSession = { + ...currentSession, + pdfText, + pdfImages, + imageStorageIds, + pdfStorageKey: undefined, // Clear so we don't re-parse + }; + setSession(updatedSession); + sessionStorage.setItem('generationSession', JSON.stringify(updatedSession)); + + // Truncation warnings + const warnings: string[] = []; + if ((parseResult.data.text as string).length > MAX_PDF_CONTENT_CHARS) { + warnings.push(t('generation.textTruncated').replace('{n}', String(MAX_PDF_CONTENT_CHARS))); + } + if (images.length > MAX_VISION_IMAGES) { + warnings.push( + t('generation.imageTruncated') + .replace('{total}', String(images.length)) + .replace('{max}', String(MAX_VISION_IMAGES)) + ); + } + if (warnings.length > 0) { + setTruncationWarnings(warnings); + } + + // Reassign local reference for subsequent steps + currentSession = updatedSession; + activeSteps = getActiveSteps(currentSession); + } + + // Step: Web Search (if enabled) + const webSearchStepIdx = activeSteps.findIndex(s => s.id === 'web-search'); + if (currentSession.requirements.webSearch && webSearchStepIdx >= 0) { + setCurrentStepIndex(webSearchStepIdx); + setWebSearchSources([]); + + const wsSettings = useSettingsStore.getState(); + const wsApiKey = wsSettings.webSearchProvidersConfig?.[wsSettings.webSearchProviderId]?.apiKey; + const res = await fetch('/api/web-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: currentSession.requirements.requirement, + apiKey: wsApiKey || undefined, + }), + signal, + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: 'Web search failed' })); + throw new Error(data.error || t('generation.webSearchFailed')); + } + + const searchData = await res.json(); + const sources = (searchData.sources || []).map((s: { title: string; url: string }) => ({ title: s.title, url: s.url })); + setWebSearchSources(sources); + + const updatedSessionWithSearch = { + ...currentSession, + researchContext: searchData.context || '', + researchSources: sources, + }; + setSession(updatedSessionWithSearch); + sessionStorage.setItem('generationSession', JSON.stringify(updatedSessionWithSearch)); + currentSession = updatedSessionWithSearch; + activeSteps = getActiveSteps(currentSession); + } + + // Load imageMapping early (needed for both outline and scene generation) + let imageMapping: ImageMapping = {}; + if (currentSession.imageStorageIds && currentSession.imageStorageIds.length > 0) { + log.debug('Loading images from IndexedDB'); + imageMapping = await loadImageMapping(currentSession.imageStorageIds); + } else if (currentSession.imageMapping && Object.keys(currentSession.imageMapping).length > 0) { + log.debug('Using imageMapping from session (old format)'); + imageMapping = currentSession.imageMapping; + } + + // ── Agent generation (before outlines so persona can influence structure) ── + const settings = useSettingsStore.getState(); + let agents: Array<{ id: string; name: string; role: string; persona?: string }> = []; + + // Create stage client-side (needed for agent generation stageId) + const stageId = nanoid(10); + const stage: Stage = { + id: stageId, + name: extractTopicFromRequirement(currentSession.requirements.requirement), + description: '', + language: currentSession.requirements.language || 'zh-CN', + style: 'professional', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + if (settings.agentMode === 'auto') { + const agentStepIdx = activeSteps.findIndex(s => s.id === 'agent-generation'); + if (agentStepIdx >= 0) setCurrentStepIndex(agentStepIdx); + + try { + const allAvatars = [ + '/avatars/assist.png', '/avatars/assist-2.png', + '/avatars/clown.png', '/avatars/clown-2.png', + '/avatars/curious.png', '/avatars/curious-2.png', + '/avatars/note-taker.png', '/avatars/note-taker-2.png', + '/avatars/teacher.png', '/avatars/teacher-2.png', + '/avatars/thinker.png', '/avatars/thinker-2.png', + ]; + + // No outlines yet — agent generation uses only stage name + description + const agentResp = await fetch('/api/generate/agent-profiles', { + method: 'POST', + headers: getApiHeaders(), + body: JSON.stringify({ + stageInfo: { name: stage.name, description: stage.description }, + language: currentSession.requirements.language || 'zh-CN', + availableAvatars: allAvatars, + }), + signal, + }); + + if (!agentResp.ok) throw new Error('Agent generation failed'); + const agentData = await agentResp.json(); + if (!agentData.success) throw new Error(agentData.error || 'Agent generation failed'); + + // Save to IndexedDB and registry + const { saveGeneratedAgents } = await import('@/lib/orchestration/registry/store'); + const savedIds = await saveGeneratedAgents(stage.id, agentData.agents); + settings.setSelectedAgentIds(savedIds); + + // Show card-reveal modal, continue generation once all cards are revealed + setGeneratedAgents(agentData.agents); + setShowAgentReveal(true); + await new Promise(resolve => { + agentRevealResolveRef.current = resolve; + }); + + agents = savedIds + .map(id => useAgentRegistry.getState().getAgent(id)) + .filter(Boolean) + .map(a => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona })); + } catch (err: unknown) { + log.warn('[Generation] Agent generation failed, falling back to presets:', err); + const registry = useAgentRegistry.getState(); + agents = settings.selectedAgentIds + .map(id => registry.getAgent(id)) + .filter(Boolean) + .map(a => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona })); + } + } else { + // Preset mode — use selected agents (include persona) + const registry = useAgentRegistry.getState(); + agents = settings.selectedAgentIds + .map(id => registry.getAgent(id)) + .filter(Boolean) + .map(a => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona })); + } + + // ── Generate outlines (with agent personas for teacher context) ── + let outlines = currentSession.sceneOutlines; + + const outlineStepIdx = activeSteps.findIndex(s => s.id === 'outline'); + setCurrentStepIndex(outlineStepIdx >= 0 ? outlineStepIdx : 0); + if (!outlines || outlines.length === 0) { + log.debug("=== Generating outlines (SSE) ==="); + setStreamingOutlines([]); + + outlines = await new Promise((resolve, reject) => { + const collected: SceneOutline[] = []; + + fetch("/api/generate/scene-outlines-stream", { + method: "POST", + headers: getApiHeaders(), + body: JSON.stringify({ + requirements: currentSession.requirements, + pdfText: currentSession.pdfText, + pdfImages: currentSession.pdfImages, + imageMapping, + researchContext: currentSession.researchContext, + agents, + }), + signal, + }).then(res => { + if (!res.ok) { + return res.json().then(d => { + reject(new Error(d.error || t('generation.outlineGenerateFailed'))); + }); + } + + const reader = res.body?.getReader(); + if (!reader) { + reject(new Error(t('generation.streamNotReadable'))); + return; + } + + const decoder = new TextDecoder(); + let sseBuffer = ''; + + const pump = (): Promise => reader.read().then(({ done, value }) => { + if (value) { + sseBuffer += decoder.decode(value, { stream: !done }); + const lines = sseBuffer.split('\n'); + sseBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const evt = JSON.parse(line.slice(6)); + if (evt.type === 'outline') { + collected.push(evt.data); + setStreamingOutlines([...collected]); + } else if (evt.type === 'retry') { + collected.length = 0; + setStreamingOutlines([]); + setStatusMessage(t('generation.outlineRetrying')); + } else if (evt.type === 'done') { + resolve(evt.outlines || collected); + return; + } else if (evt.type === 'error') { + reject(new Error(evt.error)); + return; + } + } catch (e) { + log.error('Failed to parse outline SSE:', line, e); + } + } + } + if (done) { + if (collected.length > 0) { + resolve(collected); + } else { + reject(new Error(t('generation.outlineEmptyResponse'))); + } + return; + } + return pump(); + }); + + pump().catch(reject); + }).catch(reject); + }); + + const updatedSession = { ...currentSession, sceneOutlines: outlines }; + setSession(updatedSession); + sessionStorage.setItem( + "generationSession", + JSON.stringify(updatedSession) + ); + + // Outline generation succeeded — clear homepage draft cache + try { localStorage.removeItem('requirementDraft'); } catch { /* ignore */ } + + // Brief pause to let user see the final outline state + await new Promise(resolve => setTimeout(resolve, 800)); + } + + // Move to scene generation step + setStatusMessage(''); + if (!outlines || outlines.length === 0) { + throw new Error(t('generation.outlineEmptyResponse')); + } + + // Store stage and outlines + const store = useStageStore.getState(); + store.setStage(stage); + store.setOutlines(outlines); + + // Advance to slide-content step + const contentStepIdx = activeSteps.findIndex(s => s.id === 'slide-content'); + if (contentStepIdx >= 0) setCurrentStepIndex(contentStepIdx); + + // Build stageInfo and userProfile for API call + const stageInfo = { + name: stage.name, + description: stage.description, + language: stage.language, + style: stage.style, + }; + + const userProfile = (currentSession.requirements.userNickname || currentSession.requirements.userBio) + ? `Student: ${currentSession.requirements.userNickname || 'Unknown'}${currentSession.requirements.userBio ? ` — ${currentSession.requirements.userBio}` : ''}` + : undefined; + + // Generate ONLY the first scene + store.setGeneratingOutlines(outlines); + + const firstOutline = outlines[0]; + + // Step 2: Generate content (currentStepIndex is already 2) + const contentResp = await fetch('/api/generate/scene-content', { + method: 'POST', + headers: getApiHeaders(), + body: JSON.stringify({ + outline: firstOutline, + allOutlines: outlines, + pdfImages: currentSession.pdfImages, + imageMapping, + stageInfo, + stageId: stage.id, + agents, + }), + signal, + }); + + if (!contentResp.ok) { + const errorData = await contentResp.json().catch(() => ({ error: 'Request failed' })); + throw new Error(errorData.error || t('generation.sceneGenerateFailed')); + } + + const contentData = await contentResp.json(); + if (!contentData.success || !contentData.content) { + throw new Error(contentData.error || t('generation.sceneGenerateFailed')); + } + + // Generate actions (activate actions step indicator) + const actionsStepIdx = activeSteps.findIndex(s => s.id === 'actions'); + setCurrentStepIndex(actionsStepIdx >= 0 ? actionsStepIdx : currentStepIndex + 1); + + const actionsResp = await fetch('/api/generate/scene-actions', { + method: 'POST', + headers: getApiHeaders(), + body: JSON.stringify({ + outline: contentData.effectiveOutline || firstOutline, + allOutlines: outlines, + content: contentData.content, + stageId: stage.id, + agents, + previousSpeeches: [], + userProfile, + }), + signal, + }); + + if (!actionsResp.ok) { + const errorData = await actionsResp.json().catch(() => ({ error: 'Request failed' })); + throw new Error(errorData.error || t('generation.sceneGenerateFailed')); + } + + const data = await actionsResp.json(); + if (!data.success || !data.scene) { + throw new Error(data.error || t('generation.sceneGenerateFailed')); + } + + // Generate TTS for first scene (part of actions step — blocking) + if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') { + const ttsProviderConfig = settings.ttsProvidersConfig?.[settings.ttsProviderId]; + const speechActions = (data.scene.actions || []).filter( + (a: { type: string; text?: string }) => a.type === 'speech' && a.text + ); + + let ttsFailCount = 0; + for (const action of speechActions) { + const audioId = `tts_${action.id}`; + action.audioId = audioId; + try { + const resp = await fetch('/api/generate/tts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: action.text, + audioId, + ttsProviderId: settings.ttsProviderId, + ttsVoice: settings.ttsVoice, + ttsSpeed: settings.ttsSpeed, + ttsApiKey: ttsProviderConfig?.apiKey || undefined, + ttsBaseUrl: ttsProviderConfig?.baseUrl || undefined, + }), + signal, + }); + if (!resp.ok) { ttsFailCount++; continue; } + const ttsData = await resp.json(); + if (!ttsData.success) { ttsFailCount++; continue; } + const binary = atob(ttsData.base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes], { type: `audio/${ttsData.format}` }); + await db.audioFiles.put({ id: audioId, blob, format: ttsData.format, createdAt: Date.now() }); + } catch (err) { + log.warn(`[TTS] Failed for ${audioId}:`, err); + ttsFailCount++; + } + } + + if (ttsFailCount > 0 && speechActions.length > 0) { + throw new Error(t('generation.speechFailed')); + } + } + + // Add scene to store and navigate + store.addScene(data.scene); + store.setCurrentSceneId(data.scene.id); + + // Set remaining outlines as skeleton placeholders + const remaining = outlines.filter((o) => o.order !== data.scene.order); + store.setGeneratingOutlines(remaining); + + // Store generation params for classroom to continue generation + sessionStorage.setItem('generationParams', JSON.stringify({ + pdfImages: currentSession.pdfImages, + agents, + userProfile, + })); + + sessionStorage.removeItem('generationSession'); + await store.saveToStorage(); + router.push(`/classroom/${stage.id}`); + } catch (err) { + // AbortError is expected when navigating away — don't show as error + if (err instanceof DOMException && err.name === 'AbortError') { + log.info('[GenerationPreview] Generation aborted'); + return; + } + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const extractTopicFromRequirement = (requirement: string): string => { + const trimmed = requirement.trim(); + if (trimmed.length <= 500) { + return trimmed; + } + return trimmed.substring(0, 500).trim() + "..."; + }; + + const goBackToHome = () => { + abortControllerRef.current?.abort(); + sessionStorage.removeItem("generationSession"); + router.push("/"); + }; + + // Still loading session from sessionStorage + if (!sessionLoaded) { + return ( +
+
+
+
+
+ ); + } + + // No session found + if (!session) { + return ( +
+ +
+ +

{t('generation.sessionNotFound')}

+

+ {t('generation.sessionNotFoundDesc')} +

+ +
+
+
+ ); + } + + const activeStep = activeSteps.length > 0 + ? activeSteps[Math.min(currentStepIndex, activeSteps.length - 1)] + : ALL_STEPS[0]; + + return ( +
+ {/* Background Decor */} +
+
+
+
+ + {/* Back button */} + + + + +
+ + + {/* Progress Dots */} +
+ {activeSteps.map((step, idx) => ( +
+ ))} +
+ + {/* Central Content */} +
+ {/* Icon / Visualizer Container */} +
+ + {error ? ( + + + + ) : isComplete ? ( + + + + ) : ( + + + + )} + +
+ + {/* Text Content */} +
+ + +

+ {error ? t('generation.generationFailed') : isComplete ? t('generation.generationComplete') : t(activeStep.title)} +

+

+ {error ? error : isComplete ? t('generation.classroomReady') : statusMessage || t(activeStep.description)} +

+
+
+ + {/* Truncation warning indicator */} + + {truncationWarnings.length > 0 && !error && !isComplete && ( + + + + + + + + +
+ {truncationWarnings.map((w, i) => ( +

{w}

+ ))} +
+
+
+
+ )} +
+
+
+ + + + {/* Footer Action */} +
+ + {error ? ( + + + + ) : !isComplete ? ( + + + {t('generation.aiWorking')} + {generatedAgents.length > 0 && !showAgentReveal && ( + + )} + + ) : null} + +
+
+ + {/* Agent Reveal Modal */} + setShowAgentReveal(false)} + onAllRevealed={() => { + agentRevealResolveRef.current?.(); + agentRevealResolveRef.current = null; + }} + /> +
+ ); +} + +// Step-specific visualizers +function StepVisualizer({ stepId, outlines, webSearchSources }: { + stepId: string; + outlines?: SceneOutline[] | null; + webSearchSources?: Array<{ title: string; url: string }>; +}) { + switch (stepId) { + case 'pdf-analysis': + return ; + case 'web-search': + return ; + case 'outline': + return ; + case 'agent-generation': + return ; + case 'slide-content': + return ; + case 'actions': + return ; + default: + return null; + } +} + +// PDF: Document with scanning laser line +function PdfScanVisualizer() { + return ( +
+ +
+
+ {[80, 60, 90, 45, 70].map((w, i) => ( + + ))} +
+ {/* Scanning laser */} + +
+ + + +
+ ); +} + +// Web Search: Miniature search engine results page with animated query + result rows +function WebSearchVisualizer({ sources }: { sources: Array<{ title: string; url: string }> }) { + const [activeResult, setActiveResult] = useState(0); + + // Cycle through result highlight when we have sources + useEffect(() => { + if (sources.length === 0) return; + const timer = setInterval(() => { + setActiveResult(prev => (prev + 1) % Math.min(sources.length, 4)); + }, 1400); + return () => clearInterval(timer); + }, [sources.length]); + + // Placeholder results for skeleton state + const skeletonResults = [ + { titleW: 70, urlW: 45, snippetW: [90, 60] }, + { titleW: 55, urlW: 50, snippetW: [80, 75] }, + { titleW: 65, urlW: 40, snippetW: [85, 50] }, + { titleW: 50, urlW: 55, snippetW: [70, 65] }, + ]; + + const ROW_H = 38; + + return ( +
+ {/* Background glow */} + + + {/* Search results card */} +
+ {/* Search bar header */} +
+ +
+ +
+
+ + {/* Results list */} +
+ {/* Sliding highlight */} + {sources.length > 0 && ( + + )} + + {sources.length === 0 ? ( + // Skeleton: pulsing result placeholders + skeletonResults.map((item, i) => ( + +
+
+
+ {item.snippetW.map((w, j) => ( +
+ ))} +
+ + )) + ) : ( + // Live results + sources.slice(0, 4).map((source, i) => { + const isActive = i === activeResult; + return ( + +
+ {source.title} +
+
+ {source.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 32)} +
+
+
+
+
+ + ); + }) + )} +
+ + {/* Scanning beam */} + +
+ + {/* Source count badge */} + {sources.length > 0 && ( + + + {sources.length} + + )} +
+ ); +} + +// Outline: Streams real outline data as it arrives from SSE +function StreamingOutlineVisualizer({ outlines }: { outlines: SceneOutline[] }) { + // Build display lines from outlines + const allLines: string[] = []; + outlines.forEach((outline, i) => { + allLines.push(`${i + 1}. ${outline.title}`); + outline.keyPoints?.slice(0, 2).forEach(kp => { + const text = kp.length > 18 ? kp.substring(0, 18) + '...' : kp; + allLines.push(` • ${text}`); + }); + }); + + return ( +
+
+
+
+ {allLines.length === 0 ? ( + // Waiting for first outline — show placeholder skeleton +
+ {[60, 80, 50, 70].map((w, i) => ( + + ))} +
+ ) : ( + allLines.map((line, i) => ( + + {line} + + )) + )} +
+ +
+ ); +} + +// Content: Cycles through distinct representations of Slides, Quiz, PBL, Interactive +function AgentGenerationVisualizer() { + return ( +
+
+ {[0, 1, 2].map(i => ( + +
?
+
+ ))} +
+
+ ); +} + +function ContentVisualizer() { + const [index, setIndex] = useState(0); + + // 0: Slide (Blue) + // 1: Quiz (Purple) + // 2: PBL (Amber) + // 3: Interactive (Emerald) + const totalTypes = 4; + + useEffect(() => { + const timer = setInterval(() => { + setIndex((prev) => (prev + 1) % totalTypes); + }, 3200); + return () => clearInterval(timer); + }, []); + + const variants = { + enter: { x: 50, opacity: 0, scale: 0.9, rotateY: -15 }, + center: { x: 0, opacity: 1, scale: 1, rotateY: 0 }, + exit: { x: -50, opacity: 0, scale: 0.9, rotateY: 15 }, + }; + + const getTheme = (idx: number) => { + switch (idx) { + case 0: return { + color: 'blue', + label: 'SLIDE', + badge: 'bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:border-blue-800' + }; + case 1: return { + color: 'purple', + label: 'QUIZ', + badge: 'bg-purple-100 text-purple-600 border-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:border-purple-800' + }; + case 2: return { + color: 'amber', + label: 'PBL', + badge: 'bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:border-amber-800' + }; + case 3: return { + color: 'emerald', + label: 'WEB', + badge: 'bg-emerald-100 text-emerald-600 border-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300 dark:border-emerald-800' + }; + default: return { color: 'blue', label: '', badge: '' }; + } + }; + + const theme = getTheme(index); + + return ( +
+ {/* Background glow based on current theme */} + + + {/* Subtle orbiting rings (pushed back, slower) */} + {[0, 1].map((i) => ( + + ))} + + {/* Main Content Container */} +
+ + + {/* Consistent Badge - Now outside content logic */} + + {theme.label} + + + {/* --- SLIDE TYPE --- */} + {index === 0 && ( +
+ +
+
+ {[0.8, 0.9, 0.6, 0.7].map((w, i) => ( + + ))} +
+ + + +
+
+ )} + + {/* --- QUIZ TYPE --- */} + {index === 1 && ( +
+ +
+ + +
+ {[0, 1, 2, 3].map((i) => ( + +
+
+ + ))} +
+
+ )} + + {/* --- PBL TYPE --- */} + {index === 2 && ( +
+
+ + +
+
+ {[0, 1, 2].map((col) => ( + +
+ {[0, 1].map((card) => ( +
+ ))} + + ))} +
+
+ )} + + {/* --- INTERACTIVE TYPE --- */} + {index === 3 && ( +
+ {/* Browser Chrome - Padded right to avoid badge */} +
+
+
+
+
+
+
+
+ +
+ + {[1,2,3].map(i =>
)} + +
+ + + + +
+
+
+ )} + + {/* Scanning beam (shared) */} + + + +
+
+ ); +} + +// Actions: Timeline of speech, spotlight, and interactions being orchestrated +function ActionsVisualizer() { + const [activeIdx, setActiveIdx] = useState(0); + + const actionItems = [ + { icon: MessageSquare, label: 'Speech', color: 'text-violet-500', activeBg: 'bg-violet-500/10', activeBorder: 'border-violet-200 dark:border-violet-800' }, + { icon: Focus, label: 'Spotlight', color: 'text-amber-500', activeBg: 'bg-amber-500/10', activeBorder: 'border-amber-200 dark:border-amber-800' }, + { icon: MessageSquare, label: 'Speech', color: 'text-violet-500', activeBg: 'bg-violet-500/10', activeBorder: 'border-violet-200 dark:border-violet-800' }, + { icon: Play, label: 'Interact', color: 'text-emerald-500', activeBg: 'bg-emerald-500/10', activeBorder: 'border-emerald-200 dark:border-emerald-800' }, + { icon: MessageSquare, label: 'Speech', color: 'text-violet-500', activeBg: 'bg-violet-500/10', activeBorder: 'border-violet-200 dark:border-violet-800' }, + ]; + + // Row height (py-1.5 = 6px×2 padding + icon ~16px) + gap 6px ≈ 34px per row + const ROW_H = 34; + + useEffect(() => { + const timer = setInterval(() => { + setActiveIdx(prev => (prev + 1) % actionItems.length); + }, 1600); + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ {/* Background pulse */} + + + {/* Timeline card */} +
+ {/* Header */} +
+ + +
+ + {/* Action items */} +
+ {/* Sliding highlight — absolute, animates via y transform, no layout impact */} + + + {actionItems.map((item, i) => { + const Icon = item.icon; + const isActive = i === activeIdx; + const isPast = i < activeIdx; + return ( + +
+ +
+
+ + {item.label} + +
+
+ {/* Pulsing dot — always rendered, opacity-controlled, no layout shift */} + + + ); + })} +
+ +
+
+ ); +} + +export default function GenerationPreviewPage() { + return ( + +
+
+
+
+
+ } + > + + + ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 000000000..8b9707cdf --- /dev/null +++ b/app/globals.css @@ -0,0 +1,209 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: #722ed1; + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.58 0.22 27); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: #8b47ea; + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} +/* ProseMirror Editor Styles */ +.prosemirror-editor { + cursor: text; +} + +.prosemirror-editor.format-painter { + cursor: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuMzUuMDEybC0uMDY2Ljk5OGE1LjI3MSA1LjI3MSAwIDAwLTEuMTg0LjA2IDMuOCAzLjggMCAwMC0uOTMzLjQ3MmMtLjQ0LjM1Ni0uNzgzLjgxMS0uOTk4IDEuMzI0bC4wMTgtLjAzNnY1LjEySDEuMDR2Ljk4aC0xLjA0bC0uMDAyIDQuMTVjLjE4Ny40MjYuNDYuODEuNzkxIDEuMTE3bC4xNzUuMTUyYy4yOTMuMjA4LjYxNS4zNzMuODkuNDcyLjQxLjA4Mi44My4xMTIgMS4yNDkuMDlsLjA1Ny45OTlhNi4wNjMgNi4wNjMgMCAwMS0xLjU4OC0uMTI5IDQuODM2IDQuODM2IDAgMDEtMS4yNS0uNjQ3IDQuNDYzIDQuNDYzIDAgMDEtLjgzOC0uODgzYy0uMjI0LjMzMi0uNS42NDItLjgyNC45MjdhNC4xMSA0LjExIDAgMDEtMS4zMDUuNjMzQTYuMTI2IDYuMTI2IDAgMDEwIDE1LjkwOWwuMDY4LS45OTdjLjQyNC4wMjYuODUtLjAwMSAxLjIxNy0uMDcuMzM2LS4wOTkuNjUxLS4yNTQuODk0LS40My40My0uMzguNzY1LS44NDcuOTgyLTEuMzY4bC0uMDA1LjAxNFY4LjkzSDIuMTE1di0uOThoMS4wNFYyLjg2MmEzLjc3IDMuNzcgMCAwMC0uNzc0LTEuMTY3bC0uMTY1LS4xNTZhMy4wNjQgMy4wNjQgMCAwMC0uODgtLjQ0OEE1LjA2MiA1LjA2MiAwIDAwLjA2NyAxLjAxTDAgLjAxMmE2LjE0IDYuMTQgMCAwMTEuNTkyLjExYy40NTMuMTM1Ljg3Ny4zNDUgMS4yOS42NS4zLjI2NS41NjUuNTY0Ljc4Ny44OS4yMzMtLjMzMS41Mi0uNjM0Ljg1My0uOTA0YTQuODM1IDQuODM1IDAgMDExLjMtLjY0OEE2LjE1NSA2LjE1NSAwIDAxNy4zNS4wMTJ6IiBmaWxsPSIjMEQwRDBEIi8+PHBhdGggZD0iTTE3LjM1IDE0LjVsNC41LTQuNS02LTZjLTIgMi0zIDItNS41IDIuNS40IDMuMiA0LjgzMyA2LjY2NyA3IDh6bTQuNTg4LTQuNDkzYS4zLjMgMCAwMC40MjQgMGwuNjgtLjY4YTEuNSAxLjUgMCAwMDAtMi4xMjJMMjEuNjkgNS44NTNsMi4wMjUtMS41ODNhMS42MjkgMS42MjkgMCAxMC0yLjI3OS0yLjI5NmwtMS42MDMgMi4wMjItMS4zNTctMS4zNTdhMS41IDEuNSAwIDAwLTIuMTIxIDBsLS42OC42OGEuMy4zIDAgMDAwIC40MjVsNi4yNjMgNi4yNjN6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTE1Ljg5MiAzLjk2MnMtMS4wMyAxLjIwMi0yLjQ5NCAxLjg5Yy0xLjAwNi40NzQtMi4xOC41ODYtMi43MzQuNjI3LS4yLjAxNS0uMzQ0LjIxLS4yNzYuMzk5LjI5Mi44MiAxLjExMiAyLjggMi42NTggNC4zNDYgMi4xMjYgMi4xMjcgMy42NTggMi45NjggNC4xNDIgMy4yMDMuMS4wNDguMjE0LjAzLjI5OC0uMDQyLjM4Ni0uMzI1IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5Mi0uODg5IDIuMTg3LTIuNDQ3IDIuMTg3LTIuNDQ3bS40NzkuMDU1YS4zLjMgMCAwMS0uNDI0IDBsLTYuMjY0LTYuMjYzYS4zLjMgMCAwMTAtLjQyNWwuNjgtLjY4YTEuNSAxLjUgMCAwMTIuMTIyIDBsMS4zNTcgMS4zNTcgMS42MDMtMi4wMjJhMS42MjkgMS42MjkgMCAxMTIuMjggMi4yOTZMMjEuNjkgNS44NTNsMS4zNTIgMS4zNTJhMS41IDEuNSAwIDAxMCAyLjEyMmwtLjY4LjY4eiIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+') 2 5, default !important; +} + +/* Setup-needed indicator animations */ +@keyframes setup-glow { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.4), 0 0 8px rgba(139, 92, 246, 0.15); + } + 50% { + box-shadow: 0 0 0 6px rgba(139, 92, 246, 0), 0 0 16px rgba(59, 130, 246, 0.2); + } +} + +@keyframes setup-ping { + 0% { transform: scale(1); opacity: 1; } + 75%, 100% { transform: scale(2.2); opacity: 0; } +} + +@keyframes setup-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } +} + +.animate-setup-glow { + animation: setup-glow 2.5s ease-in-out infinite; +} + +.animate-setup-ping { + animation: setup-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +.animate-setup-float { + animation: setup-float 3s ease-in-out infinite; +} + +@keyframes setup-border-breathe { + 0%, 100% { + border-color: rgba(139, 92, 246, 0.25) !important; + box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.05), inset 0 0 0 0 rgba(139, 92, 246, 0); + } + 50% { + border-color: rgba(139, 92, 246, 0.7) !important; + box-shadow: 0 0 16px 0 rgba(139, 92, 246, 0.1), inset 0 0 12px 0 rgba(139, 92, 246, 0.03); + } +} + +.animate-setup-border { + animation: setup-border-breathe 2.5s ease-in-out infinite; + border-style: solid !important; +} + +/* Animation for audio visualizer */ +@keyframes wave { + 0%, 100% { + height: 30%; + } + 50% { + height: 100%; + } +} + +/* Hide scrollbar by default, show on hover */ +@utility scrollbar-hide { + &::-webkit-scrollbar { display: none; } + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Shimmer sweep for skeleton loading */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 000000000..4e65fe978 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono, Inter } from "next/font/google"; +import "./globals.css"; +import "animate.css"; +import "katex/dist/katex.min.css"; +import { ThemeProvider } from "@/lib/use-theme"; +import { I18nProvider } from "@/lib/use-i18n"; +import { Toaster } from "@/components/ui/sonner"; +import { ServerProvidersInit } from "@/components/server-providers-init"; + +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "OpenMAIC", + description: + "The open-source AI interactive classroom. Upload a PDF to instantly generate an immersive, multi-agent learning experience.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + {children} + + + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 000000000..ff2579486 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,978 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion, AnimatePresence } from 'motion/react'; +import { + ArrowUp, + Check, + ChevronDown, + Clock, + Copy, + ImagePlus, + Pencil, + Trash2, + Settings, + Sun, + Moon, + Monitor, + BotOff, + ChevronUp, +} from 'lucide-react'; +import { useI18n } from '@/lib/use-i18n'; +import { createLogger } from '@/lib/logger'; +import { Button } from '@/components/ui/button'; +import { Textarea as UITextarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { SettingsDialog } from '@/components/settings'; +import { GenerationToolbar } from '@/components/generation-toolbar'; +import { AgentBar } from '@/components/agent-bar'; +import { useTheme } from '@/lib/use-theme'; +import { nanoid } from 'nanoid'; +import { storePdfBlob } from '@/lib/utils/image-storage'; +import type { UserRequirements } from '@/lib/types/generation'; +import { useSettingsStore } from '@/lib/store/settings'; +import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile'; +import { StageListItem, listStages, deleteStageData, getFirstSlideByStages } from '@/lib/utils/stage-storage'; +import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide'; +import type { Slide } from '@/lib/types/slides'; +import { useMediaGenerationStore } from '@/lib/store/media-generation'; +import { toast } from 'sonner'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useDraftCache } from '@/lib/hooks/use-draft-cache'; +import { SpeechButton } from '@/components/speech-button'; + +const log = createLogger('Home'); + +const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; +const LANGUAGE_STORAGE_KEY = 'generationLanguage'; +const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; + +interface FormState { + pdfFile: File | null; + requirement: string; + language: 'zh-CN' | 'en-US'; + webSearch: boolean; +} + +const initialFormState: FormState = { + pdfFile: null, + requirement: '', + language: 'zh-CN', + webSearch: false, +}; + +function HomePage() { + const { t, locale, setLocale } = useI18n(); + const { theme, setTheme } = useTheme(); + const router = useRouter(); + const [form, setForm] = useState(initialFormState); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsSection, setSettingsSection] = useState(undefined); + + // Draft cache for requirement text + const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } = useDraftCache({ key: 'requirementDraft' }); + + // Model setup state + const currentModelId = useSettingsStore((s) => s.modelId); + const [storeHydrated, setStoreHydrated] = useState(false); + const [recentOpen, setRecentOpen] = useState(true); + + // Hydrate client-only state after mount (avoids SSR mismatch) + /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */ + useEffect(() => { + setStoreHydrated(true); + try { + const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY); + if (saved !== null) setRecentOpen(saved !== 'false'); + } catch { /* localStorage unavailable */ } + try { + const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY); + const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); + const updates: Partial = {}; + if (savedWebSearch === 'true') updates.webSearch = true; + if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') { + updates.language = savedLanguage; + } else { + const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + updates.language = detected; + } + if (Object.keys(updates).length > 0) { + setForm((prev) => ({ ...prev, ...updates })); + } + } catch { /* localStorage unavailable */ } + }, []); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Restore requirement draft from cache (derived state pattern — no effect needed) + const [prevCachedRequirement, setPrevCachedRequirement] = useState(cachedRequirement); + if (cachedRequirement !== prevCachedRequirement) { + setPrevCachedRequirement(cachedRequirement); + if (cachedRequirement) { + setForm((prev) => ({ ...prev, requirement: cachedRequirement })); + } + } + + const needsSetup = storeHydrated && !currentModelId; + const [languageOpen, setLanguageOpen] = useState(false); + const [themeOpen, setThemeOpen] = useState(false); + const [error, setError] = useState(null); + const [classrooms, setClassrooms] = useState([]); + const [thumbnails, setThumbnails] = useState>({}); + const toolbarRef = useRef(null); + const textareaRef = useRef(null); + + // Close dropdowns when clicking outside + useEffect(() => { + if (!languageOpen && !themeOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) { + setLanguageOpen(false); + setThemeOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [languageOpen, themeOpen]); + + const loadClassrooms = async () => { + try { + const list = await listStages(); + setClassrooms(list); + // Load first slide thumbnails + if (list.length > 0) { + const slides = await getFirstSlideByStages(list.map((c) => c.id)); + setThumbnails(slides); + } + } catch (err) { + log.error('Failed to load classrooms:', err); + } + }; + + useEffect(() => { + // Clear stale media store to prevent cross-course thumbnail contamination. + // The store may hold tasks from a previously visited classroom whose elementIds + // (gen_img_1, etc.) collide with other courses' placeholders. + useMediaGenerationStore.getState().revokeObjectUrls(); + useMediaGenerationStore.setState({ tasks: {} }); + + // eslint-disable-next-line react-hooks/set-state-in-effect -- Store hydration on mount + loadClassrooms(); + }, []); + + const handleDelete = async (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + try { + await deleteStageData(id); + toast.success('Classroom deleted'); + await loadClassrooms(); + } catch (err) { + log.error('Failed to delete classroom:', err); + toast.error('Failed to delete classroom'); + } + }; + + const updateForm = (field: K, value: FormState[K]) => { + setForm((prev) => ({ ...prev, [field]: value })); + try { + if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value)); + if (field === 'language') localStorage.setItem(LANGUAGE_STORAGE_KEY, String(value)); + if (field === 'requirement') updateRequirementCache(value as string); + } catch { /* ignore */ } + }; + + const showSetupToast = (icon: React.ReactNode, title: string, desc: string) => { + toast.custom((id) => ( +
{ toast.dismiss(id); setSettingsOpen(true); }} + > +
+ {icon} +
+
+

{title}

+

{desc}

+
+
+ +
+
+ ), { duration: 4000 }); + }; + + const handleGenerate = async () => { + // Validate setup before proceeding + if (!currentModelId) { + showSetupToast( + , + t('settings.modelNotConfigured'), + t('settings.setupNeeded'), + ); + setSettingsOpen(true); + return; + } + + if (!form.requirement.trim()) { + setError(t('upload.requirementRequired')); + return; + } + + setError(null); + + try { + const userProfile = useUserProfileStore.getState(); + const requirements: UserRequirements = { + requirement: form.requirement, + language: form.language, + userNickname: userProfile.nickname || undefined, + userBio: userProfile.bio || undefined, + webSearch: form.webSearch || undefined, + }; + + let pdfStorageKey: string | undefined; + let pdfFileName: string | undefined; + let pdfProviderId: string | undefined; + let pdfProviderConfig: { apiKey?: string; baseUrl?: string } | undefined; + + if (form.pdfFile) { + pdfStorageKey = await storePdfBlob(form.pdfFile); + pdfFileName = form.pdfFile.name; + + const settings = useSettingsStore.getState(); + pdfProviderId = settings.pdfProviderId; + const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId]; + if (providerCfg) { + pdfProviderConfig = { + apiKey: providerCfg.apiKey, + baseUrl: providerCfg.baseUrl, + }; + } + } + + const sessionState = { + sessionId: nanoid(), + requirements, + pdfText: '', + pdfImages: [], + imageStorageIds: [], + pdfStorageKey, + pdfFileName, + pdfProviderId, + pdfProviderConfig, + sceneOutlines: null, + currentStep: 'generating' as const, + }; + sessionStorage.setItem('generationSession', JSON.stringify(sessionState)); + + router.push('/generation-preview'); + } catch (err) { + log.error('Error preparing generation:', err); + setError(err instanceof Error ? err.message : t('upload.generateFailed')); + } + }; + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return t('classroom.today'); + if (diffDays === 1) return t('classroom.yesterday'); + if (diffDays < 7) return `${diffDays} ${t('classroom.daysAgo')}`; + return date.toLocaleDateString(); + }; + + const canGenerate = !!form.requirement.trim(); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + if (canGenerate) handleGenerate(); + } + }; + + return ( +
+ {/* ═══ Top-right pill (unchanged) ═══ */} +
+ {/* Language Selector */} +
+ + {languageOpen && ( +
+ + +
+ )} +
+ +
+ + {/* Theme Selector */} +
+ + {themeOpen && ( +
+ + + +
+ )} +
+ +
+ + {/* Settings Button */} +
+ + {needsSetup && ( + <> + + + + + + {t('settings.setupNeeded')} + + + )} +
+
+ { setSettingsOpen(open); if (!open) setSettingsSection(undefined); }} + initialSection={settingsSection} + /> + + {/* ═══ Background Decor ═══ */} +
+
+
+
+ + {/* ═══ Hero section: title + input (centered, wider) ═══ */} + + {/* ── Logo ── */} + + + {/* ── Slogan ── */} + + {t('home.slogan')} + + + {/* ── Unified input area ── */} + +
+ {/* ── Greeting + Profile + Agents ── */} +
+ +
+ +
+
+ + {/* Textarea */} +