dsh-comfyui 0.3.0-beta.5 → 0.4.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 +65 -168
- package/README.md +63 -166
- package/client/client.js +831 -33
- package/client/client.js.map +1 -1
- package/lib/config.d.ts +14 -0
- package/lib/config.js +6 -0
- package/lib/index.js +30 -10
- package/lib/routes.js +259 -0
- package/lib/skill.js +50 -3
- package/lib/skillpack.d.ts +262 -0
- package/lib/skillpack.js +774 -0
- package/lib/store.d.ts +13 -0
- package/lib/store.js +36 -0
- package/lib/tools.d.ts +3 -0
- package/lib/tools.js +289 -3
- package/package.json +4 -9
package/lib/config.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export declare const Config: z<Schemastery.ObjectS<{
|
|
|
23
23
|
dataDir: z<string, string>;
|
|
24
24
|
/** Max asset records kept in the asset index. */
|
|
25
25
|
maxAssets: z<number, number>;
|
|
26
|
+
/** Directory holding the per-workflow skill packs. Empty = `<dataDir>/skills`.
|
|
27
|
+
* Separate from `dataDir` on purpose: packs are documents a user may want on
|
|
28
|
+
* another drive, in a synced folder, or under version control, while the
|
|
29
|
+
* JSON state files belong with the rest of the plugin data. Must be absolute;
|
|
30
|
+
* a relative value is ignored. */
|
|
31
|
+
skillsDir: z<string, string>;
|
|
26
32
|
/** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
|
|
27
33
|
mediaHost: z<string, string>;
|
|
28
34
|
/** ComfyUI's output directory on this machine, used to delete asset files
|
|
@@ -55,6 +61,12 @@ export declare const Config: z<Schemastery.ObjectS<{
|
|
|
55
61
|
dataDir: z<string, string>;
|
|
56
62
|
/** Max asset records kept in the asset index. */
|
|
57
63
|
maxAssets: z<number, number>;
|
|
64
|
+
/** Directory holding the per-workflow skill packs. Empty = `<dataDir>/skills`.
|
|
65
|
+
* Separate from `dataDir` on purpose: packs are documents a user may want on
|
|
66
|
+
* another drive, in a synced folder, or under version control, while the
|
|
67
|
+
* JSON state files belong with the rest of the plugin data. Must be absolute;
|
|
68
|
+
* a relative value is ignored. */
|
|
69
|
+
skillsDir: z<string, string>;
|
|
58
70
|
/** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
|
|
59
71
|
mediaHost: z<string, string>;
|
|
60
72
|
/** ComfyUI's output directory on this machine, used to delete asset files
|
|
@@ -88,6 +100,8 @@ export type Config = {
|
|
|
88
100
|
dataDir: string;
|
|
89
101
|
/** Max asset records kept in the asset index. */
|
|
90
102
|
maxAssets: number;
|
|
103
|
+
/** Directory holding the per-workflow skill packs; empty = `<dataDir>/skills`. */
|
|
104
|
+
skillsDir: string;
|
|
91
105
|
/** External base URL for generated media; empty auto-detects the request host. */
|
|
92
106
|
mediaHost: string;
|
|
93
107
|
/** ComfyUI's output directory on this machine; empty infers it from reported file paths. */
|
package/lib/config.js
CHANGED
|
@@ -23,6 +23,12 @@ export const Config = z.object({
|
|
|
23
23
|
dataDir: z.string().default(''),
|
|
24
24
|
/** Max asset records kept in the asset index. */
|
|
25
25
|
maxAssets: z.number().min(1).max(10_000).default(200),
|
|
26
|
+
/** Directory holding the per-workflow skill packs. Empty = `<dataDir>/skills`.
|
|
27
|
+
* Separate from `dataDir` on purpose: packs are documents a user may want on
|
|
28
|
+
* another drive, in a synced folder, or under version control, while the
|
|
29
|
+
* JSON state files belong with the rest of the plugin data. Must be absolute;
|
|
30
|
+
* a relative value is ignored. */
|
|
31
|
+
skillsDir: z.string().default(''),
|
|
26
32
|
/** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
|
|
27
33
|
mediaHost: z.string().default(''),
|
|
28
34
|
/** ComfyUI's output directory on this machine, used to delete asset files
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
3
|
import { Config } from './config.js';
|
|
5
4
|
import { ComfyUIClient, CLIENT_ID } from './comfyui.js';
|
|
6
5
|
import { ComfyUIStore } from './store.js';
|
|
@@ -10,6 +9,7 @@ import { analyzeGraph } from './analyze.js';
|
|
|
10
9
|
import { ProgressTracker } from './progress.js';
|
|
11
10
|
import { COMFYUI_SKILL } from './skill.js';
|
|
12
11
|
import { analyzeWorkflowParameters, applyWorkflowParameters } from './params.js';
|
|
12
|
+
import { createWorkflowSkillPacks } from './skillpack.js';
|
|
13
13
|
import { registerComfyUITools } from './tools.js';
|
|
14
14
|
import { mountComfyUIRoutes } from './routes.js';
|
|
15
15
|
import { mountComfyUIProxy } from './proxy.js';
|
|
@@ -25,7 +25,7 @@ export { Config };
|
|
|
25
25
|
* apply).
|
|
26
26
|
*/
|
|
27
27
|
export const inject = ['tools'];
|
|
28
|
-
const COMFYUI_NS =
|
|
28
|
+
const COMFYUI_NS = 'comfyui';
|
|
29
29
|
/** object_info is large and changes only when nodes are (re)installed. */
|
|
30
30
|
const OBJECT_INFO_TTL_MS = 60_000;
|
|
31
31
|
let objectInfoCache;
|
|
@@ -76,6 +76,7 @@ export async function apply(ctx, entryConfig) {
|
|
|
76
76
|
maxMediaBytes: entryConfig.maxMediaBytes ?? 64 * 1024 * 1024,
|
|
77
77
|
dataDir: entryConfig.dataDir !== undefined && entryConfig.dataDir !== '' ? entryConfig.dataDir : defaultDataDir(),
|
|
78
78
|
maxAssets: entryConfig.maxAssets ?? 200,
|
|
79
|
+
skillsDir: entryConfig.skillsDir ?? '',
|
|
79
80
|
mediaHost: entryConfig.mediaHost ?? '',
|
|
80
81
|
outputDir: entryConfig.outputDir ?? '',
|
|
81
82
|
comfyuiDirs: Array.isArray(entryConfig.comfyuiDirs)
|
|
@@ -84,6 +85,22 @@ export async function apply(ctx, entryConfig) {
|
|
|
84
85
|
};
|
|
85
86
|
const store = new ComfyUIStore(resolved.dataDir, resolved.maxAssets);
|
|
86
87
|
await store.init();
|
|
88
|
+
// Per-workflow skill packs default to `<dataDir>/skills`; `skillsDir` moves
|
|
89
|
+
// them anywhere absolute (another drive, a synced folder, a repo) without
|
|
90
|
+
// dragging the JSON state along. The root is a getter, not a snapshot, so a
|
|
91
|
+
// settings-page change applies to the next call rather than the next restart.
|
|
92
|
+
// Directories are created lazily when a user attaches a pack, so nothing is
|
|
93
|
+
// written for a library that never uses them.
|
|
94
|
+
const skillPacks = createWorkflowSkillPacks({
|
|
95
|
+
skillsRoot: () => {
|
|
96
|
+
const configured = resolved.skillsDir.trim();
|
|
97
|
+
// A relative path would resolve against the process cwd, which is not
|
|
98
|
+
// something a user typing into the settings page can predict.
|
|
99
|
+
return configured !== '' && isAbsolute(configured) ? configured : store.skillsRoot;
|
|
100
|
+
},
|
|
101
|
+
getWorkflow: (id) => store.getWorkflow(id),
|
|
102
|
+
updateWorkflowSkill: (id, patch) => store.updateWorkflowSkill(id, patch),
|
|
103
|
+
});
|
|
87
104
|
const tracker = new QueueTracker({
|
|
88
105
|
load: () => store.loadTracked(),
|
|
89
106
|
save: (state) => store.saveTracked(state),
|
|
@@ -182,6 +199,7 @@ export async function apply(ctx, entryConfig) {
|
|
|
182
199
|
return client.getOk('/api/tts-audio-suite/voice-library?refresh=1', 20_000);
|
|
183
200
|
},
|
|
184
201
|
deleteWorkflow: (id) => store.deleteWorkflow(id),
|
|
202
|
+
skillPacks,
|
|
185
203
|
listMediaSizes: () => store.loadMediaSizes(),
|
|
186
204
|
saveMediaSize: (name, size) => store.saveMediaSize(name, size),
|
|
187
205
|
lookupMediaHash: (hash) => store.lookupMediaHash(hash),
|
|
@@ -300,13 +318,15 @@ export async function apply(ctx, entryConfig) {
|
|
|
300
318
|
// The settings section rides the plugin fiber: a host without a settings
|
|
301
319
|
// service simply never registers it, and the entry config stands as composed.
|
|
302
320
|
let source = () => resolved;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
321
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
322
|
+
settingsCtx.settings.installSection(ctx, COMFYUI_NS, Config, resolved, {
|
|
323
|
+
setSource: (current) => {
|
|
324
|
+
source = current;
|
|
325
|
+
},
|
|
326
|
+
onChange: () => {
|
|
327
|
+
Object.assign(resolved, source());
|
|
328
|
+
},
|
|
329
|
+
});
|
|
310
330
|
});
|
|
311
331
|
ctx.effect(() => {
|
|
312
332
|
const disposers = registerComfyUITools(ctx, runtime);
|
package/lib/routes.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { errorMessage, readJsonBody, readRawBody, sameOrigin, sendJson } from './http.js';
|
|
2
2
|
import { analyzeWorkflowParameters, comboChildInfo, inputOptions, numberSpecOf, refreshParameterMetadata, uploadKindOf } from './params.js';
|
|
3
3
|
import { collectMedia, historyErrorMessage, mediaProxyUrl } from './comfyui.js';
|
|
4
|
+
import { MAX_ASSET_BYTES, SKILL_MAIN, SKILL_PRESET_DIRS, joinFrontmatter, splitFrontmatter } from './skillpack.js';
|
|
4
5
|
import { unlink } from 'node:fs/promises';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
5
7
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
8
|
/**
|
|
7
9
|
* Rebuild an asset's media URLs from the stored file references.
|
|
@@ -129,6 +131,10 @@ function redact(runtime, apiKey) {
|
|
|
129
131
|
maxMediaItems: config.maxMediaItems,
|
|
130
132
|
mediaHost: config.mediaHost,
|
|
131
133
|
comfyuiDirs: config.comfyuiDirs,
|
|
134
|
+
skillsDir: config.skillsDir,
|
|
135
|
+
// The path actually in use, so the page can show where packs land even
|
|
136
|
+
// when skillsDir is empty (the `<dataDir>/skills` default).
|
|
137
|
+
skillsRoot: runtime.skillPacks.root,
|
|
132
138
|
writable: runtime.settingsWritable(),
|
|
133
139
|
};
|
|
134
140
|
}
|
|
@@ -657,6 +663,253 @@ export function mountComfyUIRoutes(ctx, runtime) {
|
|
|
657
663
|
sendJson(response, 200, { ok: true });
|
|
658
664
|
}),
|
|
659
665
|
}));
|
|
666
|
+
// The skill-pack editor's whole surface: one GET for the listing (plus a
|
|
667
|
+
// single file's text when `path` is given) and one action-style POST for
|
|
668
|
+
// every mutation. Paths from the browser are untrusted and re-validated
|
|
669
|
+
// inside the pack store, which is also where the containment check lives.
|
|
670
|
+
disposers.push(webServer.register({
|
|
671
|
+
kind: 'exact',
|
|
672
|
+
path: '/comfyui/workflows/skill',
|
|
673
|
+
handler: withHint(async (request, response) => {
|
|
674
|
+
if (methodIs(request, 'GET')) {
|
|
675
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
676
|
+
const id = url.searchParams.get('id') ?? '';
|
|
677
|
+
if (id === '') {
|
|
678
|
+
sendJson(response, 400, { error: 'id is required' });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const workflow = await runtime.getWorkflow(id);
|
|
682
|
+
if (workflow === undefined) {
|
|
683
|
+
sendJson(response, 404, { error: `workflow "${id}" not found` });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const path = url.searchParams.get('path');
|
|
687
|
+
if (path !== null && path !== '') {
|
|
688
|
+
const file = await runtime.skillPacks.readFile(id, path);
|
|
689
|
+
if (!file.ok) {
|
|
690
|
+
sendJson(response, 200, { ok: false, error: file.error });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
// SKILL.md's frontmatter stays a host-side concern: the panel edits a
|
|
694
|
+
// summary field and a body, never the raw `---` block.
|
|
695
|
+
if (path === SKILL_MAIN) {
|
|
696
|
+
const split = splitFrontmatter(file.value);
|
|
697
|
+
sendJson(response, 200, { ok: true, path, content: file.value, summary: split.summary, body: split.body });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
sendJson(response, 200, { ok: true, path, content: file.value });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const pack = await runtime.skillPacks.info(id);
|
|
704
|
+
sendJson(response, 200, {
|
|
705
|
+
ok: true,
|
|
706
|
+
enabled: pack !== undefined,
|
|
707
|
+
root: runtime.skillPacks.root,
|
|
708
|
+
// Suggested directory names, defined once on the host so the panel
|
|
709
|
+
// dropdown and the agent-facing tool offer the same list.
|
|
710
|
+
presetDirs: SKILL_PRESET_DIRS,
|
|
711
|
+
pack: pack ?? null,
|
|
712
|
+
});
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (!methodIs(request, 'POST')) {
|
|
716
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const body = await readSameOriginPost(request, response);
|
|
720
|
+
if (body === undefined)
|
|
721
|
+
return;
|
|
722
|
+
const id = typeof body.id === 'string' ? body.id : '';
|
|
723
|
+
if (id === '') {
|
|
724
|
+
sendJson(response, 400, { error: 'id is required' });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const action = typeof body.action === 'string' ? body.action : '';
|
|
728
|
+
const path = typeof body.path === 'string' ? body.path : '';
|
|
729
|
+
const content = typeof body.content === 'string' ? body.content : '';
|
|
730
|
+
try {
|
|
731
|
+
switch (action) {
|
|
732
|
+
case 'enable': {
|
|
733
|
+
const result = await runtime.skillPacks.enable(id);
|
|
734
|
+
sendJson(response, 200, result.ok ? { ok: true, enabled: true, pack: result.value } : { ok: false, error: result.error });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
case 'disable': {
|
|
738
|
+
const result = await runtime.skillPacks.disable(id);
|
|
739
|
+
sendJson(response, 200, result.ok ? { ok: true, enabled: false, pack: null } : { ok: false, error: result.error });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
case 'destroy': {
|
|
743
|
+
const result = await runtime.skillPacks.destroy(id);
|
|
744
|
+
sendJson(response, 200, result.ok ? { ok: true, enabled: false, pack: null } : { ok: false, error: result.error });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
case 'require': {
|
|
748
|
+
const result = await runtime.skillPacks.setRequired(id, body.required === true);
|
|
749
|
+
sendJson(response, 200, result.ok ? { ok: true, enabled: true, pack: result.value } : { ok: false, error: result.error });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
case 'mkdir': {
|
|
753
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
754
|
+
const result = await runtime.skillPacks.makeDir(id, name);
|
|
755
|
+
sendJson(response, 200, result.ok ? { ok: true, enabled: true, pack: result.value } : { ok: false, error: result.error });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
case 'write': {
|
|
759
|
+
const text = path === SKILL_MAIN && typeof body.summary === 'string'
|
|
760
|
+
? joinFrontmatter(body.summary, content)
|
|
761
|
+
: content;
|
|
762
|
+
const result = await runtime.skillPacks.writeFile(id, path, text);
|
|
763
|
+
sendJson(response, 200, result.ok ? { ok: true, enabled: true, pack: result.value } : { ok: false, error: result.error });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
case 'rename': {
|
|
767
|
+
const to = typeof body.to === 'string' ? body.to : '';
|
|
768
|
+
const result = await runtime.skillPacks.renameFile(id, path, to);
|
|
769
|
+
sendJson(response, 200, result.ok ? { ok: true, enabled: true, pack: result.value } : { ok: false, error: result.error });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
case 'delete': {
|
|
773
|
+
const result = await runtime.skillPacks.deleteFile(id, path);
|
|
774
|
+
sendJson(response, 200, result.ok ? { ok: true, enabled: true, pack: result.value } : { ok: false, error: result.error });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
default:
|
|
778
|
+
sendJson(response, 400, { error: `unknown action: ${action}` });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
sendJson(response, 200, { ok: false, error: errorMessage(error) });
|
|
783
|
+
}
|
|
784
|
+
}),
|
|
785
|
+
}));
|
|
786
|
+
// Importing a file into a pack: the raw bytes ride the request body with the
|
|
787
|
+
// name in the query, so images and scripts arrive byte-exact without a
|
|
788
|
+
// multipart parser. The destination bucket comes from the extension unless
|
|
789
|
+
// the panel names one; the caps live in the pack store, not here.
|
|
790
|
+
disposers.push(webServer.register({
|
|
791
|
+
kind: 'exact',
|
|
792
|
+
path: '/comfyui/workflows/skill/import',
|
|
793
|
+
handler: withHint(async (request, response) => {
|
|
794
|
+
if (!methodIs(request, 'POST')) {
|
|
795
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (!sameOrigin(request)) {
|
|
799
|
+
sendJson(response, 403, { error: 'forbidden: same-origin requests only' });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
803
|
+
const id = url.searchParams.get('id') ?? '';
|
|
804
|
+
const name = url.searchParams.get('name') ?? '';
|
|
805
|
+
const bucket = url.searchParams.get('bucket') ?? '';
|
|
806
|
+
if (id === '' || name === '') {
|
|
807
|
+
sendJson(response, 400, { error: 'id and name are required' });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
const raw = await readRawBody(request);
|
|
812
|
+
if (raw.length === 0) {
|
|
813
|
+
sendJson(response, 200, { ok: false, error: '文件为空' });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
// Cheap upper bound before the store's per-bucket check, so an oversized
|
|
817
|
+
// upload is rejected on the size it actually has.
|
|
818
|
+
if (raw.length > MAX_ASSET_BYTES) {
|
|
819
|
+
sendJson(response, 200, { ok: false, error: `单个文件不能超过 ${Math.floor(MAX_ASSET_BYTES / 1024 / 1024)} MB` });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const result = await runtime.skillPacks.importFile(id, name, raw, bucket);
|
|
823
|
+
sendJson(response, 200, result.ok
|
|
824
|
+
? { ok: true, enabled: true, path: result.value.path, pack: result.value.pack }
|
|
825
|
+
: { ok: false, error: result.error });
|
|
826
|
+
}
|
|
827
|
+
catch (error) {
|
|
828
|
+
sendJson(response, 200, { ok: false, error: errorMessage(error) });
|
|
829
|
+
}
|
|
830
|
+
}),
|
|
831
|
+
}));
|
|
832
|
+
// Opens the pack directory in the desktop file manager. This is the one
|
|
833
|
+
// route that launches a local process, so it stays deliberately narrow: the
|
|
834
|
+
// directory comes from the pack store (never from the request), the opener is
|
|
835
|
+
// spawned with an argument array rather than a shell string, and the request
|
|
836
|
+
// must be same-origin. It acts on the machine running DSH, which is not the
|
|
837
|
+
// user's machine when the panel is open from another host — the panel says so
|
|
838
|
+
// and shows the path either way.
|
|
839
|
+
disposers.push(webServer.register({
|
|
840
|
+
kind: 'exact',
|
|
841
|
+
path: '/comfyui/workflows/skill/reveal',
|
|
842
|
+
handler: withHint(async (request, response) => {
|
|
843
|
+
if (!methodIs(request, 'POST')) {
|
|
844
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const body = await readSameOriginPost(request, response);
|
|
848
|
+
if (body === undefined)
|
|
849
|
+
return;
|
|
850
|
+
const id = typeof body.id === 'string' ? body.id : '';
|
|
851
|
+
if (id === '') {
|
|
852
|
+
sendJson(response, 400, { error: 'id is required' });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const pack = await runtime.skillPacks.info(id);
|
|
856
|
+
if (pack === undefined) {
|
|
857
|
+
sendJson(response, 200, { ok: false, error: '该工作流没有技能包' });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const opener = process.platform === 'win32'
|
|
861
|
+
? 'explorer.exe'
|
|
862
|
+
: process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
863
|
+
try {
|
|
864
|
+
// Detached and unref'd: the file manager outlives this request, and
|
|
865
|
+
// explorer.exe reports a non-zero exit code even when it succeeded, so
|
|
866
|
+
// the exit status is deliberately not awaited.
|
|
867
|
+
const child = spawn(opener, [pack.dir], { detached: true, stdio: 'ignore' });
|
|
868
|
+
child.on('error', () => { });
|
|
869
|
+
child.unref();
|
|
870
|
+
sendJson(response, 200, { ok: true, dir: pack.dir });
|
|
871
|
+
}
|
|
872
|
+
catch (error) {
|
|
873
|
+
sendJson(response, 200, { ok: false, error: errorMessage(error), dir: pack.dir });
|
|
874
|
+
}
|
|
875
|
+
}),
|
|
876
|
+
}));
|
|
877
|
+
// Serves one pack file verbatim so the panel can preview an imported image.
|
|
878
|
+
disposers.push(webServer.register({
|
|
879
|
+
kind: 'exact',
|
|
880
|
+
path: '/comfyui/workflows/skill/raw',
|
|
881
|
+
handler: withHint(async (request, response) => {
|
|
882
|
+
if (!methodIs(request, 'GET') && !methodIs(request, 'HEAD')) {
|
|
883
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
887
|
+
const id = url.searchParams.get('id') ?? '';
|
|
888
|
+
const path = url.searchParams.get('path') ?? '';
|
|
889
|
+
if (id === '' || path === '') {
|
|
890
|
+
sendJson(response, 400, { error: 'id and path are required' });
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const file = await runtime.skillPacks.readRaw(id, path);
|
|
894
|
+
if (!file.ok) {
|
|
895
|
+
sendJson(response, 404, { error: file.error });
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
response.writeHead(200, {
|
|
899
|
+
'content-type': file.value.contentType,
|
|
900
|
+
'content-length': String(file.value.bytes.length),
|
|
901
|
+
'cache-control': 'no-store',
|
|
902
|
+
// Pack files are user content served from the app origin; never let a
|
|
903
|
+
// browser sniff one into something executable.
|
|
904
|
+
'x-content-type-options': 'nosniff',
|
|
905
|
+
'content-disposition': 'inline',
|
|
906
|
+
});
|
|
907
|
+
if (methodIs(request, 'HEAD'))
|
|
908
|
+
response.end();
|
|
909
|
+
else
|
|
910
|
+
response.end(file.value.bytes);
|
|
911
|
+
}),
|
|
912
|
+
}));
|
|
660
913
|
disposers.push(webServer.register({
|
|
661
914
|
kind: 'exact',
|
|
662
915
|
path: '/comfyui/workflows/delete',
|
|
@@ -673,6 +926,12 @@ export function mountComfyUIRoutes(ctx, runtime) {
|
|
|
673
926
|
sendJson(response, 400, { error: 'id is required' });
|
|
674
927
|
return;
|
|
675
928
|
}
|
|
929
|
+
// A workflow with a skill pack takes its documentation with it only when
|
|
930
|
+
// the user says so: the panel asks before deleting, and an unanswered
|
|
931
|
+
// delete leaves the directory behind rather than silently discarding
|
|
932
|
+
// hand-written notes.
|
|
933
|
+
if (body.deleteSkill === true)
|
|
934
|
+
await runtime.skillPacks.destroy(id).catch(() => undefined);
|
|
676
935
|
await runtime.deleteWorkflow(id);
|
|
677
936
|
sendJson(response, 200, { ok: true });
|
|
678
937
|
}),
|
package/lib/skill.js
CHANGED
|
@@ -7,7 +7,7 @@ export const COMFYUI_SKILL = {
|
|
|
7
7
|
name: 'dsh-comfyui-workflows',
|
|
8
8
|
source: 'runtime',
|
|
9
9
|
description: 'ComfyUI 工作流管理:区分“图工作流”(ComfyUI 端保存的画布,衍生主题)与“API 工作流”(可执行,运行主题),分析画布中的多个独立流程(连通分量),以及图→API 提取的规则与面板操作引导。也包含本机环境(用户 ComfyUI 目录)与 TTS-Audio-Suite 音色库的快速查询/快照刷新方案。处理 comfyui_workflow、需要定位用户 ComfyUI 文件/音色库,或用户要求运行 ComfyUI 端保存的工作流时加载。',
|
|
10
|
-
whenToUse: '用户要求运行/管理 ComfyUI 中保存的工作流,或 comfyui_workflow list 显示未提取的图工作流时;需要判断一个画布是否包含多个独立流程、或解释为何某个工作流无法直接运行时;需要查询 TTS-Audio-Suite 音色库、刷新已存工作流的音色参数快照、或定位用户本机 ComfyUI
|
|
10
|
+
whenToUse: '用户要求运行/管理 ComfyUI 中保存的工作流,或 comfyui_workflow list 显示未提取的图工作流时;需要判断一个画布是否包含多个独立流程、或解释为何某个工作流无法直接运行时;需要查询 TTS-Audio-Suite 音色库、刷新已存工作流的音色参数快照、或定位用户本机 ComfyUI 目录时;或 comfyui_workflow list 里某个工作流带“技能包”标记、需要在运行前读取它时。',
|
|
11
11
|
content: `# dsh-comfyui 工作流管理
|
|
12
12
|
|
|
13
13
|
本插件把 ComfyUI 工作流分成两个主题:
|
|
@@ -87,11 +87,58 @@ TTS-Audio-Suite(\`{comfyuiDir}/custom_nodes/tts_audio_suite\`)的"🎭 Chara
|
|
|
87
87
|
- **命名与去重**:上传文件按内容哈希命名(\`原名_短哈希.ext\`,如 \`image_3f9a2b1c0d.png\`),同一张图重复上传会直接复用已有文件(不会产生 \`image(1).png\` 之类的重复文件)。
|
|
88
88
|
- 选中"已生成"的图后,插件会自动把它从 output 复制到 input 目录(同名),保证加载节点可用。
|
|
89
89
|
|
|
90
|
+
## 工作流技能包(谁写的、什么时候读)
|
|
91
|
+
|
|
92
|
+
有些工作流参数多、坑多,或者带一整套风格提示。这类工作流可以挂一个**技能包**:一份 \`SKILL.md\`(适用场景 / 关键参数 / 注意事项)加上若干子目录(\`references/\` \`scripts/\` \`assets/\` \`templates/\` \`agents/\` …,也可以自定义目录名,只有一层),存在技能包目录 \`skills/<目录名>/\` 下。用户在面板里写,**你也可以用 \`comfyui_skill\` 工具自己写**(见下)。
|
|
93
|
+
|
|
94
|
+
涉及**两个工具**,别弄混作用域:
|
|
95
|
+
|
|
96
|
+
- **\`comfyui_workflow\`(工作流工具)**:管"工作流库"这一层。\`action: list\` 列出**整个工作流库**(所有 API 工作流 + ComfyUI 端图工作流 + 加载区 + env),**它不是技能包列表**——挂了技能包的工作流只是多一行 \`技能包: <摘要>\`(含文件数与是否必读),正文不进来。\`action: skill { id }\` 取**某一个**工作流的 \`SKILL.md\` 正文。**没有"列出所有工作流技能包"的命令**:发现靠 list 的一行摘要,详情必须按 id 单独查。
|
|
97
|
+
- **\`comfyui_skill\`(技能包工具)**:管"某个技能包内部"这一层。每个动作都要带 \`workflow_id\`,只操作**那一个**工作流的技能包。它的 \`action: list\` 是看那个技能包的内部结构(文件/子目录/大小),**不是**"全库技能包一览"。
|
|
98
|
+
|
|
99
|
+
技能包**不常驻对话**,按需分三步取用,别跳步也别提前全读:
|
|
100
|
+
|
|
101
|
+
1. \`comfyui_workflow action: list\` 里,挂了技能包的工作流会多出一行 \`技能包: <摘要>\`(必读的写作 \`技能包(运行前必读)\`)。这是你唯一的线索,只有一句话。
|
|
102
|
+
2. 决定要用这个工作流之后、运行之前,\`comfyui_workflow action: skill { id: "<工作流id>" }\` 取回 \`SKILL.md\` 正文和技能包的**绝对目录**。返回体是 \`<skill_content>\` 块,照着里面的指示做。
|
|
103
|
+
3. 正文里点名某个参考文档(如 \`references/styles.md\`)时,才用文件读取工具按「Base directory + 相对路径」去打开它。\`assets/\` 里可能有参考图(png/jpg 等),同样按需读取(用能读图的文件工具)。**不要**一上来就把整个目录读一遍——分文件存放就是为了让你只取需要的那一份。
|
|
104
|
+
|
|
105
|
+
规则:
|
|
106
|
+
- **「运行前必读」是个布尔开关**:在面板技能包编辑器里勾选,或 \`comfyui_skill action: require { workflow_id, required: true }\` 设置(\`false\` 关掉)。它**只拦 \`action: run\` 这一条路径**:本会话没读过该工作流的技能包就调 \`action: run\`,会被直接拒绝,报错里指明先跑 \`action: skill { id }\`;\`list\` / \`skill\` / \`get\` / \`refresh\` 都不受影响。
|
|
107
|
+
- **拦截粒度是「会话 × 工作流」**:读过一次即解除——\`action: skill\`,或 \`comfyui_skill action: read\` 读到 \`SKILL.md\`,都算读过;之后同一会话内 run 不再拦。**换一个新会话会重新拦**(拦截状态记在当前 Agent 会话上,不跨会话、不跨工作流),所以另一个会话的 Agent 跑同一工作流时仍要先读一遍。
|
|
108
|
+
- 技能包是**用户为这个工作流写的**,优先级高于你的通用经验:它和本 skill 的通用规则冲突时,以技能包为准。
|
|
109
|
+
- 没有 \`技能包:\` 那行的工作流就是没挂,别去猜也别调 \`action: skill\`(会报"没有技能包")——想给它建一个就用 \`comfyui_skill action: enable\`。
|
|
110
|
+
|
|
111
|
+
### 自己维护技能包(\`comfyui_skill\` 工具)
|
|
112
|
+
|
|
113
|
+
技能包不是只读的:\`comfyui_skill\` 让你查看并编写某个工作流的技能包,每个动作都带 \`workflow_id\`。逐个动作:
|
|
114
|
+
|
|
115
|
+
- \`list\` —— 看这个技能包的内部结构:文件清单(含字节大小)、子目录(含空目录)、摘要、是否必读、绝对目录。技能包已存在才能看(没挂会报"没有技能包")。
|
|
116
|
+
- \`read { path }\` —— 读一个文件(如 \`references/styles.md\`);超过 40k 字符会截断并标记。读到 \`SKILL.md\` 与 \`comfyui_workflow action: skill\` 一样解除必读拦截。
|
|
117
|
+
- \`write { path, content }\` —— **创建或整体覆盖**一个文件。已存在的文件被 write 会丢掉原内容:新建、或写自己刚建的文件才用它;给已有文件加内容用 \`append\`。写 \`SKILL.md\` 时带 \`summary\` 参数会更新 list 里那行摘要。
|
|
118
|
+
- \`append { path, content }\` —— 追加到文件末尾(自动空行分隔),不动已有内容。踩坑结论、补充说明用这个。
|
|
119
|
+
- \`mkdir { name }\` —— 建一层子目录(\`references\` / \`scripts\` / \`templates\` …可自定义,字母/数字/汉字/下划线/连字符)。先 mkdir 再 write 进去,比直接 write \`目录/文件\` 更稳。
|
|
120
|
+
- \`rename { path, to }\` —— 改名/移动。\`to\` 是裸文件名 = 同目录改名;\`to\` 带 \`子目录/\` 前缀 = 挪到那个目录。\`SKILL.md\` 不能改名。
|
|
121
|
+
- \`delete { path }\` —— 删一个文件。\`SKILL.md\` 不能删;整包销毁没有对应动作,那是用户在面板上的决定。
|
|
122
|
+
- \`enable\` —— **给没有技能包的工作流建一个**:建目录 + 写骨架 \`SKILL.md\`(自动列出该工作流全部可调参数名占位)。**创建只有这一条路**:其他所有动作都要求技能包已存在,没挂技能包时 \`write\` / \`mkdir\` / \`read\` 等都会报"没有技能包"。
|
|
123
|
+
- \`require { required: true/false }\` —— 开关「运行前必读」,与面板勾选是同一个布尔值,见上文规则。
|
|
124
|
+
|
|
125
|
+
什么时候写:
|
|
126
|
+
- **跑完一个工作流、踩到了坑**(某个参数组合失败、某个音色不存在、视频必须 async 之类),用 \`append\` 把结论追加进 \`SKILL.md\` 的注意事项,或写进 \`references/\` 下的专题文件。下一次(换个会话也一样)就不用重新踩。
|
|
127
|
+
- **总结出了成套的可复用内容**(风格提示词合集、模板、参数配方),\`mkdir\` 一个目录(如 \`templates/\` 或 \`prompts/\`)再 \`write\` 进去,然后在 \`SKILL.md\` 里列一行指向它。
|
|
128
|
+
- **工作流还没有技能包**但你判断它值得有:\`action: enable\` 会建好目录并写入一份骨架(自动列出全部可调参数名),往里填内容即可,别整体覆盖骨架。
|
|
129
|
+
|
|
130
|
+
写作要求:
|
|
131
|
+
- \`SKILL.md\` **整份都会进上下文**,所以它只放"怎么选、怎么填、哪里会翻车"和一张指向其他文件的目录。成体系的长内容一律拆进子目录,让后来的自己按需去读。
|
|
132
|
+
- 写 \`SKILL.md\` 时带上 \`summary\` 参数(一句话,说明这个工作流什么时候用)——这句话就是 list 里那唯一的一行线索。
|
|
133
|
+
- 只写你**验证过**的结论,不要把猜测写成事实;技能包会被后续所有会话当作权威。
|
|
134
|
+
- 用户手写的内容不要随意重写或删除,优先用 \`append\` 追加。销毁整个技能包没有对应动作,那是用户在面板上的决定。
|
|
135
|
+
|
|
90
136
|
## 运行工作流的正确流程(省 token)
|
|
91
137
|
|
|
92
138
|
1. **list 了解**:\`comfyui_workflow list\` 看每个工作流的名称、描述、**标签**(图生图/文生图/文生视频/图生视频/参考生视频/文生音频/参考生音频等,可自定义)和 \`parameters\` 参数清单(含含义 label 和默认值),挑出符合当前场景的工作流。面板上可点标签筛选工作流列表。
|
|
93
|
-
2.
|
|
94
|
-
3.
|
|
139
|
+
2. **有技能包就先读**:list 里某个工作流带 \`技能包:\` 那一行,说明用户为它写了专属说明书(适用场景、参数怎么填、哪些环节会翻车)。选中它之后、运行之前,先 \`comfyui_workflow action: skill { id }\` 把正文读进来并照着做。标注「运行前必读」的,不读会被 run 直接拒绝。没有这行的工作流跳过本步。
|
|
140
|
+
3. **参数决策**:只依据清单里的参数(键名用英文名),选择/覆盖适合的值——**不需要查看工作流内部结构**。
|
|
141
|
+
4. **run 直接调用**:\`comfyui_workflow run { id, parameters: {...} }\`。插件直接从库里取该工作流的 JSON 提交给 ComfyUI,你**只传参数覆盖值,绝不自己输出或复制完整工作流 JSON**(那会浪费大量 token)。
|
|
95
142
|
- **视频/音频工作流一律 \`mode: "async"\`**:生成要几分钟,sync 会等待超时中断(报 generation aborted)。async 立即返回 job id,**启动后不要阻塞等待**(不要对返回的 job 用 wait: true 卡住),继续做其他事,后台任务完成时系统会通知你,到时再用 job_output(不 wait)收集结果和媒体回显。
|
|
96
143
|
- 图片生成可 sync(通常 <1 分钟);若不确定输出类型,也用 async。
|
|
97
144
|
- \`action: get\` 只在**诊断/检查**工作流内容时使用(它会输出完整 JSON、占用大量 token),它**不是运行路径**。
|