Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/commons-courses/app/api/educator/copilot/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ type ChatBody = {
export const maxDuration = 300;

const maxFiles = 8;
const maxTotalBytes = 18 * 1024 * 1024;
const maxTextChars = 60000;
const maxTotalBytes = 50 * 1024 * 1024;
const maxTextChars = 120000;

export async function POST(req: NextRequest) {
const result = await requireEducator();
Expand All @@ -53,7 +53,7 @@ export async function POST(req: NextRequest) {
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
if (totalBytes > maxTotalBytes) {
return NextResponse.json(
{ error: "Uploaded files must be smaller than 18 MB total." },
{ error: "Uploaded files must be smaller than 50 MB total." },
{ status: 400 }
);
}
Expand Down
30 changes: 28 additions & 2 deletions apps/commons-courses/app/api/educator/live-sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,7 @@ export async function PATCH(
{ status: 404 },
);
}
for (const item of session.parts)
item.status = item.id === partId ? "open" : "closed";
part.status = "open";
session.currentPartId = part.id;
session.pace = part.pace;
session.currentActivityId = firstActivityIdForPart(
Expand All @@ -145,6 +144,33 @@ export async function PATCH(
);
session.status = "live";
syncActivityStatusesForPace(session);
} else if (record.command === "close_part") {
const partId = typeof record.partId === "string" ? record.partId : "";
const part = session.parts.find(
(item: LiveSessionPart) => item.id === partId,
);
if (!part) {
return NextResponse.json(
{ error: "Programme session not found." },
{ status: 404 },
);
}
part.status = "closed";
const currentIsClosing = part.activityIds.includes(
session.currentActivityId || "",
);
if (currentIsClosing || session.currentPartId === part.id) {
const nextPart = session.parts.find(
(item: LiveSessionPart) => item.status === "open",
);
session.currentPartId = nextPart?.id;
session.pace = nextPart?.pace || session.pace;
session.currentActivityId = firstActivityIdForPart(
session.activities,
nextPart,
);
}
syncActivityStatusesForPace(session);
} else if (record.command === "set_part_pace") {
const partId = typeof record.partId === "string" ? record.partId : "";
const pace = record.pace === "learner" ? "learner" : "facilitator";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Paperclip,
PenLine,
Plug,
Radio,
Settings2,
Sparkles,
Trash2,
Expand Down Expand Up @@ -841,6 +842,9 @@ function actionIcon(action: EducatorCopilotAction) {
return <BookOpen className="mt-0.5 h-4 w-4 text-amber-600" />;
case "update_experience_world":
return <Globe2 className="mt-0.5 h-4 w-4 text-violet-600" />;
case "create_live_session":
case "update_live_session":
return <Radio className="mt-0.5 h-4 w-4 text-emerald-600" />;
case "update_course_lesson":
case "update_module":
case "update_skill_path":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ export function LiveFacilitatorStudio({ sessionId }: { sessionId: string }) {
parts={data.session.parts}
running={running}
onOpen={(partId) => command("open_part", undefined, partId)}
onClose={(partId) => command("close_part", undefined, partId)}
onPaceChange={(partId, pace) =>
command("set_part_pace", undefined, partId, pace)
}
Expand Down Expand Up @@ -1180,11 +1181,13 @@ function ProgrammeSessionControls({
parts,
running,
onOpen,
onClose,
onPaceChange,
}: {
parts: LiveSessionPart[];
running: boolean;
onOpen: (partId: string) => void;
onClose: (partId: string) => void;
onPaceChange: (partId: string, pace: LiveSessionPart["pace"]) => void;
}) {
return (
Expand All @@ -1195,8 +1198,8 @@ function ProgrammeSessionControls({
Programme sessions
</h3>
<p className="mt-1 text-xs leading-5 text-slate-500">
Keep one learner link. Choose which session is open and how learners
move through it.
Keep one learner link. Open any combination of sessions and choose
how learners move through each one.
</p>
</div>
<BookOpen className="h-4 w-4 shrink-0 text-slate-300" />
Expand Down Expand Up @@ -1266,16 +1269,16 @@ function ProgrammeSessionControls({
</select>
<button
type="button"
disabled={running || open}
onClick={() => onOpen(part.id)}
disabled={running}
onClick={() => (open ? onClose(part.id) : onOpen(part.id))}
className={cn(
"rounded-lg px-3 py-2 text-xs font-bold",
open
? "cursor-default bg-emerald-100 text-emerald-700"
? "border border-emerald-200 bg-white text-emerald-700"
: "bg-slate-950 text-white disabled:opacity-50",
)}
>
{open ? "Open to learners" : "Open this session"}
{open ? "Close to learners" : "Open this session"}
</button>
</div>
</article>
Expand Down
76 changes: 59 additions & 17 deletions apps/commons-courses/components/live/live-learner-room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,24 +99,37 @@ export function LiveLearnerRoom({ sessionId }: { sessionId: string }) {

const applySelection = useCallback((next: LearnerLiveSession) => {
const lastPresentedActivityId = presentedActivityRef.current;
const activePart =
const presentedChanged =
Boolean(next.currentActivityId) &&
next.currentActivityId !== lastPresentedActivityId;
const presentedPart =
next.parts.find((part) => part.id === next.currentPartId) ||
next.parts.find((part) => part.status === "open");
const activities = activePart
? next.activities.filter((activity) =>
activePart.activityIds.includes(activity.id),
)
: next.activities;
setSelectedId((selectedActivityId) =>
resolveLearnerActivitySelection({
setSelectedId((selectedActivityId) => {
const selectedPart = next.parts.find(
(part) =>
part.status === "open" &&
part.activityIds.includes(selectedActivityId),
);
const activePart =
(presentedChanged ? presentedPart : selectedPart) || presentedPart;
const activities = activePart
? next.activities.filter((activity) =>
activePart.activityIds.includes(activity.id),
)
: next.activities;
return resolveLearnerActivitySelection({
activities,
currentActivityId: next.currentActivityId,
currentActivityId:
activePart?.id === presentedPart?.id
? next.currentActivityId
: undefined,
lastPresentedActivityId,
pace: activePart?.pace || next.pace,
responses: next.responses,
selectedActivityId,
}),
);
});
});
presentedActivityRef.current = next.currentActivityId || "";
}, []);

Expand Down Expand Up @@ -299,7 +312,11 @@ export function LiveLearnerRoom({ sessionId }: { sessionId: string }) {
const activity = session?.activities.find((item) => item.id === selectedId);
const { activePart, activeActivities } = useMemo(() => {
const part = session
? session.parts.find((item) => item.id === session.currentPartId) ||
? session.parts.find(
(item) =>
item.status === "open" && item.activityIds.includes(selectedId),
) ||
session.parts.find((item) => item.id === session.currentPartId) ||
session.parts.find((item) => item.status === "open")
: undefined;
return {
Expand All @@ -312,7 +329,7 @@ export function LiveLearnerRoom({ sessionId }: { sessionId: string }) {
: session.activities
: [],
};
}, [session]);
}, [selectedId, session]);
const activityIndex = activeActivities.findIndex(
(item) => item.id === selectedId,
);
Expand Down Expand Up @@ -552,7 +569,23 @@ export function LiveLearnerRoom({ sessionId }: { sessionId: string }) {
</header>
<div className="mx-auto w-full max-w-[1600px] px-3 py-4 sm:px-6 sm:py-6 lg:px-10 lg:py-8">
{session.parts.length ? (
<ProgrammePartStrip session={session} activePartId={activePart?.id} />
<ProgrammePartStrip
session={session}
activePartId={activePart?.id}
onSelect={(partId) => {
const part = session.parts.find((item) => item.id === partId);
const firstAvailable = part?.activityIds
.map((id) => session.activities.find((item) => item.id === id))
.find(
(item) =>
item &&
(item.status === "open" ||
item.id === session.currentActivityId ||
Boolean(session.responses[item.id])),
);
if (firstAvailable) setSelectedId(firstAvailable.id);
}}
/>
) : null}
<section className="min-w-0">
<div className="mb-3 flex items-center justify-between text-xs opacity-50 sm:mb-4">
Expand Down Expand Up @@ -682,9 +715,11 @@ export function LiveLearnerRoom({ sessionId }: { sessionId: string }) {
function ProgrammePartStrip({
session,
activePartId,
onSelect,
}: {
session: LearnerLiveSession;
activePartId?: string;
onSelect: (partId: string) => void;
}) {
return (
<section className="mb-5" aria-label="Programme sessions">
Expand All @@ -695,13 +730,20 @@ function ProgrammePartStrip({
{session.parts.map((part, index) => {
const active = part.id === activePartId && part.status === "open";
return (
<div
<button
type="button"
key={part.id}
disabled={part.status === "closed"}
onClick={() => onSelect(part.id)}
className={cn(
"flex min-w-0 items-center gap-3 rounded-xl border px-3.5 py-3 lg:min-w-64",
"flex min-w-0 items-center gap-3 rounded-xl border px-3.5 py-3 text-left transition lg:min-w-64",
active
? "border-[var(--course-primary)] bg-[var(--course-surface)] shadow-sm"
: "border-slate-200 bg-[var(--course-surface)]/60 opacity-70",
part.status === "open" &&
!active &&
"hover:border-[var(--course-primary)] hover:opacity-100",
part.status === "closed" && "cursor-default",
)}
>
<span
Expand Down Expand Up @@ -731,7 +773,7 @@ function ProgrammePartStrip({
) : (
<span className="h-2 w-2 shrink-0 rounded-full bg-emerald-500" />
)}
</div>
</button>
);
})}
</div>
Expand Down
40 changes: 40 additions & 0 deletions apps/commons-courses/lib/copilot-materials.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import JSZip from "jszip";
import { extractMaterial, guessMimeType } from "./copilot-materials.ts";

test("extractMaterial reads DOCX paragraphs and table cells", async () => {
const zip = new JSZip();
zip.file(
"word/document.xml",
`<w:document><w:body><w:p><w:r><w:t>Harness canvas</w:t></w:r></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>Trigger</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>New request</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>`,
);
const bytes = await zip.generateAsync({ type: "uint8array" });
const file = new File([bytes], "workbook.docx", {
type: guessMimeType("workbook.docx"),
});

const result = await extractMaterial(file);

assert.match(result.text, /Harness canvas/);
assert.match(result.text, /Trigger/);
assert.match(result.text, /New request/);
});

test("extractMaterial reads PPTX slides in numeric order", async () => {
const zip = new JSZip();
zip.file("ppt/slides/slide10.xml", `<p:sld><a:p><a:r><a:t>Ten</a:t></a:r></a:p></p:sld>`);
zip.file("ppt/slides/slide2.xml", `<p:sld><a:p><a:r><a:t>Two &amp; safe</a:t></a:r></a:p></p:sld>`);
zip.file("ppt/slides/slide1.xml", `<p:sld><a:p><a:r><a:t>One</a:t></a:r></a:p></p:sld>`);
const bytes = await zip.generateAsync({ type: "uint8array" });
const file = new File([bytes], "slides.pptx", {
type: guessMimeType("slides.pptx"),
});

const result = await extractMaterial(file);

assert.equal(
result.text,
"--- Slide 1 ---\nOne\n\n--- Slide 2 ---\nTwo & safe\n\n--- Slide 3 ---\nTen",
);
});
Loading
Loading