From 4bd69341a56f2aa5a36ced2406f02e9e338b0c1c Mon Sep 17 00:00:00 2001 From: ellaguno Date: Wed, 25 Mar 2026 10:35:03 -0600 Subject: [PATCH 1/5] feat(i18n): add Spanish (es-MX) locale support Add complete Spanish (Mexican) translations for the entire UI: - All 5 i18n translation files (common, stage, chat, generation, settings) - Language selector in header with ES option - Browser language auto-detection for Spanish - Generation toolbar supports es-MX for course content language - Updated all type definitions to accept 'es-MX' locale - Updated normalizeLanguage and all language validation checks Co-Authored-By: Claude Opus 4.6 --- app/api/generate/scene-content/route.ts | 2 +- app/page.tsx | 11 +- components/generation/generation-toolbar.tsx | 12 +- components/header.tsx | 15 +- lib/generation/scene-generator.ts | 2 +- lib/hooks/use-i18n.tsx | 9 +- lib/i18n/chat.ts | 74 +++ lib/i18n/common.ts | 41 ++ lib/i18n/generation.ts | 68 +++ lib/i18n/index.ts | 19 +- lib/i18n/settings.ts | 596 +++++++++++++++++++ lib/i18n/stage.ts | 153 +++++ lib/i18n/types.ts | 2 +- lib/server/classroom-generation.ts | 6 +- lib/types/generation.ts | 8 +- 15 files changed, 993 insertions(+), 25 deletions(-) diff --git a/app/api/generate/scene-content/route.ts b/app/api/generate/scene-content/route.ts index db9b772e1..bf30ee561 100644 --- a/app/api/generate/scene-content/route.ts +++ b/app/api/generate/scene-content/route.ts @@ -67,7 +67,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 'zh-CN' | 'en-US' | 'es-MX') || 'zh-CN', }; // ── Model resolution from request headers ── diff --git a/app/page.tsx b/app/page.tsx index 68719c489..3882e5fa8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -56,7 +56,7 @@ const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; interface FormState { pdfFile: File | null; requirement: string; - language: 'zh-CN' | 'en-US'; + language: 'zh-CN' | 'en-US' | 'es-MX'; webSearch: boolean; } @@ -99,10 +99,15 @@ function HomePage() { const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); const updates: Partial = {}; if (savedWebSearch === 'true') updates.webSearch = true; - if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') { + if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US' || savedLanguage === 'es-MX') { updates.language = savedLanguage; } else { - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + const browserLang = navigator.language; + const detected = browserLang?.startsWith('zh') + ? 'zh-CN' + : browserLang?.startsWith('es') + ? 'es-MX' + : 'en-US'; updates.language = detected; } if (Object.keys(updates).length > 0) { diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bbd8..508a1c0ab 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -28,8 +28,8 @@ const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { - language: 'zh-CN' | 'en-US'; - onLanguageChange: (lang: 'zh-CN' | 'en-US') => void; + language: 'zh-CN' | 'en-US' | 'es-MX'; + onLanguageChange: (lang: 'zh-CN' | 'en-US' | 'es-MX') => void; webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; @@ -361,11 +361,15 @@ export function GenerationToolbar({ {t('toolbar.languageHint')} diff --git a/components/header.tsx b/components/header.tsx index 5a61ec96f..b13636e19 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 === 'es-MX' ? 'ES' : 'EN'} {languageOpen && (
@@ -138,6 +138,19 @@ export function Header({ currentSceneTitle }: HeaderProps) { > English +
)} diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937d..aacd678ab 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -735,7 +735,7 @@ function normalizeQuizAnswer(question: Record): string[] | unde async function generateInteractiveContent( outline: SceneOutline, aiCall: AICallFn, - language: 'zh-CN' | 'en-US' = 'zh-CN', + language: 'zh-CN' | 'en-US' | 'es-MX' = 'zh-CN', ): Promise { const config = outline.interactiveConfig!; diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index 4e642f4c2..2e2690b94 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', 'es-MX']; const I18nContext = createContext(undefined); @@ -26,7 +26,12 @@ export function I18nProvider({ children }: { children: ReactNode }) { setLocaleState(stored as Locale); return; } - const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US'; + const browserLang = navigator.language; + const detected = browserLang?.startsWith('zh') + ? 'zh-CN' + : browserLang?.startsWith('es') + ? 'es-MX' + : '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..0baf232f8 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 chatEsMX = { + chat: { + lecture: 'Clase', + noConversations: 'Sin conversaciones', + startConversation: 'Escribe un mensaje para iniciar la conversación', + noMessages: 'Aún no hay mensajes', + ended: 'finalizado', + unknown: 'Desconocido', + stopDiscussion: 'Detener Discusión', + endQA: 'Finalizar Preguntas', + tabs: { + lecture: 'Notas', + chat: 'Chat', + }, + lectureNotes: { + empty: 'Las notas aparecerán aquí después de reproducir la clase', + emptyHint: 'Presiona reproducir para iniciar la clase', + pageLabel: 'Página {n}', + currentPage: 'Actual', + }, + badge: { + qa: 'P&R', + discussion: 'DISC', + lecture: 'CLASE', + }, + }, + actions: { + names: { + spotlight: 'Foco', + laser: 'Láser', + wb_open: 'Abrir Pizarra', + wb_draw_text: 'Texto en Pizarra', + wb_draw_shape: 'Forma en Pizarra', + wb_draw_chart: 'Gráfica en Pizarra', + wb_draw_latex: 'Fórmula en Pizarra', + wb_draw_table: 'Tabla en Pizarra', + wb_draw_line: 'Línea en Pizarra', + wb_clear: 'Limpiar Pizarra', + wb_delete: 'Eliminar Elemento', + wb_close: 'Cerrar Pizarra', + discussion: 'Discusión', + }, + status: { + inputStreaming: 'Esperando', + inputAvailable: 'Ejecutando', + outputAvailable: 'Completado', + outputError: 'Error', + outputDenied: 'Rechazado', + running: 'Ejecutando', + result: 'Completado', + error: 'Error', + }, + }, + agentBar: { + readyToLearn: '¿Listos para aprender juntos?', + expandedTitle: 'Configuración de Roles del Aula', + configTooltip: 'Clic para configurar los roles del aula', + voiceLabel: 'Voz', + voiceLoading: 'Cargando...', + voiceAutoAssign: 'Las voces se asignarán automáticamente', + }, + proactiveCard: { + discussion: 'Discusión', + join: 'Unirse', + skip: 'Omitir', + pause: 'Pausar', + resume: 'Reanudar', + }, + voice: { + startListening: 'Entrada de voz', + stopListening: 'Detener grabación', + }, +} as const; diff --git a/lib/i18n/common.ts b/lib/i18n/common.ts index 1bceb5d61..3e1e12abe 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 commonEsMX = { + common: { + you: 'Tú', + confirm: 'Confirmar', + cancel: 'Cancelar', + loading: 'Cargando...', + }, + home: { + slogan: 'Aprendizaje Generativo en Aulas Interactivas Multi-Agente', + greeting: 'Hola, ', + }, + toolbar: { + languageHint: 'El curso se generará en este idioma', + pdfParser: 'Analizador', + pdfUpload: 'Subir PDF', + removePdf: 'Eliminar archivo', + webSearchOn: 'Activado', + webSearchOff: 'Clic para activar', + webSearchDesc: 'Buscar en la web información actualizada antes de generar', + webSearchProvider: 'Motor de búsqueda', + webSearchNoProvider: 'Configura la API Key del motor de búsqueda en Ajustes', + selectProvider: 'Seleccionar proveedor', + configureProvider: 'Configurar modelo', + configureProviderHint: 'Configura al menos un proveedor de modelo para generar cursos', + enterClassroom: 'Entrar al Aula', + advancedSettings: 'Ajustes Avanzados', + ttsTitle: 'Síntesis de Voz', + ttsHint: 'Elige una voz para el profesor IA', + ttsPreview: 'Vista previa', + ttsPreviewing: 'Reproduciendo...', + }, + export: { + pptx: 'Exportar PPTX', + resourcePack: 'Exportar Paquete de Recursos', + resourcePackDesc: 'PPTX + páginas interactivas', + exporting: 'Exportando...', + exportSuccess: 'Exportación exitosa', + exportFailed: 'Error al exportar', + }, +} as const; diff --git a/lib/i18n/generation.ts b/lib/i18n/generation.ts index 98694c234..517af78fc 100644 --- a/lib/i18n/generation.ts +++ b/lib/i18n/generation.ts @@ -133,3 +133,71 @@ export const generationEnUS = { webSearchFailed: 'Web search failed', }, } as const; + +export const generationEsMX = { + classroom: { + recentClassrooms: 'Recientes', + today: 'Hoy', + yesterday: 'Ayer', + daysAgo: 'días atrás', + slides: 'diapositivas', + nameCopied: 'Nombre copiado', + deleteConfirmTitle: 'Eliminar', + delete: 'Eliminar', + }, + upload: { + pdfSizeLimit: 'Soporta archivos PDF de hasta 50MB', + generateFailed: 'Error al generar el aula, intenta de nuevo', + requirementPlaceholder: + 'Dime lo que quieras aprender, por ejemplo:\n"Enséñame Python desde cero en 30 minutos"\n"Explícame la Transformada de Fourier en la pizarra"\n"¿Cómo se juega el juego de mesa Avalon?"', + requirementRequired: 'Por favor ingresa los requisitos del curso', + fileTooLarge: 'Archivo demasiado grande. Selecciona un PDF menor a 50MB', + }, + generation: { + analyzingPdf: 'Analizando Documento PDF', + analyzingPdfDesc: 'Extrayendo estructura y contenido del documento...', + pdfLoadFailed: 'Error al cargar el PDF, intenta de nuevo', + pdfParseFailed: 'Error al analizar el PDF', + streamNotReadable: 'No se puede leer el flujo de generación', + generatingOutlines: 'Creando Esquema del Curso', + generatingOutlinesDesc: 'Estructurando la ruta de aprendizaje...', + generatingSlideContent: 'Generando Contenido de Páginas', + generatingSlideContentDesc: 'Creando diapositivas, exámenes y contenido interactivo...', + generatingActions: 'Generando Acciones de Enseñanza', + generatingActionsDesc: 'Orquestando narración, focos e interacciones...', + generationComplete: '¡Generación completada!', + generationFailed: 'Error en la generación', + generatingCourse: 'Generando curso', + openingClassroom: 'Abriendo el aula...', + outlineReady: 'Esquema del curso generado', + generatingFirstPage: 'Generando primera página...', + firstPageReady: '¡Primera página lista! Abriendo el aula...', + speechFailed: 'Error en la generación de voz', + retryScene: 'Reintentar', + retryingScene: 'Regenerando...', + backToHome: 'Volver al Inicio', + sessionNotFound: 'Sesión No Encontrada', + sessionNotFoundDesc: + 'Por favor ingresa los requisitos del curso para iniciar el proceso de generación.', + goBackAndRetry: 'Regresar e Intentar de Nuevo', + classroomReady: + 'Tu entorno de aprendizaje personalizado con IA se ha generado exitosamente.', + aiWorking: 'Agentes IA Trabajando...', + textTruncated: + 'El texto del documento es extenso, usando los primeros {n} caracteres para la generación', + imageTruncated: + 'Se encontraron {total} imágenes, excediendo el límite de {max}. Las imágenes extra usarán solo descripciones de texto', + agentGeneration: 'Generando Roles del Aula', + agentGenerationDesc: 'Generando roles basados en el contenido del curso...', + agentRevealTitle: 'Tus Roles del Aula', + viewAgents: 'Ver Roles', + continue: 'Continuar', + outlineRetrying: 'Problema en la generación del esquema, reintentando...', + outlineEmptyResponse: + 'El modelo no devolvió esquemas válidos. Revisa la configuración del modelo e intenta de nuevo', + outlineGenerateFailed: 'Error al generar el esquema, intenta más tarde', + webSearching: 'Búsqueda Web', + webSearchingDesc: 'Buscando en la web información actualizada', + webSearchFailed: 'Error en la búsqueda web', + }, +} as const; diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts index 5fd70da52..f985433e0 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, commonEsMX } from './common'; +import { stageZhCN, stageEnUS, stageEsMX } from './stage'; +import { chatZhCN, chatEnUS, chatEsMX } from './chat'; +import { generationZhCN, generationEnUS, generationEsMX } from './generation'; +import { settingsZhCN, settingsEnUS, settingsEsMX } from './settings'; export const translations = { 'zh-CN': { @@ -21,6 +21,13 @@ export const translations = { ...generationEnUS, ...settingsEnUS, }, + 'es-MX': { + ...commonEsMX, + ...stageEsMX, + ...chatEsMX, + ...generationEsMX, + ...settingsEsMX, + }, } as const; export type TranslationKey = keyof (typeof translations)[typeof defaultLocale]; @@ -40,7 +47,7 @@ export function getClientTranslation(key: string): string { if (typeof window !== 'undefined') { try { const storedLocale = localStorage.getItem('locale'); - if (storedLocale === 'zh-CN' || storedLocale === 'en-US') { + if (storedLocale === 'zh-CN' || storedLocale === 'en-US' || storedLocale === 'es-MX') { locale = storedLocale; } } catch { diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts index 3ba0be4f3..0b3974974 100644 --- a/lib/i18n/settings.ts +++ b/lib/i18n/settings.ts @@ -1178,3 +1178,599 @@ export const settingsEnUS = { language: 'Language', }, } as const; + +export const settingsEsMX = { + settings: { + title: 'Ajustes', + description: 'Configurar ajustes de la aplicación', + language: 'Idioma', + languageDesc: 'Seleccionar idioma de la interfaz', + theme: 'Tema', + themeDesc: 'Seleccionar modo de tema (Claro/Oscuro/Sistema)', + themeOptions: { + light: 'Claro', + dark: 'Oscuro', + system: 'Sistema', + }, + apiKey: 'Clave API', + apiKeyDesc: 'Configura tu clave API', + apiBaseUrl: 'URL del Endpoint API', + apiBaseUrlDesc: 'Configura la URL de tu endpoint API', + apiKeyRequired: 'La clave API no puede estar vacía', + model: 'Configuración del Modelo', + modelDesc: 'Configurar modelos de IA', + modelPlaceholder: 'Ingresa o selecciona el nombre del modelo', + ttsModel: 'Modelo TTS', + ttsModelDesc: 'Configurar modelos TTS', + ttsModelPlaceholder: 'Ingresa o selecciona el nombre del modelo TTS', + ttsModelOptions: { + openaiTts: 'OpenAI TTS', + azureTts: 'Azure TTS', + }, + testConnection: 'Probar Conexión', + testConnectionDesc: 'Probar que la configuración API actual esté disponible', + testing: 'Probando...', + agentSettings: 'Configuración de Agentes', + agentSettingsDesc: + 'Selecciona los agentes que participarán en la conversación. Selecciona 1 para modo de agente único, selecciona varios para modo colaborativo multi-agente.', + agentMode: 'Modo de Agente', + agentModePreset: 'Preestablecido', + agentModeAuto: 'Auto-generar', + agentModeAutoDesc: 'La IA generará automáticamente roles apropiados', + autoAgentCount: 'Cantidad de Agentes', + autoAgentCountDesc: 'Número de agentes a auto-generar (incluyendo profesor)', + atLeastOneAgent: 'Por favor selecciona al menos 1 agente', + singleAgentMode: 'Modo Agente Único', + directAnswer: 'Respuesta Directa', + multiAgentMode: 'Modo Multi-Agente', + agentsCollaborating: 'Discusión Colaborativa', + agentsCollaboratingCount: '{count} agentes seleccionados para discusión colaborativa', + maxTurns: 'Turnos Máximos de Discusión', + maxTurnsDesc: + 'El número máximo de turnos de discusión entre agentes (cada agente completa acciones y responde cuenta como un turno)', + priority: 'Prioridad', + actions: 'Acciones', + actionCount: '{count} acciones', + selectedAgent: 'Agente Seleccionado', + selectedAgents: 'Agentes Seleccionados', + required: 'Requerido', + agentNames: { + 'default-1': 'Profesor IA', + 'default-2': 'Asistente IA', + 'default-3': 'El Bromista', + 'default-4': 'Mente Curiosa', + 'default-5': 'Tomador de Notas', + 'default-6': 'Pensador Profundo', + }, + agentRoles: { + teacher: 'Profesor', + assistant: 'Asistente', + student: 'Estudiante', + }, + agentDescriptions: { + 'default-1': 'Profesor principal con explicaciones claras y estructuradas', + 'default-2': 'Apoya el aprendizaje y ayuda a aclarar puntos clave', + 'default-3': 'Aporta humor y energía al aula', + 'default-4': 'Siempre curioso, le encanta preguntar por qué y cómo', + 'default-5': 'Registra y organiza diligentemente las notas de clase', + 'default-6': 'Piensa profundamente y explora la esencia de los temas', + }, + close: 'Cerrar', + save: 'Guardar', + // Provider settings + providers: 'LLM', + addProviderDescription: 'Agrega proveedores de modelos personalizados para ampliar los modelos de IA disponibles', + providerNames: { + openai: 'OpenAI', + anthropic: 'Claude', + google: 'Gemini', + deepseek: 'DeepSeek', + qwen: 'Qwen', + kimi: 'Kimi', + minimax: 'MiniMax', + glm: 'GLM', + siliconflow: 'SiliconFlow', + }, + providerTypes: { + openai: 'Protocolo OpenAI', + anthropic: 'Protocolo Claude', + google: 'Protocolo Gemini', + }, + modelCount: 'modelos', + modelSingular: 'modelo', + defaultModel: 'Modelo Predeterminado', + webSearch: 'Búsqueda Web', + mcp: 'MCP', + knowledgeBase: 'Base de Conocimiento', + documentParser: 'Analizador de Documentos', + conversationSettings: 'Conversación', + keyboardShortcuts: 'Atajos', + generalSettings: 'General', + systemSettings: 'Sistema', + addProvider: 'Agregar', + importFromClipboard: 'Importar desde Portapapeles', + apiSecret: 'Clave API', + apiHost: 'URL Base', + requestUrl: 'URL de Solicitud', + models: 'Modelos', + addModel: 'Nuevo', + reset: 'Restablecer', + fetch: 'Obtener', + connectionSuccess: 'Conexión exitosa', + connectionFailed: 'Error de conexión', + // Model capabilities + capabilities: { + vision: 'Visión', + tools: 'Herramientas', + streaming: 'Streaming', + }, + contextWindow: 'Contexto', + contextShort: 'ctx', + outputWindow: 'Salida', + // Provider management + addProviderButton: 'Agregar', + addProviderDialog: 'Agregar Proveedor de Modelos', + providerName: 'Nombre', + providerNamePlaceholder: 'ej., Mi Proxy OpenAI', + providerNameRequired: 'Por favor ingresa el nombre del proveedor', + providerApiMode: 'Modo API', + apiModeOpenAI: 'Protocolo OpenAI', + apiModeAnthropic: 'Protocolo Claude', + apiModeGoogle: 'Protocolo Gemini', + defaultBaseUrl: 'URL Base Predeterminada', + providerIcon: 'URL del Ícono del Proveedor', + requiresApiKey: 'Requiere Clave API', + deleteProvider: 'Eliminar Proveedor', + deleteProviderConfirm: '¿Estás seguro de que deseas eliminar este proveedor?', + cannotDeleteBuiltIn: 'No se puede eliminar un proveedor integrado', + resetToDefault: 'Restablecer a Predeterminado', + resetToDefaultDescription: + 'Restaurar la lista de modelos a la configuración predeterminada (se conservarán la clave API y URL Base)', + resetConfirmDescription: + 'Esto eliminará todos los modelos personalizados y restaurará la lista de modelos integrados. La clave API y URL Base se conservarán.', + confirmReset: 'Confirmar Restablecimiento', + resetSuccess: 'Restablecido exitosamente a la configuración predeterminada', + saveSuccess: 'Ajustes guardados', + saveFailed: 'Error al guardar ajustes, intenta de nuevo', + cannotDeleteBuiltInModel: 'No se puede eliminar un modelo integrado', + cannotEditBuiltInModel: 'No se puede editar un modelo integrado', + modelIdRequired: 'Por favor ingresa el ID del modelo', + noModelsAvailable: 'No hay modelos disponibles para probar', + providerMetadata: 'Metadatos del Proveedor', + // Model editing + editModel: 'Editar Modelo', + editModelDescription: 'Editar configuración y capacidades del modelo', + addNewModel: 'Nuevo Modelo', + addNewModelDescription: 'Agregar una nueva configuración de modelo', + modelId: 'ID del Modelo', + modelIdPlaceholder: 'ej., gpt-4o', + modelName: 'Nombre para Mostrar', + modelNamePlaceholder: 'Opcional', + modelCapabilities: 'Capacidades', + advancedSettings: 'Ajustes Avanzados', + contextWindowLabel: 'Ventana de Contexto', + contextWindowPlaceholder: 'ej., 128000', + outputWindowLabel: 'Tokens Máximos de Salida', + outputWindowPlaceholder: 'ej., 4096', + testModel: 'Probar Modelo', + deleteModel: 'Eliminar', + cancelEdit: 'Cancelar', + saveModel: 'Guardar', + modelsManagementDescription: + 'Administra los modelos de este proveedor. Para seleccionar el modelo activo, ve a "General".', + // General settings + howToUse: 'Cómo Usar', + step1ConfigureProvider: + 'Ve a "Proveedores de Modelos", selecciona o agrega un proveedor y configura los ajustes de conexión (clave API, URL Base, etc.)', + step2SelectModel: 'Selecciona el modelo que deseas usar en "Modelo Activo" abajo', + step3StartUsing: 'Después de guardar, el sistema usará el modelo seleccionado', + activeModel: 'Modelo Activo', + activeModelDescription: 'Selecciona el modelo para conversaciones de IA y generación de contenido', + selectModel: 'Seleccionar Modelo', + searchModels: 'Buscar modelos', + noModelsFound: 'No se encontraron modelos coincidentes', + noConfiguredProviders: 'Sin proveedores configurados', + configureProvidersFirst: + 'Por favor configura los ajustes de conexión del proveedor en "Proveedores de Modelos" a la izquierda', + currentlyUsing: 'Usando actualmente', + // TTS settings + ttsSettings: 'Síntesis de Voz', + // ASR settings + asrSettings: 'Reconocimiento de Voz', + // Audio settings (legacy) + audioSettings: 'Ajustes de Audio', + ttsSection: 'Texto a Voz (TTS)', + asrSection: 'Reconocimiento Automático de Voz (ASR)', + ttsDescription: 'TTS (Texto a Voz) - Convierte texto en voz', + asrDescription: 'ASR (Reconocimiento Automático de Voz) - Convierte voz en texto', + enableTTS: 'Habilitar Síntesis de Voz', + ttsEnabledDescription: 'Cuando está habilitado, se generará audio de voz durante la creación del curso', + ttsVoiceConfigHint: + 'La voz de cada agente se puede configurar en "Configuración de Roles del Aula" en la página principal', + enableASR: 'Habilitar Reconocimiento de Voz', + asrEnabledDescription: 'Cuando está habilitado, los estudiantes pueden usar el micrófono para entrada de voz', + ttsProvider: 'Proveedor TTS', + ttsLanguageFilter: 'Filtro de Idioma', + allLanguages: 'Todos los Idiomas', + ttsVoice: 'Voz', + ttsSpeed: 'Velocidad', + ttsBaseUrl: 'URL Base', + ttsApiKey: 'Clave API', + asrProvider: 'Proveedor ASR', + asrLanguage: 'Idioma de Reconocimiento', + asrBaseUrl: 'URL Base', + asrApiKey: 'Clave API', + enterApiKey: 'Ingresa la Clave API', + enterCustomBaseUrl: 'Ingresa URL Base personalizada', + browserNativeNote: 'ASR nativo del navegador no requiere configuración y es completamente gratuito', + // Audio provider names + providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)', + providerAzureTTS: 'Azure TTS', + providerGLMTTS: 'GLM TTS', + providerQwenTTS: 'Qwen TTS (Alibaba Cloud Bailian)', + providerElevenLabsTTS: 'ElevenLabs TTS', + providerBrowserNativeTTS: 'TTS Nativo del Navegador', + providerOpenAIWhisper: 'OpenAI ASR (gpt-4o-mini-transcribe)', + providerBrowserNative: 'ASR Nativo del Navegador', + providerQwenASR: 'Qwen ASR (Alibaba Cloud Bailian)', + providerUnpdf: 'unpdf (Integrado)', + providerMinerU: 'MinerU', + browserNativeTTSNote: + 'TTS nativo del navegador no requiere configuración y es completamente gratuito, usa las voces integradas del sistema', + testTTS: 'Probar TTS', + testASR: 'Probar ASR', + testSuccess: 'Prueba Exitosa', + testFailed: 'Prueba Fallida', + ttsTestText: 'Texto de Prueba TTS', + ttsTestSuccess: 'Prueba TTS exitosa, audio reproducido', + ttsTestFailed: 'Prueba TTS fallida', + asrTestSuccess: 'Reconocimiento de voz exitoso', + asrTestFailed: 'Reconocimiento de voz fallido', + asrResult: 'Resultado del Reconocimiento', + asrNotSupported: 'El navegador no soporta la API de Reconocimiento de Voz', + browserTTSNotSupported: 'El navegador no soporta la API de Síntesis de Voz', + browserTTSNoVoices: 'El navegador actual no tiene voces TTS disponibles', + microphoneAccessDenied: 'Acceso al micrófono denegado', + microphoneAccessFailed: 'Error al acceder al micrófono', + asrResultPlaceholder: 'El resultado del reconocimiento se mostrará después de grabar', + useThisProvider: 'Usar Este Proveedor', + fetchVoices: 'Obtener Lista de Voces', + fetchingVoices: 'Obteniendo...', + voicesFetched: 'Voces obtenidas', + fetchVoicesFailed: 'Error al obtener voces', + voiceApiKeyRequired: 'Se requiere Clave API', + voiceBaseUrlRequired: 'Se requiere URL Base', + ttsTestTextPlaceholder: 'Ingresa texto para convertir', + ttsTestTextDefault: 'Hola, esta es una prueba de voz.', + startRecording: 'Iniciar Grabación', + stopRecording: 'Detener Grabación', + recording: 'Grabando...', + transcribing: 'Transcribiendo...', + transcriptionResult: 'Resultado de Transcripción', + noTranscriptionResult: 'Sin resultado de transcripción', + baseUrlOptional: 'URL Base (Opcional)', + defaultValue: 'Predeterminado', + // TTS Voice descriptions (OpenAI) + voiceMarin: 'Recomendado - Mejor Calidad', + voiceCedar: 'Recomendado - Mejor Calidad', + voiceAlloy: 'Neutral, Equilibrado', + voiceAsh: 'Estable, Profesional', + voiceBallad: 'Elegante, Lírico', + voiceCoral: 'Cálido, Amigable', + voiceEcho: 'Masculino, Claro', + voiceFable: 'Narrativo, Vívido', + voiceNova: 'Femenino, Brillante', + voiceOnyx: 'Masculino, Profundo', + voiceSage: 'Sabio, Sereno', + voiceShimmer: 'Femenino, Suave', + voiceVerse: 'Natural, Fluido', + // TTS Voice descriptions (GLM) + glmVoiceTongtong: 'Voz predeterminada', + glmVoiceChuichui: 'Voz Chuichui', + glmVoiceXiaochen: 'Voz Xiaochen', + glmVoiceJam: 'Voz Jam', + glmVoiceKazi: 'Voz Kazi', + glmVoiceDouji: 'Voz Douji', + glmVoiceLuodo: 'Voz Luodo', + // TTS Voice descriptions (Qwen) + qwenVoiceCherry: 'Soleada, cálida y natural', + qwenVoiceSerena: 'Suave y delicada', + qwenVoiceEthan: 'Enérgico y vibrante', + qwenVoiceChelsie: 'Novia virtual anime', + qwenVoiceMomo: 'Juguetona y alegre', + qwenVoiceVivian: 'Linda y atrevida', + qwenVoiceMoon: 'Cool y apuesto', + qwenVoiceMaia: 'Intelectual y gentil', + qwenVoiceKai: 'Un SPA para tus oídos', + qwenVoiceNofish: 'Diseñador con acento peculiar', + qwenVoiceBella: 'Pequeña loli que no se emborracha', + qwenVoiceJennifer: 'Voz femenina americana de nivel cinematográfico', + qwenVoiceRyan: 'Ritmo rápido, actuación dramática', + qwenVoiceKaterina: 'Dama madura con ritmo memorable', + qwenVoiceAiden: 'Chico americano que domina la cocina', + qwenVoiceEldricSage: 'Anciano estable y sabio', + qwenVoiceMia: 'Gentil como agua de manantial', + qwenVoiceMochi: 'Pequeño adulto inteligente con inocencia infantil', + qwenVoiceBellona: 'Voz fuerte, pronunciación clara, personajes vívidos', + qwenVoiceVincent: 'Voz ronca única narrando historias de guerra y honor', + qwenVoiceBunny: 'Súper linda loli', + qwenVoiceNeil: 'Presentador de noticias profesional', + qwenVoiceElias: 'Instructor profesional', + qwenVoiceArthur: 'Voz simple curtida por los años', + qwenVoiceNini: 'Voz suave y pegajosa como pastel de arroz', + qwenVoiceEbona: 'Su susurro es como una llave oxidada', + qwenVoiceSeren: 'Voz gentil y relajante para ayudarte a dormir', + qwenVoicePip: 'Travieso pero lleno de inocencia infantil', + qwenVoiceStella: 'Voz de chica dulce y confundida que se vuelve justa al gritar', + qwenVoiceBodega: 'Tío español entusiasta', + qwenVoiceSonrisa: 'Señora latinoamericana entusiasta', + qwenVoiceAlek: 'Frío de nación guerrera, cálido bajo el abrigo de lana', + qwenVoiceDolce: 'Tío italiano relajado', + qwenVoiceSohee: 'Unnie coreana gentil y alegre', + qwenVoiceOnoAnna: 'Amiga de la infancia traviesa', + qwenVoiceLenn: 'Joven alemán racional que usa traje y escucha post-punk', + qwenVoiceEmilien: 'Hermano mayor francés romántico', + qwenVoiceAndre: 'Voz masculina magnética, natural y calmada', + qwenVoiceRadioGol: '¡Poeta del fútbol Rádio Gol!', + qwenVoiceJada: 'Señora de Shanghai vivaz', + qwenVoiceDylan: 'Chico de Beijing', + qwenVoiceLi: 'Profesora de yoga paciente', + qwenVoiceMarcus: 'Cara ancha, pocas palabras, corazón firme - sabor del viejo Shaanxi', + qwenVoiceRoy: 'Chico taiwanés humorístico y directo', + qwenVoicePeter: 'Profesional del stand-up de Tianjin', + qwenVoiceSunny: 'Chica dulce de Sichuan', + qwenVoiceEric: 'Caballero de Chengdu', + qwenVoiceRocky: 'Chico de Hong Kong humorístico', + qwenVoiceKiki: 'Chica dulce de Hong Kong', + // ASR Language names (native forms - autoglossonyms) + lang_auto: 'Detección Automática', + lang_zh: '中文', + lang_yue: '粤語', + lang_en: 'English', + lang_ja: '日本語', + lang_ko: '한국어', + lang_es: 'Español', + lang_fr: 'Français', + lang_de: 'Deutsch', + lang_ru: 'Русский', + lang_ar: 'العربية', + lang_pt: 'Português', + lang_it: 'Italiano', + lang_af: 'Afrikaans', + lang_hy: 'Հայերեն', + lang_az: 'Azərbaycan', + lang_be: 'Беларуская', + lang_bs: 'Bosanski', + lang_bg: 'Български', + lang_ca: 'Català', + lang_hr: 'Hrvatski', + lang_cs: 'Čeština', + lang_da: 'Dansk', + lang_nl: 'Nederlands', + lang_et: 'Eesti', + lang_fi: 'Suomi', + lang_gl: 'Galego', + lang_el: 'Ελληνικά', + lang_he: 'עברית', + lang_hi: 'हिन्दी', + lang_hu: 'Magyar', + lang_is: 'Íslenska', + lang_id: 'Bahasa Indonesia', + lang_kn: 'ಕನ್ನಡ', + lang_kk: 'Қазақша', + lang_lv: 'Latviešu', + lang_lt: 'Lietuvių', + lang_mk: 'Македонски', + lang_ms: 'Bahasa Melayu', + lang_mr: 'मराठी', + lang_mi: 'Te Reo Māori', + lang_ne: 'नेपाली', + lang_no: 'Norsk', + lang_fa: 'فارسی', + lang_pl: 'Polski', + lang_ro: 'Română', + lang_sr: 'Српски', + lang_sk: 'Slovenčina', + lang_sl: 'Slovenščina', + lang_sw: 'Kiswahili', + lang_sv: 'Svenska', + lang_tl: 'Tagalog', + lang_fil: 'Filipino', + lang_ta: 'தமிழ்', + lang_th: 'ไทย', + lang_tr: 'Türkçe', + lang_uk: 'Українська', + lang_ur: 'اردو', + lang_vi: 'Tiếng Việt', + lang_cy: 'Cymraeg', + // BCP-47 format language codes (for Web Speech API) + 'lang_zh-CN': '中文(简体,中国)', + 'lang_zh-TW': '中文(繁體,台灣)', + 'lang_zh-HK': '粵語(香港)', + 'lang_yue-Hant-HK': '粵語(繁體)', + 'lang_en-US': 'English (United States)', + 'lang_en-GB': 'English (United Kingdom)', + 'lang_en-AU': 'English (Australia)', + 'lang_en-CA': 'English (Canada)', + 'lang_en-IN': 'English (India)', + 'lang_en-NZ': 'English (New Zealand)', + 'lang_en-ZA': 'English (South Africa)', + 'lang_ja-JP': '日本語(日本)', + 'lang_ko-KR': '한국어(대한민국)', + 'lang_de-DE': 'Deutsch (Deutschland)', + 'lang_fr-FR': 'Français (France)', + 'lang_es-ES': 'Español (España)', + 'lang_es-MX': 'Español (México)', + 'lang_es-AR': 'Español (Argentina)', + 'lang_es-CO': 'Español (Colombia)', + 'lang_it-IT': 'Italiano (Italia)', + 'lang_pt-BR': 'Português (Brasil)', + 'lang_pt-PT': 'Português (Portugal)', + 'lang_ru-RU': 'Русский (Россия)', + 'lang_nl-NL': 'Nederlands (Nederland)', + 'lang_pl-PL': 'Polski (Polska)', + 'lang_cs-CZ': 'Čeština (Česko)', + 'lang_da-DK': 'Dansk (Danmark)', + 'lang_fi-FI': 'Suomi (Suomi)', + 'lang_sv-SE': 'Svenska (Sverige)', + 'lang_no-NO': 'Norsk (Norge)', + 'lang_tr-TR': 'Türkçe (Türkiye)', + 'lang_el-GR': 'Ελληνικά (Ελλάδα)', + 'lang_hu-HU': 'Magyar (Magyarország)', + 'lang_ro-RO': 'Română (România)', + 'lang_sk-SK': 'Slovenčina (Slovensko)', + 'lang_bg-BG': 'Български (България)', + 'lang_hr-HR': 'Hrvatski (Hrvatska)', + 'lang_ca-ES': 'Català (Espanya)', + 'lang_ar-SA': 'العربية (السعودية)', + 'lang_ar-EG': 'العربية (مصر)', + 'lang_he-IL': 'עברית (ישראל)', + 'lang_hi-IN': 'हिन्दी (भारत)', + 'lang_th-TH': 'ไทย (ประเทศไทย)', + 'lang_vi-VN': 'Tiếng Việt (Việt Nam)', + 'lang_id-ID': 'Bahasa Indonesia (Indonesia)', + 'lang_ms-MY': 'Bahasa Melayu (Malaysia)', + 'lang_fil-PH': 'Filipino (Pilipinas)', + 'lang_af-ZA': 'Afrikaans (Suid-Afrika)', + 'lang_uk-UA': 'Українська (Україна)', + // PDF settings + pdfSettings: 'Análisis de PDF', + pdfParsingSettings: 'Ajustes de Análisis de PDF', + pdfDescription: + 'Elige el motor de análisis de PDF con soporte para extracción de texto, procesamiento de imágenes y reconocimiento de tablas', + pdfProvider: 'Analizador de PDF', + pdfFeatures: 'Funciones Soportadas', + pdfApiKey: 'Clave API', + pdfBaseUrl: 'URL Base', + mineruDescription: + 'MinerU es un servicio comercial de análisis de PDF que soporta funciones avanzadas como extracción de tablas, reconocimiento de fórmulas y análisis de diseño.', + mineruApiKeyRequired: 'Necesitas solicitar una Clave API en el sitio web de MinerU antes de usar.', + mineruWarning: 'Advertencia', + mineruCostWarning: + 'MinerU es un servicio comercial y puede generar cargos. Consulta el sitio web de MinerU para detalles de precios.', + enterMinerUApiKey: 'Ingresa la Clave API de MinerU', + mineruLocalDescription: + 'MinerU soporta despliegue local con análisis avanzado de PDF (tablas, fórmulas, análisis de diseño). Requiere desplegar el servicio MinerU primero.', + mineruServerAddress: 'Dirección del servidor local MinerU (ej., http://localhost:8080)', + mineruApiKeyOptional: 'Solo requerido si el servidor tiene autenticación habilitada', + optionalApiKey: 'Clave API Opcional', + featureText: 'Extracción de Texto', + featureImages: 'Extracción de Imágenes', + featureTables: 'Extracción de Tablas', + featureFormulas: 'Reconocimiento de Fórmulas', + featureLayoutAnalysis: 'Análisis de Diseño', + featureMetadata: 'Metadatos', + // Image Generation settings + enableImageGeneration: 'Habilitar Generación de Imágenes IA', + imageGenerationDisabledHint: + 'Cuando está habilitado, las imágenes se generarán automáticamente durante la creación del curso', + imageSettings: 'Generación de Imágenes', + imageSection: 'Texto a Imagen', + imageProvider: 'Proveedor de Generación de Imágenes', + imageModel: 'Modelo de Generación de Imágenes', + providerSeedream: 'Seedream (ByteDance)', + providerQwenImage: 'Qwen Image (Alibaba)', + providerNanoBanana: 'Nano Banana (Gemini)', + providerGrokImage: 'Grok Image (xAI)', + testImageGeneration: 'Probar Generación de Imágenes', + testImageConnectivity: 'Probar Conexión', + imageConnectivitySuccess: 'Servicio de imágenes conectado exitosamente', + imageConnectivityFailed: 'Error de conexión del servicio de imágenes', + imageTestSuccess: 'Prueba de generación de imágenes exitosa', + imageTestFailed: 'Prueba de generación de imágenes fallida', + imageTestPromptPlaceholder: 'Ingresa descripción de imagen para probar', + imageTestPromptDefault: 'Un lindo gato sentado en un escritorio', + imageGenerating: 'Generando imagen...', + imageGenerationFailed: 'Error en la generación de imagen', + // Video Generation settings + enableVideoGeneration: 'Habilitar Generación de Video IA', + videoGenerationDisabledHint: + 'Cuando está habilitado, los videos se generarán automáticamente durante la creación del curso', + videoSettings: 'Generación de Video', + videoSection: 'Texto a Video', + videoProvider: 'Proveedor de Generación de Video', + videoModel: 'Modelo de Generación de Video', + providerSeedance: 'Seedance (ByteDance)', + providerKling: 'Kling (Kuaishou)', + providerVeo: 'Veo (Google)', + providerSora: 'Sora (OpenAI)', + providerGrokVideo: 'Grok Video (xAI)', + testVideoGeneration: 'Probar Generación de Video', + testVideoConnectivity: 'Probar Conexión', + videoConnectivitySuccess: 'Servicio de video conectado exitosamente', + videoConnectivityFailed: 'Error de conexión del servicio de video', + testingConnection: 'Probando...', + videoTestSuccess: 'Prueba de generación de video exitosa', + videoTestFailed: 'Prueba de generación de video fallida', + videoTestPromptDefault: 'Un lindo gato caminando sobre un escritorio', + videoGenerating: 'Generando video (est. 1-2 min)...', + videoGenerationWarning: 'La generación de video generalmente toma 1-2 minutos, por favor sé paciente', + mediaRetry: 'Reintentar', + mediaContentSensitive: 'Lo sentimos, este contenido activó una verificación de seguridad.', + mediaGenerationDisabled: 'Generación deshabilitada en ajustes', + // Agent settings (kept with main settings block above) + singleAgent: 'Agente Único', + multiAgent: 'Multi-Agente', + selectAgents: 'Seleccionar Agentes', + noVisionWarning: + 'El modelo actual no soporta visión. Las imágenes aún se pueden colocar en diapositivas, pero el modelo no puede entender el contenido de las imágenes para optimizar la selección y el diseño', + // Server provider configuration + serverConfigured: 'Servidor', + serverConfiguredNotice: + 'El administrador ha configurado una clave API para este proveedor en el servidor. Puedes usarla directamente o ingresar tu propia clave para sobrescribir.', + optionalOverride: 'Opcional — dejar vacío para usar la configuración del servidor', + // Access code + setupNeeded: 'Configuración requerida', + modelNotConfigured: 'Por favor selecciona un modelo para comenzar', + // Clear cache + dangerZone: 'Zona de Peligro', + clearCache: 'Limpiar Caché Local', + clearCacheDescription: + 'Eliminar todos los datos almacenados localmente, incluyendo registros de aulas, historial de chat, caché de audio y ajustes de la aplicación. Esta acción no se puede deshacer.', + clearCacheConfirmTitle: '¿Estás seguro de que deseas limpiar todo el caché?', + clearCacheConfirmDescription: + 'Esto eliminará permanentemente todos los siguientes datos y no se pueden recuperar:', + clearCacheConfirmItems: + 'Aulas y escenas, Historial de chat, Caché de audio e imágenes, Ajustes y preferencias de la aplicación', + clearCacheConfirmInput: 'Escribe "ELIMINAR" para continuar', + clearCacheConfirmPhrase: 'ELIMINAR', + clearCacheButton: 'Eliminar Permanentemente Todos los Datos', + clearCacheSuccess: 'Caché limpiado, la página se actualizará en breve', + clearCacheFailed: 'Error al limpiar el caché, intenta de nuevo', + // Web Search settings + webSearchSettings: 'Búsqueda Web', + webSearchApiKey: 'Clave API de Tavily', + webSearchApiKeyPlaceholder: 'Ingresa tu Clave API de Tavily', + webSearchApiKeyPlaceholderServer: 'Clave del servidor configurada, opcionalmente sobrescribir', + webSearchApiKeyHint: 'Obtén una clave API de tavily.com para búsqueda web', + webSearchBaseUrl: 'URL Base', + webSearchServerConfigured: 'Clave API de Tavily del lado del servidor configurada', + optional: 'Opcional', + }, + profile: { + title: 'Perfil', + defaultNickname: 'Estudiante', + chooseAvatar: 'Elegir Avatar', + uploadAvatar: 'Subir', + bioPlaceholder: 'Cuéntanos sobre ti — el profesor IA personalizará las clases para ti...', + avatarHint: 'Tu avatar aparecerá en las discusiones y chats del aula', + fileTooLarge: 'Imagen demasiado grande — por favor elige una menor a 5 MB', + invalidFileType: 'Por favor selecciona un archivo de imagen', + editTooltip: 'Clic para editar perfil', + }, + media: { + imageCapability: 'Generación de Imágenes', + imageHint: 'Generar imágenes en diapositivas', + videoCapability: 'Generación de Video', + videoHint: 'Generar videos en diapositivas', + ttsCapability: 'Síntesis de Voz', + ttsHint: 'El profesor IA habla en voz alta', + asrCapability: 'Reconocimiento de Voz', + asrHint: 'Entrada de voz para discusión', + provider: 'Proveedor', + model: 'Modelo', + voice: 'Voz', + speed: 'Velocidad', + language: 'Idioma', + }, +} as const; diff --git a/lib/i18n/stage.ts b/lib/i18n/stage.ts index 0a376ca10..407b2703b 100644 --- a/lib/i18n/stage.ts +++ b/lib/i18n/stage.ts @@ -296,3 +296,156 @@ export const stageEnUS = { notReady: 'Available after generation completes', }, } as const; + +export const stageEsMX = { + stage: { + currentScene: 'Escena Actual', + generating: 'Generando...', + paused: 'Pausado', + generationFailed: 'Error en la generación', + confirmSwitchTitle: 'Cambiar Escena', + confirmSwitchMessage: + 'Hay un tema en curso. Cambiar de escena finalizará el tema actual. ¿Estás seguro?', + generatingNextPage: 'La escena se está generando, por favor espera...', + fullscreen: 'Pantalla completa', + exitFullscreen: 'Salir de pantalla completa', + }, + whiteboard: { + title: 'Pizarra Interactiva', + open: 'Abrir Pizarra', + clear: 'Limpiar Pizarra', + minimize: 'Minimizar Pizarra', + ready: 'Pizarra lista', + readyHint: 'Los elementos aparecerán aquí cuando la IA los agregue', + clearSuccess: 'Pizarra limpiada exitosamente', + clearError: 'Error al limpiar la pizarra: ', + resetView: 'Restablecer Vista', + restoreError: 'Error al restaurar la pizarra: ', + history: 'Historial', + restore: 'Restaurar', + noHistory: 'Sin historial aún', + restored: 'Pizarra restaurada', + elementCount: '{count} elementos', + }, + quiz: { + title: 'Examen Rápido', + subtitle: 'Pon a prueba tus conocimientos', + questionsCount: 'preguntas', + totalPrefix: '', + pointsSuffix: 'pts', + startQuiz: 'Iniciar Examen', + multipleChoiceHint: '(Opción múltiple — selecciona todas las respuestas correctas)', + inputPlaceholder: 'Escribe tu respuesta aquí...', + charCount: 'caracteres', + yourAnswer: 'Tu respuesta:', + notAnswered: 'Sin responder', + aiComment: 'Retroalimentación IA', + singleChoice: 'Opción única', + multipleChoice: 'Opción múltiple', + shortAnswer: 'Respuesta corta', + analysis: 'Análisis: ', + excellent: '¡Excelente!', + keepGoing: '¡Sigue así!', + needsReview: 'Necesita repaso', + correct: 'correcto', + incorrect: 'incorrecto', + answering: 'En Progreso', + submitAnswers: 'Enviar Respuestas', + aiGrading: 'La IA está calificando...', + aiGradingWait: 'Por favor espera, analizando tus respuestas', + quizReport: 'Reporte del Examen', + retry: 'Reintentar', + }, + roundtable: { + teacher: 'PROFESOR', + you: 'TÚ', + inputPlaceholder: 'Escribe tu mensaje...', + listening: 'Escuchando...', + processing: 'Procesando...', + noSpeechDetected: 'No se detectó voz, intenta de nuevo', + discussionEnded: 'Discusión finalizada', + qaEnded: 'Preguntas finalizadas', + thinking: 'Pensando', + yourTurn: 'Tu turno', + stopDiscussion: 'Detener Discusión', + autoPlay: 'Reproducción automática', + autoPlayOff: 'Detener reproducción automática', + speed: 'Velocidad', + voiceInput: 'Entrada de voz', + voiceInputDisabled: 'Entrada de voz deshabilitada', + textInput: 'Entrada de texto', + stopRecording: 'Detener grabación', + startRecording: 'Iniciar grabación', + }, + pbl: { + legacyFormat: 'Esta escena PBL usa un formato anterior. Por favor regenera el curso.', + emptyProject: + 'El proyecto PBL aún no se ha generado. Por favor créalo mediante la generación de curso.', + roleSelection: { + title: 'Elige Tu Rol', + description: 'Selecciona un rol para comenzar a colaborar en el proyecto', + }, + workspace: { + restart: 'Reiniciar', + confirmRestart: '¿Reiniciar todo el progreso?', + confirm: 'Confirmar', + cancel: 'Cancelar', + }, + issueboard: { + title: 'Tablero de Tareas', + noIssues: 'Sin tareas aún', + statusDone: 'Completado', + statusActive: 'Activo', + statusPending: 'Pendiente', + }, + chat: { + title: 'Discusión del Proyecto', + currentIssue: 'Tarea Actual', + mentionHint: 'Usa @question para preguntar, @judge para enviar a revisión', + placeholder: 'Escribe un mensaje...', + send: 'Enviar', + welcomeMessage: + '¡Hola! Soy tu Agente de Preguntas para esta tarea: "{title}"\n\nPara guiar tu trabajo, he preparado algunas preguntas:\n\n{questions}\n\n¡No dudes en usar @question si necesitas ayuda o aclaraciones!', + issueCompleteMessage: + '¡Tarea "{completed}" completada! Pasando a la siguiente tarea: "{next}"', + allCompleteMessage: '🎉 ¡Todas las tareas completadas! ¡Excelente trabajo en el proyecto!', + }, + guide: { + howItWorks: 'Cómo funciona', + help: 'Ayuda', + title: 'Ayuda', + step1: { + title: 'Paso 1: Elige un Rol', + desc: 'Después de generar el proyecto, selecciona un rol de la lista (roles no del sistema marcados con 🟢)', + }, + step2: { + title: 'Paso 2: Completa las Tareas', + desc: 'Cada tarea representa un objetivo de aprendizaje:', + s1: { + title: 'Ver tarea actual', + desc: 'Revisa el título, descripción y responsable de la tarea', + }, + s2: { + title: 'Obtener orientación', + example: + '@question ¿Por dónde debo empezar?\n@question ¿Cómo implemento esta función?', + desc: 'El Agente de Preguntas proporciona preguntas guía y pistas (sin respuestas directas)', + }, + s3: { + title: 'Enviar tu trabajo', + example: '@judge Ya terminé, por favor revisa mis Notas', + desc: 'El Agente Evaluador evalúa tu trabajo y da retroalimentación:', + complete: 'Avanza automáticamente a la siguiente tarea', + revision: 'Mejora según la retroalimentación', + }, + }, + step3: { + title: 'Paso 3: Completa el Proyecto', + desc: 'Cuando todas las tareas estén listas, el sistema muestra "🎉 ¡Proyecto Completado!"', + }, + }, + }, + share: { + notReady: 'Disponible después de completar la generación', + }, +} as const; diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts index 6173b0be3..2740631dd 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' | 'es-MX'; export const defaultLocale: Locale = 'zh-CN'; diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index eda67b4c4..3c1b20193 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -96,8 +96,10 @@ 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): 'zh-CN' | 'en-US' | 'es-MX' { + if (language === 'en-US') return 'en-US'; + if (language === 'es-MX') return 'es-MX'; + return 'zh-CN'; } function stripCodeFences(text: string): string { diff --git a/lib/types/generation.ts b/lib/types/generation.ts index c1e6eb7a7..b7b0b24bc 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -43,7 +43,7 @@ export interface StylePreferences { interactivityLevel: 'low' | 'medium' | 'high'; includeExamples: boolean; includePractice: boolean; - language: string; // 'zh-CN', 'en-US' + language: string; // 'zh-CN', 'en-US', 'es-MX' } export interface UploadedDocument { @@ -64,7 +64,7 @@ export interface UploadedDocument { */ export interface UserRequirements { requirement: string; // Single free-form text for all user input - language: 'zh-CN' | 'en-US'; // Course language - critical for generation + language: 'zh-CN' | 'en-US' | 'es-MX'; // Course language - critical for generation userNickname?: string; // Student nickname for personalization userBio?: string; // Student background for personalization webSearch?: boolean; // Enable web search for richer context @@ -100,7 +100,7 @@ export interface SceneOutline { teachingObjective?: string; estimatedDuration?: number; // seconds order: number; - language?: 'zh-CN' | 'en-US'; // Generation language (inherited from requirements) + language?: 'zh-CN' | 'en-US' | 'es-MX'; // Generation language (inherited from requirements) // Suggested image IDs (from PDF-extracted images) suggestedImageIds?: string[]; // e.g., ["img_1", "img_3"] // AI-generated media requests (when PDF images are insufficient) @@ -124,7 +124,7 @@ export interface SceneOutline { projectDescription: string; targetSkills: string[]; issueCount?: number; - language: 'zh-CN' | 'en-US'; + language: 'zh-CN' | 'en-US' | 'es-MX'; }; } From 00d00f16c99f42be31a37f80efc8fd5b99bd91ec Mon Sep 17 00:00:00 2001 From: ellaguno Date: Wed, 25 Mar 2026 11:24:59 -0600 Subject: [PATCH 2/5] fix(settings): wrap dynamic text in span to prevent removeChild errors Browser extensions (translators, Grammarly, etc.) can modify bare text nodes in the DOM. When React tries to swap button content between a text label and a spinner, it fails with "removeChild: node is not a child" because the extension replaced the original text node. Wrapping `{t(...)}` calls inside `` elements gives React a stable element reference that survives external DOM mutations. Fixed in: provider-config-panel, pdf-settings, model-edit-dialog, video-settings, image-settings, tts-settings. Co-Authored-By: Claude Opus 4.6 --- components/settings/image-settings.tsx | 2 +- components/settings/model-edit-dialog.tsx | 2 +- components/settings/pdf-settings.tsx | 2 +- components/settings/provider-config-panel.tsx | 2 +- components/settings/tts-settings.tsx | 2 +- components/settings/video-settings.tsx | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/settings/image-settings.tsx b/components/settings/image-settings.tsx index 172bd7493..590ab1911 100644 --- a/components/settings/image-settings.tsx +++ b/components/settings/image-settings.tsx @@ -187,7 +187,7 @@ export function ImageSettings({ selectedProviderId }: ImageSettingsProps) { ) : ( <> - {t('settings.testConnection')} + {t('settings.testConnection')} )} diff --git a/components/settings/model-edit-dialog.tsx b/components/settings/model-edit-dialog.tsx index aae60a091..d0aa80c38 100644 --- a/components/settings/model-edit-dialog.tsx +++ b/components/settings/model-edit-dialog.tsx @@ -314,7 +314,7 @@ export function ModelEditDialog({ {testStatus === 'testing' && } {testStatus === 'success' && } {testStatus === 'error' && } - {testStatus === 'testing' ? t('settings.testing') : t('settings.testConnection')} + {testStatus === 'testing' ? t('settings.testing') : t('settings.testConnection')} {testMessage && ( diff --git a/components/settings/pdf-settings.tsx b/components/settings/pdf-settings.tsx index bfa43bdda..350cc5420 100644 --- a/components/settings/pdf-settings.tsx +++ b/components/settings/pdf-settings.tsx @@ -130,7 +130,7 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) { ) : ( <> - {t('settings.testConnection')} + {t('settings.testConnection')} )} diff --git a/components/settings/provider-config-panel.tsx b/components/settings/provider-config-panel.tsx index 7c765c9ba..1e6744caa 100644 --- a/components/settings/provider-config-panel.tsx +++ b/components/settings/provider-config-panel.tsx @@ -203,7 +203,7 @@ export function ProviderConfigPanel({ ) : ( <> - {t('settings.testConnection')} + {t('settings.testConnection')} )} diff --git a/components/settings/tts-settings.tsx b/components/settings/tts-settings.tsx index 1e6dcadd3..6348fc1f1 100644 --- a/components/settings/tts-settings.tsx +++ b/components/settings/tts-settings.tsx @@ -205,7 +205,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { ) : ( )} - {t('settings.testTTS')} + {t('settings.testTTS')} diff --git a/components/settings/video-settings.tsx b/components/settings/video-settings.tsx index ffe3506e9..011c2b4ae 100644 --- a/components/settings/video-settings.tsx +++ b/components/settings/video-settings.tsx @@ -190,7 +190,7 @@ export function VideoSettings({ selectedProviderId }: VideoSettingsProps) { ) : ( <> - {t('settings.testConnection')} + {t('settings.testConnection')} )} From 888230c4a81b065c37b6b11ebc2c33a34e8cc362 Mon Sep 17 00:00:00 2001 From: ellaguno Date: Wed, 25 Mar 2026 11:37:43 -0600 Subject: [PATCH 3/5] fix(header): prevent locale flash showing CN before hydration The language selector showed 'CN' momentarily on page load because the SSR default locale is zh-CN and localStorage is only read in useEffect after mount. Now shows '...' until client hydration completes, then displays the correct locale label (CN/EN/ES). Co-Authored-By: Claude Opus 4.6 --- components/header.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/header.tsx b/components/header.tsx index b13636e19..ed7081a87 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -32,6 +32,8 @@ export function Header({ currentSceneTitle }: HeaderProps) { const [settingsOpen, setSettingsOpen] = useState(false); const [languageOpen, setLanguageOpen] = useState(false); const [themeOpen, setThemeOpen] = useState(false); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); // Export const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX(); @@ -108,7 +110,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' : locale === 'es-MX' ? 'ES' : 'EN'} + {!mounted ? '...' : locale === 'zh-CN' ? 'CN' : locale === 'es-MX' ? 'ES' : 'EN'} {languageOpen && (
From 5e51c99f964d56f30c1f45558cfe7e8428a16c79 Mon Sep 17 00:00:00 2001 From: ellaguno Date: Wed, 25 Mar 2026 11:42:02 -0600 Subject: [PATCH 4/5] fix(i18n): resolve hydration mismatch by hiding content until locale is resolved The server always renders with defaultLocale (zh-CN) but the client may detect a different locale from localStorage or browser language. This caused React hydration mismatches on every translated string. Fix: render children with visibility:hidden until the useEffect hydrates the correct locale from localStorage/browser, then reveal. This prevents the SSR zh-CN text from conflicting with client-side es-MX/en-US text, and also prevents browser translator extensions from intercepting the wrong-language text during the flash. Co-Authored-By: Claude Opus 4.6 --- components/header.tsx | 4 +--- lib/hooks/use-i18n.tsx | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/components/header.tsx b/components/header.tsx index ed7081a87..b13636e19 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -32,8 +32,6 @@ export function Header({ currentSceneTitle }: HeaderProps) { const [settingsOpen, setSettingsOpen] = useState(false); const [languageOpen, setLanguageOpen] = useState(false); const [themeOpen, setThemeOpen] = useState(false); - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); // Export const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX(); @@ -110,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" > - {!mounted ? '...' : locale === 'zh-CN' ? 'CN' : locale === 'es-MX' ? 'ES' : 'EN'} + {locale === 'zh-CN' ? 'CN' : locale === 'es-MX' ? 'ES' : 'EN'} {languageOpen && (
diff --git a/lib/hooks/use-i18n.tsx b/lib/hooks/use-i18n.tsx index 2e2690b94..87d4b5a0f 100644 --- a/lib/hooks/use-i18n.tsx +++ b/lib/hooks/use-i18n.tsx @@ -16,6 +16,7 @@ const I18nContext = createContext(undefined); export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(defaultLocale); + const [hydrated, setHydrated] = useState(false); // Hydrate from localStorage after mount (avoids SSR mismatch) /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */ @@ -24,6 +25,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { const stored = localStorage.getItem(LOCALE_STORAGE_KEY); if (stored && VALID_LOCALES.includes(stored as Locale)) { setLocaleState(stored as Locale); + setHydrated(true); return; } const browserLang = navigator.language; @@ -37,6 +39,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { } catch { // localStorage unavailable, keep default } + setHydrated(true); }, []); /* eslint-enable react-hooks/set-state-in-effect */ @@ -47,6 +50,17 @@ export function I18nProvider({ children }: { children: ReactNode }) { const t = (key: string): string => translate(locale, key); + // Prevent hydration mismatch: server renders with defaultLocale (zh-CN), + // but client may detect a different locale from localStorage/browser. + // Hide content briefly until the correct locale is resolved. + if (!hydrated) { + return ( + +
{children}
+
+ ); + } + return {children}; } From b7a60415f34e3695dca122a5c5e78f3813ea07cf Mon Sep 17 00:00:00 2001 From: ellaguno Date: Thu, 9 Apr 2026 10:46:43 -0600 Subject: [PATCH 5/5] feat: add courses, auth, LMS integrations and admin panel Introduces four major addons plus UX fixes: - Auth + grading (NextAuth v5 + Prisma + bcrypt) with role-based middleware that protects everything except /auth/* and /api/auth/*; /admin/* is restricted to ADMIN role. - Complete-course builder (modules + chapters) backed by file storage, with inline editing for course/module title and description, delete actions from both the list and builder, and a chapter panel that links IndexedDB stages generated by the OpenMAIC core to modules. - LMS integrations (Moodle LTI 1.3, Odoo, Dolibarr) with grade sync. - Admin panel with dashboard, users list, settings, providers and integrations pages, plus a shared UserNav showing role + admin link. - Scene navigation overlay and progress bar for the canvas. Co-Authored-By: Claude Opus 4.6 --- app/admin/integrations/page.tsx | 174 ++++ app/admin/layout.tsx | 65 ++ app/admin/page.tsx | 74 ++ app/admin/providers/page.tsx | 41 + app/admin/settings/page.tsx | 47 + app/admin/users/page.tsx | 58 ++ app/api/admin/integrations/route.ts | 34 + app/api/admin/providers/route.ts | 154 +++ app/api/auth/[...nextauth]/route.ts | 3 + app/api/auth/register/route.ts | 29 + app/api/courses/[id]/modules/route.ts | 35 + app/api/courses/[id]/route.ts | 51 + app/api/courses/route.ts | 42 + app/api/enrollments/route.ts | 33 + app/api/export/scorm/[courseId]/route.ts | 90 ++ app/api/grades/[lessonId]/route.ts | 16 + app/api/grades/route.ts | 41 + app/api/lti/grades/route.ts | 20 + app/api/lti/jwks/route.ts | 21 + app/api/lti/launch/route.ts | 101 ++ app/api/lti/login/route.ts | 72 ++ app/api/progress/route.ts | 34 + app/api/quiz-grade/route.ts | 27 +- app/api/server-providers/route.ts | 2 + app/auth/signin/page.tsx | 84 ++ app/auth/signup/page.tsx | 98 ++ app/courses/[id]/page.tsx | 9 + app/courses/page.tsx | 151 +++ app/layout.tsx | 17 +- app/page.tsx | 17 +- components/admin/providers-panel.tsx | 359 +++++++ components/ai-elements/prompt-input.tsx | 4 +- components/auth/session-provider.tsx | 7 + components/auth/user-nav.tsx | 54 ++ components/canvas/canvas-area.tsx | 28 +- .../canvas/scene-navigation-overlay.tsx | 101 ++ components/canvas/scene-progress-bar.tsx | 74 ++ components/course/chapter-panel.tsx | 152 +++ components/course/collaborator-panel.tsx | 129 +++ components/course/course-builder.tsx | 221 +++++ components/course/module-list.tsx | 168 ++++ components/course/philosophy-selector.tsx | 44 + components/course/scorm-export-button.tsx | 262 +++++ components/generation/media-popover.tsx | 1 + components/grading/gradebook.tsx | 72 ++ components/settings/asr-settings.tsx | 2 +- components/settings/audio-settings.tsx | 4 +- components/settings/index.tsx | 2 + components/stage.tsx | 12 + docker-compose.yml | 23 + instrumentation.ts | 20 + lib/ai/providers.ts | 77 ++ lib/audio/asr-providers.ts | 100 ++ lib/audio/browser-tts-preview.ts | 37 +- lib/audio/constants.ts | 102 ++ lib/audio/tts-providers.ts | 60 ++ lib/audio/types.ts | 7 +- lib/audio/voice-resolver.ts | 68 +- lib/auth/auth.config.ts | 47 + lib/auth/auth.ts | 35 + lib/course/philosophies.ts | 85 ++ lib/db/prisma.ts | 20 + lib/export/scorm/manifest-builder.ts | 154 +++ lib/export/scorm/scorm-exporter.ts | 183 ++++ .../scorm/templates/index.html.template | 30 + lib/export/scorm/templates/player.css | 334 +++++++ lib/export/scorm/templates/player.js | 562 +++++++++++ .../scorm/templates/scorm-api-wrapper.js | 203 ++++ lib/export/scorm/tts-prerenderer.ts | 89 ++ lib/generation/outline-generator.ts | 10 +- lib/grading/grade-service.ts | 96 ++ lib/hooks/use-audio-recorder.ts | 2 +- lib/hooks/use-browser-tts.ts | 15 +- lib/hooks/use-discussion-tts.ts | 3 + lib/i18n/settings.ts | 32 +- lib/i18n/types.ts | 2 +- lib/lms/providers/dolibarr.ts | 98 ++ lib/lms/providers/moodle.ts | 111 +++ lib/lms/providers/odoo.ts | 154 +++ lib/lms/registry.ts | 23 + lib/lms/sync-service.ts | 80 ++ lib/lms/types.ts | 76 ++ lib/playback/engine.ts | 24 +- lib/progress/progress-service.ts | 63 ++ lib/server/course-storage.ts | 62 ++ lib/server/provider-config.ts | 96 ++ lib/store/course.ts | 139 +++ lib/store/settings.ts | 2 +- lib/types/course.ts | 62 ++ lib/types/generation.ts | 11 + lib/types/provider.ts | 3 +- middleware.ts | 48 + package.json | 11 + pnpm-lock.yaml | 913 +++++++++++++++++- prisma.config.ts | 13 + prisma/schema.prisma | 242 +++++ 96 files changed, 7892 insertions(+), 71 deletions(-) create mode 100644 app/admin/integrations/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/providers/page.tsx create mode 100644 app/admin/settings/page.tsx create mode 100644 app/admin/users/page.tsx create mode 100644 app/api/admin/integrations/route.ts create mode 100644 app/api/admin/providers/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/courses/[id]/modules/route.ts create mode 100644 app/api/courses/[id]/route.ts create mode 100644 app/api/courses/route.ts create mode 100644 app/api/enrollments/route.ts create mode 100644 app/api/export/scorm/[courseId]/route.ts create mode 100644 app/api/grades/[lessonId]/route.ts create mode 100644 app/api/grades/route.ts create mode 100644 app/api/lti/grades/route.ts create mode 100644 app/api/lti/jwks/route.ts create mode 100644 app/api/lti/launch/route.ts create mode 100644 app/api/lti/login/route.ts create mode 100644 app/api/progress/route.ts create mode 100644 app/auth/signin/page.tsx create mode 100644 app/auth/signup/page.tsx create mode 100644 app/courses/[id]/page.tsx create mode 100644 app/courses/page.tsx create mode 100644 components/admin/providers-panel.tsx create mode 100644 components/auth/session-provider.tsx create mode 100644 components/auth/user-nav.tsx create mode 100644 components/canvas/scene-navigation-overlay.tsx create mode 100644 components/canvas/scene-progress-bar.tsx create mode 100644 components/course/chapter-panel.tsx create mode 100644 components/course/collaborator-panel.tsx create mode 100644 components/course/course-builder.tsx create mode 100644 components/course/module-list.tsx create mode 100644 components/course/philosophy-selector.tsx create mode 100644 components/course/scorm-export-button.tsx create mode 100644 components/grading/gradebook.tsx create mode 100644 instrumentation.ts create mode 100644 lib/auth/auth.config.ts create mode 100644 lib/auth/auth.ts create mode 100644 lib/course/philosophies.ts create mode 100644 lib/db/prisma.ts create mode 100644 lib/export/scorm/manifest-builder.ts create mode 100644 lib/export/scorm/scorm-exporter.ts create mode 100644 lib/export/scorm/templates/index.html.template create mode 100644 lib/export/scorm/templates/player.css create mode 100644 lib/export/scorm/templates/player.js create mode 100644 lib/export/scorm/templates/scorm-api-wrapper.js create mode 100644 lib/export/scorm/tts-prerenderer.ts create mode 100644 lib/grading/grade-service.ts create mode 100644 lib/lms/providers/dolibarr.ts create mode 100644 lib/lms/providers/moodle.ts create mode 100644 lib/lms/providers/odoo.ts create mode 100644 lib/lms/registry.ts create mode 100644 lib/lms/sync-service.ts create mode 100644 lib/lms/types.ts create mode 100644 lib/progress/progress-service.ts create mode 100644 lib/server/course-storage.ts create mode 100644 lib/store/course.ts create mode 100644 lib/types/course.ts create mode 100644 middleware.ts create mode 100644 prisma.config.ts create mode 100644 prisma/schema.prisma diff --git a/app/admin/integrations/page.tsx b/app/admin/integrations/page.tsx new file mode 100644 index 000000000..d5984785d --- /dev/null +++ b/app/admin/integrations/page.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { listLMSProviders } from '@/lib/lms/registry'; + +interface Integration { + id: string; + providerId: string; + name: string; + enabled: boolean; + createdAt: string; +} + +export default function IntegrationsPage() { + const [integrations, setIntegrations] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [providerId, setProviderId] = useState('moodle'); + const [name, setName] = useState(''); + const [config, setConfig] = useState('{}'); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(''); + + const providers = listLMSProviders(); + + useEffect(() => { + fetch('/api/admin/integrations') + .then((r) => r.json()) + .then((data) => setIntegrations(data.integrations || [])) + .finally(() => setLoading(false)); + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(config); + } catch { + alert('Config inválido (debe ser JSON)'); + return; + } + const res = await fetch('/api/admin/integrations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId, name, config: parsedConfig }), + }); + if (res.ok) { + const data = await res.json(); + setIntegrations([...integrations, data.integration]); + setShowForm(false); + setName(''); + setConfig('{}'); + } + }; + + const handleSync = async () => { + setSyncing(true); + setSyncMsg(''); + const res = await fetch('/api/lti/grades', { method: 'POST' }); + const data = await res.json(); + setSyncing(false); + if (data.result) { + setSyncMsg( + `Sync: ${data.result.succeeded}/${data.result.total} OK, ${data.result.failed} fallaron.`, + ); + } else { + setSyncMsg(data.error || 'Error al sincronizar'); + } + }; + + return ( +
+
+

Integraciones LMS

+
+ + +
+
+ + {syncMsg && ( +
+ {syncMsg} +
+ )} + + {showForm && ( +
+
+ + +
+
+ + setName(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md" + /> +
+
+ + '; + } + + html += '
'; + }); + + if (existing) { + html += + '
Nota: ' + + existing.raw + + ' / ' + + existing.max + + ' (' + + Math.round((existing.raw / existing.max) * 100) + + '%)
'; + } else { + html += ''; + } + html += '
'; + return html; + } + + function attachQuizHandlers(scene) { + var btn = document.querySelector('.quiz-submit'); + if (!btn) return; + + btn.onclick = function () { + var questions = (scene.content && scene.content.questions) || []; + var totalRaw = 0; + var totalMax = 0; + + questions.forEach(function (q, qIdx) { + var points = q.points || 1; + totalMax += points; + var correct = false; + + if (q.type === 'single') { + var selected = document.querySelector('input[name="q' + qIdx + '"]:checked'); + if (selected && q.answer && q.answer.indexOf(selected.value) !== -1) correct = true; + } else if (q.type === 'multiple') { + var chosen = Array.prototype.slice + .call(document.querySelectorAll('input[name="q' + qIdx + '"]:checked')) + .map(function (i) { + return i.value; + }) + .sort(); + var expected = (q.answer || []).slice().sort(); + correct = chosen.length === expected.length && chosen.every(function (v, i) { + return v === expected[i]; + }); + } else if (q.type === 'short_answer') { + // Text questions cannot be auto-graded offline — give full credit for any response + var ta = document.querySelector('textarea[name="q' + qIdx + '"]'); + correct = !!(ta && ta.value.trim().length > 0); + } + + if (correct) totalRaw += points; + + // Visual feedback on options + var qDiv = document.querySelector('.quiz-question[data-q="' + qIdx + '"]'); + if (qDiv && (q.type === 'single' || q.type === 'multiple')) { + var labels = qDiv.querySelectorAll('.quiz-option'); + labels.forEach(function (lbl) { + var inp = lbl.querySelector('input'); + if (!inp) return; + inp.disabled = true; + if (q.answer && q.answer.indexOf(inp.value) !== -1) { + lbl.classList.add('correct'); + } else if (inp.checked) { + lbl.classList.add('incorrect'); + } + }); + if (q.analysis) { + var fb = document.createElement('div'); + fb.className = 'question-feedback'; + fb.textContent = q.analysis; + qDiv.appendChild(fb); + } + } + }); + + // Record score + var key = state.currentModuleIdx + ':' + state.currentLessonIdx + ':' + state.currentSceneIdx; + state.quizScores[key] = { raw: totalRaw, max: totalMax }; + + // Aggregate totals + recalculateTotals(); + + // Report to SCORM + ScormAPI.setScore(state.totalScore, state.totalMax, 0); + var passed = state.totalMax > 0 && state.totalScore / state.totalMax >= 0.5; + ScormAPI.setSuccess(passed ? 'passed' : 'failed'); + + persistProgress(); + renderCurrentScene(); + }; + } + + function recalculateTotals() { + state.totalScore = 0; + state.totalMax = 0; + for (var k in state.quizScores) { + if (Object.prototype.hasOwnProperty.call(state.quizScores, k)) { + state.totalScore += state.quizScores[k].raw; + state.totalMax += state.quizScores[k].max; + } + } + } + + function renderInteractive(scene) { + var html = '

' + escapeHtml(scene.title || 'Interactivo') + '

'; + var content = scene.content || {}; + if (content.html) { + html += ''; + } else if (content.url) { + html += ''; + } else { + html += '
Contenido interactivo no disponible offline.
'; + } + html += '
'; + return html; + } + + function renderPBL(scene) { + var html = '

' + escapeHtml(scene.title || 'Proyecto') + '

'; + html += + '
Las actividades PBL requieren la versión online para interactuar con los agentes de IA. Aquí puedes consultar el enunciado.
'; + var cfg = scene.content && scene.content.projectConfig; + if (cfg) { + if (cfg.description) { + html += '
' + escapeHtml(cfg.description) + '
'; + } + if (cfg.roles && cfg.roles.length) { + html += '

Roles

    '; + cfg.roles.forEach(function (r) { + html += '
  • ' + escapeHtml(r.name || '') + ' — ' + escapeHtml(r.description || '') + '
  • '; + }); + html += '
'; + } + } + html += '
'; + return html; + } + + function renderNavBar() { + var dots = $('scene-dots'); + dots.innerHTML = ''; + var total = totalScenesInLesson(); + for (var i = 0; i < total; i++) { + var d = document.createElement('button'); + d.className = 'scene-dot'; + if (i === state.currentSceneIdx) d.className += ' current'; + else { + var key = state.currentModuleIdx + ':' + state.currentLessonIdx + ':' + i; + if (state.visited[key]) d.className += ' visited'; + } + (function (idx) { + d.onclick = function () { + state.currentSceneIdx = idx; + renderCurrentScene(); + }; + })(i); + dots.appendChild(d); + } + + $('prev-btn').disabled = state.currentSceneIdx === 0 && state.currentLessonIdx === 0; + var isLastScene = state.currentSceneIdx >= total - 1; + var mod = currentModule(); + var isLastLesson = mod && state.currentLessonIdx >= mod.stageIds.length - 1; + var isLastModule = state.currentModuleIdx >= state.course.modules.length - 1; + $('next-btn').disabled = isLastScene && isLastLesson && isLastModule; + } + + function goNext() { + var total = totalScenesInLesson(); + if (state.currentSceneIdx < total - 1) { + state.currentSceneIdx++; + } else { + var mod = currentModule(); + if (mod && state.currentLessonIdx < mod.stageIds.length - 1) { + state.currentLessonIdx++; + state.currentSceneIdx = 0; + } else if (state.currentModuleIdx < state.course.modules.length - 1) { + state.currentModuleIdx++; + state.currentLessonIdx = 0; + state.currentSceneIdx = 0; + } else { + // End of course + ScormAPI.setCompletion('completed'); + persistProgress(); + return; + } + } + renderSidebar(); + renderCurrentScene(); + } + + function goPrev() { + if (state.currentSceneIdx > 0) { + state.currentSceneIdx--; + } else if (state.currentLessonIdx > 0) { + state.currentLessonIdx--; + state.currentSceneIdx = Math.max(0, totalScenesInLesson() - 1); + } else if (state.currentModuleIdx > 0) { + state.currentModuleIdx--; + var mod = currentModule(); + state.currentLessonIdx = mod.stageIds.length - 1; + state.currentSceneIdx = Math.max(0, totalScenesInLesson() - 1); + } else { + return; + } + renderSidebar(); + renderCurrentScene(); + } + + function bindEvents() { + $('next-btn').onclick = goNext; + $('prev-btn').onclick = goPrev; + document.addEventListener('keydown', function (e) { + if (e.key === 'ArrowRight') goNext(); + else if (e.key === 'ArrowLeft') goPrev(); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/lib/export/scorm/templates/scorm-api-wrapper.js b/lib/export/scorm/templates/scorm-api-wrapper.js new file mode 100644 index 000000000..acc652c43 --- /dev/null +++ b/lib/export/scorm/templates/scorm-api-wrapper.js @@ -0,0 +1,203 @@ +/** + * SCORM API wrapper (supports SCORM 1.2 and SCORM 2004). + * + * Looks for the LMS API object in the window hierarchy and normalizes + * the differences between the two standards behind a small facade. + * + * This file is copied verbatim into every exported SCORM package. + */ +(function (global) { + 'use strict'; + + var api = null; + var version = null; // '1.2' | '2004' + var initialized = false; + + function findAPI(win) { + var tries = 0; + while (win && !win.API && !win.API_1484_11 && win.parent && win.parent !== win && tries < 50) { + tries++; + win = win.parent; + } + if (!win) return null; + if (win.API_1484_11) { + version = '2004'; + return win.API_1484_11; + } + if (win.API) { + version = '1.2'; + return win.API; + } + return null; + } + + function getAPI() { + if (api) return api; + api = findAPI(global); + if (!api && global.opener) api = findAPI(global.opener); + return api; + } + + function call(name, args) { + var fn = api && api[name]; + if (typeof fn !== 'function') return ''; + try { + return fn.apply(api, args || []); + } catch (e) { + console.warn('[SCORM] ' + name + ' failed:', e); + return ''; + } + } + + var ScormAPI = { + version: function () { + return version; + }, + + init: function () { + if (initialized) return true; + if (!getAPI()) { + console.warn('[SCORM] No LMS API found — running in standalone preview mode.'); + return false; + } + var result = version === '2004' ? call('Initialize', ['']) : call('LMSInitialize', ['']); + initialized = String(result) === 'true'; + return initialized; + }, + + getValue: function (key) { + if (!initialized) return ''; + var mapped = mapKey(key); + return version === '2004' ? call('GetValue', [mapped]) : call('LMSGetValue', [mapped]); + }, + + setValue: function (key, value) { + if (!initialized) return false; + var mapped = mapKey(key); + var result = + version === '2004' + ? call('SetValue', [mapped, String(value)]) + : call('LMSSetValue', [mapped, String(value)]); + return String(result) === 'true'; + }, + + commit: function () { + if (!initialized) return false; + var result = version === '2004' ? call('Commit', ['']) : call('LMSCommit', ['']); + return String(result) === 'true'; + }, + + terminate: function () { + if (!initialized) return false; + var result = version === '2004' ? call('Terminate', ['']) : call('LMSFinish', ['']); + initialized = false; + return String(result) === 'true'; + }, + + setScore: function (score, max, min) { + if (version === '2004') { + this.setValue('cmi.score.raw', score); + if (typeof max === 'number') this.setValue('cmi.score.max', max); + if (typeof min === 'number') this.setValue('cmi.score.min', min); + if (typeof max === 'number' && max > 0) { + this.setValue('cmi.score.scaled', Math.max(-1, Math.min(1, score / max))); + } + } else { + this.setValue('cmi.core.score.raw', score); + if (typeof max === 'number') this.setValue('cmi.core.score.max', max); + if (typeof min === 'number') this.setValue('cmi.core.score.min', min); + } + }, + + setCompletion: function (status) { + // status: 'completed' | 'incomplete' | 'not_attempted' | 'unknown' + if (version === '2004') { + this.setValue('cmi.completion_status', status); + } else { + // SCORM 1.2 combines success & completion in lesson_status + var mapped = + status === 'completed' + ? 'completed' + : status === 'incomplete' + ? 'incomplete' + : 'not attempted'; + this.setValue('cmi.core.lesson_status', mapped); + } + }, + + setSuccess: function (status) { + // status: 'passed' | 'failed' | 'unknown' + if (version === '2004') { + this.setValue('cmi.success_status', status); + } else { + // In 1.2 success is merged into lesson_status; caller decides whether to promote + if (status === 'passed') this.setValue('cmi.core.lesson_status', 'passed'); + else if (status === 'failed') this.setValue('cmi.core.lesson_status', 'failed'); + } + }, + + setLocation: function (location) { + if (version === '2004') this.setValue('cmi.location', location); + else this.setValue('cmi.core.lesson_location', location); + }, + + getLocation: function () { + return version === '2004' + ? this.getValue('cmi.location') + : this.getValue('cmi.core.lesson_location'); + }, + + setSessionTime: function (seconds) { + var iso = version === '2004' ? toIso8601Duration(seconds) : toScorm12Time(seconds); + var key = version === '2004' ? 'cmi.session_time' : 'cmi.core.session_time'; + this.setValue(key, iso); + }, + + setSuspendData: function (data) { + var key = version === '2004' ? 'cmi.suspend_data' : 'cmi.suspend_data'; + this.setValue(key, typeof data === 'string' ? data : JSON.stringify(data)); + }, + + getSuspendData: function () { + var key = version === '2004' ? 'cmi.suspend_data' : 'cmi.suspend_data'; + return this.getValue(key); + }, + }; + + /** Translate our canonical keys to the version-specific ones when needed */ + function mapKey(key) { + if (version === '2004') return key; + // Naive 1.2 mapping for the subset we use + var map = { + 'cmi.location': 'cmi.core.lesson_location', + 'cmi.completion_status': 'cmi.core.lesson_status', + 'cmi.success_status': 'cmi.core.lesson_status', + 'cmi.score.raw': 'cmi.core.score.raw', + 'cmi.score.max': 'cmi.core.score.max', + 'cmi.score.min': 'cmi.core.score.min', + 'cmi.session_time': 'cmi.core.session_time', + }; + return map[key] || key; + } + + function toIso8601Duration(totalSeconds) { + var s = Math.max(0, Math.floor(totalSeconds)); + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); + var sec = s % 60; + return 'PT' + h + 'H' + m + 'M' + sec + 'S'; + } + + function toScorm12Time(totalSeconds) { + var s = Math.max(0, Math.floor(totalSeconds)); + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); + var sec = s % 60; + function pad(n) { + return n < 10 ? '0' + n : '' + n; + } + return pad(h) + ':' + pad(m) + ':' + pad(sec) + '.00'; + } + + global.ScormAPI = ScormAPI; +})(window); diff --git a/lib/export/scorm/tts-prerenderer.ts b/lib/export/scorm/tts-prerenderer.ts new file mode 100644 index 000000000..afc1ec6ae --- /dev/null +++ b/lib/export/scorm/tts-prerenderer.ts @@ -0,0 +1,89 @@ +/** + * TTS pre-renderer for SCORM export. + * + * Walks every SpeechAction in the given stages, synthesizes audio with the + * configured TTS provider, returns a map of audioId -> { buffer, format } + * and mutates each action to point at the local asset path (`assets/audio/{id}.{ext}`). + */ +import { generateTTS } from '@/lib/audio/tts-providers'; +import { resolveTTSApiKey, resolveTTSBaseUrl } from '@/lib/server/provider-config'; +import type { TTSProviderId } from '@/lib/audio/types'; +import type { Scene } from '@/lib/types/stage'; +import type { SpeechAction } from '@/lib/types/action'; +import type { PersistedClassroomData } from '@/lib/server/classroom-storage'; + +export interface TTSConfig { + providerId: TTSProviderId; + voice: string; + speed?: number; + apiKey?: string; + baseUrl?: string; +} + +export interface PrerenderedAudio { + audioId: string; + buffer: Uint8Array; + format: string; +} + +/** + * Walks all speech actions in the given stages, generates audio, and returns + * a list of audio buffers ready to be written into the zip. + * + * Mutates the scene actions in place: each SpeechAction gets its `audioUrl` + * rewritten to `assets/audio/{audioId}.{format}` so the offline player can + * load it directly. + */ +export async function prerenderCourseAudio( + stages: PersistedClassroomData[], + ttsConfig: TTSConfig, + onProgress?: (done: number, total: number) => void, +): Promise { + // Collect all speech actions that need rendering + const targets: Array<{ scene: Scene; action: SpeechAction }> = []; + for (const stage of stages) { + for (const scene of stage.scenes) { + for (const action of scene.actions ?? []) { + if (action.type === 'speech' && action.text) { + targets.push({ scene, action }); + } + } + } + } + + if (targets.length === 0) return []; + + // Resolve credentials using the same rules as the TTS API route + const apiKey = resolveTTSApiKey(ttsConfig.providerId, ttsConfig.apiKey); + const baseUrl = resolveTTSBaseUrl(ttsConfig.providerId, ttsConfig.baseUrl); + + const config = { + providerId: ttsConfig.providerId, + voice: ttsConfig.voice, + speed: ttsConfig.speed ?? 1.0, + apiKey, + baseUrl, + }; + + const results: PrerenderedAudio[] = []; + + // Sequential to avoid rate-limiting most providers. Could be made concurrent + // with a small pool if needed. + for (let i = 0; i < targets.length; i++) { + const { action } = targets[i]; + const audioId = action.audioId || action.id || `speech-${i}`; + try { + const { audio, format } = await generateTTS(config, action.text); + results.push({ audioId, buffer: audio, format }); + // Rewrite the action so the offline player reads from the local asset + action.audioUrl = `assets/audio/${audioId}.${format}`; + action.audioId = audioId; + } catch (err) { + console.warn(`[SCORM export] TTS failed for action ${audioId}:`, err); + // Leave the action's audioUrl unset so the player falls back to text-only + } + if (onProgress) onProgress(i + 1, targets.length); + } + + return results; +} diff --git a/lib/generation/outline-generator.ts b/lib/generation/outline-generator.ts index 4849bcefe..4f4ece538 100644 --- a/lib/generation/outline-generator.ts +++ b/lib/generation/outline-generator.ts @@ -127,7 +127,15 @@ export async function generateSceneOutlinesFromRequirements( totalScenes: 0, }); - const response = await aiCall(prompts.system, prompts.user, visionImages); + // Inject course philosophy into the system prompt (Addon 2: Complete Courses) + let systemPrompt = prompts.system; + if (requirements.coursePhilosophy) { + const p = requirements.coursePhilosophy; + const guidelines = p.generationGuidelines.map((g) => `- ${g}`).join('\n'); + systemPrompt = `## Pedagogical Philosophy: ${p.name}\n\n${p.systemPrompt}\n\n### Generation Guidelines\n${guidelines}\n\n---\n\n${systemPrompt}`; + } + + const response = await aiCall(systemPrompt, prompts.user, visionImages); const outlines = parseJsonResponse(response); if (!outlines || !Array.isArray(outlines)) { diff --git a/lib/grading/grade-service.ts b/lib/grading/grade-service.ts new file mode 100644 index 000000000..f62ec693d --- /dev/null +++ b/lib/grading/grade-service.ts @@ -0,0 +1,96 @@ +import { prisma } from '@/lib/db/prisma'; +import type { Grade } from '@prisma/client'; + +export interface QuizAnswer { + questionId: string; + selected: string[]; // For text answers, [text] + correct: boolean; + points: number; + maxPoints: number; +} + +export interface GradeRecord { + userId: string; + lessonId: string; + sceneId?: string; + score: number; + maxScore: number; + feedback?: string; +} + +export class GradeService { + /** Record an auto-graded quiz result */ + async recordQuizGrade( + userId: string, + lessonId: string, + sceneId: string, + answers: QuizAnswer[], + ): Promise { + const totalPoints = answers.reduce((s, a) => s + a.points, 0); + const totalMax = answers.reduce((s, a) => s + a.maxPoints, 0); + const score = totalMax > 0 ? (totalPoints / totalMax) * 100 : 0; + + return prisma.grade.create({ + data: { + userId, + lessonId, + sceneId, + score, + maxScore: 100, + gradedBy: 'system', + }, + }); + } + + /** Record a manual grade from a teacher */ + async recordManualGrade( + teacherId: string, + record: GradeRecord, + ): Promise { + return prisma.grade.create({ + data: { + userId: record.userId, + lessonId: record.lessonId, + sceneId: record.sceneId, + score: record.score, + maxScore: record.maxScore, + feedback: record.feedback, + gradedBy: teacherId, + }, + }); + } + + /** Get all grades for a student in a course */ + async getStudentGrades(userId: string, courseId: string): Promise { + return prisma.grade.findMany({ + where: { + userId, + lesson: { module: { courseId } }, + }, + orderBy: { gradedAt: 'desc' }, + }); + } + + /** Get all grades for a lesson (teacher gradebook view) */ + async getLessonGrades(lessonId: string): Promise { + return prisma.grade.findMany({ + where: { lessonId }, + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: { gradedAt: 'desc' }, + }); + } + + /** Mark grades as synced to an external LMS */ + async markSynced(gradeIds: string[], externalIds: Record): Promise { + await Promise.all( + gradeIds.map((id) => + prisma.grade.update({ + where: { id }, + data: { synced: true, externalId: externalIds[id] }, + }), + ), + ); + } +} + +export const gradeService = new GradeService(); diff --git a/lib/hooks/use-audio-recorder.ts b/lib/hooks/use-audio-recorder.ts index 77c526893..23c970103 100644 --- a/lib/hooks/use-audio-recorder.ts +++ b/lib/hooks/use-audio-recorder.ts @@ -106,7 +106,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; const recognition = new SpeechRecognition(); - recognition.lang = asrLanguage || 'zh-CN'; + recognition.lang = asrLanguage || 'es-US'; recognition.continuous = false; recognition.interimResults = false; diff --git a/lib/hooks/use-browser-tts.ts b/lib/hooks/use-browser-tts.ts index 119575fdc..9bcb4c4b6 100644 --- a/lib/hooks/use-browser-tts.ts +++ b/lib/hooks/use-browser-tts.ts @@ -26,7 +26,7 @@ export function useBrowserTTS(options: UseBrowserTTSOptions = {}) { rate = 1.0, pitch = 1.0, volume = 1.0, - lang = 'zh-CN', + lang = typeof navigator !== 'undefined' ? navigator.language : 'en-US', } = options; const [isSpeaking, setIsSpeaking] = useState(false); @@ -75,13 +75,22 @@ export function useBrowserTTS(options: UseBrowserTTSOptions = {}) { utterance.volume = volume; utterance.lang = lang; - // Set voice if specified - if (voiceURI) { + // Set voice if specified, otherwise find one matching the language + if (voiceURI && voiceURI !== 'default') { const voice = availableVoices.find((v) => v.voiceURI === voiceURI); if (voice) { utterance.voice = voice; } } + if (!utterance.voice && lang) { + const langPrefix = lang.split('-')[0]; + const langVoice = + availableVoices.find((v) => v.lang === lang) || + availableVoices.find((v) => v.lang.startsWith(langPrefix + '-')); + if (langVoice) { + utterance.voice = langVoice; + } + } utterance.onstart = () => { setIsSpeaking(true); diff --git a/lib/hooks/use-discussion-tts.ts b/lib/hooks/use-discussion-tts.ts index e512a2fdb..dae0ab539 100644 --- a/lib/hooks/use-discussion-tts.ts +++ b/lib/hooks/use-discussion-tts.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useSettingsStore } from '@/lib/store/settings'; +import { useI18n } from '@/lib/hooks/use-i18n'; import { useBrowserTTS } from '@/lib/hooks/use-browser-tts'; import { resolveAgentVoice, getAvailableProvidersWithVoices } from '@/lib/audio/voice-resolver'; import type { AgentConfig } from '@/lib/orchestration/registry/types'; @@ -24,6 +25,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); @@ -52,6 +54,7 @@ export function useDiscussionTTS({ enabled, agents, onAudioStateChange }: Discus cancel: browserCancel, } = useBrowserTTS({ rate: ttsSpeed, + lang: locale, onEnd: () => { isPlayingRef.current = false; segmentDoneCounterRef.current++; diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts index 0b3974974..1eb42fdd1 100644 --- a/lib/i18n/settings.ts +++ b/lib/i18n/settings.ts @@ -1661,47 +1661,47 @@ export const settingsEsMX = { featureLayoutAnalysis: 'Análisis de Diseño', featureMetadata: 'Metadatos', // Image Generation settings - enableImageGeneration: 'Habilitar Generación de Imágenes IA', + enableImageGeneration: 'Habilitar Gen. Imágenes IA', imageGenerationDisabledHint: 'Cuando está habilitado, las imágenes se generarán automáticamente durante la creación del curso', - imageSettings: 'Generación de Imágenes', + imageSettings: 'Gen. Imágenes', imageSection: 'Texto a Imagen', - imageProvider: 'Proveedor de Generación de Imágenes', - imageModel: 'Modelo de Generación de Imágenes', + imageProvider: 'Proveedor de Imágenes', + imageModel: 'Modelo de Imágenes', providerSeedream: 'Seedream (ByteDance)', providerQwenImage: 'Qwen Image (Alibaba)', providerNanoBanana: 'Nano Banana (Gemini)', providerGrokImage: 'Grok Image (xAI)', - testImageGeneration: 'Probar Generación de Imágenes', + testImageGeneration: 'Probar Gen. Imágenes', testImageConnectivity: 'Probar Conexión', imageConnectivitySuccess: 'Servicio de imágenes conectado exitosamente', imageConnectivityFailed: 'Error de conexión del servicio de imágenes', - imageTestSuccess: 'Prueba de generación de imágenes exitosa', - imageTestFailed: 'Prueba de generación de imágenes fallida', + imageTestSuccess: 'Prueba de gen. imágenes exitosa', + imageTestFailed: 'Prueba de gen. imágenes fallida', imageTestPromptPlaceholder: 'Ingresa descripción de imagen para probar', imageTestPromptDefault: 'Un lindo gato sentado en un escritorio', imageGenerating: 'Generando imagen...', imageGenerationFailed: 'Error en la generación de imagen', // Video Generation settings - enableVideoGeneration: 'Habilitar Generación de Video IA', + enableVideoGeneration: 'Habilitar Gen. Video IA', videoGenerationDisabledHint: 'Cuando está habilitado, los videos se generarán automáticamente durante la creación del curso', - videoSettings: 'Generación de Video', + videoSettings: 'Gen. Video', videoSection: 'Texto a Video', - videoProvider: 'Proveedor de Generación de Video', - videoModel: 'Modelo de Generación de Video', + videoProvider: 'Proveedor de Video', + videoModel: 'Modelo de Video', providerSeedance: 'Seedance (ByteDance)', providerKling: 'Kling (Kuaishou)', providerVeo: 'Veo (Google)', providerSora: 'Sora (OpenAI)', providerGrokVideo: 'Grok Video (xAI)', - testVideoGeneration: 'Probar Generación de Video', + testVideoGeneration: 'Probar Gen. Video', testVideoConnectivity: 'Probar Conexión', videoConnectivitySuccess: 'Servicio de video conectado exitosamente', videoConnectivityFailed: 'Error de conexión del servicio de video', testingConnection: 'Probando...', - videoTestSuccess: 'Prueba de generación de video exitosa', - videoTestFailed: 'Prueba de generación de video fallida', + videoTestSuccess: 'Prueba de gen. video exitosa', + videoTestFailed: 'Prueba de gen. video fallida', videoTestPromptDefault: 'Un lindo gato caminando sobre un escritorio', videoGenerating: 'Generando video (est. 1-2 min)...', videoGenerationWarning: 'La generación de video generalmente toma 1-2 minutos, por favor sé paciente', @@ -1759,9 +1759,9 @@ export const settingsEsMX = { editTooltip: 'Clic para editar perfil', }, media: { - imageCapability: 'Generación de Imágenes', + imageCapability: 'Gen. Imágenes', imageHint: 'Generar imágenes en diapositivas', - videoCapability: 'Generación de Video', + videoCapability: 'Gen. Video', videoHint: 'Generar videos en diapositivas', ttsCapability: 'Síntesis de Voz', ttsHint: 'El profesor IA habla en voz alta', diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts index 2740631dd..b30029ae3 100644 --- a/lib/i18n/types.ts +++ b/lib/i18n/types.ts @@ -1,3 +1,3 @@ export type Locale = 'zh-CN' | 'en-US' | 'es-MX'; -export const defaultLocale: Locale = 'zh-CN'; +export const defaultLocale: Locale = 'en-US'; diff --git a/lib/lms/providers/dolibarr.ts b/lib/lms/providers/dolibarr.ts new file mode 100644 index 000000000..439b50911 --- /dev/null +++ b/lib/lms/providers/dolibarr.ts @@ -0,0 +1,98 @@ +/** + * Dolibarr provider. + * + * Dolibarr exposes a REST API at {baseUrl}/api/index.php authenticated with + * the DOLAPIKEY header. Used here to push learning results to HR records. + */ +import type { + LMSProvider, + LMSConnection, + LMSCredentials, + ExternalGrade, + ExternalCourseData, + ExternalEnrollment, + UserMapping, +} from '@/lib/lms/types'; + +async function dolibarrRequest( + connection: LMSConnection, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: unknown, +): Promise { + const apiKey = (connection.metadata?.apiKey as string) || connection.accessToken; + if (!apiKey) throw new Error('Dolibarr connection missing API key'); + + const res = await fetch(`${connection.baseUrl}/api/index.php${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + DOLAPIKEY: apiKey, + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`Dolibarr ${method} ${path} failed: ${res.status}`); + return res.json() as Promise; +} + +export class DolibarrProvider implements LMSProvider { + id = 'dolibarr'; + name = 'Dolibarr'; + type = 'rest' as const; + + async authenticate(credentials: LMSCredentials): Promise { + if (!credentials.apiKey) throw new Error('Dolibarr requires an API key'); + + // Validate by hitting a lightweight endpoint + const connection: LMSConnection = { + providerId: this.id, + baseUrl: credentials.baseUrl, + accessToken: credentials.apiKey, + metadata: { apiKey: credentials.apiKey }, + }; + await dolibarrRequest(connection, 'GET', '/status'); + return connection; + } + + async pushGrade(connection: LMSConnection, grade: ExternalGrade): Promise { + // Push as a custom HR training record. The exact endpoint depends on the + // Dolibarr modules enabled (Training/HRM module). This is a generic shape. + await dolibarrRequest(connection, 'POST', '/hrm/skills/add', { + fk_user: grade.externalUserId, + fk_skill: grade.externalItemId, + score: grade.score, + score_max: grade.maxScore, + note: grade.feedback, + date: grade.timestamp.toISOString().split('T')[0], + }); + } + + async pullGrades(_connection: LMSConnection, _courseId: string): Promise { + // Dolibarr doesn't track per-course grades natively; this would query + // the custom HR training table if installed. + return []; + } + + async pushCourse(_connection: LMSConnection, _course: ExternalCourseData): Promise { + throw new Error('pushCourse not supported by Dolibarr provider'); + } + + async pullEnrollments(connection: LMSConnection, _courseId: string): Promise { + // Pull all employees as potential learners + const employees = await dolibarrRequest>( + connection, + 'GET', + '/users', + ); + return employees.map((e) => ({ + externalUserId: e.id, + externalCourseId: _courseId, + role: 'Learner', + enrolledAt: new Date(), + })); + } + + async mapUser(_connection: LMSConnection, externalUserId: string): Promise { + return { internalUserId: '', externalUserId, providerId: this.id }; + } +} diff --git a/lib/lms/providers/moodle.ts b/lib/lms/providers/moodle.ts new file mode 100644 index 000000000..576eb0f5c --- /dev/null +++ b/lib/lms/providers/moodle.ts @@ -0,0 +1,111 @@ +/** + * Moodle LTI 1.3 provider. + * + * LTI 1.3 flow: + * 1. Moodle sends OIDC login initiation → POST /api/lti/login + * 2. OpenMAIC redirects to Moodle's auth endpoint + * 3. Moodle redirects back with id_token (JWT) → POST /api/lti/launch + * 4. OpenMAIC validates JWT (signed by Moodle), creates session, opens course + * 5. Grades are pushed back via LTI Assignment and Grade Services (AGS) + */ +import type { + LMSProvider, + LMSConnection, + LMSCredentials, + ExternalGrade, + ExternalCourseData, + ExternalEnrollment, + UserMapping, +} from '@/lib/lms/types'; + +export class MoodleLTIProvider implements LMSProvider { + id = 'moodle'; + name = 'Moodle (LTI 1.3)'; + type = 'lti' as const; + + async authenticate(credentials: LMSCredentials): Promise { + // For LTI, "authentication" is configuring the platform connection. + // Real auth happens per-launch via OIDC. + return { + providerId: this.id, + baseUrl: credentials.baseUrl, + metadata: { + clientId: credentials.clientId, + deploymentId: credentials.deploymentId, + }, + }; + } + + /** + * Push a grade to Moodle via LTI AGS (Assignment and Grade Services). + * Endpoint: {lineItem}/scores + */ + async pushGrade(connection: LMSConnection, grade: ExternalGrade): Promise { + if (!connection.accessToken) { + throw new Error('Moodle connection missing AGS access token'); + } + + const lineItemUrl = `${connection.baseUrl}/mod/lti/services.php/CourseSection/${grade.externalCourseId}/lineitems/${grade.externalItemId}/lineitem/scores`; + + const payload = { + userId: grade.externalUserId, + scoreGiven: grade.score, + scoreMaximum: grade.maxScore, + comment: grade.feedback, + timestamp: grade.timestamp.toISOString(), + activityProgress: 'Completed', + gradingProgress: 'FullyGraded', + }; + + const res = await fetch(lineItemUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.ims.lis.v1.score+json', + Authorization: `Bearer ${connection.accessToken}`, + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + throw new Error(`Moodle AGS push failed: ${res.status} ${await res.text()}`); + } + } + + async pullGrades(_connection: LMSConnection, _courseId: string): Promise { + // Moodle does not typically expose grades over LTI in a pull model. + // For pulling grades use Moodle Web Services (mod_assign_get_grades). + throw new Error('pullGrades not supported via LTI; use Moodle Web Services'); + } + + async pushCourse(_connection: LMSConnection, _course: ExternalCourseData): Promise { + // LTI launches reference a single tool resource per content item. + // Course creation is done in Moodle UI; OpenMAIC is just the tool. + throw new Error('pushCourse not applicable: courses live in Moodle, OpenMAIC is the tool'); + } + + async pullEnrollments(connection: LMSConnection, courseId: string): Promise { + if (!connection.accessToken) { + throw new Error('Moodle connection missing NRPS access token'); + } + // LTI Names and Role Provisioning Service (NRPS) + const url = `${connection.baseUrl}/mod/lti/services.php/CourseSection/${courseId}/bindings/memberships`; + const res = await fetch(url, { + headers: { + Accept: 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json', + Authorization: `Bearer ${connection.accessToken}`, + }, + }); + if (!res.ok) throw new Error(`NRPS fetch failed: ${res.status}`); + const data = (await res.json()) as { members?: Array<{ user_id: string; roles: string[] }> }; + return (data.members || []).map((m) => ({ + externalUserId: m.user_id, + externalCourseId: courseId, + role: m.roles?.[0] || 'Learner', + enrolledAt: new Date(), + })); + } + + async mapUser(_connection: LMSConnection, externalUserId: string): Promise { + return { internalUserId: '', externalUserId, providerId: this.id }; + } +} diff --git a/lib/lms/providers/odoo.ts b/lib/lms/providers/odoo.ts new file mode 100644 index 000000000..a604fad4a --- /dev/null +++ b/lib/lms/providers/odoo.ts @@ -0,0 +1,154 @@ +/** + * Odoo LMS provider. + * + * Uses Odoo's JSON-RPC 2.0 web API. The Odoo LMS module exposes models like: + * - slide.channel: courses + * - slide.channel.partner: enrollments + * - slide.slide: lessons + * - slide.slide.partner: completion records + */ +import type { + LMSProvider, + LMSConnection, + LMSCredentials, + ExternalGrade, + ExternalCourseData, + ExternalEnrollment, + UserMapping, +} from '@/lib/lms/types'; + +interface OdooRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: T; + error?: { code: number; message: string; data?: unknown }; +} + +async function odooRpc( + baseUrl: string, + endpoint: string, + params: Record, + sessionId?: string, +): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (sessionId) headers['Cookie'] = `session_id=${sessionId}`; + + const res = await fetch(`${baseUrl}${endpoint}`, { + method: 'POST', + headers, + body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params, id: Date.now() }), + }); + if (!res.ok) throw new Error(`Odoo RPC HTTP ${res.status}`); + const data = (await res.json()) as OdooRpcResponse; + if (data.error) throw new Error(`Odoo RPC error: ${data.error.message}`); + return data.result as T; +} + +export class OdooLMSProvider implements LMSProvider { + id = 'odoo'; + name = 'Odoo LMS'; + type = 'rest' as const; + + async authenticate(credentials: LMSCredentials): Promise { + const result = await odooRpc<{ uid: number; session_id?: string }>( + credentials.baseUrl, + '/web/session/authenticate', + { + db: credentials.db || 'odoo', + login: credentials.username, + password: credentials.password, + }, + ); + if (!result.uid) throw new Error('Odoo authentication failed'); + + return { + providerId: this.id, + baseUrl: credentials.baseUrl, + accessToken: result.session_id, + metadata: { uid: result.uid, db: credentials.db }, + }; + } + + async pushGrade(connection: LMSConnection, grade: ExternalGrade): Promise { + // In Odoo LMS, "grading" maps to slide completion + survey scoring + await odooRpc( + connection.baseUrl, + '/web/dataset/call_kw', + { + model: 'slide.slide.partner', + method: 'create', + args: [ + { + slide_id: parseInt(grade.externalItemId, 10), + partner_id: parseInt(grade.externalUserId, 10), + completed: true, + quiz_attempts_count: 1, + }, + ], + kwargs: {}, + }, + connection.accessToken, + ); + } + + async pullGrades(connection: LMSConnection, courseId: string): Promise { + const records = await odooRpc>>( + connection.baseUrl, + '/web/dataset/call_kw', + { + model: 'slide.slide.partner', + method: 'search_read', + args: [[['channel_id', '=', parseInt(courseId, 10)], ['completed', '=', true]]], + kwargs: { fields: ['partner_id', 'slide_id', 'write_date'] }, + }, + connection.accessToken, + ); + return records.map((r) => ({ + externalUserId: String((r.partner_id as [number, string])[0]), + externalCourseId: courseId, + externalItemId: String((r.slide_id as [number, string])[0]), + score: 100, + maxScore: 100, + timestamp: new Date(r.write_date as string), + })); + } + + async pushCourse(connection: LMSConnection, course: ExternalCourseData): Promise { + const id = await odooRpc( + connection.baseUrl, + '/web/dataset/call_kw', + { + model: 'slide.channel', + method: 'create', + args: [{ name: course.title, description: course.description || '' }], + kwargs: {}, + }, + connection.accessToken, + ); + return String(id); + } + + async pullEnrollments(connection: LMSConnection, courseId: string): Promise { + const records = await odooRpc>>( + connection.baseUrl, + '/web/dataset/call_kw', + { + model: 'slide.channel.partner', + method: 'search_read', + args: [[['channel_id', '=', parseInt(courseId, 10)]]], + kwargs: { fields: ['partner_id', 'create_date'] }, + }, + connection.accessToken, + ); + return records.map((r) => ({ + externalUserId: String((r.partner_id as [number, string])[0]), + externalCourseId: courseId, + role: 'Learner', + enrolledAt: new Date(r.create_date as string), + })); + } + + async mapUser(_connection: LMSConnection, externalUserId: string): Promise { + return { internalUserId: '', externalUserId, providerId: this.id }; + } +} diff --git a/lib/lms/registry.ts b/lib/lms/registry.ts new file mode 100644 index 000000000..ef627c001 --- /dev/null +++ b/lib/lms/registry.ts @@ -0,0 +1,23 @@ +import type { LMSProvider } from '@/lib/lms/types'; +import { MoodleLTIProvider } from './providers/moodle'; +import { OdooLMSProvider } from './providers/odoo'; +import { DolibarrProvider } from './providers/dolibarr'; + +const PROVIDERS: Record LMSProvider> = { + moodle: () => new MoodleLTIProvider(), + odoo: () => new OdooLMSProvider(), + dolibarr: () => new DolibarrProvider(), +}; + +export function getLMSProvider(id: string): LMSProvider { + const factory = PROVIDERS[id]; + if (!factory) throw new Error(`Unknown LMS provider: ${id}`); + return factory(); +} + +export function listLMSProviders(): Array<{ id: string; name: string; type: string }> { + return Object.entries(PROVIDERS).map(([id, factory]) => { + const p = factory(); + return { id, name: p.name, type: p.type }; + }); +} diff --git a/lib/lms/sync-service.ts b/lib/lms/sync-service.ts new file mode 100644 index 000000000..68b712c3e --- /dev/null +++ b/lib/lms/sync-service.ts @@ -0,0 +1,80 @@ +/** + * Grade sync service: pushes pending OpenMAIC grades to configured LMS platforms. + * + * Run on-demand from /api/lms/sync or schedule via cron. + */ +import { prisma } from '@/lib/db/prisma'; +import { getLMSProvider } from '@/lib/lms/registry'; +import type { LMSConnection, SyncResult, ExternalGrade } from '@/lib/lms/types'; + +export class GradeSyncService { + /** Sync all unsynced grades for all enabled integrations */ + async syncPendingGrades(): Promise { + const integrations = await prisma.lMSIntegration.findMany({ + where: { enabled: true }, + include: { syncRules: true }, + }); + + const result: SyncResult = { total: 0, succeeded: 0, failed: 0, errors: [] }; + + for (const integration of integrations) { + const provider = getLMSProvider(integration.providerId); + const config = integration.config as Record; + + // Authenticate (real implementations should cache the connection) + let connection: LMSConnection; + try { + connection = await provider.authenticate({ + baseUrl: String(config.baseUrl || ''), + apiKey: config.apiKey as string | undefined, + username: config.username as string | undefined, + password: config.password as string | undefined, + ...config, + }); + } catch (e) { + result.errors.push({ gradeId: 'auth', error: String(e) }); + continue; + } + + for (const rule of integration.syncRules) { + if (!rule.syncGrades) continue; + + const grades = await prisma.grade.findMany({ + where: { + synced: false, + lesson: { module: { courseId: rule.courseId } }, + }, + include: { user: true, lesson: true }, + }); + + for (const g of grades) { + result.total++; + try { + const external: ExternalGrade = { + externalUserId: g.user.email || g.user.id, + externalCourseId: rule.externalCourseId, + externalItemId: g.lesson.stageId, + score: g.score, + maxScore: g.maxScore, + feedback: g.feedback || undefined, + timestamp: g.gradedAt, + }; + await provider.pushGrade(connection, external); + await prisma.grade.update({ + where: { id: g.id }, + data: { synced: true }, + }); + result.succeeded++; + } catch (e) { + result.failed++; + result.errors.push({ gradeId: g.id, error: String(e) }); + } + } + } + } + + return result; + } +} + +export const gradeSyncService = new GradeSyncService(); diff --git a/lib/lms/types.ts b/lib/lms/types.ts new file mode 100644 index 000000000..dbd409500 --- /dev/null +++ b/lib/lms/types.ts @@ -0,0 +1,76 @@ +/** + * Generic LMS integration layer. + * + * Each LMS provider implements `LMSProvider`. The registry instantiates them + * by ID. New LMS systems are added by writing a new provider class. + */ + +export type LMSProviderType = 'lti' | 'rest'; + +export interface LMSCredentials { + baseUrl: string; + apiKey?: string; + username?: string; + password?: string; + clientId?: string; + clientSecret?: string; + /** Provider-specific extra fields */ + [key: string]: unknown; +} + +export interface LMSConnection { + providerId: string; + baseUrl: string; + accessToken?: string; + metadata?: Record; +} + +export interface ExternalGrade { + externalUserId: string; + externalCourseId: string; + externalItemId: string; + score: number; + maxScore: number; + feedback?: string; + timestamp: Date; +} + +export interface ExternalCourseData { + title: string; + description?: string; + language?: string; + modules?: Array<{ title: string; lessons: Array<{ title: string; stageId: string }> }>; +} + +export interface ExternalEnrollment { + externalUserId: string; + externalCourseId: string; + role: string; + enrolledAt: Date; +} + +export interface UserMapping { + internalUserId: string; + externalUserId: string; + providerId: string; +} + +export interface LMSProvider { + id: string; + name: string; + type: LMSProviderType; + + authenticate(credentials: LMSCredentials): Promise; + pushGrade(connection: LMSConnection, grade: ExternalGrade): Promise; + pullGrades(connection: LMSConnection, courseId: string): Promise; + pushCourse(connection: LMSConnection, course: ExternalCourseData): Promise; + pullEnrollments(connection: LMSConnection, courseId: string): Promise; + mapUser(connection: LMSConnection, externalUserId: string): Promise; +} + +export interface SyncResult { + total: number; + succeeded: number; + failed: number; + errors: Array<{ gradeId: string; error: string }>; +} diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index e13e60b18..62b05c72d 100644 --- a/lib/playback/engine.ts +++ b/lib/playback/engine.ts @@ -656,11 +656,25 @@ export class PlaybackEngine { } } if (!voiceFound) { - // No usable voice configured — detect text language so the browser - // auto-selects an appropriate voice. - const cjkRatio = - (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length; - utterance.lang = cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US'; + // No usable voice configured — detect text language and find a matching voice. + const cjkCount = (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; + const cjkRatio = chunkText.length > 0 ? cjkCount / chunkText.length : 0; + const spanishChars = (chunkText.match(/[áéíóúüñ¿¡]/gi) || []).length; + const detectedLang = + cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : spanishChars > 0 ? 'es-US' : 'en-US'; + const langPrefix = detectedLang.split('-')[0]; + + // Try to find a browser voice matching the detected language + // Prefer es-US over es-ES for Latin American Spanish + const langVoice = + voices.find((v) => v.lang === detectedLang) || + voices.find((v) => v.lang.startsWith(langPrefix + '-')); + if (langVoice) { + utterance.voice = langVoice; + utterance.lang = langVoice.lang; + } else { + utterance.lang = detectedLang; + } } utterance.onend = () => { diff --git a/lib/progress/progress-service.ts b/lib/progress/progress-service.ts new file mode 100644 index 000000000..ea1f6c0c8 --- /dev/null +++ b/lib/progress/progress-service.ts @@ -0,0 +1,63 @@ +import { prisma } from '@/lib/db/prisma'; +import type { StudentProgress } from '@prisma/client'; + +export class ProgressService { + /** Update or create progress for a student in a lesson */ + async updateProgress( + userId: string, + lessonId: string, + sceneIndex: number, + timeSpentDelta: number = 0, + ): Promise { + return prisma.studentProgress.upsert({ + where: { userId_lessonId: { userId, lessonId } }, + create: { + userId, + lessonId, + sceneIndex, + timeSpent: timeSpentDelta, + }, + update: { + sceneIndex, + timeSpent: { increment: timeSpentDelta }, + }, + }); + } + + /** Mark a lesson as completed */ + async markCompleted(userId: string, lessonId: string): Promise { + return prisma.studentProgress.upsert({ + where: { userId_lessonId: { userId, lessonId } }, + create: { + userId, + lessonId, + sceneIndex: 0, + completed: true, + completedAt: new Date(), + }, + update: { + completed: true, + completedAt: new Date(), + }, + }); + } + + /** Get a student's progress for a course */ + async getCourseProgress(userId: string, courseId: string): Promise { + return prisma.studentProgress.findMany({ + where: { + userId, + lesson: { module: { courseId } }, + }, + }); + } + + /** Get progress for all students in a lesson */ + async getLessonProgress(lessonId: string): Promise { + return prisma.studentProgress.findMany({ + where: { lessonId }, + }); + } +} + +export const progressService = new ProgressService(); diff --git a/lib/server/course-storage.ts b/lib/server/course-storage.ts new file mode 100644 index 000000000..c8429b89d --- /dev/null +++ b/lib/server/course-storage.ts @@ -0,0 +1,62 @@ +/** + * Course Storage - File-based JSON persistence for complete courses. + * Mirrors the pattern from `lib/server/classroom-storage.ts`. + */ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import type { CompleteCourse } from '@/lib/types/course'; + +const COURSES_DIR = path.join(process.cwd(), 'data', 'courses'); + +async function ensureDir() { + await fs.mkdir(COURSES_DIR, { recursive: true }); +} + +function coursePath(id: string): string { + // Allow only safe characters in IDs to prevent path traversal + if (!/^[a-zA-Z0-9_-]+$/.test(id)) { + throw new Error(`Invalid course id: ${id}`); + } + return path.join(COURSES_DIR, `${id}.json`); +} + +export async function readCourse(id: string): Promise { + try { + const buf = await fs.readFile(coursePath(id), 'utf-8'); + return JSON.parse(buf) as CompleteCourse; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +export async function persistCourse(course: CompleteCourse): Promise { + await ensureDir(); + const tmp = `${coursePath(course.id)}.tmp`; + await fs.writeFile(tmp, JSON.stringify(course, null, 2), 'utf-8'); + await fs.rename(tmp, coursePath(course.id)); +} + +export async function listCourses(): Promise { + await ensureDir(); + const files = await fs.readdir(COURSES_DIR); + const courses: CompleteCourse[] = []; + for (const f of files) { + if (!f.endsWith('.json')) continue; + try { + const buf = await fs.readFile(path.join(COURSES_DIR, f), 'utf-8'); + courses.push(JSON.parse(buf)); + } catch { + // Skip corrupt files + } + } + return courses.sort((a, b) => b.updatedAt - a.updatedAt); +} + +export async function deleteCourse(id: string): Promise { + try { + await fs.unlink(coursePath(id)); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } +} diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 0b876df0f..6d8aa006e 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -49,6 +49,7 @@ const LLM_ENV_MAP: Record = { SILICONFLOW: 'siliconflow', DOUBAO: 'doubao', GROK: 'grok', + OPENROUTER: 'openrouter', }; const TTS_ENV_MAP: Record = { @@ -57,11 +58,13 @@ const TTS_ENV_MAP: Record = { TTS_GLM: 'glm-tts', TTS_QWEN: 'qwen-tts', TTS_ELEVENLABS: 'elevenlabs-tts', + TTS_CARTESIA: 'cartesia-tts', }; const ASR_ENV_MAP: Record = { ASR_OPENAI: 'openai-whisper', ASR_QWEN: 'qwen-asr', + ASR_ASSEMBLYAI: 'assemblyai-asr', }; const PDF_ENV_MAP: Record = { @@ -223,6 +226,99 @@ function getConfig(): ServerConfig { return config; } +// --------------------------------------------------------------------------- +// Database overlay (admin-editable overrides persisted in Prisma) +// --------------------------------------------------------------------------- +// +// Precedence (high → low): env vars > DB overrides > YAML. +// +// The DB overlay is applied lazily on top of getConfig()'s synchronous result +// the first time it is needed. applyDbOverrides() mutates the cached config +// and returns it; subsequent calls are cheap. Call invalidateProviderCache() +// after writing overrides from the admin API. + +let _dbOverlayApplied = false; + +async function applyDbOverrides(): Promise { + const config = getConfig(); + if (_dbOverlayApplied) return config; + try { + // Dynamic import avoids pulling Prisma into edge-runtime bundles. + const { prisma } = await import('@/lib/db/prisma'); + const rows = await prisma.providerConfigOverride.findMany({ + where: { enabled: true }, + }); + for (const row of rows) { + const section = sectionFor(config, row.category); + if (!section) continue; + const models = row.models + ? row.models + .split(',') + .map((m) => m.trim()) + .filter(Boolean) + : undefined; + const existing = section[row.providerId]; + const envApiKey = existing?.apiKey && !row.apiKey ? existing.apiKey : undefined; + const envBaseUrl = existing?.baseUrl && !row.baseUrl ? existing.baseUrl : undefined; + section[row.providerId] = { + apiKey: envApiKey || row.apiKey || existing?.apiKey || '', + baseUrl: envBaseUrl || row.baseUrl || existing?.baseUrl, + models: models || existing?.models, + proxy: row.proxy || existing?.proxy, + }; + } + _dbOverlayApplied = true; + log.info(`[ServerProviderConfig] Applied ${rows.length} DB overrides`); + } catch (err) { + // DB may not be available (e.g. during prerender, tests). Skip silently. + log.warn('[ServerProviderConfig] Could not apply DB overrides:', err); + _dbOverlayApplied = true; + } + return config; +} + +function sectionFor( + config: ServerConfig, + category: string, +): Record | null { + switch (category) { + case 'llm': + return config.providers; + case 'tts': + return config.tts; + case 'asr': + return config.asr; + case 'pdf': + return config.pdf; + case 'image': + return config.image; + case 'video': + return config.video; + case 'webSearch': + return config.webSearch; + default: + return null; + } +} + +/** + * Invalidate the in-memory provider cache. Call after writing DB overrides + * via the admin API so the next getConfig() call reloads fresh values. + */ +export function invalidateProviderCache(): void { + _configs.clear(); + _dbOverlayApplied = false; +} + +/** + * Ensure DB overrides have been applied at least once. Call this from API + * routes before using any resolver that reads from the config. Safe to call + * repeatedly — it's a no-op after the first successful run. + */ +export async function ensureProviderOverridesLoaded(): Promise { + await applyDbOverrides(); +} + // --------------------------------------------------------------------------- // Public API — LLM // --------------------------------------------------------------------------- diff --git a/lib/store/course.ts b/lib/store/course.ts new file mode 100644 index 000000000..7401e2414 --- /dev/null +++ b/lib/store/course.ts @@ -0,0 +1,139 @@ +import { create } from 'zustand'; +import type { CompleteCourse, CourseModule, CourseCollaborator, CoursePhilosophy } from '@/lib/types/course'; +import { BUILT_IN_PHILOSOPHIES } from '@/lib/course/philosophies'; + +interface CourseState { + currentCourse: CompleteCourse | null; + currentModuleId: string | null; + philosophies: CoursePhilosophy[]; + + setCourse: (course: CompleteCourse) => void; + clearCourse: () => void; + setCurrentModule: (moduleId: string | null) => void; + addModule: (module: CourseModule) => void; + updateModule: (moduleId: string, updates: Partial) => void; + removeModule: (moduleId: string) => void; + reorderModules: (orderedIds: string[]) => void; + setPhilosophy: (philosophyId: string) => void; + addCollaborator: (collaborator: CourseCollaborator) => void; + removeCollaborator: (id: string) => void; + + saveCourse: () => Promise; + loadCourse: (courseId: string) => Promise; +} + +export const useCourseStore = create((set, get) => ({ + currentCourse: null, + currentModuleId: null, + philosophies: BUILT_IN_PHILOSOPHIES, + + setCourse: (course) => set({ currentCourse: course }), + clearCourse: () => set({ currentCourse: null, currentModuleId: null }), + setCurrentModule: (moduleId) => set({ currentModuleId: moduleId }), + + addModule: (module) => { + const { currentCourse } = get(); + if (!currentCourse) return; + set({ + currentCourse: { + ...currentCourse, + modules: [...currentCourse.modules, module], + updatedAt: Date.now(), + }, + }); + }, + + updateModule: (moduleId, updates) => { + const { currentCourse } = get(); + if (!currentCourse) return; + set({ + currentCourse: { + ...currentCourse, + modules: currentCourse.modules.map((m) => (m.id === moduleId ? { ...m, ...updates } : m)), + updatedAt: Date.now(), + }, + }); + }, + + removeModule: (moduleId) => { + const { currentCourse } = get(); + if (!currentCourse) return; + set({ + currentCourse: { + ...currentCourse, + modules: currentCourse.modules.filter((m) => m.id !== moduleId), + updatedAt: Date.now(), + }, + }); + }, + + reorderModules: (orderedIds) => { + const { currentCourse } = get(); + if (!currentCourse) return; + const map = new Map(currentCourse.modules.map((m) => [m.id, m])); + const reordered = orderedIds + .map((id, idx) => { + const m = map.get(id); + return m ? { ...m, order: idx } : null; + }) + .filter((m): m is CourseModule => m !== null); + set({ + currentCourse: { ...currentCourse, modules: reordered, updatedAt: Date.now() }, + }); + }, + + setPhilosophy: (philosophyId) => { + const { currentCourse, philosophies } = get(); + if (!currentCourse) return; + const philosophy = philosophies.find((p) => p.id === philosophyId); + set({ + currentCourse: { + ...currentCourse, + philosophyId, + philosophy, + updatedAt: Date.now(), + }, + }); + }, + + addCollaborator: (collaborator) => { + const { currentCourse } = get(); + if (!currentCourse) return; + set({ + currentCourse: { + ...currentCourse, + collaborators: [...currentCourse.collaborators, collaborator], + updatedAt: Date.now(), + }, + }); + }, + + removeCollaborator: (id) => { + const { currentCourse } = get(); + if (!currentCourse) return; + set({ + currentCourse: { + ...currentCourse, + collaborators: currentCourse.collaborators.filter((c) => c.id !== id), + updatedAt: Date.now(), + }, + }); + }, + + saveCourse: async () => { + const { currentCourse } = get(); + if (!currentCourse) return; + await fetch(`/api/courses/${currentCourse.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(currentCourse), + }); + }, + + loadCourse: async (courseId) => { + const res = await fetch(`/api/courses/${courseId}`); + if (!res.ok) return; + const data = await res.json(); + set({ currentCourse: data.course }); + }, +})); diff --git a/lib/store/settings.ts b/lib/store/settings.ts index cc322f6ad..03c1f641f 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -261,7 +261,7 @@ const getDefaultAudioConfig = () => ({ ttsVoice: 'default', ttsSpeed: 1.0, asrProviderId: 'browser-native' as ASRProviderId, - asrLanguage: 'zh', + asrLanguage: 'auto', ttsProvidersConfig: { 'openai-tts': { apiKey: '', baseUrl: '', enabled: true }, 'azure-tts': { apiKey: '', baseUrl: '', enabled: false }, diff --git a/lib/types/course.ts b/lib/types/course.ts new file mode 100644 index 000000000..504997189 --- /dev/null +++ b/lib/types/course.ts @@ -0,0 +1,62 @@ +/** + * Complete Course (Meta-Course) types + * + * Hierarchy: CompleteCourse → CourseModule → Lesson (Stage in existing system) + */ + +export type AssessmentStyle = 'formative' | 'summative' | 'portfolio' | 'peer'; + +/** A pedagogical philosophy that guides AI generation across the whole course */ +export interface CoursePhilosophy { + id: string; + name: string; + description: string; + /** Injected into the system prompt of all generation calls in this course */ + systemPrompt: string; + /** Specific guidelines listed for the AI to follow */ + generationGuidelines: string[]; + assessmentStyle?: AssessmentStyle; +} + +/** A complete course bundling multiple modules, each with multiple lessons */ +export interface CompleteCourse { + id: string; + title: string; + description?: string; + philosophyId: string; + /** Embedded for convenience when serving from API */ + philosophy?: CoursePhilosophy; + modules: CourseModule[]; + collaborators: CourseCollaborator[]; + language: string; + createdBy?: string; + createdAt: number; + updatedAt: number; + tags?: string[]; +} + +/** A module groups related lessons within a course */ +export interface CourseModule { + id: string; + courseId: string; + title: string; + description?: string; + order: number; + /** References to existing classroom Stage IDs (file-based JSON) */ + stageIds: string[]; + objectives?: string[]; + estimatedDuration?: number; // minutes +} + +/** A collaborator in a course — virtual (AI agent) or real (human user) */ +export interface CourseCollaborator { + id: string; + type: 'virtual' | 'real'; + /** For virtual: the agent ID from the agent registry */ + agentId?: string; + /** For real: the user ID from auth */ + userId?: string; + nickname?: string; + role?: 'student' | 'teaching_assistant' | 'observer' | 'instructor'; + joinedAt?: number; +} diff --git a/lib/types/generation.ts b/lib/types/generation.ts index b7b0b24bc..223376d99 100644 --- a/lib/types/generation.ts +++ b/lib/types/generation.ts @@ -68,6 +68,17 @@ export interface UserRequirements { userNickname?: string; // Student nickname for personalization userBio?: string; // Student background for personalization webSearch?: boolean; // Enable web search for richer context + /** + * Optional pedagogical philosophy for the course (Addon 2: Complete Courses). + * When present, its `systemPrompt` and `generationGuidelines` are injected + * into all generation prompts so the entire course follows one approach. + */ + coursePhilosophy?: { + id: string; + name: string; + systemPrompt: string; + generationGuidelines: string[]; + }; } /** diff --git a/lib/types/provider.ts b/lib/types/provider.ts index 007688877..3f268b09a 100644 --- a/lib/types/provider.ts +++ b/lib/types/provider.ts @@ -16,7 +16,8 @@ export type BuiltInProviderId = | 'glm' | 'siliconflow' | 'doubao' - | 'grok'; + | 'grok' + | 'openrouter'; /** * Provider ID (built-in or custom) diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 000000000..be344a91d --- /dev/null +++ b/middleware.ts @@ -0,0 +1,48 @@ +import NextAuth from 'next-auth'; +import { NextResponse } from 'next/server'; +import { authConfig } from '@/lib/auth/auth.config'; + +/** + * Route protection middleware. + * + * Uses the edge-safe auth config (no Prisma/bcrypt). Full auth (with DB) is + * loaded only in server routes via @/lib/auth/auth. + * + * Strategy: everything is protected by default except a small set of public + * paths (auth pages, public APIs, health checks, etc.). + */ +const PUBLIC_PATHS = [ + '/auth', + '/api/auth', + '/api/lti', // LTI launches are signed externally +]; + +// Admin-only paths: require session.user.role === 'ADMIN' +const ADMIN_PATHS = ['/admin', '/api/admin']; + +const { auth } = NextAuth(authConfig); + +export default auth((req) => { + const { pathname } = req.nextUrl; + + const isPublic = PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/')); + if (isPublic) return NextResponse.next(); + + if (!req.auth) { + const url = new URL('/auth/signin', req.url); + url.searchParams.set('callbackUrl', pathname); + return NextResponse.redirect(url); + } + + const isAdminPath = ADMIN_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/')); + if (isAdminPath && req.auth.user?.role !== 'ADMIN') { + return NextResponse.redirect(new URL('/', req.url)); + } + + return NextResponse.next(); +}); + +export const config = { + // Match everything except static assets and Next.js internals + matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|gif|webp)$).*)'], +}; diff --git a/package.json b/package.json index 55835ca15..ff92069a6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@ai-sdk/google": "^3.0.13", "@ai-sdk/openai": "^3.0.13", "@ai-sdk/react": "^3.0.44", + "@auth/prisma-adapter": "^2.11.1", "@base-ui/react": "^1.1.0", "@copilotkit/backend": "^0.37.0", "@copilotkit/runtime": "^1.51.2", @@ -31,6 +32,8 @@ "@langchain/langgraph": "^1.1.1", "@modelcontextprotocol/sdk": "^1.27.1", "@napi-rs/canvas": "^0.1.88", + "@prisma/adapter-pg": "^7.7.0", + "@prisma/client": "^7.6.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slider": "^1.3.6", @@ -40,6 +43,7 @@ "@xyflow/react": "^12.10.0", "ai": "^6.0.42", "animate.css": "^4.1.1", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -50,6 +54,7 @@ "file-saver": "^2.0.5", "geist": "^1.7.0", "immer": "^11.1.3", + "jose": "^6.2.2", "js-yaml": "^4.1.1", "jsonrepair": "^3.13.2", "jszip": "^3.10.1", @@ -61,11 +66,14 @@ "motion": "^12.27.5", "nanoid": "^5.1.6", "next": "16.1.2", + "next-auth": "5.0.0-beta.30", "next-themes": "^0.4.6", "openai": "^4.104.0", "partial-json": "^0.1.7", + "pg": "^8.20.0", "pptxgenjs": "workspace:*", "pptxtojson": "^1.11.0", + "prisma": "^7.6.0", "prosemirror-commands": "^1.7.1", "prosemirror-dropcursor": "^1.8.2", "prosemirror-gapcursor": "^1.4.0", @@ -95,6 +103,7 @@ "undici": "^7.22.0", "unpdf": "^1.4.0", "use-stick-to-bottom": "^1.1.1", + "xmlbuilder2": "^4.0.3", "zod": "^4.3.5", "zustand": "^5.0.10" }, @@ -103,10 +112,12 @@ "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-node-resolve": "^16.0.1", "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^3.0.0", "@types/file-saver": "^2.0.7", "@types/katex": "^0.16.8", "@types/lodash": "^4.17.23", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@types/tinycolor2": "^1.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fac3752ea..cb59e7fe7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,15 @@ importers: '@ai-sdk/react': specifier: ^3.0.44 version: 3.0.118(react@19.2.3)(zod@4.3.6) + '@auth/prisma-adapter': + specifier: ^2.11.1 + version: 2.11.1(@prisma/client@7.6.0(prisma@7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)) '@base-ui/react': specifier: ^1.1.0 version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@copilotkit/backend': specifier: ^0.37.0 - version: 0.37.0(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(playwright@1.58.2)(ws@8.19.0) + version: 0.37.0(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(mysql2@3.15.3)(pg@8.20.0)(playwright@1.58.2)(ws@8.19.0) '@copilotkit/runtime': specifier: ^1.51.2 version: 1.53.0(@ag-ui/encoder@0.0.47)(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@1.53.0)(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph-sdk@1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))))(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(ws@8.19.0) @@ -44,6 +47,12 @@ importers: '@napi-rs/canvas': specifier: ^0.1.88 version: 0.1.96 + '@prisma/adapter-pg': + specifier: ^7.7.0 + version: 7.7.0 + '@prisma/client': + specifier: ^7.6.0 + version: 7.6.0(prisma@7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -71,6 +80,9 @@ importers: animate.css: specifier: ^4.1.1 version: 4.1.1 + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -101,6 +113,9 @@ importers: immer: specifier: ^11.1.3 version: 11.1.4 + jose: + specifier: ^6.2.2 + version: 6.2.2 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -134,6 +149,9 @@ importers: next: specifier: 16.1.2 version: 16.1.2(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next-auth: + specifier: 5.0.0-beta.30 + version: 5.0.0-beta.30(next@16.1.2(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -143,12 +161,18 @@ importers: partial-json: specifier: ^0.1.7 version: 0.1.7 + pg: + specifier: ^8.20.0 + version: 8.20.0 pptxgenjs: specifier: workspace:* version: link:packages/pptxgenjs pptxtojson: specifier: ^1.11.0 version: 1.12.1 + prisma: + specifier: ^7.6.0 + version: 7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) prosemirror-commands: specifier: ^1.7.1 version: 1.7.1 @@ -236,6 +260,9 @@ importers: use-stick-to-bottom: specifier: ^1.1.1 version: 1.1.3(react@19.2.3) + xmlbuilder2: + specifier: ^4.0.3 + version: 4.0.3 zod: specifier: ^4.3.5 version: 4.3.6 @@ -255,6 +282,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.2.1 + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 '@types/file-saver': specifier: ^2.0.7 version: 2.0.7 @@ -267,6 +297,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.37 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/react': specifier: ^19 version: 19.2.14 @@ -499,6 +532,39 @@ packages: '@anthropic-ai/sdk@0.9.1': resolution: {integrity: sha512-wa1meQ2WSfoY8Uor3EdrJq0jTiZJoKoSii2ZVWRY1oN4Tlr5s59pADg9T79FTbPe1/se5c3pBeZgJL63wmuoBA==} + '@auth/core@0.41.0': + resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@auth/core@0.41.1': + resolution: {integrity: sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7.0.7 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@auth/prisma-adapter@2.11.1': + resolution: {integrity: sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==} + peerDependencies: + '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -794,6 +860,12 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@copilotkit/backend@0.37.0': resolution: {integrity: sha512-GXl3FZOFcI+Ws3b+urkcFzb9pVyPkihVIKftJmp7geFDs1eSYy39Iof8RU81rWhHZeOR5sqP0CD3jfaDRW0fnQ==} @@ -862,6 +934,20 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@electric-sql/pglite-socket@0.1.1': + resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1': + resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': + resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -1399,6 +1485,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@langchain/community@0.0.57': resolution: {integrity: sha512-tib4UJNkyA4TPNsTNChiBtZmThVJBr7X/iooSmKeCr+yUEha2Yxly3A4OAO95Vlpj4Q+od8HAfCbZih/1XqAMw==} engines: {node: '>=18'} @@ -1943,6 +2032,22 @@ packages: resolution: {integrity: sha512-UTAqwXJJyRvLBvosL+1uPZYSpr8lEHgUb/EVGbPXo5WZqUIBHfJ0sR2bkBEsrj00/ar4IegKxx4YK0wn2c8SQg==} engines: {node: '>=18.0.0'} + '@oozcitak/dom@2.0.2': + resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} + engines: {node: '>=20.0'} + + '@oozcitak/infra@2.0.2': + resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} + engines: {node: '>=20.0'} + + '@oozcitak/url@3.0.0': + resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} + engines: {node: '>=20.0'} + + '@oozcitak/util@10.0.0': + resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} + engines: {node: '>=20.0'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -1963,6 +2068,9 @@ packages: '@oxc-project/types@0.115.0': resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -1974,6 +2082,72 @@ packages: engines: {node: '>=18'} hasBin: true + '@prisma/adapter-pg@7.7.0': + resolution: {integrity: sha512-q33Ta8sKbgzEpAy0lx45tAq//yMv0qcb+8nj+TCA3P4wiAY+OBFEFk/NDkZncAfHaNJeGo5WJpJdpbL+ijYx8g==} + + '@prisma/client-runtime-utils@7.6.0': + resolution: {integrity: sha512-fD7jlqubsZvVODKvsp9lOpXVecx2aWGxC2l35Ioz2t+teUJ5CfR0SAMsi7UkU1VvaZmmm+DS6BdujF622nY7tQ==} + + '@prisma/client@7.6.0': + resolution: {integrity: sha512-7Pe/1ayh3GgWPEg4mmT4ax77LJ1wC+XlnIFvQ94bLP2DsUnOpnruQQR3Jw7r+Frthk94QqDNxo3FjSg8h9PXeQ==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.6.0': + resolution: {integrity: sha512-MuAz1MK4PeG5/03YzfzX3CnFVHQ6qePGwUpQRzPzX5tT0ffJ3Tzi9zJZbBc+VzEGFCM8ghW/gTVDR85Syjt+Yw==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.6.0': + resolution: {integrity: sha512-LpHr3qos4lQZ6sxwjStf59YBht7m9/QF7NSQsMH6qGENWZu2w3UkQUGn1h5iRkDjnWRj3VHykOu9qFhps4ADvA==} + + '@prisma/debug@7.7.0': + resolution: {integrity: sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==} + + '@prisma/dev@0.24.3': + resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + + '@prisma/driver-adapter-utils@7.7.0': + resolution: {integrity: sha512-gZXREeu6mOk7zXfGFJgh86p7Vhj0sXNKp+4Cg1tWYo7V2dfncP2qxS2BiTmbIIha8xPqItkl0WSw38RuSq1HoQ==} + + '@prisma/engines-version@7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711': + resolution: {integrity: sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==} + + '@prisma/engines@7.6.0': + resolution: {integrity: sha512-Sn5edRzhHqgRV2M+A0eIbY442B4mReWWf3pKs/LKreYgW7oa/up8JtK/s4iv/EQA097cyboZ08mmkpbLp+tZ3w==} + + '@prisma/fetch-engine@7.6.0': + resolution: {integrity: sha512-N575Ni95c3FkduWY/eKTHqNYgNbceZ1tQaSknVtJjpKmiiBXmniESn/GTxsDvICC4ZeiNrXxioGInzQrCdx16w==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.6.0': + resolution: {integrity: sha512-ohZDwXvtmnbzOcutR2D13lDWpZP1wQjmPyztmt0AwXLzQI7q95EE7NYCvS+M6N6SivT+BM0NOqLmTH3wms4L3A==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.2': + resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} + engines: {bun: '>=1.3.6', node: '>=22.0.0'} + + '@prisma/studio-core@0.27.3': + resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@protobuf-ts/protoc@2.11.1': resolution: {integrity: sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==} hasBin: true @@ -3159,6 +3333,10 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3243,6 +3421,9 @@ packages: '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3774,6 +3955,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + axe-core@4.11.1: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} @@ -3876,10 +4061,18 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + beeper@1.1.1: resolution: {integrity: sha512-3vqtKL1N45I5dV0RdssXZG7X6pCqQrWPNOlBPZPrd+QkE2HEhR57Z04m0KtpbsZH73j+a3F8UD1TQnn+ExTvIA==} engines: {node: '>=0.10.0'} + better-result@2.7.0: + resolution: {integrity: sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA==} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3934,6 +4127,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -4017,10 +4218,18 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -4033,6 +4242,12 @@ packages: resolution: {integrity: sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==} deprecated: CircularJSON is in maintenance only, flatted is its successor. + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -4200,6 +4415,13 @@ packages: resolution: {integrity: sha512-Bi6v586cy1CoTFViVO4lGTtx780lfF96fUmS1lSX6wpZf6330NvHUu6fReVuDP1de8Mg0nkZb01c8tAQdz1o3w==} engines: {node: '>=18'} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + console-table-printer@2.15.0: resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==} @@ -4432,6 +4654,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -4456,10 +4682,17 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4468,6 +4701,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -4523,6 +4759,10 @@ packages: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} @@ -4555,6 +4795,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -4589,6 +4832,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -4931,6 +5178,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -4949,6 +5199,10 @@ packages: resolution: {integrity: sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw==} engines: {node: '>= 0.10'} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -5104,6 +5358,10 @@ packages: resolution: {integrity: sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==} engines: {node: '>=0.10.0'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} @@ -5201,6 +5459,9 @@ packages: peerDependencies: next: '>=13.2.0' + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -5233,6 +5494,9 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -5260,6 +5524,10 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5323,6 +5591,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + graphql-query-complexity@0.12.0: resolution: {integrity: sha512-fWEyuSL6g/+nSiIRgIipfI6UXTI7bAxrpPlCY1c0+V3pAEUo1ybaKmSBgNr1ed2r+agm1plJww8Loig9y6s2dw==} peerDependencies: @@ -5480,6 +5754,10 @@ packages: resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} engines: {node: '>=16.9.0'} + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} + engines: {node: '>=16.9.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -5493,6 +5771,9 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -5809,6 +6090,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -6092,8 +6376,8 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - jose@6.2.1: - resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -6690,6 +6974,9 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -6709,6 +6996,10 @@ packages: lru-queue@0.1.0: resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@0.562.0: resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==} peerDependencies: @@ -7066,6 +7357,14 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7092,6 +7391,22 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next-auth@5.0.0-beta.30: + resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -7131,6 +7446,9 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -7178,6 +7496,14 @@ packages: resolution: {integrity: sha512-1MQz1Ed8z2yckoBeSfkQHHO9K1yDRxxtotKSJ9yvcTUUxSvfvzEq5GwBrjjHEpMlq/k5gvXdmJ1SbYxWtpNoVg==} engines: {node: '>=8'} + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + object-assign@3.0.0: resolution: {integrity: sha512-jHP15vXVGeVh1HuaA2wY6lxk+whK/x4KBG88VXeRma7CCun7iGD5qPc4eYykQ9sdQvg8jkwFKsSxHln2ybW3xQ==} engines: {node: '>=0.10.0'} @@ -7229,6 +7555,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -7453,6 +7782,43 @@ packages: pause-stream@0.0.11: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + phoenix@1.8.5: resolution: {integrity: sha512-Oj5nlRV/NKXkjBx8uMgCMmgMf1twd/B2qOcO30cHLH1PCGR8149o4T1lRIaqN4nq2KQJAeMqPiLdh1Asd0wQFg==} @@ -7501,6 +7867,9 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.58.2: resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} @@ -7535,6 +7904,30 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + posthog-node@4.18.0: resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==} engines: {node: '>=15.0.0'} @@ -7546,6 +7939,14 @@ packages: pptxtojson@1.12.1: resolution: {integrity: sha512-zCqtmUgFRxAdxIMMpkVmi7axhJFA75THAi7b4bGEikuHR4EsqcTV84TtD4CRbzPVMb+gv3ZaM8sogzsOVEMdfw==} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prelude-ls@1.1.2: resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} engines: {node: '>= 0.8.0'} @@ -7579,6 +7980,19 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + prisma@7.6.0: + resolution: {integrity: sha512-OKJIPT81K3+F+AayIkY/Y3mkF2NWoFh7lZApaaqPYy7EHILKdO0VsmGkP+hDKYTySHsFSyLWXm/JgcR1B8fY1Q==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -7600,6 +8014,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -7701,6 +8118,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -7764,6 +8184,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -7824,6 +8248,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + remend@1.2.2: resolution: {integrity: sha512-4ZJgIB9EG9fQE41mOJCRHMmnxDTKHWawQoJWZyUbZuj680wVyogu2ihnj8Edqm7vh2mo/TWHyEZpn2kqeDvS7w==} @@ -7917,6 +8344,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -8050,6 +8481,9 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -8200,6 +8634,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -8214,6 +8652,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} @@ -8833,6 +9274,14 @@ packages: resolution: {integrity: sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==} engines: {node: '>= 10.13.0'} + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-name@7.0.2: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} @@ -9100,6 +9549,10 @@ packages: resolution: {integrity: sha512-TxyRxk9sTOUg3glxSIY6f0nfuqRll2OEF8TspLgh5mZkLuBgheCn3zClcDSGJ58TvNmiwyCCuat4UajPud/5Og==} engines: {node: '>= 16'} + xmlbuilder2@4.0.3: + resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} + engines: {node: '>=20.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -9154,6 +9607,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -9380,6 +9836,31 @@ snapshots: transitivePeerDependencies: - encoding + '@auth/core@0.41.0': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.2 + oauth4webapi: 3.8.5 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + + '@auth/core@0.41.1': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.2 + oauth4webapi: 3.8.5 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + + '@auth/prisma-adapter@2.11.1(@prisma/client@7.6.0(prisma@7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))': + dependencies: + '@auth/core': 0.41.1 + '@prisma/client': 7.6.0(prisma@7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + transitivePeerDependencies: + - '@simplewebauthn/browser' + - '@simplewebauthn/server' + - nodemailer + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -9708,14 +10189,25 @@ snapshots: '@cfworker/json-schema@4.1.1': {} - '@copilotkit/backend@0.37.0(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(playwright@1.58.2)(ws@8.19.0)': + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@copilotkit/backend@0.37.0(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(mysql2@3.15.3)(pg@8.20.0)(playwright@1.58.2)(ws@8.19.0)': dependencies: '@copilotkit/shared': 0.37.0 '@google/generative-ai': 0.11.5 '@langchain/core': 0.1.63(openai@4.104.0(ws@8.19.0)(zod@4.3.6)) '@langchain/openai': 0.0.28(ws@8.19.0) js-tiktoken: 1.0.21 - langchain: 0.1.37(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))(playwright@1.58.2)(ws@8.19.0) + langchain: 0.1.37(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(mysql2@3.15.3)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))(pg@8.20.0)(playwright@1.58.2)(ws@8.19.0) openai: 4.104.0(ws@8.19.0)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: @@ -9959,6 +10451,16 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -10149,6 +10651,10 @@ snapshots: dependencies: hono: 4.12.7 + '@hono/node-server@1.19.11(hono@4.12.9)': + dependencies: + hono: 4.12.9 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -10572,7 +11078,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@langchain/community@0.0.57(lodash@4.17.23)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))(ws@8.19.0)': + '@kurkle/color@0.3.4': {} + + '@langchain/community@0.0.57(lodash@4.17.23)(mysql2@3.15.3)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))(pg@8.20.0)(ws@8.19.0)': dependencies: '@langchain/core': 0.1.63(openai@4.104.0(ws@8.19.0)(zod@4.3.6)) '@langchain/openai': 0.0.28(ws@8.19.0) @@ -10584,6 +11092,8 @@ snapshots: zod-to-json-schema: 3.25.1(zod@3.25.76) optionalDependencies: lodash: 4.17.23 + mysql2: 3.15.3 + pg: 8.20.0 ws: 8.19.0 transitivePeerDependencies: - encoding @@ -10739,7 +11249,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) hono: 4.12.7 - jose: 6.2.1 + jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -10763,7 +11273,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) hono: 4.12.7 - jose: 6.2.1 + jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -10917,6 +11427,23 @@ snapshots: wordwrap: 1.0.0 wrap-ansi: 7.0.0 + '@oozcitak/dom@2.0.2': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/url': 3.0.0 + '@oozcitak/util': 10.0.0 + + '@oozcitak/infra@2.0.2': + dependencies: + '@oozcitak/util': 10.0.0 + + '@oozcitak/url@3.0.0': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + + '@oozcitak/util@10.0.0': {} + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -10932,6 +11459,8 @@ snapshots: '@oxc-project/types@0.115.0': {} + '@panva/hkdf@1.2.1': {} + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -10942,6 +11471,107 @@ snapshots: dependencies: playwright: 1.58.2 + '@prisma/adapter-pg@7.7.0': + dependencies: + '@prisma/driver-adapter-utils': 7.7.0 + '@types/pg': 8.20.0 + pg: 8.20.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.6.0': {} + + '@prisma/client@7.6.0(prisma@7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.6.0 + optionalDependencies: + prisma: 7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.6.0': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.6.0': {} + + '@prisma/debug@7.7.0': {} + + '@prisma/dev@0.24.3(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) + '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) + '@hono/node-server': 1.19.11(hono@4.12.9) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.2 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.12.9 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.7.0': + dependencies: + '@prisma/debug': 7.7.0 + + '@prisma/engines-version@7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711': {} + + '@prisma/engines@7.6.0': + dependencies: + '@prisma/debug': 7.6.0 + '@prisma/engines-version': 7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711 + '@prisma/fetch-engine': 7.6.0 + '@prisma/get-platform': 7.6.0 + + '@prisma/fetch-engine@7.6.0': + dependencies: + '@prisma/debug': 7.6.0 + '@prisma/engines-version': 7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711 + '@prisma/get-platform': 7.6.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.6.0': + dependencies: + '@prisma/debug': 7.6.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.2': + dependencies: + ajv: 8.18.0 + better-result: 2.7.0 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/react': 19.2.14 + chart.js: 4.5.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react-dom' + '@protobuf-ts/protoc@2.11.1': {} '@radix-ui/number@1.1.1': {} @@ -12117,6 +12747,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -12208,6 +12842,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 22.19.15 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -12754,6 +13394,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-ssl-profiles@1.1.2: {} + axe-core@4.11.1: {} axios@0.21.4(debug@4.3.2): @@ -12898,8 +13540,14 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bcryptjs@3.0.3: {} + beeper@1.1.1: {} + better-result@2.7.0: + dependencies: + '@clack/prompts': 0.11.0 + binary-extensions@2.3.0: {} binary-search@1.3.6: {} @@ -12983,6 +13631,21 @@ snapshots: bytes@3.1.2: {} + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -13055,6 +13718,10 @@ snapshots: charenc@0.0.2: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -13067,12 +13734,22 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chownr@2.0.0: {} ci-info@3.9.0: {} circular-json@0.3.3: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + cjs-module-lexer@1.4.3: {} class-transformer@0.5.1: {} @@ -13227,6 +13904,10 @@ snapshots: semver: 7.7.4 uint8array-extras: 1.5.0 + confbox@0.2.4: {} + + consola@3.4.2: {} + console-table-printer@2.15.0: dependencies: simple-wcswidth: 1.1.2 @@ -13465,6 +14146,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -13488,12 +14171,18 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} + destr@2.0.5: {} + destroy@1.2.0: {} detect-file@1.0.0: {} @@ -13535,6 +14224,8 @@ snapshots: dependencies: type-fest: 4.41.0 + dotenv@16.6.1: {} + dotenv@17.3.1: {} dset@3.1.4: {} @@ -13570,6 +14261,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -13596,6 +14292,8 @@ snapshots: emoji-regex@9.2.2: {} + empathic@2.0.0: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -14203,6 +14901,8 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.0.8: {} + ext@1.7.0: dependencies: type: 2.7.3 @@ -14227,6 +14927,10 @@ snapshots: parse-node-version: 1.0.1 time-stamp: 1.1.0 + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-copy@3.0.2: {} fast-deep-equal@1.1.0: {} @@ -14397,6 +15101,11 @@ snapshots: dependencies: for-in: 1.0.2 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} form-data@4.0.5: @@ -14488,6 +15197,10 @@ snapshots: dependencies: next: 16.1.2(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -14515,6 +15228,8 @@ snapshots: get-package-type@0.1.0: {} + get-port-please@3.2.0: {} + get-port@7.1.0: {} get-proto@1.0.1: @@ -14541,6 +15256,15 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -14616,6 +15340,10 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.12: {} + + graphmatch@1.1.1: {} + graphql-query-complexity@0.12.0(graphql@16.13.1): dependencies: graphql: 16.13.1 @@ -14895,6 +15623,8 @@ snapshots: hono@4.12.7: {} + hono@4.12.9: {} + html-escaper@2.0.2: {} html-url-attributes@3.0.1: {} @@ -14909,6 +15639,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-status-codes@2.3.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -15197,6 +15929,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -15656,7 +16390,7 @@ snapshots: jose@5.10.0: {} - jose@6.2.1: {} + jose@6.2.2: {} joycon@3.1.1: {} @@ -15739,10 +16473,10 @@ snapshots: kleur@4.1.5: {} - langchain@0.1.37(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))(playwright@1.58.2)(ws@8.19.0): + langchain@0.1.37(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(mysql2@3.15.3)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))(pg@8.20.0)(playwright@1.58.2)(ws@8.19.0): dependencies: '@anthropic-ai/sdk': 0.9.1 - '@langchain/community': 0.0.57(lodash@4.17.23)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))(ws@8.19.0) + '@langchain/community': 0.0.57(lodash@4.17.23)(mysql2@3.15.3)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))(pg@8.20.0)(ws@8.19.0) '@langchain/core': 0.1.63(openai@4.104.0(ws@8.19.0)(zod@4.3.6)) '@langchain/openai': 0.0.28(ws@8.19.0) '@langchain/textsplitters': 0.0.3(openai@4.104.0(ws@8.19.0)(zod@4.3.6)) @@ -16141,6 +16875,8 @@ snapshots: loglevel@1.9.2: {} + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -16162,6 +16898,8 @@ snapshots: dependencies: es5-ext: 0.10.64 + lru.min@1.1.4: {} + lucide-react@0.562.0(react@19.2.3): dependencies: react: 19.2.3 @@ -16711,6 +17449,22 @@ snapshots: mute-stream@2.0.0: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nanoid@3.3.11: {} nanoid@5.1.6: {} @@ -16723,6 +17477,12 @@ snapshots: negotiator@1.0.0: {} + next-auth@5.0.0-beta.30(next@16.1.2(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + dependencies: + '@auth/core': 0.41.0 + next: 16.1.2(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -16765,6 +17525,8 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-fetch-native@1.6.7: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -16804,6 +17566,14 @@ snapshots: num-sort@2.1.0: {} + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.0.2 + + oauth4webapi@3.8.5: {} + object-assign@3.0.0: {} object-assign@4.1.1: {} @@ -16863,6 +17633,8 @@ snapshots: obug@2.1.1: {} + ohash@2.0.11: {} + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -17111,6 +17883,43 @@ snapshots: dependencies: through: 2.3.8 + perfect-debounce@1.0.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + phoenix@1.8.5: {} picocolors@0.2.1: {} @@ -17168,6 +17977,12 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + playwright-core@1.58.2: {} playwright@1.58.2: @@ -17202,6 +18017,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + posthog-node@4.18.0: dependencies: axios: 1.13.6 @@ -17216,6 +18045,12 @@ snapshots: tinycolor2: 1.6.0 txml: 5.2.1 + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prelude-ls@1.1.2: {} prelude-ls@1.2.1: {} @@ -17257,6 +18092,23 @@ snapshots: dependencies: parse-ms: 4.0.0 + prisma@7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.6.0 + '@prisma/dev': 0.24.3(typescript@5.9.3) + '@prisma/engines': 7.6.0 + '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} @@ -17276,6 +18128,12 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.1.0: {} prosemirror-commands@1.7.1: @@ -17457,6 +18315,11 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -17530,6 +18393,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.1.2: {} + real-require@0.2.0: {} recast@0.23.11: @@ -17629,6 +18494,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remeda@2.33.4: {} + remend@1.2.2: {} remove-trailing-separator@1.1.0: {} @@ -17706,6 +18573,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retry@0.12.0: {} + retry@0.13.1: {} rettime@0.10.1: {} @@ -17897,6 +18766,8 @@ snapshots: transitivePeerDependencies: - supports-color + seq-queue@0.0.5: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -18132,6 +19003,8 @@ snapshots: sprintf-js@1.0.3: {} + sqlstring@2.3.3: {} + stable-hash@0.0.5: {} stack-utils@2.0.6: @@ -18142,6 +19015,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@4.0.0: {} stdin-discarder@0.2.2: {} @@ -18808,6 +19683,10 @@ snapshots: v8flags@4.0.1: {} + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validate-npm-package-name@7.0.2: {} validator@13.15.26: {} @@ -19106,6 +19985,13 @@ snapshots: xml-parser-xo@4.1.5: {} + xmlbuilder2@4.0.3: + dependencies: + '@oozcitak/dom': 2.0.2 + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + js-yaml: 4.1.1 + xtend@4.0.2: {} y18n@5.0.8: {} @@ -19158,6 +20044,11 @@ snapshots: yoctocolors@2.1.2: {} + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 000000000..a1528cff5 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,13 @@ +import 'dotenv/config'; +import path from 'node:path'; +import type { PrismaConfig } from 'prisma'; + +export default { + schema: path.join('prisma', 'schema.prisma'), + datasource: { + url: process.env.DATABASE_URL!, + }, + migrations: { + path: path.join('prisma', 'migrations'), + }, +} satisfies PrismaConfig; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 000000000..5eada2d11 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,242 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +// ==================== Authentication ==================== + +model User { + id String @id @default(cuid()) + email String? @unique + name String? + nickname String? + image String? + password String? // Hashed password for credentials provider + role Role @default(STUDENT) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + accounts Account[] + sessions Session[] + enrollments Enrollment[] + grades Grade[] + courses Course[] @relation("CourseCreator") +} + +enum Role { + STUDENT + TEACHER + ADMIN +} + +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} + +// ==================== Courses & Enrollment ==================== + +model Course { + id String @id @default(cuid()) + title String + description String? + philosophyId String? + language String @default("en-US") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdById String? + + createdBy User? @relation("CourseCreator", fields: [createdById], references: [id]) + modules Module[] + enrollments Enrollment[] +} + +model Module { + id String @id @default(cuid()) + courseId String + title String + description String? + order Int + + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + lessons Lesson[] +} + +model Lesson { + id String @id @default(cuid()) + moduleId String + stageId String // References file-based classroom JSON (/data/classrooms/{stageId}.json) + title String + order Int + + module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + grades Grade[] + progress StudentProgress[] +} + +model Enrollment { + id String @id @default(cuid()) + userId String + courseId String + role EnrollmentRole @default(STUDENT) + enrolledAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + @@unique([userId, courseId]) +} + +enum EnrollmentRole { + STUDENT + TEACHING_ASSISTANT + INSTRUCTOR + OBSERVER +} + +// ==================== Grading & Progress ==================== + +model Grade { + id String @id @default(cuid()) + userId String + lessonId String + sceneId String? // Specific scene (e.g., quiz) that was graded + score Float // 0-100 + maxScore Float @default(100) + feedback String? + gradedAt DateTime @default(now()) + gradedBy String? // "system" for auto-grading, userId for manual grading + synced Boolean @default(false) // Whether synced to external LMS + externalId String? // ID in external system (e.g., Moodle grade item ID) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) +} + +model StudentProgress { + id String @id @default(cuid()) + userId String + lessonId String + sceneIndex Int // Last viewed scene index + completed Boolean @default(false) + completedAt DateTime? + timeSpent Int @default(0) // seconds + updatedAt DateTime @updatedAt + + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + + @@unique([userId, lessonId]) +} + +// ==================== LTI Integration ==================== + +model LTIPlatform { + id String @id @default(cuid()) + name String + issuer String @unique // LTI 1.3 issuer URL + clientId String + authEndpoint String + tokenEndpoint String + jwksEndpoint String + deploymentId String? + publicKey String? // Platform's public key for verifying JWTs + + createdAt DateTime @default(now()) + launches LTILaunch[] +} + +model LTILaunch { + id String @id @default(cuid()) + platformId String + userId String? // Linked OpenMAIC user + ltiUserId String // User ID from LTI claim + courseId String? // Target OpenMAIC course + lessonId String? // Target lesson + roles String[] // LTI roles + nonce String @unique + state String? + launchedAt DateTime @default(now()) + + platform LTIPlatform @relation(fields: [platformId], references: [id]) +} + +// ==================== LMS Sync Configuration ==================== + +model LMSIntegration { + id String @id @default(cuid()) + providerId String // 'moodle', 'odoo', 'dolibarr' + name String // User-friendly name + config Json // Provider-specific config (base URL, credentials, etc.) + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + syncRules SyncRule[] +} + +model SyncRule { + id String @id @default(cuid()) + integrationId String + courseId String + externalCourseId String + syncGrades Boolean @default(true) + syncEnrollments Boolean @default(false) + + integration LMSIntegration @relation(fields: [integrationId], references: [id], onDelete: Cascade) +} + +// ==================== Provider Configuration ==================== +// Admin-editable provider credentials and metadata. Persistent override on +// top of server-providers.yml + environment variables. The category tells +// getConfig() which section of the runtime config to merge the entry into. +// +// Categories: "llm" | "tts" | "asr" | "image" | "video" | "webSearch" | "pdf" + +model ProviderConfigOverride { + id String @id @default(cuid()) + category String // "llm" | "tts" | "asr" | ... + providerId String // e.g. "openai", "cartesia-tts", "assemblyai-asr" + apiKey String? // Nullable: may be set from env only, not DB + baseUrl String? + models String? // Comma-separated list (for LLM providers) + proxy String? + enabled Boolean @default(true) + updatedAt DateTime @updatedAt + updatedBy String? // User id of the admin who last changed this + + @@unique([category, providerId]) + @@index([category]) +}