@imenam/mcp-design 1.0.12 → 1.0.14
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 +11 -4
- package/dist/src/gui-worker.js +55 -53
- package/dist/src/index.js +10 -7
- package/gui/dist/assets/index-Cbtaxo0u.js +15 -0
- package/gui/dist/assets/index-CmUxiS-4.css +1 -0
- package/gui/dist/assets/{search-jSqY2fPS.js → search-BYdwq4vz.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/gui/dist/assets/index-DNUHDrGi.css +0 -1
- package/gui/dist/assets/index-uk5sDkVA.js +0 -15
package/README.md
CHANGED
|
@@ -313,6 +313,13 @@ mark_feedback_processed { round: 1, remark_ids: ["rm_…"], note: "…" } ←
|
|
|
313
313
|
|
|
314
314
|
Trois colonnes : la liste des planches, le canvas, l'inspecteur et les calques.
|
|
315
315
|
|
|
316
|
+
- **Pages** — la barre de gauche présente l'arbre des groupes : chevron, dossier ouvert ou fermé et nombre de pages par groupe, avec un trait vertical qui relie chaque niveau à son parent. À chaque niveau, les sous-groupes viennent d'abord, puis les pages, chacun par ordre alphabétique ; les pages hors groupe sont regroupées en bas sous « Hors groupe ». À l'ouverture, l'interface rouvre la dernière page consultée dans ce navigateur (mémorisée localement, propre à chaque poste) ; à défaut, la première page de la liste.
|
|
317
|
+
- **Groupe affiché** — le sélecteur en tête montre « Tous les groupes » ou un seul groupe de premier niveau, avec son nombre de pages et de pages à synchroniser. Le choix est mémorisé dans le navigateur.
|
|
318
|
+
- **Recherche et filtres** — le champ de recherche porte sur les noms de pages et de dossiers, à toute profondeur, sans tenir compte des accents ni de la casse (`Échap` le vide) : un dossier dont le nom correspond reste affiché en entier, sinon seules ses pages et sous-dossiers qui correspondent restent. Les filtres « Toutes », « À synchroniser » et « Remarques » (remarques non transmises) portent leur compteur. Une recherche ou un filtre actif déplie tous les groupes.
|
|
319
|
+
- **Tout replier** — le bouton à droite de la recherche replie tous les dossiers du groupe affiché ; quand ils le sont déjà tous, il les déplie. Il est inactif pendant une recherche ou un filtre, qui gardent tout déplié.
|
|
320
|
+
- **Cartes de page** — un aperçu aux proportions de la planche, le nom, le format et la date de dernière modification ; à droite, la bulle violette des remarques non transmises et le point ambre des modifications à synchroniser. Le menu « … », visible au survol, propose **Déplacer vers…** (vide : hors groupe), **Dupliquer** (la copie s'ouvre aussitôt) et **Supprimer**. Une page se range aussi par glisser-déposer sur un groupe.
|
|
321
|
+
- **Nouvelle page** — le bouton en pied ouvre le formulaire de création ; le groupe est prérempli avec le groupe affiché.
|
|
322
|
+
|
|
316
323
|
- **Outils** — sélection, rectangle, ellipse, ligne, texte, icône. Glisser pour dimensionner, cliquer pour poser à la taille par défaut. L'outil icône ouvre d'abord son sélecteur : poser une icône suppose d'avoir choisi laquelle.
|
|
317
324
|
- **Édition** — déplacement au glisser, 8 poignées de redimensionnement (2 extrémités pour une ligne), sélection multiple au rectangle d'encadrement ou au Maj+clic.
|
|
318
325
|
- **Groupes** — `Ctrl+G` groupe la sélection, `Ctrl+Maj+G` la dissout. Un clic dans le canvas sélectionne le **groupe le plus haut** ; `Alt+clic` vise l'élément exact à l'intérieur. Déplacer un groupe déplace tout son contenu.
|
|
@@ -328,9 +335,9 @@ Trois colonnes : la liste des planches, le canvas, l'inspecteur et les calques.
|
|
|
328
335
|
|
|
329
336
|
Raccourcis : `V` `R` `O` `L` `T` `I` (outils), `Ctrl+Z` / `Ctrl+Y` (annuler/rétablir), `Ctrl+G` / `Ctrl+Maj+G` (grouper / dissoudre), `Ctrl+D` (dupliquer), `Ctrl+A` (tout sélectionner), `Suppr`, flèches (déplacer d'1 px, 10 px avec `Maj`), `Échap`.
|
|
330
337
|
|
|
331
|
-
La sauvegarde est automatique et différée. Une modification faite par l'agent pendant que la GUI est ouverte y apparaît
|
|
338
|
+
La sauvegarde est automatique et différée. Une modification faite par l'agent pendant que la GUI est ouverte y apparaît dans les 3 secondes : la GUI interroge `GET /api/changes` toutes les 3 s ; une modification faite dans la GUI est enregistrée comme venant de l'utilisateur, et c'est ce que `get_changes` restitue.
|
|
332
339
|
|
|
333
|
-
Le
|
|
340
|
+
Le point ambre d'une page indique qu'elle porte des modifications que l'agent n'a pas encore acquittées avec `mark_synced` ; leur nombre s'affiche au survol.
|
|
334
341
|
|
|
335
342
|
### Mode Remarques
|
|
336
343
|
|
|
@@ -359,9 +366,9 @@ Les retours créés avant l'arrivée des noms reçoivent « Retour n°N », et a
|
|
|
359
366
|
Deux processus, conformément au standard de l'écosystème (`@imenam/mcp-gui-interface`) :
|
|
360
367
|
|
|
361
368
|
- **MCP Master** (`src/index.ts`) — protocole JSON-RPC sur stdio, stockage des designs, cycle de vie du worker via `GuiLauncher`.
|
|
362
|
-
- **GUI Worker** (`src/gui-worker.ts`) — serveur Hono, enregistrement auprès du proxy via `ProxyClient`, API REST
|
|
369
|
+
- **GUI Worker** (`src/gui-worker.ts`) — serveur Hono, enregistrement auprès du proxy via `ProxyClient`, API REST, sert la SPA React.
|
|
363
370
|
|
|
364
|
-
Les deux communiquent par IPC. Le worker se termine proprement quand le master disparaît (canal IPC fermé, PID parent absent) et quand une autre instance est déjà enregistrée (HTTP 409), ce qui évite les GUI orphelines.
|
|
371
|
+
Les deux communiquent par IPC. Les évènements que le master pousse au worker (`DESIGNS_UPDATED`, `DESIGN_UPDATED`, `REMARKS_UPDATED`) entrent dans un journal en mémoire, numéroté et limité aux 200 dernières entrées. La GUI le relit toutes les 3 s par `GET /api/changes?since=<seq>&epoch=<epoch>`, qui répond immédiatement : aucune connexion ne reste ouverte, ce qui laisse libres les connexions que le navigateur accorde à l'origine du proxy, partagée par toutes les GUI. La réponse vaut `{ epoch, seq, reset: false, events }` avec les évènements postérieurs à `since`, ou `{ epoch, seq, reset: true }` quand la position du client n'est pas exploitable (premier appel, worker redémarré, entrées déjà sorties du journal) : la GUI recharge alors la liste des pages, les remarques et la page ouverte. Un `since` qui n'est pas un entier positif ou nul est refusé (400). Le worker se termine proprement quand le master disparaît (canal IPC fermé, PID parent absent) et quand une autre instance est déjà enregistrée (HTTP 409), ce qui évite les GUI orphelines.
|
|
365
372
|
|
|
366
373
|
`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.
|
|
367
374
|
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { serve } from "@hono/node-server";
|
|
2
2
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
|
-
import { streamSSE } from "hono/streaming";
|
|
5
4
|
import { GUI_LISTEN_HOST, ProxyClient, createLogger, honoGuiGuard, setupLogging } from "@imenam/mcp-gui-interface";
|
|
6
5
|
import path from "path";
|
|
7
6
|
import fs from "fs";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import { IPCMessageSchema } from "../shared/schemas/design.schema.js";
|
|
10
10
|
import { resolveLogDir } from "./core/log-dir.js";
|
|
@@ -79,7 +79,6 @@ const parentCheckInterval = setInterval(() => {
|
|
|
79
79
|
const app = new Hono();
|
|
80
80
|
app.use("*", honoGuiGuard());
|
|
81
81
|
let proxyClient = null;
|
|
82
|
-
const sseControllers = new Set();
|
|
83
82
|
const pendingRequests = new Map();
|
|
84
83
|
function safeSend(message) {
|
|
85
84
|
if (process.send && process.connected) {
|
|
@@ -115,7 +114,23 @@ function ipcRequest(type, data, timeout = 5000) {
|
|
|
115
114
|
}
|
|
116
115
|
});
|
|
117
116
|
}
|
|
118
|
-
const PUSHED_EVENTS = new Set(["
|
|
117
|
+
const PUSHED_EVENTS = new Set(["DESIGNS_UPDATED", "DESIGN_UPDATED", "REMARKS_UPDATED"]);
|
|
118
|
+
/**
|
|
119
|
+
* Journal des évènements poussés par le master, relu par la GUI via
|
|
120
|
+
* `/api/changes`. Chaque entrée porte un numéro croissant ; seules les
|
|
121
|
+
* `JOURNAL_LIMIT` dernières sont gardées. `epoch` change à chaque démarrage du
|
|
122
|
+
* worker : un client qui le voit changer sait que sa position ne vaut plus rien.
|
|
123
|
+
*/
|
|
124
|
+
const JOURNAL_LIMIT = 200;
|
|
125
|
+
const epoch = randomUUID();
|
|
126
|
+
let lastSeq = 0;
|
|
127
|
+
const journal = [];
|
|
128
|
+
function appendToJournal(event) {
|
|
129
|
+
lastSeq += 1;
|
|
130
|
+
journal.push({ seq: lastSeq, event });
|
|
131
|
+
if (journal.length > JOURNAL_LIMIT)
|
|
132
|
+
journal.splice(0, journal.length - JOURNAL_LIMIT);
|
|
133
|
+
}
|
|
119
134
|
function setupIpcErrorHandlers() {
|
|
120
135
|
process.on("error", (err) => {
|
|
121
136
|
if (err.code === "ERR_IPC_CHANNEL_CLOSED")
|
|
@@ -123,7 +138,7 @@ function setupIpcErrorHandlers() {
|
|
|
123
138
|
logger.error(`Process error: ${err.message}`);
|
|
124
139
|
});
|
|
125
140
|
process.on("disconnect", () => {
|
|
126
|
-
logger.warn("IPC disconnected from MCP master. GUI worker will exit
|
|
141
|
+
logger.warn("IPC disconnected from MCP master. GUI worker will exit.");
|
|
127
142
|
process.exit(0);
|
|
128
143
|
});
|
|
129
144
|
process.on("message", (msg) => {
|
|
@@ -143,16 +158,8 @@ function setupIpcErrorHandlers() {
|
|
|
143
158
|
});
|
|
144
159
|
return;
|
|
145
160
|
}
|
|
146
|
-
if (msg && PUSHED_EVENTS.has(msg.type))
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
controller.writeSSE({ data: JSON.stringify(msg), event: "message" });
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
sseControllers.delete(controller);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
161
|
+
if (msg && PUSHED_EVENTS.has(msg.type))
|
|
162
|
+
appendToJournal(msg);
|
|
156
163
|
});
|
|
157
164
|
}
|
|
158
165
|
// --- Routes HTTP ------------------------------------------------------------
|
|
@@ -167,45 +174,27 @@ app.get("/proxy/health", (c) => {
|
|
|
167
174
|
timestamp: new Date().toISOString(),
|
|
168
175
|
});
|
|
169
176
|
});
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
await stream.writeSSE({ data: "ping", event: "ping" });
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
logger.warn(`SSE /api/events: keepalive ping failed (id=${clientId}): ${err?.message ?? err}`);
|
|
196
|
-
sseControllers.delete(stream);
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}));
|
|
201
|
-
app.post("/api/client-diagnostics", async (c) => {
|
|
202
|
-
try {
|
|
203
|
-
logger.warn(`Client diagnostics (SSE/EventSource): ${JSON.stringify(await c.req.json())}`);
|
|
204
|
-
}
|
|
205
|
-
catch (e) {
|
|
206
|
-
logger.warn(`Client diagnostics: invalid JSON body (${e?.message ?? e})`);
|
|
207
|
-
}
|
|
208
|
-
return c.json({ ok: true });
|
|
177
|
+
/**
|
|
178
|
+
* Évènements survenus depuis la position `since` du client. `reset: true`
|
|
179
|
+
* quand cette position n'est pas exploitable — absente, d'un autre démarrage du
|
|
180
|
+
* worker, plus ancienne que le journal ou en avance sur lui : le client
|
|
181
|
+
* recharge alors tout et repart de `seq`.
|
|
182
|
+
*/
|
|
183
|
+
app.get("/api/changes", (c) => {
|
|
184
|
+
const rawSince = c.req.query("since");
|
|
185
|
+
const clientEpoch = c.req.query("epoch");
|
|
186
|
+
if (rawSince !== undefined && !/^\d+$/.test(rawSince)) {
|
|
187
|
+
return c.json({ error: `Invalid "since" parameter: expected a non-negative integer, got "${rawSince}"` }, 400);
|
|
188
|
+
}
|
|
189
|
+
const reset = { epoch, seq: lastSeq, reset: true };
|
|
190
|
+
if (rawSince === undefined || !clientEpoch || clientEpoch !== epoch)
|
|
191
|
+
return c.json(reset);
|
|
192
|
+
const since = Number(rawSince);
|
|
193
|
+
const oldestSeq = journal.length > 0 ? journal[0].seq : lastSeq + 1;
|
|
194
|
+
if (since > lastSeq || since < oldestSeq - 1)
|
|
195
|
+
return c.json(reset);
|
|
196
|
+
const events = journal.filter((entry) => entry.seq > since).map((entry) => entry.event);
|
|
197
|
+
return c.json({ epoch, seq: lastSeq, reset: false, events });
|
|
209
198
|
});
|
|
210
199
|
app.get("/api/config", async (c) => {
|
|
211
200
|
const result = await ipcRequest("GET_CONFIG");
|
|
@@ -264,6 +253,19 @@ app.put("/api/designs/:id/group", async (c) => {
|
|
|
264
253
|
return c.json({ error: result.error }, 400);
|
|
265
254
|
return c.json(result.data);
|
|
266
255
|
});
|
|
256
|
+
app.post("/api/designs/:id/duplicate", async (c) => {
|
|
257
|
+
let body = {};
|
|
258
|
+
try {
|
|
259
|
+
body = await c.req.json();
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
263
|
+
}
|
|
264
|
+
const result = await ipcRequest("DUPLICATE_DESIGN", { id: c.req.param("id"), name: body?.name }, 10000);
|
|
265
|
+
if (result.error)
|
|
266
|
+
return c.json({ error: result.error }, 400);
|
|
267
|
+
return c.json(result.data);
|
|
268
|
+
});
|
|
267
269
|
app.delete("/api/designs/:id", async (c) => {
|
|
268
270
|
const result = await ipcRequest("DELETE_DESIGN", { id: c.req.param("id") }, 10000);
|
|
269
271
|
if (result.error)
|
package/dist/src/index.js
CHANGED
|
@@ -140,13 +140,6 @@ function handleIPCMessage(msg) {
|
|
|
140
140
|
logger.info(`GUI ready at ${guiUrl}`);
|
|
141
141
|
guiStartWaiter?.({ outcome: "ready", url: msg.data?.url });
|
|
142
142
|
return;
|
|
143
|
-
case "GET_INITIAL_STATE":
|
|
144
|
-
broadcastToGUI({
|
|
145
|
-
type: "INITIAL_STATE",
|
|
146
|
-
data: { designs: store.list(), version: APP_VERSION },
|
|
147
|
-
timestamp: new Date().toISOString(),
|
|
148
|
-
});
|
|
149
|
-
return;
|
|
150
143
|
case "LIST_DESIGNS":
|
|
151
144
|
reply(msg, "LIST_DESIGNS_RESPONSE", { designs: store.list() });
|
|
152
145
|
return;
|
|
@@ -190,6 +183,16 @@ function handleIPCMessage(msg) {
|
|
|
190
183
|
replyError(msg, "DELETE_DESIGN_RESPONSE", err.message);
|
|
191
184
|
}
|
|
192
185
|
return;
|
|
186
|
+
case "DUPLICATE_DESIGN":
|
|
187
|
+
try {
|
|
188
|
+
const name = typeof msg.data?.name === "string" ? msg.data.name : undefined;
|
|
189
|
+
const design = store.duplicate(String(msg.data?.id), { name });
|
|
190
|
+
reply(msg, "DUPLICATE_DESIGN_RESPONSE", { design, designs: store.list() });
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
replyError(msg, "DUPLICATE_DESIGN_RESPONSE", err.message);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
193
196
|
case "SET_DESIGN_GROUP":
|
|
194
197
|
try {
|
|
195
198
|
store.mutate(String(msg.data?.id), "user", (draft) => {
|