From 30bd2e3a56cd16c0ca7fa7808cd3b18ac161090b Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 15:28:34 +0800 Subject: [PATCH 01/16] refactor(i18n): migrate to i18next framework Replace hand-rolled i18n with i18next + react-i18next so that adding a new language only requires dropping a JSON file in lib/i18n/locales/. - Add i18next, react-i18next, i18next-browser-languagedetector deps - Generate zh-CN.json / en-US.json from existing TS translation modules - Rewrite lib/i18n/index.ts as a thin wrapper around i18n.t() - Rewrite use-i18n hook to delegate to useTranslation(); external API (locale, setLocale, t) is unchanged so consumers need no changes - SSR-safe: LanguageDetector only loaded on client side Closes #327 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/hooks/use-i18n.tsx | 36 +- lib/i18n/config.ts | 36 ++ lib/i18n/index.ts | 52 +-- lib/i18n/locales/en-US.json | 872 ++++++++++++++++++++++++++++++++++++ lib/i18n/locales/zh-CN.json | 872 ++++++++++++++++++++++++++++++++++++ package.json | 3 + pnpm-lock.yaml | 76 ++++ 7 files changed, 1873 insertions(+), 74 deletions(-) create mode 100644 lib/i18n/config.ts create mode 100644 lib/i18n/locales/en-US.json create mode 100644 lib/i18n/locales/zh-CN.json diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index 4e642f4c2..91697da22 100644 --- a/lib/hooks/use-i18n.tsx +++ b/lib/hooks/use-i18n.tsx @@ -1,7 +1,9 @@ 'use client'; -import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { Locale, translate, defaultLocale } from '@/lib/i18n'; +import { createContext, useContext, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { type Locale, defaultLocale } from '@/lib/i18n'; +import '@/lib/i18n/config'; type I18nContextType = { locale: Locale; @@ -9,39 +11,17 @@ type I18nContextType = { t: (key: string) => string; }; -const LOCALE_STORAGE_KEY = 'locale'; -const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US']; - const I18nContext = createContext(undefined); export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocaleState] = useState(defaultLocale); - - // Hydrate from localStorage after mount (avoids SSR mismatch) - /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */ - useEffect(() => { - try { - const stored = localStorage.getItem(LOCALE_STORAGE_KEY); - if (stored && VALID_LOCALES.includes(stored as Locale)) { - setLocaleState(stored as Locale); - return; - } - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; - localStorage.setItem(LOCALE_STORAGE_KEY, detected); - setLocaleState(detected); - } catch { - // localStorage unavailable, keep default - } - }, []); - /* eslint-enable react-hooks/set-state-in-effect */ + const { t, i18n } = useTranslation(); + + const locale = (i18n.language as Locale) || defaultLocale; const setLocale = (newLocale: Locale) => { - setLocaleState(newLocale); - localStorage.setItem(LOCALE_STORAGE_KEY, newLocale); + i18n.changeLanguage(newLocale); }; - const t = (key: string): string => translate(locale, key); - return {children}; } diff --git a/lib/i18n/config.ts b/lib/i18n/config.ts new file mode 100644 index 000000000..1c2050dfe --- /dev/null +++ b/lib/i18n/config.ts @@ -0,0 +1,36 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import zhCN from './locales/zh-CN.json'; +import enUS from './locales/en-US.json'; + +const isServer = typeof window === 'undefined'; + +const instance = i18n.use(initReactI18next); + +if (!isServer) { + instance.use(LanguageDetector); +} + +instance.init({ + resources: { + 'zh-CN': { translation: zhCN }, + 'en-US': { translation: enUS }, + }, + fallbackLng: 'zh-CN', + interpolation: { + escapeValue: false, + }, + ...(isServer + ? {} + : { + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'locale', + }, + }), +}); + +export default i18n; diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts index 5fd70da52..ab44752ca 100644 --- a/lib/i18n/index.ts +++ b/lib/i18n/index.ts @@ -1,52 +1,12 @@ -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'; - -export const translations = { - 'zh-CN': { - ...commonZhCN, - ...stageZhCN, - ...chatZhCN, - ...generationZhCN, - ...settingsZhCN, - }, - 'en-US': { - ...commonEnUS, - ...stageEnUS, - ...chatEnUS, - ...generationEnUS, - ...settingsEnUS, - }, -} as const; +import i18n from './config'; -export type TranslationKey = keyof (typeof translations)[typeof defaultLocale]; +export { type Locale, defaultLocale } from './types'; +export type TranslationKey = string; -export function translate(locale: Locale, key: string): string { - const keys = key.split('.'); - let value: unknown = translations[locale]; - for (const k of keys) { - value = (value as Record)?.[k]; - } - return (typeof value === 'string' ? value : undefined) ?? key; +export function translate(locale: string, key: string): string { + return i18n.t(key, { lng: locale }); } export function getClientTranslation(key: string): string { - let locale: Locale = defaultLocale; - - if (typeof window !== 'undefined') { - try { - const storedLocale = localStorage.getItem('locale'); - if (storedLocale === 'zh-CN' || storedLocale === 'en-US') { - locale = storedLocale; - } - } catch { - // localStorage unavailable, keep default locale - } - } - - return translate(locale, key); + return i18n.t(key); } diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json new file mode 100644 index 000000000..0a6a54562 --- /dev/null +++ b/lib/i18n/locales/en-US.json @@ -0,0 +1,872 @@ +{ + "common": { + "you": "You", + "confirm": "Confirm", + "cancel": "Cancel", + "loading": "Loading..." + }, + "home": { + "slogan": "Generative Learning in Multi-Agent Interactive Classroom", + "greeting": "Hi, " + }, + "toolbar": { + "languageHint": "Course will be generated in this language", + "pdfParser": "Parser", + "pdfUpload": "Upload PDF", + "removePdf": "Remove file", + "webSearchOn": "Enabled", + "webSearchOff": "Click to enable", + "webSearchDesc": "Search the web for up-to-date information before generation", + "webSearchProvider": "Search engine", + "webSearchNoProvider": "Configure search API key in Settings", + "selectProvider": "Select provider", + "configureProvider": "Set up model", + "configureProviderHint": "Configure at least one model provider to generate courses", + "enterClassroom": "Enter Classroom", + "advancedSettings": "Advanced Settings", + "ttsTitle": "Text-to-Speech", + "ttsHint": "Choose a voice for the AI teacher", + "ttsPreview": "Preview", + "ttsPreviewing": "Playing..." + }, + "export": { + "pptx": "Export PPTX", + "resourcePack": "Export Resource Pack", + "resourcePackDesc": "PPTX + interactive pages", + "exporting": "Exporting...", + "exportSuccess": "Export successful", + "exportFailed": "Export failed" + }, + "chat": { + "lecture": "Lecture", + "noConversations": "No conversations", + "startConversation": "Type a message below to begin chatting", + "noMessages": "No messages yet", + "ended": "ended", + "unknown": "Unknown", + "stopDiscussion": "Stop Discussion", + "endQA": "End Q&A", + "tabs": { + "lecture": "Notes", + "chat": "Chat" + }, + "lectureNotes": { + "empty": "Notes will appear here after lecture playback", + "emptyHint": "Press play to start the lecture", + "pageLabel": "Page {n}", + "currentPage": "Current" + }, + "badge": { + "qa": "Q&A", + "discussion": "DISC", + "lecture": "LEC" + } + }, + "actions": { + "names": { + "spotlight": "Spotlight", + "laser": "Laser", + "wb_open": "Open Whiteboard", + "wb_draw_text": "Whiteboard Text", + "wb_draw_shape": "Whiteboard Shape", + "wb_draw_chart": "Whiteboard Chart", + "wb_draw_latex": "Whiteboard Formula", + "wb_draw_table": "Whiteboard Table", + "wb_draw_line": "Whiteboard Line", + "wb_clear": "Clear Whiteboard", + "wb_delete": "Delete Element", + "wb_close": "Close Whiteboard", + "discussion": "Discussion" + }, + "status": { + "inputStreaming": "Waiting", + "inputAvailable": "Executing", + "outputAvailable": "Completed", + "outputError": "Error", + "outputDenied": "Denied", + "running": "Executing", + "result": "Completed", + "error": "Error" + } + }, + "agentBar": { + "readyToLearn": "Ready to learn together?", + "expandedTitle": "Classroom Role Config", + "configTooltip": "Click to configure classroom roles", + "voiceLabel": "Voice", + "voiceLoading": "Loading...", + "voiceAutoAssign": "Voices will be auto-assigned" + }, + "proactiveCard": { + "discussion": "Discussion", + "join": "Join", + "skip": "Skip", + "pause": "Pause", + "resume": "Resume" + }, + "voice": { + "startListening": "Voice input", + "stopListening": "Stop recording" + }, + "stage": { + "currentScene": "Current Scene", + "generating": "Generating...", + "paused": "Paused", + "generationFailed": "Generation failed", + "confirmSwitchTitle": "Switch Scene", + "confirmSwitchMessage": "A topic is currently in progress. Switching scenes will end the current topic. Are you sure?", + "generatingNextPage": "Scene is being generated, please wait...", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit Fullscreen" + }, + "whiteboard": { + "title": "Interactive Whiteboard", + "open": "Open Whiteboard", + "clear": "Clear Whiteboard", + "minimize": "Minimize Whiteboard", + "ready": "Whiteboard is ready", + "readyHint": "Elements will appear here when added by AI", + "clearSuccess": "Whiteboard cleared successfully", + "clearError": "Failed to clear whiteboard: ", + "resetView": "Reset View", + "restoreError": "Failed to restore whiteboard: ", + "history": "History", + "restore": "Restore", + "noHistory": "No history yet", + "restored": "Whiteboard restored", + "elementCount": "{count} elements" + }, + "quiz": { + "title": "Quiz", + "subtitle": "Test your knowledge", + "questionsCount": "questions", + "totalPrefix": "", + "pointsSuffix": "pts", + "startQuiz": "Start Quiz", + "multipleChoiceHint": "(Multiple choice — select all correct answers)", + "inputPlaceholder": "Type your answer here...", + "charCount": "chars", + "yourAnswer": "Your answer:", + "notAnswered": "Not answered", + "aiComment": "AI Feedback", + "singleChoice": "Single", + "multipleChoice": "Multiple", + "shortAnswer": "Short answer", + "analysis": "Analysis: ", + "excellent": "Excellent!", + "keepGoing": "Keep going!", + "needsReview": "Needs review", + "correct": "correct", + "incorrect": "incorrect", + "answering": "In Progress", + "submitAnswers": "Submit Answers", + "aiGrading": "AI is grading...", + "aiGradingWait": "Please wait, analyzing your answers", + "quizReport": "Quiz Report", + "retry": "Retry" + }, + "roundtable": { + "teacher": "TEACHER", + "you": "YOU", + "inputPlaceholder": "Type your message...", + "listening": "Listening...", + "processing": "Processing...", + "noSpeechDetected": "No speech detected, please try again", + "discussionEnded": "Discussion ended", + "qaEnded": "Q&A ended", + "thinking": "Thinking", + "yourTurn": "Your turn", + "stopDiscussion": "Stop Discussion", + "autoPlay": "Auto-play", + "autoPlayOff": "Stop auto-play", + "speed": "Speed", + "voiceInput": "Voice input", + "voiceInputDisabled": "Voice input disabled", + "textInput": "Text input", + "stopRecording": "Stop recording", + "startRecording": "Start recording" + }, + "pbl": { + "legacyFormat": "This PBL scene uses a legacy format. Please regenerate the course.", + "emptyProject": "PBL project has not been generated yet. Please create via course generation.", + "roleSelection": { + "title": "Choose Your Role", + "description": "Select a role to start collaborating on the project" + }, + "workspace": { + "restart": "Restart", + "confirmRestart": "Reset all progress?", + "confirm": "Confirm", + "cancel": "Cancel" + }, + "issueboard": { + "title": "Issue Board", + "noIssues": "No issues yet", + "statusDone": "Done", + "statusActive": "Active", + "statusPending": "Pending" + }, + "chat": { + "title": "Project Discussion", + "currentIssue": "Current Issue", + "mentionHint": "Use @question to ask, @judge to submit for review", + "placeholder": "Type a message...", + "send": "Send", + "welcomeMessage": "Hello! I'm your Question Agent for this issue: \"{title}\"\n\nTo help guide your work, I've prepared some questions for you:\n\n{questions}\n\nFeel free to @question me anytime if you need help or clarification!", + "issueCompleteMessage": "Issue \"{completed}\" completed! Moving to next issue: \"{next}\"", + "allCompleteMessage": "🎉 All issues completed! Great work on the project!" + }, + "guide": { + "howItWorks": "How it works", + "help": "Help", + "title": "Help", + "step1": { + "title": "Step 1: Choose a Role", + "desc": "After the project is generated, select a role from the list (non-system roles marked with 🟢)" + }, + "step2": { + "title": "Step 2: Complete Issues", + "desc": "Each issue represents a learning task:", + "s1": { + "title": "View current Issue", + "desc": "Check the issue's title, description, and assignee" + }, + "s2": { + "title": "Get guidance", + "example": "@question Where should I start?\n@question How do I implement this feature?", + "desc": "The Question Agent provides guiding questions and hints (no direct answers)" + }, + "s3": { + "title": "Submit your work", + "example": "@judge I'm done, please check my Notes", + "desc": "The Judge Agent evaluates your work and gives feedback:", + "complete": "Automatically moves to the next issue", + "revision": "Improve based on feedback" + } + }, + "step3": { + "title": "Step 3: Complete the Project", + "desc": "When all issues are done, the system displays \"🎉 Project Complete!\"" + } + } + }, + "share": { + "notReady": "Available after generation completes" + }, + "classroom": { + "recentClassrooms": "Recent", + "today": "Today", + "yesterday": "Yesterday", + "daysAgo": "days ago", + "slides": "slides", + "nameCopied": "Name copied", + "deleteConfirmTitle": "Delete", + "delete": "Delete" + }, + "upload": { + "pdfSizeLimit": "Supports PDF files 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\"", + "requirementRequired": "Please enter course requirements", + "fileTooLarge": "File too large. Please select a PDF file smaller than 50MB" + }, + "generation": { + "analyzingPdf": "Analyzing PDF Document", + "analyzingPdfDesc": "Extracting document structure and content...", + "pdfLoadFailed": "Failed to load PDF file, please try again", + "pdfParseFailed": "PDF parsing failed", + "streamNotReadable": "Unable to read generation stream", + "generatingOutlines": "Drafting Course Outline", + "generatingOutlinesDesc": "Structuring the learning path...", + "generatingSlideContent": "Generating Page Content", + "generatingSlideContentDesc": "Creating slides, quizzes, and interactive content...", + "generatingActions": "Generating Teaching Actions", + "generatingActionsDesc": "Orchestrating narration, spotlights, and interactions...", + "generationComplete": "Generation complete!", + "generationFailed": "Generation failed", + "generatingCourse": "Generating course", + "openingClassroom": "Opening classroom...", + "outlineReady": "Course outline generated", + "generatingFirstPage": "Generating first page...", + "firstPageReady": "First page ready! Opening classroom...", + "speechFailed": "Speech generation failed", + "retryScene": "Retry", + "retryingScene": "Regenerating...", + "backToHome": "Back to Home", + "sessionNotFound": "Session Not Found", + "sessionNotFoundDesc": "Please fill in course requirements to start the generation process.", + "goBackAndRetry": "Go Back and Retry", + "classroomReady": "Your personalized AI learning environment has been generated successfully.", + "aiWorking": "AI Agents Working...", + "textTruncated": "Document text is long, using first {n} characters for generation", + "imageTruncated": "{total} images found, exceeding the {max} image limit. Extra images will use text descriptions only", + "agentGeneration": "Generating Classroom Roles", + "agentGenerationDesc": "Generating roles based on course content...", + "agentRevealTitle": "Your Classroom Roles", + "viewAgents": "View Roles", + "continue": "Continue", + "outlineRetrying": "Outline generation issue, retrying...", + "outlineEmptyResponse": "Model returned no valid outlines. Please check model configuration and try again", + "outlineGenerateFailed": "Outline generation failed, please try again later", + "webSearching": "Web Search", + "webSearchingDesc": "Searching the web for up-to-date information", + "webSearchFailed": "Web search failed" + }, + "settings": { + "title": "Settings", + "description": "Configure application settings", + "language": "Language", + "languageDesc": "Select interface language", + "theme": "Theme", + "themeDesc": "Select theme mode (Light/Dark/System)", + "themeOptions": { + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "apiKey": "API Key", + "apiKeyDesc": "Configure your API key", + "apiBaseUrl": "API Endpoint URL", + "apiBaseUrlDesc": "Configure your API endpoint URL", + "apiKeyRequired": "API key cannot be empty", + "model": "Model Configuration", + "modelDesc": "Configure AI models", + "modelPlaceholder": "Enter or select model name", + "ttsModel": "TTS Model", + "ttsModelDesc": "Configure TTS models", + "ttsModelPlaceholder": "Enter or select TTS model name", + "ttsModelOptions": { + "openaiTts": "OpenAI TTS", + "azureTts": "Azure TTS" + }, + "testConnection": "Test Connection", + "testConnectionDesc": "Test current API configuration is available", + "testing": "Testing...", + "agentSettings": "Agent Settings", + "agentSettingsDesc": "Select the agents to participate in the conversation. Select 1 for single agent mode, select multiple for multi-agent collaborative mode.", + "agentMode": "Agent Mode", + "agentModePreset": "Preset", + "agentModeAuto": "Auto-generate", + "agentModeAutoDesc": "AI will automatically generate appropriate roles", + "autoAgentCount": "Agent Count", + "autoAgentCountDesc": "Number of agents to auto-generate (including teacher)", + "atLeastOneAgent": "Please select at least 1 agent", + "singleAgentMode": "Single Agent Mode", + "directAnswer": "Direct Answer", + "multiAgentMode": "Multi-Agent Mode", + "agentsCollaborating": "Collaborative Discussion", + "agentsCollaboratingCount": "{count} agents selected for collaborative discussion", + "maxTurns": "Max Discussion Turns", + "maxTurnsDesc": "The maximum number of discussion turns between agents (each agent completes actions and reply counts as one turn)", + "priority": "Priority", + "actions": "Actions", + "actionCount": "{count} actions", + "selectedAgent": "Selected Agent", + "selectedAgents": "Selected Agents", + "required": "Required", + "agentNames": { + "default-1": "AI Teacher", + "default-2": "AI Assistant", + "default-3": "Class Clown", + "default-4": "Curious Mind", + "default-5": "Note Taker", + "default-6": "Deep Thinker" + }, + "agentRoles": { + "teacher": "Teacher", + "assistant": "Assistant", + "student": "Student" + }, + "agentDescriptions": { + "default-1": "Lead teacher with clear and structured explanations", + "default-2": "Supports learning and helps clarify key points", + "default-3": "Brings humor and energy to the classroom", + "default-4": "Always curious, loves asking why and how", + "default-5": "Diligently records and organizes class notes", + "default-6": "Thinks deeply and explores the essence of topics" + }, + "close": "Close", + "save": "Save", + "providers": "LLM", + "addProviderDescription": "Add custom model providers to extend available AI models", + "providerNames": { + "openai": "OpenAI", + "anthropic": "Claude", + "google": "Gemini", + "deepseek": "DeepSeek", + "qwen": "Qwen", + "kimi": "Kimi", + "minimax": "MiniMax", + "glm": "GLM", + "siliconflow": "SiliconFlow" + }, + "providerTypes": { + "openai": "OpenAI Protocol", + "anthropic": "Claude Protocol", + "google": "Gemini Protocol" + }, + "modelCount": "models", + "modelSingular": "model", + "defaultModel": "Default Model", + "webSearch": "Web Search", + "mcp": "MCP", + "knowledgeBase": "Knowledge Base", + "documentParser": "Document Parser", + "conversationSettings": "Conversation", + "keyboardShortcuts": "Shortcuts", + "generalSettings": "General", + "systemSettings": "System", + "addProvider": "Add", + "importFromClipboard": "Import from Clipboard", + "apiSecret": "API Key", + "apiHost": "Base URL", + "requestUrl": "Request URL", + "models": "Models", + "addModel": "New", + "reset": "Reset", + "fetch": "Fetch", + "connectionSuccess": "Connection successful", + "connectionFailed": "Connection failed", + "capabilities": { + "vision": "Vision", + "tools": "Tools", + "streaming": "Streaming" + }, + "contextWindow": "Context", + "contextShort": "ctx", + "outputWindow": "Output", + "addProviderButton": "Add", + "addProviderDialog": "Add Model Provider", + "providerName": "Name", + "providerNamePlaceholder": "e.g., My OpenAI Proxy", + "providerNameRequired": "Please enter provider name", + "providerApiMode": "API Mode", + "apiModeOpenAI": "OpenAI Protocol", + "apiModeAnthropic": "Claude Protocol", + "apiModeGoogle": "Gemini Protocol", + "defaultBaseUrl": "Default Base URL", + "providerIcon": "Provider Icon URL", + "requiresApiKey": "Requires API Key", + "deleteProvider": "Delete Provider", + "deleteProviderConfirm": "Are you sure you want to delete this provider?", + "cannotDeleteBuiltIn": "Cannot delete built-in provider", + "resetToDefault": "Reset to Default", + "resetToDefaultDescription": "Restore model list to default configuration (API key and Base URL will be preserved)", + "resetConfirmDescription": "This will remove all custom models and restore the built-in default model list. API key and Base URL will be preserved.", + "confirmReset": "Confirm Reset", + "resetSuccess": "Successfully reset to default configuration", + "saveSuccess": "Settings saved", + "saveFailed": "Failed to save settings, please try again", + "cannotDeleteBuiltInModel": "Cannot delete built-in model", + "cannotEditBuiltInModel": "Cannot edit built-in model", + "modelIdRequired": "Please enter model ID", + "noModelsAvailable": "No models available for testing", + "providerMetadata": "Provider Metadata", + "editModel": "Edit Model", + "editModelDescription": "Edit model configuration and capabilities", + "addNewModel": "New Model", + "addNewModelDescription": "Add a new model configuration", + "modelId": "Model ID", + "modelIdPlaceholder": "e.g., gpt-4o", + "modelName": "Display Name", + "modelNamePlaceholder": "Optional", + "modelCapabilities": "Capabilities", + "advancedSettings": "Advanced Settings", + "contextWindowLabel": "Context Window", + "contextWindowPlaceholder": "e.g., 128000", + "outputWindowLabel": "Max Output Tokens", + "outputWindowPlaceholder": "e.g., 4096", + "testModel": "Test Model", + "deleteModel": "Delete", + "cancelEdit": "Cancel", + "saveModel": "Save", + "modelsManagementDescription": "Manage models for this provider. To select the active model, go to \"General\".", + "howToUse": "How to Use", + "step1ConfigureProvider": "Go to \"Model Providers\", select or add a provider, and configure connection settings (API key, Base URL, etc.)", + "step2SelectModel": "Select the model you want to use in \"Active Model\" below", + "step3StartUsing": "After saving, the system will use your selected model", + "activeModel": "Active Model", + "activeModelDescription": "Select the model for AI conversations and content generation", + "selectModel": "Select Model", + "searchModels": "Search models", + "noModelsFound": "No matching models found", + "noConfiguredProviders": "No configured providers", + "configureProvidersFirst": "Please configure provider connection settings in \"Model Providers\" on the left", + "currentlyUsing": "Currently using", + "ttsSettings": "Text-to-Speech", + "asrSettings": "Speech Recognition", + "audioSettings": "Audio Settings", + "ttsSection": "Text-to-Speech (TTS)", + "asrSection": "Automatic Speech Recognition (ASR)", + "ttsDescription": "TTS (Text-to-Speech) - Convert text to speech", + "asrDescription": "ASR (Automatic Speech Recognition) - Convert speech to text", + "enableTTS": "Enable Text-to-Speech", + "ttsEnabledDescription": "When enabled, speech audio will be generated during course creation", + "ttsVoiceConfigHint": "Per-agent voice can be configured in \"Classroom Role Config\" on the homepage", + "enableASR": "Enable Speech Recognition", + "asrEnabledDescription": "When enabled, students can use microphone for voice input", + "ttsProvider": "TTS Provider", + "ttsLanguageFilter": "Language Filter", + "allLanguages": "All Languages", + "ttsVoice": "Voice", + "ttsSpeed": "Speed", + "ttsBaseUrl": "Base URL", + "ttsApiKey": "API Key", + "doubaoAppId": "App ID", + "doubaoAccessKey": "Access Key", + "asrProvider": "ASR Provider", + "asrLanguage": "Recognition Language", + "asrBaseUrl": "Base URL", + "asrApiKey": "API Key", + "enterApiKey": "Enter API Key", + "enterCustomBaseUrl": "Enter custom Base URL", + "browserNativeNote": "Browser Native ASR requires no configuration and is completely free", + "providerOpenAITTS": "OpenAI TTS (gpt-4o-mini-tts)", + "providerAzureTTS": "Azure TTS", + "providerGLMTTS": "GLM TTS", + "providerQwenTTS": "Qwen TTS (Alibaba Cloud Bailian)", + "providerDoubaoTTS": "Doubao TTS 2.0 (Volcengine)", + "providerElevenLabsTTS": "ElevenLabs TTS", + "providerMiniMaxTTS": "MiniMax TTS", + "providerBrowserNativeTTS": "Browser Native TTS", + "providerOpenAIWhisper": "OpenAI ASR (gpt-4o-mini-transcribe)", + "providerBrowserNative": "Browser Native ASR", + "providerQwenASR": "Qwen ASR (Alibaba Cloud Bailian)", + "providerUnpdf": "unpdf (Built-in)", + "providerMinerU": "MinerU", + "browserNativeTTSNote": "Browser Native TTS requires no configuration and is completely free, using system built-in voices", + "testTTS": "Test TTS", + "testASR": "Test ASR", + "testSuccess": "Test Successful", + "testFailed": "Test Failed", + "ttsTestText": "TTS Test Text", + "ttsTestSuccess": "TTS test successful, audio played", + "ttsTestFailed": "TTS test failed", + "asrTestSuccess": "Speech recognition successful", + "asrTestFailed": "Speech recognition failed", + "asrResult": "Recognition Result", + "asrNotSupported": "Browser does not support Speech Recognition API", + "browserTTSNotSupported": "Browser does not support Speech Synthesis API", + "browserTTSNoVoices": "Current browser has no available TTS voices", + "microphoneAccessDenied": "Microphone access denied", + "microphoneAccessFailed": "Failed to access microphone", + "asrResultPlaceholder": "Recognition result will be displayed after recording", + "useThisProvider": "Use This Provider", + "fetchVoices": "Fetch Voice List", + "fetchingVoices": "Fetching...", + "voicesFetched": "Voices fetched", + "fetchVoicesFailed": "Failed to fetch voices", + "voiceApiKeyRequired": "API Key required", + "voiceBaseUrlRequired": "Base URL required", + "ttsTestTextPlaceholder": "Enter text to convert", + "ttsTestTextDefault": "Hello, this is a test speech.", + "startRecording": "Start Recording", + "stopRecording": "Stop Recording", + "recording": "Recording...", + "transcribing": "Transcribing...", + "transcriptionResult": "Transcription Result", + "noTranscriptionResult": "No transcription result", + "baseUrlOptional": "Base URL (Optional)", + "defaultValue": "Default", + "voiceMarin": "Recommended - Best Quality", + "voiceCedar": "Recommended - Best Quality", + "voiceAlloy": "Neutral, Balanced", + "voiceAsh": "Steady, Professional", + "voiceBallad": "Elegant, Lyrical", + "voiceCoral": "Warm, Friendly", + "voiceEcho": "Male, Clear", + "voiceFable": "Narrative, Vivid", + "voiceNova": "Female, Bright", + "voiceOnyx": "Male, Deep", + "voiceSage": "Wise, Composed", + "voiceShimmer": "Female, Soft", + "voiceVerse": "Natural, Smooth", + "glmVoiceTongtong": "Default voice", + "glmVoiceChuichui": "Chuichui voice", + "glmVoiceXiaochen": "Xiaochen voice", + "glmVoiceJam": "Jam voice", + "glmVoiceKazi": "Kazi voice", + "glmVoiceDouji": "Douji voice", + "glmVoiceLuodo": "Luodo voice", + "qwenVoiceCherry": "Sunny, warm and natural", + "qwenVoiceSerena": "Gentle and soft", + "qwenVoiceEthan": "Energetic and vibrant", + "qwenVoiceChelsie": "Anime virtual girlfriend", + "qwenVoiceMomo": "Playful and cheerful", + "qwenVoiceVivian": "Cute and sassy", + "qwenVoiceMoon": "Cool and handsome", + "qwenVoiceMaia": "Intellectual and gentle", + "qwenVoiceKai": "A SPA for your ears", + "qwenVoiceNofish": "Designer who can't pronounce retroflex sounds", + "qwenVoiceBella": "Little loli who doesn't get drunk", + "qwenVoiceJennifer": "Brand-level, cinematic American female voice", + "qwenVoiceRyan": "Fast-paced, dramatic performance", + "qwenVoiceKaterina": "Mature lady with memorable rhythm", + "qwenVoiceAiden": "American boy who masters cooking", + "qwenVoiceEldricSage": "Steady and wise elder", + "qwenVoiceMia": "Gentle as spring water, well-behaved as snow", + "qwenVoiceMochi": "Smart little adult with childlike innocence", + "qwenVoiceBellona": "Loud voice, clear pronunciation, vivid characters", + "qwenVoiceVincent": "Unique hoarse voice telling tales of war and honor", + "qwenVoiceBunny": "Super cute loli", + "qwenVoiceNeil": "Professional news anchor", + "qwenVoiceElias": "Professional instructor", + "qwenVoiceArthur": "Simple voice soaked by years and dry tobacco", + "qwenVoiceNini": "Soft and sticky voice like glutinous rice cake", + "qwenVoiceEbona": "Her whisper is like a rusty key", + "qwenVoiceSeren": "Gentle and soothing voice to help you sleep", + "qwenVoicePip": "Naughty but full of childlike innocence", + "qwenVoiceStella": "Sweet confused girl voice that becomes just when shouting", + "qwenVoiceBodega": "Enthusiastic Spanish uncle", + "qwenVoiceSonrisa": "Enthusiastic Latin American lady", + "qwenVoiceAlek": "Cold of battle nation, warm under woolen coat", + "qwenVoiceDolce": "Lazy Italian uncle", + "qwenVoiceSohee": "Gentle, cheerful Korean unnie", + "qwenVoiceOnoAnna": "Mischievous childhood friend", + "qwenVoiceLenn": "Rational German youth who wears suit and listens to post-punk", + "qwenVoiceEmilien": "Romantic French big brother", + "qwenVoiceAndre": "Magnetic, natural and calm male voice", + "qwenVoiceRadioGol": "Football poet Rádio Gol!", + "qwenVoiceJada": "Lively Shanghai lady", + "qwenVoiceDylan": "Beijing boy", + "qwenVoiceLi": "Patient yoga teacher", + "qwenVoiceMarcus": "Broad face, short words, solid heart - old Shaanxi taste", + "qwenVoiceRoy": "Humorous and straightforward Taiwanese boy", + "qwenVoicePeter": "Tianjin cross-talk professional supporter", + "qwenVoiceSunny": "Sweet Sichuan girl", + "qwenVoiceEric": "Chengdu gentleman", + "qwenVoiceRocky": "Humorous Hong Kong guy", + "qwenVoiceKiki": "Sweet Hong Kong girl", + "lang_auto": "Auto Detect", + "lang_zh": "中文", + "lang_yue": "粤語", + "lang_en": "English", + "lang_ja": "日本語", + "lang_ko": "한국어", + "lang_es": "Español", + "lang_fr": "Français", + "lang_de": "Deutsch", + "lang_ru": "Русский", + "lang_ar": "العربية", + "lang_pt": "Português", + "lang_it": "Italiano", + "lang_af": "Afrikaans", + "lang_hy": "Հայերեն", + "lang_az": "Azərbaycan", + "lang_be": "Беларуская", + "lang_bs": "Bosanski", + "lang_bg": "Български", + "lang_ca": "Català", + "lang_hr": "Hrvatski", + "lang_cs": "Čeština", + "lang_da": "Dansk", + "lang_nl": "Nederlands", + "lang_et": "Eesti", + "lang_fi": "Suomi", + "lang_gl": "Galego", + "lang_el": "Ελληνικά", + "lang_he": "עברית", + "lang_hi": "हिन्दी", + "lang_hu": "Magyar", + "lang_is": "Íslenska", + "lang_id": "Bahasa Indonesia", + "lang_kn": "ಕನ್ನಡ", + "lang_kk": "Қазақша", + "lang_lv": "Latviešu", + "lang_lt": "Lietuvių", + "lang_mk": "Македонски", + "lang_ms": "Bahasa Melayu", + "lang_mr": "मराठी", + "lang_mi": "Te Reo Māori", + "lang_ne": "नेपाली", + "lang_no": "Norsk", + "lang_fa": "فارسی", + "lang_pl": "Polski", + "lang_ro": "Română", + "lang_sr": "Српски", + "lang_sk": "Slovenčina", + "lang_sl": "Slovenščina", + "lang_sw": "Kiswahili", + "lang_sv": "Svenska", + "lang_tl": "Tagalog", + "lang_fil": "Filipino", + "lang_ta": "தமிழ்", + "lang_th": "ไทย", + "lang_tr": "Türkçe", + "lang_uk": "Українська", + "lang_ur": "اردو", + "lang_vi": "Tiếng Việt", + "lang_cy": "Cymraeg", + "lang_zh-CN": "中文(简体,中国)", + "lang_zh-TW": "中文(繁體,台灣)", + "lang_zh-HK": "粵語(香港)", + "lang_yue-Hant-HK": "粵語(繁體)", + "lang_en-US": "English (United States)", + "lang_en-GB": "English (United Kingdom)", + "lang_en-AU": "English (Australia)", + "lang_en-CA": "English (Canada)", + "lang_en-IN": "English (India)", + "lang_en-NZ": "English (New Zealand)", + "lang_en-ZA": "English (South Africa)", + "lang_ja-JP": "日本語(日本)", + "lang_ko-KR": "한국어(대한민국)", + "lang_de-DE": "Deutsch (Deutschland)", + "lang_fr-FR": "Français (France)", + "lang_es-ES": "Español (España)", + "lang_es-MX": "Español (México)", + "lang_es-AR": "Español (Argentina)", + "lang_es-CO": "Español (Colombia)", + "lang_it-IT": "Italiano (Italia)", + "lang_pt-BR": "Português (Brasil)", + "lang_pt-PT": "Português (Portugal)", + "lang_ru-RU": "Русский (Россия)", + "lang_nl-NL": "Nederlands (Nederland)", + "lang_pl-PL": "Polski (Polska)", + "lang_cs-CZ": "Čeština (Česko)", + "lang_da-DK": "Dansk (Danmark)", + "lang_fi-FI": "Suomi (Suomi)", + "lang_sv-SE": "Svenska (Sverige)", + "lang_no-NO": "Norsk (Norge)", + "lang_tr-TR": "Türkçe (Türkiye)", + "lang_el-GR": "Ελληνικά (Ελλάδα)", + "lang_hu-HU": "Magyar (Magyarország)", + "lang_ro-RO": "Română (România)", + "lang_sk-SK": "Slovenčina (Slovensko)", + "lang_bg-BG": "Български (България)", + "lang_hr-HR": "Hrvatski (Hrvatska)", + "lang_ca-ES": "Català (Espanya)", + "lang_ar-SA": "العربية (السعودية)", + "lang_ar-EG": "العربية (مصر)", + "lang_he-IL": "עברית (ישראל)", + "lang_hi-IN": "हिन्दी (भारत)", + "lang_th-TH": "ไทย (ประเทศไทย)", + "lang_vi-VN": "Tiếng Việt (Việt Nam)", + "lang_id-ID": "Bahasa Indonesia (Indonesia)", + "lang_ms-MY": "Bahasa Melayu (Malaysia)", + "lang_fil-PH": "Filipino (Pilipinas)", + "lang_af-ZA": "Afrikaans (Suid-Afrika)", + "lang_uk-UA": "Українська (Україна)", + "pdfSettings": "PDF Parsing", + "pdfParsingSettings": "PDF Parsing Settings", + "pdfDescription": "Choose PDF parsing engine with support for text extraction, image processing, and table recognition", + "pdfProvider": "PDF Parser", + "pdfFeatures": "Supported Features", + "pdfApiKey": "API Key", + "pdfBaseUrl": "Base URL", + "mineruDescription": "MinerU is a commercial PDF parsing service that supports advanced features such as table extraction, formula recognition, and layout analysis.", + "mineruApiKeyRequired": "You need to apply for an API Key on the MinerU website before use.", + "mineruWarning": "Warning", + "mineruCostWarning": "MinerU is a commercial service and may incur fees. Please check the MinerU website for pricing details.", + "enterMinerUApiKey": "Enter MinerU API Key", + "mineruLocalDescription": "MinerU supports local deployment with advanced PDF parsing (tables, formulas, layout analysis). Requires deploying MinerU service first.", + "mineruServerAddress": "Local MinerU server address (e.g., http://localhost:8080)", + "mineruApiKeyOptional": "Only required if server has authentication enabled", + "optionalApiKey": "Optional API Key", + "featureText": "Text Extraction", + "featureImages": "Image Extraction", + "featureTables": "Table Extraction", + "featureFormulas": "Formula Recognition", + "featureLayoutAnalysis": "Layout Analysis", + "featureMetadata": "Metadata", + "enableImageGeneration": "Enable AI Image Generation", + "imageGenerationDisabledHint": "When enabled, images will be auto-generated during course creation", + "imageSettings": "Image Generation", + "imageSection": "Text to Image", + "imageProvider": "Image Generation Provider", + "imageModel": "Image Generation Model", + "providerSeedream": "Seedream (ByteDance)", + "providerQwenImage": "Qwen Image (Alibaba)", + "providerNanoBanana": "Nano Banana (Gemini)", + "providerMiniMaxImage": "MiniMax Image", + "providerGrokImage": "Grok Image (xAI)", + "testImageGeneration": "Test Image Generation", + "testImageConnectivity": "Test Connection", + "imageConnectivitySuccess": "Image service connected successfully", + "imageConnectivityFailed": "Image service connection failed", + "imageTestSuccess": "Image generation test succeeded", + "imageTestFailed": "Image generation test failed", + "imageTestPromptPlaceholder": "Enter image description to test", + "imageTestPromptDefault": "A cute cat sitting on a desk", + "imageGenerating": "Generating image...", + "imageGenerationFailed": "Image generation failed", + "enableVideoGeneration": "Enable AI Video Generation", + "videoGenerationDisabledHint": "When enabled, videos will be auto-generated during course creation", + "videoSettings": "Video Generation", + "videoSection": "Text to Video", + "videoProvider": "Video Generation Provider", + "videoModel": "Video Generation Model", + "providerSeedance": "Seedance (ByteDance)", + "providerKling": "Kling (Kuaishou)", + "providerVeo": "Veo (Google)", + "providerSora": "Sora (OpenAI)", + "providerMiniMaxVideo": "MiniMax Video", + "providerGrokVideo": "Grok Video (xAI)", + "testVideoGeneration": "Test Video Generation", + "testVideoConnectivity": "Test Connection", + "videoConnectivitySuccess": "Video service connected successfully", + "videoConnectivityFailed": "Video service connection failed", + "testingConnection": "Testing...", + "videoTestSuccess": "Video generation test succeeded", + "videoTestFailed": "Video generation test failed", + "videoTestPromptDefault": "A cute cat walking on a desk", + "videoGenerating": "Generating video (est. 1-2 min)...", + "videoGenerationWarning": "Video generation usually takes 1-2 minutes, please be patient", + "mediaRetry": "Retry", + "mediaContentSensitive": "Sorry, this content triggered a safety check.", + "mediaGenerationDisabled": "Generation disabled in settings", + "singleAgent": "Single Agent", + "multiAgent": "Multi-Agent", + "selectAgents": "Select Agents", + "noVisionWarning": "Current model does not support vision. Images can still be placed in slides, but the model cannot understand image content to optimize selection and layout", + "serverConfigured": "Server", + "serverConfiguredNotice": "Admin has configured an API key for this provider on the server. You can use it directly or enter your own key to override.", + "optionalOverride": "Optional — leave empty to use server config", + "setupNeeded": "Setup required", + "modelNotConfigured": "Please select a model to get started", + "dangerZone": "Danger Zone", + "clearCache": "Clear Local Cache", + "clearCacheDescription": "Delete all locally stored data, including classroom records, chat history, audio cache, and app settings. This action cannot be undone.", + "clearCacheConfirmTitle": "Are you sure you want to clear all cache?", + "clearCacheConfirmDescription": "This will permanently delete all of the following data and cannot be recovered:", + "clearCacheConfirmItems": "Classrooms & scenes, Chat history, Audio & image cache, App settings & preferences", + "clearCacheConfirmInput": "Type \"DELETE\" to continue", + "clearCacheConfirmPhrase": "DELETE", + "clearCacheButton": "Permanently Delete All Data", + "clearCacheSuccess": "Cache cleared, page will refresh shortly", + "clearCacheFailed": "Failed to clear cache, please try again", + "webSearchSettings": "Web Search", + "webSearchApiKey": "Tavily API Key", + "webSearchApiKeyPlaceholder": "Enter your Tavily API Key", + "webSearchApiKeyPlaceholderServer": "Server key configured, optionally override", + "webSearchApiKeyHint": "Get an API key from tavily.com for web search", + "webSearchBaseUrl": "Base URL", + "webSearchServerConfigured": "Server-side Tavily API key is configured", + "optional": "Optional" + }, + "profile": { + "title": "Profile", + "defaultNickname": "Student", + "chooseAvatar": "Choose Avatar", + "uploadAvatar": "Upload", + "bioPlaceholder": "Tell us about yourself — the AI teacher will personalize lessons for you...", + "avatarHint": "Your avatar will appear in classroom discussions and chats", + "fileTooLarge": "Image too large — please choose one under 5 MB", + "invalidFileType": "Please select an image file", + "editTooltip": "Click to edit profile" + }, + "media": { + "imageCapability": "Image Generation", + "imageHint": "Generate images in slides", + "videoCapability": "Video Generation", + "videoHint": "Generate videos in slides", + "ttsCapability": "Text-to-Speech", + "ttsHint": "AI teacher speaks aloud", + "asrCapability": "Speech Recognition", + "asrHint": "Voice input for discussion", + "provider": "Provider", + "model": "Model", + "voice": "Voice", + "speed": "Speed", + "language": "Language" + } +} \ No newline at end of file diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json new file mode 100644 index 000000000..9438133f3 --- /dev/null +++ b/lib/i18n/locales/zh-CN.json @@ -0,0 +1,872 @@ +{ + "common": { + "you": "你", + "confirm": "确定", + "cancel": "取消", + "loading": "加载中..." + }, + "home": { + "slogan": "Generative Learning in Multi-Agent Interactive Classroom", + "greeting": "嗨," + }, + "toolbar": { + "languageHint": "课程将以此语言生成", + "pdfParser": "解析器", + "pdfUpload": "上传 PDF", + "removePdf": "移除文件", + "webSearchOn": "已开启", + "webSearchOff": "点击开启", + "webSearchDesc": "生成前搜索网络获取最新资料,让内容更丰富准确", + "webSearchProvider": "搜索引擎", + "webSearchNoProvider": "请在设置中配置搜索引擎 API Key", + "selectProvider": "选择模型服务商", + "configureProvider": "配置模型", + "configureProviderHint": "请先配置至少一个模型服务商才能生成课程", + "enterClassroom": "进入课堂", + "advancedSettings": "高级设置", + "ttsTitle": "语音合成", + "ttsHint": "选择 AI 教师的朗读音色", + "ttsPreview": "试听", + "ttsPreviewing": "播放中..." + }, + "export": { + "pptx": "导出 PPTX", + "resourcePack": "导出教学资源包", + "resourcePackDesc": "PPTX + 交互式页面", + "exporting": "正在导出...", + "exportSuccess": "导出成功", + "exportFailed": "导出失败" + }, + "chat": { + "lecture": "授课", + "noConversations": "暂无对话", + "startConversation": "输入消息开始对话", + "noMessages": "暂无消息", + "ended": "已结束", + "unknown": "未知", + "stopDiscussion": "结束讨论", + "endQA": "结束问答", + "tabs": { + "lecture": "笔记", + "chat": "对话" + }, + "lectureNotes": { + "empty": "播放课程后,笔记将在此显示", + "emptyHint": "点击播放按钮开始授课", + "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": "课堂角色配置", + "configTooltip": "点击配置课堂角色", + "voiceLabel": "音色", + "voiceLoading": "加载中...", + "voiceAutoAssign": "音色将自动分配" + }, + "proactiveCard": { + "discussion": "讨论", + "join": "加入讨论", + "skip": "跳过", + "pause": "暂停", + "resume": "继续" + }, + "voice": { + "startListening": "语音输入", + "stopListening": "停止录音" + }, + "stage": { + "currentScene": "当前场景", + "generating": "生成中...", + "paused": "已暂停", + "generationFailed": "生成失败", + "confirmSwitchTitle": "切换页面", + "confirmSwitchMessage": "当前话题正在进行中,切换页面将结束当前话题。确定要切换吗?", + "generatingNextPage": "场景正在生成,请稍候...", + "fullscreen": "全屏", + "exitFullscreen": "退出全屏" + }, + "whiteboard": { + "title": "互动白板", + "open": "打开白板", + "clear": "清空白板", + "minimize": "最小化白板", + "ready": "白板已就绪", + "readyHint": "AI 添加元素后将在此显示", + "clearSuccess": "白板已清空", + "clearError": "清空白板失败:", + "resetView": "重置视图", + "restoreError": "恢复白板失败:", + "history": "历史记录", + "restore": "恢复", + "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": "请稍候,正在分析你的答案", + "quizReport": "答题报告", + "retry": "重新答题" + }, + "roundtable": { + "teacher": "教师", + "you": "你", + "inputPlaceholder": "输入你的消息...", + "listening": "录音中...", + "processing": "处理中...", + "noSpeechDetected": "未检测到语音,请重试", + "discussionEnded": "讨论已结束", + "qaEnded": "问答已结束", + "thinking": "思考中", + "yourTurn": "轮到你发言了", + "stopDiscussion": "结束讨论", + "autoPlay": "自动播放", + "autoPlayOff": "关闭自动播放", + "speed": "倍速", + "voiceInput": "语音输入", + "voiceInputDisabled": "语音输入已禁用", + "textInput": "文字输入", + "stopRecording": "停止录音", + "startRecording": "开始录音" + }, + "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": "第一步:选择角色", + "desc": "项目生成后,从角色列表中选择一个角色(标记为🟢的非系统角色)" + }, + "step2": { + "title": "第二步:完成任务", + "desc": "每个任务代表一个学习目标:", + "s1": { + "title": "查看当前任务", + "desc": "查看任务的标题、描述、负责人" + }, + "s2": { + "title": "获取指导", + "example": "@question 我应该从哪里开始?\n@question 如何实现这个功能?", + "desc": "提问助手会提供引导性问题和提示(不直接给答案)" + }, + "s3": { + "title": "提交作品", + "example": "@judge 我已经完成了,请检查", + "desc": "评审助手会评估你的工作并给出反馈:", + "complete": "自动进入下一个任务", + "revision": "根据反馈改进" + } + }, + "step3": { + "title": "第三步:完成项目", + "desc": "所有任务完成后,系统会显示「🎉 项目已完成!」" + } + } + }, + "share": { + "notReady": "生成完成后可分享" + }, + "classroom": { + "recentClassrooms": "最近学习", + "today": "今天", + "yesterday": "昨天", + "daysAgo": "天前", + "slides": "页", + "nameCopied": "课堂名称已复制", + "deleteConfirmTitle": "删除课堂", + "delete": "删除" + }, + "upload": { + "pdfSizeLimit": "支持最大50MB的PDF文件", + "generateFailed": "生成课堂失败,请重试", + "requirementPlaceholder": "输入你想学的任何内容,例如:\n「从零学 Python,30 分钟写出第一个程序」\n「用白板给我讲解傅里叶变换」\n「阿瓦隆桌游怎么玩」", + "requirementRequired": "请输入课程需求", + "fileTooLarge": "文件过大,请选择小于50MB的PDF文件" + }, + "generation": { + "analyzingPdf": "解析 PDF 文档", + "analyzingPdfDesc": "正在提取文档结构和内容...", + "pdfLoadFailed": "无法加载 PDF 文件,请重试", + "pdfParseFailed": "PDF 解析失败", + "streamNotReadable": "无法读取生成数据流", + "generatingOutlines": "生成课程大纲", + "generatingOutlinesDesc": "正在构建学习路径...", + "generatingSlideContent": "生成页面内容", + "generatingSlideContentDesc": "正在创建幻灯片、测验和互动内容...", + "generatingActions": "生成教学动作", + "generatingActionsDesc": "正在编排讲解、聚焦和互动流程...", + "generationComplete": "生成完成!", + "generationFailed": "生成失败", + "generatingCourse": "正在生成课程", + "openingClassroom": "即将打开课堂...", + "outlineReady": "课程大纲已生成", + "generatingFirstPage": "首页内容生成中...", + "firstPageReady": "首页已就绪!正在打开课堂...", + "speechFailed": "语音合成失败", + "retryScene": "重试生成", + "retryingScene": "正在重新生成...", + "backToHome": "返回首页", + "sessionNotFound": "未找到生成会话", + "sessionNotFoundDesc": "请先填写课程需求开始生成流程。", + "goBackAndRetry": "返回重试", + "classroomReady": "你的个性化AI学习环境已成功生成。", + "aiWorking": "AI智能体工作中...", + "textTruncated": "文档文本较长,已截取前 {n} 字符用于生成", + "imageTruncated": "文档含 {total} 张图片,超出上限 {max} 张,多余图片将仅以文字描述传递", + "agentGeneration": "生成课堂角色", + "agentGenerationDesc": "正在根据课程内容生成角色...", + "agentRevealTitle": "你的课堂角色", + "viewAgents": "查看角色", + "continue": "继续", + "outlineRetrying": "大纲生成异常,正在重试...", + "outlineEmptyResponse": "模型未返回有效的大纲内容,请检查模型配置后重试", + "outlineGenerateFailed": "大纲生成失败,请稍后重试", + "webSearching": "网络搜索", + "webSearchingDesc": "正在搜索网络获取最新资料", + "webSearchFailed": "网络搜索失败" + }, + "settings": { + "title": "设置", + "description": "配置应用程序设置", + "language": "语言", + "languageDesc": "选择界面语言", + "theme": "主题", + "themeDesc": "选择主题模式(浅色/深色/跟随系统)", + "themeOptions": { + "light": "浅色", + "dark": "深色", + "system": "跟随系统" + }, + "apiKey": "API密钥", + "apiKeyDesc": "配置你的API密钥", + "apiBaseUrl": "API端点地址", + "apiBaseUrlDesc": "配置你的API端点地址", + "apiKeyRequired": "API密钥不能为空", + "model": "模型配置", + "modelDesc": "配置AI模型", + "modelPlaceholder": "输入或选择模型名称", + "ttsModel": "TTS模型", + "ttsModelDesc": "配置TTS模型", + "ttsModelPlaceholder": "输入或选择TTS模型名称", + "ttsModelOptions": { + "openaiTts": "OpenAI TTS", + "azureTts": "Azure TTS" + }, + "testConnection": "测试连接", + "testConnectionDesc": "测试当前API配置是否可用", + "testing": "测试中...", + "agentSettings": "智能体设置", + "agentSettingsDesc": "选择参与对话的智能体。选择1个为单智能体模式,选择多个为多智能体协作模式。", + "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": "活跃气氛,用幽默让课堂更有趣", + "default-4": "充满好奇心,总爱追问为什么", + "default-5": "认真记录,整理课堂重点笔记", + "default-6": "深入思考,喜欢探讨问题本质" + }, + "close": "关闭", + "save": "保存", + "providers": "语言模型", + "addProviderDescription": "添加自定义模型提供方以扩展可用的AI模型", + "providerNames": { + "openai": "OpenAI", + "anthropic": "Claude", + "google": "Gemini", + "deepseek": "DeepSeek", + "qwen": "通义千问", + "kimi": "Kimi", + "minimax": "MiniMax", + "glm": "GLM", + "siliconflow": "硅基流动" + }, + "providerTypes": { + "openai": "OpenAI 协议", + "anthropic": "Claude 协议", + "google": "Gemini 协议" + }, + "modelCount": "个模型", + "modelSingular": "个模型", + "defaultModel": "默认模型", + "webSearch": "联网搜索", + "mcp": "MCP", + "knowledgeBase": "知识库", + "documentParser": "文档解析器", + "conversationSettings": "对话设置", + "keyboardShortcuts": "键盘快捷键", + "generalSettings": "常规设置", + "systemSettings": "系统设置", + "addProvider": "添加", + "importFromClipboard": "从剪贴板导入", + "apiSecret": "API 密钥", + "apiHost": "Base URL", + "requestUrl": "请求地址", + "models": "模型", + "addModel": "新建", + "reset": "重置", + "fetch": "获取", + "connectionSuccess": "连接成功", + "connectionFailed": "连接失败", + "capabilities": { + "vision": "视觉", + "tools": "工具", + "streaming": "流式" + }, + "contextWindow": "上下文", + "contextShort": "上下文", + "outputWindow": "输出", + "addProviderButton": "添加", + "addProviderDialog": "添加模型提供方", + "providerName": "名称", + "providerNamePlaceholder": "例如:我的OpenAI代理", + "providerNameRequired": "请输入提供方名称", + "providerApiMode": "API 模式", + "apiModeOpenAI": "OpenAI 协议", + "apiModeAnthropic": "Claude 协议", + "apiModeGoogle": "Gemini 协议", + "defaultBaseUrl": "默认 Base URL", + "providerIcon": "Provider 图标 URL", + "requiresApiKey": "需要 API 密钥", + "deleteProvider": "删除提供方", + "deleteProviderConfirm": "确定要删除此提供方吗?", + "cannotDeleteBuiltIn": "无法删除内置提供方", + "resetToDefault": "重置为默认配置", + "resetToDefaultDescription": "将模型列表恢复到默认状态(保留 API 密钥和 Base URL)", + "resetConfirmDescription": "此操作将清除所有自定义模型,恢复到内置的默认模型列表。API 密钥和 Base URL 将被保留。", + "confirmReset": "确认重置", + "resetSuccess": "已成功重置为默认配置", + "saveSuccess": "配置已保存", + "saveFailed": "保存失败,请重试", + "cannotDeleteBuiltInModel": "无法删除内置模型", + "cannotEditBuiltInModel": "无法编辑内置模型", + "modelIdRequired": "请输入模型 ID", + "noModelsAvailable": "没有可用于测试的模型", + "providerMetadata": "Provider 元数据", + "editModel": "编辑模型", + "editModelDescription": "编辑模型配置和能力", + "addNewModel": "新建模型", + "addNewModelDescription": "添加新的模型配置", + "modelId": "模型ID", + "modelIdPlaceholder": "例如:gpt-4o", + "modelName": "显示名称", + "modelNamePlaceholder": "可选", + "modelCapabilities": "能力", + "advancedSettings": "高级设置", + "contextWindowLabel": "上下文窗口", + "contextWindowPlaceholder": "例如 128000", + "outputWindowLabel": "最大输出Token数", + "outputWindowPlaceholder": "例如 4096", + "testModel": "测试模型", + "deleteModel": "删除", + "cancelEdit": "取消", + "saveModel": "保存", + "modelsManagementDescription": "在此管理该提供方的模型列表。若需选择使用的模型,请前往\"常规设置\"。", + "howToUse": "使用说明", + "step1ConfigureProvider": "前往\"模型提供方\"页面,选择或添加一个提供方,配置连接信息(API 密钥、Base URL 等)", + "step2SelectModel": "在下方\"使用模型\"中选择要使用的模型", + "step3StartUsing": "保存设置后,系统将使用您选择的模型", + "activeModel": "使用模型", + "activeModelDescription": "选择当前用于 AI 对话和内容生成的模型", + "selectModel": "选择模型", + "searchModels": "搜索模型", + "noModelsFound": "未找到匹配的模型", + "noConfiguredProviders": "暂无已配置的提供方", + "configureProvidersFirst": "请先在左侧\"模型提供方\"中配置提供方连接信息", + "currentlyUsing": "当前使用", + "ttsSettings": "语音合成", + "asrSettings": "语音识别", + "audioSettings": "音频设置", + "ttsSection": "文字转语音 (TTS)", + "asrSection": "语音识别 (ASR)", + "ttsDescription": "TTS (Text-to-Speech) - 将文字转换为语音", + "asrDescription": "ASR (Automatic Speech Recognition) - 将语音转换为文字", + "enableTTS": "启用语音合成", + "ttsEnabledDescription": "开启后,课程生成时将自动合成语音", + "ttsVoiceConfigHint": "每个 Agent 的音色可在首页「课堂角色配置」中设置", + "enableASR": "启用语音识别", + "asrEnabledDescription": "开启后,学生可使用麦克风进行语音输入", + "ttsProvider": "TTS 提供商", + "ttsLanguageFilter": "语言筛选", + "allLanguages": "全部语言", + "ttsVoice": "音色", + "ttsSpeed": "语速", + "ttsBaseUrl": "Base URL", + "ttsApiKey": "API 密钥", + "doubaoAppId": "App ID", + "doubaoAccessKey": "Access Key", + "asrProvider": "ASR 提供商", + "asrLanguage": "识别语言", + "asrBaseUrl": "Base URL", + "asrApiKey": "API 密钥", + "enterApiKey": "输入 API Key", + "enterCustomBaseUrl": "输入自定义 Base URL", + "browserNativeNote": "浏览器原生 ASR 无需配置,完全免费", + "providerOpenAITTS": "OpenAI TTS (gpt-4o-mini-tts)", + "providerAzureTTS": "Azure TTS", + "providerGLMTTS": "GLM TTS", + "providerQwenTTS": "Qwen TTS(阿里云百炼)", + "providerDoubaoTTS": "豆包 TTS 2.0(火山引擎)", + "providerElevenLabsTTS": "ElevenLabs TTS", + "providerMiniMaxTTS": "MiniMax TTS", + "providerBrowserNativeTTS": "浏览器原生 TTS", + "providerOpenAIWhisper": "OpenAI ASR (gpt-4o-mini-transcribe)", + "providerBrowserNative": "浏览器原生 ASR", + "providerQwenASR": "Qwen ASR(阿里云百炼)", + "providerUnpdf": "unpdf(内置)", + "providerMinerU": "MinerU", + "browserNativeTTSNote": "浏览器原生 TTS 无需配置,完全免费,使用系统内置语音", + "testTTS": "测试 TTS", + "testASR": "测试 ASR", + "testSuccess": "测试成功", + "testFailed": "测试失败", + "ttsTestText": "TTS 测试文本", + "ttsTestSuccess": "TTS 测试成功,音频已播放", + "ttsTestFailed": "TTS 测试失败", + "asrTestSuccess": "语音识别成功", + "asrTestFailed": "语音识别失败", + "asrResult": "识别结果", + "asrNotSupported": "浏览器不支持语音识别 API", + "browserTTSNotSupported": "浏览器不支持语音合成 API", + "browserTTSNoVoices": "当前浏览器没有可用的 TTS voice", + "microphoneAccessDenied": "麦克风访问被拒绝", + "microphoneAccessFailed": "无法访问麦克风", + "asrResultPlaceholder": "录音后将显示识别结果", + "useThisProvider": "使用此提供商", + "fetchVoices": "获取音色列表", + "fetchingVoices": "获取中...", + "voicesFetched": "已获取音色", + "fetchVoicesFailed": "获取音色失败", + "voiceApiKeyRequired": "需要 API 密钥", + "voiceBaseUrlRequired": "需要 Base URL", + "ttsTestTextPlaceholder": "输入要转换的文本", + "ttsTestTextDefault": "你好,这是一段测试语音。", + "startRecording": "开始录音", + "stopRecording": "停止录音", + "recording": "录音中...", + "transcribing": "识别中...", + "transcriptionResult": "识别结果", + "noTranscriptionResult": "无识别结果", + "baseUrlOptional": "Base URL(可选)", + "defaultValue": "默认", + "voiceMarin": "推荐 - 最佳质量", + "voiceCedar": "推荐 - 最佳质量", + "voiceAlloy": "中性、平衡", + "voiceAsh": "沉稳、专业", + "voiceBallad": "优雅、抒情", + "voiceCoral": "温暖、友好", + "voiceEcho": "男性、清晰", + "voiceFable": "叙事、生动", + "voiceNova": "女性、明亮", + "voiceOnyx": "男性、深沉", + "voiceSage": "智慧、沉着", + "voiceShimmer": "女性、柔和", + "voiceVerse": "自然、流畅", + "glmVoiceTongtong": "默认音色", + "glmVoiceChuichui": "锤锤音色", + "glmVoiceXiaochen": "小陈音色", + "glmVoiceJam": "动动动物圈jam音色", + "glmVoiceKazi": "动动动物圈kazi音色", + "glmVoiceDouji": "动动动物圈douji音色", + "glmVoiceLuodo": "动动动物圈luodo音色", + "qwenVoiceCherry": "阳光积极、亲切自然小姐姐", + "qwenVoiceSerena": "温柔小姐姐", + "qwenVoiceEthan": "阳光、温暖、活力、朝气", + "qwenVoiceChelsie": "二次元虚拟女友", + "qwenVoiceMomo": "撒娇搞怪,逗你开心", + "qwenVoiceVivian": "拽拽的、可爱的小暴躁", + "qwenVoiceMoon": "率性帅气", + "qwenVoiceMaia": "知性与温柔的碰撞", + "qwenVoiceKai": "耳朵的一场SPA", + "qwenVoiceNofish": "不会翘舌音的设计师", + "qwenVoiceBella": "喝酒不打醉拳的小萝莉", + "qwenVoiceJennifer": "品牌级、电影质感般美语女声", + "qwenVoiceRyan": "节奏拉满,戏感炸裂,真实与张力共舞", + "qwenVoiceKaterina": "御姐音色,韵律回味十足", + "qwenVoiceAiden": "精通厨艺的美语大男孩", + "qwenVoiceEldricSage": "沉稳睿智的老者,沧桑如松却心明如镜", + "qwenVoiceMia": "温顺如春水,乖巧如初雪", + "qwenVoiceMochi": "聪明伶俐的小大人,童真未泯却早慧如禅", + "qwenVoiceBellona": "声音洪亮,吐字清晰,人物鲜活,听得人热血沸腾", + "qwenVoiceVincent": "一口独特的沙哑烟嗓,一开口便道尽了千军万马与江湖豪情", + "qwenVoiceBunny": "\"萌属性\"爆棚的小萝莉", + "qwenVoiceNeil": "专业新闻主持人", + "qwenVoiceElias": "专业讲师音色", + "qwenVoiceArthur": "被岁月和旱烟浸泡过的质朴嗓音", + "qwenVoiceNini": "糯米糍一样又软又黏的嗓音,那一声声拉长了的\"哥哥\"", + "qwenVoiceEbona": "她的低语像一把生锈的钥匙,缓慢转动你内心最深处的幽暗角落", + "qwenVoiceSeren": "温和舒缓的声线,助你更快地进入睡眠", + "qwenVoicePip": "调皮捣蛋却充满童真的他来了", + "qwenVoiceStella": "平时是甜到发腻的迷糊少女音,但在喊出\"代表月亮消灭你\"时,瞬间充满不容置疑的爱与正义", + "qwenVoiceBodega": "热情的西班牙大叔", + "qwenVoiceSonrisa": "热情开朗的拉美大姐", + "qwenVoiceAlek": "一开口,是战斗民族的冷,也是毛呢大衣下的暖", + "qwenVoiceDolce": "慵懒的意大利大叔", + "qwenVoiceSohee": "温柔开朗,情绪丰富的韩国欧尼", + "qwenVoiceOnoAnna": "鬼灵精怪的青梅竹马", + "qwenVoiceLenn": "理性是底色,叛逆藏在细节里——穿西装也听后朋克的德国青年", + "qwenVoiceEmilien": "浪漫的法国大哥哥", + "qwenVoiceAndre": "声音磁性,自然舒服、沉稳男生", + "qwenVoiceRadioGol": "足球诗人Rádio Gol!今天我要用名字为你们解说足球", + "qwenVoiceJada": "风风火火的沪上阿姐", + "qwenVoiceDylan": "北京胡同里长大的少年", + "qwenVoiceLi": "耐心的瑜伽老师", + "qwenVoiceMarcus": "面宽话短,心实声沉——老陕的味道", + "qwenVoiceRoy": "诙谐直爽、市井活泼的台湾哥仔形象", + "qwenVoicePeter": "天津相声,专业捧哏", + "qwenVoiceSunny": "甜到你心里的川妹子", + "qwenVoiceEric": "跳脱市井的成都男子", + "qwenVoiceRocky": "幽默风趣的阿强", + "qwenVoiceKiki": "甜美的港妹闺蜜", + "lang_auto": "自动检测", + "lang_zh": "中文", + "lang_yue": "粤語", + "lang_en": "English", + "lang_ja": "日本語", + "lang_ko": "한국어", + "lang_es": "Español", + "lang_fr": "Français", + "lang_de": "Deutsch", + "lang_ru": "Русский", + "lang_ar": "العربية", + "lang_pt": "Português", + "lang_it": "Italiano", + "lang_af": "Afrikaans", + "lang_hy": "Հայերեն", + "lang_az": "Azərbaycan", + "lang_be": "Беларуская", + "lang_bs": "Bosanski", + "lang_bg": "Български", + "lang_ca": "Català", + "lang_hr": "Hrvatski", + "lang_cs": "Čeština", + "lang_da": "Dansk", + "lang_nl": "Nederlands", + "lang_et": "Eesti", + "lang_fi": "Suomi", + "lang_gl": "Galego", + "lang_el": "Ελληνικά", + "lang_he": "עברית", + "lang_hi": "हिन्दी", + "lang_hu": "Magyar", + "lang_is": "Íslenska", + "lang_id": "Bahasa Indonesia", + "lang_kn": "ಕನ್ನಡ", + "lang_kk": "Қазақша", + "lang_lv": "Latviešu", + "lang_lt": "Lietuvių", + "lang_mk": "Македонски", + "lang_ms": "Bahasa Melayu", + "lang_mr": "मराठी", + "lang_mi": "Te Reo Māori", + "lang_ne": "नेपाली", + "lang_no": "Norsk", + "lang_fa": "فارسی", + "lang_pl": "Polski", + "lang_ro": "Română", + "lang_sr": "Српски", + "lang_sk": "Slovenčina", + "lang_sl": "Slovenščina", + "lang_sw": "Kiswahili", + "lang_sv": "Svenska", + "lang_tl": "Tagalog", + "lang_fil": "Filipino", + "lang_ta": "தமிழ்", + "lang_th": "ไทย", + "lang_tr": "Türkçe", + "lang_uk": "Українська", + "lang_ur": "اردو", + "lang_vi": "Tiếng Việt", + "lang_cy": "Cymraeg", + "lang_zh-CN": "中文(简体,中国)", + "lang_zh-TW": "中文(繁體,台灣)", + "lang_zh-HK": "粵語(香港)", + "lang_yue-Hant-HK": "粵語(繁體)", + "lang_en-US": "English (United States)", + "lang_en-GB": "English (United Kingdom)", + "lang_en-AU": "English (Australia)", + "lang_en-CA": "English (Canada)", + "lang_en-IN": "English (India)", + "lang_en-NZ": "English (New Zealand)", + "lang_en-ZA": "English (South Africa)", + "lang_ja-JP": "日本語(日本)", + "lang_ko-KR": "한국어(대한민국)", + "lang_de-DE": "Deutsch (Deutschland)", + "lang_fr-FR": "Français (France)", + "lang_es-ES": "Español (España)", + "lang_es-MX": "Español (México)", + "lang_es-AR": "Español (Argentina)", + "lang_es-CO": "Español (Colombia)", + "lang_it-IT": "Italiano (Italia)", + "lang_pt-BR": "Português (Brasil)", + "lang_pt-PT": "Português (Portugal)", + "lang_ru-RU": "Русский (Россия)", + "lang_nl-NL": "Nederlands (Nederland)", + "lang_pl-PL": "Polski (Polska)", + "lang_cs-CZ": "Čeština (Česko)", + "lang_da-DK": "Dansk (Danmark)", + "lang_fi-FI": "Suomi (Suomi)", + "lang_sv-SE": "Svenska (Sverige)", + "lang_no-NO": "Norsk (Norge)", + "lang_tr-TR": "Türkçe (Türkiye)", + "lang_el-GR": "Ελληνικά (Ελλάδα)", + "lang_hu-HU": "Magyar (Magyarország)", + "lang_ro-RO": "Română (România)", + "lang_sk-SK": "Slovenčina (Slovensko)", + "lang_bg-BG": "Български (България)", + "lang_hr-HR": "Hrvatski (Hrvatska)", + "lang_ca-ES": "Català (Espanya)", + "lang_ar-SA": "العربية (السعودية)", + "lang_ar-EG": "العربية (مصر)", + "lang_he-IL": "עברית (ישראל)", + "lang_hi-IN": "हिन्दी (भारत)", + "lang_th-TH": "ไทย (ประเทศไทย)", + "lang_vi-VN": "Tiếng Việt (Việt Nam)", + "lang_id-ID": "Bahasa Indonesia (Indonesia)", + "lang_ms-MY": "Bahasa Melayu (Malaysia)", + "lang_fil-PH": "Filipino (Pilipinas)", + "lang_af-ZA": "Afrikaans (Suid-Afrika)", + "lang_uk-UA": "Українська (Україна)", + "pdfSettings": "PDF 解析", + "pdfParsingSettings": "PDF 解析设置", + "pdfDescription": "选择 PDF 解析引擎,支持文本提取、图片处理和表格识别", + "pdfProvider": "PDF 解析器", + "pdfFeatures": "支持功能", + "pdfApiKey": "API Key", + "pdfBaseUrl": "Base URL", + "mineruDescription": "MinerU 是一个商用 PDF 解析服务,支持高级功能如表格提取、公式识别和布局分析。", + "mineruApiKeyRequired": "使用前需要在 MinerU 官网申请 API Key。", + "mineruWarning": "注意", + "mineruCostWarning": "MinerU 为商用服务,使用可能产生费用。请查看 MinerU 官网了解定价详情。", + "enterMinerUApiKey": "输入 MinerU API Key", + "mineruLocalDescription": "MinerU 支持本地部署,提供高级 PDF 解析功能(表格、公式、布局分析)。需要先部署 MinerU 服务。", + "mineruServerAddress": "本地 MinerU 服务器地址(如:http://localhost:8080)", + "mineruApiKeyOptional": "仅在服务器启用认证时需要", + "optionalApiKey": "可选的 API Key", + "featureText": "文本提取", + "featureImages": "图片提取", + "featureTables": "表格提取", + "featureFormulas": "公式识别", + "featureLayoutAnalysis": "布局分析", + "featureMetadata": "元数据", + "enableImageGeneration": "启用 AI 图片生成", + "imageGenerationDisabledHint": "启用后,课程生成时将自动生成配图", + "imageSettings": "图像生成", + "imageSection": "文生图", + "imageProvider": "图像生成提供商", + "imageModel": "图像生成模型", + "providerSeedream": "Seedream(字节豆包)", + "providerQwenImage": "Qwen Image(阿里通义)", + "providerNanoBanana": "Nano Banana(Gemini)", + "providerMiniMaxImage": "MiniMax 图像", + "providerGrokImage": "Grok Image(xAI)", + "testImageGeneration": "测试图像生成", + "testImageConnectivity": "测试连接", + "imageConnectivitySuccess": "图像服务连接成功", + "imageConnectivityFailed": "图像服务连接失败", + "imageTestSuccess": "图像生成测试成功", + "imageTestFailed": "图像生成测试失败", + "imageTestPromptPlaceholder": "输入图像描述进行测试", + "imageTestPromptDefault": "一只可爱的猫咪坐在书桌上", + "imageGenerating": "正在生成图像...", + "imageGenerationFailed": "图像生成失败", + "enableVideoGeneration": "启用 AI 视频生成", + "videoGenerationDisabledHint": "启用后,课程生成时将自动生成视频", + "videoSettings": "视频生成", + "videoSection": "文生视频", + "videoProvider": "视频生成提供商", + "videoModel": "视频生成模型", + "providerSeedance": "Seedance(字节跳动)", + "providerKling": "可灵(快手)", + "providerVeo": "Veo(Google)", + "providerSora": "Sora(OpenAI)", + "providerMiniMaxVideo": "MiniMax 视频", + "providerGrokVideo": "Grok Video(xAI)", + "testVideoGeneration": "测试视频生成", + "testVideoConnectivity": "测试连接", + "videoConnectivitySuccess": "视频服务连接成功", + "videoConnectivityFailed": "视频服务连接失败", + "testingConnection": "正在测试...", + "videoTestSuccess": "视频生成测试成功", + "videoTestFailed": "视频生成测试失败", + "videoTestPromptDefault": "一只可爱的猫咪在书桌上行走", + "videoGenerating": "正在生成视频(预计1-2分钟)...", + "videoGenerationWarning": "视频生成通常需要1-2分钟,请耐心等待", + "mediaRetry": "重试", + "mediaContentSensitive": "抱歉,该内容触发了安全检查", + "mediaGenerationDisabled": "已在设置中关闭生成", + "singleAgent": "单智能体模式", + "multiAgent": "多智能体模式", + "selectAgents": "选择智能体", + "noVisionWarning": "当前模型不支持视觉能力,图片仍可放入幻灯片,但模型无法理解图片内容来优化选择和布局", + "serverConfigured": "服务端", + "serverConfiguredNotice": "管理员已在服务端配置了此提供方的 API Key,可直接使用。也可输入自己的 Key 覆盖。", + "optionalOverride": "可选,留空则使用服务端配置", + "setupNeeded": "请先完成配置", + "modelNotConfigured": "请选择一个模型以开始使用", + "dangerZone": "危险区域", + "clearCache": "清空本地缓存", + "clearCacheDescription": "删除所有本地存储的数据,包括课堂记录、对话历史、音频缓存和应用配置。此操作不可撤销。", + "clearCacheConfirmTitle": "确定要清空所有缓存吗?", + "clearCacheConfirmDescription": "此操作将永久删除以下所有数据,且无法恢复:", + "clearCacheConfirmItems": "课堂和场景数据、对话历史记录、音频和图片缓存、应用设置和偏好", + "clearCacheConfirmInput": "请输入「确认删除」以继续", + "clearCacheConfirmPhrase": "确认删除", + "clearCacheButton": "永久删除所有数据", + "clearCacheSuccess": "缓存已清空,页面即将刷新", + "clearCacheFailed": "清空缓存失败,请重试", + "webSearchSettings": "网络搜索", + "webSearchApiKey": "Tavily API Key", + "webSearchApiKeyPlaceholder": "输入你的 Tavily API Key", + "webSearchApiKeyPlaceholderServer": "已配置服务端密钥,可选填覆盖", + "webSearchApiKeyHint": "从 tavily.com 获取 API Key,用于网络搜索", + "webSearchBaseUrl": "Base URL", + "webSearchServerConfigured": "服务端已配置 Tavily API Key", + "optional": "可选" + }, + "profile": { + "title": "个人资料", + "defaultNickname": "同学", + "chooseAvatar": "选择头像", + "uploadAvatar": "上传", + "bioPlaceholder": "介绍一下自己,AI老师会根据你的背景个性化教学...", + "avatarHint": "你的头像将显示在课堂讨论和对话中", + "fileTooLarge": "图片过大,请选择小于 5MB 的图片", + "invalidFileType": "请选择图片文件", + "editTooltip": "点击编辑个人资料" + }, + "media": { + "imageCapability": "图像生成", + "imageHint": "课件中生成配图", + "videoCapability": "视频生成", + "videoHint": "课件中生成视频", + "ttsCapability": "语音合成", + "ttsHint": "AI 老师语音讲解", + "asrCapability": "语音识别", + "asrHint": "语音输入参与讨论", + "provider": "服务商", + "model": "模型", + "voice": "音色", + "speed": "语速", + "language": "语言" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 55835ca15..209932b12 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "embla-carousel-react": "^8.6.0", "file-saver": "^2.0.5", "geist": "^1.7.0", + "i18next": "^26.0.1", + "i18next-browser-languagedetector": "^8.2.1", "immer": "^11.1.3", "js-yaml": "^4.1.1", "jsonrepair": "^3.13.2", @@ -80,6 +82,7 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-i18next": "^17.0.1", "shadcn": "^3.6.3", "sharp": "^0.34.5", "shiki": "^3.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fac3752ea..e8ee00f0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,12 @@ importers: geist: specifier: ^1.7.0 version: 1.7.0(next@16.1.2(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + i18next: + specifier: ^26.0.1 + version: 26.0.1(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 immer: specifier: ^11.1.3 version: 11.1.4 @@ -191,6 +197,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + react-i18next: + specifier: ^17.0.1 + version: 17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) shadcn: specifier: ^3.6.3 version: 3.8.5(@cfworker/json-schema@4.1.1)(@types/node@20.19.37)(typescript@5.9.3) @@ -699,6 +708,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -5483,6 +5496,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -5520,6 +5536,17 @@ packages: engines: {node: '>=18'} hasBin: true + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@26.0.1: + resolution: {integrity: sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -7706,6 +7733,22 @@ packages: peerDependencies: react: ^19.2.3 + react-i18next@17.0.1: + resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==} + peerDependencies: + i18next: '>= 26.0.1' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8963,6 +9006,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + vue-eslint-parser@2.0.3: resolution: {integrity: sha512-ZezcU71Owm84xVF6gfurBQUGg8WQ+WZGxgDEQu1IHFBZNx7BFZg3L1yHxrCBNNwbwFtE1GuvfJKMtb6Xuwc/Bw==} engines: {node: '>=4'} @@ -9620,6 +9667,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -14897,6 +14946,10 @@ snapshots: html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -14930,6 +14983,16 @@ snapshots: husky@9.1.7: {} + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.28.6 + + i18next@26.0.1(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + optionalDependencies: + typescript: 5.9.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -17462,6 +17525,17 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-i18next@17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 26.0.1(typescript@5.9.3) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + typescript: 5.9.3 + react-is@16.13.1: {} react-is@18.3.1: {} @@ -18942,6 +19016,8 @@ snapshots: transitivePeerDependencies: - msw + void-elements@3.1.0: {} + vue-eslint-parser@2.0.3(eslint@4.19.1): dependencies: debug: 3.2.7 From d813fb4e037db09d46471e4128ec10d99476339c Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 15:36:23 +0800 Subject: [PATCH 02/16] fix(i18n): use interpolation for greeting to support natural phrasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace string concatenation (greeting + displayName) with two i18next keys: greetingWithName (with {{name}} interpolation) and greetingDefault (standalone, no name). This lets each locale choose natural phrasing independently: - zh-CN: "嗨,同学" / "嗨,Alice" - en-US: "Hi there" / "Hi, Alice" - Future locales can avoid gender issues by choosing genderless defaults Also widen the t() type signature to accept interpolation options. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/page.tsx | 11 ++++------- lib/hooks/use-i18n.tsx | 2 +- lib/i18n/common.ts | 6 ++++-- lib/i18n/locales/en-US.json | 5 +++-- lib/i18n/locales/zh-CN.json | 5 +++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 68719c489..af265f67b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -785,13 +785,10 @@ function GreetingBar() { - - - {t('home.greeting')} - - - {displayName} - + + {nickname + ? t('home.greetingWithName', { name: nickname }) + : t('home.greetingDefault')} diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index 91697da22..1c7de5e26 100644 --- a/lib/hooks/use-i18n.tsx +++ b/lib/hooks/use-i18n.tsx @@ -8,7 +8,7 @@ import '@/lib/i18n/config'; type I18nContextType = { locale: Locale; setLocale: (locale: Locale) => void; - t: (key: string) => string; + t: (key: string, options?: Record) => string; }; const I18nContext = createContext(undefined); diff --git a/lib/i18n/common.ts b/lib/i18n/common.ts index 1bceb5d61..3f9a64ecf 100644 --- a/lib/i18n/common.ts +++ b/lib/i18n/common.ts @@ -7,7 +7,8 @@ export const commonZhCN = { }, home: { slogan: 'Generative Learning in Multi-Agent Interactive Classroom', - greeting: '嗨,', + greetingWithName: '嗨,{name}', + greetingDefault: '嗨,同学', }, toolbar: { languageHint: '课程将以此语言生成', @@ -48,7 +49,8 @@ export const commonEnUS = { }, home: { slogan: 'Generative Learning in Multi-Agent Interactive Classroom', - greeting: 'Hi, ', + greetingWithName: 'Hi, {name}', + greetingDefault: 'Hi there', }, toolbar: { languageHint: 'Course will be generated in this language', diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 0a6a54562..3837bd161 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -7,7 +7,8 @@ }, "home": { "slogan": "Generative Learning in Multi-Agent Interactive Classroom", - "greeting": "Hi, " + "greetingWithName": "Hi, {{name}}", + "greetingDefault": "Hi there" }, "toolbar": { "languageHint": "Course will be generated in this language", @@ -869,4 +870,4 @@ "speed": "Speed", "language": "Language" } -} \ No newline at end of file +} diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 9438133f3..5d0e99a4b 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -7,7 +7,8 @@ }, "home": { "slogan": "Generative Learning in Multi-Agent Interactive Classroom", - "greeting": "嗨," + "greetingWithName": "嗨,{{name}}", + "greetingDefault": "嗨,同学" }, "toolbar": { "languageHint": "课程将以此语言生成", @@ -869,4 +870,4 @@ "speed": "语速", "language": "语言" } -} \ No newline at end of file +} From 2c4861fc3991a93a1d7200d008eaf35a9be45ec9 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 15:41:32 +0800 Subject: [PATCH 03/16] refactor(i18n): auto-discover locale files via dynamic import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded zh-CN/en-US imports with i18next-resources-to-backend and dynamic import(`./locales/${language}.json`). Bundler scans the locales/ directory at build time, so adding a new language now requires only dropping a JSON file — zero changes to existing code. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/i18n/config.ts | 12 ++++-------- package.json | 1 + pnpm-lock.yaml | 10 ++++++++++ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/i18n/config.ts b/lib/i18n/config.ts index 1c2050dfe..dc5631648 100644 --- a/lib/i18n/config.ts +++ b/lib/i18n/config.ts @@ -1,23 +1,19 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; - -import zhCN from './locales/zh-CN.json'; -import enUS from './locales/en-US.json'; +import resourcesToBackend from 'i18next-resources-to-backend'; const isServer = typeof window === 'undefined'; -const instance = i18n.use(initReactI18next); +const instance = i18n + .use(initReactI18next) + .use(resourcesToBackend((language: string) => import(`./locales/${language}.json`))); if (!isServer) { instance.use(LanguageDetector); } instance.init({ - resources: { - 'zh-CN': { translation: zhCN }, - 'en-US': { translation: enUS }, - }, fallbackLng: 'zh-CN', interpolation: { escapeValue: false, diff --git a/package.json b/package.json index 209932b12..df87a182a 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "geist": "^1.7.0", "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", + "i18next-resources-to-backend": "^1.2.1", "immer": "^11.1.3", "js-yaml": "^4.1.1", "jsonrepair": "^3.13.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8ee00f0e..c90be2bc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 + i18next-resources-to-backend: + specifier: ^1.2.1 + version: 1.2.1 immer: specifier: ^11.1.3 version: 11.1.4 @@ -5539,6 +5542,9 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + i18next-resources-to-backend@1.2.1: + resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} + i18next@26.0.1: resolution: {integrity: sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==} peerDependencies: @@ -14987,6 +14993,10 @@ snapshots: dependencies: '@babel/runtime': 7.28.6 + i18next-resources-to-backend@1.2.1: + dependencies: + '@babel/runtime': 7.29.2 + i18next@26.0.1(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 From 220c5de4f14b87786b1e9dcd4d75153625c71207 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 15:47:46 +0800 Subject: [PATCH 04/16] fix(i18n): resolve hydration mismatch by deferring language detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LanguageDetector ran during i18next init(), detecting browser language before React hydrated — server rendered zh-CN while client switched to en-US immediately, causing a hydration mismatch. Fix: remove i18next-browser-languagedetector; init with a fixed lng (zh-CN) so server and client agree on the first render. Language detection is now done in I18nProvider's useEffect after hydration. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/hooks/use-i18n.tsx | 26 +++++++++++++++++++++++++- lib/i18n/config.ts | 34 +++++++++------------------------- package.json | 1 - pnpm-lock.yaml | 27 ++++++++++----------------- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index 1c7de5e26..ecd805bf6 100644 --- a/lib/hooks/use-i18n.tsx +++ b/lib/hooks/use-i18n.tsx @@ -1,10 +1,13 @@ 'use client'; -import { createContext, useContext, ReactNode } from 'react'; +import { createContext, useContext, useEffect, ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { type Locale, defaultLocale } from '@/lib/i18n'; import '@/lib/i18n/config'; +const LOCALE_STORAGE_KEY = 'locale'; +const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US']; + type I18nContextType = { locale: Locale; setLocale: (locale: Locale) => void; @@ -18,8 +21,29 @@ export function I18nProvider({ children }: { children: ReactNode }) { const locale = (i18n.language as Locale) || defaultLocale; + // Detect language after hydration to avoid SSR mismatch + useEffect(() => { + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + if (stored && VALID_LOCALES.includes(stored as Locale)) { + if (stored !== i18n.language) i18n.changeLanguage(stored); + return; + } + const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + localStorage.setItem(LOCALE_STORAGE_KEY, detected); + if (detected !== i18n.language) i18n.changeLanguage(detected); + } catch { + // localStorage unavailable, keep default + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + const setLocale = (newLocale: Locale) => { i18n.changeLanguage(newLocale); + try { + localStorage.setItem(LOCALE_STORAGE_KEY, newLocale); + } catch { + // localStorage unavailable + } }; return {children}; diff --git a/lib/i18n/config.ts b/lib/i18n/config.ts index dc5631648..254da0641 100644 --- a/lib/i18n/config.ts +++ b/lib/i18n/config.ts @@ -1,32 +1,16 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; import resourcesToBackend from 'i18next-resources-to-backend'; -const isServer = typeof window === 'undefined'; - -const instance = i18n +i18n .use(initReactI18next) - .use(resourcesToBackend((language: string) => import(`./locales/${language}.json`))); - -if (!isServer) { - instance.use(LanguageDetector); -} - -instance.init({ - fallbackLng: 'zh-CN', - interpolation: { - escapeValue: false, - }, - ...(isServer - ? {} - : { - detection: { - order: ['localStorage', 'navigator'], - caches: ['localStorage'], - lookupLocalStorage: 'locale', - }, - }), -}); + .use(resourcesToBackend((language: string) => import(`./locales/${language}.json`))) + .init({ + lng: 'zh-CN', + fallbackLng: 'zh-CN', + interpolation: { + escapeValue: false, + }, + }); export default i18n; diff --git a/package.json b/package.json index df87a182a..c27dba7ce 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "file-saver": "^2.0.5", "geist": "^1.7.0", "i18next": "^26.0.1", - "i18next-browser-languagedetector": "^8.2.1", "i18next-resources-to-backend": "^1.2.1", "immer": "^11.1.3", "js-yaml": "^4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c90be2bc3..7592f157b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,9 +101,6 @@ importers: i18next: specifier: ^26.0.1 version: 26.0.1(typescript@5.9.3) - i18next-browser-languagedetector: - specifier: ^8.2.1 - version: 8.2.1 i18next-resources-to-backend: specifier: ^1.2.1 version: 1.2.1 @@ -293,7 +290,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 16.1.2 - version: 16.1.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.2(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) prettier: specifier: 3.8.1 version: 3.8.1 @@ -5539,9 +5536,6 @@ packages: engines: {node: '>=18'} hasBin: true - i18next-browser-languagedetector@8.2.1: - resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next-resources-to-backend@1.2.1: resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} @@ -13814,13 +13808,13 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.1.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.1.2(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.2 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -13853,21 +13847,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13878,7 +13873,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13889,6 +13884,8 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -14989,10 +14986,6 @@ snapshots: husky@9.1.7: {} - i18next-browser-languagedetector@8.2.1: - dependencies: - '@babel/runtime': 7.28.6 - i18next-resources-to-backend@1.2.1: dependencies: '@babel/runtime': 7.29.2 From a7cf676c47d9f42b9525d7cef1e1e3996809654c Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 15:52:49 +0800 Subject: [PATCH 05/16] refactor(i18n): remove hardcoded locale list from language detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual locale validation and startsWith('zh') prefix matching with i18next's built-in fallback mechanism. Now changeLanguage() is called with navigator.language directly — if the exact locale has no JSON file, i18next automatically falls back to fallbackLng. Also widen Locale type from union to string so adding new languages doesn't require modifying types.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/hooks/use-i18n.tsx | 16 ++++++---------- lib/i18n/types.ts | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index ecd805bf6..eb2e1faf2 100644 --- a/lib/hooks/use-i18n.tsx +++ b/lib/hooks/use-i18n.tsx @@ -6,7 +6,6 @@ import { type Locale, defaultLocale } from '@/lib/i18n'; import '@/lib/i18n/config'; const LOCALE_STORAGE_KEY = 'locale'; -const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US']; type I18nContextType = { locale: Locale; @@ -19,19 +18,16 @@ const I18nContext = createContext(undefined); export function I18nProvider({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); - const locale = (i18n.language as Locale) || defaultLocale; + const locale = i18n.language || defaultLocale; - // Detect language after hydration to avoid SSR mismatch + // Detect language after hydration to avoid SSR mismatch. + // i18next handles fallback automatically: if the detected language + // has no matching JSON file, it falls back to fallbackLng. useEffect(() => { try { const stored = localStorage.getItem(LOCALE_STORAGE_KEY); - if (stored && VALID_LOCALES.includes(stored as Locale)) { - if (stored !== i18n.language) i18n.changeLanguage(stored); - return; - } - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; - localStorage.setItem(LOCALE_STORAGE_KEY, detected); - if (detected !== i18n.language) i18n.changeLanguage(detected); + const target = stored || navigator.language || defaultLocale; + if (target !== i18n.language) i18n.changeLanguage(target); } catch { // localStorage unavailable, keep default } diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts index 6173b0be3..12656c3df 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 = string; export const defaultLocale: Locale = 'zh-CN'; From f7bd6bdb2179302e4999bd1f2ee5188005589529 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 17:06:20 +0800 Subject: [PATCH 06/16] feat(i18n): add course language config with 11 languages and UI linkage - Add lib/i18n/course-languages.ts with curated language list (zh-CN, zh-TW, en-US, ja, ko, fr, de, es, pt, ru, ar) including native labels and English prompt names - Course language defaults to UI locale on first visit; once user explicitly picks a language, that choice persists across sessions - Replace toggle button with dropdown selector showing native labels - Widen language types from 'zh-CN'|'en-US' to string throughout - Fix hardcoded language ternaries in LLM prompt injection: - prompt-builder.ts: use getCourseLanguagePromptName() - classroom-generation.ts: remove normalizeLanguage() that forced all non-English to zh-CN - PBL system prompt, agent templates, generate-pbl: append language instruction for non-zh/en languages - quiz-grade API: add language suffix for grading feedback Closes #327 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/quiz-grade/route.ts | 6 +- app/page.tsx | 10 ++-- components/generation/generation-toolbar.tsx | 48 ++++++++++----- .../requirements-to-outlines/user.md | 2 +- lib/generation/scene-generator.ts | 2 +- lib/i18n/course-languages.ts | 59 +++++++++++++++++++ lib/orchestration/prompt-builder.ts | 3 +- lib/pbl/generate-pbl.ts | 5 +- lib/pbl/mcp/agent-templates.ts | 12 +++- lib/pbl/pbl-system-prompt.ts | 10 +++- lib/server/classroom-generation.ts | 4 +- lib/types/generation.ts | 6 +- 12 files changed, 134 insertions(+), 33 deletions(-) create mode 100644 lib/i18n/course-languages.ts diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index d0aab62e0..fc8115efd 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -10,6 +10,7 @@ import { callLLM } from '@/lib/ai/llm'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; +import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; const log = createLogger('Quiz Grade'); interface GradeRequest { @@ -38,6 +39,9 @@ export async function POST(req: NextRequest) { const { model: languageModel } = resolveModelFromHeaders(req); const isZh = language === 'zh-CN'; + const langName = getCourseLanguagePromptName(language || 'en-US'); + const langSuffix = + !isZh && language !== 'en-US' ? `\nIMPORTANT: Write your comment in ${langName}.` : ''; const systemPrompt = isZh ? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。 @@ -45,7 +49,7 @@ export async function POST(req: NextRequest) { {"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": ""}`; +{"score": , "comment": ""}${langSuffix}`; const userPrompt = isZh ? `题目:${question} diff --git a/app/page.tsx b/app/page.tsx index af265f67b..a9ce4c408 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -46,6 +46,7 @@ import { toast } from 'sonner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useDraftCache } from '@/lib/hooks/use-draft-cache'; import { SpeechButton } from '@/components/audio/speech-button'; +import { findClosestCourseLanguage } from '@/lib/i18n/course-languages'; const log = createLogger('Home'); @@ -56,7 +57,7 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; interface FormState { pdfFile: File | null; requirement: string; - language: 'zh-CN' | 'en-US'; + language: string; webSearch: boolean; } @@ -99,11 +100,12 @@ 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) { + // User previously made an explicit choice — respect it updates.language = savedLanguage; } else { - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; - updates.language = detected; + // First visit: derive course language from UI locale + updates.language = findClosestCourseLanguage(locale); } if (Object.keys(updates).length > 0) { setForm((prev) => ({ ...prev, ...updates })); diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bbd8..cbdff4ede 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -21,6 +21,7 @@ import type { WebSearchProviderId } from '@/lib/web-search/types'; import type { ProviderId } from '@/lib/ai/providers'; import type { SettingsSection } from '@/lib/types/settings'; import { MediaPopover } from '@/components/generation/media-popover'; +import { courseLanguages, getCourseLanguageLabel } from '@/lib/i18n/course-languages'; // ─── Constants ─────────────────────────────────────────────── const MAX_PDF_SIZE_MB = 50; @@ -28,8 +29,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: string; + onLanguageChange: (lang: string) => void; webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; @@ -357,19 +358,36 @@ export function GenerationToolbar({ )} - {/* ── Language pill ── */} - - - - - {t('toolbar.languageHint')} - + {/* ── Language selector ── */} + + + + + + + + {t('toolbar.languageHint')} + + + {courseLanguages.map((lang) => ( + + ))} + + {/* ── Separator ── */}
diff --git a/lib/generation/prompts/templates/requirements-to-outlines/user.md b/lib/generation/prompts/templates/requirements-to-outlines/user.md index 65d0a4921..cabfc7a11 100644 --- a/lib/generation/prompts/templates/requirements-to-outlines/user.md +++ b/lib/generation/prompts/templates/requirements-to-outlines/user.md @@ -14,7 +14,7 @@ Please generate scene outlines based on the following course requirements. **Required language**: {{language}} -(If language is zh-CN, all content must be in Chinese; if en-US, all content must be in English) +(All content must be generated in the specified language) --- diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937d..efa2645bb 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: string = 'zh-CN', ): Promise { const config = outline.interactiveConfig!; diff --git a/lib/i18n/course-languages.ts b/lib/i18n/course-languages.ts new file mode 100644 index 000000000..4df337038 --- /dev/null +++ b/lib/i18n/course-languages.ts @@ -0,0 +1,59 @@ +/** + * Curated list of languages available for course generation. + * + * To add a new language, append an entry here — no other file changes needed. + * - code: BCP-47 tag stored in settings / passed to APIs + * - label: Native name shown in the UI (autoglossonym, no i18n needed) + * - promptName: English name injected into LLM prompts + */ +export const courseLanguages = [ + { code: 'zh-CN', label: '简体中文', promptName: 'Chinese (Simplified)' }, + { code: 'zh-TW', label: '繁體中文', promptName: 'Chinese (Traditional)' }, + { code: 'en-US', label: 'English', promptName: 'English' }, + { code: 'ja', label: '日本語', promptName: 'Japanese' }, + { code: 'ko', label: '한국어', promptName: 'Korean' }, + { code: 'fr', label: 'Français', promptName: 'French' }, + { code: 'de', label: 'Deutsch', promptName: 'German' }, + { code: 'es', label: 'Español', promptName: 'Spanish' }, + { code: 'pt', label: 'Português', promptName: 'Portuguese' }, + { code: 'ru', label: 'Русский', promptName: 'Russian' }, + { code: 'ar', label: 'العربية', promptName: 'Arabic' }, +] as const; + +export type CourseLanguageCode = (typeof courseLanguages)[number]['code']; + +const defaultCode: CourseLanguageCode = 'zh-CN'; + +/** Get the native display label for a course language code. */ +export function getCourseLanguageLabel(code: string): string { + return courseLanguages.find((l) => l.code === code)?.label ?? code; +} + +/** Get the English prompt name for a course language code. */ +export function getCourseLanguagePromptName(code: string): string { + return courseLanguages.find((l) => l.code === code)?.promptName ?? code; +} + +/** + * Find the closest supported course language for a given locale string. + * Matching order: exact code → base language prefix → default (zh-CN). + * + * Examples: + * 'en-US' → 'en-US' (exact) + * 'en-GB' → 'en-US' (prefix 'en' matches 'en-US') + * 'zh-TW' → 'zh-TW' (exact) + * 'zh' → 'zh-CN' (prefix 'zh' matches 'zh-CN', first in list) + * 'sv' → 'zh-CN' (no match, fallback) + */ +export function findClosestCourseLanguage(locale: string): CourseLanguageCode { + // Exact match + const exact = courseLanguages.find((l) => l.code === locale); + if (exact) return exact.code; + + // Base language prefix match (e.g. 'en' from 'en-GB') + const base = locale.split('-')[0]; + const prefixMatch = courseLanguages.find((l) => l.code.split('-')[0] === base); + if (prefixMatch) return prefixMatch.code; + + return defaultCode; +} diff --git a/lib/orchestration/prompt-builder.ts b/lib/orchestration/prompt-builder.ts index b73f310f3..c387c3fa3 100644 --- a/lib/orchestration/prompt-builder.ts +++ b/lib/orchestration/prompt-builder.ts @@ -8,6 +8,7 @@ import type { StatelessChatRequest } from '@/lib/types/chat'; import type { AgentConfig } from '@/lib/orchestration/registry/types'; import type { WhiteboardActionRecord, AgentTurnSummary } from './director-prompt'; import { getActionDescriptions, getEffectiveActions } from './tool-schemas'; +import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; // ==================== Role Guidelines ==================== @@ -166,7 +167,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 ${getCourseLanguagePromptName(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 52838b7f5..6e74e0417 100644 --- a/lib/pbl/generate-pbl.ts +++ b/lib/pbl/generate-pbl.ts @@ -18,6 +18,7 @@ import { ProjectMCP } from './mcp/project-mcp'; import { AgentMCP } from './mcp/agent-mcp'; import { IssueboardMCP } from './mcp/issueboard-mcp'; import { buildPBLSystemPrompt } from './pbl-system-prompt'; +import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; import type { PBLMode } from './types'; export interface GeneratePBLConfig { @@ -290,7 +291,7 @@ 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.`, + : `Design a PBL project. Start in project_info mode by setting the project title and description.${language !== 'en-US' ? ` Respond in ${getCourseLanguagePromptName(language)}.` : ''}`, tools: pblTools, stopWhen: stepCountIs(30), onStepFinish: ({ toolCalls, text }) => { @@ -395,7 +396,7 @@ Based on the issue information above, generate 1-3 specific, actionable question - Help break down the problem - Encourage critical thinking -Format your response as a numbered list.`; +Format your response as a numbered list.${language !== 'en-US' ? `\n\nIMPORTANT: Generate all questions in ${getCourseLanguagePromptName(language)}.` : ''}`; const questionResult = await callLLM( { diff --git a/lib/pbl/mcp/agent-templates.ts b/lib/pbl/mcp/agent-templates.ts index c2e1c8c33..eac296cb1 100644 --- a/lib/pbl/mcp/agent-templates.ts +++ b/lib/pbl/mcp/agent-templates.ts @@ -4,18 +4,26 @@ * Migrated from PBL-Nano with multi-language support. */ +import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; + +function appendLanguageInstruction(prompt: string, language: string): string { + if (language === 'zh-CN' || language === 'en-US') return prompt; + const langName = getCourseLanguagePromptName(language); + return `${prompt}\n\n## Language (CRITICAL)\nYou MUST respond in ${langName}.`; +} + export function getQuestionAgentPrompt(language: string = 'en-US'): string { if (language === 'zh-CN') { return QUESTION_AGENT_TEMPLATE_PROMPT_ZH; } - return QUESTION_AGENT_TEMPLATE_PROMPT; + return appendLanguageInstruction(QUESTION_AGENT_TEMPLATE_PROMPT, language); } export function getJudgeAgentPrompt(language: string = 'en-US'): string { if (language === 'zh-CN') { return JUDGE_AGENT_TEMPLATE_PROMPT_ZH; } - return JUDGE_AGENT_TEMPLATE_PROMPT; + return appendLanguageInstruction(JUDGE_AGENT_TEMPLATE_PROMPT, language); } export const QUESTION_AGENT_TEMPLATE_PROMPT = `You are a Question Agent in a Project-Based Learning platform. Your role is to help students understand and complete their assigned issue. diff --git a/lib/pbl/pbl-system-prompt.ts b/lib/pbl/pbl-system-prompt.ts index 72cdc10b5..3d0f34591 100644 --- a/lib/pbl/pbl-system-prompt.ts +++ b/lib/pbl/pbl-system-prompt.ts @@ -5,6 +5,8 @@ * Enhanced with multi-language support and configurable parameters. */ +import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; + export interface PBLSystemPromptConfig { projectTopic: string; projectDescription: string; @@ -20,6 +22,12 @@ export function buildPBLSystemPrompt(config: PBLSystemPromptConfig): string { return buildPBLSystemPromptZH(config); } + const langName = getCourseLanguagePromptName(language); + const langInstruction = + language !== 'en-US' + ? `\n\n## Language (CRITICAL)\nYou MUST produce ALL output in ${langName}. Every title, description, role name, issue text, and any other generated content MUST be in ${langName}.\n` + : ''; + 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 @@ -81,7 +89,7 @@ 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**.`; +Your initial mode is **project_info**.${langInstruction}`; } function buildPBLSystemPromptZH(config: PBLSystemPromptConfig): string { diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index eda67b4c4..056a1e024 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -96,8 +96,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): string { + return language || 'zh-CN'; } function stripCodeFences(text: string): string { diff --git a/lib/types/generation.ts b/lib/types/generation.ts index c1e6eb7a7..56181cab8 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -64,7 +64,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: string; // 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 +100,7 @@ export interface SceneOutline { teachingObjective?: string; estimatedDuration?: number; // seconds order: number; - language?: 'zh-CN' | 'en-US'; // Generation language (inherited from requirements) + language?: string; // 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 +124,7 @@ export interface SceneOutline { projectDescription: string; targetSkills: string[]; issueCount?: number; - language: 'zh-CN' | 'en-US'; + language: string; }; } From 5bb5133da7f829a2fb88f67d52ddd5fb570a364f Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 17:20:59 +0800 Subject: [PATCH 07/16] fix(quiz): use course language instead of UI locale for grading quiz-view was passing the UI locale to the grading API, causing AI feedback to follow the student's answer language instead of the course language. Now reads stage.language from the store. Also strengthen the grading prompt: explicitly instruct the LLM to write comments in the course language regardless of the student's input language. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/quiz-grade/route.ts | 8 +++++--- components/scene-renderers/quiz-view.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index fc8115efd..fa9525300 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -40,13 +40,15 @@ export async function POST(req: NextRequest) { const isZh = language === 'zh-CN'; const langName = getCourseLanguagePromptName(language || 'en-US'); - const langSuffix = - !isZh && language !== 'en-US' ? `\nIMPORTANT: Write your comment in ${langName}.` : ''; + const langSuffix = !isZh + ? `\nCRITICAL: The "comment" field MUST be written in ${langName}, regardless of what language the student used in their answer.` + : ''; const systemPrompt = isZh ? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。 +无论学生用什么语言作答,你的评语必须使用中文。 必须以如下 JSON 格式回复(不要包含其他内容): -{"score": <0到${points}的整数>, "comment": "<一两句评语>"}` +{"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": ""}${langSuffix}`; diff --git a/components/scene-renderers/quiz-view.tsx b/components/scene-renderers/quiz-view.tsx index 49d6d3e98..c7d57a48e 100644 --- a/components/scene-renderers/quiz-view.tsx +++ b/components/scene-renderers/quiz-view.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; +import { useStageStore } from '@/lib/store'; import { getCurrentModelConfig } from '@/lib/utils/model-config'; import { createLogger } from '@/lib/logger'; @@ -687,6 +688,7 @@ function ScoreBanner({ export function QuizView({ questions, sceneId }: QuizViewProps) { const { t, locale } = useI18n(); + const courseLanguage = useStageStore((s) => s.stage?.language) || locale; const [phase, setPhase] = useState('not_started'); const [answers, setAnswers] = useState>({}); const [results, setResults] = useState([]); @@ -753,7 +755,7 @@ export function QuizView({ questions, sceneId }: QuizViewProps) { const shortAnswerQs = questions.filter(isShortAnswer); const aiResults = await Promise.all( shortAnswerQs.map((q) => - gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', locale), + gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', courseLanguage), ), ); @@ -774,7 +776,7 @@ export function QuizView({ questions, sceneId }: QuizViewProps) { return () => { cancelled = true; }; - }, [phase, questions, answers, locale]); + }, [phase, questions, answers, courseLanguage]); const handleRetry = useCallback(() => { setPhase('not_started'); From d8f98f571510f3b0671164b003a73043ca206d45 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 18:02:17 +0800 Subject: [PATCH 08/16] revert: remove course language config and quiz fix from i18next branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts f7bd6bd and 5bb5133 — these are independent features that should go into a separate branch/PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/quiz-grade/route.ts | 10 +--- app/page.tsx | 10 ++-- components/generation/generation-toolbar.tsx | 48 +++++---------- components/scene-renderers/quiz-view.tsx | 6 +- .../requirements-to-outlines/user.md | 2 +- lib/generation/scene-generator.ts | 2 +- lib/i18n/course-languages.ts | 59 ------------------- lib/orchestration/prompt-builder.ts | 3 +- lib/pbl/generate-pbl.ts | 5 +- lib/pbl/mcp/agent-templates.ts | 12 +--- lib/pbl/pbl-system-prompt.ts | 10 +--- lib/server/classroom-generation.ts | 4 +- lib/types/generation.ts | 6 +- 13 files changed, 36 insertions(+), 141 deletions(-) delete mode 100644 lib/i18n/course-languages.ts diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index fa9525300..d0aab62e0 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -10,7 +10,6 @@ import { callLLM } from '@/lib/ai/llm'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; -import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; const log = createLogger('Quiz Grade'); interface GradeRequest { @@ -39,19 +38,14 @@ export async function POST(req: NextRequest) { const { model: languageModel } = resolveModelFromHeaders(req); const isZh = language === 'zh-CN'; - const langName = getCourseLanguagePromptName(language || 'en-US'); - const langSuffix = !isZh - ? `\nCRITICAL: The "comment" field MUST be written in ${langName}, regardless of what language the student used in their answer.` - : ''; const systemPrompt = isZh ? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。 -无论学生用什么语言作答,你的评语必须使用中文。 必须以如下 JSON 格式回复(不要包含其他内容): -{"score": <0到${points}的整数>, "comment": "<一两句中文评语>"}` +{"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": ""}${langSuffix}`; +{"score": , "comment": ""}`; const userPrompt = isZh ? `题目:${question} diff --git a/app/page.tsx b/app/page.tsx index a9ce4c408..af265f67b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -46,7 +46,6 @@ import { toast } from 'sonner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useDraftCache } from '@/lib/hooks/use-draft-cache'; import { SpeechButton } from '@/components/audio/speech-button'; -import { findClosestCourseLanguage } from '@/lib/i18n/course-languages'; const log = createLogger('Home'); @@ -57,7 +56,7 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; interface FormState { pdfFile: File | null; requirement: string; - language: string; + language: 'zh-CN' | 'en-US'; webSearch: boolean; } @@ -100,12 +99,11 @@ function HomePage() { const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); const updates: Partial = {}; if (savedWebSearch === 'true') updates.webSearch = true; - if (savedLanguage) { - // User previously made an explicit choice — respect it + if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') { updates.language = savedLanguage; } else { - // First visit: derive course language from UI locale - updates.language = findClosestCourseLanguage(locale); + const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + updates.language = detected; } if (Object.keys(updates).length > 0) { setForm((prev) => ({ ...prev, ...updates })); diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index cbdff4ede..27301bbd8 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -21,7 +21,6 @@ import type { WebSearchProviderId } from '@/lib/web-search/types'; import type { ProviderId } from '@/lib/ai/providers'; import type { SettingsSection } from '@/lib/types/settings'; import { MediaPopover } from '@/components/generation/media-popover'; -import { courseLanguages, getCourseLanguageLabel } from '@/lib/i18n/course-languages'; // ─── Constants ─────────────────────────────────────────────── const MAX_PDF_SIZE_MB = 50; @@ -29,8 +28,8 @@ const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { - language: string; - onLanguageChange: (lang: string) => void; + language: 'zh-CN' | 'en-US'; + onLanguageChange: (lang: 'zh-CN' | 'en-US') => void; webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; @@ -358,36 +357,19 @@ export function GenerationToolbar({ )} - {/* ── Language selector ── */} - - - - - - - - {t('toolbar.languageHint')} - - - {courseLanguages.map((lang) => ( - - ))} - - + {/* ── Language pill ── */} + + + + + {t('toolbar.languageHint')} + {/* ── Separator ── */}
diff --git a/components/scene-renderers/quiz-view.tsx b/components/scene-renderers/quiz-view.tsx index c7d57a48e..49d6d3e98 100644 --- a/components/scene-renderers/quiz-view.tsx +++ b/components/scene-renderers/quiz-view.tsx @@ -15,7 +15,6 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; -import { useStageStore } from '@/lib/store'; import { getCurrentModelConfig } from '@/lib/utils/model-config'; import { createLogger } from '@/lib/logger'; @@ -688,7 +687,6 @@ function ScoreBanner({ export function QuizView({ questions, sceneId }: QuizViewProps) { const { t, locale } = useI18n(); - const courseLanguage = useStageStore((s) => s.stage?.language) || locale; const [phase, setPhase] = useState('not_started'); const [answers, setAnswers] = useState>({}); const [results, setResults] = useState([]); @@ -755,7 +753,7 @@ export function QuizView({ questions, sceneId }: QuizViewProps) { const shortAnswerQs = questions.filter(isShortAnswer); const aiResults = await Promise.all( shortAnswerQs.map((q) => - gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', courseLanguage), + gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', locale), ), ); @@ -776,7 +774,7 @@ export function QuizView({ questions, sceneId }: QuizViewProps) { return () => { cancelled = true; }; - }, [phase, questions, answers, courseLanguage]); + }, [phase, questions, answers, locale]); const handleRetry = useCallback(() => { setPhase('not_started'); diff --git a/lib/generation/prompts/templates/requirements-to-outlines/user.md b/lib/generation/prompts/templates/requirements-to-outlines/user.md index cabfc7a11..65d0a4921 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}} -(All content must be generated in the specified language) +(If language is zh-CN, all content must be in Chinese; if en-US, all content must be in English) --- diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index efa2645bb..1dc22937d 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: string = 'zh-CN', + language: 'zh-CN' | 'en-US' = 'zh-CN', ): Promise { const config = outline.interactiveConfig!; diff --git a/lib/i18n/course-languages.ts b/lib/i18n/course-languages.ts deleted file mode 100644 index 4df337038..000000000 --- a/lib/i18n/course-languages.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Curated list of languages available for course generation. - * - * To add a new language, append an entry here — no other file changes needed. - * - code: BCP-47 tag stored in settings / passed to APIs - * - label: Native name shown in the UI (autoglossonym, no i18n needed) - * - promptName: English name injected into LLM prompts - */ -export const courseLanguages = [ - { code: 'zh-CN', label: '简体中文', promptName: 'Chinese (Simplified)' }, - { code: 'zh-TW', label: '繁體中文', promptName: 'Chinese (Traditional)' }, - { code: 'en-US', label: 'English', promptName: 'English' }, - { code: 'ja', label: '日本語', promptName: 'Japanese' }, - { code: 'ko', label: '한국어', promptName: 'Korean' }, - { code: 'fr', label: 'Français', promptName: 'French' }, - { code: 'de', label: 'Deutsch', promptName: 'German' }, - { code: 'es', label: 'Español', promptName: 'Spanish' }, - { code: 'pt', label: 'Português', promptName: 'Portuguese' }, - { code: 'ru', label: 'Русский', promptName: 'Russian' }, - { code: 'ar', label: 'العربية', promptName: 'Arabic' }, -] as const; - -export type CourseLanguageCode = (typeof courseLanguages)[number]['code']; - -const defaultCode: CourseLanguageCode = 'zh-CN'; - -/** Get the native display label for a course language code. */ -export function getCourseLanguageLabel(code: string): string { - return courseLanguages.find((l) => l.code === code)?.label ?? code; -} - -/** Get the English prompt name for a course language code. */ -export function getCourseLanguagePromptName(code: string): string { - return courseLanguages.find((l) => l.code === code)?.promptName ?? code; -} - -/** - * Find the closest supported course language for a given locale string. - * Matching order: exact code → base language prefix → default (zh-CN). - * - * Examples: - * 'en-US' → 'en-US' (exact) - * 'en-GB' → 'en-US' (prefix 'en' matches 'en-US') - * 'zh-TW' → 'zh-TW' (exact) - * 'zh' → 'zh-CN' (prefix 'zh' matches 'zh-CN', first in list) - * 'sv' → 'zh-CN' (no match, fallback) - */ -export function findClosestCourseLanguage(locale: string): CourseLanguageCode { - // Exact match - const exact = courseLanguages.find((l) => l.code === locale); - if (exact) return exact.code; - - // Base language prefix match (e.g. 'en' from 'en-GB') - const base = locale.split('-')[0]; - const prefixMatch = courseLanguages.find((l) => l.code.split('-')[0] === base); - if (prefixMatch) return prefixMatch.code; - - return defaultCode; -} diff --git a/lib/orchestration/prompt-builder.ts b/lib/orchestration/prompt-builder.ts index c387c3fa3..b73f310f3 100644 --- a/lib/orchestration/prompt-builder.ts +++ b/lib/orchestration/prompt-builder.ts @@ -8,7 +8,6 @@ import type { StatelessChatRequest } from '@/lib/types/chat'; import type { AgentConfig } from '@/lib/orchestration/registry/types'; import type { WhiteboardActionRecord, AgentTurnSummary } from './director-prompt'; import { getActionDescriptions, getEffectiveActions } from './tool-schemas'; -import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; // ==================== Role Guidelines ==================== @@ -167,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 ${getCourseLanguagePromptName(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}. 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 6e74e0417..52838b7f5 100644 --- a/lib/pbl/generate-pbl.ts +++ b/lib/pbl/generate-pbl.ts @@ -18,7 +18,6 @@ import { ProjectMCP } from './mcp/project-mcp'; import { AgentMCP } from './mcp/agent-mcp'; import { IssueboardMCP } from './mcp/issueboard-mcp'; import { buildPBLSystemPrompt } from './pbl-system-prompt'; -import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; import type { PBLMode } from './types'; export interface GeneratePBLConfig { @@ -291,7 +290,7 @@ 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 !== 'en-US' ? ` Respond in ${getCourseLanguagePromptName(language)}.` : ''}`, + : `Design a PBL project. Start in project_info mode by setting the project title and description.`, tools: pblTools, stopWhen: stepCountIs(30), onStepFinish: ({ toolCalls, text }) => { @@ -396,7 +395,7 @@ Based on the issue information above, generate 1-3 specific, actionable question - Help break down the problem - Encourage critical thinking -Format your response as a numbered list.${language !== 'en-US' ? `\n\nIMPORTANT: Generate all questions in ${getCourseLanguagePromptName(language)}.` : ''}`; +Format your response as a numbered list.`; const questionResult = await callLLM( { diff --git a/lib/pbl/mcp/agent-templates.ts b/lib/pbl/mcp/agent-templates.ts index eac296cb1..c2e1c8c33 100644 --- a/lib/pbl/mcp/agent-templates.ts +++ b/lib/pbl/mcp/agent-templates.ts @@ -4,26 +4,18 @@ * Migrated from PBL-Nano with multi-language support. */ -import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; - -function appendLanguageInstruction(prompt: string, language: string): string { - if (language === 'zh-CN' || language === 'en-US') return prompt; - const langName = getCourseLanguagePromptName(language); - return `${prompt}\n\n## Language (CRITICAL)\nYou MUST respond in ${langName}.`; -} - export function getQuestionAgentPrompt(language: string = 'en-US'): string { if (language === 'zh-CN') { return QUESTION_AGENT_TEMPLATE_PROMPT_ZH; } - return appendLanguageInstruction(QUESTION_AGENT_TEMPLATE_PROMPT, language); + return QUESTION_AGENT_TEMPLATE_PROMPT; } export function getJudgeAgentPrompt(language: string = 'en-US'): string { if (language === 'zh-CN') { return JUDGE_AGENT_TEMPLATE_PROMPT_ZH; } - return appendLanguageInstruction(JUDGE_AGENT_TEMPLATE_PROMPT, language); + return JUDGE_AGENT_TEMPLATE_PROMPT; } export const QUESTION_AGENT_TEMPLATE_PROMPT = `You are a Question Agent in a Project-Based Learning platform. Your role is to help students understand and complete their assigned issue. diff --git a/lib/pbl/pbl-system-prompt.ts b/lib/pbl/pbl-system-prompt.ts index 3d0f34591..72cdc10b5 100644 --- a/lib/pbl/pbl-system-prompt.ts +++ b/lib/pbl/pbl-system-prompt.ts @@ -5,8 +5,6 @@ * Enhanced with multi-language support and configurable parameters. */ -import { getCourseLanguagePromptName } from '@/lib/i18n/course-languages'; - export interface PBLSystemPromptConfig { projectTopic: string; projectDescription: string; @@ -22,12 +20,6 @@ export function buildPBLSystemPrompt(config: PBLSystemPromptConfig): string { return buildPBLSystemPromptZH(config); } - const langName = getCourseLanguagePromptName(language); - const langInstruction = - language !== 'en-US' - ? `\n\n## Language (CRITICAL)\nYou MUST produce ALL output in ${langName}. Every title, description, role name, issue text, and any other generated content MUST be in ${langName}.\n` - : ''; - 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 @@ -89,7 +81,7 @@ 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**.${langInstruction}`; +Your initial mode is **project_info**.`; } function buildPBLSystemPromptZH(config: PBLSystemPromptConfig): string { diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index 056a1e024..eda67b4c4 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -96,8 +96,8 @@ function createInMemoryStore(stage: Stage): StageStore { }; } -function normalizeLanguage(language?: string): string { - return language || 'zh-CN'; +function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' { + return language === 'en-US' ? 'en-US' : 'zh-CN'; } function stripCodeFences(text: string): string { diff --git a/lib/types/generation.ts b/lib/types/generation.ts index 56181cab8..c1e6eb7a7 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -64,7 +64,7 @@ export interface UploadedDocument { */ export interface UserRequirements { requirement: string; // Single free-form text for all user input - language: string; // Course language - critical for generation + language: 'zh-CN' | 'en-US'; // 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 +100,7 @@ export interface SceneOutline { teachingObjective?: string; estimatedDuration?: number; // seconds order: number; - language?: string; // Generation language (inherited from requirements) + language?: 'zh-CN' | 'en-US'; // 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 +124,7 @@ export interface SceneOutline { projectDescription: string; targetSkills: string[]; issueCount?: number; - language: string; + language: 'zh-CN' | 'en-US'; }; } From f4fe45fb38661858de85251d832837ea7f3321f4 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 18:07:21 +0800 Subject: [PATCH 09/16] chore(i18n): regenerate locale JSON after merging latest main Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/i18n/locales/en-US.json | 6 ++++-- lib/i18n/locales/zh-CN.json | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 3837bd161..51ed75214 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -7,7 +7,7 @@ }, "home": { "slogan": "Generative Learning in Multi-Agent Interactive Classroom", - "greetingWithName": "Hi, {{name}}", + "greetingWithName": "Hi, {name}", "greetingDefault": "Hi there" }, "toolbar": { @@ -340,6 +340,8 @@ "openaiTts": "OpenAI TTS", "azureTts": "Azure TTS" }, + "availableModels": "Available Models", + "modelSelectedViaVoice": "Model is determined by voice selection", "testConnection": "Test Connection", "testConnectionDesc": "Test current API configuration is available", "testing": "Testing...", @@ -870,4 +872,4 @@ "speed": "Speed", "language": "Language" } -} +} \ No newline at end of file diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 5d0e99a4b..73adb031e 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -7,7 +7,7 @@ }, "home": { "slogan": "Generative Learning in Multi-Agent Interactive Classroom", - "greetingWithName": "嗨,{{name}}", + "greetingWithName": "嗨,{name}", "greetingDefault": "嗨,同学" }, "toolbar": { @@ -340,6 +340,8 @@ "openaiTts": "OpenAI TTS", "azureTts": "Azure TTS" }, + "availableModels": "可用模型", + "modelSelectedViaVoice": "模型随音色选择自动确定", "testConnection": "测试连接", "testConnectionDesc": "测试当前API配置是否可用", "testing": "测试中...", @@ -870,4 +872,4 @@ "speed": "语速", "language": "语言" } -} +} \ No newline at end of file From af20e34acbbb39178b77113458169640fc010998 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 18:12:44 +0800 Subject: [PATCH 10/16] chore(i18n): remove obsolete TS translation files These files are replaced by lib/i18n/locales/*.json and are no longer imported anywhere in the codebase. Removed: chat.ts, common.ts, generation.ts, settings.ts, stage.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/i18n/chat.ts | 147 ----- lib/i18n/common.ts | 83 --- lib/i18n/generation.ts | 135 ----- lib/i18n/settings.ts | 1196 ---------------------------------------- lib/i18n/stage.ts | 298 ---------- 5 files changed, 1859 deletions(-) delete mode 100644 lib/i18n/chat.ts delete mode 100644 lib/i18n/common.ts delete mode 100644 lib/i18n/generation.ts delete mode 100644 lib/i18n/settings.ts delete mode 100644 lib/i18n/stage.ts diff --git a/lib/i18n/chat.ts b/lib/i18n/chat.ts deleted file mode 100644 index 1bb535d3e..000000000 --- a/lib/i18n/chat.ts +++ /dev/null @@ -1,147 +0,0 @@ -export const chatZhCN = { - chat: { - lecture: '授课', - noConversations: '暂无对话', - startConversation: '输入消息开始对话', - noMessages: '暂无消息', - ended: '已结束', - unknown: '未知', - stopDiscussion: '结束讨论', - endQA: '结束问答', - tabs: { - lecture: '笔记', - chat: '对话', - }, - lectureNotes: { - empty: '播放课程后,笔记将在此显示', - emptyHint: '点击播放按钮开始授课', - 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: '课堂角色配置', - configTooltip: '点击配置课堂角色', - voiceLabel: '音色', - voiceLoading: '加载中...', - voiceAutoAssign: '音色将自动分配', - }, - proactiveCard: { - discussion: '讨论', - join: '加入讨论', - skip: '跳过', - pause: '暂停', - resume: '继续', - }, - voice: { - startListening: '语音输入', - stopListening: '停止录音', - }, -} as const; - -export const chatEnUS = { - chat: { - lecture: 'Lecture', - noConversations: 'No conversations', - startConversation: 'Type a message below to begin chatting', - noMessages: 'No messages yet', - ended: 'ended', - unknown: 'Unknown', - stopDiscussion: 'Stop Discussion', - endQA: 'End Q&A', - tabs: { - lecture: 'Notes', - chat: 'Chat', - }, - lectureNotes: { - empty: 'Notes will appear here after lecture playback', - emptyHint: 'Press play to start the lecture', - pageLabel: 'Page {n}', - currentPage: 'Current', - }, - badge: { - qa: 'Q&A', - discussion: 'DISC', - lecture: 'LEC', - }, - }, - actions: { - names: { - spotlight: 'Spotlight', - laser: 'Laser', - wb_open: 'Open Whiteboard', - wb_draw_text: 'Whiteboard Text', - wb_draw_shape: 'Whiteboard Shape', - wb_draw_chart: 'Whiteboard Chart', - wb_draw_latex: 'Whiteboard Formula', - wb_draw_table: 'Whiteboard Table', - wb_draw_line: 'Whiteboard Line', - wb_clear: 'Clear Whiteboard', - wb_delete: 'Delete Element', - wb_close: 'Close Whiteboard', - discussion: 'Discussion', - }, - status: { - inputStreaming: 'Waiting', - inputAvailable: 'Executing', - outputAvailable: 'Completed', - outputError: 'Error', - outputDenied: 'Denied', - running: 'Executing', - result: 'Completed', - error: 'Error', - }, - }, - agentBar: { - readyToLearn: 'Ready to learn together?', - expandedTitle: 'Classroom Role Config', - configTooltip: 'Click to configure classroom roles', - voiceLabel: 'Voice', - voiceLoading: 'Loading...', - voiceAutoAssign: 'Voices will be auto-assigned', - }, - proactiveCard: { - discussion: 'Discussion', - join: 'Join', - skip: 'Skip', - pause: 'Pause', - resume: 'Resume', - }, - voice: { - startListening: 'Voice input', - stopListening: 'Stop recording', - }, -} as const; diff --git a/lib/i18n/common.ts b/lib/i18n/common.ts deleted file mode 100644 index 3f9a64ecf..000000000 --- a/lib/i18n/common.ts +++ /dev/null @@ -1,83 +0,0 @@ -export const commonZhCN = { - common: { - you: '你', - confirm: '确定', - cancel: '取消', - loading: '加载中...', - }, - home: { - slogan: 'Generative Learning in Multi-Agent Interactive Classroom', - greetingWithName: '嗨,{name}', - greetingDefault: '嗨,同学', - }, - toolbar: { - languageHint: '课程将以此语言生成', - pdfParser: '解析器', - pdfUpload: '上传 PDF', - removePdf: '移除文件', - webSearchOn: '已开启', - webSearchOff: '点击开启', - webSearchDesc: '生成前搜索网络获取最新资料,让内容更丰富准确', - webSearchProvider: '搜索引擎', - webSearchNoProvider: '请在设置中配置搜索引擎 API Key', - selectProvider: '选择模型服务商', - configureProvider: '配置模型', - configureProviderHint: '请先配置至少一个模型服务商才能生成课程', - enterClassroom: '进入课堂', - advancedSettings: '高级设置', - ttsTitle: '语音合成', - ttsHint: '选择 AI 教师的朗读音色', - ttsPreview: '试听', - ttsPreviewing: '播放中...', - }, - export: { - pptx: '导出 PPTX', - resourcePack: '导出教学资源包', - resourcePackDesc: 'PPTX + 交互式页面', - exporting: '正在导出...', - exportSuccess: '导出成功', - exportFailed: '导出失败', - }, -} as const; - -export const commonEnUS = { - common: { - you: 'You', - confirm: 'Confirm', - cancel: 'Cancel', - loading: 'Loading...', - }, - home: { - slogan: 'Generative Learning in Multi-Agent Interactive Classroom', - greetingWithName: 'Hi, {name}', - greetingDefault: 'Hi there', - }, - toolbar: { - languageHint: 'Course will be generated in this language', - pdfParser: 'Parser', - pdfUpload: 'Upload PDF', - removePdf: 'Remove file', - webSearchOn: 'Enabled', - webSearchOff: 'Click to enable', - webSearchDesc: 'Search the web for up-to-date information before generation', - webSearchProvider: 'Search engine', - webSearchNoProvider: 'Configure search API key in Settings', - selectProvider: 'Select provider', - configureProvider: 'Set up model', - configureProviderHint: 'Configure at least one model provider to generate courses', - enterClassroom: 'Enter Classroom', - advancedSettings: 'Advanced Settings', - ttsTitle: 'Text-to-Speech', - ttsHint: 'Choose a voice for the AI teacher', - ttsPreview: 'Preview', - ttsPreviewing: 'Playing...', - }, - export: { - pptx: 'Export PPTX', - resourcePack: 'Export Resource Pack', - resourcePackDesc: 'PPTX + interactive pages', - exporting: 'Exporting...', - exportSuccess: 'Export successful', - exportFailed: 'Export failed', - }, -} as const; diff --git a/lib/i18n/generation.ts b/lib/i18n/generation.ts deleted file mode 100644 index 98694c234..000000000 --- a/lib/i18n/generation.ts +++ /dev/null @@ -1,135 +0,0 @@ -export const generationZhCN = { - classroom: { - recentClassrooms: '最近学习', - today: '今天', - yesterday: '昨天', - daysAgo: '天前', - slides: '页', - nameCopied: '课堂名称已复制', - deleteConfirmTitle: '删除课堂', - delete: '删除', - }, - upload: { - pdfSizeLimit: '支持最大50MB的PDF文件', - generateFailed: '生成课堂失败,请重试', - requirementPlaceholder: - '输入你想学的任何内容,例如:\n「从零学 Python,30 分钟写出第一个程序」\n「用白板给我讲解傅里叶变换」\n「阿瓦隆桌游怎么玩」', - requirementRequired: '请输入课程需求', - fileTooLarge: '文件过大,请选择小于50MB的PDF文件', - }, - generation: { - // Progress steps (used dynamically via activeStep) - analyzingPdf: '解析 PDF 文档', - analyzingPdfDesc: '正在提取文档结构和内容...', - pdfLoadFailed: '无法加载 PDF 文件,请重试', - pdfParseFailed: 'PDF 解析失败', - streamNotReadable: '无法读取生成数据流', - generatingOutlines: '生成课程大纲', - generatingOutlinesDesc: '正在构建学习路径...', - generatingSlideContent: '生成页面内容', - generatingSlideContentDesc: '正在创建幻灯片、测验和互动内容...', - generatingActions: '生成教学动作', - generatingActionsDesc: '正在编排讲解、聚焦和互动流程...', - generationComplete: '生成完成!', - generationFailed: '生成失败', - generatingCourse: '正在生成课程', - openingClassroom: '即将打开课堂...', - outlineReady: '课程大纲已生成', - generatingFirstPage: '首页内容生成中...', - firstPageReady: '首页已就绪!正在打开课堂...', - speechFailed: '语音合成失败', - retryScene: '重试生成', - retryingScene: '正在重新生成...', - backToHome: '返回首页', - sessionNotFound: '未找到生成会话', - sessionNotFoundDesc: '请先填写课程需求开始生成流程。', - goBackAndRetry: '返回重试', - classroomReady: '你的个性化AI学习环境已成功生成。', - aiWorking: 'AI智能体工作中...', - textTruncated: '文档文本较长,已截取前 {n} 字符用于生成', - imageTruncated: '文档含 {total} 张图片,超出上限 {max} 张,多余图片将仅以文字描述传递', - // Agent generation - agentGeneration: '生成课堂角色', - agentGenerationDesc: '正在根据课程内容生成角色...', - agentRevealTitle: '你的课堂角色', - viewAgents: '查看角色', - continue: '继续', - // Outline errors - outlineRetrying: '大纲生成异常,正在重试...', - outlineEmptyResponse: '模型未返回有效的大纲内容,请检查模型配置后重试', - outlineGenerateFailed: '大纲生成失败,请稍后重试', - // Web Search - webSearching: '网络搜索', - webSearchingDesc: '正在搜索网络获取最新资料', - webSearchFailed: '网络搜索失败', - }, -} as const; - -export const generationEnUS = { - classroom: { - recentClassrooms: 'Recent', - today: 'Today', - yesterday: 'Yesterday', - daysAgo: 'days ago', - slides: 'slides', - nameCopied: 'Name copied', - deleteConfirmTitle: 'Delete', - delete: 'Delete', - }, - upload: { - pdfSizeLimit: 'Supports PDF files 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"', - requirementRequired: 'Please enter course requirements', - fileTooLarge: 'File too large. Please select a PDF file smaller than 50MB', - }, - generation: { - // Progress steps (used dynamically via activeStep) - analyzingPdf: 'Analyzing PDF Document', - analyzingPdfDesc: 'Extracting document structure and content...', - pdfLoadFailed: 'Failed to load PDF file, please try again', - pdfParseFailed: 'PDF parsing failed', - streamNotReadable: 'Unable to read generation stream', - generatingOutlines: 'Drafting Course Outline', - generatingOutlinesDesc: 'Structuring the learning path...', - generatingSlideContent: 'Generating Page Content', - generatingSlideContentDesc: 'Creating slides, quizzes, and interactive content...', - generatingActions: 'Generating Teaching Actions', - generatingActionsDesc: 'Orchestrating narration, spotlights, and interactions...', - generationComplete: 'Generation complete!', - generationFailed: 'Generation failed', - generatingCourse: 'Generating course', - openingClassroom: 'Opening classroom...', - outlineReady: 'Course outline generated', - generatingFirstPage: 'Generating first page...', - firstPageReady: 'First page ready! Opening classroom...', - speechFailed: 'Speech generation failed', - retryScene: 'Retry', - retryingScene: 'Regenerating...', - backToHome: 'Back to Home', - sessionNotFound: 'Session Not Found', - sessionNotFoundDesc: 'Please fill in course requirements to start the generation process.', - goBackAndRetry: 'Go Back and Retry', - classroomReady: 'Your personalized AI learning environment has been generated successfully.', - aiWorking: 'AI Agents Working...', - textTruncated: 'Document text is long, using first {n} characters for generation', - imageTruncated: - '{total} images found, exceeding the {max} image limit. Extra images will use text descriptions only', - // Agent generation - agentGeneration: 'Generating Classroom Roles', - agentGenerationDesc: 'Generating roles based on course content...', - agentRevealTitle: 'Your Classroom Roles', - viewAgents: 'View Roles', - continue: 'Continue', - // Outline errors - outlineRetrying: 'Outline generation issue, retrying...', - outlineEmptyResponse: - 'Model returned no valid outlines. Please check model configuration and try again', - outlineGenerateFailed: 'Outline generation failed, please try again later', - // Web Search - webSearching: 'Web Search', - webSearchingDesc: 'Searching the web for up-to-date information', - webSearchFailed: 'Web search failed', - }, -} as const; diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts deleted file mode 100644 index 356fea554..000000000 --- a/lib/i18n/settings.ts +++ /dev/null @@ -1,1196 +0,0 @@ -export const settingsZhCN = { - settings: { - title: '设置', - description: '配置应用程序设置', - language: '语言', - languageDesc: '选择界面语言', - theme: '主题', - themeDesc: '选择主题模式(浅色/深色/跟随系统)', - themeOptions: { - light: '浅色', - dark: '深色', - system: '跟随系统', - }, - apiKey: 'API密钥', - apiKeyDesc: '配置你的API密钥', - apiBaseUrl: 'API端点地址', - apiBaseUrlDesc: '配置你的API端点地址', - apiKeyRequired: 'API密钥不能为空', - model: '模型配置', - modelDesc: '配置AI模型', - modelPlaceholder: '输入或选择模型名称', - ttsModel: 'TTS模型', - ttsModelDesc: '配置TTS模型', - ttsModelPlaceholder: '输入或选择TTS模型名称', - ttsModelOptions: { - openaiTts: 'OpenAI TTS', - azureTts: 'Azure TTS', - }, - availableModels: '可用模型', - modelSelectedViaVoice: '模型随音色选择自动确定', - testConnection: '测试连接', - testConnectionDesc: '测试当前API配置是否可用', - testing: '测试中...', - agentSettings: '智能体设置', - agentSettingsDesc: '选择参与对话的智能体。选择1个为单智能体模式,选择多个为多智能体协作模式。', - 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': '活跃气氛,用幽默让课堂更有趣', - 'default-4': '充满好奇心,总爱追问为什么', - 'default-5': '认真记录,整理课堂重点笔记', - 'default-6': '深入思考,喜欢探讨问题本质', - }, - close: '关闭', - save: '保存', - // Provider settings - providers: '语言模型', - addProviderDescription: '添加自定义模型提供方以扩展可用的AI模型', - providerNames: { - openai: 'OpenAI', - anthropic: 'Claude', - google: 'Gemini', - deepseek: 'DeepSeek', - qwen: '通义千问', - kimi: 'Kimi', - minimax: 'MiniMax', - glm: 'GLM', - siliconflow: '硅基流动', - }, - providerTypes: { - openai: 'OpenAI 协议', - anthropic: 'Claude 协议', - google: 'Gemini 协议', - }, - modelCount: '个模型', - modelSingular: '个模型', - defaultModel: '默认模型', - webSearch: '联网搜索', - mcp: 'MCP', - knowledgeBase: '知识库', - documentParser: '文档解析器', - conversationSettings: '对话设置', - keyboardShortcuts: '键盘快捷键', - generalSettings: '常规设置', - systemSettings: '系统设置', - addProvider: '添加', - importFromClipboard: '从剪贴板导入', - apiSecret: 'API 密钥', - apiHost: 'Base URL', - requestUrl: '请求地址', - models: '模型', - addModel: '新建', - reset: '重置', - fetch: '获取', - connectionSuccess: '连接成功', - connectionFailed: '连接失败', - // Model capabilities - capabilities: { - vision: '视觉', - tools: '工具', - streaming: '流式', - }, - contextWindow: '上下文', - contextShort: '上下文', - outputWindow: '输出', - // Provider management - addProviderButton: '添加', - addProviderDialog: '添加模型提供方', - providerName: '名称', - providerNamePlaceholder: '例如:我的OpenAI代理', - providerNameRequired: '请输入提供方名称', - providerApiMode: 'API 模式', - apiModeOpenAI: 'OpenAI 协议', - apiModeAnthropic: 'Claude 协议', - apiModeGoogle: 'Gemini 协议', - defaultBaseUrl: '默认 Base URL', - providerIcon: 'Provider 图标 URL', - requiresApiKey: '需要 API 密钥', - deleteProvider: '删除提供方', - deleteProviderConfirm: '确定要删除此提供方吗?', - cannotDeleteBuiltIn: '无法删除内置提供方', - resetToDefault: '重置为默认配置', - resetToDefaultDescription: '将模型列表恢复到默认状态(保留 API 密钥和 Base URL)', - resetConfirmDescription: - '此操作将清除所有自定义模型,恢复到内置的默认模型列表。API 密钥和 Base URL 将被保留。', - confirmReset: '确认重置', - resetSuccess: '已成功重置为默认配置', - saveSuccess: '配置已保存', - saveFailed: '保存失败,请重试', - cannotDeleteBuiltInModel: '无法删除内置模型', - cannotEditBuiltInModel: '无法编辑内置模型', - modelIdRequired: '请输入模型 ID', - noModelsAvailable: '没有可用于测试的模型', - providerMetadata: 'Provider 元数据', - // Model editing - editModel: '编辑模型', - editModelDescription: '编辑模型配置和能力', - addNewModel: '新建模型', - addNewModelDescription: '添加新的模型配置', - modelId: '模型ID', - modelIdPlaceholder: '例如:gpt-4o', - modelName: '显示名称', - modelNamePlaceholder: '可选', - modelCapabilities: '能力', - advancedSettings: '高级设置', - contextWindowLabel: '上下文窗口', - contextWindowPlaceholder: '例如 128000', - outputWindowLabel: '最大输出Token数', - outputWindowPlaceholder: '例如 4096', - testModel: '测试模型', - deleteModel: '删除', - cancelEdit: '取消', - saveModel: '保存', - modelsManagementDescription: - '在此管理该提供方的模型列表。若需选择使用的模型,请前往"常规设置"。', - // General settings - howToUse: '使用说明', - step1ConfigureProvider: - '前往"模型提供方"页面,选择或添加一个提供方,配置连接信息(API 密钥、Base URL 等)', - step2SelectModel: '在下方"使用模型"中选择要使用的模型', - step3StartUsing: '保存设置后,系统将使用您选择的模型', - activeModel: '使用模型', - activeModelDescription: '选择当前用于 AI 对话和内容生成的模型', - selectModel: '选择模型', - searchModels: '搜索模型', - noModelsFound: '未找到匹配的模型', - noConfiguredProviders: '暂无已配置的提供方', - configureProvidersFirst: '请先在左侧"模型提供方"中配置提供方连接信息', - currentlyUsing: '当前使用', - // TTS settings - ttsSettings: '语音合成', - // ASR settings - asrSettings: '语音识别', - // Audio settings (legacy) - audioSettings: '音频设置', - ttsSection: '文字转语音 (TTS)', - asrSection: '语音识别 (ASR)', - ttsDescription: 'TTS (Text-to-Speech) - 将文字转换为语音', - asrDescription: 'ASR (Automatic Speech Recognition) - 将语音转换为文字', - enableTTS: '启用语音合成', - ttsEnabledDescription: '开启后,课程生成时将自动合成语音', - ttsVoiceConfigHint: '每个 Agent 的音色可在首页「课堂角色配置」中设置', - enableASR: '启用语音识别', - asrEnabledDescription: '开启后,学生可使用麦克风进行语音输入', - ttsProvider: 'TTS 提供商', - ttsLanguageFilter: '语言筛选', - allLanguages: '全部语言', - ttsVoice: '音色', - ttsSpeed: '语速', - ttsBaseUrl: 'Base URL', - ttsApiKey: 'API 密钥', - doubaoAppId: 'App ID', - doubaoAccessKey: 'Access Key', - asrProvider: 'ASR 提供商', - asrLanguage: '识别语言', - asrBaseUrl: 'Base URL', - asrApiKey: 'API 密钥', - enterApiKey: '输入 API Key', - enterCustomBaseUrl: '输入自定义 Base URL', - browserNativeNote: '浏览器原生 ASR 无需配置,完全免费', - // Audio provider names - providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)', - providerAzureTTS: 'Azure TTS', - providerGLMTTS: 'GLM TTS', - providerQwenTTS: 'Qwen TTS(阿里云百炼)', - providerDoubaoTTS: '豆包 TTS 2.0(火山引擎)', - providerElevenLabsTTS: 'ElevenLabs TTS', - providerMiniMaxTTS: 'MiniMax TTS', - providerBrowserNativeTTS: '浏览器原生 TTS', - providerOpenAIWhisper: 'OpenAI ASR (gpt-4o-mini-transcribe)', - providerBrowserNative: '浏览器原生 ASR', - providerQwenASR: 'Qwen ASR(阿里云百炼)', - providerUnpdf: 'unpdf(内置)', - providerMinerU: 'MinerU', - browserNativeTTSNote: '浏览器原生 TTS 无需配置,完全免费,使用系统内置语音', - testTTS: '测试 TTS', - testASR: '测试 ASR', - testSuccess: '测试成功', - testFailed: '测试失败', - ttsTestText: 'TTS 测试文本', - ttsTestSuccess: 'TTS 测试成功,音频已播放', - ttsTestFailed: 'TTS 测试失败', - asrTestSuccess: '语音识别成功', - asrTestFailed: '语音识别失败', - asrResult: '识别结果', - asrNotSupported: '浏览器不支持语音识别 API', - browserTTSNotSupported: '浏览器不支持语音合成 API', - browserTTSNoVoices: '当前浏览器没有可用的 TTS voice', - microphoneAccessDenied: '麦克风访问被拒绝', - microphoneAccessFailed: '无法访问麦克风', - asrResultPlaceholder: '录音后将显示识别结果', - useThisProvider: '使用此提供商', - fetchVoices: '获取音色列表', - fetchingVoices: '获取中...', - voicesFetched: '已获取音色', - fetchVoicesFailed: '获取音色失败', - voiceApiKeyRequired: '需要 API 密钥', - voiceBaseUrlRequired: '需要 Base URL', - ttsTestTextPlaceholder: '输入要转换的文本', - ttsTestTextDefault: '你好,这是一段测试语音。', - startRecording: '开始录音', - stopRecording: '停止录音', - recording: '录音中...', - transcribing: '识别中...', - transcriptionResult: '识别结果', - noTranscriptionResult: '无识别结果', - baseUrlOptional: 'Base URL(可选)', - defaultValue: '默认', - // TTS Voice descriptions (OpenAI) - voiceMarin: '推荐 - 最佳质量', - voiceCedar: '推荐 - 最佳质量', - voiceAlloy: '中性、平衡', - voiceAsh: '沉稳、专业', - voiceBallad: '优雅、抒情', - voiceCoral: '温暖、友好', - voiceEcho: '男性、清晰', - voiceFable: '叙事、生动', - voiceNova: '女性、明亮', - voiceOnyx: '男性、深沉', - voiceSage: '智慧、沉着', - voiceShimmer: '女性、柔和', - voiceVerse: '自然、流畅', - // TTS Voice descriptions (GLM) - glmVoiceTongtong: '默认音色', - glmVoiceChuichui: '锤锤音色', - glmVoiceXiaochen: '小陈音色', - glmVoiceJam: '动动动物圈jam音色', - glmVoiceKazi: '动动动物圈kazi音色', - glmVoiceDouji: '动动动物圈douji音色', - glmVoiceLuodo: '动动动物圈luodo音色', - // TTS Voice descriptions (Qwen) - qwenVoiceCherry: '阳光积极、亲切自然小姐姐', - qwenVoiceSerena: '温柔小姐姐', - qwenVoiceEthan: '阳光、温暖、活力、朝气', - qwenVoiceChelsie: '二次元虚拟女友', - qwenVoiceMomo: '撒娇搞怪,逗你开心', - qwenVoiceVivian: '拽拽的、可爱的小暴躁', - qwenVoiceMoon: '率性帅气', - qwenVoiceMaia: '知性与温柔的碰撞', - qwenVoiceKai: '耳朵的一场SPA', - qwenVoiceNofish: '不会翘舌音的设计师', - qwenVoiceBella: '喝酒不打醉拳的小萝莉', - qwenVoiceJennifer: '品牌级、电影质感般美语女声', - qwenVoiceRyan: '节奏拉满,戏感炸裂,真实与张力共舞', - qwenVoiceKaterina: '御姐音色,韵律回味十足', - qwenVoiceAiden: '精通厨艺的美语大男孩', - qwenVoiceEldricSage: '沉稳睿智的老者,沧桑如松却心明如镜', - qwenVoiceMia: '温顺如春水,乖巧如初雪', - qwenVoiceMochi: '聪明伶俐的小大人,童真未泯却早慧如禅', - qwenVoiceBellona: '声音洪亮,吐字清晰,人物鲜活,听得人热血沸腾', - qwenVoiceVincent: '一口独特的沙哑烟嗓,一开口便道尽了千军万马与江湖豪情', - qwenVoiceBunny: '"萌属性"爆棚的小萝莉', - qwenVoiceNeil: '专业新闻主持人', - qwenVoiceElias: '专业讲师音色', - qwenVoiceArthur: '被岁月和旱烟浸泡过的质朴嗓音', - qwenVoiceNini: '糯米糍一样又软又黏的嗓音,那一声声拉长了的"哥哥"', - qwenVoiceEbona: '她的低语像一把生锈的钥匙,缓慢转动你内心最深处的幽暗角落', - qwenVoiceSeren: '温和舒缓的声线,助你更快地进入睡眠', - qwenVoicePip: '调皮捣蛋却充满童真的他来了', - qwenVoiceStella: - '平时是甜到发腻的迷糊少女音,但在喊出"代表月亮消灭你"时,瞬间充满不容置疑的爱与正义', - qwenVoiceBodega: '热情的西班牙大叔', - qwenVoiceSonrisa: '热情开朗的拉美大姐', - qwenVoiceAlek: '一开口,是战斗民族的冷,也是毛呢大衣下的暖', - qwenVoiceDolce: '慵懒的意大利大叔', - qwenVoiceSohee: '温柔开朗,情绪丰富的韩国欧尼', - qwenVoiceOnoAnna: '鬼灵精怪的青梅竹马', - qwenVoiceLenn: '理性是底色,叛逆藏在细节里——穿西装也听后朋克的德国青年', - qwenVoiceEmilien: '浪漫的法国大哥哥', - qwenVoiceAndre: '声音磁性,自然舒服、沉稳男生', - qwenVoiceRadioGol: '足球诗人Rádio Gol!今天我要用名字为你们解说足球', - qwenVoiceJada: '风风火火的沪上阿姐', - qwenVoiceDylan: '北京胡同里长大的少年', - qwenVoiceLi: '耐心的瑜伽老师', - qwenVoiceMarcus: '面宽话短,心实声沉——老陕的味道', - qwenVoiceRoy: '诙谐直爽、市井活泼的台湾哥仔形象', - qwenVoicePeter: '天津相声,专业捧哏', - qwenVoiceSunny: '甜到你心里的川妹子', - qwenVoiceEric: '跳脱市井的成都男子', - qwenVoiceRocky: '幽默风趣的阿强', - qwenVoiceKiki: '甜美的港妹闺蜜', - // ASR Language names (native forms - autoglossonyms) - lang_auto: '自动检测', - lang_zh: '中文', - lang_yue: '粤語', - lang_en: 'English', - lang_ja: '日本語', - lang_ko: '한국어', - lang_es: 'Español', - lang_fr: 'Français', - lang_de: 'Deutsch', - lang_ru: 'Русский', - lang_ar: 'العربية', - lang_pt: 'Português', - lang_it: 'Italiano', - lang_af: 'Afrikaans', - lang_hy: 'Հայերեն', - lang_az: 'Azərbaycan', - lang_be: 'Беларуская', - lang_bs: 'Bosanski', - lang_bg: 'Български', - lang_ca: 'Català', - lang_hr: 'Hrvatski', - lang_cs: 'Čeština', - lang_da: 'Dansk', - lang_nl: 'Nederlands', - lang_et: 'Eesti', - lang_fi: 'Suomi', - lang_gl: 'Galego', - lang_el: 'Ελληνικά', - lang_he: 'עברית', - lang_hi: 'हिन्दी', - lang_hu: 'Magyar', - lang_is: 'Íslenska', - lang_id: 'Bahasa Indonesia', - lang_kn: 'ಕನ್ನಡ', - lang_kk: 'Қазақша', - lang_lv: 'Latviešu', - lang_lt: 'Lietuvių', - lang_mk: 'Македонски', - lang_ms: 'Bahasa Melayu', - lang_mr: 'मराठी', - lang_mi: 'Te Reo Māori', - lang_ne: 'नेपाली', - lang_no: 'Norsk', - lang_fa: 'فارسی', - lang_pl: 'Polski', - lang_ro: 'Română', - lang_sr: 'Српски', - lang_sk: 'Slovenčina', - lang_sl: 'Slovenščina', - lang_sw: 'Kiswahili', - lang_sv: 'Svenska', - lang_tl: 'Tagalog', - lang_fil: 'Filipino', - lang_ta: 'தமிழ்', - lang_th: 'ไทย', - lang_tr: 'Türkçe', - lang_uk: 'Українська', - lang_ur: 'اردو', - lang_vi: 'Tiếng Việt', - lang_cy: 'Cymraeg', - // BCP-47 format language codes (for Web Speech API) - 'lang_zh-CN': '中文(简体,中国)', - 'lang_zh-TW': '中文(繁體,台灣)', - 'lang_zh-HK': '粵語(香港)', - 'lang_yue-Hant-HK': '粵語(繁體)', - 'lang_en-US': 'English (United States)', - 'lang_en-GB': 'English (United Kingdom)', - 'lang_en-AU': 'English (Australia)', - 'lang_en-CA': 'English (Canada)', - 'lang_en-IN': 'English (India)', - 'lang_en-NZ': 'English (New Zealand)', - 'lang_en-ZA': 'English (South Africa)', - 'lang_ja-JP': '日本語(日本)', - 'lang_ko-KR': '한국어(대한민국)', - 'lang_de-DE': 'Deutsch (Deutschland)', - 'lang_fr-FR': 'Français (France)', - 'lang_es-ES': 'Español (España)', - 'lang_es-MX': 'Español (México)', - 'lang_es-AR': 'Español (Argentina)', - 'lang_es-CO': 'Español (Colombia)', - 'lang_it-IT': 'Italiano (Italia)', - 'lang_pt-BR': 'Português (Brasil)', - 'lang_pt-PT': 'Português (Portugal)', - 'lang_ru-RU': 'Русский (Россия)', - 'lang_nl-NL': 'Nederlands (Nederland)', - 'lang_pl-PL': 'Polski (Polska)', - 'lang_cs-CZ': 'Čeština (Česko)', - 'lang_da-DK': 'Dansk (Danmark)', - 'lang_fi-FI': 'Suomi (Suomi)', - 'lang_sv-SE': 'Svenska (Sverige)', - 'lang_no-NO': 'Norsk (Norge)', - 'lang_tr-TR': 'Türkçe (Türkiye)', - 'lang_el-GR': 'Ελληνικά (Ελλάδα)', - 'lang_hu-HU': 'Magyar (Magyarország)', - 'lang_ro-RO': 'Română (România)', - 'lang_sk-SK': 'Slovenčina (Slovensko)', - 'lang_bg-BG': 'Български (България)', - 'lang_hr-HR': 'Hrvatski (Hrvatska)', - 'lang_ca-ES': 'Català (Espanya)', - 'lang_ar-SA': 'العربية (السعودية)', - 'lang_ar-EG': 'العربية (مصر)', - 'lang_he-IL': 'עברית (ישראל)', - 'lang_hi-IN': 'हिन्दी (भारत)', - 'lang_th-TH': 'ไทย (ประเทศไทย)', - 'lang_vi-VN': 'Tiếng Việt (Việt Nam)', - 'lang_id-ID': 'Bahasa Indonesia (Indonesia)', - 'lang_ms-MY': 'Bahasa Melayu (Malaysia)', - 'lang_fil-PH': 'Filipino (Pilipinas)', - 'lang_af-ZA': 'Afrikaans (Suid-Afrika)', - 'lang_uk-UA': 'Українська (Україна)', - // PDF settings - pdfSettings: 'PDF 解析', - pdfParsingSettings: 'PDF 解析设置', - pdfDescription: '选择 PDF 解析引擎,支持文本提取、图片处理和表格识别', - pdfProvider: 'PDF 解析器', - pdfFeatures: '支持功能', - pdfApiKey: 'API Key', - pdfBaseUrl: 'Base URL', - mineruDescription: - 'MinerU 是一个商用 PDF 解析服务,支持高级功能如表格提取、公式识别和布局分析。', - mineruApiKeyRequired: '使用前需要在 MinerU 官网申请 API Key。', - mineruWarning: '注意', - mineruCostWarning: 'MinerU 为商用服务,使用可能产生费用。请查看 MinerU 官网了解定价详情。', - enterMinerUApiKey: '输入 MinerU API Key', - mineruLocalDescription: - 'MinerU 支持本地部署,提供高级 PDF 解析功能(表格、公式、布局分析)。需要先部署 MinerU 服务。', - mineruServerAddress: '本地 MinerU 服务器地址(如:http://localhost:8080)', - mineruApiKeyOptional: '仅在服务器启用认证时需要', - optionalApiKey: '可选的 API Key', - featureText: '文本提取', - featureImages: '图片提取', - featureTables: '表格提取', - featureFormulas: '公式识别', - featureLayoutAnalysis: '布局分析', - featureMetadata: '元数据', - // Image Generation settings - enableImageGeneration: '启用 AI 图片生成', - imageGenerationDisabledHint: '启用后,课程生成时将自动生成配图', - imageSettings: '图像生成', - imageSection: '文生图', - imageProvider: '图像生成提供商', - imageModel: '图像生成模型', - providerSeedream: 'Seedream(字节豆包)', - providerQwenImage: 'Qwen Image(阿里通义)', - providerNanoBanana: 'Nano Banana(Gemini)', - providerMiniMaxImage: 'MiniMax 图像', - providerGrokImage: 'Grok Image(xAI)', - testImageGeneration: '测试图像生成', - testImageConnectivity: '测试连接', - imageConnectivitySuccess: '图像服务连接成功', - imageConnectivityFailed: '图像服务连接失败', - imageTestSuccess: '图像生成测试成功', - imageTestFailed: '图像生成测试失败', - imageTestPromptPlaceholder: '输入图像描述进行测试', - imageTestPromptDefault: '一只可爱的猫咪坐在书桌上', - imageGenerating: '正在生成图像...', - imageGenerationFailed: '图像生成失败', - // Video Generation settings - enableVideoGeneration: '启用 AI 视频生成', - videoGenerationDisabledHint: '启用后,课程生成时将自动生成视频', - videoSettings: '视频生成', - videoSection: '文生视频', - videoProvider: '视频生成提供商', - videoModel: '视频生成模型', - providerSeedance: 'Seedance(字节跳动)', - providerKling: '可灵(快手)', - providerVeo: 'Veo(Google)', - providerSora: 'Sora(OpenAI)', - providerMiniMaxVideo: 'MiniMax 视频', - providerGrokVideo: 'Grok Video(xAI)', - testVideoGeneration: '测试视频生成', - testVideoConnectivity: '测试连接', - videoConnectivitySuccess: '视频服务连接成功', - videoConnectivityFailed: '视频服务连接失败', - testingConnection: '正在测试...', - videoTestSuccess: '视频生成测试成功', - videoTestFailed: '视频生成测试失败', - videoTestPromptDefault: '一只可爱的猫咪在书桌上行走', - videoGenerating: '正在生成视频(预计1-2分钟)...', - videoGenerationWarning: '视频生成通常需要1-2分钟,请耐心等待', - mediaRetry: '重试', - mediaContentSensitive: '抱歉,该内容触发了安全检查', - mediaGenerationDisabled: '已在设置中关闭生成', - // Agent settings (kept with main settings block above) - singleAgent: '单智能体模式', - multiAgent: '多智能体模式', - selectAgents: '选择智能体', - noVisionWarning: - '当前模型不支持视觉能力,图片仍可放入幻灯片,但模型无法理解图片内容来优化选择和布局', - // Server provider configuration - serverConfigured: '服务端', - serverConfiguredNotice: - '管理员已在服务端配置了此提供方的 API Key,可直接使用。也可输入自己的 Key 覆盖。', - optionalOverride: '可选,留空则使用服务端配置', - // Access code - setupNeeded: '请先完成配置', - modelNotConfigured: '请选择一个模型以开始使用', - // Clear cache - dangerZone: '危险区域', - clearCache: '清空本地缓存', - clearCacheDescription: - '删除所有本地存储的数据,包括课堂记录、对话历史、音频缓存和应用配置。此操作不可撤销。', - clearCacheConfirmTitle: '确定要清空所有缓存吗?', - clearCacheConfirmDescription: '此操作将永久删除以下所有数据,且无法恢复:', - clearCacheConfirmItems: '课堂和场景数据、对话历史记录、音频和图片缓存、应用设置和偏好', - clearCacheConfirmInput: '请输入「确认删除」以继续', - clearCacheConfirmPhrase: '确认删除', - clearCacheButton: '永久删除所有数据', - clearCacheSuccess: '缓存已清空,页面即将刷新', - clearCacheFailed: '清空缓存失败,请重试', - // Web Search settings - webSearchSettings: '网络搜索', - webSearchApiKey: 'Tavily API Key', - webSearchApiKeyPlaceholder: '输入你的 Tavily API Key', - webSearchApiKeyPlaceholderServer: '已配置服务端密钥,可选填覆盖', - webSearchApiKeyHint: '从 tavily.com 获取 API Key,用于网络搜索', - webSearchBaseUrl: 'Base URL', - webSearchServerConfigured: '服务端已配置 Tavily API Key', - optional: '可选', - }, - profile: { - title: '个人资料', - defaultNickname: '同学', - chooseAvatar: '选择头像', - uploadAvatar: '上传', - bioPlaceholder: '介绍一下自己,AI老师会根据你的背景个性化教学...', - avatarHint: '你的头像将显示在课堂讨论和对话中', - fileTooLarge: '图片过大,请选择小于 5MB 的图片', - 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', - description: 'Configure application settings', - language: 'Language', - languageDesc: 'Select interface language', - theme: 'Theme', - themeDesc: 'Select theme mode (Light/Dark/System)', - themeOptions: { - light: 'Light', - dark: 'Dark', - system: 'System', - }, - apiKey: 'API Key', - apiKeyDesc: 'Configure your API key', - apiBaseUrl: 'API Endpoint URL', - apiBaseUrlDesc: 'Configure your API endpoint URL', - apiKeyRequired: 'API key cannot be empty', - model: 'Model Configuration', - modelDesc: 'Configure AI models', - modelPlaceholder: 'Enter or select model name', - ttsModel: 'TTS Model', - ttsModelDesc: 'Configure TTS models', - ttsModelPlaceholder: 'Enter or select TTS model name', - ttsModelOptions: { - openaiTts: 'OpenAI TTS', - azureTts: 'Azure TTS', - }, - availableModels: 'Available Models', - modelSelectedViaVoice: 'Model is determined by voice selection', - testConnection: 'Test Connection', - testConnectionDesc: 'Test current API configuration is available', - testing: 'Testing...', - agentSettings: 'Agent Settings', - agentSettingsDesc: - 'Select the agents to participate in the conversation. Select 1 for single agent mode, select multiple for multi-agent collaborative mode.', - agentMode: 'Agent Mode', - agentModePreset: 'Preset', - agentModeAuto: 'Auto-generate', - agentModeAutoDesc: 'AI will automatically generate appropriate roles', - autoAgentCount: 'Agent Count', - autoAgentCountDesc: 'Number of agents to auto-generate (including teacher)', - atLeastOneAgent: 'Please select at least 1 agent', - singleAgentMode: 'Single Agent Mode', - directAnswer: 'Direct Answer', - multiAgentMode: 'Multi-Agent Mode', - agentsCollaborating: 'Collaborative Discussion', - agentsCollaboratingCount: '{count} agents selected for collaborative discussion', - maxTurns: 'Max Discussion Turns', - maxTurnsDesc: - 'The maximum number of discussion turns between agents (each agent completes actions and reply counts as one turn)', - priority: 'Priority', - actions: 'Actions', - actionCount: '{count} actions', - selectedAgent: 'Selected Agent', - selectedAgents: 'Selected Agents', - required: 'Required', - agentNames: { - 'default-1': 'AI Teacher', - 'default-2': 'AI Assistant', - 'default-3': 'Class Clown', - 'default-4': 'Curious Mind', - 'default-5': 'Note Taker', - 'default-6': 'Deep Thinker', - }, - agentRoles: { - teacher: 'Teacher', - assistant: 'Assistant', - student: 'Student', - }, - agentDescriptions: { - 'default-1': 'Lead teacher with clear and structured explanations', - 'default-2': 'Supports learning and helps clarify key points', - 'default-3': 'Brings humor and energy to the classroom', - 'default-4': 'Always curious, loves asking why and how', - 'default-5': 'Diligently records and organizes class notes', - 'default-6': 'Thinks deeply and explores the essence of topics', - }, - close: 'Close', - save: 'Save', - // Provider settings - providers: 'LLM', - addProviderDescription: 'Add custom model providers to extend available AI models', - providerNames: { - openai: 'OpenAI', - anthropic: 'Claude', - google: 'Gemini', - deepseek: 'DeepSeek', - qwen: 'Qwen', - kimi: 'Kimi', - minimax: 'MiniMax', - glm: 'GLM', - siliconflow: 'SiliconFlow', - }, - providerTypes: { - openai: 'OpenAI Protocol', - anthropic: 'Claude Protocol', - google: 'Gemini Protocol', - }, - modelCount: 'models', - modelSingular: 'model', - defaultModel: 'Default Model', - webSearch: 'Web Search', - mcp: 'MCP', - knowledgeBase: 'Knowledge Base', - documentParser: 'Document Parser', - conversationSettings: 'Conversation', - keyboardShortcuts: 'Shortcuts', - generalSettings: 'General', - systemSettings: 'System', - addProvider: 'Add', - importFromClipboard: 'Import from Clipboard', - apiSecret: 'API Key', - apiHost: 'Base URL', - requestUrl: 'Request URL', - models: 'Models', - addModel: 'New', - reset: 'Reset', - fetch: 'Fetch', - connectionSuccess: 'Connection successful', - connectionFailed: 'Connection failed', - // Model capabilities - capabilities: { - vision: 'Vision', - tools: 'Tools', - streaming: 'Streaming', - }, - contextWindow: 'Context', - contextShort: 'ctx', - outputWindow: 'Output', - // Provider management - addProviderButton: 'Add', - addProviderDialog: 'Add Model Provider', - providerName: 'Name', - providerNamePlaceholder: 'e.g., My OpenAI Proxy', - providerNameRequired: 'Please enter provider name', - providerApiMode: 'API Mode', - apiModeOpenAI: 'OpenAI Protocol', - apiModeAnthropic: 'Claude Protocol', - apiModeGoogle: 'Gemini Protocol', - defaultBaseUrl: 'Default Base URL', - providerIcon: 'Provider Icon URL', - requiresApiKey: 'Requires API Key', - deleteProvider: 'Delete Provider', - deleteProviderConfirm: 'Are you sure you want to delete this provider?', - cannotDeleteBuiltIn: 'Cannot delete built-in provider', - resetToDefault: 'Reset to Default', - resetToDefaultDescription: - 'Restore model list to default configuration (API key and Base URL will be preserved)', - resetConfirmDescription: - 'This will remove all custom models and restore the built-in default model list. API key and Base URL will be preserved.', - confirmReset: 'Confirm Reset', - resetSuccess: 'Successfully reset to default configuration', - saveSuccess: 'Settings saved', - saveFailed: 'Failed to save settings, please try again', - cannotDeleteBuiltInModel: 'Cannot delete built-in model', - cannotEditBuiltInModel: 'Cannot edit built-in model', - modelIdRequired: 'Please enter model ID', - noModelsAvailable: 'No models available for testing', - providerMetadata: 'Provider Metadata', - // Model editing - editModel: 'Edit Model', - editModelDescription: 'Edit model configuration and capabilities', - addNewModel: 'New Model', - addNewModelDescription: 'Add a new model configuration', - modelId: 'Model ID', - modelIdPlaceholder: 'e.g., gpt-4o', - modelName: 'Display Name', - modelNamePlaceholder: 'Optional', - modelCapabilities: 'Capabilities', - advancedSettings: 'Advanced Settings', - contextWindowLabel: 'Context Window', - contextWindowPlaceholder: 'e.g., 128000', - outputWindowLabel: 'Max Output Tokens', - outputWindowPlaceholder: 'e.g., 4096', - testModel: 'Test Model', - deleteModel: 'Delete', - cancelEdit: 'Cancel', - saveModel: 'Save', - modelsManagementDescription: - 'Manage models for this provider. To select the active model, go to "General".', - // General settings - howToUse: 'How to Use', - step1ConfigureProvider: - 'Go to "Model Providers", select or add a provider, and configure connection settings (API key, Base URL, etc.)', - step2SelectModel: 'Select the model you want to use in "Active Model" below', - step3StartUsing: 'After saving, the system will use your selected model', - activeModel: 'Active Model', - activeModelDescription: 'Select the model for AI conversations and content generation', - selectModel: 'Select Model', - searchModels: 'Search models', - noModelsFound: 'No matching models found', - noConfiguredProviders: 'No configured providers', - configureProvidersFirst: - 'Please configure provider connection settings in "Model Providers" on the left', - currentlyUsing: 'Currently using', - // TTS settings - ttsSettings: 'Text-to-Speech', - // ASR settings - asrSettings: 'Speech Recognition', - // Audio settings (legacy) - audioSettings: 'Audio Settings', - ttsSection: 'Text-to-Speech (TTS)', - asrSection: 'Automatic Speech Recognition (ASR)', - ttsDescription: 'TTS (Text-to-Speech) - Convert text to speech', - asrDescription: 'ASR (Automatic Speech Recognition) - Convert speech to text', - enableTTS: 'Enable Text-to-Speech', - ttsEnabledDescription: 'When enabled, speech audio will be generated during course creation', - ttsVoiceConfigHint: - 'Per-agent voice can be configured in "Classroom Role Config" on the homepage', - enableASR: 'Enable Speech Recognition', - asrEnabledDescription: 'When enabled, students can use microphone for voice input', - ttsProvider: 'TTS Provider', - ttsLanguageFilter: 'Language Filter', - allLanguages: 'All Languages', - ttsVoice: 'Voice', - ttsSpeed: 'Speed', - ttsBaseUrl: 'Base URL', - ttsApiKey: 'API Key', - doubaoAppId: 'App ID', - doubaoAccessKey: 'Access Key', - asrProvider: 'ASR Provider', - asrLanguage: 'Recognition Language', - asrBaseUrl: 'Base URL', - asrApiKey: 'API Key', - enterApiKey: 'Enter API Key', - enterCustomBaseUrl: 'Enter custom Base URL', - browserNativeNote: 'Browser Native ASR requires no configuration and is completely free', - // Audio provider names - providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)', - providerAzureTTS: 'Azure TTS', - providerGLMTTS: 'GLM TTS', - providerQwenTTS: 'Qwen TTS (Alibaba Cloud Bailian)', - providerDoubaoTTS: 'Doubao TTS 2.0 (Volcengine)', - providerElevenLabsTTS: 'ElevenLabs TTS', - providerMiniMaxTTS: 'MiniMax TTS', - providerBrowserNativeTTS: 'Browser Native TTS', - providerOpenAIWhisper: 'OpenAI ASR (gpt-4o-mini-transcribe)', - providerBrowserNative: 'Browser Native ASR', - providerQwenASR: 'Qwen ASR (Alibaba Cloud Bailian)', - providerUnpdf: 'unpdf (Built-in)', - providerMinerU: 'MinerU', - browserNativeTTSNote: - 'Browser Native TTS requires no configuration and is completely free, using system built-in voices', - testTTS: 'Test TTS', - testASR: 'Test ASR', - testSuccess: 'Test Successful', - testFailed: 'Test Failed', - ttsTestText: 'TTS Test Text', - ttsTestSuccess: 'TTS test successful, audio played', - ttsTestFailed: 'TTS test failed', - asrTestSuccess: 'Speech recognition successful', - asrTestFailed: 'Speech recognition failed', - asrResult: 'Recognition Result', - asrNotSupported: 'Browser does not support Speech Recognition API', - browserTTSNotSupported: 'Browser does not support Speech Synthesis API', - browserTTSNoVoices: 'Current browser has no available TTS voices', - microphoneAccessDenied: 'Microphone access denied', - microphoneAccessFailed: 'Failed to access microphone', - asrResultPlaceholder: 'Recognition result will be displayed after recording', - useThisProvider: 'Use This Provider', - fetchVoices: 'Fetch Voice List', - fetchingVoices: 'Fetching...', - voicesFetched: 'Voices fetched', - fetchVoicesFailed: 'Failed to fetch voices', - voiceApiKeyRequired: 'API Key required', - voiceBaseUrlRequired: 'Base URL required', - ttsTestTextPlaceholder: 'Enter text to convert', - ttsTestTextDefault: 'Hello, this is a test speech.', - startRecording: 'Start Recording', - stopRecording: 'Stop Recording', - recording: 'Recording...', - transcribing: 'Transcribing...', - transcriptionResult: 'Transcription Result', - noTranscriptionResult: 'No transcription result', - baseUrlOptional: 'Base URL (Optional)', - defaultValue: 'Default', - // TTS Voice descriptions (OpenAI) - voiceMarin: 'Recommended - Best Quality', - voiceCedar: 'Recommended - Best Quality', - voiceAlloy: 'Neutral, Balanced', - voiceAsh: 'Steady, Professional', - voiceBallad: 'Elegant, Lyrical', - voiceCoral: 'Warm, Friendly', - voiceEcho: 'Male, Clear', - voiceFable: 'Narrative, Vivid', - voiceNova: 'Female, Bright', - voiceOnyx: 'Male, Deep', - voiceSage: 'Wise, Composed', - voiceShimmer: 'Female, Soft', - voiceVerse: 'Natural, Smooth', - // TTS Voice descriptions (GLM) - glmVoiceTongtong: 'Default voice', - glmVoiceChuichui: 'Chuichui voice', - glmVoiceXiaochen: 'Xiaochen voice', - glmVoiceJam: 'Jam voice', - glmVoiceKazi: 'Kazi voice', - glmVoiceDouji: 'Douji voice', - glmVoiceLuodo: 'Luodo voice', - // TTS Voice descriptions (Qwen) - qwenVoiceCherry: 'Sunny, warm and natural', - qwenVoiceSerena: 'Gentle and soft', - qwenVoiceEthan: 'Energetic and vibrant', - qwenVoiceChelsie: 'Anime virtual girlfriend', - qwenVoiceMomo: 'Playful and cheerful', - qwenVoiceVivian: 'Cute and sassy', - qwenVoiceMoon: 'Cool and handsome', - qwenVoiceMaia: 'Intellectual and gentle', - qwenVoiceKai: 'A SPA for your ears', - qwenVoiceNofish: "Designer who can't pronounce retroflex sounds", - qwenVoiceBella: "Little loli who doesn't get drunk", - qwenVoiceJennifer: 'Brand-level, cinematic American female voice', - qwenVoiceRyan: 'Fast-paced, dramatic performance', - qwenVoiceKaterina: 'Mature lady with memorable rhythm', - qwenVoiceAiden: 'American boy who masters cooking', - qwenVoiceEldricSage: 'Steady and wise elder', - qwenVoiceMia: 'Gentle as spring water, well-behaved as snow', - qwenVoiceMochi: 'Smart little adult with childlike innocence', - qwenVoiceBellona: 'Loud voice, clear pronunciation, vivid characters', - qwenVoiceVincent: 'Unique hoarse voice telling tales of war and honor', - qwenVoiceBunny: 'Super cute loli', - qwenVoiceNeil: 'Professional news anchor', - qwenVoiceElias: 'Professional instructor', - qwenVoiceArthur: 'Simple voice soaked by years and dry tobacco', - qwenVoiceNini: 'Soft and sticky voice like glutinous rice cake', - qwenVoiceEbona: 'Her whisper is like a rusty key', - qwenVoiceSeren: 'Gentle and soothing voice to help you sleep', - qwenVoicePip: 'Naughty but full of childlike innocence', - qwenVoiceStella: 'Sweet confused girl voice that becomes just when shouting', - qwenVoiceBodega: 'Enthusiastic Spanish uncle', - qwenVoiceSonrisa: 'Enthusiastic Latin American lady', - qwenVoiceAlek: 'Cold of battle nation, warm under woolen coat', - qwenVoiceDolce: 'Lazy Italian uncle', - qwenVoiceSohee: 'Gentle, cheerful Korean unnie', - qwenVoiceOnoAnna: 'Mischievous childhood friend', - qwenVoiceLenn: 'Rational German youth who wears suit and listens to post-punk', - qwenVoiceEmilien: 'Romantic French big brother', - qwenVoiceAndre: 'Magnetic, natural and calm male voice', - qwenVoiceRadioGol: 'Football poet Rádio Gol!', - qwenVoiceJada: 'Lively Shanghai lady', - qwenVoiceDylan: 'Beijing boy', - qwenVoiceLi: 'Patient yoga teacher', - qwenVoiceMarcus: 'Broad face, short words, solid heart - old Shaanxi taste', - qwenVoiceRoy: 'Humorous and straightforward Taiwanese boy', - qwenVoicePeter: 'Tianjin cross-talk professional supporter', - qwenVoiceSunny: 'Sweet Sichuan girl', - qwenVoiceEric: 'Chengdu gentleman', - qwenVoiceRocky: 'Humorous Hong Kong guy', - qwenVoiceKiki: 'Sweet Hong Kong girl', - // ASR Language names (native forms - autoglossonyms) - lang_auto: 'Auto Detect', - lang_zh: '中文', - lang_yue: '粤語', - lang_en: 'English', - lang_ja: '日本語', - lang_ko: '한국어', - lang_es: 'Español', - lang_fr: 'Français', - lang_de: 'Deutsch', - lang_ru: 'Русский', - lang_ar: 'العربية', - lang_pt: 'Português', - lang_it: 'Italiano', - lang_af: 'Afrikaans', - lang_hy: 'Հայերեն', - lang_az: 'Azərbaycan', - lang_be: 'Беларуская', - lang_bs: 'Bosanski', - lang_bg: 'Български', - lang_ca: 'Català', - lang_hr: 'Hrvatski', - lang_cs: 'Čeština', - lang_da: 'Dansk', - lang_nl: 'Nederlands', - lang_et: 'Eesti', - lang_fi: 'Suomi', - lang_gl: 'Galego', - lang_el: 'Ελληνικά', - lang_he: 'עברית', - lang_hi: 'हिन्दी', - lang_hu: 'Magyar', - lang_is: 'Íslenska', - lang_id: 'Bahasa Indonesia', - lang_kn: 'ಕನ್ನಡ', - lang_kk: 'Қазақша', - lang_lv: 'Latviešu', - lang_lt: 'Lietuvių', - lang_mk: 'Македонски', - lang_ms: 'Bahasa Melayu', - lang_mr: 'मराठी', - lang_mi: 'Te Reo Māori', - lang_ne: 'नेपाली', - lang_no: 'Norsk', - lang_fa: 'فارسی', - lang_pl: 'Polski', - lang_ro: 'Română', - lang_sr: 'Српски', - lang_sk: 'Slovenčina', - lang_sl: 'Slovenščina', - lang_sw: 'Kiswahili', - lang_sv: 'Svenska', - lang_tl: 'Tagalog', - lang_fil: 'Filipino', - lang_ta: 'தமிழ்', - lang_th: 'ไทย', - lang_tr: 'Türkçe', - lang_uk: 'Українська', - lang_ur: 'اردو', - lang_vi: 'Tiếng Việt', - lang_cy: 'Cymraeg', - // BCP-47 format language codes (for Web Speech API) - 'lang_zh-CN': '中文(简体,中国)', - 'lang_zh-TW': '中文(繁體,台灣)', - 'lang_zh-HK': '粵語(香港)', - 'lang_yue-Hant-HK': '粵語(繁體)', - 'lang_en-US': 'English (United States)', - 'lang_en-GB': 'English (United Kingdom)', - 'lang_en-AU': 'English (Australia)', - 'lang_en-CA': 'English (Canada)', - 'lang_en-IN': 'English (India)', - 'lang_en-NZ': 'English (New Zealand)', - 'lang_en-ZA': 'English (South Africa)', - 'lang_ja-JP': '日本語(日本)', - 'lang_ko-KR': '한국어(대한민국)', - 'lang_de-DE': 'Deutsch (Deutschland)', - 'lang_fr-FR': 'Français (France)', - 'lang_es-ES': 'Español (España)', - 'lang_es-MX': 'Español (México)', - 'lang_es-AR': 'Español (Argentina)', - 'lang_es-CO': 'Español (Colombia)', - 'lang_it-IT': 'Italiano (Italia)', - 'lang_pt-BR': 'Português (Brasil)', - 'lang_pt-PT': 'Português (Portugal)', - 'lang_ru-RU': 'Русский (Россия)', - 'lang_nl-NL': 'Nederlands (Nederland)', - 'lang_pl-PL': 'Polski (Polska)', - 'lang_cs-CZ': 'Čeština (Česko)', - 'lang_da-DK': 'Dansk (Danmark)', - 'lang_fi-FI': 'Suomi (Suomi)', - 'lang_sv-SE': 'Svenska (Sverige)', - 'lang_no-NO': 'Norsk (Norge)', - 'lang_tr-TR': 'Türkçe (Türkiye)', - 'lang_el-GR': 'Ελληνικά (Ελλάδα)', - 'lang_hu-HU': 'Magyar (Magyarország)', - 'lang_ro-RO': 'Română (România)', - 'lang_sk-SK': 'Slovenčina (Slovensko)', - 'lang_bg-BG': 'Български (България)', - 'lang_hr-HR': 'Hrvatski (Hrvatska)', - 'lang_ca-ES': 'Català (Espanya)', - 'lang_ar-SA': 'العربية (السعودية)', - 'lang_ar-EG': 'العربية (مصر)', - 'lang_he-IL': 'עברית (ישראל)', - 'lang_hi-IN': 'हिन्दी (भारत)', - 'lang_th-TH': 'ไทย (ประเทศไทย)', - 'lang_vi-VN': 'Tiếng Việt (Việt Nam)', - 'lang_id-ID': 'Bahasa Indonesia (Indonesia)', - 'lang_ms-MY': 'Bahasa Melayu (Malaysia)', - 'lang_fil-PH': 'Filipino (Pilipinas)', - 'lang_af-ZA': 'Afrikaans (Suid-Afrika)', - 'lang_uk-UA': 'Українська (Україна)', - // PDF settings - pdfSettings: 'PDF Parsing', - pdfParsingSettings: 'PDF Parsing Settings', - pdfDescription: - 'Choose PDF parsing engine with support for text extraction, image processing, and table recognition', - pdfProvider: 'PDF Parser', - pdfFeatures: 'Supported Features', - pdfApiKey: 'API Key', - pdfBaseUrl: 'Base URL', - mineruDescription: - 'MinerU is a commercial PDF parsing service that supports advanced features such as table extraction, formula recognition, and layout analysis.', - mineruApiKeyRequired: 'You need to apply for an API Key on the MinerU website before use.', - mineruWarning: 'Warning', - mineruCostWarning: - 'MinerU is a commercial service and may incur fees. Please check the MinerU website for pricing details.', - enterMinerUApiKey: 'Enter MinerU API Key', - mineruLocalDescription: - 'MinerU supports local deployment with advanced PDF parsing (tables, formulas, layout analysis). Requires deploying MinerU service first.', - mineruServerAddress: 'Local MinerU server address (e.g., http://localhost:8080)', - mineruApiKeyOptional: 'Only required if server has authentication enabled', - optionalApiKey: 'Optional API Key', - featureText: 'Text Extraction', - featureImages: 'Image Extraction', - featureTables: 'Table Extraction', - featureFormulas: 'Formula Recognition', - featureLayoutAnalysis: 'Layout Analysis', - featureMetadata: 'Metadata', - // Image Generation settings - enableImageGeneration: 'Enable AI Image Generation', - imageGenerationDisabledHint: - 'When enabled, images will be auto-generated during course creation', - imageSettings: 'Image Generation', - imageSection: 'Text to Image', - imageProvider: 'Image Generation Provider', - imageModel: 'Image Generation Model', - providerSeedream: 'Seedream (ByteDance)', - providerQwenImage: 'Qwen Image (Alibaba)', - providerNanoBanana: 'Nano Banana (Gemini)', - providerMiniMaxImage: 'MiniMax Image', - providerGrokImage: 'Grok Image (xAI)', - testImageGeneration: 'Test Image Generation', - testImageConnectivity: 'Test Connection', - imageConnectivitySuccess: 'Image service connected successfully', - imageConnectivityFailed: 'Image service connection failed', - imageTestSuccess: 'Image generation test succeeded', - imageTestFailed: 'Image generation test failed', - imageTestPromptPlaceholder: 'Enter image description to test', - imageTestPromptDefault: 'A cute cat sitting on a desk', - imageGenerating: 'Generating image...', - imageGenerationFailed: 'Image generation failed', - // Video Generation settings - enableVideoGeneration: 'Enable AI Video Generation', - videoGenerationDisabledHint: - 'When enabled, videos will be auto-generated during course creation', - videoSettings: 'Video Generation', - videoSection: 'Text to Video', - videoProvider: 'Video Generation Provider', - videoModel: 'Video Generation Model', - providerSeedance: 'Seedance (ByteDance)', - providerKling: 'Kling (Kuaishou)', - providerVeo: 'Veo (Google)', - providerSora: 'Sora (OpenAI)', - providerMiniMaxVideo: 'MiniMax Video', - providerGrokVideo: 'Grok Video (xAI)', - testVideoGeneration: 'Test Video Generation', - testVideoConnectivity: 'Test Connection', - videoConnectivitySuccess: 'Video service connected successfully', - videoConnectivityFailed: 'Video service connection failed', - testingConnection: 'Testing...', - videoTestSuccess: 'Video generation test succeeded', - videoTestFailed: 'Video generation test failed', - videoTestPromptDefault: 'A cute cat walking on a desk', - videoGenerating: 'Generating video (est. 1-2 min)...', - videoGenerationWarning: 'Video generation usually takes 1-2 minutes, please be patient', - mediaRetry: 'Retry', - mediaContentSensitive: 'Sorry, this content triggered a safety check.', - mediaGenerationDisabled: 'Generation disabled in settings', - // Agent settings (kept with main settings block above) - singleAgent: 'Single Agent', - multiAgent: 'Multi-Agent', - selectAgents: 'Select Agents', - noVisionWarning: - 'Current model does not support vision. Images can still be placed in slides, but the model cannot understand image content to optimize selection and layout', - // Server provider configuration - serverConfigured: 'Server', - serverConfiguredNotice: - 'Admin has configured an API key for this provider on the server. You can use it directly or enter your own key to override.', - optionalOverride: 'Optional — leave empty to use server config', - // Access code - setupNeeded: 'Setup required', - modelNotConfigured: 'Please select a model to get started', - // Clear cache - dangerZone: 'Danger Zone', - clearCache: 'Clear Local Cache', - clearCacheDescription: - 'Delete all locally stored data, including classroom records, chat history, audio cache, and app settings. This action cannot be undone.', - clearCacheConfirmTitle: 'Are you sure you want to clear all cache?', - clearCacheConfirmDescription: - 'This will permanently delete all of the following data and cannot be recovered:', - clearCacheConfirmItems: - 'Classrooms & scenes, Chat history, Audio & image cache, App settings & preferences', - clearCacheConfirmInput: 'Type "DELETE" to continue', - clearCacheConfirmPhrase: 'DELETE', - clearCacheButton: 'Permanently Delete All Data', - clearCacheSuccess: 'Cache cleared, page will refresh shortly', - clearCacheFailed: 'Failed to clear cache, please try again', - // Web Search settings - webSearchSettings: 'Web Search', - webSearchApiKey: 'Tavily API Key', - webSearchApiKeyPlaceholder: 'Enter your Tavily API Key', - webSearchApiKeyPlaceholderServer: 'Server key configured, optionally override', - webSearchApiKeyHint: 'Get an API key from tavily.com for web search', - webSearchBaseUrl: 'Base URL', - webSearchServerConfigured: 'Server-side Tavily API key is configured', - optional: 'Optional', - }, - profile: { - title: 'Profile', - defaultNickname: 'Student', - chooseAvatar: 'Choose Avatar', - uploadAvatar: 'Upload', - bioPlaceholder: 'Tell us about yourself — the AI teacher will personalize lessons for you...', - avatarHint: 'Your avatar will appear in classroom discussions and chats', - fileTooLarge: 'Image too large — please choose one under 5 MB', - invalidFileType: 'Please select an image file', - editTooltip: 'Click to edit profile', - }, - media: { - imageCapability: 'Image Generation', - imageHint: 'Generate images in slides', - videoCapability: 'Video Generation', - videoHint: 'Generate videos in slides', - ttsCapability: 'Text-to-Speech', - ttsHint: 'AI teacher speaks aloud', - asrCapability: 'Speech Recognition', - asrHint: 'Voice input for discussion', - provider: 'Provider', - model: 'Model', - voice: 'Voice', - speed: 'Speed', - language: 'Language', - }, -} as const; diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts deleted file mode 100644 index 0a376ca10..000000000 --- a/lib/i18n/stage.ts +++ /dev/null @@ -1,298 +0,0 @@ -export const stageZhCN = { - stage: { - currentScene: '当前场景', - generating: '生成中...', - paused: '已暂停', - generationFailed: '生成失败', - confirmSwitchTitle: '切换页面', - confirmSwitchMessage: '当前话题正在进行中,切换页面将结束当前话题。确定要切换吗?', - generatingNextPage: '场景正在生成,请稍候...', - fullscreen: '全屏', - exitFullscreen: '退出全屏', - }, - whiteboard: { - title: '互动白板', - open: '打开白板', - clear: '清空白板', - minimize: '最小化白板', - ready: '白板已就绪', - readyHint: 'AI 添加元素后将在此显示', - clearSuccess: '白板已清空', - clearError: '清空白板失败:', - resetView: '重置视图', - restoreError: '恢复白板失败:', - history: '历史记录', - restore: '恢复', - 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: '请稍候,正在分析你的答案', - quizReport: '答题报告', - retry: '重新答题', - }, - roundtable: { - teacher: '教师', - you: '你', - inputPlaceholder: '输入你的消息...', - listening: '录音中...', - processing: '处理中...', - noSpeechDetected: '未检测到语音,请重试', - discussionEnded: '讨论已结束', - qaEnded: '问答已结束', - thinking: '思考中', - yourTurn: '轮到你发言了', - stopDiscussion: '结束讨论', - autoPlay: '自动播放', - autoPlayOff: '关闭自动播放', - speed: '倍速', - voiceInput: '语音输入', - voiceInputDisabled: '语音输入已禁用', - textInput: '文字输入', - stopRecording: '停止录音', - startRecording: '开始录音', - }, - 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: '第一步:选择角色', - desc: '项目生成后,从角色列表中选择一个角色(标记为🟢的非系统角色)', - }, - step2: { - title: '第二步:完成任务', - desc: '每个任务代表一个学习目标:', - s1: { - title: '查看当前任务', - desc: '查看任务的标题、描述、负责人', - }, - s2: { - title: '获取指导', - example: '@question 我应该从哪里开始?\n@question 如何实现这个功能?', - desc: '提问助手会提供引导性问题和提示(不直接给答案)', - }, - s3: { - title: '提交作品', - example: '@judge 我已经完成了,请检查', - desc: '评审助手会评估你的工作并给出反馈:', - complete: '自动进入下一个任务', - revision: '根据反馈改进', - }, - }, - step3: { - title: '第三步:完成项目', - desc: '所有任务完成后,系统会显示「🎉 项目已完成!」', - }, - }, - }, - share: { - notReady: '生成完成后可分享', - }, -} as const; - -export const stageEnUS = { - stage: { - currentScene: 'Current Scene', - generating: 'Generating...', - paused: 'Paused', - generationFailed: 'Generation failed', - confirmSwitchTitle: 'Switch Scene', - confirmSwitchMessage: - 'A topic is currently in progress. Switching scenes will end the current topic. Are you sure?', - generatingNextPage: 'Scene is being generated, please wait...', - fullscreen: 'Fullscreen', - exitFullscreen: 'Exit Fullscreen', - }, - whiteboard: { - title: 'Interactive Whiteboard', - open: 'Open Whiteboard', - clear: 'Clear Whiteboard', - minimize: 'Minimize Whiteboard', - ready: 'Whiteboard is ready', - readyHint: 'Elements will appear here when added by AI', - clearSuccess: 'Whiteboard cleared successfully', - clearError: 'Failed to clear whiteboard: ', - resetView: 'Reset View', - restoreError: 'Failed to restore whiteboard: ', - history: 'History', - restore: 'Restore', - noHistory: 'No history yet', - restored: 'Whiteboard restored', - elementCount: '{count} elements', - }, - quiz: { - title: 'Quiz', - subtitle: 'Test your knowledge', - questionsCount: 'questions', - totalPrefix: '', - pointsSuffix: 'pts', - startQuiz: 'Start Quiz', - multipleChoiceHint: '(Multiple choice — select all correct answers)', - inputPlaceholder: 'Type your answer here...', - charCount: 'chars', - yourAnswer: 'Your answer:', - notAnswered: 'Not answered', - aiComment: 'AI Feedback', - singleChoice: 'Single', - multipleChoice: 'Multiple', - shortAnswer: 'Short answer', - analysis: 'Analysis: ', - excellent: 'Excellent!', - keepGoing: 'Keep going!', - needsReview: 'Needs review', - correct: 'correct', - incorrect: 'incorrect', - answering: 'In Progress', - submitAnswers: 'Submit Answers', - aiGrading: 'AI is grading...', - aiGradingWait: 'Please wait, analyzing your answers', - quizReport: 'Quiz Report', - retry: 'Retry', - }, - roundtable: { - teacher: 'TEACHER', - you: 'YOU', - inputPlaceholder: 'Type your message...', - listening: 'Listening...', - processing: 'Processing...', - noSpeechDetected: 'No speech detected, please try again', - discussionEnded: 'Discussion ended', - qaEnded: 'Q&A ended', - thinking: 'Thinking', - yourTurn: 'Your turn', - stopDiscussion: 'Stop Discussion', - autoPlay: 'Auto-play', - autoPlayOff: 'Stop auto-play', - speed: 'Speed', - voiceInput: 'Voice input', - voiceInputDisabled: 'Voice input disabled', - textInput: 'Text input', - stopRecording: 'Stop recording', - startRecording: 'Start recording', - }, - pbl: { - legacyFormat: 'This PBL scene uses a legacy format. Please regenerate the course.', - emptyProject: 'PBL project has not been generated yet. Please create via course generation.', - roleSelection: { - title: 'Choose Your Role', - description: 'Select a role to start collaborating on the project', - }, - workspace: { - restart: 'Restart', - confirmRestart: 'Reset all progress?', - confirm: 'Confirm', - cancel: 'Cancel', - }, - issueboard: { - title: 'Issue Board', - noIssues: 'No issues yet', - statusDone: 'Done', - statusActive: 'Active', - statusPending: 'Pending', - }, - chat: { - title: 'Project Discussion', - currentIssue: 'Current Issue', - mentionHint: 'Use @question to ask, @judge to submit for review', - placeholder: 'Type a message...', - send: 'Send', - welcomeMessage: - 'Hello! I\'m your Question Agent for this issue: "{title}"\n\nTo help guide your work, I\'ve prepared some questions for you:\n\n{questions}\n\nFeel free to @question me anytime if you need help or clarification!', - issueCompleteMessage: 'Issue "{completed}" completed! Moving to next issue: "{next}"', - allCompleteMessage: '🎉 All issues completed! Great work on the project!', - }, - guide: { - howItWorks: 'How it works', - help: 'Help', - title: 'Help', - step1: { - title: 'Step 1: Choose a Role', - desc: 'After the project is generated, select a role from the list (non-system roles marked with 🟢)', - }, - step2: { - title: 'Step 2: Complete Issues', - desc: 'Each issue represents a learning task:', - s1: { - title: 'View current Issue', - desc: "Check the issue's title, description, and assignee", - }, - s2: { - title: 'Get guidance', - example: '@question Where should I start?\n@question How do I implement this feature?', - desc: 'The Question Agent provides guiding questions and hints (no direct answers)', - }, - s3: { - title: 'Submit your work', - example: "@judge I'm done, please check my Notes", - desc: 'The Judge Agent evaluates your work and gives feedback:', - complete: 'Automatically moves to the next issue', - revision: 'Improve based on feedback', - }, - }, - step3: { - title: 'Step 3: Complete the Project', - desc: 'When all issues are done, the system displays "🎉 Project Complete!"', - }, - }, - }, - share: { - notReady: 'Available after generation completes', - }, -} as const; From 5319e07b98cbbfd03b5a21c77286c4c8173a6612 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Mon, 30 Mar 2026 18:17:31 +0800 Subject: [PATCH 11/16] style(i18n): fix prettier formatting in locale JSON files Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/i18n/locales/en-US.json | 2 +- lib/i18n/locales/zh-CN.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 51ed75214..a9e2b1334 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -872,4 +872,4 @@ "speed": "Speed", "language": "Language" } -} \ No newline at end of file +} diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 73adb031e..de06e5d99 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -872,4 +872,4 @@ "speed": "语速", "language": "语言" } -} \ No newline at end of file +} From 9a07750b0b19d11970dcf173866908a76e46ab1f Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Fri, 3 Apr 2026 15:09:46 +0800 Subject: [PATCH 12/16] refactor(i18n): replace hardcoded language options with central locale registry Add lib/i18n/locales.ts as a single source of truth for supported languages. Language selectors in header and homepage now render dynamically from this registry. Also adds supportedLngs to i18next config so unsupported languages fall back correctly. Adding a new language now only requires a JSON file and one line in the registry. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/page.tsx | 45 +++++++++++++++++-------------------------- components/header.tsx | 45 +++++++++++++++++-------------------------- lib/i18n/config.ts | 7 +++++-- lib/i18n/index.ts | 1 + lib/i18n/locales.ts | 19 ++++++++++++++++++ 5 files changed, 61 insertions(+), 56 deletions(-) create mode 100644 lib/i18n/locales.ts diff --git a/app/page.tsx b/app/page.tsx index af265f67b..565edebbc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,6 +20,7 @@ import { ChevronUp, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; +import { supportedLocales } from '@/lib/i18n'; import { createLogger } from '@/lib/logger'; import { Button } from '@/components/ui/button'; import { Textarea as UITextarea } from '@/components/ui/textarea'; @@ -335,36 +336,26 @@ 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'} + {supportedLocales.find((l) => l.code === locale)?.shortLabel ?? locale} {languageOpen && (
- - + {supportedLocales.map((l) => ( + + ))}
)}
diff --git a/components/header.tsx b/components/header.tsx index 5a61ec96f..b2d60841b 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -12,6 +12,7 @@ import { Package, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; +import { supportedLocales } from '@/lib/i18n'; import { useTheme } from '@/lib/hooks/use-theme'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; @@ -108,36 +109,26 @@ 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'} + {supportedLocales.find((l) => l.code === locale)?.shortLabel ?? locale} {languageOpen && (
- - + {supportedLocales.map((l) => ( + + ))}
)}
diff --git a/lib/i18n/config.ts b/lib/i18n/config.ts index 254da0641..f0a427800 100644 --- a/lib/i18n/config.ts +++ b/lib/i18n/config.ts @@ -1,13 +1,16 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; +import { supportedLocales } from './locales'; +import { defaultLocale } from './types'; i18n .use(initReactI18next) .use(resourcesToBackend((language: string) => import(`./locales/${language}.json`))) .init({ - lng: 'zh-CN', - fallbackLng: 'zh-CN', + lng: defaultLocale, + fallbackLng: defaultLocale, + supportedLngs: supportedLocales.map((l) => l.code), interpolation: { escapeValue: false, }, diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts index ab44752ca..e845b2794 100644 --- a/lib/i18n/index.ts +++ b/lib/i18n/index.ts @@ -1,6 +1,7 @@ import i18n from './config'; export { type Locale, defaultLocale } from './types'; +export { type LocaleEntry, supportedLocales } from './locales'; export type TranslationKey = string; export function translate(locale: string, key: string): string { diff --git a/lib/i18n/locales.ts b/lib/i18n/locales.ts new file mode 100644 index 000000000..f2d08fa86 --- /dev/null +++ b/lib/i18n/locales.ts @@ -0,0 +1,19 @@ +export type LocaleEntry = { + code: string; + /** Native name shown in dropdown, e.g. '简体中文' */ + label: string; + /** Short label shown on the toggle button, e.g. 'CN' */ + shortLabel: string; +}; + +/** + * Supported locales registry. + * + * To add a new language: + * 1. Create `lib/i18n/locales/.json` (copy an existing file as template) + * 2. Add an entry here + */ +export const supportedLocales: LocaleEntry[] = [ + { code: 'zh-CN', label: '简体中文', shortLabel: 'CN' }, + { code: 'en-US', label: 'English', shortLabel: 'EN' }, +]; From eb363f104191755a42d038099e94269e3119b345 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Fri, 3 Apr 2026 15:29:05 +0800 Subject: [PATCH 13/16] fix(i18n): add missing rename keys to JSON locale files Add classroom.rename, classroom.renamePlaceholder, and classroom.renameFailed that were added to generation.ts on main but missing from the JSON locale files. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/i18n/locales/en-US.json | 5 ++++- lib/i18n/locales/zh-CN.json | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index a9e2b1334..e7e8e91e2 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -262,7 +262,10 @@ "slides": "slides", "nameCopied": "Name copied", "deleteConfirmTitle": "Delete", - "delete": "Delete" + "delete": "Delete", + "rename": "Rename", + "renamePlaceholder": "Enter classroom name", + "renameFailed": "Failed to rename classroom" }, "upload": { "pdfSizeLimit": "Supports PDF files up to 50MB", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index de06e5d99..61037a033 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -262,7 +262,10 @@ "slides": "页", "nameCopied": "课堂名称已复制", "deleteConfirmTitle": "删除课堂", - "delete": "删除" + "delete": "删除", + "rename": "重命名", + "renamePlaceholder": "输入课堂名称", + "renameFailed": "重命名失败" }, "upload": { "pdfSizeLimit": "支持最大50MB的PDF文件", From c0f8c5bf3a41b4bd0a64c63b154f950338d02ed6 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Fri, 3 Apr 2026 16:36:15 +0800 Subject: [PATCH 14/16] refactor(i18n): extract LanguageSwitcher, restore type safety, fix locale resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared component from page.tsx and header.tsx - Derive Locale type from supportedLocales registry (as const satisfies) - Add resolveLocale() to match browser language prefixes (e.g. 'en' → 'en-US') - Remove config.ts changes (nonExplicitSupportedLngs + inline resources) that broke translation loading when combined with resourcesToBackend Co-Authored-By: Claude Opus 4.6 (1M context) --- app/page.tsx | 43 +++------------------ components/header.tsx | 48 +++--------------------- components/language-switcher.tsx | 64 ++++++++++++++++++++++++++++++++ lib/hooks/use-i18n.tsx | 18 +++++++-- lib/i18n/locales.ts | 4 +- lib/i18n/types.ts | 4 +- 6 files changed, 95 insertions(+), 86 deletions(-) create mode 100644 components/language-switcher.tsx diff --git a/app/page.tsx b/app/page.tsx index 927bdb052..6163979b3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,7 +20,7 @@ import { ChevronUp, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; -import { supportedLocales } from '@/lib/i18n'; +import { LanguageSwitcher } from '@/components/language-switcher'; import { createLogger } from '@/lib/logger'; import { Button } from '@/components/ui/button'; import { Textarea as UITextarea } from '@/components/ui/textarea'; @@ -70,7 +70,7 @@ const initialFormState: FormState = { }; function HomePage() { - const { t, locale, setLocale } = useI18n(); + const { t } = useI18n(); const { theme, setTheme } = useTheme(); const router = useRouter(); const [form, setForm] = useState(initialFormState); @@ -125,7 +125,6 @@ function HomePage() { } } - const [languageOpen, setLanguageOpen] = useState(false); const [themeOpen, setThemeOpen] = useState(false); const [error, setError] = useState(null); const [classrooms, setClassrooms] = useState([]); @@ -136,16 +135,15 @@ function HomePage() { // Close dropdowns when clicking outside useEffect(() => { - if (!languageOpen && !themeOpen) return; + if (!themeOpen) return; const handleClickOutside = (e: MouseEvent) => { if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) { - setLanguageOpen(false); setThemeOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, [languageOpen, themeOpen]); + }, [themeOpen]); const loadClassrooms = async () => { try { @@ -339,37 +337,7 @@ function HomePage() { className="fixed top-4 right-4 z-50 flex items-center gap-1 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm" > {/* Language Selector */} -
- - {languageOpen && ( -
- {supportedLocales.map((l) => ( - - ))} -
- )} -
+ setThemeOpen(false)} />
@@ -378,7 +346,6 @@ function HomePage() { - {languageOpen && ( -
- {supportedLocales.map((l) => ( - - ))} -
- )} -
+ setThemeOpen(false)} />
@@ -140,7 +105,6 @@ export function Header({ currentSceneTitle }: HeaderProps) { + {open && ( +
+ {supportedLocales.map((l) => ( + + ))} +
+ )} +
+ ); +} diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index eb2e1faf2..18936cc1c 100644 --- a/lib/hooks/use-i18n.tsx +++ b/lib/hooks/use-i18n.tsx @@ -2,11 +2,22 @@ import { createContext, useContext, useEffect, ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { type Locale, defaultLocale } from '@/lib/i18n'; +import { type Locale, defaultLocale, supportedLocales } from '@/lib/i18n'; import '@/lib/i18n/config'; const LOCALE_STORAGE_KEY = 'locale'; +/** Match a browser language code (e.g. 'en', 'zh-TW') to a supported locale */ +function resolveLocale(lang: string): Locale { + // Exact match + const exact = supportedLocales.find((l) => l.code === lang); + if (exact) return exact.code; + // Prefix match: 'en' → 'en-US', 'zh' → 'zh-CN' + const prefix = lang.split('-')[0].toLowerCase(); + const match = supportedLocales.find((l) => l.code.toLowerCase().startsWith(prefix)); + return match?.code ?? defaultLocale; +} + type I18nContextType = { locale: Locale; setLocale: (locale: Locale) => void; @@ -18,7 +29,7 @@ const I18nContext = createContext(undefined); export function I18nProvider({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); - const locale = i18n.language || defaultLocale; + const locale = (i18n.language || defaultLocale) as Locale; // Detect language after hydration to avoid SSR mismatch. // i18next handles fallback automatically: if the detected language @@ -26,7 +37,8 @@ export function I18nProvider({ children }: { children: ReactNode }) { useEffect(() => { try { const stored = localStorage.getItem(LOCALE_STORAGE_KEY); - const target = stored || navigator.language || defaultLocale; + const raw = stored || navigator.language || defaultLocale; + const target = resolveLocale(raw); if (target !== i18n.language) i18n.changeLanguage(target); } catch { // localStorage unavailable, keep default diff --git a/lib/i18n/locales.ts b/lib/i18n/locales.ts index f2d08fa86..b155914f5 100644 --- a/lib/i18n/locales.ts +++ b/lib/i18n/locales.ts @@ -13,7 +13,7 @@ export type LocaleEntry = { * 1. Create `lib/i18n/locales/.json` (copy an existing file as template) * 2. Add an entry here */ -export const supportedLocales: LocaleEntry[] = [ +export const supportedLocales = [ { code: 'zh-CN', label: '简体中文', shortLabel: 'CN' }, { code: 'en-US', label: 'English', shortLabel: 'EN' }, -]; +] as const satisfies readonly LocaleEntry[]; diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts index 12656c3df..a58744507 100644 --- a/lib/i18n/types.ts +++ b/lib/i18n/types.ts @@ -1,3 +1,5 @@ -export type Locale = string; +import { supportedLocales } from './locales'; + +export type Locale = (typeof supportedLocales)[number]['code']; export const defaultLocale: Locale = 'zh-CN'; From 93f5e4200784d2eab4183429c6fb4f4fdfacca92 Mon Sep 17 00:00:00 2001 From: yangshen <1322568757@qq.com> Date: Fri, 3 Apr 2026 17:57:52 +0800 Subject: [PATCH 15/16] fix(i18n): use i18next double-brace interpolation instead of manual .replace() - Convert all {var} placeholders in locale JSONs to {{var}} (i18next default) - Replace .replace('{var}', value) calls with t(key, {var: value}) in all consuming components: lecture-notes-view, pbl-renderer, use-pbl-chat, generation-preview, whiteboard-history, agent-settings - Widen t() type signature in handleIssueComplete to accept interpolation options Co-Authored-By: Claude Opus 4.6 (1M context) --- app/generation-preview/page.tsx | 8 ++----- components/chat/lecture-notes-view.tsx | 2 +- components/scene-renderers/pbl-renderer.tsx | 7 +++--- .../scene-renderers/pbl/use-pbl-chat.ts | 23 +++++++++++-------- components/settings/agent-settings.tsx | 7 +++--- components/whiteboard/whiteboard-history.tsx | 5 +--- lib/i18n/locales/en-US.json | 18 +++++++-------- lib/i18n/locales/zh-CN.json | 18 +++++++-------- 8 files changed, 42 insertions(+), 46 deletions(-) diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index b63b4eb69..936cfe5c6 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -278,15 +278,11 @@ function GenerationPreviewContent() { // Truncation warnings const warnings: string[] = []; if ((parseResult.data.text as string).length > MAX_PDF_CONTENT_CHARS) { - warnings.push( - t('generation.textTruncated').replace('{n}', String(MAX_PDF_CONTENT_CHARS)), - ); + warnings.push(t('generation.textTruncated', { n: MAX_PDF_CONTENT_CHARS })); } if (images.length > MAX_VISION_IMAGES) { warnings.push( - t('generation.imageTruncated') - .replace('{total}', String(images.length)) - .replace('{max}', String(MAX_VISION_IMAGES)), + t('generation.imageTruncated', { total: images.length, max: MAX_VISION_IMAGES }), ); } if (warnings.length > 0) { diff --git a/components/chat/lecture-notes-view.tsx b/components/chat/lecture-notes-view.tsx index 2669a1afb..309283af8 100644 --- a/components/chat/lecture-notes-view.tsx +++ b/components/chat/lecture-notes-view.tsx @@ -67,7 +67,7 @@ export function LectureNotesView({ notes, currentSceneId }: LectureNotesViewProp {notes.map((note, index) => { const isCurrent = note.sceneId === currentSceneId; const pageNum = index + 1; - const pageLabel = t('chat.lectureNotes.pageLabel').replace('{n}', String(pageNum)); + const pageLabel = t('chat.lectureNotes.pageLabel', { n: pageNum }); return (
i.is_active); if (activeIssue?.generated_questions && newConfig.chat.messages.length === 0) { - const welcomeMsg = t('pbl.chat.welcomeMessage') - .replace('{title}', activeIssue.title) - .replace('{questions}', activeIssue.generated_questions); + const welcomeMsg = t('pbl.chat.welcomeMessage', { + title: activeIssue.title, + questions: activeIssue.generated_questions, + }); newConfig.chat = { messages: [ { diff --git a/components/scene-renderers/pbl/use-pbl-chat.ts b/components/scene-renderers/pbl/use-pbl-chat.ts index 8c41ff85b..2708e6fc7 100644 --- a/components/scene-renderers/pbl/use-pbl-chat.ts +++ b/components/scene-renderers/pbl/use-pbl-chat.ts @@ -165,7 +165,7 @@ async function handleIssueComplete( config: PBLProjectConfig, completedIssue: PBLIssue, headers: Record, - t: (key: string) => string, + t: (key: string, options?: Record) => string, ) { // Mark current issue as done const issue = config.issueboard.issues.find((i) => i.id === completedIssue.id); @@ -226,9 +226,10 @@ async function handleIssueComplete( config.chat.messages.push({ id: `msg_${Date.now()}_welcome`, agent_name: nextIssue.question_agent_name, - message: t('pbl.chat.welcomeMessage') - .replace('{title}', nextIssue.title) - .replace('{questions}', data.message), + message: t('pbl.chat.welcomeMessage', { + title: nextIssue.title, + questions: data.message, + }), timestamp: Date.now(), read_by: [], }); @@ -241,9 +242,10 @@ async function handleIssueComplete( config.chat.messages.push({ id: `msg_${Date.now()}_welcome`, agent_name: nextIssue.question_agent_name, - message: t('pbl.chat.welcomeMessage') - .replace('{title}', nextIssue.title) - .replace('{questions}', nextIssue.generated_questions), + message: t('pbl.chat.welcomeMessage', { + title: nextIssue.title, + questions: nextIssue.generated_questions, + }), timestamp: Date.now(), read_by: [], }); @@ -253,9 +255,10 @@ async function handleIssueComplete( config.chat.messages.push({ id: `msg_${Date.now()}_system`, agent_name: 'System', - message: t('pbl.chat.issueCompleteMessage') - .replace('{completed}', completedIssue.title) - .replace('{next}', nextIssue.title), + message: t('pbl.chat.issueCompleteMessage', { + completed: completedIssue.title, + next: nextIssue.title, + }), timestamp: Date.now(), read_by: [], }); diff --git a/components/settings/agent-settings.tsx b/components/settings/agent-settings.tsx index 6978bff73..ad0c9aa8a 100644 --- a/components/settings/agent-settings.tsx +++ b/components/settings/agent-settings.tsx @@ -159,10 +159,9 @@ export function AgentSettings({ {t('settings.multiAgentMode')} -{' '} - {t('settings.agentsCollaboratingCount').replace( - '{count}', - String(selectedAgentIds.length), - )} + {t('settings.agentsCollaboratingCount', { + count: selectedAgentIds.length, + })} )}
diff --git a/components/whiteboard/whiteboard-history.tsx b/components/whiteboard/whiteboard-history.tsx index 853292f43..e89274214 100644 --- a/components/whiteboard/whiteboard-history.tsx +++ b/components/whiteboard/whiteboard-history.tsx @@ -142,10 +142,7 @@ export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) {
{formatTime(snap.timestamp)} ·{' '} - {t('whiteboard.elementCount').replace( - '{count}', - String(snap.elements.length), - )} + {t('whiteboard.elementCount', { count: snap.elements.length })}