/**
* `openpencil_render` — render a `.op` design document to PNG without a
* window. OpenPencil's own exporter is the exact primary path; Jian is an
* explicitly-labelled runtime-preview fallback when that binary is absent.
*
* The tool returns plain JSON (never an `ImageBlock` — the DeepSeek
* adapter rejects image blocks anywhere in a request). A browser-only
* envelope with a signed preview URL rides `output.presentationMeta`
* into `ToolCallBlock.meta`, where the keyed `tool.call.toolview`
* component recognizes it and renders `
`.
* @module dsh-openpencil/tool
*/
import { defineTool, type JsonValue, type ToolRunContext } from '@deepseek-ai/dsh-tools'
import { basename } from 'node:path'
import {
RenderAccessController,
RendererBinaryMissingError,
createDocumentSnapshot,
createRenderOutput,
findOpenPencilBinary,
findJianBinary,
projectRenderGrant,
resolveInputFile,
runOpenPencilRender,
runJianRender,
verifyRenderOutput,
type RenderFrame,
type RenderResult,
} from './renderer.js'
import type { ViewerAssetController } from './viewer-assets.js'
import type { EditorHostController } from './editor-host.js'
import { OPENPENCIL_RENDER_TOOL_NAME } from './tool-names.js'
/** Session workspace the caller resolves paths against (mirrors first-party tools). */
function sessionWorkspace(exec: ToolRunContext): string {
return exec.agent?.session.header.cwd ?? process.cwd()
}
const renderJson = (_args: unknown, value: unknown): [{ type: 'text'; text: string }] => [{
type: 'text',
text: JSON.stringify(value, null, 2),
}]
/** Pure, replayable presentation projection — the browser-only envelope. */
function makePresentationMeta(
controller: RenderAccessController,
viewerAssets?: ViewerAssetController,
editorHost?: EditorHostController,
) {
return (_args: unknown, value: JsonValue): JsonValue => {
const result = value as unknown as RenderResult
const editor = result.editable === true
? editorHost?.grantFor(result.sourcePath, result.document?.sha256)
: undefined
return projectRenderGrant(value, controller, viewerAssets?.viewerGrant, editor)
}
}
export interface DesignRenderArgs {
path: string
width?: number
height?: number
scale?: number
/** Explicitly expose the original source to the managed sidebar editor. */
editable?: boolean
/** Expand the editor once on the live result card; intended for openpencil_new follow-ups. */
autoOpen?: boolean
}
/** Create the `openpencil_render` tool definition bound to one controller. */
export function createDesignRenderTool(
controller: RenderAccessController,
viewerAssets?: ViewerAssetController,
editorHost?: EditorHostController,
) {
return defineTool({
name: OPENPENCIL_RENDER_TOOL_NAME,
description: 'Render an existing OpenPencil .op design document exactly as the design canvas, '
+ 'then show a PNG and an optional interactive read-only canvas in the conversation. '
+ 'Give the absolute path to a .op file (or a path relative to the session workspace). '
+ 'For a new design when no .op file exists, call openpencil_new first. '
+ 'The image appears directly in the chat; the file path is returned for further use. '
+ 'Set editable=true when the user asks for an editable design. For the immediate render after openpencil_new, set editable=true and autoOpen=true; no extra confirmation is needed. '
+ 'Leave width/height unset for design-accurate output. Width/height are only supported '
+ 'by the lower-fidelity Jian runtime fallback.',
parameters: {
path: { type: 'string', required: true, description: 'Path to the .op design document to render.' },
width: { type: 'number', description: 'Explicit logical viewport width in pixels. Omit to use the document size.' },
height: { type: 'number', description: 'Explicit logical viewport height in pixels. Omit to use the document size.' },
scale: { type: 'number', description: 'Pixel scale factor applied to the output (device-pixel ratio). Default 1.' },
editable: { type: 'boolean', description: 'Expose an Edit in sidebar action for the original .op source. Default false.' },
autoOpen: { type: 'boolean', description: 'Automatically expand the editor once when this is the immediate render after openpencil_new. Default false.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
filename: { type: 'string', required: true },
mimeType: { type: 'string', const: 'image/png', required: true },
kind: { type: 'string', const: 'image', required: true },
description: { type: 'string', required: true },
sourceTool: { type: 'string', const: OPENPENCIL_RENDER_TOOL_NAME, required: true },
previewIntent: { type: 'string', const: 'image', required: true },
bytes: { type: 'integer', required: true },
width: { type: 'integer' },
height: { type: 'integer' },
sha256: { type: 'string' },
sourcePath: { type: 'string' },
renderer: { type: 'string' },
rendererBinary: { type: 'string' },
fidelity: { type: 'string' },
warnings: { type: 'array', items: { type: 'string' } },
frames: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
filename: { type: 'string', required: true },
mimeType: { type: 'string', const: 'image/png', required: true },
bytes: { type: 'integer', required: true },
width: { type: 'integer', required: true },
height: { type: 'integer', required: true },
sha256: { type: 'string', required: true },
id: { type: 'string' },
name: { type: 'string' },
index: { type: 'integer' },
},
},
},
frameCount: { type: 'integer' },
editable: { type: 'boolean' },
autoOpenEditor: { type: 'boolean' },
document: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
filename: { type: 'string', required: true },
mimeType: { type: 'string', const: 'application/json', required: true },
bytes: { type: 'integer', required: true },
sha256: { type: 'string', required: true },
},
},
note: { type: 'string' },
},
},
render: renderJson,
presentationMeta: makePresentationMeta(controller, viewerAssets, editorHost),
},
async execute(args: DesignRenderArgs, exec): Promise {
const input = await resolveInputFile(args.path, sessionWorkspace(exec))
const document = await createDocumentSnapshot(input)
const warnings: string[] = []
let out: string
let renderer: 'openpencil' | 'jian'
let rendererBinary: string
let fidelity: 'exact' | 'runtime-preview'
let exactFrames: Awaited>['frames'] | undefined
const openPencil = findOpenPencilBinary()
if (openPencil !== undefined) {
if (args.width !== undefined || args.height !== undefined) {
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: width/height are not supported by the exact OpenPencil renderer; omit them and use scale`)
}
try {
const exact = await runOpenPencilRender({
binary: openPencil,
input: document.path,
scale: args.scale,
signal: exec.signal,
})
out = exact.png
exactFrames = exact.frames
warnings.push(...exact.warnings)
renderer = 'openpencil'
rendererBinary = openPencil
fidelity = 'exact'
} catch (error) {
if (!(error instanceof RendererBinaryMissingError)) throw error
const fallback = await createRenderOutput()
const jian = findJianBinary()
await runJianRender({
binary: jian, input: document.path, out: fallback,
width: args.width, height: args.height, scale: args.scale, signal: exec.signal,
})
out = fallback
renderer = 'jian'
rendererBinary = jian
fidelity = 'runtime-preview'
warnings.push('OpenPencil exact renderer was unavailable; using lower-fidelity Jian runtime preview.')
}
} else {
const fallback = await createRenderOutput()
const jian = findJianBinary()
await runJianRender({
binary: jian, input: document.path, out: fallback,
width: args.width, height: args.height, scale: args.scale, signal: exec.signal,
})
out = fallback
renderer = 'jian'
rendererBinary = jian
fidelity = 'runtime-preview'
warnings.push('OpenPencil exact renderer was not found; using lower-fidelity Jian runtime preview.')
}
const verified = await verifyRenderOutput(out)
const frames: RenderFrame[] | undefined = exactFrames === undefined
? undefined
: await Promise.all(exactFrames.map(async frame => {
const frameVerified = frame.png === out ? verified : await verifyRenderOutput(frame.png)
return {
path: frame.png,
filename: basename(frame.png),
mimeType: 'image/png' as const,
bytes: frameVerified.bytes,
width: frameVerified.width,
height: frameVerified.height,
sha256: frameVerified.sha256,
...(frame.id === undefined ? {} : { id: frame.id }),
...(frame.name === undefined ? {} : { name: frame.name }),
index: frame.index,
}
}))
const result: RenderResult = {
path: out,
filename: basename(out),
mimeType: 'image/png',
kind: 'image',
description: `Rendered ${input} with ${renderer} (${fidelity})`,
sourceTool: OPENPENCIL_RENDER_TOOL_NAME,
previewIntent: 'image',
bytes: verified.bytes,
width: verified.width,
height: verified.height,
sha256: verified.sha256,
sourcePath: input,
renderer,
rendererBinary,
fidelity,
warnings,
...(frames === undefined ? {} : { frames, frameCount: frames.length }),
editable: args.editable === true,
...(args.editable === true && args.autoOpen === true ? { autoOpenEditor: true } : {}),
document,
}
return result
},
presentCall: (args: DesignRenderArgs) => ({
card: 'generic',
title: `Render ${args.path}`,
kind: 'execute',
locations: [{ path: args.path }],
}),
})
}