Generation Page (complete rebuild): - Full production-parity exam generation wizard with 4 IELTS modules - Reading: AI passage gen, 5 exercise types (MCQ, Fill, Write, T/F, Match) - Listening: 4 section types, AI context gen, TTS audio gen (ElevenLabs) - Writing: Task 1/2, AI instruction gen, word limits, marks - Speaking: 3 parts, AI script gen, avatar video gen (7 avatars) - Per-module config: timer, CEFR difficulty, access, approval, rubrics - Exam submission workflow (draft/published) Exam Structures: - New encoach.exam.structure model + CRUD controller - ExamStructuresPage wired to real API AI Module (encoach_ai): - OpenAI service, ElevenLabs TTS, AWS Polly, ELAI avatars - AI settings model with Odoo config parameters - 7 generation endpoints (passage, exercises, instructions, scripts, context) Vector Module (encoach_vector): - pgvector integration for RAG-based content search - Embedding service with sentence-transformers Exam Session Fixes: - Fixed ExamSession.tsx field mapping (question_type→type, exam_title→title) - Fixed submit payload to include attempt_id and answers - Fixed normalizeType to handle null/undefined Tested: 12/12 API tests passed, browser-verified with real OpenAI calls Made-with: Cursor
140 lines
4.8 KiB
TypeScript
140 lines
4.8 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { useLocation } from "react-router-dom";
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Sparkles, Send, Loader2 } from "lucide-react";
|
|
import { coachingService } from "@/services/coaching.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
const quickActions = [
|
|
"Platform health summary",
|
|
"Show dropout risks",
|
|
"Compare course performance",
|
|
"Suggest batch improvements",
|
|
];
|
|
|
|
export default function AiAssistantDrawer() {
|
|
const [open, setOpen] = useState(false);
|
|
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const location = useLocation();
|
|
const { toast } = useToast();
|
|
|
|
const chatMutation = useMutation({
|
|
mutationFn: (message: string) =>
|
|
coachingService.chat({ message, context: { page: location.pathname } }),
|
|
onSuccess: (data) => {
|
|
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
|
|
},
|
|
onError: (err: Error) => {
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Could not get a reply",
|
|
description: err.message || "Something went wrong. Try again.",
|
|
});
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "ai",
|
|
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
|
|
},
|
|
]);
|
|
},
|
|
});
|
|
|
|
const handleSend = (text: string) => {
|
|
if (!text.trim() || chatMutation.isPending) return;
|
|
const userMsg = text.trim();
|
|
setMessages((prev) => [...prev, { role: "user", text: userMsg }]);
|
|
setInput("");
|
|
chatMutation.mutate(userMsg);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
|
aria-label="AI Assistant"
|
|
>
|
|
<Sparkles className="h-5 w-5" />
|
|
</button>
|
|
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
|
<SheetContent className="w-[400px] sm:w-[440px] flex flex-col">
|
|
<SheetHeader>
|
|
<SheetTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-primary" />
|
|
EnCoach AI Assistant
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<div className="flex flex-wrap gap-2 mt-4">
|
|
{quickActions.map((action) => (
|
|
<Button
|
|
key={action}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs"
|
|
disabled={chatMutation.isPending}
|
|
onClick={() => handleSend(action)}
|
|
>
|
|
{action}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto mt-4 space-y-3 min-h-0">
|
|
{messages.length === 0 && (
|
|
<div className="text-center text-muted-foreground text-sm py-8">
|
|
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
|
<p>Ask me anything about the platform,</p>
|
|
<p>or click a quick action above.</p>
|
|
</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<div
|
|
key={i}
|
|
className={`rounded-lg p-3 text-sm ${
|
|
msg.role === "user"
|
|
? "bg-primary text-primary-foreground ml-8"
|
|
: "bg-muted mr-8"
|
|
}`}
|
|
>
|
|
{msg.role === "ai" && (
|
|
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
|
|
)}
|
|
{msg.text}
|
|
</div>
|
|
))}
|
|
{chatMutation.isPending && (
|
|
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
<span className="text-muted-foreground">Thinking...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-3 border-t mt-auto">
|
|
<Input
|
|
placeholder="Ask anything..."
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
onClick={() => handleSend(input)}
|
|
disabled={!input.trim() || chatMutation.isPending}
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|