'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 { 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() 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 = { 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 = { 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 { 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() 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() 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(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 quality: LayoutQualityMetrics rankdir: 'LR' | 'TB' } interface NodeRenderSpec { text: string fontSize: number width: number height: number } function separateArchitectureZones( layout: Map, 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, ): Promise> { 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() 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() 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): 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): { 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, ): 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, ): LayoutQualityMetrics { return { nodeOverlaps: countNodeOverlaps(layout), edgeCrossings: countEdgeCrossings(nodes, edges, layout), } } function scoreLayout( quality: LayoutQualityMetrics, bounds: { width: number; height: number }, diagramType: Exclude, ): 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, ): Promise | 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() 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, ): Promise { const renderSpecs = new Map() 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; 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, 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() 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 = new Map() const edgePairCount = new Map() const edgePairSeen = new Map() 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 = '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) 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}` } } }, }), })