- 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>
1213 lines
42 KiB
TypeScript
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}` }
|
|
}
|
|
},
|
|
}),
|
|
})
|