Skip to content
3 changes: 2 additions & 1 deletion app/api/generate/scene-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
generateSceneContent,
buildVisionUserContent,
} from '@/lib/generation/generation-pipeline';
import { normalizeGenerationLanguage } from '@/lib/generation/language';
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
import { createLogger } from '@/lib/logger';
Expand Down Expand Up @@ -67,7 +68,7 @@ export async function POST(req: NextRequest) {
// Ensure outline has language from stageInfo (fallback for older outlines)
const outline: SceneOutline = {
...rawOutline,
language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN',
language: rawOutline.language ?? normalizeGenerationLanguage(stageInfo?.language),
};

// ── Model resolution from request headers ──
Expand Down
19 changes: 13 additions & 6 deletions app/api/generate/scene-outlines-stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ const log = createLogger('Outlines Stream');

export const maxDuration = 300;

function getLocalizedEmpty(language: string, kind: 'images' | 'content' | 'research'): string {
if (language === 'zh-CN') {
return kind === 'images' ? '无可用图片' : '无';
}
if (language === 'ru-RU') {
return kind === 'images' ? 'Нет доступных изображений' : 'Нет';
}
return kind === 'images' ? 'No images available' : 'None';
}

/**
* Incremental JSON array parser.
* Extracts complete top-level objects from a partially-streamed JSON array.
Expand Down Expand Up @@ -120,8 +130,7 @@ export async function POST(req: NextRequest) {
const hasVision = !!modelInfo?.capabilities?.vision;

// Build prompt (same logic as generateSceneOutlinesFromRequirements)
let availableImagesText =
requirements.language === 'zh-CN' ? '无可用图片' : 'No images available';
let availableImagesText = getLocalizedEmpty(requirements.language, 'images');
let visionImages: Array<{ id: string; src: string }> | undefined;

if (pdfImages && pdfImages.length > 0) {
Expand Down Expand Up @@ -177,11 +186,9 @@ export async function POST(req: NextRequest) {
language: requirements.language,
pdfContent: pdfText
? pdfText.substring(0, MAX_PDF_CONTENT_CHARS)
: requirements.language === 'zh-CN'
? '无'
: 'None',
: getLocalizedEmpty(requirements.language, 'content'),
availableImages: availableImagesText,
researchContext: researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'),
researchContext: researchContext || getLocalizedEmpty(requirements.language, 'research'),
mediaGenerationPolicy,
teacherContext,
});
Expand Down
17 changes: 14 additions & 3 deletions app/api/quiz-grade/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,29 @@ export async function POST(req: NextRequest) {
const { model: languageModel } = resolveModelFromHeaders(req);

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

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

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

Expand Down Expand Up @@ -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.',
: isRu
? 'Ответ получен. Сверьтесь с эталонным ответом.'
: 'Answer received. Please refer to the standard answer.',
};
}

Expand Down
28 changes: 23 additions & 5 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,14 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
interface FormState {
pdfFile: File | null;
requirement: string;
language: 'zh-CN' | 'en-US';
language: 'zh-CN' | 'en-US' | 'ru-RU';
webSearch: boolean;
}

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

Expand Down Expand Up @@ -101,10 +101,15 @@ function HomePage() {
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
const updates: Partial<FormState> = {};
if (savedWebSearch === 'true') updates.webSearch = true;
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') {
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US' || savedLanguage === 'ru-RU') {
updates.language = savedLanguage;
} else {
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
const navLang = navigator.language?.toLowerCase() || '';
const detected = navLang.startsWith('zh')
? 'zh-CN'
: navLang.startsWith('ru')
? 'ru-RU'
: 'en-US';
updates.language = detected;
}
if (Object.keys(updates).length > 0) {
Expand Down Expand Up @@ -338,7 +343,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 === 'ru-RU' ? 'RU' : 'EN'}
</button>
{languageOpen && (
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[120px]">
Expand All @@ -355,6 +360,19 @@ function HomePage() {
>
简体中文
</button>
<button
onClick={() => {
setLocale('ru-RU');
setLanguageOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
locale === 'ru-RU' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
Русский
</button>
<button
onClick={() => {
setLocale('en-US');
Expand Down
12 changes: 8 additions & 4 deletions components/generation/generation-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ 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' | 'ru-RU';
onLanguageChange: (lang: 'zh-CN' | 'en-US' | 'ru-RU') => void;
webSearch: boolean;
onWebSearchChange: (v: boolean) => void;
onSettingsOpen: (section?: SettingsSection) => void;
Expand Down Expand Up @@ -361,11 +361,15 @@ export function GenerationToolbar({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : 'zh-CN')}
onClick={() =>
onLanguageChange(
language === 'zh-CN' ? 'ru-RU' : language === 'ru-RU' ? 'en-US' : 'zh-CN',
)
}
className={pillMuted}
>
<Globe className="size-3.5" />
<span>{language === 'zh-CN' ? '中文' : 'EN'}</span>
<span>{language === 'zh-CN' ? '中文' : language === 'ru-RU' ? 'RU' : 'EN'}</span>
</button>
</TooltipTrigger>
<TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
Expand Down
15 changes: 14 additions & 1 deletion components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 === 'ru-RU' ? 'RU' : 'EN'}
</button>
{languageOpen && (
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[120px]">
Expand All @@ -130,6 +130,19 @@ export function Header({ currentSceneTitle }: HeaderProps) {
>
简体中文
</button>
<button
onClick={() => {
setLocale('ru-RU');
setLanguageOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
locale === 'ru-RU' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
Русский
</button>
<button
onClick={() => {
setLocale('en-US');
Expand Down
4 changes: 3 additions & 1 deletion components/scene-renderers/quiz-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ async function gradeShortAnswerQuestion(
aiComment:
language === 'zh-CN'
? '评分服务暂时不可用,已给予基础分。'
: 'Grading service unavailable. Base score given.',
: language === 'ru-RU'
? 'Сервис оценки временно недоступен. Начислен базовый балл.'
: 'Grading service unavailable. Base score given.',
};
}
}
Expand Down
4 changes: 2 additions & 2 deletions components/settings/image-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function ImageSettings({ selectedProviderId }: ImageSettingsProps) {

{/* API Key + Test inline */}
<div className="space-y-2">
<Label>API Key</Label>
<Label>{t('settings.apiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
Expand Down Expand Up @@ -213,7 +213,7 @@ export function ImageSettings({ selectedProviderId }: ImageSettingsProps) {

{/* Base URL */}
<div className="space-y-2">
<Label>Base URL</Label>
<Label>{t('settings.apiHost')}</Label>
<Input
name={`image-base-url-${selectedProviderId}`}
type="url"
Expand Down
4 changes: 2 additions & 2 deletions components/settings/video-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function VideoSettings({ selectedProviderId }: VideoSettingsProps) {

{/* API Key + Test inline */}
<div className="space-y-2">
<Label>API Key</Label>
<Label>{t('settings.apiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
Expand Down Expand Up @@ -216,7 +216,7 @@ export function VideoSettings({ selectedProviderId }: VideoSettingsProps) {

{/* Base URL */}
<div className="space-y-2">
<Label>Base URL</Label>
<Label>{t('settings.apiHost')}</Label>
<Input
name={`video-base-url-${selectedProviderId}`}
type="url"
Expand Down
9 changes: 7 additions & 2 deletions components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui';

import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { getClientTranslation } from '@/lib/i18n';
import { XIcon } from 'lucide-react';

function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
Expand Down Expand Up @@ -47,6 +48,8 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const closeLabel = getClientTranslation('settings.close');

return (
<DialogPortal>
<DialogOverlay />
Expand All @@ -63,7 +66,7 @@ function DialogContent({
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">{closeLabel}</span>
</Button>
</DialogPrimitive.Close>
)}
Expand All @@ -86,6 +89,8 @@ function DialogFooter({
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean;
}) {
const closeLabel = getClientTranslation('settings.close');

return (
<div
data-slot="dialog-footer"
Expand All @@ -95,7 +100,7 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{closeLabel}</Button>
</DialogPrimitive.Close>
)}
</div>
Expand Down
Loading
Loading