diff --git a/CHANGELOG.md b/CHANGELOG.md index c99d799fdb3..648edba2a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp. - Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868. - Control UI/models: request the configured Gateway model-list view so dashboards with only `models.providers.*.models` show those configured models first instead of flooding the picker with the full built-in catalog. Fixes #65405. Thanks @wbyanclaw. - CLI/models: keep default-model and allowlist pickers on explicit `models.providers.*.models` entries when `models.mode` is `replace` instead of loading the full built-in catalog. Fixes #64950. Thanks @mrozentsvayg. diff --git a/src/auto-reply/reply/commands-export-session.test.ts b/src/auto-reply/reply/commands-export-session.test.ts index 6721247e896..fc055f85a93 100644 --- a/src/auto-reply/reply/commands-export-session.test.ts +++ b/src/auto-reply/reply/commands-export-session.test.ts @@ -19,6 +19,7 @@ const hoisted = await vi.hoisted(async () => { writeFileSyncMock: vi.fn(), mkdirSyncMock: vi.fn(), existsSyncMock: vi.fn(() => true), + exportHtmlTemplateContents: new Map(), }; }); @@ -54,8 +55,13 @@ vi.mock("node:fs", async () => { mkdirSync: hoisted.mkdirSyncMock, writeFileSync: hoisted.writeFileSyncMock, readFileSync: vi.fn((filePath: string) => { - if (filePath.endsWith("template.html")) { - return "{{CSS}}{{JS}}{{SESSION_DATA}}{{MARKED_JS}}{{HIGHLIGHT_JS}}"; + for (const [suffix, contents] of hoisted.exportHtmlTemplateContents) { + if (filePath.endsWith(suffix)) { + return contents; + } + } + if (filePath.includes("/export-html/")) { + return actual.readFileSync(filePath, "utf8"); } return ""; }), @@ -124,6 +130,7 @@ describe("buildExportSessionReply", () => { sandboxRuntime: { sandboxed: false, mode: "off" }, }); hoisted.existsSyncMock.mockReturnValue(true); + hoisted.exportHtmlTemplateContents.clear(); }); it("resolves store and transcript paths from the target session agent", async () => { @@ -185,4 +192,60 @@ describe("buildExportSessionReply", () => { }), ); }); + + it("injects scripts and session data through the real export template", async () => { + const { buildExportSessionReply } = await import("./commands-export-session.js"); + + await buildExportSessionReply(makeParams()); + + const html = hoisted.writeFileSyncMock.mock.calls[0]?.[1]; + expect(typeof html).toBe("string"); + expect(html).not.toContain("{{CSS}}"); + expect(html).not.toContain("{{JS}}"); + expect(html).not.toContain("{{SESSION_DATA}}"); + expect(html).not.toContain("{{MARKED_JS}}"); + expect(html).not.toContain("{{HIGHLIGHT_JS}}"); + expect(html).not.toContain("data-openclaw-export-placeholder"); + expect(html).toContain( + Buffer.from( + JSON.stringify({ + header: null, + entries: [], + leafId: null, + systemPrompt: "system prompt", + tools: [], + }), + ).toString("base64"), + ); + expect(html).toContain('const base64 = document.getElementById("session-data").textContent;'); + }); + + it("preserves replacement text with dollar sequences", async () => { + const { buildExportSessionReply } = await import("./commands-export-session.js"); + hoisted.exportHtmlTemplateContents.set( + "template.html", + [ + '', + '', + '', + '', + '', + ].join(""), + ); + hoisted.exportHtmlTemplateContents.set("template.css", "/* {{THEME_VARS}} */$&$1"); + hoisted.exportHtmlTemplateContents.set("template.js", "const marker = '$&$1';"); + hoisted.exportHtmlTemplateContents.set("vendor/marked.min.js", "const markedMarker = '$&$1';"); + hoisted.exportHtmlTemplateContents.set( + "vendor/highlight.min.js", + "const highlightMarker = '$&$1';", + ); + + await buildExportSessionReply(makeParams()); + + const html = hoisted.writeFileSyncMock.mock.calls[0]?.[1]; + expect(html).toContain("$&$1"); + expect(html).toContain("const marker = '$&$1';"); + expect(html).toContain("const markedMarker = '$&$1';"); + expect(html).toContain("const highlightMarker = '$&$1';"); + }); }); diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index d60b5d7d3b8..f2ecdfe8eb2 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -27,6 +27,25 @@ function loadTemplate(fileName: string): string { return fs.readFileSync(path.join(EXPORT_HTML_DIR, fileName), "utf-8"); } +function replaceHtmlPlaceholder(template: string, name: string, value: string): string { + let replaced = false; + const placeholder = new RegExp( + `(<(?:script|style)\\b(?=[^>]*\\bdata-openclaw-export-placeholder="${name}")[^>]*>)()`, + ); + const next = template.replace( + placeholder, + (_match: string, openTag: string, closeTag: string) => { + replaced = true; + const finalOpenTag = openTag.replace(/\sdata-openclaw-export-placeholder="[^"]*"/, ""); + return `${finalOpenTag}${value}${closeTag}`; + }, + ); + if (!replaced) { + throw new Error(`Export HTML template missing ${name} placeholder`); + } + return next; +} + function generateHtml(sessionData: SessionData): string { const template = loadTemplate("template.html"); const templateCss = loadTemplate("template.css"); @@ -88,12 +107,13 @@ function generateHtml(sessionData: SessionData): string { .replace("/* {{CONTAINER_BG_DECL}} */", `--container-bg: ${containerBg};`) .replace("/* {{INFO_BG_DECL}} */", `--info-bg: ${infoBg};`); - return template - .replace("{{CSS}}", css) - .replace("{{JS}}", templateJs) - .replace("{{SESSION_DATA}}", sessionDataBase64) - .replace("{{MARKED_JS}}", markedJs) - .replace("{{HIGHLIGHT_JS}}", hljsJs); + return [ + ["CSS", css], + ["SESSION_DATA", sessionDataBase64], + ["MARKED_JS", markedJs], + ["HIGHLIGHT_JS", hljsJs], + ["JS", templateJs], + ].reduce((html, [name, value]) => replaceHtmlPlaceholder(html, name, value), template); } export async function buildExportSessionReply(params: HandleCommandsParams): Promise { diff --git a/src/auto-reply/reply/export-html/template.html b/src/auto-reply/reply/export-html/template.html index d1fa4198268..e1256b7cb67 100644 --- a/src/auto-reply/reply/export-html/template.html +++ b/src/auto-reply/reply/export-html/template.html @@ -4,9 +4,7 @@ Session Export - +