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
This commit is contained in:
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Library,
|
||||
Link2,
|
||||
Mic,
|
||||
Music,
|
||||
@@ -28,9 +29,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type { CoursePlanGenerateBrief } from "@/types";
|
||||
import type { CoursePlanGenerateBrief, Resource } from "@/types";
|
||||
|
||||
/**
|
||||
* AI course-plan generation wizard.
|
||||
@@ -48,7 +50,7 @@ import type { CoursePlanGenerateBrief } from "@/types";
|
||||
* materials immediately.
|
||||
*/
|
||||
|
||||
type DraftSourceKind = "file" | "url" | "text";
|
||||
type DraftSourceKind = "file" | "url" | "text" | "resource";
|
||||
|
||||
interface DraftSource {
|
||||
/** Stable client-side id; not persisted. */
|
||||
@@ -61,6 +63,10 @@ interface DraftSource {
|
||||
url?: string;
|
||||
/** Only set when kind === "text". */
|
||||
text?: string;
|
||||
/** Only set when kind === "resource" — pointer into the central library. */
|
||||
resourceId?: number;
|
||||
/** Display-only metadata so we can render the row without a refetch. */
|
||||
resourceType?: string;
|
||||
}
|
||||
|
||||
interface MediaToggleState {
|
||||
@@ -383,6 +389,26 @@ export default function CoursePlanWizard() {
|
||||
});
|
||||
const planId = resp?.data?.id;
|
||||
if (planId && state.sources.length) {
|
||||
// Library picks go through the dedicated bulk endpoint — one
|
||||
// round-trip for the whole set, with server-side dedupe — so we
|
||||
// collect their ids first and post them together. The remaining
|
||||
// file/url/text drafts are posted individually as before.
|
||||
const resourceIds = state.sources
|
||||
.filter((s) => s.kind === "resource" && s.resourceId)
|
||||
.map((s) => s.resourceId as number);
|
||||
if (resourceIds.length) {
|
||||
try {
|
||||
await coursePlanService.attachResources(planId, resourceIds);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
describeApiError(
|
||||
err,
|
||||
t("coursePlan.sources.libraryAttachFailed"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort upload — we keep going even if a single one fails so
|
||||
// the user lands on the detail page with whatever did make it in.
|
||||
for (const src of state.sources) {
|
||||
@@ -404,6 +430,8 @@ export default function CoursePlanWizard() {
|
||||
name: src.name || t("coursePlan.sourceKind.text"),
|
||||
});
|
||||
}
|
||||
// src.kind === "resource" was already handled in the bulk
|
||||
// attach above.
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
describeApiError(err, t("coursePlan.sources.uploadFailed", {
|
||||
@@ -433,6 +461,15 @@ function SourcesStep({
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [draftUrl, setDraftUrl] = useState("");
|
||||
const [draftText, setDraftText] = useState("");
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
|
||||
// Resource ids already queued in this wizard session; passed to the
|
||||
// picker so the same resource can't be added twice.
|
||||
const queuedResourceIds = new Set(
|
||||
sources
|
||||
.filter((s) => s.kind === "resource" && s.resourceId)
|
||||
.map((s) => s.resourceId as number),
|
||||
);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (!files || !files.length) return;
|
||||
@@ -470,38 +507,73 @@ function SourcesStep({
|
||||
setDraftText("");
|
||||
};
|
||||
|
||||
const addLibraryResources = (picked: Resource[]) => {
|
||||
if (!picked.length) return;
|
||||
const additions: DraftSource[] = picked.map((r) => ({
|
||||
uid: genUid(),
|
||||
kind: "resource",
|
||||
name: r.name,
|
||||
resourceId: r.id,
|
||||
resourceType: r.resource_type || r.type || "resource",
|
||||
}));
|
||||
onChange([...sources, ...additions]);
|
||||
setLibraryOpen(false);
|
||||
};
|
||||
|
||||
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{t("coursePlan.sources.dropTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("coursePlan.sources.dropHint")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
|
||||
onChange={(e) => {
|
||||
addFiles(e.currentTarget.files);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.sources.uploadFiles")}
|
||||
</Button>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{t("coursePlan.sources.dropTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("coursePlan.sources.dropHint")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
|
||||
onChange={(e) => {
|
||||
addFiles(e.currentTarget.files);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.sources.uploadFiles")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||
<Library className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{t("coursePlan.sources.libraryPickTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("coursePlan.sources.libraryPickHint")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLibraryOpen(true)}
|
||||
>
|
||||
<Library className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.sources.libraryPickButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -566,8 +638,19 @@ function SourcesStep({
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<Badge variant="outline" className="capitalize text-[10px]">
|
||||
{s.kind}
|
||||
{s.kind === "resource"
|
||||
? s.resourceType || "library"
|
||||
: s.kind}
|
||||
</Badge>
|
||||
{s.kind === "resource" && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-[10px] flex items-center"
|
||||
>
|
||||
<Library className="h-3 w-3" />
|
||||
{t("coursePlan.sources.fromLibrary")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="flex-1 truncate">{s.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -583,6 +666,13 @@ function SourcesStep({
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LibraryPickerDialog
|
||||
open={libraryOpen}
|
||||
onOpenChange={setLibraryOpen}
|
||||
alreadyLinkedIds={queuedResourceIds}
|
||||
onConfirm={addLibraryResources}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user