Remove AI image generation functionality from CMS and expand Docker ignore rules.
This commit is contained in:
+43
-15
@@ -1,17 +1,45 @@
|
|||||||
target
|
# Build Artifacts
|
||||||
**/target
|
target/
|
||||||
node_modules
|
**/target/
|
||||||
**/node_modules
|
node_modules/
|
||||||
.next
|
**/node_modules/
|
||||||
**/.next
|
.next/
|
||||||
.git
|
**/ .next/
|
||||||
**/.git
|
dist/
|
||||||
|
**/dist/
|
||||||
|
|
||||||
|
# Virtual Environments
|
||||||
|
venv/
|
||||||
|
**/venv/
|
||||||
|
.venv/
|
||||||
|
**/.venv/
|
||||||
|
env/
|
||||||
|
**/env/
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
|
||||||
|
# Environments and Secrets
|
||||||
.env
|
.env
|
||||||
**/.env
|
**/.env
|
||||||
**/.env.local
|
.env.local
|
||||||
**/.env.example
|
.env.*.local
|
||||||
*.log
|
|
||||||
e2e
|
# Git and OS
|
||||||
docs
|
.git/
|
||||||
artifacts
|
.gitignore
|
||||||
.gemini
|
.dockerignore
|
||||||
|
**/.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Storage and Data
|
||||||
|
uploads/
|
||||||
|
**/uploads/
|
||||||
|
storage/
|
||||||
|
volumes/
|
||||||
|
postgres_data/
|
||||||
|
|
||||||
|
# Huge binary files/libraries
|
||||||
|
*.so
|
||||||
|
*.dll
|
||||||
|
*.dylib
|
||||||
|
*.exe
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
|
|||||||
- **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.).
|
- **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.).
|
||||||
- **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada.
|
- **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada.
|
||||||
- **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots).
|
- **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots).
|
||||||
- **AI Image Generation**: Generación automática de contenido visual a partir de guiones de lecciones o prompts personalizados, con soporte para resoluciones personalizadas (HD/4K) y gestión de portadas de curso de alta calidad.
|
|
||||||
- **Course Marketing & Summary**: Sistema de metadatos estructurados (objetivos, requisitos) y landing pages premium para una mejor presentación de cursos.
|
- **Course Marketing & Summary**: Sistema de metadatos estructurados (objetivos, requisitos) y landing pages premium para una mejor presentación de cursos.
|
||||||
- **Global AI Task Dashboard**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar tareas de IA en segundo plano (transcripciones, imágenes, etc).
|
- **Global AI Task Dashboard**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar tareas de IA en segundo plano (transcripciones, quices, etc).
|
||||||
- **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual.
|
- **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual.
|
||||||
- **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes.
|
- **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes.
|
||||||
- **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso.
|
- **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso.
|
||||||
@@ -80,7 +79,6 @@ OpenCCB es altamente escalable. A continuación se detallan los requisitos recom
|
|||||||
- **IA Local**:
|
- **IA Local**:
|
||||||
- **Faster-Whisper**: Transcripción de audio a texto.
|
- **Faster-Whisper**: Transcripción de audio a texto.
|
||||||
- **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios.
|
- **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios.
|
||||||
- **Stable Diffusion**: Motor de generación de imágenes optimizado para CPU.
|
|
||||||
- **i18n Infrastructure**: Sistema de traducción reactivo para soporte global.
|
- **i18n Infrastructure**: Sistema de traducción reactivo para soporte global.
|
||||||
- **Document Management**: Motor de previsualización de documentos PDF nativo.
|
- **Document Management**: Motor de previsualización de documentos PDF nativo.
|
||||||
|
|
||||||
@@ -646,8 +644,7 @@ Obtiene una lista de todas las organizaciones registradas.
|
|||||||
- **Course Teams UI**: Panel de gestión para añadir y configurar roles de instructores secundarios y asistentes.
|
- **Course Teams UI**: Panel de gestión para añadir y configurar roles de instructores secundarios y asistentes.
|
||||||
- **Course Preview Badges**: Indicadores visuales y lógica de acceso para lecciones accesibles sin suscripción.
|
- **Course Preview Badges**: Indicadores visuales y lógica de acceso para lecciones accesibles sin suscripción.
|
||||||
- **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo.
|
- **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo.
|
||||||
- **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad, integración de imágenes generadas por IA y desgloses de objetivos de aprendizaje.
|
- **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad y desgloses de objetivos de aprendizaje.
|
||||||
- **AI Image Resolution Selector**: Control granular sobre las dimensiones de salida para activos visuales generados por IA (720p, 1080p, 4K).
|
|
||||||
|
|
||||||
## �️ Próximos Pasos (Roadmap 2024-2025)
|
## �️ Próximos Pasos (Roadmap 2024-2025)
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -219,15 +219,13 @@
|
|||||||
- [x] Visualización de progreso y nivel de XP.
|
- [x] Visualización de progreso y nivel de XP.
|
||||||
|
|
||||||
## Fase 19: Presentación Visual y Marketing de Cursos ✅
|
## Fase 19: Presentación Visual y Marketing de Cursos ✅
|
||||||
- [x] **Generación de Imágenes de Alta Resolución**: Soporte para dimensiones personalizadas (HD/4K) para lecciones y cursos via AI Bridge. (Completado)
|
|
||||||
- [x] **Metadatos de Marketing Estructurados**: Captura de objetivos, requisitos, público objetivo y certificación en Studio. (Completado)
|
- [x] **Metadatos de Marketing Estructurados**: Captura de objetivos, requisitos, público objetivo y certificación en Studio. (Completado)
|
||||||
- [x] **Premium Course Summary**: Nueva interfaz de "Acerca del Curso" en Experience con diseño de alta fidelidad y navegación por pestañas. (Completado)
|
- [x] **Premium Course Summary**: Nueva interfaz de "Acerca del Curso" en Experience con diseño de alta fidelidad y navegación por pestañas. (Completado)
|
||||||
- [x] **Gestión de Portadas de Curso**: Integración de imágenes generadas por IA como activos principales del curso con previsualización dinámica. (Completado)
|
- [x] **Dashboard Global de Tareas AI**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar todas las generaciones en segundo plano (videos, transcripciones, quices, etc). (Completado)
|
||||||
- [x] **Dashboard Global de Tareas AI**: Panel centralizado para monitorizar, reintentar y cancelar todas las generaciones en segundo plano (imágenes, videos, transcripciones). (Completado)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos**, **Generación de Imágenes HD/4K** y **Landing Pages de Cursos (Marketing) automatizadas**.
|
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos** y **Landing Pages de Cursos (Marketing) automatizadas**.
|
||||||
|
|
||||||
**Próximas Prioridades**:
|
**Próximas Prioridades**:
|
||||||
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
|
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
|
||||||
|
|||||||
@@ -1309,381 +1309,6 @@ pub async fn generate_quiz(
|
|||||||
Ok(Json(quiz_blocks))
|
Ok(Json(quiz_blocks))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct VideoAIRequest {
|
|
||||||
pub prompt: Option<String>,
|
|
||||||
pub width: Option<u32>,
|
|
||||||
pub height: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_image(
|
|
||||||
Org(org_ctx): Org,
|
|
||||||
claims: common::auth::Claims,
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(payload): Json<VideoAIRequest>,
|
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
|
||||||
if let Some(prompt) = &payload.prompt {
|
|
||||||
tracing::info!("Received prompt for video generation: {}", prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Fetch lesson
|
|
||||||
let _lesson =
|
|
||||||
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
|
||||||
.bind(id)
|
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
||||||
|
|
||||||
// 2. Set status to queued
|
|
||||||
let updated_lesson = sqlx::query_as::<_, Lesson>(
|
|
||||||
"UPDATE lessons SET video_generation_status = 'queued', video_generation_error = NULL WHERE id = $1 RETURNING *",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!("Database update failed (video queued): {}", e);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log_action(
|
|
||||||
&pool,
|
|
||||||
org_ctx.id,
|
|
||||||
claims.sub,
|
|
||||||
"VIDEO_GENERATION_QUEUED",
|
|
||||||
"Lesson",
|
|
||||||
id,
|
|
||||||
json!({ "status": "queued" }),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 3. Spawn background task
|
|
||||||
let pool_clone = pool.clone();
|
|
||||||
let prompt_to_task = payload.prompt.clone();
|
|
||||||
let user_id = claims.sub;
|
|
||||||
let width = payload.width;
|
|
||||||
let height = payload.height;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task, Some(user_id), false, width, height).await {
|
|
||||||
tracing::error!("Image generation task failed for lesson {}: {}", id, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Json(updated_lesson))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_course_image(
|
|
||||||
Org(org_ctx): Org,
|
|
||||||
claims: common::auth::Claims,
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(payload): Json<VideoAIRequest>,
|
|
||||||
) -> Result<Json<Course>, StatusCode> {
|
|
||||||
// 1. Fetch course
|
|
||||||
let _course =
|
|
||||||
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
|
||||||
.bind(id)
|
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
||||||
|
|
||||||
// 2. Set status to queued
|
|
||||||
let updated_course = sqlx::query_as::<_, Course>(
|
|
||||||
"UPDATE courses SET generation_status = 'queued', generation_error = NULL WHERE id = $1 RETURNING *",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!("Database update failed (course image queued): {}", e);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log_action(
|
|
||||||
&pool,
|
|
||||||
org_ctx.id,
|
|
||||||
claims.sub,
|
|
||||||
"COURSE_IMAGE_GENERATION_QUEUED",
|
|
||||||
"Course",
|
|
||||||
id,
|
|
||||||
json!({ "status": "queued" }),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 3. Spawn background task
|
|
||||||
let pool_clone = pool.clone();
|
|
||||||
let prompt_to_task = payload.prompt.clone();
|
|
||||||
let user_id = claims.sub;
|
|
||||||
let width = payload.width;
|
|
||||||
let height = payload.height;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task, Some(user_id), true, width, height).await {
|
|
||||||
tracing::error!("Image generation task failed for course {}: {}", id, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Json(updated_course))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_image_generation_task(
|
|
||||||
pool: PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
custom_prompt: Option<String>,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
is_course: bool,
|
|
||||||
width: Option<u32>,
|
|
||||||
height: Option<u32>
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let (status_col, progress_col, error_col, table_name) = if is_course {
|
|
||||||
("generation_status", "generation_progress", "generation_error", "courses")
|
|
||||||
} else {
|
|
||||||
("video_generation_status", "generation_progress", "video_generation_error", "lessons")
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Set status to processing ONLY if it's still queued (not cancelled/idle)
|
|
||||||
let query = format!(
|
|
||||||
"UPDATE {} SET {} = 'processing' WHERE id = $1 AND {} = 'queued'",
|
|
||||||
table_name, status_col, status_col
|
|
||||||
);
|
|
||||||
let rows_affected = sqlx::query(&query)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Update to processing failed: {}", e))?
|
|
||||||
.rows_affected();
|
|
||||||
|
|
||||||
if rows_affected == 0 {
|
|
||||||
tracing::info!("Task {} was cancelled or is already processing. Aborting background thread.", id);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Call Local Video Bridge (Python) with Retry Logic
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let bridge_base_url = std::env::var("LOCAL_VIDEO_BRIDGE_URL")
|
|
||||||
.unwrap_or_else(|_| "http://t-800:8080".to_string());
|
|
||||||
let bridge_url = format!("{}/generate", bridge_base_url);
|
|
||||||
|
|
||||||
// Fallback logic for prompt: Custom Prompt > Title
|
|
||||||
let final_prompt = match custom_prompt {
|
|
||||||
Some(p) if !p.is_empty() => p,
|
|
||||||
_ => {
|
|
||||||
let title: String = sqlx::query_scalar(&format!("SELECT title FROM {} WHERE id = $1", table_name))
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to fetch fallback prompt: {}", e))?;
|
|
||||||
title
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let database_url = std::env::var("BRIDGE_DATABASE_URL")
|
|
||||||
.unwrap_or_else(|_| std::env::var("DATABASE_URL").unwrap_or_default());
|
|
||||||
|
|
||||||
let mut payload = serde_json::json!({
|
|
||||||
"prompt": final_prompt,
|
|
||||||
"lesson_id": id.to_string(),
|
|
||||||
"database_url": database_url,
|
|
||||||
"table_name": table_name,
|
|
||||||
"progress_column": progress_col,
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(w) = width {
|
|
||||||
payload["width"] = serde_json::json!(w);
|
|
||||||
}
|
|
||||||
if let Some(h) = height {
|
|
||||||
payload["height"] = serde_json::json!(h);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut retry_count = 0;
|
|
||||||
let max_retries = 3; // Initial quick retries
|
|
||||||
let long_retry_delay = tokio::time::Duration::from_secs(600); // 10 minutes
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let response_result = client.post(&bridge_url)
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match response_result {
|
|
||||||
Ok(response) => {
|
|
||||||
if response.status().is_success() {
|
|
||||||
let result: serde_json::Value = response.json().await
|
|
||||||
.map_err(|e| format!("Failed to parse video bridge response: {}", e))?;
|
|
||||||
|
|
||||||
let bridge_content_url = result["url"].as_str()
|
|
||||||
.ok_or_else(|| "Video bridge response missing URL".to_string())?;
|
|
||||||
|
|
||||||
// Break the loop and proceed to download
|
|
||||||
return process_image_download(&client, bridge_content_url, &pool, id, is_course, user_id, &final_prompt).await;
|
|
||||||
} else {
|
|
||||||
let err_text = response.text().await.unwrap_or_default();
|
|
||||||
let _ = sqlx::query(&format!("UPDATE {} SET {} = $1, {} = 'error' WHERE id = $2", table_name, error_col, status_col))
|
|
||||||
.bind(&err_text)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
return Err(format!("Video bridge error: {}", err_text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to reach AI bridge at {}: {}. Retry {}/{}", bridge_url, e, retry_count + 1, max_retries);
|
|
||||||
|
|
||||||
// Update DB to show we are waiting/retrying
|
|
||||||
let wait_msg = format!("El servidor de IA (t-800) no responde. Reintentando automáticamente en 10 min... (Error: {})", e);
|
|
||||||
let _ = sqlx::query(&format!("UPDATE {} SET {} = $1, {} = 'queued' WHERE id = $2", table_name, error_col, status_col))
|
|
||||||
.bind(&wait_msg)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Check if task was cancelled while we were about to sleep
|
|
||||||
let current_status: String = sqlx::query_scalar(&format!("SELECT {} FROM {} WHERE id = $1", status_col, table_name))
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| "error".to_string());
|
|
||||||
|
|
||||||
if current_status == "idle" || current_status == "error" {
|
|
||||||
tracing::info!("Task {} was cancelled or errored during retry. Aborting.", id);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
retry_count += 1;
|
|
||||||
// Wait 10 minutes before next attempt
|
|
||||||
tokio::time::sleep(long_retry_delay).await;
|
|
||||||
|
|
||||||
// After sleep, check status AGAIN to see if user cancelled during the 10min sleep
|
|
||||||
let current_status: String = sqlx::query_scalar(&format!("SELECT {} FROM {} WHERE id = $1", status_col, table_name))
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| "error".to_string());
|
|
||||||
|
|
||||||
if current_status != "queued" {
|
|
||||||
tracing::info!("Task {} status changed to {} during sleep. Aborting retry.", id, current_status);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set back to processing to "lock" it again for this attempt
|
|
||||||
let rows_affected = sqlx::query(&format!("UPDATE {} SET {} = 'processing' WHERE id = $1 AND {} = 'queued'", table_name, status_col, status_col))
|
|
||||||
.bind(id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.rows_affected();
|
|
||||||
|
|
||||||
if rows_affected == 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_image_download(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
bridge_content_url: &str,
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
is_course: bool,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
final_prompt: &str
|
|
||||||
) -> Result<(), String> {
|
|
||||||
|
|
||||||
// --- Download image and store as Asset ---
|
|
||||||
|
|
||||||
// 1. Download from bridge
|
|
||||||
let image_bytes = client.get(bridge_content_url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to download generated image: {}", e))?
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to read image bytes: {}", e))?;
|
|
||||||
|
|
||||||
// 2. Fetch context
|
|
||||||
let (org_id, course_id, instructor_id): (Uuid, Uuid, Uuid) = if is_course {
|
|
||||||
let c = sqlx::query_as::<sqlx::Postgres, (Uuid, Uuid, Uuid)>(
|
|
||||||
"SELECT organization_id, id as course_id, instructor_id FROM courses WHERE id = $1"
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to fetch course context: {}", e))?;
|
|
||||||
c
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<sqlx::Postgres, (Uuid, Uuid, Uuid)>(
|
|
||||||
"SELECT l.organization_id, m.course_id, c.instructor_id
|
|
||||||
FROM lessons l
|
|
||||||
JOIN modules m ON l.module_id = m.id
|
|
||||||
JOIN courses c ON m.course_id = c.id
|
|
||||||
WHERE l.id = $1"
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to fetch lesson context: {}", e))?
|
|
||||||
};
|
|
||||||
|
|
||||||
let final_user_id = user_id.unwrap_or(instructor_id);
|
|
||||||
|
|
||||||
// 3. Save file locally
|
|
||||||
let asset_id = Uuid::new_v4();
|
|
||||||
let storage_filename = format!("{}.png", asset_id);
|
|
||||||
let storage_path = format!("uploads/{}", storage_filename);
|
|
||||||
|
|
||||||
let _ = tokio::fs::create_dir_all("uploads").await;
|
|
||||||
tokio::fs::write(&storage_path, &image_bytes).await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let size_bytes = image_bytes.len() as i64;
|
|
||||||
let local_url = format!("/assets/{}", storage_filename);
|
|
||||||
|
|
||||||
// 4. Register in assets table
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(asset_id)
|
|
||||||
.bind(org_id)
|
|
||||||
.bind(final_user_id)
|
|
||||||
.bind(course_id)
|
|
||||||
.bind(format!("AI: {}", final_prompt))
|
|
||||||
.bind(&storage_path)
|
|
||||||
.bind("image/png")
|
|
||||||
.bind(size_bytes)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to register asset: {}", e))?;
|
|
||||||
|
|
||||||
// 5. Complete task updating entity with local URL - ONLY if not cancelled (idle)
|
|
||||||
if is_course {
|
|
||||||
sqlx::query(&format!(
|
|
||||||
"UPDATE courses SET generation_status = 'completed', course_image_url = $1 WHERE id = $2 AND generation_status = 'processing'"
|
|
||||||
))
|
|
||||||
.bind(local_url)
|
|
||||||
.bind(id)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to complete course task: {}", e))?;
|
|
||||||
} else {
|
|
||||||
sqlx::query(&format!(
|
|
||||||
"UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2 AND video_generation_status = 'processing'"
|
|
||||||
))
|
|
||||||
.bind(local_url)
|
|
||||||
.bind(id)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to complete lesson task: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_lesson(
|
pub async fn get_lesson(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
@@ -3921,29 +3546,55 @@ RULES:
|
|||||||
request = request.header("Authorization", auth_header);
|
request = request.header("Authorization", auth_header);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = request.send().await.map_err(|e| {
|
let response_result = request.send().await;
|
||||||
tracing::error!("LLM request failed: {}", e);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let mut content_str = match response_result {
|
||||||
let err_body = response.text().await.unwrap_or_default();
|
Ok(response) if response.status().is_success() => {
|
||||||
tracing::error!("LLM API error: {}", err_body);
|
let llm_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
tracing::error!("Failed to parse LLM JSON response: {}", e);
|
||||||
}
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
let llm_data: serde_json::Value = response.json().await.map_err(|e| {
|
tracing::info!("LLM Response received successfully");
|
||||||
tracing::error!("Failed to parse LLM JSON response: {}", e);
|
llm_data["choices"][0]["message"]["content"]
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
.as_str()
|
||||||
})?;
|
.unwrap_or("{}")
|
||||||
|
.trim()
|
||||||
tracing::info!("LLM Response received successfully");
|
.to_string()
|
||||||
|
}
|
||||||
let mut content_str = llm_data["choices"][0]["message"]["content"]
|
Ok(response) => {
|
||||||
.as_str()
|
let err_body = response.text().await.unwrap_or_default();
|
||||||
.unwrap_or("{}")
|
tracing::error!("LLM API error (generating course): {}", err_body);
|
||||||
.trim()
|
tracing::warn!("Falling back to default course template.");
|
||||||
.to_string();
|
r#"{
|
||||||
|
"title": "Nueva Plantilla de Curso",
|
||||||
|
"description": "El servidor de IA no respondió. Esta es una plantilla básica que puedes editar directamente.",
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"title": "Módulo 1",
|
||||||
|
"lessons": [
|
||||||
|
{ "title": "Lección 1", "content_type": "text" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#.to_string()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("LLM request failed (generating course): {}", e);
|
||||||
|
tracing::warn!("Falling back to default course template due to unreachable AI.");
|
||||||
|
r#"{
|
||||||
|
"title": "Nueva Plantilla de Curso",
|
||||||
|
"description": "El servidor de IA no está disponible en este momento. Esta es una plantilla básica.",
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"title": "Módulo 1",
|
||||||
|
"lessons": [
|
||||||
|
{ "title": "Lección 1", "content_type": "text" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!("Extracted content string (length: {})", content_str.len());
|
tracing::info!("Extracted content string (length: {})", content_str.len());
|
||||||
|
|
||||||
|
|||||||
@@ -35,34 +35,6 @@ pub async fn get_background_tasks(
|
|||||||
JOIN modules m ON l.module_id = m.id
|
JOIN modules m ON l.module_id = m.id
|
||||||
JOIN courses c ON m.course_id = c.id
|
JOIN courses c ON m.course_id = c.id
|
||||||
WHERE l.transcription_status IN ('queued', 'processing', 'failed')
|
WHERE l.transcription_status IN ('queued', 'processing', 'failed')
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
l.id,
|
|
||||||
l.title,
|
|
||||||
c.title as course_title,
|
|
||||||
'lesson_image' as task_type,
|
|
||||||
l.video_generation_status as status,
|
|
||||||
l.generation_progress as progress,
|
|
||||||
l.updated_at
|
|
||||||
FROM lessons l
|
|
||||||
JOIN modules m ON l.module_id = m.id
|
|
||||||
JOIN courses c ON m.course_id = c.id
|
|
||||||
WHERE l.video_generation_status IN ('queued', 'processing', 'failed')
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.title as title,
|
|
||||||
NULL as course_title,
|
|
||||||
'course_image' as task_type,
|
|
||||||
c.generation_status as status,
|
|
||||||
c.generation_progress as progress,
|
|
||||||
c.updated_at
|
|
||||||
FROM courses c
|
|
||||||
WHERE c.generation_status IN ('queued', 'processing', 'failed')
|
|
||||||
|
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
"#;
|
"#;
|
||||||
@@ -83,23 +55,15 @@ pub async fn get_background_tasks(
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct LessonStatusRow {
|
struct LessonStatusRow {
|
||||||
transcription_status: Option<String>,
|
transcription_status: Option<String>,
|
||||||
video_generation_status: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct CourseStatusRow {
|
|
||||||
generation_status: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn retry_task(
|
pub async fn retry_task(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
// We need to know WHAT to retry.
|
|
||||||
// Since we don't have task_type in the URL yet, we'll try to find the lesson/course and its current failing status.
|
|
||||||
|
|
||||||
// Check lessons for transcription or image failures
|
// Check lessons for transcription failures
|
||||||
let lesson = sqlx::query_as::<_, LessonStatusRow>("SELECT transcription_status, video_generation_status FROM lessons WHERE id = $1")
|
let lesson = sqlx::query_as::<_, LessonStatusRow>("SELECT transcription_status FROM lessons WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -114,44 +78,6 @@ pub async fn retry_task(
|
|||||||
});
|
});
|
||||||
return Ok(StatusCode::ACCEPTED);
|
return Ok(StatusCode::ACCEPTED);
|
||||||
}
|
}
|
||||||
if l.video_generation_status.as_deref() == Some("failed") {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
// For image generation, we need the worker pool.
|
|
||||||
// Wait, run_image_generation_task is in handlers.rs but it requires WorkerPool (not easily available here without complex wiring or just spawning the handler)
|
|
||||||
// Actually, the simplest way is to just set it to 'queued' and let a background worker pick it up if there was one,
|
|
||||||
// but currently we spawn them directly.
|
|
||||||
|
|
||||||
// For now, let's call the same handler logic.
|
|
||||||
// I need to import run_image_generation_task
|
|
||||||
let _ = sqlx::query("UPDATE lessons SET video_generation_status = 'queued' WHERE id = $1").bind(id).execute(&pool_clone).await;
|
|
||||||
// Note: We are missing prompt/width/height here if we want to restart exactly.
|
|
||||||
// But generally retry means restart with same params.
|
|
||||||
// We'll need to fetch them.
|
|
||||||
|
|
||||||
// TODO: Implement full image retry in a future cleanup if needed,
|
|
||||||
// for now transcription is the priority as it's the one that "fails" most often.
|
|
||||||
// Image generation usually works or the bridge is down.
|
|
||||||
});
|
|
||||||
return Ok(StatusCode::ACCEPTED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check courses
|
|
||||||
let course = sqlx::query_as::<_, CourseStatusRow>("SELECT generation_status FROM courses WHERE id = $1")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if let Some(c) = course {
|
|
||||||
if c.generation_status.as_deref() == Some("error") || c.generation_status.as_deref() == Some("failed") {
|
|
||||||
let pool_clone = pool.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = sqlx::query("UPDATE courses SET generation_status = 'queued' WHERE id = $1").bind(id).execute(&pool_clone).await;
|
|
||||||
// Same as above, needs a worker to pick it up.
|
|
||||||
});
|
|
||||||
return Ok(StatusCode::ACCEPTED);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StatusCode::NOT_FOUND)
|
Ok(StatusCode::NOT_FOUND)
|
||||||
@@ -161,16 +87,9 @@ pub async fn cancel_task(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
// Try to cancel in both tables
|
// Only cancel transcription in lessons
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE lessons SET transcription_status = 'idle', video_generation_status = 'idle' WHERE id = $1"
|
"UPDATE lessons SET transcription_status = 'idle' WHERE id = $1"
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE courses SET generation_status = 'idle' WHERE id = $1"
|
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
|
|||||||
@@ -74,65 +74,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for queued video generations
|
|
||||||
let queued_video_lessons: Vec<sqlx::types::Uuid> = match sqlx::query_scalar(
|
|
||||||
"SELECT id FROM lessons WHERE video_generation_status = 'queued' LIMIT 5",
|
|
||||||
)
|
|
||||||
.fetch_all(&worker_pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(ids) => ids,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to fetch queued video lessons: {}", e);
|
|
||||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for lesson_id in queued_video_lessons {
|
|
||||||
tracing::info!("Processing video generation for lesson: {}", lesson_id);
|
|
||||||
if let Err(e) =
|
|
||||||
handlers::run_image_generation_task(worker_pool.clone(), lesson_id, None, None, false, None, None).await
|
|
||||||
{
|
|
||||||
tracing::error!("Image generation task failed for lesson {}: {}", lesson_id, e);
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE lessons SET video_generation_status = 'failed' WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(lesson_id)
|
|
||||||
.execute(&worker_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for queued course image generations
|
|
||||||
let queued_course_ids: Vec<sqlx::types::Uuid> = match sqlx::query_scalar(
|
|
||||||
"SELECT id FROM courses WHERE generation_status = 'queued' LIMIT 5",
|
|
||||||
)
|
|
||||||
.fetch_all(&worker_pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(ids) => ids,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to fetch queued course images: {}", e);
|
|
||||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for course_id in queued_course_ids {
|
|
||||||
tracing::info!("Processing image generation for course: {}", course_id);
|
|
||||||
if let Err(e) =
|
|
||||||
handlers::run_image_generation_task(worker_pool.clone(), course_id, None, None, true, None, None).await
|
|
||||||
{
|
|
||||||
tracing::error!("Course image generation failed for {}: {}", course_id, e);
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE courses SET generation_status = 'failed' WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(course_id)
|
|
||||||
.execute(&worker_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
@@ -205,8 +147,6 @@ async fn main() {
|
|||||||
.route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt))
|
.route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt))
|
||||||
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
|
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
|
||||||
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
|
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
|
||||||
.route("/lessons/{id}/generate-image", post(handlers::generate_image))
|
|
||||||
.route("/courses/{id}/generate-image", post(handlers::generate_course_image))
|
|
||||||
.route("/courses/generate", post(handlers::generate_course))
|
.route("/courses/generate", post(handlers::generate_course))
|
||||||
.route("/courses/{id}/export", get(handlers::export_course))
|
.route("/courses/{id}/export", get(handlers::export_course))
|
||||||
.route("/courses/import", post(handlers::import_course))
|
.route("/courses/import", post(handlers::import_course))
|
||||||
|
|||||||
@@ -2043,13 +2043,36 @@ pub async fn get_recommendations(
|
|||||||
"response_format": { "type": "json_object" }
|
"response_format": { "type": "json_object" }
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await;
|
||||||
.map_err(|e: reqwest::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let ai_response: RecommendationResponse = response
|
let ai_response: RecommendationResponse = match response {
|
||||||
.json()
|
Ok(res) if res.status().is_success() => {
|
||||||
.await
|
res.json().await.unwrap_or_else(|_| RecommendationResponse {
|
||||||
.map_err(|e: reqwest::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
recommendations: vec![
|
||||||
|
common::models::Recommendation {
|
||||||
|
title: "Continúa practicando".to_string(),
|
||||||
|
description: "Sigue revisando las lecciones anteriores para consolidar tu conocimiento.".to_string(),
|
||||||
|
lesson_id: None,
|
||||||
|
priority: "medium".to_string(),
|
||||||
|
reason: "El servidor de IA no pudo generar recomendaciones personalizadas en este momento.".to_string(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
RecommendationResponse {
|
||||||
|
recommendations: vec![
|
||||||
|
common::models::Recommendation {
|
||||||
|
title: "Repaso General".to_string(),
|
||||||
|
description: "Te recomendamos revisar los temas donde has tenido menor puntaje recientemente.".to_string(),
|
||||||
|
lesson_id: None,
|
||||||
|
priority: "high".to_string(),
|
||||||
|
reason: "Servidor de IA temporalmente fuera de servicio. Mostrando recomendación genérica.".to_string(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Json(ai_response))
|
Ok(Json(ai_response))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import AITutor from "@/components/AITutor";
|
|||||||
import LessonLockedView from "@/components/LessonLockedView";
|
import LessonLockedView from "@/components/LessonLockedView";
|
||||||
import StudentNotes from "@/components/StudentNotes";
|
import StudentNotes from "@/components/StudentNotes";
|
||||||
import { ListMusic, StickyNote } from "lucide-react";
|
import { ListMusic, StickyNote } from "lucide-react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
|
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||||
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
|
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
|
||||||
@@ -280,12 +280,22 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
|
|
||||||
{lesson.summary && (
|
{lesson.summary && (
|
||||||
<div className="p-8 rounded-3xl bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-500/20 animate-in fade-in slide-in-from-top-4 duration-1000">
|
<div className="p-8 rounded-3xl bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-500/20 animate-in fade-in slide-in-from-top-4 duration-1000">
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 mb-4 flex items-center gap-2">
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-600 dark:text-blue-400 mb-4 flex items-center gap-2">
|
||||||
<span className="text-base">✨</span> Resumen
|
<span className="text-base">✨</span> Resumen
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-lg text-gray-300 leading-relaxed font-medium italic">
|
<div className="text-sm text-slate-700 dark:text-slate-300 leading-relaxed font-medium">
|
||||||
"{lesson.summary}"
|
<ReactMarkdown
|
||||||
</p>
|
components={{
|
||||||
|
p: ({ node, ...props }) => <p className="mb-4 last:mb-0" {...props} />,
|
||||||
|
strong: ({ node, ...props }) => <strong className="font-bold text-slate-900 dark:text-white" {...props} />,
|
||||||
|
em: ({ node, ...props }) => <em className="italic text-slate-800 dark:text-slate-200" {...props} />,
|
||||||
|
ul: ({ node, ...props }) => <ul className="list-disc pl-5 mb-4 space-y-1" {...props} />,
|
||||||
|
li: ({ node, ...props }) => <li {...props} />
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lesson.summary}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
|
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
|
||||||
const [instructors, setInstructors] = useState<any[]>([]);
|
const [instructors, setInstructors] = useState<any[]>([]);
|
||||||
const [meetings, setMeetings] = useState<Meeting[]>([]);
|
const [meetings, setMeetings] = useState<Meeting[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState<'outline' | 'about'>('outline');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -145,7 +146,6 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
return <CheckCircle2 size={18} className="text-black/20 dark:text-white/40" />;
|
return <CheckCircle2 size={18} className="text-black/20 dark:text-white/40" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'outline' | 'about'>('outline');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-6 py-20 pb-40">
|
<div className="max-w-4xl mx-auto px-6 py-20 pb-40">
|
||||||
|
|||||||
@@ -3,45 +3,27 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
cmsApi,
|
cmsApi,
|
||||||
Course,
|
Course
|
||||||
getImageUrl
|
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
Sparkles,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Type,
|
Type,
|
||||||
Target,
|
Target,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
Clock,
|
||||||
Award,
|
Award,
|
||||||
Zap,
|
Zap,
|
||||||
Maximize,
|
Megaphone
|
||||||
Monitor,
|
|
||||||
Square,
|
|
||||||
Smartphone,
|
|
||||||
X
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface MarketingTabProps {
|
interface MarketingTabProps {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOLUTIONS = [
|
|
||||||
{ label: "16:9 Landscape", width: 1024, height: 576, icon: Monitor },
|
|
||||||
{ label: "1:1 Square", width: 1024, height: 1024, icon: Square },
|
|
||||||
{ label: "4:3 Classic", width: 1024, height: 768, icon: Maximize },
|
|
||||||
{ label: "9:16 Portrait", width: 576, height: 1024, icon: Smartphone },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MarketingTab({ courseId }: MarketingTabProps) {
|
export default function MarketingTab({ courseId }: MarketingTabProps) {
|
||||||
const [course, setCourse] = useState<Course | null>(null);
|
const [course, setCourse] = useState<Course | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [prompt, setPrompt] = useState("");
|
|
||||||
const [selectedRes, setSelectedRes] = useState(RESOLUTIONS[0]);
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [objectives, setObjectives] = useState("");
|
const [objectives, setObjectives] = useState("");
|
||||||
@@ -63,12 +45,6 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
|
|||||||
setDuration(meta.duration || "");
|
setDuration(meta.duration || "");
|
||||||
setModulesSummary(meta.modules_summary || "");
|
setModulesSummary(meta.modules_summary || "");
|
||||||
setCertificationInfo(meta.certification_info || "");
|
setCertificationInfo(meta.certification_info || "");
|
||||||
|
|
||||||
if (data.generation_status === 'processing' || data.generation_status === 'queued') {
|
|
||||||
setIsGenerating(true);
|
|
||||||
} else {
|
|
||||||
setIsGenerating(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load course", err);
|
console.error("Failed to load course", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -80,22 +56,6 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
|
|||||||
loadCourse();
|
loadCourse();
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
|
|
||||||
// Polling for generation status
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: NodeJS.Timeout;
|
|
||||||
if (isGenerating) {
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
const updated = await cmsApi.getCourse(courseId);
|
|
||||||
setCourse(updated);
|
|
||||||
if (updated.generation_status === 'completed' || updated.generation_status === 'error') {
|
|
||||||
setIsGenerating(false);
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isGenerating, courseId]);
|
|
||||||
|
|
||||||
const handleSaveMetadata = async () => {
|
const handleSaveMetadata = async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -117,34 +77,6 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateImage = async () => {
|
|
||||||
try {
|
|
||||||
setIsGenerating(true);
|
|
||||||
await cmsApi.generateCourseImage(courseId, {
|
|
||||||
prompt: prompt || course?.title,
|
|
||||||
width: selectedRes.width,
|
|
||||||
height: selectedRes.height
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Generation failed", err);
|
|
||||||
alert("Failed to start generation.");
|
|
||||||
setIsGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelGeneration = async () => {
|
|
||||||
try {
|
|
||||||
await cmsApi.updateCourse(courseId, {
|
|
||||||
generation_status: 'idle'
|
|
||||||
} as any);
|
|
||||||
setIsGenerating(false);
|
|
||||||
setCourse(prev => prev ? { ...prev, generation_status: 'idle' } : null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Cancel failed", err);
|
|
||||||
alert("Failed to cancel generation.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
|
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
|
||||||
@@ -154,132 +86,6 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
|
||||||
{/* ── SECTION: COURSE IMAGE AI ── */}
|
|
||||||
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[3rem] overflow-hidden shadow-sm hover:shadow-xl transition-all duration-500">
|
|
||||||
<div className="p-10 lg:p-14 flex flex-col lg:flex-row gap-12">
|
|
||||||
|
|
||||||
{/* Left: Preview */}
|
|
||||||
<div className="lg:w-1/2 space-y-6">
|
|
||||||
<div className="group relative aspect-video rounded-[2.5rem] bg-slate-100 dark:bg-black/20 overflow-hidden border border-slate-200 dark:border-white/5 shadow-inner">
|
|
||||||
{course?.course_image_url ? (
|
|
||||||
<img
|
|
||||||
src={getImageUrl(course.course_image_url)}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-[2s]"
|
|
||||||
alt="Course Preview"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400 gap-4">
|
|
||||||
<ImageIcon size={64} className="opacity-20 stroke-[1]" />
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.3em] opacity-40">No preview generated</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isGenerating && (
|
|
||||||
<div className="absolute inset-0 bg-white/60 dark:bg-black/80 backdrop-blur-md flex flex-col items-center justify-center p-12 text-center">
|
|
||||||
<Zap size={48} className="text-blue-500 animate-pulse mb-6" />
|
|
||||||
<h4 className="text-xl font-black uppercase tracking-tight text-slate-900 dark:text-white mb-4">Generating Visual Intelligence...</h4>
|
|
||||||
|
|
||||||
<div className="w-full h-3 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mb-4 border border-white/10">
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${course?.generation_progress || 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-blue-600 dark:text-blue-400 mb-8">
|
|
||||||
Analysis Phase: {course?.generation_progress || 0}% Complete
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCancelGeneration}
|
|
||||||
className="flex items-center gap-2 px-6 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-full border border-red-500/20 transition-all active:scale-95 group/cancel"
|
|
||||||
>
|
|
||||||
<X size={14} className="group-hover/cancel:rotate-90 transition-transform" />
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.1em]">Abort Mission</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{course?.generation_error && (
|
|
||||||
<div className="p-6 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-3xl flex items-center gap-4 text-red-600 dark:text-red-400">
|
|
||||||
<AlertCircle size={24} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest mb-1">Neural Engine Error</p>
|
|
||||||
<p className="text-sm font-medium">{course.generation_error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Controls */}
|
|
||||||
<div className="lg:w-1/2 space-y-10">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-3xl font-black uppercase tracking-tighter text-slate-900 dark:text-white flex items-center gap-4">
|
|
||||||
<Sparkles className="text-blue-500" />
|
|
||||||
AI Visual Identity
|
|
||||||
</h3>
|
|
||||||
<p className="text-slate-500 dark:text-gray-500 mt-2 font-medium">Generate a cinematic landing page image using Stable Diffusion.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2">Creative Prompt</label>
|
|
||||||
<textarea
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
placeholder={course?.title || "Describe the visual mood of your course..."}
|
|
||||||
className="w-full h-32 bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[2rem] p-6 text-slate-900 dark:text-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 transition-all resize-none font-medium shadow-inner"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2">Resolution Matrix</label>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{RESOLUTIONS.map((res) => {
|
|
||||||
const Icon = res.icon;
|
|
||||||
const isSelected = selectedRes.label === res.label;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={res.label}
|
|
||||||
onClick={() => setSelectedRes(res)}
|
|
||||||
className={`p-5 rounded-3xl border transition-all flex items-center gap-4 group ${isSelected
|
|
||||||
? "bg-blue-600 border-blue-500 text-white shadow-xl shadow-blue-500/20 active:scale-95"
|
|
||||||
: "bg-white dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-600 dark:text-gray-400 hover:bg-slate-50 dark:hover:bg-white/10 active:scale-95 shadow-sm"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`p-2 rounded-xl ${isSelected ? "bg-white/20" : "bg-slate-100 dark:bg-white/10 group-hover:scale-110 transition-transform"}`}>
|
|
||||||
<Icon size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-xs font-black uppercase tracking-tight">{res.label}</p>
|
|
||||||
<p className={`text-[10px] font-black opacity-60 ${isSelected ? "text-white" : "text-slate-400"}`}>{res.width}x{res.height}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleGenerateImage}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className={`w-full py-5 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-[2rem] font-black text-[10px] uppercase tracking-[0.3em] shadow-2xl transition-all active:scale-[0.98] flex items-center justify-center gap-4 group ${isGenerating ? 'opacity-50 cursor-not-allowed' : 'hover:scale-[1.02]'}`}
|
|
||||||
>
|
|
||||||
{isGenerating ? (
|
|
||||||
<>
|
|
||||||
<div className="w-5 h-5 border-2 border-slate-300 border-t-blue-500 rounded-full animate-spin" />
|
|
||||||
Synthesizing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles size={20} className="group-hover:rotate-12 transition-transform" />
|
|
||||||
Energize Neural Engine
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── SECTION: CORE MARKETING DATA ── */}
|
{/* ── SECTION: CORE MARKETING DATA ── */}
|
||||||
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[3rem] p-10 lg:p-14 space-y-12 shadow-sm">
|
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[3rem] p-10 lg:p-14 space-y-12 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-6 flex-wrap">
|
<div className="flex items-center justify-between gap-6 flex-wrap">
|
||||||
@@ -381,5 +187,3 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Megaphone } from "lucide-react";
|
|
||||||
|
|||||||
@@ -646,8 +646,6 @@ export const cmsApi = {
|
|||||||
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
|
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
|
||||||
generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }),
|
generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
generateImage: (id: string, payload: { prompt?: string, width?: number, height?: number } = {}): Promise<Lesson> => apiFetch(`/lessons/${id}/generate-image`, { method: 'POST', body: JSON.stringify(payload) }),
|
|
||||||
generateCourseImage: (id: string, payload: { prompt?: string, width?: number, height?: number } = {}): Promise<Course> => apiFetch(`/courses/${id}/generate-image`, { method: 'POST', body: JSON.stringify(payload) }),
|
|
||||||
reviewText: (text: string): Promise<{ suggestion: string, comments: string }> => apiFetch('/api/ai/review-text', { method: 'POST', body: JSON.stringify({ text }) }),
|
reviewText: (text: string): Promise<{ suggestion: string, comments: string }> => apiFetch('/api/ai/review-text', { method: 'POST', body: JSON.stringify({ text }) }),
|
||||||
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
|
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
|
||||||
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
|
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
Reference in New Issue
Block a user