diff --git a/app/api/generate/scene-content/route.ts b/app/api/generate/scene-content/route.ts index db9b772e..dce3177e 100644 --- a/app/api/generate/scene-content/route.ts +++ b/app/api/generate/scene-content/route.ts @@ -67,7 +67,8 @@ 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 || (stageInfo?.language as 'zh-CN' | 'en-US' | 'de-DE') || 'zh-CN', }; // ── Model resolution from request headers ── diff --git a/app/api/generate/scene-outlines-stream/route.ts b/app/api/generate/scene-outlines-stream/route.ts index 36c1606a..1908194a 100644 --- a/app/api/generate/scene-outlines-stream/route.ts +++ b/app/api/generate/scene-outlines-stream/route.ts @@ -121,7 +121,11 @@ export async function POST(req: NextRequest) { // Build prompt (same logic as generateSceneOutlinesFromRequirements) let availableImagesText = - requirements.language === 'zh-CN' ? '无可用图片' : 'No images available'; + requirements.language === 'zh-CN' + ? '无可用图片' + : requirements.language === 'de-DE' + ? 'Keine Bilder verfügbar' + : 'No images available'; let visionImages: Array<{ id: string; src: string }> | undefined; if (pdfImages && pdfImages.length > 0) { @@ -179,9 +183,17 @@ export async function POST(req: NextRequest) { ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : requirements.language === 'zh-CN' ? '无' - : 'None', + : requirements.language === 'de-DE' + ? 'Keine' + : 'None', availableImages: availableImagesText, - researchContext: researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'), + researchContext: + researchContext || + (requirements.language === 'zh-CN' + ? '无' + : requirements.language === 'de-DE' + ? 'Keine' + : 'None'), mediaGenerationPolicy, teacherContext, }); diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index d0aab62e..69d98870 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -38,12 +38,17 @@ export async function POST(req: NextRequest) { const { model: languageModel } = resolveModelFromHeaders(req); const isZh = language === 'zh-CN'; + const isDe = language === 'de-DE'; const systemPrompt = isZh ? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。 必须以如下 JSON 格式回复(不要包含其他内容): {"score": <0到${points}的整数>, "comment": "<一两句评语>"}` - : `You are a professional educational assessor. Grade the student's answer and provide brief feedback. + : isDe + ? `Sie sind ein professioneller Bildungsprüfer. Bewerten Sie die Antwort des Schülers und geben Sie ein kurzes Feedback. +Sie müssen ausschließlich in folgendem JSON-Format antworten (kein anderer Inhalt): +{"score": , "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": , "comment": ""}`; @@ -51,7 +56,11 @@ You must reply in the following JSON format only (no other content): ? `题目:${question} 满分:${points}分 ${commentPrompt ? `评分要点:${commentPrompt}\n` : ''}学生答案:${userAnswer}` - : `Question: ${question} + : isDe + ? `Frage: ${question} +Volle Punktzahl: ${points} Punkte +${commentPrompt ? `Bewertungsrichtlinien: ${commentPrompt}\n` : ''}Antwort des Schülers: ${userAnswer}` + : `Question: ${question} Full marks: ${points} points ${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${userAnswer}`; @@ -83,7 +92,9 @@ ${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${ score: Math.round(points * 0.5), comment: isZh ? '已作答,请参考标准答案。' - : 'Answer received. Please refer to the standard answer.', + : isDe + ? 'Antwort erhalten. Bitte beachten Sie die Musterantwort.' + : 'Answer received. Please refer to the standard answer.', }; } diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 523e2321..80e25423 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -142,8 +142,18 @@ export default function ClassroomDetailPage() { // Resume media generation for any tasks not yet in IndexedDB. // generateMediaForOutlines skips already-completed tasks automatically. generationStartedRef.current = true; - generateMediaForOutlines(outlines, stage.id).catch((err) => { - log.warn('[Classroom] Media generation resume error:', err); + + // Load generation params to get the imageMapping for manual uploads + const genParamsStr = sessionStorage.getItem('generationParams'); + const params = genParamsStr ? JSON.parse(genParamsStr) : {}; + const storageIds = (params.pdfImages || []) + .map((img: { storageId?: string }) => img.storageId) + .filter(Boolean); + + loadImageMapping(storageIds).then((imageMapping) => { + generateMediaForOutlines(outlines, stage.id, undefined, imageMapping).catch((err) => { + log.warn('[Classroom] Media generation resume error:', err); + }); }); } }, [loading, error, generateRemaining]); diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index 213a5140..286e9d5b 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -267,8 +267,8 @@ function GenerationPreviewContent() { const updatedSession = { ...currentSession, pdfText, - pdfImages, - imageStorageIds, + pdfImages: [...(currentSession.pdfImages || []), ...pdfImages], + imageStorageIds: [...(currentSession.imageStorageIds || []), ...imageStorageIds], pdfStorageKey: undefined, // Clear so we don't re-parse }; setSession(updatedSession); @@ -339,11 +339,49 @@ function GenerationPreviewContent() { activeSteps = getActiveSteps(currentSession); } + // ── Handle manually uploaded images ── + const manualPdfImages: PdfImage[] = []; + + if ( + currentSession.uploadedImageStorageIds && + currentSession.uploadedImageStorageIds.length > 0 + ) { + log.debug('Loading manually uploaded images'); + currentSession.uploadedImageStorageIds.forEach((storageId, i) => { + const uploadedId = `uploaded_img_${i + 1}`; + manualPdfImages.push({ + id: uploadedId, + src: '', + pageNumber: 1, + description: 'Manually uploaded image', + storageId, + }); + }); + + // Add them to the session's pdfImages if not already there + const existingUploaded = (currentSession.pdfImages || []).some((img) => + img.id.startsWith('uploaded_img_'), + ); + if (!existingUploaded) { + currentSession = { + ...currentSession, + pdfImages: [...(currentSession.pdfImages || []), ...manualPdfImages], + }; + setSession(currentSession); + sessionStorage.setItem('generationSession', JSON.stringify(currentSession)); + } + } + // Load imageMapping early (needed for both outline and scene generation) let imageMapping: ImageMapping = {}; - if (currentSession.imageStorageIds && currentSession.imageStorageIds.length > 0) { + const allImageStorageIds = [ + ...(currentSession.imageStorageIds || []), + ...(currentSession.uploadedImageStorageIds || []), + ]; + + if (allImageStorageIds.length > 0) { log.debug('Loading images from IndexedDB'); - imageMapping = await loadImageMapping(currentSession.imageStorageIds); + imageMapping = await loadImageMapping(allImageStorageIds); } else if ( currentSession.imageMapping && Object.keys(currentSession.imageMapping).length > 0 diff --git a/app/generation-preview/types.ts b/app/generation-preview/types.ts index 408ae81f..d69412cf 100644 --- a/app/generation-preview/types.ts +++ b/app/generation-preview/types.ts @@ -25,6 +25,8 @@ export interface GenerationSessionState { // Web search context researchContext?: string; researchSources?: Array<{ title: string; url: string }>; + // Manually uploaded images (along with PDF) + uploadedImageStorageIds?: string[]; } export type GenerationStep = { diff --git a/app/page.tsx b/app/page.tsx index 80dfbd85..861889c7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -55,13 +55,15 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; interface FormState { pdfFile: File | null; + imageFiles: File[]; requirement: string; - language: 'zh-CN' | 'en-US'; + language: 'zh-CN' | 'en-US' | 'de-DE'; webSearch: boolean; } const initialFormState: FormState = { pdfFile: null, + imageFiles: [], requirement: '', language: 'zh-CN', webSearch: false, @@ -101,10 +103,15 @@ function HomePage() { const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); const updates: Partial = {}; if (savedWebSearch === 'true') updates.webSearch = true; - if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') { + if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US' || savedLanguage === 'de-DE') { updates.language = savedLanguage; } else { - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + const lang = navigator.language; + const detected = lang?.startsWith('zh') + ? 'zh-CN' + : lang?.startsWith('de') + ? 'de-DE' + : 'en-US'; updates.language = detected; } if (Object.keys(updates).length > 0) { @@ -251,13 +258,6 @@ function HomePage() { try { 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, - }; let pdfStorageKey: string | undefined; let pdfFileName: string | undefined; @@ -279,12 +279,31 @@ function HomePage() { } } + // Store manually uploaded images + const uploadedImageStorageIds: string[] = []; + for (const imageFile of form.imageFiles) { + const key = await storePdfBlob(imageFile); // use storePdfBlob for any binary data + uploadedImageStorageIds.push(key); + } + + const requirements: UserRequirements = { + requirement: form.requirement, + language: form.language, + userNickname: userProfile.nickname || undefined, + userBio: userProfile.bio || undefined, + webSearch: form.webSearch || undefined, + uploadedImageStorageIds: + uploadedImageStorageIds.length > 0 ? uploadedImageStorageIds : undefined, + }; + const sessionState = { sessionId: nanoid(), requirements, pdfText: '', pdfImages: [], imageStorageIds: [], + uploadedImageStorageIds: + uploadedImageStorageIds.length > 0 ? uploadedImageStorageIds : undefined, pdfStorageKey, pdfFileName, pdfProviderId, @@ -338,7 +357,7 @@ function HomePage() { }} className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all" > - {locale === 'zh-CN' ? 'CN' : 'EN'} + {locale === 'zh-CN' ? 'CN' : locale === 'de-DE' ? 'DE' : 'EN'} {languageOpen && (
@@ -368,6 +387,19 @@ function HomePage() { > English +
)} @@ -558,6 +590,8 @@ function HomePage() { }} pdfFile={form.pdfFile} onPdfFileChange={(f) => updateForm('pdfFile', f)} + imageFiles={form.imageFiles} + onImageFilesChange={(fs) => updateForm('imageFiles', fs)} onPdfError={setError} /> diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bbd..0d8b6d11 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -28,14 +28,17 @@ const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { - language: 'zh-CN' | 'en-US'; - onLanguageChange: (lang: 'zh-CN' | 'en-US') => void; + language: 'zh-CN' | 'en-US' | 'de-DE'; + onLanguageChange: (lang: 'zh-CN' | 'en-US' | 'de-DE') => void; webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; // PDF pdfFile: File | null; onPdfFileChange: (file: File | null) => void; + // Images + imageFiles: File[]; + onImageFilesChange: (files: File[]) => void; onPdfError: (error: string | null) => void; } @@ -48,6 +51,8 @@ export function GenerationToolbar({ onSettingsOpen, pdfFile, onPdfFileChange, + imageFiles, + onImageFilesChange, onPdfError, }: GenerationToolbarProps) { const { t } = useI18n(); @@ -96,15 +101,38 @@ export function GenerationToolbar({ const currentProviderConfig = providersConfig?.[currentProviderId]; - // PDF handler - const handleFileSelect = (file: File) => { - if (file.type !== 'application/pdf') return; - if (file.size > MAX_PDF_SIZE_BYTES) { - onPdfError(t('upload.fileTooLarge')); - return; + // File handler for both PDF and images + const handleFilesSelect = (files: FileList | null) => { + if (!files) return; + const newFiles = Array.from(files); + const updatedImages = [...imageFiles]; + let pdfSelected = pdfFile; + + for (const file of newFiles) { + if (file.type === 'application/pdf') { + if (file.size > MAX_PDF_SIZE_BYTES) { + onPdfError(t('upload.fileTooLarge')); + continue; + } + pdfSelected = file; + } else if (file.type.startsWith('image/')) { + if (file.size > MAX_PDF_SIZE_BYTES) { + onPdfError(t('upload.fileTooLarge')); + continue; + } + updatedImages.push(file); + } } + onPdfError(null); - onPdfFileChange(file); + onPdfFileChange(pdfSelected); + onImageFilesChange(updatedImages); + }; + + const removeImage = (index: number) => { + const updated = [...imageFiles]; + updated.splice(index, 1); + onImageFilesChange(updated); }; // ─── Pill button helper ───────────────────────────── @@ -113,6 +141,8 @@ export function GenerationToolbar({ const pillMuted = `${pillCls} border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`; const pillActive = `${pillCls} border-violet-200/60 dark:border-violet-700/50 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300`; + const totalFilesCount = (pdfFile ? 1 : 0) + imageFiles.length; + return (
{/* ── Model selector ── */} @@ -147,19 +177,25 @@ export function GenerationToolbar({ {/* ── Separator ── */}
- {/* ── PDF (parser + upload) combined Popover ── */} + {/* ── Files (PDF + Images) combined Popover ── */} - {pdfFile ? ( + {totalFilesCount > 0 ? ( )} - - {/* Parser selector */} -
+ + {/* Parser selector (only if PDF is present or to be added) */} +
{t('toolbar.pdfParser')} @@ -206,67 +242,98 @@ export function GenerationToolbar({
- {/* Upload area / file info */} -
+ {/* Upload area / file list */} +
{ - const f = e.target.files?.[0]; - if (f) handleFileSelect(f); + handleFilesSelect(e.target.files); e.target.value = ''; }} /> - {pdfFile ? ( + + {/* File List */} + {totalFilesCount > 0 && (
-
-
- -
-
-

{pdfFile.name}

-

- {(pdfFile.size / 1024 / 1024).toFixed(2)} MB -

+ {/* PDF */} + {pdfFile && ( +
+ +
+

{pdfFile.name}

+

+ {(pdfFile.size / 1024 / 1024).toFixed(2)} MB +

+
+
-
- -
- ) : ( -
fileInputRef.current?.click()} - onDragOver={(e) => { - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={() => setIsDragging(false)} - onDrop={(e) => { - e.preventDefault(); - setIsDragging(false); - const f = e.dataTransfer.files?.[0]; - if (f) handleFileSelect(f); - }} - > - -

{t('toolbar.pdfUpload')}

-

- {t('upload.pdfSizeLimit')} -

+ {/* Images */} + {imageFiles.map((file, i) => ( +
+
+ URL.revokeObjectURL((e.target as HTMLImageElement).src)} + /> +
+
+

{file.name}

+

+ {(file.size / 1024).toFixed(1)} KB +

+
+ +
+ ))}
)} + + {/* Dropzone */} +
fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + handleFilesSelect(e.dataTransfer.files); + }} + > + +

{t('toolbar.uploadFiles') || 'Upload Files'}

+

+ {t('upload.pdfAndImages') || 'PDF or Images up to 50MB'} +

+
@@ -361,11 +428,15 @@ export function GenerationToolbar({ {t('toolbar.languageHint')} diff --git a/components/header.tsx b/components/header.tsx index 77a63b03..89d64a3e 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -113,7 +113,7 @@ export function Header({ currentSceneTitle }: HeaderProps) { }} className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all" > - {locale === 'zh-CN' ? 'CN' : 'EN'} + {locale === 'zh-CN' ? 'CN' : locale === 'de-DE' ? 'DE' : 'EN'} {languageOpen && (
@@ -143,6 +143,19 @@ export function Header({ currentSceneTitle }: HeaderProps) { > English +
)}
diff --git a/lib/audio/browser-tts-preview.ts b/lib/audio/browser-tts-preview.ts index 5da69977..bbd4b48e 100644 --- a/lib/audio/browser-tts-preview.ts +++ b/lib/audio/browser-tts-preview.ts @@ -20,7 +20,14 @@ function createAbortError(): Error { function inferPreviewLang(text: string): string { const cjkCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; const ratio = text.length > 0 ? cjkCount / text.length : 0; - return ratio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US'; + if (ratio > CJK_LANG_THRESHOLD) return 'zh-CN'; + + // Detect German (äöüß or common articles/conjunctions) + const germanPattern = + /[äöüß]|(\b(der|die|das|und|ist|nicht|für|mit|den|dem|des|ein|eine|einer|eines|daß|dass|was|wenn|so|wie|bei|an|aus|auf|nach|über|vor|zu)\b)/i; + if (germanPattern.test(text)) return 'de-DE'; + + return 'en-US'; } export function isBrowserTTSAbortError(error: unknown): boolean { diff --git a/lib/audio/constants.ts b/lib/audio/constants.ts index 8e5b9976..927dae1e 100644 --- a/lib/audio/constants.ts +++ b/lib/audio/constants.ts @@ -183,6 +183,18 @@ export const TTS_PROVIDERS: Record = { gender: 'female', }, { id: 'en-US-GuyNeural', name: 'Guy', language: 'en-US', gender: 'male' }, + { + id: 'de-DE-KatjaNeural', + name: 'Katja (女)', + language: 'de-DE', + gender: 'female', + }, + { + id: 'de-DE-ConradNeural', + name: 'Conrad (男)', + language: 'de-DE', + gender: 'male', + }, ], supportedFormats: ['mp3', 'wav', 'ogg'], speedRange: { min: 0.5, max: 2.0, default: 1.0 }, diff --git a/lib/generation/outline-generator.ts b/lib/generation/outline-generator.ts index 4849bcef..9545b257 100644 --- a/lib/generation/outline-generator.ts +++ b/lib/generation/outline-generator.ts @@ -40,7 +40,11 @@ export async function generateSceneOutlinesFromRequirements( ): Promise> { // Build available images description for the prompt let availableImagesText = - requirements.language === 'zh-CN' ? '无可用图片' : 'No images available'; + requirements.language === 'zh-CN' + ? '无可用图片' + : requirements.language === 'de-DE' + ? 'Keine Bilder verfügbar' + : 'No images available'; let visionImages: Array<{ id: string; src: string }> | undefined; if (pdfImages && pdfImages.length > 0) { @@ -103,12 +107,19 @@ export async function generateSceneOutlinesFromRequirements( ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : requirements.language === 'zh-CN' ? '无' - : 'None', + : requirements.language === 'de-DE' + ? 'Keine' + : 'None', availableImages: availableImagesText, userProfile: userProfileText, mediaGenerationPolicy, researchContext: - options?.researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'), + options?.researchContext || + (requirements.language === 'zh-CN' + ? '无' + : requirements.language === 'de-DE' + ? 'Keine' + : 'None'), // 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..c512968a 100644 --- a/lib/generation/prompt-formatters.ts +++ b/lib/generation/prompt-formatters.ts @@ -79,12 +79,19 @@ export function formatImageDescription(img: PdfImage, language: string): string let dimInfo = ''; if (img.width && img.height) { const ratio = (img.width / img.height).toFixed(2); - dimInfo = ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`; + dimInfo = + language === 'zh-CN' + ? ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})` + : language === 'de-DE' + ? ` | Größe: ${img.width}×${img.height} (Seitenverhältnis ${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}`; + : language === 'de-DE' + ? `- **${img.id}**: aus PDF-Seite ${img.pageNumber}${dimInfo}${desc}` + : `- **${img.id}**: from PDF page ${img.pageNumber}${dimInfo}${desc}`; } /** @@ -95,11 +102,18 @@ export function formatImagePlaceholder(img: PdfImage, language: string): string let dimInfo = ''; if (img.width && img.height) { const ratio = (img.width / img.height).toFixed(2); - dimInfo = ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})`; + dimInfo = + language === 'zh-CN' + ? ` | 尺寸: ${img.width}×${img.height} (宽高比${ratio})` + : language === 'de-DE' + ? ` | Größe: ${img.width}×${img.height} (Seitenverhältnis ${ratio})` + : ` | Dimensions: ${img.width}×${img.height} (Aspect ratio ${ratio})`; } return language === 'zh-CN' ? `- **${img.id}**: PDF第${img.pageNumber}页的图片${dimInfo} [参见附图]` - : `- **${img.id}**: image from PDF page ${img.pageNumber}${dimInfo} [see attached]`; + : language === 'de-DE' + ? `- **${img.id}**: Bild von PDF-Seite ${img.pageNumber}${dimInfo} [siehe Anhang]` + : `- **${img.id}**: image from PDF page ${img.pageNumber}${dimInfo} [see attached]`; } /** diff --git a/lib/generation/prompts/templates/requirements-to-outlines/system.md b/lib/generation/prompts/templates/requirements-to-outlines/system.md index 72f5bc0e..1f96df02 100644 --- a/lib/generation/prompts/templates/requirements-to-outlines/system.md +++ b/lib/generation/prompts/templates/requirements-to-outlines/system.md @@ -94,7 +94,7 @@ When a slide scene needs an image or video but no suitable PDF image exists, mar - **Image IDs**: use `"gen_img_1"`, `"gen_img_2"`, etc. — IDs are **globally unique across the entire course**, NOT reset per scene - **Video IDs**: use `"gen_vid_1"`, `"gen_vid_2"`, etc. — same global numbering rule - The prompt should describe the desired media clearly and specifically -- **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. +- **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, "all labels in German" for de-DE courses). For purely visual images without text, language does not matter. - Only request media generation when it genuinely enhances the content — not every slide needs an image or video - Video generation is slow (1-2 minutes each), so only request videos when motion genuinely enhances understanding - If a suitable PDF image exists, prefer using `suggestedImageIds` instead diff --git a/lib/generation/prompts/templates/requirements-to-outlines/user.md b/lib/generation/prompts/templates/requirements-to-outlines/user.md index 65d0a492..71fce6a3 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) +(If language is zh-CN, all content must be in Chinese; if en-US, all content must be in English; if de-DE, all content must be in German) --- diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937..0ce833be 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -735,7 +735,7 @@ function normalizeQuizAnswer(question: Record): string[] | unde async function generateInteractiveContent( outline: SceneOutline, aiCall: AICallFn, - language: 'zh-CN' | 'en-US' = 'zh-CN', + language: 'zh-CN' | 'en-US' | 'de-DE' = 'zh-CN', ): Promise { const config = outline.interactiveConfig!; diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index 4e642f4c..9808716d 100644 --- a/lib/hooks/use-i18n.tsx +++ b/lib/hooks/use-i18n.tsx @@ -10,7 +10,7 @@ type I18nContextType = { }; const LOCALE_STORAGE_KEY = 'locale'; -const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US']; +const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US', 'de-DE']; const I18nContext = createContext(undefined); @@ -26,7 +26,11 @@ export function I18nProvider({ children }: { children: ReactNode }) { setLocaleState(stored as Locale); return; } - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + const detected = navigator.language?.startsWith('zh') + ? 'zh-CN' + : navigator.language?.startsWith('de') + ? 'de-DE' + : 'en-US'; localStorage.setItem(LOCALE_STORAGE_KEY, detected); setLocaleState(detected); } catch { diff --git a/lib/hooks/use-scene-generator.ts b/lib/hooks/use-scene-generator.ts index ee6ad2c0..d772e852 100644 --- a/lib/hooks/use-scene-generator.ts +++ b/lib/hooks/use-scene-generator.ts @@ -283,7 +283,12 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { // Launch media generation in parallel — does not block content/action generation mediaAbortRef.current = new AbortController(); - generateMediaForOutlines(outlines, stage.id, mediaAbortRef.current.signal).catch((err) => { + generateMediaForOutlines( + outlines, + stage.id, + mediaAbortRef.current.signal, + params.imageMapping, + ).catch((err) => { log.warn('Media generation error:', err); }); diff --git a/lib/i18n/chat.ts b/lib/i18n/chat.ts index 4a542139..cc701040 100644 --- a/lib/i18n/chat.ts +++ b/lib/i18n/chat.ts @@ -139,3 +139,74 @@ export const chatEnUS = { stopListening: 'Stop recording', }, } as const; + +export const chatDeDE = { + chat: { + lecture: 'Vortrag', + noConversations: 'Keine Unterhaltungen', + startConversation: 'Geben Sie unten eine Nachricht ein, um den Chat zu beginnen', + noMessages: 'Noch keine Nachrichten', + ended: 'beendet', + unknown: 'Unbekannt', + stopDiscussion: 'Diskussion beenden', + endQA: 'Fragen & Antworten beenden', + tabs: { + lecture: 'Notizen', + chat: 'Chat', + }, + lectureNotes: { + empty: 'Notizen erscheinen hier nach der Wiedergabe des Vortrags', + emptyHint: 'Drücken Sie Play, um den Vortrag zu starten', + pageLabel: 'Seite {n}', + currentPage: 'Aktuell', + }, + badge: { + qa: 'F&A', + discussion: 'DISK', + lecture: 'VOR', + }, + }, + actions: { + names: { + spotlight: 'Spotlight', + laser: 'Laserpointer', + wb_open: 'Whiteboard öffnen', + wb_draw_text: 'Whiteboard-Text', + wb_draw_shape: 'Whiteboard-Form', + wb_draw_chart: 'Whiteboard-Diagramm', + wb_draw_latex: 'Whiteboard-Formel', + wb_draw_table: 'Whiteboard-Tabelle', + wb_draw_line: 'Whiteboard-Linie', + wb_clear: 'Whiteboard löschen', + wb_delete: 'Element löschen', + wb_close: 'Whiteboard schließen', + discussion: 'Diskussion', + }, + status: { + inputStreaming: 'Warten', + inputAvailable: 'Ausführen', + outputAvailable: 'Abgeschlossen', + outputError: 'Fehler', + outputDenied: 'Abgelehnt', + running: 'Ausführen', + result: 'Abgeschlossen', + error: 'Fehler', + }, + }, + agentBar: { + readyToLearn: 'Bereit, gemeinsam zu lernen?', + expandedTitle: 'Konfiguration der Klassenzimmer-Rollen', + configTooltip: 'Klicken, um Klassenzimmer-Rollen zu konfigurieren', + }, + proactiveCard: { + discussion: 'Diskussion', + join: 'Beitreten', + skip: 'Überspringen', + pause: 'Pause', + resume: 'Fortsetzen', + }, + voice: { + startListening: 'Spracheingabe', + stopListening: 'Aufnahme stoppen', + }, +} as const; diff --git a/lib/i18n/common.ts b/lib/i18n/common.ts index 1bceb5d6..0837484b 100644 --- a/lib/i18n/common.ts +++ b/lib/i18n/common.ts @@ -4,6 +4,7 @@ export const commonZhCN = { confirm: '确定', cancel: '取消', loading: '加载中...', + images: '图片', }, home: { slogan: 'Generative Learning in Multi-Agent Interactive Classroom', @@ -13,6 +14,7 @@ export const commonZhCN = { languageHint: '课程将以此语言生成', pdfParser: '解析器', pdfUpload: '上传 PDF', + uploadFiles: '上传文件', removePdf: '移除文件', webSearchOn: '已开启', webSearchOff: '点击开启', @@ -45,6 +47,7 @@ export const commonEnUS = { confirm: 'Confirm', cancel: 'Cancel', loading: 'Loading...', + images: 'Images', }, home: { slogan: 'Generative Learning in Multi-Agent Interactive Classroom', @@ -54,6 +57,7 @@ export const commonEnUS = { languageHint: 'Course will be generated in this language', pdfParser: 'Parser', pdfUpload: 'Upload PDF', + uploadFiles: 'Upload Files', removePdf: 'Remove file', webSearchOn: 'Enabled', webSearchOff: 'Click to enable', @@ -79,3 +83,47 @@ export const commonEnUS = { exportFailed: 'Export failed', }, } as const; + +export const commonDeDE = { + common: { + you: 'Du', + confirm: 'Bestätigen', + cancel: 'Abbrechen', + loading: 'Laden...', + images: 'Bilder', + }, + home: { + slogan: 'Generatives Lernen im interaktiven Klassenzimmer mit mehreren Agenten', + greeting: 'Hallo, ', + }, + toolbar: { + languageHint: 'Der Kurs wird in dieser Sprache erstellt', + pdfParser: 'Parser', + pdfUpload: 'PDF hochladen', + uploadFiles: 'Dateien hochladen', + removePdf: 'Datei entfernen', + webSearchOn: 'Aktiviert', + webSearchOff: 'Klicken zum Aktivieren', + webSearchDesc: 'Vor der Erstellung im Web nach aktuellen Informationen suchen', + webSearchProvider: 'Suchmaschine', + webSearchNoProvider: 'Suchmaschinen-API-Key in den Einstellungen konfigurieren', + selectProvider: 'Anbieter auswählen', + configureProvider: 'Modell einrichten', + configureProviderHint: + 'Konfigurieren Sie mindestens einen Modellanbieter, um Kurse zu erstellen', + enterClassroom: 'Klassenzimmer betreten', + advancedSettings: 'Erweiterte Einstellungen', + ttsTitle: 'Text-zu-Sprache', + ttsHint: 'Wählen Sie eine Stimme für den KI-Lehrer', + ttsPreview: 'Vorschau', + ttsPreviewing: 'Wiedergabe...', + }, + export: { + pptx: 'PPTX exportieren', + resourcePack: 'Ressourcenpaket exportieren', + resourcePackDesc: 'PPTX + interaktive Seiten', + exporting: 'Exportieren...', + exportSuccess: 'Export erfolgreich', + exportFailed: 'Export fehlgeschlagen', + }, +} as const; diff --git a/lib/i18n/generation.ts b/lib/i18n/generation.ts index 98694c23..c7ce7a86 100644 --- a/lib/i18n/generation.ts +++ b/lib/i18n/generation.ts @@ -11,6 +11,7 @@ export const generationZhCN = { }, upload: { pdfSizeLimit: '支持最大50MB的PDF文件', + pdfAndImages: 'PDF 或图片 (最大 50MB)', generateFailed: '生成课堂失败,请重试', requirementPlaceholder: '输入你想学的任何内容,例如:\n「从零学 Python,30 分钟写出第一个程序」\n「用白板给我讲解傅里叶变换」\n「阿瓦隆桌游怎么玩」', @@ -78,6 +79,7 @@ export const generationEnUS = { }, upload: { pdfSizeLimit: 'Supports PDF files up to 50MB', + pdfAndImages: 'PDF or Images up to 50MB', generateFailed: 'Failed to generate classroom, please try again', requirementPlaceholder: 'Tell me anything you want to learn, e.g.\n"Teach me Python from scratch in 30 minutes"\n"Explain Fourier Transform on the whiteboard"\n"How to play the board game Avalon"', @@ -133,3 +135,76 @@ export const generationEnUS = { webSearchFailed: 'Web search failed', }, } as const; + +export const generationDeDE = { + classroom: { + recentClassrooms: 'Zuletzt', + today: 'Heute', + yesterday: 'Gestern', + daysAgo: 'vor {n} Tagen', + slides: 'Folien', + nameCopied: 'Name kopiert', + deleteConfirmTitle: 'Löschen', + delete: 'Löschen', + }, + upload: { + pdfSizeLimit: 'Unterstützt PDF-Dateien bis zu 50 MB', + pdfAndImages: 'PDF oder Bilder (max. 50 MB)', + generateFailed: 'Klassenzimmer konnte nicht erstellt werden, bitte versuchen Sie es erneut', + requirementPlaceholder: + 'Sagen Sie mir, was Sie lernen möchten, z. B.\n„Bring mir Python von Grund auf in 30 Minuten bei“\n„Erkläre mir die Fourier-Transformation am Whiteboard“\n„Wie spielt man das Brettspiel Avalon“', + requirementRequired: 'Bitte geben Sie die Kursanforderungen ein', + fileTooLarge: 'Datei zu groß. Bitte wählen Sie eine PDF-Datei kleiner als 50 MB', + }, + generation: { + // Progress steps (used dynamically via activeStep) + analyzingPdf: 'PDF-Dokument analysieren', + analyzingPdfDesc: 'Dokumentstruktur und -inhalt extrahieren...', + pdfLoadFailed: 'PDF-Datei konnte nicht geladen werden, bitte versuchen Sie es erneut', + pdfParseFailed: 'PDF-Parsing fehlgeschlagen', + streamNotReadable: 'Generationsstream konnte nicht gelesen werden', + generatingOutlines: 'Kursübersicht entwerfen', + generatingOutlinesDesc: 'Lernpfad strukturieren...', + generatingSlideContent: 'Seiteninhalt generieren', + generatingSlideContentDesc: 'Folien, Quiz und interaktive Inhalte erstellen...', + generatingActions: 'Unterrichtsaktionen generieren', + generatingActionsDesc: 'Erzählung, Spotlights und Interaktionen orchestrieren...', + generationComplete: 'Generierung abgeschlossen!', + generationFailed: 'Generierung fehlgeschlagen', + generatingCourse: 'Kurs wird generiert', + openingClassroom: 'Klassenzimmer wird geöffnet...', + outlineReady: 'Kursübersicht generiert', + generatingFirstPage: 'Erste Seite wird generiert...', + firstPageReady: 'Erste Seite bereit! Klassenzimmer wird geöffnet...', + speechFailed: 'Sprachgenerierung fehlgeschlagen', + retryScene: 'Wiederholen', + retryingScene: 'Wird neu generiert...', + backToHome: 'Zurück zur Startseite', + sessionNotFound: 'Sitzung nicht gefunden', + sessionNotFoundDesc: + 'Bitte füllen Sie die Kursanforderungen aus, um den Generierungsprozess zu starten.', + goBackAndRetry: 'Zurückgehen und erneut versuchen', + classroomReady: 'Ihre personalisierte KI-Lernumgebung wurde erfolgreich erstellt.', + aiWorking: 'KI-Agenten arbeiten...', + textTruncated: + 'Dokumenttext ist lang, die ersten {n} Zeichen werden für die Generierung verwendet', + imageTruncated: + '{total} Bilder gefunden, das Limit von {max} Bildern wurde überschritten. Zusätzliche Bilder verwenden nur Textbeschreibungen', + // Agent generation + agentGeneration: 'Klassenzimmer-Rollen generieren', + agentGenerationDesc: 'Rollen basierend auf dem Kursinhalt generieren...', + agentRevealTitle: 'Ihre Klassenzimmer-Rollen', + viewAgents: 'Rollen anzeigen', + continue: 'Weiter', + // Outline errors + outlineRetrying: 'Problem bei der Übersichtserstellung, Wiederholung...', + outlineEmptyResponse: + 'Modell gab keine gültigen Übersichten zurück. Bitte überprüfen Sie die Modellkonfiguration und versuchen Sie es erneut', + outlineGenerateFailed: + 'Übersichtserstellung fehlgeschlagen, bitte versuchen Sie es später erneut', + // Web Search + webSearching: 'Websuche', + webSearchingDesc: 'Websuche nach aktuellen Informationen', + webSearchFailed: 'Websuche fehlgeschlagen', + }, +} as const; diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts index 5fd70da5..62f68ed4 100644 --- a/lib/i18n/index.ts +++ b/lib/i18n/index.ts @@ -1,10 +1,10 @@ import { defaultLocale, type Locale } from './types'; export { type Locale, defaultLocale } from './types'; -import { commonZhCN, commonEnUS } from './common'; -import { stageZhCN, stageEnUS } from './stage'; -import { chatZhCN, chatEnUS } from './chat'; -import { generationZhCN, generationEnUS } from './generation'; -import { settingsZhCN, settingsEnUS } from './settings'; +import { commonZhCN, commonEnUS, commonDeDE } from './common'; +import { stageZhCN, stageEnUS, stageDeDE } from './stage'; +import { chatZhCN, chatEnUS, chatDeDE } from './chat'; +import { generationZhCN, generationEnUS, generationDeDE } from './generation'; +import { settingsZhCN, settingsEnUS, settingsDeDE } from './settings'; export const translations = { 'zh-CN': { @@ -21,6 +21,13 @@ export const translations = { ...generationEnUS, ...settingsEnUS, }, + 'de-DE': { + ...commonDeDE, + ...stageDeDE, + ...chatDeDE, + ...generationDeDE, + ...settingsDeDE, + }, } as const; export type TranslationKey = keyof (typeof translations)[typeof defaultLocale]; @@ -40,7 +47,7 @@ export function getClientTranslation(key: string): string { if (typeof window !== 'undefined') { try { const storedLocale = localStorage.getItem('locale'); - if (storedLocale === 'zh-CN' || storedLocale === 'en-US') { + if (storedLocale === 'zh-CN' || storedLocale === 'en-US' || storedLocale === 'de-DE') { locale = storedLocale; } } catch { diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts index 3dba9f66..2d42da5c 100644 --- a/lib/i18n/settings.ts +++ b/lib/i18n/settings.ts @@ -1175,3 +1175,517 @@ export const settingsEnUS = { language: 'Language', }, } as const; + +export const settingsDeDE = { + settings: { + title: 'Einstellungen', + description: 'Anwendungseinstellungen konfigurieren', + language: 'Sprache', + languageDesc: 'Sprache der Benutzeroberfläche auswählen', + theme: 'Thema', + themeDesc: 'Themenmodus auswählen (Hell/Dunkel/System)', + themeOptions: { + light: 'Hell', + dark: 'Dunkel', + system: 'System', + }, + apiKey: 'API-Schlüssel', + apiKeyDesc: 'Konfigurieren Sie Ihren API-Schlüssel', + apiBaseUrl: 'API-Endpunkt-URL', + apiBaseUrlDesc: 'Konfigurieren Sie Ihre API-Endpunkt-URL', + apiKeyRequired: 'API-Schlüssel darf nicht leer sein', + model: 'Modellkonfiguration', + modelDesc: 'KI-Modelle konfigurieren', + modelPlaceholder: 'Modellnamen eingeben oder auswählen', + ttsModel: 'TTS-Modell', + ttsModelDesc: 'TTS-Modelle konfigurieren', + ttsModelPlaceholder: 'TTS-Modellnamen eingeben oder auswählen', + ttsModelOptions: { + openaiTts: 'OpenAI TTS', + azureTts: 'Azure TTS', + }, + testConnection: 'Verbindung testen', + testConnectionDesc: 'Testen, ob die aktuelle API-Konfiguration verfügbar ist', + testing: 'Testen...', + agentSettings: 'Agenten-Einstellungen', + agentSettingsDesc: + 'Wählen Sie die Agenten aus, die an der Unterhaltung teilnehmen sollen. Wählen Sie 1 für den Einzelagenten-Modus, wählen Sie mehrere für den kollaborativen Multi-Agenten-Modus.', + agentMode: 'Agenten-Modus', + agentModePreset: 'Voreinstellung', + agentModeAuto: 'Automatisch generieren', + agentModeAutoDesc: 'Die KI wird automatisch passende Rollen generieren', + autoAgentCount: 'Anzahl der Agenten', + autoAgentCountDesc: 'Anzahl der automatisch zu generierenden Agenten (einschließlich Lehrer)', + atLeastOneAgent: 'Bitte wählen Sie mindestens einen Agenten aus', + singleAgentMode: 'Einzelagenten-Modus', + directAnswer: 'Direkte Antwort', + multiAgentMode: 'Multi-Agenten-Modus', + agentsCollaborating: 'Kollaborative Diskussion', + agentsCollaboratingCount: '{count} Agenten für kollaborative Diskussion ausgewählt', + maxTurns: 'Maximale Diskussionsrunden', + maxTurnsDesc: + 'Die maximale Anzahl von Diskussionsrunden zwischen Agenten (jeder Agent schließt Aktionen ab und eine Antwort zählt als eine Runde)', + priority: 'Priorität', + actions: 'Aktionen', + actionCount: '{count} Aktionen', + selectedAgent: 'Ausgewählter Agent', + selectedAgents: 'Ausgewählte Agenten', + required: 'Erforderlich', + agentNames: { + 'default-1': 'KI-Lehrer', + 'default-2': 'KI-Assistent', + 'default-3': 'Klassenclown', + 'default-4': 'Neugieriger Geist', + 'default-5': 'Notizenschreiber', + 'default-6': 'Tiefer Denker', + }, + agentRoles: { + teacher: 'Lehrer', + assistant: 'Assistent', + student: 'Schüler', + }, + agentDescriptions: { + 'default-1': 'Hauptlehrer mit klaren und strukturierten Erklärungen', + 'default-2': 'Unterstützt das Lernen und hilft, wichtige Punkte zu klären', + 'default-3': 'Bringt Humor und Energie in das Klassenzimmer', + 'default-4': 'Immer neugierig, liebt es zu fragen, warum und wie', + 'default-5': 'Aufmerksames Aufzeichnen und Organisieren von Unterrichtsnotizen', + 'default-6': 'Denkt tief nach und erforscht das Wesen der Themen', + }, + close: 'Schließen', + save: 'Speichern', + // Provider settings + providers: 'Sprachmodelle', + addProviderDescription: + 'Fügen Sie benutzerdefinierte Modellanbieter hinzu, um verfügbare KI-Modelle zu erweitern', + providerNames: { + openai: 'OpenAI', + anthropic: 'Claude', + google: 'Gemini', + deepseek: 'DeepSeek', + qwen: 'Qwen', + kimi: 'Kimi', + minimax: 'MiniMax', + glm: 'GLM', + siliconflow: 'SiliconFlow', + }, + providerTypes: { + openai: 'OpenAI-Protokoll', + anthropic: 'Claude-Protokoll', + google: 'Gemini-Protokoll', + }, + modelCount: 'Modelle', + modelSingular: 'Modell', + defaultModel: 'Standardmodell', + webSearch: 'Websuche', + mcp: 'MCP', + knowledgeBase: 'Wissensdatenbank', + documentParser: 'Dokumentenparser', + conversationSettings: 'Unterhaltung', + keyboardShortcuts: 'Tastenkombinationen', + generalSettings: 'Allgemein', + systemSettings: 'System', + addProvider: 'Hinzufügen', + importFromClipboard: 'Aus Zwischenablage importieren', + apiSecret: 'API-Schlüssel', + apiHost: 'Basis-URL', + requestUrl: 'Anforderungs-URL', + models: 'Modelle', + addModel: 'Neu', + reset: 'Zurücksetzen', + fetch: 'Abrufen', + connectionSuccess: 'Verbindung erfolgreich', + connectionFailed: 'Verbindung fehlgeschlagen', + // Model capabilities + capabilities: { + vision: 'Vision', + tools: 'Tools', + streaming: 'Streaming', + }, + contextWindow: 'Kontext', + contextShort: 'ctx', + outputWindow: 'Ausgabe', + // Provider management + addProviderButton: 'Hinzufügen', + addProviderDialog: 'Modellanbieter hinzufügen', + providerName: 'Name', + providerNamePlaceholder: 'z.B. Mein OpenAI-Proxy', + providerNameRequired: 'Bitte Anbieternamen eingeben', + providerApiMode: 'API-Modus', + apiModeOpenAI: 'OpenAI-Protokoll', + apiModeAnthropic: 'Claude-Protokoll', + apiModeGoogle: 'Gemini-Protokoll', + defaultBaseUrl: 'Standard-Basis-URL', + providerIcon: 'Anbieter-Icon-URL', + requiresApiKey: 'Benötigt API-Schlüssel', + deleteProvider: 'Anbieter löschen', + deleteProviderConfirm: 'Sind Sie sicher, dass Sie diesen Anbieter löschen möchten?', + cannotDeleteBuiltIn: 'Integrierter Anbieter kann nicht gelöscht werden', + resetToDefault: 'Auf Standard zurücksetzen', + resetToDefaultDescription: + 'Modellliste auf Standardkonfiguration zurücksetzen (API-Schlüssel und Basis-URL bleiben erhalten)', + resetConfirmDescription: + 'Dadurch werden alle benutzerdefinierten Modelle entfernt und die integrierte Standardmodellliste wiederhergestellt. API-Schlüssel und Basis-URL bleiben erhalten.', + confirmReset: 'Zurücksetzen bestätigen', + resetSuccess: 'Erfolgreich auf Standardkonfiguration zurückgesetzt', + saveSuccess: 'Einstellungen gespeichert', + saveFailed: 'Speichern fehlgeschlagen, bitte versuchen Sie es erneut', + cannotDeleteBuiltInModel: 'Integriertes Modell kann nicht gelöscht werden', + cannotEditBuiltInModel: 'Integriertes Modell kann nicht bearbeitet werden', + modelIdRequired: 'Bitte Modell-ID eingeben', + noModelsAvailable: 'Keine Modelle für den Test verfügbar', + providerMetadata: 'Anbieter-Metadaten', + // Model editing + editModel: 'Modell bearbeiten', + editModelDescription: 'Modellkonfiguration und -fähigkeiten bearbeiten', + addNewModel: 'Neues Modell', + addNewModelDescription: 'Neue Modellkonfiguration hinzufügen', + modelId: 'Modell-ID', + modelIdPlaceholder: 'z.B. gpt-4o', + modelName: 'Anzeigename', + modelNamePlaceholder: 'Optional', + modelCapabilities: 'Fähigkeiten', + advancedSettings: 'Erweiterte Einstellungen', + contextWindowLabel: 'Kontextfenster', + contextWindowPlaceholder: 'z.B. 128000', + outputWindowLabel: 'Max. Ausgabe-Tokens', + outputWindowPlaceholder: 'z.B. 4096', + testModel: 'Modell testen', + deleteModel: 'Löschen', + cancelEdit: 'Abbrechen', + saveModel: 'Speichern', + modelsManagementDescription: + 'Verwalten Sie die Modelle für diesen Anbieter. Um das aktive Modell auszuwählen, gehen Sie zu „Allgemein“.', + // General settings + howToUse: 'Bedienungsanleitung', + step1ConfigureProvider: + 'Gehen Sie zu „Modellanbieter“, wählen Sie einen Anbieter aus oder fügen Sie einen hinzu, und konfigurieren Sie die Verbindungseinstellungen (API-Schlüssel, Basis-URL usw.)', + step2SelectModel: 'Wählen Sie das gewünschte Modell unter „Aktives Modell“ unten aus', + step3StartUsing: 'Nach dem Speichern verwendet das System Ihr ausgewähltes Modell', + activeModel: 'Aktives Modell', + activeModelDescription: 'Wählen Sie das Modell für KI-Unterhaltungen und Inhaltserstellung aus', + selectModel: 'Modell auswählen', + searchModels: 'Modelle suchen', + noModelsFound: 'Keine passenden Modelle gefunden', + noConfiguredProviders: 'Keine konfigurierten Anbieter', + configureProvidersFirst: + 'Bitte konfigurieren Sie die Anbieter-Verbindungseinstellungen unter „Modellanbieter“ links', + currentlyUsing: 'Aktuell verwendet', + // TTS settings + ttsSettings: 'Text-zu-Sprache', + // ASR settings + asrSettings: 'Spracherkennung', + // Audio settings (legacy) + audioSettings: 'Audio-Einstellungen', + ttsSection: 'Text-zu-Sprache (TTS)', + asrSection: 'Automatische Spracherkennung (ASR)', + ttsDescription: 'TTS (Text-to-Speech) - Text in Sprache umwandeln', + asrDescription: 'ASR (Automatic Speech Recognition) - Sprache in Text umwandeln', + enableTTS: 'Text-zu-Sprache aktivieren', + ttsEnabledDescription: 'Wenn aktiviert, wird bei der Kurserstellung Sprachaudio generiert', + enableASR: 'Spracherkennung aktivieren', + asrEnabledDescription: + 'Wenn aktiviert, können Schüler das Mikrofon für die Spracheingabe verwenden', + ttsProvider: 'TTS-Anbieter', + ttsLanguageFilter: 'Sprachfilter', + allLanguages: 'Alle Sprachen', + ttsVoice: 'Stimme', + ttsSpeed: 'Geschwindigkeit', + ttsBaseUrl: 'Basis-URL', + ttsApiKey: 'API-Schlüssel', + asrProvider: 'ASR-Anbieter', + asrLanguage: 'Erkennungssprache', + asrBaseUrl: 'Basis-URL', + asrApiKey: 'API-Schlüssel', + enterApiKey: 'API-Schlüssel eingeben', + enterCustomBaseUrl: 'Benutzerdefinierte Basis-URL eingeben', + browserNativeNote: 'Browser-native ASR erfordert keine Konfiguration und ist völlig kostenlos', + // Audio provider names + providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)', + providerAzureTTS: 'Azure TTS', + providerGLMTTS: 'GLM TTS', + providerQwenTTS: 'Qwen TTS (Alibaba Cloud Bailian)', + providerElevenLabsTTS: 'ElevenLabs TTS', + providerBrowserNativeTTS: 'Browser-native TTS', + providerOpenAIWhisper: 'OpenAI ASR (gpt-4o-mini-transcribe)', + providerBrowserNative: 'Browser-native ASR', + providerQwenASR: 'Qwen ASR (Alibaba Cloud Bailian)', + providerUnpdf: 'unpdf (Integriert)', + providerMinerU: 'MinerU', + browserNativeTTSNote: + 'Browser-native TTS erfordert keine Konfiguration und ist völlig kostenlos, wobei systemeigene Stimmen verwendet werden', + testTTS: 'TTS testen', + testASR: 'ASR testen', + testSuccess: 'Test erfolgreich', + testFailed: 'Test fehlgeschlagen', + ttsTestText: 'TTS-Testtext', + ttsTestSuccess: 'TTS-Test erfolgreich, Audio wurde abgespielt', + ttsTestFailed: 'TTS-Test fehlgeschlagen', + asrTestSuccess: 'Spracherkennung erfolgreich', + asrTestFailed: 'Spracherkennung fehlgeschlagen', + asrResult: 'Erkennungsergebnis', + asrNotSupported: 'Browser unterstützt die Spracherkennungs-API nicht', + browserTTSNotSupported: 'Browser unterstützt die Sprachsynthese-API nicht', + browserTTSNoVoices: 'Der aktuelle Browser hat keine verfügbaren TTS-Stimmen', + microphoneAccessDenied: 'Mikrofonzugriff verweigert', + microphoneAccessFailed: 'Mikrofonzugriff fehlgeschlagen', + asrResultPlaceholder: 'Erkennungsergebnis wird nach der Aufnahme angezeigt', + useThisProvider: 'Diesen Anbieter verwenden', + fetchVoices: 'Stimmenliste abrufen', + fetchingVoices: 'Abrufen...', + voicesFetched: 'Stimmen abgerufen', + fetchVoicesFailed: 'Stimmen konnten nicht abgerufen werden', + voiceApiKeyRequired: 'API-Schlüssel erforderlich', + voiceBaseUrlRequired: 'Basis-URL erforderlich', + ttsTestTextPlaceholder: 'Umzuwandelnden Text eingeben', + ttsTestTextDefault: 'Hallo, dies ist ein Test-Sprachausgabe.', + startRecording: 'Aufnahme starten', + stopRecording: 'Aufnahme stoppen', + recording: 'Aufnahme...', + transcribing: 'Transkribieren...', + transcriptionResult: 'Transkriptionsergebnis', + noTranscriptionResult: 'Kein Transkriptionsergebnis', + baseUrlOptional: 'Basis-URL (optional)', + defaultValue: 'Standard', + // TTS Voice descriptions (OpenAI) + voiceMarin: 'Empfohlen - Beste Qualität', + voiceCedar: 'Empfohlen - Beste Qualität', + voiceAlloy: 'Neutral, ausgewogen', + voiceAsh: 'Stetig, professionell', + voiceBallad: 'Elegant, lyrisch', + voiceCoral: 'Warm, freundlich', + voiceEcho: 'Männlich, klar', + voiceFable: 'Erzählend, lebendig', + voiceNova: 'Weiblich, hell', + voiceOnyx: 'Männlich, tief', + voiceSage: 'Weise, gefasst', + voiceShimmer: 'Weiblich, sanft', + voiceVerse: 'Natürlich, geschmeidig', + // TTS Voice descriptions (GLM) + glmVoiceTongtong: 'Standardstimme', + glmVoiceChuichui: 'Chuichui Stimme', + glmVoiceXiaochen: 'Xiaochen Stimme', + glmVoiceJam: 'Jam Stimme', + glmVoiceKazi: 'Kazi Stimme', + glmVoiceDouji: 'Douji Stimme', + glmVoiceLuodo: 'Luodo Stimme', + // TTS Voice descriptions (Qwen) + qwenVoiceCherry: 'Sonnig, warm und natürlich', + qwenVoiceSerena: 'Sanft und weich', + qwenVoiceEthan: 'Energetisch und lebendig', + qwenVoiceChelsie: 'Anime virtuelle Freundin', + qwenVoiceMomo: 'Verspielt und fröhlich', + qwenVoiceVivian: 'Niedlich und frech', + qwenVoiceMoon: 'Cool und attraktiv', + qwenVoiceMaia: 'Intellektuell und sanft', + qwenVoiceKai: 'Ein Wellness-Erlebnis für Ihre Ohren', + qwenVoiceNofish: 'Designer, der keine retroflexen Laute aussprechen kann', + qwenVoiceBella: 'Kleine Loli, die nicht betrunken wird', + qwenVoiceJennifer: 'Marken-Qualität, filmreife amerikanische Frauenstimme', + qwenVoiceRyan: 'Schnelllebig, dramatische Darbietung', + qwenVoiceKaterina: 'Reife Dame mit einprägsamem Rhythmus', + qwenVoiceAiden: 'Amerikanischer Junge, der das Kochen beherrscht', + qwenVoiceEldricSage: 'Stetiger und weiser Älterer', + qwenVoiceMia: 'Sanft wie Quellwasser, brav wie Schnee', + qwenVoiceMochi: 'Schlauer kleiner Erwachsener mit kindlicher Unschuld', + qwenVoiceBellona: 'Laute Stimme, klare Aussprache, lebendige Charaktere', + qwenVoiceVincent: 'Einzigartige heisere Stimme, die Geschichten von Krieg und Ehre erzählt', + qwenVoiceBunny: 'Super süße Loli', + qwenVoiceNeil: 'Professioneller Nachrichtensprecher', + qwenVoiceElias: 'Professioneller Instruktor', + qwenVoiceArthur: 'Einfache Stimme, geprägt von Jahren und trockenem Tabak', + qwenVoiceNini: 'Weiche und klebrige Stimme wie Klebreiskuchen', + qwenVoiceEbona: 'Ihr Flüstern ist wie ein rostiger Schlüssel', + qwenVoiceSeren: 'Sanfte und beruhigende Stimme, die beim Einschlafen hilft', + qwenVoicePip: 'Frech, aber voller kindlicher Unschuld', + qwenVoiceStella: 'Süße, verwirrte Mädchenstimme, die beim Schreien gerecht wird', + qwenVoiceBodega: 'Begeisterter spanischer Onkel', + qwenVoiceSonrisa: 'Begeisterte lateinamerikanische Dame', + qwenVoiceAlek: 'Kälte der Kriegernation, warm unter dem Wollmantel', + qwenVoiceDolce: 'Lazy italienischer Onkel', + qwenVoiceSohee: 'Sanfte, fröhliche koreanische Unnie', + qwenVoiceOnoAnna: 'Schelmische Jugendfreundin', + qwenVoiceLenn: 'Rationaler deutscher Jugendlicher, der Anzug trägt und Post-Punk hört', + qwenVoiceEmilien: 'Romantischer französischer großer Bruder', + qwenVoiceAndre: 'Magnetische, natürliche und ruhige Männerstimme', + qwenVoiceRadioGol: 'Fußballpoet Rádio Gol!', + qwenVoiceJada: 'Lebhafte Shanghai-Dame', + qwenVoiceDylan: 'Peking-Junge', + qwenVoiceLi: 'Geduldige Yogalehrerin', + qwenVoiceMarcus: 'Breites Gesicht, kurze Worte, festes Herz - alter Shaanxi-Geschmack', + qwenVoiceRoy: 'Humorvoller und direkter taiwanesischer Junge', + qwenVoicePeter: 'Tianjin Cross-Talk-Profi-Unterstützer', + qwenVoiceSunny: 'Süßes Sichuan-Mädchen', + qwenVoiceEric: 'Chengdu-Gentleman', + qwenVoiceRocky: 'Humorvoller Typ aus Hongkong', + qwenVoiceKiki: 'Süßes Mädchen aus Hongkong', + // ASR Language names + lang_auto: 'Automatisch erkennen', + lang_zh: 'Chinesisch', + lang_yue: 'Kantonesisch', + lang_en: 'Englisch', + lang_ja: 'Japanisch', + lang_ko: 'Koreanisch', + lang_es: 'Spanisch', + lang_fr: 'Französisch', + lang_de: 'Deutsch', + lang_ru: 'Russisch', + lang_ar: 'Arabisch', + lang_pt: 'Portugiesisch', + lang_it: 'Italienisch', + // BCP-47 + 'lang_zh-CN': 'Chinesisch (Vereinfacht, China)', + 'lang_zh-TW': 'Chinesisch (Traditionell, Taiwan)', + 'lang_en-US': 'Englisch (USA)', + 'lang_en-GB': 'Englisch (Großbritannien)', + 'lang_de-DE': 'Deutsch (Deutschland)', + 'lang_fr-FR': 'Französisch (Frankreich)', + 'lang_es-ES': 'Spanisch (Spanien)', + 'lang_ja-JP': 'Japanisch (Japan)', + 'lang_ko-KR': 'Koreanisch (Südkorea)', + // PDF settings + pdfSettings: 'PDF-Parsing', + pdfParsingSettings: 'PDF-Parsing-Einstellungen', + pdfDescription: + 'Wählen Sie eine PDF-Parsing-Engine mit Unterstützung für Textextraktion, Bildverarbeitung und Tabellenerkennung', + pdfProvider: 'PDF-Parser', + pdfFeatures: 'Unterstützte Funktionen', + pdfApiKey: 'API-Schlüssel', + pdfBaseUrl: 'Basis-URL', + mineruDescription: + 'MinerU ist ein kommerzieller PDF-Parsing-Dienst, der erweiterte Funktionen wie Tabellenextraktion, Formelerkennung und Layoutanalyse unterstützt.', + mineruApiKeyRequired: + 'Sie müssen vor der Verwendung einen API-Schlüssel auf der MinerU-Website beantragen.', + mineruWarning: 'Warnung', + mineruCostWarning: + 'MinerU ist ein kommerzieller Dienst und es können Gebühren anfallen. Bitte informieren Sie sich auf der MinerU-Website über die Preise.', + enterMinerUApiKey: 'MinerU API-Schlüssel eingeben', + mineruLocalDescription: + 'MinerU unterstützt die lokale Bereitstellung mit erweitertem PDF-Parsing. Erfordert die vorherige Bereitstellung des MinerU-Dienstes.', + mineruServerAddress: 'Lokale MinerU-Serveradresse (z.B. http://localhost:8080)', + mineruApiKeyOptional: + 'Nur erforderlich, wenn auf dem Server die Authentifizierung aktiviert ist', + optionalApiKey: 'Optionaler API-Schlüssel', + featureText: 'Textextraktion', + featureImages: 'Bildextraktion', + featureTables: 'Tabellenextraktion', + featureFormulas: 'Formelerkennung', + featureLayoutAnalysis: 'Layoutanalyse', + featureMetadata: 'Metadaten', + // Image Generation + enableImageGeneration: 'KI-Bildgenerierung aktivieren', + imageGenerationDisabledHint: + 'Wenn aktiviert, werden während der Kurserstellung automatisch Bilder generiert', + imageSettings: 'Bildgenerierung', + imageSection: 'Text zu Bild', + imageProvider: 'Bildgenerierungsanbieter', + imageModel: 'Bildgenerierungsmodell', + providerSeedream: 'Seedream (ByteDance)', + providerQwenImage: 'Qwen Image (Alibaba)', + providerNanoBanana: 'Nano Banana (Gemini)', + providerGrokImage: 'Grok Image (xAI)', + testImageGeneration: 'Bildgenerierung testen', + testImageConnectivity: 'Verbindung testen', + imageConnectivitySuccess: 'Bilddienst erfolgreich verbunden', + imageConnectivityFailed: 'Verbindung zum Bilddienst fehlgeschlagen', + imageTestSuccess: 'Bildgenerierungstest erfolgreich', + imageTestFailed: 'Bildgenerierungstest fehlgeschlagen', + imageTestPromptPlaceholder: 'Bildbeschreibung zum Testen eingeben', + imageTestPromptDefault: 'Eine süße Katze, die auf einem Schreibtisch sitzt', + imageGenerating: 'Bild wird generiert...', + imageGenerationFailed: 'Bildgenerierung fehlgeschlagen', + // Video Generation + enableVideoGeneration: 'KI-Videogenerierung aktivieren', + videoGenerationDisabledHint: + 'Wenn aktiviert, werden während der Kurserstellung automatisch Videos generiert', + videoSettings: 'Videogenerierung', + videoSection: 'Text zu Video', + videoProvider: 'Videogenerierungsanbieter', + videoModel: 'Videogenerierungsmodell', + providerSeedance: 'Seedance (ByteDance)', + providerKling: 'Kling (Kuaishou)', + providerVeo: 'Veo (Google)', + providerSora: 'Sora (OpenAI)', + providerGrokVideo: 'Grok Video (xAI)', + testVideoGeneration: 'Videogenerierung testen', + testVideoConnectivity: 'Verbindung testen', + videoConnectivitySuccess: 'Videodienst erfolgreich verbunden', + videoConnectivityFailed: 'Verbindung zum Videodienst fehlgeschlagen', + testingConnection: 'Testen...', + videoTestSuccess: 'Videogenerierungstest erfolgreich', + videoTestFailed: 'Videogenerierungstest fehlgeschlagen', + videoTestPromptDefault: 'Eine süße Katze, die auf einem Schreibtisch läuft', + videoGenerating: 'Video wird generiert (ca. 1-2 Min.)...', + videoGenerationWarning: + 'Die Videogenerierung dauert normalerweise 1-2 Minuten, bitte haben Sie Geduld', + mediaRetry: 'Wiederholen', + mediaContentSensitive: + 'Entschuldigung, dieser Inhalt hat eine Sicherheitsüberprüfung ausgelöst.', + mediaGenerationDisabled: 'Generierung in den Einstellungen deaktiviert', + // Agent settings + singleAgent: 'Einzelagent', + multiAgent: 'Multi-Agent', + selectAgents: 'Agenten auswählen', + noVisionWarning: + 'Das aktuelle Modell unterstützt keine Vision-Funktionen. Bilder können weiterhin in Folien platziert werden, aber das Modell kann den Bildinhalt nicht verstehen, um Auswahl und Layout zu optimieren.', + // Server provider + serverConfigured: 'Server', + serverConfiguredNotice: + 'Der Administrator hat einen API-Schlüssel für diesen Anbieter auf dem Server konfiguriert. Sie können ihn direkt verwenden oder Ihren eigenen Schlüssel eingeben, um ihn zu überschreiben.', + optionalOverride: 'Optional — leer lassen, um die Serverkonfiguration zu verwenden', + // Access code + setupNeeded: 'Einrichtung erforderlich', + modelNotConfigured: 'Bitte wählen Sie ein Modell aus, um zu beginnen', + // Clear cache + dangerZone: 'Gefahrenzone', + clearCache: 'Lokalen Cache leeren', + clearCacheDescription: + 'Löschen Sie alle lokal gespeicherten Daten, einschließlich Klassenzimmeraufzeichnungen, Chatverlauf, Audio-Cache und App-Einstellungen. Diese Aktion kann nicht rückgängig gemacht werden.', + clearCacheConfirmTitle: 'Sind Sie sicher, dass Sie den gesamten Cache leeren möchten?', + clearCacheConfirmDescription: + 'Dadurch werden alle folgenden Daten dauerhaft gelöscht und können nicht wiederhergestellt werden:', + clearCacheConfirmItems: + 'Klassenzimmer & Szenen, Chatverlauf, Audio- & Bild-Cache, App-Einstellungen & Präferenzen', + clearCacheConfirmInput: 'Geben Sie „DELETE“ ein, um fortzufahren', + clearCacheConfirmPhrase: 'DELETE', + clearCacheButton: 'Alle Daten dauerhaft löschen', + clearCacheSuccess: 'Cache geleert, die Seite wird in Kürze aktualisiert', + clearCacheFailed: 'Cache konnte nicht geleert werden, bitte versuchen Sie es erneut', + // Web Search + webSearchSettings: 'Websuche', + webSearchApiKey: 'Tavily API-Schlüssel', + webSearchApiKeyPlaceholder: 'Geben Sie Ihren Tavily API-Schlüssel ein', + webSearchApiKeyPlaceholderServer: 'Server-Schlüssel konfiguriert, optional überschreiben', + webSearchApiKeyHint: 'Holen Sie sich einen API-Schlüssel von tavily.com für die Websuche', + webSearchBaseUrl: 'Basis-URL', + webSearchServerConfigured: 'Serverseitiger Tavily API-Schlüssel ist konfiguriert', + optional: 'Optional', + }, + profile: { + title: 'Profil', + defaultNickname: 'Schüler', + chooseAvatar: 'Avatar auswählen', + uploadAvatar: 'Hochladen', + bioPlaceholder: + 'Erzählen Sie uns etwas über sich — der KI-Lehrer wird den Unterricht für Sie personalisieren...', + avatarHint: 'Ihr Avatar wird in Diskussionsrunden und Chats im Klassenzimmer angezeigt', + fileTooLarge: 'Bild zu groß — bitte wählen Sie eines unter 5 MB', + invalidFileType: 'Bitte wählen Sie eine Bilddatei aus', + editTooltip: 'Klicken, um das Profil zu bearbeiten', + }, + media: { + imageCapability: 'Bildgenerierung', + imageHint: 'Bilder in Folien generieren', + videoCapability: 'Videogenerierung', + videoHint: 'Videos in Folien generieren', + ttsCapability: 'Text-zu-Sprache', + ttsHint: 'KI-Lehrer spricht laut', + asrCapability: 'Spracherkennung', + asrHint: 'Spracheingabe für Diskussionen', + provider: 'Anbieter', + model: 'Modell', + voice: 'Stimme', + speed: 'Geschwindigkeit', + language: 'Sprache', + }, +} as const; diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts index 81b08d8d..6b05238b 100644 --- a/lib/i18n/stage.ts +++ b/lib/i18n/stage.ts @@ -292,3 +292,154 @@ export const stageEnUS = { notReady: 'Available after generation completes', }, } as const; + +export const stageDeDE = { + stage: { + currentScene: 'Aktuelle Szene', + generating: 'Generieren...', + paused: 'Pausiert', + generationFailed: 'Generierung fehlgeschlagen', + confirmSwitchTitle: 'Szene wechseln', + confirmSwitchMessage: + 'Ein Thema ist gerade in Bearbeitung. Das Wechseln der Szene beendet das aktuelle Thema. Sind Sie sicher?', + generatingNextPage: 'Szene wird generiert, bitte warten...', + }, + whiteboard: { + title: 'Interaktives Whiteboard', + open: 'Whiteboard öffnen', + clear: 'Whiteboard löschen', + minimize: 'Whiteboard minimieren', + ready: 'Whiteboard ist bereit', + readyHint: 'Elemente erscheinen hier, wenn sie von der KI hinzugefügt werden', + clearSuccess: 'Whiteboard erfolgreich gelöscht', + clearError: 'Fehler beim Löschen des Whiteboards: ', + resetView: 'Ansicht zurücksetzen', + zoomHint: 'Scrollen zum Zoomen · Ziehen zum Schwenken', + restoreError: 'Fehler beim Wiederherstellen des Whiteboards: ', + history: 'Verlauf', + restore: 'Wiederherstellen', + beforeClear: 'Vor dem Löschen', + beforeAIClear: 'Vor dem KI-Löschen', + noHistory: 'Noch kein Verlauf', + restored: 'Whiteboard wiederhergestellt', + elementCount: '{count} Elemente', + }, + quiz: { + title: 'Quiz', + subtitle: 'Testen Sie Ihr Wissen', + questionsCount: 'Fragen', + totalPrefix: '', + pointsSuffix: 'Pkt', + startQuiz: 'Quiz starten', + multipleChoiceHint: '(Mehrfachauswahl — wählen Sie alle richtigen Antworten aus)', + inputPlaceholder: 'Geben Sie hier Ihre Antwort ein...', + charCount: 'Zeichen', + yourAnswer: 'Ihre Antwort:', + notAnswered: 'Nicht beantwortet', + aiComment: 'KI-Feedback', + singleChoice: 'Einzelwahl', + multipleChoice: 'Mehrfachwahl', + shortAnswer: 'Kurzantwort', + analysis: 'Analyse: ', + excellent: 'Ausgezeichnet!', + keepGoing: 'Weiter so!', + needsReview: 'Überprüfung erforderlich', + correct: 'richtig', + incorrect: 'falsch', + answering: 'In Bearbeitung', + submitAnswers: 'Antworten absenden', + aiGrading: 'KI korrigiert...', + aiGradingWait: 'Bitte warten, Ihre Antworten werden analysiert', + quizReport: 'Quiz-Bericht', + retry: 'Wiederholen', + }, + roundtable: { + teacher: 'LEHRER', + you: 'DU', + inputPlaceholder: 'Geben Sie Ihre Nachricht ein...', + listening: 'Zuhören...', + processing: 'Verarbeitung...', + noSpeechDetected: 'Keine Sprache erkannt, bitte versuchen Sie es erneut', + discussionEnded: 'Diskussion beendet', + qaEnded: 'Fragen & Antworten beendet', + thinking: 'Nachdenken', + yourTurn: 'Sie sind dran', + stopDiscussion: 'Diskussion beenden', + pauseDiscussion: 'Pause', + resumeDiscussion: 'Fortsetzen', + autoPlay: 'Autoplay', + autoPlayOff: 'Autoplay stoppen', + speed: 'Geschwindigkeit', + }, + pbl: { + legacyFormat: 'Diese PBL-Szene verwendet ein altes Format. Bitte generieren Sie den Kurs neu.', + emptyProject: + 'PBL-Projekt wurde noch nicht generiert. Bitte über die Kursgenerierung erstellen.', + roleSelection: { + title: 'Wählen Sie Ihre Rolle', + description: 'Wählen Sie eine Rolle aus, um mit der Zusammenarbeit am Projekt zu beginnen', + }, + workspace: { + restart: 'Neustart', + confirmRestart: 'Gesamten Fortschritt zurücksetzen?', + confirm: 'Bestätigen', + cancel: 'Abbrechen', + }, + issueboard: { + title: 'Aufgabenboard', + noIssues: 'Noch keine Aufgaben', + statusDone: 'Erledigt', + statusActive: 'Aktiv', + statusPending: 'Ausstehend', + }, + chat: { + title: 'Projektdiskussion', + currentIssue: 'Aktuelle Aufgabe', + mentionHint: 'Verwenden Sie @question zum Fragen, @judge zum Einreichen zur Überprüfung', + placeholder: 'Geben Sie eine Nachricht ein...', + send: 'Senden', + welcomeMessage: + 'Hallo! Ich bin Ihr Fragen-Agent für diese Aufgabe: "{title}"\n\nUm Ihre Arbeit zu unterstützen, habe ich einige Fragen für Sie vorbereitet:\n\n{questions}\n\nFühlen Sie sich frei, mich jederzeit mit @question zu fragen, wenn Sie Hilfe oder Klärung benötigen!', + issueCompleteMessage: + 'Aufgabe "{completed}" abgeschlossen! Weiter zur nächsten Aufgabe: "{next}"', + allCompleteMessage: '🎉 Alle Aufgaben abgeschlossen! Tolle Arbeit am Projekt!', + }, + guide: { + howItWorks: 'Wie es funktioniert', + help: 'Hilfe', + title: 'Hilfe', + step1: { + title: 'Schritt 1: Wählen Sie eine Rolle', + desc: 'Nachdem das Projekt generiert wurde, wählen Sie eine Rolle aus der Liste aus (Nicht-Systemrollen sind mit 🟢 markiert)', + }, + step2: { + title: 'Schritt 2: Aufgaben abschließen', + desc: 'Jede Aufgabe repräsentiert ein Lernziel:', + s1: { + title: 'Aktuelle Aufgabe anzeigen', + desc: 'Überprüfen Sie den Titel, die Beschreibung und den Verantwortlichen der Aufgabe', + }, + s2: { + title: 'Anleitung erhalten', + example: + '@question Wo soll ich anfangen?\n@question Wie implementiere ich diese Funktion?', + desc: 'Der Fragen-Agent stellt leitende Fragen und Hinweise zur Verfügung (keine direkten Antworten)', + }, + s3: { + title: 'Reichen Sie Ihre Arbeit ein', + example: '@judge Ich bin fertig, bitte überprüfen Sie meine Notizen', + desc: 'Der Judge-Agent bewertet Ihre Arbeit und gibt Feedback:', + complete: 'Wechselt automatisch zur nächsten Aufgabe', + revision: 'Verbessern basierend auf Feedback', + }, + }, + step3: { + title: 'Schritt 3: Schließen Sie das Projekt ab', + desc: 'Wenn alle Aufgaben erledigt sind, zeigt das System „🎉 Projekt abgeschlossen!“ an', + }, + }, + }, + share: { + notReady: 'Verfügbar nach Abschluss der Generierung', + }, +} as const; diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts index 6173b0be..d52f422e 100644 --- a/lib/i18n/types.ts +++ b/lib/i18n/types.ts @@ -1,3 +1,3 @@ -export type Locale = 'zh-CN' | 'en-US'; +export type Locale = 'zh-CN' | 'en-US' | 'de-DE'; export const defaultLocale: Locale = 'zh-CN'; diff --git a/lib/media/media-orchestrator.ts b/lib/media/media-orchestrator.ts index 151a3aca..7319e960 100644 --- a/lib/media/media-orchestrator.ts +++ b/lib/media/media-orchestrator.ts @@ -26,17 +26,54 @@ class MediaApiError extends Error { /** * Launch media generation for all mediaGenerations declared in outlines. - * Runs in parallel with content/action generation — does not block. + * Also registers manually uploaded images as "done" tasks in the store. */ export async function generateMediaForOutlines( outlines: SceneOutline[], stageId: string, abortSignal?: AbortSignal, + imageMapping?: Record, ): Promise { const settings = useSettingsStore.getState(); const store = useMediaGenerationStore.getState(); - // Collect all media requests + // ── Register manually uploaded images ── + if (imageMapping) { + Object.entries(imageMapping).forEach(([id, base64]) => { + if (id.startsWith('uploaded_img_')) { + // Skip if already tracked + if (store.getTask(id)) return; + + const parts = base64.split(','); + const mime = parts[0].match(/:(.*?);/)?.[1] || 'image/png'; + const binary = atob(parts[1]); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes], { type: mime }); + const objectUrl = URL.createObjectURL(blob); + + // Add as a completed task to the store manually + // We use setState directly because markDone only updates existing tasks + useMediaGenerationStore.setState((s) => ({ + tasks: { + ...s.tasks, + [id]: { + elementId: id, + type: 'image', + status: 'done', + prompt: 'Uploaded image', + params: {}, + objectUrl, + retryCount: 0, + stageId, + }, + }, + })); + } + }); + } + + // Collect all media requests from outlines const allRequests: MediaGenerationRequest[] = []; for (const outline of outlines) { if (!outline.mediaGenerations) continue; diff --git a/lib/pbl/generate-pbl.ts b/lib/pbl/generate-pbl.ts index 52838b7f..26f9276c 100644 --- a/lib/pbl/generate-pbl.ts +++ b/lib/pbl/generate-pbl.ts @@ -290,7 +290,9 @@ export async function generatePBLContent( prompt: language === 'zh-CN' ? `请设计一个PBL项目。现在从 project_info 模式开始,先设置项目标题和描述。` - : `Design a PBL project. Start in project_info mode by setting the project title and description.`, + : language === 'de-DE' + ? `Entwerfen Sie ein PBL-Projekt. Beginnen Sie im Modus project_info, indem Sie den Projekttitel und die Beschreibung festlegen.` + : `Design a PBL project. Start in project_info mode by setting the project title and description.`, tools: pblTools, stopWhen: stepCountIs(30), onStepFinish: ({ toolCalls, text }) => { @@ -379,7 +381,25 @@ ${firstIssue.notes ? `**备注**: ${firstIssue.notes}` : ''} - 鼓励批判性思考 请以编号列表格式回答。` - : `## Issue Information + : language === 'de-DE' + ? `## Aufgabeninformationen + +**Titel**: ${firstIssue.title} +**Beschreibung**: ${firstIssue.description} +**Verantwortliche Person**: ${firstIssue.person_in_charge} +${firstIssue.participants.length > 0 ? `**Teilnehmer**: ${firstIssue.participants.join(', ')}` : ''} +${firstIssue.notes ? `**Notizen**: ${firstIssue.notes}` : ''} + +## Ihre Aufgabe + +Basierend auf den obigen Aufgabeninformationen generieren Sie 1-3 spezifische, umsetzbare Fragen, die den Schülern helfen, diese Aufgabe zu verstehen und zu erledigen. Jede Frage sollte: +- Die Schüler zu wichtigen Lernzielen führen +- Spezifisch und umsetzbar sein +- Helfen, das Problem aufzuschlüsseln +- Kritisches Denken fördern + +Formatieren Sie Ihre Antwort als nummerierte Liste.` + : `## Issue Information **Title**: ${firstIssue.title} **Description**: ${firstIssue.description} @@ -413,7 +433,9 @@ Format your response as a numbered list.`; const welcomeMessage = language === 'zh-CN' ? `你好!我是这个任务的提问助手:"${firstIssue.title}"\n\n为了引导你的学习,我准备了一些问题:\n\n${generatedQuestions}\n\n随时 @question 我来获取帮助或澄清!` - : `Hello! I'm your Question Agent for this issue: "${firstIssue.title}"\n\nTo help guide your work, I've prepared some questions for you:\n\n${generatedQuestions}\n\nFeel free to @question me anytime if you need help or clarification!`; + : language === 'de-DE' + ? `Hallo! Ich bin Ihr Frage-Agent für diese Aufgabe: "${firstIssue.title}"\n\nUm Ihnen bei der Arbeit zu helfen, habe ich einige Fragen für Sie vorbereitet:\n\n${generatedQuestions}\n\nSie können mich jederzeit @question, wenn Sie Hilfe oder Klärung benötigen!` + : `Hello! I'm your Question Agent for this issue: "${firstIssue.title}"\n\nTo help guide your work, I've prepared some questions for you:\n\n${generatedQuestions}\n\nFeel free to @question me anytime if you need help or clarification!`; config.chat.messages.push({ id: `msg_welcome_${Date.now()}`, diff --git a/lib/pbl/mcp/agent-templates.ts b/lib/pbl/mcp/agent-templates.ts index c2e1c8c3..9946bd3e 100644 --- a/lib/pbl/mcp/agent-templates.ts +++ b/lib/pbl/mcp/agent-templates.ts @@ -8,6 +8,12 @@ export function getQuestionAgentPrompt(language: string = 'en-US'): string { if (language === 'zh-CN') { return QUESTION_AGENT_TEMPLATE_PROMPT_ZH; } + if (language === 'de-DE') { + return ( + QUESTION_AGENT_TEMPLATE_PROMPT + + '\n\n**IMPORTANT: All interactions MUST be in German (Alle Interaktionen müssen auf Deutsch sein).**' + ); + } return QUESTION_AGENT_TEMPLATE_PROMPT; } @@ -15,6 +21,12 @@ export function getJudgeAgentPrompt(language: string = 'en-US'): string { if (language === 'zh-CN') { return JUDGE_AGENT_TEMPLATE_PROMPT_ZH; } + if (language === 'de-DE') { + return ( + JUDGE_AGENT_TEMPLATE_PROMPT + + '\n\n**IMPORTANT: All interactions MUST be in German (Alle Interaktionen müssen auf Deutsch sein).**' + ); + } return JUDGE_AGENT_TEMPLATE_PROMPT; } diff --git a/lib/pbl/pbl-system-prompt.ts b/lib/pbl/pbl-system-prompt.ts index 72cdc10b..a9c79d5a 100644 --- a/lib/pbl/pbl-system-prompt.ts +++ b/lib/pbl/pbl-system-prompt.ts @@ -20,7 +20,7 @@ export function buildPBLSystemPrompt(config: PBLSystemPromptConfig): string { return buildPBLSystemPromptZH(config); } - return `You are a Teaching Assistant (TA) on a Project-Based Learning platform. You are fully responsible for designing group projects for students based on the course information provided by the teacher. + const basePrompt = `You are a Teaching Assistant (TA) on a Project-Based Learning platform. You are fully responsible for designing group projects for students based on the course information provided by the teacher. ## Your Responsibility @@ -82,6 +82,15 @@ When you create issues: **IMPORTANT**: Once you have configured the project info, defined all necessary agents (roles), and created the issueboard with tasks, you MUST set your mode to **idle** to indicate completion. Your initial mode is **project_info**.`; + + if (language === 'de-DE') { + return ( + basePrompt + + '\n\n**IMPORTANT: All content you generate (titles, descriptions, agent prompts, etc.) MUST be in German.**' + ); + } + + return basePrompt; } function buildPBLSystemPromptZH(config: PBLSystemPromptConfig): string { diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index c9c5c8bf..dfa65dc2 100644 --- a/lib/playback/engine.ts +++ b/lib/playback/engine.ts @@ -634,7 +634,15 @@ export class PlaybackEngine { // auto-selects an appropriate voice. const cjkRatio = (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length; - utterance.lang = cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US'; + + if (cjkRatio > CJK_LANG_THRESHOLD) { + utterance.lang = 'zh-CN'; + } else { + // Simple German detection: check for umlauts or common words + const germanPattern = + /[äöüß]|(\b(der|die|das|und|ist|nicht|für|mit|den|dem|des|ein|eine|einer|eines|daß|dass|was|wenn|so|wie|bei|an|aus|auf|nach|über|vor|zu)\b)/i; + utterance.lang = germanPattern.test(chunkText) ? 'de-DE' : 'en-US'; + } } utterance.onend = () => { diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index eda67b4c..c3a8d498 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -96,7 +96,8 @@ function createInMemoryStore(stage: Stage): StageStore { }; } -function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' { +function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' | 'de-DE' { + if (language === 'de-DE') return 'de-DE'; return language === 'en-US' ? 'en-US' : 'zh-CN'; } diff --git a/lib/store/media-generation.ts b/lib/store/media-generation.ts index b64e1ae2..a719137a 100644 --- a/lib/store/media-generation.ts +++ b/lib/store/media-generation.ts @@ -63,9 +63,9 @@ interface MediaGenerationState { // ==================== Helper ==================== -/** Check if a src string is a generated media placeholder ID */ +/** Check if a src string is a generated media placeholder ID or uploaded image ID */ export function isMediaPlaceholder(src: string): boolean { - return /^gen_(img|vid)_[\w-]+$/i.test(src); + return /^(gen_(img|vid)_|uploaded_img_)[\w-]+$/i.test(src); } // ==================== Store ==================== diff --git a/lib/types/generation.ts b/lib/types/generation.ts index c1e6eb7a..e1a72cd5 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -64,10 +64,11 @@ 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: 'zh-CN' | 'en-US' | 'de-DE'; // 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 + uploadedImageStorageIds?: string[]; // IDs of manually uploaded images in IndexedDB } /** @@ -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?: 'zh-CN' | 'en-US' | 'de-DE'; // 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: 'zh-CN' | 'en-US' | 'de-DE'; }; }