genlab 0.1.1 → 0.1.3
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 +43 -28
- package/dist/cli.mjs +523 -400
- package/dist/guide.md +49 -0
- package/package.json +2 -1
package/dist/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command, InvalidArgumentError } from "commander";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { access, copyFile, mkdir, readFile, readdir,
|
|
5
|
+
import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
6
|
import { openAsBlob } from "node:fs";
|
|
7
7
|
import { setTimeout } from "node:timers/promises";
|
|
8
8
|
import { homedir } from "node:os";
|
|
@@ -14,11 +14,12 @@ import { fileTypeFromBuffer } from "file-type";
|
|
|
14
14
|
import { imageSize } from "image-size";
|
|
15
15
|
import { parseBuffer } from "music-metadata";
|
|
16
16
|
import lockfile from "proper-lockfile";
|
|
17
|
-
import {
|
|
18
|
-
import { parseEnv
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { parseEnv } from "node:util";
|
|
19
19
|
import * as p from "@clack/prompts";
|
|
20
|
+
import spawn$1 from "nano-spawn";
|
|
20
21
|
// package.json
|
|
21
|
-
var version = "0.1.
|
|
22
|
+
var version = "0.1.3";
|
|
22
23
|
// src/errors.ts
|
|
23
24
|
/** 面向使用者的预期错误:只输出 message,不带堆栈。 */
|
|
24
25
|
var CliError = class extends Error {
|
|
@@ -44,7 +45,7 @@ function loadEnv() {
|
|
|
44
45
|
function requireEnv(name) {
|
|
45
46
|
loadEnv();
|
|
46
47
|
const value = process.env[name]?.trim();
|
|
47
|
-
if (!value) throw new CliError(`缺少配置 ${name}
|
|
48
|
+
if (!value) throw new CliError(`缺少配置 ${name}:运行 genlab init`);
|
|
48
49
|
return value;
|
|
49
50
|
}
|
|
50
51
|
/** 库的根目录:GENLAB_LIBRARY,支持 ~ 开头,必须是绝对路径。 */
|
|
@@ -102,10 +103,15 @@ async function uploadKieFile(filePath) {
|
|
|
102
103
|
if (!body.data?.downloadUrl) throw new CliError("kie.ai 上传未返回地址");
|
|
103
104
|
return body.data.downloadUrl;
|
|
104
105
|
}
|
|
106
|
+
/** 下载被拒绝(4xx,超时与限流除外):链接已失效或无权访问,再试也拿不到。 */
|
|
107
|
+
var DownloadRejectedError = class extends CliError {};
|
|
105
108
|
/** 下载产物原文件。 */
|
|
106
109
|
async function downloadFile(url) {
|
|
107
110
|
const response = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
|
|
108
|
-
if (!response.ok)
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const { status } = response;
|
|
113
|
+
throw new (status >= 400 && status < 500 && status !== 408 && status !== 429 ? DownloadRejectedError : CliError)(`下载产物失败:HTTP ${status}`);
|
|
114
|
+
}
|
|
109
115
|
return {
|
|
110
116
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
111
117
|
contentType: response.headers.get("content-type")
|
|
@@ -338,6 +344,8 @@ const MEDIA_KINDS = [
|
|
|
338
344
|
"video",
|
|
339
345
|
"audio"
|
|
340
346
|
];
|
|
347
|
+
/** 识别不出的媒体类型。 */
|
|
348
|
+
var UnsupportedMediaError = class extends CliError {};
|
|
341
349
|
/** 按内容识别不出时,按下载响应的 Content-Type 兜住常见格式。 */
|
|
342
350
|
const MIME_EXTENSIONS = {
|
|
343
351
|
"image/png": "png",
|
|
@@ -364,7 +372,7 @@ async function inspectMedia(bytes, contentType = null) {
|
|
|
364
372
|
const mimeType = detected?.mime ?? headerMime;
|
|
365
373
|
const extension = detected?.ext ?? MIME_EXTENSIONS[headerMime];
|
|
366
374
|
const kind = kindForMime(mimeType);
|
|
367
|
-
if (!extension || !kind) throw new
|
|
375
|
+
if (!extension || !kind) throw new UnsupportedMediaError(`无法识别的媒体类型:${mimeType || "未知"}`);
|
|
368
376
|
return {
|
|
369
377
|
mimeType,
|
|
370
378
|
extension,
|
|
@@ -405,8 +413,6 @@ async function measure(bytes, kind, mimeType) {
|
|
|
405
413
|
const ASSET_FILE = "asset.json";
|
|
406
414
|
/** 素材文件夹里存放参考输入副本的子目录,按生成编号分开。 */
|
|
407
415
|
const REFERENCES_DIR = "references";
|
|
408
|
-
/** 库根目录下的项目映射表。 */
|
|
409
|
-
const PROJECT_MAP_FILE = "projects.json";
|
|
410
416
|
const MAX_NAME_BYTES = 255;
|
|
411
417
|
/** 项目、文件夹、素材名:非空,不以 . 开头,不含路径分隔符与控制字符。 */
|
|
412
418
|
function validateName(name, what) {
|
|
@@ -441,6 +447,14 @@ function versionFromFileName(fileName) {
|
|
|
441
447
|
* 素材记录文件:素材、生成、版本的全部信息,是素材的唯一真相(见 ADR-0002)。
|
|
442
448
|
* 写入一律原子替换;修改走 updateAsset,同一素材的读改写互斥。
|
|
443
449
|
*/
|
|
450
|
+
/** 第 1 版记录没记分配过的最大号,只能取现有版本的最大号;下次写入时存为第 2 版。 */
|
|
451
|
+
function upgradeV1({ schema: _schema, ...record }) {
|
|
452
|
+
return {
|
|
453
|
+
schema: 2,
|
|
454
|
+
...record,
|
|
455
|
+
lastVersion: Math.max(0, ...record.versions.map((version) => version.number))
|
|
456
|
+
};
|
|
457
|
+
}
|
|
444
458
|
function assetFile(assetDir) {
|
|
445
459
|
return path.join(assetDir, ASSET_FILE);
|
|
446
460
|
}
|
|
@@ -453,7 +467,8 @@ async function readAsset(assetDir) {
|
|
|
453
467
|
throw error;
|
|
454
468
|
}
|
|
455
469
|
const record = JSON.parse(text);
|
|
456
|
-
if (record.schema
|
|
470
|
+
if (record.schema === 1) return upgradeV1(record);
|
|
471
|
+
if (record.schema !== 2) throw new CliError(`无法识别的素材记录版本:${assetFile(assetDir)}`);
|
|
457
472
|
return record;
|
|
458
473
|
}
|
|
459
474
|
async function writeAsset(assetDir, record) {
|
|
@@ -493,8 +508,10 @@ function now() {
|
|
|
493
508
|
return (/* @__PURE__ */ new Date()).toISOString();
|
|
494
509
|
}
|
|
495
510
|
// src/library/rules.ts
|
|
496
|
-
|
|
497
|
-
|
|
511
|
+
/** 分配新版本号(就地更新 lastVersion):接着分配过的最大号往后编,删过的号不复用。 */
|
|
512
|
+
function allocateVersion(record) {
|
|
513
|
+
record.lastVersion += 1;
|
|
514
|
+
return record.lastVersion;
|
|
498
515
|
}
|
|
499
516
|
function nextGenerationId(generations) {
|
|
500
517
|
return Math.max(0, ...generations.map((generation) => generation.id)) + 1;
|
|
@@ -573,10 +590,11 @@ async function importAsset(project, location, file) {
|
|
|
573
590
|
const fileName = versionFileName(1, media.extension);
|
|
574
591
|
const timestamp = now();
|
|
575
592
|
const record = {
|
|
576
|
-
schema:
|
|
593
|
+
schema: 2,
|
|
577
594
|
id: randomUUID(),
|
|
578
595
|
kind: media.kind,
|
|
579
596
|
currentVersion: 1,
|
|
597
|
+
lastVersion: 1,
|
|
580
598
|
createdAt: timestamp,
|
|
581
599
|
generations: [],
|
|
582
600
|
versions: [{
|
|
@@ -626,7 +644,7 @@ async function setCurrentVersion(location, number) {
|
|
|
626
644
|
async function deleteVersion(location, number) {
|
|
627
645
|
await updateAsset(location.dir, async (record) => {
|
|
628
646
|
const version = findVersion(record, number);
|
|
629
|
-
if (!isLeaf(record.versions, number)) throw new CliError(`v${number}
|
|
647
|
+
if (!isLeaf(record.versions, number)) throw new CliError(`v${number} 有子版本,不能删除`);
|
|
630
648
|
if (record.versions.length <= 1) throw new CliError("素材至少保留一个版本");
|
|
631
649
|
if (record.currentVersion === number) {
|
|
632
650
|
const next = nextCurrentAfterDelete(record.versions, number, version.sourceVersion);
|
|
@@ -656,42 +674,10 @@ async function claimAssetDir(project, location) {
|
|
|
656
674
|
throw error;
|
|
657
675
|
}
|
|
658
676
|
}
|
|
659
|
-
// src/generation/state.ts
|
|
660
|
-
/** 一个 kie 任务对应的版本里,积分只记在结果序号 0 上,避免重复计量。 */
|
|
661
|
-
function creditsFor(version, credits) {
|
|
662
|
-
return (version.resultIndex ?? 0) === 0 ? credits : null;
|
|
663
|
-
}
|
|
664
|
-
function failVersion(record, number, reason, task = {
|
|
665
|
-
credits: null,
|
|
666
|
-
kieRecord: null
|
|
667
|
-
}) {
|
|
668
|
-
const version = record.versions.find((item) => item.number === number);
|
|
669
|
-
if (!version || version.state !== "generating") return;
|
|
670
|
-
version.state = "failed";
|
|
671
|
-
version.failReason = reason;
|
|
672
|
-
version.credits = creditsFor(version, task.credits);
|
|
673
|
-
version.kieRecord = task.kieRecord;
|
|
674
|
-
version.updatedAt = now();
|
|
675
|
-
if (record.currentVersion === number) {
|
|
676
|
-
const next = nextCurrentAfterFailure(record.versions, version);
|
|
677
|
-
if (next != null) record.currentVersion = next;
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
function fulfillVersion(record, number, product, task) {
|
|
681
|
-
const version = record.versions.find((item) => item.number === number);
|
|
682
|
-
if (!version || version.state !== "generating") return;
|
|
683
|
-
Object.assign(version, product, {
|
|
684
|
-
state: "ready",
|
|
685
|
-
failReason: null,
|
|
686
|
-
credits: creditsFor(version, task.credits),
|
|
687
|
-
kieRecord: task.kieRecord,
|
|
688
|
-
updatedAt: now()
|
|
689
|
-
});
|
|
690
|
-
}
|
|
691
677
|
// src/generation/submit.ts
|
|
692
678
|
/**
|
|
693
|
-
*
|
|
694
|
-
*
|
|
679
|
+
* 发起生成:按模型的 schema 校验,上传文件并向 kie 下单,拿到任务编号后才写进记录。
|
|
680
|
+
* 记录里生成中的版本因此都有任务编号;下单失败直接报错,不留下版本。
|
|
695
681
|
*/
|
|
696
682
|
const defaultDeps$1 = {
|
|
697
683
|
upload: uploadKieFile,
|
|
@@ -699,13 +685,31 @@ const defaultDeps$1 = {
|
|
|
699
685
|
};
|
|
700
686
|
/** 一次生成最多并列几个 kie 任务。 */
|
|
701
687
|
const MAX_TASKS = 10;
|
|
702
|
-
/**
|
|
688
|
+
/** 新建素材并发起第一次生成;新素材的当前版本取版本号最靠前的版本。 */
|
|
703
689
|
async function createAsset(project, location, options, deps = defaultDeps$1) {
|
|
704
690
|
const plan = await planGeneration(options, null);
|
|
691
|
+
const references = await inspectReferences(project.root, plan.input);
|
|
705
692
|
await claimAssetDir(project, location);
|
|
706
|
-
let created;
|
|
707
693
|
try {
|
|
708
|
-
|
|
694
|
+
const orders = await placeOrders(plan.model.model, plan.input, null, plan.tasks, deps);
|
|
695
|
+
const record = {
|
|
696
|
+
schema: 2,
|
|
697
|
+
id: randomUUID(),
|
|
698
|
+
kind: plan.model.kind,
|
|
699
|
+
currentVersion: 0,
|
|
700
|
+
lastVersion: 0,
|
|
701
|
+
createdAt: now(),
|
|
702
|
+
generations: [],
|
|
703
|
+
versions: []
|
|
704
|
+
};
|
|
705
|
+
const id = nextGenerationId(record.generations);
|
|
706
|
+
const versions = appendGeneration(record, id, plan, await copyReferences(location.dir, id, references), orders);
|
|
707
|
+
record.currentVersion = versions[0];
|
|
708
|
+
await writeFile(assetFile(location.dir), `${JSON.stringify(record, null, 2)}\n`, { flag: "wx" });
|
|
709
|
+
return {
|
|
710
|
+
versions,
|
|
711
|
+
errors: orders.errors
|
|
712
|
+
};
|
|
709
713
|
} catch (error) {
|
|
710
714
|
await rm(location.dir, {
|
|
711
715
|
recursive: true,
|
|
@@ -713,8 +717,6 @@ async function createAsset(project, location, options, deps = defaultDeps$1) {
|
|
|
713
717
|
});
|
|
714
718
|
throw error;
|
|
715
719
|
}
|
|
716
|
-
await dispatch(location.dir, created.generation, created.versions, deps);
|
|
717
|
-
return created.versions;
|
|
718
720
|
}
|
|
719
721
|
/**
|
|
720
722
|
* 在已有素材里发起生成。from 指定时沿用该版本的模型与 input(重新生成),`-p` 给出的
|
|
@@ -745,27 +747,30 @@ async function editVersion(project, location, sourceVersion, options, deps = def
|
|
|
745
747
|
model: options.model ?? findGeneration(record, source.generation)?.model
|
|
746
748
|
}, sourceVersion), deps);
|
|
747
749
|
}
|
|
748
|
-
/**
|
|
750
|
+
/** 原地重试失败的版本:沿用它那次生成的输入下一个新任务,下单成功后版本才回到生成中。 */
|
|
749
751
|
async function retryVersion(location, number, deps = defaultDeps$1) {
|
|
750
752
|
const record = await readAsset(location.dir);
|
|
751
753
|
const version = findVersion(record, number);
|
|
752
754
|
const generation = findGeneration(record, version.generation);
|
|
753
755
|
if (version.state !== "failed") throw new CliError("只有失败的版本可以重试");
|
|
754
756
|
if (!generation) throw new CliError(`v${number} 不是生成产物,无法重试`);
|
|
757
|
+
const orders = await placeOrders(generation.model, absoluteTemplate(location.dir, generation.input), sourceFile(location, record, generation.sourceVersion), 1, deps);
|
|
755
758
|
await updateAsset(location.dir, (latest) => {
|
|
756
759
|
Object.assign(findVersion(latest, number), {
|
|
757
760
|
state: "generating",
|
|
758
761
|
resultIndex: 0,
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
kieRequest: null,
|
|
762
|
+
taskId: orders.taskIds[0],
|
|
763
|
+
kieRequest: orders.request,
|
|
762
764
|
kieRecord: null,
|
|
765
|
+
failReason: null,
|
|
763
766
|
credits: null,
|
|
764
767
|
updatedAt: now()
|
|
765
768
|
});
|
|
766
769
|
});
|
|
767
|
-
|
|
768
|
-
|
|
770
|
+
return {
|
|
771
|
+
versions: [number],
|
|
772
|
+
errors: []
|
|
773
|
+
};
|
|
769
774
|
}
|
|
770
775
|
/**
|
|
771
776
|
* 解析 `-p` 并与沿用的 input 合并,按模型 schema 校验(值类型就地转换)。
|
|
@@ -774,7 +779,7 @@ async function retryVersion(location, number, deps = defaultDeps$1) {
|
|
|
774
779
|
async function planGeneration(options, sourceVersion, inherited = {}) {
|
|
775
780
|
if (!options.model) throw new CliError("缺少 --model");
|
|
776
781
|
const tasks = options.count ?? 1;
|
|
777
|
-
if (!Number.isInteger(tasks) || tasks < 1 || tasks > MAX_TASKS) throw new CliError(
|
|
782
|
+
if (!Number.isInteger(tasks) || tasks < 1 || tasks > MAX_TASKS) throw new CliError(`--count 范围是 1–${MAX_TASKS}`);
|
|
778
783
|
const model = await findModel(options.model);
|
|
779
784
|
const input = {
|
|
780
785
|
...inherited,
|
|
@@ -805,30 +810,19 @@ function isLocalFile(text) {
|
|
|
805
810
|
async function appendAndDispatch(project, location, record, plan, deps) {
|
|
806
811
|
if (plan.model.kind !== record.kind) throw new CliError(`模型 ${plan.model.model} 生成 ${plan.model.kind},与素材类型 ${record.kind} 不符`);
|
|
807
812
|
const references = await inspectReferences(project.root, plan.input);
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
/** 写入新素材的记录与参考输入副本;新素材的当前版本取版本号最靠前的版本壳。 */
|
|
816
|
-
async function writeNewAsset(project, location, plan) {
|
|
817
|
-
const references = await inspectReferences(project.root, plan.input);
|
|
818
|
-
const record = {
|
|
819
|
-
schema: 1,
|
|
820
|
-
id: randomUUID(),
|
|
821
|
-
kind: plan.model.kind,
|
|
822
|
-
currentVersion: 0,
|
|
823
|
-
createdAt: now(),
|
|
824
|
-
generations: [],
|
|
825
|
-
versions: []
|
|
813
|
+
const orders = await placeOrders(plan.model.model, plan.input, sourceFile(location, record, plan.sourceVersion), plan.tasks, deps);
|
|
814
|
+
return {
|
|
815
|
+
versions: await updateAsset(location.dir, async (latest) => {
|
|
816
|
+
const id = nextGenerationId(latest.generations);
|
|
817
|
+
return appendGeneration(latest, id, plan, await copyReferences(location.dir, id, references), orders);
|
|
818
|
+
}),
|
|
819
|
+
errors: orders.errors
|
|
826
820
|
};
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
return
|
|
821
|
+
}
|
|
822
|
+
/** 源版本产物的绝对路径;没有源版本时为 null。 */
|
|
823
|
+
function sourceFile(location, record, sourceVersion) {
|
|
824
|
+
const file = sourceVersion == null ? null : findVersion(record, sourceVersion).file;
|
|
825
|
+
return file ? path.join(location.dir, file) : null;
|
|
832
826
|
}
|
|
833
827
|
/** input 引用的本机文件:识别类型;位于库内某个版本产物时记下出处。 */
|
|
834
828
|
async function inspectReferences(root, input) {
|
|
@@ -877,22 +871,12 @@ async function copyReferences(dir, generationId, references) {
|
|
|
877
871
|
}
|
|
878
872
|
return records;
|
|
879
873
|
}
|
|
880
|
-
/**
|
|
881
|
-
function appendGeneration(record, id, plan, references) {
|
|
874
|
+
/** 追加一次生成与它的版本(每个 kie 接受的任务一个,就地修改 record);返回新版本号。 */
|
|
875
|
+
function appendGeneration(record, id, plan, references, orders) {
|
|
882
876
|
const timestamp = now();
|
|
883
877
|
const copies = new Map(references.map((reference) => [reference.source, reference.file]));
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
model: plan.model.model,
|
|
887
|
-
input: mapStrings(plan.input, (text) => isLocalFile(text) && copies.has(text.slice(1)) ? `@${copies.get(text.slice(1))}` : text),
|
|
888
|
-
references: references.map(({ source: _source, ...reference }) => reference),
|
|
889
|
-
sourceVersion: plan.sourceVersion,
|
|
890
|
-
createdAt: timestamp
|
|
891
|
-
};
|
|
892
|
-
const base = nextVersionNumber(record.versions);
|
|
893
|
-
const versions = [];
|
|
894
|
-
for (let position = 0; position < plan.tasks; position++) {
|
|
895
|
-
const number = base + position;
|
|
878
|
+
const versions = orders.taskIds.map((taskId, position) => {
|
|
879
|
+
const number = allocateVersion(record);
|
|
896
880
|
record.versions.push({
|
|
897
881
|
number,
|
|
898
882
|
state: "generating",
|
|
@@ -901,8 +885,8 @@ function appendGeneration(record, id, plan, references) {
|
|
|
901
885
|
position,
|
|
902
886
|
resultIndex: 0,
|
|
903
887
|
sourceVersion: plan.sourceVersion,
|
|
904
|
-
taskId
|
|
905
|
-
kieRequest:
|
|
888
|
+
taskId,
|
|
889
|
+
kieRequest: orders.request,
|
|
906
890
|
kieRecord: null,
|
|
907
891
|
failReason: null,
|
|
908
892
|
credits: null,
|
|
@@ -916,60 +900,42 @@ function appendGeneration(record, id, plan, references) {
|
|
|
916
900
|
createdAt: timestamp,
|
|
917
901
|
updatedAt: timestamp
|
|
918
902
|
});
|
|
919
|
-
|
|
920
|
-
}
|
|
921
|
-
record.generations.push(
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
903
|
+
return number;
|
|
904
|
+
});
|
|
905
|
+
record.generations.push({
|
|
906
|
+
id,
|
|
907
|
+
model: plan.model.model,
|
|
908
|
+
input: mapStrings(plan.input, (text) => isLocalFile(text) && copies.has(text.slice(1)) ? `@${copies.get(text.slice(1))}` : text),
|
|
909
|
+
references: references.map(({ source: _source, ...reference }) => reference),
|
|
910
|
+
sourceVersion: plan.sourceVersion,
|
|
911
|
+
createdAt: timestamp
|
|
912
|
+
});
|
|
913
|
+
return versions;
|
|
926
914
|
}
|
|
927
915
|
/**
|
|
928
|
-
* 上传 input
|
|
929
|
-
*
|
|
916
|
+
* 上传 input 引用的文件与源版本换取临时地址(每次下单都重新上传,不保存),再下 count
|
|
917
|
+
* 个任务。全部下单失败时报错;部分失败时返回成功的任务与失败原因。
|
|
930
918
|
*/
|
|
931
|
-
async function
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
const files = new Map(filePaths(generation.input).map((file) => [`@${file}`, path.join(dir, file)]));
|
|
937
|
-
if (sourceFile && usesSource(generation.input)) files.set(SOURCE_TOKEN, path.join(dir, sourceFile));
|
|
938
|
-
const urls = new Map(await Promise.all([...files].map(async ([token, file]) => [token, await deps.upload(file)])));
|
|
939
|
-
task = {
|
|
940
|
-
model: generation.model,
|
|
941
|
-
input: resolveTokens(generation.input, (token) => urls.get(token))
|
|
942
|
-
};
|
|
943
|
-
} catch (error) {
|
|
944
|
-
await failVersions(dir, versions, error.message);
|
|
945
|
-
return;
|
|
919
|
+
async function placeOrders(model, input, source, count, deps) {
|
|
920
|
+
const files = new Map(filePaths(input).map((file) => [`@${file}`, file]));
|
|
921
|
+
if (usesSource(input)) {
|
|
922
|
+
if (!source) throw new CliError("源版本没有产物文件");
|
|
923
|
+
files.set(SOURCE_TOKEN, source);
|
|
946
924
|
}
|
|
947
|
-
await Promise.all(
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
}
|
|
962
|
-
});
|
|
963
|
-
}));
|
|
964
|
-
}
|
|
965
|
-
async function failVersions(dir, numbers, reason, task = null) {
|
|
966
|
-
await updateAsset(dir, (record) => {
|
|
967
|
-
for (const number of numbers) {
|
|
968
|
-
const version = record.versions.find((item) => item.number === number);
|
|
969
|
-
if (version) version.kieRequest = task;
|
|
970
|
-
failVersion(record, number, reason);
|
|
971
|
-
}
|
|
972
|
-
});
|
|
925
|
+
const urls = new Map(await Promise.all([...files].map(async ([token, file]) => [token, await deps.upload(file)])));
|
|
926
|
+
const request = {
|
|
927
|
+
model,
|
|
928
|
+
input: resolveTokens(input, (token) => urls.get(token))
|
|
929
|
+
};
|
|
930
|
+
const results = await Promise.allSettled(Array.from({ length: count }, () => deps.createTask(request)));
|
|
931
|
+
const taskIds = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
|
932
|
+
const errors = [...new Set(results.flatMap((result) => result.status === "rejected" ? [result.reason.message] : []))];
|
|
933
|
+
if (taskIds.length === 0) throw new CliError(errors[0] ?? "下单失败");
|
|
934
|
+
return {
|
|
935
|
+
request,
|
|
936
|
+
taskIds,
|
|
937
|
+
errors
|
|
938
|
+
};
|
|
973
939
|
}
|
|
974
940
|
// src/kie/record.ts
|
|
975
941
|
/**
|
|
@@ -1022,40 +988,120 @@ function parseResults(resultJson) {
|
|
|
1022
988
|
}));
|
|
1023
989
|
return [];
|
|
1024
990
|
}
|
|
991
|
+
// src/generation/state.ts
|
|
992
|
+
/** 一个 kie 任务对应的版本里,积分只记在结果序号 0 上,避免重复计量。 */
|
|
993
|
+
function creditsFor(version, credits) {
|
|
994
|
+
return (version.resultIndex ?? 0) === 0 ? credits : null;
|
|
995
|
+
}
|
|
996
|
+
function failVersion(record, number, reason, task = {
|
|
997
|
+
credits: null,
|
|
998
|
+
kieRecord: null
|
|
999
|
+
}) {
|
|
1000
|
+
const version = record.versions.find((item) => item.number === number);
|
|
1001
|
+
if (!version || version.state !== "generating") return;
|
|
1002
|
+
version.state = "failed";
|
|
1003
|
+
version.failReason = reason;
|
|
1004
|
+
version.credits = creditsFor(version, task.credits);
|
|
1005
|
+
version.kieRecord = task.kieRecord;
|
|
1006
|
+
version.updatedAt = now();
|
|
1007
|
+
if (record.currentVersion === number) {
|
|
1008
|
+
const next = nextCurrentAfterFailure(record.versions, version);
|
|
1009
|
+
if (next != null) record.currentVersion = next;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
function fulfillVersion(record, number, product, task) {
|
|
1013
|
+
const version = record.versions.find((item) => item.number === number);
|
|
1014
|
+
if (!version || version.state !== "generating") return;
|
|
1015
|
+
Object.assign(version, product, {
|
|
1016
|
+
state: "ready",
|
|
1017
|
+
failReason: null,
|
|
1018
|
+
credits: creditsFor(version, task.credits),
|
|
1019
|
+
kieRecord: task.kieRecord,
|
|
1020
|
+
updatedAt: now()
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1025
1023
|
// src/generation/sync.ts
|
|
1026
1024
|
/**
|
|
1027
1025
|
* 向 kie 求证并收取产物:按任务分组,一次 recordInfo 推进同一任务的全部版本。
|
|
1028
1026
|
* 产物先下载到素材文件夹里的临时文件,加锁确认版本仍在等待该任务后再就位,
|
|
1029
1027
|
* 因此多个进程同时收取也只会落一份(幂等)。
|
|
1028
|
+
*
|
|
1029
|
+
* 收取只在后台收取进程里进行,全库同一时间只有一个(见 ADR-0007):每条素材命令结束时、
|
|
1030
|
+
* 以及开始等待时启动它,收完或超过时限即退出;等待只读记录。
|
|
1030
1031
|
*/
|
|
1031
1032
|
const defaultDeps = {
|
|
1032
1033
|
getRecord: getKieRecord,
|
|
1033
1034
|
download: downloadFile
|
|
1034
1035
|
};
|
|
1035
|
-
/** 提交到一半中断(有记录、没任务编号)的版本,超过这个时长按失败处理,可重试。 */
|
|
1036
|
-
const STUCK_DISPATCH_MS = 6e5;
|
|
1037
1036
|
const POLL_INTERVAL_MS = 5e3;
|
|
1038
|
-
/**
|
|
1037
|
+
/** 收取者锁:库根目录下的点目录,扫描库时跳过。 */
|
|
1038
|
+
const COLLECTOR_LOCK = ".sync.lock";
|
|
1039
|
+
/** 后台收取进程最多运行的时长;超过后退出,下一条命令再启动。 */
|
|
1040
|
+
const COLLECTOR_MAX_MS = 18e5;
|
|
1041
|
+
/**
|
|
1042
|
+
* 以全库唯一收取者的身份执行 run;已有收取者时不执行。锁失效(如系统休眠)时可能
|
|
1043
|
+
* 短暂出现两个收取者,收取是幂等的,不影响结果。
|
|
1044
|
+
*/
|
|
1045
|
+
async function asCollector(root, run) {
|
|
1046
|
+
let release;
|
|
1047
|
+
try {
|
|
1048
|
+
release = await lockfile.lock(root, {
|
|
1049
|
+
lockfilePath: path.join(root, COLLECTOR_LOCK),
|
|
1050
|
+
onCompromised: () => {}
|
|
1051
|
+
});
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
if (error.code === "ELOCKED") return;
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
await run();
|
|
1058
|
+
} finally {
|
|
1059
|
+
await release().catch((error) => {
|
|
1060
|
+
if (error.code !== "ERELEASED") throw error;
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
/** 仍有生成中版本的素材;读不出记录的跳过。 */
|
|
1065
|
+
async function pendingDirs(dirs) {
|
|
1066
|
+
const pending = await Promise.all(dirs.map(async (dir) => {
|
|
1067
|
+
try {
|
|
1068
|
+
return (await readAsset(dir)).versions.some((version) => version.state === "generating");
|
|
1069
|
+
} catch {
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
}));
|
|
1073
|
+
return dirs.filter((_, index) => pending[index]);
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* 后台收取:作为全库唯一的收取者轮询,每轮重新扫描库(包括期间新发起的生成),
|
|
1077
|
+
* 全部收完或超过时限后返回;已有收取者时立即返回。
|
|
1078
|
+
*/
|
|
1079
|
+
async function collectLibrary(root, deps = defaultDeps) {
|
|
1080
|
+
const pending = async () => pendingDirs(await findAssetDirs(root));
|
|
1081
|
+
await asCollector(root, async () => {
|
|
1082
|
+
const deadline = Date.now() + COLLECTOR_MAX_MS;
|
|
1083
|
+
for (;;) {
|
|
1084
|
+
const dirs = await pending();
|
|
1085
|
+
if (dirs.length === 0) return;
|
|
1086
|
+
await syncAssets(dirs, deps);
|
|
1087
|
+
if ((await pending()).length === 0 || Date.now() + POLL_INTERVAL_MS > deadline) return;
|
|
1088
|
+
await setTimeout(POLL_INTERVAL_MS);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
/** 收取一批素材里所有生成中的版本;返回过程中遇到的问题(不中断其余任务)。 */
|
|
1039
1093
|
async function syncAssets(assetDirs, deps = defaultDeps) {
|
|
1040
1094
|
return (await Promise.all(assetDirs.map((dir) => syncAsset(dir, deps).catch((error) => [`${dir}:${error.message}`])))).flat();
|
|
1041
1095
|
}
|
|
1042
1096
|
/**
|
|
1043
|
-
*
|
|
1044
|
-
* targets 的 versions 为空表示该素材里所有生成中的版本。
|
|
1097
|
+
* 轮询记录,直到目标版本全部到达终态或超时;返回是否全部完成。只读记录,收取由后台
|
|
1098
|
+
* 收取进程负责。targets 的 versions 为空表示该素材里所有生成中的版本。
|
|
1045
1099
|
*/
|
|
1046
|
-
async function waitForVersions(targets, timeoutMs
|
|
1100
|
+
async function waitForVersions(targets, timeoutMs) {
|
|
1047
1101
|
const deadline = Date.now() + timeoutMs;
|
|
1048
|
-
const warnings = /* @__PURE__ */ new Set();
|
|
1049
1102
|
for (;;) {
|
|
1050
|
-
|
|
1051
|
-
if (
|
|
1052
|
-
done: true,
|
|
1053
|
-
warnings: [...warnings]
|
|
1054
|
-
};
|
|
1055
|
-
if (Date.now() + POLL_INTERVAL_MS > deadline) return {
|
|
1056
|
-
done: false,
|
|
1057
|
-
warnings: [...warnings]
|
|
1058
|
-
};
|
|
1103
|
+
if (await allSettled(targets)) return true;
|
|
1104
|
+
if (Date.now() + POLL_INTERVAL_MS > deadline) return false;
|
|
1059
1105
|
await setTimeout(POLL_INTERVAL_MS);
|
|
1060
1106
|
}
|
|
1061
1107
|
}
|
|
@@ -1065,17 +1111,12 @@ async function allSettled(targets) {
|
|
|
1065
1111
|
}
|
|
1066
1112
|
async function syncAsset(dir, deps) {
|
|
1067
1113
|
const record = await readAsset(dir);
|
|
1068
|
-
const
|
|
1069
|
-
const stuck = generating.filter((version) => !version.taskId && Date.now() - Date.parse(version.updatedAt) > STUCK_DISPATCH_MS);
|
|
1070
|
-
if (stuck.length > 0) await updateAsset(dir, (latest) => {
|
|
1071
|
-
for (const version of stuck) if (!latest.versions.find((item) => item.number === version.number)?.taskId) failVersion(latest, version.number, "提交中断,可重试");
|
|
1072
|
-
});
|
|
1073
|
-
const taskIds = [...new Set(generating.map((version) => version.taskId).filter((taskId) => taskId != null))];
|
|
1114
|
+
const taskIds = [...new Set(record.versions.filter((version) => version.state === "generating").map((version) => version.taskId).filter((taskId) => taskId != null))];
|
|
1074
1115
|
const warnings = [];
|
|
1075
1116
|
for (const taskId of taskIds) try {
|
|
1076
1117
|
await syncTask(dir, record, taskId, deps);
|
|
1077
1118
|
} catch (error) {
|
|
1078
|
-
warnings.push(`${path.basename(dir)} 任务 ${taskId}
|
|
1119
|
+
warnings.push(`${path.basename(dir)} 任务 ${taskId} 收取失败:${error.message}`);
|
|
1079
1120
|
}
|
|
1080
1121
|
return warnings;
|
|
1081
1122
|
}
|
|
@@ -1098,8 +1139,9 @@ async function syncTask(dir, record, taskId, deps) {
|
|
|
1098
1139
|
await updateAsset(dir, async (latest) => {
|
|
1099
1140
|
const waiting = members.filter((member) => stillWaiting(latest, member.number, taskId));
|
|
1100
1141
|
const lead = waiting[0];
|
|
1142
|
+
const targets = downloads.map((_, index) => latest.versions.find((version) => version.taskId === taskId && version.resultIndex === index) ?? (lead ? appendResultVersion(latest, lead, index) : null));
|
|
1101
1143
|
for (const [index, product] of downloads.entries()) {
|
|
1102
|
-
const target =
|
|
1144
|
+
const target = targets[index];
|
|
1103
1145
|
if (!target || target.state !== "generating") {
|
|
1104
1146
|
if (!("error" in product)) await rm(product.temp, { force: true });
|
|
1105
1147
|
continue;
|
|
@@ -1126,7 +1168,7 @@ function appendResultVersion(record, lead, resultIndex) {
|
|
|
1126
1168
|
const positions = record.versions.filter((version) => version.generation === lead.generation).map((version) => version.position ?? 0);
|
|
1127
1169
|
const version = {
|
|
1128
1170
|
...lead,
|
|
1129
|
-
number:
|
|
1171
|
+
number: allocateVersion(record),
|
|
1130
1172
|
state: "generating",
|
|
1131
1173
|
position: Math.max(...positions) + 1,
|
|
1132
1174
|
resultIndex,
|
|
@@ -1136,10 +1178,21 @@ function appendResultVersion(record, lead, resultIndex) {
|
|
|
1136
1178
|
record.versions.push(version);
|
|
1137
1179
|
return version;
|
|
1138
1180
|
}
|
|
1139
|
-
/**
|
|
1181
|
+
/**
|
|
1182
|
+
* 下载并识别产物。拿不到的产物(下载被拒绝、识别不出类型或与素材类型不符)返回错误,
|
|
1183
|
+
* 按失败处理;其余错误(如网络)直接抛出,下一轮再收。
|
|
1184
|
+
*/
|
|
1140
1185
|
async function downloadProduct(dir, record, taskId, index, item, deps) {
|
|
1141
|
-
|
|
1142
|
-
|
|
1186
|
+
let bytes;
|
|
1187
|
+
let media;
|
|
1188
|
+
try {
|
|
1189
|
+
const file = await deps.download(item.url);
|
|
1190
|
+
bytes = file.bytes;
|
|
1191
|
+
media = await inspectMedia(bytes, file.contentType);
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
if (error instanceof DownloadRejectedError || error instanceof UnsupportedMediaError) return { error: error.message };
|
|
1194
|
+
throw error;
|
|
1195
|
+
}
|
|
1143
1196
|
if (media.kind !== record.kind) return { error: `产物类型(${media.kind})与素材类型(${record.kind})不符` };
|
|
1144
1197
|
const temp = path.join(dir, `.${taskId}-${index}.${process.pid}.part`);
|
|
1145
1198
|
await writeFile(temp, bytes);
|
|
@@ -1155,174 +1208,102 @@ async function downloadProduct(dir, record, taskId, index, item, deps) {
|
|
|
1155
1208
|
}
|
|
1156
1209
|
};
|
|
1157
1210
|
}
|
|
1158
|
-
// src/project/
|
|
1159
|
-
/** 识别调用时所在的代码项目:远程地址 + 主仓库位置(见 ADR-0004)。 */
|
|
1160
|
-
const run = promisify(execFile);
|
|
1161
|
-
async function git(cwd, args) {
|
|
1162
|
-
try {
|
|
1163
|
-
const { stdout } = await run("git", [
|
|
1164
|
-
"-C",
|
|
1165
|
-
cwd,
|
|
1166
|
-
...args
|
|
1167
|
-
]);
|
|
1168
|
-
return stdout.trim() || null;
|
|
1169
|
-
} catch {
|
|
1170
|
-
return null;
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
async function identifyCodeProject(cwd) {
|
|
1174
|
-
const dir = await realpath(cwd);
|
|
1175
|
-
const commonDir = await git(dir, [
|
|
1176
|
-
"rev-parse",
|
|
1177
|
-
"--path-format=absolute",
|
|
1178
|
-
"--git-common-dir"
|
|
1179
|
-
]);
|
|
1180
|
-
if (!commonDir) return {
|
|
1181
|
-
remote: null,
|
|
1182
|
-
path: dir
|
|
1183
|
-
};
|
|
1184
|
-
const mainPath = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : await git(dir, ["rev-parse", "--show-toplevel"]) ?? dir;
|
|
1185
|
-
return {
|
|
1186
|
-
remote: await remoteUrl(mainPath),
|
|
1187
|
-
path: await realpath(mainPath)
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
/** 优先 origin,否则取第一个远程。 */
|
|
1191
|
-
async function remoteUrl(repo) {
|
|
1192
|
-
const remotes = (await git(repo, ["remote"]))?.split("\n") ?? [];
|
|
1193
|
-
const name = remotes.includes("origin") ? "origin" : remotes[0];
|
|
1194
|
-
return name ? git(repo, [
|
|
1195
|
-
"remote",
|
|
1196
|
-
"get-url",
|
|
1197
|
-
name
|
|
1198
|
-
]) : null;
|
|
1199
|
-
}
|
|
1200
|
-
// src/project/map.ts
|
|
1211
|
+
// src/project/link.ts
|
|
1201
1212
|
/**
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1204
|
-
* 仓库的,按位置找到原条目补上远程地址,名字不变。
|
|
1213
|
+
* 项目关联:代码项目里的 .genlab 点目录记下它对应库里的哪个项目(见 ADR-0006)。
|
|
1214
|
+
* 点目录自带只忽略自身的 .gitignore,不改动代码项目的任何忽略设置。
|
|
1205
1215
|
*/
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
const trimmed = url.trim();
|
|
1216
|
-
let location;
|
|
1217
|
-
if (trimmed.includes("://")) {
|
|
1218
|
-
const parsed = new URL(trimmed);
|
|
1219
|
-
location = `${parsed.hostname}${parsed.pathname}`;
|
|
1220
|
-
} else location = trimmed.slice(trimmed.indexOf("@") + 1).replace(":", "/");
|
|
1221
|
-
return location.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
1222
|
-
}
|
|
1223
|
-
function findEntry(map, project) {
|
|
1224
|
-
if (project.remote) {
|
|
1225
|
-
const remote = normalizeRemote(project.remote);
|
|
1226
|
-
const byRemote = map.projects.find((entry) => entry.remote != null && normalizeRemote(entry.remote) === remote);
|
|
1227
|
-
if (byRemote) return byRemote;
|
|
1216
|
+
const LINK_DIR = ".genlab";
|
|
1217
|
+
const LINK_FILE = "project.json";
|
|
1218
|
+
const GITIGNORE = "# genlab 自动创建\n*\n";
|
|
1219
|
+
async function readIfExists(file) {
|
|
1220
|
+
try {
|
|
1221
|
+
return await readFile(file, "utf8");
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
if (error.code === "ENOENT") return null;
|
|
1224
|
+
throw error;
|
|
1228
1225
|
}
|
|
1229
|
-
return map.projects.find((entry) => entry.remote == null && entry.path === project.path);
|
|
1230
1226
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
entry.remote = project.remote;
|
|
1238
|
-
return true;
|
|
1227
|
+
function parseLink(text, file) {
|
|
1228
|
+
let project;
|
|
1229
|
+
try {
|
|
1230
|
+
project = JSON.parse(text).project;
|
|
1231
|
+
} catch {
|
|
1232
|
+
project = void 0;
|
|
1239
1233
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
remote: project.remote,
|
|
1255
|
-
path: project.path
|
|
1256
|
-
};
|
|
1257
|
-
map.projects.push(entry);
|
|
1258
|
-
return {
|
|
1259
|
-
entry,
|
|
1260
|
-
changed: true
|
|
1261
|
-
};
|
|
1262
|
-
}
|
|
1263
|
-
/** 登记或修改当前代码项目对应的项目名;只改对应关系,不移动素材文件夹。 */
|
|
1264
|
-
function assignProject(map, project, name) {
|
|
1265
|
-
const validName = validateName(name, "项目名");
|
|
1266
|
-
if (validName === "projects.json") throw new CliError(`项目名不能是 ${PROJECT_MAP_FILE}`);
|
|
1267
|
-
const existing = findEntry(map, project);
|
|
1268
|
-
if (existing) {
|
|
1269
|
-
refreshEntry(existing, project);
|
|
1270
|
-
existing.name = validName;
|
|
1271
|
-
return existing;
|
|
1234
|
+
if (typeof project !== "string") throw new CliError(`关联文件不合法:${file}`);
|
|
1235
|
+
return project;
|
|
1236
|
+
}
|
|
1237
|
+
/** 从 cwd 逐级向上找最近的关联;都没有时返回 null。 */
|
|
1238
|
+
async function findLink(cwd) {
|
|
1239
|
+
for (let dir = path.resolve(cwd);; dir = path.dirname(dir)) {
|
|
1240
|
+
const linkDir = path.join(dir, LINK_DIR);
|
|
1241
|
+
const file = path.join(linkDir, LINK_FILE);
|
|
1242
|
+
const text = await readIfExists(file);
|
|
1243
|
+
if (text != null) return {
|
|
1244
|
+
project: parseLink(text, file),
|
|
1245
|
+
dir: linkDir
|
|
1246
|
+
};
|
|
1247
|
+
if (path.dirname(dir) === dir) return null;
|
|
1272
1248
|
}
|
|
1273
|
-
const entry = {
|
|
1274
|
-
name: validName,
|
|
1275
|
-
remote: project.remote,
|
|
1276
|
-
path: project.path
|
|
1277
|
-
};
|
|
1278
|
-
map.projects.push(entry);
|
|
1279
|
-
return entry;
|
|
1280
1249
|
}
|
|
1281
|
-
/**
|
|
1282
|
-
async function
|
|
1283
|
-
const
|
|
1284
|
-
await mkdir(
|
|
1250
|
+
/** 在 cwd 写入关联,返回点目录。 */
|
|
1251
|
+
async function writeLink(cwd, project) {
|
|
1252
|
+
const linkDir = path.join(path.resolve(cwd), LINK_DIR);
|
|
1253
|
+
await mkdir(linkDir, { recursive: true });
|
|
1254
|
+
await writeFileAtomic(path.join(linkDir, ".gitignore"), GITIGNORE);
|
|
1255
|
+
await writeFileAtomic(path.join(linkDir, LINK_FILE), `${JSON.stringify({ project }, null, 2)}\n`);
|
|
1256
|
+
return linkDir;
|
|
1257
|
+
}
|
|
1258
|
+
// src/library/context.ts
|
|
1259
|
+
/** 命令的运行上下文:库根目录 + 本次使用的项目 + 素材定位。 */
|
|
1260
|
+
/** 内置项目:一直存在,放不属于任何代码项目的素材。 */
|
|
1261
|
+
const DEFAULT_PROJECT = "default";
|
|
1262
|
+
async function isDirectory(dir) {
|
|
1285
1263
|
try {
|
|
1286
|
-
await
|
|
1264
|
+
return (await stat(dir)).isDirectory();
|
|
1287
1265
|
} catch (error) {
|
|
1288
|
-
if (error.code
|
|
1266
|
+
if (error.code === "ENOENT") return false;
|
|
1267
|
+
throw error;
|
|
1289
1268
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
maxTimeout: 200
|
|
1295
|
-
},
|
|
1296
|
-
stale: 3e4
|
|
1297
|
-
});
|
|
1269
|
+
}
|
|
1270
|
+
/** 库里的全部项目(库根目录下的文件夹),default 总在最前。 */
|
|
1271
|
+
async function listProjects(root) {
|
|
1272
|
+
let names = [];
|
|
1298
1273
|
try {
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
if (
|
|
1302
|
-
return result;
|
|
1303
|
-
} finally {
|
|
1304
|
-
await release();
|
|
1274
|
+
names = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
if (error.code !== "ENOENT") throw error;
|
|
1305
1277
|
}
|
|
1278
|
+
return [DEFAULT_PROJECT, ...names.filter((name) => name !== DEFAULT_PROJECT).sort()];
|
|
1306
1279
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
});
|
|
1280
|
+
async function projectsHint(root) {
|
|
1281
|
+
return `;已有项目:${(await listProjects(root)).join("、")}`;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* 项目必须已存在;default 除外,首次放入素材时自动建好。missing 是项目不存在时的
|
|
1285
|
+
* 提示,默认提示新建。
|
|
1286
|
+
*/
|
|
1287
|
+
async function openProject(root, name, missing) {
|
|
1288
|
+
const validName = validateName(name, "项目名");
|
|
1289
|
+
const dir = path.join(root, validName);
|
|
1290
|
+
if (validName !== "default" && !await isDirectory(dir)) throw new CliError(`${missing ?? `项目不存在:${validName},运行 genlab project create ${validName} 新建`}${await projectsHint(root)}`);
|
|
1319
1291
|
return {
|
|
1320
1292
|
root,
|
|
1321
|
-
name,
|
|
1322
|
-
dir
|
|
1323
|
-
codeProject
|
|
1293
|
+
name: validName,
|
|
1294
|
+
dir
|
|
1324
1295
|
};
|
|
1325
1296
|
}
|
|
1297
|
+
/** 本次使用的项目:指定的项目优先,否则取 cwd 的关联。 */
|
|
1298
|
+
async function resolveProject(root, cwd, override) {
|
|
1299
|
+
if (override != null) return openProject(root, override);
|
|
1300
|
+
const link = await findLink(cwd);
|
|
1301
|
+
if (link == null) throw new CliError(`当前目录未关联项目:运行 genlab project link <项目> 关联,不属于任何项目时用 ${DEFAULT_PROJECT}${await projectsHint(root)}`);
|
|
1302
|
+
return openProject(root, link.project, `当前目录关联的项目不存在:${link.project},可能已改名或删除;问用户要关联到哪个项目,再运行 genlab project link <项目>`);
|
|
1303
|
+
}
|
|
1304
|
+
function currentProject(override) {
|
|
1305
|
+
return resolveProject(libraryRoot(), process.cwd(), override);
|
|
1306
|
+
}
|
|
1326
1307
|
function locateAsset(project, assetPath) {
|
|
1327
1308
|
const segments = parseAssetPath(assetPath);
|
|
1328
1309
|
return {
|
|
@@ -1336,10 +1317,11 @@ function locateAsset(project, assetPath) {
|
|
|
1336
1317
|
function compact(object) {
|
|
1337
1318
|
return Object.fromEntries(Object.entries(object).filter(([, value]) => value != null));
|
|
1338
1319
|
}
|
|
1339
|
-
function presentVersion(dir, version) {
|
|
1320
|
+
function presentVersion(dir, record, version) {
|
|
1340
1321
|
return compact({
|
|
1341
1322
|
version: version.number,
|
|
1342
1323
|
state: version.state,
|
|
1324
|
+
model: findGeneration(record, version.generation)?.model,
|
|
1343
1325
|
file: version.file ? path.join(dir, version.file) : null,
|
|
1344
1326
|
mimeType: version.mimeType,
|
|
1345
1327
|
width: version.width,
|
|
@@ -1375,7 +1357,7 @@ function presentAsset(project, location, record, versions) {
|
|
|
1375
1357
|
kind: record.kind,
|
|
1376
1358
|
current: record.currentVersion,
|
|
1377
1359
|
pending: listed.some((version) => version.state === "generating"),
|
|
1378
|
-
versions: listed.map((version) => presentVersion(location.dir, version))
|
|
1360
|
+
versions: listed.map((version) => presentVersion(location.dir, record, version))
|
|
1379
1361
|
};
|
|
1380
1362
|
}
|
|
1381
1363
|
function presentGenerations(dir, record) {
|
|
@@ -1412,33 +1394,58 @@ function action(run) {
|
|
|
1412
1394
|
}
|
|
1413
1395
|
};
|
|
1414
1396
|
}
|
|
1397
|
+
// src/commands/sync.ts
|
|
1398
|
+
/** 后台收取:启动后台收取进程,以及它运行的隐藏命令 sync(见 ADR-0007)。 */
|
|
1399
|
+
/**
|
|
1400
|
+
* 启动后台收取进程,不等待它结束;已有收取者时它会立即退出。沿用当前进程的 node 与
|
|
1401
|
+
* 启动参数,开发时经 tsx 运行同样适用。
|
|
1402
|
+
*/
|
|
1403
|
+
function startBackgroundSync() {
|
|
1404
|
+
spawn(process.execPath, [
|
|
1405
|
+
...process.execArgv,
|
|
1406
|
+
process.argv[1],
|
|
1407
|
+
"sync"
|
|
1408
|
+
], {
|
|
1409
|
+
detached: true,
|
|
1410
|
+
stdio: "ignore",
|
|
1411
|
+
windowsHide: true
|
|
1412
|
+
}).on("error", (error) => warn(`后台收取未能启动:${error.message}`)).unref();
|
|
1413
|
+
}
|
|
1414
|
+
/** 隐藏命令 sync:作为全库唯一的收取者,收完或超过时限后退出。 */
|
|
1415
|
+
async function syncCommand() {
|
|
1416
|
+
await collectLibrary(libraryRoot());
|
|
1417
|
+
}
|
|
1415
1418
|
// src/commands/generation.ts
|
|
1416
1419
|
/** 发起生成与等待结果的命令:create / generate / edit / retry / wait。 */
|
|
1417
1420
|
function toGenerateOptions(args) {
|
|
1418
1421
|
return {
|
|
1419
1422
|
model: args.model,
|
|
1420
|
-
params: args.param,
|
|
1423
|
+
params: args.param ?? [],
|
|
1421
1424
|
count: args.count
|
|
1422
1425
|
};
|
|
1423
1426
|
}
|
|
1424
|
-
/**
|
|
1425
|
-
|
|
1427
|
+
/**
|
|
1428
|
+
* 部分下单失败的原因作为警告输出;按需等待新版本完成(先启动后台收取,等待只读记录),
|
|
1429
|
+
* 再输出这些版本的状态。
|
|
1430
|
+
*/
|
|
1431
|
+
async function finish(project, location, { versions, errors }, options) {
|
|
1432
|
+
errors.forEach((error) => warn(`部分任务下单失败:${error}`));
|
|
1426
1433
|
if (options.wait) {
|
|
1427
|
-
|
|
1434
|
+
startBackgroundSync();
|
|
1435
|
+
await waitForVersions([{
|
|
1428
1436
|
dir: location.dir,
|
|
1429
1437
|
versions
|
|
1430
1438
|
}], options.timeout * 1e3);
|
|
1431
|
-
warnings.forEach(warn);
|
|
1432
1439
|
}
|
|
1433
1440
|
return presentAsset(project, location, await readAsset(location.dir), versions);
|
|
1434
1441
|
}
|
|
1435
1442
|
async function createCommand(assetPath, args) {
|
|
1436
|
-
const project = await currentProject();
|
|
1443
|
+
const project = await currentProject(args.project);
|
|
1437
1444
|
const location = locateAsset(project, assetPath);
|
|
1438
1445
|
return finish(project, location, await createAsset(project, location, toGenerateOptions(args)), args);
|
|
1439
1446
|
}
|
|
1440
1447
|
async function generateCommand(assetPath, args) {
|
|
1441
|
-
const project = await currentProject();
|
|
1448
|
+
const project = await currentProject(args.project);
|
|
1442
1449
|
const location = locateAsset(project, assetPath);
|
|
1443
1450
|
return finish(project, location, await generateInAsset(project, location, {
|
|
1444
1451
|
...toGenerateOptions(args),
|
|
@@ -1446,18 +1453,18 @@ async function generateCommand(assetPath, args) {
|
|
|
1446
1453
|
}), args);
|
|
1447
1454
|
}
|
|
1448
1455
|
async function editCommand(assetPath, version, args) {
|
|
1449
|
-
const project = await currentProject();
|
|
1456
|
+
const project = await currentProject(args.project);
|
|
1450
1457
|
const location = locateAsset(project, assetPath);
|
|
1451
1458
|
return finish(project, location, await editVersion(project, location, parseVersionArg(version), toGenerateOptions(args)), args);
|
|
1452
1459
|
}
|
|
1453
1460
|
async function retryCommand(assetPath, version, args) {
|
|
1454
|
-
const project = await currentProject();
|
|
1461
|
+
const project = await currentProject(args.project);
|
|
1455
1462
|
const location = locateAsset(project, assetPath);
|
|
1456
1463
|
return finish(project, location, await retryVersion(location, parseVersionArg(version)), args);
|
|
1457
1464
|
}
|
|
1458
|
-
/**
|
|
1465
|
+
/** 等待某个素材、或当前项目里全部生成中的版本(先启动后台收取,等待只读记录)。 */
|
|
1459
1466
|
async function waitCommand(assetPath, args) {
|
|
1460
|
-
const project = await currentProject();
|
|
1467
|
+
const project = await currentProject(args.project);
|
|
1461
1468
|
const dirs = assetPath ? [locateAsset(project, assetPath).dir] : await findAssetDirs(project.dir);
|
|
1462
1469
|
const targets = [];
|
|
1463
1470
|
for (const dir of dirs) {
|
|
@@ -1472,19 +1479,23 @@ async function waitCommand(assetPath, args) {
|
|
|
1472
1479
|
versions: record.versions.map((v) => v.number)
|
|
1473
1480
|
});
|
|
1474
1481
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1482
|
+
startBackgroundSync();
|
|
1483
|
+
await waitForVersions(targets, args.timeout * 1e3);
|
|
1477
1484
|
return Promise.all(targets.map(async ({ dir, versions }) => presentAsset(project, {
|
|
1478
1485
|
path: path.relative(project.dir, dir).split(path.sep).join("/"),
|
|
1479
1486
|
dir
|
|
1480
1487
|
}, await readAsset(dir), versions)));
|
|
1481
1488
|
}
|
|
1482
|
-
// src/commands/
|
|
1483
|
-
/**
|
|
1484
|
-
/**
|
|
1485
|
-
async function
|
|
1486
|
-
|
|
1489
|
+
// src/commands/guide.ts
|
|
1490
|
+
/** genlab guide:输出给 agent 的完整用法。 */
|
|
1491
|
+
/** 手写的使用说明,后附由命令定义生成的命令参考,与已安装的版本一致。 */
|
|
1492
|
+
async function guideCommand(program, file) {
|
|
1493
|
+
const guide = await readFile(file, "utf8");
|
|
1494
|
+
const reference = program.createHelp().visibleCommands(program).filter((command) => program.commands.includes(command)).map((command) => command.helpInformation()).join("\n");
|
|
1495
|
+
process.stdout.write(`${guide}\n## 命令参考\n\n\`\`\`text\n${reference}\`\`\`\n`);
|
|
1487
1496
|
}
|
|
1497
|
+
// src/commands/library.ts
|
|
1498
|
+
/** 库与元信息命令:models / import / use / delete / show / list。 */
|
|
1488
1499
|
function parseKind(kind) {
|
|
1489
1500
|
if (kind != null && !MEDIA_KINDS.includes(kind)) throw new CliError(`类型可选值:${MEDIA_KINDS.join(", ")}`);
|
|
1490
1501
|
return kind;
|
|
@@ -1524,43 +1535,26 @@ async function modelsCommand(id, args) {
|
|
|
1524
1535
|
}
|
|
1525
1536
|
return models;
|
|
1526
1537
|
}
|
|
1527
|
-
async function
|
|
1528
|
-
|
|
1529
|
-
const root = libraryRoot();
|
|
1530
|
-
const codeProject = await identifyCodeProject(process.cwd());
|
|
1531
|
-
await updateProjectMap(root, (map) => ({
|
|
1532
|
-
result: assignProject(map, codeProject, name),
|
|
1533
|
-
changed: true
|
|
1534
|
-
}));
|
|
1535
|
-
}
|
|
1536
|
-
const project = await currentProject();
|
|
1537
|
-
return {
|
|
1538
|
-
project: project.name,
|
|
1539
|
-
dir: project.dir,
|
|
1540
|
-
remote: project.codeProject.remote,
|
|
1541
|
-
codeProject: project.codeProject.path
|
|
1542
|
-
};
|
|
1543
|
-
}
|
|
1544
|
-
async function importCommand(assetPath, file) {
|
|
1545
|
-
const project = await currentProject();
|
|
1538
|
+
async function importCommand(assetPath, file, args) {
|
|
1539
|
+
const project = await currentProject(args.project);
|
|
1546
1540
|
const location = locateAsset(project, assetPath);
|
|
1547
1541
|
await importAsset(project, location, file);
|
|
1548
1542
|
return presentAsset(project, location, await readAsset(location.dir));
|
|
1549
1543
|
}
|
|
1550
|
-
async function useCommand(assetPath, version) {
|
|
1551
|
-
const project = await currentProject();
|
|
1544
|
+
async function useCommand(assetPath, version, args) {
|
|
1545
|
+
const project = await currentProject(args.project);
|
|
1552
1546
|
const location = locateAsset(project, assetPath);
|
|
1553
1547
|
await setCurrentVersion(location, parseVersionArg(version));
|
|
1554
1548
|
return presentAsset(project, location, await readAsset(location.dir));
|
|
1555
1549
|
}
|
|
1556
|
-
async function deleteCommand(assetPath, version) {
|
|
1557
|
-
const project = await currentProject();
|
|
1550
|
+
async function deleteCommand(assetPath, version, args) {
|
|
1551
|
+
const project = await currentProject(args.project);
|
|
1558
1552
|
const location = locateAsset(project, assetPath);
|
|
1559
1553
|
await deleteVersion(location, parseVersionArg(version));
|
|
1560
1554
|
return presentAsset(project, location, await readAsset(location.dir));
|
|
1561
1555
|
}
|
|
1562
|
-
async function showCommand(assetPath) {
|
|
1563
|
-
const project = await currentProject();
|
|
1556
|
+
async function showCommand(assetPath, args) {
|
|
1557
|
+
const project = await currentProject(args.project);
|
|
1564
1558
|
const location = locateAsset(project, assetPath);
|
|
1565
1559
|
const record = await readAsset(location.dir);
|
|
1566
1560
|
return {
|
|
@@ -1571,7 +1565,7 @@ async function showCommand(assetPath) {
|
|
|
1571
1565
|
}
|
|
1572
1566
|
async function listCommand(folder, args) {
|
|
1573
1567
|
const kind = parseKind(args.kind);
|
|
1574
|
-
const project = await currentProject();
|
|
1568
|
+
const project = await currentProject(args.project);
|
|
1575
1569
|
const base = folder ? path.join(project.dir, ...parseAssetPath(folder)) : project.dir;
|
|
1576
1570
|
const assets = [];
|
|
1577
1571
|
for (const dir of await findAssetDirs(base)) {
|
|
@@ -1584,7 +1578,7 @@ async function listCommand(folder, args) {
|
|
|
1584
1578
|
versions: record.versions.length,
|
|
1585
1579
|
generating: record.versions.filter((v) => v.state === "generating").length,
|
|
1586
1580
|
failed: record.versions.filter((v) => v.state === "failed").length,
|
|
1587
|
-
current: current ? presentVersion(dir, current) : null
|
|
1581
|
+
current: current ? presentVersion(dir, record, current) : null
|
|
1588
1582
|
});
|
|
1589
1583
|
}
|
|
1590
1584
|
return {
|
|
@@ -1593,8 +1587,67 @@ async function listCommand(folder, args) {
|
|
|
1593
1587
|
assets
|
|
1594
1588
|
};
|
|
1595
1589
|
}
|
|
1590
|
+
// src/commands/skill.ts
|
|
1591
|
+
/** genlab skill:经 skills(npx skills)全局安装或卸载本机 agent 的 genlab skill。 */
|
|
1592
|
+
/** 固定 skills 的版本,安装行为不随它的新版本变化。 */
|
|
1593
|
+
const SKILLS_CLI = "skills@1.7.6";
|
|
1594
|
+
const SKILL_SOURCE = "WBBB0730/genlab";
|
|
1595
|
+
const SKILL_NAME = "genlab";
|
|
1596
|
+
function isInteractive() {
|
|
1597
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* 终端里由 skills 询问装到 / 卸载哪些 agent;非终端时加 --yes,作用于检测到的全部
|
|
1601
|
+
* agent。skills 的输出写 stderr,stdout 只留给命令结果。
|
|
1602
|
+
*/
|
|
1603
|
+
async function runSkills(args, interactive) {
|
|
1604
|
+
await spawn$1("npx", [
|
|
1605
|
+
"-y",
|
|
1606
|
+
SKILLS_CLI,
|
|
1607
|
+
...args,
|
|
1608
|
+
"--global",
|
|
1609
|
+
...interactive ? [] : ["--yes"]
|
|
1610
|
+
], {
|
|
1611
|
+
stdin: "inherit",
|
|
1612
|
+
stdout: process.stderr,
|
|
1613
|
+
stderr: "inherit"
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
async function installSkill(interactive) {
|
|
1617
|
+
try {
|
|
1618
|
+
await runSkills([
|
|
1619
|
+
"add",
|
|
1620
|
+
SKILL_SOURCE,
|
|
1621
|
+
"--skill",
|
|
1622
|
+
SKILL_NAME
|
|
1623
|
+
], interactive);
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
throw new CliError(`安装 skill 失败:可手动运行 npx skills add ${SKILL_SOURCE} -g -s ${SKILL_NAME}`, { cause: error });
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
async function skillAddCommand() {
|
|
1629
|
+
await installSkill(isInteractive());
|
|
1630
|
+
return { installed: SKILL_NAME };
|
|
1631
|
+
}
|
|
1632
|
+
/** 来源有变化时才更新,沿用安装时选的 agent。 */
|
|
1633
|
+
async function skillUpdateCommand() {
|
|
1634
|
+
try {
|
|
1635
|
+
await runSkills(["update", SKILL_NAME], isInteractive());
|
|
1636
|
+
} catch (error) {
|
|
1637
|
+
throw new CliError(`更新 skill 失败:可手动运行 npx skills update ${SKILL_NAME} -g`, { cause: error });
|
|
1638
|
+
}
|
|
1639
|
+
return { updated: SKILL_NAME };
|
|
1640
|
+
}
|
|
1641
|
+
async function skillRemoveCommand() {
|
|
1642
|
+
try {
|
|
1643
|
+
await runSkills(["remove", SKILL_NAME], isInteractive());
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
throw new CliError(`卸载 skill 失败:可手动运行 npx skills remove ${SKILL_NAME} -g`, { cause: error });
|
|
1646
|
+
}
|
|
1647
|
+
return { removed: SKILL_NAME };
|
|
1648
|
+
}
|
|
1596
1649
|
// src/commands/init.ts
|
|
1597
|
-
/** genlab init:写入配置文件(API Key 与库目录),先验证 Key
|
|
1650
|
+
/** genlab init:写入配置文件(API Key 与库目录),先验证 Key 可用;可顺带安装 skill。 */
|
|
1598
1651
|
const DEFAULT_LIBRARY = "~/GenLab";
|
|
1599
1652
|
async function readConfig(file) {
|
|
1600
1653
|
try {
|
|
@@ -1617,7 +1670,8 @@ function expandHome(dir) {
|
|
|
1617
1670
|
}
|
|
1618
1671
|
/**
|
|
1619
1672
|
* 未通过选项给出的值:终端里交互询问(直接回车沿用已有配置),非终端时沿用已有配置,
|
|
1620
|
-
*
|
|
1673
|
+
* 都没有就报错。未指定 --skill / --no-skill 时终端里询问是否安装 skill,非终端不安装;
|
|
1674
|
+
* 安装失败只警告,不影响已写入的配置。
|
|
1621
1675
|
*/
|
|
1622
1676
|
async function initCommand(args) {
|
|
1623
1677
|
const file = configFile();
|
|
@@ -1625,6 +1679,7 @@ async function initCommand(args) {
|
|
|
1625
1679
|
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1626
1680
|
let key = args.key ?? existing.KIE_API_KEY;
|
|
1627
1681
|
let library = args.library ?? existing.GENLAB_LIBRARY;
|
|
1682
|
+
let skill = args.skill;
|
|
1628
1683
|
if (interactive && !args.key) key = answered(await p.password({
|
|
1629
1684
|
message: existing.KIE_API_KEY ? "kie.ai API Key(回车沿用已有)" : "kie.ai API Key(https://kie.ai/api-key)",
|
|
1630
1685
|
validate: (value) => value || existing.KIE_API_KEY ? void 0 : "不能为空"
|
|
@@ -1634,6 +1689,7 @@ async function initCommand(args) {
|
|
|
1634
1689
|
initialValue: existing.GENLAB_LIBRARY ?? DEFAULT_LIBRARY,
|
|
1635
1690
|
validate: (value) => value ? void 0 : "不能为空"
|
|
1636
1691
|
}));
|
|
1692
|
+
if (interactive && skill === void 0) skill = answered(await p.confirm({ message: "安装 skill,让 agent 需要生成媒体时自动想到 genlab?" }));
|
|
1637
1693
|
if (!key) throw new CliError("缺少 --key");
|
|
1638
1694
|
library ||= DEFAULT_LIBRARY;
|
|
1639
1695
|
if (!path.isAbsolute(expandHome(library))) throw new CliError(`库目录必须是绝对路径:${library}`);
|
|
@@ -1641,49 +1697,116 @@ async function initCommand(args) {
|
|
|
1641
1697
|
await mkdir(expandHome(library), { recursive: true });
|
|
1642
1698
|
await mkdir(path.dirname(file), { recursive: true });
|
|
1643
1699
|
await writeFileAtomic(file, `KIE_API_KEY=${JSON.stringify(key)}\nGENLAB_LIBRARY=${JSON.stringify(library)}\n`, { mode: 384 });
|
|
1700
|
+
let installed = false;
|
|
1701
|
+
if (skill) try {
|
|
1702
|
+
await installSkill(interactive);
|
|
1703
|
+
installed = true;
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
warn(error.message);
|
|
1706
|
+
}
|
|
1644
1707
|
return {
|
|
1645
1708
|
config: file,
|
|
1646
1709
|
library: expandHome(library),
|
|
1647
|
-
credits
|
|
1710
|
+
credits,
|
|
1711
|
+
skill: installed ? "genlab" : void 0
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
// src/commands/project.ts
|
|
1715
|
+
/** 项目命令:查看当前目录的关联、新建项目、关联当前目录(见 ADR-0006)。 */
|
|
1716
|
+
async function projectCommand() {
|
|
1717
|
+
const root = libraryRoot();
|
|
1718
|
+
const link = await findLink(process.cwd());
|
|
1719
|
+
return {
|
|
1720
|
+
project: link?.project,
|
|
1721
|
+
dir: link ? path.join(root, link.project) : void 0,
|
|
1722
|
+
link: link?.dir,
|
|
1723
|
+
projects: await listProjects(root)
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
async function projectCreateCommand(name) {
|
|
1727
|
+
const root = libraryRoot();
|
|
1728
|
+
const validName = validateName(name, "项目名");
|
|
1729
|
+
if (validName === "default") throw new CliError(`${DEFAULT_PROJECT} 是内置项目,无需新建`);
|
|
1730
|
+
const dir = path.join(root, validName);
|
|
1731
|
+
await mkdir(root, { recursive: true });
|
|
1732
|
+
try {
|
|
1733
|
+
await mkdir(dir);
|
|
1734
|
+
} catch (error) {
|
|
1735
|
+
if (error.code === "EEXIST") throw new CliError(`项目已存在:${validName}`);
|
|
1736
|
+
throw error;
|
|
1737
|
+
}
|
|
1738
|
+
return {
|
|
1739
|
+
project: validName,
|
|
1740
|
+
dir
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
/** 在当前目录写入关联:指向已有项目或 default。 */
|
|
1744
|
+
async function projectLinkCommand(name) {
|
|
1745
|
+
const project = await openProject(libraryRoot(), name);
|
|
1746
|
+
const link = await writeLink(process.cwd(), project.name);
|
|
1747
|
+
return {
|
|
1748
|
+
project: project.name,
|
|
1749
|
+
dir: project.dir,
|
|
1750
|
+
link
|
|
1648
1751
|
};
|
|
1649
1752
|
}
|
|
1650
1753
|
// src/cli.ts
|
|
1651
|
-
/**
|
|
1754
|
+
/**
|
|
1755
|
+
* 单次命令默认最多等待的秒数:Claude Code 默认在前台等一条命令 2 分钟,超过转入后台,
|
|
1756
|
+
* 留在这之内 agent 能直接拿到结果。
|
|
1757
|
+
*/
|
|
1652
1758
|
const DEFAULT_TIMEOUT_SECONDS = 90;
|
|
1759
|
+
/** 使用说明与入口同目录:打包时由 tsdown 复制到 dist。 */
|
|
1760
|
+
const GUIDE_FILE = new URL("./guide.md", import.meta.url);
|
|
1653
1761
|
function positiveInteger(value) {
|
|
1654
1762
|
const parsed = Number(value);
|
|
1655
1763
|
if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("应为正整数");
|
|
1656
1764
|
return parsed;
|
|
1657
1765
|
}
|
|
1658
|
-
|
|
1766
|
+
/** 可重复的选项:不设默认值,帮助里不显示 (default: [])。 */
|
|
1767
|
+
function collect(value, previous = []) {
|
|
1659
1768
|
return [...previous, value];
|
|
1660
1769
|
}
|
|
1661
|
-
/**
|
|
1662
|
-
function
|
|
1770
|
+
/** 执行命令,结束后在后台收取库里未完成的生成,不等待(见 ADR-0007)。 */
|
|
1771
|
+
function withBackgroundSync(run) {
|
|
1663
1772
|
return action(async (...args) => {
|
|
1664
|
-
|
|
1665
|
-
|
|
1773
|
+
try {
|
|
1774
|
+
return await run(...args);
|
|
1775
|
+
} finally {
|
|
1776
|
+
startBackgroundSync();
|
|
1777
|
+
}
|
|
1666
1778
|
});
|
|
1667
1779
|
}
|
|
1668
|
-
const program = new Command("genlab").description("
|
|
1669
|
-
function generationOptions(command,
|
|
1670
|
-
return command.option("-m, --model <id>",
|
|
1780
|
+
const program = new Command("genlab").description("用 kie.ai 的模型生成图片、视频、音频,产物与生成记录保存在本机的库里。完整用法:genlab guide").version(version, "-V, --version", "显示版本号").helpOption("-h, --help", "显示帮助").helpCommand("help [command]", "显示命令的帮助").showHelpAfterError();
|
|
1781
|
+
function generationOptions(command, modelDescription) {
|
|
1782
|
+
return command.option("-m, --model <id>", modelDescription).option("-p, --param <key=value>", "模型参数,可重复;key:=JSON 传 JSON 值,@<路径> 上传本机文件", collect).option("-n, --count <n>", "并列任务数(1–10,默认 1)", positiveInteger);
|
|
1783
|
+
}
|
|
1784
|
+
function projectOption(command) {
|
|
1785
|
+
return command.option("--project <name>", "本次使用的项目,不改动当前目录的关联");
|
|
1671
1786
|
}
|
|
1672
1787
|
function waitOptions(command) {
|
|
1673
1788
|
return command.option("--no-wait", "提交后立即返回,不等待结果").option("--timeout <seconds>", "最多等待的秒数", positiveInteger, DEFAULT_TIMEOUT_SECONDS);
|
|
1674
1789
|
}
|
|
1675
|
-
program.command("
|
|
1676
|
-
program.command("
|
|
1677
|
-
program.command("
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
program.command("
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
program.command("
|
|
1686
|
-
program.command("
|
|
1687
|
-
program.command("
|
|
1790
|
+
program.command("guide").description("输出完整用法:概念、流程、参数写法、规则与命令参考").action(() => guideCommand(program, GUIDE_FILE));
|
|
1791
|
+
program.command("init").description("写入配置(kie.ai API Key 与库目录),可顺带安装 skill").option("--key <key>", "kie.ai API Key").option("--library <dir>", "库目录(绝对路径,可用 ~ 开头)").option("--skill", "安装 skill,不再询问").option("--no-skill", "不安装 skill").action(action(initCommand));
|
|
1792
|
+
const skill = program.command("skill").description("安装、更新或卸载 genlab skill,让 agent 能发现 genlab");
|
|
1793
|
+
skill.command("add", { isDefault: true }).description("安装 skill(默认)").action(action(skillAddCommand));
|
|
1794
|
+
skill.command("update").description("更新 skill,沿用安装时选的 agent").action(action(skillUpdateCommand));
|
|
1795
|
+
skill.command("remove").description("卸载 skill").action(action(skillRemoveCommand));
|
|
1796
|
+
program.command("models").description("列出 kie 模型;指定模型时输出价格与参数表").argument("[id]", "模型标识").option("--kind <kind>", "按类型筛选:image / video / audio").option("--search <text>", "按模型标识、供应商、名称筛选").option("--refresh", "忽略缓存,重新获取").action(action(modelsCommand));
|
|
1797
|
+
const project = program.command("project").description("查看当前目录关联的项目与库里的全部项目").action(action(projectCommand));
|
|
1798
|
+
project.command("create").description("新建项目").argument("<name>", "项目名").action(action(projectCreateCommand));
|
|
1799
|
+
project.command("link").description("把当前目录关联到项目;不属于任何代码项目时用 default").argument("<name>", "项目名").action(action(projectLinkCommand));
|
|
1800
|
+
projectOption(waitOptions(generationOptions(program.command("create").description("新建素材并发起第一次生成").argument("<asset>", "素材路径:[文件夹/]素材名,如 characters/hero"), "模型标识(必填,见 genlab models)"))).action(withBackgroundSync(createCommand));
|
|
1801
|
+
projectOption(waitOptions(generationOptions(program.command("generate").description("在已有素材里发起生成").argument("<asset>", "素材路径").option("--from <version>", "沿用某个版本的生成输入(重新生成)"), "模型标识;用 --from 时默认沿用原模型"))).action(withBackgroundSync(generateCommand));
|
|
1802
|
+
projectOption(waitOptions(generationOptions(program.command("edit").description("AI 编辑:以某个就绪版本为源版本发起生成,参数里用 @source 引用它").argument("<asset>", "素材路径").argument("<version>", "源版本号"), "模型标识,默认沿用源版本的模型"))).action(withBackgroundSync(editCommand));
|
|
1803
|
+
projectOption(waitOptions(program.command("retry").description("原地重试失败的版本").argument("<asset>", "素材路径").argument("<version>", "版本号,如 3 或 v3"))).action(withBackgroundSync(retryCommand));
|
|
1804
|
+
projectOption(program.command("wait").description("等待生成中的版本;不指定素材时等待当前项目的全部").argument("[asset]", "素材路径").option("--timeout <seconds>", "最多等待的秒数", positiveInteger, DEFAULT_TIMEOUT_SECONDS)).action(withBackgroundSync(waitCommand));
|
|
1805
|
+
projectOption(program.command("import").description("把外部文件导入为新素材的第一个版本").argument("<asset>", "素材路径").argument("<file>", "要导入的文件")).action(withBackgroundSync(importCommand));
|
|
1806
|
+
projectOption(program.command("use").description("把就绪的版本设为当前版本").argument("<asset>", "素材路径").argument("<version>", "版本号,如 3 或 v3")).action(withBackgroundSync(useCommand));
|
|
1807
|
+
projectOption(program.command("delete").description("删除没有子版本的版本").argument("<asset>", "素材路径").argument("<version>", "版本号,如 3 或 v3")).action(withBackgroundSync(deleteCommand));
|
|
1808
|
+
projectOption(program.command("show").description("查看素材的全部版本、生成输入与文件路径").argument("<asset>", "素材路径")).action(withBackgroundSync(showCommand));
|
|
1809
|
+
projectOption(program.command("list").description("列出当前项目的素材").argument("[folder]", "只列这个文件夹下的素材").option("--kind <kind>", "按类型筛选:image / video / audio")).action(withBackgroundSync(listCommand));
|
|
1810
|
+
program.command("sync", { hidden: true }).description("后台收取库里未完成的生成(由其他命令自动启动)").action(syncCommand);
|
|
1688
1811
|
await program.parseAsync();
|
|
1689
1812
|
export {};
|