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
@@ -15,7 +15,11 @@
import Callout from './callout.svelte';
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicExerciseViewData } from './types';
import type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
import { readPublicExerciseAttempts, writePublicExerciseAttempts } from './public-exercise-attempts-storage';
import {
cloneAnswersByKey,
readPublicExerciseAttempts,
writePublicExerciseAttempts
} from './public-exercise-attempts-storage';
interface Props {
exercise: PublicExerciseViewData;
@@ -77,7 +81,7 @@
const defaultIndex = rows.length - 1;
selectValue = String(defaultIndex);
const row = rows[defaultIndex];
answers = structuredClone(row.answersByKey);
answers = cloneAnswersByKey(row.answersByKey);
submitted = true;
});
});
@@ -95,7 +99,7 @@
selectValue = selected;
submitted = true;
answers = structuredClone(persistedAttempts[idx].answersByKey);
answers = cloneAnswersByKey(persistedAttempts[idx].answersByKey);
}
function handleAnswerChange(question: ExerciseQuestionModel, answer: AnswerData | null) {
@@ -317,7 +321,7 @@
const snapshot: PublicExerciseStoredAttempt = {
savedAt: new Date().toISOString(),
answersByKey: structuredClone(answers),
answersByKey: cloneAnswersByKey(answers),
correctCount: correct,
totalGradable: tot
};
@@ -11,6 +11,7 @@ export { default as PublicCourseCallout } from './callout.svelte';
export {
PUBLIC_EXERCISE_ATTEMPTS_STORE_VERSION,
cloneAnswersByKey,
publicExerciseAttemptsStorageKey,
readPublicExerciseAttempts,
writePublicExerciseAttempts
@@ -9,13 +9,51 @@
lesson: PublicLessonViewData;
/** Shown in the player captions menu; supplied by the host app (`$t(...)`) — see authenticated `lesson-video-player`. */
videoCaptionsLabel: string;
/**
* For HLS videos. Host app provides a callback that mints the public
* HLS cookie via `POST /org-site/course/.../hls-cookie`. The shared
* media player invokes it before hls.js loads the manifest, and again
* via `onPlaybackReload` when the learner clicks "Reload playback"
* after a cookie-expiry / segment failure.
*/
onBeforeHlsLoad?: () => Promise<void>;
/**
* Resolves a relative HLS URL (e.g. `/hls/{assetId}/master.m3u8`) into
* an absolute URL the browser can fetch. Host app passes a resolver
* that knows the API base URL (typically built from the typed client).
*/
resolveHlsUrl?: (link: string) => string;
/**
* Strings shown on the player's reload-after-failure surface. Defaults
* are English; host app should localise via `$t(...)` keys.
*/
playbackErrorLabel?: string;
playbackReloadLabel?: string;
callout?: PublicCourseCalloutData | null;
calloutAnimation?: PublicCourseCalloutAnimation;
class?: string;
id?: string;
}
let { lesson, videoCaptionsLabel, callout = null, calloutAnimation, class: className, id }: Props = $props();
let {
lesson,
videoCaptionsLabel,
onBeforeHlsLoad,
resolveHlsUrl,
playbackErrorLabel,
playbackReloadLabel,
callout = null,
calloutAnimation,
class: className,
id
}: Props = $props();
const isHls = $derived(Boolean(lesson.video?.hls));
const playbackUrl = $derived.by(() => {
if (!lesson.video) return '';
if (isHls && resolveHlsUrl) return resolveHlsUrl(lesson.video.link);
return lesson.video.link;
});
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
calloutAnimation ?? callout?.animation ?? 'waves'
@@ -52,11 +90,12 @@
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
{:else}
{#if lesson.video}
<div class="ui:mb-6 ui:w-full ui:overflow-hidden ui:bg-muted ui:rounded-lg">
<div class="ui:mb-6 ui:w-full ui:overflow-hidden ui:rounded-lg">
<MediaPlayer
source={{
type: lesson.video.type,
url: lesson.video.link,
url: playbackUrl,
hls: isHls,
metadata: {
...(lesson.video.metadata ?? {}),
title: lesson.video.metadata?.title ?? lesson.title
@@ -67,7 +106,11 @@
maxHeight: '569px',
width: '100%',
controls: true,
playsinline: true
playsinline: true,
onBeforeHlsLoad: isHls ? onBeforeHlsLoad : undefined,
playbackErrorLabel,
playbackReloadLabel,
onPlaybackReload: isHls ? async () => true : undefined
}}
/>
</div>
@@ -11,6 +11,11 @@ export interface PublicExerciseStoredAttempt {
totalGradable: number;
}
/** Plain deep clone — `structuredClone` throws on Svelte `$state` proxies. */
export function cloneAnswersByKey(answersByKey: Record<string, AnswerData>): Record<string, AnswerData> {
return JSON.parse(JSON.stringify(answersByKey)) as Record<string, AnswerData>;
}
interface PersistedEnvelope {
v: number;
attempts: PublicExerciseStoredAttempt[];
@@ -56,6 +56,14 @@ export interface PublicLessonViewData {
video: {
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
link: string;
/**
* When true, `link` is an HLS master playlist URL served by the
* tenant-router Worker (prod) or API streaming route (local). The
* shared MediaPlayer attaches hls.js based on this flag. The host app
* must pass an `onBeforeHlsLoad` callback that mints a cookie via the
* public org-site endpoint before playback starts.
*/
hls?: boolean;
metadata?: {
svid?: string;
thumbnailUrl?: string;