mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 17:00:58 +00:00
254 lines
8.5 KiB
JavaScript
254 lines
8.5 KiB
JavaScript
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
import { basename, join } from "path";
|
|
import { APP_NAME, getExportTemplateDir } from "../../config.js";
|
|
import {
|
|
getResolvedThemeColors,
|
|
getThemeExportColors,
|
|
} from "../../modes/interactive/theme/theme.js";
|
|
import { SessionManager } from "../session-manager.js";
|
|
/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */
|
|
function parseColor(color) {
|
|
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, g, b) {
|
|
const toLinear = (c) => {
|
|
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. Factor > 1 lightens, < 1 darkens. */
|
|
function adjustBrightness(color, factor) {
|
|
const parsed = parseColor(color);
|
|
if (!parsed) return color;
|
|
const adjust = (c) => 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 (e.g., userMessageBg). */
|
|
function deriveExportColors(baseColor) {
|
|
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);
|
|
const isLight = luminance > 0.5;
|
|
if (isLight) {
|
|
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 property declarations from theme colors.
|
|
*/
|
|
function generateThemeVars(themeName) {
|
|
const colors = getResolvedThemeColors(themeName);
|
|
const lines = [];
|
|
for (const [key, value] of Object.entries(colors)) {
|
|
lines.push(`--${key}: ${value};`);
|
|
}
|
|
// Use explicit theme export colors if available, otherwise derive from userMessageBg
|
|
const themeExport = getThemeExportColors(themeName);
|
|
const userMessageBg = colors.userMessageBg || "#343541";
|
|
const derivedColors = deriveExportColors(userMessageBg);
|
|
lines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`);
|
|
lines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`);
|
|
lines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`);
|
|
return lines.join("\n ");
|
|
}
|
|
/**
|
|
* Core HTML generation logic shared by both export functions.
|
|
*/
|
|
function generateHtml(sessionData, themeName) {
|
|
const templateDir = getExportTemplateDir();
|
|
const template = readFileSync(join(templateDir, "template.html"), "utf-8");
|
|
const templateCss = readFileSync(join(templateDir, "template.css"), "utf-8");
|
|
const templateJs = readFileSync(join(templateDir, "template.js"), "utf-8");
|
|
const markedJs = readFileSync(
|
|
join(templateDir, "vendor", "marked.min.js"),
|
|
"utf-8",
|
|
);
|
|
const hljsJs = readFileSync(
|
|
join(templateDir, "vendor", "highlight.min.js"),
|
|
"utf-8",
|
|
);
|
|
const themeVars = generateThemeVars(themeName);
|
|
const colors = getResolvedThemeColors(themeName);
|
|
const exportColors = deriveExportColors(colors.userMessageBg || "#343541");
|
|
const bodyBg = exportColors.pageBg;
|
|
const containerBg = exportColors.cardBg;
|
|
const infoBg = exportColors.infoBg;
|
|
// Base64 encode session data to avoid escaping issues
|
|
const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString(
|
|
"base64",
|
|
);
|
|
// Build the CSS with theme variables injected
|
|
const css = templateCss
|
|
.replace("{{THEME_VARS}}", themeVars)
|
|
.replace("{{BODY_BG}}", bodyBg)
|
|
.replace("{{CONTAINER_BG}}", containerBg)
|
|
.replace("{{INFO_BG}}", infoBg);
|
|
return template
|
|
.replace("{{CSS}}", css)
|
|
.replace("{{JS}}", templateJs)
|
|
.replace("{{SESSION_DATA}}", sessionDataBase64)
|
|
.replace("{{MARKED_JS}}", markedJs)
|
|
.replace("{{HIGHLIGHT_JS}}", hljsJs);
|
|
}
|
|
/** Built-in tool names that have custom rendering in template.js */
|
|
const BUILTIN_TOOLS = new Set([
|
|
"bash",
|
|
"read",
|
|
"write",
|
|
"edit",
|
|
"ls",
|
|
"find",
|
|
"grep",
|
|
]);
|
|
/**
|
|
* Pre-render custom tools to HTML using their TUI renderers.
|
|
*/
|
|
function preRenderCustomTools(entries, toolRenderer) {
|
|
const renderedTools = {};
|
|
for (const entry of entries) {
|
|
if (entry.type !== "message") continue;
|
|
const msg = entry.message;
|
|
// Find tool calls in assistant messages
|
|
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
for (const block of msg.content) {
|
|
if (block.type === "toolCall" && !BUILTIN_TOOLS.has(block.name)) {
|
|
const callHtml = toolRenderer.renderCall(block.name, block.arguments);
|
|
if (callHtml) {
|
|
renderedTools[block.id] = { callHtml };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Find tool results
|
|
if (msg.role === "toolResult" && msg.toolCallId) {
|
|
const toolName = msg.toolName || "";
|
|
// Only render if we have a pre-rendered call OR it's not a built-in tool
|
|
const existing = renderedTools[msg.toolCallId];
|
|
if (existing || !BUILTIN_TOOLS.has(toolName)) {
|
|
const resultHtml = toolRenderer.renderResult(
|
|
toolName,
|
|
msg.content,
|
|
msg.details,
|
|
msg.isError || false,
|
|
);
|
|
if (resultHtml) {
|
|
renderedTools[msg.toolCallId] = {
|
|
...existing,
|
|
resultHtml,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return renderedTools;
|
|
}
|
|
/**
|
|
* Export session to HTML using SessionManager and AgentState.
|
|
* Used by TUI's /export command.
|
|
*/
|
|
export async function exportSessionToHtml(sm, state, options) {
|
|
const opts =
|
|
typeof options === "string" ? { outputPath: options } : options || {};
|
|
const sessionFile = sm.getSessionFile();
|
|
if (!sessionFile) {
|
|
throw new Error("Cannot export in-memory session to HTML");
|
|
}
|
|
if (!existsSync(sessionFile)) {
|
|
throw new Error("Nothing to export yet - start a conversation first");
|
|
}
|
|
const entries = sm.getEntries();
|
|
// Pre-render custom tools if a tool renderer is provided
|
|
let renderedTools;
|
|
if (opts.toolRenderer) {
|
|
renderedTools = preRenderCustomTools(entries, opts.toolRenderer);
|
|
// Only include if we actually rendered something
|
|
if (Object.keys(renderedTools).length === 0) {
|
|
renderedTools = undefined;
|
|
}
|
|
}
|
|
const sessionData = {
|
|
header: sm.getHeader(),
|
|
entries,
|
|
leafId: sm.getLeafId(),
|
|
systemPrompt: state?.systemPrompt,
|
|
tools: state?.tools?.map((t) => ({
|
|
name: t.name,
|
|
description: t.description,
|
|
parameters: t.parameters,
|
|
})),
|
|
renderedTools,
|
|
};
|
|
const html = generateHtml(sessionData, opts.themeName);
|
|
let outputPath = opts.outputPath;
|
|
if (!outputPath) {
|
|
const sessionBasename = basename(sessionFile, ".jsonl");
|
|
outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
|
|
}
|
|
writeFileSync(outputPath, html, "utf8");
|
|
return outputPath;
|
|
}
|
|
/**
|
|
* Export session file to HTML (standalone, without AgentState).
|
|
* Used by CLI for exporting arbitrary session files.
|
|
*/
|
|
export async function exportFromFile(inputPath, options) {
|
|
const opts =
|
|
typeof options === "string" ? { outputPath: options } : options || {};
|
|
if (!existsSync(inputPath)) {
|
|
throw new Error(`File not found: ${inputPath}`);
|
|
}
|
|
const sm = SessionManager.open(inputPath);
|
|
const sessionData = {
|
|
header: sm.getHeader(),
|
|
entries: sm.getEntries(),
|
|
leafId: sm.getLeafId(),
|
|
systemPrompt: undefined,
|
|
tools: undefined,
|
|
};
|
|
const html = generateHtml(sessionData, opts.themeName);
|
|
let outputPath = opts.outputPath;
|
|
if (!outputPath) {
|
|
const inputBasename = basename(inputPath, ".jsonl");
|
|
outputPath = `${APP_NAME}-session-${inputBasename}.html`;
|
|
}
|
|
writeFileSync(outputPath, html, "utf8");
|
|
return outputPath;
|
|
}
|
|
//# sourceMappingURL=index.js.map
|