dsh-comfyui 0.4.0 → 0.5.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.en.md +3 -1
- package/README.md +3 -1
- package/client/client.js +644 -17
- package/client/client.js.map +1 -1
- package/lib/analyze.d.ts +3 -1
- package/lib/analyze.js +2 -1
- package/lib/convert.d.ts +0 -9
- package/lib/convert.js +15 -7
- package/lib/graph.d.ts +15 -0
- package/lib/graph.js +48 -0
- package/lib/index.js +9 -2
- package/lib/routes.js +181 -0
- package/lib/skill.js +1 -0
- package/lib/skillpack.d.ts +29 -2
- package/lib/skillpack.js +73 -5
- package/lib/transfer.d.ts +158 -0
- package/lib/transfer.js +460 -0
- package/package.json +3 -2
package/lib/analyze.d.ts
CHANGED
|
@@ -20,7 +20,9 @@ export interface GraphGroupLike {
|
|
|
20
20
|
}
|
|
21
21
|
export interface GraphLike {
|
|
22
22
|
nodes: GraphNodeLike[];
|
|
23
|
-
|
|
23
|
+
/** Saved rows are positional or object entries depending on the frontend
|
|
24
|
+
* version — always consumed through {@link normalizeLinks}. */
|
|
25
|
+
links: unknown[];
|
|
24
26
|
groups?: GraphGroupLike[];
|
|
25
27
|
}
|
|
26
28
|
export interface IsolatedNode {
|
package/lib/analyze.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* graph links (with bypassed and dangling nodes excluded). The analysis feeds
|
|
6
6
|
* the extract (拆分) choices in the panel and the agent-facing skill.
|
|
7
7
|
*/
|
|
8
|
+
import { normalizeLinks } from './graph.js';
|
|
8
9
|
function isObject(value) {
|
|
9
10
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
11
|
}
|
|
@@ -27,7 +28,7 @@ export function analyzeGraph(graph) {
|
|
|
27
28
|
return { ok: false, error: '无法解析图文件(缺少 nodes/links)' };
|
|
28
29
|
}
|
|
29
30
|
const nodes = graph.nodes;
|
|
30
|
-
const links = graph.links;
|
|
31
|
+
const links = normalizeLinks(graph.links);
|
|
31
32
|
const groups = Array.isArray(graph.groups) ? graph.groups : [];
|
|
32
33
|
const active = nodes.filter((node) => node.mode !== 4);
|
|
33
34
|
const activeById = new Map(active.map((node) => [node.id, node]));
|
package/lib/convert.d.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Convert a ComfyUI UI-graph workflow (the format the ComfyUI frontend saves
|
|
3
|
-
* to the server via /api/userdata) into the API format accepted by POST
|
|
4
|
-
* /prompt. Modeled on the frontend's convertToApiFormat: input links become
|
|
5
|
-
* [nodeId, slot] references, widgets_values are zipped onto input names in
|
|
6
|
-
* object_info order (control_after_generate is the extra seed widget), and
|
|
7
|
-
* Reroute / bypassed (mode 4) nodes are skipped with their links rewired.
|
|
8
|
-
* Nodes the conversion cannot represent fail loudly with the offending type.
|
|
9
|
-
*/
|
|
10
1
|
export interface ApiWorkflow {
|
|
11
2
|
[nodeId: string]: {
|
|
12
3
|
class_type: string;
|
package/lib/convert.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a ComfyUI UI-graph workflow (the format the ComfyUI frontend saves
|
|
3
|
+
* to the server via /api/userdata) into the API format accepted by POST
|
|
4
|
+
* /prompt. Modeled on the frontend's convertToApiFormat: input links become
|
|
5
|
+
* [nodeId, slot] references, widgets_values are zipped onto input names in
|
|
6
|
+
* object_info order (control_after_generate is the extra seed widget), and
|
|
7
|
+
* Reroute / bypassed (mode 4) nodes are skipped with their links rewired.
|
|
8
|
+
* Nodes the conversion cannot represent fail loudly with the offending type.
|
|
9
|
+
*/
|
|
10
|
+
import { normalizeLinks } from './graph.js';
|
|
1
11
|
/** Node types that exist only in the UI and carry no data flow. */
|
|
2
12
|
const UI_ONLY = new Set(['Note', 'StickyNote', 'Reroute', 'Fast Groups Bypasser (rgthree)']);
|
|
3
13
|
function isObject(value) {
|
|
@@ -195,13 +205,11 @@ export function convertGraphToApi(graph, objectInfo, options) {
|
|
|
195
205
|
};
|
|
196
206
|
});
|
|
197
207
|
const links = new Map();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
links.set(link[0], link);
|
|
204
|
-
}
|
|
208
|
+
// normalizeLinks accepts both the legacy positional rows and the v0.4
|
|
209
|
+
// frontend's object entries; anything unreadable is skipped, exactly like
|
|
210
|
+
// the old array-only guard did.
|
|
211
|
+
for (const link of normalizeLinks(rawLinks))
|
|
212
|
+
links.set(link[0], link);
|
|
205
213
|
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
|
206
214
|
const included = options?.includeNodeIds;
|
|
207
215
|
const candidates = included !== undefined ? nodes.filter((node) => included.has(node.id)) : nodes;
|
package/lib/graph.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComfyUI UI-graph link normalization. The frontend changed how saved graphs
|
|
3
|
+
* serialize the `links` array: rows used to be positional
|
|
4
|
+
* (`[id, originId, originSlot, targetId, targetSlot, type]`), while the v0.4
|
|
5
|
+
* frontend writes object entries
|
|
6
|
+
* (`{ id, origin_id, origin_slot, target_id, target_slot, type }`). Analysis
|
|
7
|
+
* and conversion both want one canonical shape, so both go through
|
|
8
|
+
* {@link normalizeLinks} before touching a graph.
|
|
9
|
+
*/
|
|
10
|
+
/** Canonical link row: [linkId, originId, originSlot, targetId, targetSlot, type]. */
|
|
11
|
+
export type GraphLink = [number, number, number, number, number, string];
|
|
12
|
+
/** Normalize one link entry; undefined when the entry is not a readable link. */
|
|
13
|
+
export declare function normalizeLink(raw: unknown): GraphLink | undefined;
|
|
14
|
+
/** Normalize a whole `links` array, skipping entries that are not readable. */
|
|
15
|
+
export declare function normalizeLinks(raw: unknown): GraphLink[];
|
package/lib/graph.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComfyUI UI-graph link normalization. The frontend changed how saved graphs
|
|
3
|
+
* serialize the `links` array: rows used to be positional
|
|
4
|
+
* (`[id, originId, originSlot, targetId, targetSlot, type]`), while the v0.4
|
|
5
|
+
* frontend writes object entries
|
|
6
|
+
* (`{ id, origin_id, origin_slot, target_id, target_slot, type }`). Analysis
|
|
7
|
+
* and conversion both want one canonical shape, so both go through
|
|
8
|
+
* {@link normalizeLinks} before touching a graph.
|
|
9
|
+
*/
|
|
10
|
+
function isObject(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
/** Normalize one link entry; undefined when the entry is not a readable link. */
|
|
14
|
+
export function normalizeLink(raw) {
|
|
15
|
+
if (Array.isArray(raw)) {
|
|
16
|
+
if (raw.length < 6)
|
|
17
|
+
return undefined;
|
|
18
|
+
const [id, origin, originSlot, target, targetSlot, type] = raw;
|
|
19
|
+
if (typeof id !== 'number' || typeof origin !== 'number' ||
|
|
20
|
+
typeof originSlot !== 'number' || typeof target !== 'number' ||
|
|
21
|
+
typeof targetSlot !== 'number') {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return [id, origin, originSlot, target, targetSlot, typeof type === 'string' ? type : ''];
|
|
25
|
+
}
|
|
26
|
+
if (isObject(raw)) {
|
|
27
|
+
const { id, origin_id: origin, origin_slot: originSlot, target_id: target, target_slot: targetSlot, type } = raw;
|
|
28
|
+
if (typeof id !== 'number' || typeof origin !== 'number' ||
|
|
29
|
+
typeof originSlot !== 'number' || typeof target !== 'number' ||
|
|
30
|
+
typeof targetSlot !== 'number') {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return [id, origin, originSlot, target, targetSlot, typeof type === 'string' ? type : ''];
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Normalize a whole `links` array, skipping entries that are not readable. */
|
|
38
|
+
export function normalizeLinks(raw) {
|
|
39
|
+
if (!Array.isArray(raw))
|
|
40
|
+
return [];
|
|
41
|
+
const links = [];
|
|
42
|
+
for (const entry of raw) {
|
|
43
|
+
const link = normalizeLink(entry);
|
|
44
|
+
if (link !== undefined)
|
|
45
|
+
links.push(link);
|
|
46
|
+
}
|
|
47
|
+
return links;
|
|
48
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -217,7 +217,9 @@ export async function apply(ctx, entryConfig) {
|
|
|
217
217
|
const entries = await client.listUserData('workflows');
|
|
218
218
|
const library = await store.listWorkflows();
|
|
219
219
|
return entries
|
|
220
|
-
|
|
220
|
+
// Dot entries are ComfyUI bookkeeping (e.g. `.index.json`), not graphs.
|
|
221
|
+
.filter((entry) => entry.type === 'file' && entry.name.endsWith('.json')
|
|
222
|
+
&& entry.name.split(/[\\/]/).every((segment) => !segment.startsWith('.')))
|
|
221
223
|
.map((entry) => {
|
|
222
224
|
const derived = library.filter((workflow) => workflow.comfyuiFile === entry.name);
|
|
223
225
|
return {
|
|
@@ -319,7 +321,12 @@ export async function apply(ctx, entryConfig) {
|
|
|
319
321
|
// service simply never registers it, and the entry config stands as composed.
|
|
320
322
|
let source = () => resolved;
|
|
321
323
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
322
|
-
settingsCtx.settings
|
|
324
|
+
const settings = settingsCtx.settings;
|
|
325
|
+
if (typeof settings.installSection !== 'function') {
|
|
326
|
+
ctx.logger.warn('comfyui: settings service lacks installSection — settings page stays read-only, entry config stands');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
settings.installSection(ctx, COMFYUI_NS, Config, resolved, {
|
|
323
330
|
setSource: (current) => {
|
|
324
331
|
source = current;
|
|
325
332
|
},
|
package/lib/routes.js
CHANGED
|
@@ -2,6 +2,7 @@ import { errorMessage, readJsonBody, readRawBody, sameOrigin, sendJson } from '.
|
|
|
2
2
|
import { analyzeWorkflowParameters, comboChildInfo, inputOptions, numberSpecOf, refreshParameterMetadata, uploadKindOf } from './params.js';
|
|
3
3
|
import { collectMedia, historyErrorMessage, mediaProxyUrl } from './comfyui.js';
|
|
4
4
|
import { MAX_ASSET_BYTES, SKILL_MAIN, SKILL_PRESET_DIRS, joinFrontmatter, splitFrontmatter } from './skillpack.js';
|
|
5
|
+
import { MAX_IMPORT_BYTES, analyzeImportPackage, applyImportPackage, buildExportPackage } from './transfer.js';
|
|
5
6
|
import { unlink } from 'node:fs/promises';
|
|
6
7
|
import { spawn } from 'node:child_process';
|
|
7
8
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
@@ -936,6 +937,186 @@ export function mountComfyUIRoutes(ctx, runtime) {
|
|
|
936
937
|
sendJson(response, 200, { ok: true });
|
|
937
938
|
}),
|
|
938
939
|
}));
|
|
940
|
+
// ── Workflow preset transfer (export / import) ──────────────────────────
|
|
941
|
+
// Export bundles selected library workflows (API format) with their
|
|
942
|
+
// parameters and skill packs into one .zip download; import parses an
|
|
943
|
+
// uploaded package and then writes the selection back as NEW workflows.
|
|
944
|
+
// The packaging, validation and pack writes live in transfer.ts — these
|
|
945
|
+
// routes only shape HTTP (same-origin, body cap, content types).
|
|
946
|
+
//
|
|
947
|
+
// The export body comes in two shapes on purpose: JSON `{ ids }` from a
|
|
948
|
+
// fetch caller, and an urlencoded form (`ids` repeated) from the panel's
|
|
949
|
+
// hidden-form submit — the panel downloads natively so the browser, not
|
|
950
|
+
// JS blob plumbing, owns the whole transfer.
|
|
951
|
+
//
|
|
952
|
+
// The native form download means the panel never sees the response body,
|
|
953
|
+
// so the facts of the last package ride back through `export/last`: the
|
|
954
|
+
// panel reads it right after submitting and reports what was ACTUALLY
|
|
955
|
+
// packaged, not what the checkboxes believed.
|
|
956
|
+
let lastExport = null;
|
|
957
|
+
disposers.push(webServer.register({
|
|
958
|
+
kind: 'exact',
|
|
959
|
+
path: '/comfyui/workflows/export/last',
|
|
960
|
+
handler: withHint(async (request, response) => {
|
|
961
|
+
if (!methodIs(request, 'GET')) {
|
|
962
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
sendJson(response, 200, { last: lastExport });
|
|
966
|
+
}),
|
|
967
|
+
}));
|
|
968
|
+
disposers.push(webServer.register({
|
|
969
|
+
kind: 'exact',
|
|
970
|
+
path: '/comfyui/workflows/export',
|
|
971
|
+
handler: withHint(async (request, response) => {
|
|
972
|
+
if (!methodIs(request, 'POST')) {
|
|
973
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
let ids;
|
|
977
|
+
const contentType = String(request.headers['content-type'] ?? '');
|
|
978
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
979
|
+
if (!sameOrigin(request)) {
|
|
980
|
+
sendJson(response, 403, { error: 'forbidden: same-origin requests only' });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const raw = await readRawBody(request);
|
|
984
|
+
ids = [...new URLSearchParams(raw.toString('utf8')).getAll('ids')];
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
const body = await readSameOriginPost(request, response);
|
|
988
|
+
if (body === undefined)
|
|
989
|
+
return;
|
|
990
|
+
ids = body.ids;
|
|
991
|
+
}
|
|
992
|
+
const result = await buildExportPackage(runtime, ids);
|
|
993
|
+
if (!result.ok) {
|
|
994
|
+
sendJson(response, 400, { error: result.error });
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
// Audit trail on purpose: download managers dedupe by URL, so "which
|
|
998
|
+
// file is which" became a live user question — the log answers it with
|
|
999
|
+
// the exact packaged set per request, and the receipt the panel shows
|
|
1000
|
+
// carries the file name + size so a stale download can't masquerade as
|
|
1001
|
+
// this export.
|
|
1002
|
+
lastExport = { at: new Date().toISOString(), count: result.count, names: result.names, warnings: result.warnings, filename: result.filename, size: result.bytes.length };
|
|
1003
|
+
ctx.logger.info(`comfyui: 导出 ${result.count} 个工作流 → ${result.filename}(${result.names.join('、')})${result.warnings.length > 0 ? ` 警告:${result.warnings.join(';')}` : ''}`);
|
|
1004
|
+
response.writeHead(200, {
|
|
1005
|
+
'content-type': 'application/zip',
|
|
1006
|
+
'content-length': String(result.bytes.length),
|
|
1007
|
+
'cache-control': 'no-store',
|
|
1008
|
+
// ASCII file name on purpose: it rides a Content-Disposition header
|
|
1009
|
+
// without RFC 5987 encoding and stays readable on every OS.
|
|
1010
|
+
'content-disposition': `attachment; filename="${result.filename}"`,
|
|
1011
|
+
});
|
|
1012
|
+
response.end(result.bytes);
|
|
1013
|
+
}),
|
|
1014
|
+
}));
|
|
1015
|
+
// Native GET download: the file name rides IN the URL path. A download
|
|
1016
|
+
// manager that derives the saved name from the URL, or re-fetches the URL
|
|
1017
|
+
// itself, still lands on the correct name — and because the export is a
|
|
1018
|
+
// pure read, on the genuine bytes for exactly these ids. This is the
|
|
1019
|
+
// antidote to the intercepted-POST downloads that kept saving stale or
|
|
1020
|
+
// renamed files on one setup (skills "sometimes missing" was those files).
|
|
1021
|
+
// Exact routes above win for `/export` and `/export/last`; this prefix only
|
|
1022
|
+
// sees `/export/<filename>`.
|
|
1023
|
+
disposers.push(webServer.register({
|
|
1024
|
+
kind: 'prefix',
|
|
1025
|
+
path: '/comfyui/workflows/export',
|
|
1026
|
+
handler: withHint(async (request, response) => {
|
|
1027
|
+
if (!methodIs(request, 'GET')) {
|
|
1028
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
1032
|
+
const filename = decodeURIComponent(url.pathname.slice('/comfyui/workflows/export/'.length));
|
|
1033
|
+
// The name is client-supplied but strictly patterned: it only ever
|
|
1034
|
+
// feeds a header value and the receipt, never a filesystem path.
|
|
1035
|
+
if (!/^dsh-comfyui-presets-[0-9A-Za-z-]+\.zip$/.test(filename)) {
|
|
1036
|
+
sendJson(response, 400, { error: 'bad export file name' });
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const result = await buildExportPackage(runtime, url.searchParams.getAll('ids'));
|
|
1040
|
+
if (!result.ok) {
|
|
1041
|
+
sendJson(response, 400, { error: result.error });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
lastExport = { at: new Date().toISOString(), count: result.count, names: result.names, warnings: result.warnings, filename, size: result.bytes.length };
|
|
1045
|
+
ctx.logger.info(`comfyui: 导出 ${result.count} 个工作流 → ${filename}(${result.names.join('、')})${result.warnings.length > 0 ? ` 警告:${result.warnings.join(';')}` : ''}`);
|
|
1046
|
+
response.writeHead(200, {
|
|
1047
|
+
'content-type': 'application/zip',
|
|
1048
|
+
'content-length': String(result.bytes.length),
|
|
1049
|
+
'cache-control': 'no-store',
|
|
1050
|
+
'content-disposition': `attachment; filename="${filename}"`,
|
|
1051
|
+
});
|
|
1052
|
+
response.end(result.bytes);
|
|
1053
|
+
}),
|
|
1054
|
+
}));
|
|
1055
|
+
// Import is two-phase with zero server-side staging: analyze parses the
|
|
1056
|
+
// uploaded bytes and lists what the package offers; apply re-reads the
|
|
1057
|
+
// same bytes the client still holds and writes the selection. Selection
|
|
1058
|
+
// travels by manifest index (stable, because the same bytes are parsed
|
|
1059
|
+
// again), which avoids URL-encoding arbitrary package ids.
|
|
1060
|
+
disposers.push(webServer.register({
|
|
1061
|
+
kind: 'exact',
|
|
1062
|
+
path: '/comfyui/workflows/import/analyze',
|
|
1063
|
+
handler: withHint(async (request, response) => {
|
|
1064
|
+
if (!methodIs(request, 'POST')) {
|
|
1065
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (!sameOrigin(request)) {
|
|
1069
|
+
sendJson(response, 403, { error: 'forbidden: same-origin requests only' });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const raw = await readRawBody(request);
|
|
1073
|
+
if (raw.length > MAX_IMPORT_BYTES) {
|
|
1074
|
+
sendJson(response, 413, { error: `预设包不能超过 ${Math.floor(MAX_IMPORT_BYTES / 1024 / 1024)} MB` });
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const result = analyzeImportPackage(raw);
|
|
1078
|
+
if (!result.ok) {
|
|
1079
|
+
sendJson(response, 400, { error: result.error });
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
sendJson(response, 200, result);
|
|
1083
|
+
}),
|
|
1084
|
+
}));
|
|
1085
|
+
disposers.push(webServer.register({
|
|
1086
|
+
kind: 'exact',
|
|
1087
|
+
path: '/comfyui/workflows/import/apply',
|
|
1088
|
+
handler: withHint(async (request, response) => {
|
|
1089
|
+
if (!methodIs(request, 'POST')) {
|
|
1090
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (!sameOrigin(request)) {
|
|
1094
|
+
sendJson(response, 403, { error: 'forbidden: same-origin requests only' });
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
1098
|
+
const selected = (url.searchParams.get('select') ?? '')
|
|
1099
|
+
.split(',')
|
|
1100
|
+
.map((part) => Number.parseInt(part, 10))
|
|
1101
|
+
.filter((index) => Number.isInteger(index) && index >= 0);
|
|
1102
|
+
const raw = await readRawBody(request);
|
|
1103
|
+
if (raw.length > MAX_IMPORT_BYTES) {
|
|
1104
|
+
sendJson(response, 413, { error: `预设包不能超过 ${Math.floor(MAX_IMPORT_BYTES / 1024 / 1024)} MB` });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const result = await applyImportPackage(runtime, raw, selected);
|
|
1109
|
+
if (!result.ok) {
|
|
1110
|
+
sendJson(response, 400, { error: result.error });
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
sendJson(response, 200, result);
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
sendJson(response, 500, { error: errorMessage(error) });
|
|
1117
|
+
}
|
|
1118
|
+
}),
|
|
1119
|
+
}));
|
|
939
1120
|
disposers.push(webServer.register({
|
|
940
1121
|
kind: 'exact',
|
|
941
1122
|
path: '/comfyui/workflows/run',
|
package/lib/skill.js
CHANGED
|
@@ -148,5 +148,6 @@ TTS-Audio-Suite(\`{comfyuiDir}/custom_nodes/tts_audio_suite\`)的"🎭 Chara
|
|
|
148
148
|
- 面板"ComfyUI 端保存"分区列出每个图工作流:未提取显示"未提取",已提取显示派生出的运行工作流列表。
|
|
149
149
|
- 点**提取**:单分量直接拆;多分量弹出选项(分析报告 + 整体/按分量/主流程)。也可点**查看**看节点清单和 JSON。
|
|
150
150
|
- 用户也可以不经过图,直接在插件库"新建工作流"粘贴 API JSON(或选择 .json 文件)作为运行工作流导入。
|
|
151
|
+
- 工具栏"导出预设 / 导入预设":导出把选中的运行工作流连同参数与技能包打包成一个 .zip 预设包;导入选中包内的工作流后**创建为新工作流**(重名自动加"(导入)"后缀),不会覆盖库里已有的工作流,技能包一并还原(超限文件会跳过并提示)。用户要换机 / 备份 / 分享工作流时引导用这对按钮。
|
|
151
152
|
`,
|
|
152
153
|
};
|
package/lib/skillpack.d.ts
CHANGED
|
@@ -16,9 +16,18 @@ export declare const MAX_FILE_BYTES: number;
|
|
|
16
16
|
/** Imported binaries live in `assets/` and never enter the prompt as text, so
|
|
17
17
|
* they get a larger budget than the documents the model reads verbatim. */
|
|
18
18
|
export declare const MAX_ASSET_BYTES: number;
|
|
19
|
-
/** A pack is documentation, not storage — these caps keep it that way.
|
|
20
|
-
|
|
19
|
+
/** A pack is documentation, not storage — these caps keep it that way. The
|
|
20
|
+
* byte cap is the real storage guard; the file count only stops pathological
|
|
21
|
+
* noise, so it sits well above real bulk-copied packs (a ComfyUI music
|
|
22
|
+
* template library lands at 1000+ small .txt files and MUST survive a
|
|
23
|
+
* preset export → import round trip). */
|
|
24
|
+
export declare const MAX_PACK_FILES = 2000;
|
|
21
25
|
export declare const MAX_PACK_BYTES: number;
|
|
26
|
+
/** Per-file size budget by extension: binaries never enter the prompt as
|
|
27
|
+
* text, so they get the larger budget wherever they live. Exported because
|
|
28
|
+
* the preset importer pre-filters oversized files per file (skip + warning)
|
|
29
|
+
* before handing the batch to {@link SkillPackStore.writeMany}. */
|
|
30
|
+
export declare function sizeLimitOf(file: string): number;
|
|
22
31
|
/** One file inside a pack. */
|
|
23
32
|
export interface SkillFileInfo {
|
|
24
33
|
/** Path relative to the pack root, always forward-slashed (`references/styles.md`). */
|
|
@@ -152,6 +161,16 @@ export declare class SkillPackStore {
|
|
|
152
161
|
* can never bypass a limit the editor honours.
|
|
153
162
|
*/
|
|
154
163
|
writeBytes(slug: string, path: string, bytes: Buffer): Promise<SkillPackResult<SkillPackInfo>>;
|
|
164
|
+
/** Bulk write for preset restore: ONE limit check for the whole batch
|
|
165
|
+
* instead of per-file enumeration. Per-file checks re-count the entire
|
|
166
|
+
* pack on every write, so restoring a 1000-file pack that way is O(n²)
|
|
167
|
+
* filesystem calls — a frozen-looking import for minutes. Grammar and
|
|
168
|
+
* per-file size limits are still enforced here; the batch is refused
|
|
169
|
+
* whole (the caller reports which file tripped), never written partially. */
|
|
170
|
+
writeMany(slug: string, entries: ReadonlyArray<{
|
|
171
|
+
path: string;
|
|
172
|
+
bytes: Buffer;
|
|
173
|
+
}>): Promise<SkillPackResult<SkillPackInfo>>;
|
|
155
174
|
/** Read one file as raw bytes (the panel's image preview and downloads). */
|
|
156
175
|
readBytes(slug: string, path: string): Promise<SkillPackResult<Buffer>>;
|
|
157
176
|
/** Rename one file; SKILL.md is fixed and both sides are validated. */
|
|
@@ -247,6 +266,14 @@ export interface WorkflowSkillPacks {
|
|
|
247
266
|
path: string;
|
|
248
267
|
pack: WorkflowSkillPack;
|
|
249
268
|
}>>;
|
|
269
|
+
/** Bulk restore path: every entry's `path` is already bucket-qualified
|
|
270
|
+
* (`references/x.txt` or `SKILL.md`). One limit check for the whole batch —
|
|
271
|
+
* see {@link SkillPackStore.writeMany} for why per-file checks are not
|
|
272
|
+
* good enough at 1000-file scale. */
|
|
273
|
+
importFiles(id: string, entries: ReadonlyArray<{
|
|
274
|
+
path: string;
|
|
275
|
+
bytes: Buffer;
|
|
276
|
+
}>): Promise<SkillPackResult<WorkflowSkillPack>>;
|
|
250
277
|
writeFile(id: string, path: string, content: string): Promise<SkillPackResult<WorkflowSkillPack>>;
|
|
251
278
|
renameFile(id: string, from: string, to: string): Promise<SkillPackResult<WorkflowSkillPack>>;
|
|
252
279
|
deleteFile(id: string, path: string): Promise<SkillPackResult<WorkflowSkillPack>>;
|
package/lib/skillpack.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* follows.
|
|
24
24
|
*/
|
|
25
25
|
import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
26
|
-
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
26
|
+
import { isAbsolute, dirname, join, relative, resolve } from 'node:path';
|
|
27
27
|
/**
|
|
28
28
|
* Sub-directories offered by default. These are suggestions, not a whitelist:
|
|
29
29
|
* a pack may hold any directory whose name matches {@link DIR_NAME}, so a user
|
|
@@ -49,11 +49,25 @@ export const MAX_FILE_BYTES = 256 * 1024;
|
|
|
49
49
|
/** Imported binaries live in `assets/` and never enter the prompt as text, so
|
|
50
50
|
* they get a larger budget than the documents the model reads verbatim. */
|
|
51
51
|
export const MAX_ASSET_BYTES = 4 * 1024 * 1024;
|
|
52
|
-
/** A pack is documentation, not storage — these caps keep it that way.
|
|
53
|
-
|
|
52
|
+
/** A pack is documentation, not storage — these caps keep it that way. The
|
|
53
|
+
* byte cap is the real storage guard; the file count only stops pathological
|
|
54
|
+
* noise, so it sits well above real bulk-copied packs (a ComfyUI music
|
|
55
|
+
* template library lands at 1000+ small .txt files and MUST survive a
|
|
56
|
+
* preset export → import round trip). */
|
|
57
|
+
export const MAX_PACK_FILES = 2000;
|
|
54
58
|
export const MAX_PACK_BYTES = 20 * 1024 * 1024;
|
|
55
|
-
/**
|
|
56
|
-
|
|
59
|
+
/** Per-file size budget by extension: binaries never enter the prompt as
|
|
60
|
+
* text, so they get the larger budget wherever they live. Exported because
|
|
61
|
+
* the preset importer pre-filters oversized files per file (skip + warning)
|
|
62
|
+
* before handing the batch to {@link SkillPackStore.writeMany}. */
|
|
63
|
+
export function sizeLimitOf(file) {
|
|
64
|
+
return ASSET_EXTENSIONS.includes(extensionOf(file)) ? MAX_ASSET_BYTES : MAX_FILE_BYTES;
|
|
65
|
+
}
|
|
66
|
+
/** File names: letters, digits, CJK, dot, dash, underscore. No separators, no
|
|
67
|
+
* leading dot. 128 chars, not 64: bulk-copied packs carry long descriptive
|
|
68
|
+
* template names ("c-pop-guofeng-traditional-chinese-style-cinematic-ballad"),
|
|
69
|
+
* and a cap they fail makes those files silently invisible to info()/export. */
|
|
70
|
+
const FILE_NAME = /^[A-Za-z0-9_一-龥][A-Za-z0-9._一-龥-]{0,127}$/;
|
|
57
71
|
/** Directory names this module generates and later trusts only after re-validation. */
|
|
58
72
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
59
73
|
/** Sub-directory names inside a pack: same shape as a file name, no dots. */
|
|
@@ -490,6 +504,54 @@ export class SkillPackStore {
|
|
|
490
504
|
await writeFile(target.value, bytes);
|
|
491
505
|
return this.info(slug);
|
|
492
506
|
}
|
|
507
|
+
/** Bulk write for preset restore: ONE limit check for the whole batch
|
|
508
|
+
* instead of per-file enumeration. Per-file checks re-count the entire
|
|
509
|
+
* pack on every write, so restoring a 1000-file pack that way is O(n²)
|
|
510
|
+
* filesystem calls — a frozen-looking import for minutes. Grammar and
|
|
511
|
+
* per-file size limits are still enforced here; the batch is refused
|
|
512
|
+
* whole (the caller reports which file tripped), never written partially. */
|
|
513
|
+
async writeMany(slug, entries) {
|
|
514
|
+
if (entries.length === 0)
|
|
515
|
+
return this.info(slug);
|
|
516
|
+
const before = await this.info(slug);
|
|
517
|
+
if (!before.ok)
|
|
518
|
+
return before;
|
|
519
|
+
const existingByPath = new Map(before.value.files.map((file) => [file.path, file.size]));
|
|
520
|
+
let existingReplacedBytes = 0;
|
|
521
|
+
let incomingBytes = 0;
|
|
522
|
+
let newCount = 0;
|
|
523
|
+
const plan = [];
|
|
524
|
+
for (const entry of entries) {
|
|
525
|
+
const target = this.fileOf(slug, entry.path);
|
|
526
|
+
if (!target.ok)
|
|
527
|
+
return target;
|
|
528
|
+
const parsed = parseSkillPath(entry.path);
|
|
529
|
+
if (!parsed.ok)
|
|
530
|
+
return parsed;
|
|
531
|
+
const limit = ASSET_EXTENSIONS.includes(extensionOf(parsed.value.file)) ? MAX_ASSET_BYTES : MAX_FILE_BYTES;
|
|
532
|
+
if (entry.bytes.length > limit) {
|
|
533
|
+
return fail(`单个文件不能超过 ${Math.floor(limit / 1024)} KB(${entry.path} 当前 ${Math.ceil(entry.bytes.length / 1024)} KB)`);
|
|
534
|
+
}
|
|
535
|
+
const existingSize = existingByPath.get(entry.path);
|
|
536
|
+
if (existingSize === undefined)
|
|
537
|
+
newCount += 1;
|
|
538
|
+
else
|
|
539
|
+
existingReplacedBytes += existingSize;
|
|
540
|
+
incomingBytes += entry.bytes.length;
|
|
541
|
+
plan.push({ target: target.value, bytes: entry.bytes });
|
|
542
|
+
}
|
|
543
|
+
if (before.value.files.length + newCount > MAX_PACK_FILES) {
|
|
544
|
+
return fail(`一个技能包最多 ${MAX_PACK_FILES} 个文件`);
|
|
545
|
+
}
|
|
546
|
+
if (before.value.totalBytes - existingReplacedBytes + incomingBytes > MAX_PACK_BYTES) {
|
|
547
|
+
return fail(`一个技能包最多 ${Math.floor(MAX_PACK_BYTES / 1024 / 1024)} MB`);
|
|
548
|
+
}
|
|
549
|
+
for (const item of plan) {
|
|
550
|
+
await mkdir(dirname(item.target), { recursive: true });
|
|
551
|
+
await writeFile(item.target, item.bytes);
|
|
552
|
+
}
|
|
553
|
+
return this.info(slug);
|
|
554
|
+
}
|
|
493
555
|
/** Read one file as raw bytes (the panel's image preview and downloads). */
|
|
494
556
|
async readBytes(slug, path) {
|
|
495
557
|
const target = this.fileOf(slug, path);
|
|
@@ -738,6 +800,12 @@ export function createWorkflowSkillPacks(host) {
|
|
|
738
800
|
return resolved;
|
|
739
801
|
return afterMutation(id, packs.write(resolved.value.slug, path, content));
|
|
740
802
|
},
|
|
803
|
+
async importFiles(id, entries) {
|
|
804
|
+
const resolved = await resolvePack(id);
|
|
805
|
+
if (!resolved.ok)
|
|
806
|
+
return resolved;
|
|
807
|
+
return afterMutation(id, packs.writeMany(resolved.value.slug, entries));
|
|
808
|
+
},
|
|
741
809
|
async renameFile(id, from, to) {
|
|
742
810
|
const resolved = await resolvePack(id);
|
|
743
811
|
if (!resolved.ok)
|