feat(generation): rebuild Generation Page with full AI workflows
- Rebuild GenerationPage.tsx from static placeholder to production-parity exam generation wizard with all 4 IELTS modules (Reading, Listening, Writing, Speaking) plus Level and Industry - Add per-module config: timer, CEFR difficulty tags, access type, entities, approval workflow, rubric, grading system, shuffling - Reading: AI passage generation, 5 exercise types (MCQ, Fill Blanks, Write Blanks, True/False, Paragraph Match), categories/types - Listening: 4 section types, AI context generation, TTS audio generation - Writing: Task 1/2, AI instruction generation, word limits, marks - Speaking: 3 parts, AI script generation, avatar video generation with 7 avatar options - Wire ExamStructuresPage to real CRUD API (list/create/delete) - Add backend exam_structure model and controller (/api/exam-structures) - Enhance ai_controller with 5 specialized generation handlers (passage, exercises, writing instructions, speaking script, listening context) - Add POST /api/exam/generation/submit for exam creation workflow - Fix media.service avatar video endpoint alignment - All 12 API tests passed, browser-verified with real OpenAI calls Made-with: Cursor
This commit is contained in:
@@ -1,24 +1,67 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Search, Plus, Layers, Trash2 } from "lucide-react";
|
import { Search, Plus, Layers, Trash2, Loader2 } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import { examsService } from "@/services/exams.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { ExamStructure } from "@/types";
|
||||||
|
|
||||||
const structures = [
|
const MODULE_OPTIONS = ["Reading", "Listening", "Writing", "Speaking"];
|
||||||
{ id: 1, name: "Standard IELTS Academic", entity: "Global", industry: "General", modules: ["Reading", "Listening", "Writing", "Speaking"] },
|
|
||||||
{ id: 2, name: "Corporate English Assessment", entity: "Acme Corp", industry: "Technology", modules: ["Reading", "Writing", "Speaking"] },
|
|
||||||
{ id: 3, name: "Hospitality English Test", entity: "EduGroup", industry: "Hospitality", modules: ["Listening", "Speaking"] },
|
|
||||||
{ id: 4, name: "Medical English Proficiency", entity: "Global", industry: "Healthcare", modules: ["Reading", "Listening", "Writing", "Speaking"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ExamStructuresPage() {
|
export default function ExamStructuresPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [entityFilter, setEntityFilter] = useState("all");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newIndustry, setNewIndustry] = useState("");
|
||||||
|
const [newModules, setNewModules] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["exam-structures", entityFilter],
|
||||||
|
queryFn: () => examsService.listStructures(entityFilter !== "all" ? { entity_id: Number(entityFilter) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const structures: ExamStructure[] = data?.items ?? [];
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (structureData: Partial<ExamStructure>) => examsService.createStructure(structureData),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setNewName("");
|
||||||
|
setNewIndustry("");
|
||||||
|
setNewModules([]);
|
||||||
|
toast({ title: "Structure created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => examsService.deleteStructure(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
|
||||||
|
toast({ title: "Structure deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = structures.filter((s) => {
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return s.name?.toLowerCase().includes(q) || (s as Record<string, unknown>).industry?.toString().toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -28,23 +71,53 @@ export default function ExamStructuresPage() {
|
|||||||
<p className="text-muted-foreground">Define exam structure templates by entity and industry.</p>
|
<p className="text-muted-foreground">Define exam structure templates by entity and industry.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<AiCreationAssistant type="exam" />
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Structure</Button>
|
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Structure</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Exam Structure</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Create Exam Structure</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><Label>Structure Name</Label><Input placeholder="e.g. Corporate Writing Test" /></div>
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Label>Structure Name</Label>
|
||||||
<div className="space-y-2"><Label>Entity</Label><Select><SelectTrigger><SelectValue placeholder="Entity" /></SelectTrigger><SelectContent><SelectItem value="global">Global</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent></Select></div>
|
<Input placeholder="e.g. Corporate Writing Test" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
<div className="space-y-2"><Label>Industry</Label><Select><SelectTrigger><SelectValue placeholder="Industry" /></SelectTrigger><SelectContent><SelectItem value="general">General</SelectItem><SelectItem value="tech">Technology</SelectItem><SelectItem value="health">Healthcare</SelectItem></SelectContent></Select></div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full">Create</Button>
|
<div className="space-y-2">
|
||||||
|
<Label>Industry</Label>
|
||||||
|
<Select value={newIndustry} onValueChange={setNewIndustry}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select Industry" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="General">General</SelectItem>
|
||||||
|
<SelectItem value="Technology">Technology</SelectItem>
|
||||||
|
<SelectItem value="Healthcare">Healthcare</SelectItem>
|
||||||
|
<SelectItem value="Hospitality">Hospitality</SelectItem>
|
||||||
|
<SelectItem value="Education">Education</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Modules</Label>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{MODULE_OPTIONS.map((m) => (
|
||||||
|
<div key={m} className="flex items-center gap-2">
|
||||||
|
<Checkbox id={`new-mod-${m}`} checked={newModules.includes(m.toLowerCase())}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setNewModules((prev) => checked ? [...prev, m.toLowerCase()] : prev.filter((x) => x !== m.toLowerCase()));
|
||||||
|
}} />
|
||||||
|
<Label htmlFor={`new-mod-${m}`} className="text-sm">{m}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" disabled={!newName || createMut.isPending}
|
||||||
|
onClick={() => createMut.mutate({ name: newName, industry: newIndustry, modules: newModules } as unknown as Partial<ExamStructure>)}>
|
||||||
|
{createMut.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<Button size="sm" variant="destructive" disabled><Trash2 className="h-4 w-4 mr-1" /> Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,27 +128,56 @@ export default function ExamStructuresPage() {
|
|||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Search structures..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search structures..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<Select><SelectTrigger className="w-[140px]"><SelectValue placeholder="Entity" /></SelectTrigger><SelectContent><SelectItem value="all">All</SelectItem></SelectContent></Select>
|
<Select value={entityFilter} onValueChange={setEntityFilter}>
|
||||||
<Select><SelectTrigger className="w-[140px]"><SelectValue placeholder="Industry" /></SelectTrigger><SelectContent><SelectItem value="all">All</SelectItem></SelectContent></Select>
|
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="all">All Entities</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardContent className="p-4 text-sm text-destructive">Failed to load structures. The backend endpoint may not be available yet.</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && filtered.length === 0 && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No exam structures found. Create one to get started.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{structures.map((s) => (
|
{filtered.map((s) => (
|
||||||
<Card key={s.id} className="border-0 shadow-sm">
|
<Card key={s.id} className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-primary" />{s.name}
|
<Layers className="h-4 w-4 text-primary" />{s.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => deleteMut.mutate(s.id)} disabled={deleteMut.isPending}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
|
||||||
<span>Entity: <span className="text-foreground font-medium">{s.entity}</span></span>
|
{(s as Record<string, unknown>).entity_name && <span>Entity: <span className="text-foreground font-medium">{String((s as Record<string, unknown>).entity_name)}</span></span>}
|
||||||
<span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>
|
{(s as Record<string, unknown>).industry && <span>Industry: <span className="text-foreground font-medium">{String((s as Record<string, unknown>).industry)}</span></span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{(Array.isArray((s as Record<string, unknown>).modules) ? (s as Record<string, unknown>).modules as string[] : []).map((m) => (
|
||||||
|
<Badge key={m} variant="outline" className="capitalize">{m}</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 flex-wrap">{s.modules.map(m => <Badge key={m} variant="outline">{m}</Badge>)}</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,21 +2,144 @@ import { api } from "@/lib/api-client";
|
|||||||
import type { ExamModule } from "@/types";
|
import type { ExamModule } from "@/types";
|
||||||
|
|
||||||
export interface GenerationParams {
|
export interface GenerationParams {
|
||||||
title: string;
|
title?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
entity_id?: number;
|
entity_id?: number;
|
||||||
subject_id?: number;
|
subject_id?: number;
|
||||||
topic_id?: number;
|
topic_id?: number;
|
||||||
difficulty?: string;
|
difficulty?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
|
topic?: string;
|
||||||
|
passage_length?: string;
|
||||||
|
task_type?: string;
|
||||||
|
part?: string;
|
||||||
|
question_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PassageGenerationParams {
|
||||||
|
topic?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
word_count?: number;
|
||||||
|
category?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExerciseConfig {
|
||||||
|
passage_index: number;
|
||||||
|
exercise_types: string[];
|
||||||
|
count_per_type?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleConfig {
|
||||||
|
module: ExamModule;
|
||||||
|
timer_minutes: number;
|
||||||
|
difficulty: string[];
|
||||||
|
access_type: string;
|
||||||
|
entities?: number[];
|
||||||
|
approval_workflow?: string;
|
||||||
|
rubric_criteria_group?: string;
|
||||||
|
rubric_criteria?: string;
|
||||||
|
grading_system?: string;
|
||||||
|
shuffling_enabled: boolean;
|
||||||
|
passages?: PassageConfig[];
|
||||||
|
sections?: SectionConfig[];
|
||||||
|
tasks?: TaskConfig[];
|
||||||
|
speaking_parts?: SpeakingPartConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PassageConfig {
|
||||||
|
index: number;
|
||||||
|
category?: string;
|
||||||
|
type?: string;
|
||||||
|
divider?: string;
|
||||||
|
text?: string;
|
||||||
|
exercise_types: string[];
|
||||||
|
exercises?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionConfig {
|
||||||
|
type: string;
|
||||||
|
category?: string;
|
||||||
|
divider?: string;
|
||||||
|
audio_context?: string;
|
||||||
|
audio_url?: string;
|
||||||
|
exercise_types: string[];
|
||||||
|
exercises?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskConfig {
|
||||||
|
index: number;
|
||||||
|
category?: string;
|
||||||
|
type?: string;
|
||||||
|
divider?: string;
|
||||||
|
instructions?: string;
|
||||||
|
word_limit: number;
|
||||||
|
marks: number;
|
||||||
|
images?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakingPartConfig {
|
||||||
|
type: string;
|
||||||
|
category?: string;
|
||||||
|
divider?: string;
|
||||||
|
script?: string;
|
||||||
|
video_url?: string;
|
||||||
|
avatar_id?: string;
|
||||||
|
marks: number;
|
||||||
|
topics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generationService = {
|
export const generationService = {
|
||||||
async generate(module: ExamModule, params: GenerationParams): Promise<{ exam_id: number; exercises: unknown[] }> {
|
generate(module: ExamModule, params: GenerationParams): Promise<{ questions: unknown[] }> {
|
||||||
return api.post(`/exam/${module}/generate`, params);
|
return api.post(`/exam/${module}/generate`, params);
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateFromScratch(module: ExamModule, params: GenerationParams): Promise<{ exam_id: number; exercises: unknown[] }> {
|
saveGenerated(module: ExamModule, data: unknown): Promise<{ saved: number; module: string }> {
|
||||||
return api.post(`/exam/${module}/generate/scratch`, params);
|
const payload = Array.isArray(data) ? { questions: data } : data;
|
||||||
|
return api.post(`/exam/${module}/generate/save`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
generatePassage(params: PassageGenerationParams): Promise<{ passage: string; title?: string }> {
|
||||||
|
return api.post("/exam/reading/generate", {
|
||||||
|
...params,
|
||||||
|
generate_passage: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateExercises(module: ExamModule, config: ExerciseConfig & { passage_text?: string }): Promise<{ questions: unknown[] }> {
|
||||||
|
return api.post(`/exam/${module}/generate`, {
|
||||||
|
...config,
|
||||||
|
generate_exercises: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateWritingInstructions(params: { topic?: string; difficulty?: string; task_type?: string }): Promise<{ instructions: string }> {
|
||||||
|
return api.post("/exam/writing/generate", {
|
||||||
|
...params,
|
||||||
|
generate_instructions: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateSpeakingScript(params: { topics?: string[]; difficulty?: string; part?: string }): Promise<{ script: string }> {
|
||||||
|
return api.post("/exam/speaking/generate", {
|
||||||
|
...params,
|
||||||
|
generate_script: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateListeningContext(params: { topic?: string; section_type?: string }): Promise<{ context: string }> {
|
||||||
|
return api.post("/exam/listening/generate", {
|
||||||
|
...params,
|
||||||
|
generate_context: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
submitExam(data: {
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
modules: Record<string, unknown>;
|
||||||
|
skip_approval?: boolean;
|
||||||
|
}): Promise<{ exam_id: number; status: string; template_id?: number }> {
|
||||||
|
return api.post("/exam/generation/submit", data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export interface Avatar {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
voice?: string;
|
||||||
|
gender?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const mediaService = {
|
export const mediaService = {
|
||||||
async generateListeningAudio(data: { text: string; voice_id?: string }): Promise<{ audio_url: string }> {
|
generateListeningAudio(data: { text: string; voice_id?: string }): Promise<{ audio_url: string; audio_base64?: string; content_type?: string }> {
|
||||||
return api.post("/exam/listening/media", data);
|
return api.post("/exam/listening/media", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateSpeakingVideo(data: { text: string; avatar_id?: number }): Promise<{ video_url: string; job_id: string }> {
|
generateSpeakingAudio(data: { text: string; voice_id?: string }): Promise<{ audio_url: string; audio_base64?: string }> {
|
||||||
return api.post("/exam/speaking/media", data);
|
return api.post("/exam/speaking/media", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAvatars(): Promise<{ id: number; name: string; thumbnail: string; voice: string }[]> {
|
getAvatars(): Promise<Avatar[]> {
|
||||||
return api.get("/exam/avatars");
|
return api.get("/exam/avatars");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createAvatarVideo(data: { script: string; avatar_id: string; title?: string }): Promise<{ video_id: string; status: string }> {
|
||||||
|
return api.post("/exam/avatar/video", data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getVideoStatus(videoId: string): Promise<{ status: string; video_url?: string; progress?: number }> {
|
||||||
|
return api.get(`/exam/avatar/video/${videoId}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user