@eventcatalog/core 5.7.0 → 5.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-FMWQ2YYN.js → chunk-B3FUBDJN.js} +1 -1
  6. package/dist/{chunk-4CV7VC4K.js → chunk-EPI5BHEU.js} +1 -1
  7. package/dist/{chunk-MOPKQMQP.js → chunk-NNTWQNQ5.js} +1 -1
  8. package/dist/{chunk-BE5PTWNC.js → chunk-OR4VBY3F.js} +1 -1
  9. package/dist/{chunk-22KZ4TJF.js → chunk-TYZENGGJ.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +702 -128
  19. package/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx +3 -2
  20. package/eventcatalog/src/components/ChatPanel/assistant-events.ts +37 -0
  21. package/eventcatalog/src/components/ChatPanel/panel-width.ts +28 -0
  22. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +2 -34
  23. package/eventcatalog/src/features/ai/chat-api.ts +24 -5
  24. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +72 -71
  25. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +1 -1
  26. package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
  27. package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
  28. package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
  29. package/eventcatalog/src/features/studio/catalog.ts +5 -3
  30. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
  31. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +116 -13
  32. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  33. package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
  34. package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
  35. package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
  36. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +3 -15
  37. package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
  38. package/package.json +4 -4
@@ -97,15 +97,17 @@ export const buildContainer = (resource: CatalogResource, center: { x: number; y
97
97
  * A domain or system as a container with everything it holds in the catalog: its systems (as containers too)
98
98
  * and services, and the messages and data stores of those services, so every level of detail (L1 to L3) has
99
99
  * something to show. Laid out with the visualiser's layout and centred on a point, with the connections between
100
- * them and to related resources already on the canvas. Resources already on the canvas aren't added again.
100
+ * them and to related resources already on the canvas. Resources already on the canvas aren't added again, nor
101
+ * any in `alsoAdding` (by catalog key: added some other way at the same time, e.g. by an agent in the same call).
101
102
  */
102
103
  export const buildContainerWithContents = async (
103
104
  resource: CatalogResource,
104
105
  center: { x: number; y: number },
105
106
  catalog: { resourcesByKey: Map<string, CatalogResource>; relationsByKey: Map<string, CatalogRelation[]> },
106
- existing: Node[]
107
+ existing: Node[],
108
+ alsoAdding: ReadonlySet<string> = new Set()
107
109
  ): Promise<{ nodes: Node[]; edges: Edge[] }> => {
108
- const onCanvas = new Set(existing.map((node) => getCatalogLink(node)?.key).filter(Boolean));
110
+ const onCanvas = new Set([...existing.map((node) => getCatalogLink(node)?.key).filter(Boolean), ...alsoAdding]);
109
111
  const root = buildContainer(resource, { x: 0, y: 0 });
110
112
  const nodes: Node[] = [root];
111
113
  const added = (key: string) => onCanvas.has(key) || nodes.some((node) => getCatalogLink(node)?.key === key);
@@ -10,6 +10,7 @@ import {
10
10
  Plug,
11
11
  Plus,
12
12
  Share2,
13
+ Sparkles,
13
14
  Trash2,
14
15
  Users,
15
16
  } from 'lucide-react';
@@ -440,6 +441,7 @@ export default memo(function CanvasHeader({
440
441
  statusHistory,
441
442
  onStatusChange,
442
443
  onDelete,
444
+ onDrawWithAi,
443
445
  }: {
444
446
  title?: string;
445
447
  onRetitle: (title: string) => void;
@@ -470,6 +472,8 @@ export default memo(function CanvasHeader({
470
472
  onHelp: () => void;
471
473
  /** Ask to delete the canvas (not given where it can't be, e.g. inside a chat) */
472
474
  onDelete?: () => void;
475
+ /** Opens the assistant to draw on the canvas (not given where there isn't one, e.g. inside a chat) */
476
+ onDrawWithAi?: () => void;
473
477
  }) {
474
478
  const peers = usePeople(presence);
475
479
  const agentsHere = peers.some((peer) => peer.agent);
@@ -505,6 +509,16 @@ export default memo(function CanvasHeader({
505
509
  </div>
506
510
  )}
507
511
  <ProfileMenu me={me} onRename={onRename} onRecolor={onRecolor} />
512
+ {onDrawWithAi && (
513
+ <button
514
+ onClick={onDrawWithAi}
515
+ title="Describe what to diagram and the assistant draws it here"
516
+ className={`${barClass} gap-2 px-3 text-sm font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:bg-[rgb(var(--ec-accent-subtle))]`}
517
+ >
518
+ <Sparkles size={16} className="text-[rgb(var(--ec-accent))]" aria-hidden />
519
+ Draw with AI
520
+ </button>
521
+ )}
508
522
  <button
509
523
  onClick={onShare}
510
524
  className="pointer-events-auto inline-flex h-[38px] items-center gap-2 rounded-[9px] bg-[rgb(var(--ec-page-text))] px-4 text-sm font-semibold text-[rgb(var(--ec-page-bg))] shadow-sm transition-colors hover:bg-[rgb(var(--ec-page-text)/0.85)]"
@@ -94,6 +94,7 @@ import {
94
94
  isGroupType,
95
95
  type Hotspot,
96
96
  type HotspotDraft,
97
+ type HotspotPlace,
97
98
  type HotspotFields,
98
99
  type HotspotSeverity,
99
100
  type HotspotSpot,
@@ -133,6 +134,12 @@ import ShareDialog from './ShareDialog';
133
134
  import DeleteCanvasDialog from './DeleteCanvasDialog';
134
135
  import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
135
136
  import ShortcutsDialog from './ShortcutsDialog';
137
+ import {
138
+ ASSISTANT_DRAWING_EVENT,
139
+ CANVAS_FOCUS_EVENT,
140
+ CANVAS_HOTSPOT_EVENT,
141
+ OPEN_CHAT_EVENT,
142
+ } from '../../../components/ChatPanel/assistant-events';
136
143
 
137
144
  type CanvasProps = {
138
145
  canvasId: string;
@@ -165,6 +172,8 @@ type CanvasProps = {
165
172
  asAgent?: { name: string; color: string };
166
173
  /** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
167
174
  signedInAs?: { name: string; picture?: string };
175
+ /** The catalog's assistant (Ask AI) is on the page: "Draw with AI" opens it to draw on the canvas */
176
+ assistant?: boolean;
168
177
  resources: CatalogResource[];
169
178
  relations: CatalogRelation[];
170
179
  };
@@ -198,6 +207,7 @@ type Landing = {
198
207
  };
199
208
  const DELETE_KEYS = ['Backspace', 'Delete'];
200
209
  const NO_THREADS: ReadonlySet<string> = new Set();
210
+ const NO_HOTSPOTS: ReadonlySet<string> = new Set();
201
211
  // React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
202
212
  const onFlowError = (code: string, message: string) => {
203
213
  if (code !== '010') console.warn(message);
@@ -339,6 +349,9 @@ export default function StudioDesigner({ asAgent, signedInAs, ...props }: Canvas
339
349
  );
340
350
  }
341
351
 
352
+ // The assistant's panel (ChatPanelButton, in the page header) opens on this; on a canvas it draws on it
353
+ const openAssistant = () => window.dispatchEvent(new Event(OPEN_CHAT_EVENT));
354
+
342
355
  const toSocketUrl = (socketPath = DEFAULT_SOCKET_PATH) =>
343
356
  `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${socketPath}`;
344
357
 
@@ -353,6 +366,7 @@ function Canvas({
353
366
  mcpUrl = '/docs/mcp',
354
367
  relayScriptPath,
355
368
  keptInMemory = false,
369
+ assistant = false,
356
370
  syncViaTools,
357
371
  followChanges = false,
358
372
  paletteCollapsed,
@@ -686,6 +700,8 @@ function Canvas({
686
700
  const [lastCopied, setLastCopied] = useState<ClipboardContent | null>(null);
687
701
  // Nodes to copy instead of the selection (copying from the context menu)
688
702
  const copyOnly = useRef<string[] | null>(null);
703
+ // Whether the menu's copy got onto the clipboard (Cut only deletes once it has)
704
+ const copiedFromMenu = useRef(false);
689
705
 
690
706
  /** Adds copies (as one change, so one undo takes them back) and selects them instead of what was selected */
691
707
  const paste = useStableCallback((content: ClipboardContent, place: { at: XYPosition } | { offset: XYPosition }) => {
@@ -718,6 +734,7 @@ function Canvas({
718
734
  event.clipboardData.setData('text/plain', content.nodes.map((node) => getNodeName(node.type, node.data)).join('\n'));
719
735
  pastes.current = 0;
720
736
  setLastCopied(content);
737
+ if (fromMenu) copiedFromMenu.current = true;
721
738
  if (event.type === 'cut') flow.deleteNodes(ids);
722
739
  };
723
740
  const onPaste = (event: ClipboardEvent) => {
@@ -973,7 +990,13 @@ function Canvas({
973
990
  // Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
974
991
  // are selected. While what's selected is dragged, the ones not on a node move with it by this much.
975
992
  const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
976
- const clearSelectedThreads = useCallback(() => setSelectedThreads((current) => (current.size ? NO_THREADS : current)), []);
993
+ // Hotspots too (selecting everything selects them, and deleting it deletes them)
994
+ const [selectedHotspots, setSelectedHotspots] = useState<ReadonlySet<string>>(NO_HOTSPOTS);
995
+ /** Comments and hotspots stop being part of the selection */
996
+ const clearSelectedPins = useCallback(() => {
997
+ setSelectedThreads((current) => (current.size ? NO_THREADS : current));
998
+ setSelectedHotspots((current) => (current.size ? NO_HOTSPOTS : current));
999
+ }, []);
977
1000
  const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
978
1001
  const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
979
1002
  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
@@ -1173,6 +1196,10 @@ function Canvas({
1173
1196
  setHotspotDraft(null);
1174
1197
  });
1175
1198
  const cancelHotspot = useCallback(() => setHotspotDraft(null), []);
1199
+ // The hotspot being added, dragged somewhere else before it's added: onto a node, or a place on the level shown
1200
+ const moveHotspotDraft = useStableCallback((to: HotspotPlace) =>
1201
+ setHotspotDraft((draft) => draft && { ...(to.nodeId ? { nodeId: to.nodeId } : placeLevel()), position: to.position })
1202
+ );
1176
1203
  const deleteHotspot = useStableCallback((id: string) => flow.deleteHotspot(id));
1177
1204
  // Dragged onto a node (they move with it), or to a place; open where it lands if it was open
1178
1205
  const moveHotspots = useStableCallback((ids: string[], to: { nodeId?: string; position: XYPosition }) => {
@@ -1194,8 +1221,8 @@ function Canvas({
1194
1221
  const width = node ? (node.measured?.width ?? node.width ?? 0) : 0;
1195
1222
  void setCenter(at.x + width / 2 + 120, at.y + 80, { zoom: Math.max(getZoom(), 0.8), duration: 300 });
1196
1223
  setHotspotDraft(null);
1224
+ // (the list stays open, so they can go through them one by one)
1197
1225
  setOpenHotspot(node ? `node:${node.id}` : `place:${hotspot.id}`);
1198
- setListingHotspots(false);
1199
1226
  });
1200
1227
  const nameOfNode = useStableCallback((id: string) => {
1201
1228
  const node = nodesRef.current.find((candidate) => candidate.id === id);
@@ -1328,6 +1355,57 @@ function Canvas({
1328
1355
 
1329
1356
  const [following, setFollowing] = useState(followChanges);
1330
1357
  const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
1358
+ // Asking the assistant (Ask AI) to draw on this canvas follows it while it works
1359
+ useEffect(() => {
1360
+ const followAssistant = (event: Event) => {
1361
+ if ((event as CustomEvent<{ canvasId?: string }>).detail?.canvasId === canvasId) setFollowing(true);
1362
+ };
1363
+ window.addEventListener(ASSISTANT_DRAWING_EVENT, followAssistant);
1364
+ return () => window.removeEventListener(ASSISTANT_DRAWING_EVENT, followAssistant);
1365
+ }, [canvasId]);
1366
+
1367
+ // The assistant taking you to part of the canvas (showOnCanvas, a walkthrough's steps): zoomed to, and selected. On
1368
+ // the canvas people edit (L3), where everything is.
1369
+ const focusOn = useStableCallback((ids: string[]) => {
1370
+ const nodes = lookupNodes();
1371
+ const found = ids.filter((id) => nodes.has(id));
1372
+ if (!found.length) return;
1373
+ const changingLevel = levelRef.current !== 3;
1374
+ if (changingLevel) changeLevel(3);
1375
+ // You're looking around now: following the agent would take the camera back to its pointer a moment later
1376
+ setFollowing(false);
1377
+ // Once the level's switched (and drawn), if it was
1378
+ setTimeout(
1379
+ () => {
1380
+ select(new Set(found));
1381
+ void fitView({ nodes: found.map((id) => ({ id })), duration: 600, padding: 0.35, maxZoom: 1.2 });
1382
+ },
1383
+ changingLevel ? 400 : 0
1384
+ );
1385
+ });
1386
+ useEffect(() => {
1387
+ const focus = (event: Event) => {
1388
+ const { canvasId: on, nodeIds } = (event as CustomEvent<{ canvasId?: string; nodeIds?: string[] }>).detail ?? {};
1389
+ if (on === canvasId && nodeIds?.length) focusOn(nodeIds);
1390
+ };
1391
+ window.addEventListener(CANVAS_FOCUS_EVENT, focus);
1392
+ return () => window.removeEventListener(CANVAS_FOCUS_EVENT, focus);
1393
+ }, [canvasId, focusOn]);
1394
+ // ...and to a hotspot it flagged, opened
1395
+ const showHotspot = useStableCallback((hotspotId: string) => {
1396
+ const hotspot = flow.hotspots.find((candidate) => candidate.id === hotspotId);
1397
+ if (!hotspot) return;
1398
+ setFollowing(false);
1399
+ goToHotspot(hotspot);
1400
+ });
1401
+ useEffect(() => {
1402
+ const show = (event: Event) => {
1403
+ const { canvasId: on, hotspotId } = (event as CustomEvent<{ canvasId?: string; hotspotId?: string }>).detail ?? {};
1404
+ if (on === canvasId && hotspotId) showHotspot(hotspotId);
1405
+ };
1406
+ window.addEventListener(CANVAS_HOTSPOT_EVENT, show);
1407
+ return () => window.removeEventListener(CANVAS_HOTSPOT_EVENT, show);
1408
+ }, [canvasId, showHotspot]);
1331
1409
 
1332
1410
  const [shareOpen, setShareOpen] = useState<false | 'choose' | 'invite'>(false);
1333
1411
  const openShare = useCallback(() => setShareOpen('choose'), []);
@@ -1458,6 +1536,8 @@ function Canvas({
1458
1536
  flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
1459
1537
  // ...and the comments shown on it
1460
1538
  setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
1539
+ // ...and its hotspots (the ones on L1 and L2's own places are on those levels)
1540
+ setSelectedHotspots(new Set(flow.hotspots.filter((hotspot) => !hotspot.level).map((hotspot) => hotspot.id)));
1461
1541
  },
1462
1542
  { preventDefault: true }
1463
1543
  );
@@ -1492,7 +1572,7 @@ function Canvas({
1492
1572
  setTool('select');
1493
1573
  setDraft(null);
1494
1574
  setOpenThreadId(null);
1495
- clearSelectedThreads();
1575
+ clearSelectedPins();
1496
1576
  setEditingId(null);
1497
1577
  flow.onNodesChange(
1498
1578
  nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
@@ -1501,11 +1581,16 @@ function Canvas({
1501
1581
  edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
1502
1582
  );
1503
1583
  });
1504
- // Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
1584
+ // Selected comments and hotspots are deleted with what's selected (React Flow deletes the nodes and edges)
1505
1585
  useHotkeys(DELETE_KEYS, () => {
1506
- if (!onCanvas() || selectedThreads.size === 0) return;
1507
- comments.deleteThreads([...selectedThreads]);
1508
- clearSelectedThreads();
1586
+ if (!onCanvas() || (selectedThreads.size === 0 && selectedHotspots.size === 0)) return;
1587
+ if (selectedThreads.size) comments.deleteThreads([...selectedThreads]);
1588
+ // (hotspots, like nodes, stay on a locked canvas)
1589
+ if (!locked) {
1590
+ selectedHotspots.forEach((id) => flow.deleteHotspot(id));
1591
+ if (selectedHotspots.size) setOpenHotspot(null);
1592
+ }
1593
+ clearSelectedPins();
1509
1594
  });
1510
1595
  /** A tool that puts something down: picked, or put back to selecting if it was already */
1511
1596
  const pickTool = useStableCallback((next: 'text' | 'note' | 'comment' | 'hotspot') => {
@@ -1538,7 +1623,9 @@ function Canvas({
1538
1623
  // A place on L1 or L2 is on that level (they're laid out on their own): what's put there says so
1539
1624
  const placeLevel = () => (levelRef.current === 3 ? {} : { level: levelRef.current as 1 | 2 });
1540
1625
  const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
1541
- clearSelectedThreads();
1626
+ clearSelectedPins();
1627
+ // An open hotspot closes on a click anywhere else on the canvas
1628
+ setOpenHotspot(null);
1542
1629
  if (tool === 'hand') return;
1543
1630
  if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
1544
1631
  // Flagging: a hotspot where it's clicked
@@ -1574,7 +1661,8 @@ function Canvas({
1574
1661
 
1575
1662
  const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
1576
1663
  // Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
1577
- if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
1664
+ if (!event.shiftKey && !event.metaKey) clearSelectedPins();
1665
+ setOpenHotspot(null);
1578
1666
  // Not on notes and text: they're written on, not what's being flagged or commented on
1579
1667
  if (isWrittenOnCanvas(node.type)) return;
1580
1668
  // A hotspot on it (it shows on the node, wherever it goes, on every level it's on)
@@ -1645,6 +1733,13 @@ function Canvas({
1645
1733
  flow.deleteNodes(menuTargets());
1646
1734
  setContextMenu(null);
1647
1735
  });
1736
+ // Copies, then deletes, as ⌘X does: only on L3, where copying works (so it's not offered on L1 and L2)
1737
+ const cutFromMenu = useStableCallback(() => {
1738
+ copiedFromMenu.current = false;
1739
+ copyFromMenu();
1740
+ // Not copied (the browser refused, or there was nothing to copy): nothing's deleted, as with ⌘X
1741
+ if (copiedFromMenu.current) deleteFromMenu();
1742
+ });
1648
1743
 
1649
1744
  const openThread = useStableCallback((thread: Thread) => {
1650
1745
  setListingComments(false);
@@ -1761,7 +1856,7 @@ function Canvas({
1761
1856
  ref={canvasRef}
1762
1857
  className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
1763
1858
  transitioning ? 'is-layout-transition' : ''
1764
- } ${placing || tool === 'comment' || tool === 'hotspot' ? 'is-placing' : ''} ${tool === 'hand' ? 'is-panning' : ''}`}
1859
+ } ${placing || tool === 'comment' || tool === 'hotspot' ? 'is-placing' : ''} ${tool === 'hotspot' ? 'is-placing-hotspot' : ''} ${tool === 'hand' ? 'is-panning' : ''}`}
1765
1860
  onPointerMove={trackPointer}
1766
1861
  onPointerLeave={clearPointer}
1767
1862
  onDoubleClick={onCanvasDoubleClick}
@@ -1799,7 +1894,7 @@ function Canvas({
1799
1894
  onNodeClick={onNodeClick}
1800
1895
  onNodeDoubleClick={onNodeDoubleClick}
1801
1896
  onError={onFlowError}
1802
- onSelectionStart={clearSelectedThreads}
1897
+ onSelectionStart={clearSelectedPins}
1803
1898
  onPaneContextMenu={onPaneContextMenu}
1804
1899
  onNodeContextMenu={onNodeContextMenu}
1805
1900
  connectionLineType={ConnectionLineType.SmoothStep}
@@ -1849,7 +1944,7 @@ function Canvas({
1849
1944
  draft={hotspotDraft}
1850
1945
  locked={locked}
1851
1946
  movable={!locked}
1852
- selectedIds={null}
1947
+ selectedIds={selectedHotspots.size ? selectedHotspots : null}
1853
1948
  onMove={moveHotspots}
1854
1949
  onDropTarget={setHotspotDropTarget}
1855
1950
  onOpen={setOpenHotspot}
@@ -1858,6 +1953,7 @@ function Canvas({
1858
1953
  onUpdate={flow.updateHotspot}
1859
1954
  onDelete={deleteHotspot}
1860
1955
  onGo={goToHotspot}
1956
+ onMoveDraft={moveHotspotDraft}
1861
1957
  />
1862
1958
  </ReactFlow>
1863
1959
 
@@ -1971,7 +2067,12 @@ function Canvas({
1971
2067
  </button>
1972
2068
  </div>
1973
2069
  <div className="min-h-0 flex-1 overflow-y-auto">
1974
- <HotspotList hotspots={flow.hotspots} nameOf={nameOfNode} onOpen={goToHotspot} />
2070
+ <HotspotList
2071
+ hotspots={flow.hotspots}
2072
+ nameOf={nameOfNode}
2073
+ onOpen={goToHotspot}
2074
+ goOnHover={!hotspotDraft}
2075
+ />
1975
2076
  </div>
1976
2077
  </div>
1977
2078
  )}
@@ -2001,6 +2102,7 @@ function Canvas({
2001
2102
  onAddNote={noteFromMenu}
2002
2103
  onEditDetails={editFromMenu}
2003
2104
  onCopy={copyFromMenu}
2105
+ onCut={level === 3 ? cutFromMenu : undefined}
2004
2106
  onDuplicate={duplicateFromMenu}
2005
2107
  onPaste={lastCopied ? pasteFromMenu : undefined}
2006
2108
  onReorder={reorderFromMenu}
@@ -2033,6 +2135,7 @@ function Canvas({
2033
2135
  statusHistory={flow.canvasStatus.history}
2034
2136
  onStatusChange={flow.changeStatus}
2035
2137
  onDelete={canvasesApiUrl ? openDelete : undefined}
2138
+ onDrawWithAi={assistant ? openAssistant : undefined}
2036
2139
  />
2037
2140
 
2038
2141
  {flow.deleted && !leaving && (
@@ -277,7 +277,7 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
277
277
  defineTool({
278
278
  name: 'remove_from_canvas',
279
279
  title: 'Remove from the canvas',
280
- description: 'Removes nodes (and their connections) or connections. Only remove what the user asked for.',
280
+ description: 'Removes nodes (and their connections and hotspots) or connections. Only remove what the user asked for.',
281
281
  input: z.object({ nodeIds: z.array(z.string()).optional(), connectionIds: z.array(z.string()).optional() }),
282
282
  annotations: { consequentialHint: true },
283
283
  execute: async ({ nodeIds = [], connectionIds = [] }) => {
@@ -11,6 +11,7 @@ import { getCatalogResources } from '../catalog-resources';
11
11
  import { startStudio, STUDIO_SOCKET_PATH } from '../server/runtime';
12
12
  import { getSignedInUser, isSignedIn } from '../server/sign-in';
13
13
  import { CANVASES_API_PATH } from '../api/canvases-api';
14
+ import { isEventCatalogChatVisible } from '@utils/feature';
14
15
 
15
16
  export const prerender = false;
16
17
 
@@ -59,6 +60,7 @@ const signedInAs = getSignedInUser(Astro.locals);
59
60
  mcpUrl={buildUrl('/docs/mcp', true)}
60
61
  relayScriptPath={buildUrl('/webmcp-relay/embed.js', true)}
61
62
  keptInMemory={keptInMemory}
63
+ assistant={isEventCatalogChatVisible()}
62
64
  signedInAs={signedInAs}
63
65
  resources={resources}
64
66
  relations={relations}
@@ -0,0 +1,207 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { getCanvasMaps, getLayoutPositions, readCanvas } from '@eventcatalog/visualiser/studio';
4
+ import {
5
+ ADD_TO_CANVAS_DESCRIPTION,
6
+ describeCanvas,
7
+ edgeSpecsSchema,
8
+ indexCatalog,
9
+ nodeSpecsSchema,
10
+ withNotesPlaced,
11
+ } from '../canvas-actions';
12
+ import { getCatalogResources } from '../catalog-resources';
13
+ import { playAddToCanvas, playConnectAll, playHotspot, playLayout, playRemove, playUpdateNode } from '../agent-choreography';
14
+ import { getAgent } from '../agents';
15
+ import { agentOnCanvas } from './canvas-agent';
16
+ import { findStudioRuntime } from './runtime';
17
+
18
+ /**
19
+ * The assistant (Ask AI) on a Studio canvas: the canvas it was opened on, drawn on with the model the catalog has
20
+ * configured. Its tools play changes out like the MCP canvas tools do (same choreography), so everyone on the canvas
21
+ * watches the assistant's pointer draw the diagram.
22
+ */
23
+
24
+ const ASSISTANT = getAgent('EventCatalog AI');
25
+ // Someone's waiting on the assistant in the chat: it draws about three times as fast as an MCP agent
26
+ const ASSISTANT_PACE = 0.3;
27
+
28
+ /** What the assistant is told when it's opened on a canvas, after its usual instructions */
29
+ const CANVAS_ASSISTANT_PROMPT = `
30
+ The user is on an EventCatalog Studio canvas: a live, collaborative diagram. They opened you there to draw on it with them.
31
+ When they ask you to diagram, design, map out, sketch or change something, draw it on the canvas with the canvas tools; don't answer with Mermaid or text diagrams.
32
+
33
+ If you need to ask the user something first (what to diagram, or a request too vague to draw), ask straight away, without calling any tools.
34
+
35
+ How to draw:
36
+ 1. Read the canvas before you change it (getCanvas), so you build on what's there and don't add what's already on it.
37
+ 2. Find what they asked about in the catalog (getResources, getResource, getMessagesProducedOrConsumedByResource, getArchitectureDiagramAsMermaid) and add those resources by collection and id. Catalog resources are connected to the related resources already on the canvas for you.
38
+ - A domain or system from the catalog: add it with contents: true, which brings everything in it at once. Don't look up and add its services and messages yourself.
39
+ - A service or message with what it connects to: add it with connections (e.g. ["sends", "receives"]), rather than finding each one.
40
+ 3. Only add new components (type, name, summary) for what isn't in the catalog yet, e.g. a proposed service or event when the user is designing something new.
41
+ 4. Add things in one or a few addToCanvas calls (with refs and edges), rather than one call per node. Use containers (domains, systems) when the diagram covers more than one of them.
42
+ 5. Don't lay the canvas out yourself: when you've finished, the canvas is tidied for you if you added things or connections. Only call layoutCanvas when the user asks you to tidy up.
43
+
44
+ Explaining the diagram on the canvas:
45
+ - Hotspots flag something people should know about a node, or a place on the canvas: a risk, a problem or something worth knowing (addHotspot: critical, warning or info, with a short title and why it matters). Use them when the user asks to flag, highlight or review things, e.g. single points of failure.
46
+ - Sticky notes hold free text on the canvas: add a node with type "note" and its text in summary. Use them for notes, assumptions, open questions, suggestions and explanations. Keep each one very short and precise, like a sticky note on a whiteboard: one point in a few words (at most about 12), e.g. "Kafka: ordered, replayable order events". Add as many as are useful, one point per note, each beside the part of the diagram it's about: give it x/y about 300 to the right of, or 250 above, that node's centre (from getCanvas, after you've drawn), clear of other nodes. Add notes and text after the nodes they're about, so you know where those are. A note or text that would land on a node is moved to the nearest clear spot beside it, and notes stay beside their node when the canvas is tidied.
47
+ - Text is words written straight on the canvas, without a note around them: add a node with type "text", what it says in summary, and fontSize for how big (22 by default; 40 to 60 for a title or a heading over part of the diagram). Use it for a diagram's title (above everything: y about 200 above the top of what's on the canvas), headings over areas (just above the domain or group they name), or short labels. Never put text where a node is. Move it with updateCanvasNode (x, y), and make it bigger or smaller with fontSize.
48
+ - When asked to review or improve the design: flag each problem or risk with a hotspot on the node it's about, and write your suggestions on a few sticky notes (one per idea), then sum up what you found in a sentence or two. The hotspots you flag are listed under your reply for the user to go to, so don't list them again.
49
+ Only add hotspots and notes when the user asks for them, or the request is about reviewing or annotating the design.
50
+
51
+ Showing and explaining what's on the canvas:
52
+ - showOnCanvas takes the user to nodes: their view zooms to them and they're highlighted. Use it to show them what you're talking about.
53
+ - Whenever you mention something that's on the canvas in your reply, write it as a reference to it, which the user can hover or click to be taken there: [Order Service](#canvas-ref=service:<node id>). The type before the colon gives it its icon and colour: service, event, command, query, channel, domain, system, container (a data store), entity, agent, flow, user, team or data-product; for anything else, leave it out: [Customer](#canvas-ref=:<node id>). Use the node ids from getCanvas (or what addToCanvas created).
54
+ - To walk the user through how something works (e.g. "walk me through how checkout works"), read the canvas, then call walkThrough with the steps in order: each a short title, a sentence or two on what happens, and the nodes involved (ids from getCanvas). The user sees the steps in the chat, and each takes them to its nodes on the canvas, starting with the first: don't write the steps out again in your reply, just a sentence to introduce them. If what they ask about isn't on the canvas, draw it first.
55
+
56
+ Keep your reply short: say what you drew and anything you couldn't find in the catalog.
57
+ `;
58
+
59
+ /** The assistant's tools and instructions for the canvas it was opened on, or undefined if there's no such canvas */
60
+ export const getCanvasAssistant = (canvasId: string) => {
61
+ const runtime = findStudioRuntime();
62
+ if (!runtime?.exists(canvasId)) return undefined;
63
+ const agent = () => agentOnCanvas(runtime, canvasId, ASSISTANT, { pace: ASSISTANT_PACE });
64
+ // Added things or connections since the canvas was last laid out: it's tidied once the assistant has finished
65
+ let drew = false;
66
+
67
+ const layout = async () => {
68
+ drew = false;
69
+ const { nodes, edges } = await runtime.withCanvas(canvasId, readCanvas);
70
+ // Notes and text stay beside what they're about
71
+ const positions = withNotesPlaced(nodes, await getLayoutPositions(nodes, edges));
72
+ await playLayout(agent().stage, positions);
73
+ return { laidOut: positions.nodes.size };
74
+ };
75
+
76
+ const tools = {
77
+ getCanvas: tool({
78
+ description:
79
+ 'Reads everything on the canvas the user has open: its nodes (with ids, names, positions (their centres), sizes and the catalog resource they stand for), the connections between them, its hotspots and the comment threads. Read it before changing the canvas, and again to see what people have changed.',
80
+ inputSchema: z.object({}),
81
+ execute: async () => runtime.withCanvas(canvasId, describeCanvas),
82
+ }),
83
+ addToCanvas: tool({
84
+ description: `${ADD_TO_CANVAS_DESCRIPTION} The user watches you add them one by one. Use getResources to find catalog resources first, and prefer them over new components.`,
85
+ inputSchema: z.object({ nodes: nodeSpecsSchema, edges: edgeSpecsSchema.optional() }),
86
+ execute: async ({ nodes, edges }) => {
87
+ const { resources, relations } = await getCatalogResources();
88
+ const result = await playAddToCanvas(agent().stage, { nodes, edges }, indexCatalog(resources, relations));
89
+ // Notes and text alone (a review) leave the layout as it is
90
+ if (result.connections.length || nodes.some((node) => node.type !== 'note' && node.type !== 'text')) drew = true;
91
+ return result;
92
+ },
93
+ }),
94
+ updateCanvasNode: tool({
95
+ description:
96
+ 'Renames, re-describes, re-versions or moves a node on the canvas (x/y: its new centre). Catalog resources keep their catalog name, summary and version. For notes and text, summary is what is written; fontSize makes text bigger or smaller.',
97
+ inputSchema: z.object({
98
+ nodeId: z.string(),
99
+ name: z.string().optional(),
100
+ summary: z.string().optional(),
101
+ version: z.string().optional().describe('e.g. 1.0.0'),
102
+ fontSize: z.number().min(8).max(200).optional().describe('For text: how big it is written, in pixels'),
103
+ x: z.number().optional(),
104
+ y: z.number().optional(),
105
+ }),
106
+ execute: async (update) => playUpdateNode(agent().stage, update),
107
+ }),
108
+ connectCanvasNodes: tool({
109
+ description:
110
+ 'Draws connections between nodes already on the canvas, one at a time. Labels default to EventCatalog wording (e.g. "publishes event", "sent to").',
111
+ inputSchema: z.object({ edges: edgeSpecsSchema.min(1) }),
112
+ execute: async ({ edges }) => {
113
+ const result = await playConnectAll(agent().stage, edges);
114
+ if (result.connections.length) drew = true;
115
+ return result;
116
+ },
117
+ }),
118
+ addHotspot: tool({
119
+ description:
120
+ 'Flags a hotspot on a node (nodeId), or at a place on the canvas (x, y): a marker everyone sees, with how serious it is, a short title and, optionally, why it matters and what to do about it. For risks, problems and things worth knowing, e.g. a single point of failure.',
121
+ inputSchema: z.object({
122
+ severity: z.enum(['critical', 'warning', 'info']).describe('critical: a problem; warning: a risk; info: worth knowing'),
123
+ title: z.string().describe('What people should know, in a few words, e.g. "Single point of failure"'),
124
+ description: z.string().optional().describe('Why it matters, and what to do about it'),
125
+ nodeId: z.string().optional().describe('The node it is about (from getCanvas or addToCanvas)'),
126
+ x: z.number().optional(),
127
+ y: z.number().optional(),
128
+ }),
129
+ execute: async (hotspot) => {
130
+ const { stage, author } = agent();
131
+ return playHotspot(stage, hotspot, author);
132
+ },
133
+ }),
134
+ showOnCanvas: tool({
135
+ description:
136
+ "Takes the user to nodes on the canvas: their view zooms to them and they're highlighted (only their view, not anyone else's). Use it to show them what you're talking about, e.g. the first step of a walkthrough.",
137
+ inputSchema: z.object({ nodeIds: z.array(z.string()).min(1).describe('The nodes to show (ids from getCanvas)') }),
138
+ execute: async ({ nodeIds }) => {
139
+ const onCanvas = await runtime.withCanvas(canvasId, (doc) => new Set(getCanvasMaps(doc).nodes.keys()));
140
+ const shown = nodeIds.filter((id) => onCanvas.has(id));
141
+ const missing = nodeIds.filter((id) => !onCanvas.has(id));
142
+ if (!shown.length)
143
+ return { error: `None of these are on the canvas: ${missing.join(', ')}. Use getCanvas for their ids.` };
144
+ return { nodeIds: shown, ...(missing.length && { notOnCanvas: missing }) };
145
+ },
146
+ }),
147
+ walkThrough: tool({
148
+ description:
149
+ 'Walks the user through how something on the canvas works, step by step: they see the steps in the chat, and each takes them to its nodes on the canvas (starting with the first). Use it when they ask how something works or flows, e.g. "walk me through checkout".',
150
+ inputSchema: z.object({
151
+ title: z.string().describe('What it walks through, e.g. "How checkout works"'),
152
+ steps: z
153
+ .array(
154
+ z.object({
155
+ title: z.string().describe('The step, in a few words, e.g. "Customer places an order"'),
156
+ description: z.string().describe('What happens in this step, in a sentence or two'),
157
+ nodeIds: z.array(z.string()).min(1).describe('The nodes involved in this step (ids from getCanvas)'),
158
+ })
159
+ )
160
+ .min(1)
161
+ .max(20),
162
+ }),
163
+ execute: async ({ title, steps }) => {
164
+ const onCanvas = await runtime.withCanvas(canvasId, (doc) => new Set(getCanvasMaps(doc).nodes.keys()));
165
+ const missing = [...new Set(steps.flatMap((step) => step.nodeIds).filter((id) => !onCanvas.has(id)))];
166
+ return {
167
+ title,
168
+ steps: steps.map((step) => ({ ...step, nodeIds: step.nodeIds.filter((id) => onCanvas.has(id)) })),
169
+ ...(missing.length && { notOnCanvas: missing }),
170
+ };
171
+ },
172
+ }),
173
+ removeFromCanvas: tool({
174
+ description:
175
+ 'Removes nodes (and their connections) or connections from the canvas. Only remove what the user asked for or what you added.',
176
+ inputSchema: z.object({ nodeIds: z.array(z.string()).optional(), connectionIds: z.array(z.string()).optional() }),
177
+ execute: async ({ nodeIds = [], connectionIds = [] }) => {
178
+ await playRemove(agent().stage, nodeIds, connectionIds);
179
+ return { removedNodes: nodeIds, removedConnections: connectionIds };
180
+ },
181
+ }),
182
+ layoutCanvas: tool({
183
+ description:
184
+ "Lays the whole canvas out again with EventCatalog's visualiser layout (left to right, connections routed around nodes). Only when the user asks you to tidy up: what you draw is laid out for you when you've finished.",
185
+ inputSchema: z.object({}),
186
+ execute: layout,
187
+ }),
188
+ };
189
+
190
+ return {
191
+ tools,
192
+ instructions: CANVAS_ASSISTANT_PROMPT,
193
+ /**
194
+ * The model started writing a call to a tool: a big addToCanvas takes a while to write, so the canvas shows the
195
+ * assistant planning it meanwhile
196
+ */
197
+ startedWriting: (toolName: string) => {
198
+ if (toolName !== 'addToCanvas') return;
199
+ const { stage } = agent();
200
+ stage.present({ pointer: stage.lastPointer ?? { x: 0, y: 0 }, selection: [], activity: 'Planning the diagram' });
201
+ },
202
+ /** The assistant has finished: what it drew is laid out, so the canvas is tidy (the model doesn't always remember to) */
203
+ finished: async () => {
204
+ if (drew) await layout();
205
+ },
206
+ };
207
+ };
@@ -0,0 +1,27 @@
1
+ import { reopeningIfEdited, type Author } from '@eventcatalog/visualiser/studio';
2
+ import type { AgentStage } from '../agent-choreography';
3
+ import type { AgentIdentity } from '../agents';
4
+ import type { StudioRuntime } from './runtime';
5
+
6
+ /**
7
+ * Work on a canvas as an agent: changes are played out like a person would make them (see agent-choreography),
8
+ * with the agent's pointer, selection and what it's doing shown to everyone on the canvas. Used by the MCP canvas
9
+ * tools and the assistant's.
10
+ */
11
+ export const agentOnCanvas = (
12
+ runtime: StudioRuntime,
13
+ canvasId: string,
14
+ agent: AgentIdentity,
15
+ { pace }: Pick<AgentStage, 'pace'> = {}
16
+ ) => {
17
+ const author = { ...agent, agent: true } as Author;
18
+ const stage: AgentStage = {
19
+ // An agent changing an accepted (or rejected) canvas makes it a draft again, like a person doing it
20
+ change: (fn) => runtime.withCanvas(canvasId, (doc) => reopeningIfEdited(doc, author, () => fn(doc))),
21
+ present: (presence) => runtime.setAgentPresence(canvasId, agent, presence),
22
+ lastPointer: runtime.getAgentPointer(canvasId, agent),
23
+ sharesMoves: true,
24
+ pace,
25
+ };
26
+ return { stage, author };
27
+ };