import rehypeStringify from 'rehype-stringify' import remarkGfm from 'remark-gfm' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import { unified } from 'unified' import { apiRequest, getAuthenticatedWorkspaceId } from './common.ts' // ============================================================================ // Types // ============================================================================ export interface Variable { id?: string name: string type: 'file' | 'text' required: boolean description?: string processingOptions?: { allowMultimodal: boolean } } export interface StructuredOutputSchemaProperty { id?: string type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'enum' description?: string optional?: boolean items?: StructuredOutputSchemaProperty properties?: Record metadata?: { isTable?: boolean } hideFromValidation?: boolean } export interface StructuredOutputSchema { properties: Record title: string description: string type: 'object' required: string[] } export interface StructuredOutput { enabled: boolean schema?: StructuredOutputSchema } export interface CanvasVersionConfiguration { model: string temperature?: number type: 'chat' | 'completion' structuredOutput?: StructuredOutput functions?: Array<{ id: string name: string description?: string parameters?: { type: 'object' properties: Record required?: string[] } }> } export interface CanvasMessage { id?: string role: 'user' | 'assistant' | 'system' | 'function' content: string | null markdownContent?: string | null index: number } export interface CanvasVersion { id: string promptId: string title: string content: string | null markdownContent: string | null configuration: CanvasVersionConfiguration | null variables: Variable[] promoted: boolean draft: boolean isWorkflow: boolean | null workspaceId: string createdBy: string updatedBy: string createdAt: string updatedAt: string deletedAt: string | null message?: CanvasMessage } export interface Canvas { id: string title: string projectId: string | null workspaceId: string layoutVersion: string customTags: string[] createdBy: string updatedBy: string createdAt: string updatedAt: string deletedAt: string | null version?: CanvasVersion } // ============================================================================ // Create Canvas Payload // ============================================================================ export interface CreateCanvasVersionPayload { title: string content?: string markdownContent?: string configuration: CanvasVersionConfiguration variables?: Variable[] promoted?: boolean draft?: boolean message?: Omit } export interface CreateCanvasPayload { title: string isWorkflow?: boolean projectId?: string workspaceId?: string customTags?: string[] version?: CreateCanvasVersionPayload } // ============================================================================ // Update Canvas Version Payload // ============================================================================ export interface UpdateCanvasVersionPayload { title?: string content?: string markdownContent?: string configuration?: Partial variables?: Variable[] promoted?: boolean draft?: boolean message?: Omit } function assertNoLegacyMessages(payload: object | undefined): void { if (payload && 'messages' in payload) throw new Error('Use message instead of messages (pass a single CanvasMessage object, not an array)') } function toMessagePayload(message: CanvasMessage | Omit | undefined): Omit | undefined { if (!message) return undefined return { role: message.role, content: message.content, markdownContent: message.markdownContent, index: message.index, } } // ============================================================================ // API Functions // ============================================================================ /** * Create a new canvas (prompt) with optional initial version and message */ export async function createCanvas(payload: CreateCanvasPayload): Promise { assertNoLegacyMessages(payload.version) const { isWorkflow = false, ...canvasPayload } = payload // Ensure required fields have defaults const requestPayload = { ...canvasPayload, workspaceId: payload.workspaceId || getAuthenticatedWorkspaceId(), layoutVersion: isWorkflow ? 'v2' : 'v1', // If version is provided, ensure variables array exists version: payload.version ? { ...payload.version, variables: payload.version.variables ?? [], isWorkflow, } : undefined, } return await apiRequest('/prompt', { method: 'POST', body: JSON.stringify(requestPayload), }) } /** * Move a personal draft into a project. * * The only way to give a draft a project. `updateCanvas({ projectId })` writes to the version and * discards it, and `PATCH /prompt/:id` answers 400 for a draft. * * A canvas can be promoted once: on one that already belongs to a project the API answers * `400 Prompt already belongs to a project`. There is no way to move it to a different one. */ export async function promoteCanvas(canvasId: string, projectId: string): Promise { return await apiRequest(`/prompt/${canvasId}/promote`, { method: 'POST', body: JSON.stringify({ projectId }), }) } /** * Update a canvas version including its message */ export async function updateCanvasVersion( versionId: string, payload: UpdateCanvasVersionPayload, ): Promise { assertNoLegacyMessages(payload) const hasWorkflowData = 'graph' in payload || 'workflowSpec' in payload return await apiRequest(`/prompt-version/${versionId}`, { method: 'PATCH', body: JSON.stringify({ ...payload, ...(hasWorkflowData ? { isWorkflow: true } : {}), }), }) } /** * Create a new version for an existing canvas */ export async function createCanvasVersion( canvasId: string, payload: CreateCanvasVersionPayload, ): Promise { assertNoLegacyMessages(payload) const hasWorkflowData = 'graph' in payload || 'workflowSpec' in payload return await apiRequest('/prompt-version', { method: 'POST', body: JSON.stringify({ ...payload, promptId: canvasId, isWorkflow: hasWorkflowData, }), }) } /** * Get a canvas version with its message by version ID */ export async function getCanvasVersion(versionId: string): Promise { return await apiRequest(`/prompt-version/${versionId}`) } /** * List every version of a canvas, ordered oldest to newest. */ export async function listCanvasVersions(canvasId: string): Promise { const versions = await apiRequest(`/prompt-version?promptId=${canvasId}`) return versions.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ) } /** * Promote a version, publishing it to production. * * Promotion is exclusive: this demotes the previously promoted version. There is no unpromote * operation — to go back, promote the earlier version again. * * The `promoted` field on create/update payloads does NOT promote anything; this is the only path. */ export async function promoteCanvasVersion(versionId: string): Promise { return await apiRequest(`/prompt-version/${versionId}/promote`, { method: 'POST', }) } /** * Delete a version. Refused for the promoted version. */ export async function deleteCanvasVersion(versionId: string): Promise { await apiRequest(`/prompt-version/${versionId}`, { method: 'DELETE' }) } /** * Rename a version without creating a new one. */ export async function updateCanvasVersionTitle(versionId: string, title: string): Promise { return await apiRequest(`/prompt-version/${versionId}/title`, { method: 'PATCH', body: JSON.stringify({ title }), }) } /** * Get canvas with its latest version by canvas ID */ export async function getCanvas(canvasId: string): Promise<{ canvas: Canvas & { latestVersion?: CanvasVersion } version: CanvasVersion | null }> { // Get canvas info const canvas = await apiRequest(`/prompt/${canvasId}`) // Get versions for this canvas const versions = await apiRequest(`/prompt-version?promptId=${canvasId}`) // Get the latest (most recent) version const latestVersion = versions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), )[0] return { canvas: { ...canvas, latestVersion }, version: latestVersion ?? null, } } /** * Update a canvas by creating a new version (does not modify existing versions) * @param canvasId - The canvas/prompt ID * @param payload - Fields to update (merged with current version) * @param options - Options object * @param options.merge - If true, merges configuration instead of replacing (preserves structuredOutput). Default: true */ export async function updateCanvas( canvasId: string, payload: UpdateCanvasVersionPayload, options: { merge?: boolean } = { merge: true }, ): Promise { assertNoLegacyMessages(payload) const { version } = await getCanvas(canvasId) if (!version) { throw new Error('Canvas has no version to update') } // Build new version payload by merging with current version const newVersionPayload: CreateCanvasVersionPayload = { title: payload.title ?? version.title, content: payload.content ?? version.content ?? undefined, markdownContent: payload.markdownContent ?? version.markdownContent ?? undefined, configuration: version.configuration ?? { model: 'gpt-5', type: 'chat' }, variables: payload.variables ?? version.variables, promoted: payload.promoted ?? false, draft: payload.draft ?? version.draft, message: toMessagePayload(payload.message ?? version.message), } // Merge configuration if requested (default behavior) if (options.merge && payload.configuration && version.configuration) { newVersionPayload.configuration = { ...version.configuration, ...payload.configuration, structuredOutput: payload.configuration.structuredOutput ?? version.configuration.structuredOutput, } } else if (payload.configuration) { newVersionPayload.configuration = payload.configuration as CanvasVersionConfiguration } return await createCanvasVersion(canvasId, newVersionPayload) } // ============================================================================ // Helper Functions // ============================================================================ /** * Create a structured output schema from a simple object definition * * @example * const schema = createStructuredOutput({ * sentiment: { type: 'string', description: 'The sentiment' }, * confidence: { type: 'number', description: 'Confidence 0-1' }, * keywords: { type: 'array', description: 'Keywords', items: { type: 'string' } } * }) */ export function createStructuredOutput( properties: Record, options?: { title?: string, description?: string }, ): StructuredOutput { return { enabled: true, schema: { properties, title: options?.title ?? 'defaultOutput', description: options?.description ?? 'Always use this output schema to answer the user, and respect the schema format very strictly.', type: 'object', required: Object.keys(properties), }, } } /** * Create a variable definition */ export function createVariable( name: string, type: 'file' | 'text', options?: { id?: string, required?: boolean, description?: string, allowMultimodal?: boolean }, ): Variable { return { id: options?.id, name, type, required: options?.required ?? true, description: options?.description, processingOptions: type === 'file' ? { allowMultimodal: options?.allowMultimodal ?? false } : undefined, } } // ============================================================================ // Export // ============================================================================ export interface ExportCanvasPayload { promptIds: string[] includeTestCases?: boolean includeApplications?: boolean includeFiles?: boolean } /** * Export one or more canvases with optional test cases, applications, and files. * Returns a ZIP file as an ArrayBuffer. Save it with Bun.write() or fs.writeFileSync(). */ export async function exportCanvas(payload: ExportCanvasPayload): Promise { const { loadFreshApiKey, getApiBaseUrl, parsePayload } = await import('./common.ts') const apiKey = await loadFreshApiKey() const response = await fetch(`${getApiBaseUrl()}/canvas/export`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(parsePayload(payload)), }) if (!response.ok) { const error = await response.text() throw new Error(`API request failed: ${response.status} ${response.statusText} - ${error}`) } return await response.arrayBuffer() } // ============================================================================ // Content Formatting Helpers // ============================================================================ interface MarkdownNode { type: string value?: string children?: MarkdownNode[] data?: Record } const TAG_ONLY_LINE = /^\s*<\/?[a-z][\w.:-]*(?:\s[^<>]*)?\/?>\s*$/i const CODE_FENCE = /^\s{0,3}([`~]{3,})/ const VARIABLE = /\{([^{}\n]+)\}/g const BLOCK_PARENTS = new Set(['root', 'listItem', 'blockquote', 'footnoteDefinition']) /** * Prompts routinely wrap sections in XML-style tags (``, ``) with markdown * inside. CommonMark treats a tag on its own line as the start of a raw HTML block that swallows * everything up to the next blank line, so the list under `` would be lost. Surrounding * such lines with blank lines keeps the tag as its own block and lets the content parse as * markdown. Fenced code blocks are left untouched. */ function isolateTagOnlyLines(markdown: string): string { const output: string[] = [] let fence: string | null = null for (const line of markdown.split('\n')) { const fenceMatch = CODE_FENCE.exec(line) const marker = fenceMatch?.[1] ?? '' if (fenceMatch && marker === marker[0]!.repeat(marker.length)) { const rest = line.slice(fenceMatch[0].length) const closes = fence !== null && marker[0] === fence[0] && marker.length >= fence.length && rest.trim() === '' if (fence === null) fence = marker else if (closes) fence = null output.push(line) continue } if (fence || !TAG_ONLY_LINE.test(line)) { output.push(line) continue } if (output.length && output[output.length - 1]!.trim() !== '') output.push('') output.push(line, '') } return output.join('\n') } function textNode(value: string): MarkdownNode { return { type: 'text', value } } function variableNode(name: string): MarkdownNode { return { type: 'variable', data: { hName: 'variable', hProperties: { className: ['variable'], dataVariable: name }, hChildren: [textNode(name)], }, } } /** * remark parses XML-style tags as raw HTML, which the editor would drop. They are turned back * into text so they reach the editor escaped, exactly as if a person had typed them: inline tags * stay in their paragraph, block-level tags become one paragraph per line. */ function keepRawHtmlAsText() { return (tree: MarkdownNode) => { const visit = (node: MarkdownNode) => { if (!node.children) return node.children = node.children.flatMap((child) => { if (child.type !== 'html') return [child] if (!BLOCK_PARENTS.has(node.type)) return [textNode(child.value ?? '')] return (child.value ?? '') .split('\n') .filter(line => line.trim() !== '') .map(line => ({ type: 'paragraph', children: [textNode(line)] })) }) node.children.forEach(visit) } visit(tree) } } /** * Turns `{variableName}` in prose into editor variable nodes. Only text nodes are touched, so * braces inside fenced or inline code stay literal. */ function markVariables() { return (tree: MarkdownNode) => { const visit = (node: MarkdownNode) => { if (!node.children) return node.children = node.children.flatMap((child) => { if (child.type !== 'text' || !child.value) return [child] const parts: MarkdownNode[] = [] let cursor = 0 for (const match of child.value.matchAll(VARIABLE)) { if (match.index > cursor) parts.push(textNode(child.value.slice(cursor, match.index))) parts.push(variableNode(match[1]!)) cursor = match.index + match[0].length } if (cursor < child.value.length) parts.push(textNode(child.value.slice(cursor))) return parts }) node.children.forEach(visit) } visit(tree) } } const markdownProcessor = unified() .use(remarkParse) .use(remarkGfm) .use(keepRawHtmlAsText) .use(markVariables) .use(remarkRehype) .use(rehypeStringify) .freeze() /** * Convert markdown content to the HTML shape the canvas editor loads. * * Mirrors `convertMarkdownToEditorHTML` in the Tela monorepo: full CommonMark + GFM (nested and * ordered lists, every heading level, `*italic*` and `_italic_`, code, blockquotes, links, tables), * with `{variableName}` turned into `` nodes * and tables wrapped for horizontal scrolling. */ export function markdownToHtml(markdown: string): string { const html = String(markdownProcessor.processSync(isolateTagOnlyLines(markdown))) return html .replace(/]*)>/g, '
') .replace(/<\/table>/g, '
') } /** * Create a message for a canvas version with proper HTML and markdown formatting * Use {variableName} syntax in the content - it will be converted to proper HTML */ export function createMessage( role: CanvasMessage['role'], content: string, index: number, ): Omit { return { role, content: markdownToHtml(content), markdownContent: content, index, } }