feat: Implement AI-generated Role Playing and Hotspot interactive content blocks with UI and service integration.
This commit is contained in:
@@ -35,6 +35,7 @@ import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock";
|
||||
import AudioResponseBlock from "@/components/blocks/AudioResponseBlock";
|
||||
import HotspotBlock from "@/components/blocks/HotspotBlock";
|
||||
import MemoryBlock from "@/components/blocks/MemoryBlock";
|
||||
import RolePlayingBlock from "@/components/blocks/RolePlayingBlock";
|
||||
import PeerReviewBlock from "@/components/blocks/PeerReviewBlock";
|
||||
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
|
||||
import LibraryPanel from "@/components/LibraryPanel";
|
||||
@@ -215,7 +216,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
|
||||
const addBlock = (type: Block['type']) => {
|
||||
const newBlock: Block = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
...(type === 'description' && { content: "" }),
|
||||
...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }),
|
||||
@@ -230,6 +231,14 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
...(type === 'hotspot' && { imageUrl: "", description: "Find the following items...", hotspots: [] }),
|
||||
...(type === 'memory-match' && { pairs: [{ id: "1", left: "Term A", right: "Match A" }] }),
|
||||
...(type === 'peer-review' && { prompt: "Submit your work below.", reviewCriteria: "Evaluate based on clarity and completeness." }),
|
||||
...(type === 'role-playing' && {
|
||||
title: "Simulación de Rol",
|
||||
scenario: "Contexto de la simulación...",
|
||||
ai_persona: "Personalidad de la IA...",
|
||||
user_role: "Rol del estudiante...",
|
||||
objectives: "Objetivos...",
|
||||
initial_message: "Hola, ¿cómo puedo ayudarte?"
|
||||
}),
|
||||
};
|
||||
setBlocks([...blocks, newBlock]);
|
||||
};
|
||||
@@ -323,14 +332,27 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
setIsAIQuizModalOpen(false);
|
||||
setIsGeneratingQuiz(true);
|
||||
try {
|
||||
const newBlocks = await cmsApi.generateQuiz(lesson.id, {
|
||||
context: aiQuizContext,
|
||||
quiz_type: aiQuizType
|
||||
});
|
||||
setBlocks([...blocks, ...newBlocks]);
|
||||
if (aiQuizType === 'role-playing') {
|
||||
const data = await cmsApi.generateRolePlay(lesson.id, {
|
||||
prompt_hint: aiQuizContext
|
||||
});
|
||||
const newBlock: Block = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
type: 'role-playing',
|
||||
...data
|
||||
};
|
||||
setBlocks([...blocks, newBlock]);
|
||||
} else {
|
||||
const newBlocks = await cmsApi.generateQuiz(lesson.id, {
|
||||
prompt_hint: aiQuizContext,
|
||||
quiz_type: aiQuizType
|
||||
});
|
||||
setBlocks([...blocks, ...newBlocks]);
|
||||
}
|
||||
setAiQuizContext("");
|
||||
} catch {
|
||||
alert("Failed to generate quiz.");
|
||||
} catch (err) {
|
||||
console.error("AI Generation failed:", err);
|
||||
alert("Failed to generate content.");
|
||||
} finally {
|
||||
setIsGeneratingQuiz(false);
|
||||
}
|
||||
@@ -1041,6 +1063,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
hotspots={block.hotspots || []}
|
||||
editMode={editMode}
|
||||
courseId={params.id}
|
||||
lessonId={params.lessonId}
|
||||
onChange={(updates) => updateBlock(block.id, updates)}
|
||||
/>
|
||||
)}
|
||||
@@ -1063,6 +1086,13 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
onChange={(updates) => updateBlock(block.id, updates)}
|
||||
/>
|
||||
)}
|
||||
{block.type === 'role-playing' && (
|
||||
<RolePlayingBlock
|
||||
block={block}
|
||||
lessonId={params.lessonId}
|
||||
onUpdate={(updates) => updateBlock(block.id, updates)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1102,9 +1132,10 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
{ type: 'ordering', icon: '🔢', label: 'Sequence', color: 'blue' },
|
||||
{ type: 'short-answer', icon: '💬', label: 'Open-Ended', color: 'indigo' },
|
||||
{ type: 'hotspot', icon: '🔍', label: 'Hotspot', color: 'amber' },
|
||||
{ type: 'audio-response', icon: '🎤', label: 'Phonetic', color: 'purple' },
|
||||
{ type: 'memory-match', icon: '🧠', label: 'Cognitive', color: 'fuchsia' },
|
||||
{ type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'rose' },
|
||||
{ type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' },
|
||||
{ type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' },
|
||||
{ type: 'role-playing', icon: '🎭', label: 'Persona Play', color: 'violet' },
|
||||
{ type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.type}
|
||||
@@ -1197,6 +1228,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
<option value="vocabulary">Lexical Focus / Vocab</option>
|
||||
<option value="grammar">Structural / Grammar Focus</option>
|
||||
<option value="memory-match">Conceptual Memory Match</option>
|
||||
<option value="role-playing">AI Role-Playing Simulation</option>
|
||||
</select>
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
|
||||
<ChevronDown size={18} />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair } from "lucide-react";
|
||||
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair, Wand2, Loader2 } from "lucide-react";
|
||||
import AssetPickerModal from "../AssetPickerModal";
|
||||
import { Asset, getImageUrl } from "@/lib/api";
|
||||
import { Asset, getImageUrl, cmsApi } from "@/lib/api";
|
||||
|
||||
interface Hotspot {
|
||||
id: string;
|
||||
@@ -22,6 +22,7 @@ interface HotspotBlockProps {
|
||||
hotspots?: Hotspot[];
|
||||
editMode: boolean;
|
||||
courseId: string;
|
||||
lessonId: string;
|
||||
onChange: (updates: { title?: string; description?: string; imageUrl?: string; hotspots?: Hotspot[] }) => void;
|
||||
}
|
||||
|
||||
@@ -33,11 +34,34 @@ export default function HotspotBlock({
|
||||
hotspots = [],
|
||||
editMode,
|
||||
courseId,
|
||||
lessonId,
|
||||
onChange
|
||||
}: HotspotBlockProps) {
|
||||
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleGenerateAI = async () => {
|
||||
if (!imageUrl) return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const data = await cmsApi.generateHotspots(lessonId, { image_url: imageUrl });
|
||||
const newHotspots = data.map(h => ({
|
||||
id: crypto.randomUUID(),
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
radius: 5,
|
||||
label: h.label
|
||||
}));
|
||||
onChange({ hotspots: [...hotspots, ...newHotspots] });
|
||||
} catch (error) {
|
||||
console.error("AI Hotspot Generation failed:", error);
|
||||
alert("No se pudieron generar los puntos de interés con IA. Por favor, intenta de nuevo.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = (asset: Asset) => {
|
||||
const url = asset.storage_path.replace('uploads/', '/assets/');
|
||||
onChange({ imageUrl: url });
|
||||
@@ -52,7 +76,7 @@ export default function HotspotBlock({
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
|
||||
const newHotspot: Hotspot = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
id: crypto.randomUUID(),
|
||||
x,
|
||||
y,
|
||||
radius: 5,
|
||||
@@ -123,7 +147,19 @@ export default function HotspotBlock({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 pl-1">Tactical Instructions</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 pl-1">Tactical Instructions</label>
|
||||
{imageUrl && (
|
||||
<button
|
||||
onClick={handleGenerateAI}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-[9px] font-black uppercase tracking-widest hover:bg-amber-700 transition-all disabled:opacity-50 shadow-lg shadow-amber-500/20 active:scale-95"
|
||||
>
|
||||
{isGenerating ? <Loader2 className="animate-spin" size={12} /> : <Wand2 size={12} />}
|
||||
{isGenerating ? "Analyzing..." : "Auto-Detect with AI"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={description || ""}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Block, cmsApi } from "@/lib/api";
|
||||
import { Sparkles, MessageSquare, User, Bot, Target, Wand2, Loader2 } from "lucide-react";
|
||||
|
||||
interface RolePlayingBlockProps {
|
||||
block: Block;
|
||||
onUpdate: (updates: Partial<Block>) => void;
|
||||
lessonId: string;
|
||||
}
|
||||
|
||||
export default function RolePlayingBlock({ block, onUpdate, lessonId }: RolePlayingBlockProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const handleGenerateAI = async () => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const data = await cmsApi.generateRolePlay(lessonId, {});
|
||||
onUpdate({
|
||||
title: data.title,
|
||||
scenario: data.scenario,
|
||||
ai_persona: data.ai_persona,
|
||||
user_role: data.user_role,
|
||||
objectives: data.objectives,
|
||||
initial_message: data.initial_message
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("AI Generation failed:", error);
|
||||
alert("No se pudo generar el escenario con IA. Por favor, intenta de nuevo.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-bold flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
|
||||
<MessageSquare size={16} />
|
||||
</div>
|
||||
Simulación de Rol Interactiva
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">Configura un escenario para que el estudiante practique con la IA.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateAI}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-bold hover:bg-indigo-700 transition-all disabled:opacity-50 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
{isGenerating ? <Loader2 className="animate-spin" size={14} /> : <Wand2 size={14} />}
|
||||
Generar con IA
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Título de la Simulación</label>
|
||||
<input
|
||||
type="text"
|
||||
value={block.title || ""}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
placeholder="Ej: Negociación con un Cliente Difícil"
|
||||
className="w-full bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Bot size={12} className="text-indigo-500" />
|
||||
Persona de la IA
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={block.ai_persona || ""}
|
||||
onChange={(e) => onUpdate({ ai_persona: e.target.value })}
|
||||
placeholder="Ej: Un cliente frustrado que busca un reembolso"
|
||||
className="w-full bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<User size={12} className="text-blue-500" />
|
||||
Rol del Estudiante
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={block.user_role || ""}
|
||||
onChange={(e) => onUpdate({ user_role: e.target.value })}
|
||||
placeholder="Ej: Representante de soporte técnico"
|
||||
className="w-full bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Sparkles size={12} className="text-amber-500" />
|
||||
Escenario y Contexto
|
||||
</label>
|
||||
<textarea
|
||||
value={block.scenario || ""}
|
||||
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||
placeholder="Describe detalladamente dónde ocurre la acción y cuál es el problema..."
|
||||
rows={4}
|
||||
className="w-full bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Target size={12} className="text-green-500" />
|
||||
Objetivos de Aprendizaje
|
||||
</label>
|
||||
<textarea
|
||||
value={block.objectives || ""}
|
||||
onChange={(e) => onUpdate({ objectives: e.target.value })}
|
||||
placeholder="¿Qué debe conseguir el estudiante al final de la charla?"
|
||||
rows={3}
|
||||
className="w-full bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-indigo-500/5 dark:bg-indigo-500/10 rounded-2xl border border-indigo-500/20 space-y-3 shadow-inner">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-indigo-600/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400">
|
||||
<MessageSquare size={12} />
|
||||
</div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400">Mensaje Inicial de la IA</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={block.initial_message || ""}
|
||||
onChange={(e) => onUpdate({ initial_message: e.target.value })}
|
||||
placeholder="Escribe la primera línea de la IA para romper el hielo..."
|
||||
className="w-full bg-white/50 dark:bg-black/20 border-none rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all outline-none"
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground italic">Este mensaje aparecerá automáticamente al iniciar la simulación para dar el primer paso.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export interface QuizQuestion {
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match' | 'hotspot' | 'peer-review';
|
||||
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match' | 'hotspot' | 'peer-review' | 'role-playing';
|
||||
title?: string;
|
||||
content?: string;
|
||||
url?: string;
|
||||
@@ -99,6 +99,12 @@ export interface Block {
|
||||
}[];
|
||||
imageUrl?: string;
|
||||
reviewCriteria?: string;
|
||||
// Role-playing fields
|
||||
scenario?: string;
|
||||
ai_persona?: string;
|
||||
user_role?: string;
|
||||
objectives?: string;
|
||||
initial_message?: string;
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
@@ -110,6 +116,7 @@ export interface Lesson {
|
||||
metadata?: {
|
||||
blocks: Block[];
|
||||
};
|
||||
content_blocks?: Block[];
|
||||
is_graded: boolean;
|
||||
grading_category_id: string | null;
|
||||
max_attempts: number | null;
|
||||
@@ -641,7 +648,36 @@ export const cmsApi = {
|
||||
getLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}`),
|
||||
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' }),
|
||||
generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
async generateQuiz(lessonId: string, payload: { prompt_hint?: string, quiz_type?: string }): Promise<any> {
|
||||
return apiFetch(`/lessons/${lessonId}/generate-quiz`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
async generateRolePlay(lessonId: string, payload: { prompt_hint?: string }): Promise<{
|
||||
title: string;
|
||||
scenario: string;
|
||||
ai_persona: string;
|
||||
user_role: string;
|
||||
objectives: string;
|
||||
initial_message: string;
|
||||
}> {
|
||||
return apiFetch(`/lessons/${lessonId}/generate-role-play`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
async generateHotspots(lessonId: string, payload: { image_url: string, prompt_hint?: string }): Promise<{
|
||||
label: string;
|
||||
description: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}[]> {
|
||||
return apiFetch(`/lessons/${lessonId}/generate-hotspots`, {
|
||||
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 }) }),
|
||||
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
|
||||
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),
|
||||
|
||||
Reference in New Issue
Block a user