From 833aafe6623479ba48bfcfd0838bceb5d7a04a1a Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Mon, 5 Oct 2026 11:02:34 +0200 Subject: [PATCH] feat(ai): one tool catalogue for Studio's AI, Radical Forge and the MCP server Every AI tool now lives in @radical/common/ai/tools, and a facade decides where an edit lands: Studio's store, or the headless model that the MCP server writes to a Markdown folder. New tools: move_node, update_view and dynamic views, create/update/ delete_sequence, create/update/delete_presentation, upsert/delete node and relation types, and an async smart_layout (All elements or one view). search_model gains LIST SEQUENCES and GET SEQUENCE. Radical Forge leaves out the metamodel and presentation groups (excludeToolGroups); the runner rebuilds tool schemas when the metamodel changes mid-run. MCP server: get_model_summary returns the metamodel context message that the tool descriptions refer to, plus views, sequences and presentations. New and moved nodes are placed inside their parent, parents are refitted, defaultPositions stay in step with node geometry, and tool schemas are re-advertised after a metamodel change. Shared code: reparent rules (checkReparent/reparentNodes) and the metamodel message move to @radical/common; the view layout input, parent fitting and placement move to @radical/layout. Studio's store uses them. Fix: editing a built-in metamodel (Metamodel Editor or tools) now works on a "-custom" copy. documentMetamodel swaps a preset id back to the preset on load, which silently dropped those edits. Co-Authored-By: Claude Opus 5.5 --- apps/mcp/README.md | 18 +- apps/mcp/package.json | 4 +- apps/mcp/src/catalogueTools.test.ts | 115 +++++++ apps/mcp/src/folderModel.test.ts | 70 +++++ apps/mcp/src/folderModel.ts | 277 ++++++++++++----- apps/mcp/src/index.ts | 53 ++-- apps/mcp/tsup.config.ts | 2 +- .../src/renderer/src/ai/forgePrompts.ts | 2 +- apps/studio/src/renderer/src/ai/runner.ts | 44 ++- .../src/renderer/src/ai/systemPrompt.ts | 74 +---- .../src/renderer/src/ai/useDiagramFacade.ts | 41 +++ .../src/components/MetamodelEditor.tsx | 3 +- .../src/components/RadicalForgeModal.tsx | 6 +- apps/studio/tests/aiRunner.test.ts | 42 +++ apps/studio/tests/aiSystemPrompt.test.ts | 56 ---- apps/studio/tests/modelFacadeParity.test.ts | 27 +- docs/IMPROVEMENTS.md | 28 ++ package-lock.json | 4 +- packages/common/package.json | 1 + packages/common/src/ai/diagramFacade.ts | 27 +- packages/common/src/ai/metamodelContext.ts | 79 +++++ packages/common/src/ai/modelFacade.ts | 99 ++++-- packages/common/src/ai/queryLanguage.ts | 29 +- packages/common/src/ai/tools/index.ts | 62 +++- packages/common/src/ai/tools/layoutTools.ts | 40 +++ .../common/src/ai/tools/metamodelTools.ts | 261 ++++++++++++++++ packages/common/src/ai/tools/modelTools.ts | 1 + packages/common/src/ai/tools/nodeTools.ts | 43 ++- .../common/src/ai/tools/presentationTools.ts | 135 ++++++++ packages/common/src/ai/tools/sequenceTools.ts | 128 ++++++++ packages/common/src/ai/tools/types.ts | 9 +- packages/common/src/ai/tools/viewTools.ts | 115 ++++++- packages/common/src/model.ts | 127 +++++++- packages/common/tests/aiDocumentTools.test.ts | 126 ++++++++ .../common/tests/aiMetamodelContext.test.ts | 64 ++++ packages/common/tests/aiSequenceTools.test.ts | 100 ++++++ packages/common/tests/aiToolCatalogue.test.ts | 36 ++- packages/common/tests/modelFacade.test.ts | 23 ++ packages/layout/package.json | 3 +- packages/layout/src/geometry.ts | 54 ++++ packages/layout/src/viewInput.ts | 184 +++++++++++ packages/layout/tests/geometry.test.ts | 49 +++ packages/ui/src/store/diagramStore.ts | 294 ++++-------------- packages/ui/tests/model.test.ts | 57 ++++ 44 files changed, 2483 insertions(+), 529 deletions(-) create mode 100644 apps/mcp/src/catalogueTools.test.ts create mode 100644 packages/common/src/ai/metamodelContext.ts create mode 100644 packages/common/src/ai/tools/layoutTools.ts create mode 100644 packages/common/src/ai/tools/metamodelTools.ts create mode 100644 packages/common/src/ai/tools/presentationTools.ts create mode 100644 packages/common/src/ai/tools/sequenceTools.ts create mode 100644 packages/common/tests/aiDocumentTools.test.ts create mode 100644 packages/common/tests/aiMetamodelContext.test.ts create mode 100644 packages/common/tests/aiSequenceTools.test.ts create mode 100644 packages/layout/src/viewInput.ts create mode 100644 packages/layout/tests/geometry.test.ts diff --git a/apps/mcp/README.md b/apps/mcp/README.md index bb07d0bf..2f87fedb 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -19,14 +19,26 @@ codex mcp add radical-folder -- node /absolute/path/to/radical.tools/apps/mcp/di codex mcp list ``` -Launch Studio with `npm run dev:web` from the repository root, open it in Chromium, and open the **same OS folder** via the Models dialog. The browser asks for File System Access permission. The MCP server cannot access a browser-only OPFS folder. Ask Codex to use `get_model_summary` first, then `search_model` or the node/relation tools. +Claude Code takes the same command (`claude mcp add radical-folder -- node … --folder …`). For Claude Desktop, add it under `mcpServers` in `~/Library/Application Support/Claude/claude_desktop_config.json`, with an absolute path to `node`, because the app does not see your shell's `PATH`. + +Launch Studio with `npm run dev:web` from the repository root, open it in Chromium, and open the **same OS folder** via the Models dialog. The browser asks for File System Access permission. The MCP server cannot access a browser-only OPFS folder. Ask the client to use `get_model_summary` first, then `search_model` or the node/relation tools. ## Tool and file behavior -The server binds to one absolute folder path at launch, then reads it afresh for every tool call. It provides `get_model_summary`, `search_model`, and `add_`, `update_`, and `delete_` tools for nodes and relations. These use Studio's shared schemas and model rules. New nodes get a deterministic position; existing positions are not laid out again. `add_node` returns the persisted real ID, and a `tempId` can be used in later calls on the same MCP connection. Newly added nodes appear in Studio's **All elements** view; the server does not add them to named views. The server refreshes its model data per call, but if the metamodel's tool schemas change, restart the server. +The server binds to one absolute folder path at launch, then reads it afresh for every tool call. Its tools are the shared AI tool catalogue from `@radical/common/ai/tools`, the one Studio's AI chat and Radical Forge use, minus the canvas-only `set_active_view` and `focus_node` and the destructive `reset_diagram`. It provides: + +- `get_model_summary` (counts, views, sequences, presentations and the metamodel rules) and `search_model` (a small query language, including `LIST SEQUENCES` and `GET SEQUENCE`); +- `add_`, `update_` and `delete_` tools for nodes and relations, and `move_node` to change a node's parent (it keeps the node's canvas position and enforces the metamodel's `allowedParents`); +- `create_view`, `update_view` (name, kind, linked sequence, hidden relations), `set_view_nodes` and `delete_view`; +- `create_sequence`, `update_sequence` and `delete_sequence`: ordered relation flows that a `dynamic` view plays; +- `create_presentation`, `update_presentation` and `delete_presentation`: slides over views, which Studio frames to fit; +- `upsert_node_type`, `delete_node_type`, `upsert_relation_type` and `delete_relation_type`. A built-in metamodel is copied to a `…-custom` id on the first edit, because Studio replaces built-in metamodels with their preset on load. After a metamodel change (from these tools or from Studio) the server re-advertises the node and relation tool schemas; +- `smart_layout`, for All elements or one static or dynamic view (`viewId`), which then gets its own saved positions. + + These use Studio's shared schemas and model rules. Besides counts, `get_model_summary` returns the metamodel context message the other tool descriptions refer to: each type's property keys and enum options, allowed parents, cardinality and relation pairs. The server's MCP instructions tell clients to call it first. New nodes are placed inside their parent below its header (root nodes to the right of the existing ones), and every parent a change adds to, moves or removes from is refitted to its children. `smart_layout` runs Studio's Smart Layout over the whole model (the **All elements** arrangement) and saves the result; views with their own saved positions keep them. Writes also update `defaultPositions`, which Studio restores when you switch back to **All elements**. `add_node` returns the persisted real ID, and a `tempId` can be used in later calls on the same MCP connection. Newly added nodes appear in Studio's **All elements** view until a view lists them. The server refreshes its model data per call. Writes preserve unrelated files and the existing `radical.md` text. A tool reports success only after the changed model files are written. If a file changed between the server's read and write, it returns a conflict without writing. Studio's external-change reload no longer queues an autosave of the loaded data. -For now, keep Studio in **viewer use while Codex writes**. Folder writes span several files, and Studio and MCP do not share a cross-process transaction lock. Editing the same folder simultaneously from both can still race; the stamp check only catches changes visible before a write begins. Single `.radical` JSON files, view editing, browser commands, and remote HTTP MCP are not supported by this server. +For now, keep Studio in **viewer use while the MCP client writes**. Folder writes span several files, and Studio and MCP do not share a cross-process transaction lock. Editing the same folder simultaneously from both can still race; the stamp check only catches changes visible before a write begins. Single `.radical` JSON files, snapshots (milestones), browser commands, and remote HTTP MCP are not supported by this server. Run `npm run test -w @radical/mcp` to build and exercise a real stdio connection and temporary disk folder. diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 9e80cff0..cd1934c5 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -13,7 +13,9 @@ "dependencies": { "@modelcontextprotocol/server": "2.2.0", "@radical/common": "*", - "@radical/node-files": "*" + "@radical/layout": "*", + "@radical/node-files": "*", + "elkjs": "^0.9.3" }, "devDependencies": { "@modelcontextprotocol/client": "2.2.0", diff --git a/apps/mcp/src/catalogueTools.test.ts b/apps/mcp/src/catalogueTools.test.ts new file mode 100644 index 00000000..ffe74e6d --- /dev/null +++ b/apps/mcp/src/catalogueTools.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { Client } from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' +import { builtInGovernanceMetamodel } from '@radical/common/metamodel' +import { serializeToMdFolder, deserializeFromMdFolder } from '@radical/common/formats/mdFolder' +import { MdFolderSession } from '@radical/common/formats/mdFolderSync' +import { createModelFacade } from '@radical/common/ai/modelFacade' +import { diskFolderStorage } from '@radical/node-files/diskFolderStorage' + +const folders: string[] = [] +afterEach(async () => { await Promise.all(folders.splice(0).map((folder) => rm(folder, { recursive: true, force: true }))) }) + +async function connect(): Promise<{ client: Client; folder: string; read: () => Promise['data']> }> { + const folder = await mkdtemp(join(tmpdir(), 'radical-mcp-doc-')) + folders.push(folder) + const files = serializeToMdFolder({ nodes: [], relations: [], metamodel: builtInGovernanceMetamodel() }, 'MCP docs') + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(folder, path)), { recursive: true }) + await writeFile(join(folder, path), content) + } + const client = new Client({ name: 'radical-test', version: '1.0.0' }) + await client.connect(new StdioClientTransport({ + command: process.execPath, + args: [join(import.meta.dirname, '../dist/index.js'), '--folder', folder], + })) + const read = async () => deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data + return { client, folder, read } +} + +async function call(client: Client, name: string, args: Record): Promise { + const result = await client.callTool({ name, arguments: args }) + const text = JSON.stringify(result.content) + expect(result.isError, `${name}: ${text}`).not.toBe(true) + return text +} + +describe('MCP document tools', () => { + it('lays out one view into its own positions and builds a presentation over it', async () => { + const { client, read } = await connect() + try { + await call(client, 'add_node', { tempId: 'user', type: 'person', label: 'Buyer' }) + await call(client, 'add_node', { tempId: 'shop', type: 'system', label: 'Shop' }) + await call(client, 'add_node', { tempId: 'bank', type: 'system', label: 'Bank' }) + await call(client, 'add_relation', { sourceId: 'user', targetId: 'shop', relationType: 'interacts', label: 'Orders' }) + await call(client, 'add_relation', { sourceId: 'shop', targetId: 'bank', relationType: 'interacts', label: 'Charges' }) + await call(client, 'create_view', { tempId: 'ctx', name: 'Context', nodeIds: ['user', 'shop', 'bank'] }) + const before = await read() + const view = before.views![0] + + const laidOut = await call(client, 'smart_layout', { viewId: view.id }) + expect(laidOut).toContain('Smart Layout (Context)') + const after = await read() + // The model's own (All elements) positions are untouched; the view got its own. + expect(after.nodes.map((n) => [n.x, n.y])).toEqual(before.nodes.map((n) => [n.x, n.y])) + const positions = after.views![0].positions + expect(Object.keys(positions).sort()).toEqual(after.nodes.map((n) => n.id).sort()) + + await call(client, 'create_presentation', { name: 'Pitch', slides: [{ name: 'Overview' }, { name: 'Context', viewId: view.id }] }) + const presented = await read() + const pitch = presented.presentations!.find((p) => p.name === 'Pitch')! + expect(pitch.slides).toEqual([ + expect.objectContaining({ name: 'Overview', viewId: null, viewport: { x: 0, y: 0, zoom: 0 } }), + expect.objectContaining({ name: 'Context', viewId: view.id }), + ]) + await call(client, 'update_presentation', { id: pitch.id, slides: [{ id: pitch.slides[1].id, name: 'Context only', viewId: view.id }] }) + expect((await read()).presentations!.find((p) => p.id === pitch.id)!.slides).toEqual([expect.objectContaining({ id: pitch.slides[1].id, name: 'Context only' })]) + + const summary = await call(client, 'get_model_summary', {}) + expect(summary).toContain('Pitch') + const bad = await client.callTool({ name: 'smart_layout', arguments: { viewId: 'nope' } }) + expect(bad.isError).toBe(true) + } finally { + await client.close() + } + }, 60_000) + + it('copies a built-in metamodel on the first edit, persists it and refreshes tool schemas', async () => { + const { client, read } = await connect() + try { + const enumOf = async () => { + const tools = await client.listTools() + const addNode = tools.tools.find((t) => t.name === 'add_node')! + return (addNode.inputSchema as unknown as { properties: { type: { enum: string[] } } }).properties.type.enum + } + expect(await enumOf()).not.toContain('risk') + const added = await call(client, 'upsert_node_type', { + id: 'risk', label: 'Risk', baseType: 'adr', tableTab: true, allowedParents: ['system'], allowedAtRoot: true, + properties: [{ key: 'severity', label: 'Severity', type: 'enum', options: ['low', 'high'] }], + }) + expect(added).toContain('copied to the custom metamodel') + expect(await enumOf()).toContain('risk') + + await call(client, 'upsert_relation_type', { id: 'mitigates', label: 'Mitigates', allowedPairs: [{ from: 'adr', to: 'risk' }] }) + await call(client, 'add_node', { tempId: 'r', type: 'risk', label: 'Data loss', properties: { severity: 'high' } }) + await call(client, 'add_node', { tempId: 'a', type: 'adr', label: 'Backups' }) + await call(client, 'add_relation', { sourceId: 'a', targetId: 'r', relationType: 'mitigates' }) + + const data = await read() + expect(data.metamodel).toMatchObject({ id: 'c4-ddd-governance-custom' }) + // Reloading keeps the custom metamodel (a built-in id would be swapped for the preset). + expect(createModelFacade(data).getMetamodel!()!.nodeTypes.risk).toMatchObject({ label: 'Risk', tableTab: true }) + expect(data.nodes.find((n) => n.label === 'Data loss')).toMatchObject({ type: 'risk', severity: 'high' }) + + const inUse = await client.callTool({ name: 'delete_node_type', arguments: { id: 'risk' } }) + expect(inUse.isError).toBe(true) + const builtin = await client.callTool({ name: 'delete_relation_type', arguments: { id: 'interacts' } }) + expect(builtin.isError).toBe(true) + } finally { + await client.close() + } + }, 60_000) +}) diff --git a/apps/mcp/src/folderModel.test.ts b/apps/mcp/src/folderModel.test.ts index 69c5399f..81fb2c70 100644 --- a/apps/mcp/src/folderModel.test.ts +++ b/apps/mcp/src/folderModel.test.ts @@ -40,6 +40,10 @@ describe('folder-backed MCP model', () => { const tools = await client.listTools() expect(tools.tools.map((tool) => tool.name)).toContain('add_node') expect(tools.tools.map((tool) => tool.name)).not.toContain('reset_diagram') + expect(client.getInstructions()).toContain('get_model_summary') + const summary = JSON.stringify((await client.callTool({ name: 'get_model_summary', arguments: {} })).content) + expect(summary).toContain('ears_type') + expect(summary).toContain('allowedPairs') const result = await client.callTool({ name: 'add_node', arguments: { tempId: 'new-system', type: 'system', label: 'New System' } }) expect(result.isError).not.toBe(true) const message = JSON.stringify(result.content) @@ -67,11 +71,77 @@ describe('folder-backed MCP model', () => { expect(update.isError, JSON.stringify(update.content)).not.toBe(true) const updated = deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data expect(updated.nodes.find((node) => node.id === id)?.label).toBe('Renamed System') + + const domain = await client.callTool({ name: 'add_node', arguments: { tempId: 'shop', type: 'domain', label: 'Shop' } }) + expect(domain.isError, JSON.stringify(domain.content)).not.toBe(true) + const moved = await client.callTool({ name: 'move_node', arguments: { id: 'new-system', parentId: 'shop' } }) + expect(moved.isError, JSON.stringify(moved.content)).not.toBe(true) + const view = await client.callTool({ name: 'create_view', arguments: { tempId: 'ctx', name: 'Context', nodeIds: ['actor', 'new-system'] } }) + expect(view.isError, JSON.stringify(view.content)).not.toBe(true) + expect(JSON.stringify(view.content)).toContain('views.json') + const final = deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data + const domainId = final.nodes.find((node) => node.label === 'Shop')?.id + expect(final.nodes.find((node) => node.id === id)?.parentId).toBe(domainId) + expect(final.views).toEqual([expect.objectContaining({ name: 'Context', nodeIds: expect.arrayContaining([id]) })]) } finally { await client.close() } }) + it('places children inside their parent, keeps defaultPositions in step and runs Smart Layout', async () => { + const folder = await fixture() + const files = await new MdFolderSession(diskFolderStorage(folder)).readAll() + // Studio writes defaultPositions; give the fixture an (empty) map like a saved model. + const layout = JSON.parse(files['_layout.json'] ?? '{}') + await writeFile(join(folder, '_layout.json'), JSON.stringify({ ...layout, defaultPositions: {} })) + const client = new Client({ name: 'radical-test', version: '1.0.0' }) + const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(import.meta.dirname, '../dist/index.js'), '--folder', folder], + }) + const read = async () => deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data + try { + await client.connect(transport) + for (const args of [ + { tempId: 'shop', type: 'domain', label: 'Shop' }, + { tempId: 'orders', type: 'system', label: 'Orders', parentId: 'shop' }, + { tempId: 'billing', type: 'system', label: 'Billing', parentId: 'shop' }, + { tempId: 'crm', type: 'system', label: 'CRM' }, + ]) { + const added = await client.callTool({ name: 'add_node', arguments: args }) + expect(added.isError, JSON.stringify(added.content)).not.toBe(true) + } + let data = await read() + const byLabel = (label: string) => data.nodes.find((node) => node.label === label)! + const [shop, orders, billing] = [byLabel('Shop'), byLabel('Orders'), byLabel('Billing')] + expect(orders).toMatchObject({ x: 30, y: 120 }) + expect(billing.x).toBeGreaterThan(orders.x + orders.width) + // The parent grew to wrap both children. + expect(shop.width).toBeGreaterThanOrEqual(billing.x + billing.width + 30) + expect(shop.height).toBeGreaterThanOrEqual(orders.y + orders.height + 30) + + const moved = await client.callTool({ name: 'move_node', arguments: { id: 'crm', parentId: 'shop' } }) + expect(moved.isError, JSON.stringify(moved.content)).not.toBe(true) + data = await read() + const crm = byLabel('CRM') + expect(crm.parentId).toBe(shop.id) + expect(data.defaultPositions?.[crm.id]).toEqual({ x: crm.x, y: crm.y, width: crm.width, height: crm.height }) + + const user = await client.callTool({ name: 'add_node', arguments: { tempId: 'user', type: 'person', label: 'Buyer' } }) + expect(user.isError).not.toBe(true) + await client.callTool({ name: 'add_relation', arguments: { sourceId: 'user', targetId: 'orders', relationType: 'interacts' } }) + const laidOut = await client.callTool({ name: 'smart_layout', arguments: {} }) + expect(laidOut.isError, JSON.stringify(laidOut.content)).not.toBe(true) + expect(JSON.stringify(laidOut.content)).toMatch(/Smart Layout/) + data = await read() + for (const node of data.nodes) { + expect(data.defaultPositions?.[node.id]).toEqual({ x: node.x, y: node.y, width: node.width, height: node.height }) + } + } finally { + await client.close() + } + }, 60_000) + it('rejects a changed folder before writing', async () => { const folder = await fixture() const session = new MdFolderSession(diskFolderStorage(folder)) diff --git a/apps/mcp/src/folderModel.ts b/apps/mcp/src/folderModel.ts index b27dc047..a9f08980 100644 --- a/apps/mcp/src/folderModel.ts +++ b/apps/mcp/src/folderModel.ts @@ -1,24 +1,47 @@ import { realpath, stat } from 'node:fs/promises' import { isAbsolute, resolve } from 'node:path' import { createHash } from 'node:crypto' -import { buildToolDefs, buildToolHandlers, type ToolDef, type ToolRunContext } from '@radical/common/ai/tools' +import { buildToolDefs, runTool, type ToolDef, type ToolRunContext } from '@radical/common/ai/tools' import { createModelFacade } from '@radical/common/ai/modelFacade' +import { buildMetamodelMessage } from '@radical/common/ai/metamodelContext' import { deserializeFromMdFolder, isMdFolder, isOwnedMdFolderFile, serializeToMdFolderWithPaths, type FolderFiles } from '@radical/common/formats/mdFolder' import { MdFolderSession } from '@radical/common/formats/mdFolderSync' -import { validateModel } from '@radical/common/metamodel' +import { validateModel, type Metamodel } from '@radical/common/metamodel' import { diskFolderStorage } from '@radical/node-files/diskFolderStorage' -import type { DiagramData } from '@radical/common/c4' +import { fitAncestors, placeNewNode } from '@radical/layout/geometry' +import { runSmartLayoutCore } from '@radical/layout/smartLayout' +import { viewLayoutInput, applyLayoutPositions, resizeParentsBottomUp } from '@radical/layout/viewInput' +import type { C4Node, DiagramData, NodePosition } from '@radical/common/c4' +import { documentMetamodel } from '@radical/common/model' -const TOOL_NAMES = new Set([ - 'search_model', 'add_node', 'update_node', 'delete_node', - 'add_relation', 'update_relation', 'delete_relation', -]) -const MUTATIONS = new Set([...TOOL_NAMES].filter((name) => name !== 'search_model')) +/** The shared AI catalogue minus its Studio-only tools: set_active_view and + * focus_node drive the canvas, and reset_diagram is too destructive for an + * external client. */ +const EXCLUDED_TOOLS = new Set(['set_active_view', 'focus_node', 'reset_diagram']) +const READ_ONLY = new Set(['get_model_summary', 'search_model']) const JSON_FILES = ['_layout.json', 'relations.json', 'views.json', 'sequences.json', 'snapshots.json', 'presentations.json', 'metamodel.json', 'hubTemplates.json'] +const SERVER_TOOL_DEFS: ToolDef[] = [ + { + name: 'get_model_summary', + description: "Call this first. Shows counts, the views, sequences and presentations in the bound Radical model folder, plus the metamodel context message: each type's valid properties keys and enum options, allowed parents, cardinality and relation allowedPairs.", + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, +] + +/** Read-only tools, for MCP annotations. */ +export const READ_ONLY_TOOLS = READ_ONLY + +const buildTools = (metamodel: Metamodel | undefined): ToolDef[] => [ + ...SERVER_TOOL_DEFS, + ...buildToolDefs(metamodel).filter((tool) => !EXCLUDED_TOOLS.has(tool.name)), +] + export interface CallOutcome { ok: boolean text: string + /** The metamodel changed and `tools` were rebuilt; re-advertise their schemas. */ + toolsChanged?: boolean } function checkFiles(files: FolderFiles): void { @@ -54,18 +77,62 @@ function checkData(data: DiagramData): void { } } +const byId = (items: T[]): Record => + Object.fromEntries(items.map((item) => [item.id, item])) + +const depth = (node: C4Node, nodes: Record): number => { + let d = 0 + for (let cur = node.parentId ? nodes[node.parentId] : undefined; cur && d < 64; cur = cur.parentId ? nodes[cur.parentId] : undefined) d++ + return d +} + +/** Refits every parent whose children a change added, moved or removed, and + * keeps defaultPositions in step with node geometry: Studio restores them + * when you switch back to All elements, so stale entries would undo the + * change. Mutates `after`. */ +function settleGeometry(before: DiagramData, after: DiagramData): void { + const old = byId(before.nodes) + const nodes = byId(after.nodes) + const parents = new Set() + const touch = (id: string | undefined): void => { if (id && nodes[id]) parents.add(id) } + for (const node of after.nodes) { + const prev = old[node.id] + if (!prev) touch(node.parentId) + else if (prev.parentId !== node.parentId) { touch(prev.parentId); touch(node.parentId) } + } + for (const node of before.nodes) if (!nodes[node.id]) touch(node.parentId) + for (const id of [...parents].sort((a, b) => depth(nodes[b], nodes) - depth(nodes[a], nodes))) fitAncestors(nodes, id) + + if (!after.defaultPositions) return + for (const node of after.nodes) { + const prev = old[node.id] + if (prev && prev.x === node.x && prev.y === node.y && prev.width === node.width && prev.height === node.height) continue + after.defaultPositions[node.id] = { x: node.x, y: node.y, width: node.width, height: node.height } + } +} + export class FolderModel { - private readonly handlers = buildToolHandlers() private readonly tempIds = new Map() private readonly folder: string - private readonly metamodelKey: string - readonly tools: ToolDef[] + private metamodelKey: string + /** Every tool the server offers; node/relation schemas follow the metamodel. */ + tools: ToolDef[] private tail: Promise = Promise.resolve() - private constructor(folder: string, tools: ToolDef[], metamodelKey: string) { + private constructor(folder: string, metamodel: Metamodel | undefined) { this.folder = folder - this.tools = tools - this.metamodelKey = metamodelKey + this.tools = buildTools(metamodel) + this.metamodelKey = JSON.stringify(metamodel) + } + + /** Rebuilds the tool schemas when `metamodel` differs from the one they + * were built for. True when they changed. */ + private refreshTools(metamodel: Metamodel | undefined): boolean { + const key = JSON.stringify(metamodel) + if (key === this.metamodelKey) return false + this.metamodelKey = key + this.tools = buildTools(metamodel) + return true } static async open(folder: string): Promise { @@ -76,9 +143,7 @@ export class FolderModel { checkFiles(files) const data = deserializeFromMdFolder(files).data checkData(data) - const metamodel = createModelFacade(data).getMetamodel?.() - const defs = buildToolDefs(metamodel).filter((tool) => TOOL_NAMES.has(tool.name)) - return new FolderModel(canonical, defs, JSON.stringify(metamodel)) + return new FolderModel(canonical, createModelFacade(data).getMetamodel?.()) } call(name: string, input: unknown): Promise { @@ -88,91 +153,161 @@ export class FolderModel { } private async run(name: string, input: unknown): Promise { - if (name !== 'get_model_summary' && !TOOL_NAMES.has(name)) return { ok: false, text: `Unknown tool ${name}` } - let priorTempIds: Map | undefined + if (!this.tools.some((tool) => tool.name === name)) return { ok: false, text: `Unknown tool ${name}` } try { const session = new MdFolderSession(diskFolderStorage(this.folder)) const before = await session.readAll() checkFiles(before) const data = deserializeFromMdFolder(before).data checkData(data) - const facade = createModelFacade(data) - if (JSON.stringify(facade.getMetamodel?.()) !== this.metamodelKey) { - return { ok: false, text: 'The metamodel changed; restart the MCP server to refresh tool schemas.' } - } + const facade = createModelFacade(data, { + runLayout: async (doc, viewId) => { + const layout = await this.smartLayout(doc, documentMetamodel(doc.metamodel), viewId) + return { ok: layout.ok, text: layout.text, data: layout.changed } + }, + }) + // Someone (Studio, another client) changed the metamodel: re-advertise + // the schemas, and still run this call — every handler validates input. + const toolsChanged = this.refreshTools(facade.getMetamodel?.()) + const outcome = await this.dispatch(name, input, session, before, data, facade) + return toolsChanged || outcome.toolsChanged ? { ...outcome, toolsChanged: true } : outcome + } catch (error) { + return { ok: false, text: (error as Error).message } + } + } + + private async dispatch( + name: string, input: unknown, session: MdFolderSession, before: FolderFiles, + data: DiagramData, facade: ReturnType, + ): Promise { + let priorTempIds: Map | undefined + try { if (name === 'get_model_summary') { - return { ok: true, text: JSON.stringify({ + const metamodel = facade.getMetamodel?.() + const summary = JSON.stringify({ folder: this.folder, nodes: data.nodes.length, relations: data.relations.length, - views: data.views?.length ?? 0, - nodeTypes: Object.keys(facade.getMetamodel?.()?.nodeTypes ?? {}), - relationTypes: Object.keys(facade.getMetamodel?.()?.relationTypes ?? {}), - }) } + views: (data.views ?? []).map((view) => ({ id: view.id, name: view.name, kind: view.kind ?? 'static', ...(view.sequenceId ? { sequenceId: view.sequenceId } : {}) })), + sequences: (data.sequences ?? []).map((sequence) => ({ id: sequence.id, name: sequence.name, steps: sequence.relationIds.length })), + presentations: (data.presentations ?? []).map((presentation) => ({ + id: presentation.id, + name: presentation.name, + slides: presentation.slides.map((slide) => ({ id: slide.id, name: slide.name, viewId: slide.viewId ?? null })), + })), + metamodel: { id: metamodel?.id, name: metamodel?.name }, + nodeTypes: Object.keys(metamodel?.nodeTypes ?? {}), + relationTypes: Object.keys(metamodel?.relationTypes ?? {}), + }) + // The other tools' descriptions point at "the metamodel context + // message" for property keys and pairing rules; MCP clients get it here. + return { ok: true, text: `${summary}\n\n${buildMetamodelMessage(metamodel)}` } } - const handler = this.handlers.get(name) - if (!handler) return { ok: false, text: `No handler for ${name}` } priorTempIds = new Map(this.tempIds) const ctx: ToolRunContext = { diagram: facade, resolveId: (id) => this.tempIds.get(id) ?? id, registerTempId: (tempId, realId) => { this.tempIds.set(tempId, realId) }, resetTempIds: () => this.tempIds.clear(), - placeNext: () => { - const nodes = Object.values(facade.getNodes()) - return { x: nodes.length ? Math.max(...nodes.map((node) => node.x + node.width)) + 80 : 0, y: 0 } - }, + placeNext: (parentId) => placeNewNode(facade.getNodes(), parentId), } - const result = handler(input, ctx) + const result = await runTool(name, input, ctx) if (!result.ok || facade.lastError) { this.restoreTempIds(priorTempIds) return { ok: false, text: facade.lastError ?? result.resultText } } - if (!MUTATIONS.has(name)) return { ok: true, text: result.resultText } + if (READ_ONLY.has(name)) return { ok: true, text: result.resultText } const changed = facade.toDiagramData() - checkData(changed) + settleGeometry(data, changed) const metamodel = facade.getMetamodel?.() - if (!metamodel) throw new Error('Model has no metamodel') - const previousErrors = new Set(validateModel( - Object.fromEntries(data.nodes.map((node) => [node.id, node])), - Object.fromEntries(data.relations.map((relation) => [relation.id, relation])), - metamodel, - ).filter((issue) => issue.severity === 'error').map((issue) => issue.id)) - const newError = validateModel( - Object.fromEntries(changed.nodes.map((node) => [node.id, node])), - Object.fromEntries(changed.relations.map((relation) => [relation.id, relation])), - metamodel, - ).find((issue) => issue.severity === 'error' && !previousErrors.has(issue.id)) - if (newError) { - this.restoreTempIds(priorTempIds) - return { ok: false, text: newError.message } - } - const next = serializeToMdFolderWithPaths(changed).files - // Preserve the user's manifest prose/name. The format codec only needs - // to rewrite model-owned sidecars and node Markdown. - next['radical.md'] = before['radical.md'] - const paths = [...new Set([...Object.keys(before), ...Object.keys(next)])] - .filter((path) => before[path] !== next[path] - && (path in next || isOwnedMdFolderFile(path, before[path]))) - .sort() - if (!paths.length) return { ok: true, text: `${result.resultText} No files changed.` } - const write = await session.write(next) - if (!write.ok) { - this.restoreTempIds(priorTempIds) - return { ok: false, text: `Folder changed before write; no files written. Conflicts: ${write.conflict.join(', ')}` } - } - const revision = createHash('sha256').update(paths.map((path) => `${path}\0${next[path] ?? ''}`).join('\0')).digest('hex').slice(0, 12) - const createdRelation = name === 'add_relation' - ? changed.relations.find((relation) => !data.relations.some((old) => old.id === relation.id))?.id - : undefined - return { ok: true, text: `${result.resultText}${createdRelation ? ` Relation ID: ${createdRelation}.` : ''} Changed: ${paths.join(', ')}. Revision: ${revision}.` } + const outcome = await this.commit(session, before, data, changed, metamodel, result.resultText) + if (!outcome.ok) this.restoreTempIds(priorTempIds) + // A metamodel tool changed the types: re-advertise the schemas. + else if (this.refreshTools(metamodel)) return { ...outcome, toolsChanged: true } + return outcome } catch (error) { if (priorTempIds) this.restoreTempIds(priorTempIds) return { ok: false, text: (error as Error).message } } } + /** Runs Smart Layout the way Studio does for one view (undefined = All + * elements) and returns the laid-out model, or no model when nothing + * changes. A view's result goes to its own saved positions, like the + * positions Studio keeps per view. */ + private async smartLayout( + data: DiagramData, metamodel: Metamodel | undefined, viewId: string | undefined, + ): Promise<{ ok: boolean; text: string; changed?: DiagramData }> { + const changed = JSON.parse(JSON.stringify(data)) as DiagramData + const view = viewId === undefined ? undefined : changed.views?.find((v) => v.id === viewId) + if (viewId !== undefined && !view) return { ok: false, text: `smart_layout: unknown view id "${viewId}"` } + if (view?.kind && view.kind !== 'static' && view.kind !== 'dynamic') { + return { ok: false, text: `smart_layout: "${view.name}" is a ${view.kind} view; only static and dynamic views have a canvas layout.` } + } + // The canvas the view shows: its saved positions over the model's. + const nodes = byId(view ? (JSON.parse(JSON.stringify(changed.nodes)) as C4Node[]) : changed.nodes) + for (const [id, pos] of Object.entries(view?.positions ?? {})) { + const node = nodes[id] + if (node) Object.assign(node, { x: pos.x, y: pos.y, width: pos.width, height: pos.height }) + } + const input = viewLayoutInput(view, nodes, byId(changed.relations)) + if (!Object.keys(input.nodes).length) return { ok: true, text: 'Smart Layout: there are no nodes to lay out.' } + const result = await runSmartLayoutCore(input.nodes, input.relations, metamodel) + if (!result.candidates.length) return { ok: false, text: 'Smart Layout: no candidate produced a result.' } + if (result.keptCurrent) return { ok: true, text: 'Smart Layout: the current layout already scores best; nothing changed.' } + applyLayoutPositions(nodes, result.winner.positions, input) + resizeParentsBottomUp(nodes, input) + // All elements writes the nodes themselves (settleGeometry then syncs + // defaultPositions); a view keeps its own positions. + if (view) { + view.positions = Object.fromEntries(Object.values(nodes).map((node): [string, NodePosition] => + [node.id, { x: node.x, y: node.y, width: node.width, height: node.height }])) + } + const before = result.baseline + const after = result.winner.metrics + return { + ok: true, + changed, + text: `Smart Layout${view ? ` (${view.name})` : ''}: ${result.winner.name}; crossings ${before.crossings} -> ${after.crossings}, overdraws ${before.overdraws} -> ${after.overdraws}.`, + } + } + + /** Validates `changed` against the metamodel and writes the files that + * differ from `before`, unless the folder changed in the meantime. */ + private async commit( + session: MdFolderSession, before: FolderFiles, data: DiagramData, changed: DiagramData, + metamodel: Metamodel | undefined, resultText: string, + ): Promise { + checkData(changed) + if (!metamodel) throw new Error('Model has no metamodel') + const previousErrors = new Set(validateModel( + Object.fromEntries(data.nodes.map((node) => [node.id, node])), + Object.fromEntries(data.relations.map((relation) => [relation.id, relation])), + metamodel, + ).filter((issue) => issue.severity === 'error').map((issue) => issue.id)) + const newError = validateModel( + Object.fromEntries(changed.nodes.map((node) => [node.id, node])), + Object.fromEntries(changed.relations.map((relation) => [relation.id, relation])), + metamodel, + ).find((issue) => issue.severity === 'error' && !previousErrors.has(issue.id)) + if (newError) return { ok: false, text: newError.message } + const next = serializeToMdFolderWithPaths(changed).files + // Preserve the user's manifest prose/name. The format codec only needs + // to rewrite model-owned sidecars and node Markdown. + next['radical.md'] = before['radical.md'] + const paths = [...new Set([...Object.keys(before), ...Object.keys(next)])] + .filter((path) => before[path] !== next[path] + && (path in next || isOwnedMdFolderFile(path, before[path]))) + .sort() + if (!paths.length) return { ok: true, text: `${resultText} No files changed.` } + const write = await session.write(next) + if (!write.ok) return { ok: false, text: `Folder changed before write; no files written. Conflicts: ${write.conflict.join(', ')}` } + const revision = createHash('sha256').update(paths.map((path) => `${path}\0${next[path] ?? ''}`).join('\0')).digest('hex').slice(0, 12) + const createdRelation = changed.relations.find((relation) => !data.relations.some((old) => old.id === relation.id))?.id + return { ok: true, text: `${resultText}${createdRelation ? ` Relation ID: ${createdRelation}.` : ''} Changed: ${paths.join(', ')}. Revision: ${revision}.` } + } + private restoreTempIds(previous: Map): void { this.tempIds.clear() for (const [temp, real] of previous) this.tempIds.set(temp, real) diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 947b3d29..970bc828 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server' +import { McpServer, fromJsonSchema, type RegisteredTool } from '@modelcontextprotocol/server' import { StdioServerTransport } from '@modelcontextprotocol/server/stdio' -import { FolderModel } from './folderModel' +import { FolderModel, READ_ONLY_TOOLS } from './folderModel' function folderArg(args: string[]): string { if (args.length !== 2 || args[0] !== '--folder' || !args[1]) { @@ -10,27 +10,40 @@ function folderArg(args: string[]): string { return args[1] } +const INSTRUCTIONS = [ + 'Reads and edits one Radical Markdown model folder (C4 architecture plus governance records such as ADRs and requirements).', + 'Call get_model_summary first: besides counts it returns the metamodel context message the other tools refer to,', + 'with each type\'s valid `properties` keys and enum options, allowedParents/allowedAtRoot/cardinality,', + 'and the node-type pairs each relation type allows. Use search_model for exact model data before changing it.', + 'New nodes appear only in Studio\'s All elements view until you list them in a view (create_view, set_view_nodes).', + 'move_node changes a node\'s parent. New and moved nodes get a simple placement; call smart_layout afterwards to arrange the model or one view.', + 'Sequences (create_sequence) are ordered relation flows that dynamic views play; presentations are slides over views.', + 'Metamodel tools (upsert_node_type, upsert_relation_type, …) change the types; the tool schemas refresh after them.', +].join(' ') + async function main(): Promise { const model = await FolderModel.open(folderArg(process.argv.slice(2))) - const server = new McpServer({ name: 'radical-folder', version: '0.1.0' }) - server.registerTool('get_model_summary', { - description: 'Show counts and available element types in the currently bound Radical model folder.', - inputSchema: fromJsonSchema({ type: 'object', properties: {}, additionalProperties: false }), - annotations: { readOnlyHint: true }, - }, async () => { - const outcome = await model.call('get_model_summary', {}) - return { content: [{ type: 'text', text: outcome.text }], isError: !outcome.ok } - }) - for (const tool of model.tools) { - server.registerTool(tool.name, { - description: tool.description, - inputSchema: fromJsonSchema(tool.inputSchema), - annotations: { readOnlyHint: tool.name === 'search_model' }, - }, async (input) => { - const outcome = await model.call(tool.name, input) - return { content: [{ type: 'text', text: outcome.text }], isError: !outcome.ok } - }) + const server = new McpServer({ name: 'radical-folder', version: '0.1.0' }, { instructions: INSTRUCTIONS }) + const registered = new Map() + const advertise = (): void => { + for (const tool of model.tools) { + const existing = registered.get(tool.name) + if (existing) { + existing.update({ description: tool.description, paramsSchema: fromJsonSchema(tool.inputSchema) }) + continue + } + registered.set(tool.name, server.registerTool(tool.name, { + description: tool.description, + inputSchema: fromJsonSchema(tool.inputSchema), + annotations: { readOnlyHint: READ_ONLY_TOOLS.has(tool.name) }, + }, async (input) => { + const outcome = await model.call(tool.name, input) + if (outcome.toolsChanged) advertise() + return { content: [{ type: 'text', text: outcome.text }], isError: !outcome.ok } + })) + } } + advertise() await server.connect(new StdioServerTransport()) } diff --git a/apps/mcp/tsup.config.ts b/apps/mcp/tsup.config.ts index 2387492b..b0aa8e83 100644 --- a/apps/mcp/tsup.config.ts +++ b/apps/mcp/tsup.config.ts @@ -6,5 +6,5 @@ export default defineConfig({ platform: 'node', target: 'node20', outDir: 'dist', - noExternal: ['@radical/common', '@radical/node-files'], + noExternal: ['@radical/common', '@radical/layout', '@radical/node-files'], }) diff --git a/apps/studio/src/renderer/src/ai/forgePrompts.ts b/apps/studio/src/renderer/src/ai/forgePrompts.ts index a2eb1faf..6ea1a4d3 100644 --- a/apps/studio/src/renderer/src/ai/forgePrompts.ts +++ b/apps/studio/src/renderer/src/ai/forgePrompts.ts @@ -38,7 +38,7 @@ export interface ForgeStage { /** The node type(s) each stage is actually meant to create — used to scope * the metamodel context message down to full detail for these (plus * whatever's already in the diagram) and abbreviated for the rest (see - * ai/systemPrompt.ts's `buildMetamodelMessage`). */ + * @radical/common/ai/metamodelContext's `buildMetamodelMessage`). */ export const PRIMARY_TYPE_IDS_FOR_STAGE: Record = { requirements: ['requirement'], fitness: ['fitness-fn'], diff --git a/apps/studio/src/renderer/src/ai/runner.ts b/apps/studio/src/renderer/src/ai/runner.ts index e27e0cf8..2b8f6932 100644 --- a/apps/studio/src/renderer/src/ai/runner.ts +++ b/apps/studio/src/renderer/src/ai/runner.ts @@ -8,7 +8,7 @@ import { emptyReport, mergeReport, type ApplyReport, type DiagramFacade } from '@radical/common/ai/diagramFacade' import { getAdapter } from './registry' import { buildContextMessage, buildSystemMessages } from './systemPrompt' -import { buildToolDefs, buildToolHandlers, type ToolRunContext } from '@radical/common/ai/tools' +import { buildToolDefs, runTool, type ToolGroup, type ToolRunContext } from '@radical/common/ai/tools' import { addTokenUsage, textOf, toolCallsOf, type AISettings, type ChatContentBlock, type ChatMessage, type TokenUsage } from './types' export interface RunAIResult { @@ -50,6 +50,9 @@ export interface RunAIOptions { * `buildMetamodelMessage`. Radical Forge passes its active stage's * primary type(s); QuickSearch omits it for full detail always. */ relevantTypeIds?: Set + /** Tool groups to leave out. Radical Forge only builds the model, so it + * leaves out 'metamodel' and 'presentation'; QuickSearch's chat has all. */ + excludeToolGroups?: ToolGroup[] } /** Appends a fresh text block to a turn's content ahead of sending it — used @@ -87,6 +90,19 @@ function describeToolCall(name: string, input: unknown, ctx: ToolRunContext): st case 'set_view_nodes': return '~ updated view' case 'set_active_view': return 'switched view' case 'delete_view': return '− removed view' + case 'move_node': return `↳ moved ${nodeLabel(i.id)}` + case 'update_view': return `~ view: ${String(i.name ?? 'updated')}` + case 'create_sequence': return `+ sequence: ${String(i.name ?? '')}` + case 'update_sequence': return '~ updated sequence' + case 'delete_sequence': return '− removed sequence' + case 'create_presentation': return `+ presentation: ${String(i.name ?? '')}` + case 'update_presentation': return '~ updated presentation' + case 'delete_presentation': return '− removed presentation' + case 'upsert_node_type': return `~ element type: ${String(i.label ?? i.id ?? '')}` + case 'delete_node_type': return `− element type: ${String(i.id ?? '')}` + case 'upsert_relation_type': return `~ relation type: ${String(i.label ?? i.id ?? '')}` + case 'delete_relation_type': return `− relation type: ${String(i.id ?? '')}` + case 'smart_layout': return 'ran Smart Layout' case 'search_model': return 'searched the model' case 'focus_node': return 'focused a node' case 'reset_diagram': return 'reset the diagram' @@ -95,15 +111,16 @@ function describeToolCall(name: string, input: unknown, ctx: ToolRunContext): st } export async function runAIPrompt(opts: RunAIOptions): Promise { - const { settings, prompt, diagram, history = [], signal, onProgress, relevantTypeIds } = opts + const { settings, prompt, diagram, history = [], signal, onProgress, relevantTypeIds, excludeToolGroups } = opts const maxIterations = opts.maxIterations ?? 12 const cfg = settings.providers[settings.active] const adapter = getAdapter(settings.active) const model = cfg.model || adapter.defaultModel - const metamodel = diagram.getMetamodel?.() - const tools = buildToolDefs(metamodel) - const handlers = buildToolHandlers() + // Rebuilt when a metamodel tool changes the types mid-run, so the next + // round's schemas (node-type enums) and metamodel message match. + let metamodel = diagram.getMetamodel?.() + let tools = buildToolDefs(metamodel, { exclude: excludeToolGroups }) // Shared across the WHOLE run — a node created in round 1 must still be // resolvable by a relation created in round 3, and reset_diagram (any @@ -152,6 +169,12 @@ export async function runAIPrompt(opts: RunAIOptions): Promise { round++ onProgress?.({ type: 'round', round }) + const liveMetamodel = diagram.getMetamodel?.() + if (liveMetamodel !== metamodel) { + metamodel = liveMetamodel + tools = buildToolDefs(metamodel, { exclude: excludeToolGroups }) + } + // The prompt + metamodel prefix is byte-identical every round (see // systemPrompt.ts) so it stays cacheable across rounds AND stages. const systemMessages = buildSystemMessages(metamodel, diagram.getNodes(), relevantTypeIds) @@ -208,20 +231,15 @@ export async function runAIPrompt(opts: RunAIOptions): Promise { const resultBlocks: ChatContentBlock[] = [] const roundErrors: string[] = [] for (const call of calls) { - const handler = handlers.get(call.name) - if (!handler) { + // Only tools offered this run: a left-out group stays out even if named. + if (!tools.some((tool) => tool.name === call.name)) { const msg = `Unknown tool "${call.name}"` resultBlocks.push({ type: 'tool_result', toolCallId: call.id, content: msg, isError: true }) roundErrors.push(msg) onProgress?.({ type: 'action', ok: false, label: msg }) continue } - let result - try { - result = handler(call.input, ctx) - } catch (err) { - result = { ok: false, resultText: (err as Error).message } - } + const result = await runTool(call.name, call.input, ctx) resultBlocks.push({ type: 'tool_result', toolCallId: call.id, content: result.resultText, isError: !result.ok }) if (result.ok) mergeReport(report, result) else roundErrors.push(`${call.name}: ${result.resultText}`) diff --git a/apps/studio/src/renderer/src/ai/systemPrompt.ts b/apps/studio/src/renderer/src/ai/systemPrompt.ts index 8bcd95b1..86fbc667 100644 --- a/apps/studio/src/renderer/src/ai/systemPrompt.ts +++ b/apps/studio/src/renderer/src/ai/systemPrompt.ts @@ -5,12 +5,9 @@ import type { C4Node, C4Relation, DiagramView } from '@radical/common/c4' import type { Metamodel } from '@radical/common/metamodel' +import { buildMetamodelMessage } from '@radical/common/ai/metamodelContext' import type { ChatMessage } from './types' -const FALLBACK_TYPES = [ - 'person', 'system', 'container', 'component', 'database', 'webapp', 'queue', -] as const - export const AI_SYSTEM_PROMPT = `You are a C4 architecture diagram editor embedded in the Radical Diagram tool. Use the provided tools to inspect and change the diagram. Call search_model when you need exact data from the live model before answering or acting — it is free to interleave with mutating tool calls in the same turn. When you have enough information, reply with a short final answer and make NO further tool calls — that ends the turn. @@ -20,75 +17,10 @@ Rules: - STRICTLY follow the metamodel rules in the context block below: a node/relation that violates allowedParents/allowedAtRoot/cardinality/allowedPairs will be rejected. Call search_model or re-read the metamodel context if unsure. - Views are FILTERS over the model graph: a view only stores which nodes are visible. Relations whose both endpoints are visible are shown automatically. - When decomposing a requirement into sub-requirements, create each child with add_node (type "requirement") and a \`properties\` bag setting \`ears_type\` plus the fields it implies (trigger/precondition/unwanted_condition/feature/action/rationale — see the metamodel context for the exact set), then link child → parent with add_relation using relationType "derives". -- Pick a view's \`kind\` deliberately: "table" for governance record types (ADR/Fitness Function/Requirement), "treemap" for hierarchy overviews, "wiki" for docs, "matrix" for relation grids, "static" otherwise. -- Cannot change the layout, themes, or the metamodel itself. If asked for any of those, say so in your final answer and make no tool calls. +- Pick a view's \`kind\` deliberately: "table" for governance record types (ADR/Fitness Function/Requirement), "treemap" for hierarchy overviews, "wiki" for docs, "matrix" for relation grids, "dynamic" to play a sequence (create_sequence first: an ordered list of existing relations), "static" otherwise. +- Use smart_layout only when asked to arrange or tidy the diagram, or after adding many nodes. Change the metamodel (upsert_/delete_ node and relation types) only when the user asks for new or different element types, fields or relation types; it is not offered in every context. Cannot change themes — if asked, say so in your final answer and make no tool calls. - reset_diagram erases EVERYTHING. Use ONLY when explicitly asked to start from scratch or replace the whole model, and call it before any other tool in the same task.`.trim() -/** Build a compact, machine-readable summary of the metamodel rules — - * including each type's custom `properties`, which is how the model learns - * what keys are valid in add_node/add_relation's `properties` bag. - * - * `relevantTypeIds` (when given — Radical Forge passes its current stage's - * primary type(s); QuickSearch's freeform chat has no "stage" and omits it, - * keeping today's always-full-detail behavior) trims the verbose - * `properties` array for node types that are both irrelevant to the active - * stage AND not yet used anywhere in `nodes` — a type already present in the - * diagram keeps full detail regardless, since the model may still need to - * reference/link to it correctly. This never removes a type from the tool - * schema's own `type` enum (ai/tools/nodeTools.ts) — the model can still - * create any type, just with less up-front documentation for the unlikely - * ones. Abbreviated entries keep id/label/allowedParents/allowedAtRoot/ - * cardinality — enough to know the type exists and roughly where it fits. */ -export function buildMetamodelMessage( - mm: Metamodel | undefined, - relevantTypeIds?: Set, - nodes?: Record, -): string { - if (!mm) { - return [ - 'Metamodel: (none loaded — falling back to default C4 types)', - 'Allowed node types: ' + FALLBACK_TYPES.map(t => `"${t}"`).join(', '), - ].join('\n') - } - const typesInUse = new Set(Object.values(nodes ?? {}).map((n) => n.type)) - const types = Object.values(mm.nodeTypes).map((t) => { - // Mirror the same defaulting that diagramStore uses: when allowedParents is - // empty/undefined the type is allowed at the root unless explicitly false. - const allowedParents = t.allowedParents && t.allowedParents.length > 0 ? t.allowedParents : [] - const rootDefault = allowedParents.length === 0 - const atRoot = t.allowedAtRoot ?? rootDefault - const fullDetail = !relevantTypeIds || relevantTypeIds.has(t.id) || typesInUse.has(t.id) - return { - id: t.id, - label: t.label, - allowedParents, - allowedAtRoot: atRoot, - cardinality: t.cardinality, - ...(fullDetail ? { properties: t.properties } : {}), - } - }) - const relations = Object.values(mm.relationTypes).map((r) => ({ - id: r.id, - label: r.label, - allowedPairs: r.allowedPairs, - properties: r.properties, - })) - return [ - `Metamodel "${mm.name}". Use ONLY the node/relation types listed below; respect`, - '`allowedParents` (empty ⇒ requires `allowedAtRoot: true` to be a root node),', - '`cardinality.max`, and `allowedPairs`. Each type\'s `properties` array (where', - 'present) is the exact set of keys valid in that type\'s `properties` tool-call', - 'bag — key, label, type, and (for enums) the allowed `options`. A type with no', - '`properties` array here still exists and can be created — its property schema', - 'was just omitted here as unlikely to be needed; call search_model or add_node', - 'without a `properties` bag if unsure (unknown keys are dropped with a note,', - 'not a hard failure).', - '```json', - JSON.stringify({ nodeTypes: types, relationTypes: relations }), - '```', - ].join('\n') -} - const LAYOUT_ONLY_NODE_KEYS = new Set(['collapsed', 'x', 'y', 'width', 'height']) function serializeNode(n: C4Node): Record { diff --git a/apps/studio/src/renderer/src/ai/useDiagramFacade.ts b/apps/studio/src/renderer/src/ai/useDiagramFacade.ts index ddea5726..cc38ea8b 100644 --- a/apps/studio/src/renderer/src/ai/useDiagramFacade.ts +++ b/apps/studio/src/renderer/src/ai/useDiagramFacade.ts @@ -9,6 +9,8 @@ import { useMemo } from 'react' import { useDiagramStore } from '@radical/ui/store/diagramStore' import type { DiagramFacade } from '@radical/common/ai/diagramFacade' +import type { Presentation } from '@radical/common/c4' +import type { Metamodel } from '@radical/common/metamodel' export function useDiagramFacade(): DiagramFacade { return useMemo(createStoreFacade, []) @@ -32,6 +34,8 @@ export function createStoreFacade(): DiagramFacade { updateNode: (id: string, u: Parameters['updateNode']>[1]) => useDiagramStore.getState().updateNode(id, u), removeNode: (id: string) => useDiagramStore.getState().removeNode(id), + moveNodes: (ids: string[], parentId: string | null) => + useDiagramStore.getState().reparentNodes(ids, parentId), addRelation: (r: Parameters['addRelation']>[0]) => useDiagramStore.getState().addRelation(r), updateRelation: (id: string, u: Parameters['updateRelation']>[1]) => @@ -46,6 +50,43 @@ export function createStoreFacade(): DiagramFacade { setActiveView: (id: string | null) => useDiagramStore.getState().setActiveView(id), setViewKind: (id: string, kind: Parameters['setViewKind']>[1]) => useDiagramStore.getState().setViewKind(id, kind), + renameView: (id: string, name: string) => useDiagramStore.getState().renameView(id, name), + setViewSequence: (id: string, sequenceId: string | null) => + useDiagramStore.getState().setViewSequence(id, sequenceId), + setViewHiddenRelations: (id: string, relationIds: string[]) => { + const s = useDiagramStore.getState() + const hidden = new Set(s.views[id]?.hiddenRelationIds ?? []) + const wanted = new Set(relationIds) + for (const rid of hidden) if (!wanted.has(rid)) s.unhideRelationInView(id, rid) + for (const rid of wanted) if (!hidden.has(rid)) s.hideRelationFromView(id, rid) + }, + // ── sequences ── + getSequences: () => useDiagramStore.getState().sequences, + addSequence: (name: string) => useDiagramStore.getState().addSequence(name), + renameSequence: (id: string, name: string) => useDiagramStore.getState().renameSequence(id, name), + setSequenceSteps: (id: string, steps: Array<{ relationId: string; description?: string }>) => { + const s = useDiagramStore.getState() + s.clearSequence(id) + steps.forEach((step, i) => { + s.toggleRelationInSequence(id, step.relationId) + if (step.description) s.updateStepDescription(id, i, step.description) + }) + }, + removeSequence: (id: string) => useDiagramStore.getState().removeSequence(id), + // ── metamodel, presentations, layout ── + setMetamodel: (metamodel: Metamodel) => useDiagramStore.getState().setMetamodel(metamodel), + getPresentations: () => useDiagramStore.getState().presentations, + setPresentations: (presentations: Presentation[]) => useDiagramStore.getState().setPresentations(presentations), + runLayout: async (viewId?: string) => { + const s = useDiagramStore.getState() + if (s.appMode !== 'designer') return { ok: false, text: 'Smart Layout runs only in the designer.' } + // Studio lays out what is on screen, so switch to the requested canvas first. + const target = viewId ?? null + if (target !== s.activeViewId) s.setActiveView(target) + await useDiagramStore.getState().runSmartLayout() + const name = target ? useDiagramStore.getState().views[target]?.name ?? target : 'All elements' + return { ok: true, text: `Ran Smart Layout on ${name}.` } + }, // ── diagram-level ── clearDiagram: () => { const s = useDiagramStore.getState() diff --git a/apps/studio/src/renderer/src/components/MetamodelEditor.tsx b/apps/studio/src/renderer/src/components/MetamodelEditor.tsx index 329fe163..fe5029eb 100644 --- a/apps/studio/src/renderer/src/components/MetamodelEditor.tsx +++ b/apps/studio/src/renderer/src/components/MetamodelEditor.tsx @@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect } from 'react' import { createPortal } from 'react-dom' import { useDiagramStore } from '@radical/ui/store/diagramStore' import { builtInC4Metamodel, validateModel } from '@radical/common/metamodel' +import { forkPresetMetamodel } from '@radical/common/model' import type { NodeTypeDef, RelationTypeDef, @@ -552,7 +553,7 @@ export function MetamodelEditor(): React.ReactElement { setMetamodel({ ...metamodel, name: e.target.value })} + onChange={(e) => setMetamodel({ ...(forkPresetMetamodel(metamodel) ?? metamodel), name: e.target.value })} />
Per-document metamodel · {nodeTypes.length} node type(s) · {relationTypes.length} relation type(s) diff --git a/apps/studio/src/renderer/src/components/RadicalForgeModal.tsx b/apps/studio/src/renderer/src/components/RadicalForgeModal.tsx index 3a377fa9..f0a956eb 100644 --- a/apps/studio/src/renderer/src/components/RadicalForgeModal.tsx +++ b/apps/studio/src/renderer/src/components/RadicalForgeModal.tsx @@ -377,7 +377,11 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement ) const prompt = buildForgeStagePrompt(stageId, description, effectiveMatches, clarifications, priorStageSummaries) const relevantTypeIds = new Set(PRIMARY_TYPE_IDS_FOR_STAGE[stageId]) - const result = await runAIPrompt({ prompt, settings: aiSettings, diagram, signal: ctl.signal, onProgress, relevantTypeIds }) + const result = await runAIPrompt({ + prompt, settings: aiSettings, diagram, signal: ctl.signal, onProgress, relevantTypeIds, + // Forge builds the model; editing types or slides is out of scope and costs schema tokens every stage. + excludeToolGroups: ['metamodel', 'presentation'], + }) setStageReports((r) => ({ ...r, [stageId]: result.report })) setStageSummaries((s) => ({ ...s, [stageId]: result.summary || 'Done.' })) if (result.usage) { diff --git a/apps/studio/tests/aiRunner.test.ts b/apps/studio/tests/aiRunner.test.ts index b22a5d8f..6ff6ef68 100644 --- a/apps/studio/tests/aiRunner.test.ts +++ b/apps/studio/tests/aiRunner.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { runAIPrompt } from '../src/renderer/src/ai/runner' import { defaultAISettings } from '../src/renderer/src/ai/settings' import type { DiagramFacade } from '@radical/common/ai/diagramFacade' +import { createModelFacade } from '@radical/common/ai/modelFacade' +import { builtInGovernanceMetamodel } from '@radical/common/metamodel' import type { C4Node, C4Relation } from '@radical/common/c4' function makeFacade(): DiagramFacade & { _nodes: Record; _rels: Record } { @@ -509,3 +511,43 @@ describe('runAIPrompt — onProgress (live feed for Radical Forge)', () => { expect(result.usage).toBeUndefined() }) }) + +describe('runAIPrompt — tool groups and metamodel changes', () => { + beforeEach(() => { delete (globalThis as any).fetch }) + + const toolNames = (body: any): string[] => body.tools.map((t: { name: string }) => t.name) + + it('leaves excluded groups out of the request and refuses calls to them', async () => { + const fake = fakeAnthropicFetch([ + { content: [toolUse('c1', 'upsert_node_type', { id: 'risk', label: 'Risk' })], stop_reason: 'tool_use' }, + { content: [text('Could not.')], stop_reason: 'end_turn' }, + ]) + const facade = createModelFacade({ nodes: [], relations: [], metamodel: builtInGovernanceMetamodel() }) + const result = await runAIPrompt({ + prompt: 'Add a risk type', settings: anthropicSettings(), diagram: facade, + excludeToolGroups: ['metamodel', 'presentation'], + }) + const names = toolNames(fake.bodies()[0]) + expect(names).toContain('add_node') + expect(names).not.toContain('upsert_node_type') + expect(names).not.toContain('create_presentation') + expect(result.report.errors).toEqual(['Unknown tool "upsert_node_type"']) + expect(JSON.stringify(fake.bodies()[1].messages)).toContain('Unknown tool') + expect(facade.getMetamodel!()!.nodeTypes.risk).toBeUndefined() + }) + + it('rebuilds the tool schemas after a metamodel tool adds a type', async () => { + const fake = fakeAnthropicFetch([ + { content: [toolUse('c1', 'upsert_node_type', { id: 'risk', label: 'Risk', allowedAtRoot: true })], stop_reason: 'tool_use' }, + { content: [toolUse('c2', 'add_node', { tempId: 'r', type: 'risk', label: 'Outage' })], stop_reason: 'tool_use' }, + { content: [text('Done.')], stop_reason: 'end_turn' }, + ]) + const facade = createModelFacade({ nodes: [], relations: [], metamodel: builtInGovernanceMetamodel() }) + const result = await runAIPrompt({ prompt: 'Add a risk type and one risk', settings: anthropicSettings(), diagram: facade }) + const enumOf = (body: any) => body.tools.find((t: { name: string }) => t.name === 'add_node').input_schema.properties.type.enum + expect(enumOf(fake.bodies()[0])).not.toContain('risk') + expect(enumOf(fake.bodies()[1])).toContain('risk') + expect(result.report.added.nodes).toBe(1) + expect(Object.values(facade.getNodes()).map((n) => n.type)).toEqual(['risk']) + }) +}) diff --git a/apps/studio/tests/aiSystemPrompt.test.ts b/apps/studio/tests/aiSystemPrompt.test.ts index b0111ea9..7caf2cb2 100644 --- a/apps/studio/tests/aiSystemPrompt.test.ts +++ b/apps/studio/tests/aiSystemPrompt.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest' import { AI_SYSTEM_PROMPT, buildContextMessage, - buildMetamodelMessage, buildSystemMessages, } from '../src/renderer/src/ai/systemPrompt' import type { C4Node, C4Relation } from '@radical/common/c4' @@ -29,61 +28,6 @@ describe('AI_SYSTEM_PROMPT contract', () => { }) }) -describe('buildMetamodelMessage', () => { - it("includes each type's properties, so the model knows valid property-bag keys", () => { - const mm: Metamodel = { - id: 'm', - name: 'M', - nodeTypes: { - requirement: { - id: 'requirement', label: 'Requirement', color: '', fg: '', iconPath: '', width: 0, height: 0, - properties: [{ key: 'ears_type', label: 'EARS type', type: 'enum', options: ['ubiquitous'] }], - }, - }, - relationTypes: { - derives: { - id: 'derives', label: 'Derives from', - allowedPairs: [{ from: 'requirement', to: 'requirement' }], - properties: [], - }, - }, - } - const msg = buildMetamodelMessage(mm) - expect(msg).toContain('ears_type') - expect(msg).toContain('ubiquitous') - expect(msg).toContain('derives') - }) - - it('abbreviates properties for types outside relevantTypeIds and not yet in use, but keeps full detail for relevant or in-use types', () => { - const mm: Metamodel = { - id: 'm', - name: 'M', - nodeTypes: { - requirement: { - id: 'requirement', label: 'Requirement', color: '', fg: '', iconPath: '', width: 0, height: 0, - properties: [{ key: 'ears_type', label: 'EARS type', type: 'enum', options: ['ubiquitous'] }], - }, - system: { - id: 'system', label: 'System', color: '', fg: '', iconPath: '', width: 0, height: 0, - properties: [{ key: 'tech', label: 'Tech', type: 'text' }], - }, - container: { - id: 'container', label: 'Container', color: '', fg: '', iconPath: '', width: 0, height: 0, - properties: [{ key: 'stack', label: 'Stack', type: 'text' }], - }, - }, - relationTypes: {}, - } - const nodes: Record = { s1: node({ id: 's1', type: 'system' }) } - const msg = buildMetamodelMessage(mm, new Set(['requirement']), nodes) - // relevant (requirement) and in-use (system) keep full property detail - expect(msg).toContain('ears_type') - expect(msg).toContain('"tech"') - // irrelevant and unused (container) loses its properties array - expect(msg).not.toContain('"stack"') - }) -}) - describe('buildContextMessage', () => { it('serialises base fields and spreads governance properties, but not layout', () => { const n = node({ id: 'n1', label: 'A', type: 'requirement' }) as unknown as Record diff --git a/apps/studio/tests/modelFacadeParity.test.ts b/apps/studio/tests/modelFacadeParity.test.ts index e98c0b8f..2d6cb7af 100644 --- a/apps/studio/tests/modelFacadeParity.test.ts +++ b/apps/studio/tests/modelFacadeParity.test.ts @@ -47,9 +47,12 @@ function normalize(data: DiagramData, tempToReal: Map) { .map(({ id: _id, ...r }) => ({ ...r, sourceId: id(r.sourceId), targetId: id(r.targetId) })) .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) const views = (data.views ?? []) - .map(({ positions: _p, viewport: _v, ...v }) => ({ ...v, id: id(v.id), nodeIds: v.nodeIds.map(id) })) + .map(({ positions: _p, viewport: _v, ...v }) => ({ ...v, id: id(v.id), nodeIds: v.nodeIds.map(id), sequenceId: id(v.sequenceId) })) .sort((a, b) => String(a.id).localeCompare(String(b.id))) - return { nodes, relations, views, text } + const sequences = (data.sequences ?? []) + .map((sequence) => ({ ...sequence, id: id(sequence.id) })) + .sort((a, b) => String(a.id).localeCompare(String(b.id))) + return { nodes, relations, views, sequences, metamodel: data.metamodel, text } } const blueprint = readCatalogue().find((e) => e.doc.hub.category === 'blueprint')!.doc @@ -62,6 +65,7 @@ describe('headless facade matches the Studio store', () => { const start = useDiagramStore.getState().saveDiagram() const someNode = start.nodes.find((n) => !n.parentId)! const someRelation = start.relations[0] + const otherRelation = start.relations[1] let seq = 0 const headless = createModelFacade(start, { newId: () => `id${++seq}` }) @@ -79,6 +83,22 @@ describe('headless facade matches the Studio store', () => { ['create_view', { tempId: 'v', name: 'Billing', nodeIds: ['sys', 'api', 'sys', 'ghost'], kind: 'table', active: true }], ['add_node', { tempId: 'db', type: 'container', label: 'Ledger', parentId: 'sys' }], // lands in the active view ['set_view_nodes', { id: 'v', nodeIds: ['db', 'sys'] }], + ['add_node', { tempId: 'sys2', type: 'system', label: 'Payments' }], + ['move_node', { id: 'db', parentId: 'sys2' }], + ['move_node', { id: 'db', parentId: null }], // refused: containment + ['update_view', { id: 'v', name: 'Billing overview', kind: 'matrix' }], + ['create_sequence', { tempId: 'seq', name: 'Flow', steps: [{ relationId: otherRelation.id, description: 'First step' }] }], + ['update_sequence', { id: 'seq', name: 'Main flow', steps: [{ relationId: otherRelation.id }, { relationId: otherRelation.id }] }], + ['create_view', { tempId: 'dyn', name: 'Flow view', kind: 'dynamic', sequenceId: 'seq' }], + ['create_view', { tempId: 'bad', name: 'No flow', kind: 'dynamic' }], // refused: no sequence + ['upsert_node_type', { + id: 'risk', label: 'Risk', allowedAtRoot: true, + properties: [{ key: 'severity', label: 'Severity', type: 'enum', options: ['low', 'high'] }], + }], + ['add_node', { tempId: 'risk1', type: 'risk', label: 'Outage', properties: { severity: 'high' } }], + ['upsert_relation_type', { id: 'mitigates', label: 'Mitigates', allowedPairs: [{ from: 'requirement', to: 'risk' }] }], + ['add_relation', { sourceId: 'req', targetId: 'risk1', relationType: 'mitigates' }], + ['delete_node_type', { id: 'risk' }], // refused: in use ['delete_relation', { id: someRelation.id }], ['delete_node', { id: 'sys' }], // cascades ['set_active_view', { id: null }], @@ -100,6 +120,9 @@ describe('headless facade matches the Studio store', () => { expect(nb.nodes).toEqual(na.nodes) expect(nb.relations).toEqual(na.relations) expect(nb.views).toEqual(na.views) + expect(nb.sequences).toEqual(na.sequences) + expect(nb.metamodel).toEqual(na.metamodel) + expect(na.metamodel?.nodeTypes.risk).toBeDefined() }) it('resets like the store does', () => { diff --git a/docs/IMPROVEMENTS.md b/docs/IMPROVEMENTS.md index 651c2684..84520d87 100644 --- a/docs/IMPROVEMENTS.md +++ b/docs/IMPROVEMENTS.md @@ -204,3 +204,31 @@ ADR, fitness function, requirement, scenario and mockup, and 5. **Fields have no per-field hint.** Guidance is per step, so steps with several fields share one help text. A `PropertyDef.placeholder` would let the properties panel show the same prompts. + +### AI tool catalogue and MCP server (2026-10-05) + +One catalogue in `packages/common/src/ai/tools` serves Studio's AI +(QuickSearch chat, Radical Forge) and `apps/mcp`: nodes (including +`move_node`), relations, views (`update_view`, dynamic views), sequences, +presentations, the metamodel and `smart_layout` (All elements or one view). +Forge leaves out the metamodel and presentation groups +(`excludeToolGroups`). The facade decides how a tool lands: Studio's store or +the headless model, which the MCP server writes to the folder. Editing a +built-in metamodel (in the Metamodel Editor or through a tool) now works on a +`…-custom` copy, because `documentMetamodel` swaps a preset id back to the +preset on load. Still open: + +1. **Studio's smart_layout switches the active view.** Studio lays out what + is on screen, so the tool first activates the requested view (or All + elements). The facade's `runLayout` is not covered by a test, because Smart + Layout needs ELK, which the Studio unit tests don't run. +2. **No wizard editing through the tools.** `upsert_node_type` sets look, containment, + cardinality, table tab and properties, not `wizard` or + `hierarchyRelation`. +3. **Slides are framed by fit.** Tool-made slides have no captured viewport or + canvas state, so Studio fits the slide's view; framing is captured only in + Studio. +4. **Polish characters in folder slugs.** `slugify` in + `packages/common/src/formats/mdFolder.ts` turns "zamówienie" into + "zamo-wienie" (NFKD leaves the accent as a separate mark, which becomes a + dash). Check whether fixing it renames files in existing folders. diff --git a/package-lock.json b/package-lock.json index af116118..56500673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,9 @@ "dependencies": { "@modelcontextprotocol/server": "2.2.0", "@radical/common": "*", - "@radical/node-files": "*" + "@radical/layout": "*", + "@radical/node-files": "*", + "elkjs": "^0.9.3" }, "devDependencies": { "@modelcontextprotocol/client": "2.2.0", diff --git a/packages/common/package.json b/packages/common/package.json index e0f58108..87230e20 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -17,6 +17,7 @@ "./ai/queryLanguage": "./src/ai/queryLanguage.ts", "./ai/diagramFacade": "./src/ai/diagramFacade.ts", "./ai/modelFacade": "./src/ai/modelFacade.ts", + "./ai/metamodelContext": "./src/ai/metamodelContext.ts", "./ai/tools": "./src/ai/tools/index.ts" }, "scripts": { diff --git a/packages/common/src/ai/diagramFacade.ts b/packages/common/src/ai/diagramFacade.ts index 21d79ee4..d1991a31 100644 --- a/packages/common/src/ai/diagramFacade.ts +++ b/packages/common/src/ai/diagramFacade.ts @@ -4,7 +4,7 @@ // applyPatch.ts — there is no single "apply a whole patch" function anymore, // each tool applies its own single change. -import type { C4Node, C4Relation, DiagramView } from '../c4' +import type { C4Node, C4Relation, DiagramSequence, DiagramView, Presentation } from '../c4' import type { Metamodel } from '../metamodel' export interface ApplyReport { @@ -62,6 +62,9 @@ export interface DiagramFacade { addNode(node: Omit): string updateNode(id: string, updates: Partial>): void removeNode(id: string): void + /** Optional — moves sibling nodes under a new parent (null = the root), + * keeping their canvas position. move_node checks the move first. */ + moveNodes?(ids: string[], parentId: string | null): void addRelation(rel: Omit): void updateRelation?(id: string, updates: Partial>): void removeRelation(id: string): void @@ -70,8 +73,28 @@ export interface DiagramFacade { setViewNodes?(viewId: string, nodeIds: string[]): void removeView?(id: string): void setActiveView?(id: string | null): void - /** Optional — sets a view's kind (table/treemap/wiki/matrix/static). */ + /** Optional — sets a view's kind (table/treemap/wiki/matrix/static/dynamic). */ setViewKind?(id: string, kind: DiagramView['kind']): void + renameView?(id: string, name: string): void + /** Optional — links a dynamic view to a sequence (null unlinks it). */ + setViewSequence?(id: string, sequenceId: string | null): void + /** Optional — hides exactly these relations in the view. */ + setViewHiddenRelations?(id: string, relationIds: string[]): void + /** Optional sequence actions — omitted = AI sequence tools report a clear error. */ + getSequences?(): Record + addSequence?(name: string): string + renameSequence?(id: string, name: string): void + /** Replaces the steps, in order; a relation may repeat. */ + setSequenceSteps?(id: string, steps: Array<{ relationId: string; description?: string }>): void + removeSequence?(id: string): void + /** Optional — replaces the document's metamodel (metamodel tools). */ + setMetamodel?(metamodel: Metamodel): void + /** Optional presentation access — omitted = presentation tools report a clear error. */ + getPresentations?(): Presentation[] + setPresentations?(presentations: Presentation[]): void + /** Optional — runs Smart Layout over All elements (no viewId) or one + * static/dynamic view, and keeps the result. */ + runLayout?(viewId?: string): Promise<{ ok: boolean; text: string }> /** Optional — clears the entire diagram (nodes, relations, views) so the * AI can build a fresh model from scratch. Omitting it means reset_diagram * calls fail with a descriptive error. */ diff --git a/packages/common/src/ai/metamodelContext.ts b/packages/common/src/ai/metamodelContext.ts new file mode 100644 index 00000000..ee899e23 --- /dev/null +++ b/packages/common/src/ai/metamodelContext.ts @@ -0,0 +1,79 @@ +// ─── Metamodel context message ────────────────────────────────────────────── +// The tool schemas (ai/tools) only enumerate type ids. This message carries +// the rest of the metamodel — each type's property keys/enum options, +// allowedParents/allowedAtRoot/cardinality and relation allowedPairs — and is +// what the tool descriptions call "the metamodel context message". Studio +// sends it as a system message; the MCP server returns it from +// get_model_summary. + +import type { C4Node } from '../c4' +import type { Metamodel } from '../metamodel' + +const FALLBACK_TYPES = [ + 'person', 'system', 'container', 'component', 'database', 'webapp', 'queue', +] as const + +/** Build a compact, machine-readable summary of the metamodel rules — + * including each type's custom `properties`, which is how the model learns + * what keys are valid in add_node/add_relation's `properties` bag. + * + * `relevantTypeIds` (when given — Radical Forge passes its current stage's + * primary type(s); QuickSearch's freeform chat has no "stage" and omits it, + * keeping today's always-full-detail behavior) trims the verbose + * `properties` array for node types that are both irrelevant to the active + * stage AND not yet used anywhere in `nodes` — a type already present in the + * diagram keeps full detail regardless, since the model may still need to + * reference/link to it correctly. This never removes a type from the tool + * schema's own `type` enum (ai/tools/nodeTools.ts) — the model can still + * create any type, just with less up-front documentation for the unlikely + * ones. Abbreviated entries keep id/label/allowedParents/allowedAtRoot/ + * cardinality — enough to know the type exists and roughly where it fits. */ +export function buildMetamodelMessage( + mm: Metamodel | undefined, + relevantTypeIds?: Set, + nodes?: Record, +): string { + if (!mm) { + return [ + 'Metamodel: (none loaded — falling back to default C4 types)', + 'Allowed node types: ' + FALLBACK_TYPES.map(t => `"${t}"`).join(', '), + ].join('\n') + } + const typesInUse = new Set(Object.values(nodes ?? {}).map((n) => n.type)) + const types = Object.values(mm.nodeTypes).map((t) => { + // Mirror the same defaulting that diagramStore uses: when allowedParents is + // empty/undefined the type is allowed at the root unless explicitly false. + const allowedParents = t.allowedParents && t.allowedParents.length > 0 ? t.allowedParents : [] + const rootDefault = allowedParents.length === 0 + const atRoot = t.allowedAtRoot ?? rootDefault + const fullDetail = !relevantTypeIds || relevantTypeIds.has(t.id) || typesInUse.has(t.id) + return { + id: t.id, + label: t.label, + allowedParents, + allowedAtRoot: atRoot, + cardinality: t.cardinality, + ...(fullDetail ? { properties: t.properties } : {}), + } + }) + const relations = Object.values(mm.relationTypes).map((r) => ({ + id: r.id, + label: r.label, + allowedPairs: r.allowedPairs, + properties: r.properties, + })) + return [ + `Metamodel "${mm.name}". Use ONLY the node/relation types listed below; respect`, + '`allowedParents` (empty ⇒ requires `allowedAtRoot: true` to be a root node),', + '`cardinality.max`, and `allowedPairs`. Each type\'s `properties` array (where', + 'present) is the exact set of keys valid in that type\'s `properties` tool-call', + 'bag — key, label, type, and (for enums) the allowed `options`. A type with no', + '`properties` array here still exists and can be created — its property schema', + 'was just omitted here as unlikely to be needed; call search_model or add_node', + 'without a `properties` bag if unsure (unknown keys are dropped with a note,', + 'not a hard failure).', + '```json', + JSON.stringify({ nodeTypes: types, relationTypes: relations }), + '```', + ].join('\n') +} diff --git a/packages/common/src/ai/modelFacade.ts b/packages/common/src/ai/modelFacade.ts index ac3139b4..913c9ba1 100644 --- a/packages/common/src/ai/modelFacade.ts +++ b/packages/common/src/ai/modelFacade.ts @@ -9,13 +9,14 @@ // per-view node positions, and fitting a parent's size around a new child. // Lay the result out with @radical/layout if positions matter. -import type { C4Node, C4Relation, DiagramData, DiagramView } from '../c4' +import type { C4Node, C4Relation, DiagramData, DiagramView, Presentation } from '../c4' +import type { Metamodel } from '../metamodel' import * as model from '../model' import type { DiagramFacade } from './diagramFacade' export interface ModelFacade extends DiagramFacade { /** The edited model as a document. Fields the facade does not edit - * (sequences, snapshots, presentations, …) are carried over from the input. */ + * (snapshots, layout positions, …) are carried over from the input. */ toDiagramData(): DiagramData /** Why the most recent refused change was refused, or null. */ readonly lastError: string | null @@ -24,6 +25,10 @@ export interface ModelFacade extends DiagramFacade { export interface ModelFacadeOptions { /** Id generator for new nodes, relations and views. Defaults to crypto.randomUUID. */ newId?: () => string + /** Lays out a document (All elements, or one view) and returns the result + * as `data`, or no data when nothing changed. Enables runLayout; the MCP + * server passes Smart Layout from @radical/layout. */ + runLayout?: (doc: DiagramData, viewId?: string) => Promise<{ ok: boolean; text: string; data?: DiagramData }> } const randomId = (): string => @@ -36,15 +41,30 @@ const byId = (items: T[] | undefined): Record = clone(rest) - const state: model.ModelState = { - c4Nodes: byId(nodes), - c4Relations: byId(relations), - views: byId(views), - activeViewId: null, - metamodel: model.documentMetamodel(data.metamodel), + let carried: Omit = {} + const state: model.ModelState & { sequences: NonNullable } = { + c4Nodes: {}, c4Relations: {}, views: {}, activeViewId: null, sequences: {}, } + // Keep an input's (possibly empty) sequences list; a cleared model drops it. + let keepSequences = false + const load = (doc: DiagramData): void => { + const { nodes, relations, views, sequences, ...rest } = doc + carried = clone(rest) + state.c4Nodes = byId(nodes) + state.c4Relations = byId(relations) + state.views = byId(views) + state.sequences = byId(sequences) + state.metamodel = model.documentMetamodel(doc.metamodel) + keepSequences = sequences !== undefined + } + load(data) + const toDiagramData = (): DiagramData => clone({ + ...carried, + nodes: Object.values(state.c4Nodes), + relations: Object.values(state.c4Relations), + views: Object.values(state.views), + ...(keepSequences || Object.keys(state.sequences).length ? { sequences: Object.values(state.sequences) } : {}), + }) let lastError: string | null = null const refuse = (reason: string): void => { lastError = reason } @@ -76,6 +96,11 @@ export function createModelFacade(data: DiagramData, options: ModelFacadeOptions removeNode(id: string) { model.deleteNode(state, id) }, + moveNodes(ids: string[], parentId: string | null) { + const refused = model.checkReparent(state, ids, parentId) + if (refused) { refuse(refused); return } + model.reparentNodes(state, ids, parentId) + }, addRelation(rel: Omit) { const refused = model.checkAddRelation(state, rel) @@ -107,23 +132,61 @@ export function createModelFacade(data: DiagramData, options: ModelFacadeOptions setViewKind(id: string, kind: DiagramView['kind']) { model.setViewKind(state, id, kind) }, + renameView(id: string, name: string) { + model.renameView(state, id, name) + }, + setViewSequence(id: string, sequenceId: string | null) { + model.setViewSequence(state, id, sequenceId) + }, + setViewHiddenRelations(id: string, relationIds: string[]) { + model.setViewHiddenRelations(state, id, relationIds) + }, + + getSequences: () => state.sequences, + addSequence(name: string) { + const id = newId() + model.insertSequence(state, id, name) + return id + }, + renameSequence(id: string, name: string) { + model.renameSequence(state, id, name) + }, + setSequenceSteps(id: string, steps: model.SequenceStep[]) { + model.setSequenceSteps(state, id, steps) + }, + removeSequence(id: string) { + model.deleteSequence(state, id) + }, + + setMetamodel(metamodel: Metamodel) { + state.metamodel = clone(metamodel) + carried.metamodel = clone(metamodel) + }, + + getPresentations: () => carried.presentations ?? [], + setPresentations(presentations: Presentation[]) { + carried.presentations = clone(presentations) + }, + + ...(options.runLayout ? { + async runLayout(viewId?: string) { + const outcome = await options.runLayout!(toDiagramData(), viewId) + if (outcome.data) load(outcome.data) + return { ok: outcome.ok, text: outcome.text } + }, + } : {}), // Same as Studio's reset: everything goes except the metamodel. clearDiagram() { state.c4Nodes = {} state.c4Relations = {} state.views = {} + state.sequences = {} + keepSequences = false state.activeViewId = null carried = carried.metamodel ? { metamodel: carried.metamodel } : {} }, - toDiagramData(): DiagramData { - return clone({ - ...carried, - nodes: Object.values(state.c4Nodes), - relations: Object.values(state.c4Relations), - views: Object.values(state.views), - }) - }, + toDiagramData, } } diff --git a/packages/common/src/ai/queryLanguage.ts b/packages/common/src/ai/queryLanguage.ts index a3ab6e51..e0546fae 100644 --- a/packages/common/src/ai/queryLanguage.ts +++ b/packages/common/src/ai/queryLanguage.ts @@ -1,4 +1,4 @@ -import type { C4Node, C4Relation, DiagramView } from '../c4' +import type { C4Node, C4Relation, DiagramSequence, DiagramView } from '../c4' export const MODEL_QUERY_LANGUAGE_HELP = `Query the CURRENT diagram structure with a small built-in query language — use this whenever you need exact data before answering or acting, instead of @@ -12,9 +12,11 @@ Syntax: - LIST RELATIONS WHERE [LIMIT n] - LIST VIEWS - LIST VIEWS WHERE [LIMIT n] +- LIST SEQUENCES - LIST TECHNOLOGIES - GET NODE - GET VIEW +- GET SEQUENCE - GET CHILDREN OF - GET NEIGHBORS OF [DEPTH n] - GET DEPENDENCIES OF [DEPTH n] @@ -56,6 +58,7 @@ export interface ModelQueryContext { nodes: Record relations: Record views?: Record + sequences?: Record } export interface ModelQueryResult { @@ -538,6 +541,30 @@ export function runModelQuery(query: string, ctx: ModelQueryContext): ModelQuery } } + if (upper === 'LIST SEQUENCES') { + const rows = Object.values(ctx.sequences ?? {}) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) + .map((sequence) => ({ id: sequence.id, name: sequence.name, stepCount: sequence.relationIds.length })) + return { query: raw, command: 'LIST SEQUENCES', result: { total: rows.length, rows } } + } + + const getSequenceMatch = raw.match(/^GET\s+SEQUENCE\s+(.+)$/i) + if (getSequenceMatch) { + const sequenceId = stripQuotes(getSequenceMatch[1]) + const sequence = ctx.sequences?.[sequenceId] + if (!sequence) throw new Error(`Unknown sequence id: ${sequenceId}`) + const steps = sequence.relationIds.map((relationId, i) => { + const rel = ctx.relations[relationId] + return { + step: i + 1, + ...(rel ? summarizeRelation(rel, ctx.nodes) : { id: relationId, missing: true }), + ...(sequence.stepDescriptions?.[i] ? { stepDescription: sequence.stepDescriptions[i] } : {}), + } + }) + const shownIn = Object.values(views).filter((view) => view.sequenceId === sequence.id).map((view) => ({ id: view.id, name: view.name })) + return { query: raw, command: 'GET SEQUENCE', result: { id: sequence.id, name: sequence.name, steps, views: shownIn } } + } + if (upper === 'LIST TECHNOLOGIES') { const stats = new Map() for (const node of Object.values(ctx.nodes)) { diff --git a/packages/common/src/ai/tools/index.ts b/packages/common/src/ai/tools/index.ts index 39145b59..bf0db786 100644 --- a/packages/common/src/ai/tools/index.ts +++ b/packages/common/src/ai/tools/index.ts @@ -2,30 +2,72 @@ // Built fresh from the live metamodel every run — same "no code changes for a // custom type" promise as TableView.tsx's deriveNodeCols/propToCol — so a // type added via the Metamodel Editor gets full AI support automatically. +// Studio's AI (QuickSearch chat, Radical Forge) and the MCP server share it. import type { Metamodel } from '../../metamodel' +import { buildLayoutToolDefs, buildLayoutToolHandlers } from './layoutTools' +import { buildMetamodelToolDefs, buildMetamodelToolHandlers } from './metamodelTools' import { buildModelToolDefs, buildModelToolHandlers } from './modelTools' import { buildNodeToolDefs, buildNodeToolHandlers } from './nodeTools' +import { buildPresentationToolDefs, buildPresentationToolHandlers } from './presentationTools' import { buildRelationToolDefs, buildRelationToolHandlers } from './relationTools' +import { buildSequenceToolDefs, buildSequenceToolHandlers } from './sequenceTools' import { buildViewToolDefs, buildViewToolHandlers } from './viewTools' -import type { ToolDef, ToolHandler } from './types' - -export function buildToolDefs(metamodel: Metamodel | undefined): ToolDef[] { - return [ - ...buildNodeToolDefs(metamodel), - ...buildRelationToolDefs(metamodel), - ...buildViewToolDefs(), - ...buildModelToolDefs(), - ] +import type { AsyncToolHandler, ToolDef, ToolHandler, ToolResult, ToolRunContext } from './types' + +/** Tool groups a caller can leave out, e.g. Radical Forge, which only builds + * the model, leaves out 'metamodel' and 'presentation'. */ +export type ToolGroup = 'node' | 'relation' | 'view' | 'sequence' | 'model' | 'layout' | 'presentation' | 'metamodel' + +export interface ToolCatalogueOptions { + exclude?: ToolGroup[] } +export function buildToolDefs(metamodel: Metamodel | undefined, options: ToolCatalogueOptions = {}): ToolDef[] { + const groups: Record ToolDef[]> = { + node: () => buildNodeToolDefs(metamodel), + relation: () => buildRelationToolDefs(metamodel), + view: buildViewToolDefs, + sequence: buildSequenceToolDefs, + model: buildModelToolDefs, + layout: buildLayoutToolDefs, + presentation: buildPresentationToolDefs, + metamodel: buildMetamodelToolDefs, + } + const exclude = new Set(options.exclude ?? []) + return (Object.keys(groups) as ToolGroup[]).filter((group) => !exclude.has(group)).flatMap((group) => groups[group]()) +} + +/** The synchronous handlers. Use runTool to call any tool, async ones included. */ export function buildToolHandlers(): Map { return new Map(Object.entries({ ...buildNodeToolHandlers(), ...buildRelationToolHandlers(), ...buildViewToolHandlers(), + ...buildSequenceToolHandlers(), ...buildModelToolHandlers(), + ...buildPresentationToolHandlers(), + ...buildMetamodelToolHandlers(), })) } -export type { ToolDef, ToolHandler, ToolRunContext, ToolResult } from './types' +const SYNC = buildToolHandlers() +const ASYNC: Map = new Map(Object.entries(buildLayoutToolHandlers())) + +/** True when the catalogue has a tool with this name. */ +export function hasTool(name: string): boolean { + return SYNC.has(name) || ASYNC.has(name) +} + +/** Runs any catalogue tool. A thrown error becomes a failed result. */ +export async function runTool(name: string, input: unknown, ctx: ToolRunContext): Promise { + const handler = ASYNC.get(name) ?? SYNC.get(name) + if (!handler) return { ok: false, resultText: `Unknown tool "${name}"` } + try { + return await handler(input, ctx) + } catch (err) { + return { ok: false, resultText: (err as Error).message } + } +} + +export type { AsyncToolHandler, ToolDef, ToolHandler, ToolRunContext, ToolResult } from './types' diff --git a/packages/common/src/ai/tools/layoutTools.ts b/packages/common/src/ai/tools/layoutTools.ts new file mode 100644 index 00000000..e6105c82 --- /dev/null +++ b/packages/common/src/ai/tools/layoutTools.ts @@ -0,0 +1,40 @@ +// ─── Layout tool: smart_layout ─────────────────────────────────────────────── +// Smart Layout lives in @radical/layout, which depends on this package, so +// the facade runs it: Studio through its store, the MCP server headlessly. + +import { fail, type AsyncToolHandler, type ToolDef } from './types' + +export function buildLayoutToolDefs(): ToolDef[] { + return [ + { + name: 'smart_layout', + description: "Run Smart Layout and keep the new positions and container sizes. Without viewId it arranges the All elements canvas; with the id of a static or dynamic view it arranges that view's own positions. Call it after adding or moving several nodes.", + inputSchema: { + type: 'object', + properties: { viewId: { type: 'string', description: 'A static or dynamic view (real id or tempId); omit for All elements.' } }, + additionalProperties: false, + }, + }, + ] +} + +export function buildLayoutToolHandlers(): Record { + return { + smart_layout: async (rawInput, ctx) => { + const input = (rawInput ?? {}) as { viewId?: unknown } + if (!ctx.diagram.runLayout) return fail('smart_layout: layout is not available in this context') + if (input.viewId !== undefined && (typeof input.viewId !== 'string' || !input.viewId)) return fail('smart_layout: viewId must be a view id') + let viewId: string | undefined + if (typeof input.viewId === 'string') { + viewId = ctx.resolveId(input.viewId) + const view = ctx.diagram.getViews?.()[viewId] + if (!view) return fail(`smart_layout: unknown view id "${input.viewId}"`) + if (view.kind && view.kind !== 'static' && view.kind !== 'dynamic') { + return fail(`smart_layout: "${view.name}" is a ${view.kind} view; only static and dynamic views have a canvas layout`) + } + } + const outcome = await ctx.diagram.runLayout(viewId) + return { ok: outcome.ok, resultText: outcome.text } + }, + } +} diff --git a/packages/common/src/ai/tools/metamodelTools.ts b/packages/common/src/ai/tools/metamodelTools.ts new file mode 100644 index 00000000..9b4454ca --- /dev/null +++ b/packages/common/src/ai/tools/metamodelTools.ts @@ -0,0 +1,261 @@ +// ─── Metamodel tools: upsert_/delete_ node and relation types ─────────────── +// Edit the document's metamodel. A built-in preset is copied to a custom id +// on the first edit (forkPresetMetamodel); otherwise loading the document +// would swap the preset back in and drop the change. Tool schemas are built +// from the metamodel, so callers rebuild them after these run. + +import { builtInC4Metamodel, type Metamodel, type NodeTypeDef, type PropertyDef, type RelationPair, type RelationTypeDef } from '../../metamodel' +import { forkPresetMetamodel } from '../../model' +import { fail, type ToolDef, type ToolHandler, type ToolResult, type ToolRunContext } from './types' + +const PROPERTY_TYPES = ['text', 'textarea', 'boolean', 'number', 'enum'] +const TYPE_ID = /^[a-z][a-z0-9-]*$/ + +const PROPERTIES_SCHEMA = { + type: 'array', + description: 'The complete list of custom properties (replaces the current one).', + items: { + type: 'object', + properties: { + key: { type: 'string' }, + label: { type: 'string' }, + type: { type: 'string', enum: PROPERTY_TYPES }, + options: { type: 'array', items: { type: 'string' }, description: "Allowed values, for type 'enum'." }, + default: { type: ['string', 'number', 'boolean'] }, + required: { type: 'boolean' }, + }, + required: ['key', 'label', 'type'], + additionalProperties: false, + }, +} + +export function buildMetamodelToolDefs(): ToolDef[] { + return [ + { + name: 'upsert_node_type', + description: 'Add a node type to the metamodel, or change an existing one (only the fields you pass). Use only when the user asks for new or different element types or fields. A built-in metamodel is first copied to a custom one.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Lowercase id, e.g. "service" or "risk".' }, + label: { type: 'string' }, + baseType: { type: 'string', description: 'New types only: copy shape, size and icon from this existing type (default: a system-like box).' }, + color: { type: 'string', description: 'Fill colour, e.g. "#0ea5e9".' }, + fg: { type: 'string', description: 'Text colour.' }, + allowedParents: { type: 'array', items: { type: 'string' }, description: 'Node types it may sit inside. [] = none.' }, + allowedAtRoot: { type: 'boolean' }, + maxCount: { type: ['number', 'null'], description: 'Maximum number of nodes of this type; null removes the limit.' }, + tableTab: { type: 'boolean', description: 'Give the type its own tab in table views (record-like types).' }, + properties: PROPERTIES_SCHEMA, + }, + required: ['id'], + additionalProperties: false, + }, + }, + { + name: 'delete_node_type', + description: 'Delete a custom node type that no node uses. Built-in types cannot be deleted.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }, + }, + { + name: 'upsert_relation_type', + description: 'Add a relation type to the metamodel, or change an existing one (only the fields you pass). Use only when the user asks for new or different relation types. Copies a built-in metamodel first, like upsert_node_type.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + label: { type: 'string' }, + color: { type: 'string' }, + allowedPairs: { + type: 'array', + description: 'The complete list of allowed source → target node-type pairs. [] allows any pair.', + items: { + type: 'object', + properties: { from: { type: 'string' }, to: { type: 'string' } }, + required: ['from', 'to'], + additionalProperties: false, + }, + }, + properties: PROPERTIES_SCHEMA, + }, + required: ['id'], + additionalProperties: false, + }, + }, + { + name: 'delete_relation_type', + description: 'Delete a custom relation type that no relation uses. Built-in types cannot be deleted.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }, + }, + ] +} + +function parseProperties(tool: string, raw: unknown): PropertyDef[] | string { + if (!Array.isArray(raw)) return `${tool}: properties must be an array` + const seen = new Set() + const out: PropertyDef[] = [] + for (const [i, item] of raw.entries()) { + const p = item as Partial + if (typeof p?.key !== 'string' || !/^[a-z_][a-z0-9_]*$/i.test(p.key)) return `${tool}: property ${i + 1} needs a key (letters, digits, _)` + if (seen.has(p.key)) return `${tool}: duplicate property key "${p.key}"` + seen.add(p.key) + if (typeof p.label !== 'string' || !p.label.trim()) return `${tool}: property "${p.key}" needs a label` + if (!PROPERTY_TYPES.includes(p.type as string)) return `${tool}: property "${p.key}" has unknown type "${String(p.type)}"` + if (p.type === 'enum' && (!Array.isArray(p.options) || !p.options.length || !p.options.every((o) => typeof o === 'string'))) { + return `${tool}: enum property "${p.key}" needs string options` + } + out.push({ + key: p.key, + label: p.label.trim(), + type: p.type as PropertyDef['type'], + ...(p.type === 'enum' ? { options: p.options } : {}), + ...(p.default !== undefined ? { default: p.default } : {}), + ...(p.required ? { required: true } : {}), + }) + } + return out +} + +/** The live metamodel, or the reason it cannot be edited here. */ +function currentMetamodel(tool: string, ctx: ToolRunContext): Metamodel | string { + if (!ctx.diagram.setMetamodel) return `${tool}: the metamodel is not editable in this context` + return ctx.diagram.getMetamodel?.() ?? builtInC4Metamodel() +} + +/** A deep copy to edit (a custom copy of a preset), and the result text note. */ +function editable(current: Metamodel): { mm: Metamodel; note: string } { + const fork = forkPresetMetamodel(current) + const mm = fork ?? (JSON.parse(JSON.stringify(current)) as Metamodel) + return { mm, note: fork ? ` The built-in metamodel was copied to the custom metamodel "${mm.name}" (${mm.id}).` : '' } +} + +function save(ctx: ToolRunContext, mm: Metamodel, text: string): ToolResult { + ctx.diagram.setMetamodel!(mm) + return { ok: true, resultText: text } +} + +export function buildMetamodelToolHandlers(): Record { + return { + upsert_node_type: (rawInput, ctx) => { + const input = rawInput as Record + const current = currentMetamodel('upsert_node_type', ctx) + if (typeof current === 'string') return fail(current) + const id = input.id + if (typeof id !== 'string' || !TYPE_ID.test(id)) return fail('upsert_node_type: id must be lowercase letters, digits and dashes') + const existing = current.nodeTypes[id] + if (!existing && (typeof input.label !== 'string' || !input.label.trim())) return fail('upsert_node_type: a new type needs a label') + if (input.label !== undefined && (typeof input.label !== 'string' || !input.label.trim())) return fail('upsert_node_type: label must be a non-empty string') + const typeIds = new Set([...Object.keys(current.nodeTypes), id]) + if (input.allowedParents !== undefined) { + if (!Array.isArray(input.allowedParents)) return fail('upsert_node_type: allowedParents must be an array') + const unknown = input.allowedParents.filter((t) => typeof t !== 'string' || !typeIds.has(t)) + if (unknown.length) return fail(`upsert_node_type: unknown parent type(s) ${unknown.join(', ')}`) + } + if (input.baseType !== undefined && (typeof input.baseType !== 'string' || !current.nodeTypes[input.baseType])) { + return fail(`upsert_node_type: unknown baseType "${String(input.baseType)}"`) + } + if (input.maxCount !== undefined && input.maxCount !== null && (typeof input.maxCount !== 'number' || input.maxCount < 1)) { + return fail('upsert_node_type: maxCount must be a positive number or null') + } + const properties = input.properties === undefined ? undefined : parseProperties('upsert_node_type', input.properties) + if (typeof properties === 'string') return fail(properties) + + const { mm, note } = editable(current) + let def: NodeTypeDef + if (existing) { + def = { ...mm.nodeTypes[id] } + } else { + const base = input.baseType ? mm.nodeTypes[input.baseType as string] : builtInC4Metamodel().nodeTypes.system + const { wizard: _wizard, hierarchyRelation: _hierarchy, hubOnly: _hubOnly, ...look } = base + def = { ...look, id, label: '', builtin: false, color: '#6b7280', fg: '#ffffff', allowedParents: undefined, properties: [] } + } + if (typeof input.label === 'string') def.label = input.label.trim() + if (typeof input.color === 'string') def.color = input.color + if (typeof input.fg === 'string') def.fg = input.fg + if (Array.isArray(input.allowedParents)) def.allowedParents = input.allowedParents as string[] + if (typeof input.allowedAtRoot === 'boolean') def.allowedAtRoot = input.allowedAtRoot + if (input.maxCount === null) def.cardinality = def.cardinality?.min !== undefined ? { min: def.cardinality.min } : undefined + else if (typeof input.maxCount === 'number') def.cardinality = { ...def.cardinality, max: input.maxCount } + if (typeof input.tableTab === 'boolean') def.tableTab = input.tableTab + if (properties) def.properties = properties + mm.nodeTypes[id] = def + return save(ctx, mm, `${existing ? 'Updated' : 'Added'} node type "${id}".${note}`) + }, + + delete_node_type: (rawInput, ctx) => { + const input = rawInput as { id?: unknown } + const current = currentMetamodel('delete_node_type', ctx) + if (typeof current === 'string') return fail(current) + const def = typeof input.id === 'string' ? current.nodeTypes[input.id] : undefined + if (!def) return fail(`delete_node_type: unknown node type "${String(input.id)}"`) + if (def.builtin) return fail(`delete_node_type: "${def.id}" is built in and cannot be deleted`) + const used = Object.values(ctx.diagram.getNodes()).filter((node) => node.type === def.id).length + if (used) return fail(`delete_node_type: ${used} node(s) still use "${def.id}"; retype or delete them first`) + const { mm, note } = editable(current) + delete mm.nodeTypes[def.id] + for (const type of Object.values(mm.nodeTypes)) { + if (type.allowedParents?.includes(def.id)) type.allowedParents = type.allowedParents.filter((t) => t !== def.id) + } + for (const rel of Object.values(mm.relationTypes)) { + rel.allowedPairs = rel.allowedPairs.filter((pair) => pair.from !== def.id && pair.to !== def.id) + } + return save(ctx, mm, `Deleted node type "${def.id}".${note}`) + }, + + upsert_relation_type: (rawInput, ctx) => { + const input = rawInput as Record + const current = currentMetamodel('upsert_relation_type', ctx) + if (typeof current === 'string') return fail(current) + const id = input.id + if (typeof id !== 'string' || !TYPE_ID.test(id)) return fail('upsert_relation_type: id must be lowercase letters, digits and dashes') + const existing = current.relationTypes[id] + if (!existing && (typeof input.label !== 'string' || !input.label.trim())) return fail('upsert_relation_type: a new type needs a label') + if (input.label !== undefined && (typeof input.label !== 'string' || !input.label.trim())) return fail('upsert_relation_type: label must be a non-empty string') + let pairs: RelationPair[] | undefined + if (input.allowedPairs !== undefined) { + if (!Array.isArray(input.allowedPairs)) return fail('upsert_relation_type: allowedPairs must be an array') + pairs = [] + for (const pair of input.allowedPairs as Array<{ from?: unknown; to?: unknown }>) { + if (typeof pair?.from !== 'string' || !current.nodeTypes[pair.from]) return fail(`upsert_relation_type: unknown source type "${String(pair?.from)}"`) + if (typeof pair.to !== 'string' || !current.nodeTypes[pair.to]) return fail(`upsert_relation_type: unknown target type "${String(pair.to)}"`) + pairs.push({ from: pair.from, to: pair.to }) + } + } + const properties = input.properties === undefined ? undefined : parseProperties('upsert_relation_type', input.properties) + if (typeof properties === 'string') return fail(properties) + + const { mm, note } = editable(current) + const def: RelationTypeDef = existing ? { ...mm.relationTypes[id] } : { id, label: '', allowedPairs: [], builtin: false } + if (typeof input.label === 'string') def.label = input.label.trim() + if (typeof input.color === 'string') def.color = input.color + if (pairs) def.allowedPairs = pairs + if (properties) def.properties = properties + mm.relationTypes[id] = def + return save(ctx, mm, `${existing ? 'Updated' : 'Added'} relation type "${id}".${note}`) + }, + + delete_relation_type: (rawInput, ctx) => { + const input = rawInput as { id?: unknown } + const current = currentMetamodel('delete_relation_type', ctx) + if (typeof current === 'string') return fail(current) + const def = typeof input.id === 'string' ? current.relationTypes[input.id] : undefined + if (!def) return fail(`delete_relation_type: unknown relation type "${String(input.id)}"`) + if (def.builtin) return fail(`delete_relation_type: "${def.id}" is built in and cannot be deleted`) + const used = Object.values(ctx.diagram.getRelations()).filter((relation) => relation.relationType === def.id).length + if (used) return fail(`delete_relation_type: ${used} relation(s) still use "${def.id}"; change or delete them first`) + const { mm, note } = editable(current) + delete mm.relationTypes[def.id] + return save(ctx, mm, `Deleted relation type "${def.id}".${note}`) + }, + } +} diff --git a/packages/common/src/ai/tools/modelTools.ts b/packages/common/src/ai/tools/modelTools.ts index a47c1176..a65acc83 100644 --- a/packages/common/src/ai/tools/modelTools.ts +++ b/packages/common/src/ai/tools/modelTools.ts @@ -43,6 +43,7 @@ export function buildModelToolHandlers(): Record { nodes: ctx.diagram.getNodes(), relations: ctx.diagram.getRelations(), views: ctx.diagram.getViews?.(), + sequences: ctx.diagram.getSequences?.(), }) return { ok: true, resultText: JSON.stringify(result.result) } } catch (err) { diff --git a/packages/common/src/ai/tools/nodeTools.ts b/packages/common/src/ai/tools/nodeTools.ts index b0b90755..1775e75f 100644 --- a/packages/common/src/ai/tools/nodeTools.ts +++ b/packages/common/src/ai/tools/nodeTools.ts @@ -1,7 +1,8 @@ -// ─── Node tools: add_node / update_node / delete_node ─────────────────────── +// ─── Node tools: add_node / update_node / move_node / delete_node ─────────── import { NODE_SIZES, type C4ElementType, type C4Node } from '../../c4' import type { Metamodel } from '../../metamodel' +import { checkReparent } from '../../model' import { fail, type ToolDef, type ToolHandler } from './types' import { validateProperties } from './propertyBag' @@ -50,6 +51,19 @@ export function buildNodeToolDefs(mm: Metamodel | undefined): ToolDef[] { additionalProperties: false, }, }, + { + name: 'move_node', + description: 'Move an existing node (with its children) into another parent, or to the root with parentId null. Its canvas position is kept; the metamodel\'s allowedParents applies.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Real node id or a tempId used earlier in this run.' }, + parentId: { type: ['string', 'null'], description: 'New parent: real node id or tempId, or null for the root.' }, + }, + required: ['id', 'parentId'], + additionalProperties: false, + }, + }, { name: 'delete_node', description: 'Delete an existing node.', @@ -78,11 +92,11 @@ export function buildNodeToolHandlers(): Record { const { values: propValues, notes } = validateProperties(input.properties, mm?.nodeTypes[input.type]?.properties) const size = NODE_SIZES[input.type as C4ElementType] ?? { width: 240, height: 140 } - const pos = ctx.placeNext() const parentReal = typeof input.parentId === 'string' && input.parentId ? ctx.resolveId(input.parentId) : undefined if (parentReal && !(parentReal in ctx.diagram.getNodes())) { return fail(`add_node "${input.label}": unknown parentId "${input.parentId}"`) } + const pos = ctx.placeNext(parentReal) const nodeInput = { type: input.type as C4ElementType, @@ -135,6 +149,31 @@ export function buildNodeToolHandlers(): Record { return { ok: true, resultText, updated: { nodes: 1 } } }, + move_node: (rawInput, ctx) => { + const input = rawInput as { id?: unknown; parentId?: unknown } + if (typeof input.id !== 'string' || !input.id) return fail('move_node: id is required') + if (input.parentId !== null && (typeof input.parentId !== 'string' || !input.parentId)) { + return fail('move_node: parentId must be a node id or null') + } + if (!ctx.diagram.moveNodes) return fail('move_node: nodes cannot be moved in this context') + const realId = ctx.resolveId(input.id) + const nodes = ctx.diagram.getNodes() + if (!(realId in nodes)) return fail(`move_node: unknown id "${input.id}"`) + const parentReal = input.parentId === null ? null : ctx.resolveId(input.parentId) + if (parentReal && !(parentReal in nodes)) return fail(`move_node: unknown parentId "${input.parentId}"`) + if ((nodes[realId].parentId ?? null) === parentReal) return { ok: true, resultText: `Node ${realId} is already there.` } + const refused = checkReparent({ + c4Nodes: nodes, + c4Relations: ctx.diagram.getRelations(), + views: ctx.diagram.getViews?.() ?? {}, + activeViewId: null, + metamodel: ctx.diagram.getMetamodel?.(), + }, [realId], parentReal) + if (refused) return fail(`move_node: ${refused}`) + ctx.diagram.moveNodes([realId], parentReal) + return { ok: true, resultText: `Moved node ${realId} to ${parentReal ?? 'the root'}.`, updated: { nodes: 1 } } + }, + delete_node: (rawInput, ctx) => { const input = rawInput as { id?: unknown } if (typeof input.id !== 'string' || !input.id) return fail('delete_node: id is required') diff --git a/packages/common/src/ai/tools/presentationTools.ts b/packages/common/src/ai/tools/presentationTools.ts new file mode 100644 index 00000000..821277b3 --- /dev/null +++ b/packages/common/src/ai/tools/presentationTools.ts @@ -0,0 +1,135 @@ +// ─── Presentation tools: create_presentation / update_presentation / delete_presentation +// A presentation is an ordered list of slides, each showing one view (or +// every node). Slides made here carry no captured viewport or canvas state, +// so Studio's presenter mode frames each one to fit its view. + +import type { Presentation, PresentationSlide } from '../../c4' +import { fail, type ToolDef, type ToolHandler, type ToolRunContext } from './types' + +const randomId = (): string => + (globalThis as unknown as { crypto: { randomUUID(): string } }).crypto.randomUUID() + +const SLIDES_SCHEMA = { + type: 'array', + description: "Slides in order. Each shows one view (or every node without viewId), framed to fit. Pass an existing slide's id to keep it (and any framing captured in Studio).", + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Existing slide id to keep; omit for a new slide.' }, + name: { type: 'string' }, + viewId: { type: ['string', 'null'], description: 'Real view id or a tempId from this run.' }, + }, + required: ['name'], + additionalProperties: false, + }, +} + +export function buildPresentationToolDefs(): ToolDef[] { + return [ + { + name: 'create_presentation', + description: 'Create a presentation: an ordered list of slides, each showing one view.', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' }, slides: SLIDES_SCHEMA }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'update_presentation', + description: 'Rename a presentation or replace its slides (the full ordered list).', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' }, name: { type: 'string' }, slides: SLIDES_SCHEMA }, + required: ['id'], + additionalProperties: false, + }, + }, + { + name: 'delete_presentation', + description: 'Delete a presentation.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }, + }, + ] +} + +const newSlide = (id: string, name: string, viewId: string | null): PresentationSlide => + // A zero viewport and no canvas state make Studio fit the slide's view. + ({ id, name, snapshotId: null, viewId, viewport: { x: 0, y: 0, zoom: 0 } }) + +function parseSlides(tool: string, raw: unknown, ctx: ToolRunContext, existing: PresentationSlide[]): PresentationSlide[] | string { + if (!Array.isArray(raw)) return `${tool}: slides must be an array` + const views = ctx.diagram.getViews?.() ?? {} + const kept = new Map(existing.map((slide) => [slide.id, slide])) + const slides: PresentationSlide[] = [] + for (const [i, item] of raw.entries()) { + const s = item as { id?: unknown; name?: unknown; viewId?: unknown } + if (typeof s?.name !== 'string' || !s.name.trim()) return `${tool}: slide ${i + 1} needs a name` + let viewId: string | null = null + if (s.viewId != null) { + if (typeof s.viewId !== 'string') return `${tool}: slide ${i + 1} has an invalid viewId` + viewId = ctx.resolveId(s.viewId) + if (!(viewId in views)) return `${tool}: slide ${i + 1} has unknown viewId "${s.viewId}"` + } + const previous = typeof s.id === 'string' ? kept.get(s.id) : undefined + if (typeof s.id === 'string' && !previous) return `${tool}: slide ${i + 1} has unknown id "${s.id}"` + const name = s.name.trim() + if (!previous) slides.push(newSlide(randomId(), name, viewId)) + // A slide moved to another view loses the framing captured for the old one. + else if ((previous.viewId ?? null) === viewId) slides.push({ ...previous, name }) + else slides.push(newSlide(previous.id, name, viewId)) + } + return slides +} + +function presentationsOf(tool: string, ctx: ToolRunContext): Presentation[] | string { + if (!ctx.diagram.getPresentations || !ctx.diagram.setPresentations) return `${tool}: presentations are not editable in this context` + return ctx.diagram.getPresentations().map((p) => ({ ...p, slides: [...p.slides] })) +} + +export function buildPresentationToolHandlers(): Record { + return { + create_presentation: (rawInput, ctx) => { + const input = rawInput as { name?: unknown; slides?: unknown } + const list = presentationsOf('create_presentation', ctx) + if (typeof list === 'string') return fail(list) + if (typeof input.name !== 'string' || !input.name.trim()) return fail('create_presentation: name is required') + const slides = input.slides === undefined ? [] : parseSlides('create_presentation', input.slides, ctx, []) + if (typeof slides === 'string') return fail(slides) + const id = randomId() + ctx.diagram.setPresentations!([...list, { id, name: input.name.trim(), slides }]) + return { ok: true, resultText: `Created presentation ${id} with ${slides.length} slide(s).` } + }, + + update_presentation: (rawInput, ctx) => { + const input = rawInput as { id?: unknown; name?: unknown; slides?: unknown } + const list = presentationsOf('update_presentation', ctx) + if (typeof list === 'string') return fail(list) + const presentation = list.find((p) => p.id === input.id) + if (!presentation) return fail(`update_presentation: unknown presentation id "${String(input.id)}"`) + if (input.name !== undefined && (typeof input.name !== 'string' || !input.name.trim())) return fail('update_presentation: name must be a non-empty string') + const slides = input.slides === undefined ? undefined : parseSlides('update_presentation', input.slides, ctx, presentation.slides) + if (typeof slides === 'string') return fail(slides) + if (input.name === undefined && !slides) return fail('update_presentation: nothing to change') + if (typeof input.name === 'string') presentation.name = input.name.trim() + if (slides) presentation.slides = slides + ctx.diagram.setPresentations!(list) + return { ok: true, resultText: `Updated presentation ${presentation.id}.` } + }, + + delete_presentation: (rawInput, ctx) => { + const input = rawInput as { id?: unknown } + const list = presentationsOf('delete_presentation', ctx) + if (typeof list === 'string') return fail(list) + if (!list.some((p) => p.id === input.id)) return fail(`delete_presentation: unknown presentation id "${String(input.id)}"`) + ctx.diagram.setPresentations!(list.filter((p) => p.id !== input.id)) + return { ok: true, resultText: `Deleted presentation ${String(input.id)}.` } + }, + } +} diff --git a/packages/common/src/ai/tools/sequenceTools.ts b/packages/common/src/ai/tools/sequenceTools.ts new file mode 100644 index 00000000..c31978ce --- /dev/null +++ b/packages/common/src/ai/tools/sequenceTools.ts @@ -0,0 +1,128 @@ +// ─── Sequence tools: create_sequence / update_sequence / delete_sequence ──── +// A sequence is an ordered list of existing relations (the steps of a flow, +// e.g. "Checkout"). A view of kind 'dynamic' plays one — see create_view. + +import { fail, type ToolDef, type ToolHandler, type ToolRunContext } from './types' + +const STEPS_SCHEMA = { + type: 'array', + description: 'Ordered steps. Each step is an existing relation (real id or tempId); the same relation may appear more than once.', + items: { + type: 'object', + properties: { + relationId: { type: 'string' }, + description: { type: 'string', description: 'What happens in this step, shown instead of the relation label.' }, + }, + required: ['relationId'], + additionalProperties: false, + }, +} + +export function buildSequenceToolDefs(): ToolDef[] { + return [ + { + name: 'create_sequence', + description: 'Create a named sequence (an ordered interaction flow) from existing relations. Show it with create_view kind "dynamic".', + inputSchema: { + type: 'object', + properties: { + tempId: { type: 'string' }, + name: { type: 'string' }, + steps: STEPS_SCHEMA, + }, + required: ['tempId', 'name'], + additionalProperties: false, + }, + }, + { + name: 'update_sequence', + description: "Rename a sequence or replace its steps (the full ordered list).", + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + steps: STEPS_SCHEMA, + }, + required: ['id'], + additionalProperties: false, + }, + }, + { + name: 'delete_sequence', + description: 'Delete a sequence. Dynamic views that played it stay, unlinked.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }, + }, + ] +} + +type Steps = Array<{ relationId: string; description?: string }> + +/** Validated steps with real relation ids, or the reason they are invalid. */ +function parseSteps(tool: string, raw: unknown, ctx: ToolRunContext): { steps: Steps } | { error: string } { + if (!Array.isArray(raw)) return { error: `${tool}: steps must be an array` } + const relations = ctx.diagram.getRelations() + const steps: Steps = [] + for (const [i, step] of raw.entries()) { + const s = step as { relationId?: unknown; description?: unknown } + if (typeof s?.relationId !== 'string' || !s.relationId) return { error: `${tool}: step ${i + 1} needs a relationId` } + const relationId = ctx.resolveId(s.relationId) + if (!(relationId in relations)) return { error: `${tool}: step ${i + 1} has unknown relationId "${s.relationId}"` } + steps.push({ relationId, ...(typeof s.description === 'string' && s.description ? { description: s.description } : {}) }) + } + return { steps } +} + +export function buildSequenceToolHandlers(): Record { + return { + create_sequence: (rawInput, ctx) => { + const input = rawInput as { tempId?: unknown; name?: unknown; steps?: unknown } + if (typeof input.tempId !== 'string' || !input.tempId) return fail('create_sequence: tempId is required') + if (typeof input.name !== 'string' || !input.name.trim()) return fail('create_sequence: name is required') + if (!ctx.diagram.addSequence || !ctx.diagram.setSequenceSteps) return fail('create_sequence: sequences are not editable in this context') + const parsed = input.steps === undefined ? { steps: [] } : parseSteps('create_sequence', input.steps, ctx) + if ('error' in parsed) return fail(parsed.error) + const id = ctx.diagram.addSequence(input.name.trim()) + if (!id) return fail(`create_sequence "${input.name}" was rejected by the store`) + ctx.registerTempId(input.tempId, id) + if (parsed.steps.length) ctx.diagram.setSequenceSteps(id, parsed.steps) + return { ok: true, resultText: `Created sequence ${id} with ${parsed.steps.length} step(s).` } + }, + + update_sequence: (rawInput, ctx) => { + const input = rawInput as { id?: unknown; name?: unknown; steps?: unknown } + if (typeof input.id !== 'string' || !input.id) return fail('update_sequence: id is required') + if (!ctx.diagram.getSequences) return fail('update_sequence: sequences are not editable in this context') + const id = ctx.resolveId(input.id) + if (!(id in ctx.diagram.getSequences())) return fail(`update_sequence: unknown sequence id "${input.id}"`) + if (input.name !== undefined && (typeof input.name !== 'string' || !input.name.trim())) return fail('update_sequence: name must be a non-empty string') + const parsed = input.steps === undefined ? undefined : parseSteps('update_sequence', input.steps, ctx) + if (parsed && 'error' in parsed) return fail(parsed.error) + if (input.name === undefined && !parsed) return fail('update_sequence: nothing to change') + if (typeof input.name === 'string') { + if (!ctx.diagram.renameSequence) return fail('update_sequence: sequences cannot be renamed in this context') + ctx.diagram.renameSequence(id, input.name.trim()) + } + if (parsed) { + if (!ctx.diagram.setSequenceSteps) return fail('update_sequence: steps cannot be set in this context') + ctx.diagram.setSequenceSteps(id, parsed.steps) + } + return { ok: true, resultText: `Updated sequence ${id}.` } + }, + + delete_sequence: (rawInput, ctx) => { + const input = rawInput as { id?: unknown } + if (typeof input.id !== 'string' || !input.id) return fail('delete_sequence: id is required') + if (!ctx.diagram.getSequences || !ctx.diagram.removeSequence) return fail('delete_sequence: sequences are not editable in this context') + const id = ctx.resolveId(input.id) + if (!(id in ctx.diagram.getSequences())) return fail(`delete_sequence: unknown sequence id "${input.id}"`) + ctx.diagram.removeSequence(id) + return { ok: true, resultText: `Deleted sequence ${id}.` } + }, + } +} diff --git a/packages/common/src/ai/tools/types.ts b/packages/common/src/ai/tools/types.ts index 61c86e5e..77fa3c0d 100644 --- a/packages/common/src/ai/tools/types.ts +++ b/packages/common/src/ai/tools/types.ts @@ -22,8 +22,10 @@ export interface ToolRunContext { /** Clears the tempId map — call after reset_diagram, since prior tempIds * point at nodes that no longer exist. */ resetTempIds(): void - /** Simple grid placement for a newly created node; live layout reshuffles. */ - placeNext(): { x: number; y: number } + /** Placement for a newly created node under `parentId` (undefined = the + * root), relative to that parent. Studio's grid ignores the parent, since + * its live layout reshuffles; the MCP server places it inside. */ + placeNext(parentId?: string): { x: number; y: number } } export interface ToolResult { @@ -38,6 +40,9 @@ export interface ToolResult { export type ToolHandler = (input: unknown, ctx: ToolRunContext) => ToolResult +/** A tool that has to wait, e.g. for a layout run. */ +export type AsyncToolHandler = (input: unknown, ctx: ToolRunContext) => Promise + export function fail(resultText: string): ToolResult { return { ok: false, resultText } } diff --git a/packages/common/src/ai/tools/viewTools.ts b/packages/common/src/ai/tools/viewTools.ts index 39a89f74..2b92c0e2 100644 --- a/packages/common/src/ai/tools/viewTools.ts +++ b/packages/common/src/ai/tools/viewTools.ts @@ -1,11 +1,32 @@ -// ─── View tools: create_view / set_view_nodes / delete_view / set_active_view +// ─── View tools: create_view / update_view / set_view_nodes / delete_view / set_active_view import type { DiagramView } from '../../c4' -import { fail, type ToolDef, type ToolHandler } from './types' +import { fail, type ToolDef, type ToolHandler, type ToolRunContext } from './types' -// 'dynamic' is deliberately excluded: it needs a sequenceId, and there is no -// tool here to create one — exposing it would let the model build a broken view. -const VIEW_KIND_ENUM = ['static', 'table', 'treemap', 'matrix', 'wiki'] as const +// 'dynamic' needs a sequence (create_sequence) — the handlers refuse it without one. +const VIEW_KIND_ENUM = ['static', 'table', 'treemap', 'matrix', 'wiki', 'dynamic'] as const + +const KIND_DESCRIPTION = "'table' for governance record types (ADR/Fitness Function/Requirement), 'treemap' for hierarchy overviews, 'wiki' for docs, 'matrix' for relation grids, 'dynamic' to play a sequence (needs sequenceId), 'static' (default) otherwise." + +/** The real id of an existing sequence, or the reason there is none. */ +function sequenceFor(ctx: ToolRunContext, tool: string, raw: unknown): { id: string } | { error: string } { + if (typeof raw !== 'string' || !raw) return { error: `${tool}: a dynamic view needs a sequenceId` } + if (!ctx.diagram.getSequences || !ctx.diagram.setViewSequence) return { error: `${tool}: sequences are not editable in this context` } + const id = ctx.resolveId(raw) + if (!(id in ctx.diagram.getSequences())) return { error: `${tool}: unknown sequenceId "${raw}"` } + return { id } +} + +/** Step endpoints of a sequence — what a new dynamic view shows by default. */ +function sequenceEndpoints(ctx: ToolRunContext, sequenceId: string): string[] { + const relations = ctx.diagram.getRelations() + const ids = new Set() + for (const relationId of ctx.diagram.getSequences?.()[sequenceId]?.relationIds ?? []) { + const relation = relations[relationId] + if (relation) { ids.add(relation.sourceId); ids.add(relation.targetId) } + } + return [...ids] +} export function buildViewToolDefs(): ToolDef[] { return [ @@ -17,15 +38,12 @@ export function buildViewToolDefs(): ToolDef[] { properties: { tempId: { type: 'string' }, name: { type: 'string' }, - kind: { - type: 'string', - enum: [...VIEW_KIND_ENUM], - description: "'table' for governance record types (ADR/Fitness Function/Requirement), 'treemap' for hierarchy overviews, 'wiki' for docs, 'matrix' for relation grids, 'static' (default) otherwise.", - }, + kind: { type: 'string', enum: [...VIEW_KIND_ENUM], description: KIND_DESCRIPTION }, + sequenceId: { type: 'string', description: "For kind 'dynamic': the sequence (real id or tempId) the view plays." }, nodeIds: { type: 'array', items: { type: 'string' }, - description: 'Real node ids or tempIds from this run. Omit or leave empty to show every node.', + description: "Real node ids or tempIds from this run. Omit or leave empty to show every node (a dynamic view defaults to its sequence's nodes).", }, active: { type: 'boolean', description: 'Switch to this view immediately after creating it.' }, }, @@ -33,6 +51,26 @@ export function buildViewToolDefs(): ToolDef[] { additionalProperties: false, }, }, + { + name: 'update_view', + description: "Rename a view, change its kind or linked sequence, or set which relations it hides. Use set_view_nodes to change its nodes.", + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + kind: { type: 'string', enum: [...VIEW_KIND_ENUM], description: KIND_DESCRIPTION }, + sequenceId: { type: ['string', 'null'], description: 'Sequence a dynamic view plays (real id or tempId); null unlinks it.' }, + hiddenRelationIds: { + type: 'array', + items: { type: 'string' }, + description: 'The complete set of relations to hide in this view even though both endpoints are visible. [] shows them all.', + }, + }, + required: ['id'], + additionalProperties: false, + }, + }, { name: 'set_view_nodes', description: "Replace a view's visible-node set.", @@ -72,11 +110,14 @@ export function buildViewToolDefs(): ToolDef[] { export function buildViewToolHandlers(): Record { return { create_view: (rawInput, ctx) => { - const input = rawInput as { tempId?: unknown; name?: unknown; kind?: unknown; nodeIds?: unknown; active?: unknown } + const input = rawInput as { tempId?: unknown; name?: unknown; kind?: unknown; sequenceId?: unknown; nodeIds?: unknown; active?: unknown } if (typeof input.tempId !== 'string' || !input.tempId) return fail('create_view: tempId is required') if (typeof input.name !== 'string' || !input.name.trim()) return fail('create_view: name is required') if (!ctx.diagram.addView || !ctx.diagram.setViewNodes) return fail('create_view: views are not editable in this context') + const sequence = input.kind === 'dynamic' ? sequenceFor(ctx, 'create_view', input.sequenceId) : undefined + if (sequence && 'error' in sequence) return fail(sequence.error) + const realId = ctx.diagram.addView(input.name.trim()) if (!realId) return fail(`create_view "${input.name}" was rejected by the store`) ctx.registerTempId(input.tempId, realId) @@ -84,7 +125,10 @@ export function buildViewToolHandlers(): Record { if (Array.isArray(input.nodeIds) && input.nodeIds.length > 0) { const resolved = input.nodeIds.filter((x): x is string => typeof x === 'string').map((id) => ctx.resolveId(id)) ctx.diagram.setViewNodes(realId, resolved) + } else if (sequence) { + ctx.diagram.setViewNodes(realId, sequenceEndpoints(ctx, sequence.id)) } + if (sequence) ctx.diagram.setViewSequence!(realId, sequence.id) if (typeof input.kind === 'string' && input.kind !== 'static') { if (!ctx.diagram.setViewKind) return fail('create_view: view kind cannot be set in this context') ctx.diagram.setViewKind(realId, input.kind as DiagramView['kind']) @@ -94,6 +138,53 @@ export function buildViewToolHandlers(): Record { return { ok: true, resultText: `Created view ${realId}.`, added: { views: 1 } } }, + update_view: (rawInput, ctx) => { + const input = rawInput as { id?: unknown; name?: unknown; kind?: unknown; sequenceId?: unknown; hiddenRelationIds?: unknown } + if (typeof input.id !== 'string' || !input.id) return fail('update_view: id is required') + if (!ctx.diagram.getViews) return fail('update_view: views are not editable in this context') + const vId = ctx.resolveId(input.id) + const view = ctx.diagram.getViews()[vId] + if (!view) return fail(`update_view: unknown view id "${input.id}"`) + if (input.name !== undefined && (typeof input.name !== 'string' || !input.name.trim())) return fail('update_view: name must be a non-empty string') + if (input.kind !== undefined && !(VIEW_KIND_ENUM as readonly unknown[]).includes(input.kind)) return fail(`update_view: unknown kind "${String(input.kind)}"`) + if (input.hiddenRelationIds !== undefined && !Array.isArray(input.hiddenRelationIds)) return fail('update_view: hiddenRelationIds must be an array') + + const kind = (input.kind ?? view.kind ?? 'static') as DiagramView['kind'] + let sequenceId: string | null | undefined + if (input.sequenceId === null) sequenceId = null + else if (input.sequenceId !== undefined) { + const sequence = sequenceFor(ctx, 'update_view', input.sequenceId) + if ('error' in sequence) return fail(sequence.error) + sequenceId = sequence.id + } + if (kind === 'dynamic' && (sequenceId === null || (sequenceId === undefined && !view.sequenceId))) { + return fail('update_view: a dynamic view needs a sequenceId') + } + + const changes: Array<() => void> = [] + if (typeof input.name === 'string') { + if (!ctx.diagram.renameView) return fail('update_view: views cannot be renamed in this context') + const name = input.name.trim() + changes.push(() => ctx.diagram.renameView!(vId, name)) + } + if (input.kind !== undefined) { + if (!ctx.diagram.setViewKind) return fail('update_view: view kind cannot be set in this context') + changes.push(() => ctx.diagram.setViewKind!(vId, kind)) + } + if (sequenceId !== undefined) changes.push(() => ctx.diagram.setViewSequence!(vId, sequenceId)) + if (Array.isArray(input.hiddenRelationIds)) { + if (!ctx.diagram.setViewHiddenRelations) return fail('update_view: hidden relations cannot be set in this context') + const relations = ctx.diagram.getRelations() + const ids = input.hiddenRelationIds.filter((x): x is string => typeof x === 'string').map((id) => ctx.resolveId(id)) + const unknown = ids.filter((id) => !(id in relations)) + if (unknown.length) return fail(`update_view: unknown relation id(s) ${unknown.join(', ')}`) + changes.push(() => ctx.diagram.setViewHiddenRelations!(vId, ids)) + } + if (!changes.length) return fail('update_view: nothing to change') + for (const change of changes) change() + return { ok: true, resultText: `Updated view ${vId}.`, updated: { views: 1 } } + }, + set_view_nodes: (rawInput, ctx) => { const input = rawInput as { id?: unknown; nodeIds?: unknown } if (typeof input.id !== 'string' || !input.id) return fail('set_view_nodes: id is required') diff --git a/packages/common/src/model.ts b/packages/common/src/model.ts index 0126aaba..c0e4a264 100644 --- a/packages/common/src/model.ts +++ b/packages/common/src/model.ts @@ -10,7 +10,7 @@ // The mutating functions assume the matching check passed, and work on either // a plain object or an immer draft. -import type { C4Node, C4Relation, DiagramView } from './c4' +import type { C4Node, C4Relation, DiagramSequence, DiagramView } from './c4' import { builtInC4Metamodel, builtInDddC4Metamodel, @@ -28,6 +28,8 @@ export interface ModelState { views: Record activeViewId: string | null metamodel?: Metamodel + /** Optional so callers that only check node rules can omit it. */ + sequences?: Record } type NodeRecord = C4Node & Record @@ -43,6 +45,17 @@ export function documentMetamodel(stored: Metamodel | undefined): Metamodel { return stored } +const PRESET_IDS = new Set(['c4-builtin', 'c4-ddd-builtin', 'c4-ddd-governance-builtin']) + +/** A copy of a built-in preset under a custom id, or null when `mm` is + * already custom. Edit the copy: documentMetamodel swaps a preset id back + * to the current preset on load, which would drop the edit. */ +export function forkPresetMetamodel(mm: Metamodel): Metamodel | null { + if (!PRESET_IDS.has(mm.id)) return null + const copy = JSON.parse(JSON.stringify(mm)) as Metamodel + return { ...copy, id: `${mm.id.replace(/-builtin$/, '')}-custom`, name: `${mm.name} (custom)` } +} + /** Ids of every node below `id` in the containment tree. */ export function descendantIds(id: string, nodes: Record): string[] { const children = new Map() @@ -127,6 +140,55 @@ export function patchNode(state: ModelState, id: string, updates: Partial state.c4Nodes[id]).filter((n): n is C4Node => !!n) + if (nodes.length === 0) return 'Nothing to move: no such node.' + const oldParentId = nodes[0].parentId ?? null + if (!nodes.every((n) => (n.parentId ?? null) === oldParentId)) { + return 'Cannot move: selected nodes have different parents. Select siblings only.' + } + const newParent = newParentId ? state.c4Nodes[newParentId] : undefined + if (newParentId && !newParent) return 'Target parent no longer exists.' + const moved = new Set(nodes.flatMap((n) => [n.id, ...descendantIds(n.id, state.c4Nodes)])) + if (newParentId && moved.has(newParentId)) return 'Cannot move a node into itself or one of its descendants.' + for (const n of nodes) { + if (!isParentAllowed(state.metamodel, n.type, newParent?.type)) { + return `Cannot place ${typeLabel(state.metamodel, n.type)} "${n.label}" inside ${placementError(state.metamodel, n.type, newParent)}` + } + } + return null +} + +/** Moves the nodes under `newParentId` (null = the root), keeping each one + * where it is on the canvas: positions are relative to the parent, so they + * are rebased onto the new parent's absolute position. */ +export function reparentNodes(state: ModelState, ids: string[], newParentId: string | null): void { + const absOf = (id: string | null): { x: number; y: number } => { + let x = 0, y = 0 + let cur: C4Node | undefined = id ? state.c4Nodes[id] : undefined + const seen = new Set() + while (cur && !seen.has(cur.id)) { + seen.add(cur.id) + x += cur.x + y += cur.y + cur = cur.parentId ? state.c4Nodes[cur.parentId] : undefined + } + return { x, y } + } + const target = absOf(newParentId) + // Measure every node against the original tree before changing any parent. + const placed = ids.filter((id) => state.c4Nodes[id]).map((id) => ({ id, abs: absOf(id) })) + for (const { id, abs } of placed) { + const node = state.c4Nodes[id] + node.parentId = newParentId ?? undefined + node.x = abs.x - target.x + node.y = abs.y - target.y + } +} + /** Removes the node, its descendants, their relations and every view * reference to them. Returns the removed node ids. */ export function deleteNode(state: ModelState, id: string): Set { @@ -192,3 +254,66 @@ export function setViewKind(state: ModelState, viewId: string, kind: DiagramView const view = state.views[viewId] if (view) view.kind = kind } + +export function renameView(state: ModelState, viewId: string, name: string): void { + const view = state.views[viewId] + if (view) view.name = name +} + +/** Links a dynamic view to a sequence (null unlinks it). */ +export function setViewSequence(state: ModelState, viewId: string, sequenceId: string | null): void { + const view = state.views[viewId] + if (view) view.sequenceId = sequenceId ?? undefined +} + +/** Hides exactly these relations in the view; unknown ids are dropped. */ +export function setViewHiddenRelations(state: ModelState, viewId: string, relationIds: string[]): void { + const view = state.views[viewId] + if (view) view.hiddenRelationIds = [...new Set(relationIds.filter((id) => id in state.c4Relations))] +} + +// ── Sequences ──────────────────────────────────────────────────────────────── + +export interface SequenceStep { + relationId: string + description?: string +} + +export function insertSequence(state: ModelState, id: string, name: string): void { + state.sequences ??= {} + state.sequences[id] = { id, name, relationIds: [] } +} + +export function renameSequence(state: ModelState, id: string, name: string): void { + const sequence = state.sequences?.[id] + if (sequence) sequence.name = name +} + +/** Replaces the steps. A relation may appear more than once; the caller + * checks the relation ids exist. */ +export function setSequenceSteps(state: ModelState, id: string, steps: SequenceStep[]): void { + const sequence = state.sequences?.[id] + if (!sequence) return + sequence.relationIds = steps.map((step) => step.relationId) + // Like Studio's step list: descriptions run up to the last described step. + const last = steps.reduce((end, step, i) => (step.description ? i + 1 : end), 0) + sequence.stepDescriptions = steps.slice(0, last).map((step) => step.description || undefined) +} + +/** Removes the sequence and unlinks the views that showed it. */ +export function deleteSequence(state: ModelState, id: string): void { + if (state.sequences) delete state.sequences[id] + for (const view of Object.values(state.views)) { + if (view.sequenceId === id) view.sequenceId = undefined + } +} + +/** The nodes a dynamic view of the sequence shows: every step's endpoints. */ +export function sequenceNodeIds(state: ModelState, id: string): string[] { + const nodes = new Set() + for (const relationId of state.sequences?.[id]?.relationIds ?? []) { + const relation = state.c4Relations[relationId] + if (relation) { nodes.add(relation.sourceId); nodes.add(relation.targetId) } + } + return [...nodes] +} diff --git a/packages/common/tests/aiDocumentTools.test.ts b/packages/common/tests/aiDocumentTools.test.ts new file mode 100644 index 00000000..65cb07a8 --- /dev/null +++ b/packages/common/tests/aiDocumentTools.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' +import { buildToolDefs, hasTool, runTool } from '../src/ai/tools/index' +import type { ToolRunContext } from '../src/ai/tools/types' +import { createModelFacade, type ModelFacadeOptions } from '../src/ai/modelFacade' +import { builtInGovernanceMetamodel } from '../src/metamodel/index' +import { documentMetamodel } from '../src/model' +import type { DiagramData } from '../src/c4' + +function setup(options: ModelFacadeOptions = {}) { + const facade = createModelFacade({ nodes: [], relations: [], metamodel: builtInGovernanceMetamodel() }, options) + const temps = new Map() + const ctx: ToolRunContext = { + diagram: facade, + resolveId: (id) => temps.get(id) ?? id, + registerTempId: (t, r) => { temps.set(t, r) }, + resetTempIds: () => temps.clear(), + placeNext: () => ({ x: 0, y: 0 }), + } + return { facade, ctx, call: (name: string, input: unknown) => runTool(name, input, ctx) } +} + +describe('tool groups', () => { + it('leave out the excluded groups, keeping the rest in order', () => { + const all = buildToolDefs(builtInGovernanceMetamodel()).map((d) => d.name) + expect(all).toEqual(expect.arrayContaining(['smart_layout', 'create_presentation', 'upsert_node_type'])) + const forge = buildToolDefs(builtInGovernanceMetamodel(), { exclude: ['metamodel', 'presentation'] }).map((d) => d.name) + expect(forge).not.toContain('upsert_node_type') + expect(forge).not.toContain('create_presentation') + expect(forge).toContain('smart_layout') + expect(forge).toEqual(all.filter((name) => forge.includes(name))) + expect(hasTool('smart_layout')).toBe(true) + }) +}) + +describe('metamodel tools', () => { + it('copy a preset on the first edit, and the copy survives reloading', async () => { + const { facade, call } = setup() + const added = await call('upsert_node_type', { + id: 'risk', label: 'Risk', baseType: 'adr', tableTab: true, + properties: [{ key: 'severity', label: 'Severity', type: 'enum', options: ['low', 'high'] }], + }) + expect(added.ok, added.resultText).toBe(true) + expect(added.resultText).toContain('copied to the custom metamodel') + const mm = facade.getMetamodel!()! + expect(mm.id).toBe('c4-ddd-governance-custom') + expect(mm.nodeTypes.risk).toMatchObject({ label: 'Risk', builtin: false, tableTab: true }) + expect(mm.nodeTypes.risk.wizard).toBeUndefined() + // The next edit works on the custom copy and says nothing about copying. + const second = await call('upsert_node_type', { id: 'risk', color: '#ff0000' }) + expect(second.resultText).not.toContain('copied') + expect(documentMetamodel(facade.toDiagramData().metamodel).nodeTypes.risk.color).toBe('#ff0000') + expect(buildToolDefs(facade.getMetamodel!()).find((d) => d.name === 'add_node')!.inputSchema).toMatchObject({ + properties: { type: { enum: expect.arrayContaining(['risk']) } }, + }) + }) + + it('refuse bad input, built-in deletes and types still in use', async () => { + const { call } = setup() + expect((await call('upsert_node_type', { id: 'Bad Id', label: 'X' })).resultText).toContain('lowercase') + expect((await call('upsert_node_type', { id: 'x', label: 'X', allowedParents: ['nope'] })).resultText).toContain('unknown parent') + expect((await call('upsert_node_type', { id: 'x', label: 'X', properties: [{ key: 'k', label: 'K', type: 'enum' }] })).resultText).toContain('needs string options') + expect((await call('upsert_relation_type', { id: 'r', label: 'R', allowedPairs: [{ from: 'nope', to: 'adr' }] })).resultText).toContain('unknown source') + expect((await call('delete_node_type', { id: 'system' })).resultText).toContain('built in') + await call('upsert_node_type', { id: 'risk', label: 'Risk', allowedAtRoot: true }) + await call('add_node', { tempId: 'r', type: 'risk', label: 'Outage' }) + expect((await call('delete_node_type', { id: 'risk' })).resultText).toContain('still use') + }) + + it('delete a custom type and its parent and pair references', async () => { + const { facade, call } = setup() + await call('upsert_node_type', { id: 'risk', label: 'Risk', allowedAtRoot: true }) + await call('upsert_node_type', { id: 'control', label: 'Control', allowedParents: ['risk'] }) + await call('upsert_relation_type', { id: 'mitigates', label: 'Mitigates', allowedPairs: [{ from: 'adr', to: 'risk' }] }) + expect((await call('delete_node_type', { id: 'risk' })).ok).toBe(true) + const mm = facade.getMetamodel!()! + expect(mm.nodeTypes.control.allowedParents).toEqual([]) + expect(mm.relationTypes.mitigates.allowedPairs).toEqual([]) + expect((await call('delete_relation_type', { id: 'mitigates' })).ok).toBe(true) + }) +}) + +describe('presentation tools', () => { + it('create, update (keeping a slide) and delete presentations', async () => { + const { facade, call, ctx } = setup() + await call('add_node', { tempId: 's', type: 'system', label: 'Shop' }) + await call('create_view', { tempId: 'v', name: 'Context', nodeIds: ['s'] }) + const created = await call('create_presentation', { name: 'Pitch', slides: [{ name: 'All' }, { name: 'Context', viewId: 'v' }] }) + expect(created.ok, created.resultText).toBe(true) + const pitch = facade.getPresentations!()[0] + expect(pitch.slides).toEqual([ + expect.objectContaining({ name: 'All', viewId: null, viewport: { x: 0, y: 0, zoom: 0 } }), + expect.objectContaining({ name: 'Context', viewId: ctx.resolveId('v') }), + ]) + const kept = pitch.slides[1] + expect((await call('update_presentation', { id: pitch.id, name: 'Pitch v2', slides: [{ id: kept.id, name: 'Context!', viewId: 'v' }] })).ok).toBe(true) + expect(facade.toDiagramData().presentations).toEqual([expect.objectContaining({ name: 'Pitch v2', slides: [{ ...kept, name: 'Context!' }] })]) + expect((await call('update_presentation', { id: pitch.id, slides: [{ name: 'X', viewId: 'nope' }] })).resultText).toContain('unknown viewId') + expect((await call('delete_presentation', { id: pitch.id })).ok).toBe(true) + expect(facade.getPresentations!()).toEqual([]) + }) +}) + +describe('smart_layout', () => { + it('runs the injected layout and loads its result', async () => { + const seen: Array = [] + const { facade, call } = setup({ + runLayout: async (doc: DiagramData, viewId?: string) => { + seen.push(viewId) + return { ok: true, text: 'laid out', data: { ...doc, nodes: doc.nodes.map((n) => ({ ...n, x: 999 })) } } + }, + }) + await call('add_node', { tempId: 's', type: 'system', label: 'Shop' }) + await call('create_view', { tempId: 'v', name: 'Context', nodeIds: ['s'] }) + await call('create_view', { tempId: 't', name: 'Records', kind: 'table' }) + expect(await call('smart_layout', {})).toMatchObject({ ok: true, resultText: 'laid out' }) + expect(Object.values(facade.getNodes())[0].x).toBe(999) + await call('smart_layout', { viewId: 'v' }) + expect(seen).toEqual([undefined, Object.keys(facade.getViews!()).find((id) => facade.getViews!()[id].name === 'Context')]) + expect((await call('smart_layout', { viewId: 't' })).resultText).toContain('table view') + }) + + it('reports when the context cannot lay out', async () => { + const { call } = setup() + expect((await call('smart_layout', {})).resultText).toContain('not available') + }) +}) diff --git a/packages/common/tests/aiMetamodelContext.test.ts b/packages/common/tests/aiMetamodelContext.test.ts new file mode 100644 index 00000000..b958026b --- /dev/null +++ b/packages/common/tests/aiMetamodelContext.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { buildMetamodelMessage } from '../src/ai/metamodelContext' +import type { C4Node } from '../src/c4' +import type { Metamodel } from '../src/metamodel' + +const node = (over: Partial): C4Node => ({ + id: 'n', type: 'system', label: 'L', collapsed: false, + x: 0, y: 0, width: 100, height: 100, ...over, +}) + +describe('buildMetamodelMessage', () => { + it("includes each type's properties, so the model knows valid property-bag keys", () => { + const mm: Metamodel = { + id: 'm', + name: 'M', + nodeTypes: { + requirement: { + id: 'requirement', label: 'Requirement', color: '', fg: '', iconPath: '', width: 0, height: 0, + properties: [{ key: 'ears_type', label: 'EARS type', type: 'enum', options: ['ubiquitous'] }], + }, + }, + relationTypes: { + derives: { + id: 'derives', label: 'Derives from', + allowedPairs: [{ from: 'requirement', to: 'requirement' }], + properties: [], + }, + }, + } + const msg = buildMetamodelMessage(mm) + expect(msg).toContain('ears_type') + expect(msg).toContain('ubiquitous') + expect(msg).toContain('derives') + }) + + it('abbreviates properties for types outside relevantTypeIds and not yet in use, but keeps full detail for relevant or in-use types', () => { + const mm: Metamodel = { + id: 'm', + name: 'M', + nodeTypes: { + requirement: { + id: 'requirement', label: 'Requirement', color: '', fg: '', iconPath: '', width: 0, height: 0, + properties: [{ key: 'ears_type', label: 'EARS type', type: 'enum', options: ['ubiquitous'] }], + }, + system: { + id: 'system', label: 'System', color: '', fg: '', iconPath: '', width: 0, height: 0, + properties: [{ key: 'tech', label: 'Tech', type: 'text' }], + }, + container: { + id: 'container', label: 'Container', color: '', fg: '', iconPath: '', width: 0, height: 0, + properties: [{ key: 'stack', label: 'Stack', type: 'text' }], + }, + }, + relationTypes: {}, + } + const nodes: Record = { s1: node({ id: 's1', type: 'system' }) } + const msg = buildMetamodelMessage(mm, new Set(['requirement']), nodes) + // relevant (requirement) and in-use (system) keep full property detail + expect(msg).toContain('ears_type') + expect(msg).toContain('"tech"') + // irrelevant and unused (container) loses its properties array + expect(msg).not.toContain('"stack"') + }) +}) diff --git a/packages/common/tests/aiSequenceTools.test.ts b/packages/common/tests/aiSequenceTools.test.ts new file mode 100644 index 00000000..0781c51a --- /dev/null +++ b/packages/common/tests/aiSequenceTools.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { buildToolDefs, buildToolHandlers } from '../src/ai/tools/index' +import type { ToolRunContext } from '../src/ai/tools/types' +import { createModelFacade } from '../src/ai/modelFacade' +import { runModelQuery } from '../src/ai/queryLanguage' +import { builtInC4Metamodel } from '../src/metamodel/index' + +function setup() { + const facade = createModelFacade({ nodes: [], relations: [], metamodel: builtInC4Metamodel() }) + const temps = new Map() + const ctx: ToolRunContext = { + diagram: facade, + resolveId: (id) => temps.get(id) ?? id, + registerTempId: (t, r) => { temps.set(t, r) }, + resetTempIds: () => temps.clear(), + placeNext: () => ({ x: 0, y: 0 }), + } + const handlers = buildToolHandlers() + const call = (name: string, input: unknown) => handlers.get(name)!(input, ctx) + call('add_node', { tempId: 'user', type: 'person', label: 'User' }) + call('add_node', { tempId: 'shop', type: 'system', label: 'Shop' }) + call('add_node', { tempId: 'bank', type: 'system', label: 'Bank' }) + call('add_relation', { sourceId: 'user', targetId: 'shop', label: 'Orders' }) + call('add_relation', { sourceId: 'shop', targetId: 'bank', label: 'Charges' }) + const rel = (label: string) => Object.values(facade.getRelations()).find((r) => r.label === label)!.id + return { facade, ctx, call, rel } +} + +describe('sequence tools', () => { + it('are in the catalogue', () => { + const names = buildToolDefs(builtInC4Metamodel()).map((d) => d.name) + expect(names).toEqual(expect.arrayContaining(['create_sequence', 'update_sequence', 'delete_sequence', 'update_view'])) + }) + + it('create a sequence, play it in a dynamic view, and persist both', () => { + const { facade, call, ctx, rel } = setup() + const created = call('create_sequence', { + tempId: 'checkout', name: 'Checkout', + steps: [{ relationId: rel('Orders'), description: 'User places an order' }, { relationId: rel('Charges') }], + }) + expect(created.ok, created.resultText).toBe(true) + const view = call('create_view', { tempId: 'v', name: 'Checkout flow', kind: 'dynamic', sequenceId: 'checkout' }) + expect(view.ok, view.resultText).toBe(true) + + const data = facade.toDiagramData() + expect(data.sequences).toEqual([expect.objectContaining({ + name: 'Checkout', relationIds: [rel('Orders'), rel('Charges')], + // Descriptions run up to the last described step, like Studio's step list. + stepDescriptions: ['User places an order'], + })]) + const dyn = data.views!.find((v) => v.name === 'Checkout flow')! + expect(dyn).toMatchObject({ kind: 'dynamic', sequenceId: ctx.resolveId('checkout') }) + // Defaults to the sequence's endpoints. + expect(new Set(dyn.nodeIds)).toEqual(new Set(['user', 'shop', 'bank'].map((t) => ctx.resolveId(t)))) + + const listed = runModelQuery('LIST SEQUENCES', { nodes: facade.getNodes(), relations: facade.getRelations(), sequences: facade.getSequences!() }) + expect(listed.result).toEqual({ total: 1, rows: [expect.objectContaining({ name: 'Checkout', stepCount: 2 })] }) + const got = runModelQuery(`GET SEQUENCE ${ctx.resolveId('checkout')}`, { + nodes: facade.getNodes(), relations: facade.getRelations(), views: facade.getViews!(), sequences: facade.getSequences!(), + }).result as { steps: Array<{ step: number; stepDescription?: string }>; views: unknown[] } + expect(got.steps[0]).toMatchObject({ step: 1, stepDescription: 'User places an order' }) + expect(got.views).toHaveLength(1) + }) + + it('refuses a dynamic view without a sequence and steps with unknown relations', () => { + const { call } = setup() + expect(call('create_view', { tempId: 'v', name: 'X', kind: 'dynamic' }).resultText).toContain('needs a sequenceId') + expect(call('create_sequence', { tempId: 's', name: 'S', steps: [{ relationId: 'nope' }] }).resultText).toContain('unknown relationId') + }) + + it('update_sequence replaces steps; delete_sequence unlinks its views', () => { + const { facade, call, ctx, rel } = setup() + call('create_sequence', { tempId: 's', name: 'S', steps: [{ relationId: rel('Orders') }] }) + call('create_view', { tempId: 'v', name: 'V', kind: 'dynamic', sequenceId: 's' }) + expect(call('update_sequence', { id: 's', name: 'Renamed', steps: [{ relationId: rel('Charges') }, { relationId: rel('Charges') }] }).ok).toBe(true) + expect(facade.getSequences!()[ctx.resolveId('s')]).toMatchObject({ name: 'Renamed', relationIds: [rel('Charges'), rel('Charges')] }) + expect(call('delete_sequence', { id: 's' }).ok).toBe(true) + expect(facade.getViews!()[ctx.resolveId('v')].sequenceId).toBeUndefined() + }) +}) + +describe('update_view', () => { + it('renames, changes kind and sets hidden relations', () => { + const { facade, call, ctx, rel } = setup() + call('create_view', { tempId: 'v', name: 'Context' }) + const result = call('update_view', { id: 'v', name: 'System context', kind: 'matrix', hiddenRelationIds: [rel('Charges')] }) + expect(result.ok, result.resultText).toBe(true) + expect(facade.getViews!()[ctx.resolveId('v')]).toMatchObject({ name: 'System context', kind: 'matrix', hiddenRelationIds: [rel('Charges')] }) + expect(call('update_view', { id: 'v', hiddenRelationIds: [] }).ok).toBe(true) + expect(facade.getViews!()[ctx.resolveId('v')].hiddenRelationIds).toEqual([]) + }) + + it('refuses switching to dynamic without a sequence, and unknown relations', () => { + const { call } = setup() + call('create_view', { tempId: 'v', name: 'Context' }) + expect(call('update_view', { id: 'v', kind: 'dynamic' }).resultText).toContain('needs a sequenceId') + expect(call('update_view', { id: 'v', hiddenRelationIds: ['nope'] }).resultText).toContain('unknown relation') + expect(call('update_view', { id: 'v' }).resultText).toContain('nothing to change') + }) +}) diff --git a/packages/common/tests/aiToolCatalogue.test.ts b/packages/common/tests/aiToolCatalogue.test.ts index c2cbd6d9..547987da 100644 --- a/packages/common/tests/aiToolCatalogue.test.ts +++ b/packages/common/tests/aiToolCatalogue.test.ts @@ -4,6 +4,7 @@ import type { ToolRunContext } from '../src/ai/tools/types' import type { DiagramFacade } from '../src/ai/diagramFacade' import type { C4Node, C4Relation, DiagramView } from '../src/c4' import type { Metamodel } from '../src/metamodel/index' +import { createModelFacade } from '../src/ai/modelFacade' function makeFacade(mm?: Metamodel): DiagramFacade & { _nodes: Record @@ -77,7 +78,7 @@ describe('buildToolDefs', () => { const defs = buildToolDefs(REQUIREMENT_MM) const names = defs.map((d) => d.name) expect(names).toEqual(expect.arrayContaining([ - 'add_node', 'update_node', 'delete_node', + 'add_node', 'update_node', 'move_node', 'delete_node', 'add_relation', 'update_relation', 'delete_relation', 'create_view', 'set_view_nodes', 'delete_view', 'set_active_view', 'search_model', 'focus_node', 'reset_diagram', @@ -141,6 +142,39 @@ describe('node tools', () => { expect(Object.keys(facade._nodes)).toHaveLength(0) }) + it('move_node resolves tempIds, moves to the root with null, and reports refusals', () => { + const mm: Metamodel = { + id: 'test', + name: 'Test', + nodeTypes: { + system: { id: 'system', label: 'System', color: '', fg: '', iconPath: '', width: 0, height: 0, allowedAtRoot: true, allowedParents: ['group'] }, + group: { id: 'group', label: 'Group', color: '', fg: '', iconPath: '', width: 0, height: 0, allowedAtRoot: true }, + container: { id: 'container', label: 'Container', color: '', fg: '', iconPath: '', width: 0, height: 0, allowedParents: ['system'] }, + }, + relationTypes: {}, + } + const facade = createModelFacade({ nodes: [], relations: [], metamodel: mm }) + const ctx = makeCtx(facade) + const handlers = buildToolHandlers() + handlers.get('add_node')!({ tempId: 'grp', type: 'group', label: 'Group' }, ctx) + handlers.get('add_node')!({ tempId: 'sys', type: 'system', label: 'Sys' }, ctx) + handlers.get('add_node')!({ tempId: 'api', type: 'container', label: 'API', parentId: 'sys' }, ctx) + + const moved = handlers.get('move_node')!({ id: 'sys', parentId: 'grp' }, ctx) + expect(moved.ok, moved.resultText).toBe(true) + expect(facade.getNodes()[ctx.resolveId('sys')].parentId).toBe(ctx.resolveId('grp')) + + const toRoot = handlers.get('move_node')!({ id: 'sys', parentId: null }, ctx) + expect(toRoot.ok).toBe(true) + expect(facade.getNodes()[ctx.resolveId('sys')].parentId).toBeUndefined() + + const forbidden = handlers.get('move_node')!({ id: 'api', parentId: 'grp' }, ctx) + expect(forbidden.ok).toBe(false) + expect(forbidden.resultText).toContain('Allowed parents: System') + expect(handlers.get('move_node')!({ id: 'sys', parentId: 'api' }, ctx).ok).toBe(false) + expect(facade.getNodes()[ctx.resolveId('api')].parentId).toBe(ctx.resolveId('sys')) + }) + it('add_node fails cleanly when the store rejects it (empty id)', () => { const facade = makeFacade(REQUIREMENT_MM) facade.addNode = () => '' diff --git a/packages/common/tests/modelFacade.test.ts b/packages/common/tests/modelFacade.test.ts index 76f6d4ae..a3d46711 100644 --- a/packages/common/tests/modelFacade.test.ts +++ b/packages/common/tests/modelFacade.test.ts @@ -61,6 +61,29 @@ describe('createModelFacade', () => { expect(f.getNodes()[api].parentId).toBe(sys) }) + it('moves nodes into another parent, keeping their canvas position', () => { + const f = facade() + const a = f.addNode(node('system', 'A', { x: 100, y: 50 })) + const b = f.addNode(node('system', 'B', { x: 600, y: 0 })) + const api = f.addNode(node('container', 'API', { parentId: a, x: 20, y: 130 })) + f.moveNodes!([api], b) + expect(f.lastError).toBeNull() + // Absolute (120, 180) stays put, now relative to B at (600, 0). + expect(f.getNodes()[api]).toMatchObject({ parentId: b, x: -480, y: 180 }) + }) + + it('refuses moves into a descendant or a forbidden parent, leaving the node unchanged', () => { + const f = facade() + const a = f.addNode(node('system', 'A')) + const api = f.addNode(node('container', 'API', { parentId: a })) + f.moveNodes!([a], api) + expect(f.lastError).toBe('Cannot move a node into itself or one of its descendants.') + f.moveNodes!([api], null) + expect(f.lastError).toBe('Cannot place Container "API" inside the canvas root. Allowed parents: System.') + expect(f.getNodes()[api].parentId).toBe(a) + expect(f.getNodes()[a].parentId).toBeUndefined() + }) + it('infers the relation type and refuses pairs the metamodel does not allow', () => { const f = facade() const a = f.addNode(node('system', 'A')) diff --git a/packages/layout/package.json b/packages/layout/package.json index 72367994..ff3b27a0 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -12,7 +12,8 @@ "./geometry": "./src/geometry.ts", "./portAllocator": "./src/portAllocator.ts", "./edgeRouting": "./src/edgeRouting.ts", - "./side": "./src/side.ts" + "./side": "./src/side.ts", + "./viewInput": "./src/viewInput.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/layout/src/geometry.ts b/packages/layout/src/geometry.ts index 325fbf83..42f7378b 100644 --- a/packages/layout/src/geometry.ts +++ b/packages/layout/src/geometry.ts @@ -40,6 +40,60 @@ export function compoundPadding(type: string): { top: number; side: number; bott return { top: 120, side: margin, bottom: margin } } +/** + * The size a compound node takes to wrap its children (boxes in the + * parent's own coordinates, at their effective size), never smaller than + * its type's default size. The store's fitParentToChildren measures the + * children for the active view; fitAncestors measures them headlessly. + */ +export function fittedParentSize( + parent: C4Node, + children: Array<{ x: number; y: number; width: number; height: number }>, +): { width: number; height: number } { + const pad = compoundPadding(parent.type) + let maxRight = 0 + let maxBottom = 0 + for (const child of children) { + maxRight = Math.max(maxRight, child.x + child.width) + maxBottom = Math.max(maxBottom, child.y + child.height) + } + return { + width: Math.max(maxRight + pad.side, NODE_SIZES[parent.type].width), + height: Math.max(maxBottom + pad.bottom, NODE_SIZES[parent.type].height), + } +} + +/** Refits `startId` (when it is a compound node) and then every ancestor + * above it to wrap their children. Mutates `nodes`; collapsed and + * childless nodes keep their size. For callers without a canvas. */ +export function fitAncestors(nodes: Record, startId: string | undefined): void { + const seen = new Set() + let cur = startId ? nodes[startId] : undefined + while (cur && !seen.has(cur.id)) { + seen.add(cur.id) + const parentId = cur.id + const children = Object.values(nodes).filter((n) => n.parentId === parentId) + if (isContainerType(cur.type) && !cur.collapsed && children.length > 0) { + const size = fittedParentSize(cur, children.map((c) => ({ x: c.x, y: c.y, width: effectiveWidth(c), height: effectiveHeight(c) }))) + cur.width = size.width + cur.height = size.height + } + cur = cur.parentId ? nodes[cur.parentId] : undefined + } +} + +/** Where a new node goes before any layout runs: inside its parent below + * the header, right of the last sibling; at the root, right of every root + * node. Positions are relative to the parent, like C4Node.x/y. */ +export function placeNewNode(nodes: Record, parentId: string | undefined): { x: number; y: number } { + const parent = parentId ? nodes[parentId] : undefined + const siblings = Object.values(nodes).filter((n) => (n.parentId ?? undefined) === (parent?.id ?? undefined)) + const right = Math.max(...siblings.map((n) => n.x + effectiveWidth(n))) + if (!parent) return { x: siblings.length ? right + 80 : 0, y: 0 } + const pad = compoundPadding(parent.type) + return { x: siblings.length ? right + 20 : pad.side, y: pad.top } +} + /** * The graph as the canvas draws it: nodes hidden under a collapsed ancestor * are dropped, collapsed containers take their collapsed size, and every diff --git a/packages/layout/src/viewInput.ts b/packages/layout/src/viewInput.ts new file mode 100644 index 00000000..8bf20539 --- /dev/null +++ b/packages/layout/src/viewInput.ts @@ -0,0 +1,184 @@ +// ─── View layout input ────────────────────────────────────────────────────── +// What a layout run sees for one view — the node filter, view-level collapse +// rules and hidden relations the canvas applies when it renders — and how +// its result is written back. Shared by Studio's store and the MCP server. + +import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, isContainerType, type C4Node, type C4Relation, type DiagramView, type PositionMap } from '@radical/common/c4' +import { fittedParentSize } from './geometry' + +/** Compute the effective set of node IDs for a view: explicit nodeIds + all their ancestors */ +export function computeViewNodeSet(view: DiagramView | undefined, nodes: Record): Set | undefined { + if (!view) return undefined + if (view.nodeIds.length === 0) return undefined + const result = new Set() + for (const id of view.nodeIds) { + let cur = id + while (cur && nodes[cur]) { + result.add(cur) + cur = nodes[cur].parentId ?? '' + } + } + return result +} + +/** + * Compute which nodes should be treated as collapsed in a view. + * A parent (system/container) is view-collapsed if: + * - it has children in the full model, AND + * - none of those children are in the view filter, AND + * - it is not already collapsed on the model. + * Returns empty set when no view filter is active. + */ +export function computeViewCollapsedSet( + viewFilter: Set | undefined, + allNodes: Record +): Set { + const result = new Set() + if (!viewFilter) return result + + // Which parents have at least one child in the view? + const parentHasViewChild = new Set() + for (const n of Object.values(allNodes)) { + if (n.parentId && viewFilter.has(n.id)) parentHasViewChild.add(n.parentId) + } + + for (const [id, n] of Object.entries(allNodes)) { + if (!viewFilter.has(id)) continue + if (!isContainerType(n.type)) continue + if (n.collapsed) continue // already collapsed on the model + if (parentHasViewChild.has(id)) continue // has visible children + + // Check it actually has children in the full model + const hasChildInModel = Object.values(allNodes).some(c => c.parentId === id) + if (hasChildInModel) result.add(id) + } + return result +} + +/** Is the node effectively collapsed (model-collapsed OR view-collapsed)? + * Pass `expandedSet` (from `view.expandedNodeIds`) to allow a named view to + * override a model-level collapse. */ +export function isEffectivelyCollapsed( + node: C4Node, + viewCollapsedSet?: Set, + expandedSet?: Set +): boolean { + if (expandedSet?.has(node.id)) return false // view-level explicit expansion + return node.collapsed || (viewCollapsedSet?.has(node.id) ?? false) +} + +/** Return the subset of nodes/relations visible in the active view (or all if no view). */ +export function filterForView( + allNodes: Record, + allRelations: Record, + viewFilter: Set | undefined, + viewCollapsedSet?: Set +): { nodes: Record; relations: Record } { + if (!viewFilter) return { nodes: allNodes, relations: allRelations } + const nodes: Record = {} + for (const [id, n] of Object.entries(allNodes)) { + if (viewFilter.has(id)) { + nodes[id] = viewCollapsedSet?.has(id) ? { ...n, collapsed: true } : n + } + } + + const relations: Record = {} + for (const [id, r] of Object.entries(allRelations)) { + if (viewFilter.has(r.sourceId) && viewFilter.has(r.targetId)) relations[id] = r + } + return { nodes, relations } +} + +export interface LayoutInput { + nodes: Record + relations: Record + viewFilter: Set | undefined + viewCollapsedSet: Set + expandedSet: Set | undefined +} + +/** + * The graph a layout algorithm should see for `view` (undefined = All + * elements): the same + * node filter, collapse rules (view-collapsed, per-view collapsed and + * expanded overrides) and hidden relations the canvas applies when it + * renders. `collapsed` on the returned nodes is the effective state. + */ +export function viewLayoutInput( + view: DiagramView | undefined, + allNodes: Record, + allRelations: Record, +): LayoutInput { + const viewFilter = computeViewNodeSet(view, allNodes) + const viewCollapsedSet = computeViewCollapsedSet(viewFilter, allNodes) + for (const id of view?.collapsedNodeIds ?? []) viewCollapsedSet.add(id) + const expandedSet = view?.expandedNodeIds?.length ? new Set(view.expandedNodeIds) : undefined + + const filtered = filterForView(allNodes, allRelations, viewFilter) + const nodes: Record = {} + for (const [id, n] of Object.entries(filtered.nodes)) { + const collapsed = isEffectivelyCollapsed(n, viewCollapsedSet, expandedSet) + nodes[id] = collapsed === n.collapsed ? n : { ...n, collapsed } + } + const hidden = new Set(view?.hiddenRelationIds ?? []) + const relations: Record = {} + for (const [id, r] of Object.entries(filtered.relations)) { + if (!hidden.has(id)) relations[id] = r + } + return { nodes, relations, viewFilter, viewCollapsedSet, expandedSet } +} + +/** + * Write a layout result into the model. Collapsed nodes keep their stored + * size: the canvas draws them at the collapsed size anyway, and the stored + * one is what they expand back to. + */ +export function applyLayoutPositions( + c4Nodes: Record, + positions: PositionMap, + input: LayoutInput, +): void { + for (const [id, pos] of Object.entries(positions)) { + const node = c4Nodes[id] + if (!node) continue + node.x = pos.x + node.y = pos.y + if (input.nodes[id]?.collapsed) continue + if (pos.width) node.width = pos.width + if (pos.height) node.height = pos.height + } +} + +/** + * Refits every compound node that has children in the view, deepest first, + * to wrap those children at their effective (view-collapsed) size — what + * Studio's store does after a layout run. Mutates `nodes`. + */ +export function resizeParentsBottomUp( + nodes: Record, + input: Pick, +): void { + const { viewFilter, viewCollapsedSet, expandedSet } = input + const inView = (n: C4Node): boolean => !viewFilter || viewFilter.has(n.id) + const size = (n: C4Node): { width: number; height: number } => ( + isContainerType(n.type) && isEffectivelyCollapsed(n, viewCollapsedSet, expandedSet) + ? { width: COLLAPSED_WIDTH[n.type], height: COLLAPSED_HEIGHT[n.type] } + : { width: n.width, height: n.height } + ) + const depth = (id: string): number => { + let d = 0 + for (let cur = nodes[id]?.parentId; cur && d < 64; cur = nodes[cur]?.parentId) d++ + return d + } + const parentIds = [...new Set(Object.values(nodes).filter((n) => inView(n) && n.parentId).map((n) => n.parentId!))] + parentIds.sort((a, b) => depth(b) - depth(a)) + for (const id of parentIds) { + const parent = nodes[id] + if (!parent || !isContainerType(parent.type) || isEffectivelyCollapsed(parent, viewCollapsedSet, expandedSet)) continue + const children = Object.values(nodes).filter((c) => c.parentId === id && inView(c)) + if (!children.length) continue + const fitted = fittedParentSize(parent, children.map((c) => ({ x: c.x, y: c.y, ...size(c) }))) + parent.width = fitted.width + parent.height = fitted.height + } +} diff --git a/packages/layout/tests/geometry.test.ts b/packages/layout/tests/geometry.test.ts new file mode 100644 index 00000000..e4bd7a83 --- /dev/null +++ b/packages/layout/tests/geometry.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { fitAncestors, fittedParentSize, placeNewNode } from '../src/geometry' +import { NODE_SIZES, type C4Node } from '@radical/common/c4' + +const node = (id: string, type: string, extra: Partial = {}): C4Node => + ({ id, type, label: id, collapsed: false, x: 0, y: 0, ...NODE_SIZES[type as keyof typeof NODE_SIZES], ...extra }) as C4Node + +describe('fittedParentSize', () => { + it('wraps the children plus padding, never below the type default', () => { + const sys = node('sys', 'system') + expect(fittedParentSize(sys, [{ x: 30, y: 120, width: 900, height: 600 }])).toEqual({ width: 960, height: 750 }) + expect(fittedParentSize(sys, [{ x: 0, y: 0, width: 10, height: 10 }])).toEqual(NODE_SIZES.system) + }) +}) + +describe('fitAncestors', () => { + it('refits the parent and every ancestor above it, leaving collapsed ones alone', () => { + const nodes: Record = { + dom: node('dom', 'domain'), + sys: node('sys', 'system', { parentId: 'dom', x: 30, y: 120 }), + api: node('api', 'container', { parentId: 'sys', x: 30, y: 120, width: 1000, height: 400 }), + shut: node('shut', 'system', { collapsed: true, width: 10, height: 10 }), + kid: node('kid', 'container', { parentId: 'shut', x: 5000, y: 5000 }), + } + fitAncestors(nodes, 'sys') + expect(nodes.sys).toMatchObject({ width: 1060, height: 550 }) + expect(nodes.dom).toMatchObject({ width: 1120, height: 700 }) + fitAncestors(nodes, 'shut') + expect(nodes.shut).toMatchObject({ width: 10, height: 10 }) + }) +}) + +describe('placeNewNode', () => { + it('places a first child below the header, later ones right of their siblings', () => { + const nodes: Record = { sys: node('sys', 'system', { x: 500, y: 500 }) } + expect(placeNewNode(nodes, 'sys')).toEqual({ x: 30, y: 120 }) + nodes.api = node('api', 'container', { parentId: 'sys', x: 30, y: 120 }) + expect(placeNewNode(nodes, 'sys')).toEqual({ x: 30 + NODE_SIZES.container.width + 20, y: 120 }) + }) + + it('places a root node right of the root nodes only', () => { + const nodes: Record = { + sys: node('sys', 'system', { x: 100 }), + api: node('api', 'container', { parentId: 'sys', x: 9000 }), + } + expect(placeNewNode({}, undefined)).toEqual({ x: 0, y: 0 }) + expect(placeNewNode(nodes, undefined)).toEqual({ x: 100 + NODE_SIZES.system.width + 80, y: 0 }) + }) +}) diff --git a/packages/ui/src/store/diagramStore.ts b/packages/ui/src/store/diagramStore.ts index 2ac7961a..e053e057 100644 --- a/packages/ui/src/store/diagramStore.ts +++ b/packages/ui/src/store/diagramStore.ts @@ -51,7 +51,18 @@ import { applyTreeLayout } from '@radical/layout/elkLayout' import { applyRadicalLayout } from '@radical/layout/radicalLayout' import { runSmartLayout, type SmartLayoutProgress } from '../layout/smartLayoutRunner' import { minimizeCrossings } from '@radical/layout/crossingOpt' -import { compoundPadding } from '@radical/layout/geometry' +import { fittedParentSize } from '@radical/layout/geometry' +import { + applyLayoutPositions, + computeViewCollapsedSet, + computeViewNodeSet, + filterForView, + isEffectivelyCollapsed, + viewLayoutInput, + type LayoutInput, +} from '@radical/layout/viewInput' +// Re-exported for components that read view collapse state from the store module. +export { computeViewCollapsedSet, isEffectivelyCollapsed } import { LiveColaLayout } from '../layout/liveColaLayout' import { documentBackend } from './documentBackend' import { isViewerProfile } from '../runtime' @@ -262,67 +273,6 @@ function computeDiffGhosts( return { nodes, relations } } -/** Compute the effective set of node IDs for a view: explicit nodeIds + all their ancestors */ -function computeViewNodeSet(view: DiagramView | undefined, nodes: Record): Set | undefined { - if (!view) return undefined - if (view.nodeIds.length === 0) return undefined - const result = new Set() - for (const id of view.nodeIds) { - let cur = id - while (cur && nodes[cur]) { - result.add(cur) - cur = nodes[cur].parentId ?? '' - } - } - return result -} - -/** - * Compute which nodes should be treated as collapsed in a view. - * A parent (system/container) is view-collapsed if: - * - it has children in the full model, AND - * - none of those children are in the view filter, AND - * - it is not already collapsed on the model. - * Returns empty set when no view filter is active. - */ -export function computeViewCollapsedSet( - viewFilter: Set | undefined, - allNodes: Record -): Set { - const result = new Set() - if (!viewFilter) return result - - // Which parents have at least one child in the view? - const parentHasViewChild = new Set() - for (const n of Object.values(allNodes)) { - if (n.parentId && viewFilter.has(n.id)) parentHasViewChild.add(n.parentId) - } - - for (const [id, n] of Object.entries(allNodes)) { - if (!viewFilter.has(id)) continue - if (!isContainerType(n.type)) continue - if (n.collapsed) continue // already collapsed on the model - if (parentHasViewChild.has(id)) continue // has visible children - - // Check it actually has children in the full model - const hasChildInModel = Object.values(allNodes).some(c => c.parentId === id) - if (hasChildInModel) result.add(id) - } - return result -} - -/** Is the node effectively collapsed (model-collapsed OR view-collapsed)? - * Pass `expandedSet` (from `view.expandedNodeIds`) to allow a named view to - * override a model-level collapse. */ -export function isEffectivelyCollapsed( - node: C4Node, - viewCollapsedSet?: Set, - expandedSet?: Set -): boolean { - if (expandedSet?.has(node.id)) return false // view-level explicit expansion - return node.collapsed || (viewCollapsedSet?.has(node.id) ?? false) -} - /** * Compute whether a node is effectively collapsed in a given named view. * Used by tree-panel components (Sidebar, RightPanel) to show ▶/▼ correctly @@ -344,82 +294,9 @@ export function nodeEffectivelyCollapsedInView( ) } -/** Return the subset of nodes/relations visible in the active view (or all if no view). */ -function filterForView( - allNodes: Record, - allRelations: Record, - viewFilter: Set | undefined, - viewCollapsedSet?: Set -): { nodes: Record; relations: Record } { - if (!viewFilter) return { nodes: allNodes, relations: allRelations } - const nodes: Record = {} - for (const [id, n] of Object.entries(allNodes)) { - if (viewFilter.has(id)) { - nodes[id] = viewCollapsedSet?.has(id) ? { ...n, collapsed: true } : n - } - } - - const relations: Record = {} - for (const [id, r] of Object.entries(allRelations)) { - if (viewFilter.has(r.sourceId) && viewFilter.has(r.targetId)) relations[id] = r - } - return { nodes, relations } -} - -interface LayoutInput { - nodes: Record - relations: Record - viewFilter: Set | undefined - viewCollapsedSet: Set - expandedSet: Set | undefined -} - -/** - * The graph a layout algorithm should see for the active view: the same - * node filter, collapse rules (view-collapsed, per-view collapsed and - * expanded overrides) and hidden relations the canvas applies when it - * renders. `collapsed` on the returned nodes is the effective state. - */ +/** The layout input for the store's active view. */ function layoutInputForView(state: DiagramStore): LayoutInput { - const view = state.activeViewId ? state.views[state.activeViewId] : undefined - const viewFilter = computeViewNodeSet(view, state.c4Nodes) - const viewCollapsedSet = computeViewCollapsedSet(viewFilter, state.c4Nodes) - for (const id of view?.collapsedNodeIds ?? []) viewCollapsedSet.add(id) - const expandedSet = view?.expandedNodeIds?.length ? new Set(view.expandedNodeIds) : undefined - - const filtered = filterForView(state.c4Nodes, state.c4Relations, viewFilter) - const nodes: Record = {} - for (const [id, n] of Object.entries(filtered.nodes)) { - const collapsed = isEffectivelyCollapsed(n, viewCollapsedSet, expandedSet) - nodes[id] = collapsed === n.collapsed ? n : { ...n, collapsed } - } - const hidden = new Set(view?.hiddenRelationIds ?? []) - const relations: Record = {} - for (const [id, r] of Object.entries(filtered.relations)) { - if (!hidden.has(id)) relations[id] = r - } - return { nodes, relations, viewFilter, viewCollapsedSet, expandedSet } -} - -/** - * Write a layout result into the model. Collapsed nodes keep their stored - * size: the canvas draws them at the collapsed size anyway, and the stored - * one is what they expand back to. - */ -function applyLayoutPositions( - c4Nodes: Record, - positions: PositionMap, - input: LayoutInput, -): void { - for (const [id, pos] of Object.entries(positions)) { - const node = c4Nodes[id] - if (!node) continue - node.x = pos.x - node.y = pos.y - if (input.nodes[id]?.collapsed) continue - if (pos.width) node.width = pos.width - if (pos.height) node.height = pos.height - } + return viewLayoutInput(state.activeViewId ? state.views[state.activeViewId] : undefined, state.c4Nodes, state.c4Relations) } /** @@ -1255,6 +1132,9 @@ interface DiagramStore { presentationActive: boolean presentationSlideIndex: number addPresentation: (name?: string) => string + /** Replace every presentation (AI tools). Keeps the active one when it + * still exists; an empty list leaves one empty presentation. */ + setPresentations: (presentations: Presentation[]) => void removePresentation: (id: string) => void renamePresentation: (id: string, name: string) => void setActivePresentation: (id: string) => void @@ -2273,96 +2153,27 @@ export const useDiagramStore = create()( reparentNodes(ids, newParentId) { const state0 = get() - const mm = state0.metamodel const nodes = ids .map((id) => state0.c4Nodes[id]) .filter((n): n is C4Node => !!n) if (nodes.length === 0) return - - // All selected nodes must currently share the same parent so we don't - // silently merge two different sub-trees. const oldParentId = nodes[0].parentId ?? null - if (!nodes.every((n) => (n.parentId ?? null) === oldParentId)) { - state0.pushNotification( - 'Cannot move: selected nodes have different parents. Select siblings only.', - 'error', - ) - return - } - if ((newParentId ?? null) === oldParentId) { - // Nothing to do — new parent is the current parent. - return - } + // Same parent for every selected node: nothing to do. + if ((newParentId ?? null) === oldParentId && nodes.every((n) => (n.parentId ?? null) === oldParentId)) return - const newParent = newParentId ? state0.c4Nodes[newParentId] : undefined - if (newParentId && !newParent) { - state0.pushNotification('Target parent no longer exists.', 'error') + const refused = model.checkReparent(state0, ids, newParentId) + if (refused) { + state0.pushNotification(refused, 'error') return } + const newParent = newParentId ? state0.c4Nodes[newParentId] : undefined const newParentLabel = newParent - ? (mm?.nodeTypes[newParent.type]?.label ?? newParent.type) + ? (state0.metamodel?.nodeTypes[newParent.type]?.label ?? newParent.type) : 'the canvas root' - // Cycle guard: new parent must not be one of the moved nodes nor any - // of their descendants. - const movedSet = new Set(ids) - for (const id of ids) { - for (const d of getDescendants(id, state0.c4Nodes)) movedSet.add(d) - } - if (newParentId && movedSet.has(newParentId)) { - state0.pushNotification( - 'Cannot move a node into itself or one of its descendants.', - 'error', - ) - return - } - - // Metamodel: each moved node's type must be allowed inside the target. - for (const n of nodes) { - if (!isParentAllowed(mm, n.type, newParent?.type)) { - const childDef = mm?.nodeTypes[n.type] - const childLabel = childDef?.label ?? n.type - const allowed = childDef?.allowedParents - const allowedStr = allowed && allowed.length - ? allowed.map((t) => mm?.nodeTypes[t]?.label ?? t).join(', ') - : 'the canvas root' - state0.pushNotification( - `Cannot place ${childLabel} "${n.label}" inside ${newParentLabel}. Allowed: ${allowedStr}.`, - 'error', - ) - return - } - } - - // Compute absolute (root-space) coords by walking up the parent chain. - const absOf = (id: string): { x: number; y: number } => { - let x = 0, y = 0 - let cur: C4Node | undefined = state0.c4Nodes[id] - const seen = new Set() - while (cur && !seen.has(cur.id)) { - seen.add(cur.id) - x += cur.x - y += cur.y - cur = cur.parentId ? state0.c4Nodes[cur.parentId] : undefined - } - return { x, y } - } - const newParentAbs = newParentId - ? absOf(newParentId) - : { x: 0, y: 0 } - get()._pushUndo() get()._markMilestoneEdit() - set((state) => { - for (const n of nodes) { - const sn = state.c4Nodes[n.id] - if (!sn) continue - const a = absOf(n.id) // computed against the original tree (state0) - sn.parentId = newParentId ?? undefined - sn.x = a.x - newParentAbs.x - sn.y = a.y - newParentAbs.y - } - }) + set((state) => { model.reparentNodes(state, ids, newParentId) }) // Refit both sides of the move bottom-up so containers grow/shrink. const refitChain = (startId: string | null | undefined): void => { @@ -2901,21 +2712,12 @@ export const useDiagramStore = create()( if (viewFilter) children = children.filter((c) => viewFilter.has(c.id)) if (children.length === 0) return - const pad = compoundPadding(parent.type) - const padRight = pad.side - const padBottom = pad.bottom - - let maxRight = 0 - let maxBottom = 0 - for (const child of children) { - const h = effectiveNodeHeight(child, viewCollapsedSet, expandedSet) - const w = effectiveNodeWidth(child, viewCollapsedSet, expandedSet) - maxRight = Math.max(maxRight, child.x + w) - maxBottom = Math.max(maxBottom, child.y + h) - } - - const newW = Math.max(maxRight + padRight, NODE_SIZES[parent.type].width) - const newH = Math.max(maxBottom + padBottom, NODE_SIZES[parent.type].height) + const { width: newW, height: newH } = fittedParentSize(parent, children.map((child) => ({ + x: child.x, + y: child.y, + width: effectiveNodeWidth(child, viewCollapsedSet, expandedSet), + height: effectiveNodeHeight(child, viewCollapsedSet, expandedSet), + }))) set((state) => { const n = state.c4Nodes[parentId] @@ -3855,28 +3657,33 @@ export const useDiagramStore = create()( resetMetamodelToC4() { set((state) => { state.metamodel = builtInC4Metamodel() as any }) }, + // Editing a built-in preset works on a custom copy: on load, + // documentMetamodel swaps a preset id back to the preset, which would + // silently drop the edit. upsertNodeType(def) { set((state) => { - (state.metamodel as Metamodel).nodeTypes[def.id] = def + state.metamodel = (model.forkPresetMetamodel(state.metamodel as Metamodel) ?? state.metamodel) as any + ;(state.metamodel as Metamodel).nodeTypes[def.id] = def }) }, removeNodeType(id) { set((state) => { - const mm = state.metamodel as Metamodel - if (mm.nodeTypes[id]?.builtin) return - delete mm.nodeTypes[id] + if ((state.metamodel as Metamodel).nodeTypes[id]?.builtin) return + state.metamodel = (model.forkPresetMetamodel(state.metamodel as Metamodel) ?? state.metamodel) as any + delete (state.metamodel as Metamodel).nodeTypes[id] }) }, upsertRelationType(def) { set((state) => { - (state.metamodel as Metamodel).relationTypes[def.id] = def + state.metamodel = (model.forkPresetMetamodel(state.metamodel as Metamodel) ?? state.metamodel) as any + ;(state.metamodel as Metamodel).relationTypes[def.id] = def }) }, removeRelationType(id) { set((state) => { - const mm = state.metamodel as Metamodel - if (mm.relationTypes[id]?.builtin) return - delete mm.relationTypes[id] + if ((state.metamodel as Metamodel).relationTypes[id]?.builtin) return + state.metamodel = (model.forkPresetMetamodel(state.metamodel as Metamodel) ?? state.metamodel) as any + delete (state.metamodel as Metamodel).relationTypes[id] }) }, @@ -3901,6 +3708,19 @@ export const useDiagramStore = create()( return id }, + setPresentations(presentations) { + set((state) => { + state.presentations = (presentations.length ? presentations : [{ id: uid(), name: 'Presentation 1', slides: [] }]) as any + if (!state.presentations.some((p) => p.id === state.activePresentationId)) { + state.activePresentationId = state.presentations[0].id + state.presentationSlideIndex = 0 + } + const active = state.presentations.find((p) => p.id === state.activePresentationId)! + state.presentationSlides = active.slides + state.presentationSlideIndex = Math.min(state.presentationSlideIndex, Math.max(0, active.slides.length - 1)) + }) + }, + removePresentation(id) { set((state) => { state.presentations = state.presentations.filter(p => p.id !== id) diff --git a/packages/ui/tests/model.test.ts b/packages/ui/tests/model.test.ts index 4066c023..e0a5e0a7 100644 --- a/packages/ui/tests/model.test.ts +++ b/packages/ui/tests/model.test.ts @@ -226,3 +226,60 @@ describe('group nesting', () => { expect(useDiagramStore.getState().c4Nodes[sysId].parentId).toBe(groupId) }) }) + +describe('reparentNodes', () => { + const add = (node: Omit): string => useDiagramStore.getState().addNode(node) + const base = { collapsed: false, width: 200, height: 100 } + + beforeEach(() => { + useDiagramStore.setState({ metamodel: builtInGovernanceMetamodel(), activeViewId: null, notifications: [] } as any) + }) + + it('moves a node into another parent, keeping its canvas position', () => { + const shop = add({ ...base, type: 'domain', label: 'Shop', x: 1000, y: 500, width: 800, height: 600 } as Omit) + const sys = add({ ...base, type: 'system', label: 'Orders', x: 1100, y: 700 } as Omit) + useDiagramStore.getState().reparentNodes([sys], shop) + const moved = useDiagramStore.getState().c4Nodes[sys] + expect(moved.parentId).toBe(shop) + const parent = useDiagramStore.getState().c4Nodes[shop] + expect({ x: parent.x + moved.x, y: parent.y + moved.y }).toEqual({ x: 1100, y: 700 }) + }) + + it('refuses a move into a descendant with a notification, leaving the tree unchanged', () => { + const outer = add({ ...base, type: 'domain', label: 'Outer', x: 0, y: 0 } as Omit) + const inner = add({ ...base, type: 'domain', label: 'Inner', parentId: outer, x: 20, y: 130 } as Omit) + useDiagramStore.getState().reparentNodes([outer], inner) + const s = useDiagramStore.getState() + expect(s.c4Nodes[outer].parentId).toBeUndefined() + expect(s.notifications[s.notifications.length - 1]?.message).toBe('Cannot move a node into itself or one of its descendants.') + }) +}) + +describe('metamodel edits on a built-in preset', () => { + it('work on a custom copy, so reloading keeps them', () => { + useDiagramStore.setState({ metamodel: builtInGovernanceMetamodel() } as any) + const s = useDiagramStore.getState() + s.upsertNodeType({ ...s.metamodel.nodeTypes.adr, id: 'risk', label: 'Risk', builtin: false }) + const mm = useDiagramStore.getState().metamodel + expect(mm.id).toBe('c4-ddd-governance-custom') + expect(mm.name).toBe('C4 + DDD + Governance (custom)') + expect(mm.nodeTypes.risk.label).toBe('Risk') + const saved = useDiagramStore.getState().saveDiagram() + useDiagramStore.getState().loadDiagram(saved) + expect(useDiagramStore.getState().metamodel.nodeTypes.risk?.label).toBe('Risk') + }) +}) + +describe('setPresentations', () => { + it('replaces the list, keeps a valid active presentation and never leaves none', () => { + const s = useDiagramStore.getState() + s.setPresentations([{ id: 'p1', name: 'Pitch', slides: [{ id: 's1', name: 'All', snapshotId: null, viewId: null, viewport: { x: 0, y: 0, zoom: 0 } }] }]) + let after = useDiagramStore.getState() + expect(after.activePresentationId).toBe('p1') + expect(after.presentationSlides.map((slide) => slide.id)).toEqual(['s1']) + s.setPresentations([]) + after = useDiagramStore.getState() + expect(after.presentations).toHaveLength(1) + expect(after.activePresentationId).toBe(after.presentations[0].id) + }) +})