@imenam/mcp-design 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -8
- package/dist/shared/icons/icon-data.d.ts +2 -0
- package/dist/shared/icons/icon-data.js +5 -0
- package/dist/shared/icons/icon-keywords.d.ts +1 -0
- package/dist/shared/icons/icon-keywords.js +4 -0
- package/dist/shared/icons/index.d.ts +47 -0
- package/dist/shared/icons/index.js +107 -0
- package/dist/shared/icons/search.d.ts +31 -0
- package/dist/shared/icons/search.js +104 -0
- package/dist/shared/instances.d.ts +39 -0
- package/dist/shared/instances.js +94 -0
- package/dist/shared/node-defaults.d.ts +2 -0
- package/dist/shared/node-defaults.js +32 -4
- package/dist/shared/node-fields.d.ts +2 -0
- package/dist/shared/node-fields.js +17 -4
- package/dist/shared/render.d.ts +34 -1
- package/dist/shared/render.js +80 -7
- package/dist/shared/schemas/design.schema.d.ts +19 -1
- package/dist/shared/schemas/design.schema.js +13 -1
- package/dist/src/core/clone.d.ts +23 -0
- package/dist/src/core/clone.js +34 -0
- package/dist/src/core/diff.js +2 -2
- package/dist/src/core/export.js +69 -4
- package/dist/src/core/hierarchy.d.ts +7 -0
- package/dist/src/core/hierarchy.js +75 -5
- package/dist/src/core/node-factory.js +55 -5
- package/dist/src/core/screenshot.d.ts +12 -0
- package/dist/src/core/screenshot.js +17 -6
- package/dist/src/index.js +97 -7
- package/dist/src/tools/add-nodes.js +7 -1
- package/dist/src/tools/delete-nodes.js +10 -1
- package/dist/src/tools/detach-instances.d.ts +14 -0
- package/dist/src/tools/detach-instances.js +61 -0
- package/dist/src/tools/duplicate-nodes.js +20 -28
- package/dist/src/tools/search-icons.d.ts +12 -0
- package/dist/src/tools/search-icons.js +87 -0
- package/dist/src/tools/ungroup-nodes.js +8 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/{index-C6fXmzfu.css → index-BIeiaHA2.css} +1 -1
- package/gui/dist/assets/index-Dpd_cZgh.js +10 -0
- package/gui/dist/assets/search--VtHhIew.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/scripts/generate-icons.js +85 -0
- package/gui/dist/assets/index-ffF8_vFp.js +0 -10
|
@@ -1,14 +1,50 @@
|
|
|
1
1
|
import { DesignNodeSchema } from "../../shared/schemas/design.schema.js";
|
|
2
|
-
import { GROUP_DERIVED_FIELDS, NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, clampNodeSize, defaultNodeName, } from "../../shared/node-defaults.js";
|
|
2
|
+
import { GROUP_DERIVED_FIELDS, NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, SIZE_DERIVED_KINDS, clampNodeSize, defaultNodeName, } from "../../shared/node-defaults.js";
|
|
3
|
+
import { CORE_ICONS, hasIcon, iconCount } from "../../shared/icons/index.js";
|
|
4
|
+
import { suggestIcons } from "../../shared/icons/search.js";
|
|
3
5
|
import { shortId } from "./ids.js";
|
|
4
6
|
export class DesignError extends Error {
|
|
5
7
|
}
|
|
6
8
|
const GEOMETRY = NODE_GEOMETRY_FIELDS;
|
|
7
9
|
const ALLOWED = NODE_STYLE_FIELDS;
|
|
8
10
|
const DERIVED = GROUP_DERIVED_FIELDS;
|
|
11
|
+
/**
|
|
12
|
+
* Valide le nom d'icone d'un noeud `icon`.
|
|
13
|
+
*
|
|
14
|
+
* Un nom refuse doit repartir avec une piste : sans cela l'agent retombe sur ce
|
|
15
|
+
* qu'on cherche precisement a eviter — reconstituer l'icone a coups d'ellipses.
|
|
16
|
+
* Les suggestions passent par les synonymes de la bibliotheque, ce qui rattrape
|
|
17
|
+
* aussi bien une faute qu'un renommage ("home" -> "house").
|
|
18
|
+
*/
|
|
19
|
+
function requireIconName(value) {
|
|
20
|
+
const name = typeof value === "string" ? value.trim() : "";
|
|
21
|
+
if (name === "") {
|
|
22
|
+
throw new DesignError(`An "icon" node needs an "icon" field naming the icon to draw, e.g. "shopping-cart". ` +
|
|
23
|
+
`Call search_icons to find one among the ${iconCount()} available, or pick from: ${CORE_ICONS.slice(0, 24).join(", ")}...`);
|
|
24
|
+
}
|
|
25
|
+
if (hasIcon(name))
|
|
26
|
+
return name;
|
|
27
|
+
const suggestions = suggestIcons(name);
|
|
28
|
+
throw new DesignError(`Unknown icon "${name}".` +
|
|
29
|
+
(suggestions.length > 0 ? ` Closest matches: ${suggestions.join(", ")}.` : "") +
|
|
30
|
+
` Call search_icons to search the ${iconCount()} available icons by description.`);
|
|
31
|
+
}
|
|
9
32
|
/** Champs geometriques réellement modifiables pour un type donné. */
|
|
10
33
|
function geometryFieldsFor(type) {
|
|
11
|
-
return type
|
|
34
|
+
return SIZE_DERIVED_KINDS.includes(type) ? GEOMETRY.filter((field) => !DERIVED.includes(field)) : GEOMETRY;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Valide le lien d'un noeud `instance` vers son composant. L'existence du
|
|
38
|
+
* groupe cible, elle, est verifiee par `validateHierarchy` : elle depend de la
|
|
39
|
+
* planche entiere, pas du seul noeud.
|
|
40
|
+
*/
|
|
41
|
+
function requireComponentId(value) {
|
|
42
|
+
const componentId = typeof value === "string" ? value.trim() : "";
|
|
43
|
+
if (componentId === "") {
|
|
44
|
+
throw new DesignError(`An "instance" node needs a "componentId" field naming the GROUP it mirrors, e.g. { type: "instance", componentId: "g_card" }. ` +
|
|
45
|
+
`Build the block once with group_nodes, then instantiate it — editing the group then updates every instance at once.`);
|
|
46
|
+
}
|
|
47
|
+
return componentId;
|
|
12
48
|
}
|
|
13
49
|
function readParentId(value) {
|
|
14
50
|
if (value === null)
|
|
@@ -34,7 +70,7 @@ function requireFiniteNumber(value, field) {
|
|
|
34
70
|
export function buildNode(input, existingIds, index) {
|
|
35
71
|
const type = input.type;
|
|
36
72
|
if (!type || !(type in NODE_DEFAULTS)) {
|
|
37
|
-
throw new DesignError(`Unknown node type "${String(input.type)}". Expected one of:
|
|
73
|
+
throw new DesignError(`Unknown node type "${String(input.type)}". Expected one of: ${Object.keys(NODE_DEFAULTS).join(", ")}.`);
|
|
38
74
|
}
|
|
39
75
|
const id = typeof input.id === "string" && input.id.trim() !== "" ? input.id.trim() : shortId("n");
|
|
40
76
|
if (existingIds.has(id))
|
|
@@ -58,6 +94,15 @@ export function buildNode(input, existingIds, index) {
|
|
|
58
94
|
if (input[field] !== undefined)
|
|
59
95
|
draft[field] = input[field];
|
|
60
96
|
}
|
|
97
|
+
if (type === "instance")
|
|
98
|
+
draft.componentId = requireComponentId(draft.componentId);
|
|
99
|
+
if (type === "icon") {
|
|
100
|
+
draft.icon = requireIconName(draft.icon);
|
|
101
|
+
// Un calque nomme "Icône 7" ne dit rien au relecteur ; le nom de l'icone,
|
|
102
|
+
// lui, porte deja le sens. C'est le nom par defaut le plus utile.
|
|
103
|
+
if (!(typeof input.name === "string" && input.name.trim() !== ""))
|
|
104
|
+
draft.name = String(draft.icon);
|
|
105
|
+
}
|
|
61
106
|
const size = clampNodeSize(type, Number(draft.width), Number(draft.height));
|
|
62
107
|
draft.width = size.width;
|
|
63
108
|
draft.height = size.height;
|
|
@@ -86,11 +131,16 @@ export function applyPatch(node, patch) {
|
|
|
86
131
|
if (patch[field] !== undefined)
|
|
87
132
|
next[field] = patch[field];
|
|
88
133
|
}
|
|
134
|
+
if (node.type === "icon" && patch.icon !== undefined)
|
|
135
|
+
next.icon = requireIconName(patch.icon);
|
|
136
|
+
// Changer le lien echange l'instance de composant, comme un "swap instance".
|
|
137
|
+
if (node.type === "instance" && patch.componentId !== undefined)
|
|
138
|
+
next.componentId = requireComponentId(patch.componentId);
|
|
89
139
|
const editable = ["name", "parentId", ...geometryFieldsFor(node.type), ...ALLOWED[node.type]];
|
|
90
140
|
const unknownFields = Object.keys(patch).filter((key) => key !== "id" && !editable.includes(key));
|
|
91
141
|
if (unknownFields.length > 0) {
|
|
92
|
-
const derived = unknownFields.filter((key) => node.type
|
|
93
|
-
const reason = derived.length > 0 ? ` The size of a
|
|
142
|
+
const derived = unknownFields.filter((key) => SIZE_DERIVED_KINDS.includes(node.type) && DERIVED.includes(key));
|
|
143
|
+
const reason = derived.length > 0 ? ` The size of a ${node.type} is derived from its content and cannot be set.` : "";
|
|
94
144
|
throw new DesignError(`Field(s) [${unknownFields.join(", ")}] cannot be set on a "${node.type}" node.${reason} ` +
|
|
95
145
|
`Allowed: ${editable.join(", ")}.`);
|
|
96
146
|
}
|
|
@@ -28,4 +28,16 @@ export interface Screenshot {
|
|
|
28
28
|
sourceHeight: number;
|
|
29
29
|
scale: number;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Rasterisation d'un SVG quelconque, avec la police de secours du systeme.
|
|
33
|
+
*
|
|
34
|
+
* Partagee par la capture d'un design et par la planche de vignettes de
|
|
35
|
+
* `search_icons` : une seule configuration de resvg, donc un seul endroit ou le
|
|
36
|
+
* rendu du texte peut diverger.
|
|
37
|
+
*/
|
|
38
|
+
export declare function rasterize(svg: string, scale: number): {
|
|
39
|
+
png: Buffer;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
};
|
|
31
43
|
export declare function screenshotDesign(design: Design, options?: ScreenshotOptions): Screenshot;
|
|
@@ -25,6 +25,21 @@ function svgSize(svg) {
|
|
|
25
25
|
const height = Number(/\bheight="([\d.]+)"/.exec(svg)?.[1] ?? 0);
|
|
26
26
|
return { width, height };
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Rasterisation d'un SVG quelconque, avec la police de secours du systeme.
|
|
30
|
+
*
|
|
31
|
+
* Partagee par la capture d'un design et par la planche de vignettes de
|
|
32
|
+
* `search_icons` : une seule configuration de resvg, donc un seul endroit ou le
|
|
33
|
+
* rendu du texte peut diverger.
|
|
34
|
+
*/
|
|
35
|
+
export function rasterize(svg, scale) {
|
|
36
|
+
const resvg = new Resvg(svg, {
|
|
37
|
+
fitTo: { mode: "zoom", value: scale },
|
|
38
|
+
font: { loadSystemFonts: true, defaultFontFamily: "Segoe UI", sansSerifFamily: "Segoe UI" },
|
|
39
|
+
});
|
|
40
|
+
const rendered = resvg.render();
|
|
41
|
+
return { png: Buffer.from(rendered.asPng()), width: rendered.width, height: rendered.height };
|
|
42
|
+
}
|
|
28
43
|
export function screenshotDesign(design, options = {}) {
|
|
29
44
|
const doc = toDocument(design);
|
|
30
45
|
const padding = Math.max(0, options.padding ?? 0);
|
|
@@ -37,13 +52,9 @@ export function screenshotDesign(design, options = {}) {
|
|
|
37
52
|
const requested = Math.max(0.05, Math.min(8, options.scale ?? 1));
|
|
38
53
|
// L'écrêtage ne peut que réduire : un zoom demandé plus petit reste respecté.
|
|
39
54
|
const scale = Math.min(requested, maxDimension / Math.max(source.width, source.height));
|
|
40
|
-
const
|
|
41
|
-
fitTo: { mode: "zoom", value: scale },
|
|
42
|
-
font: { loadSystemFonts: true, defaultFontFamily: "Segoe UI", sansSerifFamily: "Segoe UI" },
|
|
43
|
-
});
|
|
44
|
-
const rendered = resvg.render();
|
|
55
|
+
const rendered = rasterize(svg, scale);
|
|
45
56
|
return {
|
|
46
|
-
png:
|
|
57
|
+
png: rendered.png,
|
|
47
58
|
width: rendered.width,
|
|
48
59
|
height: rendered.height,
|
|
49
60
|
sourceWidth: source.width,
|
package/dist/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { DesignStore } from "./core/design-store.js";
|
|
|
13
13
|
import { NODE_DEFAULTS } from "./core/node-factory.js";
|
|
14
14
|
import { EXPORT_FORMATS } from "./core/export.js";
|
|
15
15
|
import { MIN_LINE_HEIGHT_PX } from "../shared/render.js";
|
|
16
|
+
import { CORE_ICONS, iconCount } from "../shared/icons/index.js";
|
|
16
17
|
import { NODE_FIELD_DOCS } from "../shared/node-fields.js";
|
|
17
18
|
import { REORDER_DIRECTIONS } from "../shared/tree.js";
|
|
18
19
|
import { normalizeDesignGroup } from "../shared/schemas/design.schema.js";
|
|
@@ -28,11 +29,13 @@ import { duplicateNodes, MAX_DUPLICATE_COPIES } from "./tools/duplicate-nodes.js
|
|
|
28
29
|
import { groupNodes } from "./tools/group-nodes.js";
|
|
29
30
|
import { groupDesigns } from "./tools/group-designs.js";
|
|
30
31
|
import { ungroupNodes } from "./tools/ungroup-nodes.js";
|
|
32
|
+
import { detachInstances } from "./tools/detach-instances.js";
|
|
31
33
|
import { getChanges } from "./tools/get-changes.js";
|
|
32
34
|
import { markSynced } from "./tools/mark-synced.js";
|
|
33
35
|
import { exportDesignTool } from "./tools/export-design.js";
|
|
34
36
|
import { screenshotDesignTool } from "./tools/screenshot-design.js";
|
|
35
37
|
import { reorderNodesTool } from "./tools/reorder-nodes.js";
|
|
38
|
+
import { searchIconsTool, DEFAULT_ICON_RESULTS, MAX_ICON_RESULTS } from "./tools/search-icons.js";
|
|
36
39
|
const __filename = fileURLToPath(import.meta.url);
|
|
37
40
|
const __dirname = path.dirname(__filename);
|
|
38
41
|
// Les flags CLI sont traités avant toute initialisation : ils s'exécutent dans le
|
|
@@ -319,8 +322,22 @@ const NODE_FIELDS_DOC = "Every length is in PIXELS (x, y, width, height, strokeW
|
|
|
319
322
|
"height are derived from its content and cannot be set. " +
|
|
320
323
|
"Fields accepted per type — rect: fill, stroke, strokeWidth, radius; ellipse: fill, stroke, strokeWidth; " +
|
|
321
324
|
"line: stroke, strokeWidth (the line goes from (x, y) to (x + width, y + height)); " +
|
|
322
|
-
"text: text, color, fontSize, fontFamily, fontWeight, fontStyle, textAlign, lineHeight
|
|
325
|
+
"text: text, color, fontSize, fontFamily, fontWeight, fontStyle, textAlign, lineHeight; " +
|
|
326
|
+
"icon: icon (required — the name of the drawing), fill, stroke, strokeWidth; " +
|
|
327
|
+
"instance: componentId (required — the id of the group it mirrors); nothing else, its look comes from that group. " +
|
|
323
328
|
"Any field left out falls back to a sensible default, so a node can be created from its type alone.";
|
|
329
|
+
/**
|
|
330
|
+
* Consigne sur les icones, repetee partout ou un noeud se cree.
|
|
331
|
+
*
|
|
332
|
+
* Sans elle, un agent qui n'a que des formes a sa disposition approxime une
|
|
333
|
+
* icone avec trois ellipses et deux traits : le rendu est mediocre, et la
|
|
334
|
+
* maquette relue plus tard ne dit plus ce que ce tas de formes representait.
|
|
335
|
+
*/
|
|
336
|
+
const ICON_DOC = `DRAW ICONS WITH AN "icon" NODE, NEVER OUT OF SHAPES. ${iconCount()} icons (Lucide) are built in: give the icon's name in the "icon" field ` +
|
|
337
|
+
`and the drawing is resolved at render time — sharp at any size, restyled with a single field, and still readable to whoever opens the design later. ` +
|
|
338
|
+
`Assembling a cart, a bell or a chevron out of rects, ellipses and lines is the failure mode to avoid. ` +
|
|
339
|
+
`Common names, enough for most interfaces: ${CORE_ICONS.join(", ")}. ` +
|
|
340
|
+
`For anything else, call search_icons with a description — it also matches synonyms.`;
|
|
324
341
|
/**
|
|
325
342
|
* Propriétés d'un noeud exposées à l'agent. Les descriptions viennent de
|
|
326
343
|
* shared/node-fields.ts, partagées avec le schéma zod : l'unité d'un champ n'est
|
|
@@ -340,6 +357,8 @@ const NODE_PROPERTIES = {
|
|
|
340
357
|
stroke: { type: "string", description: NODE_FIELD_DOCS.stroke },
|
|
341
358
|
strokeWidth: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.strokeWidth },
|
|
342
359
|
radius: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.radius },
|
|
360
|
+
icon: { type: "string", description: NODE_FIELD_DOCS.icon },
|
|
361
|
+
componentId: { type: "string", description: NODE_FIELD_DOCS.componentId },
|
|
343
362
|
text: { type: "string", description: NODE_FIELD_DOCS.text },
|
|
344
363
|
color: { type: "string", description: NODE_FIELD_DOCS.color },
|
|
345
364
|
fontSize: { type: "number", exclusiveMinimum: 0, description: NODE_FIELD_DOCS.fontSize },
|
|
@@ -362,6 +381,18 @@ const HIERARCHY_DOC = "STRUCTURE THE ARTBOARD AS A TREE OF NESTED GROUPS, NOT AS
|
|
|
362
381
|
"Build the hierarchy as you go: create a \"group\" node and, in the SAME add_nodes call, give its children that group's id as parentId — a child's x/y are then " +
|
|
363
382
|
"read from the group's origin, so the whole block moves as one. Name each group after what it is (\"Header\", \"Card / Pricing\", \"Nav item\"), never \"Groupe 1\": " +
|
|
364
383
|
"those names are what the user reads in the layer panel and what an export turns into component names.";
|
|
384
|
+
/**
|
|
385
|
+
* Consigne sur les groupes reutilisables, repetee partout ou une repetition se
|
|
386
|
+
* cree. Sans elle, l'agent recopie le meme bloc N fois : le fichier gonfle, et
|
|
387
|
+
* la moindre retouche doit etre refaite autant de fois.
|
|
388
|
+
*/
|
|
389
|
+
const INSTANCE_DOC = "REPEAT A BLOCK WITH INSTANCES, NOT WITH COPIES. When the same block appears more than once (cards, list rows, " +
|
|
390
|
+
"menu items, buttons), build it ONCE as a group, then add nodes of type \"instance\" pointing at that group with " +
|
|
391
|
+
"componentId. An instance stores no content of its own: editing the group updates every instance at once, and the " +
|
|
392
|
+
"repeated block stays a single thing to read and to change — the way a main component works in Figma. " +
|
|
393
|
+
"Nothing can be overridden inside an instance (that is what keeps them in sync): when one copy must differ, either " +
|
|
394
|
+
"make it a second group, or call detach_instances on it. Instances can be nested (a card instance holding a button " +
|
|
395
|
+
"instance), but a component can never contain itself.";
|
|
365
396
|
const DESIGN_ID_DOC = "Id of the design, as returned by list_designs or create_design.";
|
|
366
397
|
const GROUP_PATH_DOC = "Page group this design belongs to, as a path like \"Marketing\" or \"Marketing/Landing\" (the \"/\" nests sub-groups). Pages sharing a path are folded together in the sidebar, which is how a project with many pages stays readable. Omit or pass null to leave the page at the root.";
|
|
367
398
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -429,7 +460,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
429
460
|
description: `Add one or more nodes on top of the design. Node types: ${Object.keys(NODE_DEFAULTS).join(", ")}. ` +
|
|
430
461
|
NODE_FIELDS_DOC +
|
|
431
462
|
" Nodes are drawn in array order, so the last one added sits on top. " +
|
|
432
|
-
HIERARCHY_DOC
|
|
463
|
+
HIERARCHY_DOC +
|
|
464
|
+
" " +
|
|
465
|
+
ICON_DOC +
|
|
466
|
+
" " +
|
|
467
|
+
INSTANCE_DOC,
|
|
433
468
|
inputSchema: {
|
|
434
469
|
type: "object",
|
|
435
470
|
properties: {
|
|
@@ -440,7 +475,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
440
475
|
items: {
|
|
441
476
|
type: "object",
|
|
442
477
|
properties: {
|
|
443
|
-
type: {
|
|
478
|
+
type: {
|
|
479
|
+
type: "string",
|
|
480
|
+
enum: ["rect", "ellipse", "line", "text", "icon", "group", "instance"],
|
|
481
|
+
description: NODE_FIELD_DOCS.type,
|
|
482
|
+
},
|
|
444
483
|
...NODE_PROPERTIES,
|
|
445
484
|
},
|
|
446
485
|
required: ["type"],
|
|
@@ -456,6 +495,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
456
495
|
description: "Patch existing nodes. Each entry needs an id plus only the fields to change; everything else is preserved. " +
|
|
457
496
|
"Changing parentId is how an existing element is moved into (or out of) a group, which is the way to deepen a structure that came out too flat. " +
|
|
458
497
|
NODE_FIELDS_DOC +
|
|
498
|
+
" On an instance, only name, position, rotation, opacity, parentId and componentId apply — patch the nodes of its " +
|
|
499
|
+
"component to change how every instance looks. " +
|
|
459
500
|
" The whole batch is validated before anything is written, so an invalid entry changes nothing.",
|
|
460
501
|
inputSchema: {
|
|
461
502
|
type: "object",
|
|
@@ -480,7 +521,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
480
521
|
},
|
|
481
522
|
{
|
|
482
523
|
name: "delete_nodes",
|
|
483
|
-
description: "Remove nodes from a design by id. Deleting a group also deletes everything inside it."
|
|
524
|
+
description: "Remove nodes from a design by id. Deleting a group also deletes everything inside it. " +
|
|
525
|
+
"A group still mirrored by instances cannot be deleted — delete those instances too, or detach them first.",
|
|
484
526
|
inputSchema: {
|
|
485
527
|
type: "object",
|
|
486
528
|
properties: {
|
|
@@ -493,7 +535,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
493
535
|
{
|
|
494
536
|
name: "duplicate_nodes",
|
|
495
537
|
description: "Copy elements or whole groups. A group is copied with everything inside it, and every copy gets fresh ids. " +
|
|
496
|
-
"
|
|
538
|
+
"Use it for a one-off copy that is MEANT TO DIVERGE. For a block repeated identically, do not copy it: " +
|
|
539
|
+
"instantiate it instead (add_nodes with type \"instance\"), so a single edit updates every occurrence. " +
|
|
497
540
|
"Copies land next to the originals by default; use offset_x/offset_y to place them, count to lay out a series, and parent_id to drop them into another group.",
|
|
498
541
|
inputSchema: {
|
|
499
542
|
type: "object",
|
|
@@ -520,7 +563,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
520
563
|
name: "group_nodes",
|
|
521
564
|
description: "Wrap existing nodes into a new group, so they can be moved, hidden or reordered together. The nodes must currently share the same parent, and nothing moves on screen: their coordinates are rebased onto the new group. " +
|
|
522
565
|
"Call it repeatedly to build depth: group the nodes of one card, then group the cards into a row, then the row with its heading into a section. " +
|
|
523
|
-
|
|
566
|
+
"A group is also what an instance mirrors: group a block once, then repeat it with instances. " +
|
|
567
|
+
HIERARCHY_DOC +
|
|
568
|
+
" " +
|
|
569
|
+
INSTANCE_DOC,
|
|
524
570
|
inputSchema: {
|
|
525
571
|
type: "object",
|
|
526
572
|
properties: {
|
|
@@ -534,7 +580,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
534
580
|
},
|
|
535
581
|
{
|
|
536
582
|
name: "ungroup_nodes",
|
|
537
|
-
description: "Dissolve groups: their content moves up one level, keeping its position on screen. The groups themselves are removed."
|
|
583
|
+
description: "Dissolve groups: their content moves up one level, keeping its position on screen. The groups themselves are removed. " +
|
|
584
|
+
"A group still mirrored by instances cannot be dissolved — detach those instances first.",
|
|
538
585
|
inputSchema: {
|
|
539
586
|
type: "object",
|
|
540
587
|
properties: {
|
|
@@ -544,6 +591,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
544
591
|
required: ["design_id", "group_ids"],
|
|
545
592
|
},
|
|
546
593
|
},
|
|
594
|
+
{
|
|
595
|
+
name: "detach_instances",
|
|
596
|
+
description: "Break the link between instances and their component: the content becomes ordinary nodes, editable one by one, " +
|
|
597
|
+
"and stops following the component. This is the way out when a single copy must differ — nothing can be overridden " +
|
|
598
|
+
"inside an instance. Each node keeps its id, name and position; it simply becomes a group. An instance nested " +
|
|
599
|
+
"inside the component stays an instance.",
|
|
600
|
+
inputSchema: {
|
|
601
|
+
type: "object",
|
|
602
|
+
properties: {
|
|
603
|
+
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
604
|
+
node_ids: { type: "array", items: { type: "string" }, description: "Ids of the instances to detach.", minItems: 1 },
|
|
605
|
+
},
|
|
606
|
+
required: ["design_id", "node_ids"],
|
|
607
|
+
},
|
|
608
|
+
},
|
|
547
609
|
{
|
|
548
610
|
name: "reorder_nodes",
|
|
549
611
|
description: "Change the stacking order of nodes — which one is drawn on top of which. This is the layer panel, agent side. " +
|
|
@@ -654,6 +716,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
654
716
|
required: ["design_id"],
|
|
655
717
|
},
|
|
656
718
|
},
|
|
719
|
+
{
|
|
720
|
+
name: "search_icons",
|
|
721
|
+
description: `Find an icon by description among the ${iconCount()} built into the canvas (Lucide), and get its name for the "icon" field of an "icon" node. ` +
|
|
722
|
+
`Search in English, one concept at a time ("cart", "warning", "user profile"); synonyms are matched too, so "basket" finds "shopping-cart". ` +
|
|
723
|
+
`The common icons are already listed in add_nodes — call this tool for anything they do not cover, not before every icon.`,
|
|
724
|
+
inputSchema: {
|
|
725
|
+
type: "object",
|
|
726
|
+
properties: {
|
|
727
|
+
query: { type: "string", description: 'What the icon should depict, e.g. "shopping cart", "download", "alert".' },
|
|
728
|
+
limit: {
|
|
729
|
+
type: "number",
|
|
730
|
+
description: `How many results to return (default ${DEFAULT_ICON_RESULTS}, max ${MAX_ICON_RESULTS}).`,
|
|
731
|
+
},
|
|
732
|
+
preview: {
|
|
733
|
+
type: "boolean",
|
|
734
|
+
description: "Return a thumbnail sheet of the results alongside their names (default true). Set false to save context.",
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
required: ["query"],
|
|
738
|
+
},
|
|
739
|
+
},
|
|
657
740
|
{
|
|
658
741
|
name: "reconnect_gui",
|
|
659
742
|
description: "Relaunch the GUI worker and re-register it with the proxy. Use it when the canvas is unreachable, or after starting the proxy server.",
|
|
@@ -718,6 +801,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
718
801
|
notifyDesignChanged(result.design.id);
|
|
719
802
|
return result;
|
|
720
803
|
}
|
|
804
|
+
case "detach_instances": {
|
|
805
|
+
const result = detachInstances(store, params);
|
|
806
|
+
notifyDesignChanged(result.design.id);
|
|
807
|
+
return result;
|
|
808
|
+
}
|
|
721
809
|
case "reorder_nodes": {
|
|
722
810
|
const result = reorderNodesTool(store, params);
|
|
723
811
|
notifyDesignChanged(result.design.id);
|
|
@@ -739,6 +827,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
739
827
|
return exportDesignTool(store, params);
|
|
740
828
|
case "screenshot_design":
|
|
741
829
|
return screenshotDesignTool(store, params);
|
|
830
|
+
case "search_icons":
|
|
831
|
+
return searchIconsTool(params);
|
|
742
832
|
case "reconnect_gui":
|
|
743
833
|
return await reconnectGui(params);
|
|
744
834
|
default:
|
|
@@ -18,7 +18,13 @@ export function addNodes(store, args) {
|
|
|
18
18
|
const design = store.mutate(id, "agent", (draft) => {
|
|
19
19
|
draft.nodes.push(...created);
|
|
20
20
|
});
|
|
21
|
-
|
|
21
|
+
// Les tailles sont relues APRES l'écriture : celle d'un groupe — ou d'une
|
|
22
|
+
// instance — est déduite de son contenu, donc inconnue avant la mutation.
|
|
23
|
+
const byId = new Map(design.nodes.map((n) => [n.id, n]));
|
|
24
|
+
const lines = created.map((node) => {
|
|
25
|
+
const n = byId.get(node.id) ?? node;
|
|
26
|
+
return `- ${n.id} — "${n.name}" (${n.type}) at ${Math.round(n.x)},${Math.round(n.y)} size ${Math.round(n.width)}x${Math.round(n.height)}`;
|
|
27
|
+
});
|
|
22
28
|
return {
|
|
23
29
|
...textResult([`Added ${created.length} node(s) to "${design.name}":`, ...lines].join("\n")),
|
|
24
30
|
design,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DesignError } from "../core/node-factory.js";
|
|
2
|
-
import { withDescendants } from "../core/hierarchy.js";
|
|
2
|
+
import { survivingInstancesOf, withDescendants } from "../core/hierarchy.js";
|
|
3
3
|
import { requireString, textResult } from "./types.js";
|
|
4
4
|
export function deleteNodes(store, args) {
|
|
5
5
|
const id = requireString(args, "design_id");
|
|
@@ -17,6 +17,15 @@ export function deleteNodes(store, args) {
|
|
|
17
17
|
// des noeuds orphelins pointant vers un parent disparu.
|
|
18
18
|
const toRemove = withDescendants(current.nodes, nodeIds.map((nodeId) => String(nodeId)));
|
|
19
19
|
const nested = toRemove.size - nodeIds.length;
|
|
20
|
+
// Un composant encore instancie ne peut pas disparaitre : ses instances
|
|
21
|
+
// n'auraient plus rien a reproduire.
|
|
22
|
+
const orphaned = survivingInstancesOf(current.nodes, toRemove);
|
|
23
|
+
if (orphaned.length > 0) {
|
|
24
|
+
const groups = [...new Set(orphaned.map((instance) => instance.componentId))];
|
|
25
|
+
throw new DesignError(`Group(s) ${groups.join(", ")} are still mirrored by ${orphaned.length} instance(s) (${orphaned
|
|
26
|
+
.map((instance) => instance.id)
|
|
27
|
+
.join(", ")}). Delete those instances too, or run detach_instances on them first.`);
|
|
28
|
+
}
|
|
20
29
|
const design = store.mutate(id, "agent", (draft) => {
|
|
21
30
|
draft.nodes = draft.nodes.filter((n) => !toRemove.has(n.id));
|
|
22
31
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DesignStore } from "../core/design-store.js";
|
|
2
|
+
import type { MutationResult } from "./add-nodes.js";
|
|
3
|
+
/**
|
|
4
|
+
* Rompt le lien d'une instance avec son composant : le contenu devient des
|
|
5
|
+
* noeuds ordinaires, editables un par un, et cesse de suivre le composant.
|
|
6
|
+
*
|
|
7
|
+
* C'est la soupape du modele : rien ne se surcharge dans une instance, donc une
|
|
8
|
+
* copie qui doit diverger se detache. Le noeud garde son id, son nom et sa
|
|
9
|
+
* position — il devient simplement un groupe, a la meme place dans la pile.
|
|
10
|
+
*
|
|
11
|
+
* Une instance imbriquee dans le composant reste, elle, une instance : detacher
|
|
12
|
+
* une carte ne detache pas les boutons qu'elle contient.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detachInstances(store: DesignStore, args: Record<string, unknown> | undefined): MutationResult;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { childrenByParent } from "../../shared/tree.js";
|
|
2
|
+
import { cloneSubtrees } from "../core/clone.js";
|
|
3
|
+
import { DesignError } from "../core/node-factory.js";
|
|
4
|
+
import { requireString, textResult } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Rompt le lien d'une instance avec son composant : le contenu devient des
|
|
7
|
+
* noeuds ordinaires, editables un par un, et cesse de suivre le composant.
|
|
8
|
+
*
|
|
9
|
+
* C'est la soupape du modele : rien ne se surcharge dans une instance, donc une
|
|
10
|
+
* copie qui doit diverger se detache. Le noeud garde son id, son nom et sa
|
|
11
|
+
* position — il devient simplement un groupe, a la meme place dans la pile.
|
|
12
|
+
*
|
|
13
|
+
* Une instance imbriquee dans le composant reste, elle, une instance : detacher
|
|
14
|
+
* une carte ne detache pas les boutons qu'elle contient.
|
|
15
|
+
*/
|
|
16
|
+
export function detachInstances(store, args) {
|
|
17
|
+
const id = requireString(args, "design_id");
|
|
18
|
+
const nodeIds = args?.node_ids;
|
|
19
|
+
if (!Array.isArray(nodeIds) || nodeIds.length === 0) {
|
|
20
|
+
throw new Error(`"node_ids" is required and must be a non-empty array of instance ids.`);
|
|
21
|
+
}
|
|
22
|
+
const current = store.get(id);
|
|
23
|
+
const byId = new Map(current.nodes.map((node) => [node.id, node]));
|
|
24
|
+
const instances = nodeIds.map((raw) => {
|
|
25
|
+
const node = byId.get(String(raw));
|
|
26
|
+
if (!node)
|
|
27
|
+
throw new DesignError(`Node "${String(raw)}" not found in design "${id}".`);
|
|
28
|
+
if (node.type !== "instance") {
|
|
29
|
+
throw new DesignError(`Node "${node.id}" is a "${node.type}", not an instance — nothing to detach.`);
|
|
30
|
+
}
|
|
31
|
+
return node;
|
|
32
|
+
});
|
|
33
|
+
const children = childrenByParent(current.nodes);
|
|
34
|
+
let materialized = 0;
|
|
35
|
+
const design = store.mutate(id, "agent", (draft) => {
|
|
36
|
+
const result = [];
|
|
37
|
+
for (const node of draft.nodes) {
|
|
38
|
+
const instance = instances.find((candidate) => candidate.id === node.id);
|
|
39
|
+
if (!instance) {
|
|
40
|
+
result.push(node);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// L'instance prend la place du groupe qu'elle reproduisait, au meme rang
|
|
44
|
+
// dans la pile ; le contenu du composant est copie juste derriere.
|
|
45
|
+
result.push({ ...node, type: "group", componentId: undefined });
|
|
46
|
+
const sourceChildren = (children.get(instance.componentId ?? "") ?? []).map((child) => child.id);
|
|
47
|
+
const clone = cloneSubtrees(current.nodes, sourceChildren);
|
|
48
|
+
const rootSet = new Set(clone.rootIds);
|
|
49
|
+
for (const copy of clone.copies) {
|
|
50
|
+
result.push(rootSet.has(copy.id) ? { ...copy, parentId: node.id } : copy);
|
|
51
|
+
}
|
|
52
|
+
materialized += clone.copies.length;
|
|
53
|
+
}
|
|
54
|
+
draft.nodes = result;
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
...textResult(`Detached ${instances.length} instance(s) in "${design.name}": ${materialized} node(s) are now ordinary nodes ` +
|
|
58
|
+
`and no longer follow their component.`),
|
|
59
|
+
design,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withoutRedundantDescendants } from "../../shared/tree.js";
|
|
2
|
+
import { cloneSubtrees } from "../core/clone.js";
|
|
2
3
|
import { DesignError } from "../core/node-factory.js";
|
|
3
|
-
import { shortId } from "../core/ids.js";
|
|
4
4
|
import { requireString, textResult } from "./types.js";
|
|
5
5
|
/** Décalage par défaut d'une copie, en pixels : assez pour la voir, assez peu pour la retrouver. */
|
|
6
6
|
const DEFAULT_OFFSET = 20;
|
|
@@ -56,36 +56,28 @@ export function duplicateNodes(store, args) {
|
|
|
56
56
|
parentId = parent.id;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
-
// Le sous-arbre complet de chaque racine, dans l'ordre du document : l'ordre
|
|
60
|
-
// d'empilement relatif des copies reste celui des originaux.
|
|
61
|
-
const included = new Set(roots);
|
|
62
|
-
for (const rootId of roots)
|
|
63
|
-
for (const descendant of descendantIds(current.nodes, rootId))
|
|
64
|
-
included.add(descendant);
|
|
65
|
-
const source = current.nodes.filter((node) => included.has(node.id));
|
|
66
59
|
const copies = [];
|
|
67
60
|
const createdRootIds = [];
|
|
68
61
|
for (let copyIndex = 1; copyIndex <= count; copyIndex++) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (isRoot)
|
|
87
|
-
createdRootIds.push(copy.id);
|
|
62
|
+
// Un sous-arbre complet par copie : les ids sont neufs et les liens
|
|
63
|
+
// parent/enfant recables (cf. src/core/clone.ts).
|
|
64
|
+
const clone = cloneSubtrees(current.nodes, roots);
|
|
65
|
+
const rootSet = new Set(clone.rootIds);
|
|
66
|
+
for (const copy of clone.copies) {
|
|
67
|
+
// Seules les racines sont decalees et reparentees : les descendants sont
|
|
68
|
+
// exprimes dans l'espace de leur parent et suivent donc tout seuls.
|
|
69
|
+
if (!rootSet.has(copy.id)) {
|
|
70
|
+
copies.push(copy);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
copies.push({
|
|
74
|
+
...copy,
|
|
75
|
+
parentId: parentId === undefined ? copy.parentId : parentId,
|
|
76
|
+
x: copy.x + offsetX * copyIndex,
|
|
77
|
+
y: copy.y + offsetY * copyIndex,
|
|
78
|
+
});
|
|
88
79
|
}
|
|
80
|
+
createdRootIds.push(...clone.rootIds);
|
|
89
81
|
}
|
|
90
82
|
const design = store.mutate(id, "agent", (draft) => {
|
|
91
83
|
draft.nodes.push(...copies);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ImageToolResult } from "./types.js";
|
|
2
|
+
/** Au-dela, la planche coute plus de contexte qu'elle n'aide a choisir. */
|
|
3
|
+
export declare const MAX_ICON_RESULTS = 48;
|
|
4
|
+
export declare const DEFAULT_ICON_RESULTS = 24;
|
|
5
|
+
/**
|
|
6
|
+
* Recherche d'une icone par description.
|
|
7
|
+
*
|
|
8
|
+
* C'est la traine longue de la bibliotheque : les icones les plus courantes
|
|
9
|
+
* sont deja listees dans la description de `add_nodes`, donc un agent qui
|
|
10
|
+
* habille une interface ordinaire n'a pas besoin de passer par ici.
|
|
11
|
+
*/
|
|
12
|
+
export declare function searchIconsTool(args: Record<string, unknown> | undefined): ImageToolResult;
|