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
3 changes: 2 additions & 1 deletion app/api/generate/scene-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
generateSceneContent,
buildVisionUserContent,
} from '@/lib/generation/generation-pipeline';
import { normalizeGenerationLanguage } from '@/lib/generation/language';
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
import { createLogger } from '@/lib/logger';
Expand Down Expand Up @@ -67,7 +68,7 @@ export async function POST(req: NextRequest) {
// 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',
language: rawOutline.language ?? normalizeGenerationLanguage(stageInfo?.language),
};

// ── Model resolution from request headers ──
Expand Down
112 changes: 112 additions & 0 deletions lib/generation/language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
export type SupportedGenerationLanguage = 'zh-CN' | 'en-US' | 'ru-RU';

export interface GenerationLanguageSpec {
code: SupportedGenerationLanguage;
englishName: string;
nativeName: string;
noImagesAvailableText: string;
noneText: string;
noImagesForSlideText: string;
autoGenerateElementsText: string;
slideFocusTitle: string;
slideSpeechTitle: string;
slideSpeechFallback: string;
quizGuideTitle: string;
quizGuideText: string;
interactiveGuideTitle: string;
interactiveGuideText: string;
pblIntroTitle: string;
pblIntroText: string;
}

const SPECS: Record<SupportedGenerationLanguage, GenerationLanguageSpec> = {
'zh-CN': {
code: 'zh-CN',
englishName: 'Chinese',
nativeName: '中文',
noImagesAvailableText: '无可用图片',
noneText: '无',
noImagesForSlideText: '无可用图片,禁止插入任何 image 元素',
autoGenerateElementsText: '(根据要点自动生成)',
slideFocusTitle: '聚焦重点',
slideSpeechTitle: '场景讲解',
slideSpeechFallback: '请先关注这一页的核心要点。',
quizGuideTitle: '测验引导',
quizGuideText: '现在让我们来做一个小测验,检验一下学习成果。',
interactiveGuideTitle: '交互引导',
interactiveGuideText:
'现在让我们通过交互式可视化来探索这个概念。请尝试操作页面中的元素,观察变化。',
pblIntroTitle: 'PBL 项目介绍',
pblIntroText:
'现在让我们开始一个项目式学习活动。请选择你的角色,查看任务看板,开始协作完成项目。',
},
'en-US': {
code: 'en-US',
englishName: 'English',
nativeName: 'English',
noImagesAvailableText: 'No images available',
noneText: 'None',
noImagesForSlideText: 'No images available. Do not insert any image elements.',
autoGenerateElementsText: '(generate automatically from the key points)',
slideFocusTitle: 'Focus on the key point',
slideSpeechTitle: 'Scene explanation',
slideSpeechFallback: "Let's focus on the key ideas on this page first.",
quizGuideTitle: 'Quiz guidance',
quizGuideText: "Let's do a short quiz now to check what we have learned.",
interactiveGuideTitle: 'Interactive guidance',
interactiveGuideText:
"Now let's explore this concept through an interactive visualization. Try the controls on the page and observe what changes.",
pblIntroTitle: 'PBL project introduction',
pblIntroText:
"Now let's begin a project-based learning activity. Choose your role, review the task board, and start collaborating on the project.",
},
'ru-RU': {
code: 'ru-RU',
englishName: 'Russian',
nativeName: 'Русский',
noImagesAvailableText: 'Нет доступных изображений',
noneText: 'Нет',
noImagesForSlideText: 'Нет доступных изображений. Не вставляй элементы image.',
autoGenerateElementsText: 'сгенерируй автоматически по ключевым пунктам',
slideFocusTitle: 'Фокус на главном',
slideSpeechTitle: 'Объяснение сцены',
slideSpeechFallback: 'Сначала разберём ключевые идеи этой страницы.',
quizGuideTitle: 'Введение к квизу',
quizGuideText: 'Сейчас сделаем короткий квиз, чтобы проверить, что уже усвоили.',
interactiveGuideTitle: 'Введение к интерактиву',
interactiveGuideText:
'Теперь давай разберём этот концепт через интерактивную визуализацию. Попробуй элементы управления на странице и посмотри, что меняется.',
pblIntroTitle: 'Введение в PBL-проект',
pblIntroText:
'Теперь начинаем проектное задание. Выбери роль, посмотри на доску задач и приступай к совместной работе над проектом.',
},
};

export function normalizeGenerationLanguage(language?: string): SupportedGenerationLanguage {
const normalized = (language || '').trim().toLowerCase();

if (normalized.startsWith('ru')) return 'ru-RU';
if (normalized.startsWith('en')) return 'en-US';
if (normalized.startsWith('zh')) return 'zh-CN';

return 'zh-CN';
}

export function getGenerationLanguageSpec(language?: string): GenerationLanguageSpec {
return SPECS[normalizeGenerationLanguage(language)];
}

export function buildLanguageInstruction(language?: string): string {
const spec = getGenerationLanguageSpec(language);

return [
`Output language must be ${spec.englishName} (${spec.nativeName}).`,
`All natural-language text, titles, explanations, quiz text, hints, labels, and UI copy must be in ${spec.englishName}.`,
spec.code === 'ru-RU'
? 'English is allowed only for code, SQL keywords, API field names, or other technical syntax that must remain unchanged.'
: 'Keep technical syntax unchanged only when necessary.',
spec.code === 'ru-RU'
? 'Never output Chinese.'
: 'Do not switch to another language unless the user explicitly asks for it.',
].join(' ');
}
15 changes: 6 additions & 9 deletions lib/generation/outline-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
ImageMapping,
} from '@/lib/types/generation';
import { buildPrompt, PROMPT_IDS } from './prompts';
import { getGenerationLanguageSpec } from './language';
import { formatImageDescription, formatImagePlaceholder } from './prompt-formatters';
import { parseJsonResponse } from './json-repair';
import { uniquifyMediaElementIds } from './scene-builder';
Expand All @@ -38,9 +39,10 @@ export async function generateSceneOutlinesFromRequirements(
teacherContext?: string;
},
): Promise<GenerationResult<SceneOutline[]>> {
const languageSpec = getGenerationLanguageSpec(requirements.language);

// Build available images description for the prompt
let availableImagesText =
requirements.language === 'zh-CN' ? '无可用图片' : 'No images available';
let availableImagesText = languageSpec.noImagesAvailableText;
let visionImages: Array<{ id: string; src: string }> | undefined;

if (pdfImages && pdfImages.length > 0) {
Expand Down Expand Up @@ -99,16 +101,11 @@ export async function generateSceneOutlinesFromRequirements(
// New simplified variables
requirement: requirements.requirement,
language: requirements.language,
pdfContent: pdfText
? pdfText.substring(0, MAX_PDF_CONTENT_CHARS)
: requirements.language === 'zh-CN'
? '无'
: 'None',
pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : languageSpec.noneText,
availableImages: availableImagesText,
userProfile: userProfileText,
mediaGenerationPolicy,
researchContext:
options?.researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'),
researchContext: options?.researchContext || languageSpec.noneText,
// Server-side generation populates this via options; client-side populates via formatTeacherPersonaForPrompt
teacherContext: options?.teacherContext || '',
});
Expand Down
35 changes: 26 additions & 9 deletions lib/generation/prompt-formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { PdfImage } from '@/lib/types/generation';
import { getGenerationLanguageSpec } from './language';
import type { AgentInfo, SceneGenerationContext } from './pipeline-types';

/** Build a course context string for injection into action prompts */
Expand Down Expand Up @@ -76,30 +77,46 @@ export function formatTeacherPersonaForPrompt(agents?: AgentInfo[]): string {
* Includes dimension/aspect-ratio info when available.
*/
export function formatImageDescription(img: PdfImage, language: string): string {
const spec = getGenerationLanguageSpec(language);
let dimInfo = '';
if (img.width && img.height) {
const ratio = (img.width / img.height).toFixed(2);
dimInfo = ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`;
dimInfo =
spec.code === 'zh-CN'
? ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`
: ` | dimensions: ${img.width}×${img.height} (aspect ratio ${ratio})`;
}
const desc = img.description ? ` | ${img.description}` : '';
return language === 'zh-CN'
? `- **${img.id}**: 来自PDF第${img.pageNumber}页${dimInfo}${desc}`
: `- **${img.id}**: from PDF page ${img.pageNumber}${dimInfo}${desc}`;
if (spec.code === 'zh-CN') {
return `- **${img.id}**: 来自PDF第${img.pageNumber}页${dimInfo}${desc}`;
}
if (spec.code === 'ru-RU') {
return `- **${img.id}**: из PDF, страница ${img.pageNumber}${dimInfo}${desc}`;
}
return `- **${img.id}**: from PDF page ${img.pageNumber}${dimInfo}${desc}`;
}

/**
* Format a short image placeholder for vision mode.
* Only ID + page + dimensions + aspect ratio (no description), since the model can see the actual image.
*/
export function formatImagePlaceholder(img: PdfImage, language: string): string {
const spec = getGenerationLanguageSpec(language);
let dimInfo = '';
if (img.width && img.height) {
const ratio = (img.width / img.height).toFixed(2);
dimInfo = ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`;
dimInfo =
spec.code === 'zh-CN'
? ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`
: ` | dimensions: ${img.width}×${img.height} (aspect ratio ${ratio})`;
}
if (spec.code === 'zh-CN') {
return `- **${img.id}**: PDF第${img.pageNumber}页的图片${dimInfo} [参见附图]`;
}
if (spec.code === 'ru-RU') {
return `- **${img.id}**: изображение со страницы ${img.pageNumber} PDF${dimInfo} [см. вложение]`;
}
return language === 'zh-CN'
? `- **${img.id}**: PDF第${img.pageNumber}页的图片${dimInfo} [参见附图]`
: `- **${img.id}**: image from PDF page ${img.pageNumber}${dimInfo} [see attached]`;
return `- **${img.id}**: image from PDF page ${img.pageNumber}${dimInfo} [see attached]`;
}

/**
Expand All @@ -121,7 +138,7 @@ export function buildVisionUserContent(
let dimInfo = '';
if (img.width && img.height) {
const ratio = (img.width / img.height).toFixed(2);
dimInfo = ` (${img.width}×${img.height}, 宽高比${ratio})`;
dimInfo = ` (${img.width}×${img.height}, aspect ratio ${ratio})`;
}
parts.push({ type: 'text', text: `\n**${img.id}**${dimInfo}:` });
// Strip data URI prefix — AI SDK only accepts http(s) URLs or raw base64
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Please generate scene outlines based on the following course requirements.

**Required language**: {{language}}

(If language is zh-CN, all content must be in Chinese; if en-US, all content must be in English)
All content must be in the required language above. Do not switch to another language.

---

Expand Down
42 changes: 26 additions & 16 deletions lib/generation/scene-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { StageStore } from '@/lib/api/stage-api';
import { createStageAPI } from '@/lib/api/stage-api';
import { generatePBLContent } from '@/lib/pbl/generate-pbl';
import { buildPrompt, PROMPT_IDS } from './prompts';
import { getGenerationLanguageSpec } from './language';
import { postProcessInteractiveHtml } from './interactive-post-processor';
import { parseActionsFromStructuredOutput } from './action-parser';
import { parseJsonResponse } from './json-repair';
Expand Down Expand Up @@ -468,9 +469,10 @@ async function generateSlideContent(
agents?: AgentInfo[],
): Promise<GeneratedSlideContent | null> {
const lang = outline.language || 'zh-CN';
const languageSpec = getGenerationLanguageSpec(lang);

// Build assigned images description for the prompt
let assignedImagesText = '无可用图片,禁止插入任何 image 元素';
let assignedImagesText = languageSpec.noImagesForSlideText;
let visionImages: Array<{ id: string; src: string }> | undefined;

if (assignedImages && assignedImages.length > 0) {
Expand Down Expand Up @@ -539,7 +541,7 @@ async function generateSlideContent(
title: outline.title,
description: outline.description,
keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
elements: '(根据要点自动生成)',
elements: languageSpec.autoGenerateElementsText,
assignedImages: assignedImagesText,
canvas_width: canvasWidth,
canvas_height: canvasHeight,
Expand Down Expand Up @@ -735,7 +737,7 @@ function normalizeQuizAnswer(question: Record<string, unknown>): string[] | unde
async function generateInteractiveContent(
outline: SceneOutline,
aiCall: AICallFn,
language: 'zh-CN' | 'en-US' = 'zh-CN',
language: SceneOutline['language'] = 'zh-CN',
): Promise<GeneratedInteractiveContent | null> {
const config = outline.interactiveConfig!;

Expand Down Expand Up @@ -1035,13 +1037,15 @@ export async function generateSceneActions(
/**
* Generate default PBL Actions (fallback)
*/
function generateDefaultPBLActions(_outline: SceneOutline): Action[] {
function generateDefaultPBLActions(outline: SceneOutline): Action[] {
const languageSpec = getGenerationLanguageSpec(outline.language);

return [
{
id: `action_${nanoid(8)}`,
type: 'speech',
title: 'PBL 项目介绍',
text: '现在让我们开始一个项目式学习活动。请选择你的角色,查看任务看板,开始协作完成项目。',
title: languageSpec.pblIntroTitle,
text: languageSpec.pblIntroText,
},
];
}
Expand Down Expand Up @@ -1140,6 +1144,7 @@ function processActions(actions: Action[], elements: PPTElement[], agents?: Agen
* Generate default slide Actions (fallback)
*/
function generateDefaultSlideActions(outline: SceneOutline, elements: PPTElement[]): Action[] {
const languageSpec = getGenerationLanguageSpec(outline.language);
const actions: Action[] = [];

// Add spotlight for text elements
Expand All @@ -1148,19 +1153,20 @@ function generateDefaultSlideActions(outline: SceneOutline, elements: PPTElement
actions.push({
id: `action_${nanoid(8)}`,
type: 'spotlight',
title: '聚焦重点',
title: languageSpec.slideFocusTitle,
elementId: textElements[0].id,
});
}

// Add opening speech based on key points
const joiner = languageSpec.code === 'zh-CN' ? '。' : '. ';
const speechText = outline.keyPoints?.length
? outline.keyPoints.join('。') + '。'
: outline.description || outline.title;
? `${outline.keyPoints.join(joiner)}${languageSpec.code === 'zh-CN' ? '。' : '.'}`
: outline.description || outline.title || languageSpec.slideSpeechFallback;
actions.push({
id: `action_${nanoid(8)}`,
type: 'speech',
title: '场景讲解',
title: languageSpec.slideSpeechTitle,
text: speechText,
});

Expand All @@ -1170,27 +1176,31 @@ function generateDefaultSlideActions(outline: SceneOutline, elements: PPTElement
/**
* Generate default quiz Actions (fallback)
*/
function generateDefaultQuizActions(_outline: SceneOutline): Action[] {
function generateDefaultQuizActions(outline: SceneOutline): Action[] {
const languageSpec = getGenerationLanguageSpec(outline.language);

return [
{
id: `action_${nanoid(8)}`,
type: 'speech',
title: '测验引导',
text: '现在让我们来做一个小测验,检验一下学习成果。',
title: languageSpec.quizGuideTitle,
text: languageSpec.quizGuideText,
},
];
}

/**
* Generate default interactive Actions (fallback)
*/
function generateDefaultInteractiveActions(_outline: SceneOutline): Action[] {
function generateDefaultInteractiveActions(outline: SceneOutline): Action[] {
const languageSpec = getGenerationLanguageSpec(outline.language);

return [
{
id: `action_${nanoid(8)}`,
type: 'speech',
title: '交互引导',
text: '现在让我们通过交互式可视化来探索这个概念。请尝试操作页面中的元素,观察变化。',
title: languageSpec.interactiveGuideTitle,
text: languageSpec.interactiveGuideText,
},
];
}
Expand Down
Loading
Loading