actualizaciones
This commit is contained in:
@@ -27,7 +27,7 @@ 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).
|
||||||
- **Auto Transcription**: Integración con Whisper para generación automática de transcripciones y evaluación precisa de voz.
|
- **AI Video Generation**: Generación automática de contenido de video a partir de guiones de lecciones o prompts personalizados, optimizada para ejecución local o remota (servidor t-800) mediante *Stable Video Diffusion*.
|
||||||
- **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.
|
||||||
@@ -78,6 +78,7 @@ 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 Video Diffusion**: Motor de generación de video 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.
|
||||||
|
|
||||||
@@ -654,6 +655,7 @@ OpenCCB evoluciona constantemente. Estos son los pilares de nuestro desarrollo f
|
|||||||
- **Offline Sync**: Capacidad de descargar lecciones y sincronizar progreso al recuperar conexión.
|
- **Offline Sync**: Capacidad de descargar lecciones y sincronizar progreso al recuperar conexión.
|
||||||
|
|
||||||
### 🧠 Inteligencia Artificial Avanzada
|
### 🧠 Inteligencia Artificial Avanzada
|
||||||
|
- **AI Video Generation (v1)**: ✅ Generación de clips a partir de prompts y guiones de lecciones.
|
||||||
- **AI Proctoring**: Monitoreo basado en visión artificial para exámenes de alta integridad, 100% privado y local.
|
- **AI Proctoring**: Monitoreo basado en visión artificial para exámenes de alta integridad, 100% privado y local.
|
||||||
- **Multimodal Tutoring**: El tutor de IA podrá analizar imágenes y videos subidos por el alumno para dar feedback.
|
- **Multimodal Tutoring**: El tutor de IA podrá analizar imágenes y videos subidos por el alumno para dar feedback.
|
||||||
- **Automated Grading for Open Questions**: Evaluación masiva de ensayos y respuestas abiertas con rúbricas personalizadas.
|
- **Automated Grading for Open Questions**: Evaluación masiva de ensayos y respuestas abiertas con rúbricas personalizadas.
|
||||||
|
|||||||
+4
-1
@@ -117,11 +117,14 @@ read -p "Ingrese la URL de Ollama Remoto [$DEFAULT_OLLAMA]: " REMOTE_OLLAMA_URL
|
|||||||
REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-$DEFAULT_OLLAMA}
|
REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-$DEFAULT_OLLAMA}
|
||||||
read -p "Ingrese la URL de Whisper Remoto [$DEFAULT_WHISPER]: " REMOTE_WHISPER_URL
|
read -p "Ingrese la URL de Whisper Remoto [$DEFAULT_WHISPER]: " REMOTE_WHISPER_URL
|
||||||
REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-$DEFAULT_WHISPER}
|
REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-$DEFAULT_WHISPER}
|
||||||
|
read -p "Ingrese la URL del Video Bridge Remoto [http://t-800:8080]: " REMOTE_VIDEO_URL
|
||||||
|
REMOTE_VIDEO_URL=${REMOTE_VIDEO_URL:-"http://t-800:8080"}
|
||||||
read -p "Ingrese el nombre del Modelo (en el servidor remoto) [llama3.2:3b]: " LLM_MODEL
|
read -p "Ingrese el nombre del Modelo (en el servidor remoto) [llama3.2:3b]: " LLM_MODEL
|
||||||
LLM_MODEL=${LLM_MODEL:-llama3.2:3b}
|
LLM_MODEL=${LLM_MODEL:-llama3.2:3b}
|
||||||
|
|
||||||
update_env "AI_PROVIDER" "local"
|
update_env "AI_PROVIDER" "local"
|
||||||
update_env "LOCAL_LLM_MODEL" "$LLM_MODEL"
|
update_env "LOCAL_LLM_MODEL" "$LLM_MODEL"
|
||||||
|
update_env "LOCAL_VIDEO_BRIDGE_URL" "$REMOTE_VIDEO_URL"
|
||||||
|
|
||||||
if [ "$ENV_CHOICE" == "dev" ]; then
|
if [ "$ENV_CHOICE" == "dev" ]; then
|
||||||
update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL"
|
update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL"
|
||||||
@@ -213,7 +216,7 @@ if [ "$ADMIN_EXISTS" != "t" ]; then
|
|||||||
read -s -p "Contraseña del Administrador [password123]: " ADMIN_PASS
|
read -s -p "Contraseña del Administrador [password123]: " ADMIN_PASS
|
||||||
ADMIN_PASS=${ADMIN_PASS:-password123}
|
ADMIN_PASS=${ADMIN_PASS:-password123}
|
||||||
echo ""
|
echo ""
|
||||||
ORG_NAME="Organización por Defecto"
|
ORG_NAME="Default Organization"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ DECLARE
|
|||||||
v_org_id UUID;
|
v_org_id UUID;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Find or create organization
|
-- Find or create organization
|
||||||
IF p_org_name IS NULL OR p_org_name = '' OR p_org_name = 'Default Organization' THEN
|
IF p_org_name IS NULL OR p_org_name = '' OR p_org_name = 'Default Organization' OR p_org_name = 'Organización por Defecto' THEN
|
||||||
v_org_id := '00000000-0000-0000-0000-000000000001';
|
v_org_id := '00000000-0000-0000-0000-000000000001';
|
||||||
ELSE
|
ELSE
|
||||||
INSERT INTO organizations (name)
|
INSERT INTO organizations (name)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add video_generation_status and video_generation_error to lessons table
|
||||||
|
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS video_generation_status VARCHAR(20) DEFAULT 'idle';
|
||||||
|
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS video_generation_error TEXT;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
torch
|
||||||
|
diffusers
|
||||||
|
transformers
|
||||||
|
accelerate
|
||||||
|
pillow
|
||||||
|
imageio-ffmpeg
|
||||||
|
pydantic
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import torch
|
||||||
|
from diffusers import StableDiffusionPipeline
|
||||||
|
from PIL import Image
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
MODEL_ID = "runwayml/stable-diffusion-v1-5"
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
OUTPUT_DIR = os.path.join(BASE_DIR, "outputs")
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Global variables for the model
|
||||||
|
pipe = None
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
global pipe
|
||||||
|
print("Loading Stable Diffusion model on CPU...")
|
||||||
|
pipe = StableDiffusionPipeline.from_pretrained(
|
||||||
|
MODEL_ID,
|
||||||
|
torch_dtype=torch.float32,
|
||||||
|
)
|
||||||
|
pipe.to("cpu")
|
||||||
|
# pipe.enable_model_cpu_offload()
|
||||||
|
pipe.enable_attention_slicing()
|
||||||
|
print("Model loaded successfully.")
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Model loading is heavy, we'll do it on first request to avoid timeout at startup
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
# Serve generated images
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
|
||||||
|
|
||||||
|
class ImageRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
lesson_id: str
|
||||||
|
|
||||||
|
@app.post("/generate")
|
||||||
|
async def generate_image(request: ImageRequest):
|
||||||
|
global pipe
|
||||||
|
if pipe is None:
|
||||||
|
load_model()
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"Generating image for prompt: {request.prompt}")
|
||||||
|
|
||||||
|
generator = torch.manual_seed(42)
|
||||||
|
# Using a small number of steps for speed since it's on CPU
|
||||||
|
image = pipe(
|
||||||
|
request.prompt,
|
||||||
|
num_inference_steps=20,
|
||||||
|
generator=generator
|
||||||
|
).images[0]
|
||||||
|
|
||||||
|
image_filename = f"image_{request.lesson_id}_{uuid.uuid4().hex[:8]}.png"
|
||||||
|
image_path = os.path.join(OUTPUT_DIR, image_filename)
|
||||||
|
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
# Return the absolute URL pointing to t-800 so the frontend can find it
|
||||||
|
hostname = os.getenv("BRIDGE_HOSTNAME", "localhost")
|
||||||
|
full_url = f"http://{hostname}:8080/outputs/{image_filename}"
|
||||||
|
|
||||||
|
return {"status": "completed", "url": full_url}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Generation error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "model_loaded": pipe is not None}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||||
@@ -1283,6 +1283,125 @@ pub async fn generate_quiz(
|
|||||||
Ok(Json(quiz_blocks))
|
Ok(Json(quiz_blocks))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VideoAIRequest {
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task).await {
|
||||||
|
tracing::error!("Image generation task failed for lesson {}: {}", id, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(updated_lesson))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_image_generation_task(pool: PgPool, lesson_id: Uuid, custom_prompt: Option<String>) -> Result<(), String> {
|
||||||
|
// 1. Set status to processing
|
||||||
|
sqlx::query("UPDATE lessons SET video_generation_status = 'processing' WHERE id = $1")
|
||||||
|
.bind(lesson_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Update to processing failed: {}", e))?;
|
||||||
|
|
||||||
|
// 2. Call Local Video Bridge (Python)
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let bridge_base_url = std::env::var("LOCAL_VIDEO_BRIDGE_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost: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,
|
||||||
|
_ => {
|
||||||
|
sqlx::query_scalar("SELECT title FROM lessons WHERE id = $1")
|
||||||
|
.bind(lesson_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch fallback prompt: {}", e))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.post(bridge_url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"prompt": final_prompt,
|
||||||
|
"lesson_id": lesson_id.to_string()
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to call video bridge: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let err_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Video bridge error: {}", err_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = response.json().await
|
||||||
|
.map_err(|e| format!("Failed to parse video bridge response: {}", e))?;
|
||||||
|
|
||||||
|
let content_url = result["url"].as_str()
|
||||||
|
.ok_or_else(|| "Video bridge response missing URL".to_string())?;
|
||||||
|
|
||||||
|
// 3. Complete task
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2"
|
||||||
|
)
|
||||||
|
.bind(content_url)
|
||||||
|
.bind(lesson_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Update to completed failed: {}", 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>,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct BackgroundTask {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub course_title: Option<String>,
|
pub course_title: Option<String>,
|
||||||
pub transcription_status: Option<String>,
|
pub transcription_status: Option<String>,
|
||||||
|
pub video_generation_status: Option<String>,
|
||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +35,13 @@ pub async fn get_background_tasks(
|
|||||||
l.title,
|
l.title,
|
||||||
c.title as course_title,
|
c.title as course_title,
|
||||||
l.transcription_status,
|
l.transcription_status,
|
||||||
|
l.video_generation_status,
|
||||||
l.updated_at
|
l.updated_at
|
||||||
FROM lessons l
|
FROM lessons l
|
||||||
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')
|
||||||
|
OR l.video_generation_status IN ('queued', 'processing', 'failed')
|
||||||
ORDER BY l.updated_at DESC
|
ORDER BY l.updated_at DESC
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,36 @@ 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).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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -145,6 +175,7 @@ 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/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))
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ pub struct Lesson {
|
|||||||
pub due_date: Option<DateTime<Utc>>,
|
pub due_date: Option<DateTime<Utc>>,
|
||||||
pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc.
|
pub important_date_type: Option<String>, // "exam", "assignment", "milestone", etc.
|
||||||
pub transcription_status: Option<String>,
|
pub transcription_status: Option<String>,
|
||||||
|
pub video_generation_status: Option<String>,
|
||||||
|
pub video_generation_error: Option<String>,
|
||||||
pub is_previewable: bool,
|
pub is_previewable: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Library,
|
Library,
|
||||||
BookMarked,
|
BookMarked,
|
||||||
ArrowLeft
|
ArrowLeft,
|
||||||
|
ImageIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
|
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
|
||||||
import MediaBlock from "@/components/blocks/MediaBlock";
|
import MediaBlock from "@/components/blocks/MediaBlock";
|
||||||
@@ -78,6 +79,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
|
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
|
||||||
const [aiQuizContext, setAiQuizContext] = useState("");
|
const [aiQuizContext, setAiQuizContext] = useState("");
|
||||||
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
|
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
|
||||||
|
const [aiImagePrompt, setAiImagePrompt] = useState("");
|
||||||
|
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
||||||
|
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
@@ -86,7 +89,10 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: NodeJS.Timeout;
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
if (lesson && (lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing')) {
|
if (lesson && (
|
||||||
|
lesson.video_generation_status === 'queued' ||
|
||||||
|
lesson.video_generation_status === 'processing'
|
||||||
|
)) {
|
||||||
interval = setInterval(async () => {
|
interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const updated = await cmsApi.getLesson(params.lessonId);
|
const updated = await cmsApi.getLesson(params.lessonId);
|
||||||
@@ -362,6 +368,32 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateImage = async () => {
|
||||||
|
if (!lesson) return;
|
||||||
|
setIsGeneratingImage(true);
|
||||||
|
try {
|
||||||
|
// Option 2: Use lesson content (blocks) as script if no prompt
|
||||||
|
const scriptFromBlocks = blocks
|
||||||
|
.map(b => b.content || b.description || b.title || "")
|
||||||
|
.filter(txt => txt.trim().length > 0)
|
||||||
|
.join(". ");
|
||||||
|
|
||||||
|
// Option 3: Prioritize manual prompt, then script
|
||||||
|
const finalPrompt = aiImagePrompt.trim() || scriptFromBlocks;
|
||||||
|
|
||||||
|
const updated = await cmsApi.generateImage(lesson.id, {
|
||||||
|
prompt: finalPrompt
|
||||||
|
});
|
||||||
|
setLesson(updated);
|
||||||
|
setAiImagePrompt("");
|
||||||
|
alert("Generación de imagen iniciada con el prompt/guion detectado.");
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || "Failed to initiate image generation.");
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingImage(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className="py-20 text-center text-gray-500 animate-pulse font-medium">Initializing Activity Builder...</div>;
|
if (loading) return <div className="py-20 text-center text-gray-500 animate-pulse font-medium">Initializing Activity Builder...</div>;
|
||||||
if (!lesson) return <div className="py-20 text-center text-red-400">Activity not found.</div>;
|
if (!lesson) return <div className="py-20 text-center text-red-400">Activity not found.</div>;
|
||||||
|
|
||||||
@@ -872,6 +904,35 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
<div className="font-black text-xl tracking-tight">{isGeneratingQuiz ? 'Building Quiz...' : 'Generate New Test'}</div>
|
<div className="font-black text-xl tracking-tight">{isGeneratingQuiz ? 'Building Quiz...' : 'Generate New Test'}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="col-span-full space-y-3 mt-4">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<ImageIcon size={14} className="text-amber-500" />
|
||||||
|
Custom Image Prompt (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={aiImagePrompt}
|
||||||
|
onChange={(e) => setAiImagePrompt(e.target.value)}
|
||||||
|
placeholder="Describe what you want to see in the image, or leave blank to use the lesson script..."
|
||||||
|
className="w-full bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-2xl p-4 text-sm focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 outline-none transition-all min-h-[100px] text-slate-700 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateImage}
|
||||||
|
disabled={isGeneratingImage || lesson.video_generation_status === 'queued' || lesson.video_generation_status === 'processing'}
|
||||||
|
className={`group/btn p-8 border rounded-[2rem] transition-all text-left flex flex-col gap-4 shadow-sm relative overflow-hidden ${isGeneratingImage || lesson.video_generation_status === 'queued' || lesson.video_generation_status === 'processing' ? 'bg-amber-50 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/50 text-amber-600 dark:text-amber-300 animate-pulse' : 'bg-white dark:bg-white/5 border-slate-100 dark:border-white/10 text-slate-400 dark:text-amber-400 hover:border-amber-500/50 hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-500/10'}`}
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-amber-100 dark:bg-amber-500/20 flex items-center justify-center text-amber-600 dark:text-amber-400 shadow-sm group-hover/btn:scale-110 transition-transform">
|
||||||
|
{(isGeneratingImage || lesson.video_generation_status === 'queued' || lesson.video_generation_status === 'processing') ? '⏳' : <ImageIcon />}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-80">Generative AI</div>
|
||||||
|
<div className="font-black text-xl tracking-tight">
|
||||||
|
{(isGeneratingImage || lesson.video_generation_status === 'queued' || lesson.video_generation_status === 'processing') ? 'Generating Image...' : 'Generate AI Image'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -281,13 +281,13 @@ export default function StudioDashboard() {
|
|||||||
title="AI Course Wizard"
|
title="AI Course Wizard"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleAIGenerate} className="space-y-6">
|
<form onSubmit={handleAIGenerate} className="space-y-6">
|
||||||
<div className="p-4 rounded-xl bg-indigo-500/5 border border-indigo-500/10 mb-2">
|
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 mb-2">
|
||||||
<p className="text-xs text-indigo-300 leading-relaxed font-medium">
|
<p className="text-sm text-blue-800 dark:text-blue-300 leading-relaxed font-medium">
|
||||||
Describe the course topic and target audience. Our AI will structure the modules and lessons for you in seconds.
|
Describe the course topic and target audience. Our AI will structure the modules and lessons for you in seconds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
<label className="block text-sm font-bold text-slate-700 dark:text-gray-300 mb-2">
|
||||||
Course Topic or Description
|
Course Topic or Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -297,7 +297,7 @@ export default function StudioDashboard() {
|
|||||||
value={aiPrompt}
|
value={aiPrompt}
|
||||||
onChange={(e) => setAiPrompt(e.target.value)}
|
onChange={(e) => setAiPrompt(e.target.value)}
|
||||||
placeholder="e.g. A comprehensive guide to building distributed systems with Rust and Axum for intermediate developers."
|
placeholder="e.g. A comprehensive guide to building distributed systems with Rust and Axum for intermediate developers."
|
||||||
className="w-full bg-black/5 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-purple-500/50 transition-all text-gray-900 dark:text-white resize-none"
|
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white resize-none placeholder:text-slate-400 dark:placeholder:text-gray-600"
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,14 +306,14 @@ export default function StudioDashboard() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsAIModalOpen(false)}
|
onClick={() => setIsAIModalOpen(false)}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium"
|
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-white/5 hover:bg-slate-200 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl transition-all text-sm font-bold text-slate-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className="flex-[2] px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-all shadow-lg shadow-indigo-500/20 font-bold text-sm flex items-center justify-center gap-2"
|
className="flex-[2] px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/25 font-bold text-sm flex items-center justify-center gap-2 active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -32,21 +32,21 @@ export default function Modal({ isOpen, onClose, title, children }: ModalProps)
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-md animate-in fade-in duration-300">
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-title"
|
aria-labelledby="modal-title"
|
||||||
className="w-full max-w-md glass-card bg-[#1a1d23] border border-white/10 rounded-2xl p-8 shadow-2xl animate-in zoom-in-95 duration-200"
|
className="w-full max-w-md glass-card bg-white/90 dark:bg-slate-900/90 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl animate-in zoom-in-95 duration-200 flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 id="modal-title" className="text-xl font-bold bg-gradient-to-r from-white to-gray-400 bg-clip-text text-transparent italic">
|
<h2 id="modal-title" className="text-xl font-bold bg-gradient-to-r from-slate-900 to-slate-600 dark:from-white dark:to-slate-400 bg-clip-text text-transparent">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-white/5 rounded-full transition-colors text-gray-400 hover:text-white"
|
className="p-2 hover:bg-slate-100 dark:hover:bg-white/5 rounded-full transition-colors text-slate-400 dark:text-gray-400 hover:text-slate-900 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
<X size={20} aria-hidden="true" />
|
<X size={20} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export interface Lesson {
|
|||||||
cues?: { start: number; end: number; text: string }[];
|
cues?: { start: number; end: number; text: string }[];
|
||||||
} | null;
|
} | null;
|
||||||
transcription_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed';
|
transcription_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed';
|
||||||
|
video_generation_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed';
|
||||||
is_previewable: boolean;
|
is_previewable: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -631,6 +632,7 @@ 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 } = {}): Promise<Lesson> => apiFetch(`/lessons/${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' }),
|
||||||
@@ -958,5 +960,6 @@ export interface BackgroundTask {
|
|||||||
title: string;
|
title: string;
|
||||||
course_title?: string;
|
course_title?: string;
|
||||||
transcription_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
|
transcription_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
|
||||||
|
video_generation_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user