paintress 0.6.0 → 0.7.1
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 +60 -17
- package/README.zh-CN.md +60 -15
- package/dist/cli.js +956 -58
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/{render-BSf62388.js → render-DWs1iQ0v.js} +878 -56
- package/dist/server-Buuoq3kq.js +3580 -0
- package/package.json +1 -1
- package/skills/paintress/SKILL.md +39 -24
- package/ui/dist/assets/index-CSUM68ms.js +8 -0
- package/ui/dist/assets/{index-BFcvu7wr.css → index-CXBgFia_.css} +1 -1
- package/ui/dist/index.html +2 -2
- package/dist/server-DZRNNWac.js +0 -1599
- package/ui/dist/assets/index-CEhK1JXE.js +0 -8
package/dist/server-DZRNNWac.js
DELETED
|
@@ -1,1599 +0,0 @@
|
|
|
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
|
-
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
-
import { chromium } from "playwright-core";
|
|
8
|
-
import { connect } from "node:net";
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
10
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
-
// \0rolldown/runtime.js
|
|
13
|
-
var __defProp = Object.defineProperty;
|
|
14
|
-
var __exportAll = (all, flags) => {
|
|
15
|
-
let target = Object.setPrototypeOf({}, null);
|
|
16
|
-
for (var name in all) __defProp(target, name, {
|
|
17
|
-
get: all[name],
|
|
18
|
-
enumerable: true
|
|
19
|
-
});
|
|
20
|
-
if (!(flags & 1)) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
21
|
-
return flags & 2 ? target : Object.preventExtensions(target);
|
|
22
|
-
};
|
|
23
|
-
// src/doc/create.ts
|
|
24
|
-
function createDoc(opts = {}) {
|
|
25
|
-
const boardId = opts.boardId ?? genId(/* @__PURE__ */ new Set());
|
|
26
|
-
const board = {
|
|
27
|
-
type: "frame",
|
|
28
|
-
id: boardId,
|
|
29
|
-
name: opts.name ?? "Board",
|
|
30
|
-
x: 0,
|
|
31
|
-
y: 0,
|
|
32
|
-
width: opts.width ?? 900,
|
|
33
|
-
height: opts.height ?? 600,
|
|
34
|
-
fill: opts.fill ?? "#FFFFFF"
|
|
35
|
-
};
|
|
36
|
-
const layout = opts.layout ?? "vertical";
|
|
37
|
-
if (layout !== "none") {
|
|
38
|
-
board["layout"] = layout;
|
|
39
|
-
board["gap"] = opts.gap ?? 0;
|
|
40
|
-
board["padding"] = opts.padding ?? 0;
|
|
41
|
-
}
|
|
42
|
-
return {
|
|
43
|
-
doc: DocumentSchema.parse({
|
|
44
|
-
version: PEN_DOC_VERSION,
|
|
45
|
-
fileToken: opts.fileToken ?? randomUUID(),
|
|
46
|
-
variables: {},
|
|
47
|
-
children: [board]
|
|
48
|
-
}),
|
|
49
|
-
boardId
|
|
50
|
-
};
|
|
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
|
-
}
|
|
393
|
-
// src/shot/screenshot.ts
|
|
394
|
-
/**
|
|
395
|
-
* 截图层:渲染 → 临时 HTML → headless Chromium → PNG(决策 3:两个消费者分开,AI 要 PNG)。
|
|
396
|
-
* 浏览器探测:PTR_BROWSER 环境变量 → 系统 chrome → playwright 浏览器缓存。
|
|
397
|
-
*/
|
|
398
|
-
function findBrowser() {
|
|
399
|
-
const env = process.env["PTR_BROWSER"];
|
|
400
|
-
if (env && existsSync(env)) return env;
|
|
401
|
-
for (const c of [
|
|
402
|
-
"/usr/bin/google-chrome",
|
|
403
|
-
"/usr/bin/google-chrome-stable",
|
|
404
|
-
"/usr/bin/chromium",
|
|
405
|
-
"/usr/bin/chromium-browser"
|
|
406
|
-
]) if (existsSync(c)) return c;
|
|
407
|
-
const cacheDir = join(homedir(), ".cache", "ms-playwright");
|
|
408
|
-
if (existsSync(cacheDir)) {
|
|
409
|
-
const dirs = readdirSync(cacheDir).filter((d) => d.startsWith("chromium-")).sort((a, b) => b.localeCompare(a));
|
|
410
|
-
for (const d of dirs) for (const rel of [
|
|
411
|
-
"chrome-linux/chrome",
|
|
412
|
-
"chrome-linux64/chrome",
|
|
413
|
-
"chrome-headless-shell-linux64/chrome-headless-shell"
|
|
414
|
-
]) {
|
|
415
|
-
const p = join(cacheDir, d, rel);
|
|
416
|
-
if (existsSync(p)) return p;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
return null;
|
|
420
|
-
}
|
|
421
|
-
function boardLabel(b) {
|
|
422
|
-
return b.name !== void 0 && b.name !== "" ? `${b.name}(id=${b.id})` : b.id;
|
|
423
|
-
}
|
|
424
|
-
/**
|
|
425
|
-
* 画板选择子:id 精确 → name 精确 → name/id 模糊(不区分大小写)。
|
|
426
|
-
* 模糊命中多个时报歧义并列出候选,不猜——猜错会让 AI 以为看到了另一页。
|
|
427
|
-
*/
|
|
428
|
-
function selectBoard(boards, query) {
|
|
429
|
-
if (boards.length === 0) throw new Error("设计稿没有画板(顶层节点为空),无法按画板截图");
|
|
430
|
-
const q = query.trim();
|
|
431
|
-
if (q === "") throw new Error("board 参数不能为空");
|
|
432
|
-
const byId = boards.filter((b) => b.id === q);
|
|
433
|
-
if (byId.length === 1) return byId[0];
|
|
434
|
-
const byName = boards.filter((b) => b.name === q);
|
|
435
|
-
if (byName.length === 1) return byName[0];
|
|
436
|
-
const lower = q.toLowerCase();
|
|
437
|
-
const fuzzy = boards.filter((b) => b.id.toLowerCase().includes(lower) || (b.name ?? "").toLowerCase().includes(lower));
|
|
438
|
-
if (fuzzy.length === 1) return fuzzy[0];
|
|
439
|
-
const all = boards.map(boardLabel).join("、");
|
|
440
|
-
if (fuzzy.length === 0) throw new Error(`没有匹配「${q}」的画板;可选:${all}`);
|
|
441
|
-
throw new Error(`「${q}」命中 ${fuzzy.length} 个画板,请写更精确的名字或 id:${fuzzy.map(boardLabel).join("、")}`);
|
|
442
|
-
}
|
|
443
|
-
/** 渲染并截图整张画布;指定 board 时只裁该画板(走同一套渲染与浏览器) */
|
|
444
|
-
async function screenshotPen(penPath, outPng, opts = {}) {
|
|
445
|
-
const browserPath = findBrowser();
|
|
446
|
-
if (!browserPath) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER 指向可执行文件");
|
|
447
|
-
const loaded = loadDoc(penPath);
|
|
448
|
-
const rendered = renderDocument(applyProjectPresets(loaded.doc, penPath).doc);
|
|
449
|
-
const target = opts.board !== void 0 ? selectBoard(rendered.boards, opts.board) : void 0;
|
|
450
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "paintress-shot-"));
|
|
451
|
-
const htmlPath = join(tmpDir, "page.html");
|
|
452
|
-
writeFileSync(htmlPath, rendered.html, "utf8");
|
|
453
|
-
let clip;
|
|
454
|
-
let measuredBoard;
|
|
455
|
-
try {
|
|
456
|
-
const browser = await chromium.launch({
|
|
457
|
-
executablePath: browserPath,
|
|
458
|
-
args: ["--no-sandbox", "--disable-dev-shm-usage"]
|
|
459
|
-
});
|
|
460
|
-
try {
|
|
461
|
-
const page = await browser.newPage({
|
|
462
|
-
viewport: {
|
|
463
|
-
width: Math.max(800, rendered.canvas.width, target !== void 0 ? Math.ceil(target.x + 3200) : 0),
|
|
464
|
-
height: Math.max(600, rendered.canvas.height, target !== void 0 ? Math.ceil(target.y + 3200) : 0)
|
|
465
|
-
},
|
|
466
|
-
deviceScaleFactor: opts.scale ?? 2
|
|
467
|
-
});
|
|
468
|
-
await page.goto(`file://${htmlPath}`);
|
|
469
|
-
await page.waitForTimeout(opts.settleMs ?? 120);
|
|
470
|
-
const measured = await page.evaluate(`(() => {
|
|
471
|
-
const canvasEl = document.querySelector('.ptr-canvas');
|
|
472
|
-
const cr = canvasEl ? canvasEl.getBoundingClientRect() : null;
|
|
473
|
-
const nodes = Array.from(document.querySelectorAll('[data-board]'));
|
|
474
|
-
const boards = nodes.map((el) => {
|
|
475
|
-
const r = el.getBoundingClientRect();
|
|
476
|
-
return {
|
|
477
|
-
id: el.getAttribute('data-node-id') || '',
|
|
478
|
-
name: el.getAttribute('data-board-name') || undefined,
|
|
479
|
-
x: cr ? r.left - cr.left : 0,
|
|
480
|
-
y: cr ? r.top - cr.top : 0,
|
|
481
|
-
w: r.width,
|
|
482
|
-
h: r.height
|
|
483
|
-
};
|
|
484
|
-
});
|
|
485
|
-
// 自动尺寸的设计稿常常撑出 .ptr-canvas 的固定宽高:以真实内容盒为准
|
|
486
|
-
let contentW = cr ? cr.width : 0;
|
|
487
|
-
let contentH = cr ? cr.height : 0;
|
|
488
|
-
for (const b of boards) {
|
|
489
|
-
contentW = Math.max(contentW, b.x + b.w);
|
|
490
|
-
contentH = Math.max(contentH, b.y + b.h);
|
|
491
|
-
}
|
|
492
|
-
return { width: Math.ceil(contentW), height: Math.ceil(contentH), boards };
|
|
493
|
-
})()`);
|
|
494
|
-
const realTarget = target === void 0 ? void 0 : measured.boards.find((b) => b.id === target.id) ?? void 0;
|
|
495
|
-
if (realTarget !== void 0 && target !== void 0) {
|
|
496
|
-
if (realTarget.w <= 0 || realTarget.h <= 0) throw new Error(`画板 ${boardLabel(target)} 尺寸为 0(${realTarget.w}×${realTarget.h}),无法截图`);
|
|
497
|
-
measuredBoard = {
|
|
498
|
-
id: target.id,
|
|
499
|
-
...target.name !== void 0 ? { name: target.name } : {},
|
|
500
|
-
x: Math.round(realTarget.x),
|
|
501
|
-
y: Math.round(realTarget.y),
|
|
502
|
-
w: realTarget.w,
|
|
503
|
-
h: realTarget.h
|
|
504
|
-
};
|
|
505
|
-
} else if (target !== void 0) {
|
|
506
|
-
if (target.w <= 0 || target.h <= 0) throw new Error(`画板 ${boardLabel(target)} 尺寸为 0(${target.w}×${target.h}),无法截图`);
|
|
507
|
-
measuredBoard = {
|
|
508
|
-
id: target.id,
|
|
509
|
-
...target.name !== void 0 ? { name: target.name } : {},
|
|
510
|
-
x: target.x,
|
|
511
|
-
y: target.y,
|
|
512
|
-
w: target.w,
|
|
513
|
-
h: target.h
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
const box = measuredBoard ?? {
|
|
517
|
-
x: 0,
|
|
518
|
-
y: 0,
|
|
519
|
-
w: measured.width,
|
|
520
|
-
h: measured.height
|
|
521
|
-
};
|
|
522
|
-
clip = {
|
|
523
|
-
x: Math.max(0, Math.floor(box.x)),
|
|
524
|
-
y: Math.max(0, Math.floor(box.y)),
|
|
525
|
-
width: Math.max(1, Math.ceil(box.w)),
|
|
526
|
-
height: Math.max(1, Math.ceil(box.h))
|
|
527
|
-
};
|
|
528
|
-
const vp = page.viewportSize();
|
|
529
|
-
const needW = clip.x + clip.width;
|
|
530
|
-
const needH = clip.y + clip.height;
|
|
531
|
-
if (vp === null || vp.width < needW || vp.height < needH) {
|
|
532
|
-
await page.setViewportSize({
|
|
533
|
-
width: Math.max(vp?.width ?? 0, needW),
|
|
534
|
-
height: Math.max(vp?.height ?? 0, needH)
|
|
535
|
-
});
|
|
536
|
-
await page.waitForTimeout(20);
|
|
537
|
-
}
|
|
538
|
-
await page.screenshot({
|
|
539
|
-
path: outPng,
|
|
540
|
-
clip
|
|
541
|
-
});
|
|
542
|
-
} finally {
|
|
543
|
-
await browser.close();
|
|
544
|
-
}
|
|
545
|
-
} finally {
|
|
546
|
-
rmSync(tmpDir, {
|
|
547
|
-
recursive: true,
|
|
548
|
-
force: true
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
if (statSync(outPng).size === 0) throw new Error("截图产物为空文件");
|
|
552
|
-
const result = {
|
|
553
|
-
out: outPng,
|
|
554
|
-
canvas: {
|
|
555
|
-
width: Math.max(rendered.canvas.width, clip.x + clip.width),
|
|
556
|
-
height: Math.max(rendered.canvas.height, clip.y + clip.height)
|
|
557
|
-
},
|
|
558
|
-
warnings: rendered.warnings,
|
|
559
|
-
browser: browserPath
|
|
560
|
-
};
|
|
561
|
-
if (measuredBoard !== void 0) result.board = {
|
|
562
|
-
id: measuredBoard.id,
|
|
563
|
-
...measuredBoard.name !== void 0 ? { name: measuredBoard.name } : {},
|
|
564
|
-
x: measuredBoard.x,
|
|
565
|
-
y: measuredBoard.y,
|
|
566
|
-
width: measuredBoard.w,
|
|
567
|
-
height: measuredBoard.h
|
|
568
|
-
};
|
|
569
|
-
return result;
|
|
570
|
-
}
|
|
571
|
-
// src/web/protocol.ts
|
|
572
|
-
function encodeLine(msg) {
|
|
573
|
-
return `${JSON.stringify(msg)}\n`;
|
|
574
|
-
}
|
|
575
|
-
/** 逐行解析器:处理流式分片,容忍尾部不完整行 */
|
|
576
|
-
var LineDecoder = class {
|
|
577
|
-
buf = "";
|
|
578
|
-
push(chunk) {
|
|
579
|
-
this.buf += chunk;
|
|
580
|
-
const out = [];
|
|
581
|
-
for (;;) {
|
|
582
|
-
const idx = this.buf.indexOf("\n");
|
|
583
|
-
if (idx < 0) break;
|
|
584
|
-
const line = this.buf.slice(0, idx);
|
|
585
|
-
this.buf = this.buf.slice(idx + 1);
|
|
586
|
-
if (line.trim() !== "") out.push(line);
|
|
587
|
-
}
|
|
588
|
-
return out;
|
|
589
|
-
}
|
|
590
|
-
};
|
|
591
|
-
// src/web/paths.ts
|
|
592
|
-
/** 运行期文件位置:默认 ~/.paintress/;可用 PAINTRESS_HOME 覆盖(测试隔离用) */
|
|
593
|
-
function runtimeDir() {
|
|
594
|
-
const dir = process.env["PAINTRESS_HOME"] ?? join(homedir(), ".paintress");
|
|
595
|
-
mkdirSync(dir, {
|
|
596
|
-
recursive: true,
|
|
597
|
-
mode: 448
|
|
598
|
-
});
|
|
599
|
-
return dir;
|
|
600
|
-
}
|
|
601
|
-
function socketPath() {
|
|
602
|
-
return join(runtimeDir(), "web.sock");
|
|
603
|
-
}
|
|
604
|
-
function lockPath() {
|
|
605
|
-
return join(runtimeDir(), "web.lock");
|
|
606
|
-
}
|
|
607
|
-
function pidPath() {
|
|
608
|
-
return join(runtimeDir(), "web.pid");
|
|
609
|
-
}
|
|
610
|
-
/**
|
|
611
|
-
* 项目名:URL 安全(字母数字点下划线连字符与 `/`),冲突时加短 hash 后缀。
|
|
612
|
-
* 允许 `/` 表达目录层级:默认项目名就是 `父目录/自身`(如 `c-thun/raybend`)。
|
|
613
|
-
*/
|
|
614
|
-
function sanitizeProjectName(name) {
|
|
615
|
-
const cleaned = name.replace(/[^A-Za-z0-9._/-]/g, "-").replace(/\.{2,}/g, ".").replace(/\/{2,}/g, "/").replace(/^[./-]+|[./-]+$/g, "");
|
|
616
|
-
return cleaned === "" ? "project" : cleaned;
|
|
617
|
-
}
|
|
618
|
-
/**
|
|
619
|
-
* 由目录推导默认项目名:`父目录/自身`(适配 repos/<org>/<project> 结构)。
|
|
620
|
-
* key 里直接带 `/`,URL 与展示都无需再转义。
|
|
621
|
-
*/
|
|
622
|
-
function deriveProjectName(dir) {
|
|
623
|
-
const abs = resolve(dir);
|
|
624
|
-
const parent = basename(dirname(abs));
|
|
625
|
-
const self = basename(abs);
|
|
626
|
-
return sanitizeProjectName(parent && parent !== "/" ? `${parent}/${self}` : self);
|
|
627
|
-
}
|
|
628
|
-
/** 校验并解析注册目录:必须存在且是目录;返回真实路径(供穿越校验基准) */
|
|
629
|
-
function resolveProjectDir(dir) {
|
|
630
|
-
const abs = resolve(dir);
|
|
631
|
-
if (!existsSync(abs)) throw new Error(`目录不存在:${abs}`);
|
|
632
|
-
if (!statSync(abs).isDirectory()) throw new Error(`不是目录:${abs}`);
|
|
633
|
-
return {
|
|
634
|
-
root: abs,
|
|
635
|
-
rootReal: realpathSync(abs)
|
|
636
|
-
};
|
|
637
|
-
}
|
|
638
|
-
/**
|
|
639
|
-
* 路径穿越防护:把请求路径解析为真实绝对路径,并确认它仍在项目根内。
|
|
640
|
-
* 失败返回 null(调用方回 404,不泄露真实路径)。
|
|
641
|
-
*/
|
|
642
|
-
function safeJoin(projectRootReal, requestPath) {
|
|
643
|
-
if (requestPath.includes("\0")) return null;
|
|
644
|
-
const normalized = requestPath.replace(/^\/+/, "");
|
|
645
|
-
const target = resolve(join(projectRootReal, normalized));
|
|
646
|
-
const relStr = relative(projectRootReal, target);
|
|
647
|
-
if (relStr === "" || relStr.startsWith("..") || relStr.includes(`..${sep}`)) return null;
|
|
648
|
-
if (!existsSync(target)) return null;
|
|
649
|
-
let real;
|
|
650
|
-
try {
|
|
651
|
-
real = realpathSync(target);
|
|
652
|
-
} catch {
|
|
653
|
-
return null;
|
|
654
|
-
}
|
|
655
|
-
const relReal = relative(projectRootReal, real);
|
|
656
|
-
if (relReal.startsWith("..") || relReal === "") return null;
|
|
657
|
-
return real;
|
|
658
|
-
}
|
|
659
|
-
// src/web/client.ts
|
|
660
|
-
/** 控制面客户端:一次请求/响应,或保持长连接(attach 用) */
|
|
661
|
-
function isDaemonRunning() {
|
|
662
|
-
return existsSync(socketPath());
|
|
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
|
-
}
|
|
680
|
-
function connectSocket() {
|
|
681
|
-
return new Promise((resolve, reject) => {
|
|
682
|
-
const sock = connect(socketPath());
|
|
683
|
-
const onError = (err) => {
|
|
684
|
-
sock.destroy();
|
|
685
|
-
reject(/* @__PURE__ */ new Error(`无法连接 paintress web(${socketPath()}):${err.message}`));
|
|
686
|
-
};
|
|
687
|
-
sock.once("error", onError);
|
|
688
|
-
sock.once("connect", () => {
|
|
689
|
-
sock.off("error", onError);
|
|
690
|
-
sock.on("error", () => {});
|
|
691
|
-
resolve(sock);
|
|
692
|
-
});
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
/** 单次请求;超时默认 5s */
|
|
696
|
-
async function sendRequest(req, timeoutMs = 5e3) {
|
|
697
|
-
const sock = await connectSocket();
|
|
698
|
-
return new Promise((resolve, reject) => {
|
|
699
|
-
const decoder = new LineDecoder();
|
|
700
|
-
const timer = setTimeout(() => {
|
|
701
|
-
sock.destroy();
|
|
702
|
-
reject(/* @__PURE__ */ new Error(`请求超时(${timeoutMs}ms)`));
|
|
703
|
-
}, timeoutMs);
|
|
704
|
-
sock.on("data", (chunk) => {
|
|
705
|
-
for (const line of decoder.push(chunk.toString("utf8"))) {
|
|
706
|
-
let parsed;
|
|
707
|
-
try {
|
|
708
|
-
parsed = JSON.parse(line);
|
|
709
|
-
} catch {
|
|
710
|
-
continue;
|
|
711
|
-
}
|
|
712
|
-
clearTimeout(timer);
|
|
713
|
-
sock.end();
|
|
714
|
-
resolve(parsed);
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
});
|
|
718
|
-
sock.on("close", () => {
|
|
719
|
-
clearTimeout(timer);
|
|
720
|
-
reject(/* @__PURE__ */ new Error("daemon 在响应前关闭了连接"));
|
|
721
|
-
});
|
|
722
|
-
sock.on("error", (err) => {
|
|
723
|
-
clearTimeout(timer);
|
|
724
|
-
reject(err);
|
|
725
|
-
});
|
|
726
|
-
sock.write(encodeLine(req));
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
/**
|
|
730
|
-
* 持久 attach:连接保持打开,进程退出时 OS 关闭连接 → daemon 立即感知。
|
|
731
|
-
* 这取代了 PID 轮询(PID 会被内核复用,不可靠)。
|
|
732
|
-
*/
|
|
733
|
-
async function attachPersistent(project) {
|
|
734
|
-
const sock = await connectSocket();
|
|
735
|
-
const decoder = new LineDecoder();
|
|
736
|
-
let closed = false;
|
|
737
|
-
const closeCbs = [];
|
|
738
|
-
return new Promise((resolve, reject) => {
|
|
739
|
-
const timer = setTimeout(() => {
|
|
740
|
-
sock.destroy();
|
|
741
|
-
reject(/* @__PURE__ */ new Error("attach 超时"));
|
|
742
|
-
}, 5e3);
|
|
743
|
-
sock.on("data", (chunk) => {
|
|
744
|
-
for (const line of decoder.push(chunk.toString("utf8"))) {
|
|
745
|
-
let parsed;
|
|
746
|
-
try {
|
|
747
|
-
parsed = JSON.parse(line);
|
|
748
|
-
} catch {
|
|
749
|
-
continue;
|
|
750
|
-
}
|
|
751
|
-
if (parsed.ok && "attached" in parsed) {
|
|
752
|
-
clearTimeout(timer);
|
|
753
|
-
resolve({
|
|
754
|
-
close: () => {
|
|
755
|
-
if (closed) return;
|
|
756
|
-
closed = true;
|
|
757
|
-
sock.end();
|
|
758
|
-
},
|
|
759
|
-
isClosed: () => closed,
|
|
760
|
-
onClose: (cb) => closeCbs.push(cb)
|
|
761
|
-
});
|
|
762
|
-
} else if (!parsed.ok) {
|
|
763
|
-
clearTimeout(timer);
|
|
764
|
-
sock.destroy();
|
|
765
|
-
reject(new Error(parsed.error));
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
});
|
|
769
|
-
sock.on("close", () => {
|
|
770
|
-
closed = true;
|
|
771
|
-
for (const cb of closeCbs) cb();
|
|
772
|
-
});
|
|
773
|
-
sock.on("error", (err) => {
|
|
774
|
-
clearTimeout(timer);
|
|
775
|
-
reject(err);
|
|
776
|
-
});
|
|
777
|
-
sock.write(encodeLine({
|
|
778
|
-
kind: "attach",
|
|
779
|
-
project
|
|
780
|
-
}));
|
|
781
|
-
});
|
|
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
|
-
}
|
|
961
|
-
// src/mcp/tools.ts
|
|
962
|
-
function reqStr(args, key) {
|
|
963
|
-
const v = args[key];
|
|
964
|
-
if (typeof v !== "string" || v === "") throw new Error(`缺少必填参数:${key}`);
|
|
965
|
-
return v;
|
|
966
|
-
}
|
|
967
|
-
function optStr(args, key) {
|
|
968
|
-
const v = args[key];
|
|
969
|
-
return typeof v === "string" && v !== "" ? v : void 0;
|
|
970
|
-
}
|
|
971
|
-
function optNum(args, key) {
|
|
972
|
-
const v = args[key];
|
|
973
|
-
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
974
|
-
}
|
|
975
|
-
function optBool(args, key) {
|
|
976
|
-
const v = args[key];
|
|
977
|
-
return typeof v === "boolean" ? v : void 0;
|
|
978
|
-
}
|
|
979
|
-
function objArg(args, key) {
|
|
980
|
-
const v = args[key];
|
|
981
|
-
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error(`参数 ${key} 必须是对象`);
|
|
982
|
-
return v;
|
|
983
|
-
}
|
|
984
|
-
/** 读:load 后执行,直接返回负载 */
|
|
985
|
-
function read(path, fn) {
|
|
986
|
-
return fn(loadDoc(path));
|
|
987
|
-
}
|
|
988
|
-
/**
|
|
989
|
-
* 把「本次写操作动到的节点 id」告知 daemon,供前端做变更高亮。
|
|
990
|
-
* 先转绝对路径:daemon 的 cwd 未必与调用方一致,不能让 daemon 替我们 resolve 相对路径。
|
|
991
|
-
* 非阻塞发送(写路径性能语义不变);socket 默认有 ref,进程不会在响应前提前退出。
|
|
992
|
-
*/
|
|
993
|
-
function notifyChanges(path, ids) {
|
|
994
|
-
if (ids.length === 0 || !isDaemonRunning()) return;
|
|
995
|
-
sendRequest({
|
|
996
|
-
kind: "changes",
|
|
997
|
-
path: resolve(path),
|
|
998
|
-
ids
|
|
999
|
-
}, 2e3).catch(() => {});
|
|
1000
|
-
}
|
|
1001
|
-
/**
|
|
1002
|
-
* 写:load → 变更 → 校验(结构错误不落盘)→ 原子保存(保持原风味)。
|
|
1003
|
-
* fn 可通过 mark() 登记受影响的节点 id,写成功后统一通知 daemon。
|
|
1004
|
-
*/
|
|
1005
|
-
async function write(path, fn) {
|
|
1006
|
-
const loaded = loadDoc(path);
|
|
1007
|
-
const touched = [];
|
|
1008
|
-
const mark = (ids) => {
|
|
1009
|
-
touched.push(...ids);
|
|
1010
|
-
};
|
|
1011
|
-
const result = fn(loaded.doc, mark);
|
|
1012
|
-
const presetNames = projectPresetNames(path);
|
|
1013
|
-
const v = validateDoc(loaded.doc, {
|
|
1014
|
-
knownVars: presetNames,
|
|
1015
|
-
presetVars: presetNames
|
|
1016
|
-
});
|
|
1017
|
-
if (!v.ok) throw new Error(`变更后结构校验失败,未落盘:\n${v.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
|
|
1018
|
-
saveDoc(path, loaded.doc, {
|
|
1019
|
-
expectedMtimeMs: loaded.mtimeMs,
|
|
1020
|
-
flavor: loaded.flavor
|
|
1021
|
-
});
|
|
1022
|
-
notifyChanges(path, touched);
|
|
1023
|
-
return {
|
|
1024
|
-
ok: true,
|
|
1025
|
-
result,
|
|
1026
|
-
warnings: v.warnings
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
function countByType(children) {
|
|
1030
|
-
const counts = {};
|
|
1031
|
-
const walk = (nodes) => {
|
|
1032
|
-
for (const n of nodes) {
|
|
1033
|
-
if (!n || typeof n !== "object") continue;
|
|
1034
|
-
const rec = n;
|
|
1035
|
-
const t = typeof rec["type"] === "string" ? rec["type"] : "?";
|
|
1036
|
-
counts[t] = (counts[t] ?? 0) + 1;
|
|
1037
|
-
const kids = rec["children"];
|
|
1038
|
-
if (Array.isArray(kids)) walk(kids);
|
|
1039
|
-
}
|
|
1040
|
-
};
|
|
1041
|
-
walk(children);
|
|
1042
|
-
return counts;
|
|
1043
|
-
}
|
|
1044
|
-
/**
|
|
1045
|
-
* 新建设计稿。path 不带扩展名时补默认扩展名;调用方显式写 `.pen` 时按兼容格式产出
|
|
1046
|
-
* (不替用户转换已有文件)。
|
|
1047
|
-
*/
|
|
1048
|
-
function newDoc(args) {
|
|
1049
|
-
const raw = reqStr(args, "path");
|
|
1050
|
-
const path = /\.(pen|ptr)$/i.test(raw) ? raw : `${raw}.ptr`;
|
|
1051
|
-
const flavor = path.toLowerCase().endsWith(".pen") ? "pen" : "ptr";
|
|
1052
|
-
if (existsSync(path) && optBool(args, "overwrite") !== true) throw new Error(`文件已存在,未覆盖:${path}(确需覆盖传 overwrite=true)`);
|
|
1053
|
-
const width = optNum(args, "width");
|
|
1054
|
-
const height = optNum(args, "height");
|
|
1055
|
-
if (width !== void 0 && width <= 0) throw new Error(`width 必须大于 0,收到:${width}`);
|
|
1056
|
-
if (height !== void 0 && height <= 0) throw new Error(`height 必须大于 0,收到:${height}`);
|
|
1057
|
-
const layoutArg = optStr(args, "layout");
|
|
1058
|
-
if (layoutArg !== void 0 && ![
|
|
1059
|
-
"vertical",
|
|
1060
|
-
"horizontal",
|
|
1061
|
-
"none"
|
|
1062
|
-
].includes(layoutArg)) throw new Error(`layout 仅支持 vertical / horizontal / none,收到:${layoutArg}`);
|
|
1063
|
-
const { doc, boardId } = createDoc({
|
|
1064
|
-
name: optStr(args, "name"),
|
|
1065
|
-
width,
|
|
1066
|
-
height,
|
|
1067
|
-
fill: optStr(args, "fill"),
|
|
1068
|
-
layout: layoutArg === "vertical" || layoutArg === "horizontal" || layoutArg === "none" ? layoutArg : void 0,
|
|
1069
|
-
padding: optNum(args, "padding"),
|
|
1070
|
-
gap: optNum(args, "gap")
|
|
1071
|
-
});
|
|
1072
|
-
const check = validateDoc(doc);
|
|
1073
|
-
if (!check.ok) throw new Error(`新建文档结构校验失败,未落盘:\n${check.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
|
|
1074
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1075
|
-
saveDoc(path, doc, {
|
|
1076
|
-
flavor,
|
|
1077
|
-
createOnly: optBool(args, "overwrite") !== true
|
|
1078
|
-
});
|
|
1079
|
-
const board = doc.children[0];
|
|
1080
|
-
return {
|
|
1081
|
-
ok: true,
|
|
1082
|
-
path,
|
|
1083
|
-
flavor,
|
|
1084
|
-
version: doc.version,
|
|
1085
|
-
boardId,
|
|
1086
|
-
canvas: {
|
|
1087
|
-
width: Number(board?.["width"] ?? 0),
|
|
1088
|
-
height: Number(board?.["height"] ?? 0)
|
|
1089
|
-
},
|
|
1090
|
-
warnings: check.warnings
|
|
1091
|
-
};
|
|
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
|
-
}
|
|
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
|
-
},
|
|
1134
|
-
paintress_new: {
|
|
1135
|
-
description: "新建一份设计稿:创建一个带空 board 的最小可用文档,可直接 render/screenshot 再逐层 insert;已存在同名文件时报错,除非 overwrite=true。path 不带扩展名时自动补默认扩展名",
|
|
1136
|
-
inputSchema: {
|
|
1137
|
-
path: z.string().describe("新文档路径;不带扩展名时自动补默认扩展名"),
|
|
1138
|
-
name: z.string().optional().describe("board 名称(默认 Board)"),
|
|
1139
|
-
width: z.number().positive().optional().describe("board 宽(默认 900)"),
|
|
1140
|
-
height: z.number().positive().optional().describe("board 高(默认 600)"),
|
|
1141
|
-
fill: z.string().optional().describe("board 背景色(默认 #FFFFFF)"),
|
|
1142
|
-
layout: z.enum([
|
|
1143
|
-
"vertical",
|
|
1144
|
-
"horizontal",
|
|
1145
|
-
"none"
|
|
1146
|
-
]).optional().describe("子节点布局(默认 vertical)"),
|
|
1147
|
-
padding: z.number().optional().describe("board 内边距(默认 0)"),
|
|
1148
|
-
gap: z.number().optional().describe("主轴向间距(默认 0)"),
|
|
1149
|
-
overwrite: z.boolean().optional().describe("已存在时覆盖(默认 false)")
|
|
1150
|
-
},
|
|
1151
|
-
run: (a) => newDoc(a)
|
|
1152
|
-
},
|
|
1153
|
-
paintress_doc_info: {
|
|
1154
|
-
description: "文档摘要:版本、格式风味、节点统计、变量表、顶层节点",
|
|
1155
|
-
inputSchema: { path: z.string().describe("设计稿路径") },
|
|
1156
|
-
run: (a) => read(reqStr(a, "path"), (ctx) => ({
|
|
1157
|
-
flavor: ctx.flavor,
|
|
1158
|
-
version: ctx.doc.version,
|
|
1159
|
-
fileToken: ctx.doc.fileToken,
|
|
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
|
-
})(),
|
|
1168
|
-
topLevel: ctx.doc.children.map((c) => ({
|
|
1169
|
-
id: c["id"],
|
|
1170
|
-
name: c["name"],
|
|
1171
|
-
type: c["type"]
|
|
1172
|
-
})),
|
|
1173
|
-
nodeCount: Object.values(countByType(ctx.doc.children)).reduce((s, n) => s + n, 0),
|
|
1174
|
-
byType: countByType(ctx.doc.children),
|
|
1175
|
-
warnings: ctx.validation.warnings
|
|
1176
|
-
}))
|
|
1177
|
-
},
|
|
1178
|
-
paintress_query: {
|
|
1179
|
-
description: "条件查询节点:type / name(精确或正则)/ id / reusable",
|
|
1180
|
-
inputSchema: {
|
|
1181
|
-
path: z.string(),
|
|
1182
|
-
type: z.string().optional(),
|
|
1183
|
-
name: z.string().optional(),
|
|
1184
|
-
nameRegex: z.boolean().optional().describe("name 按正则解释"),
|
|
1185
|
-
id: z.string().optional(),
|
|
1186
|
-
reusable: z.boolean().optional()
|
|
1187
|
-
},
|
|
1188
|
-
run: (a) => read(reqStr(a, "path"), (ctx) => query(ctx.doc, {
|
|
1189
|
-
type: optStr(a, "type"),
|
|
1190
|
-
name: optStr(a, "name"),
|
|
1191
|
-
nameRegex: optBool(a, "nameRegex"),
|
|
1192
|
-
id: optStr(a, "id"),
|
|
1193
|
-
reusable: optBool(a, "reusable")
|
|
1194
|
-
}))
|
|
1195
|
-
},
|
|
1196
|
-
paintress_get: {
|
|
1197
|
-
description: "取单个节点子树;depth 控制深度(0=仅本节点,children 省略为 …)",
|
|
1198
|
-
inputSchema: {
|
|
1199
|
-
path: z.string(),
|
|
1200
|
-
id: z.string(),
|
|
1201
|
-
depth: z.number().int().min(0).optional()
|
|
1202
|
-
},
|
|
1203
|
-
run: (a) => read(reqStr(a, "path"), (ctx) => {
|
|
1204
|
-
const res = get(ctx.doc, reqStr(a, "id"), optNum(a, "depth") ?? Infinity);
|
|
1205
|
-
if (!res) throw new Error(`节点不存在:${reqStr(a, "id")}`);
|
|
1206
|
-
return res;
|
|
1207
|
-
})
|
|
1208
|
-
},
|
|
1209
|
-
paintress_update: {
|
|
1210
|
-
description: "浅合并更新节点属性(id 与 type 不可改)",
|
|
1211
|
-
inputSchema: {
|
|
1212
|
-
path: z.string(),
|
|
1213
|
-
id: z.string(),
|
|
1214
|
-
patch: z.record(z.unknown())
|
|
1215
|
-
},
|
|
1216
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
1217
|
-
const id = reqStr(a, "id");
|
|
1218
|
-
const res = update(doc, id, objArg(a, "patch"));
|
|
1219
|
-
mark([id]);
|
|
1220
|
-
return res;
|
|
1221
|
-
})
|
|
1222
|
-
},
|
|
1223
|
-
paintress_insert: {
|
|
1224
|
-
description: "插入子节点(缺 id 自动生成;返回新 id)",
|
|
1225
|
-
inputSchema: {
|
|
1226
|
-
path: z.string(),
|
|
1227
|
-
parent: z.string().describe("父节点 id,或 'root'"),
|
|
1228
|
-
node: z.record(z.unknown()),
|
|
1229
|
-
index: z.number().int().min(0).optional()
|
|
1230
|
-
},
|
|
1231
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
1232
|
-
const newId = insert(doc, reqStr(a, "parent"), objArg(a, "node"), optNum(a, "index"));
|
|
1233
|
-
mark([newId]);
|
|
1234
|
-
return newId;
|
|
1235
|
-
})
|
|
1236
|
-
},
|
|
1237
|
-
paintress_delete: {
|
|
1238
|
-
description: "删除节点(含子树)",
|
|
1239
|
-
inputSchema: {
|
|
1240
|
-
path: z.string(),
|
|
1241
|
-
id: z.string()
|
|
1242
|
-
},
|
|
1243
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
1244
|
-
const id = reqStr(a, "id");
|
|
1245
|
-
const node = remove(doc, id);
|
|
1246
|
-
mark([id]);
|
|
1247
|
-
return { deleted: {
|
|
1248
|
-
id: node["id"],
|
|
1249
|
-
name: node["name"],
|
|
1250
|
-
type: node["type"]
|
|
1251
|
-
} };
|
|
1252
|
-
})
|
|
1253
|
-
},
|
|
1254
|
-
paintress_move: {
|
|
1255
|
-
description: "移动/重排节点到新父(防环校验)",
|
|
1256
|
-
inputSchema: {
|
|
1257
|
-
path: z.string(),
|
|
1258
|
-
id: z.string(),
|
|
1259
|
-
parent: z.string(),
|
|
1260
|
-
index: z.number().int().min(0).optional()
|
|
1261
|
-
},
|
|
1262
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
1263
|
-
const id = reqStr(a, "id");
|
|
1264
|
-
move(doc, id, reqStr(a, "parent"), optNum(a, "index"));
|
|
1265
|
-
mark([id]);
|
|
1266
|
-
return {
|
|
1267
|
-
moved: id,
|
|
1268
|
-
to: reqStr(a, "parent")
|
|
1269
|
-
};
|
|
1270
|
-
})
|
|
1271
|
-
},
|
|
1272
|
-
paintress_batch: {
|
|
1273
|
-
description: "批量原子操作:update/insert/delete/move 数组,任一步失败整体作废",
|
|
1274
|
-
inputSchema: {
|
|
1275
|
-
path: z.string(),
|
|
1276
|
-
ops: z.array(z.record(z.unknown()))
|
|
1277
|
-
},
|
|
1278
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
1279
|
-
const raw = a["ops"];
|
|
1280
|
-
if (!Array.isArray(raw)) throw new Error("ops 必须是数组");
|
|
1281
|
-
const ops = raw.map((op, idx) => {
|
|
1282
|
-
const rec = op;
|
|
1283
|
-
const kind = rec["op"];
|
|
1284
|
-
if (kind === "update") return {
|
|
1285
|
-
op: "update",
|
|
1286
|
-
id: String(rec["id"]),
|
|
1287
|
-
patch: rec["patch"] ?? {}
|
|
1288
|
-
};
|
|
1289
|
-
if (kind === "insert") return {
|
|
1290
|
-
op: "insert",
|
|
1291
|
-
parent: String(rec["parent"]),
|
|
1292
|
-
node: rec["node"],
|
|
1293
|
-
index: typeof rec["index"] === "number" ? rec["index"] : void 0
|
|
1294
|
-
};
|
|
1295
|
-
if (kind === "delete") return {
|
|
1296
|
-
op: "delete",
|
|
1297
|
-
id: String(rec["id"])
|
|
1298
|
-
};
|
|
1299
|
-
if (kind === "move") return {
|
|
1300
|
-
op: "move",
|
|
1301
|
-
id: String(rec["id"]),
|
|
1302
|
-
parent: String(rec["parent"]),
|
|
1303
|
-
index: typeof rec["index"] === "number" ? rec["index"] : void 0
|
|
1304
|
-
};
|
|
1305
|
-
throw new Error(`ops[${idx}] 的 op 必须是 update|insert|delete|move`);
|
|
1306
|
-
});
|
|
1307
|
-
const before = new Set(collectIds(doc.children));
|
|
1308
|
-
doc.children = batch(doc, ops).children;
|
|
1309
|
-
const insertedIds = collectIds(doc.children).filter((id) => !before.has(id));
|
|
1310
|
-
mark([...ops.flatMap((op) => op.op === "insert" ? [] : [op.id]), ...insertedIds]);
|
|
1311
|
-
return {
|
|
1312
|
-
applied: ops.length,
|
|
1313
|
-
inserted: insertedIds.length
|
|
1314
|
-
};
|
|
1315
|
-
})
|
|
1316
|
-
},
|
|
1317
|
-
paintress_validate: {
|
|
1318
|
-
description: "校验文档:结构错误 + 语义警告(重复 id、悬空 ref、未知类型、未定义变量)。项目预设里的变量算已定义",
|
|
1319
|
-
inputSchema: { path: z.string() },
|
|
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
|
-
}
|
|
1432
|
-
},
|
|
1433
|
-
paintress_render: {
|
|
1434
|
-
description: "渲染设计稿为 HTML 文件(每节点带 data-node-id);返回输出路径与画布尺寸",
|
|
1435
|
-
inputSchema: {
|
|
1436
|
-
path: z.string(),
|
|
1437
|
-
out: z.string().describe("输出 .html 路径")
|
|
1438
|
-
},
|
|
1439
|
-
run: (a) => {
|
|
1440
|
-
const out = reqStr(a, "out");
|
|
1441
|
-
const path = reqStr(a, "path");
|
|
1442
|
-
const loaded = loadDoc(path);
|
|
1443
|
-
const r = renderDocument(applyProjectPresets(loaded.doc, path).doc);
|
|
1444
|
-
writeFileSync(out, r.html, "utf8");
|
|
1445
|
-
return {
|
|
1446
|
-
ok: true,
|
|
1447
|
-
out,
|
|
1448
|
-
canvas: r.canvas,
|
|
1449
|
-
warnings: r.warnings
|
|
1450
|
-
};
|
|
1451
|
-
}
|
|
1452
|
-
},
|
|
1453
|
-
paintress_screenshot: {
|
|
1454
|
-
description: "渲染并截图为 PNG(默认 2x);需要本机 Chromium/Chrome 或 PTR_BROWSER 指向的可执行文件。指定 board 时只截该画板(顶层 frame,即“一页”),而不是整张画布",
|
|
1455
|
-
inputSchema: {
|
|
1456
|
-
path: z.string(),
|
|
1457
|
-
out: z.string().describe("输出 .png 路径"),
|
|
1458
|
-
scale: z.number().int().min(1).max(4).optional().describe("像素倍率,默认 2"),
|
|
1459
|
-
board: z.string().optional().describe("只截某个画板:id 或 name;模糊命中多个时会报错并列出候选")
|
|
1460
|
-
},
|
|
1461
|
-
run: async (a) => {
|
|
1462
|
-
if (!findBrowser()) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER");
|
|
1463
|
-
const board = typeof a["board"] === "string" ? a["board"] : "";
|
|
1464
|
-
const res = await screenshotPen(reqStr(a, "path"), reqStr(a, "out"), {
|
|
1465
|
-
...typeof a["scale"] === "number" ? { scale: a["scale"] } : {},
|
|
1466
|
-
...board !== "" ? { board } : {}
|
|
1467
|
-
});
|
|
1468
|
-
return {
|
|
1469
|
-
ok: true,
|
|
1470
|
-
out: res.out,
|
|
1471
|
-
canvas: res.canvas,
|
|
1472
|
-
...res.board !== void 0 ? { board: res.board } : {},
|
|
1473
|
-
browser: res.browser,
|
|
1474
|
-
warnings: res.warnings
|
|
1475
|
-
};
|
|
1476
|
-
}
|
|
1477
|
-
},
|
|
1478
|
-
paintress_marks: {
|
|
1479
|
-
description: "读取/清空当前会话的节点标记:用户在浏览器里点击的节点会分配短号 #1 #2…,返回短号 → 节点 id 映射,便于对话中指代(如“#1 改成蓝色”)",
|
|
1480
|
-
inputSchema: {
|
|
1481
|
-
project: z.string().optional().describe("项目名;缺省返回全部项目的标记"),
|
|
1482
|
-
action: z.enum(["list", "clear"]).optional().describe("默认 list")
|
|
1483
|
-
},
|
|
1484
|
-
run: async (a) => {
|
|
1485
|
-
if (!isDaemonRunning()) throw new Error("paintress web 未运行,无法读取标记;先调用 paintress_viewer(或运行 paintress web --daemon)");
|
|
1486
|
-
const project = optStr(a, "project") ?? "";
|
|
1487
|
-
if ((optStr(a, "action") ?? "list") === "clear") return await sendRequest({
|
|
1488
|
-
kind: "clear-marks",
|
|
1489
|
-
project
|
|
1490
|
-
});
|
|
1491
|
-
return await sendRequest({
|
|
1492
|
-
kind: "marks",
|
|
1493
|
-
project
|
|
1494
|
-
});
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
};
|
|
1498
|
-
// src/mcp/server.ts
|
|
1499
|
-
const SERVER_NAME = "paintress";
|
|
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
|
-
]);
|
|
1524
|
-
/** 构建 MCP server:把共享工具注册到 stdio 协议上 */
|
|
1525
|
-
function createMcpServer(opts = {}) {
|
|
1526
|
-
const server = new McpServer({
|
|
1527
|
-
name: SERVER_NAME,
|
|
1528
|
-
version: SERVER_VERSION
|
|
1529
|
-
}, { instructions: INSTRUCTIONS });
|
|
1530
|
-
for (const [name, def] of Object.entries(TOOLS)) server.registerTool(name, {
|
|
1531
|
-
description: def.description,
|
|
1532
|
-
inputSchema: def.inputSchema
|
|
1533
|
-
}, async (args) => {
|
|
1534
|
-
try {
|
|
1535
|
-
if (opts.ensureViewerBeforeWrite === true && WRITE_TOOLS.has(name)) await ensureViewerQuiet();
|
|
1536
|
-
const payload = await def.run(args);
|
|
1537
|
-
return { content: [{
|
|
1538
|
-
type: "text",
|
|
1539
|
-
text: JSON.stringify(payload, null, 2)
|
|
1540
|
-
}] };
|
|
1541
|
-
} catch (cause) {
|
|
1542
|
-
return {
|
|
1543
|
-
content: [{
|
|
1544
|
-
type: "text",
|
|
1545
|
-
text: cause instanceof Error ? cause.message : String(cause)
|
|
1546
|
-
}],
|
|
1547
|
-
isError: true
|
|
1548
|
-
};
|
|
1549
|
-
}
|
|
1550
|
-
});
|
|
1551
|
-
return server;
|
|
1552
|
-
}
|
|
1553
|
-
/**
|
|
1554
|
-
* 父进程看门狗:stdio MCP server 必须随宿主死亡而退出,否则变成孤儿进程
|
|
1555
|
-
* (MCP 生态的已知问题)。两道保险:ppid 变化 + stdin EOF。
|
|
1556
|
-
*/
|
|
1557
|
-
function installParentWatchdog() {
|
|
1558
|
-
const parentPid = process.ppid;
|
|
1559
|
-
setInterval(() => {
|
|
1560
|
-
if (process.ppid !== parentPid) exitClean(0);
|
|
1561
|
-
try {
|
|
1562
|
-
process.kill(parentPid, 0);
|
|
1563
|
-
} catch {
|
|
1564
|
-
exitClean(0);
|
|
1565
|
-
}
|
|
1566
|
-
}, 5e3).unref();
|
|
1567
|
-
process.stdin.on("end", () => exitClean(0));
|
|
1568
|
-
process.stdin.on("close", () => exitClean(0));
|
|
1569
|
-
}
|
|
1570
|
-
/**
|
|
1571
|
-
* attach 连接引用:由 autoregister 持有(ensureViewer 每次调用都会复用/刷新)。
|
|
1572
|
-
* 退出时显式关闭——比等 OS 回收更快让 daemon 看到 we're gone。
|
|
1573
|
-
*/
|
|
1574
|
-
function exitClean(code = 0) {
|
|
1575
|
-
closeViewerHandles();
|
|
1576
|
-
process.exit(code);
|
|
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
|
-
}
|
|
1586
|
-
/** 自动注册自身(best-effort,失败不阻断 MCP) */
|
|
1587
|
-
async function registerSelf() {
|
|
1588
|
-
const res = await autoRegister();
|
|
1589
|
-
if (res) process.stderr.write(`paintress: 已注册项目 "${res.project}",浏览器打开 ${res.url}\n`);
|
|
1590
|
-
}
|
|
1591
|
-
/** CLI `paintress mcp` 入口:stdio transport + 自动注册 */
|
|
1592
|
-
async function runStdioServer() {
|
|
1593
|
-
const server = createMcpServer({ ensureViewerBeforeWrite: true });
|
|
1594
|
-
const transport = new StdioServerTransport();
|
|
1595
|
-
await server.connect(transport);
|
|
1596
|
-
installParentWatchdog();
|
|
1597
|
-
registerSelf();
|
|
1598
|
-
}
|
|
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 };
|