dsh-comfyui 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/client/client.js +640 -17
- package/client/client.js.map +1 -1
- 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/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)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { WorkflowParameter } from './params.js';
|
|
2
|
+
import { type StoredWorkflow } from './store.js';
|
|
3
|
+
import { type SkillPackResult, type WorkflowSkillPack } from './skillpack.js';
|
|
4
|
+
/** Marker plus layout version of the packages this module writes. */
|
|
5
|
+
export declare const PRESET_FORMAT = "dsh-comfyui-workflow-preset";
|
|
6
|
+
export declare const PRESET_VERSION = 1;
|
|
7
|
+
/** Hard cap on the uploaded archive (route-level check before parsing). */
|
|
8
|
+
export declare const MAX_IMPORT_BYTES: number;
|
|
9
|
+
/** One workflow record inside preset.json. */
|
|
10
|
+
export interface PresetWorkflow {
|
|
11
|
+
/** Id in the EXPORTING library; import mints a fresh one. */
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
tags?: string[];
|
|
16
|
+
parameters?: WorkflowParameter[];
|
|
17
|
+
requireSkill?: boolean;
|
|
18
|
+
/** Skill-pack contents; null when the workflow has no pack (or it was unreadable). */
|
|
19
|
+
skill: PresetSkillRef | null;
|
|
20
|
+
workflow: Record<string, {
|
|
21
|
+
class_type: string;
|
|
22
|
+
inputs: Record<string, unknown>;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
/** What the panel and tools need to know about one archived pack. */
|
|
26
|
+
export interface PresetSkillRef {
|
|
27
|
+
files: Array<{
|
|
28
|
+
path: string;
|
|
29
|
+
size: number;
|
|
30
|
+
}>;
|
|
31
|
+
/** Sub-directories, kept so empty ones survive the round trip. */
|
|
32
|
+
dirs: string[];
|
|
33
|
+
}
|
|
34
|
+
/** The whole manifest. `count` mirrors `workflows.length` on export and is
|
|
35
|
+
* recomputed (not trusted) on import. */
|
|
36
|
+
export interface PresetManifest {
|
|
37
|
+
format: string;
|
|
38
|
+
version: number;
|
|
39
|
+
exportedAt: string;
|
|
40
|
+
pluginVersion: string;
|
|
41
|
+
count: number;
|
|
42
|
+
workflows: PresetWorkflow[];
|
|
43
|
+
}
|
|
44
|
+
/** Structural slice of the runtime the transfer routes operate through. */
|
|
45
|
+
export interface TransferHost {
|
|
46
|
+
listWorkflows(): Promise<StoredWorkflow[]>;
|
|
47
|
+
getWorkflow(id: string): Promise<StoredWorkflow | undefined>;
|
|
48
|
+
saveWorkflow(input: {
|
|
49
|
+
name: string;
|
|
50
|
+
description: string;
|
|
51
|
+
workflow: unknown;
|
|
52
|
+
parameters?: WorkflowParameter[];
|
|
53
|
+
tags?: string[];
|
|
54
|
+
}): Promise<{
|
|
55
|
+
ok: true;
|
|
56
|
+
workflow: StoredWorkflow;
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
error: string;
|
|
60
|
+
}>;
|
|
61
|
+
skillPacks: {
|
|
62
|
+
info(id: string): Promise<WorkflowSkillPack | undefined>;
|
|
63
|
+
readRaw(id: string, path: string): Promise<SkillPackResult<{
|
|
64
|
+
bytes: Buffer;
|
|
65
|
+
contentType: string;
|
|
66
|
+
}>>;
|
|
67
|
+
enable(id: string): Promise<SkillPackResult<WorkflowSkillPack>>;
|
|
68
|
+
disable(id: string): Promise<SkillPackResult<true>>;
|
|
69
|
+
writeFile(id: string, path: string, content: string): Promise<SkillPackResult<WorkflowSkillPack>>;
|
|
70
|
+
importFile(id: string, file: string, bytes: Buffer, bucket?: string): Promise<SkillPackResult<{
|
|
71
|
+
path: string;
|
|
72
|
+
pack: WorkflowSkillPack;
|
|
73
|
+
}>>;
|
|
74
|
+
importFiles(id: string, entries: ReadonlyArray<{
|
|
75
|
+
path: string;
|
|
76
|
+
bytes: Buffer;
|
|
77
|
+
}>): Promise<SkillPackResult<WorkflowSkillPack>>;
|
|
78
|
+
makeDir(id: string, name: string): Promise<SkillPackResult<WorkflowSkillPack>>;
|
|
79
|
+
setRequired(id: string, required: boolean): Promise<SkillPackResult<WorkflowSkillPack>>;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export type ExportResult = {
|
|
83
|
+
ok: true;
|
|
84
|
+
bytes: Uint8Array;
|
|
85
|
+
filename: string;
|
|
86
|
+
count: number;
|
|
87
|
+
names: string[];
|
|
88
|
+
warnings: string[];
|
|
89
|
+
} | {
|
|
90
|
+
ok: false;
|
|
91
|
+
error: string;
|
|
92
|
+
};
|
|
93
|
+
/** One listed workflow inside an analyzed package. */
|
|
94
|
+
export interface ImportCandidate {
|
|
95
|
+
/** Position in the manifest; the apply call selects by this index, which
|
|
96
|
+
* stays stable because the same bytes are parsed again. */
|
|
97
|
+
index: number;
|
|
98
|
+
id: string;
|
|
99
|
+
name: string;
|
|
100
|
+
description: string;
|
|
101
|
+
tags: string[];
|
|
102
|
+
paramCount: number;
|
|
103
|
+
skill: {
|
|
104
|
+
fileCount: number;
|
|
105
|
+
totalBytes: number;
|
|
106
|
+
required: boolean;
|
|
107
|
+
} | null;
|
|
108
|
+
/** Non-fatal problems already visible at analysis time. */
|
|
109
|
+
warnings: string[];
|
|
110
|
+
}
|
|
111
|
+
export interface ImportAnalysis {
|
|
112
|
+
version: number;
|
|
113
|
+
exportedAt: string;
|
|
114
|
+
pluginVersion: string;
|
|
115
|
+
workflows: ImportCandidate[];
|
|
116
|
+
}
|
|
117
|
+
/** Per-workflow result of an import; `ok` entries carry their new identity. */
|
|
118
|
+
export interface ImportOutcome {
|
|
119
|
+
index: number;
|
|
120
|
+
name: string;
|
|
121
|
+
ok: boolean;
|
|
122
|
+
newName?: string;
|
|
123
|
+
newId?: string;
|
|
124
|
+
error?: string;
|
|
125
|
+
warnings: string[];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Build one preset package from the selected library workflows.
|
|
129
|
+
* @param host - the runtime face (workflow store + skill packs).
|
|
130
|
+
* @param ids - workflow ids to export; unknown ids are skipped with a warning.
|
|
131
|
+
*/
|
|
132
|
+
export declare function buildExportPackage(host: TransferHost, ids: unknown): Promise<ExportResult>;
|
|
133
|
+
/**
|
|
134
|
+
* Analyze one uploaded package: list the workflows it offers, with per-item
|
|
135
|
+
* warnings for anything that would fail at apply time. No disk writes.
|
|
136
|
+
*/
|
|
137
|
+
export declare function analyzeImportPackage(bytes: Buffer): {
|
|
138
|
+
ok: true;
|
|
139
|
+
analysis: ImportAnalysis;
|
|
140
|
+
} | {
|
|
141
|
+
ok: false;
|
|
142
|
+
error: string;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Import the selected workflows from one uploaded package. Every workflow
|
|
146
|
+
* becomes a NEW library record (fresh id, name suffixed on clash); skill
|
|
147
|
+
* packs land in freshly slugged directories, so nothing on disk is reused
|
|
148
|
+
* or overwritten.
|
|
149
|
+
* @param selected - manifest indexes to import (as listed by analyze).
|
|
150
|
+
*/
|
|
151
|
+
export declare function applyImportPackage(host: TransferHost, bytes: Buffer, selected: unknown): Promise<{
|
|
152
|
+
ok: true;
|
|
153
|
+
results: ImportOutcome[];
|
|
154
|
+
imported: number;
|
|
155
|
+
} | {
|
|
156
|
+
ok: false;
|
|
157
|
+
error: string;
|
|
158
|
+
}>;
|