mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
283 lines
9.2 KiB
TypeScript
283 lines
9.2 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
import { execFile } from "child_process";
|
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { basename, dirname, join } from "path";
|
|
import { promisify } from "util";
|
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
import { NextResponse } from "next/server";
|
|
import { resolveSessionPath } from "@/lib/session-reader";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
type PiCodingAgentModule = {
|
|
getPackageDir: () => string;
|
|
};
|
|
|
|
type ExportHtmlModule = {
|
|
exportFromFile: (inputPath: string, outputPath: string) => Promise<string>;
|
|
};
|
|
|
|
async function getPiPackageDir(): Promise<string | null> {
|
|
try {
|
|
const { getPackageDir } = (await import("@earendil-works/pi-coding-agent")) as PiCodingAgentModule;
|
|
return getPackageDir();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function encodeHeaderValue(value: string): string {
|
|
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
|
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
|
);
|
|
}
|
|
|
|
function getContentDisposition(fileName: string, inline: boolean): string {
|
|
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "session.html";
|
|
const disposition = inline ? "inline" : "attachment";
|
|
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
|
}
|
|
|
|
async function getPiCliPath(): Promise<string | null> {
|
|
const candidates = new Set<string>();
|
|
const packageDir = await getPiPackageDir();
|
|
|
|
if (packageDir) {
|
|
candidates.add(join(packageDir, "dist", "cli.js"));
|
|
}
|
|
|
|
try {
|
|
const resolver = (import.meta as ImportMeta & {
|
|
resolve?: (specifier: string) => string | Promise<string>;
|
|
}).resolve;
|
|
if (typeof resolver === "function") {
|
|
const indexUrl = await resolver("@earendil-works/pi-coding-agent");
|
|
candidates.add(join(dirname(fileURLToPath(indexUrl)), "cli.js"));
|
|
}
|
|
} catch {
|
|
// Next.js production bundles can strip import.meta.resolve.
|
|
}
|
|
|
|
candidates.add(
|
|
join(
|
|
process.cwd(),
|
|
"node_modules",
|
|
"@earendil-works",
|
|
"pi-coding-agent",
|
|
"dist",
|
|
"cli.js"
|
|
)
|
|
);
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Patch the exported HTML to fix recursive functions that overflow
|
|
* the call stack on deep linear session trees (e.g., 5000+ entries).
|
|
*
|
|
* ## Root Cause
|
|
* pi-coding-agent's template.js uses recursive helpers to render and
|
|
* navigate the session tree in the exported HTML:
|
|
*
|
|
* 1. sortChildren(node) — recursively sorts children of every node.
|
|
* Calls itself via node.children.forEach(sortChildren).
|
|
* On a 5527-entry linear chain (no branches), this recurses 5527
|
|
* levels deep → stack overflow.
|
|
*
|
|
* 2. mapNodes(node) — recursively indexes tree nodes the first time
|
|
* a tree item is clicked. Same depth -> same overflow.
|
|
*
|
|
* 3. markActive(node) — recursively marks nodes on the active path.
|
|
* Calls itself via markActive(child) for each child.
|
|
* Same depth → same overflow.
|
|
*
|
|
* Both functions are inlined in the HTML by pi-coding-agent at export
|
|
* time. We cannot modify template.js directly (it's in node_modules
|
|
* and would be overwritten on npm install). Instead, we patch the
|
|
* generated HTML string before returning it to the client.
|
|
*
|
|
* ## Fix
|
|
* Replace each recursive function with an iterative equivalent:
|
|
*
|
|
* sortChildren → explicit stack (DFS pre-order, push children in
|
|
* reverse to maintain order)
|
|
* mapNodes → explicit stack (DFS pre-order)
|
|
* markActive → two-stack post-order (stack1 for traversal,
|
|
* stack2 for processing children before parent)
|
|
*
|
|
* ## Line Ending Normalization
|
|
* This file (route.ts) uses CRLF (Windows), while template.js uses LF
|
|
* (Unix). The template strings in the backtick literals inherit the
|
|
* file's CRLF line endings. At runtime, readFileSync() also returns
|
|
* CRLF on Windows. We normalize everything to LF before matching.
|
|
*
|
|
* The helper `n(s)` strips \r\n → \n on both the HTML and the
|
|
* replacement strings, ensuring cross-platform matching.
|
|
*/
|
|
function patchExportHtml(html: string): string {
|
|
// Normalize line endings: route.ts is CRLF, template.js is LF.
|
|
// Without this, the replace() below would fail on Windows.
|
|
const n = (s: string) => s.replace(/\r\n/g, "\n");
|
|
html = n(html);
|
|
|
|
const replaceRequired = (source: string, name: string, search: string, replacement: string) => {
|
|
const normalizedSearch = n(search);
|
|
const normalizedReplacement = n(replacement);
|
|
const matches = source.split(normalizedSearch).length - 1;
|
|
if (matches !== 1) {
|
|
throw new Error(`Failed to patch exported HTML: ${name} expected 1 match, found ${matches}`);
|
|
}
|
|
return source.replace(normalizedSearch, normalizedReplacement);
|
|
};
|
|
|
|
html = replaceRequired(
|
|
html,
|
|
"sortChildren",
|
|
` function sortChildren(node) {
|
|
node.children.sort((a, b) =>
|
|
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
|
);
|
|
node.children.forEach(sortChildren);
|
|
}`,
|
|
` function sortChildren(root) {
|
|
const stack = [root];
|
|
while (stack.length) {
|
|
const node = stack.pop();
|
|
node.children.sort((a, b) =>
|
|
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
|
);
|
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
stack.push(node.children[i]);
|
|
}
|
|
}
|
|
}`
|
|
);
|
|
|
|
html = replaceRequired(
|
|
html,
|
|
"mapNodes",
|
|
` function mapNodes(node) {
|
|
treeNodeMap.set(node.entry.id, node);
|
|
node.children.forEach(mapNodes);
|
|
}
|
|
tree.forEach(mapNodes);`,
|
|
` const stack = [...tree].reverse();
|
|
while (stack.length) {
|
|
const node = stack.pop();
|
|
treeNodeMap.set(node.entry.id, node);
|
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
stack.push(node.children[i]);
|
|
}
|
|
}`
|
|
);
|
|
|
|
html = replaceRequired(
|
|
html,
|
|
"markActive",
|
|
` function markActive(node) {
|
|
let has = activePathIds.has(node.entry.id);
|
|
for (const child of node.children) {
|
|
if (markActive(child)) has = true;
|
|
}
|
|
containsActive.set(node, has);
|
|
return has;
|
|
}`,
|
|
` function markActive(root) {
|
|
// Post-order traversal using two stacks
|
|
const stack1 = [root];
|
|
const stack2 = [];
|
|
while (stack1.length) {
|
|
const node = stack1.pop();
|
|
stack2.push(node);
|
|
for (const child of node.children) {
|
|
stack1.push(child);
|
|
}
|
|
}
|
|
while (stack2.length) {
|
|
const node = stack2.pop();
|
|
let has = activePathIds.has(node.entry.id);
|
|
for (const child of node.children) {
|
|
if (containsActive.get(child)) has = true;
|
|
}
|
|
containsActive.set(node, has);
|
|
}
|
|
}`
|
|
);
|
|
|
|
return html;
|
|
}
|
|
|
|
async function exportSession(filePath: string, outputPath: string): Promise<void> {
|
|
const cliPath = await getPiCliPath();
|
|
if (cliPath) {
|
|
await execFileAsync(process.execPath, [cliPath, "--export", filePath, outputPath], {
|
|
cwd: process.cwd(),
|
|
timeout: 30_000,
|
|
env: {
|
|
...process.env,
|
|
PI_OFFLINE: "1",
|
|
PI_SKIP_VERSION_CHECK: "1",
|
|
},
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const packageDir = await getPiPackageDir();
|
|
if (!packageDir) throw new Error("pi CLI not found");
|
|
|
|
const exporterUrl = pathToFileURL(join(packageDir, "dist", "core", "export-html", "index.js")).href;
|
|
const { exportFromFile } = (await import(exporterUrl)) as ExportHtmlModule;
|
|
await exportFromFile(filePath, outputPath);
|
|
}
|
|
|
|
export async function GET(
|
|
req: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const { id } = await params;
|
|
const inline = new URL(req.url).searchParams.get("inline") === "1";
|
|
|
|
try {
|
|
const filePath = await resolveSessionPath(id);
|
|
if (!filePath) {
|
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
|
}
|
|
|
|
const tempDir = join(tmpdir(), "pi-web-export");
|
|
mkdirSync(tempDir, { recursive: true });
|
|
|
|
const sessionBase = basename(filePath, ".jsonl");
|
|
const fileName = `pi-session-${sessionBase}.html`;
|
|
const outputPath = join(tempDir, `${randomUUID()}.html`);
|
|
|
|
try {
|
|
await exportSession(filePath, outputPath);
|
|
|
|
const html = readFileSync(outputPath, "utf8");
|
|
const patchedHtml = patchExportHtml(html);
|
|
return new Response(patchedHtml, {
|
|
headers: {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
"Content-Disposition": getContentDisposition(fileName, inline),
|
|
"Cache-Control": "no-cache",
|
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
},
|
|
});
|
|
} finally {
|
|
rmSync(outputPath, { force: true });
|
|
}
|
|
} catch (error) {
|
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
|
}
|
|
}
|