Skip to content
Closed
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
14 changes: 12 additions & 2 deletions app/api/generate/scene-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function POST(req: NextRequest) {
allOutlines,
pdfImages,
imageMapping,
stageInfo: _stageInfo,
stageInfo,
stageId,
agents,
languageDirective,
Expand All @@ -45,6 +45,7 @@ export async function POST(req: NextRequest) {
stageInfo: {
name: string;
description?: string;
language?: string;
style?: string;
};
stageId: string;
Expand All @@ -67,7 +68,16 @@ export async function POST(req: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required');
}

const outline: SceneOutline = { ...rawOutline };
const outline: SceneOutline = {
...rawOutline,
language:
rawOutline.language ||
(stageInfo?.language === 'en-US'
? 'en-US'
: stageInfo?.language === 'ru-RU'
? 'ru-RU'
: 'zh-CN'),
};

// ── Model resolution from request headers ──
const { model: languageModel, modelInfo, modelString } = await resolveModelFromHeaders(req);
Expand Down
90 changes: 31 additions & 59 deletions app/api/generate/scene-outlines-stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
* so the frontend can display them incrementally.
*
* SSE events:
* { type: 'languageDirective', data: string }
* { type: 'outline', data: SceneOutline, index: number }
* { type: 'done', outlines: SceneOutline[], languageDirective: string }
* { type: 'done', outlines: SceneOutline[] }
* { type: 'error', error: string }
*/

Expand Down Expand Up @@ -38,45 +37,33 @@ const log = createLogger('Outlines Stream');

export const maxDuration = 300;

/**
* Extract the languageDirective from the streamed wrapper JSON.
* Matches `"languageDirective":"<value>"` in partial JSON like:
* {"languageDirective":"用中文授课...","outlines":[...
*/
function extractLanguageDirective(buffer: string): string | null {
const match = buffer.match(/"languageDirective"\s*:\s*"((?:[^"\\]|\\.)*)"/);
if (!match) return null;
try {
return JSON.parse(`"${match[1]}"`);
} catch {
return match[1];
function getLocalizedEmpty(language: string, kind: 'images' | 'content' | 'research'): string {
if (language === 'zh-CN') {
return kind === 'images' ? '无可用图片' : '无';
}
if (language === 'ru-RU') {
return kind === 'images' ? 'Нет доступных изображений' : 'Нет';
}
return kind === 'images' ? 'No images available' : 'None';
}

function resolveRequirementLanguage(language?: string): 'zh-CN' | 'en-US' | 'ru-RU' {
if (language === 'en-US') return 'en-US';
if (language === 'ru-RU') return 'ru-RU';
return 'zh-CN';
}

/**
* Incremental JSON array parser.
* Extracts complete top-level objects from a partially-streamed JSON array.
* Supports both a flat array `[{...},{...}]` and a wrapper object
* `{"languageDirective":"...","outlines":[{...},{...}]}`.
* Returns newly found objects (skipping `alreadyParsed` count).
*/
function extractNewOutlines(buffer: string, alreadyParsed: number): SceneOutline[] {
const results: SceneOutline[] = [];

// Strip markdown fencing if present
const stripped = buffer.replace(/^[\s\S]*?(?=[\[{])/, '');

// Find the outlines array — either nested in {"outlines": [...]} or a flat array
let arrayStart = -1;
const outlinesKeyIdx = stripped.indexOf('"outlines"');
if (outlinesKeyIdx >= 0) {
// Wrapper format: find [ after "outlines":
arrayStart = stripped.indexOf('[', outlinesKeyIdx);
} else {
// Flat array fallback
arrayStart = stripped.indexOf('[');
}

// 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;
Expand Down Expand Up @@ -149,17 +136,12 @@ export async function POST(req: NextRequest) {
};
requirementSnippet = requirements?.requirement?.substring(0, 60);

// Build user profile string for language inference context
const userProfileText =
requirements.userNickname || requirements.userBio
? `## Student Profile\n\nStudent: ${requirements.userNickname || 'Unknown'}${requirements.userBio ? ` — ${requirements.userBio}` : ''}\n\nConsider this student's background when designing the course. Adapt difficulty, examples, and teaching approach accordingly.\n\n---`
: '';

// Detect vision capability
const hasVision = !!modelInfo?.capabilities?.vision;
const requirementLanguage = resolveRequirementLanguage(requirements.language);

// Build prompt (same logic as generateSceneOutlinesFromRequirements)
let availableImagesText = 'No images available';
let availableImagesText = getLocalizedEmpty(requirementLanguage, 'images');
let visionImages: Array<{ id: string; src: string }> | undefined;

if (pdfImages && pdfImages.length > 0) {
Expand All @@ -170,9 +152,11 @@ export async function POST(req: NextRequest) {
const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES);
const noSrcImages = pdfImages.filter((img) => !imageMapping[img.id]);

const visionDescriptions = visionSlice.map((img) => formatImagePlaceholder(img));
const visionDescriptions = visionSlice.map((img) =>
formatImagePlaceholder(img, requirementLanguage),
);
const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) =>
formatImageDescription(img),
formatImageDescription(img, requirementLanguage),
);
availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n');

Expand All @@ -184,7 +168,9 @@ export async function POST(req: NextRequest) {
}));
} else {
// Text-only mode: full descriptions
availableImagesText = pdfImages.map((img) => formatImageDescription(img)).join('\n');
availableImagesText = pdfImages
.map((img) => formatImageDescription(img, requirementLanguage))
.join('\n');
}
}

Expand All @@ -208,12 +194,14 @@ export async function POST(req: NextRequest) {

const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, {
requirement: requirements.requirement,
pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : 'None',
language: requirementLanguage,
pdfContent: pdfText
? pdfText.substring(0, MAX_PDF_CONTENT_CHARS)
: getLocalizedEmpty(requirementLanguage, 'content'),
availableImages: availableImagesText,
researchContext: researchContext || 'None',
researchContext: researchContext || getLocalizedEmpty(requirementLanguage, 'research'),
mediaGenerationPolicy,
teacherContext,
userProfile: userProfileText,
});

if (!prompts) {
Expand Down Expand Up @@ -273,7 +261,6 @@ export async function POST(req: NextRequest) {
};

let parsedOutlines: SceneOutline[] = [];
let languageDirective: string | null = null;
let lastError: string | undefined;

for (let attempt = 1; attempt <= MAX_STREAM_RETRIES + 1; attempt++) {
Expand All @@ -282,23 +269,10 @@ export async function POST(req: NextRequest) {

let fullText = '';
parsedOutlines = [];
languageDirective = null;

for await (const chunk of result.textStream) {
fullText += chunk;

// Try to extract language directive early
if (!languageDirective) {
languageDirective = extractLanguageDirective(fullText);
if (languageDirective) {
const ldEvent = JSON.stringify({
type: 'languageDirective',
data: languageDirective,
});
controller.enqueue(encoder.encode(`data: ${ldEvent}\n\n`));
}
}

// Try to extract new outlines from the accumulated text
const newOutlines = extractNewOutlines(fullText, parsedOutlines.length);
for (const outline of newOutlines) {
Expand Down Expand Up @@ -365,8 +339,6 @@ export async function POST(req: NextRequest) {
const doneEvent = JSON.stringify({
type: 'done',
outlines: uniquifiedOutlines,
languageDirective:
languageDirective || 'Teach in the language that matches the user requirement.',
});
controller.enqueue(encoder.encode(`data: ${doneEvent}\n\n`));
} else {
Expand Down
17 changes: 14 additions & 3 deletions app/api/quiz-grade/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,29 @@ export async function POST(req: NextRequest) {
const { model: languageModel } = await resolveModelFromHeaders(req);

const isZh = language === 'zh-CN';
const isRu = language === 'ru-RU';

const systemPrompt = isZh
? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。
必须以如下 JSON 格式回复(不要包含其他内容):
{"score": <0到${points}的整数>, "comment": "<一两句评语>"}`
: `You are a professional educational assessor. Grade the student's answer and provide brief feedback.
: isRu
? `Ты профессиональный эксперт по образовательной оценке. Оцени ответ студента и дай краткий комментарий.
Ты должен ответить только в следующем 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": <integer from 0 to ${points}>, "comment": "<one or two sentences of feedback>"}`;

const userPrompt = isZh
? `题目:${question}
满分:${points}分
${commentPrompt ? `评分要点:${commentPrompt}\n` : ''}学生答案:${userAnswer}`
: `Question: ${question}
: isRu
? `Вопрос: ${question}
Максимум: ${points} баллов
${commentPrompt ? `Критерии оценивания: ${commentPrompt}\n` : ''}Ответ студента: ${userAnswer}`
: `Question: ${question}
Full marks: ${points} points
${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${userAnswer}`;

Expand Down Expand Up @@ -92,7 +101,9 @@ ${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${
score: Math.round(points * 0.5),
comment: isZh
? '已作答,请参考标准答案。'
: 'Answer received. Please refer to the standard answer.',
: isRu
? 'Ответ получен. Сверьтесь с эталонным ответом.'
: 'Answer received. Please refer to the standard answer.',
};
}

Expand Down
18 changes: 18 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,20 @@ import { useImportClassroom } from '@/lib/import/use-import-classroom';
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' | 'ru-RU';
webSearch: boolean;
}

const initialFormState: FormState = {
pdfFile: null,
requirement: '',
language: 'zh-CN',
webSearch: false,
};

Expand Down Expand Up @@ -97,8 +100,19 @@ function HomePage() {
}
try {
const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
const updates: Partial<FormState> = {};
if (savedWebSearch === 'true') updates.webSearch = true;
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US' || savedLanguage === 'ru-RU') {
updates.language = savedLanguage;
} else {
const navLang = navigator.language?.toLowerCase() || '';
updates.language = navLang.startsWith('zh')
? 'zh-CN'
: navLang.startsWith('ru')
? 'ru-RU'
: 'en-US';
}
if (Object.keys(updates).length > 0) {
setForm((prev) => ({ ...prev, ...updates }));
}
Expand Down Expand Up @@ -198,6 +212,7 @@ function HomePage() {
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 */
Expand Down Expand Up @@ -257,6 +272,7 @@ function HomePage() {
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,
Expand Down Expand Up @@ -503,6 +519,8 @@ function HomePage() {
<div className="px-3 pb-3 flex items-end gap-2">
<div className="flex-1 min-w-0">
<GenerationToolbar
language={form.language}
onLanguageChange={(lang) => updateForm('language', lang)}
webSearch={form.webSearch}
onWebSearchChange={(v) => updateForm('webSearch', v)}
onSettingsOpen={(section) => {
Expand Down
24 changes: 23 additions & 1 deletion components/generation/generation-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useState, useRef, useMemo } from 'react';
import { Bot, Check, ChevronLeft, Paperclip, FileText, X, Globe2 } from 'lucide-react';
import { Bot, Check, ChevronLeft, Globe, Paperclip, FileText, X, Globe2 } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
Expand Down Expand Up @@ -29,6 +29,8 @@ const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;

// ─── Types ───────────────────────────────────────────────────
export interface GenerationToolbarProps {
language: 'zh-CN' | 'en-US' | 'ru-RU';
onLanguageChange: (lang: 'zh-CN' | 'en-US' | 'ru-RU') => void;
webSearch: boolean;
onWebSearchChange: (v: boolean) => void;
onSettingsOpen: (section?: SettingsSection) => void;
Expand All @@ -40,6 +42,8 @@ export interface GenerationToolbarProps {

// ─── Component ───────────────────────────────────────────────
export function GenerationToolbar({
language,
onLanguageChange,
webSearch,
onWebSearchChange,
onSettingsOpen,
Expand Down Expand Up @@ -356,6 +360,24 @@ export function GenerationToolbar({
</Tooltip>
)}

{/* ── Language pill ── */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() =>
onLanguageChange(
language === 'zh-CN' ? 'ru-RU' : language === 'ru-RU' ? 'en-US' : 'zh-CN',
)
}
className={pillMuted}
>
<Globe className="size-3.5" />
<span>{language === 'zh-CN' ? '中文' : language === 'ru-RU' ? 'RU' : 'EN'}</span>
</button>
</TooltipTrigger>
<TooltipContent>Generation language</TooltipContent>
</Tooltip>

{/* ── Separator ── */}
<div className="w-px h-4 bg-border/60 mx-1" />

Expand Down
4 changes: 3 additions & 1 deletion components/scene-renderers/quiz-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ async function gradeShortAnswerQuestion(
aiComment:
language === 'zh-CN'
? '评分服务暂时不可用,已给予基础分。'
: 'Grading service unavailable. Base score given.',
: language === 'ru-RU'
? 'Сервис оценки временно недоступен. Начислен базовый балл.'
: 'Grading service unavailable. Base score given.',
};
}
}
Expand Down
Loading
Loading