genlab 0.1.2 → 0.1.4
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 +45 -30
- package/dist/cli.mjs +438 -275
- package/dist/guide.md +49 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,60 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# GenLab
|
|
2
2
|
|
|
3
|
-
给本地 agent(Claude Code、Codex 等)用的 AI 媒体生成命令行工具。通过 [
|
|
3
|
+
给本地 agent(Claude Code、Codex 等)用的 AI 媒体生成命令行工具。通过 [Kie.ai](https://kie.ai) 生成图片、视频、音频(音乐、音效、语音),产物与完整生成记录保存在本机的库里。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 安装与配置
|
|
6
6
|
|
|
7
|
-
需要 Node.js 22.19
|
|
7
|
+
需要 Node.js 22.19 及以上。推荐用 npx 运行,每次都是最新版本;下文的 `genlab` 均可写作 `npx -y genlab@latest`。也可 `npm install -g genlab` 全局安装,需自行更新。
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
|
|
10
|
+
genlab init # 交互填写 API Key 与库目录
|
|
11
|
+
genlab init --key <KIE_API_KEY> --library ~/GenLab # 或直接给出
|
|
11
12
|
```
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
配置写入 `~/.config/genlab/.env`,同名环境变量优先。
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
```
|
|
16
|
+
## 给 agent 使用
|
|
17
|
+
|
|
18
|
+
agent 使用前先运行 `genlab guide`:它输出完整用法(概念、流程、参数写法、命令参考),内容总与当前版本一致。
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
可选安装 GenLab skill,`genlab init` 时会询问,也可单独管理:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
|
|
23
|
+
genlab skill # 安装
|
|
24
|
+
genlab skill update # 更新
|
|
25
|
+
genlab skill remove # 卸载
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
库目录在代码项目之外。agent 在沙箱里运行时,需要允许它读写库目录与 `~/.cache/genlab`,并访问网络。
|
|
29
|
+
|
|
30
|
+
## 示例
|
|
27
31
|
|
|
28
32
|
```bash
|
|
33
|
+
# 新建项目,把当前目录关联到它
|
|
34
|
+
genlab project create pixel-quest
|
|
35
|
+
genlab project link pixel-quest
|
|
36
|
+
|
|
37
|
+
# 找模型,查看价格与参数表
|
|
29
38
|
genlab models --kind image --search gpt-image
|
|
30
|
-
genlab models gpt-image-2-5-sunburst-text-to-image
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
genlab
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
genlab models gpt-image-2-5-sunburst-text-to-image
|
|
40
|
+
|
|
41
|
+
# 新建素材,一次出 4 张(v1–v4)
|
|
42
|
+
genlab create characters/hero -m gpt-image-2-5-sunburst-text-to-image -p prompt="像素风骑士立绘" -n 4
|
|
43
|
+
|
|
44
|
+
# 对 v2 做 AI 编辑(产出 v5),把 v3 设为当前版本
|
|
45
|
+
genlab edit characters/hero 2 -m gpt-image-2-5-sunburst-image-to-image -p prompt="盔甲改成蓝色" -p input_urls=@source
|
|
46
|
+
genlab use characters/hero 3
|
|
47
|
+
|
|
48
|
+
# 以 v3 为首帧生成视频
|
|
49
|
+
genlab create intro/shot -m wan/3-0-video -p prompt="骑士转身拔剑" -p first_frame_url=@<库目录>/pixel-quest/characters/hero/v3.png
|
|
36
50
|
```
|
|
37
51
|
|
|
38
|
-
参数用 `-p key=value`(`key:=JSON` 传 JSON
|
|
52
|
+
参数用 `-p key=value`(`key:=JSON` 传 JSON 值,同键重复即数组),参数名以 `genlab models <模型>` 的参数表为准;值写 `@<路径>` 上传本机文件,`@source` 引用 AI 编辑的源版本。
|
|
53
|
+
|
|
54
|
+
## 项目与库
|
|
39
55
|
|
|
40
|
-
|
|
56
|
+
代码项目用 `genlab project link <项目>` 关联到库里的项目,关联记在代码项目的 `.genlab/` 里(自带忽略文件,不会被 git 提交)。未关联时素材命令会报错;不属于任何代码项目的素材放进内置项目 `default`。
|
|
41
57
|
|
|
42
|
-
|
|
58
|
+
库就是普通文件夹,可以直接在文件管理器里浏览、改名、移动、删除:
|
|
43
59
|
|
|
44
60
|
```
|
|
45
|
-
|
|
46
|
-
projects.json 代码项目 → 项目的映射
|
|
61
|
+
<库目录>/
|
|
47
62
|
<项目>/<文件夹…>/<素材>/
|
|
48
63
|
asset.json 素材、生成、版本的完整记录
|
|
49
64
|
v1.png v2.png … 各版本产物
|
|
50
65
|
references/<生成编号>/ 参考输入副本
|
|
51
66
|
```
|
|
52
67
|
|
|
53
|
-
项目由当前代码项目自动确定:有远程仓库时取仓库名,没有时用 `genlab project <名字>` 登记一次。
|
|
54
|
-
|
|
55
68
|
## 模型
|
|
56
69
|
|
|
57
|
-
|
|
70
|
+
模型清单与参数取自 Kie.ai 的模型目录,本地缓存一天,`genlab models --refresh` 强制刷新。支持 Kie.ai 上的图片、视频、音频生成模型,不支持对话模型。
|
|
58
71
|
|
|
59
72
|
## 开发
|
|
60
73
|
|
|
@@ -64,16 +77,18 @@ pnpm dev <命令> # 直接运行源码
|
|
|
64
77
|
pnpm test
|
|
65
78
|
pnpm lint
|
|
66
79
|
pnpm typecheck
|
|
80
|
+
pnpm check # 检查格式
|
|
81
|
+
pnpm format # 格式化并自动修复
|
|
67
82
|
pnpm build
|
|
68
83
|
pnpm link --global # 本机全局使用开发版
|
|
69
84
|
```
|
|
70
85
|
|
|
71
86
|
## 发布
|
|
72
87
|
|
|
73
|
-
推送 `v*` tag 后由 GitHub Actions 发布到 npm,tag 含 `-beta` 时以 `beta` 标签发布。首次使用前需在 npmjs.com 为本包配置 Trusted Publishing,指向 `.github/workflows/publish.yml`。
|
|
74
|
-
|
|
75
88
|
```bash
|
|
76
89
|
pnpm release # bumpp:改版本号、提交、打 tag 并推送
|
|
77
90
|
```
|
|
78
91
|
|
|
79
|
-
|
|
92
|
+
推送 `v*` tag 后由 GitHub Actions 经 npm Trusted Publishing 发布,tag 含 `-beta` 时以 `beta` 标签发布。
|
|
93
|
+
|
|
94
|
+
设计文档:术语表 [CONTEXT.md](CONTEXT.md)、命令行约定 [DESIGN.md](DESIGN.md)、需求 [docs/prd](docs/prd)、架构决策 [docs/adr](docs/adr)。
|
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.4";
|
|
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,支持 ~ 开头,必须是绝对路径。 */
|
|
@@ -67,7 +68,7 @@ async function createKieTask(task) {
|
|
|
67
68
|
method: "POST",
|
|
68
69
|
body: JSON.stringify(task)
|
|
69
70
|
});
|
|
70
|
-
if (!body.data?.taskId) throw new CliError("
|
|
71
|
+
if (!body.data?.taskId) throw new CliError("Kie.ai 未返回 taskId");
|
|
71
72
|
return body.data.taskId;
|
|
72
73
|
}
|
|
73
74
|
async function listKieModels() {
|
|
@@ -84,7 +85,7 @@ async function getKieCredits(apiKey) {
|
|
|
84
85
|
/** 任务的原始记录(完整保存进素材记录,解析见 parseKieRecord)。 */
|
|
85
86
|
async function getKieRecord(taskId) {
|
|
86
87
|
const body = await kieFetch(`${API_BASE_URL}/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`);
|
|
87
|
-
if (!body.data) throw new CliError("
|
|
88
|
+
if (!body.data) throw new CliError("Kie.ai 未返回任务记录");
|
|
88
89
|
return body.data;
|
|
89
90
|
}
|
|
90
91
|
/**
|
|
@@ -99,13 +100,18 @@ async function uploadKieFile(filePath) {
|
|
|
99
100
|
method: "POST",
|
|
100
101
|
body: form
|
|
101
102
|
}, TRANSFER_TIMEOUT_MS);
|
|
102
|
-
if (!body.data?.downloadUrl) throw new CliError("
|
|
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")
|
|
@@ -139,9 +145,9 @@ async function kieFetch(url, init, timeoutMs = REQUEST_TIMEOUT_MS, apiKey = requ
|
|
|
139
145
|
try {
|
|
140
146
|
body = await response.json();
|
|
141
147
|
} catch {
|
|
142
|
-
throw new CliError(`
|
|
148
|
+
throw new CliError(`Kie.ai 请求失败:HTTP ${response.status}`);
|
|
143
149
|
}
|
|
144
|
-
if (!response.ok || body.code !== 200) throw new (body.code === 429 ? KieRateLimitError : CliError)(`
|
|
150
|
+
if (!response.ok || body.code !== 200) throw new (body.code === 429 ? KieRateLimitError : CliError)(`Kie.ai 请求失败(${body.code ?? response.status}):${body.msg || response.statusText}`);
|
|
145
151
|
return body;
|
|
146
152
|
}
|
|
147
153
|
// src/catalog/cache.ts
|
|
@@ -279,7 +285,7 @@ const TASK_PATH = "/api/v1/jobs/createTask";
|
|
|
279
285
|
async function getInputSchema(model, refresh = false) {
|
|
280
286
|
const doc = await cached(`schemas/${encodeURIComponent(model)}.json`, async () => {
|
|
281
287
|
const openapi = await getKieModelSchema(model);
|
|
282
|
-
if (!openapi) throw new CliError(`
|
|
288
|
+
if (!openapi) throw new CliError(`Kie.ai 尚未提供模型 ${model} 的参数定义`);
|
|
283
289
|
return openapi;
|
|
284
290
|
}, refresh);
|
|
285
291
|
const operation = (await dereference(structuredClone(doc), { resolve: { external: false } })).paths?.[TASK_PATH]?.post;
|
|
@@ -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);
|
|
@@ -675,10 +693,11 @@ async function createAsset(project, location, options, deps = defaultDeps$1) {
|
|
|
675
693
|
try {
|
|
676
694
|
const orders = await placeOrders(plan.model.model, plan.input, null, plan.tasks, deps);
|
|
677
695
|
const record = {
|
|
678
|
-
schema:
|
|
696
|
+
schema: 2,
|
|
679
697
|
id: randomUUID(),
|
|
680
698
|
kind: plan.model.kind,
|
|
681
699
|
currentVersion: 0,
|
|
700
|
+
lastVersion: 0,
|
|
682
701
|
createdAt: now(),
|
|
683
702
|
generations: [],
|
|
684
703
|
versions: []
|
|
@@ -760,7 +779,7 @@ async function retryVersion(location, number, deps = defaultDeps$1) {
|
|
|
760
779
|
async function planGeneration(options, sourceVersion, inherited = {}) {
|
|
761
780
|
if (!options.model) throw new CliError("缺少 --model");
|
|
762
781
|
const tasks = options.count ?? 1;
|
|
763
|
-
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}`);
|
|
764
783
|
const model = await findModel(options.model);
|
|
765
784
|
const input = {
|
|
766
785
|
...inherited,
|
|
@@ -856,9 +875,8 @@ async function copyReferences(dir, generationId, references) {
|
|
|
856
875
|
function appendGeneration(record, id, plan, references, orders) {
|
|
857
876
|
const timestamp = now();
|
|
858
877
|
const copies = new Map(references.map((reference) => [reference.source, reference.file]));
|
|
859
|
-
const base = nextVersionNumber(record.versions);
|
|
860
878
|
const versions = orders.taskIds.map((taskId, position) => {
|
|
861
|
-
const number =
|
|
879
|
+
const number = allocateVersion(record);
|
|
862
880
|
record.versions.push({
|
|
863
881
|
number,
|
|
864
882
|
state: "generating",
|
|
@@ -921,7 +939,7 @@ async function placeOrders(model, input, source, count, deps) {
|
|
|
921
939
|
}
|
|
922
940
|
// src/kie/record.ts
|
|
923
941
|
/**
|
|
924
|
-
* 把
|
|
942
|
+
* 把 Kie.ai 的五态任务记录归约成三态。结果在 resultJson 里:图片、视频、语音为
|
|
925
943
|
* `{ resultUrls: [...] }`;Suno(音乐、音效)为 `{ data: [{ audio_url, duration }, ...] }`。
|
|
926
944
|
*/
|
|
927
945
|
function parseKieRecord(record) {
|
|
@@ -1007,33 +1025,83 @@ function fulfillVersion(record, number, product, task) {
|
|
|
1007
1025
|
* 向 kie 求证并收取产物:按任务分组,一次 recordInfo 推进同一任务的全部版本。
|
|
1008
1026
|
* 产物先下载到素材文件夹里的临时文件,加锁确认版本仍在等待该任务后再就位,
|
|
1009
1027
|
* 因此多个进程同时收取也只会落一份(幂等)。
|
|
1028
|
+
*
|
|
1029
|
+
* 收取只在后台收取进程里进行,全库同一时间只有一个(见 ADR-0007):每条素材命令结束时、
|
|
1030
|
+
* 以及开始等待时启动它,收完或超过时限即退出;等待只读记录。
|
|
1010
1031
|
*/
|
|
1011
1032
|
const defaultDeps = {
|
|
1012
1033
|
getRecord: getKieRecord,
|
|
1013
1034
|
download: downloadFile
|
|
1014
1035
|
};
|
|
1015
1036
|
const POLL_INTERVAL_MS = 5e3;
|
|
1016
|
-
/**
|
|
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
|
+
/** 收取一批素材里所有生成中的版本;返回过程中遇到的问题(不中断其余任务)。 */
|
|
1017
1093
|
async function syncAssets(assetDirs, deps = defaultDeps) {
|
|
1018
1094
|
return (await Promise.all(assetDirs.map((dir) => syncAsset(dir, deps).catch((error) => [`${dir}:${error.message}`])))).flat();
|
|
1019
1095
|
}
|
|
1020
1096
|
/**
|
|
1021
|
-
*
|
|
1022
|
-
* targets 的 versions 为空表示该素材里所有生成中的版本。
|
|
1097
|
+
* 轮询记录,直到目标版本全部到达终态或超时;返回是否全部完成。只读记录,收取由后台
|
|
1098
|
+
* 收取进程负责。targets 的 versions 为空表示该素材里所有生成中的版本。
|
|
1023
1099
|
*/
|
|
1024
|
-
async function waitForVersions(targets, timeoutMs
|
|
1100
|
+
async function waitForVersions(targets, timeoutMs) {
|
|
1025
1101
|
const deadline = Date.now() + timeoutMs;
|
|
1026
|
-
const warnings = /* @__PURE__ */ new Set();
|
|
1027
1102
|
for (;;) {
|
|
1028
|
-
|
|
1029
|
-
if (
|
|
1030
|
-
done: true,
|
|
1031
|
-
warnings: [...warnings]
|
|
1032
|
-
};
|
|
1033
|
-
if (Date.now() + POLL_INTERVAL_MS > deadline) return {
|
|
1034
|
-
done: false,
|
|
1035
|
-
warnings: [...warnings]
|
|
1036
|
-
};
|
|
1103
|
+
if (await allSettled(targets)) return true;
|
|
1104
|
+
if (Date.now() + POLL_INTERVAL_MS > deadline) return false;
|
|
1037
1105
|
await setTimeout(POLL_INTERVAL_MS);
|
|
1038
1106
|
}
|
|
1039
1107
|
}
|
|
@@ -1048,7 +1116,7 @@ async function syncAsset(dir, deps) {
|
|
|
1048
1116
|
for (const taskId of taskIds) try {
|
|
1049
1117
|
await syncTask(dir, record, taskId, deps);
|
|
1050
1118
|
} catch (error) {
|
|
1051
|
-
warnings.push(`${path.basename(dir)} 任务 ${taskId}
|
|
1119
|
+
warnings.push(`${path.basename(dir)} 任务 ${taskId} 收取失败:${error.message}`);
|
|
1052
1120
|
}
|
|
1053
1121
|
return warnings;
|
|
1054
1122
|
}
|
|
@@ -1071,8 +1139,9 @@ async function syncTask(dir, record, taskId, deps) {
|
|
|
1071
1139
|
await updateAsset(dir, async (latest) => {
|
|
1072
1140
|
const waiting = members.filter((member) => stillWaiting(latest, member.number, taskId));
|
|
1073
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));
|
|
1074
1143
|
for (const [index, product] of downloads.entries()) {
|
|
1075
|
-
const target =
|
|
1144
|
+
const target = targets[index];
|
|
1076
1145
|
if (!target || target.state !== "generating") {
|
|
1077
1146
|
if (!("error" in product)) await rm(product.temp, { force: true });
|
|
1078
1147
|
continue;
|
|
@@ -1099,7 +1168,7 @@ function appendResultVersion(record, lead, resultIndex) {
|
|
|
1099
1168
|
const positions = record.versions.filter((version) => version.generation === lead.generation).map((version) => version.position ?? 0);
|
|
1100
1169
|
const version = {
|
|
1101
1170
|
...lead,
|
|
1102
|
-
number:
|
|
1171
|
+
number: allocateVersion(record),
|
|
1103
1172
|
state: "generating",
|
|
1104
1173
|
position: Math.max(...positions) + 1,
|
|
1105
1174
|
resultIndex,
|
|
@@ -1109,10 +1178,21 @@ function appendResultVersion(record, lead, resultIndex) {
|
|
|
1109
1178
|
record.versions.push(version);
|
|
1110
1179
|
return version;
|
|
1111
1180
|
}
|
|
1112
|
-
/**
|
|
1181
|
+
/**
|
|
1182
|
+
* 下载并识别产物。拿不到的产物(下载被拒绝、识别不出类型或与素材类型不符)返回错误,
|
|
1183
|
+
* 按失败处理;其余错误(如网络)直接抛出,下一轮再收。
|
|
1184
|
+
*/
|
|
1113
1185
|
async function downloadProduct(dir, record, taskId, index, item, deps) {
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
+
}
|
|
1116
1196
|
if (media.kind !== record.kind) return { error: `产物类型(${media.kind})与素材类型(${record.kind})不符` };
|
|
1117
1197
|
const temp = path.join(dir, `.${taskId}-${index}.${process.pid}.part`);
|
|
1118
1198
|
await writeFile(temp, bytes);
|
|
@@ -1128,174 +1208,102 @@ async function downloadProduct(dir, record, taskId, index, item, deps) {
|
|
|
1128
1208
|
}
|
|
1129
1209
|
};
|
|
1130
1210
|
}
|
|
1131
|
-
// src/project/
|
|
1132
|
-
/** 识别调用时所在的代码项目:远程地址 + 主仓库位置(见 ADR-0004)。 */
|
|
1133
|
-
const run = promisify(execFile);
|
|
1134
|
-
async function git(cwd, args) {
|
|
1135
|
-
try {
|
|
1136
|
-
const { stdout } = await run("git", [
|
|
1137
|
-
"-C",
|
|
1138
|
-
cwd,
|
|
1139
|
-
...args
|
|
1140
|
-
]);
|
|
1141
|
-
return stdout.trim() || null;
|
|
1142
|
-
} catch {
|
|
1143
|
-
return null;
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
async function identifyCodeProject(cwd) {
|
|
1147
|
-
const dir = await realpath(cwd);
|
|
1148
|
-
const commonDir = await git(dir, [
|
|
1149
|
-
"rev-parse",
|
|
1150
|
-
"--path-format=absolute",
|
|
1151
|
-
"--git-common-dir"
|
|
1152
|
-
]);
|
|
1153
|
-
if (!commonDir) return {
|
|
1154
|
-
remote: null,
|
|
1155
|
-
path: dir
|
|
1156
|
-
};
|
|
1157
|
-
const mainPath = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : await git(dir, ["rev-parse", "--show-toplevel"]) ?? dir;
|
|
1158
|
-
return {
|
|
1159
|
-
remote: await remoteUrl(mainPath),
|
|
1160
|
-
path: await realpath(mainPath)
|
|
1161
|
-
};
|
|
1162
|
-
}
|
|
1163
|
-
/** 优先 origin,否则取第一个远程。 */
|
|
1164
|
-
async function remoteUrl(repo) {
|
|
1165
|
-
const remotes = (await git(repo, ["remote"]))?.split("\n") ?? [];
|
|
1166
|
-
const name = remotes.includes("origin") ? "origin" : remotes[0];
|
|
1167
|
-
return name ? git(repo, [
|
|
1168
|
-
"remote",
|
|
1169
|
-
"get-url",
|
|
1170
|
-
name
|
|
1171
|
-
]) : null;
|
|
1172
|
-
}
|
|
1173
|
-
// src/project/map.ts
|
|
1211
|
+
// src/project/link.ts
|
|
1174
1212
|
/**
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
* 仓库的,按位置找到原条目补上远程地址,名字不变。
|
|
1213
|
+
* 项目关联:代码项目里的 .genlab 点目录记下它对应库里的哪个项目(见 ADR-0006)。
|
|
1214
|
+
* 点目录自带只忽略自身的 .gitignore,不改动代码项目的任何忽略设置。
|
|
1178
1215
|
*/
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
const trimmed = url.trim();
|
|
1189
|
-
let location;
|
|
1190
|
-
if (trimmed.includes("://")) {
|
|
1191
|
-
const parsed = new URL(trimmed);
|
|
1192
|
-
location = `${parsed.hostname}${parsed.pathname}`;
|
|
1193
|
-
} else location = trimmed.slice(trimmed.indexOf("@") + 1).replace(":", "/");
|
|
1194
|
-
return location.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
1195
|
-
}
|
|
1196
|
-
function findEntry(map, project) {
|
|
1197
|
-
if (project.remote) {
|
|
1198
|
-
const remote = normalizeRemote(project.remote);
|
|
1199
|
-
const byRemote = map.projects.find((entry) => entry.remote != null && normalizeRemote(entry.remote) === remote);
|
|
1200
|
-
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;
|
|
1201
1225
|
}
|
|
1202
|
-
return map.projects.find((entry) => entry.remote == null && entry.path === project.path);
|
|
1203
1226
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
entry.remote = project.remote;
|
|
1211
|
-
return true;
|
|
1227
|
+
function parseLink(text, file) {
|
|
1228
|
+
let project;
|
|
1229
|
+
try {
|
|
1230
|
+
project = JSON.parse(text).project;
|
|
1231
|
+
} catch {
|
|
1232
|
+
project = void 0;
|
|
1212
1233
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
remote: project.remote,
|
|
1228
|
-
path: project.path
|
|
1229
|
-
};
|
|
1230
|
-
map.projects.push(entry);
|
|
1231
|
-
return {
|
|
1232
|
-
entry,
|
|
1233
|
-
changed: true
|
|
1234
|
-
};
|
|
1235
|
-
}
|
|
1236
|
-
/** 登记或修改当前代码项目对应的项目名;只改对应关系,不移动素材文件夹。 */
|
|
1237
|
-
function assignProject(map, project, name) {
|
|
1238
|
-
const validName = validateName(name, "项目名");
|
|
1239
|
-
if (validName === "projects.json") throw new CliError(`项目名不能是 ${PROJECT_MAP_FILE}`);
|
|
1240
|
-
const existing = findEntry(map, project);
|
|
1241
|
-
if (existing) {
|
|
1242
|
-
refreshEntry(existing, project);
|
|
1243
|
-
existing.name = validName;
|
|
1244
|
-
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;
|
|
1245
1248
|
}
|
|
1246
|
-
const entry = {
|
|
1247
|
-
name: validName,
|
|
1248
|
-
remote: project.remote,
|
|
1249
|
-
path: project.path
|
|
1250
|
-
};
|
|
1251
|
-
map.projects.push(entry);
|
|
1252
|
-
return entry;
|
|
1253
1249
|
}
|
|
1254
|
-
/**
|
|
1255
|
-
async function
|
|
1256
|
-
const
|
|
1257
|
-
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) {
|
|
1258
1263
|
try {
|
|
1259
|
-
await
|
|
1264
|
+
return (await stat(dir)).isDirectory();
|
|
1260
1265
|
} catch (error) {
|
|
1261
|
-
if (error.code
|
|
1266
|
+
if (error.code === "ENOENT") return false;
|
|
1267
|
+
throw error;
|
|
1262
1268
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
maxTimeout: 200
|
|
1268
|
-
},
|
|
1269
|
-
stale: 3e4
|
|
1270
|
-
});
|
|
1269
|
+
}
|
|
1270
|
+
/** 库里的全部项目(库根目录下的文件夹),default 总在最前。 */
|
|
1271
|
+
async function listProjects(root) {
|
|
1272
|
+
let names = [];
|
|
1271
1273
|
try {
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
if (
|
|
1275
|
-
return result;
|
|
1276
|
-
} finally {
|
|
1277
|
-
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;
|
|
1278
1277
|
}
|
|
1278
|
+
return [DEFAULT_PROJECT, ...names.filter((name) => name !== DEFAULT_PROJECT).sort()];
|
|
1279
1279
|
}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
});
|
|
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)}`);
|
|
1292
1291
|
return {
|
|
1293
1292
|
root,
|
|
1294
|
-
name,
|
|
1295
|
-
dir
|
|
1296
|
-
codeProject
|
|
1293
|
+
name: validName,
|
|
1294
|
+
dir
|
|
1297
1295
|
};
|
|
1298
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
|
+
}
|
|
1299
1307
|
function locateAsset(project, assetPath) {
|
|
1300
1308
|
const segments = parseAssetPath(assetPath);
|
|
1301
1309
|
return {
|
|
@@ -1309,10 +1317,11 @@ function locateAsset(project, assetPath) {
|
|
|
1309
1317
|
function compact(object) {
|
|
1310
1318
|
return Object.fromEntries(Object.entries(object).filter(([, value]) => value != null));
|
|
1311
1319
|
}
|
|
1312
|
-
function presentVersion(dir, version) {
|
|
1320
|
+
function presentVersion(dir, record, version) {
|
|
1313
1321
|
return compact({
|
|
1314
1322
|
version: version.number,
|
|
1315
1323
|
state: version.state,
|
|
1324
|
+
model: findGeneration(record, version.generation)?.model,
|
|
1316
1325
|
file: version.file ? path.join(dir, version.file) : null,
|
|
1317
1326
|
mimeType: version.mimeType,
|
|
1318
1327
|
width: version.width,
|
|
@@ -1348,7 +1357,7 @@ function presentAsset(project, location, record, versions) {
|
|
|
1348
1357
|
kind: record.kind,
|
|
1349
1358
|
current: record.currentVersion,
|
|
1350
1359
|
pending: listed.some((version) => version.state === "generating"),
|
|
1351
|
-
versions: listed.map((version) => presentVersion(location.dir, version))
|
|
1360
|
+
versions: listed.map((version) => presentVersion(location.dir, record, version))
|
|
1352
1361
|
};
|
|
1353
1362
|
}
|
|
1354
1363
|
function presentGenerations(dir, record) {
|
|
@@ -1385,34 +1394,58 @@ function action(run) {
|
|
|
1385
1394
|
}
|
|
1386
1395
|
};
|
|
1387
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
|
+
}
|
|
1388
1418
|
// src/commands/generation.ts
|
|
1389
1419
|
/** 发起生成与等待结果的命令:create / generate / edit / retry / wait。 */
|
|
1390
1420
|
function toGenerateOptions(args) {
|
|
1391
1421
|
return {
|
|
1392
1422
|
model: args.model,
|
|
1393
|
-
params: args.param,
|
|
1423
|
+
params: args.param ?? [],
|
|
1394
1424
|
count: args.count
|
|
1395
1425
|
};
|
|
1396
1426
|
}
|
|
1397
|
-
/**
|
|
1427
|
+
/**
|
|
1428
|
+
* 部分下单失败的原因作为警告输出;按需等待新版本完成(先启动后台收取,等待只读记录),
|
|
1429
|
+
* 再输出这些版本的状态。
|
|
1430
|
+
*/
|
|
1398
1431
|
async function finish(project, location, { versions, errors }, options) {
|
|
1399
1432
|
errors.forEach((error) => warn(`部分任务下单失败:${error}`));
|
|
1400
1433
|
if (options.wait) {
|
|
1401
|
-
|
|
1434
|
+
startBackgroundSync();
|
|
1435
|
+
await waitForVersions([{
|
|
1402
1436
|
dir: location.dir,
|
|
1403
1437
|
versions
|
|
1404
1438
|
}], options.timeout * 1e3);
|
|
1405
|
-
warnings.forEach(warn);
|
|
1406
1439
|
}
|
|
1407
1440
|
return presentAsset(project, location, await readAsset(location.dir), versions);
|
|
1408
1441
|
}
|
|
1409
1442
|
async function createCommand(assetPath, args) {
|
|
1410
|
-
const project = await currentProject();
|
|
1443
|
+
const project = await currentProject(args.project);
|
|
1411
1444
|
const location = locateAsset(project, assetPath);
|
|
1412
1445
|
return finish(project, location, await createAsset(project, location, toGenerateOptions(args)), args);
|
|
1413
1446
|
}
|
|
1414
1447
|
async function generateCommand(assetPath, args) {
|
|
1415
|
-
const project = await currentProject();
|
|
1448
|
+
const project = await currentProject(args.project);
|
|
1416
1449
|
const location = locateAsset(project, assetPath);
|
|
1417
1450
|
return finish(project, location, await generateInAsset(project, location, {
|
|
1418
1451
|
...toGenerateOptions(args),
|
|
@@ -1420,18 +1453,18 @@ async function generateCommand(assetPath, args) {
|
|
|
1420
1453
|
}), args);
|
|
1421
1454
|
}
|
|
1422
1455
|
async function editCommand(assetPath, version, args) {
|
|
1423
|
-
const project = await currentProject();
|
|
1456
|
+
const project = await currentProject(args.project);
|
|
1424
1457
|
const location = locateAsset(project, assetPath);
|
|
1425
1458
|
return finish(project, location, await editVersion(project, location, parseVersionArg(version), toGenerateOptions(args)), args);
|
|
1426
1459
|
}
|
|
1427
1460
|
async function retryCommand(assetPath, version, args) {
|
|
1428
|
-
const project = await currentProject();
|
|
1461
|
+
const project = await currentProject(args.project);
|
|
1429
1462
|
const location = locateAsset(project, assetPath);
|
|
1430
1463
|
return finish(project, location, await retryVersion(location, parseVersionArg(version)), args);
|
|
1431
1464
|
}
|
|
1432
|
-
/**
|
|
1465
|
+
/** 等待某个素材、或当前项目里全部生成中的版本(先启动后台收取,等待只读记录)。 */
|
|
1433
1466
|
async function waitCommand(assetPath, args) {
|
|
1434
|
-
const project = await currentProject();
|
|
1467
|
+
const project = await currentProject(args.project);
|
|
1435
1468
|
const dirs = assetPath ? [locateAsset(project, assetPath).dir] : await findAssetDirs(project.dir);
|
|
1436
1469
|
const targets = [];
|
|
1437
1470
|
for (const dir of dirs) {
|
|
@@ -1446,19 +1479,23 @@ async function waitCommand(assetPath, args) {
|
|
|
1446
1479
|
versions: record.versions.map((v) => v.number)
|
|
1447
1480
|
});
|
|
1448
1481
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1482
|
+
startBackgroundSync();
|
|
1483
|
+
await waitForVersions(targets, args.timeout * 1e3);
|
|
1451
1484
|
return Promise.all(targets.map(async ({ dir, versions }) => presentAsset(project, {
|
|
1452
1485
|
path: path.relative(project.dir, dir).split(path.sep).join("/"),
|
|
1453
1486
|
dir
|
|
1454
1487
|
}, await readAsset(dir), versions)));
|
|
1455
1488
|
}
|
|
1456
|
-
// src/commands/
|
|
1457
|
-
/**
|
|
1458
|
-
/**
|
|
1459
|
-
async function
|
|
1460
|
-
|
|
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`);
|
|
1461
1496
|
}
|
|
1497
|
+
// src/commands/library.ts
|
|
1498
|
+
/** 库与元信息命令:models / import / use / delete / show / list。 */
|
|
1462
1499
|
function parseKind(kind) {
|
|
1463
1500
|
if (kind != null && !MEDIA_KINDS.includes(kind)) throw new CliError(`类型可选值:${MEDIA_KINDS.join(", ")}`);
|
|
1464
1501
|
return kind;
|
|
@@ -1498,43 +1535,26 @@ async function modelsCommand(id, args) {
|
|
|
1498
1535
|
}
|
|
1499
1536
|
return models;
|
|
1500
1537
|
}
|
|
1501
|
-
async function
|
|
1502
|
-
|
|
1503
|
-
const root = libraryRoot();
|
|
1504
|
-
const codeProject = await identifyCodeProject(process.cwd());
|
|
1505
|
-
await updateProjectMap(root, (map) => ({
|
|
1506
|
-
result: assignProject(map, codeProject, name),
|
|
1507
|
-
changed: true
|
|
1508
|
-
}));
|
|
1509
|
-
}
|
|
1510
|
-
const project = await currentProject();
|
|
1511
|
-
return {
|
|
1512
|
-
project: project.name,
|
|
1513
|
-
dir: project.dir,
|
|
1514
|
-
remote: project.codeProject.remote,
|
|
1515
|
-
codeProject: project.codeProject.path
|
|
1516
|
-
};
|
|
1517
|
-
}
|
|
1518
|
-
async function importCommand(assetPath, file) {
|
|
1519
|
-
const project = await currentProject();
|
|
1538
|
+
async function importCommand(assetPath, file, args) {
|
|
1539
|
+
const project = await currentProject(args.project);
|
|
1520
1540
|
const location = locateAsset(project, assetPath);
|
|
1521
1541
|
await importAsset(project, location, file);
|
|
1522
1542
|
return presentAsset(project, location, await readAsset(location.dir));
|
|
1523
1543
|
}
|
|
1524
|
-
async function useCommand(assetPath, version) {
|
|
1525
|
-
const project = await currentProject();
|
|
1544
|
+
async function useCommand(assetPath, version, args) {
|
|
1545
|
+
const project = await currentProject(args.project);
|
|
1526
1546
|
const location = locateAsset(project, assetPath);
|
|
1527
1547
|
await setCurrentVersion(location, parseVersionArg(version));
|
|
1528
1548
|
return presentAsset(project, location, await readAsset(location.dir));
|
|
1529
1549
|
}
|
|
1530
|
-
async function deleteCommand(assetPath, version) {
|
|
1531
|
-
const project = await currentProject();
|
|
1550
|
+
async function deleteCommand(assetPath, version, args) {
|
|
1551
|
+
const project = await currentProject(args.project);
|
|
1532
1552
|
const location = locateAsset(project, assetPath);
|
|
1533
1553
|
await deleteVersion(location, parseVersionArg(version));
|
|
1534
1554
|
return presentAsset(project, location, await readAsset(location.dir));
|
|
1535
1555
|
}
|
|
1536
|
-
async function showCommand(assetPath) {
|
|
1537
|
-
const project = await currentProject();
|
|
1556
|
+
async function showCommand(assetPath, args) {
|
|
1557
|
+
const project = await currentProject(args.project);
|
|
1538
1558
|
const location = locateAsset(project, assetPath);
|
|
1539
1559
|
const record = await readAsset(location.dir);
|
|
1540
1560
|
return {
|
|
@@ -1545,7 +1565,7 @@ async function showCommand(assetPath) {
|
|
|
1545
1565
|
}
|
|
1546
1566
|
async function listCommand(folder, args) {
|
|
1547
1567
|
const kind = parseKind(args.kind);
|
|
1548
|
-
const project = await currentProject();
|
|
1568
|
+
const project = await currentProject(args.project);
|
|
1549
1569
|
const base = folder ? path.join(project.dir, ...parseAssetPath(folder)) : project.dir;
|
|
1550
1570
|
const assets = [];
|
|
1551
1571
|
for (const dir of await findAssetDirs(base)) {
|
|
@@ -1558,7 +1578,7 @@ async function listCommand(folder, args) {
|
|
|
1558
1578
|
versions: record.versions.length,
|
|
1559
1579
|
generating: record.versions.filter((v) => v.state === "generating").length,
|
|
1560
1580
|
failed: record.versions.filter((v) => v.state === "failed").length,
|
|
1561
|
-
current: current ? presentVersion(dir, current) : null
|
|
1581
|
+
current: current ? presentVersion(dir, record, current) : null
|
|
1562
1582
|
});
|
|
1563
1583
|
}
|
|
1564
1584
|
return {
|
|
@@ -1567,8 +1587,67 @@ async function listCommand(folder, args) {
|
|
|
1567
1587
|
assets
|
|
1568
1588
|
};
|
|
1569
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
|
+
}
|
|
1570
1649
|
// src/commands/init.ts
|
|
1571
|
-
/** genlab init:写入配置文件(API Key 与库目录),先验证 Key
|
|
1650
|
+
/** genlab init:写入配置文件(API Key 与库目录),先验证 Key 可用;可顺带安装 skill。 */
|
|
1572
1651
|
const DEFAULT_LIBRARY = "~/GenLab";
|
|
1573
1652
|
async function readConfig(file) {
|
|
1574
1653
|
try {
|
|
@@ -1589,75 +1668,159 @@ function answered(value) {
|
|
|
1589
1668
|
function expandHome(dir) {
|
|
1590
1669
|
return dir === "~" || dir.startsWith("~/") ? path.join(homedir(), dir.slice(1)) : dir;
|
|
1591
1670
|
}
|
|
1671
|
+
/** 验证 Key 后建好库目录、写入配置,返回余额。 */
|
|
1672
|
+
async function writeConfig(file, key, library) {
|
|
1673
|
+
if (!key) throw new CliError("缺少 --key");
|
|
1674
|
+
if (!path.isAbsolute(expandHome(library))) throw new CliError(`库目录必须是绝对路径:${library}`);
|
|
1675
|
+
const credits = await getKieCredits(key);
|
|
1676
|
+
await mkdir(expandHome(library), { recursive: true });
|
|
1677
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1678
|
+
await writeFileAtomic(file, `KIE_API_KEY=${JSON.stringify(key)}\nGENLAB_LIBRARY=${JSON.stringify(library)}\n`, { mode: 384 });
|
|
1679
|
+
return credits;
|
|
1680
|
+
}
|
|
1592
1681
|
/**
|
|
1593
1682
|
* 未通过选项给出的值:终端里交互询问(直接回车沿用已有配置),非终端时沿用已有配置,
|
|
1594
|
-
*
|
|
1683
|
+
* 都没有就报错。未指定 --skill / --no-skill 时终端里询问是否安装 skill,非终端不安装;
|
|
1684
|
+
* 安装失败只警告,不影响已写入的配置。有询问时以 intro 开始,配置写入成败作为结尾。
|
|
1595
1685
|
*/
|
|
1596
1686
|
async function initCommand(args) {
|
|
1597
1687
|
const file = configFile();
|
|
1598
1688
|
const existing = await readConfig(file);
|
|
1599
1689
|
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1690
|
+
const asking = interactive && (!args.key || !args.library || args.skill === void 0);
|
|
1600
1691
|
let key = args.key ?? existing.KIE_API_KEY;
|
|
1601
1692
|
let library = args.library ?? existing.GENLAB_LIBRARY;
|
|
1693
|
+
let skill = args.skill;
|
|
1694
|
+
if (asking) p.intro("genlab init");
|
|
1602
1695
|
if (interactive && !args.key) key = answered(await p.password({
|
|
1603
|
-
message: existing.KIE_API_KEY ? "
|
|
1696
|
+
message: existing.KIE_API_KEY ? "请输入 Kie.ai API Key(留空沿用已保存的 Key):" : "请输入 Kie.ai API Key(获取地址:https://kie.ai/api-key):",
|
|
1604
1697
|
validate: (value) => value || existing.KIE_API_KEY ? void 0 : "不能为空"
|
|
1605
1698
|
})) || existing.KIE_API_KEY;
|
|
1606
1699
|
if (interactive && !args.library) library = answered(await p.text({
|
|
1607
|
-
message: "
|
|
1700
|
+
message: "请输入库目录(存放所有项目与素材):",
|
|
1608
1701
|
initialValue: existing.GENLAB_LIBRARY ?? DEFAULT_LIBRARY,
|
|
1609
1702
|
validate: (value) => value ? void 0 : "不能为空"
|
|
1610
1703
|
}));
|
|
1611
|
-
if (
|
|
1704
|
+
if (interactive && skill === void 0) skill = answered(await p.confirm({ message: "是否为本机 agent 安装 GenLab skill?" }));
|
|
1612
1705
|
library ||= DEFAULT_LIBRARY;
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1706
|
+
let credits;
|
|
1707
|
+
try {
|
|
1708
|
+
credits = await writeConfig(file, key, library);
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
if (asking) p.cancel("配置未写入");
|
|
1711
|
+
throw error;
|
|
1712
|
+
}
|
|
1713
|
+
if (asking) p.outro("配置已写入");
|
|
1714
|
+
let installed = false;
|
|
1715
|
+
if (skill) try {
|
|
1716
|
+
await installSkill(interactive);
|
|
1717
|
+
installed = true;
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
warn(error.message);
|
|
1720
|
+
}
|
|
1618
1721
|
return {
|
|
1619
1722
|
config: file,
|
|
1620
1723
|
library: expandHome(library),
|
|
1621
|
-
credits
|
|
1724
|
+
credits,
|
|
1725
|
+
skill: installed ? "genlab" : void 0
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
// src/commands/project.ts
|
|
1729
|
+
/** 项目命令:查看当前目录的关联、新建项目、关联当前目录(见 ADR-0006)。 */
|
|
1730
|
+
async function projectCommand() {
|
|
1731
|
+
const root = libraryRoot();
|
|
1732
|
+
const link = await findLink(process.cwd());
|
|
1733
|
+
return {
|
|
1734
|
+
project: link?.project,
|
|
1735
|
+
dir: link ? path.join(root, link.project) : void 0,
|
|
1736
|
+
link: link?.dir,
|
|
1737
|
+
projects: await listProjects(root)
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
async function projectCreateCommand(name) {
|
|
1741
|
+
const root = libraryRoot();
|
|
1742
|
+
const validName = validateName(name, "项目名");
|
|
1743
|
+
if (validName === "default") throw new CliError(`${DEFAULT_PROJECT} 是内置项目,无需新建`);
|
|
1744
|
+
const dir = path.join(root, validName);
|
|
1745
|
+
await mkdir(root, { recursive: true });
|
|
1746
|
+
try {
|
|
1747
|
+
await mkdir(dir);
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
if (error.code === "EEXIST") throw new CliError(`项目已存在:${validName}`);
|
|
1750
|
+
throw error;
|
|
1751
|
+
}
|
|
1752
|
+
return {
|
|
1753
|
+
project: validName,
|
|
1754
|
+
dir
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
/** 在当前目录写入关联:指向已有项目或 default。 */
|
|
1758
|
+
async function projectLinkCommand(name) {
|
|
1759
|
+
const project = await openProject(libraryRoot(), name);
|
|
1760
|
+
const link = await writeLink(process.cwd(), project.name);
|
|
1761
|
+
return {
|
|
1762
|
+
project: project.name,
|
|
1763
|
+
dir: project.dir,
|
|
1764
|
+
link
|
|
1622
1765
|
};
|
|
1623
1766
|
}
|
|
1624
1767
|
// src/cli.ts
|
|
1625
|
-
/**
|
|
1768
|
+
/**
|
|
1769
|
+
* 单次命令默认最多等待的秒数:Claude Code 默认在前台等一条命令 2 分钟,超过转入后台,
|
|
1770
|
+
* 留在这之内 agent 能直接拿到结果。
|
|
1771
|
+
*/
|
|
1626
1772
|
const DEFAULT_TIMEOUT_SECONDS = 90;
|
|
1773
|
+
/** 使用说明与入口同目录:打包时由 tsdown 复制到 dist。 */
|
|
1774
|
+
const GUIDE_FILE = new URL("./guide.md", import.meta.url);
|
|
1627
1775
|
function positiveInteger(value) {
|
|
1628
1776
|
const parsed = Number(value);
|
|
1629
1777
|
if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("应为正整数");
|
|
1630
1778
|
return parsed;
|
|
1631
1779
|
}
|
|
1632
|
-
|
|
1780
|
+
/** 可重复的选项:不设默认值,帮助里不显示 (default: [])。 */
|
|
1781
|
+
function collect(value, previous = []) {
|
|
1633
1782
|
return [...previous, value];
|
|
1634
1783
|
}
|
|
1635
|
-
/**
|
|
1636
|
-
function
|
|
1784
|
+
/** 执行命令,结束后在后台收取库里未完成的生成,不等待(见 ADR-0007)。 */
|
|
1785
|
+
function withBackgroundSync(run) {
|
|
1637
1786
|
return action(async (...args) => {
|
|
1638
|
-
|
|
1639
|
-
|
|
1787
|
+
try {
|
|
1788
|
+
return await run(...args);
|
|
1789
|
+
} finally {
|
|
1790
|
+
startBackgroundSync();
|
|
1791
|
+
}
|
|
1640
1792
|
});
|
|
1641
1793
|
}
|
|
1642
|
-
const program = new Command("genlab").description("
|
|
1643
|
-
function generationOptions(command,
|
|
1644
|
-
return command.option("-m, --model <id>",
|
|
1794
|
+
const program = new Command("genlab").description("用 Kie.ai 的模型生成图片、视频、音频,产物与生成记录保存在本机的库里。完整用法:genlab guide").version(version, "-V, --version", "显示版本号").helpOption("-h, --help", "显示帮助").helpCommand("help [command]", "显示命令的帮助").showHelpAfterError();
|
|
1795
|
+
function generationOptions(command, modelDescription) {
|
|
1796
|
+
return command.option("-m, --model <id>", modelDescription).option("-p, --param <key=value>", "模型参数,可重复;key:=JSON 传 JSON 值,@<路径> 上传本机文件", collect).option("-n, --count <n>", "并列任务数(1–10,默认 1)", positiveInteger);
|
|
1797
|
+
}
|
|
1798
|
+
function projectOption(command) {
|
|
1799
|
+
return command.option("--project <name>", "本次使用的项目,不改动当前目录的关联");
|
|
1645
1800
|
}
|
|
1646
1801
|
function waitOptions(command) {
|
|
1647
1802
|
return command.option("--no-wait", "提交后立即返回,不等待结果").option("--timeout <seconds>", "最多等待的秒数", positiveInteger, DEFAULT_TIMEOUT_SECONDS);
|
|
1648
1803
|
}
|
|
1649
|
-
program.command("
|
|
1650
|
-
program.command("
|
|
1651
|
-
program.command("
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
program.command("
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
program.command("
|
|
1660
|
-
program.command("
|
|
1661
|
-
program.command("
|
|
1804
|
+
program.command("guide").description("输出完整用法:概念、流程、参数写法、规则与命令参考").action(() => guideCommand(program, GUIDE_FILE));
|
|
1805
|
+
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));
|
|
1806
|
+
const skill = program.command("skill").description("安装、更新或卸载 GenLab skill");
|
|
1807
|
+
skill.command("add", { isDefault: true }).description("安装 skill(默认)").action(action(skillAddCommand));
|
|
1808
|
+
skill.command("update").description("更新 skill,沿用安装时选的 agent").action(action(skillUpdateCommand));
|
|
1809
|
+
skill.command("remove").description("卸载 skill").action(action(skillRemoveCommand));
|
|
1810
|
+
program.command("models").description("列出 Kie.ai 模型;指定模型时输出价格与参数表").argument("[id]", "模型标识").option("--kind <kind>", "按类型筛选:image / video / audio").option("--search <text>", "按模型标识、供应商、名称筛选").option("--refresh", "忽略缓存,重新获取").action(action(modelsCommand));
|
|
1811
|
+
const project = program.command("project").description("查看当前目录关联的项目与库里的全部项目").action(action(projectCommand));
|
|
1812
|
+
project.command("create").description("新建项目").argument("<name>", "项目名").action(action(projectCreateCommand));
|
|
1813
|
+
project.command("link").description("把当前目录关联到项目;不属于任何代码项目时用 default").argument("<name>", "项目名").action(action(projectLinkCommand));
|
|
1814
|
+
projectOption(waitOptions(generationOptions(program.command("create").description("新建素材并发起第一次生成").argument("<asset>", "素材路径:[文件夹/]素材名,如 characters/hero"), "模型标识(必填,见 genlab models)"))).action(withBackgroundSync(createCommand));
|
|
1815
|
+
projectOption(waitOptions(generationOptions(program.command("generate").description("在已有素材里发起生成").argument("<asset>", "素材路径").option("--from <version>", "沿用某个版本的生成输入(重新生成)"), "模型标识;用 --from 时默认沿用原模型"))).action(withBackgroundSync(generateCommand));
|
|
1816
|
+
projectOption(waitOptions(generationOptions(program.command("edit").description("AI 编辑:以某个就绪版本为源版本发起生成,参数里用 @source 引用它").argument("<asset>", "素材路径").argument("<version>", "源版本号"), "模型标识,默认沿用源版本的模型"))).action(withBackgroundSync(editCommand));
|
|
1817
|
+
projectOption(waitOptions(program.command("retry").description("原地重试失败的版本").argument("<asset>", "素材路径").argument("<version>", "版本号,如 3 或 v3"))).action(withBackgroundSync(retryCommand));
|
|
1818
|
+
projectOption(program.command("wait").description("等待生成中的版本;不指定素材时等待当前项目的全部").argument("[asset]", "素材路径").option("--timeout <seconds>", "最多等待的秒数", positiveInteger, DEFAULT_TIMEOUT_SECONDS)).action(withBackgroundSync(waitCommand));
|
|
1819
|
+
projectOption(program.command("import").description("把外部文件导入为新素材的第一个版本").argument("<asset>", "素材路径").argument("<file>", "要导入的文件")).action(withBackgroundSync(importCommand));
|
|
1820
|
+
projectOption(program.command("use").description("把就绪的版本设为当前版本").argument("<asset>", "素材路径").argument("<version>", "版本号,如 3 或 v3")).action(withBackgroundSync(useCommand));
|
|
1821
|
+
projectOption(program.command("delete").description("删除没有子版本的版本").argument("<asset>", "素材路径").argument("<version>", "版本号,如 3 或 v3")).action(withBackgroundSync(deleteCommand));
|
|
1822
|
+
projectOption(program.command("show").description("查看素材的全部版本、生成输入与文件路径").argument("<asset>", "素材路径")).action(withBackgroundSync(showCommand));
|
|
1823
|
+
projectOption(program.command("list").description("列出当前项目的素材").argument("[folder]", "只列这个文件夹下的素材").option("--kind <kind>", "按类型筛选:image / video / audio")).action(withBackgroundSync(listCommand));
|
|
1824
|
+
program.command("sync", { hidden: true }).description("后台收取库里未完成的生成(由其他命令自动启动)").action(syncCommand);
|
|
1662
1825
|
await program.parseAsync();
|
|
1663
1826
|
export {};
|
package/dist/guide.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# GenLab 使用说明
|
|
2
|
+
|
|
3
|
+
用 Kie.ai 的模型生成图片、视频、音频,产物与完整生成记录保存在本机的库里。
|
|
4
|
+
|
|
5
|
+
下文的 `genlab` 均可写作 `npx -y genlab@latest`(推荐,每次都用最新版本)。
|
|
6
|
+
|
|
7
|
+
## 准备
|
|
8
|
+
|
|
9
|
+
- 报错「缺少配置」时,请用户在终端运行 `genlab init`,填写 Kie.ai API Key 与库目录。
|
|
10
|
+
- 库目录在代码项目之外。在沙箱里运行时,需要允许读写库目录与 `~/.cache/genlab`(模型目录缓存),并允许访问网络。
|
|
11
|
+
|
|
12
|
+
## 概念
|
|
13
|
+
|
|
14
|
+
- **项目**:库里最外层的分组。当前目录用 `genlab project link` 关联到一个项目后,素材命令都作用于它;`--project` 临时指定其他项目,不改关联。
|
|
15
|
+
- **默认项目**:内置的 `default`,一直存在,放不属于任何代码项目的素材。
|
|
16
|
+
- **素材**:项目里的一个创作单元,用「文件夹/素材名」指定,文件夹可省略(如 `characters/hero`、`logo`)。类型为 image / video / audio,由第一次生成的模型或导入的文件决定,之后的生成须用同类型的模型。
|
|
17
|
+
- **版本**:素材的一个产物。版本号在素材内递增,删过的号不复用;状态为 generating / ready / failed。
|
|
18
|
+
- **当前版本**:素材选定的那一版。新素材默认取版本号最小、没有失败的版本(还在生成中也等它);用 `genlab use` 改选。
|
|
19
|
+
- **AI 编辑**:以某个就绪版本为源版本、按要求修改,新版本仍属于同一素材。
|
|
20
|
+
|
|
21
|
+
## 流程
|
|
22
|
+
|
|
23
|
+
1. `genlab project` 查看当前目录关联的项目。没有关联时先关联:
|
|
24
|
+
- 属于某个代码项目:关联到对应的项目,库里还没有就先 `genlab project create`。
|
|
25
|
+
- 不属于任何代码项目(家目录、临时目录、下载目录等):关联到 `default`。
|
|
26
|
+
- 判断不了:问用户。不要为了省事把属于某个项目的素材放进 `default`。
|
|
27
|
+
- 关联的项目不存在(可能已改名或删除):问用户要关联到哪个,不要新建同名项目。
|
|
28
|
+
2. `genlab list` 查看已有素材与文件夹,新素材沿用已有的文件夹分法。
|
|
29
|
+
3. `genlab models --kind <类型> [--search <关键词>]` 找模型,`genlab models <模型>` 查看价格与参数表。参数名以参数表为准,不凭记忆填。文生与图生常是两个模型(`…-text-to-image` / `…-image-to-image`),按是否带输入图选择。
|
|
30
|
+
4. 用户没指定模型时,为新素材的第一次生成挑 2–3 个候选(模型、价格、特点)请用户选;同一素材之后的生成默认沿用该模型(需要带输入图时换成同系列的图生模型),不再询问。
|
|
31
|
+
5. `create` 新建素材,`generate` 在已有素材里生成,`edit` 做 AI 编辑,`retry` 重试失败的版本。命令默认最多等 90 秒;输出 `"pending": true` 时用 `genlab wait` 继续等。同时为多个素材发起生成时加 `--no-wait`,再用 `genlab wait` 一起等。
|
|
32
|
+
6. 生成完向用户汇报使用的模型(版本的 `model`)、消耗的积分(各版本 `credits` 之和)与折合美元(Kie.ai 基础价 1 积分 = $0.005)。
|
|
33
|
+
7. 每个版本的 `file` 是产物的绝对路径;一个任务可能产出多个版本(如一次出两首的音乐模型)。用户选定后用 `genlab use` 设为当前版本;需要放进代码项目时自行复制该文件。
|
|
34
|
+
|
|
35
|
+
## 模型参数(-p)
|
|
36
|
+
|
|
37
|
+
- `key=value`:字符串;数字、布尔、数组按参数表自动转换。
|
|
38
|
+
- `key:=<JSON>`:JSON 值,如 `-p duration:=5`。
|
|
39
|
+
- 同键重复即数组,如 `-p input_urls=@a.png -p input_urls=@b.png`。
|
|
40
|
+
- `@<路径>`:本机文件(相对当前目录),自动上传;引用库里的产物时直接用它的 `file` 路径。
|
|
41
|
+
- `@source`:AI 编辑的源版本文件,只能用在 `edit`。
|
|
42
|
+
- `@@`:字面量 `@`。
|
|
43
|
+
|
|
44
|
+
## 规则
|
|
45
|
+
|
|
46
|
+
- 生成消耗 Kie.ai 积分:先看价格,任务数、分辨率、时长按需设置。
|
|
47
|
+
- 不手动修改库里的 `asset.json`;改名、移动、删除整个素材由用户在文件管理器里操作。
|
|
48
|
+
- 项目、文件夹、素材的名字用英文。
|
|
49
|
+
- 结果为 JSON;错误写 stderr 的 `{"error": ...}`,警告为 `{"warning": ...}`。
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genlab",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"description": "给本地 agent 用的 AI 媒体生成命令行工具(
|
|
4
|
+
"version": "0.1.4",
|
|
5
|
+
"description": "给本地 agent 用的 AI 媒体生成命令行工具(Kie.ai),产物与完整生成记录保存在本机",
|
|
6
6
|
"author": "WBBB",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"homepage": "https://github.com/WBBB0730/genlab#readme",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"file-type": "^22.3.0",
|
|
43
43
|
"image-size": "^2.0.4",
|
|
44
44
|
"music-metadata": "^12.3.4",
|
|
45
|
+
"nano-spawn": "^2.1.0",
|
|
45
46
|
"proper-lockfile": "^4.1.2",
|
|
46
47
|
"write-file-atomic": "^7.0.1"
|
|
47
48
|
},
|