From b4b58682232372c2effc41accd63347d540d39a1 Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Sun, 26 Apr 2026 02:34:52 +0400 Subject: [PATCH] feat(platform): ship AI fallback stack and entity-scoped course planning Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform. Made-with: Cursor --- src/components/AdminLmsLayout.tsx | 66 +- src/components/AppLayout.tsx | 59 +- .../coursePlan/LibraryPickerDialog.tsx | 214 ++++++ .../coursePlan/MaterialBookView.tsx | 78 ++ src/contexts/AuthContext.tsx | 57 +- src/i18n/locales/ar.ts | 100 +++ src/i18n/locales/en.ts | 103 +++ src/lib/api-client.ts | 77 +- src/pages/EntitiesPage.tsx | 149 +++- src/pages/admin/AIPromptEditor.tsx | 12 + src/pages/admin/AIProviderSettings.tsx | 704 ++++++++++++++++++ src/pages/admin/AdminCoursePlanDetail.tsx | 578 +++++++++++--- src/pages/admin/ResourceManager.tsx | 65 +- src/pages/admin/wizards/CoursePlanWizard.tsx | 152 +++- src/pages/student/ExamSession.tsx | 17 +- src/pages/student/StudentCoursePlanDetail.tsx | 50 +- src/pages/teacher/TeacherLibrary.tsx | 4 +- src/services/aiSettings.service.ts | 82 ++ src/services/coursePlan.service.ts | 42 ++ src/services/entities.service.ts | 24 + src/services/resources.service.ts | 19 +- src/types/adaptive.ts | 18 +- src/types/coursePlan.ts | 31 +- 23 files changed, 2502 insertions(+), 199 deletions(-) create mode 100644 src/components/coursePlan/LibraryPickerDialog.tsx create mode 100644 src/components/coursePlan/MaterialBookView.tsx create mode 100644 src/pages/admin/AIProviderSettings.tsx create mode 100644 src/services/aiSettings.service.ts diff --git a/src/components/AdminLmsLayout.tsx b/src/components/AdminLmsLayout.tsx index 9f1a4bb..4c41af6 100644 --- a/src/components/AdminLmsLayout.tsx +++ b/src/components/AdminLmsLayout.tsx @@ -1,4 +1,4 @@ -import { Outlet, Link, useNavigate, useLocation } from "react-router-dom"; +import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom"; import { Suspense } from "react"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { @@ -16,6 +16,7 @@ import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer"; import AiSearchBar from "@/components/ai/AiSearchBar"; import { usePermissions } from "@/hooks/usePermissions"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, @@ -238,9 +239,16 @@ function RouteContentFallback() { // ============= Main Layout ============= export default function AdminLmsLayout() { - const { user, logout } = useAuth(); + const { user, logout, selectedEntity, setSelectedEntityId } = useAuth(); const navigate = useNavigate(); const { t } = useTranslation(); + // Hide the floating "Need help?" pill on multi-step wizard routes. + // It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks + // on the wizard's own Next/Finish footer (real bug repro: trying to + // click "Next" in the Course-plan wizard at the standard 1024×768 + // viewport hits the pill instead). The AI Assistant orb also at the + // bottom-right is preserved — it's smaller and useful in-wizard. + const isWizardRoute = !!useMatch("/admin/smart-wizard/*"); const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"; @@ -261,6 +269,44 @@ export default function AdminLmsLayout() {
+ {user?.entities?.length ? ( + + + + + +
+ {t("nav.entities")} +
+ + {user.entities.map((entity) => ( + { + setSelectedEntityId(entity.id); + // Force page data to refresh against the newly + // selected entity scope. + window.location.reload(); + }} + className="flex items-center justify-between gap-2" + > + {entity.name} + {selectedEntity?.id === entity.id && ( + + {t("common.active", "Active")} + + )} + + ))} +
+
+ ) : null} @@ -303,13 +349,15 @@ export default function AdminLmsLayout() {
- - - {t("chrome.needHelp")} - + {!isWizardRoute && ( + + + {t("chrome.needHelp")} + + )} ); } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 0140528..03dd0b8 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,4 +1,4 @@ -import { Outlet, useLocation, Link, useNavigate } from "react-router-dom"; +import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom"; import { Suspense } from "react"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { AppSidebar } from "@/components/AppSidebar"; @@ -7,14 +7,16 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react"; +import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react"; import React from "react"; import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer"; import AiSearchBar from "@/components/ai/AiSearchBar"; +import { useAuth } from "@/contexts/AuthContext"; const routeLabels: Record = { "dashboard": "Dashboard", @@ -90,6 +92,14 @@ function RouteContentFallback() { export default function AppLayout() { const navigate = useNavigate(); + const { user, selectedEntity, setSelectedEntityId } = useAuth(); + // Hide the floating "Need help?" pill on multi-step wizard routes. + // The pill sits at `fixed bottom-6 right-6` with z-50 and was + // intercepting clicks on the wizard's own Next/Finish footer at most + // viewport heights, blocking users from finishing the flow. The AI + // Assistant orb (also bottom-right) stays — it's smaller and + // genuinely useful inside the wizard. + const isWizardRoute = !!useMatch("/admin/smart-wizard/*"); return ( @@ -103,6 +113,34 @@ export default function AppLayout() {
+ {user?.entities?.length ? ( + + + + + + {user.entities.map((entity) => { + const active = selectedEntity?.id === entity.id; + return ( + setSelectedEntityId(entity.id)} + className="flex items-center justify-between gap-3" + > + {entity.name} + {active ? Current : null} + + ); + })} + + + ) : null} @@ -145,14 +183,15 @@ export default function AppLayout() {
- {/* Floating help button */} - - - Need help? - + {!isWizardRoute && ( + + + Need help? + + )}
); } diff --git a/src/components/coursePlan/LibraryPickerDialog.tsx b/src/components/coursePlan/LibraryPickerDialog.tsx new file mode 100644 index 0000000..46ef364 --- /dev/null +++ b/src/components/coursePlan/LibraryPickerDialog.tsx @@ -0,0 +1,214 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { Library, Loader2 } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { resourcesService } from "@/services/resources.service"; +import type { Resource } from "@/types"; + +/** + * Generic, reusable picker over `/api/resources`. + * + * Two callers wire this up today: + * + * 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards + * the picked resources to `coursePlanService.attachResources` in the + * `onConfirm` handler and invalidates the sources query. + * + * 2. **CoursePlanWizard** — the plan does not exist yet at pick time, + * so the wizard simply pushes the picks into its draft state and + * attaches them in one batch after the plan is created. + * + * The dialog itself is purposefully agnostic: it only reports the + * selected `Resource` rows; the parent decides what to do. + */ +export function LibraryPickerDialog({ + open, + onOpenChange, + alreadyLinkedIds, + onConfirm, + isPending, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Resources whose ids are in this set render disabled + checked. */ + alreadyLinkedIds?: Set; + /** Called when the admin clicks "Attach selected". */ + onConfirm: (resources: Resource[]) => void | Promise; + /** Optional spinner state from the parent's mutation. */ + isPending?: boolean; +}) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("all"); + const [selected, setSelected] = useState>(new Set()); + + // Reset every time the dialog re-opens so a previous session's + // selection doesn't bleed into the next attach. + useEffect(() => { + if (open) { + setSelected(new Set()); + setSearch(""); + setTypeFilter("all"); + } + }, [open]); + + const { data, isLoading } = useQuery({ + queryKey: ["library-resources", { search, type: typeFilter }], + queryFn: () => + resourcesService.list({ + search: search || undefined, + resource_type: typeFilter === "all" ? undefined : typeFilter, + review_status: "approved", + }), + enabled: open, + }); + const resources: Resource[] = data?.items ?? []; + + const toggle = (id: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleConfirm = async () => { + const picked = resources.filter((r) => selected.has(r.id)); + if (!picked.length) return; + await onConfirm(picked); + }; + + return ( + + + + + + {t("coursePlan.sources.libraryTitle")} + + + {t("coursePlan.sources.libraryDescription")} + + + +
+ setSearch(e.target.value)} + className="flex-1" + /> + +
+ +
+ {isLoading && } + {!isLoading && resources.length === 0 && ( +

+ {t("coursePlan.sources.libraryEmpty")} +

+ )} + {!isLoading && + resources.map((r) => { + const isLinked = alreadyLinkedIds?.has(r.id) ?? false; + const isSelected = selected.has(r.id); + return ( + + ); + })} +
+ + + + {t("coursePlan.sources.librarySelectedCount", { + count: selected.size, + })} + + + + +
+
+ ); +} diff --git a/src/components/coursePlan/MaterialBookView.tsx b/src/components/coursePlan/MaterialBookView.tsx new file mode 100644 index 0000000..6a4c4c4 --- /dev/null +++ b/src/components/coursePlan/MaterialBookView.tsx @@ -0,0 +1,78 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { CoursePlanMaterial } from "@/types"; + +export const SKILL_STYLE: Record = { + reading: "bg-blue-100 text-blue-800 border-blue-200", + writing: "bg-purple-100 text-purple-800 border-purple-200", + listening: "bg-emerald-100 text-emerald-800 border-emerald-200", + speaking: "bg-amber-100 text-amber-800 border-amber-200", + grammar: "bg-rose-100 text-rose-800 border-rose-200", + vocabulary: "bg-cyan-100 text-cyan-800 border-cyan-200", + integrated: "bg-slate-100 text-slate-800 border-slate-200", +}; + +export function SkillBadge({ skill }: { skill: string }) { + return ( + + {skill || "integrated"} + + ); +} + +function renderAny(value: unknown): React.ReactNode { + if (value == null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return

{String(value)}

; + } + if (Array.isArray(value)) { + if (value.length === 0) return null; + return ( +
    + {value.map((item, idx) => ( +
  • {renderAny(item)}
  • + ))} +
+ ); + } + if (typeof value === "object") { + const entries = Object.entries(value as Record); + return ( +
+ {entries.map(([k, v]) => ( +
+
+ {k.replace(/_/g, " ")} +
+
{renderAny(v)}
+
+ ))} +
+ ); + } + return null; +} + +export default function MaterialBookView({ + material, +}: { + material: Pick; +}) { + const body = material.body ?? {}; + const hasStructured = Object.keys(body).length > 0; + + return ( +
+ {hasStructured ? ( + renderAny(body) + ) : ( +

+ {material.body_text || "No content available yet."} +

+ )} +
+ ); +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 6bafd2a..59041d2 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; import { authService } from "@/services/auth.service"; -import { clearToken } from "@/lib/api-client"; +import { clearToken, getActiveEntityId, setActiveEntityId } from "@/lib/api-client"; import type { User, UserRole } from "@/types/auth"; export type { UserRole }; @@ -9,6 +9,9 @@ interface AuthContextType { user: User | null; isAuthenticated: boolean; isLoading: boolean; + selectedEntityId: number | null; + selectedEntity: User["entities"][number] | null; + setSelectedEntityId: (entityId: number | null) => void; login: (email: string, password: string) => Promise; logout: () => Promise; } @@ -17,8 +20,23 @@ const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); + const [selectedEntityId, setSelectedEntityIdState] = useState(getActiveEntityId()); const [isLoading, setIsLoading] = useState(true); + const syncEntitySelection = useCallback((nextUser: User | null) => { + const available = nextUser?.entities ?? []; + if (available.length === 0) { + setSelectedEntityIdState(null); + setActiveEntityId(null); + return; + } + const stored = getActiveEntityId(); + const validStored = stored && available.some((e) => e.id === stored) ? stored : null; + const next = validStored ?? available[0].id; + setSelectedEntityIdState(next); + setActiveEntityId(next); + }, []); + useEffect(() => { const token = localStorage.getItem("encoach_token"); if (!token) { @@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) { authService .getUser() - .then(setUser) + .then((u) => { + setUser(u); + syncEntitySelection(u); + }) .catch(() => { clearToken(); + setActiveEntityId(null); }) .finally(() => setIsLoading(false)); - }, []); + }, [syncEntitySelection]); const login = useCallback(async (email: string, password: string): Promise => { const res = await authService.login({ login: email, password }); setUser(res.user); + syncEntitySelection(res.user); return res.user; - }, []); + }, [syncEntitySelection]); + + const setSelectedEntityId = useCallback((entityId: number | null) => { + if (!user) { + setSelectedEntityIdState(null); + setActiveEntityId(null); + return; + } + if (entityId == null) { + setSelectedEntityIdState(null); + setActiveEntityId(null); + return; + } + if (!user.entities.some((e) => e.id === entityId)) return; + setSelectedEntityIdState(entityId); + setActiveEntityId(entityId); + }, [user]); const logout = useCallback(async () => { await authService.logout(); setUser(null); + setSelectedEntityIdState(null); + setActiveEntityId(null); }, []); + const selectedEntity = + user?.entities.find((e) => e.id === selectedEntityId) ?? + user?.entities[0] ?? + null; + return ( ; agents: Record; tools: Record; + /** AI provider selection & API key management (Providers & Keys tab). */ + aiProviders: Record; } const en: Translations = { @@ -874,9 +876,33 @@ const en: Translations = { file: "File", url: "URL", text: "Text", + resource: "Library", }, chunks_one: "{{count}} chunk", chunks_other: "{{count}} chunks", + // Library picker — reuses /admin/resources items as RAG sources. + pickFromLibrary: "Pick from library", + libraryHint: "Reuse a resource you've already uploaded.", + libraryTitle: "Pick from resource library", + libraryDescription: + "Select one or more approved resources to ground the AI on. We'll index them just like uploaded files.", + librarySearchPlaceholder: "Search by title…", + libraryTypeAll: "All types", + libraryEmpty: "No resources match those filters.", + libraryAlreadyLinked: "Already linked", + libraryAttach: "Attach selected", + librarySelectedCount_one: "{{count}} selected", + librarySelectedCount_other: "{{count}} selected", + libraryAttached_one: "Attached {{count}} resource.", + libraryAttached_other: "Attached {{count}} resources.", + librarySkipped_one: "{{count}} resource was already linked.", + librarySkipped_other: "{{count}} resources were already linked.", + libraryAttachFailed: "Couldn't attach the selected resources.", + linkedToLibrary: "Linked to /admin/resources", + fromLibrary: "Library", + libraryPickTitle: "Pick from library", + libraryPickHint: "Reuse approved resources you've already uploaded to /admin/resources.", + libraryPickButton: "Browse library", }, sourceKind: { file: "File", @@ -991,6 +1017,83 @@ const en: Translations = { agents: "Agents", tools: "Tools", prompts: "Prompts", + providers: "Providers & Keys", + }, + }, + aiProviders: { + title: "AI Providers & API Keys", + subtitle: + "Pick the active provider per capability and store API keys. Changes take effect on the next request — no Odoo restart required.", + activeProvider: "Active provider", + save: "Save settings", + testButton: "Test fallback chain", + noPaidKeys: + "No paid API keys configured for this capability — free fallback will be used.", + paidConfigured: "Configured paid providers:", + empty: "Settings could not be loaded.", + footer: + "Provider settings are read fresh from ir.config_parameter on every request — no caching, no restart required.", + cap: { + text: "Text generation", + text_hint: "Used by every LangGraph agent (course planner, exam generator, graders).", + image: "Image generation", + image_hint: "Illustrations for reading texts, listening scenes, and vocabulary cards.", + audio: "Audio (TTS)", + audio_hint: "Listening-script narration and speaking-prompt model answers.", + video: "Video composition", + video_hint: "Slideshow MP4s combining a generated image with the audio narration.", + }, + kind: { + paid: "Paid", + free: "Free", + auto: "Auto", + }, + keys: { + title: "API keys", + subtitle: + "Keys are write-only and stored encrypted at rest in ir.config_parameter. They are never returned to the browser.", + saved: "Saved", + unsaved: "Unsaved", + clear: "Clear", + placeholderSaved: "•••••• (click to replace)", + openai: "OpenAI API key", + openai_hint: "Used for GPT-4o, embeddings, and DALL-E 3.", + aws_access: "AWS Access Key ID", + aws_secret: "AWS Secret Access Key", + elevenlabs: "ElevenLabs API key", + gptzero: "GPTZero API key", + paymob_api: "Paymob API key", + paymob_integration: "Paymob integration ID", + paymob_iframe: "Paymob iframe ID", + paymob_hmac: "Paymob HMAC secret", + }, + group: { + openai: "OpenAI", + aws: "AWS Polly", + elevenlabs: "ElevenLabs", + other: "Other AI services", + paymob: "Paymob (payments)", + }, + plain: { + title: "Model & runtime", + subtitle: + "Non-secret runtime parameters: default model, AWS region, request timeout.", + openai_model: "OpenAI model", + openai_fast: "OpenAI fast model", + aws_region: "AWS region", + elevenlabs_model: "ElevenLabs model", + timeout: "Request timeout (seconds)", + max_retries: "Max retries", + }, + toast: { + loadFailed: "Could not load AI settings", + saved: "AI provider settings saved", + savedDescription: + "Active providers updated. Changes apply to the next request.", + saveFailed: "Could not save settings", + testOk: "Provider chain ready", + testPartial: "Some providers missing credentials", + testFailed: "Provider test failed", }, }, agents: { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 0d2ad17..5d67f6f 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -101,11 +101,53 @@ export function describeApiError( const ACCESS_KEY = "encoach_token"; const REFRESH_KEY = "encoach_refresh_token"; const EXP_KEY = "encoach_token_exp"; +const ENTITY_KEY = "encoach_entity_id"; function getAccessToken(): string | null { return localStorage.getItem(ACCESS_KEY); } +export function getActiveEntityId(): number | null { + try { + const raw = localStorage.getItem(ENTITY_KEY); + if (!raw) return null; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : null; + } catch { + return null; + } +} + +export function setActiveEntityId(entityId: number | null | undefined): void { + try { + if (entityId && Number.isFinite(entityId)) { + localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId))); + } else { + localStorage.removeItem(ENTITY_KEY); + } + } catch { + // ignore storage errors + } +} + +/** + * Build a URL to a media-streaming endpoint with the JWT attached as a + * query parameter. Used by ```` / ``