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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user