Files
encoach_frontend_new_v3/frontend/src/pages/teacher/AiWorkbench.tsx
Yamen Ahmad 82ec3debcc feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
2026-04-12 14:26:39 +04:00

219 lines
9.6 KiB
TypeScript

import { useState, useMemo } from "react";
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, Sparkles, ChevronRight, CheckCircle2, BookOpen } from "lucide-react";
import { useGenerateOutline, useGenerateChapterContent, usePublishWorkbench } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { WorkbenchGeneratedOutline, WorkbenchGeneratedChapter } from "@/types/courseware";
function sanitizeHtml(dirty: string): string {
const div = document.createElement("div");
div.textContent = "";
const parser = new DOMParser();
const doc = parser.parseFromString(dirty, "text/html");
const scripts = doc.querySelectorAll("script, iframe, object, embed, link[rel=import]");
scripts.forEach((el) => el.remove());
doc.querySelectorAll("*").forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (attr.name.startsWith("on") || attr.value.trim().toLowerCase().startsWith("javascript:")) {
el.removeAttribute(attr.name);
}
}
});
return doc.body.innerHTML;
}
type Complexity = "beginner" | "intermediate" | "advanced";
export default function AiWorkbench() {
const { courseId } = useParams<{ courseId: string }>();
const cid = Number(courseId);
const { toast } = useToast();
const [step, setStep] = useState(1);
const [topic, setTopic] = useState("");
const [objectives, setObjectives] = useState("");
const [complexity, setComplexity] = useState<Complexity>("intermediate");
const [outline, setOutline] = useState<WorkbenchGeneratedOutline | null>(null);
const [generatedContent, setGeneratedContent] = useState<WorkbenchGeneratedChapter | null>(null);
const generateOutline = useGenerateOutline();
const generateContent = useGenerateChapterContent();
const publishWorkbench = usePublishWorkbench();
const handleGenerateOutline = () => {
generateOutline.mutate(
{ course_id: cid, topic, objectives, complexity },
{
onSuccess: (data) => { setOutline(data); setStep(2); },
onError: () => toast({ title: "Error", description: "Failed to generate outline", variant: "destructive" }),
},
);
};
const handleGenerateContent = () => {
generateContent.mutate(
{ course_id: cid, topic, objectives, complexity },
{
onSuccess: (data) => { setGeneratedContent(data); setStep(3); },
onError: () => toast({ title: "Error", description: "Failed to generate content", variant: "destructive" }),
},
);
};
const handlePublish = () => {
publishWorkbench.mutate(
{ course_id: cid, topic, objectives, complexity, outline, content: generatedContent },
{
onSuccess: () => { toast({ title: "Published Successfully" }); setStep(4); },
onError: () => toast({ title: "Error", description: "Failed to publish", variant: "destructive" }),
},
);
};
const steps = [
{ num: 1, label: "Requirements" },
{ num: 2, label: "Review Outline" },
{ num: 3, label: "Generated Content" },
{ num: 4, label: "Published" },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">AI Workbench</h1>
<p className="text-muted-foreground">Generate course content using AI assistance.</p>
</div>
<div className="flex items-center gap-2">
{steps.map((s, i) => (
<div key={s.num} className="flex items-center">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm ${step >= s.num ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground"}`}>
{step > s.num ? <CheckCircle2 className="h-4 w-4" /> : <span className="h-5 w-5 rounded-full border flex items-center justify-center text-xs">{s.num}</span>}
{s.label}
</div>
{i < steps.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground mx-1" />}
</div>
))}
</div>
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Input Requirements</CardTitle>
<CardDescription>Describe what content you want the AI to generate.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Topic</Label>
<Input placeholder="e.g. Introduction to Calculus" value={topic} onChange={e => setTopic(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Learning Objectives</Label>
<Textarea placeholder="List the key objectives, one per line..." rows={4} value={objectives} onChange={e => setObjectives(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Complexity Level</Label>
<Select value={complexity} onValueChange={v => setComplexity(v as Complexity)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleGenerateOutline} disabled={generateOutline.isPending || !topic || !objectives}>
{generateOutline.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Sparkles className="mr-2 h-4 w-4" /> Generate Outline
</Button>
</CardContent>
</Card>
)}
{step === 2 && outline && (
<Card>
<CardHeader>
<CardTitle>Review Generated Outline</CardTitle>
<CardDescription>Review the AI-generated chapter outline before generating detailed content.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{outline.chapters.map((ch, i) => (
<div key={i} className="p-4 rounded-lg border space-y-2">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">{ch.name}</span>
<Badge variant="outline">{ch.estimated_duration_hours}h</Badge>
</div>
<p className="text-sm text-muted-foreground">{ch.description}</p>
<div className="flex flex-wrap gap-1">
{ch.learning_outcomes.map((lo, j) => (
<Badge key={j} variant="secondary" className="text-xs">{lo}</Badge>
))}
</div>
</div>
))}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
<Button onClick={handleGenerateContent} disabled={generateContent.isPending}>
{generateContent.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Content
</Button>
</div>
</CardContent>
</Card>
)}
{step === 3 && generatedContent && (
<Card>
<CardHeader>
<CardTitle>Generated Content</CardTitle>
<CardDescription>Review the detailed content before publishing.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: sanitizeHtml(generatedContent.content) }} />
{generatedContent.exercises.length > 0 && (
<div className="space-y-2">
<h3 className="font-medium">Exercises ({generatedContent.exercises.length})</h3>
{generatedContent.exercises.map((ex, i) => (
<div key={i} className="p-3 rounded border text-sm">
<p className="font-medium">{ex.question}</p>
<p className="text-muted-foreground mt-1">{ex.answer}</p>
<Badge variant="outline" className="mt-1 text-xs">{ex.type}</Badge>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(2)}>Back</Button>
<Button onClick={handlePublish} disabled={publishWorkbench.isPending}>
{publishWorkbench.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Publish
</Button>
</div>
</CardContent>
</Card>
)}
{step === 4 && (
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500 mx-auto mb-3" />
<h2 className="text-xl font-semibold">Content Published!</h2>
<p className="text-muted-foreground mt-1">The AI-generated content has been added to your course.</p>
<Button variant="outline" className="mt-4" onClick={() => { setStep(1); setOutline(null); setGeneratedContent(null); setTopic(""); setObjectives(""); }}>
Generate More
</Button>
</CardContent>
</Card>
)}
</div>
);
}