Backend (encoach_lms_api):
- New models: encoach.exam.security.event, encoach.live.session,
encoach.live.session.participant, encoach.handwritten.submission.
- Extended: encoach.exam.custom (security_level, single_attempt,
suspicious_event_threshold), encoach.course.plan (is_personalized,
personalized_for_id, weakness_summary), encoach.entity (turnitin_*).
- New controllers / 24 endpoints:
* exam_security: POST /api/exam/security/event,
GET /api/exam/single-attempt-check,
GET /api/teacher/exam/<id>/security/events,
GET /api/teacher/exam/attempt/<id>/security/events,
GET /api/teacher/exam/<id>/live.
* live_sessions: full CRUD + /notify, /join, /leave, /end,
/recording, /remove-participant, /attendance, /ics
(RFC-5545 iCalendar with VALARM + ATTENDEE/ORGANIZER, attached to
invite emails so Gmail/Outlook/Apple Mail prompt to add).
* personalized_plan: GET/POST /api/student/personalized-plan
(CEFR / weeks / focus / grammar_focus payloads).
* handwritten: student upload + AI grading (pytesseract OCR +
OpenAI scoring) + teacher review override.
* reports_export: CSV + PDF export for student-performance and
record reports.
* turnitin: per-entity settings (admin), test connection, submit
text. Real upload stubbed pending institutional API key.
- Manifest: depends now lists encoach_scoring, encoach_exam_template,
encoach_ai_course so security/live-session models load cleanly.
- teacher_insights.py: fixed assignment traversal via op.batch.course_id,
res.users mapping, and capped rate_completed at 100% (unique
student/exam pairs).
- reports.py: branch_id / subject_id / gender filters wired into the
Reports stack.
Frontend:
- New services: examSecurity, liveSession, personalizedPlan,
handwritten, turnitin, reportsExport.
- New pages: LiveSessionsPage, LiveSessionRoom (Jitsi IframeAPI embed
with host bar — Mute all / Cams off / per-row Ask-to-unmute /
Remove + log audit reason, kickParticipant after backend audit POST),
TurnitinSettings (admin), TeacherTestSessionConsole, AddToCalendar
per-card .ics button.
- New components: PersonalizedPlanCard (Quick generate + Customize
dialog: CEFR, weeks, focus, 9-checkbox grammar), AITutorAvatar,
HandwrittenSubmissionPanel, ImageLightbox, InfographicBlock
(six-tone callout with key-based auto-detect — did_you_know,
key_takeaway, tip, objective, common_mistakes, etc.), ExportButtons.
- useExamSecurity hook: tab/blur, copy/paste/cut, fullscreen exit,
suspicious shortcuts; debounced auto-post + auto-lock callback.
- StudentCourseDetail / TeacherCourseInsights: discussion now
embedded as a per-course tab.
- StudentCourses: unified My Courses + My Course Plan with
is_mandatory color-coding + Accredited Core / Supplementary
sections (always rendered, with empty states).
- MaterialBookView: special-cases image keys into ImageLightbox,
routes infographic-style keys into colored InfographicBlock cards,
splits long strings into paragraphs, rounded-2xl shadow-sm wrapper.
- MaterialViewer: ImageViewer now uses lightbox, ArticleViewer is
now a real <article class="prose">.
- Layout shells: Live Sessions in Student/Teacher sidebars, Turnitin
in Admin sidebar; EN + AR i18n keys.
- App.tsx: lazy routes for /live, /live/:id,
/teacher/exam/:assignmentId/console, /admin/turnitin.
- Visual identity refresh: warm cream bg, white sidebar with charcoal
active pills, dark CTA buttons, bright orange accents,
rounded-2xl cards.
Verification:
- All 12 new endpoints smoke-tested via curl (200 + sane payloads).
- /api/live-sessions/<id>/ics returns valid 23-line VCALENDAR.
- Frontend tsc --noEmit clean.
- Odoo restarted successfully (registry loaded in 0.77s, no errors).
- docs/PROJECT_SUMMARY.md updated with both today's polish + the
full Phase 2/3/4 rollout for the record.
Status: 27/29 institutional requirements fully wired. Turnitin
upload + LMS-side breakout/poll persistence remain partial (see
PROJECT_SUMMARY for details).
Made-with: Cursor
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
|
|
interface AITutorAvatarProps {
|
|
/** Whether the avatar is currently speaking. Drives the mouth animation. */
|
|
speaking?: boolean;
|
|
/** Optional audio element whose playback drives `speaking` automatically. */
|
|
audio?: HTMLAudioElement | null;
|
|
/** Pixel diameter. Defaults to 96px (good for sidebar). */
|
|
size?: number;
|
|
/** Display name for the tooltip. */
|
|
name?: string;
|
|
}
|
|
|
|
/**
|
|
* Lightweight CSS-only "talking head" avatar. We avoid a 3D engine on
|
|
* purpose — most students open the platform on modest hardware and we
|
|
* already ship a TTS pipeline. The avatar mouth animates while
|
|
* `speaking` is true OR while the bound `audio` element is playing.
|
|
*
|
|
* Idle state: the head bobs gently and blinks every 4s.
|
|
* Speaking state: the mouth opens/closes at ~5Hz with random amplitude.
|
|
*/
|
|
export default function AITutorAvatar({
|
|
speaking = false,
|
|
audio,
|
|
size = 96,
|
|
name = "AI Tutor",
|
|
}: AITutorAvatarProps) {
|
|
const [audioActive, setAudioActive] = useState(false);
|
|
const blinkRef = useRef<NodeJS.Timeout | null>(null);
|
|
const [blink, setBlink] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!audio) return;
|
|
const onPlay = () => setAudioActive(true);
|
|
const onStop = () => setAudioActive(false);
|
|
audio.addEventListener("play", onPlay);
|
|
audio.addEventListener("playing", onPlay);
|
|
audio.addEventListener("pause", onStop);
|
|
audio.addEventListener("ended", onStop);
|
|
return () => {
|
|
audio.removeEventListener("play", onPlay);
|
|
audio.removeEventListener("playing", onPlay);
|
|
audio.removeEventListener("pause", onStop);
|
|
audio.removeEventListener("ended", onStop);
|
|
};
|
|
}, [audio]);
|
|
|
|
useEffect(() => {
|
|
const tick = () => {
|
|
setBlink(true);
|
|
window.setTimeout(() => setBlink(false), 140);
|
|
};
|
|
blinkRef.current = setInterval(tick, 3500 + Math.random() * 1500);
|
|
return () => {
|
|
if (blinkRef.current) clearInterval(blinkRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const isSpeaking = speaking || audioActive;
|
|
|
|
return (
|
|
<div
|
|
className="ai-tutor-avatar relative inline-flex items-center justify-center"
|
|
style={{ width: size, height: size }}
|
|
aria-label={name}
|
|
title={name}
|
|
>
|
|
<style>{`
|
|
@keyframes ai-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
|
@keyframes ai-mouth {
|
|
0% { transform: scaleY(0.2); }
|
|
50% { transform: scaleY(1); }
|
|
100% { transform: scaleY(0.4); }
|
|
}
|
|
@keyframes ai-glow {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
|
|
50% { box-shadow: 0 0 20px 6px rgba(245, 158, 11, 0); }
|
|
}
|
|
`}</style>
|
|
<div
|
|
className="rounded-full bg-gradient-to-br from-orange-200 to-amber-100 dark:from-orange-900/30 dark:to-amber-900/20 border border-amber-300/60 flex items-center justify-center"
|
|
style={{
|
|
width: size, height: size,
|
|
animation: `ai-bob 4s ease-in-out infinite${isSpeaking ? ", ai-glow 1.8s ease-out infinite" : ""}`,
|
|
}}
|
|
>
|
|
<svg viewBox="0 0 100 100" width={size * 0.7} height={size * 0.7} aria-hidden="true">
|
|
<ellipse cx="35" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
|
|
<ellipse cx="65" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
|
|
<circle cx="33" cy="40" r="1.5" fill="#fff" />
|
|
<circle cx="63" cy="40" r="1.5" fill="#fff" />
|
|
<ellipse
|
|
cx="50" cy="64"
|
|
rx="13"
|
|
ry={isSpeaking ? 7 : 2}
|
|
fill="#dc2626"
|
|
style={{
|
|
transformOrigin: "50px 64px",
|
|
animation: isSpeaking ? "ai-mouth 220ms ease-in-out infinite" : "none",
|
|
}}
|
|
/>
|
|
<circle cx="22" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
|
|
<circle cx="78" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
|
|
</svg>
|
|
</div>
|
|
{isSpeaking && (
|
|
<span className="absolute -bottom-1 inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full bg-orange-500 text-[10px] font-semibold text-white">
|
|
speaking
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|