paintress 0.5.2 → 0.6.0
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 +29 -13
- package/README.zh-CN.md +29 -13
- package/dist/cli.js +367 -63
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/{render-CCxYNL6A.js → render-BSf62388.js} +9 -3
- package/dist/{server-ssrAxbTR.js → server-DZRNNWac.js} +752 -129
- package/package.json +1 -1
- package/skills/paintress/SKILL.md +42 -16
- package/ui/dist/assets/{index-CP8lZ8lp.css → index-BFcvu7wr.css} +1 -1
- package/ui/dist/assets/index-CEhK1JXE.js +8 -0
- package/ui/dist/assets/marked.esm-DZCVybOJ.js +72 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BnDj5nL8.js +0 -8
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { A as genId, C as update, E as saveDoc, M as DocumentSchema, N as PEN_DOC_VERSION, O as validateDoc, S as remove, T as loadDoc, b as move, g as batch, k as collectIds, qt as VERSION, t as renderDocument, v as get, x as query, y as insert } from "./render-
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { A as genId, C as update, E as saveDoc, M as DocumentSchema, N as PEN_DOC_VERSION, O as validateDoc, S as remove, T as loadDoc, b as move, g as batch, k as collectIds, m as expandRefs, qt as VERSION, t as renderDocument, v as get, x as query, y as insert } from "./render-BSf62388.js";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { chromium } from "playwright-core";
|
|
8
8
|
import { connect } from "node:net";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
9
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
12
|
// \0rolldown/runtime.js
|
|
13
13
|
var __defProp = Object.defineProperty;
|
|
14
14
|
var __exportAll = (all, flags) => {
|
|
@@ -49,6 +49,347 @@ function createDoc(opts = {}) {
|
|
|
49
49
|
boardId
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
// src/doc/project.ts
|
|
53
|
+
/**
|
|
54
|
+
* 项目级预设与组件库(规格见 specs/project-presets.md、specs/components.md)。
|
|
55
|
+
*
|
|
56
|
+
* 约定:项目根 = 从设计稿所在目录向上找、最近的含 `__preset.ptr` / `__components.ptr` 的目录。
|
|
57
|
+
* 两个文件都是**普通设计稿**:预设值走原生 `variables`,组件走 `reusable`;项目级语义只放在
|
|
58
|
+
* `x-paintress` 扩展位里。因此 pen 读取器看到它们仍是合法文档。
|
|
59
|
+
*/
|
|
60
|
+
const PRESET_FILE = "__preset.ptr";
|
|
61
|
+
const COMPONENTS_FILE = "__components.ptr";
|
|
62
|
+
function isPresetType(v) {
|
|
63
|
+
return v === "color" || v === "radius";
|
|
64
|
+
}
|
|
65
|
+
/** 拼出设计稿旁边那份笔记的绝对路径(只认 .ptr / .pen,其余扩展名返回 null) */
|
|
66
|
+
function notePathFor(designAbsPath) {
|
|
67
|
+
if (!/\.(ptr|pen)$/i.test(designAbsPath)) return null;
|
|
68
|
+
return designAbsPath.replace(/\.(ptr|pen)$/i, ".md");
|
|
69
|
+
}
|
|
70
|
+
function isRecord(v) {
|
|
71
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
72
|
+
}
|
|
73
|
+
/** 目录 → 项目根(最近的含预设/组件文件的祖先目录);找不到返回 null */
|
|
74
|
+
function findProjectRoot(from) {
|
|
75
|
+
let dir = resolve(from);
|
|
76
|
+
try {
|
|
77
|
+
if (!statSync(dir).isDirectory()) dir = dirname(dir);
|
|
78
|
+
} catch {
|
|
79
|
+
dir = dirname(dir);
|
|
80
|
+
}
|
|
81
|
+
for (;;) {
|
|
82
|
+
if (existsSync(join(dir, "__preset.ptr")) || existsSync(join(dir, "__components.ptr"))) return dir;
|
|
83
|
+
const parent = dirname(dir);
|
|
84
|
+
if (parent === dir || !parent.startsWith(sep)) return null;
|
|
85
|
+
dir = parent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** 传入文件 → 其所在目录;传入目录 → 自身 */
|
|
89
|
+
function projectDirOf(from) {
|
|
90
|
+
const abs = resolve(from);
|
|
91
|
+
try {
|
|
92
|
+
return statSync(abs).isDirectory() ? abs : dirname(abs);
|
|
93
|
+
} catch {
|
|
94
|
+
return dirname(abs);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** 从设计稿 / 目录定位预设文件(存在才算命中) */
|
|
98
|
+
function presetPathFor(from) {
|
|
99
|
+
const root = findProjectRoot(from);
|
|
100
|
+
if (root === null) return null;
|
|
101
|
+
const p = join(root, PRESET_FILE);
|
|
102
|
+
return existsSync(p) ? p : null;
|
|
103
|
+
}
|
|
104
|
+
function componentsPathFor(from) {
|
|
105
|
+
const root = findProjectRoot(from);
|
|
106
|
+
if (root === null) return null;
|
|
107
|
+
const p = join(root, COMPONENTS_FILE);
|
|
108
|
+
return existsSync(p) ? p : null;
|
|
109
|
+
}
|
|
110
|
+
function presetKindOf(key, doc) {
|
|
111
|
+
const meta = doc["x-paintress"];
|
|
112
|
+
const items = isRecord(meta) && isRecord(meta["presets"]) ? meta["presets"]["items"] : void 0;
|
|
113
|
+
const declared = isRecord(items) && isRecord(items[key]) ? items[key]["type"] : void 0;
|
|
114
|
+
if (isPresetType(declared)) return declared;
|
|
115
|
+
const def = doc.variables?.[key];
|
|
116
|
+
const t = isRecord(def) ? def["type"] : void 0;
|
|
117
|
+
if (t === "color") return "color";
|
|
118
|
+
if (t === "number") return "radius";
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/** 读预设文件;不存在返回 null。结构错误直接抛出(带路径上下文) */
|
|
122
|
+
function readPresetFile(path) {
|
|
123
|
+
if (!existsSync(path)) return null;
|
|
124
|
+
const loaded = loadDoc(path);
|
|
125
|
+
const items = [];
|
|
126
|
+
for (const [key, def] of Object.entries(loaded.doc.variables ?? {})) {
|
|
127
|
+
if (!isRecord(def)) continue;
|
|
128
|
+
const type = presetKindOf(key, loaded.doc);
|
|
129
|
+
if (type === null) continue;
|
|
130
|
+
const raw = def["value"];
|
|
131
|
+
if (Array.isArray(raw)) continue;
|
|
132
|
+
const meta = loaded.doc["x-paintress"];
|
|
133
|
+
const metaItems = isRecord(meta) && isRecord(meta["presets"]) ? meta["presets"]["items"] : void 0;
|
|
134
|
+
const declared = isRecord(metaItems) && isRecord(metaItems[key]) ? metaItems[key] : void 0;
|
|
135
|
+
items.push({
|
|
136
|
+
key,
|
|
137
|
+
type,
|
|
138
|
+
name: typeof declared?.["name"] === "string" && declared["name"] !== "" ? declared["name"] : key,
|
|
139
|
+
value: String(raw)
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
items.sort((a, b) => a.type === b.type ? a.key.localeCompare(b.key) : a.type.localeCompare(b.type));
|
|
143
|
+
return {
|
|
144
|
+
path,
|
|
145
|
+
doc: loaded.doc,
|
|
146
|
+
mtimeMs: loaded.mtimeMs,
|
|
147
|
+
items
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** 项目预设(从任意设计稿/目录出发);没有项目或没有文件都返回空列表 */
|
|
151
|
+
function listPresets(from) {
|
|
152
|
+
const path = presetPathFor(from);
|
|
153
|
+
if (path === null) return {
|
|
154
|
+
path: null,
|
|
155
|
+
items: []
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
path,
|
|
159
|
+
items: readPresetFile(path)?.items ?? []
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 渲染/校验用的副本:预设打底,文档本地同名变量优先。
|
|
164
|
+
* **绝不能拿去保存** —— 否则一次写入就把预设值烤进了设计稿。
|
|
165
|
+
*/
|
|
166
|
+
function withProjectPresets(doc, presetDoc) {
|
|
167
|
+
const vars = presetDoc?.variables;
|
|
168
|
+
if (!vars || Object.keys(vars).length === 0) return doc;
|
|
169
|
+
return {
|
|
170
|
+
...doc,
|
|
171
|
+
variables: {
|
|
172
|
+
...vars,
|
|
173
|
+
...doc.variables ?? {}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 渲染 / 校验用:按设计稿所在项目的预设补全变量,返回**副本**(绝不可拿去保存)。
|
|
179
|
+
* 这是「预设只在项目里存一份」的唯一兑付点:渲染、截图、校验、写前检查都走这里。
|
|
180
|
+
*/
|
|
181
|
+
function applyProjectPresets(doc, filePath) {
|
|
182
|
+
const p = presetPathFor(filePath);
|
|
183
|
+
const presetDoc = p === null ? null : readPresetFile(p)?.doc ?? null;
|
|
184
|
+
return {
|
|
185
|
+
doc: withProjectPresets(doc, presetDoc),
|
|
186
|
+
presetNames: presetVarNames(presetDoc)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** 项目预设提供的变量名(校验用;只读名字,不做合并) */
|
|
190
|
+
function projectPresetNames(filePath) {
|
|
191
|
+
const p = presetPathFor(filePath);
|
|
192
|
+
if (p === null) return /* @__PURE__ */ new Set();
|
|
193
|
+
return presetVarNames(readPresetFile(p)?.doc ?? null);
|
|
194
|
+
}
|
|
195
|
+
/** 预设文件对应的变量名集合(校验时用来区分「未定义」与「来自项目预设」) */
|
|
196
|
+
function presetVarNames(presetDoc) {
|
|
197
|
+
return new Set(Object.keys(presetDoc?.variables ?? {}));
|
|
198
|
+
}
|
|
199
|
+
const COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
200
|
+
/** 值归一:对外字串 → 落盘值;类型不合法时抛出可读错误 */
|
|
201
|
+
function normalizePresetValue(type, value) {
|
|
202
|
+
const raw = value.trim();
|
|
203
|
+
if (type === "color") {
|
|
204
|
+
if (!COLOR_RE.test(raw)) throw new Error(`颜色预设只接受 #RGB / #RRGGBB / #RRGGBBAA,收到:${value}`);
|
|
205
|
+
return raw.toUpperCase();
|
|
206
|
+
}
|
|
207
|
+
const n = Number(raw);
|
|
208
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`倒角预设只接受非负数,收到:${value}`);
|
|
209
|
+
return n;
|
|
210
|
+
}
|
|
211
|
+
/** 变量表里的 type:color → color,radius → number */
|
|
212
|
+
function varTypeOf(type) {
|
|
213
|
+
return type === "color" ? "color" : "number";
|
|
214
|
+
}
|
|
215
|
+
function presetMetaItems(doc) {
|
|
216
|
+
const meta = doc["x-paintress"];
|
|
217
|
+
const presets = isRecord(meta) ? meta["presets"] : void 0;
|
|
218
|
+
const items = isRecord(presets) ? presets["items"] : void 0;
|
|
219
|
+
return isRecord(items) ? items : {};
|
|
220
|
+
}
|
|
221
|
+
function emptyDoc() {
|
|
222
|
+
return {
|
|
223
|
+
version: PEN_DOC_VERSION,
|
|
224
|
+
fileToken: randomUUID(),
|
|
225
|
+
variables: {},
|
|
226
|
+
children: []
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** 注册项目时确保两个文件存在;已存在则一个字都不动 */
|
|
230
|
+
function ensureProjectScaffold(root) {
|
|
231
|
+
const created = [];
|
|
232
|
+
const targets = [{
|
|
233
|
+
file: PRESET_FILE,
|
|
234
|
+
doc: {
|
|
235
|
+
...emptyDoc(),
|
|
236
|
+
["x-paintress"]: {
|
|
237
|
+
spec: "1",
|
|
238
|
+
presets: {
|
|
239
|
+
version: 1,
|
|
240
|
+
items: {}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}, {
|
|
245
|
+
file: COMPONENTS_FILE,
|
|
246
|
+
doc: {
|
|
247
|
+
...emptyDoc(),
|
|
248
|
+
["x-paintress"]: {
|
|
249
|
+
spec: "1",
|
|
250
|
+
components: { version: 1 }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}];
|
|
254
|
+
for (const t of targets) {
|
|
255
|
+
const path = join(root, t.file);
|
|
256
|
+
if (existsSync(path)) continue;
|
|
257
|
+
try {
|
|
258
|
+
saveDoc(path, t.doc, {
|
|
259
|
+
flavor: "ptr",
|
|
260
|
+
createOnly: true
|
|
261
|
+
});
|
|
262
|
+
created.push(path);
|
|
263
|
+
} catch (cause) {
|
|
264
|
+
if (existsSync(path)) continue;
|
|
265
|
+
throw cause;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { created };
|
|
269
|
+
}
|
|
270
|
+
/** 写一条预设(新建或改值);返回最新列表。from 可以是设计稿、预设文件或目录 */
|
|
271
|
+
function setPreset(from, input) {
|
|
272
|
+
const key = input.key.trim();
|
|
273
|
+
if (key === "") throw new Error("预设 key 不能为空");
|
|
274
|
+
if (!/^[A-Za-z0-9._-]+$/.test(key)) throw new Error(`预设 key 只允许字母数字与 . _ -,收到:${key}`);
|
|
275
|
+
let path = presetPathFor(from);
|
|
276
|
+
if (path === null) {
|
|
277
|
+
const root = findProjectRoot(from) ?? projectDirOf(from);
|
|
278
|
+
ensureProjectScaffold(root);
|
|
279
|
+
path = join(root, PRESET_FILE);
|
|
280
|
+
}
|
|
281
|
+
const file = readPresetFile(path);
|
|
282
|
+
const doc = file?.doc ?? emptyDoc();
|
|
283
|
+
const value = normalizePresetValue(input.type, input.value);
|
|
284
|
+
const variables = { ...doc.variables ?? {} };
|
|
285
|
+
variables[key] = {
|
|
286
|
+
type: varTypeOf(input.type),
|
|
287
|
+
value
|
|
288
|
+
};
|
|
289
|
+
const metaItems = { ...presetMetaItems(doc) };
|
|
290
|
+
metaItems[key] = {
|
|
291
|
+
type: input.type,
|
|
292
|
+
...input.name !== void 0 && input.name !== "" ? { name: input.name } : {}
|
|
293
|
+
};
|
|
294
|
+
const next = {
|
|
295
|
+
...doc,
|
|
296
|
+
variables,
|
|
297
|
+
["x-paintress"]: {
|
|
298
|
+
...doc["x-paintress"],
|
|
299
|
+
spec: "1",
|
|
300
|
+
presets: {
|
|
301
|
+
version: 1,
|
|
302
|
+
items: metaItems
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
saveDoc(path, next, {
|
|
307
|
+
flavor: "ptr",
|
|
308
|
+
...file !== null ? { expectedMtimeMs: file.mtimeMs } : {}
|
|
309
|
+
});
|
|
310
|
+
return readPresetFile(path)?.items ?? [];
|
|
311
|
+
}
|
|
312
|
+
/** 删一条预设;返回最新列表(不存在时什么都不做) */
|
|
313
|
+
function removePreset(from, key) {
|
|
314
|
+
const path = presetPathFor(from);
|
|
315
|
+
if (path === null) return [];
|
|
316
|
+
const file = readPresetFile(path);
|
|
317
|
+
if (file === null) return [];
|
|
318
|
+
const variables = { ...file.doc.variables ?? {} };
|
|
319
|
+
if (!(key in variables)) return file.items;
|
|
320
|
+
delete variables[key];
|
|
321
|
+
const metaItems = { ...presetMetaItems(file.doc) };
|
|
322
|
+
delete metaItems[key];
|
|
323
|
+
const next = {
|
|
324
|
+
...file.doc,
|
|
325
|
+
variables,
|
|
326
|
+
["x-paintress"]: {
|
|
327
|
+
...file.doc["x-paintress"],
|
|
328
|
+
spec: "1",
|
|
329
|
+
presets: {
|
|
330
|
+
version: 1,
|
|
331
|
+
items: metaItems
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
saveDoc(path, next, {
|
|
336
|
+
flavor: "ptr",
|
|
337
|
+
expectedMtimeMs: file.mtimeMs
|
|
338
|
+
});
|
|
339
|
+
return readPresetFile(path)?.items ?? [];
|
|
340
|
+
}
|
|
341
|
+
/** 全树按 id 找节点(组件库、复制插入、画廊都用这一处) */
|
|
342
|
+
function findNodeIn(nodes, id) {
|
|
343
|
+
for (const n of nodes) {
|
|
344
|
+
if (!n || typeof n !== "object") continue;
|
|
345
|
+
const rec = n;
|
|
346
|
+
if (rec["id"] === id) return rec;
|
|
347
|
+
const kids = rec["children"];
|
|
348
|
+
if (Array.isArray(kids)) {
|
|
349
|
+
const hit = findNodeIn(kids, id);
|
|
350
|
+
if (hit !== null) return hit;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
/** 组件库里的所有 reusable 节点(全树扫描) */
|
|
356
|
+
function listComponents(componentsPath) {
|
|
357
|
+
const doc = readPresetFile(componentsPath)?.doc ?? loadDoc(componentsPath).doc;
|
|
358
|
+
const out = [];
|
|
359
|
+
const walk = (nodes) => {
|
|
360
|
+
for (const n of nodes) {
|
|
361
|
+
if (!isRecord(n)) continue;
|
|
362
|
+
if (n["reusable"] === true && typeof n["id"] === "string") out.push({
|
|
363
|
+
id: n["id"],
|
|
364
|
+
name: typeof n["name"] === "string" && n["name"] !== "" ? n["name"] : n["id"],
|
|
365
|
+
type: typeof n["type"] === "string" ? n["type"] : "?",
|
|
366
|
+
...typeof n["width"] === "number" ? { width: n["width"] } : {},
|
|
367
|
+
...typeof n["height"] === "number" ? { height: n["height"] } : {}
|
|
368
|
+
});
|
|
369
|
+
const kids = n["children"];
|
|
370
|
+
if (Array.isArray(kids)) walk(kids);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
walk(doc.children ?? []);
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
/** 读组件库文件(含 mtime,供乐观并发写入用) */
|
|
377
|
+
function readComponentsFile(path) {
|
|
378
|
+
if (!existsSync(path)) return null;
|
|
379
|
+
const loaded = loadDoc(path);
|
|
380
|
+
return {
|
|
381
|
+
doc: loaded.doc,
|
|
382
|
+
mtimeMs: loaded.mtimeMs
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/** 备注:`readFileSync` 保留给调用方做小文件快读(笔记接口用) */
|
|
386
|
+
function readTextIfExists(path, maxBytes = 524288) {
|
|
387
|
+
if (!existsSync(path)) return null;
|
|
388
|
+
const st = statSync(path);
|
|
389
|
+
if (!st.isFile()) return null;
|
|
390
|
+
if (st.size > maxBytes) throw new Error(`文件过大(${st.size} 字节,上限 ${maxBytes}):${path}`);
|
|
391
|
+
return readFileSync(path, "utf8");
|
|
392
|
+
}
|
|
52
393
|
// src/shot/screenshot.ts
|
|
53
394
|
/**
|
|
54
395
|
* 截图层:渲染 → 临时 HTML → headless Chromium → PNG(决策 3:两个消费者分开,AI 要 PNG)。
|
|
@@ -104,7 +445,7 @@ async function screenshotPen(penPath, outPng, opts = {}) {
|
|
|
104
445
|
const browserPath = findBrowser();
|
|
105
446
|
if (!browserPath) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER 指向可执行文件");
|
|
106
447
|
const loaded = loadDoc(penPath);
|
|
107
|
-
const rendered = renderDocument(loaded.doc);
|
|
448
|
+
const rendered = renderDocument(applyProjectPresets(loaded.doc, penPath).doc);
|
|
108
449
|
const target = opts.board !== void 0 ? selectBoard(rendered.boards, opts.board) : void 0;
|
|
109
450
|
const tmpDir = mkdtempSync(join(tmpdir(), "paintress-shot-"));
|
|
110
451
|
const htmlPath = join(tmpDir, "page.html");
|
|
@@ -320,6 +661,22 @@ function safeJoin(projectRootReal, requestPath) {
|
|
|
320
661
|
function isDaemonRunning() {
|
|
321
662
|
return existsSync(socketPath());
|
|
322
663
|
}
|
|
664
|
+
/**
|
|
665
|
+
* 查询运行中的 daemon:未运行、socket 残留、daemon 无响应都返回 null。
|
|
666
|
+
* 调用方拿到 null 就可以安全地走「启动」分支 —— 幂等的判据只有这一处。
|
|
667
|
+
*/
|
|
668
|
+
async function queryDaemon(timeoutMs = 2e3) {
|
|
669
|
+
if (!isDaemonRunning()) return null;
|
|
670
|
+
try {
|
|
671
|
+
const res = await sendRequest({ kind: "ping" }, timeoutMs);
|
|
672
|
+
if (res.ok && "pong" in res) return {
|
|
673
|
+
pid: res.pid,
|
|
674
|
+
port: res.port,
|
|
675
|
+
uptimeMs: res.uptimeMs
|
|
676
|
+
};
|
|
677
|
+
} catch {}
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
323
680
|
function connectSocket() {
|
|
324
681
|
return new Promise((resolve, reject) => {
|
|
325
682
|
const sock = connect(socketPath());
|
|
@@ -399,6 +756,7 @@ async function attachPersistent(project) {
|
|
|
399
756
|
closed = true;
|
|
400
757
|
sock.end();
|
|
401
758
|
},
|
|
759
|
+
isClosed: () => closed,
|
|
402
760
|
onClose: (cb) => closeCbs.push(cb)
|
|
403
761
|
});
|
|
404
762
|
} else if (!parsed.ok) {
|
|
@@ -422,6 +780,184 @@ async function attachPersistent(project) {
|
|
|
422
780
|
}));
|
|
423
781
|
});
|
|
424
782
|
}
|
|
783
|
+
function daemonPid() {
|
|
784
|
+
try {
|
|
785
|
+
const raw = readFileSync(pidPath(), "utf8").trim();
|
|
786
|
+
const pid = Number(raw);
|
|
787
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
788
|
+
} catch {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// src/web/spawn.ts
|
|
793
|
+
const START_TIMEOUT_MS = 6e3;
|
|
794
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
795
|
+
/**
|
|
796
|
+
* 复用已在运行的 daemon;显式指定了端口且与运行中的不一致时抛错
|
|
797
|
+
* (幂等不等于「悄悄忽略你的参数」:要换端口得先 `paintress stop`)。
|
|
798
|
+
*/
|
|
799
|
+
async function reuseDaemon(port) {
|
|
800
|
+
const info = await queryDaemon();
|
|
801
|
+
if (info === null) return null;
|
|
802
|
+
if (port !== void 0 && port !== info.port) throw new Error(`paintress web 已在运行(pid ${info.pid},端口 ${info.port}),与请求的端口 ${port} 不同;如需换端口请先 paintress stop`);
|
|
803
|
+
return info;
|
|
804
|
+
}
|
|
805
|
+
/** 启动超时的诊断信息:区分「进程没起来」与「进程在但控制面不通」 */
|
|
806
|
+
function timeoutHint() {
|
|
807
|
+
const pid = daemonPid();
|
|
808
|
+
if (pid !== null) try {
|
|
809
|
+
process.kill(pid, 0);
|
|
810
|
+
return `;进程 ${pid} 仍在运行但控制面无响应,先运行 paintress stop(或 kill ${pid})后重试`;
|
|
811
|
+
} catch {}
|
|
812
|
+
return ";可能是锁或端口残留,先 paintress ping 看状态,或前台跑 paintress web 看报错";
|
|
813
|
+
}
|
|
814
|
+
async function ensureDaemon(opts = {}) {
|
|
815
|
+
const running = await reuseDaemon(opts.port);
|
|
816
|
+
if (running !== null) return {
|
|
817
|
+
started: false,
|
|
818
|
+
pid: running.pid,
|
|
819
|
+
port: running.port
|
|
820
|
+
};
|
|
821
|
+
const entry = process.argv[1];
|
|
822
|
+
if (!entry) throw new Error("无法确定 CLI 入口路径,不能后台启动 daemon");
|
|
823
|
+
const args = [entry, "web"];
|
|
824
|
+
if (opts.port !== void 0) args.push("--port", String(opts.port));
|
|
825
|
+
if (opts.idleTimeoutMs !== void 0) args.push("--idle", String(opts.idleTimeoutMs));
|
|
826
|
+
if (opts.noOpen === true) args.push("--no-open");
|
|
827
|
+
const child = spawn(process.execPath, args, {
|
|
828
|
+
detached: true,
|
|
829
|
+
stdio: "ignore"
|
|
830
|
+
});
|
|
831
|
+
child.unref();
|
|
832
|
+
let exitCode = null;
|
|
833
|
+
child.once("exit", (code) => {
|
|
834
|
+
exitCode = code;
|
|
835
|
+
});
|
|
836
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
837
|
+
while (Date.now() < deadline) {
|
|
838
|
+
const info = await queryDaemon(500);
|
|
839
|
+
if (info !== null) {
|
|
840
|
+
if (opts.port !== void 0 && info.port !== opts.port) throw new Error(`daemon 起在端口 ${info.port} 而不是请求的 ${opts.port};先 paintress stop 再重试`);
|
|
841
|
+
return {
|
|
842
|
+
started: true,
|
|
843
|
+
pid: info.pid,
|
|
844
|
+
port: info.port
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
if (exitCode !== null) throw new Error(`后台 daemon 启动失败(退出码 ${exitCode})${timeoutHint()}`);
|
|
848
|
+
await sleep(100);
|
|
849
|
+
}
|
|
850
|
+
throw new Error(`后台启动 daemon 超时(${START_TIMEOUT_MS}ms)${timeoutHint()}`);
|
|
851
|
+
}
|
|
852
|
+
// src/web/open-browser.ts
|
|
853
|
+
var open_browser_exports = /* @__PURE__ */ __exportAll({ openBrowser: () => openBrowser });
|
|
854
|
+
/**
|
|
855
|
+
* 在宿主机上打开浏览器。
|
|
856
|
+
*
|
|
857
|
+
* 设计前提(operator 2026-11-13):界面状态由 agent 侧驱动,
|
|
858
|
+
* 人不需要去记「进程起没起、项目绑没绑」—— agent 第一次把项目交给 web 时,
|
|
859
|
+
* 浏览器就该自己弹出来,并且直接落在那个项目上。
|
|
860
|
+
*
|
|
861
|
+
* 关闭方式:`PAINTRESS_NO_OPEN=1`(CI、远程服务器、或在 SSH 里跑 agent 时用)。
|
|
862
|
+
* 无图形环境(Linux 无 DISPLAY / WAYLAND_DISPLAY)自动跳过,不报错。
|
|
863
|
+
*/
|
|
864
|
+
function openBrowser(url) {
|
|
865
|
+
if (process.env["PAINTRESS_NO_OPEN"] === "1") return false;
|
|
866
|
+
if (process.env["CI"] === "1" || process.env["CI"] === "true") return false;
|
|
867
|
+
const platform = process.platform;
|
|
868
|
+
if (platform === "linux" && !process.env["DISPLAY"] && !process.env["WAYLAND_DISPLAY"]) return false;
|
|
869
|
+
const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", [
|
|
870
|
+
"/c",
|
|
871
|
+
"start",
|
|
872
|
+
"",
|
|
873
|
+
url
|
|
874
|
+
]] : ["xdg-open", [url]];
|
|
875
|
+
try {
|
|
876
|
+
const child = spawn(cmd, args, {
|
|
877
|
+
detached: true,
|
|
878
|
+
stdio: "ignore"
|
|
879
|
+
});
|
|
880
|
+
child.on("error", () => {});
|
|
881
|
+
child.unref();
|
|
882
|
+
return true;
|
|
883
|
+
} catch {
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/** 顶层页面地址(项目列表);项目深链见 projectUrl */
|
|
888
|
+
function baseUrl(port) {
|
|
889
|
+
return `http://localhost:${port}/`;
|
|
890
|
+
}
|
|
891
|
+
/** 项目深链:项目 key 本身就带 `/`(`c-thun/raybend`),URL 里不需要转义 */
|
|
892
|
+
function projectUrl(port, project, file) {
|
|
893
|
+
if (!project) return baseUrl(port);
|
|
894
|
+
return `http://localhost:${port}/${project.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${file ? `?file=${encodeURIComponent(file)}` : ""}`;
|
|
895
|
+
}
|
|
896
|
+
// src/web/autoregister.ts
|
|
897
|
+
/**
|
|
898
|
+
* 每个项目的 attach 连接必须被持有:daemon 用「连接是否还在」判定 agent 是否活着。
|
|
899
|
+
* 连接一断,agent 自动注册的项目会在宽限期后退场。
|
|
900
|
+
*/
|
|
901
|
+
const handles = /* @__PURE__ */ new Map();
|
|
902
|
+
/** 进程退出时释放连接(daemon 立刻看到 agent 走了,比等 OS 回收快) */
|
|
903
|
+
function closeViewerHandles() {
|
|
904
|
+
for (const handle of handles.values()) try {
|
|
905
|
+
handle.close();
|
|
906
|
+
} catch {}
|
|
907
|
+
handles.clear();
|
|
908
|
+
}
|
|
909
|
+
function autoWebDisabled() {
|
|
910
|
+
return process.env["PAINTRESS_NO_AUTO_WEB"] === "1";
|
|
911
|
+
}
|
|
912
|
+
/** 幂等入口:确保 daemon 在跑、项目已注册、本进程持有存活连接,返回可分享的 URL */
|
|
913
|
+
async function ensureViewer(opts = {}) {
|
|
914
|
+
if (autoWebDisabled()) throw new Error("PAINTRESS_NO_AUTO_WEB=1:已关闭自动查看器,需要手动运行 paintress web");
|
|
915
|
+
const dir = resolve(opts.dir ?? process.cwd());
|
|
916
|
+
const project = opts.project !== void 0 ? sanitizeProjectName(opts.project) : deriveProjectName(dir);
|
|
917
|
+
const running = await queryDaemon();
|
|
918
|
+
const attached = handles.get(project);
|
|
919
|
+
if (running !== null && attached !== void 0 && !attached.isClosed()) return {
|
|
920
|
+
project,
|
|
921
|
+
pid: running.pid,
|
|
922
|
+
port: running.port,
|
|
923
|
+
url: projectUrl(running.port, project),
|
|
924
|
+
startedDaemon: false,
|
|
925
|
+
openedBrowser: false
|
|
926
|
+
};
|
|
927
|
+
const { started, pid, port } = await ensureDaemon();
|
|
928
|
+
const watched = await sendRequest({
|
|
929
|
+
kind: "watch",
|
|
930
|
+
project,
|
|
931
|
+
dir,
|
|
932
|
+
owner: "agent",
|
|
933
|
+
...opts.depth !== void 0 ? { depth: opts.depth } : {}
|
|
934
|
+
}, 8e3);
|
|
935
|
+
if (!watched.ok) throw new Error(watched.error);
|
|
936
|
+
attached?.close();
|
|
937
|
+
handles.set(project, await attachPersistent(project));
|
|
938
|
+
const openedBrowser = started && openBrowser(projectUrl(port, project));
|
|
939
|
+
return {
|
|
940
|
+
project,
|
|
941
|
+
pid,
|
|
942
|
+
port,
|
|
943
|
+
url: projectUrl(port, project),
|
|
944
|
+
startedDaemon: started,
|
|
945
|
+
openedBrowser
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* MCP server 启动时自动注册自身(best-effort,失败不阻断 MCP)。
|
|
950
|
+
* 与 ensureViewer 同一条路径 —— 启动时注册和「开始画之前补一次」是同一件事。
|
|
951
|
+
*/
|
|
952
|
+
async function autoRegister(cwd = process.cwd()) {
|
|
953
|
+
if (autoWebDisabled()) return null;
|
|
954
|
+
try {
|
|
955
|
+
return await ensureViewer({ dir: cwd });
|
|
956
|
+
} catch (cause) {
|
|
957
|
+
process.stderr.write(`paintress: web 自动注册失败(${cause instanceof Error ? cause.message : String(cause)}),MCP 工具不受影响\n`);
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
425
961
|
// src/mcp/tools.ts
|
|
426
962
|
function reqStr(args, key) {
|
|
427
963
|
const v = args[key];
|
|
@@ -473,7 +1009,11 @@ async function write(path, fn) {
|
|
|
473
1009
|
touched.push(...ids);
|
|
474
1010
|
};
|
|
475
1011
|
const result = fn(loaded.doc, mark);
|
|
476
|
-
const
|
|
1012
|
+
const presetNames = projectPresetNames(path);
|
|
1013
|
+
const v = validateDoc(loaded.doc, {
|
|
1014
|
+
knownVars: presetNames,
|
|
1015
|
+
presetVars: presetNames
|
|
1016
|
+
});
|
|
477
1017
|
if (!v.ok) throw new Error(`变更后结构校验失败,未落盘:\n${v.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
|
|
478
1018
|
saveDoc(path, loaded.doc, {
|
|
479
1019
|
expectedMtimeMs: loaded.mtimeMs,
|
|
@@ -502,8 +1042,8 @@ function countByType(children) {
|
|
|
502
1042
|
return counts;
|
|
503
1043
|
}
|
|
504
1044
|
/**
|
|
505
|
-
*
|
|
506
|
-
*
|
|
1045
|
+
* 新建设计稿。path 不带扩展名时补默认扩展名;调用方显式写 `.pen` 时按兼容格式产出
|
|
1046
|
+
* (不替用户转换已有文件)。
|
|
507
1047
|
*/
|
|
508
1048
|
function newDoc(args) {
|
|
509
1049
|
const raw = reqStr(args, "path");
|
|
@@ -550,11 +1090,51 @@ function newDoc(args) {
|
|
|
550
1090
|
warnings: check.warnings
|
|
551
1091
|
};
|
|
552
1092
|
}
|
|
1093
|
+
/** 复制进设计稿前给整棵子树换新 id:组件库与目标文件的 id 空间互不相干 */
|
|
1094
|
+
function remapIds(node, taken) {
|
|
1095
|
+
if (!node || typeof node !== "object") return;
|
|
1096
|
+
const rec = node;
|
|
1097
|
+
if (typeof rec["id"] === "string") {
|
|
1098
|
+
const fresh = genId(taken);
|
|
1099
|
+
taken.add(fresh);
|
|
1100
|
+
rec["id"] = fresh;
|
|
1101
|
+
}
|
|
1102
|
+
const kids = rec["children"];
|
|
1103
|
+
if (Array.isArray(kids)) for (const k of kids) remapIds(k, taken);
|
|
1104
|
+
}
|
|
553
1105
|
const TOOLS = {
|
|
1106
|
+
paintress_viewer: {
|
|
1107
|
+
description: "确保本地查看器在运行,并把当前项目注册进去,返回人可以打开的 URL。幂等:已在运行就直接复用(返回现有 pid/端口,绝不起第二个进程),可以在开始画/改/展示设计稿之前无条件调用。**开始作画前先调它,不要等人提醒**",
|
|
1108
|
+
inputSchema: {
|
|
1109
|
+
dir: z.string().optional().describe("要注册的目录,默认当前工作目录"),
|
|
1110
|
+
project: z.string().optional().describe("项目名(URL 里那一段),默认取「父目录/目录名」"),
|
|
1111
|
+
depth: z.number().int().min(1).max(6).optional().describe("列举设计稿的最大深度,默认 3")
|
|
1112
|
+
},
|
|
1113
|
+
run: async (a) => {
|
|
1114
|
+
const dir = optStr(a, "dir");
|
|
1115
|
+
const project = optStr(a, "project");
|
|
1116
|
+
const depth = optNum(a, "depth");
|
|
1117
|
+
const info = await ensureViewer({
|
|
1118
|
+
...dir !== void 0 ? { dir } : {},
|
|
1119
|
+
...project !== void 0 ? { project } : {},
|
|
1120
|
+
...depth !== void 0 ? { depth } : {}
|
|
1121
|
+
});
|
|
1122
|
+
return {
|
|
1123
|
+
ok: true,
|
|
1124
|
+
project: info.project,
|
|
1125
|
+
url: info.url,
|
|
1126
|
+
pid: info.pid,
|
|
1127
|
+
port: info.port,
|
|
1128
|
+
started: info.startedDaemon,
|
|
1129
|
+
openedBrowser: info.openedBrowser,
|
|
1130
|
+
hint: `把人指向 ${info.url}(单份设计稿加深链 ?file=<相对路径>)`
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
},
|
|
554
1134
|
paintress_new: {
|
|
555
|
-
description: "
|
|
1135
|
+
description: "新建一份设计稿:创建一个带空 board 的最小可用文档,可直接 render/screenshot 再逐层 insert;已存在同名文件时报错,除非 overwrite=true。path 不带扩展名时自动补默认扩展名",
|
|
556
1136
|
inputSchema: {
|
|
557
|
-
path: z.string().describe("
|
|
1137
|
+
path: z.string().describe("新文档路径;不带扩展名时自动补默认扩展名"),
|
|
558
1138
|
name: z.string().optional().describe("board 名称(默认 Board)"),
|
|
559
1139
|
width: z.number().positive().optional().describe("board 宽(默认 900)"),
|
|
560
1140
|
height: z.number().positive().optional().describe("board 高(默认 600)"),
|
|
@@ -578,6 +1158,13 @@ const TOOLS = {
|
|
|
578
1158
|
version: ctx.doc.version,
|
|
579
1159
|
fileToken: ctx.doc.fileToken,
|
|
580
1160
|
variables: ctx.doc.variables ? Object.keys(ctx.doc.variables) : [],
|
|
1161
|
+
projectPresets: (() => {
|
|
1162
|
+
const { path: presetPath, items } = listPresets(reqStr(a, "path"));
|
|
1163
|
+
return {
|
|
1164
|
+
path: presetPath,
|
|
1165
|
+
count: items.length
|
|
1166
|
+
};
|
|
1167
|
+
})(),
|
|
581
1168
|
topLevel: ctx.doc.children.map((c) => ({
|
|
582
1169
|
id: c["id"],
|
|
583
1170
|
name: c["name"],
|
|
@@ -728,9 +1315,120 @@ const TOOLS = {
|
|
|
728
1315
|
})
|
|
729
1316
|
},
|
|
730
1317
|
paintress_validate: {
|
|
731
|
-
description: "校验文档:结构错误 + 语义警告(重复 id、悬空 ref
|
|
1318
|
+
description: "校验文档:结构错误 + 语义警告(重复 id、悬空 ref、未知类型、未定义变量)。项目预设里的变量算已定义",
|
|
732
1319
|
inputSchema: { path: z.string() },
|
|
733
|
-
run: (a) =>
|
|
1320
|
+
run: (a) => {
|
|
1321
|
+
const path = reqStr(a, "path");
|
|
1322
|
+
const loaded = loadDoc(path);
|
|
1323
|
+
const presetNames = projectPresetNames(path);
|
|
1324
|
+
return validateDoc(loaded.doc, {
|
|
1325
|
+
knownVars: presetNames,
|
|
1326
|
+
presetVars: presetNames
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
},
|
|
1330
|
+
paintress_preset: {
|
|
1331
|
+
description: "项目预设:颜色 / 倒角这类会反复微调的值在项目里存一份(__preset.ptr),设计稿里写 $key 引用。作画前先把颜色与倒角定下来注册成预设;人在查看器里改预设值,画布实时变,不用一句句说「红一点」。action=list 查看,set 新建/改值,remove 删除",
|
|
1332
|
+
inputSchema: {
|
|
1333
|
+
action: z.enum([
|
|
1334
|
+
"list",
|
|
1335
|
+
"set",
|
|
1336
|
+
"remove"
|
|
1337
|
+
]),
|
|
1338
|
+
path: z.string().optional().describe("设计稿或项目目录,默认当前工作目录"),
|
|
1339
|
+
key: z.string().optional().describe("变量名,设计稿里写 $key;set / remove 必填"),
|
|
1340
|
+
type: z.enum(["color", "radius"]).optional().describe("预设类型;set 必填"),
|
|
1341
|
+
name: z.string().optional().describe("人看的名称,默认同 key"),
|
|
1342
|
+
value: z.string().optional().describe("值:颜色写 #RRGGBB(或 #RGB / #RRGGBBAA),倒角写非负数字,如 '8';set 必填")
|
|
1343
|
+
},
|
|
1344
|
+
run: (a) => {
|
|
1345
|
+
const from = resolve(optStr(a, "path") ?? process.cwd());
|
|
1346
|
+
const action = optStr(a, "action") ?? "list";
|
|
1347
|
+
if (action === "list") {
|
|
1348
|
+
const { path, items } = listPresets(from);
|
|
1349
|
+
return {
|
|
1350
|
+
ok: true,
|
|
1351
|
+
path,
|
|
1352
|
+
items
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
const key = reqStr(a, "key");
|
|
1356
|
+
if (action === "remove") return {
|
|
1357
|
+
ok: true,
|
|
1358
|
+
path: presetPathFor(from),
|
|
1359
|
+
items: removePreset(from, key)
|
|
1360
|
+
};
|
|
1361
|
+
const type = optStr(a, "type");
|
|
1362
|
+
if (!isPresetType(type)) throw new Error("type 必须是 color 或 radius");
|
|
1363
|
+
const name = optStr(a, "name");
|
|
1364
|
+
const items = setPreset(from, {
|
|
1365
|
+
key,
|
|
1366
|
+
type,
|
|
1367
|
+
value: reqStr(a, "value"),
|
|
1368
|
+
...name !== void 0 ? { name } : {}
|
|
1369
|
+
});
|
|
1370
|
+
return {
|
|
1371
|
+
ok: true,
|
|
1372
|
+
path: presetPathFor(from),
|
|
1373
|
+
items
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
},
|
|
1377
|
+
paintress_component: {
|
|
1378
|
+
description: "项目组件库:先查已有组件再动手,避免重复造轮子。action=list 列出可复用组件(__components.ptr 里 reusable 节点);action=insert 把某个组件复制进设计稿——复制件自带新 id、内部 ref 已展开,可独立渲染(只复制,不做联动编辑)",
|
|
1379
|
+
inputSchema: {
|
|
1380
|
+
action: z.enum(["list", "insert"]),
|
|
1381
|
+
path: z.string().optional().describe("设计稿或项目目录;insert 时是目标设计稿"),
|
|
1382
|
+
id: z.string().optional().describe("组件 id;insert 必填"),
|
|
1383
|
+
parent: z.string().optional().describe("插到哪个父节点,默认 'root'"),
|
|
1384
|
+
index: z.number().int().min(0).optional()
|
|
1385
|
+
},
|
|
1386
|
+
run: (a) => {
|
|
1387
|
+
const action = optStr(a, "action") ?? "list";
|
|
1388
|
+
const from = resolve(optStr(a, "path") ?? process.cwd());
|
|
1389
|
+
if (action === "list") {
|
|
1390
|
+
const compPath = componentsPathFor(from);
|
|
1391
|
+
if (compPath === null) return {
|
|
1392
|
+
ok: true,
|
|
1393
|
+
path: null,
|
|
1394
|
+
items: []
|
|
1395
|
+
};
|
|
1396
|
+
return {
|
|
1397
|
+
ok: true,
|
|
1398
|
+
path: compPath,
|
|
1399
|
+
items: listComponents(compPath)
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
const target = reqStr(a, "path");
|
|
1403
|
+
const compId = reqStr(a, "id");
|
|
1404
|
+
const compPath = componentsPathFor(resolve(target));
|
|
1405
|
+
if (compPath === null) throw new Error("找不到项目组件库 __components.ptr(先在项目里注册)");
|
|
1406
|
+
const compLoaded = readComponentsFile(compPath);
|
|
1407
|
+
if (compLoaded === null) throw new Error(`组件库不可读:${compPath}`);
|
|
1408
|
+
const warnings = [];
|
|
1409
|
+
const node = findNodeIn(expandRefs(applyProjectPresets(compLoaded.doc, compPath).doc, warnings), compId);
|
|
1410
|
+
if (node === null) throw new Error(`组件不存在:${compId}(先 paintress_component list 看有哪些)`);
|
|
1411
|
+
const copy = structuredClone(node);
|
|
1412
|
+
const stripReusable = (n) => {
|
|
1413
|
+
if (!n || typeof n !== "object") return;
|
|
1414
|
+
const rec = n;
|
|
1415
|
+
delete rec["reusable"];
|
|
1416
|
+
const kids = rec["children"];
|
|
1417
|
+
if (Array.isArray(kids)) for (const k of kids) stripReusable(k);
|
|
1418
|
+
};
|
|
1419
|
+
stripReusable(copy);
|
|
1420
|
+
return write(target, (doc, mark) => {
|
|
1421
|
+
remapIds(copy, new Set(collectIds(doc.children)));
|
|
1422
|
+
const newId = insert(doc, optStr(a, "parent") ?? "root", copy, optNum(a, "index"));
|
|
1423
|
+
mark([newId]);
|
|
1424
|
+
return {
|
|
1425
|
+
id: newId,
|
|
1426
|
+
from: compId,
|
|
1427
|
+
componentPath: compPath,
|
|
1428
|
+
warnings
|
|
1429
|
+
};
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
734
1432
|
},
|
|
735
1433
|
paintress_render: {
|
|
736
1434
|
description: "渲染设计稿为 HTML 文件(每节点带 data-node-id);返回输出路径与画布尺寸",
|
|
@@ -740,8 +1438,9 @@ const TOOLS = {
|
|
|
740
1438
|
},
|
|
741
1439
|
run: (a) => {
|
|
742
1440
|
const out = reqStr(a, "out");
|
|
743
|
-
const
|
|
744
|
-
const
|
|
1441
|
+
const path = reqStr(a, "path");
|
|
1442
|
+
const loaded = loadDoc(path);
|
|
1443
|
+
const r = renderDocument(applyProjectPresets(loaded.doc, path).doc);
|
|
745
1444
|
writeFileSync(out, r.html, "utf8");
|
|
746
1445
|
return {
|
|
747
1446
|
ok: true,
|
|
@@ -783,7 +1482,7 @@ const TOOLS = {
|
|
|
783
1482
|
action: z.enum(["list", "clear"]).optional().describe("默认 list")
|
|
784
1483
|
},
|
|
785
1484
|
run: async (a) => {
|
|
786
|
-
if (!isDaemonRunning()) throw new Error("paintress web
|
|
1485
|
+
if (!isDaemonRunning()) throw new Error("paintress web 未运行,无法读取标记;先调用 paintress_viewer(或运行 paintress web --daemon)");
|
|
787
1486
|
const project = optStr(a, "project") ?? "";
|
|
788
1487
|
if ((optStr(a, "action") ?? "list") === "clear") return await sendRequest({
|
|
789
1488
|
kind: "clear-marks",
|
|
@@ -796,123 +1495,44 @@ const TOOLS = {
|
|
|
796
1495
|
}
|
|
797
1496
|
}
|
|
798
1497
|
};
|
|
799
|
-
// src/web/spawn.ts
|
|
800
|
-
const START_TIMEOUT_MS = 6e3;
|
|
801
|
-
const DEFAULT_PORT = 3316;
|
|
802
|
-
async function ensureDaemon(opts = {}) {
|
|
803
|
-
if (isDaemonRunning()) {
|
|
804
|
-
const res = await sendRequest({ kind: "ping" });
|
|
805
|
-
if (res.ok && "pong" in res) return {
|
|
806
|
-
started: false,
|
|
807
|
-
pid: res.pid,
|
|
808
|
-
port: opts.port ?? DEFAULT_PORT
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
const entry = process.argv[1];
|
|
812
|
-
if (!entry) throw new Error("无法确定 CLI 入口路径,不能后台启动 daemon");
|
|
813
|
-
const args = [entry, "web"];
|
|
814
|
-
if (opts.port !== void 0) args.push("--port", String(opts.port));
|
|
815
|
-
if (opts.idleTimeoutMs !== void 0) args.push("--idle", String(opts.idleTimeoutMs));
|
|
816
|
-
spawn(process.execPath, args, {
|
|
817
|
-
detached: true,
|
|
818
|
-
stdio: "ignore"
|
|
819
|
-
}).unref();
|
|
820
|
-
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
821
|
-
while (Date.now() < deadline) {
|
|
822
|
-
if (isDaemonRunning()) {
|
|
823
|
-
const res = await sendRequest({ kind: "ping" });
|
|
824
|
-
if (res.ok && "pong" in res) return {
|
|
825
|
-
started: true,
|
|
826
|
-
pid: res.pid,
|
|
827
|
-
port: opts.port ?? DEFAULT_PORT
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
831
|
-
}
|
|
832
|
-
throw new Error(`后台启动 daemon 超时(${START_TIMEOUT_MS}ms)`);
|
|
833
|
-
}
|
|
834
|
-
// src/web/open-browser.ts
|
|
835
|
-
var open_browser_exports = /* @__PURE__ */ __exportAll({ openBrowser: () => openBrowser });
|
|
836
|
-
/**
|
|
837
|
-
* 在宿主机上打开浏览器。
|
|
838
|
-
*
|
|
839
|
-
* 设计前提(operator 2026-11-13):界面状态由 agent 侧驱动,
|
|
840
|
-
* 人不需要去记「进程起没起、项目绑没绑」—— agent 第一次把项目交给 web 时,
|
|
841
|
-
* 浏览器就该自己弹出来,并且直接落在那个项目上。
|
|
842
|
-
*
|
|
843
|
-
* 关闭方式:`PAINTRESS_NO_OPEN=1`(CI、远程服务器、或在 SSH 里跑 agent 时用)。
|
|
844
|
-
* 无图形环境(Linux 无 DISPLAY / WAYLAND_DISPLAY)自动跳过,不报错。
|
|
845
|
-
*/
|
|
846
|
-
function openBrowser(url) {
|
|
847
|
-
if (process.env["PAINTRESS_NO_OPEN"] === "1") return false;
|
|
848
|
-
if (process.env["CI"] === "1" || process.env["CI"] === "true") return false;
|
|
849
|
-
const platform = process.platform;
|
|
850
|
-
if (platform === "linux" && !process.env["DISPLAY"] && !process.env["WAYLAND_DISPLAY"]) return false;
|
|
851
|
-
const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", [
|
|
852
|
-
"/c",
|
|
853
|
-
"start",
|
|
854
|
-
"",
|
|
855
|
-
url
|
|
856
|
-
]] : ["xdg-open", [url]];
|
|
857
|
-
try {
|
|
858
|
-
const child = spawn(cmd, args, {
|
|
859
|
-
detached: true,
|
|
860
|
-
stdio: "ignore"
|
|
861
|
-
});
|
|
862
|
-
child.on("error", () => {});
|
|
863
|
-
child.unref();
|
|
864
|
-
return true;
|
|
865
|
-
} catch {
|
|
866
|
-
return false;
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
/** 顶层页面地址(项目列表);项目深链见 projectUrl */
|
|
870
|
-
function baseUrl(port) {
|
|
871
|
-
return `http://localhost:${port}/`;
|
|
872
|
-
}
|
|
873
|
-
/** 项目深链:项目 key 本身就带 `/`(`c-thun/raybend`),URL 里不需要转义 */
|
|
874
|
-
function projectUrl(port, project, file) {
|
|
875
|
-
if (!project) return baseUrl(port);
|
|
876
|
-
return `http://localhost:${port}/${project.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${file ? `?file=${encodeURIComponent(file)}` : ""}`;
|
|
877
|
-
}
|
|
878
|
-
// src/web/autoregister.ts
|
|
879
|
-
async function autoRegister(cwd = process.cwd()) {
|
|
880
|
-
if (process.env["PAINTRESS_NO_AUTO_WEB"] === "1") return null;
|
|
881
|
-
try {
|
|
882
|
-
const { started, port } = await ensureDaemon();
|
|
883
|
-
const project = deriveProjectName(cwd);
|
|
884
|
-
const watched = await sendRequest({
|
|
885
|
-
kind: "watch",
|
|
886
|
-
project,
|
|
887
|
-
dir: cwd,
|
|
888
|
-
owner: "agent"
|
|
889
|
-
}, 8e3);
|
|
890
|
-
if (!watched.ok) throw new Error(watched.error);
|
|
891
|
-
return {
|
|
892
|
-
handle: await attachPersistent(project),
|
|
893
|
-
project,
|
|
894
|
-
startedDaemon: started,
|
|
895
|
-
openedBrowser: started && port !== void 0 ? openBrowser(projectUrl(port, project)) : false
|
|
896
|
-
};
|
|
897
|
-
} catch (cause) {
|
|
898
|
-
process.stderr.write(`paintress: web 自动注册失败(${cause instanceof Error ? cause.message : String(cause)}),MCP 工具不受影响\n`);
|
|
899
|
-
return null;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
1498
|
// src/mcp/server.ts
|
|
903
1499
|
const SERVER_NAME = "paintress";
|
|
904
1500
|
const SERVER_VERSION = VERSION;
|
|
1501
|
+
/**
|
|
1502
|
+
* 连接时交给客户端模型的使用说明(MCP initialize 的 instructions)。
|
|
1503
|
+
* 头号规则是「作画前把查看器拉起来」——人看不到进程状态,agent 不等提醒就该做。
|
|
1504
|
+
*/
|
|
1505
|
+
const INSTRUCTIONS = `paintress is a design tool for AI agents: design files are JSON, edited through tools and rendered by a local viewer.
|
|
1506
|
+
|
|
1507
|
+
VIEWER — the human only sees your work in the viewer, so keep it running:
|
|
1508
|
+
- Call \`paintress_viewer\` before the first write of a drawing session (creating, editing or showing a design) — do not wait to be asked.
|
|
1509
|
+
- It is idempotent: if the viewer is already up it returns the existing pid/port/URL and starts nothing; calling it again is always safe and never starts a second process.
|
|
1510
|
+
- CLI equivalent, same idempotence: \`paintress web --daemon\` returns immediately with the URL. Never block on plain \`paintress web\`.
|
|
1511
|
+
- Hand the returned URL to the human (append \`?file=<relative path>\` to deep-link one document). Never ask them to start the viewer or run \`watch\`.
|
|
1512
|
+
|
|
1513
|
+
WORKFLOW: \`paintress_new\` → \`paintress_query\`/\`paintress_get\` for real node ids (never invent them) → \`paintress_update\`/\`insert\`/\`move\`/\`delete\` or \`paintress_batch\` → \`paintress_validate\` → \`paintress_screenshot\` to check the result with your own eyes.
|
|
1514
|
+
\`#1\` in the conversation is a click marker from the viewer: resolve it with \`paintress_marks\`.`;
|
|
1515
|
+
/** 写操作前补一次查看器:这是「光画不开 web」的兜底网,不靠 agent 自觉 */
|
|
1516
|
+
const WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
1517
|
+
"paintress_new",
|
|
1518
|
+
"paintress_update",
|
|
1519
|
+
"paintress_insert",
|
|
1520
|
+
"paintress_delete",
|
|
1521
|
+
"paintress_move",
|
|
1522
|
+
"paintress_batch"
|
|
1523
|
+
]);
|
|
905
1524
|
/** 构建 MCP server:把共享工具注册到 stdio 协议上 */
|
|
906
|
-
function createMcpServer() {
|
|
1525
|
+
function createMcpServer(opts = {}) {
|
|
907
1526
|
const server = new McpServer({
|
|
908
1527
|
name: SERVER_NAME,
|
|
909
1528
|
version: SERVER_VERSION
|
|
910
|
-
});
|
|
1529
|
+
}, { instructions: INSTRUCTIONS });
|
|
911
1530
|
for (const [name, def] of Object.entries(TOOLS)) server.registerTool(name, {
|
|
912
1531
|
description: def.description,
|
|
913
1532
|
inputSchema: def.inputSchema
|
|
914
1533
|
}, async (args) => {
|
|
915
1534
|
try {
|
|
1535
|
+
if (opts.ensureViewerBeforeWrite === true && WRITE_TOOLS.has(name)) await ensureViewerQuiet();
|
|
916
1536
|
const payload = await def.run(args);
|
|
917
1537
|
return { content: [{
|
|
918
1538
|
type: "text",
|
|
@@ -948,29 +1568,32 @@ function installParentWatchdog() {
|
|
|
948
1568
|
process.stdin.on("close", () => exitClean(0));
|
|
949
1569
|
}
|
|
950
1570
|
/**
|
|
951
|
-
* attach
|
|
1571
|
+
* attach 连接引用:由 autoregister 持有(ensureViewer 每次调用都会复用/刷新)。
|
|
952
1572
|
* 退出时显式关闭——比等 OS 回收更快让 daemon 看到 we're gone。
|
|
953
1573
|
*/
|
|
954
|
-
let registrar = null;
|
|
955
|
-
/** 干净退出:断开 attach 连接后退出 */
|
|
956
1574
|
function exitClean(code = 0) {
|
|
957
|
-
|
|
958
|
-
registrar?.handle.close();
|
|
959
|
-
} catch {}
|
|
1575
|
+
closeViewerHandles();
|
|
960
1576
|
process.exit(code);
|
|
961
1577
|
}
|
|
1578
|
+
/** 写之前补一次查看器:best-effort,失败不阻断写入(人看不到图,但文件还是要改) */
|
|
1579
|
+
async function ensureViewerQuiet() {
|
|
1580
|
+
try {
|
|
1581
|
+
await ensureViewer();
|
|
1582
|
+
} catch (cause) {
|
|
1583
|
+
process.stderr.write(`paintress: 写操作前自动拉起查看器失败(${cause instanceof Error ? cause.message : String(cause)})\n`);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
962
1586
|
/** 自动注册自身(best-effort,失败不阻断 MCP) */
|
|
963
1587
|
async function registerSelf() {
|
|
964
1588
|
const res = await autoRegister();
|
|
965
|
-
|
|
966
|
-
if (res) process.stderr.write(`paintress: 已注册项目 "${res.project}",浏览器打开 http://localhost:3316/${encodeURIComponent(res.project)}\n`);
|
|
1589
|
+
if (res) process.stderr.write(`paintress: 已注册项目 "${res.project}",浏览器打开 ${res.url}\n`);
|
|
967
1590
|
}
|
|
968
1591
|
/** CLI `paintress mcp` 入口:stdio transport + 自动注册 */
|
|
969
1592
|
async function runStdioServer() {
|
|
970
|
-
const server = createMcpServer();
|
|
1593
|
+
const server = createMcpServer({ ensureViewerBeforeWrite: true });
|
|
971
1594
|
const transport = new StdioServerTransport();
|
|
972
1595
|
await server.connect(transport);
|
|
973
1596
|
installParentWatchdog();
|
|
974
1597
|
registerSelf();
|
|
975
1598
|
}
|
|
976
|
-
export {
|
|
1599
|
+
export { isPresetType as C, presetPathFor as D, notePathFor as E, readTextIfExists as O, findNodeIn as S, listPresets as T, LineDecoder as _, ensureDaemon as a, componentsPathFor as b, isDaemonRunning as c, lockPath as d, pidPath as f, socketPath as g, sanitizeProjectName as h, open_browser_exports as i, setPreset as k, queryDaemon as l, safeJoin as m, runStdioServer as n, reuseDaemon as o, resolveProjectDir as p, TOOLS as r, attachPersistent as s, createMcpServer as t, sendRequest as u, encodeLine as v, listComponents as w, ensureProjectScaffold as x, applyProjectPresets as y };
|