feat: add plan viewer with browser preview (LET-7645) (#1089)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
65
src/web/generate-plan-viewer.ts
Normal file
65
src/web/generate-plan-viewer.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Plan Viewer Generator
|
||||
*
|
||||
* Creates a self-contained HTML file that renders a plan's markdown content
|
||||
* in the browser, reusing the Memory Palace's visual language. Writes to
|
||||
* ~/.letta/viewers/ and opens in the default browser.
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import planViewerTemplate from "./plan-viewer-template.txt";
|
||||
import type { PlanViewerData } from "./types";
|
||||
|
||||
const VIEWERS_DIR = join(homedir(), ".letta", "viewers");
|
||||
|
||||
export interface GeneratePlanResult {
|
||||
filePath: string;
|
||||
opened: boolean;
|
||||
}
|
||||
|
||||
export async function generateAndOpenPlanViewer(
|
||||
planContent: string,
|
||||
planFilePath: string,
|
||||
options?: { agentName?: string },
|
||||
): Promise<GeneratePlanResult> {
|
||||
const data: PlanViewerData = {
|
||||
agent: { name: options?.agentName ?? "" },
|
||||
planContent,
|
||||
planFilePath,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Safely embed JSON - escape < to \u003c to prevent </script> injection
|
||||
const jsonPayload = JSON.stringify(data).replace(/</g, "\\u003c");
|
||||
const html = planViewerTemplate.replace(
|
||||
"<!--LETTA_PLAN_DATA_PLACEHOLDER-->",
|
||||
() => jsonPayload,
|
||||
);
|
||||
|
||||
// Write to ~/.letta/viewers/ with owner-only permissions
|
||||
if (!existsSync(VIEWERS_DIR)) {
|
||||
mkdirSync(VIEWERS_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
try {
|
||||
chmodSync(VIEWERS_DIR, 0o700);
|
||||
} catch {}
|
||||
|
||||
const filePath = join(VIEWERS_DIR, "plan.html");
|
||||
writeFileSync(filePath, html);
|
||||
chmodSync(filePath, 0o600);
|
||||
|
||||
// Open in browser (skip inside tmux)
|
||||
const isTmux = Boolean(process.env.TMUX);
|
||||
if (!isTmux) {
|
||||
try {
|
||||
const { default: openUrl } = await import("open");
|
||||
await openUrl(filePath, { wait: false });
|
||||
} catch {
|
||||
throw new Error(`Could not open browser. Run: open ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath, opened: !isTmux };
|
||||
}
|
||||
5
src/web/html.d.ts
vendored
5
src/web/html.d.ts
vendored
@@ -2,3 +2,8 @@ declare module "*memory-viewer-template.txt" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*plan-viewer-template.txt" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
351
src/web/plan-viewer-template.txt
Normal file
351
src/web/plan-viewer-template.txt
Normal file
File diff suppressed because one or more lines are too long
@@ -28,6 +28,13 @@ export interface MemoryFile {
|
||||
content: string; // raw markdown body (after frontmatter)
|
||||
}
|
||||
|
||||
export interface PlanViewerData {
|
||||
agent: { name: string };
|
||||
planContent: string;
|
||||
planFilePath: string;
|
||||
generatedAt: string; // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
export interface MemoryCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
|
||||
Reference in New Issue
Block a user