@imenam/mcp-design 1.0.14 → 1.0.16
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 +58 -1
- package/dist/shared/feedback.d.ts +3 -2
- package/dist/shared/feedback.js +4 -3
- package/dist/src/core/design-model.d.ts +54 -0
- package/dist/src/core/design-model.js +129 -0
- package/dist/src/core/design-store.d.ts +3 -18
- package/dist/src/core/design-store.js +15 -103
- package/dist/src/core/exchange.d.ts +86 -0
- package/dist/src/core/exchange.js +430 -0
- package/dist/src/core/feedback-model.d.ts +64 -0
- package/dist/src/core/feedback-model.js +211 -0
- package/dist/src/core/feedback-store.d.ts +4 -31
- package/dist/src/core/feedback-store.js +14 -195
- package/dist/src/core/ids.js +1 -2
- package/gui/dist/assets/index-DTIGOSpK.js +15 -0
- package/gui/dist/assets/{search-BYdwq4vz.js → search-CH3uadrp.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +63 -59
- package/scripts/generate-icons.js +85 -85
- package/gui/dist/assets/index-Cbtaxo0u.js +0 -15
package/README.md
CHANGED
|
@@ -361,6 +361,51 @@ Les retours créés avant l'arrivée des noms reçoivent « Retour n°N », et a
|
|
|
361
361
|
|
|
362
362
|
---
|
|
363
363
|
|
|
364
|
+
## Version autonome : un seul fichier HTML
|
|
365
|
+
|
|
366
|
+
La même interface existe en un fichier HTML unique, à ouvrir directement dans le navigateur : ni serveur, ni agent MCP, ni connexion réseau. Les pages naissent d'un assistant conversationnel (ChatGPT ou autre) et s'échangent avec lui par fichiers JSON.
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
npm run build:standalone # produit gui/dist-standalone/mcp-design.html
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Le fichier embarque tout : le code, les 1 791 icônes, la police Inter (le canvas, les captures et le récap ont donc le même rendu sur toutes les machines).
|
|
373
|
+
|
|
374
|
+
### Le circuit
|
|
375
|
+
|
|
376
|
+
1. **Projet › Copier le prompt de création**, coller dans ChatGPT, puis décrire ce qu'on veut. Le prompt décrit le format d'une page, les types d'éléments et leurs champs, les règles de structure, les icônes et un exemple complet.
|
|
377
|
+
2. ChatGPT renvoie **une page par réponse**, en JSON. **Projet › Importer une page** : coller la réponse (entière, ou seulement son bloc JSON) ou choisir un fichier `.json`. La page est vérifiée en entier avant tout changement ; à la moindre erreur, rien n'est importé et chaque problème est listé, dans un message que **Copier pour ChatGPT** permet de lui renvoyer tel quel. Une page dont l'`id` existe déjà la remplace ; la boîte de dialogue prévient alors des modifications faites à la main qui seraient perdues.
|
|
378
|
+
3. Retoucher la page à la main, et poser des remarques comme dans la version serveur (mode Remarques, **Terminer les retours**).
|
|
379
|
+
4. **Télécharger le récap** (à la fin de « Terminer les retours », ou dans la page « Retours ») : une archive `recap-retour-N.zip` qui réunit un fichier `recap-retour-N.md` et des images — une vue de chaque page concernée, remarques entourées et numérotées, et les captures des remarques. Le texte reprend les remarques, le JSON courant de chaque page et un rappel du format. **Copier le texte** le met dans le presse-papiers à la place du fichier.
|
|
380
|
+
5. ChatGPT renvoie les pages corrigées, une par réponse, à importer comme à l'étape 2. Chacune liste dans `processed` les remarques traitées, avec une note : elles passent à *Traitée par l'agent* dans la page « Retours ».
|
|
381
|
+
|
|
382
|
+
### Format d'une page
|
|
383
|
+
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"format": "mcp-design-page",
|
|
387
|
+
"version": 1,
|
|
388
|
+
"page": { "id": "home", "name": "Home", "group": null, "width": 1440, "height": 900, "background": "#ffffff", "nodes": [] },
|
|
389
|
+
"processed": [{ "remark": "rm_xxxxxxxx", "note": "Logo agrandi" }]
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Les éléments de `nodes` suivent le modèle des outils MCP (liste plate, `parentId`, coordonnées relatives au parent) et passent par la même validation que `add_nodes`. L'import est plus strict : un champ qui ne s'applique pas au type de l'élément est refusé, et les éléments `image` n'existent pas dans cette version (pas de médiathèque). La taille des groupes et des instances, dérivée de leur contenu, n'apparaît pas dans le JSON transmis et n'a pas à être fournie.
|
|
394
|
+
|
|
395
|
+
### Données
|
|
396
|
+
|
|
397
|
+
Le projet (pages, remarques, captures) vit dans le navigateur, qui en garde une copie (IndexedDB) relue à l'ouverture : recharger l'onglet ne perd rien. Cette copie dépend du navigateur et de son profil ; **le fichier exporté fait foi**. **Projet › Exporter le projet** télécharge `mcp-design-projet-AAAA-MM-JJ.json`, **Ouvrir un projet…** le recharge, après confirmation, à la place du projet courant. Le bouton **Projet** porte un point orange tant que des modifications n'ont pas été exportées, rouge si la copie du navigateur ne peut pas être lue ou écrite.
|
|
398
|
+
|
|
399
|
+
Le filtre **À synchroniser** retient les pages modifiées à la main depuis le dernier échange avec ChatGPT : import de la page, ou récap qui la contient.
|
|
400
|
+
|
|
401
|
+
### Ce qui diffère de la version serveur
|
|
402
|
+
|
|
403
|
+
- Pas d'agent MCP : ni outils, ni `get_changes`, ni rafraîchissement périodique. L'assistant ne voit que ce qu'on lui transmet.
|
|
404
|
+
- Pas d'images posées sur la planche.
|
|
405
|
+
- Une seule personne, un seul navigateur : le projet circule par fichier.
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
364
409
|
## Architecture
|
|
365
410
|
|
|
366
411
|
Deux processus, conformément au standard de l'écosystème (`@imenam/mcp-gui-interface`) :
|
|
@@ -372,7 +417,11 @@ Les deux communiquent par IPC. Les évènements que le master pousse au worker (
|
|
|
372
417
|
|
|
373
418
|
`shared/feedback.ts` porte le modèle des remarques, la détection des éléments d'une zone et la mise en forme d'un retour ; le serveur et la GUI l'importent tous deux, ce qui garantit que l'aperçu affiché est le texte transmis. `shared/render.ts` contient le rendu SVG d'un élément, `shared/tree.ts` la résolution de la hiérarchie en coordonnées document, `shared/instances.ts` l'expansion des instances en groupes, `shared/node-defaults.ts` les valeurs par défaut et `shared/icons/` la bibliothèque d'icônes. Tous sont importés **à la fois** par l'exporteur côté serveur et par le canvas côté navigateur : ce que l'utilisateur voit est exactement ce que `export_design` — et donc `screenshot_design`, qui rasterise ce même SVG via resvg — produit.
|
|
374
419
|
|
|
375
|
-
Les invariants de hiérarchie — parent existant et de type `group`, absence de cycle (y compris dans le graphe composant/instance), taille des groupes et des instances recalculée, ordre préfixe — sont appliqués
|
|
420
|
+
Les invariants de hiérarchie — parent existant et de type `group`, absence de cycle (y compris dans le graphe composant/instance), taille des groupes et des instances recalculée, ordre préfixe — sont appliqués par `mutatedDesign` (`src/core/design-model.ts`), donc sur **tout** chemin d'écriture : outil MCP, sauvegarde depuis la GUI, import dans la version autonome.
|
|
421
|
+
|
|
422
|
+
`src/core/design-model.ts` et `src/core/feedback-model.ts` portent les règles des pages et des remarques indépendamment de leur stockage : `DesignStore` et `FeedbackStore` les appliquent à des fichiers, la version autonome à son projet en mémoire. `src/core/exchange.ts` porte ce qui est propre à la version autonome et reste testable sans navigateur : prompt, lecture d'une page, récap, export du projet.
|
|
423
|
+
|
|
424
|
+
La version autonome (`gui/vite.standalone.config.ts`) bâtit la même application React depuis `gui/mcp-design.html`, en substituant deux modules : `utils/api.ts` (appels HTTP au worker) par `standalone/api.ts` (projet en mémoire), et `hooks/useServerChanges.ts` par un simple chargement initial. Ce qui distingue les deux hôtes — textes qui nomment le destinataire des retours, menu **Projet**, actions du récap — passe par `gui/src/host.tsx`.
|
|
376
425
|
|
|
377
426
|
**Limite assumée de cette version** : un groupe se déplace, se pivote et change d'opacité, mais ne se redimensionne pas — mettre son contenu à l'échelle demanderait de propager un facteur dans tout le sous-arbre, ce qui dépasse le cadre d'un premier jet sobre.
|
|
378
427
|
|
|
@@ -383,6 +432,7 @@ Les invariants de hiérarchie — parent existant et de type `group`, absence de
|
|
|
383
432
|
```bash
|
|
384
433
|
npm install
|
|
385
434
|
npm run build # backend (tsc) + GUI (vite)
|
|
435
|
+
npm run build:standalone # version autonome : gui/dist-standalone/mcp-design.html
|
|
386
436
|
npm test # suite complète
|
|
387
437
|
npm run generate-icons # refige shared/icons/ après une mise à jour de lucide-static
|
|
388
438
|
```
|
|
@@ -391,6 +441,7 @@ La suite comprend :
|
|
|
391
441
|
|
|
392
442
|
- des tests unitaires du store, de la fabrique d'éléments, du moteur de diff et de l'export ;
|
|
393
443
|
- des tests des outils MCP appelés directement ;
|
|
444
|
+
- des tests des échanges de la version autonome (`test/exchange.test.ts`) : l'exemple du prompt s'importe tel quel, une page exportée se réimporte à l'identique, chaque erreur est signalée sans rien importer, les remarques traitées sont vérifiées puis marquées ;
|
|
394
445
|
- des tests d'exposition réseau (`test/network-exposure.test.ts`) qui lancent le worker GUI en mode `APP_PORT` et vérifient l'écoute sur `127.0.0.1` seul, le refus d'un `Host` étranger et des modifications cross-site, et l'acceptation des requêtes relayées par le proxy ;
|
|
395
446
|
- une suite de bout en bout (`test/e2e.test.ts`) qui lance un vrai serveur MCP sur stdio, attend l'enregistrement de son worker auprès du proxy, puis vérifie l'aller-retour complet agent → GUI → agent.
|
|
396
447
|
|
|
@@ -401,3 +452,9 @@ Pour inspecter le worker sans traverser le proxy, ajoutez `?__direct=1` à son p
|
|
|
401
452
|
### Méta-configurations du proxy
|
|
402
453
|
|
|
403
454
|
`mcp-design` n'expose pas `GET /api/configurations` : il n'a pas de configuration métier (ni token, ni identifiant de ressource) qui justifierait des profils. Le proxy ignore simplement les MCPs qui n'implémentent pas ces routes.
|
|
455
|
+
|
|
456
|
+
## Publication
|
|
457
|
+
|
|
458
|
+
Chaque push sur `master`, merge de pull request compris, publie une nouvelle version de `@imenam/mcp-design` sur npm (`.github/workflows/publish.yml`) : `npm ci`, build et tests, puis `npm version patch`, dont le commit et le tag sont poussés sur `master`, et `npm publish`. La publication passe par le trusted publishing de npm (OIDC, sans token), avec attestation de provenance. Un échec du build ou des tests arrête le workflow avant la montée de version.
|
|
459
|
+
|
|
460
|
+
Sur npmjs.com, le paquet déclare ce workflow comme publieur de confiance (Settings → Trusted publishing → GitHub Actions : `Matthieu-Pesnot-Pin`, `mcp-design`, `publish.yml`, avec `npm publish` dans les actions autorisées). Le commit de version est poussé avec `GITHUB_TOKEN` : si `master` est protégée, la règle doit autoriser GitHub Actions à y pousser.
|
|
@@ -123,6 +123,7 @@ export declare const PATH_SEPARATOR = " \u203A ";
|
|
|
123
123
|
* La GUI l'affiche en aperçu avant l'enregistrement : c'est la même fonction,
|
|
124
124
|
* donc le même texte. Chaque remarque porte son identifiant, que l'agent passe
|
|
125
125
|
* à `mark_feedback_processed`. Les captures sont numérotées dans l'ordre des
|
|
126
|
-
* remarques ; `get_feedback` joint les images dans cet ordre.
|
|
126
|
+
* remarques ; `get_feedback` joint les images dans cet ordre. `coordinatesSource`
|
|
127
|
+
* nomme ce qui, chez le lecteur, exprime les coordonnées dans le même repère.
|
|
127
128
|
*/
|
|
128
|
-
export declare function formatFeedbackRound(round: Pick<FeedbackRound, "number" | "title" | "submittedAt" | "remarks"
|
|
129
|
+
export declare function formatFeedbackRound(round: Pick<FeedbackRound, "number" | "title" | "submittedAt" | "remarks">, coordinatesSource?: string): string;
|
package/dist/shared/feedback.js
CHANGED
|
@@ -118,9 +118,10 @@ function indent(text, prefix) {
|
|
|
118
118
|
* La GUI l'affiche en aperçu avant l'enregistrement : c'est la même fonction,
|
|
119
119
|
* donc le même texte. Chaque remarque porte son identifiant, que l'agent passe
|
|
120
120
|
* à `mark_feedback_processed`. Les captures sont numérotées dans l'ordre des
|
|
121
|
-
* remarques ; `get_feedback` joint les images dans cet ordre.
|
|
121
|
+
* remarques ; `get_feedback` joint les images dans cet ordre. `coordinatesSource`
|
|
122
|
+
* nomme ce qui, chez le lecteur, exprime les coordonnées dans le même repère.
|
|
122
123
|
*/
|
|
123
|
-
export function formatFeedbackRound(round) {
|
|
124
|
+
export function formatFeedbackRound(round, coordinatesSource = "get_design") {
|
|
124
125
|
const pages = new Map();
|
|
125
126
|
for (const remark of round.remarks) {
|
|
126
127
|
const list = pages.get(remark.designId);
|
|
@@ -133,7 +134,7 @@ export function formatFeedbackRound(round) {
|
|
|
133
134
|
const lines = [
|
|
134
135
|
`Feedback round ${round.number} "${round.title}" — submitted ${round.submittedAt} — ` +
|
|
135
136
|
`${round.remarks.length} remark(s) on ${pages.size} page(s), ${progress.done} processed.`,
|
|
136
|
-
|
|
137
|
+
`Coordinates are design pixels in the artboard's space, as in ${coordinatesSource}.`,
|
|
137
138
|
];
|
|
138
139
|
let image = 0;
|
|
139
140
|
for (const [designId, remarks] of pages) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type Design, type DesignSummary } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Règles d'un design indépendantes de son stockage : création, copie,
|
|
4
|
+
* mutation validée, synchronisation. Le `DesignStore` du serveur les applique à
|
|
5
|
+
* des fichiers JSON, la version autonome de la GUI à son projet en mémoire.
|
|
6
|
+
*/
|
|
7
|
+
export interface CreateDesignInput {
|
|
8
|
+
name: string;
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
background?: string;
|
|
12
|
+
/** Groupe de pages, chemin "Parent/Enfant". Null ou omis : a la racine. */
|
|
13
|
+
group?: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface MutateOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Révision sur laquelle l'édition est basée. Quand elle est fournie et que le
|
|
18
|
+
* design a avancé entre-temps, l'écriture est refusée : sans cette garde, un
|
|
19
|
+
* éditeur parti d'un état périmé réécrit le document entier et efface le
|
|
20
|
+
* travail de l'autre.
|
|
21
|
+
*/
|
|
22
|
+
baseRevision?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
|
|
25
|
+
export declare function allocateDesignId(name: string, exists: (id: string) => boolean): string;
|
|
26
|
+
/**
|
|
27
|
+
* Lit un design brut, après mise à niveau de son format. `migrated` indique
|
|
28
|
+
* qu'il diffère de ce qui a été lu et mérite d'être réécrit.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseDesign(raw: unknown, label: string): {
|
|
31
|
+
design: Design;
|
|
32
|
+
migrated: boolean;
|
|
33
|
+
};
|
|
34
|
+
export declare function newDesign(id: string, input: CreateDesignInput, now: string): Design;
|
|
35
|
+
/**
|
|
36
|
+
* Nouveau design reprenant la planche et tous les éléments d'un design
|
|
37
|
+
* existant, identifiants compris. La copie part synchronisée : sa référence
|
|
38
|
+
* de comparaison est son propre contenu.
|
|
39
|
+
*/
|
|
40
|
+
export declare function duplicatedDesign(source: Design, id: string, input: {
|
|
41
|
+
name?: string;
|
|
42
|
+
group?: string | null;
|
|
43
|
+
}, now: string): Design;
|
|
44
|
+
/**
|
|
45
|
+
* Applique une mutation à une copie du design et incrémente la révision.
|
|
46
|
+
* `editor` distingue une modification de l'agent d'une modification faite à la
|
|
47
|
+
* main dans la GUI — c'est ce qui permet à get_changes de rester lisible.
|
|
48
|
+
*/
|
|
49
|
+
export declare function mutatedDesign(current: Design, editor: "agent" | "user", fn: (design: Design) => void, options?: MutateOptions): Design;
|
|
50
|
+
/** Le design avec son état courant comme nouvelle référence de comparaison. */
|
|
51
|
+
export declare function syncedDesign(current: Design, now: string): Design;
|
|
52
|
+
export declare function summaryOf(design: Design): DesignSummary;
|
|
53
|
+
/** Plus récemment modifiés d'abord, comme la liste des pages de la GUI. */
|
|
54
|
+
export declare function sortSummaries(summaries: DesignSummary[]): DesignSummary[];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { DesignSchema, normalizeDesignGroup, } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
import { DesignError } from "./node-factory.js";
|
|
3
|
+
import { normalizeHierarchy } from "./hierarchy.js";
|
|
4
|
+
import { migrateDesign } from "./migrate.js";
|
|
5
|
+
import { shortId, slugify } from "./ids.js";
|
|
6
|
+
import { countChanges } from "./diff.js";
|
|
7
|
+
function issuesOf(error) {
|
|
8
|
+
return error.issues.map((issue) => `${issue.path.map(String).join(".")} ${issue.message}`).join("; ");
|
|
9
|
+
}
|
|
10
|
+
/** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
|
|
11
|
+
export function allocateDesignId(name, exists) {
|
|
12
|
+
const base = slugify(name);
|
|
13
|
+
if (!exists(base))
|
|
14
|
+
return base;
|
|
15
|
+
for (let i = 2; i < 100; i++) {
|
|
16
|
+
const candidate = `${base}-${i}`;
|
|
17
|
+
if (!exists(candidate))
|
|
18
|
+
return candidate;
|
|
19
|
+
}
|
|
20
|
+
return `${base}-${shortId("x", 4)}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Lit un design brut, après mise à niveau de son format. `migrated` indique
|
|
24
|
+
* qu'il diffère de ce qui a été lu et mérite d'être réécrit.
|
|
25
|
+
*/
|
|
26
|
+
export function parseDesign(raw, label) {
|
|
27
|
+
const migrated = migrateDesign(raw);
|
|
28
|
+
const parsed = DesignSchema.safeParse(migrated ?? raw);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
throw new DesignError(`Design ${label} does not match the expected schema: ${issuesOf(parsed.error)}`);
|
|
31
|
+
}
|
|
32
|
+
return { design: parsed.data, migrated: migrated !== null };
|
|
33
|
+
}
|
|
34
|
+
export function newDesign(id, input, now) {
|
|
35
|
+
const name = input.name?.trim();
|
|
36
|
+
if (!name)
|
|
37
|
+
throw new DesignError(`"name" is required to create a design.`);
|
|
38
|
+
const width = input.width ?? 1440;
|
|
39
|
+
const height = input.height ?? 900;
|
|
40
|
+
if (!Number.isFinite(width) || width <= 0)
|
|
41
|
+
throw new DesignError(`"width" must be a positive number.`);
|
|
42
|
+
if (!Number.isFinite(height) || height <= 0)
|
|
43
|
+
throw new DesignError(`"height" must be a positive number.`);
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
name,
|
|
47
|
+
group: normalizeDesignGroup(input.group),
|
|
48
|
+
width,
|
|
49
|
+
height,
|
|
50
|
+
background: input.background ?? "#ffffff",
|
|
51
|
+
nodes: [],
|
|
52
|
+
createdAt: now,
|
|
53
|
+
updatedAt: now,
|
|
54
|
+
revision: 0,
|
|
55
|
+
lastEditor: "agent",
|
|
56
|
+
baseline: { revision: 0, at: now, nodes: [] },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Nouveau design reprenant la planche et tous les éléments d'un design
|
|
61
|
+
* existant, identifiants compris. La copie part synchronisée : sa référence
|
|
62
|
+
* de comparaison est son propre contenu.
|
|
63
|
+
*/
|
|
64
|
+
export function duplicatedDesign(source, id, input, now) {
|
|
65
|
+
const name = input.name?.trim() || `${source.name} (copy)`;
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
name,
|
|
69
|
+
group: input.group === undefined ? source.group : normalizeDesignGroup(input.group),
|
|
70
|
+
width: source.width,
|
|
71
|
+
height: source.height,
|
|
72
|
+
background: source.background,
|
|
73
|
+
nodes: structuredClone(source.nodes),
|
|
74
|
+
createdAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
revision: 0,
|
|
77
|
+
lastEditor: "agent",
|
|
78
|
+
baseline: { revision: 0, at: now, nodes: structuredClone(source.nodes) },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Applique une mutation à une copie du design et incrémente la révision.
|
|
83
|
+
* `editor` distingue une modification de l'agent d'une modification faite à la
|
|
84
|
+
* main dans la GUI — c'est ce qui permet à get_changes de rester lisible.
|
|
85
|
+
*/
|
|
86
|
+
export function mutatedDesign(current, editor, fn, options = {}) {
|
|
87
|
+
if (options.baseRevision !== undefined && options.baseRevision !== current.revision) {
|
|
88
|
+
throw new DesignError(`Design "${current.id}" has changed since it was loaded: your edit is based on revision ${options.baseRevision}, ` +
|
|
89
|
+
`the stored design is at revision ${current.revision}. Reload it with get_design and reapply your change — ` +
|
|
90
|
+
`writing now would discard the other editor's work.`);
|
|
91
|
+
}
|
|
92
|
+
const draft = structuredClone(current);
|
|
93
|
+
fn(draft);
|
|
94
|
+
// Les invariants de hiérarchie sont vérifiés ici, une seule fois : aucun
|
|
95
|
+
// appelant — outil MCP ou sauvegarde depuis la GUI — ne peut les contourner.
|
|
96
|
+
draft.nodes = normalizeHierarchy(draft.nodes);
|
|
97
|
+
draft.revision = current.revision + 1;
|
|
98
|
+
draft.updatedAt = new Date().toISOString();
|
|
99
|
+
draft.lastEditor = editor;
|
|
100
|
+
const parsed = DesignSchema.safeParse(draft);
|
|
101
|
+
if (!parsed.success)
|
|
102
|
+
throw new DesignError(`Resulting design is invalid: ${issuesOf(parsed.error)}`);
|
|
103
|
+
return parsed.data;
|
|
104
|
+
}
|
|
105
|
+
/** Le design avec son état courant comme nouvelle référence de comparaison. */
|
|
106
|
+
export function syncedDesign(current, now) {
|
|
107
|
+
return {
|
|
108
|
+
...structuredClone(current),
|
|
109
|
+
baseline: { revision: current.revision, at: now, nodes: structuredClone(current.nodes) },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function summaryOf(design) {
|
|
113
|
+
return {
|
|
114
|
+
id: design.id,
|
|
115
|
+
name: design.name,
|
|
116
|
+
group: design.group,
|
|
117
|
+
width: design.width,
|
|
118
|
+
height: design.height,
|
|
119
|
+
nodeCount: design.nodes.length,
|
|
120
|
+
updatedAt: design.updatedAt,
|
|
121
|
+
revision: design.revision,
|
|
122
|
+
lastEditor: design.lastEditor,
|
|
123
|
+
pendingChanges: countChanges(design.baseline.nodes, design.nodes),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Plus récemment modifiés d'abord, comme la liste des pages de la GUI. */
|
|
127
|
+
export function sortSummaries(summaries) {
|
|
128
|
+
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
129
|
+
}
|
|
@@ -1,22 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Design, DesignSummary } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
import { type CreateDesignInput, type MutateOptions } from "./design-model.js";
|
|
2
3
|
import { ImageStore } from "./image-store.js";
|
|
3
|
-
export
|
|
4
|
-
name: string;
|
|
5
|
-
width?: number;
|
|
6
|
-
height?: number;
|
|
7
|
-
background?: string;
|
|
8
|
-
/** Groupe de pages, chemin "Parent/Enfant". Null ou omis : a la racine. */
|
|
9
|
-
group?: string | null;
|
|
10
|
-
}
|
|
11
|
-
export interface MutateOptions {
|
|
12
|
-
/**
|
|
13
|
-
* Révision sur laquelle l'édition est basée. Quand elle est fournie et que le
|
|
14
|
-
* fichier a avancé entre-temps, l'écriture est refusée : sans cette garde, un
|
|
15
|
-
* éditeur parti d'un état périmé réécrit le document entier et efface le
|
|
16
|
-
* travail de l'autre.
|
|
17
|
-
*/
|
|
18
|
-
baseRevision?: number;
|
|
19
|
-
}
|
|
4
|
+
export type { CreateDesignInput, MutateOptions };
|
|
20
5
|
/**
|
|
21
6
|
* Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
|
|
22
7
|
* Pas de base de données — un design tient dans quelques dizaines de Ko et le
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { DesignSchema, normalizeDesignGroup, } from "../../shared/schemas/design.schema.js";
|
|
4
3
|
import { DesignError } from "./node-factory.js";
|
|
5
4
|
import { withFileLock } from "./file-lock.js";
|
|
6
|
-
import {
|
|
7
|
-
import { migrateDesign } from "./migrate.js";
|
|
8
|
-
import { shortId, slugify } from "./ids.js";
|
|
9
|
-
import { countChanges } from "./diff.js";
|
|
5
|
+
import { allocateDesignId, duplicatedDesign, mutatedDesign, newDesign, parseDesign, sortSummaries, summaryOf, syncedDesign, } from "./design-model.js";
|
|
10
6
|
import { ImageStore } from "./image-store.js";
|
|
11
7
|
/**
|
|
12
8
|
* Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
|
|
@@ -75,15 +71,7 @@ export class DesignStore {
|
|
|
75
71
|
}
|
|
76
72
|
/** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
|
|
77
73
|
allocateId(name) {
|
|
78
|
-
|
|
79
|
-
if (!fs.existsSync(this.filePath(base)))
|
|
80
|
-
return base;
|
|
81
|
-
for (let i = 2; i < 100; i++) {
|
|
82
|
-
const candidate = `${base}-${i}`;
|
|
83
|
-
if (!fs.existsSync(this.filePath(candidate)))
|
|
84
|
-
return candidate;
|
|
85
|
-
}
|
|
86
|
-
return `${base}-${shortId("x", 4)}`;
|
|
74
|
+
return allocateDesignId(name, (id) => fs.existsSync(this.filePath(id)));
|
|
87
75
|
}
|
|
88
76
|
list() {
|
|
89
77
|
const files = fs.existsSync(this.designsDir)
|
|
@@ -99,20 +87,9 @@ export class DesignStore {
|
|
|
99
87
|
catch {
|
|
100
88
|
continue; // Fichier corrompu ou hors schéma : il ne doit pas casser la liste.
|
|
101
89
|
}
|
|
102
|
-
summaries.push(
|
|
103
|
-
id: design.id,
|
|
104
|
-
name: design.name,
|
|
105
|
-
group: design.group,
|
|
106
|
-
width: design.width,
|
|
107
|
-
height: design.height,
|
|
108
|
-
nodeCount: design.nodes.length,
|
|
109
|
-
updatedAt: design.updatedAt,
|
|
110
|
-
revision: design.revision,
|
|
111
|
-
lastEditor: design.lastEditor,
|
|
112
|
-
pendingChanges: countChanges(design.baseline.nodes, design.nodes),
|
|
113
|
-
});
|
|
90
|
+
summaries.push(summaryOf(design));
|
|
114
91
|
}
|
|
115
|
-
return summaries
|
|
92
|
+
return sortSummaries(summaries);
|
|
116
93
|
}
|
|
117
94
|
has(id) {
|
|
118
95
|
return fs.existsSync(this.filePath(id));
|
|
@@ -138,13 +115,7 @@ export class DesignStore {
|
|
|
138
115
|
}
|
|
139
116
|
// Mise à niveau du format avant validation : un fichier écrit par une
|
|
140
117
|
// version antérieure reste lisible, et n'est réécrit qu'en cas de changement.
|
|
141
|
-
const migrated =
|
|
142
|
-
const parsed = DesignSchema.safeParse(migrated ?? raw);
|
|
143
|
-
if (!parsed.success) {
|
|
144
|
-
throw new DesignError(`Design "${id}" does not match the expected schema: ${parsed.error.issues
|
|
145
|
-
.map((i) => `${i.path.join(".")} ${i.message}`)
|
|
146
|
-
.join("; ")}`);
|
|
147
|
-
}
|
|
118
|
+
const { design, migrated } = parseDesign(raw, `"${id}"`);
|
|
148
119
|
if (migrated) {
|
|
149
120
|
// Best effort : un répertoire de données en lecture seule ne doit pas
|
|
150
121
|
// empêcher de lire le design, la migration reste appliquée en mémoire.
|
|
@@ -152,42 +123,19 @@ export class DesignStore {
|
|
|
152
123
|
// de `withLock`, et le verrou n'est pas réentrant. Le contenu écrit est
|
|
153
124
|
// celui qu'on vient de lire, la mise à niveau ne perd donc rien.
|
|
154
125
|
try {
|
|
155
|
-
this.write(
|
|
126
|
+
this.write(design);
|
|
156
127
|
}
|
|
157
128
|
catch {
|
|
158
129
|
this.cache.delete(id);
|
|
159
130
|
}
|
|
160
|
-
return
|
|
131
|
+
return design;
|
|
161
132
|
}
|
|
162
133
|
if (stamp)
|
|
163
|
-
this.cache.set(id, { design
|
|
164
|
-
return
|
|
134
|
+
this.cache.set(id, { design, stamp });
|
|
135
|
+
return design;
|
|
165
136
|
}
|
|
166
137
|
create(input) {
|
|
167
|
-
const
|
|
168
|
-
if (!name)
|
|
169
|
-
throw new DesignError(`"name" is required to create a design.`);
|
|
170
|
-
const width = input.width ?? 1440;
|
|
171
|
-
const height = input.height ?? 900;
|
|
172
|
-
if (!Number.isFinite(width) || width <= 0)
|
|
173
|
-
throw new DesignError(`"width" must be a positive number.`);
|
|
174
|
-
if (!Number.isFinite(height) || height <= 0)
|
|
175
|
-
throw new DesignError(`"height" must be a positive number.`);
|
|
176
|
-
const now = new Date().toISOString();
|
|
177
|
-
const design = {
|
|
178
|
-
id: this.allocateId(name),
|
|
179
|
-
name,
|
|
180
|
-
group: normalizeDesignGroup(input.group),
|
|
181
|
-
width,
|
|
182
|
-
height,
|
|
183
|
-
background: input.background ?? "#ffffff",
|
|
184
|
-
nodes: [],
|
|
185
|
-
createdAt: now,
|
|
186
|
-
updatedAt: now,
|
|
187
|
-
revision: 0,
|
|
188
|
-
lastEditor: "agent",
|
|
189
|
-
baseline: { revision: 0, at: now, nodes: [] },
|
|
190
|
-
};
|
|
138
|
+
const design = newDesign(this.allocateId(input.name?.trim() ?? ""), input, new Date().toISOString());
|
|
191
139
|
this.write(design);
|
|
192
140
|
return design;
|
|
193
141
|
}
|
|
@@ -199,21 +147,7 @@ export class DesignStore {
|
|
|
199
147
|
duplicate(sourceId, input = {}) {
|
|
200
148
|
const source = this.get(sourceId);
|
|
201
149
|
const name = input.name?.trim() || `${source.name} (copy)`;
|
|
202
|
-
const
|
|
203
|
-
const design = {
|
|
204
|
-
id: this.allocateId(name),
|
|
205
|
-
name,
|
|
206
|
-
group: input.group === undefined ? source.group : normalizeDesignGroup(input.group),
|
|
207
|
-
width: source.width,
|
|
208
|
-
height: source.height,
|
|
209
|
-
background: source.background,
|
|
210
|
-
nodes: structuredClone(source.nodes),
|
|
211
|
-
createdAt: now,
|
|
212
|
-
updatedAt: now,
|
|
213
|
-
revision: 0,
|
|
214
|
-
lastEditor: "agent",
|
|
215
|
-
baseline: { revision: 0, at: now, nodes: structuredClone(source.nodes) },
|
|
216
|
-
};
|
|
150
|
+
const design = duplicatedDesign(source, this.allocateId(name), { ...input, name }, new Date().toISOString());
|
|
217
151
|
this.write(design);
|
|
218
152
|
return design;
|
|
219
153
|
}
|
|
@@ -228,36 +162,14 @@ export class DesignStore {
|
|
|
228
162
|
return this.withLock(id, () => this.mutateLocked(id, editor, fn, options));
|
|
229
163
|
}
|
|
230
164
|
mutateLocked(id, editor, fn, options) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
`the stored design is at revision ${current.revision}. Reload it with get_design and reapply your change — ` +
|
|
235
|
-
`writing now would discard the other editor's work.`);
|
|
236
|
-
}
|
|
237
|
-
const draft = structuredClone(current);
|
|
238
|
-
fn(draft);
|
|
239
|
-
// Les invariants de hiérarchie sont vérifiés ici, une seule fois : aucun
|
|
240
|
-
// appelant — outil MCP ou sauvegarde depuis la GUI — ne peut les contourner.
|
|
241
|
-
draft.nodes = normalizeHierarchy(draft.nodes);
|
|
242
|
-
draft.revision = current.revision + 1;
|
|
243
|
-
draft.updatedAt = new Date().toISOString();
|
|
244
|
-
draft.lastEditor = editor;
|
|
245
|
-
const parsed = DesignSchema.safeParse(draft);
|
|
246
|
-
if (!parsed.success) {
|
|
247
|
-
throw new DesignError(`Resulting design is invalid: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
|
|
248
|
-
}
|
|
249
|
-
this.write(parsed.data);
|
|
250
|
-
return parsed.data;
|
|
165
|
+
const next = mutatedDesign(this.get(id), editor, fn, options);
|
|
166
|
+
this.write(next);
|
|
167
|
+
return next;
|
|
251
168
|
}
|
|
252
169
|
/** Fige l'état courant comme nouvelle référence de comparaison. */
|
|
253
170
|
markSynced(id) {
|
|
254
171
|
return this.withLock(id, () => {
|
|
255
|
-
const
|
|
256
|
-
const now = new Date().toISOString();
|
|
257
|
-
const next = {
|
|
258
|
-
...structuredClone(current),
|
|
259
|
-
baseline: { revision: current.revision, at: now, nodes: structuredClone(current.nodes) },
|
|
260
|
-
};
|
|
172
|
+
const next = syncedDesign(this.get(id), new Date().toISOString());
|
|
261
173
|
this.write(next);
|
|
262
174
|
return next;
|
|
263
175
|
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type FeedbackRound } from "../../shared/feedback.js";
|
|
2
|
+
import { type Design } from "../../shared/schemas/design.schema.js";
|
|
3
|
+
import type { FeedbackFile } from "../../shared/schemas/feedback.schema.js";
|
|
4
|
+
import { DesignError } from "./node-factory.js";
|
|
5
|
+
/**
|
|
6
|
+
* Échanges de la version autonome avec un assistant conversationnel :
|
|
7
|
+
*
|
|
8
|
+
* - le prompt qui lui décrit le format d'une page ;
|
|
9
|
+
* - la lecture stricte d'une page qu'il renvoie, refusée en bloc à la moindre
|
|
10
|
+
* erreur avec un message qu'on peut lui recoller tel quel ;
|
|
11
|
+
* - le récap d'un retour, qui lui transmet les remarques et l'état courant des
|
|
12
|
+
* pages concernées ;
|
|
13
|
+
* - l'export et la relecture du projet complet.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PAGE_FORMAT = "mcp-design-page";
|
|
16
|
+
export declare const PROJECT_FORMAT = "mcp-design-project";
|
|
17
|
+
export declare const EXCHANGE_VERSION = 1;
|
|
18
|
+
/** Pages, remarques et captures (data URI PNG par nom de fichier) d'un projet autonome. */
|
|
19
|
+
export interface ProjectData {
|
|
20
|
+
designs: Design[];
|
|
21
|
+
feedback: FeedbackFile;
|
|
22
|
+
captures: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
/** Refus d'un fichier importé. `issues` liste chaque problème, `message` s'adresse à l'assistant. */
|
|
25
|
+
export declare class ExchangeError extends DesignError {
|
|
26
|
+
readonly issues: string[];
|
|
27
|
+
constructor(issues: string[]);
|
|
28
|
+
}
|
|
29
|
+
/** Une page lue et validée, prête à remplacer ou rejoindre celles du projet. */
|
|
30
|
+
export interface PageImport {
|
|
31
|
+
/** La page telle qu'elle sera enregistrée, synchronisée sur son nouveau contenu. */
|
|
32
|
+
design: Design;
|
|
33
|
+
/** Page existante que l'import remplace, ou null pour une nouvelle page. */
|
|
34
|
+
replaces: Design | null;
|
|
35
|
+
/** Remarques déclarées traitées, avec le retour qui les contient. */
|
|
36
|
+
processed: {
|
|
37
|
+
round: number;
|
|
38
|
+
remarkId: string;
|
|
39
|
+
note: string | null;
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Lit une page renvoyée par l'assistant. Rien n'est appliqué ici : le résultat
|
|
44
|
+
* dit ce que l'import ferait, `applyPageImport` l'exécute.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parsePageReply(text: string, project: Pick<ProjectData, "designs" | "feedback">): PageImport;
|
|
47
|
+
/** Enregistre une page lue par `parsePageReply` et note les remarques traitées. */
|
|
48
|
+
export declare function applyPageImport(project: ProjectData, plan: PageImport): void;
|
|
49
|
+
interface PageFile {
|
|
50
|
+
format: string;
|
|
51
|
+
version: number;
|
|
52
|
+
page: {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
group: string | null;
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
background: string;
|
|
59
|
+
nodes: object[];
|
|
60
|
+
};
|
|
61
|
+
processed: {
|
|
62
|
+
remark: string;
|
|
63
|
+
note?: string;
|
|
64
|
+
}[];
|
|
65
|
+
}
|
|
66
|
+
/** La page au format d'échange. La taille des groupes et des instances, dérivée, n'y figure pas. */
|
|
67
|
+
export declare function pageFileOf(design: Design): PageFile;
|
|
68
|
+
/** JSON d'une page, un élément par ligne : lisible et bien plus court qu'une indentation complète. */
|
|
69
|
+
export declare function pageJson(file: PageFile): string;
|
|
70
|
+
export declare function projectFileOf(project: ProjectData, exportedAt: string): object;
|
|
71
|
+
/** Relit un export complet ; refusé en bloc à la moindre incohérence. */
|
|
72
|
+
export declare function parseProjectFile(text: string): ProjectData;
|
|
73
|
+
/** Ce que l'utilisateur colle en tête de conversation, suivi de sa demande. */
|
|
74
|
+
export declare function creationPrompt(): string;
|
|
75
|
+
/** Ce qui, pour l'assistant, exprime les coordonnées des remarques. */
|
|
76
|
+
export declare const COORDINATES_SOURCE = "the page JSON";
|
|
77
|
+
/** Fichiers joints au récap : une vue de chaque page, et une image par capture. */
|
|
78
|
+
export interface RecapFiles {
|
|
79
|
+
/** Vue d'ensemble de chaque page concernée, par id de page. */
|
|
80
|
+
pages: Map<string, string>;
|
|
81
|
+
/** Fichier de chaque capture, dans l'ordre où le texte du retour les numérote. */
|
|
82
|
+
captures: string[];
|
|
83
|
+
}
|
|
84
|
+
/** Texte du récap d'un retour, à transmettre à l'assistant avec les fichiers joints. */
|
|
85
|
+
export declare function recapText(round: FeedbackRound, designs: Design[], files: RecapFiles): string;
|
|
86
|
+
export {};
|