/** * RenderMermaid (#1025) — check a diagram, then show it. * * The agent gets no guidance anywhere about how to write Mermaid, so it picks a * CDN and a version per document and the syntax it writes often doesn't match * the version that ends up running. This tool closes that loop: it parses with * the version WE ship, hands back the failing line, renders a PNG the agent can * actually look at (ReadImage takes no SVG), and — for HTML — swaps the diagram * source for an inline SVG so the diagram itself needs no script, no network and * no version agreement with anything. * * One render pass produces both files, so what the agent inspects and what the * document carries can never disagree. * * The rest of the page is the author's. This tool replaces diagram blocks and * touches nothing else — in particular it never deletes the page's scripts, not * even a Mermaid loader the SVG has made redundant (#1027). */ import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'fs'; import { dirname, join, extname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { chromium } from 'playwright'; import sharp from 'sharp'; import type { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './tool-context.js'; import { resolveAndGuard, resolveOutputPathWithin, assertWritable } from './core.js'; import { buildLaunchOptions } from '../browser-launch.js'; import { logger } from '../../logger.js'; import { loadConfig } from '../../config.js'; import { extractMermaidBlocks, replaceHtmlBlocksWithSvg, type MermaidSourceKind } from './mermaid-blocks.js'; const __dirnameMermaid = dirname(fileURLToPath(import.meta.url)); /** * Budget for the WHOLE call, shared by every page operation. Per-diagram limits * alone don't bound anything: 30 diagrams × 20s is ten minutes of worker slot * (#1027 P2), so the deadline is what actually protects it. */ const RENDER_TIMEOUT_MS = 60_000; /** Ceiling for one diagram, capped further by whatever is left of the budget. */ const DIAGRAM_TIMEOUT_MS = 20_000; const MAX_DIAGRAMS = 30; /** A diagram this long is a mistake, and it is cheaper to refuse than to render. */ const MAX_CODE_CHARS = 20_000; /** * The PNG is a screenshot, so it carries exactly as many pixels as the browser * says the device has. At 1 the labels came out at the CSS size and turned to * mush the moment anyone zoomed or printed the page (#1257). 2 doubles the * raster without touching the layout — the SVG in the HTML output is unaffected. */ const DEVICE_SCALE_FACTOR = 2; /** * Ceiling on the pixel count of the PNG we write, applied after the screenshot. * * The doc tells the agent to open the file with ReadImage, and ReadImage does * not shrink anything — the bytes go into the conversation as they are. The gate * in agent-loop/image-context.ts allows at most 8,000 tokens of image per round * and, when the total is over, drops EVERY image of that round, not just the big * one. context/token-estimate.ts charges pixels / 750 and stops counting at * 1568×1568 = 2,458,624 px = 3,279 tokens. * * So the cap has to sit well under that plateau. Cutting at 2.45 Mpx would buy * nothing: a measured 25-step chain came out 504×5174 = 2,607,696 px at scale 2, * already charged the full 3,279, so three of them are 9,837 — over the ceiling * no matter how much context is free. Dividing the ceiling by five diagrams * gives 1,600 tokens each, and 1,600 × 750 = 1,200,000 px. Five capped images * total exactly 8,000, and the gate compares with `>`, so they still get in. The * same 25-step chain measures 341×3501 = 1,193,841 px after the cap: 1,592 * tokens, three of them 4,776. * * The cost: nothing until the picture is past 1.2 Mpx (the 10-step chain at * 1,035,216 px is untouched), and output below what scale 1 used to give only * once the CSS box ITSELF is past 1.2 Mpx — a chain of roughly 45 steps in the * shape measured above. The capped 25-step is still 1.8× its own CSS box. */ const MAX_PNG_PIXELS = 1_200_000; /** * page.evaluate takes no timeout of its own — Playwright's default only covers * navigation and locator work. Without this, a pathological diagram parks a * Chromium instance and a worker slot forever; the reject unwinds into the * finally that closes the browser. */ async function withTimeout(work: Promise, ms: number, what: string): Promise { let timer: NodeJS.Timeout | undefined; try { return await Promise.race([ work, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms); }), ]); } finally { if (timer) clearTimeout(timer); } } /** A PNG carries its pixel size in the IHDR chunk: width at byte 16, height at 20. */ export function readPngPixelSize(png: Buffer): { width: number; height: number } { if (png.length < 24 || png.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG'); return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; } /** * Shrink the screenshot to MAX_PNG_PIXELS if it is over, keeping the shape. * * Both sides are floored on purpose: floor × floor is always at or under the * cap, which is what makes the token estimate provably bounded. Scaling one side * and letting the other be derived can round the product a few hundred pixels * past the cap, and the estimator rounds tokens up. */ export async function capPngPixels(png: Buffer, maxPixels = MAX_PNG_PIXELS): Promise { const { width, height } = readPngPixelSize(png); const pixels = width * height; if (pixels <= maxPixels) return png; const scale = Math.sqrt(maxPixels / pixels); const w = Math.max(1, Math.floor(width * scale)); const h = Math.max(1, Math.floor(height * scale)); // fit 'inside' preserves the aspect ratio; sharp's default ('cover') crops. return await sharp(png).resize(w, h, { fit: 'inside' }).png().toBuffer(); } /** * Where the Mermaid bundle lives at runtime. `vendor/` is what the Docker image * ships (the builder copies it out of the UI's node_modules); the node_modules * path is the source checkout. Deliberately ONE source — the UI's own * dependency — so the version that validates is the version that renders. */ export function mermaidBundleCandidates(rootDir: string): string[] { return [ join(rootDir, 'vendor', 'mermaid', 'mermaid.min.js'), join(rootDir, 'ui', 'node_modules', 'mermaid', 'dist', 'mermaid.min.js'), ]; } export function resolveMermaidBundle(rootDir = resolve(__dirnameMermaid, '../../..')): string { for (const candidate of mermaidBundleCandidates(rootDir)) { if (existsSync(candidate)) return candidate; } throw new Error( 'Mermaid bundle not found. Run `npm run build:ui` (it copies mermaid into vendor/) or install the UI dependencies.', ); } /** * The UMD bundle doesn't carry its own version, so read it from the sidecar the * copy script writes (vendor/) or from the package we copied it out of (dev). * Reporting it matters: "which version said this was fine" is the question the * whole tool exists to answer. */ export function readMermaidVersion(bundlePath: string): string { const sidecar = join(dirname(bundlePath), 'version.txt'); if (existsSync(sidecar)) return readFileSync(sidecar, 'utf8').trim(); const pkg = join(dirname(bundlePath), '..', 'package.json'); if (existsSync(pkg)) { try { return String(JSON.parse(readFileSync(pkg, 'utf8')).version ?? 'unknown'); } catch { /* fall through */ } } return 'unknown'; } export interface MermaidDiagnostic { message: string; /** 1-based line within the diagram source, when the parser reported one. */ line?: number; /** The offending line itself — the message alone rarely points anywhere useful. */ sourceLine?: string; } /** * Turn a Mermaid parse error into something the agent can act on. Mermaid says * "Parse error on line 2:" and then quotes a flattened, context-free excerpt; * pairing the number with the real line is what makes it fixable. */ export function parseMermaidError(message: string, code: string): MermaidDiagnostic { const firstLine = message.split('\n')[0].trim(); const match = message.match(/(?:on|at) line (\d+)/i); if (!match) return { message: firstLine }; const line = Number(match[1]); const sourceLine = code.split('\n')[line - 1]; return { message: firstLine, line, ...(sourceLine !== undefined ? { sourceLine } : {}) }; } export interface MermaidRenderResult { ok: boolean; svg?: string; png?: Buffer; diagnostic?: MermaidDiagnostic; } /** * Render every diagram in ONE browser. Launching Chromium costs about a second, * so a page per diagram would make checking a document with five of them * pointlessly slow. */ export async function renderMermaidDiagrams( codes: string[], opts: { bundlePath?: string; timeoutMs?: number; totalTimeoutMs?: number } = {}, ): Promise<{ version: string; results: MermaidRenderResult[] }> { const totalMs = opts.totalTimeoutMs ?? RENDER_TIMEOUT_MS; const perDiagramMs = Math.min(opts.timeoutMs ?? DIAGRAM_TIMEOUT_MS, totalMs); const deadline = Date.now() + totalMs; const remaining = () => deadline - Date.now(); const bundlePath = opts.bundlePath ?? resolveMermaidBundle(); const bundle = readFileSync(bundlePath, 'utf8'); // Honour the operator's browser settings (executable path / channel) exactly // like the browser tool and the session manager do — this was the only launch // site ignoring them. const browser = await chromium.launch(buildLaunchOptions(loadConfig().browser, true)); try { const page = await browser.newPage({ deviceScaleFactor: DEVICE_SCALE_FACTOR }); page.setDefaultTimeout(perDiagramMs); await page.setContent('
'); await page.addScriptTag({ content: bundle }); await page.evaluate(() => { const m = (window as unknown as { mermaid: { initialize: (c: unknown) => void } }).mermaid; // Same options the UI initializes with (FilePreview) — a diagram that // passes here must behave the same when the app renders it. m.initialize({ startOnLoad: false, theme: 'default' }); }); const version = readMermaidVersion(bundlePath); const results: MermaidRenderResult[] = []; // A page that stopped answering does not come back, so the first timeout // ends the run. Carrying on would spend the full per-diagram budget on // every remaining diagram for nothing (#1027 P2). let giveUp: string | null = null; for (let i = 0; i < codes.length; i++) { if (!giveUp && remaining() <= 0) giveUp = `the ${totalMs}ms render budget ran out`; if (giveUp) { results.push({ ok: false, diagnostic: { message: `Not rendered: ${giveUp}.` } }); continue; } const budget = Math.min(perDiagramMs, remaining()); const rendered = await withTimeout( page.evaluate( async ([code, id]) => { const m = (window as unknown as { mermaid: { parse: (c: string) => Promise; render: (id: string, c: string) => Promise<{ svg: string }> }; }).mermaid; try { await m.parse(code); const { svg } = await m.render(id, code); return { ok: true as const, svg }; } catch (e) { const err = e as { str?: string; message?: string }; return { ok: false as const, message: String(err?.str ?? err?.message ?? e) }; } }, [codes[i], `render-${i}`] as [string, string], ), budget, `diagram ${i + 1}`, ).catch((e: unknown) => ({ ok: false as const, timedOut: true, message: (e as Error).message ?? String(e) })); if (!rendered.ok) { if ('timedOut' in rendered) giveUp = `the renderer stopped responding (diagram ${i + 1})`; results.push({ ok: false, diagnostic: parseMermaidError(rendered.message, codes[i]) }); continue; } // Screenshot the SVG we just produced — the PNG is a picture OF the // embedded artwork, not a second, independent render. Both steps draw on // the same budget, so the whole call stays bounded. const png = await withTimeout( (async () => { await page.evaluate((svg) => { const stage = document.getElementById('stage')!; stage.innerHTML = svg; stage.style.cssText = 'display:inline-block;background:#fff;padding:8px'; }, rendered.svg); return page.locator('#stage').screenshot({ type: 'png' }); })(), Math.max(1, Math.min(perDiagramMs, remaining())), `diagram ${i + 1} screenshot`, ).catch((e: unknown) => { giveUp = `the renderer stopped responding (diagram ${i + 1})`; logger.warn(`[mermaid] screenshot failed: ${(e as Error).message ?? String(e)}`); return undefined; }); // Scale 2 is what makes the labels readable, but it also quadruples the // pixels, and a tall diagram then blows the image budget and takes every // other image of that round down with it. Shrink here, once, so the file // on disk is the one the agent can actually open. const capped = png ? await capPngPixels(png).catch((e: unknown) => { logger.warn(`[mermaid] downscale failed: ${(e as Error).message ?? String(e)}`); return undefined; }) : undefined; if (!capped) { results.push({ ok: false, diagnostic: { message: `Rendered, but the image could not be produced (diagram ${i + 1}).` } }); continue; } results.push({ ok: true, svg: rendered.svg, png: capped }); } return { version, results }; } finally { await browser.close().catch(() => {}); } } // --- Tool surface ----------------------------------------------------------- export const TOOL_DEFS: Record = { RenderMermaid: { type: 'function', function: { name: 'RenderMermaid', description: 'Mermaid 図の構文を検査して PNG 画像に描画し、HTML ファイルなら図を SVG に置き換えて外部依存を無くす。詳細は ReadToolDoc({ name: "RenderMermaid" }) で取得可能。', parameters: { type: 'object', properties: { code: { type: 'string', description: 'Mermaid diagram source to check and render.' }, path: { type: 'string', description: 'Workspace file (.md / .html) to check instead: every Mermaid block in it is validated and rendered.', }, embed: { type: 'boolean', description: 'HTML only, default true: replace each diagram with the rendered SVG so it displays without a script.', }, }, }, }, }, }; function detectKind(filePath: string): MermaidSourceKind { return /\.html?$/i.test(filePath) ? 'html' : 'md'; } /** * PNG name for a source file. Flattening the WHOLE relative path matters: * naming by basename alone made `output/a.html` and `output/sub/a.html` write * the same file, so the second overwrote the first and the agent then looked at * the wrong diagram (#1027 P3). */ function outputStem(filePath: string | undefined): string { if (!filePath) return join('output', 'diagrams', 'diagram'); const withoutExt = filePath.slice(0, filePath.length - extname(filePath).length); const flat = withoutExt.replace(/[^\w.-]+/g, '_').replace(/^_+/, '') || 'diagram'; return join('output', 'diagrams', flat); } /** `String(e)` on an Error prints "Error: …" into a sentence that already says * what went wrong (#1027 P3). */ function errText(e: unknown): string { return e instanceof Error ? e.message : String(e); } /** Returns null for names this module doesn't own — the dispatch chain in * tools/index.ts relies on that to fall through to the next module. */ export async function executeTool(name: string, input: unknown, ctx: ToolContext): Promise { if (name !== 'RenderMermaid') return null; const args = (input ?? {}) as { code?: unknown; path?: unknown; embed?: unknown }; const codeArg = typeof args.code === 'string' ? args.code : undefined; const pathArg = typeof args.path === 'string' ? args.path : undefined; if (!codeArg && !pathArg) return { isError: true, output: 'RenderMermaid needs either `code` or `path`.' }; if (codeArg && pathArg) return { isError: true, output: 'RenderMermaid takes `code` or `path`, not both.' }; // Every path through this tool saves a PNG, so a task that may not write // files cannot use it at all. Say so before launching a browser, the way // PdfToImages does — rendering first and refusing afterwards left images on // disk under a report that said nothing had been written (#1027 P3). if (!ctx.editAllowed) { return { isError: true, output: 'RenderMermaid is not allowed: edit flag is false' }; } let sourceText = ''; let absPath: string | undefined; let kind: MermaidSourceKind = 'md'; if (pathArg) { try { absPath = resolveAndGuard(ctx.workspacePath, pathArg); sourceText = readFileSync(absPath, 'utf8'); } catch (e) { return { isError: true, output: `Could not read ${pathArg}: ${errText(e)}` }; } kind = detectKind(pathArg); } // Same reasoning, and the same rule Write/Edit enforce: readonly/ is the // user's own area. Refuse before rendering rather than after, so the refusal // and what is left on disk agree. const wantsEmbed = Boolean(pathArg) && kind === 'html' && args.embed !== false; if (wantsEmbed && absPath) { try { assertWritable(ctx.workspacePath, absPath); } catch (e) { return { isError: true, output: `Could not update ${pathArg}: ${errText(e)}\n` + 'Pass embed: false to check the diagrams and get their PNGs without rewriting the file.', }; } } const blocks = codeArg ? [{ code: codeArg, start: 0, end: 0 }] : extractMermaidBlocks(sourceText, kind); if (blocks.length === 0) { return { isError: false, output: `No Mermaid blocks found in ${pathArg}.` }; } if (blocks.length > MAX_DIAGRAMS) { return { isError: true, output: `Too many diagrams (${blocks.length}); the limit is ${MAX_DIAGRAMS}.` }; } const oversized = blocks.findIndex(b => b.code.length > MAX_CODE_CHARS); if (oversized >= 0) { return { isError: true, output: `Diagram ${oversized + 1} is ${blocks[oversized].code.length} characters; the limit is ${MAX_CODE_CHARS}. ` + 'Split it into several smaller diagrams — one that large is unreadable anyway.', }; } let version: string; let results: MermaidRenderResult[]; try { ({ version, results } = await renderMermaidDiagrams(blocks.map(b => b.code))); } catch (e) { logger.warn(`[mermaid] render failed: ${errText(e)}`); return { isError: true, output: `Mermaid rendering is unavailable: ${errText(e)}` }; } const stem = outputStem(pathArg); const lines: string[] = [`mermaid ${version} — ${blocks.length} diagram(s)`]; const svgs: (string | null)[] = []; for (let i = 0; i < results.length; i++) { const r = results[i]; const label = blocks.length === 1 ? '' : ` #${i + 1}`; if (!r.ok) { svgs.push(null); const d = r.diagnostic!; lines.push( `FAILED${label}: ${d.message}` + (d.line ? `\n line ${d.line}: ${d.sourceLine ?? ''}` : ''), ); continue; } svgs.push(r.svg ?? null); const pngRel = `${stem}${blocks.length === 1 ? '' : `-${i + 1}`}.png`; try { const pngAbs = resolveOutputPathWithin(ctx.workspacePath, pngRel, ['output']); mkdirSync(dirname(pngAbs), { recursive: true }); writeFileSync(pngAbs, r.png!); lines.push(`OK${label}: ${pngRel} — open it with ReadImage to check the diagram is what you meant.`); } catch (e) { lines.push(`OK${label}: rendered, but the image could not be saved (${errText(e)}).`); } } const failed = results.filter(r => !r.ok).length; // Failing to write is a result the agent must not read past — every diagram // can render fine and the file still not be updated (#1027 P3). let embedError: string | null = null; if (wantsEmbed && absPath) { const rendered = svgs.filter(Boolean).length; if (rendered > 0) { try { writeFileSync(absPath, replaceHtmlBlocksWithSvg(sourceText, blocks, svgs), 'utf8'); // Claim only what is true, and only about the diagrams: nothing else in // the file was touched, so nothing else can be promised about it. lines.push( `Embedded ${rendered} diagram(s) into ${pathArg} as inline SVG — they display with no script and no ` + 'network. Everything else in the file is untouched, and the original source is kept in ' + 'data-mermaid-source for later edits.', ); } catch (e) { embedError = errText(e); lines.push(`Could not update ${pathArg}: ${embedError}`); } } } if (failed > 0) { lines.push( `${failed} diagram(s) failed. Common cause: unquoted parentheses or brackets in a label — ` + 'write A["text (note)"] instead of A[text (note)].', ); } return { isError: failed > 0 || embedError !== null, output: lines.join('\n') }; }