diff --git a/app/api/generate/scene-content/route.ts b/app/api/generate/scene-content/route.ts index db9b772e1..afb091127 100644 --- a/app/api/generate/scene-content/route.ts +++ b/app/api/generate/scene-content/route.ts @@ -15,6 +15,7 @@ import { } from '@/lib/generation/generation-pipeline'; import type { AgentInfo } from '@/lib/generation/generation-pipeline'; import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation'; +import type { Locale } from '@/lib/i18n/types'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; @@ -67,7 +68,7 @@ export async function POST(req: NextRequest) { // Ensure outline has language from stageInfo (fallback for older outlines) const outline: SceneOutline = { ...rawOutline, - language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN', + language: rawOutline.language || (stageInfo?.language as Locale) || 'zh-CN', }; // ── Model resolution from request headers ── diff --git a/app/page.tsx b/app/page.tsx index 68719c489..645c252f9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -53,10 +53,12 @@ const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; const LANGUAGE_STORAGE_KEY = 'generationLanguage'; const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; +import type { Locale } from '@/lib/i18n/types'; + interface FormState { pdfFile: File | null; requirement: string; - language: 'zh-CN' | 'en-US'; + language: Locale; webSearch: boolean; } @@ -99,8 +101,8 @@ 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') { - updates.language = savedLanguage; + if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US' || savedLanguage === 'vi-VN') { + updates.language = savedLanguage as Locale; } else { const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; updates.language = detected; diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bbd8..ec846798b 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 type { Locale } from '@/lib/i18n/types'; // ─── 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: Locale; + onLanguageChange: (lang: Locale) => void; webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; @@ -361,11 +362,11 @@ export function GenerationToolbar({ {t('toolbar.languageHint')} diff --git a/components/generation/media-popover.tsx b/components/generation/media-popover.tsx index a09a32432..de9a56618 100644 --- a/components/generation/media-popover.tsx +++ b/components/generation/media-popover.tsx @@ -91,6 +91,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin 'doubao-tts': t('settings.providerDoubaoTTS'), 'elevenlabs-tts': t('settings.providerElevenLabsTTS'), 'minimax-tts': t('settings.providerMiniMaxTTS'), + 'vieneu-tts': t('settings.providerVieNeuTTS'), 'browser-native-tts': t('settings.providerBrowserNativeTTS'), }; return names[providerId] || providerId; diff --git a/components/header.tsx b/components/header.tsx index 5a61ec96f..27a3b1909 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -108,7 +108,7 @@ export function Header({ currentSceneTitle }: HeaderProps) { }} className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all" > - {locale === 'zh-CN' ? 'CN' : 'EN'} + {locale === 'zh-CN' ? 'CN' : locale === 'vi-VN' ? 'VN' : 'EN'} {languageOpen && (
@@ -138,6 +138,19 @@ export function Header({ currentSceneTitle }: HeaderProps) { > English +
)} diff --git a/components/settings/audio-settings.tsx b/components/settings/audio-settings.tsx index d88590ac0..994661280 100644 --- a/components/settings/audio-settings.tsx +++ b/components/settings/audio-settings.tsx @@ -40,6 +40,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin 'doubao-tts': t('settings.providerDoubaoTTS'), 'elevenlabs-tts': t('settings.providerElevenLabsTTS'), 'minimax-tts': t('settings.providerMiniMaxTTS'), + 'vieneu-tts': t('settings.providerVieNeuTTS'), 'browser-native-tts': t('settings.providerBrowserNativeTTS'), }; return names[providerId]; diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 5f122e2e5..d76c68065 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -124,6 +124,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin 'doubao-tts': t('settings.providerDoubaoTTS'), 'elevenlabs-tts': t('settings.providerElevenLabsTTS'), 'minimax-tts': t('settings.providerMiniMaxTTS'), + 'vieneu-tts': t('settings.providerVieNeuTTS'), 'browser-native-tts': t('settings.providerBrowserNativeTTS'), }; return names[providerId]; diff --git a/components/settings/tts-settings.tsx b/components/settings/tts-settings.tsx index 4a35a4f36..75f9ded19 100644 --- a/components/settings/tts-settings.tsx +++ b/components/settings/tts-settings.tsx @@ -4,9 +4,16 @@ import { useState, useEffect } from 'react'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; -import { TTS_PROVIDERS, DEFAULT_TTS_VOICES } from '@/lib/audio/constants'; +import { TTS_PROVIDERS, DEFAULT_TTS_VOICES, getTTSVoices } from '@/lib/audio/constants'; import type { TTSProviderId } from '@/lib/audio/types'; import { Volume2, Loader2, CheckCircle2, XCircle, Eye, EyeOff } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -26,6 +33,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { const ttsSpeed = useSettingsStore((state) => state.ttsSpeed); const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig); const setTTSProviderConfig = useSettingsStore((state) => state.setTTSProviderConfig); + const setTTSVoice = useSettingsStore((state) => state.setTTSVoice); const activeProviderId = useSettingsStore((state) => state.ttsProviderId); // When testing a non-active provider, use that provider's default voice @@ -46,6 +54,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { // Doubao TTS uses compound "appId:accessKey" — split for separate UI fields const isDoubao = selectedProviderId === 'doubao-tts'; + const isVieNeu = selectedProviderId === 'vieneu-tts'; const rawApiKey = ttsProvidersConfig[selectedProviderId]?.apiKey || ''; const doubaoColonIdx = rawApiKey.indexOf(':'); const doubaoAppId = isDoubao && doubaoColonIdx > 0 ? rawApiKey.slice(0, doubaoColonIdx) : ''; @@ -112,8 +121,50 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { )} + {/* VieNeu TTS: Base URL + Voice (no API key required) */} + {isVieNeu && ( +
+
+ + + setTTSProviderConfig(selectedProviderId, { + baseUrl: e.target.value, + }) + } + className="text-sm" + /> +
+
+ + +
+
+ )} + {/* API Key & Base URL */} - {(ttsProvider.requiresApiKey || isServerConfigured) && ( + {!isVieNeu && (ttsProvider.requiresApiKey || isServerConfigured) && ( <>
{isDoubao ? ( diff --git a/lib/audio/constants.ts b/lib/audio/constants.ts index 423f5b82c..b2882952c 100644 --- a/lib/audio/constants.ts +++ b/lib/audio/constants.ts @@ -883,6 +883,50 @@ export const TTS_PROVIDERS: Record = { speedRange: { min: 0.7, max: 1.2, default: 1.0 }, }, + 'vieneu-tts': { + id: 'vieneu-tts', + name: 'VieNeu TTS (Tiếng Việt)', + requiresApiKey: false, + defaultBaseUrl: 'http://127.0.0.1:8001', + icon: '/logos/vieneu.svg', + models: [], + defaultModelId: '', + voices: [ + // Miền Nam (Southern accent) + { + id: 'Xuân Vĩnh (Nam - Miền Nam)', + name: 'Xuân Vĩnh (Nam - Miền Nam)', + language: 'vi-VN', + gender: 'male', + description: 'vieneuVoiceXuanVinh', + }, + { + id: 'Thục Đoan (Nữ - Miền Nam)', + name: 'Thục Đoan (Nữ - Miền Nam)', + language: 'vi-VN', + gender: 'female', + description: 'vieneuVoiceThucDoan', + }, + // Miền Bắc (Northern accent) + { + id: 'Đoan Trang (Nữ - Miền Bắc)', + name: 'Đoan Trang (Nữ - Miền Bắc)', + language: 'vi-VN', + gender: 'female', + description: 'vieneuVoiceDoanTrang', + }, + { + id: 'Phạm Tuyên (Nam - Miền Bắc)', + name: 'Phạm Tuyên (Nam - Miền Bắc)', + language: 'vi-VN', + gender: 'male', + description: 'vieneuVoicePhamTuyen', + }, + ], + supportedFormats: ['wav'], + speedRange: { min: 0.5, max: 2.0, default: 1.0 }, + }, + 'browser-native-tts': { id: 'browser-native-tts', name: '浏览器原生 (Web Speech API)', @@ -1125,6 +1169,7 @@ export const DEFAULT_TTS_VOICES: Record = { 'doubao-tts': 'zh_female_vv_uranus_bigtts', 'elevenlabs-tts': 'EXAVITQu4vr4xnSDxMaL', 'minimax-tts': 'female-yujie', + 'vieneu-tts': 'Xuân Vĩnh (Nam - Miền Nam)', 'browser-native-tts': 'default', }; @@ -1136,6 +1181,7 @@ export const DEFAULT_TTS_MODELS: Record = { 'doubao-tts': '', 'elevenlabs-tts': 'eleven_multilingual_v2', 'minimax-tts': 'speech-2.8-hd', + 'vieneu-tts': '', 'browser-native-tts': '', }; diff --git a/lib/audio/tts-providers.ts b/lib/audio/tts-providers.ts index 67f0e7cc0..397d5db57 100644 --- a/lib/audio/tts-providers.ts +++ b/lib/audio/tts-providers.ts @@ -157,6 +157,9 @@ export async function generateTTS( case 'elevenlabs-tts': return await generateElevenLabsTTS(config, text); + case 'vieneu-tts': + return await generateVieNeuTTS(config, text); + case 'browser-native-tts': throw new Error( 'Browser Native TTS must be handled client-side using Web Speech API. This provider cannot be used on the server.', @@ -581,6 +584,41 @@ async function generateDoubaoTTS( return { audio: combined, format: 'mp3' }; } +/** + * VieNeu TTS implementation (local Vietnamese TTS server) + * API: GET /stream?text=&voice_id= + * Returns: WAV audio + */ +async function generateVieNeuTTS( + config: TTSModelConfig, + text: string, +): Promise { + const baseUrl = (config.baseUrl || TTS_PROVIDERS['vieneu-tts'].defaultBaseUrl || '').replace( + /\/$/, + '', + ); + + const params = new URLSearchParams({ + text, + voice_id: config.voice, + }); + + const response = await fetch(`${baseUrl}/stream?${params.toString()}`, { + method: 'GET', + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(`VieNeu TTS API error (${response.status}): ${errorText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + return { + audio: new Uint8Array(arrayBuffer), + format: 'wav', + }; +} + /** * Escape XML special characters for SSML */ diff --git a/lib/audio/types.ts b/lib/audio/types.ts index 0c3c91792..645e2ac31 100644 --- a/lib/audio/types.ts +++ b/lib/audio/types.ts @@ -86,6 +86,7 @@ export type TTSProviderId = | 'doubao-tts' | 'elevenlabs-tts' | 'minimax-tts' + | 'vieneu-tts' | 'browser-native-tts'; // Add new TTS providers below (uncomment and modify): // | 'fish-audio-tts' diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937d..344c980cb 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -18,6 +18,7 @@ import type { PdfImage, ImageMapping, } from '@/lib/types/generation'; +import type { Locale } from '@/lib/i18n/types'; import type { LanguageModel } from 'ai'; import type { StageStore } from '@/lib/api/stage-api'; import { createStageAPI } from '@/lib/api/stage-api'; @@ -735,7 +736,7 @@ function normalizeQuizAnswer(question: Record): string[] | unde async function generateInteractiveContent( outline: SceneOutline, aiCall: AICallFn, - language: 'zh-CN' | 'en-US' = 'zh-CN', + language: Locale = 'zh-CN', ): Promise { const config = outline.interactiveConfig!; diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index 4e642f4c2..d83a30ce2 100644 --- a/lib/hooks/use-i18n.tsx +++ b/lib/hooks/use-i18n.tsx @@ -10,7 +10,7 @@ type I18nContextType = { }; const LOCALE_STORAGE_KEY = 'locale'; -const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US']; +const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US', 'vi-VN']; const I18nContext = createContext(undefined); @@ -26,7 +26,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { setLocaleState(stored as Locale); return; } - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : navigator.language?.startsWith('vi') ? 'vi-VN' : 'en-US'; localStorage.setItem(LOCALE_STORAGE_KEY, detected); setLocaleState(detected); } catch { diff --git a/lib/i18n/chat.ts b/lib/i18n/chat.ts index 1bb535d3e..ac62b3512 100644 --- a/lib/i18n/chat.ts +++ b/lib/i18n/chat.ts @@ -145,3 +145,77 @@ export const chatEnUS = { stopListening: 'Stop recording', }, } as const; + +export const chatViVN = { + chat: { + lecture: 'Bài giảng', + noConversations: 'Không có cuộc trò chuyện nào', + startConversation: 'Nhập tin nhắn bên dưới để bắt đầu trò chuyện', + noMessages: 'Chưa có tin nhắn nào', + ended: 'đã kết thúc', + unknown: 'Không rõ', + stopDiscussion: 'Dừng thảo luận', + endQA: 'Kết thúc Q&A', + tabs: { + lecture: 'Ghi chú', + chat: 'Trò chuyện', + }, + lectureNotes: { + empty: 'Ghi chú sẽ xuất hiện ở đây sau khi bài giảng phát', + emptyHint: 'Dùng nút Phát để bắt đầu bài giảng', + pageLabel: 'Trang {n}', + currentPage: 'Trang hiện tại', + }, + badge: { + qa: 'Q&A', + discussion: 'THẢO LUẬN', + lecture: 'BÀI GIẢNG', + }, + }, + actions: { + names: { + spotlight: 'Đèn nổi bật', + laser: 'Con trỏ Laser', + wb_open: 'Mở Bảng trắng', + wb_draw_text: 'Văn bản Bảng trắng', + wb_draw_shape: 'Hình khối Bảng trắng', + wb_draw_chart: 'Biểu đồ Bảng trắng', + wb_draw_latex: 'Công thức Bảng trắng', + wb_draw_table: 'Bảng trên Bảng trắng', + wb_draw_line: 'Đường thẳng Bảng trắng', + wb_clear: 'Xóa Bảng trắng', + wb_delete: 'Xóa phần tử', + wb_close: 'Đóng Bảng trắng', + discussion: 'Thảo luận', + }, + status: { + inputStreaming: 'Đang chờ', + inputAvailable: 'Đang thực thi', + outputAvailable: 'Hoàn thành', + outputError: 'Lỗi', + outputDenied: 'Bị từ chối', + running: 'Đang thực thi', + result: 'Hoàn thành', + error: 'Lỗi', + }, + }, + agentBar: { + readyToLearn: 'Cùng nhau học nhé?', + expandedTitle: 'Đội hình Lớp học', + configTooltip: 'Bấm để cấu hình các nhân vật trong lớp', + voiceLabel: 'Giọng nói', + voiceLoading: 'Đang tải...', + voiceAutoAssign: 'Giọng nói sẽ được tự động chỉ định', + }, + proactiveCard: { + discussion: 'Thảo luận', + join: 'Tham gia', + skip: 'Bỏ qua', + pause: 'Tạm dừng', + resume: 'Tiếp tục', + }, + voice: { + startListening: 'Nhập bằng giọng nói', + stopListening: 'Dừng ghi âm', + }, +} as const; diff --git a/lib/i18n/common.ts b/lib/i18n/common.ts index 1bceb5d61..2d3f552ba 100644 --- a/lib/i18n/common.ts +++ b/lib/i18n/common.ts @@ -79,3 +79,44 @@ export const commonEnUS = { exportFailed: 'Export failed', }, } as const; + +export const commonViVN = { + common: { + you: 'Bạn', + confirm: 'Xác nhận', + cancel: 'Hủy', + loading: 'Đang tải...', + }, + home: { + slogan: 'Học tập với AI trong Lớp học Đa nhân vật Tương tác', + greeting: 'Chào, ', + }, + toolbar: { + languageHint: 'Khóa học sẽ được tạo bằng ngôn ngữ này', + pdfParser: 'Bộ phân tích', + pdfUpload: 'Tải PDF lên', + removePdf: 'Xóa tệp', + webSearchOn: 'Đã bật', + webSearchOff: 'Nhấn để bật', + webSearchDesc: 'Tìm kiếm trên web để cập nhật thông tin chuẩn nhất trước khi tạo', + webSearchProvider: 'Công cụ tìm kiếm', + webSearchNoProvider: 'Cấu hình API Key trong mục Cài đặt', + selectProvider: 'Chọn dịch vụ', + configureProvider: 'Thiết lập mô hình', + configureProviderHint: 'Cấu hình ít nhất một dịch vụ mô hình để bắt đầu tính năng tạo khóa học', + enterClassroom: 'Vào lớp học', + advancedSettings: 'Cài đặt nâng cao', + ttsTitle: 'Chuyển văn bản thành giọng nói', + ttsHint: 'Chọn giọng nói cho giáo viên AI', + ttsPreview: 'Nghe thử', + ttsPreviewing: 'Đang phát...', + }, + export: { + pptx: 'Xuất ra PPTX', + resourcePack: 'Xuất gói tài liệu', + resourcePackDesc: 'PPTX + trang tài liệu tương tác', + exporting: 'Đang xuất...', + exportSuccess: 'Xuất thành công', + exportFailed: 'Xuất thất bại', + }, +} as const; diff --git a/lib/i18n/generation.ts b/lib/i18n/generation.ts index 98694c234..39957fb39 100644 --- a/lib/i18n/generation.ts +++ b/lib/i18n/generation.ts @@ -133,3 +133,72 @@ export const generationEnUS = { webSearchFailed: 'Web search failed', }, } as const; + +export const generationViVN = { + classroom: { + recentClassrooms: 'Gần đây', + today: 'Hôm nay', + yesterday: 'Hôm qua', + daysAgo: 'ngày trước', + slides: 'trang', + nameCopied: 'Đã sao chép tên', + deleteConfirmTitle: 'Xóa lớp học', + delete: 'Xóa', + }, + upload: { + pdfSizeLimit: 'Hỗ trợ tệp PDF lên đến 50MB', + generateFailed: 'Không thể tạo lớp học, vui lòng thử lại', + requirementPlaceholder: + 'Nhập bất kỳ điều gì bạn muốn học, ví dụ:\n"Dạy tôi Python từ cơ bản trong 30 phút"\n"Giải thích Biến đổi Fourier trên bảng trắng"\n"Cách chơi board game Avalon"', + requirementRequired: 'Vui lòng nhập yêu cầu khóa học', + fileTooLarge: 'Tệp quá lớn. Vui lòng chọn tệp PDF dưới 50MB', + }, + generation: { + // Progress steps (used dynamically via activeStep) + analyzingPdf: 'Đang phân tích tài liệu PDF', + analyzingPdfDesc: 'Đang trích xuất cấu trúc và nội dung tài liệu...', + pdfLoadFailed: 'Không thể tải tệp PDF, vui lòng thử lại', + pdfParseFailed: 'Phân tích PDF thất bại', + streamNotReadable: 'Không thể đọc luồng dữ liệu tiến trình tạo', + generatingOutlines: 'Đang phác thảo Khóa học', + generatingOutlinesDesc: 'Đang xây dựng lộ trình học tập...', + generatingSlideContent: 'Đang tạo nội dung Trang', + generatingSlideContentDesc: 'Đang tạo trang chiếu, câu đố và nội dung tương tác...', + generatingActions: 'Đang tạo Hành động Sư phạm', + generatingActionsDesc: 'Đang biên soạn lời giảng, hướng điểm nhìn, và tương tác...', + generationComplete: 'Tạo hoàn tất!', + generationFailed: 'Tạo lớp học thất bại', + generatingCourse: 'Đang tạo khóa học', + openingClassroom: 'Đang mở lớp học...', + outlineReady: 'Đã tạo xong dàn ý học tập', + generatingFirstPage: 'Đang tạo nội dung trang đầu tiên...', + firstPageReady: 'Trang đầu đã sẵn sàng! Đang mở lớp học...', + speechFailed: 'Tổng hợp giọng nói thất bại', + retryScene: 'Thử lại phần này', + retryingScene: 'Đang tạo lại...', + backToHome: 'Quay về Trang chủ', + sessionNotFound: 'Không tìm thấy Phiên', + sessionNotFoundDesc: 'Vui lòng điền yêu cầu để bắt đầu quá trình tạo.', + goBackAndRetry: 'Quay lại và thử lại', + classroomReady: 'Môi trường học tập cá nhân hóa AI của bạn đã được tạo thành công.', + aiWorking: 'Các tác nhân AI đang hoạt động...', + textTruncated: 'Văn bản tài liệu khá dài, sử dụng {n} ký tự đầu tiên để tạo bài', + imageTruncated: + 'Đã tìm thấy {total} hình ảnh, vượt quá giới hạn {max} hình ảnh. Các hình ảnh dư sẽ chỉ sử dụng mô tả văn bản', + // Agent generation + agentGeneration: 'Đang thiết lập Đội hình Lớp học', + agentGenerationDesc: 'Tạo các vai diễn phù hợp dựa trên nội dung khóa học...', + agentRevealTitle: 'Vai diễn Lớp học của bạn', + viewAgents: 'Xem các vai diễn', + continue: 'Tiếp tục', + // Outline errors + outlineRetrying: 'Lỗi lúc tạo dàn ý, đang thử lại...', + outlineEmptyResponse: + 'Mô hình không trả về dàn ý hợp lệ. Vui lòng kiểm tra cấu hình mô hình và thử lại', + outlineGenerateFailed: 'Tạo dàn ý thất bại, vui lòng thử lại sau', + // Web Search + webSearching: 'Tìm kiếm trên Web', + webSearchingDesc: 'Đang tìm kiếm trên web để cập nhật thông tin mới nhất', + webSearchFailed: 'Tìm kiếm không thành công', + }, +} as const; diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts index 5fd70da52..35ff0e09b 100644 --- a/lib/i18n/index.ts +++ b/lib/i18n/index.ts @@ -1,10 +1,10 @@ import { defaultLocale, type Locale } from './types'; export { type Locale, defaultLocale } from './types'; -import { commonZhCN, commonEnUS } from './common'; -import { stageZhCN, stageEnUS } from './stage'; -import { chatZhCN, chatEnUS } from './chat'; -import { generationZhCN, generationEnUS } from './generation'; -import { settingsZhCN, settingsEnUS } from './settings'; +import { commonZhCN, commonEnUS, commonViVN } from './common'; +import { stageZhCN, stageEnUS, stageViVN } from './stage'; +import { chatZhCN, chatEnUS, chatViVN } from './chat'; +import { generationZhCN, generationEnUS, generationViVN } from './generation'; +import { settingsZhCN, settingsEnUS, settingsViVN } from './settings'; export const translations = { 'zh-CN': { @@ -21,6 +21,13 @@ export const translations = { ...generationEnUS, ...settingsEnUS, }, + 'vi-VN': { + ...commonViVN, + ...stageViVN, + ...chatViVN, + ...generationViVN, + ...settingsViVN, + }, } as const; export type TranslationKey = keyof (typeof translations)[typeof defaultLocale]; @@ -40,8 +47,8 @@ export function getClientTranslation(key: string): string { if (typeof window !== 'undefined') { try { const storedLocale = localStorage.getItem('locale'); - if (storedLocale === 'zh-CN' || storedLocale === 'en-US') { - locale = storedLocale; + if (storedLocale === 'zh-CN' || storedLocale === 'en-US' || storedLocale === 'vi-VN') { + locale = storedLocale as Locale; } } catch { // localStorage unavailable, keep default locale diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts index 356fea554..560b60827 100644 --- a/lib/i18n/settings.ts +++ b/lib/i18n/settings.ts @@ -229,6 +229,7 @@ export const settingsZhCN = { providerDoubaoTTS: '豆包 TTS 2.0(火山引擎)', providerElevenLabsTTS: 'ElevenLabs TTS', providerMiniMaxTTS: 'MiniMax TTS', + providerVieNeuTTS: 'VieNeu TTS(越南本地语音)', providerBrowserNativeTTS: '浏览器原生 TTS', providerOpenAIWhisper: 'OpenAI ASR (gpt-4o-mini-transcribe)', providerBrowserNative: '浏览器原生 ASR', @@ -342,6 +343,11 @@ export const settingsZhCN = { qwenVoiceEric: '跳脱市井的成都男子', qwenVoiceRocky: '幽默风趣的阿强', qwenVoiceKiki: '甜美的港妹闺蜜', + // TTS Voice descriptions (VieNeu) + vieneuVoiceXuanVinh: '男声 - 南部口音(默认)', + vieneuVoiceThucDoan: '女声 - 南部口音', + vieneuVoiceDoanTrang: '女声 - 北部口音', + vieneuVoicePhamTuyen: '男声 - 北部口音', // ASR Language names (native forms - autoglossonyms) lang_auto: '自动检测', lang_zh: '中文', @@ -827,6 +833,7 @@ export const settingsEnUS = { providerDoubaoTTS: 'Doubao TTS 2.0 (Volcengine)', providerElevenLabsTTS: 'ElevenLabs TTS', providerMiniMaxTTS: 'MiniMax TTS', + providerVieNeuTTS: 'VieNeu TTS (Vietnamese Local TTS)', providerBrowserNativeTTS: 'Browser Native TTS', providerOpenAIWhisper: 'OpenAI ASR (gpt-4o-mini-transcribe)', providerBrowserNative: 'Browser Native ASR', @@ -940,6 +947,11 @@ export const settingsEnUS = { qwenVoiceEric: 'Chengdu gentleman', qwenVoiceRocky: 'Humorous Hong Kong guy', qwenVoiceKiki: 'Sweet Hong Kong girl', + // TTS Voice descriptions (VieNeu) + vieneuVoiceXuanVinh: 'Male - Southern accent (default)', + vieneuVoiceThucDoan: 'Female - Southern accent', + vieneuVoiceDoanTrang: 'Female - Northern accent', + vieneuVoicePhamTuyen: 'Male - Northern accent', // ASR Language names (native forms - autoglossonyms) lang_auto: 'Auto Detect', lang_zh: '中文', @@ -1194,3 +1206,613 @@ export const settingsEnUS = { language: 'Language', }, } as const; + +export const settingsViVN = { + settings: { + title: 'Cài đặt', + description: 'Cấu hình ứng dụng', + language: 'Ngôn ngữ', + languageDesc: 'Chọn ngôn ngữ giao diện', + theme: 'Giao diện', + themeDesc: 'Chọn chế độ giao diện (Sáng/Tối/Hệ thống)', + themeOptions: { + light: 'Sáng', + dark: 'Tối', + system: 'Hệ thống', + }, + apiKey: 'API Key', + apiKeyDesc: 'Cấu hình API key của bạn', + apiBaseUrl: 'API Base URL', + apiBaseUrlDesc: 'Cấu hình đường dẫn URL gốc của API', + apiKeyRequired: 'API key không được để trống', + model: 'Cấu hình mô hình', + modelDesc: 'Cấu hình mô hình AI', + modelPlaceholder: 'Nhập hoặc chọn tên mô hình', + ttsModel: 'Mô hình TTS', + ttsModelDesc: 'Cấu hình mô hình chuyển văn bản thành giọng nói (TTS)', + ttsModelPlaceholder: 'Nhập hoặc chọn tên mô hình TTS', + ttsModelOptions: { + openaiTts: 'OpenAI TTS', + azureTts: 'Azure TTS', + }, + availableModels: 'Các mô hình khả dụng', + modelSelectedViaVoice: 'Mô hình được xác định tự động bằng giọng nói', + testConnection: 'Kiểm tra kết nối', + testConnectionDesc: 'Kiểm tra cấu hình API hiện tại có hoạt động không', + testing: 'Đang kiểm tra...', + agentSettings: 'Cài đặt Tác nhân', + agentSettingsDesc: + 'Chọn các tác nhân (agent) tham gia cuộc trò chuyện. Chọn 1 cho chế độ đơn tác nhân, nhiều cho chế độ tương tác đa tác nhân.', + agentMode: 'Chế độ Tác nhân', + agentModePreset: 'Mặc định', + agentModeAuto: 'Tự động tạo', + agentModeAutoDesc: 'AI sẽ tự động tạo vai trò phù hợp', + autoAgentCount: 'Số lượng tác nhân', + autoAgentCountDesc: 'Số lượng tác nhân sẽ tự động tạo (bao gồm giáo viên)', + atLeastOneAgent: 'Vui lòng chọn ít nhất 1 tác nhân', + singleAgentMode: 'Chế độ Đơn Tác Nhân', + directAnswer: 'Trả lời trực tiếp', + multiAgentMode: 'Chế độ Đa Tác Nhân', + agentsCollaborating: 'Thảo luận hợp tác', + agentsCollaboratingCount: 'Đã chọn {count} tác nhân để thảo luận', + maxTurns: 'Số vòng thảo luận tối đa', + maxTurnsDesc: + 'Lượt tương tác tối đa giữa các tác nhân (khi một tác nhân thực hiện hành động và trả lời tính là 1 lượt)', + priority: 'Độ ưu tiên', + actions: 'Hành động', + actionCount: '{count} hành động', + selectedAgent: 'Tác nhân đã chọn', + selectedAgents: 'Tác nhân đã chọn', + required: 'Bắt buộc', + agentNames: { + 'default-1': 'Giáo Viên AI', + 'default-2': 'Trợ Giảng AI', + 'default-3': 'Người Vui Tính', + 'default-4': 'Học Sinh Tò Mò', + 'default-5': 'Người Ghi Chép', + 'default-6': 'Nhà Tư Tưởng', + }, + agentRoles: { + teacher: 'Giáo viên', + assistant: 'Trợ giảng', + student: 'Học sinh', + }, + agentDescriptions: { + 'default-1': 'Giảng viên chính, diễn giải mạch lạc và có hệ thống', + 'default-2': 'Hỗ trợ việc học và giải thích các điểm chính', + 'default-3': 'Mang lại sự hài hước và năng lượng cho bài giảng', + 'default-4': 'Luôn tò mò, thích hỏi tại sao và làm thế nào', + 'default-5': 'Chăm chỉ ghi chép và tổ chức lại kiến thức', + 'default-6': 'Suy nghĩ sâu vào bản chất của bất kỳ chủ đề nào', + }, + close: 'Đóng', + save: 'Lưu', + // Provider settings + providers: 'LLM', + addProviderDescription: 'Thêm nhà cung cấp mô hình AI (LLM) để mở rộng các mô hình bạn có', + providerNames: { + openai: 'OpenAI', + anthropic: 'Claude', + google: 'Gemini', + deepseek: 'DeepSeek', + qwen: 'Qwen', + kimi: 'Kimi', + minimax: 'MiniMax', + glm: 'GLM', + siliconflow: 'SiliconFlow', + }, + providerTypes: { + openai: 'Giao thức OpenAI', + anthropic: 'Giao thức Claude', + google: 'Giao thức Gemini', + }, + modelCount: 'mô hình', + modelSingular: 'mô hình', + defaultModel: 'Mô hình mặc định', + webSearch: 'Tìm kiếm web', + mcp: 'MCP', + knowledgeBase: 'Cơ sở tri thức', + documentParser: 'Bộ phân tích tài liệu', + conversationSettings: 'Trò chuyện', + keyboardShortcuts: 'Phím tắt', + generalSettings: 'Chung', + systemSettings: 'Hệ thống', + addProvider: 'Thêm', + importFromClipboard: 'Nhập từ Clipboard', + apiSecret: 'API Key', + apiHost: 'Base URL', + requestUrl: 'Request URL', + models: 'Các mô hình', + addModel: 'Thêm mới', + reset: 'Đặt lại', + fetch: 'Tải', + connectionSuccess: 'Kết nối thành công', + connectionFailed: 'Kết nối thất bại', + // Model capabilities + capabilities: { + vision: 'Thị giác', + tools: 'Công cụ', + streaming: 'Truyền (Stream)', + }, + contextWindow: 'Ngữ cảnh', + contextShort: 'ctx', + outputWindow: 'Giới hạn Output', + // Provider management + addProviderButton: 'Thêm', + addProviderDialog: 'Thêm nhà cung cấp mô hình', + providerName: 'Tên', + providerNamePlaceholder: 'VD: Proxy OpenAI Của Tôi', + providerNameRequired: 'Vui lòng nhập tên nhà cung cấp', + providerApiMode: 'Chế độ API', + apiModeOpenAI: 'Giao thức OpenAI', + apiModeAnthropic: 'Giao thức Claude', + apiModeGoogle: 'Giao thức Gemini', + defaultBaseUrl: 'Base URL mặc định', + providerIcon: 'URL icon nhà cung cấp', + requiresApiKey: 'Yêu cầu API Key', + deleteProvider: 'Xóa nhà cung cấp', + deleteProviderConfirm: 'Bạn có chắc chắn muốn xóa nhà cung cấp này không?', + cannotDeleteBuiltIn: 'Không thể xóa nhà cung cấp mặc định', + resetToDefault: 'Đặt lại về mặc định', + resetToDefaultDescription: + 'Khôi phục danh sách mô hình về cấu hình gốc (API Key và Base URL sẽ giữ nguyên)', + resetConfirmDescription: + 'Thao tác này sẽ xóa tất cả các mô hình tùy chỉnh và khôi phục mô hình mặc định. API Key và Base URL vẫn giữ.', + confirmReset: 'Xác nhận đặt lại', + resetSuccess: 'Đặt lại về mặc định thành công', + saveSuccess: 'Đã lưu cấu hình', + saveFailed: 'Lưu thất bại, vui lòng thử lại', + cannotDeleteBuiltInModel: 'Không thể xóa cấu hình mặc định', + cannotEditBuiltInModel: 'Không thể chỉnh mô hình mặc định', + modelIdRequired: 'Vui lòng điền model ID', + noModelsAvailable: 'Không có mô hình để thử nghiệm', + providerMetadata: 'Siêu dữ liệu nhà cung cấp', + // Model editing + editModel: 'Chỉnh sửa mô hình', + editModelDescription: 'Điều chỉnh năng lực và cấu hình mô hình', + addNewModel: 'Mô hình mới', + addNewModelDescription: 'Thêm cấu hình của máy chủ', + modelId: 'Model ID', + modelIdPlaceholder: 'VD: gpt-4o', + modelName: 'Tên hiển thị', + modelNamePlaceholder: 'Tùy chọn', + modelCapabilities: 'Chức năng', + advancedSettings: 'Nâng cao', + contextWindowLabel: 'Giới hạn Ngữ cảnh (Tokens)', + contextWindowPlaceholder: 'VD: 128000', + outputWindowLabel: 'Giới hạn Output tối đa', + outputWindowPlaceholder: 'VD: 4096', + testModel: 'Thử nghiệm mô hình', + deleteModel: 'Xóa', + cancelEdit: 'Hủy', + saveModel: 'Lưu', + modelsManagementDescription: + 'Quản lý danh sách mô hình của đối tác này. Để phân bổ mô hình kích hoạt, vào phần Cài đặt Chung.', + // General settings + howToUse: 'Cách sử dụng', + step1ConfigureProvider: + 'Vào phần "LLM", chọn hoặc thêm một nhà cung cấp gốc, thiết lập thông tin liên kết (API key, Base URL, v.v.)', + step2SelectModel: 'Chọn mô hình dùng trong mục "Mô hình Hiện tại" phía dưới', + step3StartUsing: 'Sau khi lưu, hệ thống sẽ sử dụng mô hình bạn chọn', + activeModel: 'Mô hình đang kích hoạt', + activeModelDescription: 'Vui lòng chọn mô hình AI chính bạn muốn dùng để tạo bài', + selectModel: 'Chọn mô hình', + searchModels: 'Tìm mô hình', + noModelsFound: 'Không có thông tin mô hình', + noConfiguredProviders: 'Chưa có nhà cung cấp được cấu hình', + configureProvidersFirst: + 'Vui lòng cấu hình các nhà cung cấp thông tin tại bộ phận "LLM" ở cột bên trái trước', + currentlyUsing: 'Đang dùng', + // TTS settings + ttsSettings: 'Chuyển văn bản thành giọng nói', + // ASR settings + asrSettings: 'Nhận dạng giọng nói', + // Audio settings (legacy) + audioSettings: 'Cấu hình âm thanh', + ttsSection: 'Văn bản thành Giọng nói (TTS)', + asrSection: 'Nhận dạng Giọng nói tự động (ASR)', + ttsDescription: 'TTS (Text-to-Speech) - Đọc văn bản lên thành lời', + asrDescription: 'ASR (Automatic Speech Recognition) - Chuyển lời nói sang chữ', + enableTTS: 'Kích hoạt Văn bản thành giọng nói', + ttsEnabledDescription: 'Âm thanh bài giảng AI sẽ được xuất sinh khi đang tạo lớp học', + ttsVoiceConfigHint: + 'Bạn cũng có thể điều chỉnh giọng nói từng nhân vật ở trang chủ bằng cách xem "Đội hình Lớp học"', + enableASR: 'Kích hoạt dịch vụ Nhận diện giọng nói', + asrEnabledDescription: 'Người dùng lúc học có thể thu âm qua micro để trò chuyện', + ttsProvider: 'Dịch vụ cung cấp TTS', + ttsLanguageFilter: 'Bộ lọc ngôn ngữ', + allLanguages: 'Tất cả ngôn ngữ', + ttsVoice: 'Giọng nói', + ttsSpeed: 'Tốc độ', + ttsBaseUrl: 'Base URL', + ttsApiKey: 'API Key', + doubaoAppId: 'App ID', + doubaoAccessKey: 'Access Key', + asrProvider: 'Dịch vụ cung cấp ASR', + asrLanguage: 'Ngôn ngữ của ASR', + asrBaseUrl: 'Base URL', + asrApiKey: 'API Key', + enterApiKey: 'Ghi lại Key API', + enterCustomBaseUrl: 'Ghi mã Base URL cá nhân', + browserNativeNote: 'Giao thức Native của Trình duyệt đều hoàn toàn miễn phí và tốt sẵn', + // Audio provider names + providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)', + providerAzureTTS: 'Azure TTS', + providerGLMTTS: 'GLM TTS', + providerQwenTTS: 'Qwen TTS (Chuyên Gia Alibaba Cloud)', + providerDoubaoTTS: 'Doubao TTS 2.0 (Volcengine)', + providerElevenLabsTTS: 'ElevenLabs TTS', + providerMiniMaxTTS: 'MiniMax TTS', + providerVieNeuTTS: 'VieNeu TTS (Tiếng Việt địa phương)', + providerBrowserNativeTTS: 'Giọng nói cơ bản trong Trình duyệt', + providerOpenAIWhisper: 'OpenAI ASR (gpt-4o-mini-transcribe)', + providerBrowserNative: 'Hỗ trợ bằng micro từ Trình duyệt', + providerQwenASR: 'Qwen ASR (Alibaba Cloud Bailian)', + providerUnpdf: 'unpdf (Mặc định được hỗ trợ)', + providerMinerU: 'MinerU', + browserNativeTTSNote: + 'Công cụ hỗ trợ gốc không cần cấu hình cũng không tính thêm khoản phí nào', + testTTS: 'Phát thanh mẫu âm thanh', + testASR: 'Phát thanh mẫu giọng nói', + testSuccess: 'Phát thành công', + testFailed: 'Không thể phát thành công', + ttsTestText: 'Thử lại phần tạo dòng lệnh TTS', + ttsTestSuccess: 'Đã phát đoạn âm thanh tương thích TTS', + ttsTestFailed: 'Gặp sự cố TTS', + asrTestSuccess: 'ASR đã chạy thành công', + asrTestFailed: 'Không thành công tạo bản phân định âm thanh', + asrResult: 'Tóm lược âm thanh', + asrNotSupported: 'Trình duyệt không cho phép Truy vấn ASR API', + browserTTSNotSupported: 'Không có cách nào tạo ra giao thức TTS API bằng trình phát Web này', + browserTTSNoVoices: 'Không xác định dạng nội dung TTS', + microphoneAccessDenied: 'Micro từ thiết bị chưa cấp quyền', + microphoneAccessFailed: 'Không xử lý cho thiết bị vào dạng micro', + asrResultPlaceholder: 'Khi đã ghi âm bằng máy xong phần nội dung sẽ chạy ra màn hình', + useThisProvider: 'Sử dụng Tùy chọn', + fetchVoices: 'Cập nhật lại Danh sách', + fetchingVoices: 'Đang tải về...', + voicesFetched: 'Tải Xong', + fetchVoicesFailed: 'Tải thất bại xin gặp lại', + voiceApiKeyRequired: 'Bạn thiếu thông tin API', + voiceBaseUrlRequired: 'Điền liên kết URL Cấp', + ttsTestTextPlaceholder: 'Thử kiểm lại', + ttsTestTextDefault: 'Cảm ơn, đây là phần giới thiệu âm trường mẫu.', + startRecording: 'Luôn ghi', + stopRecording: 'Dừng ghi', + recording: 'Kỳ hoạt ghi âm...', + transcribing: 'Đang định cấu trúc thành văn bản...', + transcriptionResult: 'Bản phân giải', + noTranscriptionResult: 'Ghi chưa cấu trúc xong', + baseUrlOptional: 'Base URL (Tự điền)', + defaultValue: 'Nguyên văn', + // TTS Voice descriptions (OpenAI) + voiceMarin: 'Được ưu tiên chọn - Chất lượng hạng A', + voiceCedar: 'Được ưu tiên chọn - Mềm và dẻo', + voiceAlloy: 'Tầm trung - Trầm ấm', + voiceAsh: 'Phù hợp công sở', + voiceBallad: 'Hùng ca uy quyền', + voiceCoral: 'Giọng thân thiện, hài hòa', + voiceEcho: 'Giọng nam tính nổi loạn', + voiceFable: 'Kiểu như kể chuyện cho trẻ em', + voiceNova: 'Giọng nữ sắc và ngọt', + voiceOnyx: 'Nam trầm nhất', + voiceSage: 'Âm sắc của người thông thái', + voiceShimmer: 'Giọng nữ uyển chuyển', + voiceVerse: 'Giọng văn nói liên tưởng', + // TTS Voice descriptions (GLM) + glmVoiceTongtong: 'Giọng mặc định', + glmVoiceChuichui: 'Giọng tên Chuichui', + glmVoiceXiaochen: 'Giọng tên Xiaochen', + glmVoiceJam: 'Giọng kiểu Jam', + glmVoiceKazi: 'Giọng kiểu Kazi', + glmVoiceDouji: 'Giọng kiểu Douji', + glmVoiceLuodo: 'Giọng tên Luodo', + // TTS Voice descriptions (Qwen) + qwenVoiceCherry: 'Sôi nổi, trẻ và nhẹ nhàng', + qwenVoiceSerena: 'Dễ nhận dạng và nữ tính', + qwenVoiceEthan: 'Sôi động và dứt khoát', + qwenVoiceChelsie: 'Theo kiểu virtual girlfriend', + qwenVoiceMomo: 'Lan truyền năng lượng cao và tích cực', + qwenVoiceVivian: 'Ngổ tiếng con nít dễ thương', + qwenVoiceMoon: 'Tự tại và ngầu', + qwenVoiceMaia: 'Tri thức, có học vấn thâm sâu', + qwenVoiceKai: 'Thư giãn nghe SPA', + qwenVoiceNofish: "Không biết đọc các giọng điệu cao siêu", + qwenVoiceBella: "Cô nương nhép dễ dãi", + qwenVoiceJennifer: 'Giọng tiêu chuẩn cho thương mại quốc tế', + qwenVoiceRyan: 'Nhịp độ nói luyến thoắng kịch tính', + qwenVoiceKaterina: 'Mạch liên hồi mạnh nhịp âm nhạc', + qwenVoiceAiden: 'Đam mê nấu ăn tiếng Mỹ cực xịn', + qwenVoiceEldricSage: 'Đề nén cho cụ già thông thạo nhiều lẽ phải', + qwenVoiceMia: 'Dịu và nhẹ', + qwenVoiceMochi: 'Thông minh con lớn lộng lẫy', + qwenVoiceBellona: 'Tự dưng hăng hái vĩ đại', + qwenVoiceVincent: 'Đeo đai đen trong cuộc tình qua thời hoàng kim', + qwenVoiceBunny: 'Loli đáng yêu vô độ', + qwenVoiceNeil: 'Khung thông tin kênh phát thanh', + qwenVoiceElias: 'Dành làm giọng chuyên gia phân tích', + qwenVoiceArthur: 'Phong cách chậm rải nhẹ nhàng', + qwenVoiceNini: 'Mềm như vỏ bánh bao nước', + qwenVoiceEbona: 'Bảo thạch rỉ sét bí ẩn', + qwenVoiceSeren: 'Mang đến tiếng ru ru dịu em bé', + qwenVoicePip: 'Góc tinh thần như con trẻ rạng ngời', + qwenVoiceStella: 'Hiền lành nhưng sẵn sàng rực rỡ nếu bộc lộ', + qwenVoiceBodega: 'Chú bồi bàn nước láng giềng nhiệt liệt', + qwenVoiceSonrisa: 'Niềm nở chia sẻ của cư ngụ nam Mỹ Châu', + qwenVoiceAlek: 'Đắm mình của không phân bổ lạnh', + qwenVoiceDolce: 'Lạ và quen của các người chú Ý gia dụng', + qwenVoiceSohee: 'Vui vẻ như nữ unnie từ xứ Kim Chi', + qwenVoiceOnoAnna: 'Cô gái trẻ vui tươi thời niên thiếu', + qwenVoiceLenn: 'Trưởng thành thích ăn mặc thời trang có gout Đức', + qwenVoiceEmilien: 'Ông anh trai từ dòng pháp lai tình ý', + qwenVoiceAndre: 'Cử chỉ thanh khiết có tính thu hút phái nữ', + qwenVoiceRadioGol: 'Người sáng tạo thơ bóng đá!', + qwenVoiceJada: 'Mợ Thượng Hải nhạy bén cá tính', + qwenVoiceDylan: 'Tài tử mang dòng máu Bắc Kinh rộn rã', + qwenVoiceLi: 'Đóng vai dạy tư thế thiền Yoga đỉnh cao', + qwenVoiceMarcus: 'Khí chất cứng nhắc mà đầy sự thật', + qwenVoiceRoy: 'Giao thoa đầy kếu ngạo dí dỏm chuẩn Đài Loan', + qwenVoicePeter: 'Độc quyền Tinh Lộc mậu dịch', + qwenVoiceSunny: 'Say lị đầy tính xảo ngọt xuyên tim', + qwenVoiceEric: 'Thục nam Thành Đô quý giá', + qwenVoiceRocky: 'Người nhà bên Hồng Kông ngông ngênh', + qwenVoiceKiki: 'Gái kiêu sa Hongkong thuần 100%', + // TTS Voice descriptions (VieNeu) + vieneuVoiceXuanVinh: 'Nam - Miền Nam (Mặc định)', + vieneuVoiceThucDoan: 'Nữ - Miền Nam', + vieneuVoiceDoanTrang: 'Nữ - Miền Bắc', + vieneuVoicePhamTuyen: 'Nam - Miền Bắc', + // ASR Language names (native forms - autoglossonyms) + lang_auto: 'Nhận diện tự động', + 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': 'Tiếng Trung (Giản thể, TQ)', + 'lang_zh-TW': 'Tiếng Trung (Phồn thể, Đài Loan)', + 'lang_zh-HK': 'Tiếng Quảng Đông (Hong Kong)', + 'lang_yue-Hant-HK': 'Tiếng Quảng Đông (Phồn thể)', + 'lang_en-US': 'Tiếng Anh (Mỹ)', + 'lang_en-GB': 'Tiếng Anh (Anh)', + 'lang_en-AU': 'Tiếng Anh (Úc)', + 'lang_en-CA': 'Tiếng Anh (Canada)', + 'lang_en-IN': 'Tiếng Anh (Ấn Độ)', + 'lang_en-NZ': 'Tiếng Anh (New Zealand)', + 'lang_en-ZA': 'Tiếng Anh (Nam Phi)', + 'lang_ja-JP': 'Tiếng Nhật', + 'lang_ko-KR': 'Tiếng Hàn', + 'lang_de-DE': 'Tiếng Đức', + 'lang_fr-FR': 'Tiếng Pháp', + 'lang_es-ES': 'Tiếng Tây Ban Nha (TBN)', + 'lang_es-MX': 'Tiếng Tây Ban Nha (Mexico)', + 'lang_es-AR': 'Tiếng Tây Ban Nha (Argentina)', + 'lang_es-CO': 'Tiếng Tây Ban Nha (Colombia)', + 'lang_it-IT': 'Tiếng Ý', + 'lang_pt-BR': 'Tiếng Bồ Đào Nha (Brazil)', + 'lang_pt-PT': 'Tiếng Bồ Đào Nha (BĐN)', + 'lang_ru-RU': 'Tiếng Nga', + 'lang_nl-NL': 'Tiếng Hà Lan', + 'lang_pl-PL': 'Tiếng Ba Lan', + 'lang_cs-CZ': 'Tiếng Séc', + 'lang_da-DK': 'Tiếng Đan Mạch', + 'lang_fi-FI': 'Tiếng Phần Lan', + 'lang_sv-SE': 'Tiếng Thụy Điển', + 'lang_no-NO': 'Tiếng Na Uy', + 'lang_tr-TR': 'Tiếng Thổ Nhĩ Kỳ', + 'lang_el-GR': 'Tiếng Hy Lạp', + 'lang_hu-HU': 'Tiếng Hungary', + 'lang_ro-RO': 'Tiếng Romania', + 'lang_sk-SK': 'Tiếng Slovak', + 'lang_bg-BG': 'Tiếng Bulgaria', + 'lang_hr-HR': 'Tiếng Croatia', + 'lang_ca-ES': 'Tiếng Catalan', + 'lang_ar-SA': 'Tiếng Ả Rập (Saudi)', + 'lang_ar-EG': 'Tiếng Ả Rập (Ai Cập)', + 'lang_he-IL': 'Tiếng Do Thái', + 'lang_hi-IN': 'Tiếng Hindi', + 'lang_th-TH': 'Tiếng Thái', + 'lang_vi-VN': 'Tiếng Việt', + 'lang_id-ID': 'Tiếng Indonesia', + 'lang_ms-MY': 'Tiếng Mã Lai', + 'lang_fil-PH': 'Tiếng Philippines', + 'lang_af-ZA': 'Tiếng Afrikaans', + 'lang_uk-UA': 'Tiếng Ukraina', + // PDF settings + pdfSettings: 'Phân tích tài liệu PDF', + pdfParsingSettings: 'Thiết lập lấy dữ liệu PDF', + pdfDescription: + 'Chọn hệ thống lấy tài liệu dạng văn bản lẫn biểu mẫu hình ảnh và số từ bên trong', + pdfProvider: 'Trình đọc PDF', + pdfFeatures: 'Tính năng hỗ trợ', + pdfApiKey: 'API Key', + pdfBaseUrl: 'Base URL', + mineruDescription: + 'MinerU là công cụ thương mại phục vụ chức năng phân tích nâng cao đối với file PDF khó dùng.', + mineruApiKeyRequired: 'Bạn phải có Key bản quyền tải về tại website MinerU.', + mineruWarning: 'Cảnh báo', + mineruCostWarning: + 'MinerU tính phí dựa vào số lượt xem lấy thông tin dữ liệu. Mời check giá cả tại MinerU web.', + enterMinerUApiKey: 'Nhập API key MinerU', + mineruLocalDescription: + 'Có thể cung cấp bằng localhost của máy của bạn, sau đó thiết lập bằng link vào đây.', + mineruServerAddress: 'Đia chỉ dạng Web API Service cục bộ hoặc từ xa máy con (http://...)', + mineruApiKeyOptional: 'Thích hợp khi cấu hình khóa bảo mật tại Node Server', + optionalApiKey: 'Tùy chọn cho API Key', + featureText: 'Trích xuất chữ viết', + featureImages: 'Trích xuất Hình', + featureTables: 'Lọc biểu mẫu', + featureFormulas: 'Nhận dạng Hàm/Công thức', + featureLayoutAnalysis: 'Ghi chú đánh giá layout', + featureMetadata: 'Tính toán dữ liệu File', + // Image Generation settings + enableImageGeneration: 'Mở tính năng vẽ tranh 2D ảo AI', + imageGenerationDisabledHint: + 'Khi đã cấp quyền thì tranh vẽ 2D ảo sẽ tự động bồi chung vào khoá học', + imageSettings: 'Năng lực làm Tranh và Ảnh (AI Generation)', + imageSection: 'Vẽ (Text To Image)', + imageProvider: 'Dịch vụ vẽ khung', + imageModel: 'Mô hình', + providerSeedream: 'Seedream (ByteDance)', + providerQwenImage: 'Qwen Image (Alibaba)', + providerNanoBanana: 'Nano Banana (Gemini)', + providerMiniMaxImage: 'MiniMax Image', + providerGrokImage: 'Grok Image (xAI)', + testImageGeneration: 'Xác minh AI tạo ảnh', + testImageConnectivity: 'Kiểm duyệt', + imageConnectivitySuccess: 'Hệ thống nhận liên lạc ok', + imageConnectivityFailed: 'Không nhận API', + imageTestSuccess: 'Bấm phát hình ngay thành công', + imageTestFailed: 'Không lên được bản phác họa', + imageTestPromptPlaceholder: 'Cho mô phỏng vẽ...', + imageTestPromptDefault: 'Tranh ảnh vẽ siêu thực về bạn mèo ngoan cực xinh trên chiếc Laptop', + imageGenerating: 'Phác thảo bản thiết kế...', + imageGenerationFailed: 'Thất bại khi bồi xuất', + // Video Generation settings + enableVideoGeneration: 'Bật khả năng Video AI thế hệ', + videoGenerationDisabledHint: + 'Hệ thống AI Video Engine tự động thêm vào dự án khi đăng ký', + videoSettings: 'Năng lực thiết kế Video (AI Generation)', + videoSection: 'Tạo Video theo Văn Học (TTV)', + videoProvider: 'Nhà cung cấp AI Engine', + videoModel: 'Cấu hình Engine', + providerSeedance: 'Seedance (ByteDance)', + providerKling: 'Kling (Kuaishou)', + providerVeo: 'Veo (Google)', + providerSora: 'Sora (OpenAI)', + providerMiniMaxVideo: 'MiniMax Video', + providerGrokVideo: 'Grok Video (xAI)', + testVideoGeneration: 'Bắt đầu bấm thử', + testVideoConnectivity: 'Kết nối cho server test', + videoConnectivitySuccess: 'Ping nhận phản hồi ổn', + videoConnectivityFailed: 'Không gọi về máy chủ Engine API', + testingConnection: 'Hệ thống load thử...', + videoTestSuccess: 'Qua bài đăng ký tạo Video API Test', + videoTestFailed: 'Test kết thúc bằng 1 lỗi', + videoTestPromptDefault: 'Một chú mèo đi lại trên bàn', + videoGenerating: 'Đang khởi động tạo nền video AI (1-2 Phút)...', + videoGenerationWarning: 'Trình AI làm video mất cỡ bằng vài phút để gánh đủ khung hình nên lưu lại chút đợi nhe', + mediaRetry: 'Gỡ Lỗi và Gọi lại', + mediaContentSensitive: 'Đã chặn tính năng xử video có nội dung xấu', + mediaGenerationDisabled: 'Đã bị cấm xài trình này ở trong Hệ Thống Cài Đặt', + // Agent settings (kept with main settings block above) + singleAgent: 'Đơn Tác nhân', + multiAgent: 'Đa Tác nhân (Lớp học)', + selectAgents: 'Vai diễn', + noVisionWarning: + 'Ghi nhận rằng LLM thiếu khả năng nhận dạng Vision nhưng nó vẫn rải hình ảnh trên bản vẽ vì không kiểm hóa rõ ràng chất lượng cho bạn', + // Server provider configuration + serverConfigured: 'Phía Server Backend', + serverConfiguredNotice: + 'Admin hiện có định dạng API này sẵn thay cho API cá nhân. Tự xài Key cá nhân bằng bỏ mã vào bên trên để ưu tiên xài.', + optionalOverride: 'Tùy chọn - nếu bỏ trống thì ứng dụng xài mã của Backend Cty phân bố', + // Access code + setupNeeded: 'Chưa đủ điều kiện kích hoạt ứng dụng, khai báo bổ sung cài đặt', + modelNotConfigured: 'Chọn 1 engine để hoàn thành phần khai báo', + // Clear cache + dangerZone: 'Khu vực Xóa rác', + clearCache: 'Toàn bộ nội bộ xoá Cache', + clearCacheDescription: + 'Dọn tất cả dữ liệu ghi nhớ của bạn bao gồm quá trình, tài liệu cache, và sở thích. Dữ liệu một đi không trở về.', + clearCacheConfirmTitle: 'Rõ là đang dọn trắng xóa ứng dụng nội bộ phải không?', + clearCacheConfirmDescription: + 'Bạn đang xoá cả vũng thông tin mà sau này chẳng kiếm đâu lại được đồ cổ:', + clearCacheConfirmItems: + 'Bảng mô phỏng ghi âm, nhật ký nhắn tin lớp học qua lại, bài học ghi chú, v.v.', + clearCacheConfirmInput: 'Bạn nhập "XOA" vào để máy đi đổ rác', + clearCacheConfirmPhrase: 'XOA', + clearCacheButton: 'Phá hũi toàn dữ liệu bên trong của máy trạm', + clearCacheSuccess: 'Thành công xong màn dọn rác. Máy tự động reload', + clearCacheFailed: 'Toang, không xoá hết được do có vài dữ liệu đang ghi chưa xong', + // Web Search settings + webSearchSettings: 'Tìm kiếm mạng lưới Data web', + webSearchApiKey: 'Tavily API Key', + webSearchApiKeyPlaceholder: 'Cho cái Key Tavily mà bạn mượn', + webSearchApiKeyPlaceholderServer: 'Chạy key có từ Server bên trên trừ khi bạn muốn nạp riêng', + webSearchApiKeyHint: 'Sang trang tavily.com đăng ký một thẻ chìa khoá API Key về đây đăng ký lệnh cho AI', + webSearchBaseUrl: 'Đường dẫn Base URL', + webSearchServerConfigured: 'Server Cty đã làm thủ tục để bật được Tavily Key cho bạn', + optional: 'Tùy chọn', + }, + profile: { + title: 'Hồ sơ cá nhân', + defaultNickname: 'Học sinh', + chooseAvatar: 'Thay Avatar', + uploadAvatar: 'Tải File ảnh', + bioPlaceholder: 'Hãy giới thiệu tí chuyện hay về bạn, những giáo viên ảo sẽ giảng học bám theo mô phỏng của bạn...', + avatarHint: 'Người thật và những vị giáo viên khác sẽ thấy rõ hình đại diện này trong Lớp Học Tương tác', + fileTooLarge: 'Vui lòng chọn hình nào nhẹ dưới kích thước 5MB', + invalidFileType: 'Cho mình xin hình gốc nghen', + editTooltip: 'Bấm sửa', + }, + media: { + imageCapability: 'Tạo Hình', + imageHint: 'Tìm ảnh trang chiếu', + videoCapability: 'Tạo Video', + videoHint: 'Nhúng Clip trang chiếu', + ttsCapability: 'Phát Động Tiếng (TTS)', + ttsHint: 'LLM Cô/Thầy dùng Voice', + asrCapability: 'Dịch Thoại Thành Chữ (ASR)', + asrHint: 'Cho nói micro trên web', + provider: 'Provider', + model: 'Engine LLM Mô Hình', + voice: 'Giọng nói điệu', + speed: 'Tốc âm', + language: 'Ngôn Lệnh', + }, +} as const; diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts index 0a376ca10..f48c00bd4 100644 --- a/lib/i18n/stage.ts +++ b/lib/i18n/stage.ts @@ -296,3 +296,152 @@ export const stageEnUS = { notReady: 'Available after generation completes', }, } as const; + +export const stageViVN = { + stage: { + currentScene: 'Cảnh hiện tại', + generating: 'Đang tạo...', + paused: 'Đã tạm dừng', + generationFailed: 'Tạo thất bại', + confirmSwitchTitle: 'Chuyển đổi cảnh', + confirmSwitchMessage: 'Một chủ đề hiện đang được tiến hành. Chuyển đổi cảnh sẽ kết thúc chủ đề hiện tại. Bạn có chắc chắn không?', + generatingNextPage: 'Cảnh đang được tạo, vui lòng đợi...', + fullscreen: 'Toàn màn hình', + exitFullscreen: 'Thoát toàn màn hình', + }, + whiteboard: { + title: 'Bảng trắng tương tác', + open: 'Mở Bảng trắng', + clear: 'Xóa Bảng trắng', + minimize: 'Thu gọn Bảng trắng', + ready: 'Bảng trắng đã sẵn sàng', + readyHint: 'Các thành phần sẽ hiển thị ở đây khi AI thêm vào', + clearSuccess: 'Xóa bảng trắng thành công', + clearError: 'Không thể xóa bảng trắng: ', + resetView: 'Đặt lại góc nhìn', + restoreError: 'Không thể khôi phục bảng trắng: ', + history: 'Lịch sử', + restore: 'Khôi phục', + noHistory: 'Chưa có lịch sử', + restored: 'Đã khôi phục bảng trắng', + elementCount: '{count} phần tử', + }, + quiz: { + title: 'Câu hỏi ôn tập', + subtitle: 'Kiểm tra kiến thức của bạn', + questionsCount: 'câu hỏi', + totalPrefix: 'Tất cả', + pointsSuffix: 'điểm', + startQuiz: 'Bắt đầu làm bài', + multipleChoiceHint: '(Nhiều lựa chọn — chọn tất cả đáp án đúng)', + inputPlaceholder: 'Nhập câu trả lời của bạn ở đây...', + charCount: 'ký tự', + yourAnswer: 'Câu trả lời của bạn:', + notAnswered: 'Chưa trả lời', + aiComment: 'Đánh giá của AI', + singleChoice: 'Một đáp án', + multipleChoice: 'Nhiều đáp án', + shortAnswer: 'Câu trả lời ngắn', + analysis: 'Giải thích: ', + excellent: 'Tuyệt vời!', + keepGoing: 'Tiếp tục phát huy nhé!', + needsReview: 'Cần ôn tập lại', + correct: 'chính xác', + incorrect: 'chưa đúng', + answering: 'Đang làm bài', + submitAnswers: 'Nộp bài', + aiGrading: 'AI đang chấm điểm...', + aiGradingWait: 'Vui lòng chờ, đang phân tích câu trả lời của bạn', + quizReport: 'Kết quả làm bài', + retry: 'Làm lại', + }, + roundtable: { + teacher: 'GIÁO VIÊN', + you: 'BẠN', + inputPlaceholder: 'Nhập tin nhắn của bạn...', + listening: 'Đang nghe...', + processing: 'Đang xử lý...', + noSpeechDetected: 'Không nhận diện được giọng nói, vui lòng thử lại', + discussionEnded: 'Thảo luận đã kết thúc', + qaEnded: 'Q&A đã kết thúc', + thinking: 'Đang suy nghĩ', + yourTurn: 'Lượt của bạn', + stopDiscussion: 'Dừng thảo luận', + autoPlay: 'Tự động phát lời nói', + autoPlayOff: 'Dừng tự động phát', + speed: 'Tốc độ', + voiceInput: 'Nhập bằng giọng nói', + voiceInputDisabled: 'Nhập giọng nói đang bị tắt', + textInput: 'Nhập văn bản', + stopRecording: 'Dừng ghi âm', + startRecording: 'Bắt đầu ghi âm', + }, + pbl: { + legacyFormat: 'Cảnh PBL này sử dụng định dạng cũ. Vui lòng tạo lại khóa học.', + emptyProject: 'Dự án PBL chưa được tạo. Vui lòng tạo thông qua phần Tạo khóa học.', + roleSelection: { + title: 'Chọn vai trò của bạn', + description: 'Chọn một vai trò để bắt đầu cộng tác trong dự án', + }, + workspace: { + restart: 'Khởi động lại', + confirmRestart: 'Đặt lại toàn bộ tiến độ?', + confirm: 'Xác nhận', + cancel: 'Hủy bỏ', + }, + issueboard: { + title: 'Danh sách công việc', + noIssues: 'Chưa có công việc nào', + statusDone: 'Hoàn thành', + statusActive: 'Đang thực hiện', + statusPending: 'Chờ xử lý', + }, + chat: { + title: 'Thảo luận dự án', + currentIssue: 'Công việc hiện tại', + mentionHint: 'Dùng @question để đặt câu hỏi, @judge để gửi duyệt', + placeholder: 'Nhập tin nhắn...', + send: 'Gửi', + welcomeMessage: + 'Xin chào! Tôi là Trợ lý giải đáp cho nhiệm vụ: "{title}"\n\nĐể hỗ trợ bạn, tôi đã chuẩn bị một số câu hỏi gợi ý:\n\n{questions}\n\nHãy thoải mái @question tôi lúc nào cũng được nếu bạn cần trợ giúp nhé!', + issueCompleteMessage: 'Công việc "{completed}" đã hoàn thành! Chuyển sang phần tiếp theo: "{next}"', + allCompleteMessage: '🎉 Tất cả công việc đã hoàn tất! Bạn làm dự án tốt lắm!', + }, + guide: { + howItWorks: 'Hướng dẫn sử dụng', + help: 'Trợ giúp', + title: 'Trợ giúp', + step1: { + title: 'Bước 1: Chọn một Vai trò', + desc: 'Sau khi nội dung dự án được tạo, hãy chọn một vai trò từ danh sách (các vai trò không có biểu tượng 🟢 là vai trò của hệ thống)', + }, + step2: { + title: 'Bước 2: Hoàn thành nhiệm vụ', + desc: 'Mỗi nhiệm vụ tượng trưng cho một mục tiêu học tập:', + s1: { + title: 'Xem công việc hiện tại', + desc: 'Kiểm tra chủ đề, mô tả và người được giao', + }, + s2: { + title: 'Nhận hướng dẫn', + example: '@question Tôi nên bắt đầu từ đâu?\n@question Làm sao thiết lập được tính năng này?', + desc: 'Trợ lý câu hỏi sẽ đặt các câu hỏi để định hướng giúp bạn (sẽ không cung cấp ngay ra đáp án)', + }, + s3: { + title: 'Nộp bài làm của bạn', + example: "@judge Tôi đã làm xong, vui lòng kiểm tra Ghi chú của tôi", + desc: 'Trợ lý giám khảo sẽ đánh giá và đưa ra nhận xét:', + complete: 'Tự động tiến đến nhiệm vụ tiếp theo', + revision: 'Cải tiến dựa trên nhận xét mới nhất', + }, + }, + step3: { + title: 'Bước 3: Hoàn thành dự án', + desc: 'Sau khi tất cả các nhiệm vụ được đánh dấu hoàn tất, hệ thống sẽ hiện thông báo "🎉 Hoàn tất dự án!"', + }, + }, + }, + share: { + notReady: 'Có thể chia sẻ sau khi chạy tạo hoàn tất', + }, +} as const; diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts index 6173b0be3..17228bb43 100644 --- a/lib/i18n/types.ts +++ b/lib/i18n/types.ts @@ -1,3 +1,3 @@ -export type Locale = 'zh-CN' | 'en-US'; +export type Locale = 'zh-CN' | 'en-US' | 'vi-VN'; export const defaultLocale: Locale = 'zh-CN'; diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index eda67b4c4..f2eb796db 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -28,6 +28,7 @@ import { } from '@/lib/server/classroom-media-generation'; import type { UserRequirements } from '@/lib/types/generation'; import type { Scene, Stage } from '@/lib/types/stage'; +import type { Locale } from '@/lib/i18n/types'; const log = createLogger('Classroom'); @@ -96,8 +97,8 @@ function createInMemoryStore(stage: Stage): StageStore { }; } -function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' { - return language === 'en-US' ? 'en-US' : 'zh-CN'; +function normalizeLanguage(language?: string): Locale { + return language === 'en-US' ? 'en-US' : language === 'vi-VN' ? 'vi-VN' : 'zh-CN'; } function stripCodeFences(text: string): string { diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 4b088bbc6..f49ebddc7 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -290,6 +290,7 @@ const getDefaultAudioConfig = () => ({ 'doubao-tts': { apiKey: '', baseUrl: '', enabled: false }, 'elevenlabs-tts': { apiKey: '', baseUrl: '', enabled: false }, 'minimax-tts': { apiKey: '', baseUrl: '', modelId: 'speech-2.8-hd', enabled: false }, + 'vieneu-tts': { apiKey: '', baseUrl: '', enabled: false }, 'browser-native-tts': { apiKey: '', baseUrl: '', enabled: true }, } as Record< TTSProviderId, diff --git a/lib/types/generation.ts b/lib/types/generation.ts index c1e6eb7a7..8e07df40e 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -7,6 +7,7 @@ import type { ActionType } from './action'; import type { MediaGenerationRequest } from '@/lib/media/types'; +import type { Locale } from '@/lib/i18n/types'; // ==================== PDF Image Types ==================== @@ -64,7 +65,7 @@ export interface UploadedDocument { */ export interface UserRequirements { requirement: string; // Single free-form text for all user input - language: 'zh-CN' | 'en-US'; // Course language - critical for generation + language: Locale; // Course language - critical for generation userNickname?: string; // Student nickname for personalization userBio?: string; // Student background for personalization webSearch?: boolean; // Enable web search for richer context @@ -100,7 +101,7 @@ export interface SceneOutline { teachingObjective?: string; estimatedDuration?: number; // seconds order: number; - language?: 'zh-CN' | 'en-US'; // Generation language (inherited from requirements) + language?: Locale; // Generation language (inherited from requirements) // Suggested image IDs (from PDF-extracted images) suggestedImageIds?: string[]; // e.g., ["img_1", "img_3"] // AI-generated media requests (when PDF images are insufficient) @@ -124,7 +125,7 @@ export interface SceneOutline { projectDescription: string; targetSkills: string[]; issueCount?: number; - language: 'zh-CN' | 'en-US'; + language: Locale; }; } diff --git a/public/logos/vieneu.svg b/public/logos/vieneu.svg new file mode 100644 index 000000000..e808621e6 --- /dev/null +++ b/public/logos/vieneu.svg @@ -0,0 +1,3 @@ + + 🦜 + diff --git a/result.mp3 b/result.mp3 new file mode 100644 index 000000000..b388c36dd Binary files /dev/null and b/result.mp3 differ diff --git a/result.wav b/result.wav new file mode 100644 index 000000000..be20b0e3b Binary files /dev/null and b/result.wav differ