feat: Implement AI-generated Role Playing and Hotspot interactive content blocks with UI and service integration.

This commit is contained in:
2026-03-09 13:46:47 -03:00
parent c292efdc28
commit bc5b240984
20 changed files with 947 additions and 42 deletions
@@ -18,6 +18,7 @@ import HotspotPlayer from "@/components/blocks/HotspotPlayer";
import MemoryPlayer from "@/components/blocks/MemoryPlayer";
import DocumentPlayer from "@/components/blocks/DocumentPlayer";
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
import RolePlayingPlayer from "@/components/blocks/RolePlayingPlayer";
import PeerReviewPlayer from "@/components/blocks/PeerReviewPlayer";
import InteractiveTranscript from "@/components/InteractiveTranscript";
import AITutor from "@/components/AITutor";
@@ -449,6 +450,19 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'role-playing':
return (
<RolePlayingPlayer
id={block.id}
lessonId={params.lessonId}
title={block.title}
scenario={block.scenario}
ai_persona={block.ai_persona}
user_role={block.user_role}
objectives={block.objectives}
initial_message={block.initial_message}
/>
);
case 'peer-review':
return (
<PeerReviewPlayer
@@ -37,7 +37,7 @@ export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
aria-labelledby={`ann-title-${announcement.id}`}
className={`relative p-6 rounded-2xl border transition-all duration-300 ${announcement.is_pinned
? 'bg-primary-500/10 border-primary-500/30'
: 'bg-white/5 border-white/10 hover:border-white/20'
: 'bg-white dark:bg-white/5 border-slate-100 dark:border-white/10 hover:border-slate-200 dark:hover:border-white/20'
}`}>
{announcement.is_pinned && (
<div className="absolute top-4 right-4 text-primary-400">
@@ -55,8 +55,8 @@ export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
)}
</div>
<div>
<h4 className="font-semibold text-white">{announcement.author_name}</h4>
<p className="text-sm text-gray-400">
<h4 className="font-semibold text-slate-900 dark:text-white">{announcement.author_name}</h4>
<p className="text-sm text-slate-500 dark:text-gray-400">
{formatDistanceToNow(new Date(announcement.created_at), { addSuffix: true, locale: es })}
</p>
</div>
@@ -77,8 +77,8 @@ export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
)}
</div>
<h3 id={`ann-title-${announcement.id}`} className="text-xl font-bold text-white mb-2">{announcement.title}</h3>
<div className="text-gray-300 whitespace-pre-wrap leading-relaxed">
<h3 id={`ann-title-${announcement.id}`} className="text-xl font-bold text-slate-900 dark:text-white mb-2">{announcement.title}</h3>
<div className="text-slate-600 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
{announcement.content}
</div>
</article>
@@ -44,8 +44,8 @@ export const AnnouncementsList: React.FC<AnnouncementsListProps> = ({ courseId,
<Megaphone className="w-6 h-6" />
</div>
<div>
<h2 className="text-2xl font-bold text-white">Anuncios del Curso</h2>
<p className="text-gray-400 italic">Mantente al día con las últimas noticias</p>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Anuncios del Curso</h2>
<p className="text-slate-500 dark:text-gray-400 italic">Mantente al día con las últimas noticias</p>
</div>
</div>
@@ -57,7 +57,7 @@ export const AnnouncementsList: React.FC<AnnouncementsListProps> = ({ courseId,
placeholder="Buscar anuncios..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors w-full sm:w-64"
className="pl-10 pr-4 py-2 bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-xl text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors w-full sm:w-64"
/>
</div>
{isInstructor && (
@@ -89,12 +89,12 @@ export const AnnouncementsList: React.FC<AnnouncementsListProps> = ({ courseId,
))}
</div>
) : (
<div className="bg-white/5 border border-white/10 rounded-2xl p-12 text-center">
<div className="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-gray-500" />
<div className="bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-2xl p-12 text-center">
<div className="w-16 h-16 bg-slate-100 dark:bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4">
<Megaphone className="w-8 h-8 text-slate-400 dark:text-gray-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">No hay anuncios</h3>
<p className="text-gray-400 max-w-md mx-auto">
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">No hay anuncios</h3>
<p className="text-slate-500 dark:text-gray-400 max-w-md mx-auto">
{searchTerm
? `No se encontraron anuncios que coincidan con "${searchTerm}"`
: "Aún no se han publicado anuncios en este curso."}
@@ -0,0 +1,185 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { lmsApi } from "@/lib/api";
import { Send, User, Bot, Sparkles, MessageCircle, RotateCcw } from "lucide-react";
import ReactMarkdown from "react-markdown";
interface Message {
role: "user" | "assistant";
content: string;
}
interface RolePlayingPlayerProps {
id: string;
lessonId: string;
title?: string;
scenario?: string;
ai_persona?: string;
user_role?: string;
objectives?: string;
initial_message?: string;
}
export default function RolePlayingPlayer({
id,
lessonId,
title,
scenario = "",
ai_persona = "",
user_role = "",
objectives = "",
initial_message = "Hola, ¿cómo puedo ayudarte hoy?"
}: RolePlayingPlayerProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
if (messages.length === 0 && initial_message) {
setMessages([{ role: "assistant", content: initial_message }]);
}
}, [initial_message, messages.length]);
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSend = async () => {
if (!input.trim() || loading) return;
const userMessage = input.trim();
setInput("");
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
setLoading(true);
try {
const res = await lmsApi.chatRolePlay(lessonId, id, userMessage, sessionId || undefined);
setMessages(prev => [...prev, { role: "assistant", content: res.response }]);
if (!sessionId) setSessionId(res.session_id);
} catch (error) {
console.error("Error in role-play chat:", error);
setMessages(prev => [...prev, { role: "assistant", content: "Lo siento, hubo un error procesando la simulación. Por favor, intenta de nuevo." }]);
} finally {
setLoading(false);
}
};
const handleReset = () => {
setMessages([{ role: "assistant", content: initial_message }]);
setSessionId(null);
};
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white">
<MessageCircle size={18} />
</div>
<h3 className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">
{title || "Simulación de Rol"}
</h3>
</div>
</div>
<div className="glass border-black/5 dark:border-white/5 rounded-[2.5rem] overflow-hidden flex flex-col h-[600px] shadow-2xl bg-white/50 dark:bg-black/50 backdrop-blur-xl">
{/* Scenario Header */}
<div className="p-6 bg-gradient-to-r from-indigo-500/10 to-blue-500/10 border-b border-black/5 dark:border-white/5">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center text-white shrink-0 shadow-lg shadow-indigo-500/20">
<Sparkles size={20} />
</div>
<div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400">Escenario Activo</p>
<p className="text-sm font-bold text-gray-800 dark:text-gray-200 leading-tight">{scenario}</p>
</div>
</div>
</div>
{/* Information Bar */}
<div className="px-6 py-4 bg-black/5 dark:bg-white/5 flex flex-wrap gap-4 text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400 border-b border-black/5 dark:border-white/5">
<div className="flex items-center gap-2">
<User size={12} className="text-indigo-500" />
<span>Tu Rol: <span className="text-gray-900 dark:text-white">{user_role}</span></span>
</div>
<div className="flex items-center gap-2">
<Bot size={12} className="text-blue-500" />
<span>IA: <span className="text-gray-900 dark:text-white">{ai_persona}</span></span>
</div>
</div>
{/* Chat Area */}
<div className="flex-1 overflow-y-auto p-6 space-y-6 scrollbar-thin scrollbar-thumb-indigo-500/20">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"} animate-in fade-in slide-in-from-bottom-2 duration-300`}>
<div className={`max-w-[85%] p-4 rounded-2xl ${msg.role === "user"
? "bg-indigo-600 text-white shadow-lg shadow-indigo-500/20"
: "bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 text-gray-800 dark:text-gray-200"
}`}>
<div className="text-sm prose dark:prose-invert max-w-none">
<ReactMarkdown>{msg.content}</ReactMarkdown>
</div>
</div>
</div>
))}
{loading && (
<div className="flex justify-start animate-pulse">
<div className="bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 p-4 rounded-2xl">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500/50"></div>
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500/50"></div>
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500/50"></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-6 bg-black/5 dark:bg-white/5 border-t border-black/5 dark:border-white/5 space-y-4">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Escribe tu mensaje en la simulación..."
className="flex-1 bg-white dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-2xl px-6 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all text-gray-900 dark:text-white shadow-inner"
/>
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="p-4 bg-indigo-600 text-white rounded-2xl hover:bg-indigo-700 transition-all disabled:opacity-50 disabled:grayscale shadow-lg shadow-indigo-500/20 active:scale-95"
>
<Send size={20} />
</button>
<button
onClick={handleReset}
title="Reiniciar Simulación"
className="p-4 bg-black/10 dark:bg-white/10 text-gray-600 dark:text-gray-400 rounded-2xl hover:bg-black/20 dark:hover:bg-white/20 transition-all active:scale-95"
>
<RotateCcw size={20} />
</button>
</div>
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2 text-[8px] font-black uppercase tracking-widest text-gray-400">
<Sparkles size={10} className="text-indigo-500" />
<span>IA Generativa Avanzada</span>
</div>
<div className="text-[8px] font-black uppercase tracking-widest text-gray-400 truncate max-w-[200px]">
Objetivo: {objectives.slice(0, 40)}{objectives.length > 40 ? "..." : ""}
</div>
</div>
</div>
</div>
</div>
);
}
+14 -1
View File
@@ -84,7 +84,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing';
title: string;
content?: string;
url?: string;
@@ -111,6 +111,12 @@ export interface Block {
radius: number;
label: string;
}[];
// Role-playing fields
scenario?: string;
ai_persona?: string;
user_role?: string;
objectives?: string;
initial_message?: string;
metadata?: any;
}
@@ -139,6 +145,7 @@ export interface Lesson {
metadata?: {
blocks: Block[];
};
content_blocks?: Block[];
is_graded: boolean;
grading_category_id: string | null;
max_attempts: number | null;
@@ -621,6 +628,12 @@ export const lmsApi = {
body: JSON.stringify({ message, session_id: sessionId })
});
},
async chatRolePlay(lessonId: string, blockId: string, message: string, sessionId?: string): Promise<{ response: string, session_id: string }> {
return apiFetch(`/lessons/${lessonId}/chat-role-play`, {
method: 'POST',
body: JSON.stringify({ message, block_id: blockId, session_id: sessionId })
});
},
async getLessonFeedback(lessonId: string): Promise<{ response: string, session_id: string }> {
return apiFetch(`/lessons/${lessonId}/feedback`);
},
@@ -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>
);
}
+38 -2
View File
@@ -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' }),