Files
Momento/memento-note/lib/ai/tools/excalidraw.tool.ts
Antigravity 5728452b4a
Some checks failed
CI / Lint, Test & Build (push) Failing after 17s
CI / Deploy production (on server) (push) Has been skipped
feat: add slides generation tool with multiple slide types
- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types
- Chart types: bar, horizontal-bar, line, donut, radar
- Integrate with agent executor and canvas system
- Add multilingual support (en/fr)
- Various UI improvements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:18:48 +00:00

1213 lines
42 KiB
TypeScript

'use server'
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
// import type is erased at build time — Turbopack won't try to resolve the module
import type dagreType from 'dagre'
let _dagre: typeof dagreType | null = null
async function getDagre(): Promise<typeof dagreType> {
if (!_dagre) {
const mod = await import('dagre')
_dagre = (mod.default ?? mod) as typeof dagreType
}
return _dagre
}
interface SimplifiedNode {
id: string
label: string
type: 'rect' | 'ellipse' | 'diamond'
zoneId?: string
}
interface SimplifiedEdge {
from: string
to: string
label?: string
}
interface GraphSanitizeResult {
nodes: SimplifiedNode[]
edges: SimplifiedEdge[]
metrics: {
inputNodes: number
inputEdges: number
outputNodes: number
outputEdges: number
removedNodes: number
removedEdges: number
addedEdges: number
forcedFirstNodeEllipse: boolean
}
}
let _seedCounter = 100
function nextSeed(): number { return _seedCounter++ }
function normalizeElement(el: any, index: number): any {
const base = {
id: el.id || `el-${index}`,
type: el.type || 'rectangle',
x: typeof el.x === 'number' ? el.x : 0,
y: typeof el.y === 'number' ? el.y : 0,
width: typeof el.width === 'number' ? el.width : 200,
height: typeof el.height === 'number' ? el.height : 80,
angle: el.angle ?? 0,
strokeColor: el.strokeColor ?? '#1e1e1e',
backgroundColor: el.backgroundColor ?? 'transparent',
fillStyle: el.fillStyle ?? 'solid',
strokeWidth: el.strokeWidth ?? 2,
strokeStyle: el.strokeStyle ?? 'solid',
roughness: el.roughness ?? 0,
opacity: el.opacity ?? 100,
roundness: el.roundness ?? { type: 3 },
boundElements: Array.isArray(el.boundElements) ? [...el.boundElements] : [],
seed: el.seed ?? nextSeed(),
version: el.version ?? 1,
versionNonce: el.versionNonce ?? nextSeed(),
isDeleted: false,
groupIds: el.groupIds ?? [],
frameId: el.frameId ?? null,
updated: Date.now(),
link: el.link ?? null,
locked: false,
}
if (el.type === 'text') {
return { ...base, type: 'text',
fontSize: el.fontSize ?? 16, fontFamily: el.fontFamily ?? 3,
textAlign: el.textAlign ?? 'center', verticalAlign: el.verticalAlign ?? 'middle',
text: el.text ?? '', containerId: el.containerId ?? null,
originalText: el.originalText ?? el.text ?? '',
autoResize: el.autoResize ?? true, lineHeight: el.lineHeight ?? 1.25,
}
}
if (el.type === 'arrow' || el.type === 'line') {
return { ...base, type: el.type,
points: el.points ?? [[0, 0]],
startBinding: el.startBinding ?? null, endBinding: el.endBinding ?? null,
lastCommittedPoint: el.lastCommittedPoint ?? null,
startArrowhead: el.startArrowhead ?? null,
endArrowhead: el.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null),
elbowed: el.elbowed ?? false,
}
}
return base
}
function ensureBidirectionalBindings(elements: any[]): void {
const elMap = new Map<string, any>()
for (const el of elements) {
elMap.set(el.id, el)
}
for (const el of elements) {
if (el.type !== 'arrow' && el.type !== 'line') continue
const arrowId = el.id
if (el.startBinding?.elementId) {
const target = elMap.get(el.startBinding.elementId)
if (target && Array.isArray(target.boundElements)) {
if (!target.boundElements.some((b: any) => b.id === arrowId)) {
target.boundElements.push({ id: arrowId, type: 'element' })
}
}
}
if (el.endBinding?.elementId) {
const target = elMap.get(el.endBinding.elementId)
if (target && Array.isArray(target.boundElements)) {
if (!target.boundElements.some((b: any) => b.id === arrowId)) {
target.boundElements.push({ id: arrowId, type: 'element' })
}
}
}
}
}
type DiagramStyle = 'default' | 'austere' | 'sketch-plus'
type DiagramType = 'auto' | 'flowchart' | 'mindmap' | 'architecture-cloud' | 'org-chart' | 'timeline' | 'process-map'
interface DiagramZone {
id: string
label: string
}
const VISUAL_STYLES: Record<DiagramStyle, {
center: { bg: string; stroke: string }
process: { bg: string; stroke: string }
decision: { bg: string; stroke: string }
terminal: { bg: string; stroke: string }
text: string
edge: string
edgeLabel: string
}> = {
default: {
center: { bg: '#dbeafe', stroke: '#2563eb' },
process: { bg: '#f8fafc', stroke: '#475569' },
decision: { bg: '#fef3c7', stroke: '#b45309' },
terminal: { bg: '#dcfce7', stroke: '#15803d' },
text: '#1f2937',
edge: '#475569',
edgeLabel: '#374151',
},
austere: {
center: { bg: '#f1f3f5', stroke: '#495057' },
process: { bg: '#f8f9fa', stroke: '#6c757d' },
decision: { bg: '#f1f3f5', stroke: '#495057' },
terminal: { bg: '#f8f9fa', stroke: '#6c757d' },
text: '#212529',
edge: '#5c6770',
edgeLabel: '#343a40',
},
'sketch-plus': {
center: { bg: '#dbeafe', stroke: '#1d4ed8' },
process: { bg: '#ecfeff', stroke: '#0f766e' },
decision: { bg: '#fef3c7', stroke: '#b45309' },
terminal: { bg: '#dcfce7', stroke: '#15803d' },
text: '#1f2937',
edge: '#334155',
edgeLabel: '#1f2937',
},
}
const STYLE_PRESETS: Record<DiagramStyle, {
shapeFillStyle: 'solid' | 'hachure' | 'cross-hatch'
shapeRoughness: number
shapeStrokeWidth: number
shapeStrokeStyle: 'solid' | 'dashed' | 'dotted'
textFontFamily: number
arrowStrokeWidth: number
arrowRoughness: number
}> = {
default: {
// Excalidraw-like hand-drawn look
shapeFillStyle: 'hachure',
shapeRoughness: 1.1,
shapeStrokeWidth: 2,
shapeStrokeStyle: 'solid',
textFontFamily: 1, // Virgil (handwritten)
arrowStrokeWidth: 2,
arrowRoughness: 1.1,
},
austere: {
shapeFillStyle: 'solid',
shapeRoughness: 0,
shapeStrokeWidth: 1.8,
shapeStrokeStyle: 'solid',
textFontFamily: 2,
arrowStrokeWidth: 1.9,
arrowRoughness: 0,
},
'sketch-plus': {
// Extra hand-drawn style inspired by Excalidraw demos.
shapeFillStyle: 'hachure',
shapeRoughness: 1.9,
shapeStrokeWidth: 2.2,
shapeStrokeStyle: 'solid',
textFontFamily: 1,
arrowStrokeWidth: 2.2,
arrowRoughness: 1.8,
},
}
const DEFAULT_NODE_PALETTE = [
{ bg: '#dbeafe', stroke: '#2563eb' },
{ bg: '#dcfce7', stroke: '#15803d' },
{ bg: '#fef3c7', stroke: '#b45309' },
{ bg: '#ede9fe', stroke: '#6d28d9' },
{ bg: '#ffe4e6', stroke: '#be123c' },
{ bg: '#cffafe', stroke: '#0e7490' },
]
function resolveDiagramStyle(rawStyle: unknown): DiagramStyle {
if (typeof rawStyle !== 'string') return 'default'
const value = rawStyle.trim().toLowerCase()
if (value === 'austere') return 'austere'
if (value === 'sketch-plus') return 'sketch-plus'
return 'default'
}
function resolveDiagramType(rawType: unknown): DiagramType {
if (typeof rawType !== 'string') return 'flowchart'
const value = rawType.trim().toLowerCase()
if (value === 'auto' || value === 'mindmap' || value === 'architecture-cloud' || value === 'org-chart' || value === 'timeline' || value === 'process-map') return value
return 'flowchart'
}
function inferDiagramType(
requestedType: DiagramType,
title: string,
nodes: SimplifiedNode[],
edges: SimplifiedEdge[],
zones: DiagramZone[],
): Exclude<DiagramType, 'auto'> {
if (requestedType !== 'auto') return requestedType
if (zones.length > 0 || /(?:azure|aws|gcp|kubernetes|rg|vnet|vault|function|service|cluster|infra|architecture)/i.test(title)) {
return 'architecture-cloud'
}
if (/(?:timeline|roadmap|milestone|phase|quarter|q[1-4]|release)/i.test(title)) {
return 'timeline'
}
if (/(?:org|organization|organisation|team|department|manager|reporting)/i.test(title)) {
return 'org-chart'
}
const rootId = nodes[0]?.id
const rootOut = rootId ? edges.filter((e) => e.from === rootId).length : 0
if (rootOut >= Math.max(3, Math.floor(nodes.length / 2))) return 'mindmap'
if (nodes.length >= 7 && edges.length <= nodes.length + 1) return 'process-map'
return 'flowchart'
}
function measureTextWidth(text: string, fontSize: number): number {
return Math.ceil(text.length * fontSize * 0.58)
}
function getCenter(el: { x: number; y: number; width: number; height: number }) {
return { cx: el.x + el.width / 2, cy: el.y + el.height / 2 }
}
function getEdgePoint(el: { x: number; y: number; width: number; height: number }, target: { cx: number; cy: number }) {
const cx = el.x + el.width / 2
const cy = el.y + el.height / 2
const dx = target.cx - cx
const dy = target.cy - cy
const hw = el.width / 2
const hh = el.height / 2
if (dx === 0 && dy === 0) return { x: cx, y: cy }
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
const scaleX = hw > 0 ? absDx / hw : 0
const scaleY = hh > 0 ? absDy / hh : 0
let ex: number, ey: number
if (scaleX * hh > scaleY * hw) {
ex = cx + (dx > 0 ? hw : -hw)
ey = cy + (hw > 0 ? dy * hw / absDx : 0)
} else {
ey = cy + (dy > 0 ? hh : -hh)
ex = cx + (hh > 0 ? dx * hh / absDy : 0)
}
return { x: ex, y: ey }
}
function sanitizeNodeId(rawId: unknown, index: number): string {
const normalized = String(rawId ?? '').trim().replace(/\s+/g, '-')
return normalized || `n${index + 1}`
}
function sanitizeLabel(rawLabel: unknown, fallback: string): string {
const base = String(rawLabel ?? '').replace(/\s+/g, ' ').trim()
const finalLabel = base || fallback
return finalLabel.substring(0, 60)
}
function sanitizeNodeType(rawType: unknown, isFirst: boolean): SimplifiedNode['type'] {
if (isFirst) return 'ellipse'
if (rawType === 'ellipse' || rawType === 'diamond') return rawType
return 'rect'
}
function sanitizeGraph(rawNodes: unknown[], rawEdges: unknown[]): GraphSanitizeResult {
const inputNodes = Array.isArray(rawNodes) ? rawNodes.length : 0
const inputEdges = Array.isArray(rawEdges) ? rawEdges.length : 0
const uniqueNodes = new Map<string, SimplifiedNode>()
let forcedFirstNodeEllipse = false
for (let i = 0; i < inputNodes; i++) {
const n: any = rawNodes[i]
const id = sanitizeNodeId(n?.id, i)
if (uniqueNodes.has(id)) continue
const node: SimplifiedNode = {
id,
label: sanitizeLabel(n?.label ?? n?.text, `Node ${i + 1}`),
type: sanitizeNodeType(n?.type, uniqueNodes.size === 0),
zoneId: typeof n?.zoneId === 'string' && n.zoneId.trim() ? n.zoneId.trim() : undefined,
}
if (uniqueNodes.size === 0 && node.type !== 'ellipse') {
node.type = 'ellipse'
forcedFirstNodeEllipse = true
}
uniqueNodes.set(id, node)
}
const nodes = [...uniqueNodes.values()]
const nodeIds = new Set(nodes.map((n) => n.id))
const dedupEdgeKeys = new Set<string>()
const edges: SimplifiedEdge[] = []
let removedEdges = 0
for (let i = 0; i < inputEdges; i++) {
const e: any = rawEdges[i]
const from = String(e?.from ?? e?.fromId ?? '').trim()
const to = String(e?.to ?? e?.toId ?? '').trim()
if (!from || !to || from === to || !nodeIds.has(from) || !nodeIds.has(to)) {
removedEdges++
continue
}
const label = typeof e?.label === 'string' && e.label.trim() ? e.label.trim().substring(0, 40) : undefined
const key = `${from}::${to}::${label ?? ''}`
if (dedupEdgeKeys.has(key)) {
removedEdges++
continue
}
dedupEdgeKeys.add(key)
edges.push({ from, to, label })
}
let addedEdges = 0
if (nodes.length > 1) {
const rootId = nodes[0].id
const degree = new Map<string, number>(nodes.map((n) => [n.id, 0]))
for (const edge of edges) {
degree.set(edge.from, (degree.get(edge.from) || 0) + 1)
degree.set(edge.to, (degree.get(edge.to) || 0) + 1)
}
for (const node of nodes) {
if (node.id === rootId) continue
if ((degree.get(node.id) || 0) > 0) continue
const key = `${rootId}::${node.id}::`
if (!dedupEdgeKeys.has(key)) {
dedupEdgeKeys.add(key)
edges.push({ from: rootId, to: node.id })
addedEdges++
}
}
}
return {
nodes,
edges,
metrics: {
inputNodes,
inputEdges,
outputNodes: nodes.length,
outputEdges: edges.length,
removedNodes: Math.max(0, inputNodes - nodes.length),
removedEdges,
addedEdges,
forcedFirstNodeEllipse,
},
}
}
interface NodeLayoutBox {
x: number
y: number
w: number
h: number
}
interface LayoutQualityMetrics {
nodeOverlaps: number
edgeCrossings: number
}
interface LayoutResult {
layout: Map<string, NodeLayoutBox>
quality: LayoutQualityMetrics
rankdir: 'LR' | 'TB'
}
interface NodeRenderSpec {
text: string
fontSize: number
width: number
height: number
}
function separateArchitectureZones(
layout: Map<string, NodeLayoutBox>,
nodes: SimplifiedNode[],
zones: DiagramZone[],
): void {
const zoneGroups = zones
.map((zone) => {
const zoneNodeIds = nodes.filter((n) => n.zoneId === zone.id).map((n) => n.id)
const boxes = zoneNodeIds.map((id) => layout.get(id)).filter(Boolean) as NodeLayoutBox[]
if (boxes.length === 0) return null
const minX = Math.min(...boxes.map((b) => b.x))
const minY = Math.min(...boxes.map((b) => b.y))
const maxX = Math.max(...boxes.map((b) => b.x + b.w))
const maxY = Math.max(...boxes.map((b) => b.y + b.h))
return {
id: zone.id,
nodeIds: zoneNodeIds,
minX,
minY,
maxX,
maxY,
}
})
.filter(Boolean) as Array<{
id: string
nodeIds: string[]
minX: number
minY: number
maxX: number
maxY: number
}>
if (zoneGroups.length <= 1) return
zoneGroups.sort((a, b) => a.minY - b.minY)
const zoneGapY = 90
let cursorY = zoneGroups[0].minY
for (const group of zoneGroups) {
const height = group.maxY - group.minY
const dy = cursorY - group.minY
if (dy !== 0) {
for (const nodeId of group.nodeIds) {
const box = layout.get(nodeId)
if (!box) continue
layout.set(nodeId, { ...box, y: box.y + dy })
}
}
cursorY += height + zoneGapY
}
}
function wrapText(text: string, maxCharsPerLine: number, maxLines: number): string {
const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
if (words.length === 0) return text
const lines: string[] = []
let current = ''
for (const word of words) {
const next = current ? `${current} ${word}` : word
if (next.length <= maxCharsPerLine) {
current = next
continue
}
if (current) lines.push(current)
current = word
if (lines.length >= maxLines - 1) break
}
if (current && lines.length < maxLines) {
lines.push(current)
}
if (lines.length === maxLines && words.join(' ').length > lines.join(' ').length) {
const last = lines[maxLines - 1]
lines[maxLines - 1] = last.length > 2 ? `${last.substring(0, Math.max(1, last.length - 1))}` : `${last}`
}
return lines.join('\n')
}
function getNodeRenderSpec(node: SimplifiedNode, isCenter: boolean): NodeRenderSpec {
const fontSize = isCenter ? 19 : 15
const wrapped = wrapText(node.label, isCenter ? 30 : 26, isCenter ? 4 : 3)
const lines = wrapped.split('\n').filter(Boolean)
const longestLine = lines.reduce((max, line) => Math.max(max, line.length), 0)
const lineHeightPx = Math.ceil(fontSize * 1.25)
const width = Math.max(170, Math.min(320, measureTextWidth('W'.repeat(longestLine || 8), fontSize) + 44))
const height = Math.max(isCenter ? 78 : 62, Math.min(170, lines.length * lineHeightPx + 28))
return { text: wrapped, fontSize, width, height }
}
async function computeNodeLayoutForRankdir(
nodes: SimplifiedNode[],
edges: SimplifiedEdge[],
rankdir: 'LR' | 'TB',
renderSpecs: Map<string, NodeRenderSpec>,
): Promise<Map<string, NodeLayoutBox>> {
const dagre = await getDagre()
const graph = new dagre.graphlib.Graph()
graph.setGraph({
rankdir,
nodesep: 70,
ranksep: 110,
marginx: 100,
marginy: 100,
})
graph.setDefaultEdgeLabel(() => ({}))
const nodeSize = new Map<string, { width: number; height: number }>()
nodes.forEach((node) => {
const spec = renderSpecs.get(node.id)
const width = spec?.width ?? 180
const height = spec?.height ?? 70
nodeSize.set(node.id, { width, height })
graph.setNode(node.id, { width, height })
})
for (const edge of edges) {
if (nodeSize.has(edge.from) && nodeSize.has(edge.to)) {
graph.setEdge(edge.from, edge.to)
}
}
dagre.layout(graph)
const layout = new Map<string, NodeLayoutBox>()
nodes.forEach((node, index) => {
const gNode = graph.node(node.id)
const size = nodeSize.get(node.id)!
if (gNode && typeof gNode.x === 'number' && typeof gNode.y === 'number') {
layout.set(node.id, {
x: Math.round(gNode.x - size.width / 2),
y: Math.round(gNode.y - size.height / 2),
w: size.width,
h: size.height,
})
return
}
// Fallback should never happen, but keeps generation robust.
layout.set(node.id, {
x: 100 + index * 260,
y: 100,
w: size.width,
h: size.height,
})
})
return layout
}
function countNodeOverlaps(layout: Map<string, NodeLayoutBox>): number {
const boxes = [...layout.values()]
let overlaps = 0
for (let i = 0; i < boxes.length; i++) {
for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i]
const b = boxes[j]
const intersects = a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
if (intersects) overlaps++
}
}
return overlaps
}
function getLayoutBounds(layout: Map<string, NodeLayoutBox>): { width: number; height: number } {
const boxes = [...layout.values()]
if (boxes.length === 0) return { width: 0, height: 0 }
const minX = Math.min(...boxes.map((b) => b.x))
const minY = Math.min(...boxes.map((b) => b.y))
const maxX = Math.max(...boxes.map((b) => b.x + b.w))
const maxY = Math.max(...boxes.map((b) => b.y + b.h))
return { width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }
}
function orientation(ax: number, ay: number, bx: number, by: number, cx: number, cy: number): number {
const value = (by - ay) * (cx - bx) - (bx - ax) * (cy - by)
if (Math.abs(value) < 1e-6) return 0
return value > 0 ? 1 : 2
}
function segmentsIntersect(
ax: number, ay: number, bx: number, by: number,
cx: number, cy: number, dx: number, dy: number,
): boolean {
const o1 = orientation(ax, ay, bx, by, cx, cy)
const o2 = orientation(ax, ay, bx, by, dx, dy)
const o3 = orientation(cx, cy, dx, dy, ax, ay)
const o4 = orientation(cx, cy, dx, dy, bx, by)
return o1 !== o2 && o3 !== o4
}
function countEdgeCrossings(
nodes: SimplifiedNode[],
edges: SimplifiedEdge[],
layout: Map<string, NodeLayoutBox>,
): number {
const lines: Array<{ from: string; to: string; x1: number; y1: number; x2: number; y2: number }> = []
for (const edge of edges) {
const fromBox = layout.get(edge.from)
const toBox = layout.get(edge.to)
if (!fromBox || !toBox) continue
const fromCenter = { cx: fromBox.x + fromBox.w / 2, cy: fromBox.y + fromBox.h / 2 }
const toCenter = { cx: toBox.x + toBox.w / 2, cy: toBox.y + toBox.h / 2 }
const start = getEdgePoint({ x: fromBox.x, y: fromBox.y, width: fromBox.w, height: fromBox.h }, toCenter)
const end = getEdgePoint({ x: toBox.x, y: toBox.y, width: toBox.w, height: toBox.h }, fromCenter)
lines.push({ from: edge.from, to: edge.to, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
}
let crossings = 0
for (let i = 0; i < lines.length; i++) {
for (let j = i + 1; j < lines.length; j++) {
const a = lines[i]
const b = lines[j]
// Ignore edges sharing a node; those naturally "intersect" at endpoints.
if (a.from === b.from || a.from === b.to || a.to === b.from || a.to === b.to) continue
if (segmentsIntersect(a.x1, a.y1, a.x2, a.y2, b.x1, b.y1, b.x2, b.y2)) {
crossings++
}
}
}
return crossings
}
function computeLayoutQuality(
nodes: SimplifiedNode[],
edges: SimplifiedEdge[],
layout: Map<string, NodeLayoutBox>,
): LayoutQualityMetrics {
return {
nodeOverlaps: countNodeOverlaps(layout),
edgeCrossings: countEdgeCrossings(nodes, edges, layout),
}
}
function scoreLayout(
quality: LayoutQualityMetrics,
bounds: { width: number; height: number },
diagramType: Exclude<DiagramType, 'auto'>,
): number {
const ratio = bounds.width / bounds.height
const targetRatio = diagramType === 'org-chart'
? 1.1
: diagramType === 'timeline'
? 2.4
: diagramType === 'mindmap'
? 1.4
: 1.7
const ratioPenalty = Math.abs(ratio - targetRatio) * 8
const areaPenalty = (bounds.width * bounds.height) / 350000
return quality.edgeCrossings * 16 + quality.nodeOverlaps * 28 + ratioPenalty + areaPenalty
}
async function computeElkLayoutForRankdir(
nodes: SimplifiedNode[],
edges: SimplifiedEdge[],
rankdir: 'LR' | 'TB',
renderSpecs: Map<string, NodeRenderSpec>,
): Promise<Map<string, NodeLayoutBox> | null> {
try {
const ElkClass = (await import('elkjs/lib/elk.bundled.js')).default
const elk = new ElkClass()
const children = nodes.map((node) => {
const spec = renderSpecs.get(node.id)
return {
id: node.id,
width: spec?.width ?? 180,
height: spec?.height ?? 70,
}
})
const nodeIds = new Set(nodes.map((n) => n.id))
const elkEdges = edges
.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to))
.map((edge, index) => ({
id: `e-${index}-${edge.from}-${edge.to}`,
sources: [edge.from],
targets: [edge.to],
}))
const graph = {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': rankdir === 'LR' ? 'RIGHT' : 'DOWN',
'elk.spacing.nodeNode': '70',
'elk.layered.spacing.nodeNodeBetweenLayers': '110',
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
'elk.edgeRouting': 'ORTHOGONAL',
},
children,
edges: elkEdges,
}
const result = await elk.layout(graph as any)
if (!result?.children) return null
const layout = new Map<string, NodeLayoutBox>()
for (const child of result.children) {
if (!child.id || typeof child.x !== 'number' || typeof child.y !== 'number') continue
layout.set(child.id, {
x: Math.round(child.x + 100),
y: Math.round(child.y + 100),
w: Math.round(child.width ?? (renderSpecs.get(child.id)?.width ?? 180)),
h: Math.round(child.height ?? (renderSpecs.get(child.id)?.height ?? 70)),
})
}
return layout
} catch {
return null
}
}
async function computeNodeLayout(
nodes: SimplifiedNode[],
edges: SimplifiedEdge[],
diagramType: Exclude<DiagramType, 'auto'>,
): Promise<LayoutResult & { engine: 'dagre' | 'elk' }> {
const renderSpecs = new Map<string, NodeRenderSpec>()
nodes.forEach((node, index) => {
renderSpecs.set(node.id, getNodeRenderSpec(node, index === 0))
})
const firstRankdir: 'LR' | 'TB' = (diagramType === 'org-chart') ? 'TB' : 'LR'
const secondRankdir: 'LR' | 'TB' = firstRankdir === 'LR' ? 'TB' : 'LR'
const lrLayout = await computeNodeLayoutForRankdir(nodes, edges, firstRankdir, renderSpecs)
const lrQuality = computeLayoutQuality(nodes, edges, lrLayout)
const lrBounds = getLayoutBounds(lrLayout)
const tbLayout = await computeNodeLayoutForRankdir(nodes, edges, secondRankdir, renderSpecs)
const tbQuality = computeLayoutQuality(nodes, edges, tbLayout)
const tbBounds = getLayoutBounds(tbLayout)
const lrScore = scoreLayout(lrQuality, lrBounds, diagramType)
const tbScore = scoreLayout(tbQuality, tbBounds, diagramType)
const bestDagre = tbScore < lrScore
? { layout: tbLayout, quality: tbQuality, rankdir: secondRankdir, score: tbScore }
: { layout: lrLayout, quality: lrQuality, rankdir: firstRankdir, score: lrScore }
const graphComplexity = edges.length + Math.max(0, nodes.length - 6)
const shouldTryElk = graphComplexity >= 10 || bestDagre.score > 25
if (!shouldTryElk) {
return { layout: bestDagre.layout, quality: bestDagre.quality, rankdir: bestDagre.rankdir, engine: 'dagre' }
}
const elkLR = await computeElkLayoutForRankdir(nodes, edges, firstRankdir, renderSpecs)
const elkTB = await computeElkLayoutForRankdir(nodes, edges, secondRankdir, renderSpecs)
const elkCandidates: Array<{ layout: Map<string, NodeLayoutBox>; quality: LayoutQualityMetrics; rankdir: 'LR' | 'TB'; score: number; engine: 'elk' }> = []
if (elkLR && elkLR.size === nodes.length) {
const quality = computeLayoutQuality(nodes, edges, elkLR)
const bounds = getLayoutBounds(elkLR)
elkCandidates.push({ layout: elkLR, quality, rankdir: firstRankdir, score: scoreLayout(quality, bounds, diagramType), engine: 'elk' })
}
if (elkTB && elkTB.size === nodes.length) {
const quality = computeLayoutQuality(nodes, edges, elkTB)
const bounds = getLayoutBounds(elkTB)
elkCandidates.push({ layout: elkTB, quality, rankdir: secondRankdir, score: scoreLayout(quality, bounds, diagramType), engine: 'elk' })
}
if (elkCandidates.length === 0) {
return { layout: bestDagre.layout, quality: bestDagre.quality, rankdir: bestDagre.rankdir, engine: 'dagre' }
}
const bestElk = elkCandidates.sort((a, b) => a.score - b.score)[0]
if (bestElk.score + 2 < bestDagre.score) {
return { layout: bestElk.layout, quality: bestElk.quality, rankdir: bestElk.rankdir, engine: 'elk' }
}
return { layout: bestDagre.layout, quality: bestDagre.quality, rankdir: bestDagre.rankdir, engine: 'dagre' }
}
async function buildElementsFromSimplified(
nodes: SimplifiedNode[],
edges: SimplifiedEdge[],
style: DiagramStyle,
diagramType: Exclude<DiagramType, 'auto'>,
zones: DiagramZone[],
): Promise<{ elements: any[]; layoutQuality: LayoutQualityMetrics; rankdir: 'LR' | 'TB'; engine: 'dagre' | 'elk' }> {
const elements: any[] = []
const count = nodes.length
const { layout, quality, rankdir, engine } = await computeNodeLayout(nodes, edges, diagramType)
if (diagramType === 'architecture-cloud' && zones.length > 1) {
separateArchitectureZones(layout, nodes, zones)
}
const renderSpecs = new Map<string, NodeRenderSpec>()
const visualStyle = VISUAL_STYLES[style]
const stylePreset = STYLE_PRESETS[style]
nodes.forEach((node, index) => {
renderSpecs.set(node.id, getNodeRenderSpec(node, index === 0))
})
const nodeElements: Map<string, any> = new Map()
const edgePairCount = new Map<string, number>()
const edgePairSeen = new Map<string, number>()
for (const edge of edges) {
const directKey = `${edge.from}->${edge.to}`
const reverseKey = `${edge.to}->${edge.from}`
const canonical = directKey < reverseKey ? `${directKey}|${reverseKey}` : `${reverseKey}|${directKey}`
edgePairCount.set(canonical, (edgePairCount.get(canonical) || 0) + 1)
}
for (let i = 0; i < count; i++) {
const node = nodes[i]
const box = layout.get(node.id)
if (!box) continue
const isCenter = i === 0
const nodeType = isCenter ? 'ellipse' : (node.type === 'ellipse' ? 'ellipse' : node.type === 'diamond' ? 'diamond' : 'rectangle')
const isTerminal = !isCenter && node.type === 'ellipse'
const spec = renderSpecs.get(node.id)
const paletteColor = DEFAULT_NODE_PALETTE[i % DEFAULT_NODE_PALETTE.length]
const visual = isCenter
? visualStyle.center
: nodeType === 'diamond'
? visualStyle.decision
: isTerminal
? visualStyle.terminal
: (style === 'default' ? paletteColor : visualStyle.process)
const fontSize = spec?.fontSize ?? (isCenter ? 19 : 15)
const w = box.w
const h = box.h
const x = box.x
const y = box.y
const shape = {
id: node.id, type: nodeType, x, y, width: w, height: h,
angle: 0, strokeColor: visual.stroke, backgroundColor: visual.bg,
fillStyle: stylePreset.shapeFillStyle,
strokeWidth: isCenter ? stylePreset.shapeStrokeWidth + 0.3 : stylePreset.shapeStrokeWidth,
strokeStyle: stylePreset.shapeStrokeStyle,
roughness: stylePreset.shapeRoughness,
opacity: 100,
roundness: { type: nodeType === 'rectangle' ? 3 : 2 },
boundElements: [{ type: 'text', id: `${node.id}-text` }],
seed: nextSeed(), version: 1, versionNonce: nextSeed(),
isDeleted: false, groupIds: [], frameId: null, updated: Date.now(), link: null, locked: false,
}
nodeElements.set(node.id, shape)
elements.push(shape)
const textPadX = 12
const textValue = spec?.text ?? node.label
const lineCount = textValue.split('\n').filter(Boolean).length || 1
const textHeight = Math.ceil(fontSize * 1.25 * lineCount)
elements.push({
id: `${node.id}-text`, type: 'text',
x: x + textPadX, y: y + h / 2 - textHeight / 2,
width: w - textPadX * 2, height: textHeight,
angle: 0, strokeColor: visualStyle.text, backgroundColor: 'transparent',
fillStyle: 'solid', strokeWidth: 1, strokeStyle: 'solid', roughness: 0, opacity: 100,
fontSize, fontFamily: stylePreset.textFontFamily, textAlign: 'center', verticalAlign: 'middle',
text: textValue, boundElements: [],
seed: nextSeed(), version: 1, versionNonce: nextSeed(),
isDeleted: false, groupIds: [], frameId: null, updated: Date.now(), link: null, locked: false,
containerId: node.id, originalText: node.label, autoResize: true, lineHeight: 1.25,
})
}
if (diagramType === 'architecture-cloud' && zones.length > 0) {
const zoneElements: any[] = []
for (const zone of zones) {
const zoneNodes = nodes.filter((n) => n.zoneId === zone.id)
if (zoneNodes.length === 0) continue
const shapes = zoneNodes.map((n) => nodeElements.get(n.id)).filter(Boolean)
if (shapes.length === 0) continue
const minX = Math.min(...shapes.map((s: any) => s.x))
const minY = Math.min(...shapes.map((s: any) => s.y))
const maxX = Math.max(...shapes.map((s: any) => s.x + s.width))
const maxY = Math.max(...shapes.map((s: any) => s.y + s.height))
const padding = 46
const zoneX = minX - padding
const zoneY = minY - padding
const zoneW = maxX - minX + padding * 2
const zoneH = maxY - minY + padding * 2
const zoneRectId = `zone-${zone.id}`
const zoneTitleId = `zone-title-${zone.id}`
const zoneStroke = style === 'austere' ? '#9ca3af' : '#7dd3fc'
const zoneBg = style === 'austere' ? '#f8f9fa' : '#ecfeff'
const zoneRect = {
id: zoneRectId,
type: 'rectangle',
x: zoneX,
y: zoneY,
width: zoneW,
height: zoneH,
angle: 0,
strokeColor: zoneStroke,
backgroundColor: zoneBg,
fillStyle: 'hachure',
strokeWidth: 1.4,
strokeStyle: 'solid',
roughness: 0.8,
opacity: 100,
roundness: { type: 3 },
boundElements: [{ type: 'text', id: zoneTitleId }],
seed: nextSeed(),
version: 1,
versionNonce: nextSeed(),
isDeleted: false,
groupIds: [],
frameId: null,
updated: Date.now(),
link: null,
locked: false,
}
const zoneTitle = {
id: zoneTitleId,
type: 'text',
x: zoneX + 14,
y: zoneY + 10,
width: Math.min(zoneW - 28, Math.max(120, measureTextWidth(zone.label, 20) + 12)),
height: 28,
angle: 0,
strokeColor: style === 'austere' ? '#495057' : '#0f172a',
backgroundColor: 'transparent',
fillStyle: 'solid',
strokeWidth: 1,
strokeStyle: 'solid',
roughness: 0,
opacity: 100,
fontSize: 20,
fontFamily: stylePreset.textFontFamily,
textAlign: 'left',
verticalAlign: 'top',
text: zone.label,
boundElements: [],
seed: nextSeed(),
version: 1,
versionNonce: nextSeed(),
isDeleted: false,
groupIds: [],
frameId: null,
updated: Date.now(),
link: null,
locked: false,
containerId: zoneRectId,
originalText: zone.label,
autoResize: true,
lineHeight: 1.25,
}
// Important: keep container before bound text to preserve
// Excalidraw bound-element fractional index invariants.
zoneElements.push(zoneRect, zoneTitle)
}
elements.unshift(...zoneElements)
}
for (let edgeIndex = 0; edgeIndex < edges.length; edgeIndex++) {
const edge = edges[edgeIndex]
const fromShape = nodeElements.get(edge.from)
const toShape = nodeElements.get(edge.to)
if (!fromShape || !toShape) continue
const arrowId = `arrow-${edge.from}-${edge.to}-${edgeIndex}`
const fromCenter = getCenter(fromShape)
const toCenter = getCenter(toShape)
const fromEdge = getEdgePoint(fromShape, toCenter)
const toEdge = getEdgePoint(toShape, fromCenter)
let dx = toEdge.x - fromEdge.x
let dy = toEdge.y - fromEdge.y
const directKey = `${edge.from}->${edge.to}`
const reverseKey = `${edge.to}->${edge.from}`
const canonical = directKey < reverseKey ? `${directKey}|${reverseKey}` : `${reverseKey}|${directKey}`
const pairTotal = edgePairCount.get(canonical) || 1
const pairIndex = edgePairSeen.get(canonical) || 0
edgePairSeen.set(canonical, pairIndex + 1)
if (pairTotal > 1) {
const nx = -dy
const ny = dx
const norm = Math.hypot(nx, ny) || 1
const spacing = 16
const centeredOffset = (pairIndex - (pairTotal - 1) / 2) * spacing
const ox = (nx / norm) * centeredOffset
const oy = (ny / norm) * centeredOffset
dx += ox
dy += oy
}
fromShape.boundElements.push({ id: arrowId, type: 'element' })
toShape.boundElements.push({ id: arrowId, type: 'element' })
elements.push({
id: arrowId, type: 'arrow',
x: fromEdge.x, y: fromEdge.y, width: Math.abs(dx), height: Math.abs(dy),
angle: 0, strokeColor: visualStyle.edge, backgroundColor: 'transparent',
fillStyle: 'solid', strokeWidth: stylePreset.arrowStrokeWidth, strokeStyle: 'solid', roughness: stylePreset.arrowRoughness, opacity: 100,
roundness: { type: 2 }, boundElements: [],
seed: nextSeed(), version: 1, versionNonce: nextSeed(),
isDeleted: false, groupIds: [], frameId: null, updated: Date.now(), link: null, locked: false,
startBinding: { elementId: edge.from, focus: 0, gap: 8, fixedPoint: null },
endBinding: { elementId: edge.to, focus: 0, gap: 8, fixedPoint: null },
lastCommittedPoint: null, startArrowhead: null, endArrowhead: 'arrow',
points: [[0, 0], [dx, dy]], elbowed: false,
})
if (edge.label) {
const labelId = `label-${edge.from}-${edge.to}-${edgeIndex}`
const labelFontSize = 12
const labelW = measureTextWidth(edge.label, labelFontSize) + 16
const nx = -dy
const ny = dx
const norm = Math.hypot(nx, ny) || 1
const labelOffset = 12
const midX = fromEdge.x + dx / 2 - labelW / 2 + (nx / norm) * labelOffset
const midY = fromEdge.y + dy / 2 - 8 + (ny / norm) * labelOffset
elements.push({
id: labelId, type: 'text',
x: midX, y: midY, width: labelW, height: Math.ceil(labelFontSize * 1.25),
angle: 0, strokeColor: visualStyle.edgeLabel, backgroundColor: 'transparent',
fillStyle: 'solid', strokeWidth: 1, strokeStyle: 'solid', roughness: 0, opacity: 100,
fontSize: labelFontSize, fontFamily: stylePreset.textFontFamily, textAlign: 'center', verticalAlign: 'middle',
text: edge.label, boundElements: [{ id: arrowId, type: 'element' }],
seed: nextSeed(), version: 1, versionNonce: nextSeed(),
isDeleted: false, groupIds: [], frameId: null, updated: Date.now(), link: null, locked: false,
containerId: null, originalText: edge.label, autoResize: true, lineHeight: 1.25,
})
}
}
ensureBidirectionalBindings(elements)
return { elements, layoutQuality: quality, rankdir, engine }
}
toolRegistry.register({
name: 'generate_excalidraw',
description: 'Generate an Excalidraw diagram and save it as a Canvas in the Lab.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: `Generate an Excalidraw diagram and save it as a Canvas in the Lab.
YOU MUST USE THIS EXACT FORMAT (simplified nodes + edges — auto-layout handles positioning and arrows):
{
"title": "Diagram Title",
"type": "auto",
"style": "default",
"nodes": [
{"id":"c","label":"Central Concept","type":"ellipse","zoneId":"z1"},
{"id":"n1","label":"Process Step","zoneId":"z1"},
{"id":"n2","label":"Decision?","type":"diamond","zoneId":"z1"},
{"id":"n3","label":"Result","type":"ellipse","zoneId":"z2"}
],
"zones": [
{"id":"z1","label":"Admin RG"},
{"id":"z2","label":"Target RG"}
],
"edges": [
{"from":"c","to":"n1","label":"triggers"},
{"from":"n1","to":"n2"},
{"from":"n2","to":"n3","label":"yes"}
]
}
RULES:
- First node MUST be type "ellipse" (entry point)
- Use "diamond" for decisions/choices
- Use "rect" or omit type for process steps (default)
- ALL nodes must be connected via edges
- 4-10 nodes, short labels (max 40 chars)
- Add labels on important edges to explain relationships
- type: "auto" | "flowchart" | "mindmap" | "architecture-cloud" | "org-chart" | "timeline" | "process-map"
- style: "default" (coloré Excalidraw), "sketch-plus" (sketch Excalidraw+), or "austere" (sobre)
- Do NOT use raw element arrays — only use the simplified {nodes, edges} format`,
inputSchema: z.object({
title: z.string().describe('Title for the canvas'),
diagram: z.string().describe('JSON object with {title, nodes:[], edges:[]}'),
}),
execute: async ({ title, diagram }) => {
try {
console.log('[Excalidraw Tool] INPUT title:', title)
console.log('[Excalidraw Tool] INPUT diagram (first 500):', diagram?.substring(0, 500))
let elements: any[]
let effectiveTitle = title
let forcedAgentStyle: DiagramStyle | null = null
let appliedStyle: DiagramStyle = 'default'
let appliedType: Exclude<DiagramType, 'auto'> = 'flowchart'
if (ctx.agentId) {
const agentConfig = await prisma.agent.findUnique({
where: { id: ctx.agentId },
select: { slideStyle: true },
})
if (agentConfig?.slideStyle === 'austere' || agentConfig?.slideStyle === 'default' || agentConfig?.slideStyle === 'sketch-plus') {
forcedAgentStyle = agentConfig.slideStyle
}
}
const parsed = JSON.parse(diagram)
if (parsed.nodes && Array.isArray(parsed.nodes)) {
const sanitized = sanitizeGraph(parsed.nodes || [], parsed.edges || [])
const nodes = sanitized.nodes
const edges = sanitized.edges
const style = forcedAgentStyle ?? resolveDiagramStyle(parsed.style)
const diagramType = resolveDiagramType(parsed.type)
const zones: DiagramZone[] = Array.isArray(parsed.zones)
? parsed.zones
.map((z: any, i: number) => ({
id: String(z?.id || `z${i + 1}`).trim(),
label: String(z?.label || z?.id || `Zone ${i + 1}`).trim().substring(0, 40),
}))
.filter((z: DiagramZone) => z.id.length > 0)
: []
const effectiveType = inferDiagramType(diagramType, effectiveTitle, nodes, edges, zones)
effectiveTitle = parsed.title || title || 'Diagram'
const built = await buildElementsFromSimplified(nodes, edges, style, effectiveType, zones)
elements = built.elements
appliedStyle = style
appliedType = effectiveType
console.log('[Excalidraw Tool] Graph sanitize metrics:', sanitized.metrics)
console.log('[Excalidraw Tool] Layout quality:', { ...built.layoutQuality, rankdir: built.rankdir, engine: built.engine, style, forcedAgentStyle, diagramType: effectiveType, zones: zones.length })
} else if (parsed.elements && Array.isArray(parsed.elements)) {
elements = parsed.elements.map((el: any, i: number) => normalizeElement(el, i))
effectiveTitle = parsed.title || title || 'Diagram'
} else if (Array.isArray(parsed)) {
elements = parsed.map((el: any, i: number) => normalizeElement(el, i))
} else {
return { success: false, error: 'Invalid format. Use {nodes:[], edges:[]} format.' }
}
ensureBidirectionalBindings(elements)
if (!elements || elements.length === 0) {
return { success: false, error: 'No elements in diagram.' }
}
console.log('[Excalidraw Tool] Elements count:', elements.length, '| Types:', [...new Set(elements.map((e: any) => e.type))])
const canvas = await prisma.canvas.create({
data: {
name: effectiveTitle || 'Diagram',
data: JSON.stringify({
type: 'excalidraw', version: 2, source: 'https://excalidraw.com',
elements,
appState: { viewBackgroundColor: '#ffffff', gridSize: 20 },
files: {},
meta: { appliedStyle, appliedType },
}),
userId: ctx.userId,
},
})
console.log('[Excalidraw Tool] Canvas created:', canvas.id, canvas.name)
// Immediately mark the AgentAction as success so frontend polling unblocks
if (ctx.actionId) {
await prisma.agentAction.update({
where: { id: ctx.actionId },
data: {
status: 'success',
result: canvas.id,
log: `Diagram generated: ${elements.length} elements`,
},
}).catch(err => console.error('[Excalidraw Tool] Failed to update action status:', err))
}
return {
success: true, canvasId: canvas.id, canvasName: canvas.name,
elementCount: elements.length,
appliedStyle,
appliedType,
message: `Canvas created with ${elements.length} elements. Open in Lab to view/edit.`,
}
} catch (e: any) {
console.error('[Excalidraw Tool] FATAL:', e)
return { success: false, error: `Failed: ${e.message}` }
}
},
}),
})