diff --git a/.env.example b/.env.example
index e4acd3ec2..2ddbb3d9c 100644
--- a/.env.example
+++ b/.env.example
@@ -805,3 +805,11 @@ DEFAULT_MODEL=
# successful verification clears its own counter. Without a trusted proxy the
# app cannot attribute a request to a client, so there is no throttle; rely on
# the length and randomness of the code instead.
+
+# Interactive scene libraries (public, build-time URL setting).
+# Defaults to /vendor/interactive on the deployment's own origin.
+# Override with an absolute HTTPS base URL if you mirror public/vendor/interactive
+# to object storage or your own CDN. Preserve package/version paths and enable
+# CORS (Access-Control-Allow-Origin: *) for sandboxed iframe modules and fonts.
+# Set before pnpm build; Docker builds also accept this as a build argument.
+# INTERACTIVE_ASSET_BASE_URL=https://assets.example.com/interactive
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f91334e68..05a2be600 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -330,7 +330,10 @@ jobs:
COVER_LAYOUT_BROWSER: '1'
- name: Interactive static HTML Chromium smoke
- run: pnpm exec vitest run tests/video-export/interactive-static-html.browser.test.ts
+ run: >-
+ pnpm exec vitest run
+ tests/video-export/interactive-static-html.browser.test.ts
+ tests/video-export/interactive-self-hosted.browser.test.ts
env:
INTERACTIVE_STATIC_BROWSER: '1'
diff --git a/.gitignore b/.gitignore
index da8bfed46..b0714fa4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -97,3 +97,6 @@ e2e/__screenshots-619/
# Temporary render-service sources extracted by the mixed-pool regression test
render-service/.owner-bound-parent-*/
+
+# Generated interactive libraries
+/public/vendor/interactive
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 832846cb4..a1c5fc8d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
+- Interactive scenes load KaTeX, Three.js, Chart.js and the Tailwind v4 browser runtime from the deployment (`/vendor/interactive`, configurable with the build-time `INTERACTIVE_ASSET_BASE_URL`) instead of a public CDN. KaTeX is injected only into pages that contain math, and loads ahead of the page's scripts as before. The interactive prompts list the hosted libraries, and public CDN URLs for them in generated or stored pages are rewritten to the hosted copies when a scene opens and before export; other URLs are left as written. `@openmaic/generation` 0.3.24. [#1971](https://github.com/THU-MAIC/OpenMAIC/issues/1971)
+
- Course generation passes each catalogue voice's `gender` into the voice list and asks the model to pick a voice whose gender matches the agent, keeping the agent's name and persona consistent with that gender. `voiceDesign.gender` (`male` or `female`) is part of the agent DSL, the export sanitizer, and `set_roster`, so a course-language identity can still drive the match. When the model's voice is missing or invalid, playback falls back by index within that gender. A catalogue with no male/female data, or an agent whose gender is unknown, keeps the previous full-list index pick. [#1084](https://github.com/THU-MAIC/OpenMAIC/issues/1084)
### Bug Fixes
diff --git a/Dockerfile b/Dockerfile
index f88e77ef8..e4b0f1618 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -49,6 +49,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
FROM base AS builder
ARG ALLOWED_FRAME_ANCESTORS
+ARG INTERACTIVE_ASSET_BASE_URL
ARG NEXT_PUBLIC_MAIC_EDITOR_ENABLED
ARG NEXT_PUBLIC_MAIC_EDITOR_RENDERER_ENABLED
ARG NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED
@@ -60,6 +61,7 @@ ARG NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION
ARG NEXT_PUBLIC_ENABLE_PPTX_IMPORT
ARG NEXT_PUBLIC_PRO_WORKBENCH_ENABLED
ENV ALLOWED_FRAME_ANCESTORS=$ALLOWED_FRAME_ANCESTORS
+ENV INTERACTIVE_ASSET_BASE_URL=$INTERACTIVE_ASSET_BASE_URL
ENV NEXT_PUBLIC_MAIC_EDITOR_ENABLED=$NEXT_PUBLIC_MAIC_EDITOR_ENABLED
ENV NEXT_PUBLIC_MAIC_EDITOR_RENDERER_ENABLED=$NEXT_PUBLIC_MAIC_EDITOR_RENDERER_ENABLED
ENV NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED=$NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a202bedc3..7832d3166 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -161,19 +161,19 @@ const eslintConfig = defineConfig([
},
{
selector:
- 'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json)(\\/|$)|node:|\\.\\.?\\/).+/]',
+ 'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json|es-module-lexer|postcss|postcss-value-parser)(\\/|$)|node:|\\.\\.?\\/).+/]',
message:
'@openmaic/generation may import only from @openmaic/dsl, @openmaic/geometry, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).',
},
{
selector:
- 'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json)(\\/|$)|node:|\\.\\.?\\/).+/]',
+ 'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json|es-module-lexer|postcss|postcss-value-parser)(\\/|$)|node:|\\.\\.?\\/).+/]',
message:
'@openmaic/generation may re-export only from @openmaic/dsl, @openmaic/geometry, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).',
},
{
selector:
- 'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json)(\\/|$)|node:|\\.\\.?\\/).+/]',
+ 'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json|es-module-lexer|postcss|postcss-value-parser)(\\/|$)|node:|\\.\\.?\\/).+/]',
message:
'@openmaic/generation may re-export only from @openmaic/dsl, @openmaic/geometry, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).',
},
diff --git a/lib/export/inline-assets-shared.ts b/lib/export/inline-assets-shared.ts
index dbf54d5bc..9d1b2f5bb 100644
--- a/lib/export/inline-assets-shared.ts
+++ b/lib/export/inline-assets-shared.ts
@@ -4,6 +4,8 @@ export interface InlineReport {
}
export interface InlineOptions {
+ /** Deployment origin for resolving self-hosted interactive libraries outside a browser. */
+ baseUrl?: string;
fetchImpl?: typeof fetch;
maxAssetBytes?: number;
fetcher?: FetchAsset;
diff --git a/lib/export/inline-assets.ts b/lib/export/inline-assets.ts
index c76d6f78b..ba542630b 100644
--- a/lib/export/inline-assets.ts
+++ b/lib/export/inline-assets.ts
@@ -1,3 +1,8 @@
+import {
+ postProcessInteractiveHtml,
+ rewriteInteractiveLibraries,
+ interactiveAssetBaseUrl,
+} from '@openmaic/generation/browser';
import {
toDataUri,
type InlineReport,
@@ -604,6 +609,12 @@ export async function inlineHtmlAssets(
html: string,
options?: InlineOptions,
): Promise<{ html: string; report: InlineReport; unresolved: string[] }> {
+ html = postProcessInteractiveHtml(html);
+ const assetBase = interactiveAssetBaseUrl();
+ const origin =
+ options?.baseUrl ?? (typeof window !== 'undefined' ? window.location.origin : undefined);
+ if (origin && assetBase.startsWith('/'))
+ html = rewriteInteractiveLibraries(html, new URL(assetBase, origin).href).html;
const fetchAsset = options?.fetcher ?? createAssetFetcher(options);
const report: InlineReport = { inlined: [], failed: [] };
const unsupported = unsupportedImportmapFeatures(html);
diff --git a/lib/export/proxied-fetch.ts b/lib/export/proxied-fetch.ts
index dc7660896..a5a5777d1 100644
--- a/lib/export/proxied-fetch.ts
+++ b/lib/export/proxied-fetch.ts
@@ -4,9 +4,23 @@
* into cross-origin-only mode so local, blob, and data URLs stay direct, and can
* try a CORS-enabled CDN directly before falling back to the bounded proxy. The
* proxy validates the URL server-side (SSRF guard) and returns the bytes.
+ *
+ * Hosted interactive-scene libraries are always fetched directly: they are
+ * static files served with CORS, and on a localhost or private-address
+ * deployment the proxy's SSRF guard would reject them.
*/
+import { interactiveAssetBaseUrl } from '@openmaic/generation/browser';
import { fetchProxiedMediaUrl } from '@/lib/media/proxy-media-cache';
+function isInteractiveAsset(url: URL, pageHref: string | undefined): boolean {
+ try {
+ const base = new URL(interactiveAssetBaseUrl().replace(/\/?$/, '/'), pageHref);
+ return url.origin === base.origin && url.pathname.startsWith(base.pathname);
+ } catch {
+ return false;
+ }
+}
+
export function createProxiedFetch(
options: { crossOriginOnly?: boolean; directFirst?: boolean } = {},
): typeof fetch {
@@ -16,6 +30,7 @@ export function createProxiedFetch(
let shouldProxy = false;
try {
const resolved = pageHref ? new URL(url, pageHref) : new URL(url);
+ if (isInteractiveAsset(resolved, pageHref)) return fetch(input, init);
shouldProxy =
(resolved.protocol === 'http:' || resolved.protocol === 'https:') &&
(!pageHref || resolved.origin !== new URL(pageHref).origin);
diff --git a/lib/export/standalone-html/prepare-manifest.ts b/lib/export/standalone-html/prepare-manifest.ts
index b7ca6836b..575f669f0 100644
--- a/lib/export/standalone-html/prepare-manifest.ts
+++ b/lib/export/standalone-html/prepare-manifest.ts
@@ -482,7 +482,7 @@ function prepareScene(
return {
...rest,
content: html
- ? { ...interactive, html: patchHtmlForIframe(html) }
+ ? { ...interactive, html: patchHtmlForIframe(html, undefined, { processed: true }) }
: { ...interactive, html: undefined },
};
}
diff --git a/lib/utils/iframe.ts b/lib/utils/iframe.ts
index 093416081..1166cce00 100644
--- a/lib/utils/iframe.ts
+++ b/lib/utils/iframe.ts
@@ -1,3 +1,4 @@
+import { postProcessInteractiveHtml } from '@openmaic/generation/browser';
import { injectIntoDocumentHead } from './html-document';
import {
withObservationResponder,
@@ -387,7 +388,12 @@ const ELEMENT_PICKER_SHIM = `
-
-
+
+
@@ -663,6 +663,8 @@ Return ONLY the HTML document, no markdown fences or explanations.
- The output must end with exactly one `