495 lines
21 KiB
TypeScript
495 lines
21 KiB
TypeScript
/**
|
||
* 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<T>(work: Promise<T>, ms: number, what: string): Promise<T> {
|
||
let timer: NodeJS.Timeout | undefined;
|
||
try {
|
||
return await Promise.race([
|
||
work,
|
||
new Promise<never>((_, 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<Buffer> {
|
||
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('<!doctype html><body><div id="stage"></div></body>');
|
||
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<unknown>; 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<string, ToolDef> = {
|
||
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<ToolResult | null> {
|
||
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') };
|
||
}
|