Files
oh-my-pi/packages/coding-agent/src/export/html/index.ts
T
can1357 162c9ca422 feat(coding-agent): sealed /share behind encrypted links and embedded subagent transcripts in exports
- Added `src/export/share.ts`: `/share` now snapshots the session JSON, gzips and seals it with a fresh AES-256-GCM key, and pushes the blob to a secret gist or the share server (1 MB cap with image/string/entry truncation via `sealToFit`); links are `<serverUrl>/<id>#<key>` with the key only in the fragment.
- Added `share-loader.js` and `scripts/generate-share-viewer.ts` building the static viewer the relay serves at `GET /s/<id>`: it fetches the sealed blob, decrypts in-browser, and hands the JSON to the export template via `window.__OMP_SESSION_DATA__`.
- Reworked the `/share` command in `command-controller.ts`/`builtin-registry.ts` off the plaintext-gist HTML upload, exported `LoadedCustomShare`, and exposed the session `SecretObfuscator` getter on `AgentSession` for redaction.
- Added `share.serverUrl` and `share.redactSecrets` settings backed by `DEFAULT_SHARE_URL` from pi-wire.
- HTML exports now embed subagent transcripts: `collectSubSessions` walks `<session>/<AgentId>.jsonl` recursively into `SessionData.subSessions`, with `includeSubSessions` opt-out and the exported `buildSessionData` reused by share snapshots.
- Added `share.test.ts` (snapshot/seal/server-url contracts) and `export-subsessions.test.ts`.
2026-06-12 15:00:41 +02:00

270 lines
10 KiB
TypeScript

import * as fs from "node:fs/promises";
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 {
loadEntriesFromFile,
type SessionEntry,
type SessionHeader,
SessionManager,
} from "../../session/session-manager";
import templateCss from "./template.css" with { type: "text" };
import templateHtml from "./template.html" with { type: "text" };
import templateJs from "./template.js" with { type: "text" };
// Pre-built React tool renderers: built by `bun --cwd=packages/collab-web run build:tool-views`,
// run automatically by root `prepare` on install and by `prepack` at publish.
import toolViewsJs from "./tool-views.generated.js" with { type: "text" };
let cachedTemplate: string | undefined;
/** Compose the standalone export template: minified CSS, tool renderers, and viewer JS inlined. */
export function getTemplate(): string {
if (cachedTemplate) return cachedTemplate;
const minifiedCss = templateCss
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\s+/g, " ")
.replace(/\s*([{}:;,])\s*/g, "$1")
.trim();
// Function replacements so `$'`, `$&`, `$$`, etc. inside the embedded
// CSS/JS are not interpreted as substitution patterns. The cast is safe:
// `with { type: "text" }` yields a string at runtime; bun-types just types
// every *.html import as HTMLBundle (TS can't vary types by import attribute).
cachedTemplate = (templateHtml as unknown as string)
.replace("<template-css/>", () => `<style>${minifiedCss}</style>`)
.replace("<template-tool-views/>", () => `<script>${toolViewsJs}</script>`)
.replace("<template-js/>", () => `<script>${templateJs}</script>`);
return cachedTemplate;
}
export interface ExportOptions {
outputPath?: string;
themeName?: string;
/** Embed subagent session transcripts found next to the session file (default true). */
includeSubSessions?: boolean;
}
/** 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. Exported for the share-viewer build script. */
export 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(" ");
}
/** Embedded subagent session transcript, keyed by slash-joined agent path in `SessionData.subSessions`. */
export interface SubSession {
/** Bare agent id (session file stem), e.g. "ToolAsk". */
agentId: string;
/** Key of the parent sub-session, or null when spawned by the main session. */
parent: string | null;
header: SessionHeader | null;
entries: SessionEntry[];
leafId: string | null;
}
export interface SessionData {
header: SessionHeader | null;
entries: SessionEntry[];
leafId: string | null;
systemPrompt?: string;
tools?: { name: string; description: string }[];
subSessions?: Record<string, SubSession>;
}
/** Snapshot the session (plus optional agent state) into the JSON shape the viewer renders. */
export function buildSessionData(sm: SessionManager, state?: AgentState): SessionData {
return {
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 })),
};
}
/**
* Collect subagent session transcripts stored next to a session file.
*
* A session at `<dir>/<name>.jsonl` keeps its subagent sessions at `<dir>/<name>/<AgentId>.jsonl`;
* each subagent's own children nest the same way under `<dir>/<name>/<AgentId>/`. Keys in the
* returned record are slash-joined ids relative to the main session ("ToolAsk", "ToolAsk/Helper").
* Corrupt or empty files are skipped silently.
*/
export async function collectSubSessions(sessionFile: string): Promise<Record<string, SubSession>> {
const result: Record<string, SubSession> = {};
if (!sessionFile.endsWith(".jsonl")) return result;
await collectSubSessionsFromDir(sessionFile.slice(0, -6), null, result);
return result;
}
async function collectSubSessionsFromDir(
dir: string,
parentKey: string | null,
out: Record<string, SubSession>,
): Promise<void> {
let names: string[];
try {
names = await fs.readdir(dir);
} catch (err) {
if (isEnoent(err)) return;
throw err;
}
for (const name of names) {
if (!name.endsWith(".jsonl") || name.includes(".bak")) continue;
const agentId = name.slice(0, -6);
const key = parentKey ? `${parentKey}/${agentId}` : agentId;
const fileEntries = await loadEntriesFromFile(path.join(dir, name));
// Empty/corrupt files (no valid session header) load as [] — skip silently.
if (fileEntries.length > 0) {
const header = (fileEntries.find(e => e.type === "session") as SessionHeader | undefined) ?? null;
const entries = fileEntries.filter((e): e is SessionEntry => e.type !== "session");
out[key] = {
agentId,
parent: parentKey,
header,
entries,
leafId: entries.length > 0 ? entries[entries.length - 1].id : null,
};
}
await collectSubSessionsFromDir(path.join(dir, agentId), key, out);
}
}
/** 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 getTemplate()
.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 = buildSessionData(sm, state);
if (opts.includeSubSessions !== false) {
const subSessions = await collectSubSessions(sessionFile);
if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions;
}
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(),
};
if (opts.includeSubSessions !== false) {
const subSessions = await collectSubSessions(inputPath);
if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions;
}
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;
}