Skip to content

Commit b194fc3

Browse files
author
harshana
committed
feat: add German language support (de-DE) following new i18n system
Refactored the initial German language support to align with the newly introduced i18n system. Changes: - Added de-DE.json with all translation keys. - Registered de-DE in supportedLocales. - Localized fallback strings in the generation pipeline. - Updated UI components to dynamically use supportedLocales. - Added Azure TTS German voices. - Made prompts generic for multiple languages. Closes #215
1 parent d81b4de commit b194fc3

14 files changed

Lines changed: 1044 additions & 47 deletions

File tree

app/api/generate/scene-content/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
generateSceneContent,
1414
buildVisionUserContent,
1515
} from '@/lib/generation/generation-pipeline';
16+
import type { Locale } from '@/lib/i18n/types';
1617
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
1718
import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
1819
import { createLogger } from '@/lib/logger';
@@ -69,7 +70,7 @@ export async function POST(req: NextRequest) {
6970
// Ensure outline has language from stageInfo (fallback for older outlines)
7071
const outline: SceneOutline = {
7172
...rawOutline,
72-
language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN',
73+
language: rawOutline.language || (stageInfo?.language as Locale) || 'zh-CN',
7374
};
7475

7576
// ── Model resolution from request headers ──

app/page.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { useTheme } from '@/lib/hooks/use-theme';
3232
import { nanoid } from 'nanoid';
3333
import { storePdfBlob } from '@/lib/utils/image-storage';
3434
import type { UserRequirements } from '@/lib/types/generation';
35+
import type { Locale } from '@/lib/i18n/types';
36+
import { supportedLocales } from '@/lib/i18n/locales';
3537
import { useSettingsStore } from '@/lib/store/settings';
3638
import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile';
3739
import {
@@ -58,7 +60,7 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
5860
interface FormState {
5961
pdfFile: File | null;
6062
requirement: string;
61-
language: 'zh-CN' | 'en-US';
63+
language: Locale;
6264
webSearch: boolean;
6365
}
6466

@@ -98,14 +100,21 @@ function HomePage() {
98100
}
99101
try {
100102
const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
101-
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
103+
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
102104
const updates: Partial<FormState> = {};
103105
if (savedWebSearch === 'true') updates.webSearch = true;
104-
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') {
106+
107+
const isSupported = (lang: string | null): lang is Locale =>
108+
!!lang && supportedLocales.some((l) => l.code === lang);
109+
110+
if (isSupported(savedLanguage)) {
105111
updates.language = savedLanguage;
106112
} else {
107-
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
108-
updates.language = detected;
113+
const browserLang = navigator.language;
114+
const matched =
115+
supportedLocales.find((l) => browserLang.startsWith(l.code.split('-')[0]))?.code ||
116+
(browserLang.startsWith('zh') ? 'zh-CN' : 'en-US');
117+
updates.language = matched as Locale;
109118
}
110119
if (Object.keys(updates).length > 0) {
111120
setForm((prev) => ({ ...prev, ...updates }));

components/generation/generation-toolbar.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@ import type { ProviderId } from '@/lib/ai/providers';
2222
import type { SettingsSection } from '@/lib/types/settings';
2323
import { MediaPopover } from '@/components/generation/media-popover';
2424

25+
import type { Locale } from '@/lib/i18n/types';
26+
import { supportedLocales } from '@/lib/i18n/locales';
27+
2528
// ─── Constants ───────────────────────────────────────────────
2629
const MAX_PDF_SIZE_MB = 50;
2730
const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;
2831

2932
// ─── Types ───────────────────────────────────────────────────
3033
export interface GenerationToolbarProps {
31-
language: 'zh-CN' | 'en-US';
32-
onLanguageChange: (lang: 'zh-CN' | 'en-US') => void;
34+
language: Locale;
35+
onLanguageChange: (lang: Locale) => void;
3336
webSearch: boolean;
3437
onWebSearchChange: (v: boolean) => void;
3538
onSettingsOpen: (section?: SettingsSection) => void;
@@ -64,6 +67,15 @@ export function GenerationToolbar({
6467
const fileInputRef = useRef<HTMLInputElement>(null);
6568
const [isDragging, setIsDragging] = useState(false);
6669

70+
// Cycle language among supported locales
71+
const cycleLanguage = () => {
72+
const currentIndex = supportedLocales.findIndex((l) => l.code === language);
73+
const nextIndex = (currentIndex + 1) % supportedLocales.length;
74+
onLanguageChange(supportedLocales[nextIndex].code);
75+
};
76+
77+
const currentLocaleInfo = supportedLocales.find((l) => l.code === language) || supportedLocales[0];
78+
6779
// Check if the selected web search provider has a valid config (API key or server-configured)
6880
const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId];
6981
const webSearchConfig = webSearchProvidersConfig[webSearchProviderId];
@@ -360,12 +372,9 @@ export function GenerationToolbar({
360372
{/* ── Language pill ── */}
361373
<Tooltip>
362374
<TooltipTrigger asChild>
363-
<button
364-
onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : 'zh-CN')}
365-
className={pillMuted}
366-
>
375+
<button onClick={cycleLanguage} className={pillMuted}>
367376
<Globe className="size-3.5" />
368-
<span>{language === 'zh-CN' ? '中文' : 'EN'}</span>
377+
<span>{currentLocaleInfo.shortLabel}</span>
369378
</button>
370379
</TooltipTrigger>
371380
<TooltipContent>{t('toolbar.languageHint')}</TooltipContent>

lib/audio/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,18 @@ export const TTS_PROVIDERS: Record<TTSProviderId, TTSProviderConfig> = {
204204
gender: 'female',
205205
},
206206
{ id: 'en-US-GuyNeural', name: 'Guy', language: 'en-US', gender: 'male' },
207+
{
208+
id: 'de-DE-KatjaNeural',
209+
name: 'Katja (女)',
210+
language: 'de-DE',
211+
gender: 'female',
212+
},
213+
{
214+
id: 'de-DE-ConradNeural',
215+
name: 'Conrad (男)',
216+
language: 'de-DE',
217+
gender: 'male',
218+
},
207219
],
208220
supportedFormats: ['mp3', 'wav', 'ogg'],
209221
speedRange: { min: 0.5, max: 2.0, default: 1.0 },

lib/audio/tts-providers.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,16 @@ async function generateAzureTTS(
212212
): Promise<TTSGenerationResult> {
213213
const baseUrl = config.baseUrl || TTS_PROVIDERS['azure-tts'].defaultBaseUrl;
214214

215+
// Extract language from voice ID (e.g., "zh-CN-XiaoxiaoNeural" -> "zh-CN")
216+
// Fallback to "zh-CN" if no match
217+
const langMatch = config.voice.match(/^[a-z]{2}-[A-Z]{2}/);
218+
const lang = langMatch ? langMatch[0] : 'zh-CN';
219+
215220
// Build SSML
216221
const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%';
217222
const ssml = `
218-
<speak version='1.0' xml:lang='zh-CN'>
219-
<voice xml:lang='zh-CN' name='${config.voice}'>
223+
<speak version='1.0' xml:lang='${lang}'>
224+
<voice xml:lang='${lang}' name='${config.voice}'>
220225
<prosody rate='${rate}'>${escapeXml(text)}</prosody>
221226
</voice>
222227
</speak>

lib/generation/outline-generator.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ export async function generateSceneOutlinesFromRequirements(
3939
},
4040
): Promise<GenerationResult<SceneOutline[]>> {
4141
// Build available images description for the prompt
42-
let availableImagesText =
43-
requirements.language === 'zh-CN' ? '无可用图片' : 'No images available';
42+
let availableImagesText = 'No images available';
43+
if (requirements.language === 'zh-CN') availableImagesText = '无可用图片';
44+
if (requirements.language === 'ja-JP') availableImagesText = '利用可能な画像はありません';
45+
if (requirements.language === 'de-DE') availableImagesText = 'Keine Bilder verfügbar';
4446
let visionImages: Array<{ id: string; src: string }> | undefined;
4547

4648
if (pdfImages && pdfImages.length > 0) {
@@ -103,12 +105,23 @@ export async function generateSceneOutlinesFromRequirements(
103105
? pdfText.substring(0, MAX_PDF_CONTENT_CHARS)
104106
: requirements.language === 'zh-CN'
105107
? '无'
106-
: 'None',
108+
: requirements.language === 'ja-JP'
109+
? 'なし'
110+
: requirements.language === 'de-DE'
111+
? 'Keine'
112+
: 'None',
107113
availableImages: availableImagesText,
108114
userProfile: userProfileText,
109115
mediaGenerationPolicy,
110116
researchContext:
111-
options?.researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'),
117+
options?.researchContext ||
118+
(requirements.language === 'zh-CN'
119+
? '无'
120+
: requirements.language === 'ja-JP'
121+
? 'なし'
122+
: requirements.language === 'de-DE'
123+
? 'Keine'
124+
: 'None'),
112125
// Server-side generation populates this via options; client-side populates via formatTeacherPersonaForPrompt
113126
teacherContext: options?.teacherContext || '',
114127
});

lib/generation/prompt-formatters.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,16 @@ export function formatImageDescription(img: PdfImage, language: string): string
7979
let dimInfo = '';
8080
if (img.width && img.height) {
8181
const ratio = (img.width / img.height).toFixed(2);
82-
dimInfo = ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`;
82+
if (language === 'zh-CN') dimInfo = ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`;
83+
else if (language === 'ja-JP') dimInfo = ` | サイズ: ${img.width}×${img.height} (アスペクト比${ratio})`;
84+
else if (language === 'de-DE') dimInfo = ` | Größe: ${img.width}×${img.height} (Seitenverhältnis${ratio})`;
85+
else dimInfo = ` | size: ${img.width}×${img.height} (ratio ${ratio})`;
8386
}
8487
const desc = img.description ? ` | ${img.description}` : '';
85-
return language === 'zh-CN'
86-
? `- **${img.id}**: 来自PDF第${img.pageNumber}${dimInfo}${desc}`
87-
: `- **${img.id}**: from PDF page ${img.pageNumber}${dimInfo}${desc}`;
88+
if (language === 'zh-CN') return `- **${img.id}**: 来自PDF第${img.pageNumber}${dimInfo}${desc}`;
89+
if (language === 'ja-JP') return `- **${img.id}**: PDFの${img.pageNumber}ページ目から${dimInfo}${desc}`;
90+
if (language === 'de-DE') return `- **${img.id}**: von PDF-Seite ${img.pageNumber}${dimInfo}${desc}`;
91+
return `- **${img.id}**: from PDF page ${img.pageNumber}${dimInfo}${desc}`;
8892
}
8993

9094
/**
@@ -95,11 +99,15 @@ export function formatImagePlaceholder(img: PdfImage, language: string): string
9599
let dimInfo = '';
96100
if (img.width && img.height) {
97101
const ratio = (img.width / img.height).toFixed(2);
98-
dimInfo = ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`;
102+
if (language === 'zh-CN') dimInfo = ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`;
103+
else if (language === 'ja-JP') dimInfo = ` | サイズ: ${img.width}×${img.height} (アスペクト比${ratio})`;
104+
else if (language === 'de-DE') dimInfo = ` | Größe: ${img.width}×${img.height} (Seitenverhältnis${ratio})`;
105+
else dimInfo = ` | size: ${img.width}×${img.height} (ratio ${ratio})`;
99106
}
100-
return language === 'zh-CN'
101-
? `- **${img.id}**: PDF第${img.pageNumber}页的图片${dimInfo} [参见附图]`
102-
: `- **${img.id}**: image from PDF page ${img.pageNumber}${dimInfo} [see attached]`;
107+
if (language === 'zh-CN') return `- **${img.id}**: PDF第${img.pageNumber}页的图片${dimInfo} [参见附图]`;
108+
if (language === 'ja-JP') return `- **${img.id}**: PDFの${img.pageNumber}ページ目の画像${dimInfo} [添付画像参照]`;
109+
if (language === 'de-DE') return `- **${img.id}**: Bild von PDF-Seite ${img.pageNumber}${dimInfo} [siehe Anhang]`;
110+
return `- **${img.id}**: image from PDF page ${img.pageNumber}${dimInfo} [see attached]`;
103111
}
104112

105113
/**

lib/generation/prompts/templates/requirements-to-outlines/system.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ When a slide scene needs an image or video but no suitable PDF image exists, mar
9494
- **Image IDs**: use `"gen_img_1"`, `"gen_img_2"`, etc. — IDs are **globally unique across the entire course**, NOT reset per scene
9595
- **Video IDs**: use `"gen_vid_1"`, `"gen_vid_2"`, etc. — same global numbering rule
9696
- The prompt should describe the desired media clearly and specifically
97-
- **Language in images**: If the image contains text, labels, or annotations, the prompt MUST explicitly specify that all text in the image should be in the course language (e.g., "all labels in Chinese" for zh-CN courses, "all labels in English" for en-US courses). For purely visual images without text, language does not matter.
97+
- **Language in images**: If the image contains text, labels, or annotations, the prompt MUST explicitly specify that all text in the image should be in the course language (e.g., "all labels in German" for de-DE courses). For purely visual images without text, language does not matter.
9898
- Only request media generation when it genuinely enhances the content — not every slide needs an image or video
9999
- Video generation is slow (1-2 minutes each), so only request videos when motion genuinely enhances understanding
100100
- If a suitable PDF image exists, prefer using `suggestedImageIds` instead
@@ -280,7 +280,7 @@ You must output a JSON array where each element is a scene outline object:
280280
"projectDescription": "Brief description of what students will build/accomplish",
281281
"targetSkills": ["Skill 1", "Skill 2", "Skill 3"],
282282
"issueCount": 3,
283-
"language": "zh-CN"
283+
"language": "de-DE"
284284
}
285285
```
286286

lib/generation/prompts/templates/requirements-to-outlines/user.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Please generate scene outlines based on the following course requirements.
1414

1515
**Required language**: {{language}}
1616

17-
(If language is zh-CN, all content must be in Chinese; if en-US, all content must be in English)
17+
(Strictly generate all content in the required language: zh-CN -> Chinese, en-US -> English, ja-JP -> Japanese, de-DE -> German, etc.)
1818

1919
---
2020

lib/generation/scene-generator.ts

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,13 +1035,27 @@ export async function generateSceneActions(
10351035
/**
10361036
* Generate default PBL Actions (fallback)
10371037
*/
1038-
function generateDefaultPBLActions(_outline: SceneOutline): Action[] {
1038+
function generateDefaultPBLActions(outline: SceneOutline): Action[] {
1039+
const lang = outline.language || 'zh-CN';
1040+
let title = 'PBL Project Intro';
1041+
let text = 'Let\'s start a Project-Based Learning activity. Choose your role, check the issue board, and collaborate to complete the project.';
1042+
if (lang === 'zh-CN') {
1043+
title = 'PBL 项目介绍';
1044+
text = '现在让我们开始一个项目式学习活动。请选择你的角色,查看任务看板,开始协作完成项目。';
1045+
} else if (lang === 'ja-JP') {
1046+
title = 'PBLプロジェクト紹介';
1047+
text = 'プロジェクトベース学習活動を開始しましょう。役割を選択し、課題ボードを確認して、協力してプロジェクトを完了させてください。';
1048+
} else if (lang === 'de-DE') {
1049+
title = 'PBL Projekt-Einführung';
1050+
text = 'Beginnen wir mit einer projektbasierten Lernaktivität. Wählen Sie Ihre Rolle, prüfen Sie das Aufgabenboard und arbeiten Sie zusammen, um das Projekt abzuschließen.';
1051+
}
1052+
10391053
return [
10401054
{
10411055
id: `action_${nanoid(8)}`,
10421056
type: 'speech',
1043-
title: 'PBL 项目介绍',
1044-
text: '现在让我们开始一个项目式学习活动。请选择你的角色,查看任务看板,开始协作完成项目。',
1057+
title,
1058+
text,
10451059
},
10461060
];
10471061
}
@@ -1143,26 +1157,39 @@ function processActions(actions: Action[], elements: PPTElement[], agents?: Agen
11431157
*/
11441158
function generateDefaultSlideActions(outline: SceneOutline, elements: PPTElement[]): Action[] {
11451159
const actions: Action[] = [];
1160+
const lang = outline.language || 'zh-CN';
11461161

11471162
// Add spotlight for text elements
11481163
const textElements = elements.filter((el) => el.type === 'text');
11491164
if (textElements.length > 0) {
1165+
let spotlightTitle = 'Focus';
1166+
if (lang === 'zh-CN') spotlightTitle = '聚焦重点';
1167+
else if (lang === 'ja-JP') spotlightTitle = '重要ポイント';
1168+
else if (lang === 'de-DE') spotlightTitle = 'Fokus';
1169+
11501170
actions.push({
11511171
id: `action_${nanoid(8)}`,
11521172
type: 'spotlight',
1153-
title: '聚焦重点',
1173+
title: spotlightTitle,
11541174
elementId: textElements[0].id,
11551175
});
11561176
}
11571177

11581178
// Add opening speech based on key points
1179+
let speechTitle = 'Scene Explanation';
1180+
if (lang === 'zh-CN') speechTitle = '场景讲解';
1181+
else if (lang === 'ja-JP') speechTitle = 'シーン解説';
1182+
else if (lang === 'de-DE') speechTitle = 'Szenenerklärung';
1183+
1184+
const separator = lang === 'zh-CN' || lang === 'ja-JP' ? '。' : '. ';
11591185
const speechText = outline.keyPoints?.length
1160-
? outline.keyPoints.join('。') + '。'
1186+
? outline.keyPoints.join(separator) + (lang === 'zh-CN' || lang === 'ja-JP' ? '。' : '.')
11611187
: outline.description || outline.title;
1188+
11621189
actions.push({
11631190
id: `action_${nanoid(8)}`,
11641191
type: 'speech',
1165-
title: '场景讲解',
1192+
title: speechTitle,
11661193
text: speechText,
11671194
});
11681195

@@ -1172,27 +1199,55 @@ function generateDefaultSlideActions(outline: SceneOutline, elements: PPTElement
11721199
/**
11731200
* Generate default quiz Actions (fallback)
11741201
*/
1175-
function generateDefaultQuizActions(_outline: SceneOutline): Action[] {
1202+
function generateDefaultQuizActions(outline: SceneOutline): Action[] {
1203+
const lang = outline.language || 'zh-CN';
1204+
let title = 'Quiz Intro';
1205+
let text = 'Now let\'s take a short quiz to test what we\'ve learned.';
1206+
if (lang === 'zh-CN') {
1207+
title = '测验引导';
1208+
text = '现在让我们来做一个小测验,检验一下学习成果。';
1209+
} else if (lang === 'ja-JP') {
1210+
title = 'クイズ案内';
1211+
text = '学んだことをテストするために、短いクイズに答えましょう。';
1212+
} else if (lang === 'de-DE') {
1213+
title = 'Quiz-Einführung';
1214+
text = 'Machen wir nun ein kurzes Quiz, um das Gelernte zu testen.';
1215+
}
1216+
11761217
return [
11771218
{
11781219
id: `action_${nanoid(8)}`,
11791220
type: 'speech',
1180-
title: '测验引导',
1181-
text: '现在让我们来做一个小测验,检验一下学习成果。',
1221+
title,
1222+
text,
11821223
},
11831224
];
11841225
}
11851226

11861227
/**
11871228
* Generate default interactive Actions (fallback)
11881229
*/
1189-
function generateDefaultInteractiveActions(_outline: SceneOutline): Action[] {
1230+
function generateDefaultInteractiveActions(outline: SceneOutline): Action[] {
1231+
const lang = outline.language || 'zh-CN';
1232+
let title = 'Interactive Intro';
1233+
let text = 'Now let\'s explore this concept through interactive visualization. Try interacting with the elements on the page to see how they change.';
1234+
if (lang === 'zh-CN') {
1235+
title = '交互引导';
1236+
text = '现在让我们通过交互式可视化来探索这个概念。请尝试操作页面中的元素,观察变化。';
1237+
} else if (lang === 'ja-JP') {
1238+
title = 'インタラクティブ案内';
1239+
text = 'インタラクティブな視覚化を通じて、この概念を探求しましょう。ページ上の要素を操作して、どのように変化するか確認してください。';
1240+
} else if (lang === 'de-DE') {
1241+
title = 'Interaktive Einführung';
1242+
text = 'Erforschen wir nun dieses Konzept durch eine interaktive Visualisierung. Versuchen Sie, mit den Elementen auf der Seite zu interagieren, um zu sehen, wie sie sich verändern.';
1243+
}
1244+
11901245
return [
11911246
{
11921247
id: `action_${nanoid(8)}`,
11931248
type: 'speech',
1194-
title: '交互引导',
1195-
text: '现在让我们通过交互式可视化来探索这个概念。请尝试操作页面中的元素,观察变化。',
1249+
title,
1250+
text,
11961251
},
11971252
];
11981253
}

0 commit comments

Comments
 (0)