Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/parse-pdf/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
5 changes: 5 additions & 0 deletions app/api/quiz-grade/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
12 changes: 11 additions & 1 deletion lib/action/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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();
}
Expand Down
21 changes: 10 additions & 11 deletions lib/generation/action-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 9 additions & 4 deletions lib/generation/json-repair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,15 @@ export function tryParseJson<T>(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}"`;
});

Expand Down
4 changes: 3 additions & 1 deletion lib/generation/scene-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
4 changes: 1 addition & 3 deletions lib/hooks/use-canvas-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
2 changes: 2 additions & 0 deletions lib/hooks/use-order-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
6 changes: 2 additions & 4 deletions lib/media/video-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}
}

Expand Down
10 changes: 5 additions & 5 deletions lib/orchestration/stateless-generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 7 additions & 3 deletions lib/playback/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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';
}

Expand Down
Loading