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/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, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
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 { execFile } from "node:child_process";
18
- import { parseEnv, promisify } from "node:util";
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.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}:在 ${configFile()} 中设置`);
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) throw new CliError(`下载产物失败:HTTP ${response.status}`);
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 CliError(`无法识别的媒体类型:${mimeType || "未知"}`);
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 !== 1) throw new CliError(`无法识别的素材记录版本:${assetFile(assetDir)}`);
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
- function nextVersionNumber(versions) {
497
- return Math.max(0, ...versions.map((version) => version.number)) + 1;
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: 1,
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
- * 发起生成:先按模型的 schema 完成校验,再落记录(版本壳为生成中),最后上传文件、
694
- * 向 kie 下单并回填任务编号。下单失败的版本直接标失败,可重试。
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
- created = await writeNewAsset(project, location, plan);
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
- failReason: null,
760
- taskId: null,
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
- await dispatch(location.dir, generation, [number], deps);
768
- return [number];
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(`count 范围是 1–${MAX_TASKS}`);
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 { generation, versions } = await updateAsset(location.dir, async (latest) => {
809
- const id = nextGenerationId(latest.generations);
810
- return appendGeneration(latest, id, plan, await copyReferences(location.dir, id, references));
811
- });
812
- await dispatch(location.dir, generation, versions, deps);
813
- return versions;
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
- const id = nextGenerationId(record.generations);
828
- const appended = appendGeneration(record, id, plan, await copyReferences(location.dir, id, references));
829
- record.currentVersion = appended.versions[0];
830
- await writeFile(assetFile(location.dir), `${JSON.stringify(record, null, 2)}\n`, { flag: "wx" });
831
- return appended;
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
- /** 追加一次生成与它的版本壳(每个任务一个,就地修改 record);返回新版本号。 */
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 generation = {
885
- id,
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: null,
905
- kieRequest: null,
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
- versions.push(number);
920
- }
921
- record.generations.push(generation);
922
- return {
923
- generation,
924
- versions
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 dispatch(dir, generation, versions, deps) {
932
- let task;
933
- try {
934
- const record = await readAsset(dir);
935
- const sourceFile = generation.sourceVersion != null ? findVersion(record, generation.sourceVersion).file : null;
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(versions.map(async (number) => {
948
- let taskId;
949
- try {
950
- taskId = await deps.createTask(task);
951
- } catch (error) {
952
- await failVersions(dir, [number], error.message, task);
953
- return;
954
- }
955
- await updateAsset(dir, (record) => {
956
- const version = record.versions.find((item) => item.number === number);
957
- if (version?.state === "generating") {
958
- version.taskId = taskId;
959
- version.kieRequest = task;
960
- version.updatedAt = now();
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, deps = defaultDeps) {
1100
+ async function waitForVersions(targets, timeoutMs) {
1047
1101
  const deadline = Date.now() + timeoutMs;
1048
- const warnings = /* @__PURE__ */ new Set();
1049
1102
  for (;;) {
1050
- for (const warning of await syncAssets(targets.map((target) => target.dir), deps)) warnings.add(warning);
1051
- if (await allSettled(targets)) return {
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 generating = record.versions.filter((version) => version.state === "generating");
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} 同步失败:${error.message}`);
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 = latest.versions.find((version) => version.taskId === taskId && version.resultIndex === index) ?? (lead ? appendResultVersion(latest, lead, index) : null);
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: nextVersionNumber(record.versions),
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
- const { bytes, contentType } = await deps.download(item.url);
1142
- const media = await inspectMedia(bytes, contentType);
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/identify.ts
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
- * 项目映射:代码项目 → 库里的项目(见 ADR-0004)。有远程仓库的按远程地址识别、
1203
- * 首次自动以仓库名登记;没有的按主仓库位置识别、首次必须指定名字;中途建了远程
1204
- * 仓库的,按位置找到原条目补上远程地址,名字不变。
1213
+ * 项目关联:代码项目里的 .genlab 点目录记下它对应库里的哪个项目(见 ADR-0006)。
1214
+ * 点目录自带只忽略自身的 .gitignore,不改动代码项目的任何忽略设置。
1205
1215
  */
1206
- /** 远程地址 → host/owner/repo(小写、去掉协议、用户名与 .git),用于判断是否同一仓库。 */
1207
- function normalizeRemote(url) {
1208
- return remotePath(url).toLowerCase();
1209
- }
1210
- /** 远程仓库名(保留大小写),如 https://github.com/owner/pixel-quest.git → pixel-quest。 */
1211
- function repoName(url) {
1212
- return remotePath(url).split("/").pop() ?? "";
1213
- }
1214
- function remotePath(url) {
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
- function refreshEntry(entry, project) {
1236
- if (project.remote && entry.remote == null) {
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
- return false;
1241
- }
1242
- /** 查表;有远程仓库且未登记时自动以仓库名登记。就地修改 map,返回条目与是否有变化。 */
1243
- function resolveProject(map, project) {
1244
- const existing = findEntry(map, project);
1245
- if (existing) return {
1246
- entry: existing,
1247
- changed: refreshEntry(existing, project)
1248
- };
1249
- if (!project.remote) throw new CliError(`当前代码项目未登记,运行 genlab project <名字> 登记:${project.path}`);
1250
- const name = validateName(repoName(project.remote), "项目名");
1251
- if (map.projects.some((entry) => entry.name === name)) throw new CliError(`项目名 ${name} 已被其他代码项目使用,运行 genlab project <名字> 指定`);
1252
- const entry = {
1253
- name,
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
- /** 互斥地读改写库根目录下的映射表;mutate 返回是否需要写回。 */
1282
- async function updateProjectMap(root, mutate) {
1283
- const file = path.join(root, PROJECT_MAP_FILE);
1284
- await mkdir(root, { recursive: true });
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 writeFile(file, `${JSON.stringify({ projects: [] }, null, 2)}\n`, { flag: "wx" });
1264
+ return (await stat(dir)).isDirectory();
1287
1265
  } catch (error) {
1288
- if (error.code !== "EEXIST") throw error;
1266
+ if (error.code === "ENOENT") return false;
1267
+ throw error;
1289
1268
  }
1290
- const release = await lockfile.lock(file, {
1291
- retries: {
1292
- retries: 50,
1293
- minTimeout: 20,
1294
- maxTimeout: 200
1295
- },
1296
- stale: 3e4
1297
- });
1269
+ }
1270
+ /** 库里的全部项目(库根目录下的文件夹),default 总在最前。 */
1271
+ async function listProjects(root) {
1272
+ let names = [];
1298
1273
  try {
1299
- const map = JSON.parse(await readFile(file, "utf8"));
1300
- const { result, changed } = mutate(map);
1301
- if (changed) await writeFileAtomic(file, `${JSON.stringify(map, null, 2)}\n`);
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
- // src/library/context.ts
1308
- /** 命令的运行上下文:库根目录 + 当前代码项目对应的项目 + 素材定位。 */
1309
- async function currentProject(cwd = process.cwd()) {
1310
- const root = libraryRoot();
1311
- const codeProject = await identifyCodeProject(cwd);
1312
- const name = await updateProjectMap(root, (map) => {
1313
- const { entry, changed } = resolveProject(map, codeProject);
1314
- return {
1315
- result: entry.name,
1316
- changed
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: path.join(root, name),
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
- async function finish(project, location, versions, options) {
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
- const { warnings } = await waitForVersions([{
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
- const { warnings } = await waitForVersions(targets, args.timeout * 1e3);
1476
- warnings.forEach(warn);
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/library.ts
1483
- /** 库与元信息命令:models / project / import / use / delete / show / list。 */
1484
- /** 收取库里所有已完成的生成(每条库命令执行前跑一遍,不等待)。 */
1485
- async function syncLibrary() {
1486
- (await syncAssets(await findAssetDirs(libraryRoot()))).forEach(warn);
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 projectCommand(name) {
1528
- if (name) {
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
- /** 单次命令默认最多等待的秒数:agent 执行命令通常 2 分钟超时。 */
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
- function collect(value, previous) {
1766
+ /** 可重复的选项:不设默认值,帮助里不显示 (default: [])。 */
1767
+ function collect(value, previous = []) {
1659
1768
  return [...previous, value];
1660
1769
  }
1661
- /** 先收取库里已完成的生成,再执行命令。 */
1662
- function librarySynced(run) {
1770
+ /** 执行命令,结束后在后台收取库里未完成的生成,不等待(见 ADR-0007)。 */
1771
+ function withBackgroundSync(run) {
1663
1772
  return action(async (...args) => {
1664
- await syncLibrary();
1665
- return run(...args);
1773
+ try {
1774
+ return await run(...args);
1775
+ } finally {
1776
+ startBackgroundSync();
1777
+ }
1666
1778
  });
1667
1779
  }
1668
- const program = new Command("genlab").description("AI 媒体生成(kie.ai),产物与生成记录保存在本机的库里").version(version).showHelpAfterError();
1669
- function generationOptions(command, requireModel) {
1670
- return command.option("-m, --model <id>", requireModel ? "模型(必填,见 genlab models)" : "模型,默认沿用").option("-p, --param <key=value>", "模型参数,可重复;key:=JSON 传 JSON 值,值写 @文件 上传本机文件", collect, []).option("-n, --count <n>", "并列发起几个任务", positiveInteger);
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("init").description("写入配置(kie.ai API Key 与库目录)").option("--key <key>", "kie.ai API Key").option("--library <dir>", "库目录").action(action(initCommand));
1676
- program.command("models").description("列出 kie 模型;指定模型时输出价格与参数表").argument("[id]", "模型标识").option("--kind <kind>", "按类型筛选:image / video / audio").option("--search <text>", "按模型标识、供应商、标题筛选").option("--refresh", "忽略缓存,重新获取").action(action(modelsCommand));
1677
- program.command("project").description("查看当前代码项目对应的项目;带名字时登记或修改").argument("[name]", "项目名").action(action(projectCommand));
1678
- waitOptions(generationOptions(program.command("create").description("新建素材并发起第一次生成").argument("<asset>", "素材路径,如 角色/主角立绘"), true)).action(librarySynced(createCommand));
1679
- waitOptions(generationOptions(program.command("generate").description("在已有素材里发起生成").argument("<asset>", "素材路径").option("--from <version>", "沿用某个版本的生成输入(重新生成)"), false)).action(librarySynced(generateCommand));
1680
- waitOptions(generationOptions(program.command("edit").description("AI 编辑:以某个就绪版本为源版本发起生成,参数里用 @source 引用它").argument("<asset>", "素材路径").argument("<version>", "源版本号"), false)).action(librarySynced(editCommand));
1681
- waitOptions(program.command("retry").description("原地重试失败的版本").argument("<asset>", "素材路径").argument("<version>", "版本号")).action(librarySynced(retryCommand));
1682
- program.command("wait").description("等待生成中的版本;不指定素材时等待当前项目的全部").argument("[asset]", "素材路径").option("--timeout <seconds>", "最多等待的秒数", positiveInteger, DEFAULT_TIMEOUT_SECONDS).action(action(waitCommand));
1683
- program.command("import").description("把外部文件导入为新素材的第一个版本").argument("<asset>", "素材路径").argument("<file>", "文件").action(librarySynced(importCommand));
1684
- program.command("use").description("设为当前版本").argument("<asset>", "素材路径").argument("<version>", "版本号").action(librarySynced(useCommand));
1685
- program.command("delete").description("删除版本(只能删没有派生版本的版本)").argument("<asset>", "素材路径").argument("<version>", "版本号").action(librarySynced(deleteCommand));
1686
- program.command("show").description("查看素材的全部版本、生成输入与文件路径").argument("<asset>", "素材路径").action(librarySynced(showCommand));
1687
- program.command("list").description("列出当前项目的素材").argument("[folder]", "只列某个文件夹").option("--kind <kind>", "按类型筛选:image / video / audio").action(librarySynced(listCommand));
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 {};