075e2f47ad
- Switched template CSS/JS inlining replacements to callback form in generation scripts to avoid `$` replacement expansion semantics. - Updated HTML export generation to use callback-based replacements for theme variables and session data injection to prevent accidental `$` substitution parsing. - Added regression tests for the inlined script ensuring literal `$'` regex tokens remain intact, no closing HTML tags are injected, and the script parses via `new Function`.
165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
import * as path from "node:path";
|
|
import type { AgentState } from "@oh-my-pi/pi-agent-core";
|
|
import { APP_NAME, isEnoent } from "@oh-my-pi/pi-utils";
|
|
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/theme";
|
|
import { type SessionEntry, type SessionHeader, SessionManager } from "../../session/session-manager";
|
|
// Pre-generated template (created by scripts/generate-template.ts at publish time)
|
|
import { TEMPLATE } from "./template.generated";
|
|
|
|
export interface ExportOptions {
|
|
outputPath?: string;
|
|
themeName?: string;
|
|
}
|
|
|
|
/** Parse a color string to RGB values. */
|
|
function parseColor(color: string): { r: number; g: number; b: number } | undefined {
|
|
const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
|
|
if (hexMatch) {
|
|
return {
|
|
r: Number.parseInt(hexMatch[1], 16),
|
|
g: Number.parseInt(hexMatch[2], 16),
|
|
b: Number.parseInt(hexMatch[3], 16),
|
|
};
|
|
}
|
|
const rgbMatch = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
if (rgbMatch) {
|
|
return {
|
|
r: Number.parseInt(rgbMatch[1], 10),
|
|
g: Number.parseInt(rgbMatch[2], 10),
|
|
b: Number.parseInt(rgbMatch[3], 10),
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Calculate relative luminance of a color (0-1, higher = lighter). */
|
|
function getLuminance(r: number, g: number, b: number): number {
|
|
const toLinear = (c: number) => {
|
|
const s = c / 255;
|
|
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
};
|
|
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
}
|
|
|
|
/** Adjust color brightness. */
|
|
function adjustBrightness(color: string, factor: number): string {
|
|
const parsed = parseColor(color);
|
|
if (!parsed) return color;
|
|
const adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor)));
|
|
return `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`;
|
|
}
|
|
|
|
/** Derive export background colors from a base color. */
|
|
function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } {
|
|
const parsed = parseColor(baseColor);
|
|
if (!parsed) {
|
|
return { pageBg: "rgb(24, 24, 30)", cardBg: "rgb(30, 30, 36)", infoBg: "rgb(60, 55, 40)" };
|
|
}
|
|
|
|
const luminance = getLuminance(parsed.r, parsed.g, parsed.b);
|
|
if (luminance > 0.5) {
|
|
return {
|
|
pageBg: adjustBrightness(baseColor, 0.96),
|
|
cardBg: baseColor,
|
|
infoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`,
|
|
};
|
|
}
|
|
return {
|
|
pageBg: adjustBrightness(baseColor, 0.7),
|
|
cardBg: adjustBrightness(baseColor, 0.85),
|
|
infoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`,
|
|
};
|
|
}
|
|
|
|
/** Generate CSS custom properties for theme. */
|
|
async function generateThemeVars(themeName?: string): Promise<string> {
|
|
const colors = await getResolvedThemeColors(themeName);
|
|
const lines: string[] = [];
|
|
for (const [key, value] of Object.entries(colors)) {
|
|
lines.push(`--${key}: ${value};`);
|
|
}
|
|
|
|
const themeExport = await getThemeExportColors(themeName);
|
|
const userMessageBg = colors.userMessageBg || "#343541";
|
|
const derived = deriveExportColors(userMessageBg);
|
|
|
|
lines.push(`--body-bg: ${themeExport.pageBg ?? derived.pageBg};`);
|
|
lines.push(`--container-bg: ${themeExport.cardBg ?? derived.cardBg};`);
|
|
lines.push(`--info-bg: ${themeExport.infoBg ?? derived.infoBg};`);
|
|
|
|
return lines.join(" ");
|
|
}
|
|
|
|
interface SessionData {
|
|
header: SessionHeader | null;
|
|
entries: SessionEntry[];
|
|
leafId: string | null;
|
|
systemPrompt?: string;
|
|
tools?: { name: string; description: string }[];
|
|
}
|
|
|
|
/** Generate HTML from bundled template with runtime substitutions. */
|
|
async function generateHtml(sessionData: SessionData, themeName?: string): Promise<string> {
|
|
const themeVars = await generateThemeVars(themeName);
|
|
const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toBase64();
|
|
|
|
// Use function replacements so `$'`, `$&`, `$$`, `$n`, etc. in the
|
|
// substituted CSS/base64 are not interpreted as substitution patterns
|
|
// (see https://mdn.io/String.replace).
|
|
return TEMPLATE.replace("<theme-vars/>", () => `<style>:root { ${themeVars} }</style>`).replace(
|
|
"{{SESSION_DATA}}",
|
|
() => sessionDataBase64,
|
|
);
|
|
}
|
|
|
|
/** Export session to HTML using SessionManager and AgentState. */
|
|
export async function exportSessionToHtml(
|
|
sm: SessionManager,
|
|
state?: AgentState,
|
|
options?: ExportOptions | string,
|
|
): Promise<string> {
|
|
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
|
|
|
|
const sessionFile = sm.getSessionFile();
|
|
if (!sessionFile) throw new Error("Cannot export in-memory session to HTML");
|
|
|
|
const sessionData: SessionData = {
|
|
header: sm.getHeader(),
|
|
entries: sm.getEntries(),
|
|
leafId: sm.getLeafId(),
|
|
systemPrompt: state?.systemPrompt.join("\n\n"),
|
|
tools: state?.tools?.map(t => ({ name: t.name, description: t.description })),
|
|
};
|
|
|
|
const html = await generateHtml(sessionData, opts.themeName);
|
|
const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(sessionFile, ".jsonl")}.html`;
|
|
|
|
await Bun.write(outputPath, html);
|
|
return outputPath;
|
|
}
|
|
|
|
/** Export session file to HTML (standalone). */
|
|
export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {
|
|
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
|
|
|
|
let sm: SessionManager;
|
|
try {
|
|
sm = await SessionManager.open(inputPath);
|
|
} catch (err) {
|
|
if (isEnoent(err)) throw new Error(`File not found: ${inputPath}`);
|
|
throw err;
|
|
}
|
|
|
|
const sessionData: SessionData = {
|
|
header: sm.getHeader(),
|
|
entries: sm.getEntries(),
|
|
leafId: sm.getLeafId(),
|
|
};
|
|
|
|
const html = await generateHtml(sessionData, opts.themeName);
|
|
const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`;
|
|
|
|
await Bun.write(outputPath, html);
|
|
return outputPath;
|
|
}
|