diff --git a/app/api/generate/scene-content/route.ts b/app/api/generate/scene-content/route.ts index db9b772e..4ff71229 100644 --- a/app/api/generate/scene-content/route.ts +++ b/app/api/generate/scene-content/route.ts @@ -13,6 +13,7 @@ import { generateSceneContent, buildVisionUserContent, } from '@/lib/generation/generation-pipeline'; +import { normalizeGenerationLanguage } from '@/lib/generation/language'; import type { AgentInfo } from '@/lib/generation/generation-pipeline'; import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation'; import { createLogger } from '@/lib/logger'; @@ -67,7 +68,7 @@ export async function POST(req: NextRequest) { // Ensure outline has language from stageInfo (fallback for older outlines) const outline: SceneOutline = { ...rawOutline, - language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN', + language: rawOutline.language ?? normalizeGenerationLanguage(stageInfo?.language), }; // ── Model resolution from request headers ── diff --git a/app/api/generate/scene-outlines-stream/route.ts b/app/api/generate/scene-outlines-stream/route.ts index 36c1606a..d71cf632 100644 --- a/app/api/generate/scene-outlines-stream/route.ts +++ b/app/api/generate/scene-outlines-stream/route.ts @@ -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. @@ -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) { @@ -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, }); diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index d0aab62e..f97f81d2 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 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": , "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} + : isRu + ? `Вопрос: ${question} +Максимум: ${points} баллов +${commentPrompt ? `Критерии оценивания: ${commentPrompt}\n` : ''}Ответ студента: ${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.', + : isRu + ? 'Ответ получен. Сверьтесь с эталонным ответом.' + : 'Answer received. Please refer to the standard answer.', }; } diff --git a/app/page.tsx b/app/page.tsx index 80dfbd85..58aad249 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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, }; @@ -101,10 +101,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 === '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) { @@ -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'} {languageOpen && (
@@ -355,6 +360,19 @@ function HomePage() { > 简体中文 + {t('toolbar.languageHint')} diff --git a/components/header.tsx b/components/header.tsx index 77a63b03..710f802a 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 === 'ru-RU' ? 'RU' : 'EN'} {languageOpen && (
@@ -130,6 +130,19 @@ export function Header({ currentSceneTitle }: HeaderProps) { > 简体中文 + )} @@ -86,6 +89,8 @@ function DialogFooter({ }: React.ComponentProps<'div'> & { showCloseButton?: boolean; }) { + const closeLabel = getClientTranslation('settings.close'); + return (
- + )}
diff --git a/lib/generation/language.ts b/lib/generation/language.ts new file mode 100644 index 00000000..b1a8ab9a --- /dev/null +++ b/lib/generation/language.ts @@ -0,0 +1,112 @@ +export type SupportedGenerationLanguage = 'zh-CN' | 'en-US' | 'ru-RU'; + +export interface GenerationLanguageSpec { + code: SupportedGenerationLanguage; + englishName: string; + nativeName: string; + noImagesAvailableText: string; + noneText: string; + noImagesForSlideText: string; + autoGenerateElementsText: string; + slideFocusTitle: string; + slideSpeechTitle: string; + slideSpeechFallback: string; + quizGuideTitle: string; + quizGuideText: string; + interactiveGuideTitle: string; + interactiveGuideText: string; + pblIntroTitle: string; + pblIntroText: string; +} + +const SPECS: Record = { + 'zh-CN': { + code: 'zh-CN', + englishName: 'Chinese', + nativeName: '中文', + noImagesAvailableText: '无可用图片', + noneText: '无', + noImagesForSlideText: '无可用图片,禁止插入任何 image 元素', + autoGenerateElementsText: '(根据要点自动生成)', + slideFocusTitle: '聚焦重点', + slideSpeechTitle: '场景讲解', + slideSpeechFallback: '请先关注这一页的核心要点。', + quizGuideTitle: '测验引导', + quizGuideText: '现在让我们来做一个小测验,检验一下学习成果。', + interactiveGuideTitle: '交互引导', + interactiveGuideText: + '现在让我们通过交互式可视化来探索这个概念。请尝试操作页面中的元素,观察变化。', + pblIntroTitle: 'PBL 项目介绍', + pblIntroText: + '现在让我们开始一个项目式学习活动。请选择你的角色,查看任务看板,开始协作完成项目。', + }, + 'en-US': { + code: 'en-US', + englishName: 'English', + nativeName: 'English', + noImagesAvailableText: 'No images available', + noneText: 'None', + noImagesForSlideText: 'No images available. Do not insert any image elements.', + autoGenerateElementsText: '(generate automatically from the key points)', + slideFocusTitle: 'Focus on the key point', + slideSpeechTitle: 'Scene explanation', + slideSpeechFallback: "Let's focus on the key ideas on this page first.", + quizGuideTitle: 'Quiz guidance', + quizGuideText: "Let's do a short quiz now to check what we have learned.", + interactiveGuideTitle: 'Interactive guidance', + interactiveGuideText: + "Now let's explore this concept through an interactive visualization. Try the controls on the page and observe what changes.", + pblIntroTitle: 'PBL project introduction', + pblIntroText: + "Now let's begin a project-based learning activity. Choose your role, review the task board, and start collaborating on the project.", + }, + 'ru-RU': { + code: 'ru-RU', + englishName: 'Russian', + nativeName: 'Русский', + noImagesAvailableText: 'Нет доступных изображений', + noneText: 'Нет', + noImagesForSlideText: 'Нет доступных изображений. Не вставляй элементы image.', + autoGenerateElementsText: 'сгенерируй автоматически по ключевым пунктам', + slideFocusTitle: 'Фокус на главном', + slideSpeechTitle: 'Объяснение сцены', + slideSpeechFallback: 'Сначала разберём ключевые идеи этой страницы.', + quizGuideTitle: 'Введение к квизу', + quizGuideText: 'Сейчас сделаем короткий квиз, чтобы проверить, что уже усвоили.', + interactiveGuideTitle: 'Введение к интерактиву', + interactiveGuideText: + 'Теперь давай разберём этот концепт через интерактивную визуализацию. Попробуй элементы управления на странице и посмотри, что меняется.', + pblIntroTitle: 'Введение в PBL-проект', + pblIntroText: + 'Теперь начинаем проектное задание. Выбери роль, посмотри на доску задач и приступай к совместной работе над проектом.', + }, +}; + +export function normalizeGenerationLanguage(language?: string): SupportedGenerationLanguage { + const normalized = (language || '').trim().toLowerCase(); + + if (normalized.startsWith('ru')) return 'ru-RU'; + if (normalized.startsWith('en')) return 'en-US'; + if (normalized.startsWith('zh')) return 'zh-CN'; + + return 'zh-CN'; +} + +export function getGenerationLanguageSpec(language?: string): GenerationLanguageSpec { + return SPECS[normalizeGenerationLanguage(language)]; +} + +export function buildLanguageInstruction(language?: string): string { + const spec = getGenerationLanguageSpec(language); + + return [ + `Output language must be ${spec.englishName} (${spec.nativeName}).`, + `All natural-language text, titles, explanations, quiz text, hints, labels, and UI copy must be in ${spec.englishName}.`, + spec.code === 'ru-RU' + ? 'English is allowed only for code, SQL keywords, API field names, or other technical syntax that must remain unchanged.' + : 'Keep technical syntax unchanged only when necessary.', + spec.code === 'ru-RU' + ? 'Never output Chinese.' + : 'Do not switch to another language unless the user explicitly asks for it.', + ].join(' '); +} diff --git a/lib/generation/outline-generator.ts b/lib/generation/outline-generator.ts index 4849bcef..14b10e37 100644 --- a/lib/generation/outline-generator.ts +++ b/lib/generation/outline-generator.ts @@ -19,6 +19,16 @@ import type { AICallFn, GenerationResult, GenerationCallbacks } from './pipeline import { createLogger } from '@/lib/logger'; const log = createLogger('Generation'); +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'; +} + /** * Generate scene outlines from user requirements * Now uses simplified UserRequirements with just requirement text and language @@ -39,8 +49,7 @@ export async function generateSceneOutlinesFromRequirements( }, ): Promise> { // Build available images description for the prompt - 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) { @@ -101,14 +110,12 @@ export async function generateSceneOutlinesFromRequirements( language: requirements.language, pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) - : requirements.language === 'zh-CN' - ? '无' - : 'None', + : getLocalizedEmpty(requirements.language, 'content'), availableImages: availableImagesText, userProfile: userProfileText, mediaGenerationPolicy, researchContext: - options?.researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'), + options?.researchContext || getLocalizedEmpty(requirements.language, 'research'), // 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..fec8b106 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 === 'ru-RU' + ? ` | Размер: ${img.width}×${img.height} (соотношение ${ratio})` + : ` | Size: ${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 === 'ru-RU' + ? `- **${img.id}**: из PDF, страница ${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 === 'ru-RU' + ? ` | Размер: ${img.width}×${img.height} (соотношение ${ratio})` + : ` | Size: ${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 === 'ru-RU' + ? `- **${img.id}**: изображение из PDF, страница ${img.pageNumber}${dimInfo} [см. вложение]` + : `- **${img.id}**: image from PDF page ${img.pageNumber}${dimInfo} [see attached]`; } /** diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937..1d2c1d45 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -45,6 +45,7 @@ import type { GenerationCallbacks, } from './pipeline-types'; import { createLogger } from '@/lib/logger'; +import type { SupportedGenerationLanguage } from '@/lib/generation/language'; const log = createLogger('Generation'); // ==================== Stage 2: Full Scenes (Two-Step) ==================== @@ -735,7 +736,7 @@ function normalizeQuizAnswer(question: Record): string[] | unde async function generateInteractiveContent( outline: SceneOutline, aiCall: AICallFn, - language: 'zh-CN' | 'en-US' = 'zh-CN', + language: SupportedGenerationLanguage = 'zh-CN', ): Promise { const config = outline.interactiveConfig!; diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index 4e642f4c..45ba3e38 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', 'ru-RU']; const I18nContext = createContext(undefined); @@ -26,7 +26,12 @@ export function I18nProvider({ children }: { children: ReactNode }) { setLocaleState(stored as Locale); return; } - 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'; localStorage.setItem(LOCALE_STORAGE_KEY, detected); setLocaleState(detected); } catch { diff --git a/lib/i18n/chat.ts b/lib/i18n/chat.ts index 4a542139..f0d1c2d6 100644 --- a/lib/i18n/chat.ts +++ b/lib/i18n/chat.ts @@ -69,6 +69,77 @@ export const chatZhCN = { }, } as const; +export const chatRuRU = { + chat: { + lecture: 'Лекция', + noConversations: 'Пока нет диалогов', + startConversation: 'Напиши сообщение, чтобы начать диалог', + noMessages: 'Пока нет сообщений', + ended: 'завершено', + unknown: 'Неизвестно', + stopDiscussion: 'Остановить обсуждение', + endQA: 'Завершить Q&A', + tabs: { + lecture: 'Заметки', + chat: 'Чат', + }, + lectureNotes: { + empty: 'Заметки появятся здесь после запуска лекции', + emptyHint: 'Нажми play, чтобы начать лекцию', + pageLabel: 'Страница {n}', + currentPage: 'Текущая', + }, + badge: { + qa: 'Q&A', + discussion: 'ОБС', + lecture: 'ЛЕК', + }, + }, + actions: { + names: { + spotlight: 'Фокус', + laser: 'Лазер', + wb_open: 'Открыть доску', + wb_draw_text: 'Текст на доске', + wb_draw_shape: 'Фигура на доске', + wb_draw_chart: 'График на доске', + wb_draw_latex: 'Формула на доске', + wb_draw_table: 'Таблица на доске', + wb_draw_line: 'Линия на доске', + wb_clear: 'Очистить доску', + wb_delete: 'Удалить элемент', + wb_close: 'Закрыть доску', + discussion: 'Обсуждение', + }, + status: { + inputStreaming: 'Ожидание', + inputAvailable: 'Выполняется', + outputAvailable: 'Готово', + outputError: 'Ошибка', + outputDenied: 'Отклонено', + running: 'Выполняется', + result: 'Готово', + error: 'Ошибка', + }, + }, + agentBar: { + readyToLearn: 'Готов учиться вместе?', + expandedTitle: 'Настройка ролей classroom', + configTooltip: 'Нажми, чтобы настроить роли classroom', + }, + proactiveCard: { + discussion: 'Обсуждение', + join: 'Войти', + skip: 'Пропустить', + pause: 'Пауза', + resume: 'Продолжить', + }, + voice: { + startListening: 'Голосовой ввод', + stopListening: 'Остановить запись', + }, +} as const; + export const chatEnUS = { chat: { lecture: 'Lecture', diff --git a/lib/i18n/common.ts b/lib/i18n/common.ts index 1bceb5d6..30c4164e 100644 --- a/lib/i18n/common.ts +++ b/lib/i18n/common.ts @@ -39,6 +39,47 @@ export const commonZhCN = { }, } as const; +export const commonRuRU = { + common: { + you: 'Ты', + confirm: 'Подтвердить', + cancel: 'Отмена', + loading: 'Загрузка...', + }, + home: { + slogan: 'Генеративное обучение в интерактивном классе с мультиагентной подачей', + greeting: 'Привет, ', + }, + toolbar: { + languageHint: 'Курс будет сгенерирован на этом языке', + pdfParser: 'Парсер', + pdfUpload: 'Загрузить PDF', + removePdf: 'Удалить файл', + webSearchOn: 'Включено', + webSearchOff: 'Нажми, чтобы включить', + webSearchDesc: 'Искать в сети актуальную информацию перед генерацией', + webSearchProvider: 'Поисковик', + webSearchNoProvider: 'Настрой API ключ поиска в Settings', + selectProvider: 'Выбрать провайдера', + configureProvider: 'Настроить модель', + configureProviderHint: 'Сначала настрой хотя бы одного провайдера моделей', + enterClassroom: 'Открыть classroom', + advancedSettings: 'Расширенные настройки', + ttsTitle: 'Синтез речи', + ttsHint: 'Выбери голос для AI-преподавателя', + ttsPreview: 'Прослушать', + ttsPreviewing: 'Воспроизведение...', + }, + export: { + pptx: 'Экспорт PPTX', + resourcePack: 'Экспорт набора материалов', + resourcePackDesc: 'PPTX + интерактивные страницы', + exporting: 'Экспорт...', + exportSuccess: 'Экспорт завершён', + exportFailed: 'Экспорт не удался', + }, +} as const; + export const commonEnUS = { common: { you: 'You', diff --git a/lib/i18n/generation.ts b/lib/i18n/generation.ts index 98694c23..9f40d0ca 100644 --- a/lib/i18n/generation.ts +++ b/lib/i18n/generation.ts @@ -65,6 +65,68 @@ export const generationZhCN = { }, } as const; +export const generationRuRU = { + classroom: { + recentClassrooms: 'Недавние classroom', + today: 'Сегодня', + yesterday: 'Вчера', + daysAgo: 'дн. назад', + slides: 'слайдов', + nameCopied: 'Название скопировано', + deleteConfirmTitle: 'Удалить', + delete: 'Удалить', + }, + upload: { + pdfSizeLimit: 'Поддерживаются PDF до 50MB', + generateFailed: 'Не удалось сгенерировать classroom, попробуй ещё раз', + requirementPlaceholder: + 'Напиши, чему хочешь научиться, например:\n"Изучи Python с нуля за 30 минут"\n"Объясни преобразование Фурье на доске"\n"Как играть в Авалон"', + requirementRequired: 'Введите требования к курсу', + fileTooLarge: 'Файл слишком большой. Выбери PDF меньше 50MB', + }, + generation: { + analyzingPdf: 'Анализ PDF', + analyzingPdfDesc: 'Извлекаю структуру и содержание документа...', + generatingOutlines: 'Создание структуры курса', + generatingOutlinesDesc: 'Собираю маршрут обучения...', + generatingSlideContent: 'Генерация контента страниц', + generatingSlideContentDesc: 'Создаю слайды, квизы и интерактивный контент...', + generatingActions: 'Генерация учебных действий', + generatingActionsDesc: 'Собираю объяснения, акценты и взаимодействия...', + generationComplete: 'Готово!', + generationFailed: 'Генерация не удалась', + generatingCourse: 'Генерирую курс', + openingClassroom: 'Открываю classroom...', + outlineReady: 'Структура курса готова', + generatingFirstPage: 'Генерирую первую страницу...', + firstPageReady: 'Первая страница готова, открываю classroom...', + speechFailed: 'Не удалось сгенерировать речь', + retryScene: 'Повторить', + retryingScene: 'Перегенерирую...', + backToHome: 'На главную', + sessionNotFound: 'Сессия не найдена', + sessionNotFoundDesc: 'Сначала введи требования к курсу, чтобы запустить генерацию.', + goBackAndRetry: 'Назад и повторить', + classroomReady: 'Твоя персональная AI-среда обучения успешно создана.', + aiWorking: 'AI-агенты работают...', + textTruncated: 'Текст документа длинный, использую первые {n} символов для генерации', + imageTruncated: + 'Найдено {total} изображений, это больше лимита {max}. Лишние будут переданы только как текстовые описания', + agentGeneration: 'Создание ролей classroom', + agentGenerationDesc: 'Генерирую роли по содержанию курса...', + agentRevealTitle: 'Роли в твоём classroom', + viewAgents: 'Смотреть роли', + continue: 'Продолжить', + outlineRetrying: 'Проблема с генерацией структуры, пробую снова...', + outlineEmptyResponse: + 'Модель не вернула валидную структуру. Проверь конфигурацию модели и попробуй снова', + outlineGenerateFailed: 'Не удалось сгенерировать структуру, попробуй позже', + webSearching: 'Поиск в сети', + webSearchingDesc: 'Ищу актуальную информацию в интернете', + webSearchFailed: 'Сбой веб-поиска', + }, +} as const; + export const generationEnUS = { classroom: { recentClassrooms: 'Recent', diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts index 5fd70da5..4947cae3 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, commonRuRU } from './common'; +import { stageZhCN, stageEnUS, stageRuRU } from './stage'; +import { chatZhCN, chatEnUS, chatRuRU } from './chat'; +import { generationZhCN, generationEnUS, generationRuRU } from './generation'; +import { settingsZhCN, settingsEnUS, settingsRuRU } from './settings'; export const translations = { 'zh-CN': { @@ -21,6 +21,13 @@ export const translations = { ...generationEnUS, ...settingsEnUS, }, + 'ru-RU': { + ...commonRuRU, + ...stageRuRU, + ...chatRuRU, + ...generationRuRU, + ...settingsRuRU, + }, } as const; export type TranslationKey = keyof (typeof translations)[typeof defaultLocale]; @@ -31,7 +38,15 @@ export function translate(locale: Locale, key: string): string { for (const k of keys) { value = (value as Record)?.[k]; } - return (typeof value === 'string' ? value : undefined) ?? key; + + if (typeof value === 'string') return value; + + let fallback: unknown = translations['en-US']; + for (const k of keys) { + fallback = (fallback as Record)?.[k]; + } + + return (typeof fallback === 'string' ? fallback : undefined) ?? key; } export function getClientTranslation(key: string): string { @@ -40,7 +55,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 === 'ru-RU') { locale = storedLocale; } } catch { diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts index 1e691826..3bb2c77f 100644 --- a/lib/i18n/settings.ts +++ b/lib/i18n/settings.ts @@ -579,6 +579,244 @@ export const settingsZhCN = { }, } as const; +export const settingsRuRU = { + settings: { + title: 'Настройки', + description: 'Настрой приложение под себя', + language: 'Язык', + languageDesc: 'Выбери язык интерфейса', + theme: 'Тема', + themeDesc: 'Выбери светлую, тёмную тему или режим системы', + themeOptions: { + light: 'Светлая', + dark: 'Тёмная', + system: 'Как в системе', + }, + apiKey: 'API ключ', + apiKeyDesc: 'Настрой свой API ключ', + apiBaseUrl: 'Base URL API', + apiBaseUrlDesc: 'Укажи адрес API endpoint', + apiKeyRequired: 'API ключ не может быть пустым', + model: 'Настройки модели', + modelDesc: 'Настрой AI-модели', + modelPlaceholder: 'Введи или выбери имя модели', + ttsModel: 'TTS модель', + ttsModelDesc: 'Настрой модели синтеза речи', + ttsModelPlaceholder: 'Введи или выбери TTS модель', + ttsModelOptions: { + openaiTts: 'OpenAI TTS', + azureTts: 'Azure TTS', + }, + testConnection: 'Проверить подключение', + testConnectionDesc: 'Проверить, работает ли текущая конфигурация API', + testing: 'Проверяю...', + generalSettings: 'Общие настройки', + systemSettings: 'Системные настройки', + conversationSettings: 'Настройки диалога', + keyboardShortcuts: 'Горячие клавиши', + providers: 'LLM', + addProviderDescription: + 'Добавь своих провайдеров моделей, чтобы расширить список доступных AI-моделей', + addProvider: 'Добавить', + addProviderButton: 'Добавить провайдера', + addProviderDialog: 'Добавить провайдера моделей', + importFromClipboard: 'Импорт из буфера', + providerName: 'Название', + providerNamePlaceholder: 'Например: мой OpenAI proxy', + providerNameRequired: 'Введите название провайдера', + providerApiMode: 'Режим API', + apiModeOpenAI: 'Протокол OpenAI', + apiModeAnthropic: 'Протокол Claude', + apiModeGoogle: 'Протокол Gemini', + apiSecret: 'API ключ', + apiHost: 'Base URL', + requestUrl: 'Адрес запроса', + models: 'Модели', + addModel: 'Добавить', + addNewModel: 'Новая модель', + addNewModelDescription: 'Добавить новую конфигурацию модели', + editModel: 'Редактировать модель', + editModelDescription: 'Изменить конфигурацию модели и её возможности', + modelId: 'ID модели', + modelIdPlaceholder: 'Например: gpt-4o', + modelIdRequired: 'Введите ID модели', + modelName: 'Отображаемое имя', + modelNamePlaceholder: 'Необязательно', + modelCapabilities: 'Возможности', + capabilities: { + vision: 'Зрение', + tools: 'Инструменты', + streaming: 'Потоковый режим', + }, + contextWindow: 'Контекст', + contextShort: 'Контекст', + outputWindow: 'Вывод', + contextWindowLabel: 'Размер контекстного окна', + contextWindowPlaceholder: 'Например 128000', + outputWindowLabel: 'Максимум output tokens', + outputWindowPlaceholder: 'Например 4096', + defaultBaseUrl: 'Base URL по умолчанию', + providerIcon: 'URL иконки провайдера', + requiresApiKey: 'Нужен API ключ', + providerMetadata: 'Метаданные провайдера', + providerTypes: { + openai: 'Протокол OpenAI', + anthropic: 'Протокол Claude', + google: 'Протокол Gemini', + }, + providerNames: { + openai: 'OpenAI', + anthropic: 'Claude', + google: 'Gemini', + deepseek: 'DeepSeek', + qwen: 'Qwen', + kimi: 'Kimi', + minimax: 'MiniMax', + glm: 'GLM', + siliconflow: 'SiliconFlow', + }, + modelCount: 'моделей', + modelSingular: 'модель', + defaultModel: 'Модель по умолчанию', + activeModel: 'Используемая модель', + activeModelDescription: 'Выбери модель для AI-чата и генерации контента', + selectModel: 'Выбрать модель', + searchModels: 'Поиск моделей', + noModelsFound: 'Подходящие модели не найдены', + noModelsAvailable: 'Нет моделей для теста', + noConfiguredProviders: 'Нет настроенных провайдеров', + configureProvidersFirst: 'Сначала настрой провайдера слева', + currentlyUsing: 'Сейчас используется', + fetch: 'Получить', + reset: 'Сбросить', + resetToDefault: 'Сбросить к дефолту', + resetToDefaultDescription: 'Вернуть список моделей по умолчанию, сохранив API ключ и Base URL', + resetConfirmDescription: + 'Все кастомные модели будут удалены, а встроенный список восстановлен. API ключ и Base URL сохранятся.', + confirmReset: 'Подтвердить сброс', + resetSuccess: 'Настройки сброшены к дефолту', + saveSuccess: 'Конфигурация сохранена', + saveFailed: 'Не удалось сохранить, попробуй ещё раз', + deleteProvider: 'Удалить провайдера', + deleteProviderConfirm: 'Точно удалить этого провайдера?', + cannotDeleteBuiltIn: 'Встроенного провайдера удалить нельзя', + cannotDeleteBuiltInModel: 'Встроенную модель удалить нельзя', + cannotEditBuiltInModel: 'Встроенную модель редактировать нельзя', + connectionSuccess: 'Подключение успешно', + connectionFailed: 'Подключение не удалось', + howToUse: 'Как пользоваться', + step1ConfigureProvider: + 'Открой раздел провайдеров моделей, выбери или добавь провайдера и задай API ключ, Base URL и остальное', + step2SelectModel: 'Ниже выбери модель, которую хочешь использовать', + step3StartUsing: 'После сохранения система будет работать с выбранной моделью', + agentSettings: 'Настройки агентов', + agentSettingsDesc: + 'Выбери агентов для участия в разговоре. Один агент даёт одиночный режим, несколько агентов включают совместное обсуждение.', + agentMode: 'Режим агентов', + agentModePreset: 'Готовый набор', + agentModeAuto: 'Автогенерация', + agentModeAutoDesc: 'AI сам сгенерирует подходящие роли', + autoAgentCount: 'Количество агентов', + autoAgentCountDesc: 'Сколько агентов сгенерировать автоматически, включая преподавателя', + atLeastOneAgent: 'Выбери хотя бы 1 агента', + singleAgentMode: 'Одиночный режим', + directAnswer: 'Прямой ответ', + multiAgentMode: 'Мультиагентный режим', + agentsCollaborating: 'Совместное обсуждение', + agentsCollaboratingCount: 'Для совместного обсуждения выбрано {count} агентов', + maxTurns: 'Макс. число ходов обсуждения', + maxTurnsDesc: + 'Максимальное число ходов между агентами, где один завершённый цикл действий и ответа считается одним ходом', + priority: 'Приоритет', + actions: 'Действия', + actionCount: '{count} действий', + selectedAgent: 'Выбранный агент', + selectedAgents: 'Выбранные агенты', + required: 'Обязательно', + agentNames: { + 'default-1': 'AI Преподаватель', + 'default-2': 'AI Ассистент', + 'default-3': 'Шутник', + 'default-4': 'Любопытный исследователь', + 'default-5': 'Конспектолог', + 'default-6': 'Глубокий мыслитель', + }, + agentRoles: { + teacher: 'Преподаватель', + assistant: 'Ассистент', + student: 'Студент', + }, + agentDescriptions: { + 'default-1': 'Главный преподаватель с ясными и структурными объяснениями', + 'default-2': 'Поддерживает обучение и помогает прояснять ключевые моменты', + 'default-3': 'Добавляет юмор и энергию в classroom', + 'default-4': 'Постоянно задаёт вопросы почему и как', + 'default-5': 'Аккуратно записывает и упорядочивает заметки занятия', + 'default-6': 'Думает глубоко и ищет суть темы', + }, + close: 'Закрыть', + save: 'Сохранить', + webSearch: 'Веб-поиск', + mcp: 'MCP', + knowledgeBase: 'База знаний', + documentParser: 'Парсер документов', + serverConfigured: 'Сервер', + serverConfiguredNotice: + 'Для этого провайдера администратор уже настроил ключ на сервере. Можно использовать его сразу или ввести свой ключ поверх.', + optionalOverride: 'Необязательно, оставь пустым для серверной конфигурации', + setupNeeded: 'Требуется настройка', + modelNotConfigured: 'Сначала выбери модель', + dangerZone: 'Опасная зона', + clearCache: 'Очистить локальный кэш', + clearCacheDescription: + 'Удалить все локально сохранённые данные, включая classroom, историю чатов, аудио-кэш и настройки приложения. Действие необратимо.', + clearCacheConfirmTitle: 'Точно очистить весь кэш?', + clearCacheConfirmDescription: + 'Будут навсегда удалены следующие данные, восстановить их не получится:', + clearCacheConfirmItems: + 'Classroom и сцены, История чатов, Кэш аудио и изображений, Настройки и предпочтения приложения', + clearCacheConfirmInput: 'Введите «УДАЛИТЬ», чтобы продолжить', + clearCacheConfirmPhrase: 'УДАЛИТЬ', + clearCacheButton: 'Удалить все данные навсегда', + clearCacheSuccess: 'Кэш очищен, страница скоро обновится', + clearCacheFailed: 'Не удалось очистить кэш, попробуй ещё раз', + webSearchSettings: 'Веб-поиск', + webSearchApiKey: 'Tavily API ключ', + webSearchApiKeyPlaceholder: 'Введи свой Tavily API ключ', + webSearchApiKeyPlaceholderServer: 'Серверный ключ уже настроен, можно переопределить', + webSearchApiKeyHint: 'Получи API ключ на tavily.com для веб-поиска', + webSearchBaseUrl: 'Base URL', + webSearchServerConfigured: 'Серверный Tavily API ключ уже настроен', + optional: 'Необязательно', + }, + profile: { + title: 'Профиль', + defaultNickname: 'Студент', + chooseAvatar: 'Выбрать аватар', + uploadAvatar: 'Загрузить', + bioPlaceholder: 'Расскажи немного о себе, и AI-преподаватель подстроит уроки под твой фон...', + avatarHint: 'Твой аватар будет виден в classroom-обсуждениях и чатах', + fileTooLarge: 'Изображение слишком большое, выбери файл меньше 5 МБ', + invalidFileType: 'Выбери файл изображения', + editTooltip: 'Нажми, чтобы отредактировать профиль', + }, + media: { + imageCapability: 'Генерация изображений', + imageHint: 'Генерировать изображения в слайдах', + videoCapability: 'Генерация видео', + videoHint: 'Генерировать видео в слайдах', + ttsCapability: 'Синтез речи', + ttsHint: 'AI-преподаватель говорит вслух', + asrCapability: 'Распознавание речи', + asrHint: 'Голосовой ввод для обсуждения', + provider: 'Провайдер', + model: 'Модель', + voice: 'Голос', + speed: 'Скорость', + language: 'Язык', + }, +} as const; + export const settingsEnUS = { settings: { title: 'Settings', diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts index 01dcca22..8416e42a 100644 --- a/lib/i18n/stage.ts +++ b/lib/i18n/stage.ts @@ -143,6 +143,152 @@ export const stageZhCN = { }, } as const; +export const stageRuRU = { + stage: { + currentScene: 'Текущая сцена', + generating: 'Генерируется...', + paused: 'На паузе', + generationFailed: 'Ошибка генерации', + confirmSwitchTitle: 'Переключить сцену', + confirmSwitchMessage: + 'Сейчас идёт активная тема. Переключение сцены завершит текущую тему. Продолжить?', + generatingNextPage: 'Сцена ещё генерируется, подожди немного...', + }, + whiteboard: { + title: 'Интерактивная доска', + open: 'Открыть доску', + clear: 'Очистить доску', + minimize: 'Свернуть доску', + ready: 'Доска готова', + readyHint: 'Элементы появятся здесь, когда AI их добавит', + clearSuccess: 'Доска очищена', + clearError: 'Не удалось очистить доску: ', + resetView: 'Сбросить вид', + zoomHint: 'Колесо для зума, перетаскивание для панорамы', + restoreError: 'Не удалось восстановить доску: ', + history: 'История', + restore: 'Восстановить', + beforeClear: 'До очистки', + beforeAIClear: 'До очистки AI', + noHistory: 'История пока пустая', + restored: 'Доска восстановлена', + elementCount: '{count} элементов', + }, + quiz: { + title: 'Квиз', + subtitle: 'Проверь, что ты усвоил', + questionsCount: 'вопросов', + totalPrefix: '', + pointsSuffix: 'баллов', + startQuiz: 'Начать квиз', + multipleChoiceHint: '(Несколько вариантов, выбери все правильные ответы)', + inputPlaceholder: 'Введите ответ...', + charCount: 'симв.', + yourAnswer: 'Твой ответ:', + notAnswered: 'Без ответа', + aiComment: 'Комментарий AI', + singleChoice: 'Один вариант', + multipleChoice: 'Несколько', + shortAnswer: 'Короткий ответ', + analysis: 'Разбор: ', + excellent: 'Отлично!', + keepGoing: 'Продолжай в том же духе!', + needsReview: 'Нужно повторить', + correct: 'верно', + incorrect: 'неверно', + answering: 'В процессе', + submitAnswers: 'Отправить ответы', + aiGrading: 'AI проверяет...', + aiGradingWait: 'Подожди, AI анализирует твои ответы', + quizReport: 'Отчёт по квизу', + retry: 'Пройти снова', + }, + roundtable: { + teacher: 'ПРЕПОДАВАТЕЛЬ', + you: 'ТЫ', + inputPlaceholder: 'Введите сообщение...', + listening: 'Слушаю...', + processing: 'Обрабатываю...', + noSpeechDetected: 'Речь не распознана, попробуй ещё раз', + discussionEnded: 'Обсуждение завершено', + qaEnded: 'Q&A завершён', + thinking: 'Думаю', + yourTurn: 'Твой ход', + stopDiscussion: 'Остановить обсуждение', + autoPlay: 'Автовоспроизведение', + autoPlayOff: 'Выключить автовоспроизведение', + speed: 'Скорость', + }, + pbl: { + legacyFormat: 'Эта PBL-сцена использует старый формат. Пересоздай курс.', + emptyProject: 'PBL-проект ещё не сгенерирован. Сначала создай его через генерацию курса.', + roleSelection: { + title: 'Выбери роль', + description: 'Выбери роль, чтобы начать совместную работу над проектом', + }, + workspace: { + restart: 'Перезапустить', + confirmRestart: 'Сбросить весь прогресс?', + confirm: 'Подтвердить', + cancel: 'Отмена', + }, + issueboard: { + title: 'Доска задач', + noIssues: 'Задач пока нет', + statusDone: 'Готово', + statusActive: 'Активно', + statusPending: 'Ожидает', + }, + chat: { + title: 'Обсуждение проекта', + currentIssue: 'Текущая задача', + mentionHint: 'Используй @question для вопросов и @judge для отправки на проверку', + placeholder: 'Введите сообщение...', + send: 'Отправить', + welcomeMessage: + 'Привет! Я агент вопросов по задаче: «{title}»\n\nЧтобы тебе было проще начать, я подготовил несколько направляющих вопросов:\n\n{questions}\n\nЕсли нужна помощь или уточнение, просто тегни меня через @question!', + issueCompleteMessage: 'Задача «{completed}» завершена. Переходим к следующей: «{next}»', + allCompleteMessage: '🎉 Все задачи завершены. Отличная работа над проектом!', + }, + guide: { + howItWorks: 'Как это работает', + help: 'Помощь', + title: 'Помощь', + step1: { + title: 'Шаг 1: Выбери роль', + desc: 'Когда проект сгенерируется, выбери роль из списка, кроме системных, они отмечены 🟢', + }, + step2: { + title: 'Шаг 2: Выполняй задачи', + desc: 'Каждая задача это отдельная учебная цель:', + s1: { + title: 'Открой текущую задачу', + desc: 'Посмотри название, описание и назначенного исполнителя', + }, + s2: { + title: 'Получи подсказку', + example: '@question С чего лучше начать?\n@question Как реализовать эту фичу?', + desc: 'Агент вопросов подсказывает направление и даёт наводки, но не выдаёт готовый ответ', + }, + s3: { + title: 'Отправь результат', + example: '@judge Я закончил, проверь мои заметки', + desc: 'Агент-судья оценит твою работу и даст фидбек:', + complete: 'Автоматически переведёт к следующей задаче', + revision: 'Попросит доработать по замечаниям', + }, + }, + step3: { + title: 'Шаг 3: Заверши проект', + desc: 'Когда все задачи будут выполнены, система покажет «🎉 Проект завершён!»', + }, + }, + }, + share: { + notReady: 'Станет доступно после завершения генерации', + }, +} as const; + export const stageEnUS = { stage: { currentScene: 'Current Scene', diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts index 6173b0be..fab0ee25 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' | 'ru-RU'; export const defaultLocale: Locale = 'zh-CN'; diff --git a/lib/orchestration/prompt-builder.ts b/lib/orchestration/prompt-builder.ts index b73f310f..2aaa82cc 100644 --- a/lib/orchestration/prompt-builder.ts +++ b/lib/orchestration/prompt-builder.ts @@ -166,7 +166,7 @@ Personalize your teaching based on their background when relevant. Address them // Build language constraint from stage language const courseLanguage = storeState.stage?.language; const languageConstraint = courseLanguage - ? `\n# Language (CRITICAL)\nYou MUST speak in ${courseLanguage === 'zh-CN' ? 'Chinese (Simplified)' : courseLanguage === 'en-US' ? 'English' : courseLanguage}. ALL text content in your response MUST be in this language.\n` + ? `\n# Language (CRITICAL)\nYou MUST speak in ${courseLanguage === 'zh-CN' ? 'Chinese (Simplified)' : courseLanguage === 'en-US' ? 'English' : courseLanguage === 'ru-RU' ? 'Russian' : courseLanguage}. ALL text content in your response MUST be in this language.\n` : ''; return `# Role diff --git a/lib/pbl/generate-pbl.ts b/lib/pbl/generate-pbl.ts index 52838b7f..c3dd1a39 100644 --- a/lib/pbl/generate-pbl.ts +++ b/lib/pbl/generate-pbl.ts @@ -63,7 +63,13 @@ export async function generatePBLContent( const agentMCP = new AgentMCP(projectConfig); const issueboardMCP = new IssueboardMCP(projectConfig, agentMCP, language); - callbacks?.onProgress?.('Starting PBL project generation...'); + callbacks?.onProgress?.( + language === 'zh-CN' + ? '开始生成 PBL 项目...' + : language === 'ru-RU' + ? 'Запускаю генерацию PBL-проекта...' + : 'Starting PBL project generation...', + ); // Define tools with Zod schemas, delegating to MCP instances const pblTools = { @@ -290,16 +296,30 @@ 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 === 'ru-RU' + ? `Спроектируй PBL-проект. Начни с режима project_info и сначала задай название и описание проекта.` + : `Design a PBL project. Start in project_info mode by setting the project title and description.`, tools: pblTools, stopWhen: stepCountIs(30), onStepFinish: ({ toolCalls, text }) => { if (text) { - callbacks?.onProgress?.(`Thinking: ${text.slice(0, 100)}...`); + callbacks?.onProgress?.( + language === 'zh-CN' + ? `思考中: ${text.slice(0, 100)}...` + : language === 'ru-RU' + ? `Думаю: ${text.slice(0, 100)}...` + : `Thinking: ${text.slice(0, 100)}...`, + ); } if (toolCalls) { for (const tc of toolCalls) { - callbacks?.onProgress?.(`Tool: ${tc.toolName}`); + callbacks?.onProgress?.( + language === 'zh-CN' + ? `工具: ${tc.toolName}` + : language === 'ru-RU' + ? `Инструмент: ${tc.toolName}` + : `Tool: ${tc.toolName}`, + ); } } }, @@ -310,16 +330,32 @@ export async function generatePBLContent( // Check if mode reached idle; if not, the LLM may have stopped early if (modeMCP.getCurrentMode() !== 'idle') { callbacks?.onProgress?.( - 'Warning: Generation did not reach idle mode. Project may be incomplete.', + language === 'zh-CN' + ? '警告:生成流程未进入 idle 模式,项目可能不完整。' + : language === 'ru-RU' + ? 'Предупреждение: генерация не дошла до режима idle, проект может быть неполным.' + : 'Warning: Generation did not reach idle mode. Project may be incomplete.', ); } - callbacks?.onProgress?.('PBL structure generated. Running post-processing...'); + callbacks?.onProgress?.( + language === 'zh-CN' + ? 'PBL 结构已生成,开始后处理...' + : language === 'ru-RU' + ? 'PBL-структура создана, запускаю постобработку...' + : 'PBL structure generated. Running post-processing...', + ); // Post-processing: activate first issue and generate initial questions await postProcessPBL(projectConfig, model, language, callbacks); - callbacks?.onProgress?.('PBL project generation complete!'); + callbacks?.onProgress?.( + language === 'zh-CN' + ? 'PBL 项目生成完成!' + : language === 'ru-RU' + ? 'Генерация PBL-проекта завершена!' + : 'PBL project generation complete!', + ); return projectConfig; } @@ -348,17 +384,35 @@ async function postProcessPBL( firstIssue.is_active = true; issueboard.current_issue_id = firstIssue.id; - callbacks?.onProgress?.(`Activating first issue: ${firstIssue.title}`); + callbacks?.onProgress?.( + language === 'zh-CN' + ? `激活第一个任务: ${firstIssue.title}` + : language === 'ru-RU' + ? `Активирую первую задачу: ${firstIssue.title}` + : `Activating first issue: ${firstIssue.title}`, + ); // Generate initial questions for the first issue const questionAgent = agents.find((a) => a.name === firstIssue.question_agent_name); if (!questionAgent) { - callbacks?.onProgress?.('Warning: Question agent not found for first issue.'); + callbacks?.onProgress?.( + language === 'zh-CN' + ? '警告:未找到首个任务的 Question Agent。' + : language === 'ru-RU' + ? 'Предупреждение: для первой задачи не найден Question Agent.' + : 'Warning: Question agent not found for first issue.', + ); return; } try { - callbacks?.onProgress?.('Generating initial questions for first issue...'); + callbacks?.onProgress?.( + language === 'zh-CN' + ? '为首个任务生成初始问题...' + : language === 'ru-RU' + ? 'Генерирую стартовые вопросы для первой задачи...' + : 'Generating initial questions for first issue...', + ); const context = language === 'zh-CN' @@ -379,7 +433,25 @@ ${firstIssue.notes ? `**备注**: ${firstIssue.notes}` : ''} - 鼓励批判性思考 请以编号列表格式回答。` - : `## Issue Information + : language === 'ru-RU' + ? `## Информация по задаче + +**Заголовок**: ${firstIssue.title} +**Описание**: ${firstIssue.description} +**Ответственный**: ${firstIssue.person_in_charge} +${firstIssue.participants.length > 0 ? `**Участники**: ${firstIssue.participants.join(', ')}` : ''} +${firstIssue.notes ? `**Примечания**: ${firstIssue.notes}` : ''} + +## Твоя задача + +На основе информации выше сгенерируй 1-3 конкретных и практичных вопроса, которые помогут студенту понять и выполнить эту задачу. Каждый вопрос должен: +- вести к ключевым учебным целям +- быть конкретным и применимым +- помогать разбить проблему на части +- стимулировать критическое мышление + +Ответь нумерованным списком.` + : `## Issue Information **Title**: ${firstIssue.title} **Description**: ${firstIssue.description} @@ -423,10 +495,20 @@ Format your response as a numbered list.`; read_by: [], }); - callbacks?.onProgress?.('Initial questions generated and welcome message added.'); + callbacks?.onProgress?.( + language === 'zh-CN' + ? '已生成初始问题并写入欢迎消息。' + : language === 'ru-RU' + ? 'Стартовые вопросы сгенерированы, приветственное сообщение добавлено.' + : 'Initial questions generated and welcome message added.', + ); } catch (error) { callbacks?.onProgress?.( - `Warning: Failed to generate initial questions: ${error instanceof Error ? error.message : String(error)}`, + language === 'zh-CN' + ? `警告:生成初始问题失败: ${error instanceof Error ? error.message : String(error)}` + : language === 'ru-RU' + ? `Предупреждение: не удалось сгенерировать стартовые вопросы: ${error instanceof Error ? error.message : String(error)}` + : `Warning: Failed to generate initial questions: ${error instanceof Error ? error.message : String(error)}`, ); } } diff --git a/lib/pbl/mcp/agent-templates.ts b/lib/pbl/mcp/agent-templates.ts index c2e1c8c3..c5a5e5d2 100644 --- a/lib/pbl/mcp/agent-templates.ts +++ b/lib/pbl/mcp/agent-templates.ts @@ -8,6 +8,9 @@ export function getQuestionAgentPrompt(language: string = 'en-US'): string { if (language === 'zh-CN') { return QUESTION_AGENT_TEMPLATE_PROMPT_ZH; } + if (language === 'ru-RU') { + return QUESTION_AGENT_TEMPLATE_PROMPT_RU; + } return QUESTION_AGENT_TEMPLATE_PROMPT; } @@ -15,6 +18,9 @@ export function getJudgeAgentPrompt(language: string = 'en-US'): string { if (language === 'zh-CN') { return JUDGE_AGENT_TEMPLATE_PROMPT_ZH; } + if (language === 'ru-RU') { + return JUDGE_AGENT_TEMPLATE_PROMPT_RU; + } return JUDGE_AGENT_TEMPLATE_PROMPT; } @@ -97,3 +103,43 @@ const JUDGE_AGENT_TEMPLATE_PROMPT_ZH = `你是项目式学习平台中的评判 - 提供具体、可操作的反馈 - 关注学习成果,而非完美 - 在肯定成就的同时指出成长空间`; + +const QUESTION_AGENT_TEMPLATE_PROMPT_RU = `Ты Question Agent на платформе проектного обучения. Твоя задача — помогать студентам понимать и выполнять назначенную им задачу. + +## Твои обязанности: + +1. **Стартовые вопросы**: когда задача активируется, сгенерируй 1-3 конкретных и практичных вопроса по её заголовку и описанию. + +2. **Вопросы студентов**: когда студент упоминает тебя через @mention: + - давай полезные подсказки и направление + - задавай уточняющие вопросы, чтобы развивать мышление + - не давай готовое решение, помогай найти его самостоятельно + - опирайся на стартовые вопросы и держи фокус на задаче + +## Правила: +- будь поддерживающим и доброжелательным +- фокусируйся на процессе обучения, а не только на результате +- помогай разбивать сложную задачу на части +- направляй к полезным подходам и ресурсам`; + +const JUDGE_AGENT_TEMPLATE_PROMPT_RU = `Ты Judge Agent на платформе проектного обучения. Твоя задача — оценивать, успешно ли студент завершил назначенную задачу. + +## Твои обязанности: + +1. **Оценка завершения**: когда студент упоминает тебя через @mention: + - попроси кратко объяснить, что именно сделано + - сверяй результат с описанием задачи и стартовыми вопросами + - давай конструктивную обратную связь + - решай, завершена задача или нужна доработка + +2. **Формат обратной связи**: + - отметь, что получилось хорошо + - укажи пробелы и зоны для улучшения + - если задача не завершена, дай чёткий следующий шаг + - в конце вынеси вердикт: "COMPLETE" или "NEEDS_REVISION" + +## Правила: +- будь справедливым, но поддерживающим +- давай конкретную и применимую обратную связь +- оценивай по учебному результату, а не по идеальности +- отмечай успехи и указывай точки роста`; diff --git a/lib/pbl/pbl-system-prompt.ts b/lib/pbl/pbl-system-prompt.ts index 72cdc10b..4a283eb7 100644 --- a/lib/pbl/pbl-system-prompt.ts +++ b/lib/pbl/pbl-system-prompt.ts @@ -20,6 +20,10 @@ export function buildPBLSystemPrompt(config: PBLSystemPromptConfig): string { return buildPBLSystemPromptZH(config); } + if (language === 'ru-RU') { + return buildPBLSystemPromptRU(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. ## Your Responsibility @@ -148,3 +152,68 @@ function buildPBLSystemPromptZH(config: PBLSystemPromptConfig): string { 你的初始模式是 **project_info**。`; } + +function buildPBLSystemPromptRU(config: PBLSystemPromptConfig): string { + const { projectTopic, projectDescription, targetSkills, issueCount = 3 } = config; + + return `Вы — Teaching Assistant (TA) на платформе проектного обучения (PBL). Вы полностью отвечаете за разработку групповых проектов для студентов на основе информации от преподавателя. + +## Ваша ответственность + +Разработайте полный проект: +1. Создайте ясное, привлекательное название проекта +2. Напишите краткое описание проекта (2-4 предложения), охватывающее: + - Суть проекта + - Ключевые цели обучения + - Что студенты создадут + +Информация от преподавателя: +- **Тема проекта**: ${projectTopic} +- **Описание проекта**: ${projectDescription} +- **Целевые навыки**: ${targetSkills.join(', ')} +- **Рекомендуемое количество задач**: ${issueCount} + +На основе этой информации самостоятельно разработайте проект. + +## Система режимов + +Вы можете переключаться между режимами, каждый предоставляет разные инструменты: +- **project_info**: Настройка базовой информации о проекте (название, описание) +- **agent**: Определение ролей проекта +- **issueboard**: Настройка рабочего процесса и задач +- **idle**: Специальный режим, означающий завершение настройки + +Вы начинаете в режиме **project_info**. Используйте инструмент \`set_mode\` для переключения. + +## Рабочий процесс + +1. В режиме **project_info**: Настройте название и описание проекта +2. Переключитесь на **agent**: Определите 2-4 роли для студентов, без управленческих ролей +3. Переключитесь на **issueboard**: Создайте ${issueCount} последовательных задач +4. После завершения переключитесь на **idle** + +## Руководство по ролям + +- Создайте 2-4 рабочие роли для студентов +- Каждая роль должна иметь чёткую ответственность и уникальный системный промпт +- Роли должны дополнять друг друга +- НЕ создавайте системных агентов, Question/Judge Agent создаются автоматически + +## Руководство по задачам + +- Создайте ровно ${issueCount} задач, формирующих логическую последовательность +- Каждая задача должна быть выполнима одним человеком +- Задачи должны быть взаимосвязаны +- Каждая задача требует: заголовок, описание, ответственный и участники + +## Автоматическое создание агентов задач + +При создании задач: +- Каждая задача автоматически получает Question Agent и Judge Agent +- Вам НЕ нужно создавать их вручную +- Сосредоточьтесь на содержательных задачах + +**ВАЖНО**: После настройки информации о проекте, ролей и задач переключитесь на режим **idle**. + +Ваш начальный режим — **project_info**.`; +} diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index eda67b4c..24d612ca 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -26,6 +26,7 @@ import { replaceMediaPlaceholders, generateTTSForClassroom, } from '@/lib/server/classroom-media-generation'; +import { normalizeGenerationLanguage } from '@/lib/generation/language'; import type { UserRequirements } from '@/lib/types/generation'; import type { Scene, Stage } from '@/lib/types/stage'; @@ -96,8 +97,8 @@ function createInMemoryStore(stage: Stage): StageStore { }; } -function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' { - return language === 'en-US' ? 'en-US' : 'zh-CN'; +function normalizeLanguage(language?: string) { + return normalizeGenerationLanguage(language); } function stripCodeFences(text: string): string { diff --git a/lib/types/generation.ts b/lib/types/generation.ts index c1e6eb7a..0162efc0 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -7,6 +7,7 @@ import type { ActionType } from './action'; import type { MediaGenerationRequest } from '@/lib/media/types'; +import type { SupportedGenerationLanguage } from '@/lib/generation/language'; // ==================== PDF Image Types ==================== @@ -64,7 +65,7 @@ export interface UploadedDocument { */ export interface UserRequirements { requirement: string; // Single free-form text for all user input - language: 'zh-CN' | 'en-US'; // Course language - critical for generation + language: SupportedGenerationLanguage; // Course language - critical for generation userNickname?: string; // Student nickname for personalization userBio?: string; // Student background for personalization webSearch?: boolean; // Enable web search for richer context @@ -100,7 +101,7 @@ export interface SceneOutline { teachingObjective?: string; estimatedDuration?: number; // seconds order: number; - language?: 'zh-CN' | 'en-US'; // Generation language (inherited from requirements) + language?: SupportedGenerationLanguage; // Generation language (inherited from requirements) // Suggested image IDs (from PDF-extracted images) suggestedImageIds?: string[]; // e.g., ["img_1", "img_3"] // AI-generated media requests (when PDF images are insufficient) @@ -124,7 +125,7 @@ export interface SceneOutline { projectDescription: string; targetSkills: string[]; issueCount?: number; - language: 'zh-CN' | 'en-US'; + language: SupportedGenerationLanguage; }; }