diff --git a/app/api/parse-pdf/route.ts b/app/api/parse-pdf/route.ts index 94feff54..422a64cc 100644 --- a/app/api/parse-pdf/route.ts +++ b/app/api/parse-pdf/route.ts @@ -62,8 +62,8 @@ export async function POST(req: NextRequest) { const resultWithMetadata: ParsedPdfContent = { ...result, metadata: { - pageCount: result.metadata?.pageCount || 0, // Ensure pageCount is always a number ...result.metadata, + pageCount: result.metadata?.pageCount ?? 0, // Ensure pageCount is always a number fileName: pdfFile.name, fileSize: pdfFile.size, }, diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index d0aab62e..f49a9366 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -34,6 +34,11 @@ export async function POST(req: NextRequest) { return apiError('MISSING_REQUIRED_FIELD', 400, 'question and userAnswer are required'); } + // Validate points is a positive finite number + if (!points || !Number.isFinite(points) || points <= 0) { + return apiError('INVALID_REQUEST', 400, 'points must be a positive number'); + } + // Resolve model from request headers const { model: languageModel } = resolveModelFromHeaders(req); diff --git a/lib/action/engine.ts b/lib/action/engine.ts index 22b80c87..cb0d49e6 100644 --- a/lib/action/engine.ts +++ b/lib/action/engine.ts @@ -214,15 +214,25 @@ export class ActionEngine { useCanvasStore.getState().playVideo(action.elementId); - // Wait until the video finishes playing + // Wait until the video finishes playing, with a safety timeout to prevent + // the playback engine from hanging indefinitely if the video element is + // invalid or the state change is missed. return new Promise((resolve) => { + const MAX_VIDEO_WAIT_MS = 5 * 60 * 1000; // 5 minutes + const timeout = setTimeout(() => { + unsubscribe(); + log.warn(`[playVideo] Timeout waiting for video ${action.elementId} to finish`); + resolve(); + }, MAX_VIDEO_WAIT_MS); const unsubscribe = useCanvasStore.subscribe((state) => { if (state.playingVideoElementId !== action.elementId) { + clearTimeout(timeout); unsubscribe(); resolve(); } }); if (useCanvasStore.getState().playingVideoElementId !== action.elementId) { + clearTimeout(timeout); unsubscribe(); resolve(); } diff --git a/lib/generation/action-parser.ts b/lib/generation/action-parser.ts index 7f934ba2..a471b688 100644 --- a/lib/generation/action-parser.ts +++ b/lib/generation/action-parser.ts @@ -127,28 +127,27 @@ export function parseActionsFromStructuredOutput( } // Step 6: Filter out slide-only actions for non-slide scenes (defense in depth) + let result = actions; if (sceneType && sceneType !== 'slide') { - const before = actions.length; - const filtered = actions.filter((a) => !SLIDE_ONLY_ACTIONS.includes(a.type as ActionType)); - if (filtered.length < before) { - log.info(`Stripped ${before - filtered.length} slide-only action(s) from ${sceneType} scene`); + const before = result.length; + result = result.filter((a) => !SLIDE_ONLY_ACTIONS.includes(a.type as ActionType)); + if (result.length < before) { + log.info(`Stripped ${before - result.length} slide-only action(s) from ${sceneType} scene`); } - return filtered; } // Step 7: Filter by allowedActions whitelist (defense in depth for role-based isolation) // Catches hallucinated actions not in the agent's permitted set, e.g. a student agent // mimicking spotlight/laser after seeing teacher actions in chat history. if (allowedActions && allowedActions.length > 0) { - const before = actions.length; - const filtered = actions.filter((a) => a.type === 'speech' || allowedActions.includes(a.type)); - if (filtered.length < before) { + const before = result.length; + result = result.filter((a) => a.type === 'speech' || allowedActions.includes(a.type)); + if (result.length < before) { log.info( - `Stripped ${before - filtered.length} disallowed action(s) by allowedActions whitelist`, + `Stripped ${before - result.length} disallowed action(s) by allowedActions whitelist`, ); } - return filtered; } - return actions; + return result; } diff --git a/lib/generation/json-repair.ts b/lib/generation/json-repair.ts index 0487714b..89f7fa0b 100644 --- a/lib/generation/json-repair.ts +++ b/lib/generation/json-repair.ts @@ -111,10 +111,15 @@ export function tryParseJson(jsonStr: string): T | null { // Fix 1: Handle LaTeX-style escapes that break JSON (e.g., \frac, \left, \right, \times, etc.) // These are common in math content and need to be double-escaped - // Match backslash followed by letters (LaTeX commands) inside strings - fixed = fixed.replace(/"([^"]*?)"/g, (_match, content) => { - // Double-escape any backslash followed by a letter (except valid JSON escapes) - const fixedContent = content.replace(/\\([a-zA-Z])/g, '\\\\$1'); + // Match backslash followed by letters (LaTeX commands) inside strings, + // but skip valid JSON escape sequences (\b, \f, \n, \r, \t, \u) + fixed = fixed.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (_match, content) => { + // Double-escape backslash+letter ONLY for non-JSON-escape letters + const fixedContent = content.replace(/\\([a-zA-Z])/g, (_m: string, ch: string) => { + // Preserve valid JSON escape sequences + if ('bfnrtu'.includes(ch)) return `\\${ch}`; + return `\\\\${ch}`; + }); return `"${fixedContent}"`; }); diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937..ff81a840 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -1079,7 +1079,9 @@ function formatElementsForPrompt(elements: PPTElement[]): string { function formatQuestionsForPrompt(questions: QuizQuestion[]): string { return questions .map((q, i) => { - const optionsText = q.options ? `Options: ${q.options.join(', ')}` : ''; + const optionsText = q.options + ? `Options: ${q.options.map((o) => `${o.value}. ${o.label}`).join(', ')}` + : ''; return `Q${i + 1} (${q.type}): ${q.question}\n${optionsText}`; }) .join('\n\n'); diff --git a/lib/hooks/use-canvas-operations.ts b/lib/hooks/use-canvas-operations.ts index 92c6d0ca..bfcbfd3e 100644 --- a/lib/hooks/use-canvas-operations.ts +++ b/lib/hooks/use-canvas-operations.ts @@ -370,9 +370,7 @@ export function useCanvasOperations() { const firstGroupId = activeElementList[0].groupId; if (!firstGroupId) return true; - const inSameGroup = activeElementList.every( - (el) => (el.groupId && el.groupId) === firstGroupId, - ); + const inSameGroup = activeElementList.every((el) => el.groupId && el.groupId === firstGroupId); return !inSameGroup; }, [activeElementList]); diff --git a/lib/hooks/use-order-element.ts b/lib/hooks/use-order-element.ts index 5fef1e5f..5defc12a 100644 --- a/lib/hooks/use-order-element.ts +++ b/lib/hooks/use-order-element.ts @@ -35,6 +35,8 @@ export function useOrderElement() { const { minLevel, maxLevel } = getCombineElementLevelRange(elementList, combineElementList); // Already at the top level, cannot move further + if (maxLevel >= elementList.length - 1) return; + const nextElement = copyOfElementList[maxLevel + 1]; const movedElementList = copyOfElementList.splice(minLevel, combineElementList.length); diff --git a/lib/media/video-providers.ts b/lib/media/video-providers.ts index bdad4dd0..8c9c4d69 100644 --- a/lib/media/video-providers.ts +++ b/lib/media/video-providers.ts @@ -134,10 +134,8 @@ export function normalizeVideoOptions( !normalized.aspectRatio || !provider.supportedAspectRatios.includes(normalized.aspectRatio) ) { - normalized.aspectRatio = - normalized.aspectRatio && provider.supportedAspectRatios.includes(normalized.aspectRatio) - ? normalized.aspectRatio - : (provider.supportedAspectRatios[0] as VideoGenerationOptions['aspectRatio']); + normalized.aspectRatio = provider + .supportedAspectRatios[0] as VideoGenerationOptions['aspectRatio']; } } diff --git a/lib/orchestration/stateless-generate.ts b/lib/orchestration/stateless-generate.ts index c307f091..56c69e04 100644 --- a/lib/orchestration/stateless-generate.ts +++ b/lib/orchestration/stateless-generate.ts @@ -213,12 +213,12 @@ export function parseStructuredChunk(chunk: string, state: ParserState): ParseRe const remaining = content.slice(state.lastPartialTextLength); if (remaining) { result.textChunks.push(remaining); + // Only push ordered entry when there is actual content to emit + result.ordered.push({ + type: 'text', + index: result.textChunks.length - 1, + }); } - // Use per-call array index for consistency with emitItem fix - result.ordered.push({ - type: 'text', - index: result.textChunks.length - 1, - }); textSegmentIndex++; state.lastPartialTextLength = 0; continue; diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index c9c5c8bf..cd55d36f 100644 --- a/lib/playback/engine.ts +++ b/lib/playback/engine.ts @@ -500,8 +500,10 @@ export class PlaybackEngine { ? { dimOpacity: action.dimOpacity } : { color: action.color }), } as Effect); - // Don't block — continue immediately - this.processNext(); + // Don't block — continue immediately (use queueMicrotask to avoid + // stack overflow from deep synchronous recursion when many consecutive + // spotlight/laser actions appear in sequence) + queueMicrotask(() => this.processNext()); break; } @@ -633,7 +635,9 @@ export class PlaybackEngine { // No usable voice configured — detect text language so the browser // auto-selects an appropriate voice. const cjkRatio = - (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length; + chunkText.length > 0 + ? (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length + : 0; utterance.lang = cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US'; }