feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
@@ -1,6 +1,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
import { accountApi } from '$features/account/api/account.svelte';
interface OpenCoursePreviewOptions {
courseId: string;
@@ -8,6 +9,11 @@ interface OpenCoursePreviewOptions {
currentOrgDomain?: string;
}
interface ViewAsStudentOptions {
courseSlug?: string | null;
currentOrgDomain?: string;
}
/** Absolute URL for the org-site public course page (`/course/{slug}`). Client-only. */
export function getPublicCoursePageUrl(courseSlug: string, currentOrgDomain = ''): string {
if (typeof window === 'undefined') {
@@ -41,6 +47,45 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
return true;
}
/**
* "View as student" handoff. Mints a short-lived login-link token for the current
* user and navigates to the org domain's `/api/auth/login-link`, which signs them in
* there (host-only session) and redirects to the course's student view.
*
* One flow for cloud and self-hosted: on cloud the tenant-router worker fronts
* `/api/auth/*`→API and `/course/*`→dashboard under the tenant host; on self-hosted the
* dashboard's own `hooks.server.ts` proxies `/api/auth/*` to the API. Either way the
* cookie lands on `currentOrgDomain` and the relative `redirect` resolves there.
*/
export async function viewAsStudent({ courseSlug, currentOrgDomain = '' }: ViewAsStudentOptions) {
if (typeof window === 'undefined') {
return false;
}
if (!courseSlug) {
snackbar.info('course.header.preview_missing_slug');
return false;
}
const token = await accountApi.createViewAsStudentToken();
if (!token) {
snackbar.error('snackbar.view_as_student.failed');
return false;
}
const origin = currentOrgDomain?.trim() || window.location.origin;
const loginLinkUrl = new URL('/api/auth/login-link', origin);
loginLinkUrl.searchParams.set('token', token);
loginLinkUrl.searchParams.set('redirect', `/course/${courseSlug}`);
// Cross-origin handoff — open in a new tab so the teacher keeps their dashboard.
window.open(loginLinkUrl.toString(), '_blank', 'noopener,noreferrer');
return true;
}
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
@@ -2,8 +2,6 @@ import type { CourseContentItem, CourseMembers } from '$features/course/utils/ty
import type { Marks } from './types';
import { ContentType } from '@cio/utils/constants/content';
import Papa from 'papaparse';
import autoTable from 'jspdf-autotable';
import jsPDF from 'jspdf';
/** Exercise information for display in marks table (linear, no lesson grouping) */
export interface ExerciseInfo {
@@ -188,13 +186,18 @@ export function generateMarksCSV(
* @param studentMarksByExerciseId Student marks by exercise
* @param courseTitle Course title for filename and header
*/
export function generateMarksPDF(
export async function generateMarksPDF(
students: CourseMembers,
exercises: ExerciseInfo[],
studentMarksByExerciseId: StudentMarksByExercise,
courseTitle: string
): void {
): Promise<void> {
try {
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
import('jspdf'),
import('jspdf-autotable')
]);
const doc = new jsPDF({ orientation: 'landscape' });
const head = [['Student', ...exercises.map((ex) => ex.title), 'Avg grade']];
@@ -69,7 +69,9 @@ export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson'
title: lesson.title,
sectionTitle: lesson.sectionTitle,
body: lesson.body ?? '',
video: lesson.video,
// `lesson.video.hls` may be present on the wire from the public-course
// API but isn't in the older inferred type; spread through verbatim.
video: lesson.video as PublicLessonViewData['video'],
isUnlocked: lesson.isUnlocked
};
}
@@ -301,6 +301,8 @@ export type CourseMetadata = (NonNullable<ApiCourse['metadata']> extends object
? NonNullable<ApiCourse['metadata']>
: Record<string, unknown>) & {
isContentGroupingEnabled?: boolean;
skills?: string[];
tools?: string[];
};
export type CourseCertificate =
NonNullable<ApiCourse['certificate']> extends object