diff --git a/app/api/generate/scene-content/route.ts b/app/api/generate/scene-content/route.ts index db9b772e..4ff71229 100644 --- a/app/api/generate/scene-content/route.ts +++ b/app/api/generate/scene-content/route.ts @@ -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'; @@ -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 ── diff --git a/lib/generation/language.ts b/lib/generation/language.ts new file mode 100644 index 00000000..b1a8ab9a --- /dev/null +++ b/lib/generation/language.ts @@ -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 = { + '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(' '); +} diff --git a/lib/generation/outline-generator.ts b/lib/generation/outline-generator.ts index 4849bcef..97a46179 100644 --- a/lib/generation/outline-generator.ts +++ b/lib/generation/outline-generator.ts @@ -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'; @@ -38,9 +39,10 @@ export async function generateSceneOutlinesFromRequirements( teacherContext?: string; }, ): Promise> { + 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) { @@ -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 || '', }); diff --git a/lib/generation/prompt-formatters.ts b/lib/generation/prompt-formatters.ts index 4486ba09..12732ad9 100644 --- a/lib/generation/prompt-formatters.ts +++ b/lib/generation/prompt-formatters.ts @@ -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 */ @@ -76,15 +77,23 @@ 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}`; } /** @@ -92,14 +101,22 @@ export function formatImageDescription(img: PdfImage, language: string): string * 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]`; } /** @@ -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 diff --git a/lib/generation/prompts/templates/requirements-to-outlines/user.md b/lib/generation/prompts/templates/requirements-to-outlines/user.md index 65d0a492..263f0ae8 100644 --- a/lib/generation/prompts/templates/requirements-to-outlines/user.md +++ b/lib/generation/prompts/templates/requirements-to-outlines/user.md @@ -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. --- diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937..f857f9bb 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -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'; @@ -468,9 +469,10 @@ async function generateSlideContent( agents?: AgentInfo[], ): Promise { 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) { @@ -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, @@ -735,7 +737,7 @@ function normalizeQuizAnswer(question: Record): string[] | unde async function generateInteractiveContent( outline: SceneOutline, aiCall: AICallFn, - language: 'zh-CN' | 'en-US' = 'zh-CN', + language: SceneOutline['language'] = 'zh-CN', ): Promise { const config = outline.interactiveConfig!; @@ -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, }, ]; } @@ -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 @@ -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, }); @@ -1170,13 +1176,15 @@ 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, }, ]; } @@ -1184,13 +1192,15 @@ function generateDefaultQuizActions(_outline: SceneOutline): Action[] { /** * 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, }, ]; } diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index eda67b4c..6d64a90c 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -26,6 +26,7 @@ import { replaceMediaPlaceholders, generateTTSForClassroom, } from '@/lib/server/classroom-media-generation'; +import { buildLanguageInstruction, normalizeGenerationLanguage } from '@/lib/generation/language'; import type { UserRequirements } from '@/lib/types/generation'; import type { Scene, Stage } from '@/lib/types/stage'; @@ -96,10 +97,6 @@ function createInMemoryStore(stage: Stage): StageStore { }; } -function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' { - return language === 'en-US' ? 'en-US' : 'zh-CN'; -} - function stripCodeFences(text: string): string { let cleaned = text.trim(); if (cleaned.startsWith('```')) { @@ -188,13 +185,19 @@ export async function generateClassroom( ); } + const lang = normalizeGenerationLanguage(input.language); + const languageInstruction = buildLanguageInstruction(lang); + const aiCall: AICallFn = async (systemPrompt, userPrompt, _images) => { const result = await callLLM( { model: languageModel, messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, + { role: 'system', content: `${languageInstruction}\n\n${systemPrompt}` }, + { + role: 'user', + content: `${userPrompt}\n\nLanguage enforcement: ${languageInstruction}`, + }, ], maxOutputTokens: modelInfo?.outputWindow, }, @@ -203,7 +206,6 @@ export async function generateClassroom( return result.text; }; - const lang = normalizeLanguage(input.language); const requirements: UserRequirements = { requirement, language: lang, diff --git a/lib/types/generation.ts b/lib/types/generation.ts index c1e6eb7a..0162efc0 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -7,6 +7,7 @@ import type { ActionType } from './action'; import type { MediaGenerationRequest } from '@/lib/media/types'; +import type { SupportedGenerationLanguage } from '@/lib/generation/language'; // ==================== PDF Image Types ==================== @@ -64,7 +65,7 @@ export interface UploadedDocument { */ export interface UserRequirements { requirement: string; // Single free-form text for all user input - language: 'zh-CN' | 'en-US'; // Course language - critical for generation + language: SupportedGenerationLanguage; // Course language - critical for generation userNickname?: string; // Student nickname for personalization userBio?: string; // Student background for personalization webSearch?: boolean; // Enable web search for richer context @@ -100,7 +101,7 @@ export interface SceneOutline { teachingObjective?: string; estimatedDuration?: number; // seconds order: number; - language?: 'zh-CN' | 'en-US'; // Generation language (inherited from requirements) + language?: SupportedGenerationLanguage; // Generation language (inherited from requirements) // Suggested image IDs (from PDF-extracted images) suggestedImageIds?: string[]; // e.g., ["img_1", "img_3"] // AI-generated media requests (when PDF images are insufficient) @@ -124,7 +125,7 @@ export interface SceneOutline { projectDescription: string; targetSkills: string[]; issueCount?: number; - language: 'zh-CN' | 'en-US'; + language: SupportedGenerationLanguage; }; }