@eventcatalog/core 5.7.0 → 5.7.1

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 (35) 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-22KZ4TJF.js → chunk-36XVRD36.js} +1 -1
  6. package/dist/{chunk-FMWQ2YYN.js → chunk-AXWTR2FV.js} +1 -1
  7. package/dist/{chunk-BE5PTWNC.js → chunk-CPF5WE3R.js} +1 -1
  8. package/dist/{chunk-4CV7VC4K.js → chunk-GRC67WHC.js} +1 -1
  9. package/dist/{chunk-MOPKQMQP.js → chunk-HZ3XZKUT.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 +589 -120
  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/MDX/ResourceRef/ResourceRef.astro +2 -34
  22. package/eventcatalog/src/features/ai/chat-api.ts +24 -5
  23. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +62 -61
  24. package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
  25. package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
  26. package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
  27. package/eventcatalog/src/features/studio/catalog.ts +5 -3
  28. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
  29. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +91 -10
  30. package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
  31. package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
  32. package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
  33. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +2 -14
  34. package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
  35. 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)]"
@@ -133,6 +133,12 @@ import ShareDialog from './ShareDialog';
133
133
  import DeleteCanvasDialog from './DeleteCanvasDialog';
134
134
  import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
135
135
  import ShortcutsDialog from './ShortcutsDialog';
136
+ import {
137
+ ASSISTANT_DRAWING_EVENT,
138
+ CANVAS_FOCUS_EVENT,
139
+ CANVAS_HOTSPOT_EVENT,
140
+ OPEN_CHAT_EVENT,
141
+ } from '../../../components/ChatPanel/assistant-events';
136
142
 
137
143
  type CanvasProps = {
138
144
  canvasId: string;
@@ -165,6 +171,8 @@ type CanvasProps = {
165
171
  asAgent?: { name: string; color: string };
166
172
  /** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
167
173
  signedInAs?: { name: string; picture?: string };
174
+ /** The catalog's assistant (Ask AI) is on the page: "Draw with AI" opens it to draw on the canvas */
175
+ assistant?: boolean;
168
176
  resources: CatalogResource[];
169
177
  relations: CatalogRelation[];
170
178
  };
@@ -198,6 +206,7 @@ type Landing = {
198
206
  };
199
207
  const DELETE_KEYS = ['Backspace', 'Delete'];
200
208
  const NO_THREADS: ReadonlySet<string> = new Set();
209
+ const NO_HOTSPOTS: ReadonlySet<string> = new Set();
201
210
  // React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
202
211
  const onFlowError = (code: string, message: string) => {
203
212
  if (code !== '010') console.warn(message);
@@ -339,6 +348,9 @@ export default function StudioDesigner({ asAgent, signedInAs, ...props }: Canvas
339
348
  );
340
349
  }
341
350
 
351
+ // The assistant's panel (ChatPanelButton, in the page header) opens on this; on a canvas it draws on it
352
+ const openAssistant = () => window.dispatchEvent(new Event(OPEN_CHAT_EVENT));
353
+
342
354
  const toSocketUrl = (socketPath = DEFAULT_SOCKET_PATH) =>
343
355
  `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${socketPath}`;
344
356
 
@@ -353,6 +365,7 @@ function Canvas({
353
365
  mcpUrl = '/docs/mcp',
354
366
  relayScriptPath,
355
367
  keptInMemory = false,
368
+ assistant = false,
356
369
  syncViaTools,
357
370
  followChanges = false,
358
371
  paletteCollapsed,
@@ -973,7 +986,13 @@ function Canvas({
973
986
  // Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
974
987
  // are selected. While what's selected is dragged, the ones not on a node move with it by this much.
975
988
  const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
976
- const clearSelectedThreads = useCallback(() => setSelectedThreads((current) => (current.size ? NO_THREADS : current)), []);
989
+ // Hotspots too (selecting everything selects them, and deleting it deletes them)
990
+ const [selectedHotspots, setSelectedHotspots] = useState<ReadonlySet<string>>(NO_HOTSPOTS);
991
+ /** Comments and hotspots stop being part of the selection */
992
+ const clearSelectedPins = useCallback(() => {
993
+ setSelectedThreads((current) => (current.size ? NO_THREADS : current));
994
+ setSelectedHotspots((current) => (current.size ? NO_HOTSPOTS : current));
995
+ }, []);
977
996
  const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
978
997
  const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
979
998
  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
@@ -1328,6 +1347,57 @@ function Canvas({
1328
1347
 
1329
1348
  const [following, setFollowing] = useState(followChanges);
1330
1349
  const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
1350
+ // Asking the assistant (Ask AI) to draw on this canvas follows it while it works
1351
+ useEffect(() => {
1352
+ const followAssistant = (event: Event) => {
1353
+ if ((event as CustomEvent<{ canvasId?: string }>).detail?.canvasId === canvasId) setFollowing(true);
1354
+ };
1355
+ window.addEventListener(ASSISTANT_DRAWING_EVENT, followAssistant);
1356
+ return () => window.removeEventListener(ASSISTANT_DRAWING_EVENT, followAssistant);
1357
+ }, [canvasId]);
1358
+
1359
+ // The assistant taking you to part of the canvas (showOnCanvas, a walkthrough's steps): zoomed to, and selected. On
1360
+ // the canvas people edit (L3), where everything is.
1361
+ const focusOn = useStableCallback((ids: string[]) => {
1362
+ const nodes = lookupNodes();
1363
+ const found = ids.filter((id) => nodes.has(id));
1364
+ if (!found.length) return;
1365
+ const changingLevel = levelRef.current !== 3;
1366
+ if (changingLevel) changeLevel(3);
1367
+ // You're looking around now: following the agent would take the camera back to its pointer a moment later
1368
+ setFollowing(false);
1369
+ // Once the level's switched (and drawn), if it was
1370
+ setTimeout(
1371
+ () => {
1372
+ select(new Set(found));
1373
+ void fitView({ nodes: found.map((id) => ({ id })), duration: 600, padding: 0.35, maxZoom: 1.2 });
1374
+ },
1375
+ changingLevel ? 400 : 0
1376
+ );
1377
+ });
1378
+ useEffect(() => {
1379
+ const focus = (event: Event) => {
1380
+ const { canvasId: on, nodeIds } = (event as CustomEvent<{ canvasId?: string; nodeIds?: string[] }>).detail ?? {};
1381
+ if (on === canvasId && nodeIds?.length) focusOn(nodeIds);
1382
+ };
1383
+ window.addEventListener(CANVAS_FOCUS_EVENT, focus);
1384
+ return () => window.removeEventListener(CANVAS_FOCUS_EVENT, focus);
1385
+ }, [canvasId, focusOn]);
1386
+ // ...and to a hotspot it flagged, opened
1387
+ const showHotspot = useStableCallback((hotspotId: string) => {
1388
+ const hotspot = flow.hotspots.find((candidate) => candidate.id === hotspotId);
1389
+ if (!hotspot) return;
1390
+ setFollowing(false);
1391
+ goToHotspot(hotspot);
1392
+ });
1393
+ useEffect(() => {
1394
+ const show = (event: Event) => {
1395
+ const { canvasId: on, hotspotId } = (event as CustomEvent<{ canvasId?: string; hotspotId?: string }>).detail ?? {};
1396
+ if (on === canvasId && hotspotId) showHotspot(hotspotId);
1397
+ };
1398
+ window.addEventListener(CANVAS_HOTSPOT_EVENT, show);
1399
+ return () => window.removeEventListener(CANVAS_HOTSPOT_EVENT, show);
1400
+ }, [canvasId, showHotspot]);
1331
1401
 
1332
1402
  const [shareOpen, setShareOpen] = useState<false | 'choose' | 'invite'>(false);
1333
1403
  const openShare = useCallback(() => setShareOpen('choose'), []);
@@ -1458,6 +1528,8 @@ function Canvas({
1458
1528
  flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
1459
1529
  // ...and the comments shown on it
1460
1530
  setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
1531
+ // ...and its hotspots (the ones on L1 and L2's own places are on those levels)
1532
+ setSelectedHotspots(new Set(flow.hotspots.filter((hotspot) => !hotspot.level).map((hotspot) => hotspot.id)));
1461
1533
  },
1462
1534
  { preventDefault: true }
1463
1535
  );
@@ -1492,7 +1564,7 @@ function Canvas({
1492
1564
  setTool('select');
1493
1565
  setDraft(null);
1494
1566
  setOpenThreadId(null);
1495
- clearSelectedThreads();
1567
+ clearSelectedPins();
1496
1568
  setEditingId(null);
1497
1569
  flow.onNodesChange(
1498
1570
  nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
@@ -1501,11 +1573,16 @@ function Canvas({
1501
1573
  edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
1502
1574
  );
1503
1575
  });
1504
- // Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
1576
+ // Selected comments and hotspots are deleted with what's selected (React Flow deletes the nodes and edges)
1505
1577
  useHotkeys(DELETE_KEYS, () => {
1506
- if (!onCanvas() || selectedThreads.size === 0) return;
1507
- comments.deleteThreads([...selectedThreads]);
1508
- clearSelectedThreads();
1578
+ if (!onCanvas() || (selectedThreads.size === 0 && selectedHotspots.size === 0)) return;
1579
+ if (selectedThreads.size) comments.deleteThreads([...selectedThreads]);
1580
+ // (hotspots, like nodes, stay on a locked canvas)
1581
+ if (!locked) {
1582
+ selectedHotspots.forEach((id) => flow.deleteHotspot(id));
1583
+ if (selectedHotspots.size) setOpenHotspot(null);
1584
+ }
1585
+ clearSelectedPins();
1509
1586
  });
1510
1587
  /** A tool that puts something down: picked, or put back to selecting if it was already */
1511
1588
  const pickTool = useStableCallback((next: 'text' | 'note' | 'comment' | 'hotspot') => {
@@ -1538,7 +1615,9 @@ function Canvas({
1538
1615
  // A place on L1 or L2 is on that level (they're laid out on their own): what's put there says so
1539
1616
  const placeLevel = () => (levelRef.current === 3 ? {} : { level: levelRef.current as 1 | 2 });
1540
1617
  const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
1541
- clearSelectedThreads();
1618
+ clearSelectedPins();
1619
+ // An open hotspot closes on a click anywhere else on the canvas
1620
+ setOpenHotspot(null);
1542
1621
  if (tool === 'hand') return;
1543
1622
  if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
1544
1623
  // Flagging: a hotspot where it's clicked
@@ -1574,7 +1653,8 @@ function Canvas({
1574
1653
 
1575
1654
  const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
1576
1655
  // Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
1577
- if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
1656
+ if (!event.shiftKey && !event.metaKey) clearSelectedPins();
1657
+ setOpenHotspot(null);
1578
1658
  // Not on notes and text: they're written on, not what's being flagged or commented on
1579
1659
  if (isWrittenOnCanvas(node.type)) return;
1580
1660
  // A hotspot on it (it shows on the node, wherever it goes, on every level it's on)
@@ -1799,7 +1879,7 @@ function Canvas({
1799
1879
  onNodeClick={onNodeClick}
1800
1880
  onNodeDoubleClick={onNodeDoubleClick}
1801
1881
  onError={onFlowError}
1802
- onSelectionStart={clearSelectedThreads}
1882
+ onSelectionStart={clearSelectedPins}
1803
1883
  onPaneContextMenu={onPaneContextMenu}
1804
1884
  onNodeContextMenu={onNodeContextMenu}
1805
1885
  connectionLineType={ConnectionLineType.SmoothStep}
@@ -1849,7 +1929,7 @@ function Canvas({
1849
1929
  draft={hotspotDraft}
1850
1930
  locked={locked}
1851
1931
  movable={!locked}
1852
- selectedIds={null}
1932
+ selectedIds={selectedHotspots.size ? selectedHotspots : null}
1853
1933
  onMove={moveHotspots}
1854
1934
  onDropTarget={setHotspotDropTarget}
1855
1935
  onOpen={setOpenHotspot}
@@ -2033,6 +2113,7 @@ function Canvas({
2033
2113
  statusHistory={flow.canvasStatus.history}
2034
2114
  onStatusChange={flow.changeStatus}
2035
2115
  onDelete={canvasesApiUrl ? openDelete : undefined}
2116
+ onDrawWithAi={assistant ? openAssistant : undefined}
2036
2117
  />
2037
2118
 
2038
2119
  {flow.deleted && !leaving && (
@@ -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
+ };
@@ -16,7 +16,6 @@ import {
16
16
  getCanvasMaps,
17
17
  readCanvas,
18
18
  readMeta,
19
- reopeningIfEdited,
20
19
  replyToThread,
21
20
  setCanvasStatus,
22
21
  setThreadResolved,
@@ -33,10 +32,10 @@ import {
33
32
  playOnNode,
34
33
  playRemove,
35
34
  playUpdateNode,
36
- type AgentStage,
37
35
  } from '../agent-choreography';
38
36
  import { STUDIO_SOCKET_PATH, findStudioRuntime } from './runtime';
39
37
  import { getAgent } from '../agents';
38
+ import { agentOnCanvas } from './canvas-agent';
40
39
 
41
40
  /**
42
41
  * MCP tools for the collaborative canvas: agents create canvases, join the ones people are on, and
@@ -100,18 +99,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
100
99
  * Work on a canvas as an agent: changes are played out like a person would make them (see agent-choreography),
101
100
  * with the agent's pointer, selection and what it's doing shown to everyone on the canvas.
102
101
  */
103
- const asAgent = (id: string, name: string | undefined) => {
104
- const agent = getAgent(name, userAgent);
105
- const author = { ...agent, agent: true } as Author;
106
- const stage: AgentStage = {
107
- // An agent changing an accepted (or rejected) canvas makes it a draft again, like a person doing it
108
- change: (fn) => runtime.withCanvas(id, (doc) => reopeningIfEdited(doc, author, () => fn(doc))),
109
- present: (presence) => runtime.setAgentPresence(id, agent, presence),
110
- lastPointer: runtime.getAgentPointer(id, agent),
111
- sharesMoves: true,
112
- };
113
- return { stage, author };
114
- };
102
+ const asAgent = (id: string, name: string | undefined) => agentOnCanvas(runtime, id, getAgent(name, userAgent));
115
103
 
116
104
  // ---- The canvas in the chat (MCP App) ----
117
105
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Each kind of resource reference's icon (heroicons outline paths, drawn in a 24x24 viewBox with a stroke): the docs'
3
+ * [[service|OrderService]] references, and the assistant's references to what's on a Studio canvas
4
+ */
5
+ const RESOURCE_REFERENCE_ICONS: Record<string, string> = {
6
+ agent:
7
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V3m-4.5 7.5h9m-9 3h9M7.5 18h9A2.5 2.5 0 0019 15.5v-5A2.5 2.5 0 0016.5 8h-9A2.5 2.5 0 005 10.5v5A2.5 2.5 0 007.5 18zM9 21h6" />',
8
+ entity:
9
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />', // Box/cube
10
+ service:
11
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />', // Server
12
+ event:
13
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />', // Bolt
14
+ command:
15
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />', // ChatBubble
16
+ query:
17
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />', // MagnifyingGlass
18
+ domain:
19
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z" />', // RectangleGroup
20
+ flow: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />', // QueueList
21
+ channel:
22
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />', // ArrowsRightLeft
23
+ diagram:
24
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z" />', // Map
25
+ container:
26
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />', // Database
27
+ user: '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />', // User
28
+ team: '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />', // UserGroup
29
+ doc: '<path stroke-linecap="round" stroke-linejoin="round" d="M14 2v4a2 2 0 002 2h4" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V7z" /><path stroke-linecap="round" stroke-linejoin="round" d="M10 9H8" /><path stroke-linecap="round" stroke-linejoin="round" d="M16 13H8" /><path stroke-linecap="round" stroke-linejoin="round" d="M16 17H8" />', // FileText
30
+ 'data-product':
31
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25M21 7.5v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />', // Cube
32
+ system:
33
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z" />', // Group (systems)
34
+ adr: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /><path stroke-linecap="round" stroke-linejoin="round" d="M9 7h2" />', // ClipboardList-ish (ADR)
35
+ };
36
+
37
+ export const getResourceReferenceIcon = (type: string) => RESOURCE_REFERENCE_ICONS[type] ?? RESOURCE_REFERENCE_ICONS.entity;
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "BUSL-1.1",
9
9
  "type": "module",
10
- "version": "5.7.0",
10
+ "version": "5.7.1",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -141,11 +141,11 @@
141
141
  "y-protocols": "^1.0.7",
142
142
  "yjs": "^13.6.33",
143
143
  "zod": "^4.6.6",
144
+ "@eventcatalog/exporters": "0.1.4",
145
+ "@eventcatalog/linter": "1.3.1",
144
146
  "@eventcatalog/fitness": "0.1.1",
145
147
  "@eventcatalog/sdk": "2.32.0",
146
- "@eventcatalog/visualiser": "^5.2.0",
147
- "@eventcatalog/linter": "1.3.1",
148
- "@eventcatalog/exporters": "0.1.4"
148
+ "@eventcatalog/visualiser": "^5.2.1"
149
149
  },
150
150
  "devDependencies": {
151
151
  "@astrojs/check": "^0.9.10",