diff --git a/app/page.tsx b/app/page.tsx index dc01d95dd..08d85cfd9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -55,16 +55,19 @@ const log = createLogger('Home'); const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; +const LANGUAGE_STORAGE_KEY = 'courseLanguage'; interface FormState { pdfFile: File | null; requirement: string; + language: 'zh-CN' | 'zh-TW' | 'en-US'; webSearch: boolean; } const initialFormState: FormState = { pdfFile: null, requirement: '', + language: 'zh-CN', webSearch: false, }; @@ -97,8 +100,15 @@ function HomePage() { } try { const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY); + const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); const updates: Partial = {}; if (savedWebSearch === 'true') updates.webSearch = true; + if (savedLanguage === 'zh-CN' || savedLanguage === 'zh-TW' || savedLanguage === 'en-US') { + updates.language = savedLanguage; + } else { + const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + updates.language = detected; + } if (Object.keys(updates).length > 0) { setForm((prev) => ({ ...prev, ...updates })); } @@ -257,6 +267,7 @@ function HomePage() { const userProfile = useUserProfileStore.getState(); const requirements: UserRequirements = { requirement: form.requirement, + language: form.language, userNickname: userProfile.nickname || undefined, userBio: userProfile.bio || undefined, webSearch: form.webSearch || undefined, @@ -503,6 +514,8 @@ function HomePage() {
updateForm('language', lang)} webSearch={form.webSearch} onWebSearchChange={(v) => updateForm('webSearch', v)} onSettingsOpen={(section) => { diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index e95ff1a90..c7bd79071 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useRef, useMemo } from 'react'; -import { Bot, Check, ChevronLeft, Paperclip, FileText, X, Globe2 } from 'lucide-react'; +import { Bot, Check, ChevronLeft, Paperclip, FileText, X, Globe, Globe2 } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, @@ -28,7 +28,11 @@ const MAX_PDF_SIZE_MB = 50; const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── +export type CourseLanguage = 'zh-CN' | 'zh-TW' | 'en-US'; + export interface GenerationToolbarProps { + language: CourseLanguage; + onLanguageChange: (lang: CourseLanguage) => void; webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; @@ -40,6 +44,8 @@ export interface GenerationToolbarProps { // ─── Component ─────────────────────────────────────────────── export function GenerationToolbar({ + language, + onLanguageChange, webSearch, onWebSearchChange, onSettingsOpen, @@ -356,6 +362,25 @@ export function GenerationToolbar({ )} + {/* ── Language pill ── */} + + + + + {t('toolbar.languageHint')} + + {/* ── Separator ── */}
diff --git a/components/settings/tts-settings.tsx b/components/settings/tts-settings.tsx index d253b8aad..6c80f30e2 100644 --- a/components/settings/tts-settings.tsx +++ b/components/settings/tts-settings.tsx @@ -32,7 +32,7 @@ interface TTSSettingsProps { } export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { - const { t } = useI18n(); + const { t, locale } = useI18n(); const ttsVoice = useSettingsStore((state) => state.ttsVoice); const ttsSpeed = useSettingsStore((state) => state.ttsSpeed); @@ -116,6 +116,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { ttsProvidersConfig[selectedProviderId]?.baseUrl || providerConfig?.customDefaultBaseUrl || '', + locale, // Pass UI locale for browser TTS language selection }); setTestStatus('success'); setTestMessage(t('settings.ttsTestSuccess')); diff --git a/components/stage.tsx b/components/stage.tsx index 04b826fc6..acf55fbde 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -481,6 +481,23 @@ export function Stage({ return ids.includes(agentId); }, getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1, + getCourseLanguage: () => { + // Get course language from stage (set during generation) + // Use languageDirective field or fallback to zh-CN + const stage = useStageStore.getState().stage; + const lang = stage?.languageDirective; + if (lang === 'zh-TW' || lang === 'zh-CN' || lang === 'en-US') { + return lang; + } + // Infer from languageDirective if it's a language note + if (lang?.includes('繁體') || lang?.includes('台灣') || lang?.includes('香港')) { + return 'zh-TW'; + } + if (lang?.includes('中文') || lang?.includes('简体')) { + return 'zh-CN'; + } + return 'zh-CN'; // Default + }, onComplete: () => { // lectureSpeech intentionally NOT cleared — last sentence stays visible // until scene transition (auto-play) or user restarts. Scene change diff --git a/lib/audio/browser-tts-preview.ts b/lib/audio/browser-tts-preview.ts index 5da699773..d96122907 100644 --- a/lib/audio/browser-tts-preview.ts +++ b/lib/audio/browser-tts-preview.ts @@ -4,11 +4,28 @@ const VOICES_LOAD_TIMEOUT_MS = 2000; const PREVIEW_TIMEOUT_MS = 30000; const CJK_LANG_THRESHOLD = 0.3; +/** + * Map UI locale to browser TTS language code. + * zh-TW (Traditional Chinese) → zh-HK (Cantonese - better voice quality) + * zh-CN (Simplified Chinese) → zh-CN + * en-US → en-US + */ +function localeToBrowserTTSLang(locale: string): string { + if (locale === 'zh-TW') { + return 'zh-HK'; + } + if (locale === 'zh-CN') { + return 'zh-CN'; + } + return 'en-US'; +} + type PlayBrowserTTSPreviewOptions = { text: string; voice?: string; rate?: number; voices?: SpeechSynthesisVoice[]; + locale?: string; // UI locale (zh-CN, zh-TW, en-US) for language selection }; function createAbortError(): Error { @@ -68,11 +85,12 @@ export async function ensureVoicesLoaded(): Promise { }); } -/** Resolve a browser voice by voiceURI, name, or lang, with language fallback by text. */ +/** Resolve a browser voice by voiceURI, name, or lang, with language fallback by locale or text. */ export function resolveBrowserVoice( voices: SpeechSynthesisVoice[], voiceNameOrLang: string, text: string, + locale?: string, ): { voice: SpeechSynthesisVoice | null; lang: string } { const target = voiceNameOrLang.trim(); const matchedVoice = @@ -82,9 +100,18 @@ export function resolveBrowserVoice( ) || null : null; + if (matchedVoice) { + return { + voice: matchedVoice, + lang: matchedVoice.lang, + }; + } + + // No voice matched — use locale if provided, otherwise infer from text + const lang = locale ? localeToBrowserTTSLang(locale) : inferPreviewLang(text); return { - voice: matchedVoice, - lang: matchedVoice?.lang || inferPreviewLang(text), + voice: null, + lang, }; } @@ -153,7 +180,12 @@ export function playBrowserTTSPreview(options: PlayBrowserTTSPreviewOptions): { const utterance = new SpeechSynthesisUtterance(options.text); utterance.rate = options.rate ?? 1; - const { voice, lang } = resolveBrowserVoice(voices, options.voice ?? '', options.text); + const { voice, lang } = resolveBrowserVoice( + voices, + options.voice ?? '', + options.text, + options.locale, + ); if (voice) { utterance.voice = voice; } diff --git a/lib/audio/constants.ts b/lib/audio/constants.ts index db4a54315..b434a0a95 100644 --- a/lib/audio/constants.ts +++ b/lib/audio/constants.ts @@ -1147,6 +1147,74 @@ export const DEFAULT_TTS_MODELS: Record = { 'browser-native-tts': '', }; +/** + * Map course generation language to appropriate TTS voice for each provider. + * When generating course in Traditional Chinese (zh-TW), use Cantonese (zh-HK) voice. + */ +export const LANGUAGE_TO_TTS_VOICE: Record> = { + 'zh-CN': { + 'openai-tts': 'alloy', + 'azure-tts': 'zh-CN-XiaoxiaoNeural', + 'glm-tts': 'tongtong', + 'qwen-tts': 'Cherry', + 'doubao-tts': 'zh_female_vv_uranus_bigtts', + 'elevenlabs-tts': 'EXAVITQu4vr4xnSDxMaL', + 'minimax-tts': 'female-yujie', + 'browser-native-tts': 'zh-CN', + }, + 'zh-TW': { + 'openai-tts': 'alloy', // OpenAI TTS doesn't support Chinese, will fallback + 'azure-tts': 'zh-HK-HiuGaaiNeural', // Cantonese voice for Traditional Chinese content + 'glm-tts': 'tongtong', + 'qwen-tts': 'Tingting', + 'doubao-tts': 'zh_female_vv_uranus_bigtts', + 'elevenlabs-tts': 'EXAVITQu4vr4xnSDxMaL', + 'minimax-tts': 'female-yujie', + 'browser-native-tts': 'zh-HK', + }, + 'en-US': { + 'openai-tts': 'alloy', + 'azure-tts': 'en-US-JennyNeural', + 'glm-tts': 'tongtong', + 'qwen-tts': 'Amy', + 'doubao-tts': 'en_male_tim_uranus_bigtts', + 'elevenlabs-tts': 'EXAVITQu4vr4xnSDxMaL', + 'minimax-tts': 'English_Trustworthy_Man', + 'browser-native-tts': 'en-US', + }, +}; + +/** + * Get the appropriate TTS voice for a given course language and provider. + */ +export function getVoiceForLanguage(courseLanguage: string, providerId: string): string { + const languageMap = LANGUAGE_TO_TTS_VOICE[courseLanguage]; + if (languageMap && languageMap[providerId]) { + return languageMap[providerId]; + } + // Fallback to default voice + return DEFAULT_TTS_VOICES[providerId as keyof typeof DEFAULT_TTS_VOICES] || 'alloy'; +} + +/** + * Map UI locale (zh-CN / zh-TW / en-US) to browser-native-tts voice ID. + * zh-TW → zh-HK (Cantonese voice for Traditional Chinese UI) + * zh-CN → zh-CN (Mandarin voice for Simplified Chinese UI) + * en-US → en-US (English voice) + */ +export const LOCALE_TO_BROWSER_VOICE: Record = { + 'zh-CN': 'zh-CN', + 'zh-TW': 'zh-HK', + 'en-US': 'en-US', +}; + +/** + * Get the browser-native-tts voice ID for a given UI locale. + */ +export function getBrowserVoiceForLocale(locale: string): string { + return LOCALE_TO_BROWSER_VOICE[locale] || 'zh-CN'; +} + /** * Get all available TTS providers (built-in + custom) */ diff --git a/lib/audio/use-tts-preview.ts b/lib/audio/use-tts-preview.ts index 72493e706..581382eee 100644 --- a/lib/audio/use-tts-preview.ts +++ b/lib/audio/use-tts-preview.ts @@ -15,6 +15,7 @@ export interface TTSPreviewOptions { speed: number; apiKey?: string; baseUrl?: string; + locale?: string; // UI locale (zh-CN, zh-TW, en-US) for browser TTS language selection } /** @@ -81,6 +82,7 @@ export function useTTSPreview() { voice: options.voice, rate: options.speed, voices, + locale: options.locale, }); cancelRef.current = controller.cancel; await controller.promise; diff --git a/lib/hooks/use-browser-tts.ts b/lib/hooks/use-browser-tts.ts index 119575fdc..6838f32a1 100644 --- a/lib/hooks/use-browser-tts.ts +++ b/lib/hooks/use-browser-tts.ts @@ -16,6 +16,12 @@ export interface UseBrowserTTSOptions { pitch?: number; // 0 to 2 volume?: number; // 0 to 1 lang?: string; // e.g., 'zh-CN', 'en-US' + /** + * Locale code (zh-CN, zh-TW, en-US) for language selection. + * zh-TW maps to zh-HK (Cantonese) for better voice quality. + * If provided, overrides the static lang option when no specific voice is set. + */ + locale?: string; } export function useBrowserTTS(options: UseBrowserTTSOptions = {}) { @@ -27,6 +33,7 @@ export function useBrowserTTS(options: UseBrowserTTSOptions = {}) { pitch = 1.0, volume = 1.0, lang = 'zh-CN', + locale, } = options; const [isSpeaking, setIsSpeaking] = useState(false); @@ -73,13 +80,26 @@ export function useBrowserTTS(options: UseBrowserTTSOptions = {}) { utterance.rate = rate; utterance.pitch = pitch; utterance.volume = volume; - utterance.lang = lang; + + // Determine language: use locale if provided, otherwise fall back to lang option + let finalLang = lang; + if (locale) { + if (locale === 'zh-TW') { + finalLang = 'zh-HK'; // Cantonese for Traditional Chinese + } else if (locale === 'zh-CN') { + finalLang = 'zh-CN'; + } else { + finalLang = locale; // en-US or others + } + } + utterance.lang = finalLang; // Set voice if specified if (voiceURI) { const voice = availableVoices.find((v) => v.voiceURI === voiceURI); if (voice) { utterance.voice = voice; + utterance.lang = voice.lang; // Use voice's language if available } } @@ -114,7 +134,7 @@ export function useBrowserTTS(options: UseBrowserTTSOptions = {}) { utteranceRef.current = utterance; window.speechSynthesis.speak(utterance); }, - [rate, pitch, volume, lang, availableVoices, onStart, onEnd, onError], + [rate, pitch, volume, lang, locale, availableVoices, onStart, onEnd, onError], ); const pause = useCallback(() => { diff --git a/lib/hooks/use-discussion-tts.ts b/lib/hooks/use-discussion-tts.ts index 2d90b8845..514c904b6 100644 --- a/lib/hooks/use-discussion-tts.ts +++ b/lib/hooks/use-discussion-tts.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useSettingsStore } from '@/lib/store/settings'; import { useBrowserTTS } from '@/lib/hooks/use-browser-tts'; +import { useI18n } from '@/lib/hooks/use-i18n'; import { resolveAgentVoice, getAvailableProvidersWithVoices, @@ -29,6 +30,7 @@ interface QueueItem { } export function useDiscussionTTS({ enabled, agents, onAudioStateChange }: DiscussionTTSOptions) { + const { locale } = useI18n(); const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig); const ttsSpeed = useSettingsStore((s) => s.ttsSpeed); const ttsMuted = useSettingsStore((s) => s.ttsMuted); @@ -57,6 +59,7 @@ export function useDiscussionTTS({ enabled, agents, onAudioStateChange }: Discus cancel: browserCancel, } = useBrowserTTS({ rate: ttsSpeed, + locale, // Pass UI locale for browser TTS language selection onEnd: () => { isPlayingRef.current = false; segmentDoneCounterRef.current++; @@ -86,37 +89,72 @@ export function useDiscussionTTS({ enabled, agents, onAudioStateChange }: Discus const resolveVoiceForAgent = useCallback( (agentId: string | null): ResolvedVoice => { + // If no available server providers, always use browser-native-tts + // This ensures discussion TTS works even when no server TTS is configured const providers = getAvailableProvidersWithVoices(ttsProvidersConfig); + const hasServerProviders = providers.length > 0; + + // If no agentId provided if (!agentId) { - if (providers.length > 0) { + if (hasServerProviders && globalTtsProviderId !== 'browser-native-tts') { return { - providerId: providers[0].providerId, - voiceId: providers[0].voices[0]?.id ?? 'default', + providerId: globalTtsProviderId, + voiceId: globalTtsVoice, + modelId: ttsProvidersConfig[globalTtsProviderId]?.modelId, }; } return { providerId: 'browser-native-tts', voiceId: 'default' }; } + const agent = agents.find((a) => a.id === agentId); if (!agent) { - if (providers.length > 0) { + if (hasServerProviders && globalTtsProviderId !== 'browser-native-tts') { return { - providerId: providers[0].providerId, - voiceId: providers[0].voices[0]?.id ?? 'default', + providerId: globalTtsProviderId, + voiceId: globalTtsVoice, modelId: undefined, }; } return { providerId: 'browser-native-tts', voiceId: 'default', modelId: undefined }; } - // Teacher: always use global lecture voice (single source of truth with settings) + + // Teacher: always use global lecture voice if (agent.role === 'teacher') { - return { - providerId: globalTtsProviderId, - voiceId: globalTtsVoice, - modelId: ttsProvidersConfig[globalTtsProviderId]?.modelId, - }; + if (hasServerProviders && globalTtsProviderId !== 'browser-native-tts') { + return { + providerId: globalTtsProviderId, + voiceId: globalTtsVoice, + modelId: ttsProvidersConfig[globalTtsProviderId]?.modelId, + }; + } + return { providerId: 'browser-native-tts', voiceId: 'default' }; } + + // For non-teacher agents: + // 1. If global TTS is browser-native-tts, use browser-native + // 2. If no server providers available, use browser-native + // 3. Otherwise use agent's voice config or first available provider + if (!hasServerProviders || globalTtsProviderId === 'browser-native-tts') { + return { providerId: 'browser-native-tts', voiceId: 'default' }; + } + const index = agentIndexMap.current.get(agentId) ?? 0; - return resolveAgentVoice(agent, index, providers); + const resolved = resolveAgentVoice(agent, index, providers); + + // Double-check: if resolved to browser-native, use it; otherwise ensure it's a valid server provider + if (resolved.providerId === 'browser-native-tts') { + return resolved; + } + + // Additional safety: if the resolved provider doesn't have an API key configured, use browser-native + const providerConfig = ttsProvidersConfig[resolved.providerId]; + const hasApiKey = providerConfig?.apiKey && providerConfig.apiKey.trim().length > 0; + const isServerConfigured = providerConfig?.isServerConfigured === true; + if (!hasApiKey && !isServerConfigured) { + return { providerId: 'browser-native-tts', voiceId: 'default' }; + } + + return resolved; }, [agents, ttsProvidersConfig, globalTtsProviderId, globalTtsVoice], ); diff --git a/lib/i18n/locales.ts b/lib/i18n/locales.ts index 8be8d4418..d8b9fafc1 100644 --- a/lib/i18n/locales.ts +++ b/lib/i18n/locales.ts @@ -15,6 +15,7 @@ export type LocaleEntry = { */ export const supportedLocales = [ { code: 'zh-CN', label: '简体中文', shortLabel: 'CN' }, + { code: 'zh-TW', label: '繁體中文', shortLabel: 'TW' }, { code: 'en-US', label: 'English', shortLabel: 'EN' }, { code: 'ja-JP', label: '日本語', shortLabel: 'JA' }, { code: 'ru-RU', label: 'Русский', shortLabel: 'RU' }, diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json new file mode 100644 index 000000000..68c1f96c0 --- /dev/null +++ b/lib/i18n/locales/zh-TW.json @@ -0,0 +1,899 @@ +{ + "common": { + "you": "你", + "confirm": "確定", + "cancel": "取消", + "loading": "載入中..." + }, + "home": { + "slogan": "多智能體互動課室中的生成式學習", + "greetingWithName": "嗨,{{name}}" + }, + "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": "刪除", + "rename": "重新命名", + "renamePlaceholder": "輸入課堂名稱", + "renameFailed": "重新命名失敗" + }, + "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" + }, + "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": "儲存", + "providers": "語言模型", + "addProviderDescription": "新增自訂模型供應商以擴充可用的AI模型", + "providerNames": { + "openai": "OpenAI", + "anthropic": "Claude", + "google": "Gemini", + "deepseek": "DeepSeek", + "qwen": "通義千問", + "kimi": "Kimi", + "minimax": "MiniMax", + "glm": "GLM", + "siliconflow": "矽基流動", + "doubao": "豆包", + "ollama": "Ollama(本機模型)" + }, + "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": "確定要刪除此供應商嗎?", + "addCustomTTSProvider": "新增自訂語音合成", + "addCustomASRProvider": "新增自訂語音辨識", + "addCustomAudioProviderDescription": "新增相容 OpenAI 協定的音訊服務", + "customVoices": "聲線清單", + "voiceIdPlaceholder": "聲線 ID(如 alloy)", + "voiceNamePlaceholder": "顯示名稱", + "addVoice": "新增", + "modelNamePlaceholder": "顯示名稱", + "addModel": "新增", + "defaultModelHint": "API 請求中的模型名(如 kokoro、tts-1)", + "noVoicesAdded": "暫無聲線,請在下方新增以支援 Agent 選擇不同聲線。", + "noModelsAdded": "暫無模型,請在下方新增以支援模型選擇。", + "noModelsWarning": "請先在下方新增至少一個模型,才能使用此服務。", + "asrNoTranscription": "未生成轉寫結果,請嘗試說大聲一些或說長一些。", + "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": "語音辨識失敗", + "asrProcessing": "處理中...", + "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": "Magyarország", + "lang_ro-RO": "România", + "lang_sk-SK": "Slovenčina (Slovensko)", + "lang_bg-BG": "България", + "lang_hr-HR": "Hrvatska", + "lang_ca-ES": "Cataluña", + "lang_ar-SA": "السعودية", + "lang_ar-EG": "مصر", + "lang_he-IL": "ישראל", + "lang_hi-IN": "भारत", + "lang_th-TH": "ประเทศไทย", + "lang_vi-VN": "Việt Nam", + "lang_id-ID": "Indonesia", + "lang_ms-MY": "Malaysia", + "lang_fil-PH": "Pilipinas", + "lang_af-ZA": "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": "語言" + }, + "accessCode": { + "title": "請輸入課堂碼", + "placeholder": "課堂碼", + "error": "課堂碼錯誤,請重試。" + } +} diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index 364cee0bd..28cd3ed75 100644 --- a/lib/playback/engine.ts +++ b/lib/playback/engine.ts @@ -658,13 +658,16 @@ export class PlaybackEngine { } } if (!voiceFound) { - // No usable voice configured — detect text language so the browser - // auto-selects an appropriate voice. - const cjkRatio = - chunkText.length > 0 - ? (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length - : 0; - utterance.lang = cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US'; + // No usable voice configured — use course language to select browser TTS voice + // For zh-TW (Traditional Chinese), use zh-HK (Cantonese) for better voice quality + const courseLanguage = this.callbacks.getCourseLanguage?.() ?? 'zh-CN'; + if (courseLanguage === 'zh-TW') { + utterance.lang = 'zh-HK'; // Cantonese voice for Traditional Chinese + } else if (courseLanguage === 'zh-CN') { + utterance.lang = 'zh-CN'; + } else { + utterance.lang = 'en-US'; + } } utterance.onend = () => { diff --git a/lib/playback/types.ts b/lib/playback/types.ts index 6347d2e1f..9981afdca 100644 --- a/lib/playback/types.ts +++ b/lib/playback/types.ts @@ -58,5 +58,8 @@ export interface PlaybackEngineCallbacks { /** Get current playback speed multiplier (e.g. 1, 1.5, 2) */ getPlaybackSpeed?: () => number; + /** Get course language (zh-CN | zh-TW | en-US) for browser-native TTS voice selection */ + getCourseLanguage?: () => string; + onComplete?: () => void; } diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index 5e4b6dfa1..a0806743e 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -221,6 +221,7 @@ export async function generateClassroom( const requirements: UserRequirements = { requirement, + language: 'zh-CN', // Default language for server-side generation }; const pdfText = pdfContent?.text || undefined; diff --git a/lib/types/generation.ts b/lib/types/generation.ts index d3a78f503..16a437dad 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -48,6 +48,7 @@ export interface UploadedDocument { */ export interface UserRequirements { requirement: string; // Single free-form text for all user input + language: 'zh-CN' | 'zh-TW' | '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 @@ -68,6 +69,7 @@ export interface SceneOutline { teachingObjective?: string; estimatedDuration?: number; // seconds order: number; + language?: 'zh-CN' | 'zh-TW' | 'en-US'; // Generation language (inherited from requirements) languageNote?: string; // LLM-inferred language note for this scene // Suggested image IDs (from PDF-extracted images) suggestedImageIds?: string[]; // e.g., ["img_1", "img_3"] @@ -92,6 +94,7 @@ export interface SceneOutline { projectDescription: string; targetSkills: string[]; issueCount?: number; + language: 'zh-CN' | 'zh-TW' | 'en-US'; }; } diff --git a/tests/generation/outline-language.eval.test.ts b/tests/generation/outline-language.eval.test.ts index d929c852b..0f06a672e 100644 --- a/tests/generation/outline-language.eval.test.ts +++ b/tests/generation/outline-language.eval.test.ts @@ -177,7 +177,7 @@ describe('Outline Language Inference Evaluation', () => { async () => { // Call the REAL outline generation function const result = await generateSceneOutlinesFromRequirements( - { requirement: tc.requirement }, + { requirement: tc.requirement, language: 'zh-CN' }, tc.pdfTextSample || undefined, // pass PDF text if available undefined, // no PDF images aiCall,